{"data":[{"title":"不写文章的博客生成速度最快","url":"/posts/Fastest-Blog-Has-No-Post/","content":"春节假期虽然是假期，但是在家的时间里我基本没闲着，毕竟按照传统（指 Ken Thompson 在假期里写出了 UNIX 的第一个雏形的传统，不过维基百科上并没有详述是不是这样，我也懒得考证），假期是造轮子的好机会。所以我就胡乱捣鼓一通看看能不能让我的博客生成器更圆一点。\n重新理解 await\n第一个简单的修改就是把启动时加载文件的部分并行化，其实我也没有非要尽可能并行，性能也不是我第一位的追求。\n我一开始写代码的时候知道有 await 之后就开始在各种地方给 Promise 用 await，随便你怎么说都行，后来我也才意识到，给所有的 Promise 都加上 await 不就是相当于每一个 Promise 都写在上一个的 then 里面，那不还是顺序执行吗？写多了以后对异步有理解了我才想起来这里完全可以用 Promise.all()，反正加载插件/脚本/模板/语言是不太有先后依赖顺序的（非要说的话，插件和脚本里可能包含模板引擎，所以可能下一步是给前两个和后两个分开顺序加载）。\n程序解决不了的事情就让人来做\n随便你怎么说，我一直觉得有些程序很难理解的事情而人类很好理解的就该让人类来做，而不是费很大力气得到不好的效果。一个多少接近这一条的问题就是主题文件的依赖问题——很多生成器比如 Hexo 在以 server 模式运行的时候都有 watch 功能，可以监测文件变化然后实时重新生成，用户只要一刷新就可以看到最新的更改。直接看上去不难，写 Node 的谁还不知道 chokidar 了。但是问题就在于，普通用户写文章不太需要一边看渲染一边写（你不会连 Markdown 这么简单的格式都不能脑补吧？我鄙视 Typora），反而是主题作者经常需要看到自己刚编写的样式是什么样子，而这是一个很复杂的地方，CSS 预处理器和 HTML 模板引擎通常都有 import/include 一类的语法，让你不用写很多重复的片段，而博客生成器并不能理解每一种预处理器和模板引擎的语法。假如你在 A 模板里 include 了 B，然后修改了 B，此时你刷新一个用了 A 模板的页面，你是看不到页面更新的，那在最需要这个功能的时候这个功能变成了废物。\n有一个简单的解决方案是不要预编译你的模板，而是每次需要渲染页面的时候重新读取模板文件编译出一个函数来。不过这对于生成器而言不太优雅，有些模板引擎比如 nunjucks 可能有内建的 cache 支持，但另一些可能是没有的。再说你来来回回读这么多次磁盘，那干嘛不直接把渲染页面的工作也挪到用户浏览器（指前端）里呢？还要博客生成器做什么。\n我一开始的打算是既然生成器自己不好理解依赖语法，那就像处理博客文章一样，让主题作者给文件加上 front matter 记录这个文件的依赖。不过问题又来了，对于直接使用的模板（指博客生成器读取并编译它们）可以在读取时去掉 front matter，但是假如这个模板 include 的模板也带有 front matter，此时实际上是模板引擎（比如 nunjucks）自己去磁盘上读那个文件，它不会去掉 front matter。而给每一种模板引擎都重写文件加载器显然是不现实的，所以最后的方案就是要求主题作者提供一个 file-dependencies.yaml 的文件，里面记录每个文件的依赖关系，大概像是这样：\nlayouts:\n  includes/layout.njk:\n    - includes/footer.njk\n    - includes/header.njk\n    - includes/sidebar.njk\n\n第一层是需要 watch 的目录名，因为不是所有的目录都需要实时刷新，显然这种耗时的操作越少越好，第二层是依赖其他文件的文件，第三层则是一个被它依赖的文件的列表。程序里面我写了一个 Watcher 类，启动时会读取这个文件，然后将它翻转过来：以被其它文件依赖的文件作为 key，而依赖这个文件的文件成为 value 列表。因为我们总是检测到被依赖的文件变化之后才开始统计受到它影响的文件。然后就是一个递归收集受到影响的文件的函数，因为文件依赖并不是只有一层的。收集之后分别给这些文件也加入到需要更新的列表里面。\n写完这个功能之后我最大的感叹就是：要是我写主题的时候能有这种功能，我应该能节省不少检查的时间吧……\n后面闲着又给这个做了点修改，一个是支持用 glob pattern 作为被依赖的文件，虽然我个人觉得还是把需要的文件都列出来算了，但是想必对于处在开发调试期的主题来说很有用，作者可能不一定想得起来及时更新这个文件。另一个是考虑到使用 glob 之后很容易在无意间搞出循环依赖，又写了在递归里打破循环依赖的功能，我通常不擅长处理递归的逻辑，但我脑子灵光一闪想起来可以给函数最后一个参数设置成一个记录已经处理过哪些文件的 Set，然后递归的时候作为参数传进去，然后就这么成了。\n现在如果开启 server 模式，各种文件以及语言文件模板文件，甚至包括这个依赖描述文件自己都是实时更新的了，只有主题和站点的两个关键配置文件需要重启之后才能重新加载。\n削减依赖的路上没有最少只有更少\n事实上我在添加依赖的时候已经非常克制了，我尽量只使用那些我不得不用的 npm 包，有些功能能自己实现的我都自己实现了。但是总有还能去掉的依赖。我首先干掉了 Stylus，理由很哭笑不得，它好像不是那么流行了，我都找不到合适的支持它的 Emacs 插件。我本来想换成 Less 或者 SCSS，但是前者语法和 Stylus 差得有点多，后者有好几种语言的实现，本来我看 GNOME 都在用 C 写的 libsass，我觉得挺好，也打算用这个，结果发现它教程里挂着说“不推荐使用 @import 而推荐 @use 但目前只有 dart 版本的实现支持了后者”，让我感到说不出来的难受，我不太喜欢这种奇怪的新语言，而且搞这么多实现，还整出参差不齐的语法，实在是麻烦，虽然 dart 可以编译到 JavaScript，我还是放弃了。其实我自己写 Stylus 的时候不是放飞自我型的，而是尽可能贴近 CSS 型的，所以干脆把我的样式都改成了原生 CSS 了，顺手就干掉了一个依赖，如果有需要的人还是装插件吧。\n再之后就是我一直使用 glob 来匹配文件，但是它还依赖一些别的包，我在想能不能用依赖更少的包替换它。glob 使用 minimatch 分析 glob pattern，但是比如 chokidar 使用的是另一个替代品叫 picomatch。我找到一个叫 fdir 的库，虽然接口用起来怪怪的，但是如果你用它做 glob，只需要它和 picomatch，没有引入别的依赖，于是我就试了一下。但是不得不感叹作者能不能写点阳间的代码，这个库默认忽略软链接文件，这显然是不能用的，但是假如你设置解析链接文件，它倒好，就算我要的是相对路径，它还是直接解析并拼接了绝对路径——我只是想让你把链接文件当普通文件返回一下！就在我打算放弃并用回阳间的 glob 包的时候，我发现 chokidar 自己依赖的 readdirp 库也可以配合 picomatch 使用。但是如果你直接把 glob pattern 丢给它让它自己调用 picomatch 编译的话，又有一些阴间的限制，所幸可以给它传自定义的过滤器函数，正好我还需要支持一些别的功能，就自己编译 glob 传给它了。\n实际上我现在只有 10 个左右的直接依赖，全部的运行依赖大概是 36 个，在 Node.js 编写的程序里面应该算是相当克制的了，考虑到这里面大部分都是 nunjucks 的间接依赖，我觉得我做得相当不错。\n对于其它我编写的简单的 npm 包我还是比较追求 0 依赖，只有 0 依赖的包才是比较可控的，如果一个 npm 包在 npm 上显示有 1 个依赖，那你就不能确定这一个依赖有多少间接依赖，而不更新的间接依赖会引入多少安全问题你也无法得知。我在给 Hikaru 选依赖的时候一般也会倾向于 0 依赖的包（这就是为什么选择 commander.js 而不是 yargs 的原因）或者是一些虽然有依赖，但是递归翻几次就是 0 依赖的包（chokidar 属于这类，而且我还给 nunjucks 提过一个 PR 使用 commander.js 代替了 yargs），而不会选那些依赖摞依赖无穷无尽的包。\n这里不得不吐槽一下 jsdoc、mocha 这些大型的开发用的工具库，依赖实在是太混乱了，甚至 jsdoc 似乎同时依赖 marked 和 markdown-it……开发者写新功能的时候能不能长点心啊。还有我之前用 react-scripts 的时候，用旧版提示依赖里面有的版本有安全问题，升级到新版依赖算了一大堆，甚至出现了不同版本的间接依赖，而且仍然是有问题的版本，还是希望开发者对自己的依赖都关心关心，及时更新依赖版本，不要锁死一个版本懒得改就觉得万事大吉了。\n如果你对削减 npm 包的依赖感兴趣，强烈建议你读一下 chokidar 开发者写的这篇文章。\nworker_threads 是多线程，但和我想的不太一样……\n\n我：你懂 Node 吗？为什么我多线程比单线程慢？\n铁道迷：Node 就是单线程拖拉机啊。\n我：你不懂，88。\n\n凡是跟你说“Node.js 只有单线程”的人，都可以直接和他说“你不懂 Node.js”了。Node 要解决的就是非阻塞 IO 的问题，而非阻塞 IO 肯定不是多线程能解决的。至少对于内部的 fs 来说，调用异步函数的时候是 libuv 从线程池里面拉出一个线程去解决任务，而 JavaScript 本身的线程不会被阻塞。如果你还想了解“有哪些代码会阻塞主线程而哪些代码不会”，可以看 Node.js 官网的这篇文章。\n说实话，对于用户自己写的 JavaScript 代码段，似乎是没什么好办法把它丢到主线程之外的线程上去执行的……请务必注意“回调”，“异步”和“非阻塞”的区别——不是所有回调都是异步的，比如我传了一个回调函数它也可能是同步的，而就算你把一段耗时的同步代码套上 setImmediate 和 Promise，也不意味着它就不会阻塞主线程了……它执行的时候还是在主线程执行，只是你把它延迟了，推到了 event loop 空闲的时候去运行——但轮到它运行起来还是阻塞住了主线程。（说出来不怕笑话，我也是写得多了最近才认识到这些区别（乐），如果我写的不对还希望大家指正。）\n起因是我读了 Sukka 的这篇文章，介绍了 Node 新加的 worker_threads 模块，是允许你开启子线程运行代码的。正好我想到我的博客生成器里有一段分析每篇文章内容并修饰里面的图片和链接的代码，要是能起一个线程池，把每个文章分配给不同的线程去处理，那多是一件美事啊！\n我发现这个 worker_threads 并不是很好用，它并不像 pthread，直接运行一个函数，而是要求你必须传一个 JavaScript 脚本的路径进去，这就给代码编写搞出了很大的麻烦。我做的修改在另一个 worker 分支里面，可以访问 https://github.com/AlynxZhou/hikaru/commits/worker 看到。不过一个尴尬的事情是写好以后我跑了几次，发现多线程还没有我之前单线程运行快……而且很反直觉的是线程越多越慢，在我 12 核心的处理器上四个线程是跑在主线程之外最快的，比什么 2、6、8、12 都快。\n说实话，我也没有搞清楚到底是怎么回事，可以确定任务确实是分发给了不同的线程，但是为什么会这么慢呢？一个可能的原因是在不同的线程之间并不是像 C 一样直接共享内存空间，传递参数的时候 Node 是复制之后传递的，虽然我觉得我传递的数据量不至于让复制造成瓶颈吧，但这是我能想到唯一的原因了。而且阅读例子的时候也发现，好像推荐的用法是比如做 http server，每一个线程都是启动之后一直监听，而不像我这样是不断的分配大量的小任务。总之我很需要一个像 pthread 那样简单的给我执行一段代码的线程，而 worker_threads 好像倾向长时间运行一个单独文件……强扭的瓜不甜，我还是放弃吧。\n然后说回到性能这件事上，我倒不是觉得生成器现在的性能难以忍受，否则我就该用 Hugo，相反我实在是觉得现在的生成速度出乎意料的棒了，而且又不像 Hugo 只支持 go template，Node 有很多种模板引擎可以选（我就是不喜欢 go 你来打我呀）。完整生成一遍我的博客大概耗时 800 毫秒，而且我也没有把渲染啦解析啦之类的功能用多线程解决，并且我的生成器没有缓存，每次都是从头生成的。没有缓存这点其实和之前 watch 文件的功能很类似，不够可靠还不如不要算了。在我以前用 Hexo 的时候它是有缓存的，但是这个缓存经常导致奇怪的结果，比如该更新的页面没有伴随文章一起更新之类的，我也不是很理解到底怎么回事（特别是考虑到 Hexo 里面还有一个奇怪的 JSON 数据库 warehouse），所以每次我都是 clean 之后再从头生成，那我自己实现的时候自然就砍掉这种不靠谱的功能了。\n当然我问了琪神 Hugo 生成他的博客耗时多少，似乎只需要 200 毫秒的样子，不过除了 go 是编译型语言而且 Hugo 肯定用了协程渲染以外，他只有 6 篇文章而我有 83 篇文章也是一个很重要的因素！他甚至连分页都不需要生成！\n所以如果你真的只追求速度不追求别的，什么语言都是浮云，总结出来就一句话：不写文章的博客生成速度最快！\n"},{"title":"StackHarbor 的 2021 尾记","url":"/posts/2021-Tail/","content":"今年一直拖着没写总结，不是因为懒，主要是因为我总觉得好像距离去年写总结也没太久，我还能回忆起来去年写总结时候是什么样……也说不上是因为我记忆力不太好还是因为这一年实在没什么能让我记住的事情。\n2021 年工作没什么变化，不过我换了个好一点的住处，虽然仍然是旧楼但是里面是新装修过的，至少让人待着的时候感到舒适，而且还可以吸室友的猫，虽然小猫也经常搞破坏，不过猫做什么都可以原谅。\n看了去年的总结，才发现我是 2021 年把 FlipClock 改成 Meson 管理编译的，于是想起来我这一年学了 Meson 怎么用，因为 CMake 实在是让我烧脑筋。我今年还终于让 scrcpy 能够模拟 USB 键盘，这样使用 scrcpy 的时候也能用数字键选词了，多少算是在一个大项目里面做了一个大贡献。\n然后其他能记得住的就是最近折腾的一些东西了，春节假期没写博客也是因为忙着写代码。写了一个简单的网页小游戏，基本是平面版神庙逃亡，感觉还是挺有成就感的。然后给之前的弹钢琴页面改成了原生 JS 实现，放弃 React 的原因是依赖太多了，而且不是每个软件包都能及时更新自己的依赖。比如你用一个老版本的 create-react-app 会带来许多有漏洞的依赖，然后你更新到最新版因为依赖变了，有些库又依赖了另一个有漏洞的版本。我觉得为了简单的 UI 和数据绑定这个代价是不值得的，所以我就重写了一个。然后给我的博客生成器添加了文件依赖的支持，很久很久以前当我刚开始用静态博客生成器的时候就在头疼这个，博客生成器理解不了你的模板引擎或者预处理器的导入和扩展语句，于是没办法在一个文件更新的时候更新所有包含它的依赖，导致主题开发要不停的关掉生成器再开。一开始我是想给这些文件都加上 front matter，但是我发现虽然我自己加载的时候可以去掉 front matter，模板引擎处理导入语句的时候是他自己读文件所以没办法去掉 front matter，我不想给每个模板引擎都写自定义的 loader，所以最后改成用一个单独的文件记录依赖了。最后的结果就是单独抽出了一个 Watcher 类，可以查表查出所有需要更新的文件然后调回调，效果还挺不错的。以及本来我想清理一下博客的样式然后顺便换个 CSS 预处理器，因为感觉 Stylus 不是特别火了，但是考察了一下，less 的语法和 Stylus 差得有点多，而 scss 虽然使用广泛，而且有纯 C 语言写的实现，但是它有好多个实现，并且官网上说只有 Dart 语言写的版本才不过时，其它的都只实现了过时的 @import 而用户应该用最新的 @use，总之搞得这么麻烦看起来就不想用。虽然 Dart 能编译成纯 JS，我还是觉得没兴趣。于是最后我把我的样式都改成了原生 CSS，然后去掉了生成器内置的 Stylus 支持。\n显示器从去年的一个换成了两个，因为我是一个经常最大化窗口的人，切工作区虽然快，但是比如在浏览器和代码编辑器之间切换的时候还是会打断思路，有了双屏就没这个烦恼。一开始我想的是双屏更方便一边全屏游戏一边干别的事情，不过好像也就那样，因为从全屏切出来到另一个屏幕也不是很顺畅。不过买双屏的时候很头疼的就是因为不是一个批次，两个显示器的色温差得不是一点，最终是换了一台然后用校色仪调了好久，虽然不是 100% 一致，但是至少差不多了。\n说到这个我想说，感觉幸运和倒霉是伴随而来的，不太可能一直幸运，经常是发生一些开心的事之后又发生一些不开心的事，比如去年买了新相机，经常没事拍点照片什么的，结果昨天晚上我突然发现不开镜头盖的时候 CMOS 有个红点……我觉得我基本可以排除激光损伤，应该就是坏点，但是我用机内的像素映射却解决不掉。感觉最后还得跑一趟维修站，而我在老家，一时半会不会回北京，这边也没有维修站，总之麻烦的事情就会让我心累。买了一张升降桌，不过这个最便宜的款式不太靠谱，现在升降功能不太正常，于是只是变成了一台大桌子。不过椅子坏了之后换了个比较便宜的工学椅倒确实令我满意。\n今年没有换手机，主要是把这个钱投资到镜头或者灯上让我更高兴一点，手机厂商出的手机真是越来越烂了。不过把备用的 iPhone 8 换成了 iPhone XR，在二手频道看到一个成色很好，而且是红色款的，我早就觉得 iPhone 8 屏幕太小了。\n动漫也想不起来看过什么，今年也许只看了 EVA 最后一部剧场版和 love live superstar，反正看 EVA 就是大家都想看看他怎么收尾而已，总体来说还是不错，特别是看了纪录片之后。然后 love live 我以前没看过，我的评价就是挺好听的。然后我感觉在电影院看到 Fate/Stay Night HF 第三季的可能性应该是没了，特别是考虑到今年院线上的国外电影的比例……还能说什么呢懂得都懂哈哈。\n想不起来看过多少书，总之前段时间有天晚上睡不着，一口气把挪威的森林看完了，我早就买了这本书，但是以前都是打开看几页就看不下去了，这次一口气看完感觉还挺好的。\n今年的另一个好消息是 NVIDIA 驱动终于补全了 Wayland 支持，感谢所有在这个过程中努力的人，比如 NVIDIA 那个一直在处理相关事情的用蜗牛做头像的老哥，事情总要是靠人推进的嘛。不过回家之后莫名发现我的 Atom 在 XWayland 下面会疯狂地闪回，明明我自己住处的电脑也是一样的配置就没问题。然后这时候我发现 Emacs 的纯 GTK 分支已经合并进了主线，也就是说 Emacs 现在终于是纯 Wayland 程序了，我立刻搞了一个最新版本，然后捡起来以前的配置文件，又仔细研究仔细鼓捣，目前多少是堪用了，虽然还有不少地方不像 Atom 那么习惯，主要还是我懒得仔细研究，反正凑合用，我是真的怀念 Atom 上的一些功能，比如注释反注释代码块、移动代码块、方便的查找替换还有现代化的界面……Emacs 的界面元素感觉还是基于文本的，然后包括粗体字渲染感觉也不太光滑，比如 indent guide 或者 80 column ruler 这种东西就应该直接在界面上划线嘛，不要用字符做。不过我在用 Atom 的时候也怀念 Emacs 的一些功能比如纯键盘分屏和 C-SPC Mark Region。\n今年的游戏时间基本都拿来打 Dota 了，虽然看 TI 看得很难受，但是和朋友一起玩游戏还是挺快乐的，这也属于幸运和倒霉的周期了吧。塞尔达什么的，等我想起来慢慢玩……\n跨年的时候还是和去年一样朋友聚餐了，我还在 B 站上传了 vlog，和蓝猫铁道迷一起吃寿喜锅很开心。今年写代码没少让琪神当小黄鸭，谢谢琪神。\n总之先写这些，如果我又想起什么，我后面再慢慢加上吧，最近冻着了嗓子疼，希望倒霉的周期赶紧过去，过段时间还得找找机会去修相机呢……\n"},{"title":"把我的时间戳还给我！","url":"/posts/Please-Bring-back-My-Timestamp/","content":"当然，我在标题里说的时间戳并不是狭义的 UNIX 时间戳，我只是想表达一下精确的时间记录而已。\n不知道从什么时候开始，许多网站都不再显示 2021-11-09 09:00:00 这样精确的时间，而是开始显示“刚刚”、“5 分钟前”、“3 个月前”、“1 年前”，我能猜出来这又是哪些自以为聪明的产品经理以“对用户友好”的理由想出来的或者抄来的，但不幸的是大部分这种行为都很愚蠢。据我所知 Twitter 是这样，Twitter 做什么就抄什么的微博也是这样，令人难以忍受的是 GitHub 也是这样显示时间，以及今天彻底惹恼了我的 YouTube 在视频页面也是这样显示。\n如果你还没搞清楚我的愤怒来源，我实际上是想做这样的事情：我知道索尼 A7S3 是在 2020 年 7 月发布，我想快速找到相关的视频，请问我怎么在这一大片的“1 年前”里猜到去年 7 月的大致位置？\n\n“1 年前”这个描述可以包含从“1 年零 1 天”到“1 年零 364 天”这样不精确的时间范围，我觉得和直接不显示时间范围也没什么区别了。或者像 GitHub 那样鼠标悬浮在上方显示时间戳的方案也不好，比如这个页面这么多视频，我的目的是一眼快速找到我需要的时间段，一个一个悬浮多慢？所谓的用户友好？不见得，用户要被气死了。人类的脑子还没弱到连算个大致的时间差都算不出来。从工程上来说，这还引入了额外的复杂度，比如有一个 JS 脚本是专门用来更新这些时间的，你需要引入额外的代码，才能保证从“刚刚”变成“7 秒前”、“10 秒前”、“不到一分钟”、“5 分钟前”（说的就是你 GitHub），哈哈，这简直太好笑了。更别提弱智的微博 API 竟然直接在 JSON 里面返回“X 分钟前”，不过反正这种互不联网说不定什么时候就把 API 给砍了。\n我觉得 Twitter 和微博这种本身就是短平快讲究时效性的网站使用大致的时间差而不是时间戳还算可以理解，至于 YouTube、 GitHub 这种经常需要查看精确时间的显示大致的时间差纯粹是给用户填堵，这一点 B 站经常脑残的前端反而做得不错，在视频页面显示的是精确的日期。\n"},{"title":"通过 USB HID over AOAv2 在 scrcpy 里模拟真实键盘","url":"/posts/Simulate-Physical-Keyboard-in-Scrcpy-via-USB-HID-over-AOAv2/","content":"这篇文章同时有 中文版本 和 英文版本。\nThis post is both available in Chinese version and English version.\n中文版本\n三年前（2018 年）我在 scrcpy 的 GitHub 仓库里提了 这个 issue，因为我当时发现这个项目能把手机投屏到电脑上，也就是说我就可以在 Linux 下面通过 Android 手机聊 QQ 了（我当时大概也许还有高强度聊 QQ 的需求），不过试了之后发现很难用，因为它和直接在手机上插键盘不一样，显示的还是软键盘，虽然能通过电脑键盘触发输入法，但是却不能用数字键选词。我当时也不太懂，于是就发 issue 问开发者，@rom1v 回复说 Android 有个叫 HID over AOA 的协议可以实现，但是对有些设备来说有 bug，同时也需要有人花时间读 USB 规范然后做把 SDL event 转换成 HID event 的工作。我又想那能不能直接用电脑的输入法生成字符然后传给手机，@rom1v 表示现在就是这么做的，但是 Android 相关的 API 限制只能发送 ASCII 的字符，所以也行不通。\n我当时稍微了解了一下这些相关的东西，不过显然超出了我的能力范围，于是这件事我就搁置了。一直到最近或者准确的说就是上周和 Hackghost 跟我提起说苹果似乎打算做手机投屏到电脑的功能，然后又说华为好像有个现成的。不过我对这些一向是漠不关心的，这些厂商就是又懒又坏的典型，不会做一个 Linux 版的客户端的。如果他们自己做不了或者不打算做，那就应该公开一点，让能做的人来做而不是藏着掖着。然后就是我们讨论了一气关于成本到底谁付了谁亏了的问题，我坚持认为厂商赚得已经够多了，成本和利润 Linux 用户在买手机的时候也是照样付的，只是这些人贪得无厌能少付出成本就少付出一点而已。最后我说已经有能做的人做了 scrcpy 这个项目出来，投屏完全没问题，支持各种平台，美中不足就是输入体验不太好。这时候我又想已经过去很久了，不如我再试试去看看能不能解决输入体验的问题，于是就回去翻了这个 issue。\n运气不错，翻过去看到在 2019 年年初的时候 @amosbird 已经写了一份代码，他自称是能用的，然而不知道为什么当初没有合并进主线，现在多半也是跑不起来。我一开始想我把他这个在当前的 HEAD 上重构一下就好了，于是开始读他的代码。一开始还是没什么头绪，看起来他似乎写了不止一个功能，但是都在一个 commit 里面，又没什么关于思路的注释。随后我加上了他的 Telegram，不过他本人表示时间有点久了他也不记得自己写的代码都是什么意思（笑）。于是我只能硬着头皮啃了，好在我现在的经验比以前涨了很多，然后再同时啃 USB 和 Android 的协议，配合一些搜到的其他资料，最终了解了大概是怎么回事。最开始本来以为简单地合并一下代码就可以了，没想到还发现了他代码里的错误，基本是变成重写了。花了头两天事件让程序跑起来，然后用了几天调整成和现有代码一致的风格以便合并进去。 总之很是有一点新手村出来遇到 BOSS 暂时撤退，升级打怪三年之后杀回来的感觉。\n做好之后的效果基本就是下面两张截图，Gboard 开始工作在外接硬件键盘的模式了：\n\n\n既然是 USB HID over AOAv2，那很显然需要知道 USB HID 是怎么回事，USB 官方有一个 很长的 PDF 规定怎么成为一个合法的 HID 设备，说实话，看不太下去。如果你想要查一些有帮助的例子，直接查 AOAv2 多半是没戏的，只有 Android 自己一个惜字如金的文档页，我的经验就是你找那些主题是用单片机模拟键盘鼠标的文章，他们的目标和这个基本是一致的。不过我说好不碰硬件的话看来是算作废了。\n基本上成为一个 USB HID 设备需要你发送一大堆的描述符到主机，不过我们这里有点不一样，因为 Android 设备连接电脑的时候，Android 是 USB 从设备，电脑是主设备，而 AOAv2 是从主机反向发数据到从设备，它不要求我们发送一大堆 USB 的描述符，只要向 Android 注册一个设备，发送 HID 的报告描述符，再发送 HID event，再注销就好了。这部分可以通过 libusb 这个库来实现 USB 的数据包传输，然后把 Android 的几个命令封装成函数就可以了，基本是在 https://github.com/AlynxZhou/scrcpy/blob/dev/app/src/aoa_hid.c#L155-L246 这部分。\n基础的 API 有了之后则是具体的发什么数据包了，HID 的数据实际上就是由 byte 组成的 buffer，首先就是报告描述符，这个描述符是让主设备知道每个发过来的 event 里面的每个 byte 都是什么含义，键盘的描述符其实相对是比较固定的，在 Device Class Definition for Human Interface Devices 这个 PDF 里面其实给了一个最简单的 USB 键盘的例子，这个也是保证在 BIOS 里面能正常使用 USB 键盘的最小集合，是在 Appendix B: Boot Interface Descriptors 下面的 B.1 Protocol 1 (Keyboard) 和 Appendix E: Example USB Descriptors for HID Class Devices 下面的 E.6 Report Descriptor (Keyboard)。不过有时候光知道这些还不够，比如报告描述符里面大部分都是两个 byte 一句话，第一个 byte 表示的是类别而第二个表示的是具体的值，后面的大概很好理解，但是第一个 byte 是怎么算出来的可能需要了解，这需要看那个 PDF 里 8. Report Protocol 这一节了，或者中文的话可以看 这篇知乎文章，然后你就会明白为什么有时候看起来数字不一样结果含义却一样了，因为其实第一个 byte 的每个 bit 都是有分别的含义的。然后就是对于 Usage Tag 这个有很多，被放在 另一个单独的 PDF 里面了。\n我还是把 https://github.com/AlynxZhou/scrcpy/blob/dev/app/src/hid_keyboard.c#L28-L144 这段代码贴过来好了，详细的说明我加在了注释里面：\nunsigned char kb_report_desc_buffer[]  = {\n    // Usage Page (Generic Desktop)\n    // 键盘这个字段应该是 Generic Desktop，为啥是这个我也不知道。\n    0x05, 0x01,\n    // Usage (Keyboard)\n    // 也不用我解释了吧，自己查表去。\n    0x09, 0x06,\n\n    // Collection (Application)\n    // 然后基本上 USB HID 描述符要有不同的 Collection，\n    // 代表你发到主机的一组数据，\n    // 一个设备最少有一个 Collection 吧，不然也没意义了。\n    // 为啥是 Application 好像是因为 Keyboard 的 Usage Page tag 在这个里面。\n    0xA1, 0x01,\n    // Report ID (1)\n    // 你会发现最基础的那个键盘示例里面没有这个字段，\n    // 实际上当你只发一种数据的时候这个字段可以省略。\n    // 但是现在是个机械键盘都带媒体控制按键吧，那个要算另一个 Collection 的，\n    // 所以就通过 Report ID 区分，同时你发数据的时候第一个 byte 就得说明自己发的是哪个\n    // Report ID。所以不要问为什么网上说 USB HID 键盘一个事件 8 个 byte 我们却发了 9 个。\n    0x85, 0x01,\n\n    // Usage Page (Keyboard)\n    // 这里注意 Usage Page 和 Usage 不一样咯。好像这个文档里也叫 Key Codes 来着。\n    0x05, 0x07,\n    // Usage Minimum (224)\n    // 一般来说一组报告数据包含很多用途，你不可能把所有的用途都列出来，给个用途范围就可以了。\n    // 这里表示最小的用途 tag 是 0xE0，应该是 Left Control 的意思。\n    0x19, 0xE0,\n    // Usage Maximum (231)\n    // 最大的用途 tag，是 Right GUI 键。这个 byte 的含义就是键盘上的八个修饰键。\n    // Left Control，Right Control，Left Alt，Right Alt，Left Shift，Right Shift，\n    // Left GUI，Right GUI。\n    // 不过一定要记住 16 进制数转成 2 进制时候最右边是第 0 位，也就是说上边的顺序要倒过来。\n    0x29, 0xE7,\n    // Logical Minimum (0)\n    // 每个 bit 的逻辑最小值当然是 0 啦。\n    0x15, 0x00,\n    // Logical Maximum (1)\n    // 按下去就变成最大值 1。\n    0x25, 0x01,\n    // Report Size (1)\n    // 每个数据只占 1 bit。\n    0x75, 0x01,\n    // Report Count (8)\n    // 一共 8 个数据。\n    0x95, 0x08,\n    // Input (Data, Variable, Absolute): Modifier byte\n    // 关于 Input tag 是什么意思太长了，自己查去吧，总之和上面那些要符合。\n    0x81, 0x02,\n\n    // 下面这段是厂商保留位，一般只要写一个为 0 的 byte 就可以了。\n    // Report Size (8)\n    0x75, 0x08,\n    // Report Count (1)\n    0x95, 0x01,\n    // Input (Constant): Reserved byte\n    0x81, 0x01,\n\n    // 这部分表示主设备返回的关于 LED 灯亮的信号，\n    // HID 键盘自己是不保存什么大写锁定的状态的。\n    // 不过我们毕竟不是真的键盘，这部分抄一下就完事了，程序直接忽略。\n    // 总之键盘上有 5 个灯，和上面的修饰键差不多，一个 bit 代表一个灯。\n    // Usage Page (LEDs)\n    0x05, 0x08,\n    // Usage Minimum (1)\n    0x19, 0x01,\n    // Usage Maximum (5)\n    0x29, 0x05,\n    // Report Size (1)\n    0x75, 0x01,\n    // Report Count (5)\n    0x95, 0x05,\n    // Output (Data, Variable, Absolute): LED report\n    0x91, 0x02,\n\n    // 5 个 bit 对不齐，这 3 个 bit 是为了凑整的。\n    // Report Size (3)\n    0x75, 0x03,\n    // Report Count (1)\n    0x95, 0x01,\n    // Output (Constant): LED report padding\n    0x91, 0x01,\n\n    // 下面就有意思了，键盘上有 101 个普通键，你肯定是不会同时按下 101 个键的，\n    // 所以也不需要像修饰键那样每个 bit 代表一个键\n    //（当然理论上你写个描述符说我这个键盘就是这样的也未尝不可啦），\n    // 所以实际上这里返回的是一个数组，每个 byte 代表一个被按下去的键的键码。\n    // Usage Page (Key Codes)\n    0x05, 0x07,\n    // Usage Minimum (0)\n    // 用途最小值从不代表任何键的空值 0 开始。\n    0x19, 0x00,\n    // Usage Maximum (101)\n    // 这里就是 101 啦，标准键盘上的按键数量，如果要支持非标准键盘自己查表去。\n    0x29, HID_KEYBOARD_KEYS - 1,\n    // Logical Minimum (0)\n    // 因为每个 byte 放的都是键码，键码最小值是 0。\n    0x15, 0x00,\n    // Logical Maximum(101)\n    // 最大值就是第 101 个键。\n    0x25, HID_KEYBOARD_KEYS - 1,\n    // Report Size (8)\n    // 每个值都是 1 byte。\n    0x75, 0x08,\n    // Report Count (6)\n    // 最基础的 USB 键盘一次最多能返回 6 个被同时按下的按键，\n    // 你的 USB 键盘实际上很可能会告诉电脑它能报告更多，\n    // 不过我们毕竟不是真的键盘，6 个键应该能满足大部分情况了。\n    0x95, HID_KEYBOARD_MAX_KEYS,\n    // Input (Data, Array): Keys\n    // 这里也查表去吧，总之变成了 Array 而不是 Variable。\n    // 一个 Collection 应该是只能有一个 Array，必须在末尾来着。\n    0x81, 0x00,\n\n    // End Collection\n    // 这样我们代表正常键盘的 Collection 就结束了。\n    0xC0,\n\n    // Usage Page (Consumer)\n    // 下面则是代表媒体控制的按键，他们属于另一个用途页了。\n    0x05, 0x0C,\n    // Usage (Consumer Control)\n    // 这些键在另一个用途下面。\n    0x09, 0x01,\n\n    // Collection (Application)\n    // 一个新的 Collection，数据和之前不一样了。\n    0xA1, 0x01,\n    // Report ID (2)\n    // 一个新的 Report ID，\n    // 所以这种 event 是 1 byte Report ID 和 1 byte 键码一共 2 byte。\n    0x85, 0x02,\n\n    // Usage Page (Consumer)\n    // 下面的用途都算这里。\n    0x05, 0x0C,\n    // 这些和修饰键基本是一个意思，每个 bit 代表一个按键，不过这些用途不连贯了。\n    // Usage (Scan Next Track)\n    0x09, 0xB5,\n    // Usage (Scan Previous Track)\n    0x09, 0xB6,\n    // Usage (Stop)\n    0x09, 0xB7,\n    // Usage (Eject)\n    0x09, 0xB8,\n    // Usage (Play/Pause)\n    0x09, 0xCD,\n    // Usage (Mute)\n    0x09, 0xE2,\n    // Usage (Volume Increment)\n    0x09, 0xE9,\n    // Usage (Volume Decrement)\n    0x09, 0xEA,\n    // 和修饰键差不多啦下面。\n    // Logical Minimum (0)\n    0x15, 0x00,\n    // Logical Maximum (1)\n    0x25, 0x01,\n    // Report Size (1)\n    0x75, 0x01,\n    // Report Count (8)\n    0x95, 0x08,\n    // Input (Data, Array)\n    0x81, 0x02,\n\n    // End Collection\n    0xC0\n};\n\n所以基本上我们构建的虚拟键盘就告诉主设备它是这么报告数据的。里面除了要注意具体的数据之外第一个 byte 要放 Report ID 之外也没什么。然后就是怎么把 SDL 的事件转换成 HID 事件了，一开始看 @amosbird 的代码，发现他把 HID event 理解成了和 SDL event 一样的东西了——有一个类似修饰键的数据和一个按下抬起的数据和一个具体哪个键的数据，用户按一个键就生成一个针对这个键单独的 event——但是其实不是，HID 键盘并不会直接告诉你哪个键按下还是抬起了，从上面的报告描述符也能看出来，实际上它是一个基于顺序的协议，比方说我按下了 C，给主机的事件可能是 C键 00 00 00 00 00，又按下了 B，再发一个 C键 B键 00 00 00 00，抬起来 C，再发一个 B键 00 00 00 00 00，然后主机对比之前和之后的事件，得到“C 抬起了”或者“B 按下了”的信息，这才是我们司空见惯的“键盘事件”。所以在程序里面我们需要做个反向操作，把单独针对某个按键的按下抬起的数据变成一个“有哪些键按下”的数据。这里其实很简单，我们内部用一个数组保存当前的按键状态，每次有 SDL 的按键事件发来就更新状态（不是所有按键事件都是那 101 个键哦），然后遍历这个数组，就可以利用哪些索引对应的值是 true 生成 HID 事件的内容了，同时 HID 也不要求发送的按键在数组里的顺序和按下的顺序一致，而且 SDL 的 scancode 和 HID 的值是一致的。对于修饰键更简单，SDL 每个事件里面的修饰键和 HID 一样，都是包含当前所有修饰键的状态，只是具体的哪个 bit 表示哪个键不一样，转换一下就可以了（https://github.com/AlynxZhou/scrcpy/blob/dev/app/src/hid_keyboard.c#L195-L223）。但是千万不要因为发来的按键不是那 101 个键就直接忽略掉整个事件，因为可能用户只是单独按了一下修饰键，所以如果要跳过只要跳过更新按键状态就好了（https://github.com/AlynxZhou/scrcpy/blob/dev/app/src/hid_keyboard.c#L225-L269）。\n可能有聪明的小朋友要问了，你这最多只能发六个键，我按下七个怎么办？自己好好读一下 HID 协议就行了，这种情况下需要回一个 phantom state，具体就是修饰键一切照常，六个 key 全返回 0x01（https://github.com/AlynxZhou/scrcpy/blob/dev/app/src/hid_keyboard.c#L250-L259）。\n基本上看到这里已经可以成功的给 Android 手机发 HID 键盘事件了，另外就是如果你用桌面环境的话，媒体按键应该会被桌面环境拦截，所以其实我没有发媒体事件，scrcpy 是用组合键单独处理了一部分功能。然后就是一些收尾，比如 @amosbird 忘记在程序结束之前取消注销 HID 设备了，这会导致如果你不拔掉 USB 线，触摸用的软键盘就一直出不来。\n最后 @rom1v 表示 scrcpy 是在单独的线程里处理输入的，所以我也给 AOA 单独起了一个线程，这个只要照着 scrcpy 原本 controller 的线程抄一个就可以了。\n你要是问为什么不把鼠标也用 HID 的方式模拟进去的话，我可以告诉你我也试过了，效果并不算好，AOA 是可以注册不止一个 HID 设备的，只要你分配不同的 ID 并作为参数发过去就可以了，这一部分代码我都留好了。但是一个主要的问题是 HID 鼠标只汇报 X 和 Y 的变化量，导致比方说我把鼠标从 SDL 的窗口挪走，MotionEvent 停止，HID 鼠标就会停在边框上，这时候我再从另一个方向挪进窗口，HID 鼠标的指针和你本机的指针就不再同步了，体验不如 scrcpy 自己注入事件的方式，所以我放弃了。\nWindows 下面 libusb 似乎不能很好的连接 Android 手机发送数据，这个我没什么办法，看起来是个 陈年老 bug，考虑到我一开始的目的只是我自己能在 Linux 下面方便地聊 QQ，做成这样我觉得就可以了，我的代码逻辑正确，即使要修复，也不至于动 scrcpy 这部分而是动 libusb，@rom1v 也表示现在就很不错了。\n当然因为依赖 USB，所以你利用 ADB over WiFi 的话就没办法用这个功能了，不过我用电脑时候一般会给手机充电，也无所谓。\n提交的 PR 链接是 https://github.com/Genymobile/scrcpy/pull/2632/files，感觉是个大工程，最后实际上也就修改了一千行？不过确实挺难的。\n\nEnglish Version\n(Title is Simulate Physical Keyboards in Scrcpy via USB HID over AOAv2.)\nThree years (2018) ago I opened an issue on scrcpy's GitHub repo, because I just found using scrcpy I can mirror my Android phone to computer and control it, so I could use QQ on Linux via my Android phone (I still had to use QQ frequently at that time), but after I tried it, I found it's inconvenient, because it shows soft keyboard on Android phone instead of simulating physical keyboard, I can trigger input method via typing but using numbers to choose words is not available. I know little about the code at that time and ask the maintainer for some reason, @rom1v replied that there is a protocol called HID over AOA supported by Android can implement a physical keyboard, but there is a bug on some devices and needs some one take their time to read specifications and write some code to convert SDL event into HID event. Then I asked that how about using computer's IME and pass Chinese into Android, @rom1v said that's what scrcpy did but Android has a limitation that you can only pass ASCII chars.\nI just tried reading some related things but it beyonds my ability, I just put it here. Until last week when I am talking with my friend Hackghost, he said Apple is making their own screen mirror implementation, and seems Huawei has one on their phone. I never care about Hardware manufacturer, neither Apple nor Huawei, because I think they are both lazy and evil, and they won't make a Linux client for their screen mirror implementation. If they are unable to make it, they should be open and allow users to do it by themselves. Then we talked about cost and profit, I insist that they have enough profit and Linux users also paid for their phone, so the feature should be available on Linux, but they are greedy and want more profit. Finally I said that there is already a hero who made scrcpy, and scrcpy runs on Windows, Linux and macOS, except for the bad IME experience. And I remebered it takes a long time after I opened the issue, maybe I should try again so I did it.\nI am lucky that @amosbird already wrote some code in 2019 and he said it is able to work but he didn't send a PR, I guess it won't work now but I could refactor it to current HEAD so I start to read his code. At first I am wired that he implemented not only one feature in a single commit without any hinting about what he did. Then I managed to contact to him via Telegram, but he forget his code because 2 years past (lol). So I need to do it myself, I am more experienced that 3 years ago and I read USB and Android documents, with some other articles, I know what I should do. At first I think I just merge code, but finally I am re-writing because some mistakes in his code. It takes 2 days to make it work and some other days to tweak it. Sounds like that you met a BOSS when you are a newbie and you escaped, fight to level up and come back after 3 years.\nThose are screenshots of this feature:\n\n\nSo it's USB HID over AOAv2, you need to know what's USB HID, there is a long PDF from USB IF telling you how to be a HID device, it's hard to read. And if you want examples, typing AOAv2 in Google is useless, you can only get a little page from Android's documents, I found that articles about simulating keyboard with single chip microcomputer (not sure whether this is the correct translation, some thing like Arduino or STM32) are helpful, however I've said that I won't touch hardware, my words are fake now.\nBasically you need to send a lot of descriptors to host if you want to be a USB HID device, but things are different here. When you connect your Android phone to computer, your computer is the host and your Android is sub-device, AOAv2 is about sending data from host to sub-device, so we don't need most USB descriptors, just register a device to Android and send HID report descriptor, then send HID event, then unregister it. We can use libusb for data transfering and make some Android's command into functions, see https://github.com/AlynxZhou/scrcpy/blob/dev/app/src/aoa_hid.c#L155-L246.\nAfter API we need to decide what to send, HID just sends buffer made of bytes, first is the report descriptor, telling host that what's the meaning of every bytes in event, keyboard has a basic example in Device Class Definition for Human Interface Devices which could be used in BIOS, you need to read Appendix B: Boot Interface Descriptors, B.1 Protocol 1 (Keyboard) 和 Appendix E: Example USB Descriptors for HID Class Devices and Appendix E: Example USB Descriptors for HID Class Devices, E.6 Report Descriptor (Keyboard). But those are not enough, mostly two bytes in descriptor made one meaning, the first byte is about type and the second is value, if you want to know how to calculate the first bye, you need to read 8. Report Protocol, and then you'll know why sometimes different numbers have the same meaning because each bits of the byte have different meaning. And their are a lot of Usage tags, they are in another PDF.\nI copy https://github.com/AlynxZhou/scrcpy/blob/dev/app/src/hid_keyboard.c#L28-L144 here and add detailed meaning in comments:\nunsigned char kb_report_desc_buffer[]  = {\n    // Usage Page (Generic Desktop)\n    // I don't know why keyboard should be Generic Desktop.\n    0x05, 0x01,\n    // Usage (Keyboard)\n    // Go to look up in the table.\n    0x09, 0x06,\n\n    // Collection (Application)\n    // There are collections in descriptor,\n    // they represent for data you send to computer.\n    // A device should have at least 1 collection.\n    // Keyboard's usage page tag is under Application.\n    0xA1, 0x01,\n    // Report ID (1)\n    // You'll find there are no Report ID in the keyboard example,\n    // because you can ignore it if you have only 1 collection.\n    // But if your keyboard has media keys, they are in different collection.\n    // So you need a Report ID and you send it as the first byte of event.\n    // You know why we send 9 bytes in event while people say 8 for keyboards?\n    0x85, 0x01,\n\n    // Usage Page (Keyboard)\n    // Usage Page is different from Usage.\n    0x05, 0x07,\n    // Usage Minimum (224)\n    // You have many Usages in a collection, but you cannot list them all,\n    // so you have a range from 224, which is Left Control.\n    0x19, 0xE0,\n    // Usage Maximum (231)\n    // And end to 231, the Right GUI, those are 8 modifier keys.\n    // Left Control，Right Control，Left Alt，Right Alt，Left Shift，Right Shift，\n    // Left GUI，Right GUI。\n    // But when you convert hex to binary, the right is bit 0,\n    // so reverse the sequence.\n    0x29, 0xE7,\n    // Logical Minimum (0)\n    // 0 is the minimum of a bit.\n    0x15, 0x00,\n    // Logical Maximum (1)\n    // Press is the maximum 1.\n    0x25, 0x01,\n    // Report Size (1)\n    // One key for one bit.\n    0x75, 0x01,\n    // Report Count (8)\n    // 8 keys.\n    0x95, 0x08,\n    // Input (Data, Variable, Absolute): Modifier byte\n    // Too long to describe what input means, please read the table,\n    // should match the above.\n    0x81, 0x02,\n\n    // Reserved, just send a byte of 0.\n    // Report Size (8)\n    0x75, 0x08,\n    // Report Count (1)\n    0x95, 0x01,\n    // Input (Constant): Reserved byte\n    0x81, 0x01,\n\n    // LEDs on keyboard, HID keyboard does not handle LED states itself.\n    // This is not a real keyboard so just grab it from Internet.\n    // 5 LEDs works like modifiers but output.\n    // Usage Page (LEDs)\n    0x05, 0x08,\n    // Usage Minimum (1)\n    0x19, 0x01,\n    // Usage Maximum (5)\n    0x29, 0x05,\n    // Report Size (1)\n    0x75, 0x01,\n    // Report Count (5)\n    0x95, 0x05,\n    // Output (Data, Variable, Absolute): LED report\n    0x91, 0x02,\n\n    // 5 bits of LEDs are not aligned so add 3 bits as padding.\n    // Report Size (3)\n    0x75, 0x03,\n    // Report Count (1)\n    0x95, 0x01,\n    // Output (Constant): LED report padding\n    0x91, 0x01,\n\n    // We have 101 normal keys on keyboards so you don't want to use 101 bits\n    // for them (it might be OK but hard to write), so we return an array and\n    // a byte is for a keycode.\n    // Usage Page (Key Codes)\n    0x05, 0x07,\n    // Usage Minimum (0)\n    // Minimum is 0 for no key pressed.\n    0x19, 0x00,\n    // Usage Maximum (101)\n    // 101 is the max keycode on standard keyboard.\n    0x29, HID_KEYBOARD_KEYS - 1,\n    // Logical Minimum (0)\n    // Bytes are keyboards and the minimum is 0.\n    0x15, 0x00,\n    // Logical Maximum(101)\n    // Maximum is 101.\n    0x25, HID_KEYBOARD_KEYS - 1,\n    // Report Size (8)\n    // Each key takes 1 byte.\n    0x75, 0x08,\n    // Report Count (6)\n    // USB keyboards need to report 6 keys at lease,\n    // your keyboard might tell your computer it can report more,\n    // but 6 is mostly OK.\n    0x95, HID_KEYBOARD_MAX_KEYS,\n    // Input (Data, Array): Keys\n    // Input is an array here, not a variable, array can only be the last\n    // of a collecton.\n    0x81, 0x00,\n\n    // End Collection\n    // Collection for keyboard is end.\n    0xC0,\n\n    // Usage Page (Consumer)\n    // FOr media keys.\n    0x05, 0x0C,\n    // Usage (Consumer Control)\n    0x09, 0x01,\n\n    // Collection (Application)\n    // A new collection because data format changes.\n    0xA1, 0x01,\n    // Report ID (2)\n    // A new Report ID, so event has two bytes, first Report ID then keycode.\n    0x85, 0x02,\n\n    // Usage Page (Consumer)\n    0x05, 0x0C,\n    // Like the modifiers, but those usages are not consistent,\n    // we have to list them here.\n    // Usage (Scan Next Track)\n    0x09, 0xB5,\n    // Usage (Scan Previous Track)\n    0x09, 0xB6,\n    // Usage (Stop)\n    0x09, 0xB7,\n    // Usage (Eject)\n    0x09, 0xB8,\n    // Usage (Play/Pause)\n    0x09, 0xCD,\n    // Usage (Mute)\n    0x09, 0xE2,\n    // Usage (Volume Increment)\n    0x09, 0xE9,\n    // Usage (Volume Decrement)\n    0x09, 0xEA,\n    // Like modifiers.\n    // Logical Minimum (0)\n    0x15, 0x00,\n    // Logical Maximum (1)\n    0x25, 0x01,\n    // Report Size (1)\n    0x75, 0x01,\n    // Report Count (8)\n    0x95, 0x08,\n    // Input (Data, Array)\n    0x81, 0x02,\n\n    // End Collection\n    0xC0\n};\n\nSo our keyboard sends this to host. The only thing you need to remember is send Report ID as the first byte of event. Then how to convert SDL event into HID event? Firstly I read @amosbird's code and he thinks HID event is like SDL event——one field for modifiers and another field for press/release and one field for which key, an event represents for one single key——but it's not, HID keyboards won't tell you which key is pressed or release, it's based on sequence, for example I press C first, it sends C 00 00 00 00 00, then I press B, sends C B 00 00 00 00, release C, sends B 00 00 00 00 00, the host is responsible for comparing the events and getting info like \"B pressed\" or \"C released\". So we need to convert it back to \"what are pressed keys\", it's simple, just use an array for state of current keys, update it when a SDL event is fired (not all key events are the 101 keys), then iterate the array to generate a event contains the indices of true, the sequence of keys does not matter in HID and SDL's scancode is the same as HID values. For modifiers are easier, every SDL events contains all modifiers just like HID events, they are just different in bits, so we do a convert (https://github.com/AlynxZhou/scrcpy/blob/dev/app/src/hid_keyboard.c#L195-L223). But don't ignore events just because it's not inside the 101 keys! User may just press a modifer, and we only skip updating keys state here (https://github.com/AlynxZhou/scrcpy/blob/dev/app/src/hid_keyboard.c#L225-L269&gt;).\nYou may ask that what will happen if I press 7 keys at the same time? HID requires to report a phantom state, which fills 0x01 as all 6 keys (https://github.com/AlynxZhou/scrcpy/blob/dev/app/src/hid_keyboard.c#L250-L259).\nWe almost done it, and if you have a DE, media keys should be caught by it before SDL, so I never send it in code. Scrcpy uses some combination keys for it. And some cleaning, for example @amosbird forget to unregister HID device before program exitting, and you cannot use soft keybaords until you disconnect the USB cable.\nAt last @rom1v says scrcpy uses another thread for input, so I make a thread for AOA, too. Just grab the code from controller.\nYou may ask about HID mouse, I've tried it, not good, AOA can register different HID devices, just pass different ID as argument, I have those code. But the biggest problem is that HID mouse only report the delta of X and Y, if I move the mouse outside the window from left edge, MotionEvent stops, the HID cursor will stop on the edge, and then I move my mouse inside the window from right edge, the HID cursor then loses sync with my mouse. The typicall scrcpy injection events work better so I give up.\nlibusb has an old bug on Windows so this cannot work on windows, I have no idea and because at first I just want to use QQ on Linux, I think it's OK, so does @rom1v. My code is correct and once libusb fixed it, my code should not be updated I think.\nHowever you need USB and this won't work with ADB over WiFi, I typically charge my phone while using computer so it's not a problem for me.\nPR is https://github.com/Genymobile/scrcpy/pull/2632/files, only 1 thousand lines? I spend a lot of time on it.\n"},{"title":"Fontconfig 和 Noto Color Emoji 和抗锯齿","url":"/posts/Fontconfig-NotoColorEmoji-Antialias/","content":"我一直用的是以前积攒的一份 fontconfig 配置，主要功能就是设置对于无衬线字体优先用 Roboto 显示英文字体，然后回退到 Noto Sans CJK SC 显示中文字体，因为 Roboto 比 Noto 的英文字好看，以及对等宽字体优先用 Monaco。虽然大部分都是网上抄来的，我自己并不太懂，但是这个配置一直工作的还可以。直到我开启了 RIME 内置的 emoji 输入法，发现 emoji 显示成了空白。\n一开始没觉得是什么很难解决的问题，在字体列表末尾加上 Noto Color Emoji 不就行了？不过事情要是真的这么简单，也就没必要写个博客记下来了。忘记当时怎么查的了，总之是搜到 一个 firefox 的 bug，提 bug 的人表示自己一直是开着 hintfull 和 antialias 的，关掉这个 Noto Color Emoji 才能显示。于是我看了一眼我的配置，也有这么一段：\n\n\n  \n  \n    false\n  \n  \n  \n    false\n  \n  \n  \n    rgb\n  \n  \n  \n    true\n  \n  \n    true\n  \n  \n    hintslight\n  \n  \n  \n    true\n  \n\n\" data-info=\"language-xml\" data-lang=\"xml\" class=\"code-block\">&lt;!-- 针对所有字体的默认设置，力求显示效果最好。 --&gt;\n&lt;match target=\"font\"&gt;\n  &lt;!-- 禁用内嵌点阵。 --&gt;\n  &lt;edit name=\"embeddedbitmap\" mode=\"assign\"&gt;\n    &lt;bool&gt;false&lt;/bool&gt;\n  &lt;/edit&gt;\n  &lt;!-- 禁用合成粗体。 --&gt;\n  &lt;edit name=\"embolden\" mode=\"assign\"&gt;\n    &lt;bool&gt;false&lt;/bool&gt;\n  &lt;/edit&gt;\n  &lt;!-- 设置显示器为 RGB 排列。 --&gt;\n  &lt;edit name=\"rgba\" mode=\"assign\"&gt;\n    &lt;const&gt;rgb&lt;/const&gt;\n  &lt;/edit&gt;\n  &lt;!-- 开启轻度微调。 --&gt;\n  &lt;edit name=\"hinting\" mode=\"assign\"&gt;\n    &lt;bool&gt;true&lt;/bool&gt;\n  &lt;/edit&gt;\n  &lt;edit name=\"autohint\" mode=\"assign\"&gt;\n    &lt;bool&gt;true&lt;/bool&gt;\n  &lt;/edit&gt;\n  &lt;edit name=\"hintstyle\" mode=\"assign\"&gt;\n    &lt;const&gt;hintslight&lt;/const&gt;\n  &lt;/edit&gt;\n  &lt;!-- 开启抗锯齿。 --&gt;\n  &lt;edit name=\"antialias\" mode=\"assign\"&gt;\n    &lt;bool&gt;true&lt;/bool&gt;\n  &lt;/edit&gt;\n&lt;/match&gt;\n\n那我觉得针对 Noto Color Emoji 关掉这几个选项就行了，正好这个 bug 下面就有人提供了配置：\n\n\n  \n    Noto Color Emoji\n  \n  \n    true\n  \n  \n    true\n  \n  \n    false\n  \n  \n    false\n  \n\n\" data-info=\"language-xml\" data-lang=\"xml\" class=\"code-block\">&lt;!-- Noto Color Emoji 不支持开启抗锯齿和微调，所以在全局开启之后还得给它关掉。 --&gt;\n&lt;match target=\"font\"&gt;\n  &lt;test name=\"family\" qual=\"any\"&gt;\n    &lt;string&gt;Noto Color Emoji&lt;/string&gt;\n  &lt;/test&gt;\n  &lt;edit name=\"scalable\" mode=\"assign\"&gt;\n    &lt;bool&gt;true&lt;/bool&gt;\n  &lt;/edit&gt;\n  &lt;edit name=\"embeddedbitmap\" mode=\"assign\"&gt;\n    &lt;bool&gt;true&lt;/bool&gt;\n  &lt;/edit&gt;\n  &lt;edit name=\"hinting\" mode=\"assign\"&gt;\n    &lt;bool&gt;false&lt;/bool&gt;\n  &lt;/edit&gt;\n  &lt;edit name=\"antialias\" mode=\"assign\"&gt;\n    &lt;bool&gt;false&lt;/bool&gt;\n  &lt;/edit&gt;\n&lt;/match&gt;\n\n重新登录，输入法里面的 emoji 都显示出来了，我以为事情到这里就结束了，没想到噩梦才刚刚开始……\n某一天用 firefox 打开 twitter，发现里面字体都是毛毛糙糙的，我觉得很离奇，明明我只是对 Noto Color Emoji 关了抗锯齿，为什么 twitter 的字体也受到影响了？而且其他页面的字体都是正常的，后来我又打开过一个微软的文档页面，也出现了类似的毛病。我研究了一下发现这两个页面都用了自定义的 webfont。可是 webfont 和 Noto Color Emoji 有什么关系？人类摸不着头发。\n我查了很多博客文章，不过它们都关注在正常的字体顺序上，还没有人研究 webfont 有什么问题。于是我只能自己研究，不过我不能保证我对 fontconfig 的理解完全正确，这东西看起来非常复杂，不过我还是大致搞清楚了一些事情。引用我看到的 一篇英文博客 的标题：\n\n当我凝视着 fontconfig 时，fontconfig 也在凝视着我。\n首先我找到了这个叫 fontconfig 几个常见的坑 的文章，里面信息量挺大的，不过说实话没有解决掉我的问题——我明明只对匹配到 Noto Color Emoji 时候关掉了抗锯齿，为什么 webfont 也受到影响了呢？这篇文章主要介绍怎么调整字体列表顺序，不过说实话我没有遇到那么多障碍，简单的写法就工作了。同一个作者还写了 Color Emoji in openSUSE 的文章，不过总感觉是交待到一半，翻了文章列表没有后续了。而且这篇文章关心的也是字体 fallback 时候某些字符选到错误的字体的问题，和我这个不太相关。\n然后还有 Linux fontconfig 的字体匹配 这篇文章，它介绍了浏览器是怎么和 fontconfig 合作匹配字体的，但是对于 webfont 也是没有提到。包括它的后续文章 用 fontconfig 治理 Linux 中的字体 也没提到和 Noto Color Emoji 相关的调整（可能和它用的 Twemoji 有关系）。\n然后我尝试按照文章里的办法对 firefox 进行一个 fontconfig 的调试，FC_DEBUG=4 firefox --new-instance --private-window https://twitter.com/ 直接吐了 12 万行输出，我实在是不知道从哪里看了。翻了一下午胡乱修改了好几次配置也没找出来哪里有问题。就在我眼花的时候偶然看到一条有用的，fontconfig 吐出来一个匹配结果，family 是 Roboto, Noto Sans CJK SC, Noto Color Emoji, sans-serif… 等等一长串，style 是 ¶。这个就很有趣了，因为我看 firefox 里面，twitter 用的 webfont 叫 TwitterChirp，它的 style 也是这么个 ¶（我怀疑是个 bug），那这条可能就是相关的了。然后我发现这个匹配结果下面 antialias 是 false，但理论上来说这条又不是最先匹配到 Noto Color Emoji，怎么会应用 Noto Color Emoji 的设置呢？我猜测是因为默认 test 语句是 qual=\"any\" 含义似乎是只要匹配到的有一个满足就应用，正常匹配一个字体应该是不会返回这么一长串 family 的（我用 fc-match 一般只返回一个符合条件的 family），虽然我不知道为什么 webfont 会返回这么一长串结果，总之我改成 qual=\"all\" 也就是必须所有结果都是 Noto Color Emoji 时候才应用就好了对吧！然后重新登录，emoji 能显示，我以为已经胜利了！结果事情证明是半场开香槟……\n当我以为完全没问题，正打算用 atom 整理一下这份配置的时候，突然发现不对劲了……Atom里面的中文字体失去了抗锯齿……你可以想像我当时心情有多绝望。具体为什么还是同样的我不知道，不过我猜测恰好是和 Linux fontconfig 的字体匹配 里面提到的 chromium 查询字体的奇葩逻辑有关系……可能只是它某次查询刚好漏掉了 Noto Sans CJK SC 匹配到了 Noto Color Emoji 然后就给了个 antialias false，然后再通过什么奇怪的 UI 字体查询找出了中文字体……反正总之我的心好累，我实在不知道该怎么解决了，我只想用个 emoji，我甚至还给 Noto Color Emoji 提了请求支持抗锯齿的 issue……然后我决定要不换成 Twemoji 算了。\nTwemoji 倒也不是开箱即用，它不能禁用内嵌点阵字体，不过好歹这个不像抗锯齿那么要命，就算影响了其他字体大概也许能够忽略吧……于是我改成了这个。不过这时候我突然灵光一闪有了新主意。\n按照前面那几篇博客和 这一篇手册翻译 的讲解，fontconfig match 的 target 分为三个阶段，第一是 scan，扫描字体文件，构建一个集合。然后是 pattern，按照规则构建一个字体列表。最后是 font，意味着已经挑出了需要的字体列表。所以调整字体回退顺序一般都放在 pattern 阶段。而网上大部分都把对于 freetype2 微调的选项放在了 font 阶段，不过我想放在 scan 阶段是不是也可以？这样扫描到 Noto Color Emoji 的时候就对它设置选项，也就不存在 family 列表可能有很多项的问题了。测试了一下发现基本一切正常，于是就把所有 freetype2 微调选项移到了 scan 阶段。（我不是 fontconfig 的专家，这一段要是有错误还希望指正。）\n下面是我现在的配置，首先我配置文件放的位置是 /etc/fonts/local.conf，因为 /etc/fonts/fonts.conf 是发行版默认的设置，用来加载其他配置文件所以不能改，然后我想对所有用户都生效，所以没有放在我的家目录，Arch Linux 包含一个 /etc/fonts/conf.d/51-local.conf 文件，/etc/fonts/fonts.conf 会加载它，然后它再加载 /etc/fonts/local.conf。\n首先是 XML 开头必须要有的那些东西：\n\n\n\n\" data-info=\"language-xml\" data-lang=\"xml\" class=\"code-block\">&lt;?xml version=\"1.0\"?&gt;\n&lt;!DOCTYPE fontconfig SYSTEM \"fonts.dtd\"&gt;\n&lt;fontconfig&gt;\n\n然后接下来就是针对所有字体的 freetype2 微调选项，按照上面介绍的放在 scan 阶段：\n\n  \n    \n    \n      false\n    \n    \n    \n      false\n    \n    \n    \n      rgb\n    \n    \n    \n      true\n    \n    \n      true\n    \n    \n      hintslight\n    \n    \n    \n      true\n    \n  \n\" data-info=\"language-xml\" data-lang=\"xml\" class=\"code-block\">  &lt;!-- 针对所有字体的默认设置，力求显示效果最好。 --&gt;\n  &lt;match target=\"scan\"&gt;\n    &lt;!-- 禁用内嵌点阵。 --&gt;\n    &lt;edit name=\"embeddedbitmap\" mode=\"assign\"&gt;\n      &lt;bool&gt;false&lt;/bool&gt;\n    &lt;/edit&gt;\n    &lt;!-- 禁用合成粗体。 --&gt;\n    &lt;edit name=\"embolden\" mode=\"assign\"&gt;\n      &lt;bool&gt;false&lt;/bool&gt;\n    &lt;/edit&gt;\n    &lt;!-- 设置显示器为 RGB 排列。 --&gt;\n    &lt;edit name=\"rgba\" mode=\"assign\"&gt;\n      &lt;const&gt;rgb&lt;/const&gt;\n    &lt;/edit&gt;\n    &lt;!-- 开启轻度微调。 --&gt;\n    &lt;edit name=\"hinting\" mode=\"assign\"&gt;\n      &lt;bool&gt;true&lt;/bool&gt;\n    &lt;/edit&gt;\n    &lt;edit name=\"autohint\" mode=\"assign\"&gt;\n      &lt;bool&gt;true&lt;/bool&gt;\n    &lt;/edit&gt;\n    &lt;edit name=\"hintstyle\" mode=\"assign\"&gt;\n      &lt;const&gt;hintslight&lt;/const&gt;\n    &lt;/edit&gt;\n    &lt;!-- 开启抗锯齿。 --&gt;\n    &lt;edit name=\"antialias\" mode=\"assign\"&gt;\n      &lt;bool&gt;true&lt;/bool&gt;\n    &lt;/edit&gt;\n  &lt;/match&gt;\n\n内嵌点阵的效果通常没有矢量绘制的效果好，合成粗体也只是个临时方案，微调什么的见仁见智了就。\n然后对于 Twemoji 要打开内嵌点阵：\n\n  \n    \n      Twemoji\n    \n    \n      true\n    \n  \n\" data-info=\"language-xml\" data-lang=\"xml\" class=\"code-block\">  &lt;!-- 当然另一个简单方案是换成 Twemoji，不过它不能关这个。 --&gt;\n  &lt;match target=\"scan\"&gt;\n    &lt;test name=\"family\" qual=\"any\"&gt;\n      &lt;string&gt;Twemoji&lt;/string&gt;\n    &lt;/test&gt;\n    &lt;edit name=\"embeddedbitmap\" mode=\"assign\"&gt;\n      &lt;bool&gt;true&lt;/bool&gt;\n    &lt;/edit&gt;\n  &lt;/match&gt;\n\n对于 Noto Color Emoji 也是打开内嵌点阵关闭微调和抗锯齿：\n\n  \n    \n      Noto Color Emoji\n    \n    \n      true\n    \n    \n      true\n    \n    \n      false\n    \n    \n      false\n    \n  \n\" data-info=\"language-xml\" data-lang=\"xml\" class=\"code-block\">  &lt;!-- Noto Color Emoji 不支持开启抗锯齿和微调，所以在全局开启之后还得给它关掉。 --&gt;\n  &lt;match target=\"scan\"&gt;\n    &lt;test name=\"family\" qual=\"any\"&gt;\n      &lt;string&gt;Noto Color Emoji&lt;/string&gt;\n    &lt;/test&gt;\n    &lt;edit name=\"scalable\" mode=\"assign\"&gt;\n      &lt;bool&gt;true&lt;/bool&gt;\n    &lt;/edit&gt;\n    &lt;edit name=\"embeddedbitmap\" mode=\"assign\"&gt;\n      &lt;bool&gt;true&lt;/bool&gt;\n    &lt;/edit&gt;\n    &lt;edit name=\"hinting\" mode=\"assign\"&gt;\n      &lt;bool&gt;false&lt;/bool&gt;\n    &lt;/edit&gt;\n    &lt;edit name=\"antialias\" mode=\"assign\"&gt;\n      &lt;bool&gt;false&lt;/bool&gt;\n    &lt;/edit&gt;\n  &lt;/match&gt;\n\n我个人还有一个需求，Monaco 作为一个等宽字体竟然支持连字，导致某次我看别人的代码时候 fi 连在一起让我以为他没有对齐缩进，结果人家说是我的问题，十分尴尬，于是我在这里关掉了它。不过有些程序比如 firefox 是不支持这个的，解决方法是用 FontForge 编辑字体文件删掉连字相关的数据再导出……\n\n  \n    \n      Monaco\n    \n    \n      liga off\n      dlig off\n    \n  \n\" data-info=\"language-xml\" data-lang=\"xml\" class=\"code-block\">  &lt;!-- 我真不理解一个等宽字体要连字功能干嘛？故意变得不等宽？ --&gt;\n  &lt;match target=\"scan\"&gt;\n    &lt;test name=\"family\" qual=\"any\"&gt;\n      &lt;string&gt;Monaco&lt;/string&gt;\n    &lt;/test&gt;\n    &lt;edit name=\"fontfeatures\" mode=\"append\"&gt;\n      &lt;string&gt;liga off&lt;/string&gt;\n      &lt;string&gt;dlig off&lt;/string&gt;\n    &lt;/edit&gt;\n  &lt;/match&gt;\n\n更新（2022-11-24）：某些网站的前端脑子里不知道装的什么东西，比如简书的前端可能脑子里装的是苹果，他们把 -apple-system,BlinkMacSystemFont,\"Apple Color Emoji\" 排在 sans-serif 前边，于是你看到的数字可能是 Emoji 里面的。再比如 B 站直播首页所有的字体都是微软字体，连 sans-serif 都没有，你没看错，这个不合格的前端连最后要加 sans-serif 保证兼容性都不知道，而且还把 Microsoft Sans Serif 拼错了，如果我是他的老板，我真想一拳打在他头上告诉他这个世界不是只有微软字体，然后再把他开除掉。现在我需要加条规则让他匹配到我想要的字体（虽然这样在一些需要微软雅黑的 office 软件里面可能有问题，不过反正我的工作不需要用垃圾 office 哈哈哈哈哈哈哈哈哈哈），所以需要对这些弱智做一些特殊照顾。因为 Fontconfig 是按顺序处理的，所以如果你想把某个字体换成 sans-serif 之类的处理，就得在那之前进行替换。然后很多网站写的都是 Apple Color Emoji，我们这里自然是没有的，要换成我们默认的。\n\n  \n  \n    -apple-system\n    \n      sans-serif\n    \n  \n\n  \n    BlinkMacSystemFont\n    \n      sans-serif\n    \n  \n\n  \n  \n    Microsoft Sans Serif\n    \n      sans-serif\n    \n  \n\n  \n  \n    Microsoft YaHei\n    \n      Roboto\n      Noto Sans CJK SC\n    \n  \n\n  \n  \n    Apple Color Emoji\n    \n      emoji\n    \n  \n\n  \n   -->\n  Noto Color Emoji -->\n   -->\n  emoji -->\n   -->\n   -->\n\n  \n  \n    system-ui\n    \n      sans-serif\n    \n  \n\" data-info=\"language-xml\" data-lang=\"xml\" class=\"code-block\">&lt;!--\n    有一点需要注意，sans-serif，serif，monospace, emoji 这些默认字体名并不是真正\n    的字体，而是锚点，是用来进行后续的字体插入的。因此如果你想通过把一些字体映射\n    成 sans-serif 来修正一些网站奇怪的逻辑的话，请务必写在处理 sans-serif 部分之\n    前，这样才会被正常替换。\n  --&gt;\n  &lt;!--\n    我建议这个世界新增一种物种叫苹果人，即打开脑壳之后没有脑子，装的全是苹果的\n    人，显然简书的前端就属于此类（-apple-system,BlinkMacSystemFont,\n    \"Apple Color Emoji\",\"Segoe UI Emoji\",\"Segoe UI Symbol\",\"Segoe UI\",\n    \"PingFang SC\",\"Hiragino Sans GB\",\"Microsoft YaHei\",\"Helvetica Neue\",\n    Helvetica,Arial,sans-serif）。正常人怎么会把 Apple Color Emoji 放前面？\n  --&gt;\n  &lt;alias&gt;\n    &lt;family&gt;-apple-system&lt;/family&gt;\n    &lt;prefer&gt;\n      &lt;family&gt;sans-serif&lt;/family&gt;\n    &lt;/prefer&gt;\n  &lt;/alias&gt;\n\n  &lt;alias&gt;\n    &lt;family&gt;BlinkMacSystemFont&lt;/family&gt;\n    &lt;prefer&gt;\n      &lt;family&gt;sans-serif&lt;/family&gt;\n    &lt;/prefer&gt;\n  &lt;/alias&gt;\n\n  &lt;!--\n    B 站直播首页的前端认为这个世界上只有微软字体（Arial,Microsoft YaHei,\n    \"Microsoft Sans Serif\",Microsoft SanSerf,微软雅黑），\n    所以我不得不开着这几个规则。如果我是他的老板，我就会开除掉这个不合格的前端。\n  --&gt;\n  &lt;alias&gt;\n    &lt;family&gt;Microsoft Sans Serif&lt;/family&gt;\n    &lt;prefer&gt;\n      &lt;family&gt;sans-serif&lt;/family&gt;\n    &lt;/prefer&gt;\n  &lt;/alias&gt;\n\n  &lt;!-- 我机器上可能真的有微软雅黑，而我真的不想看见它们，反正我也不做排版。 --&gt;\n  &lt;alias&gt;\n    &lt;family&gt;Microsoft YaHei&lt;/family&gt;\n    &lt;prefer&gt;\n      &lt;family&gt;Roboto&lt;/family&gt;\n      &lt;family&gt;Noto Sans CJK SC&lt;/family&gt;\n    &lt;/prefer&gt;\n  &lt;/alias&gt;\n\n  &lt;!-- 替换 Apple Color Emoji 字体。 --&gt;\n  &lt;alias&gt;\n    &lt;family&gt;Apple Color Emoji&lt;/family&gt;\n    &lt;prefer&gt;\n      &lt;family&gt;emoji&lt;/family&gt;\n    &lt;/prefer&gt;\n  &lt;/alias&gt;\n\n  &lt;!-- 替换 Noto Color Emoji 字体。 --&gt;\n  &lt;!-- &lt;alias&gt; --&gt;\n  &lt;!--   &lt;family&gt;Noto Color Emoji&lt;/family&gt; --&gt;\n  &lt;!--   &lt;prefer&gt; --&gt;\n  &lt;!--     &lt;family&gt;emoji&lt;/family&gt; --&gt;\n  &lt;!--   &lt;/prefer&gt; --&gt;\n  &lt;!-- &lt;/alias&gt; --&gt;\n\n  &lt;!-- 这也是一个常见的默认字体，我的 UI 字体就是无衬线。 --&gt;\n  &lt;alias&gt;\n    &lt;family&gt;system-ui&lt;/family&gt;\n    &lt;prefer&gt;\n      &lt;family&gt;sans-serif&lt;/family&gt;\n    &lt;/prefer&gt;\n  &lt;/alias&gt;\n\n然后就是常见的默认字体代称（sans-serif，serif，monospace，emoji）回退列表了，我是按照 Roboto/Roboto Slab/Monaco -&gt; Noto Sans/Serif (Mono) CJK SC -&gt; Noto Color Emoji 的顺序回退的：\n\n  \n  \n    sans\n    \n      Roboto\n      Noto Sans CJK SC\n      emoji\n    \n  \n\n  \n    sans-serif\n    \n      Roboto\n      Noto Sans CJK SC\n      emoji\n    \n  \n\n  \n  \n    serif\n    \n      Roboto Slab\n      Noto Serif CJK SC\n      emoji\n    \n  \n\n  \n  \n    monospace\n    \n      Monaco\n      Noto Sans Mono CJK SC\n      emoji\n    \n  \n\n  \n  \n    emoji\n    \n      Noto Color Emoji\n    \n  \n\" data-info=\"language-xml\" data-lang=\"xml\" class=\"code-block\">  &lt;!-- 然后该配置我们喜欢的默认字体了。 --&gt;\n  &lt;!-- 默认无衬线字体。 --&gt;\n  &lt;alias&gt;\n    &lt;family&gt;sans&lt;/family&gt;\n    &lt;prefer&gt;\n      &lt;family&gt;Roboto&lt;/family&gt;\n      &lt;family&gt;Noto Sans CJK SC&lt;/family&gt;\n      &lt;family&gt;emoji&lt;/family&gt;\n    &lt;/prefer&gt;\n  &lt;/alias&gt;\n\n  &lt;alias&gt;\n    &lt;family&gt;sans-serif&lt;/family&gt;\n    &lt;prefer&gt;\n      &lt;family&gt;Roboto&lt;/family&gt;\n      &lt;family&gt;Noto Sans CJK SC&lt;/family&gt;\n      &lt;family&gt;emoji&lt;/family&gt;\n    &lt;/prefer&gt;\n  &lt;/alias&gt;\n\n  &lt;!-- 默认有衬线字体。 --&gt;\n  &lt;alias&gt;\n    &lt;family&gt;serif&lt;/family&gt;\n    &lt;prefer&gt;\n      &lt;family&gt;Roboto Slab&lt;/family&gt;\n      &lt;family&gt;Noto Serif CJK SC&lt;/family&gt;\n      &lt;family&gt;emoji&lt;/family&gt;\n    &lt;/prefer&gt;\n  &lt;/alias&gt;\n\n  &lt;!-- 默认等宽字体。 --&gt;\n  &lt;alias&gt;\n    &lt;family&gt;monospace&lt;/family&gt;\n    &lt;prefer&gt;\n      &lt;family&gt;Monaco&lt;/family&gt;\n      &lt;family&gt;Noto Sans Mono CJK SC&lt;/family&gt;\n      &lt;family&gt;emoji&lt;/family&gt;\n    &lt;/prefer&gt;\n  &lt;/alias&gt;\n\n  &lt;!-- 默认 emoji 字体。 --&gt;\n  &lt;alias&gt;\n    &lt;family&gt;emoji&lt;/family&gt;\n    &lt;prefer&gt;\n      &lt;family&gt;Noto Color Emoji&lt;/family&gt;\n    &lt;/prefer&gt;\n  &lt;/alias&gt;\n\n这里的写法和 &lt;alias&gt; 是等价的。设置完这个记得把所有软件的自定义字体设置里面无衬线设置为 sans-serif，有衬线设置为 serif，等宽设置为 monospace，这样才会遵守这里的回退顺序。\n剩下还有一些冗长的用来在不同语言下选择不同的 Noto Sans CJK 变体的设置，因为 Noto Sans CJK 是一个多语种集合字体，然后中日韩对一些汉字有不同的变体，所以需要这一段，不过我就不贴在这里了，完整的文件可以在文章末尾下载。\n最后收个尾：\n\n\" data-info=\"language-xml\" data-lang=\"xml\" class=\"code-block\">&lt;/fontconfig&gt;\n\n完整的文件在这里：local.conf。\n😼\n"},{"title":"NVIDIA 驱动和 GNOME 和 Wayland","url":"/posts/NVIDIA-GNOME-Wayland/","content":"更新（2021-11-09）：最新的 NVIDIA 495 驱动终于支持了大家都在用的 GBM，同时最新的 XWayland 21.1.3 也添加了这方面的支持，也就意味着 NVIDIA 用户不再需要单独的轮子而是使用现有的稳定的代码。我已经切换到 GNOME Wayland 不止一周，目前各种功能都很正常。\n\n由于各种各样的历史原因和近期的变化，我在最近的聊天里发现很多朋友对 NVIDIA 驱动对 Wayland 的支持情况不甚了解，正好我最近在折腾相关的东西，所以打算简单介绍一下我了解的。\n常见问题\n为什么 NVIDIA 的 Wayland 支持这么差？\n长话短说，大部分驱动（AMD 的开源和闭源驱动，NVIDIA 的开源驱动 nouveau，Intel 的开源驱动）都同意采用 GBM 作为缓存 API，但是 NVIDIA 像个赌气的小孩一样表示“你们这个不好，我要做个更好的 EGLStreams”所以不支持 GBM，于是所有要兼容 NVIDIA 闭源驱动的混成器都要为它做单独的实现代码。诸如 GNOME 或者 KDE 这种大型项目有足够的人手，同时 NVIDIA 似乎也提供了一定的帮助所以已经有了混成器内部的 EGLStreams 实现，但也有些小型项目并不支持这个玩意。而且 NVIDIA 吹牛皮吹得很大，EGLStreams 哪里“更好”从他们的龟速进展上根本体现不出来。可能是因为挖矿赚得盆满钵满，忘记了显卡的本职工作是显示，NVIDIA 的闭源 Linux 驱动质量一直停留在能用的程度，同时又做各种各样限制导致开源的 nouveau 在较新的显卡上几乎不能用。\nNVIDIA 闭源驱动之前一直是稀泥巴糊不上墙的垃圾，因为它在 Wayland 下对使用 XWayland 的程序没有硬件加速支持——也就是说如果你使用 Xorg 会话，程序是有硬件加速的，你使用 Wayland 会话，Wayland 程序也是有硬件加速的，但是在 Wayland 会话下面那些旧的 Xorg 程序就没有硬件加速（听起来就像捡了芝麻丢了西瓜一样）——而仍然有相当一部分旧程序在 Wayland 下依赖 XWayland 运行，所以 NVIDIA 闭源驱动的 Wayland 会话基本可以认为是不能日常使用的。\n从版本 470 之后 NVIDIA 闭源驱动有所改善，首先是他们的员工提交的补丁在 XWayland 21.1.1 版本释出了，这个补丁添加了对 XWayland 硬件加速的支持（需要新版本驱动），然后新版本驱动在上周变为稳定版，用户可以安装，所以在拖了至少三年之后最后一块空缺终于被补上了。使用 KDE 和 GNOME 的 NVIDIA 显卡用户理论上就可以获得完整的 Wayland 体验了。\n现在 NVIDIA 用户的 Wayland 会话可以日常使用了吗？\n更新到 470 驱动之后我第一时间体验了一下，结果就是很抱歉，还是不行，不过 我遇到的都是一些小问题：\n\nFirefox 稳定版没法正常工作，但是 Nightly 版本是可以的，原因是 NVIDIA 和 Mesa 的 EGL 实现存在一点小区别，修复补丁已经在 Nightly 里面了，我们只要等稳定版版本升上去就可以了。\nGTK3 程序都能工作，但是 GTK4 的程序（比如 GNOME Extensions）却全都挂了，原因应该和 Firefox 一致，修复补丁也已经合并了，只是还没有释出稳定版。\nGoogle Chrome 不能工作，但是比较新的 Chromium 似乎可以，原因推测也是一样的，我懒得查了，同时 所有一直抱着旧版 Electron 不更新的程序应该都有同样的问题。\nGNOME Shell 的缩略图会显示错误，看起来是个两年的老 bug，最近开发者似乎找到了原因，还没修复。\n\n不过总而言之这些都是能很快修复（废话，大家动作都比 NVIDIA 快，建议老黄把自己的皮衣换成乌龟壳）的问题，不至于被卡很久，至于你的发行版是那种追求“稳定”选择了一个不支持的大版本然后不更新的？哈哈，关我 X 事。\n更新（2021-09-17）：Arch 这边 Firefox 更新到了 92.0，所以已经修好了。Google Chrome 现在是 93.0.4577.63，我也能正常打开。GTK4 的 MR 已经释出了稳定版，所以没问题。Atom 在我这用的是 electron9，能用，反而是用 electron12 的 Code - OSS 不能用了……最后那些不知道打包了几百年前的闭源拖拉机（要不把拖去掉变成垃圾？）也就是我指的很明确的 Slack 不能用，加上 --disable-gpu 是能用的，但是是他的问题，为什么要我关掉硬件加速消耗 CPU 资源？最后 GNOME Shell 自己的那个 bug……目前没有开发者搞这个，我简单看了一下发现是个我没涉足过的领域，不过总之我觉得是个看起来不爽但不影响你实际使用的问题，所以现在的状态就是 “又不是不能用” ！（我可没说后半句啊！上一个说了后半句的珠海小厂现在还活着吗？）\n另外我在 openSUSE Tumbleweed 上测试的时候还遇到一个问题啊，它默认安装的是 nouveau，需要手动在 YaST 里面安装 NVIDIA，装完之后似乎两个驱动冲突把我显示器搞黑屏了，于是我就没法直接重启。而且在那之后我显示器神奇的再也不亮了（PS4、Switch 全都点不亮它），拔掉电源放半分钟再插上才好，很头疼啊。\n我是 GNOME 用户，为什么装了 470 版本 GDM 选单里的 GNOME 还是 Xorg 会话？\n这个有几个原因，首先就是 Wayland 需要 Kernel Mode Setting 的支持（这也是个 NVIDIA 拖了好久才补上的特性），所以你的内核参数要有 nvidia-drm.modeset=1。其次要看 /etc/gdm/custom.conf 里面 WaylandEnable=false 是不是被注释掉的（默认应该是）。\n最可能的是 udev 规则，GDM 带了一个 /usr/lib/udev/rules.d/61-gdm.rules 文件，里面有这么几行：\n# disable Wayland on Hi1710 chipsets\nATTR{vendor}==\"0x19e5\", ATTR{device}==\"0x1711\", RUN+=\"/usr/lib/gdm-runtime-config set daemon WaylandEnable false\"\n# disable Wayland when using the proprietary nvidia driver\nDRIVER==\"nvidia\", RUN+=\"/usr/lib/gdm-runtime-config set daemon WaylandEnable false\"\n# disable Wayland if modesetting is disabled\nIMPORT{cmdline}=\"nomodeset\", RUN+=\"/usr/lib/gdm-runtime-config set daemon WaylandEnable false\"\n\n第二行表示如果检测到 NVIDIA 闭源驱动就关闭 Wayland，第三行则是如果没开启 Kernel Mode Setting 也关掉 Wayland。\n我猜大部分用户都没有第一行的设备，所以我建议直接 ln -s /dev/null /etc/udev/rules.d/61-gdm.rules 屏蔽掉这个文件。\n别问我既然 470 驱动都出来了 GNOME 开发者为什么不删掉这个文件，NVIDIA 憋出来 470 驱动总共也就一周多点，还有一堆小问题呢，删了怕不是又有一堆人来骂“怎么又把不稳定的东西搞给我了”。\n我是笔记本双显卡用户，主用的是核芯显卡，为什么我 GDM 选单里的 GNOME 也是 Xorg 会话？\n检查一下你是不是用了 NVIDIA 之前提供的 Xorg 下面的双显卡管理功能（有群友说叫 prime，我不知道是不是叫 prime）（顺便一提这也是 NVIDIA 憋了好几年才憋出来的本来就该有的东西）？如果你使用那个，首先它是 Xorg 下面的支持，其次它会加载 nvidia 模块，自然就会落到上一个问题里面的 udev 规则上。\n有群友在群里表示他删了那个规则然后进 Wayland，可以是可以，但我感到很迷惑。这个 prime 功能 NVIDIA 之前只是说了在 Xorg 下面可用，所以我不是很理解又要 prime 又要 Wayland 的可行性，我也不知道 NVIDIA 的按需渲染现在到 Wayland 里面还能不能像之前 Xorg 里面提供的功能一样正常工作。就理论上我给 NVIDIA 掏了钱，这是他的义务给我支持这个功能，但这可是垃圾 NVIDIA 啊！\n你问我有没有什么建议？如果你需要这玩意，就按照 GNOME 的规则用 Xorg 就好了，没错虽然我是 Wayland 支持者，但我在这里建议你还是先用着 Xorg。如果你只是想用 Wayland 然后并不是很需要用 NVIDIA 显卡，那就关掉它不让它加载驱动，没错，我在自己的笔记本上用的是 Bumblebee。\n我是 KDE 用户，你扯这么多 GNOME，跟我说说 KDE 现在有什么问题呗？\n大哥，我是 GNOME 用户啊，既然你是 KDE 用户，建议你自己试试然后给我讲讲……\n没用的观点\n你看这个 GDM 就是逊啦，我这里卡死根本不能用，大家都推荐换 SDDM 巴拉巴拉……\n强调一下我的目的是解决问题，比如群里有人问为什么我双显卡 GDM 里没 Wayland 可选你来这么一句我觉得很不礼貌，当别人就是需要用 GDM 的时候说“大家都推荐换 SDDM”我觉得不管是 GNOME 用户还是 KDE 用户都会觉得不是个好回答。其次只说自己 GDM 卡死完全没法判断问题所在，别人都没这个问题，或许问题就不在 GDM 而在你的配置呢？同理，这个“大家都推荐换 SDDM”里的“大家”是哪里来的呢？我觉得这样的回复对解决问题没什么帮助。\nXorg 好！Wayland 坏！\n请列举出充分而恰当的理由支持你的观点，而不是人云亦云。比方说你可能觉得 Wayland 缺乏你需要的某些功能，这可能是设计理念上的不同，或者发展时间不够所导致的，毕竟 X 协议的历史比 Wayland 长得多，所以请多一点耐心。而至于不讲道理或者二极管或者言论不友善的行为在哪里都是不受欢迎的。\n我也是 NVIDIA 用户，我玩 XX 游戏卡成幻灯片了！\n……信息太少，无法给出合理推断，只能告诉你我游戏玩的可欢乐了！\n你们 NVIDIA 用户真麻烦，谁叫你们自己掏钱买 N 卡受罪！\n你又不知道老子都有什么需求，买 N 卡我是暂时没法用 Wayland，但是不买 N 卡我就要达芬奇受罪 Windows 下面打游戏受罪直播编码受罪跑 hashcat 受罪（存疑）。我做的选择是我权衡之后的结果。再说一遍，解决问题和“换 XX”是完全不一样的态度。\n所以就是钱是掏了，骂还是照样骂，而且我都掏钱了，骂得然更有底气了！\n"},{"title":"新项目和新相机和新住处","url":"/posts/New-Project-New-Camera-New-Home/","content":"本来之前是想等新开的项目搞差不多了就来更新博客，结果没想到越写 TODO 越多一直搞到这个月才搞定，所以到现在才更新。\n新项目\n从家里回来北京之后第一件事情就是 SUSE 的 HackWeek，我之前大致想好了要做什么，我用过 screenkey 这个项目，但它使用了 X11 的 API，所以不支持 Wayland，我简单调查了一下，发现其实是可以绕过显示部分直接读取输入设备的事件的，于是就打算造一个替代品。\n读取键盘事件的部分其实很容易，简单试了一下就完成了。但是反而是显示部分比较难搞。一开始我打算用刚发布稳定版的 GTK4，结果发现 GTK4 在 NVIDIA 驱动的 Wayland 会话下面反而是有问题的，只能回退到没问题的 GTK3。我原本以为一周的时间做出一个能用的程序还是挺充足的，但是后来发现中间有各种各样的问题和奇怪的 work around。比如涉及到 GObject 对象在什么时候释放，有些文档说的也不是很清楚。以及因为要用单独的子进程执行需要 root 权限的后端读取输入事件和用单独的子线程查询后端输出带来的进程/线程间通信问题。篇幅有限，打算后续再开一篇博客来介绍这里面的经验。在一周的时间里勉强做出了能用的 demo 参加了 HackWeek 的成果展示环节（给有始有终的参与者的小礼物大概还放在我公司的桌子上，我都不知道是什么，因为一直没去取），然后用了几周打游戏的时间整理代码里面的问题（主要是各种资源释放），以及做一下翻译工作和打包工作，终于在上周达到基本稳定了。如果有兴趣，可以访问 https://showmethekey.alynx.one/ 或者观看 我发在 B 站上的介绍视频。\n新相机\n之前一直想提升视频的画质，但是还有一个想法是想玩玩摄影，因为平时周末经常是起得很晚然后在家里玩游戏，感觉也挺无聊的，所以打算买个相机，这样周末就有理由出去走走了。拍出来什么东西无所谓，主要是找点乐趣。然后朋友建议我买黑卡，虽然这个玩意确实很黑科技，但我并不是太感兴趣。很久以前我家里有一个数码相机，不是卡片机，是一个能变焦变很长的型号，但是后来智能手机出现之后就很快的不用它了，因为没有手机轻便的同时也没有足够的专业程度。所以我对相机的看法就是要么就搞高级一点的，肯定不会被手机取代，要么就用手机算了，而且说实话最高端的黑卡价格也都达到微单的价格区间了。考虑到要拿来录视频，很多录视频的 UP 主选择的都是微单而不是单反，再查找了一下发现很多人都用的索尼 A73，那选一个大家都在用的总是错不了的吧，于是初步选定了目标。然后又开始考虑是 A73 还是 A7C，虽然很多人对于 A7C 只是缩小体积然后价格却比 A73 高感到不满，但我阅读了一个对比表格之后，觉得还是 A7C 更适合我这种有录视频需求的人，于是就选择了 A7C。\n我个人是对 A7C 相比 A73 砍掉的部分没什么需求的，只看参数很容易会变成那种“这个也想要那个也想要”的情况，虽然作为消费者而言，当然是希望厂家提供越多的功能越好。但是在只有这几个选项的情况下还是得考虑需求做一下取舍。比如去掉 MicroUSB 接口导致只能接一些 USB-C 接口的配件，看了看配件价格，短期内我大概是不会考虑这些配件的……去掉前拨轮和摇杆对我来说也不是不能接受，毕竟触摸对焦也挺好用的。虽然少了很多自定义按键，但是方向键现在都是可以自定义的，其实差别也不大。反而轻巧的体积和翻转屏，以及没有录制时间限制于我来说很重要。有人说没有双卡槽不够专业，万一给别人录东西丢了数据没有冗余，但可以预见的未来我不会靠这玩意获得收入，录我自己的话丢了也不算什么无法挽回的后果（反正自己选的）。\n看点作品？虽然我就是随便拍拍，还是有几张觉得好看的。\n\n之前去五棵松玩，想试试夜拍效果，结果套头的焦段显然不够拍月亮，不过回家看看感觉这张图还不错。\n\n某天吃完饭散步，坐在长椅上拍了一张，莫名觉得很好看。\n\n之前经常来我窗口咕咕咕叫吵我清梦的珠颈斑鸠，我管它叫大鸽，经常我拿着相机走到窗口它跑了，这张一开始其实拍到了部分室内，导致窗外的部分有点过曝，不过好在 RAW 可以调整，拉低了一些曝光然后裁掉窗户周围的部分，就变得很生动了。\n\n最近大鸽不怎么来了，是不是因为楼下在锯树，大鸽的家没了？（虽然我对珠颈斑鸠再造一个窝需要花的时间表示怀疑。）\n买完了 A7C 莫名开始种草 A7S3，4K 60 帧看起来真诱人啊……1080P 60 帧和 4K 30 帧只能二选一到底是谁想出来的牙膏啊！\n说起来最近拍照片之后发现存储空间的需求还是很大啊，虽然我两块 2T 的机械应该还足以满足短期内的需求。最近挖矿导致大容量硬盘也涨价了，估计更新存储设备也不太现实，还是有空精简一下吧。NAS 的问题我也考虑过，但是后来想想还是放弃了把星际蜗牛重新搞起来的想法，因为看了很多视频/数码 UP 工作室的介绍之后意识到 NAS 比起存储的用途，更大的用途还是共享，比如多个剪辑师可以同时访问 NAS 上面的一份素材，不用在每台电脑上都复制一份。但我显然没这个需求也没这个网速，只是存储一些数据的话，还是直接插台式机里面比较方便吧！\n顺便我朋友一直怂恿我买一个大光圈定焦镜头，我也确实心动了，比较了一下之后打算先入 FE 55 F1.8 ZA，然后再考虑 FE 24-105 G F4 代替套头，不过因为一些原因还是暂时搁置了，原因的话就是下一部分。\n新住处\n当初因为时间比较紧所以选的这个住处，有几个不太满意的地方：房间太小了，放下柜子床电脑桌就没什么地方了。为了节约空间我是把桌子沿着床边摆的，我自己坐在桌子和衣柜之间，所以如果我要打开衣柜找东西就要把椅子推到桌子下面。然后在我 B 站录的视频里也能看出来，有朋友不止一次吐槽过我录视频环境过于混乱了。房子太旧了，实在是让人连做饭的想法都没，又破又旧，同时上班的话也比较麻烦，不是说交通不便，而是这个房子在一个院子的深处，出门坐公交或者去超市都要走很长一段距离。再加上我的卧室是直接挨着楼道，隔音又比较差，有时候午睡就会被上下楼的脚步声打扰。以及说实话我想要个稍微便宜点的房子，谁不想呢？\n所以今年快到期的时候我就在考虑搬家的问题。我们现在还是在家办公的状态，所以相对来说位置不太重要，虽然还是想尽可能找个上班方便的地方。但是很少有人和我一个方向上班，所以折腾了很久。本来是打算搬到更西南边的，甚至这一个月跑了好几个不同地方看房子，但是都因为各种原因没有选中，比如同学上班太远，周围没什么生活设施（虽然就在路边但是出门走好远好远才有超市到底是怎么搞的啊，为什么北京这边经常小区靠着路的一侧都是围墙，没有什么商业网点之类的，真的很没生活感），离地铁站太远，小区门禁是人脸识别（谁允许你们随便采集人脸信息的，而且约朋友来我家还要登记算几个意思啊）。而且想一起合租的同学以后可能考了教师资格证之后回老家工作，那样到时候就要再找人合租，我实在是不抱信心，所以还是决定再找别人合租。\n最后和有猫的孙老师在交大附近找了另一个房子。看了一上午最后直接选中的原因只有一个：实在是太新了，刚装修完，非常干净，而且卫生间和厨房都有采光。让我这个饱受破旧和阴暗厨房之苦的人非常满意。同时面积分配也非常科学，孙老师希望要有阳台的卧室，养猫比较方便，我希望卧室面积别太小，这样我录视频的时候把电脑桌当作背景，可以有一定的景深，让桌子在焦外就不会显得太乱。很多房子都是有阳台的卧室大得不得了而另一个卧室只能放个床，这个房子的次卧则不一样，有充足的地方放桌子。所以我们就敲定了这个，兜兜转转还是没离开学校周边。\n当然也没有完美的事情，满足了空间和干净和隔音（卧室并不是直接挨着楼道），价格和位置也就不能太强求了，交通的话看起来还好，离路边不会太远，大学周围也不至于太荒凉吧（街头生活感本来就和整齐不沾边，城市到底是让大人物拿来看的地方还是让小人物生活的地方呢？）。虽然考虑到更高的房租+中介费让我感到心疼肉疼（感觉镜头离自己远去了）……但是安慰自己多花几百块买来了刚装修完崭新的房子其实也很赚了对吧！\n所以希望接下来能轻松地搬家，毕竟我东西还是挺多的，有了空间就可以仔细布置一下了，让视频看起来更精致一点。\n总之还是要对生活有希望吧。\n"},{"title":"运行在 JACK 上层的 PulseAudio","url":"/posts/Run-PulseAudio-on-Top-of-JACK/","content":"很多朋友都知道我除了是个程序员以外还是个乐器玩家，很久之前因为想要录音上网了解了一下需要购买专门的麦克风声卡从此掉进深坑一发不可收拾。当然 Linux 用户在购买硬件之前需要做好功课，大概 16 年左右我还在上高中的时候用我还凑合的英语水平翻了几个 Linux 音乐论坛最后决定买一台 Focusrite Scarlett 2i4（不过它后来似乎更新了几个版本所以我购买的变成了 1st Gen），我不太清楚除了更换了接口之后还有什么奇怪的改动没有，所以这里就不盲目推荐大家买更新的型号了，反正声卡这玩意够用的话也不太需要追新。\n做功课的时候还了解到常见的 Linux 桌面采用的都是 PulseAudio，但专业录音为了追求更低的延迟所以都使用 JACK，于是简单学习了一下，发现只要打开 Qjackctl 选择设备然后启动，Audacity 就可以选择使用 JACK 设备了。系统的其它软件仍然通过 PulseAudio 输出到板载声卡，不会冲突，基本满足我的需求也就没研究过其它的。\n但是最近直播的时候总有人说我的耳机麦克风不太灵敏，我想了想不如干脆把录音用的声卡和话筒利用上，整个系统直接采用 2i4 作为默认声卡？但是虽然 PulseAudio 可以直接控制 2i4，软件的兼容性却不太好，比如 Audacity 启动的时候即使没有运行 JACK 它似乎也会尝试通过 JACK 连接 2i4 于是导致缓慢的启动和几下破音。于是只能采用网上常见的方案也就是把 PulseAudio 的音频输出到 JACK，但我觉得其实这样不太准确因为音频是既有输出又有输入的，所以我的标题是把 PulseAudio 运行在 JACK 上层。当然还有一种方案是采用 PipeWire，打算自己替代 PulseAudio 和 JACK 一桶浆糊的新项目，它欺骗程序让它们以为它就是 JACK 和 PulseAudio，但虽然我是个 GNOME Dev，我对这个不太感冒。有个常见的笑话是“现在有 N 个不那么好用的系统了，我们写个新的把它们全部替代掉！然后现在有 N + 1 个不那么好用的系统了！”。JACK 对于专业用户来说很好用，而专业用户通常是不太喜欢变化的，所以我不太期待 PipeWire 替代 JACK。\n有人说 Linux 的音频系统比意大利面条还复杂，这倒不能说错，因为假如你看维基百科上那张巨恐怖的图的话确实是这样。但本质上来说音频不过是把信号丢给声卡，所以只要是个能写声卡的软件都可以叫音频系统嘛，也就不奇怪那张图那么复杂，实际上在现代的 Linux 桌面通常都集成 PulseAudio，我们也没必要去研究那些边边角角的奇怪方案，于是整个结构其实很清晰，一般的用户看到的都是这样的：\n\n然后作为对比，我之前的方式是这样的：\n\n而这篇文章要达到的目的则是下图这样：\n\n当然实际上假如你理解了这个结构的话，其实也没必要只用一个声卡，完全可以用独立声卡录音用板载声卡输出，只是 JACK 是绑定独立声卡的，于是就像下图这样：\n\n总之这样做的依据在于 JACK 被我们绑定了独立声卡，然后利用 PulseAudio 设置不同的输入输出设备控制基于 PulseAudio 的桌面程序的输入输出，同时它还可以把自己作为 JACK 的客户端。而具体到与声卡交互，则全部都是内核里面的 ALSA 组件控制的。这里没有涉及到使用 ALSA 用户态组件的客户端程序，因为 PulseAudio 会把自己伪装成 ALSA 的客户态组件，于是这些老旧的程序就被连接到了 PulseAudio 上面从而无法直接占据声卡了。\n了解原理之后就可以具体操作，首先需要安装 jack2 和 jack2-dbus，这个软件包包含的是 JACK 的组件，必须要装 jack2 因为 jack1 不支持 DBus 所以也就没办法和桌面交互了。安装 qjackctl 来控制 JACK，然后安装 pulseaudio-jack，这是让 PulseAudio 作为 JACK 客户端的兼容层。\n然后需要启动 Qjackctl，在 Setup 的 Settings 选项卡里面选择 Advanced，将 Input Device 和 Output Device 全都设置为 2i4（应该就是 hw:USB 那一项），然后切换到 Misc 选项卡，像下图那样设置 Others 部分（基本就是除了 Keep child windows always on top 的全都勾上），这样就可以做到毫无感觉的启动 JACK（只要打开 Qjackctl 它就在后台启动 JACK，退出 Qjackctl 也只是退到后台）。\n\n这样再次启动 Qjackctl，应该 JACK 就已经在控制 2i4 了，PulseAudio 应该会自动把 2i4 的控制权交给 JACK，省了不少事情。对于一些原生支持 JACK 的客户端程序来说这已经足够了，比如 Ardour 或者 Audacity，它们不经过 PulseAudio，直接连接到 JACK。接下来需要调整的是那些基于 PulseAudio 的客户端程序。\n然后打开你桌面环境的音频控制器，比如我是 GNOME 就是 GNOME Control Center 里面的 Sound 选项卡，这里基本都是集成的 PulseAudio 控制，把 Input Device 设置成 Jack Source，于是 2i4 上的麦克风的录音便通过 JACK 传到 PulseAudio 再传到 PulseAudio 的客户端程序比如 OBS Studio 和 Firefox。然后如果你想让 PulseAudio 的客户端程序把音频输出到 2i4 上的耳机里面，那就将 Output Device 设置为 Jack sink，这样其实 PulseAudio 就是运行在 JACK 上层。\n最后你需要设置 JACK 在登录时启动，这样 PulseAudio 才能找到 JACK，这个很简单，因为我们已经设置 Qjackctl 无感启动 JACK 了，那只要将 Qjackctl 设置为自动启动即可，比如 GNOME 用户可以在 GNOME Tweaks 里面设置。\n当然，一般的家庭录音用户都是单声道麦克风，某些客户端程序需要自己手动设置，比如 OBS Studio 需要在 Advanced Sound Properties 里面勾选 Mono。\n如果你遇到了一些奇怪的明明已经设置 PulseAudio 重定向到 JACK 却没有声音的情况，可能是因为你某些软件或者插件带了奇怪的设置，建议先重置它们试试。\n"},{"title":"StackHarbor 的 2020 尾记","url":"/posts/2020-Tail/","content":"我最近思考了一下，总是记不起来去年的总结写了什么，结果翻了一下博客发现我的记忆力是对的——我去年还真的就忘了写总结。\n今年的总结因为各种原因写的稍微晚了一点，不过总之还算是写了，比忘记写要好得多吧！\n以前小时候总是觉得一年过得很慢，要过很久才到新一年放烟花吃饺子，但是现在觉得一年过得很快，可能要忙的事情多了就会觉得时间不够用。但我一般来说又不觉得自己做了什么值得记录的事情，看到别人的博客年终总结写的特别充实，又是自己出国求学又是自己找实习转正的，但是到了我自己总觉得这些也没什么好写的。再加上我是个相当讨厌计划的人，所以也没什么“检查自己一年的完成度”的机会。\n不过我在那些不错的年终总结里面还是学到了一些东西，所以打算也写点类似的。不过我虽然是程序员，写文章还是习惯从头到尾写，不擅长做那些分类加标题的事情，所以就想到哪写到哪。\n2020 年感觉最不错的事情大概是加入某绿色蜥蜴工作，虽然这个严格来说从 2019 年就开始了，但是我去年忘记写总结了……多亏了同学的推荐得到了一份实习，面试感觉很好，没有考什么我特别不擅长的算法题而是一些实践性的知识，这个我还是挺擅长的。然后同事也都相当好相处，一开始是测试相关的工作，也了解了很多测试方面的知识，甚至还写了点 perl（虽然只了解皮毛），总之是很有意思的经历，然后更意外的是领导居然主动问我有没有什么别的感兴趣的领域，因为我一直是 GNOME 用户所以对 GNOME 维护挺感兴趣的，结果后面就转到 GNOME 组去实习了（这也太好了吧天哪）。然后就是快毕业了需要准备正式工作，一般来说这边没有类似国内互联网企业那样招一大堆实习生然后给几个转正名额竞（yang）争（gu）的途径，并且他们也几乎不进行校招，对毕业生和社招一视同仁，虽然我个人很想在这工作，但是如果想留下来的话还挺看运气的。这时候领导又和我说正好组里有空缺职位，可以安排面试，只要能保证一直实习到正式入职就可以了，于是又十分幸运的毕业之后正式入职。总之能在自己感兴趣的领域做工作已经是十分幸运了，然后待遇相对来说也不错，特别是看多了加班猝死的新闻，心里更加满足了……同事也都很友善，而且都是技术类型的，做的又都是开源相关，平时也很聊得来。今年整体来说不是那么容易找工作的，我都要反思我为什么那么幸运了……顺便由于疫情原因，我司今年一直是在家办公状态，也节省了好多通勤的时间金钱……\n在找工作这方面我实在是没什么经验可谈，我太靠运气了……如果非要说的话，就是平时自己多学习多写写程序吧……\n因为工作的原因今年一直是自己在外面住的，房租好贵啊……至于自己住虽然挺安静的但也挺麻烦的……把东西从学校搬出来也花了不少麻烦。\n今年个人项目方面没做什么新东西，去年把 Hikaru 从 CoffeeScript 换成 JavaScript 之后基本就只是写文档、加测试、改功能，大改是去掉了 cheerio 改成自己实现了一部分功能，谁叫他们一年不更新……主题方面给 ARIA 做了个大改是去掉了 jQuery 加上了暗色模式，当然我自己看起来界面并没有什么变化。大一时候写的 FlipClock 努努力改成了 CMake，然后这样就可以跨平台做成 Windows 屏保了。顺便了解了一下怎么在 Android 上面运行 SDL，做了个 Android 的 FlipClock。而至于我的弹钢琴页面和 Telegram Bot，我已经忘记是今年还是去年写的了……\n今年折腾了一遍我的电脑，因为终于有时间和钱玩自己的台式机了，仔细想想好像把之前的能换的都换了，27 寸的显示器对没有双屏空间的人来说提升了不少工作效率，5800X 打游戏也很爽，就是钱包不太舒服……\n口琴方面今年年末又高产起来了，而且开始剪视频，发现达芬奇可以在 Linux 下面用（虽然有些限制）（Adobe 看看人家！），而且还挺流畅的，于是看了影视飓风的达芬奇教程学了一些基本的剪辑知识，为了用的更舒服还闲鱼买了个加密狗（假货很多，安全下车），今后可能剪视频的频率会逐渐增多，就当练习新技能了。\n手机打算再用一年，今年手机厂商出的都是什么垃圾？我现在也想清楚了反正手机又不能给我带来收入，有这个钱还不如投资到台式机上，希望各种换手机患者也考虑一下，我现在是能用就行了。除非哪个厂家出一个摄像头不丑还有耳机孔最好还是直屏系统不要乱删乱改的旗舰机。\n动漫除了看电磁炮 T 以外就是看了紫罗兰永恒花园，一开始很多人吹导致我对这个比较反感，实际看了以后觉得还是很不错的，所以吹得太过果然会招黑吗……电磁炮 T 总之中规中矩，能有第三部已经很不错了，我还想看第四部……、\n认识了一些新朋友，同时很多老朋友也都有各自要忙的事情，总之几乎没什么人一起打游戏了……不过经常能和蓝猫她们一起出去玩还是避免了成为死宅的命运，本来我都打算在家打游戏跨年了，最后和蓝猫狐狸一起吃了海底捞，虽然三点才回家导致第二天犯了鼻炎，不过还是非常开心。\n年末通关了 Titanfall 2，剧情很短，中规中矩，但是就已经是非常不错了，除了操作不适合我这个手残以外都很适合我。今年几乎没怎么玩 CSGO，但是下班之后有很多空余时间基本都投入在 Dota 2 上面了，虽然我也看很久 Dota 2 了，但是玩起来确实很难……不过我这一年一直都沉迷在中单光一直播里面，已经成了我玩 Dota 2 的动力了……现在多少也算入门了，虽然偶尔还是操作不过来，但至少明白是个怎么回事了。中单光一的直播真的很好看！正人君子，皮又好看，说话又好听，打游戏厉害，又很温柔。一开始我只是看他打 Dota，反正讲围棋我又看不懂，但我发现他读围棋棋手传记有意思多了，已经进入追小说模式了……拖到现在才写年终总结也是因为坐了 16 个小时的火车跑到上海去看 VirtuaReal 的第一次线下 Live，不过互动环节没抽到我实在是令人沮丧，我太非了，那么多人根本没我的机会呜呜呜呜呜……\n就写这些吧，希望 2021 年大家的生活都能变得顺利！\n"},{"title":"解决 Spleeter 愚蠢的依赖问题","url":"/posts/Fix-Silly-Spleeter-Dependencies/","content":"我倒不是对机器学习有什么莫名的偏见。事实上有些只能用机器学习搞定的东西我也很支持用机器学习解决，比如 waifu2x 这种增加图片分辨率的或者 Spleeter 这种分离人声和伴奏的，用传统的分离人声的方法就是不能完美解决这类问题，而机器学习模糊分类则可以无限接近完美解决。我讨厌的有两个，一个是传统方法很好解决的东西非要用机器学习解决，另一个是混乱的机器学习项目，后者更严重。\n不知道是因为什么原因，许多机器学习从业者似乎都缺乏整理代码和依赖的能力——能轻松地把一个这类开源项目打包简直是奇迹，更多时候别提打包了，你想自己安装然后跑起来都不太现实，哪怕是同样做机器学习的其他人也有这样的苦恼：实现者似乎把论文写好然后在自己电脑上能跑起来就心满意足了，丢出来一份没什么文档的代码，不折腾几天根本不知道这坨代码需要装些什么才能跑起来。对我这种洁癖用户就更严重了，我可不希望 pip 在我的系统里塞一堆乱七八糟的东西，这个需要其他项目才能避免全局安装的程序比起 npm 真是差远了，解决依赖的能力也远不如 pacman 和 npm。\n然后有人发明了 conda 和 docker 这样的 辣鸡 项目来拯救其他的辣鸡，但事实上不过是变得更糟而已——它们给了一些懒人名正言顺不维护项目的借口——反正我丢一个 conda 配置或者 docker 文件上去，你们拉上一大堆和我一样的过期依赖就能跑了，至于占了你多少硬盘，干不干净，关我什么事？如果不是物理限制，恐怕这些懒人会把整个宇宙都塞进去。而且他们再也不会管版本更新，什么 tensorflow 2 与我何干？用了我的 docker 你的系统里就会有几百个不同的过期的 tensorflow 1，买硬盘就完了呗？这和买显卡就完了呗还真是一路货色，这样依赖商业公司的产品，总有一天感觉会被割韭菜。\n今天我用到的这个 Spleeter 某种意义上也是这样的一坨辣鸡，它的仓库里面的 README 是完全过时的，但这起码是件好事：代码还在更新。只是你按照文档是没办法顺利的搞定它的，我总结了一下我的解决方案，可能不适合其他人，但我大概知道怎么解决了。\n这个文档有多离谱呢？它说你如果使用 GPU 加速的版本，必须从 conda-forge 安装，pip 和 GitHub 的版本都只有 CPU——那你这也是按揭开源？然后整个文档没有一个地方告诉我我需要单独下载训练好的模型才能跑起来，直接运行程序则丢一个段错误——可能他们搞机器学习的人觉得下模型是常识不需要说吧。\n如果直接按它的方法运行 conda install -c conda-forge spleeter-gpu，你会得到一个不能用 GPU 的 GPU 版，这实在是太搞笑了。我捏着鼻子用 conda 就是为了能让你一步把这一坨东西给我弄好，现在你说你弄不好？\n但是有一点好处就是实际上他们的代码已经更新到支持 tensorflow 2 了，所以其实完全不需要用那个过期的 conda-forge 的版本，你完全可以直接在 Python 3.8 里面 pip install spleeter-gpu 安装最新的版本——然后我又遇到了依赖问题，我的一些系统软件包依赖 numpy，然后 Arch 官方源里的 numpy 版本还是比 spleeter-gpu 依赖的 tensorflow 依赖的 numpy 版本新——你们写 Python 的人真麻烦，版本号兼容性是可以随便 break 的吗？\n所以这时候我还是用 conda 解决，总之就是搞一个和系统独立的虚拟环境（npm：这不应该是内置功能吗？还需要用商业软件？）。总之去 TUNA 搞一个 Miniconda 3 最新的安装包来，然后直接安装，看着它往你的 shell 配置里塞一坨辣鸡（我把实际用到的命令拿出来做了个 alias，这样就可以只在我需要的时候打开 conda 了）。\n为什么使用 miniconda 而不是 anaconda？因为 anaconda 带的那个图形界面根本用不了，miniconda 够用了。\n然后创建一个 Python 3.8 的环境，高了低了都不行，真是难伺候，同时别忘了带上 cuda 和 cudnn，不然它一声不吭的就会只能用 CPU：\n$ conda create -n spleeter python=3.8 cudatoolkit cudnn\n\ncuda 不叫 cuda，叫 cudatoolkit 就离谱。\n然后切进去：\n$ conda activate spleeter\n\n然后装 spleeter-gpu 到 conda 新创建的这个 Python 环境：\n$ pip install spleeter-gpu\n\n所有的依赖应该 pip 都会解决，但这个弱智有时候还会说我系统已经装了 numpy 1.19.4，比 tensorflow 需要的版本高，此时可以 pip install numpy==1.18.5 在虚拟环境里装一个它需要的版本。\n更新（2023-06-27）：现在它又报错说 protobuf 版本太新了需要降级 protobuf 版本或者重新生成某些东西，同理就 pip install protobuf==3.20.* 装一个老版本在虚拟环境里。\n然后到 https://github.com/deezer/spleeter/releases 去下载训练好的模型，这也是个弱智的地方：哪有 tar 打包不把目录本身打进去的？然后还得给这个程序创建一个工作目录，因为它是写死的到当前目录下面的 pretrained_models 下面去找模型。\n$ mkdir -p spleeter/pretrained_models\n$ cd spleeter/pretrained_models\n\n我这里用 2stems 的模型示范，因为我只需要分离人声和伴奏：\n$ wget -c 'https://github.com/deezer/spleeter/releases/download/v1.4.0/2stems.tar.gz'\n$ mkdir 2stems\n$ cd 2stems\n$ tar -xpvzf ../2stems.tar.gz\n\n因为他们打 tar 包时候没把目录打进去，所以别忘了自己创建目录！\n然后回到你的工作目录就可以用了，我这里结构是 spleeter/pretrained_models/2stems 所以工作目录就是 spleeter：\n$ cd ../../\n$ spleeter separate -i 你要处理的歌曲 -p spleeter:2stems -o 输出目录 -B tensorflow\n\n如果你想使用其他的几个模型，那就把 2stems 改成其他模型的名字，但是这里还有个开发者脑子抽了的地方：带有 -finetune 的是高品质模型，它们的模型目录名字应该是比如 2stems-finetune，但是参数名字却不是这个而是 -p spleeter:2stems-16kHz，文档里当然是没找到的，我觉得应该揍开发者一顿让他老实写文档（不过我又看了一下，这个 finetune 对于分离音轨没什么用好像）。\n我这里必须使用 -B tensorflow 才会走显卡加速。\n用完了就可以 conda deactivate 退出虚拟环境，要用的时候别忘了 conda activate spleeter 切换进来，\n遇到类似问题的同学可以参考我的文章，但是因为这个处理过程影响因素太多了，如果你的不能用我也没什么办法。\n回头一看，这个项目犯了一大堆禁忌：难以解决的依赖，写死的模型路径还有匮乏的文档，导致配环境就要配一大堆。当然，好在他们还是在努力更新跟上依赖而不是撒手不管让它慢慢死去，并且功能非常好。真正对开发者友好应该是不需要配环境的，比如在 Linux 下面开发软件，包管理已经帮你考虑好各种依赖了。只有像 Windows 或者 Android 这种不以开发者为中心或者是许多机器学习项目这种“数据好看就行”的地方才会有这么多麻烦。真的很希望这些人能补一点务实的基础，不要让他们的软件这么难用。\n"},{"title":"奇怪的书名和我和吐槽","url":"/posts/Strange-Book-Names/","content":"好久没更新非技术类博文了。可能很多人不但丢掉了阅读的习惯，连书店都不去了，书店都沦为练习册店，学生们更加对书店敬而远之，这种恶性循环实在是可悲又无趣的事情。\n今天和同学一起去了书店，在常规意义的书店而非练习册店的部分发现了一些有趣或奇怪的书名，很有槽点，让人想要吐槽。于是拍了点照片发到这里。\n套娃型\n此类书名非常适合套娃，比如这本《如何阅读一本书》，且不说阅读方法因人而异且随经验变化，单就这个名字还可以有《如何阅读如何阅读一本书》、《如何阅读如何阅读如何阅读一本书》。\n\n我以为只有当代为搏销量疯狂吸引眼球的作者会起这种名字，然而接下来我看到了一本《木心谈木心》。虽然我不太清楚木心是谁，但似乎是个有名的作者。这书名总是让我想后续会不会有《木心谈木心谈木心》、《木心谈木心谈木心谈木心》。\n\n性转型\n这本书的名字非常奇特，她叫做《少女福尔摩斯》，作为半资深福尔摩斯迷和半资深月球氪金母X，我只能说型月都没敢做的这作者做了。由于原作实在是珠玉在前，写老福的故事已经很难了，性转老福怕是难上加难，建议寄一本给蘑菇看看。\n\n蹭热度型\n这位叫蒋勋的作者在书架上占据了一片，又是说唐诗又是说宋词，一会谈莫奈一会谈高达哦不是梵高和达芬奇，想必上知天文下知地理。不过这书脊把这些名人加粗放在下面，让我总是有一种这是个达芬奇/梵高/莫奈的著作、作品标题叫做蒋勋的感觉。 不知道这个叫蒋勋的作品是不是样样通样样松呢？\n\n还有这本《逝水年华》，连我的输入法都怀疑我是不是想输入《追忆似水年华》，敢问作者是不是有点大舌头，让出版社的编辑听错了标题？\n\n连锁反应型\n《知更鸟》吗？大家都说有本书想杀你，不知道与你是什么关系？\n\n虽然《重返美丽新世界》和《美丽新世界》是一个作者，但总让我想起狗尾续貂，不知道是不是我错了。\n\n好家伙，你把《24个比利》放在这，那他们肯定会打架的，不过叫《比利打群架》比《比利战争》更好吧，无谓夸大不可取，要实事求是。\n\n《面包树上的女人》怎么啦？她没怎么，《面包树出走了》，那可真是个离奇的故事，但是你这内容就不知道是不是好故事了。\n\n我以为文学大家不屑于做这种在标题上吸引人的无聊事情，毕竟内容才是王道。我也不是有意冒犯杨绛先生，但是《洗澡》做书名已经是太随意了，再整一本《洗澡之后》是不是过分了？还是说这是出书的不负责任的编辑洗澡前洗澡后决定的两本文集的名字？那这就说得通了。但是说实话《洗澡》、《洗澡之后》接下来摆一本《暗示》是不是店员的恶趣味？\n\n希望看了我文章的朋友不要给我评论“一看到白胳膊……”，我老实承认我不是一个完全脱离了低级趣味的人，写这文章也是让大家开心一下，毕竟实事求是更重要。我也希望写书的作者们都实事求是一点，内容不行，靠标题党吸引人肯定是经不起考验的。\n"},{"title":"固定 GNOME Shell 的输入法列表","url":"/posts/Fixed-IME-List-for-GNOME-Shell/","content":"GNOME Shell 有个令人很不爽的“特性”，它的输入法列表使用的是最近使用优先排列。也就是说当你有三个或以上输入法的时候，比如我，我有英文简体中文和日语输入法，我经常在中英之间切换，这没什么，前两个总是中英所以按一次就可以在这两个之间切换，但假如我偶尔用了一次日语输入法，我的列表就被打乱了，我不清楚按几下才能切回中文，并且再切到英文也得看一眼才能知道。\n我不是很理解这个特性存在的意义，设置里面是可以手动调节输入法的顺序的，我明明调成了我想要的顺序，你就给我这个顺序好了，这样我闭着眼睛不用动脑子都能猜出来要按几下，比如从英文到日语按两下，中文到日语按一下等等。可能有些人的脑子长得比较擅长模拟最近使用优先排列？反正我不行。\n既然感觉不爽那就动手处理一下好了，最近看了一些有关写 GNOME Shell 扩展的文档，所以写个扩展解决一下就可以了。为什么不直接提交给上游？因为上游一开始是固定顺序的，但是很久以前某个人加了这个“特性”，现在如果提个请求说删掉这个特性，势必会陷入一场“用户到底是喜欢最近使用优先排列还是固定排列”的争论，这肯定很难得出结论（毕竟大部分的人实际上是不需要使用输入法的英语用户以及只有两种输入法的用户！），并且按照 GNOME 上游的习惯他们也是不愿意为了这个多添加一个开关的。所以比起在拉锯战上浪费时间，先搞一个能用的才是我的风格。至于升级之后扩展挂掉……不就是在上游里和其他代码一起被重构和我自己单独重构的区别吗？只要我还在用应该就会持续更新了。\n具体的解决方法比较 dirty，是我从别的扩展里学来的：把 GNOME Shell 里面的类的原型上的方法替换成自己的，就可以修改实例调用时的函数了（也算 JS 特性之一），不过不要用箭头函数，因为显然我们希望 this 是调用时的上下文也就是实例，而不是绑定到当前上下文。\n因为这算是我第一个扩展所以也多少记录一下踩的坑。\n首先 Gjs 的导入和 Node.js 的导入是不一样的，它通过一个 imports 对象引入其他库，比如通过 GI 导入的就在 gi 下面，因为是 GNOME Shell 扩展所以可以访问 GNOME Shell 的 JS 库，就是简单地把 JS 路径换成对象的 key 然后 JS 文件里所有的 var 和 function 都会被导出。比如导入 Main 就是 imports.ui.main.Main。\n然后就是怎么知道要修改什么以及如何获取到相关对象，不过因为 GNOME Shell JS 部分经常重构，也没什么完整的文档，反正只能多花时间看代码吧，而且它的结构其实比看起来的要复杂，所以经常需要仔细翻来翻去的。比如 GNOME Shell 的输入法部分很多人认为是需要修改 iBus，实际上 GNOME Shell 只是调用 iBus 作为后端，自己处理状态和界面，这部分的代码都在 js/ui/status/keyboard.js 里面。\n扩展主要有 init()，enable() 和 disable() 三个函数，init() 在 GNOME Shell 加载扩展时候调用，我这个显然不需要。enable() 是你在 Extensions app 里面打开开关时候调用的，disable() 是关掉开关时候调用的。\n在 enable() 里面有几个需要我修改的地方，一个是阻止 InputSourceManager 在输入法切换之后的最近使用优先排列，解决方法很简单，需要自己替换掉 _currentInputSourceChanged 函数，注释掉 https://gitlab.gnome.org/GNOME/gnome-shell/-/blob/gnome-3-36/js/ui/status/keyboard.js#L447-453 这一段更新代码。\n当然光有这个还是不行的，这样假如你是先切换过再打开扩展，实际上列表是你开启扩展之前的状态而不是用户设置的顺序，所以我们还需要在打开扩展之后更新它的列表，让它直接读取用户设置。更新列表的函数是 _updateMruSources，假如检测到当前列表为空，会先从一个缓存的 gsettings 里读取之前存储的最近使用优先排列列表，这显然是很恶心的所以要注释掉 https://gitlab.gnome.org/GNOME/gnome-shell/-/blob/gnome-3-36/js/ui/status/keyboard.js#L504-522 这一段。之后它会先加载当前列表里的，然后再把用户列表里增加的当前列表里没有的加到后面，因为我们已经决定要清空当前列表并且不加载 gsettings 里面的缓存，所以这个当前列表肯定是空，那直接加用户列表就行了，所以注释掉 https://gitlab.gnome.org/GNOME/gnome-shell/-/blob/gnome-3-36/js/ui/status/keyboard.js#L525-533。这样我们后续只要清空 _mruSources 设置 _mruSourcesBackup 为 null 然后调用 _updateMruSources 就可以了。\n然后我们需要获取运行时的这个 InputSourceManager 实例，这个实例没有被绑定到 Main 对象上，不过我阅读代码发现它是个单例模式，就是说在 js/ui/status/keyboard.js 有一个 _inputSourceManager 变量，然后有个 getInputSourceManager() 的函数，被调用时候如果有就返回 _inputSourceManager 否则创建一个赋值返回，其他代码都用的这个，所以我们也导入这个就行了。\n然后你会发现另一个弱智的地方，怎么每次按下切换键，切换框都是从第一个切换到第二个？不是应该从我当前的切换到下一个吗？这个对于当前输入法总在第一个的最近使用优先排列是可以的，但在我们这个场景选中的并不总是第一个，所以需要修改。这部分函数是 _switchInputSource，可以看到它只是展示了一个 InputSourcePopup，而 InputSourcePopup 继承的是 imports.ui.switcherPopup.SwitcherPopup，这个类有一个叫做 _selectedIndex 的变量用于选择下一个上一个时候的计算，而且它默认是 0！不能通过参数初始化！真是头秃，不过我们可以在创建完切换框但展示之前单独设置这个值就行了，所以我在 https://gitlab.gnome.org/GNOME/gnome-shell/-/blob/gnome-3-36/js/ui/status/keyboard.js#L412 的下一行插入如下代码：\nif (this._currentSource != null) {\n  popup._selectedIndex = this._mruSources.indexOf(this._currentSource);\n}\n\n因为我们不一定总有 _currentSource 所以还是要检查一下，如果没有的话让它从 0 开始也无所谓。\n然后还有一个比较头痛的是快捷键是绑定的回调函数，回调函数又绑定了 this，所以我们光修改原型是改不了被回调的函数的，这个也简单，我们需要读一下 InputSourceManager 的 constructor 的代码，然后删掉它在 Main.wm 里面绑定的组合键重新绑定成我们的，就是这样：\nMain.wm.removeKeybinding(\"switch-input-source\");\n_inputSourceManager._keybindingAction =\n  Main.wm.addKeybinding(\n    \"switch-input-source\",\n    new Gio.Settings({\"schema_id\": \"org.gnome.desktop.wm.keybindings\"}),\n    Meta.KeyBindingFlags.NONE,\n    Shell.ActionMode.ALL,\n    InputSourceManager.prototype._switchInputSource.bind(_inputSourceManager)\n  );\nMain.wm.removeKeybinding(\"switch-input-source-backward\");\n_inputSourceManager._keybindingActionBackward =\n  Main.wm.addKeybinding(\n    \"switch-input-source-backward\",\n    new Gio.Settings({\"schema_id\": \"org.gnome.desktop.wm.keybindings\"}),\n    Meta.KeyBindingFlags.IS_REVERSED,\n    Shell.ActionMode.ALL,\n    InputSourceManager.prototype._switchInputSource.bind(_inputSourceManager)\n  );\n\n同样我们也不要忘记绑定 this，实际上我们希望调用的时候绑定的 this 是那个单例，那直接 bind 它就好了。\n但是你会发现这个弱智的家伙没有按我们想象的工作！这是什么意思！仔细阅读代码之后我发现有如下逻辑：你按下的第一次组合键其实并不是算在那个弹框的按键回调里面，而是我们通过构造函数传递进去的，然后它分析这个传进去的按键是哪一种，调用 _initialSelection 执行第一次切换，而这个家伙更弱智了！明明有 _selectedIndex 它不用，竟然用硬编码的倒数第一个和第一个！真有你的啊！我不太敢修改 SwitcherPopup 因为还有别的东西使用它，那就修改 InputSourcePopup 这个子类吧，其实就是把 InputSourcePopup.prototype._initialSelection 这个函数原来的的 this._select(this._items.length - 1); 换成 this._select(this._previous());，this._select(1) 换成 this._select(this._next())（1 其实是 0 + 1 的意思），不但功能增加了，可读性也提升了！\n现在搭配起来应该和我们的需求一致了！但假如我关掉扩展之后希望列表是打开之前的状态怎么办！还记得之前说的那个 _updateMruSources 会读取 gsettings 吗？这个 gsettings 实际上在每次切换输入法的时候都会写入当前状态，那我们只要让它开启扩展时候不要写入，关掉扩展恢复的时候再更新不就读取了之前的状态吗。所以需要修改 InputSourceManager.prototype._updateMruSettings，注释掉 https://gitlab.gnome.org/GNOME/gnome-shell/-/blob/gnome-3-36/js/ui/status/keyboard.js#L432-438。\n总结一下其实就是在 enable 的时候修改这些函数，然后获取单例，重新绑定快捷键，然后清空当前的列表重新更新列表，然后为了避免 bug，我们总是激活列表里的第一个输入法：\n 0) {\n  _inputSourceManager._mruSources[0].activate(true);\n}\n\" data-info=\"language-JavaScript\" data-lang=\"JavaScript\" class=\"code-block\">if (_inputSourceManager._mruSources.length &gt; 0) {\n  _inputSourceManager._mruSources[0].activate(true);\n}\n\ndisable 的时候同样是把函数修改回去，然后获取单例，重新绑定快捷键这样它又绑成了原来的函数，然后清空当前的列表重新更新列表这样它就恢复到开启之前的顺序了，接下来同样地，因为最近使用优先列表的第一个元素肯定是正在用的，所以我们也还是激活第一个输入法就可以了。\n完整的项目参见 GitHub，Arch Linux 用户也可以从 AUR 或者 Arch Linux CN 源安装 gnome-shell-extension-fixed-ime-list。\n"},{"title":"GObject 备忘录","url":"/posts/GObject-Reminder/","content":"说来惭愧，我一直记不太清 GObject 到底是怎么用的，毕竟作为一个写过 C++ 和 Python 然后常用 JS 的人来说，面向对象的实现是非常自然的，不需要考虑为什么。所以我总是看着一大堆类型转换和分散的定义以及各种 chain up 感到眩晕。而 GObject 的文档写的也相当分散，有种管中窥豹之感。\n有同学觉得其实把函数变成指针放在结构体里看起来就面向对象了，以前我也这么觉得，但是显然这样不能实现继承封装等等特性，GObject 独特之处在于它实现了这些，并且是和语言独立的。当然，要想在一个没有这些概念的语言里面做到这些，就有很多需要自己手动处理和函数库处理的地方，就是因为有些隐藏了起来有些又要自己做，才让它看起来像古老的黑魔法。\n最近我尝试做了一个小项目，以便搞清楚如何按照 GObject 的模式编写一些继承封装的代码。\n首先得给项目起名字，因为 GObject 要求你的命名符合一定的约定，其中一个前缀是项目名，不过这个 简单，就叫 test 好了，然后做一个基类叫 animal，于是就有了 test-animal.h:\n\n\nG_BEGIN_DECLS\n\n// 这个玩意必须要手动定义。\n#define TEST_TYPE_ANIMAL test_animal_get_type()\n// 这个玩意会展开成一大堆函数声明、typedef 什么的，\n// 所以我们只要按约定定义结构体实现函数。\nG_DECLARE_DERIVABLE_TYPE(TestAnimal, test_animal, TEST, ANIMAL, GObject)\n\n// 但是上面那个玩意其实不会给你搞一个类结构体出来，\n// 而且这个因为是给其他文件用的所以必须写在头文件里，\n// 不然人家怎么知道你有什么虚函数！\nstruct _TestAnimalClass {\n\tGObjectClass parent_class;\n\t// 定义一个可以继承的函数。\n\tvoid (*print)(TestAnimal *animal);\n\tgpointer padding[12];\n};\n\n// 因为上面的 class 里面定义了，这个只是调用那个。\nvoid test_animal_print(TestAnimal *animal);\n// 这个玩意得手动定义，是个不可继承的公开函数。\nTestAnimal *test_animal_new(gchar *animal_name);\n\nG_END_DECLS\n\n#endif\n\" data-info=\"language-c\" data-lang=\"c\" class=\"code-block\">#ifndef __TEST_ANIMAL_H__\n#define __TEST_ANIMAL_H__\n\n#include &lt;glib-object.h&gt;\n\nG_BEGIN_DECLS\n\n// 这个玩意必须要手动定义。\n#define TEST_TYPE_ANIMAL test_animal_get_type()\n// 这个玩意会展开成一大堆函数声明、typedef 什么的，\n// 所以我们只要按约定定义结构体实现函数。\nG_DECLARE_DERIVABLE_TYPE(TestAnimal, test_animal, TEST, ANIMAL, GObject)\n\n// 但是上面那个玩意其实不会给你搞一个类结构体出来，\n// 而且这个因为是给其他文件用的所以必须写在头文件里，\n// 不然人家怎么知道你有什么虚函数！\nstruct _TestAnimalClass {\n\tGObjectClass parent_class;\n\t// 定义一个可以继承的函数。\n\tvoid (*print)(TestAnimal *animal);\n\tgpointer padding[12];\n};\n\n// 因为上面的 class 里面定义了，这个只是调用那个。\nvoid test_animal_print(TestAnimal *animal);\n// 这个玩意得手动定义，是个不可继承的公开函数。\nTestAnimal *test_animal_new(gchar *animal_name);\n\nG_END_DECLS\n\n#endif\n\n因为代码里写了很多注释所以我这里就不再啰嗦一遍了，说点里面没写的。\nG_DECLARE_DERIVABLE_TYPE 表示你声明了一个可以继承的类，也就是说你需要自己弄一个虚函数表出来。有时候你会在某个 GObject 的项目里定义了一大堆宏（比如 GTK 就不爱用这个而是手动定义），其实它们和 G_DECLARE_DERIVABLE_TYPE 做了一样的工作，因为总要做，就写了个宏实现。这个并不会给你定义具体的类结构体（其实就是虚函数表，用来存放所有可以继承重载的函数），所以我们要按照约定自己写一个 _TestAnimalClass 的结构体，在类型名字前面加下划线作为结构体名也是约定俗成的，G_DECLARE_DERIVABLE_TYPE 会展开出一句 typedef struct _TestAnimalClass TestAnimalClass。\n这个类结构体约定第一个元素是它的父类型的类结构体——这其实意味着我们复制了一份父类型的虚函数表出来，于是我们就可以覆盖父类型的方法而不修改原本的父类型。这一句可能比较难懂，不过后面还有关联。\n这个类结构体存放的并不是实例的变量，它有点类似于 JS 里面的原型，这样我们就不需要给每一个生成的实例复制一份虚函数了，它们共用一个虚函数表。\n然后会有一个 test-animal.c:\nanimal_name)\n\t\t\tg_free(priv->animal_name);\n\t\t// 所以其实我们是在设置属性的时候更新私有成员。\n\t\tpriv->animal_name = g_value_dup_string(value);\n\t\tbreak;\n\tdefault:\n\t\t/* We don't have any other property... */\n\t\tG_OBJECT_WARN_INVALID_PROPERTY_ID(object, property_id, pspec);\n\t\tbreak;\n\t}\n}\n\nstatic void get_property_impl(\n\tGObject *object,\n\tguint property_id,\n\tGValue *value,\n\tGParamSpec *pspec\n)\n{\n\tTestAnimal *animal = TEST_ANIMAL(object);\n\tTestAnimalPrivate *priv = test_animal_get_instance_private(animal);\n\n\tswitch (property_id) {\n\tcase PROP_ANIMAL_NAME:\n\t\t// 所以你看属性这个名称和我们想的属性不一样，\n\t\t// 其他语言里面属性就是成员，能存点东西，\n\t\t// 但这里好像属性只是成员的一个代理，\n\t\t// 具体的东西存在成员里面，通过属性设置。\n\t\tg_value_set_string(value, priv->animal_name);\n\t\tbreak;\n\tdefault:\n\t\t/* We don't have any other property... */\n\t\tG_OBJECT_WARN_INVALID_PROPERTY_ID(object, property_id, pspec);\n\t\tbreak;\n\t}\n}\n\n// 给子类提供一个默认的实现，这样它就不是一个纯虚函数了。\nstatic void print_impl(TestAnimal *animal)\n{\n\tTestAnimalPrivate *priv = test_animal_get_instance_private(animal);\n\tg_message(&quot;I am an Animal called %s.&quot;, priv->animal_name);\n}\n\nstatic void test_animal_class_init(TestAnimalClass *animal_class)\n{\n\t// 首先我们覆盖这个类里面的 GObject 类的方法，\n\t// 你调用 GObject 的函数，他会先获取参数的 GObject 类，\n\t// 那就获取到我们这个了，然后具体的实现就是我们覆盖的这个。\n\tGObjectClass *object_class = G_OBJECT_CLASS(animal_class);\n\tobject_class->get_property = get_property_impl;\n\tobject_class->set_property = set_property_impl;\n\n\t// 给虚方法设置默认实现。\n\tanimal_class->print = print_impl;\n\n\tobj_properties[PROP_ANIMAL_NAME] = g_param_spec_string(\n\t\t&quot;animal-name&quot;,\n\t\t&quot;Animal Name&quot;,\n\t\t&quot;Name of Animal&quot;,\n\t\tNULL,\n\t\tG_PARAM_CONSTRUCT_ONLY | G_PARAM_READWRITE\n\t);\n\n\tg_object_class_install_properties(\n\t\tobject_class,\n\t\tN_PROPERTIES,\n\t\tobj_properties\n\t);\n}\n\nstatic void test_animal_init(TestAnimal *animal)\n{\n}\n\nvoid test_animal_print(TestAnimal *animal)\n{\n\t// 你看其实这里和 GObject 类设置属性的原理是一样的。\n\t// 子类调用这个函数，实际上获取到的是子类自己那个 Animal 类，\n\t// 于是他只要覆盖自己的那个 Animal 类，调用的就是他自己的。\n\tTestAnimalClass *animal_class;\n\tanimal_class = TEST_ANIMAL_GET_CLASS(animal);\n\tg_return_if_fail(animal_class->print != NULL);\n\tanimal_class->print(animal);\n}\n\nTestAnimal *test_animal_new(gchar *animal_name)\n{\n\tg_return_val_if_fail(animal_name != NULL, NULL);\n\treturn g_object_new(\n\t\tTEST_TYPE_ANIMAL,\n\t\t&quot;animal-name&quot;, animal_name,\n\t\tNULL\n\t);\n}\n\" data-info=\"language-c\" data-lang=\"c\" class=\"code-block\">#include \"test-animal.h\"\n\n// Derivable 类型会自动帮你定义实例结构体，\n// 所以你想夹带点私货就得自己搞个 Private 类型。\ntypedef struct {\n\tgchar *animal_name;\n} TestAnimalPrivate;\n\n// 这个也会展开一大堆声明什么的。\nG_DEFINE_TYPE_WITH_PRIVATE(TestAnimal, test_animal, G_TYPE_OBJECT)\n\n// 要想通过 new 函数直接初始化一些值就需要搞点属性。\nenum {\n\tPROP_0,\n\tPROP_ANIMAL_NAME,\n\tN_PROPERTIES\n};\n\nstatic GParamSpec *obj_properties[N_PROPERTIES] = {NULL};\n\nstatic void set_property_impl(\n\tGObject *object,\n\tguint property_id,\n\tconst GValue *value,\n\tGParamSpec *pspec\n)\n{\n\tTestAnimal *animal = TEST_ANIMAL(object);\n\tTestAnimalPrivate *priv = test_animal_get_instance_private(animal);\n\n\tswitch (property_id) {\n\tcase PROP_ANIMAL_NAME:\n\t\tif (priv-&gt;animal_name)\n\t\t\tg_free(priv-&gt;animal_name);\n\t\t// 所以其实我们是在设置属性的时候更新私有成员。\n\t\tpriv-&gt;animal_name = g_value_dup_string(value);\n\t\tbreak;\n\tdefault:\n\t\t/* We don't have any other property... */\n\t\tG_OBJECT_WARN_INVALID_PROPERTY_ID(object, property_id, pspec);\n\t\tbreak;\n\t}\n}\n\nstatic void get_property_impl(\n\tGObject *object,\n\tguint property_id,\n\tGValue *value,\n\tGParamSpec *pspec\n)\n{\n\tTestAnimal *animal = TEST_ANIMAL(object);\n\tTestAnimalPrivate *priv = test_animal_get_instance_private(animal);\n\n\tswitch (property_id) {\n\tcase PROP_ANIMAL_NAME:\n\t\t// 所以你看属性这个名称和我们想的属性不一样，\n\t\t// 其他语言里面属性就是成员，能存点东西，\n\t\t// 但这里好像属性只是成员的一个代理，\n\t\t// 具体的东西存在成员里面，通过属性设置。\n\t\tg_value_set_string(value, priv-&gt;animal_name);\n\t\tbreak;\n\tdefault:\n\t\t/* We don't have any other property... */\n\t\tG_OBJECT_WARN_INVALID_PROPERTY_ID(object, property_id, pspec);\n\t\tbreak;\n\t}\n}\n\n// 给子类提供一个默认的实现，这样它就不是一个纯虚函数了。\nstatic void print_impl(TestAnimal *animal)\n{\n\tTestAnimalPrivate *priv = test_animal_get_instance_private(animal);\n\tg_message(\"I am an Animal called %s.\", priv-&gt;animal_name);\n}\n\nstatic void test_animal_class_init(TestAnimalClass *animal_class)\n{\n\t// 首先我们覆盖这个类里面的 GObject 类的方法，\n\t// 你调用 GObject 的函数，他会先获取参数的 GObject 类，\n\t// 那就获取到我们这个了，然后具体的实现就是我们覆盖的这个。\n\tGObjectClass *object_class = G_OBJECT_CLASS(animal_class);\n\tobject_class-&gt;get_property = get_property_impl;\n\tobject_class-&gt;set_property = set_property_impl;\n\n\t// 给虚方法设置默认实现。\n\tanimal_class-&gt;print = print_impl;\n\n\tobj_properties[PROP_ANIMAL_NAME] = g_param_spec_string(\n\t\t\"animal-name\",\n\t\t\"Animal Name\",\n\t\t\"Name of Animal\",\n\t\tNULL,\n\t\tG_PARAM_CONSTRUCT_ONLY | G_PARAM_READWRITE\n\t);\n\n\tg_object_class_install_properties(\n\t\tobject_class,\n\t\tN_PROPERTIES,\n\t\tobj_properties\n\t);\n}\n\nstatic void test_animal_init(TestAnimal *animal)\n{\n}\n\nvoid test_animal_print(TestAnimal *animal)\n{\n\t// 你看其实这里和 GObject 类设置属性的原理是一样的。\n\t// 子类调用这个函数，实际上获取到的是子类自己那个 Animal 类，\n\t// 于是他只要覆盖自己的那个 Animal 类，调用的就是他自己的。\n\tTestAnimalClass *animal_class;\n\tanimal_class = TEST_ANIMAL_GET_CLASS(animal);\n\tg_return_if_fail(animal_class-&gt;print != NULL);\n\tanimal_class-&gt;print(animal);\n}\n\nTestAnimal *test_animal_new(gchar *animal_name)\n{\n\tg_return_val_if_fail(animal_name != NULL, NULL);\n\treturn g_object_new(\n\t\tTEST_TYPE_ANIMAL,\n\t\t\"animal-name\", animal_name,\n\t\tNULL\n\t);\n}\n\n理论上来说我们直接往实例结构体里添加成员作为私有成员就可以，但是在头文件里定义实例结构体，其它文件是可以看到实例结构体的成员的，所以 G_DECLARE_DERIVABLE_TYPE 不允许我们这么做。\n但有一个省事的宏 G_DEFINE_TYPE_WITH_PRIVATE，它要求我们做一个 Private 类型，里面存放我们想要的任何私有元素，它不会被继承，并且因为定义在 .c 文件里所以也不会被其它文件看到。它还会提供一些 GObject 内置类型系统的实现函数（不然 GObject 系统怎么知道你建了哪些类型！），还有一些只在实现里面用到的定义。当然 Private 类型的名字也是约定好的。\n多说一句，按照 GTK 的代码实现，其实 Private 最后就是在实例结构体里面定义了一个叫 priv 的指针，类型是自己定义的 Private 类型，因为 GTK 是手写的头文件定义然后用的 G_DEFINE_TYPE_WITH_PRIVATE 同时又没产生冲突，其实你也可以完全不理会这两个宏，全都自己写，只要保证类结构体和实例结构体的第一个成员是父类结构体和父实例结构体就可以了。但是 G_DEFINE_TYPE_WITH_PRIVATE 提供了一个 项目名_类型名_get_instance_private 的函数，我们就不用直接访问 priv 成员了。\n在使用 g_object_new 新建一个对象的时候可以通过 key-value 的方式设置一些初始值，这个其实是通过 GObject 提供的 property 功能实现的，因为 C 并没有哈希表这种东西。这个过程很繁琐但也很固定，其实就是实现其它语言里面传一个对象作为构造函数参数然后以此设置私有成员初始值的功能。property 就是参数，具体的内容还是存在初始值里面的，不过其实你也可以通过 property 访问具体的值。这里很容易理解成 property 有自己单独的存储空间，其实不是。property 是可以继承的，所以子类可以同时设置父类和自己的 property。\n然后我们要接触到第一个重载的部分了，因为 TestAnimal 继承了 GObject，所以我们要重载掉它类结构体里面的父类结构体的 set_property 和 get_property 函数。过程也很简单，GObject 要求我们实现两个函数 项目名_类型名_class_init 和 项目名_类型名_class_init，其中前者就是让我们初始化类结构体用的。首先进行一个类型转换把 TestAnimalClass 转换成 GObjectClass（为什么可以强转？那你先思考一下为什么定义类结构体时候第一个元素是父类结构体？就是因为要这样才能进行类型转换，本质上是个套娃），然后直接赋值。这样假如有人对我们这个类型执行 g_object_set_property，实际上是调用的我们重载过的函数（为什么？怎么做到的？往下看）。\n那其实我们知道如何覆盖父类的方法，但运行的时候是如何动态重载到我们自己的函数的也不清楚，我们先搞定我们自己的那个虚函数，其实很简单，我们定义具体的方法的时候（指 test_animal_print），不要让它实现具体的逻辑，而是让它通过参数的实例执行虚函数表里的函数就可以了。这需要一个自动生成的 项目名_类型名_GET_CLASS 的宏，它的作用是通过一个实例查找到 这个实例本身 对应的类结构体，然后就可以运行虚函数了。比如我给 g_object_set_property 传一个 TestAnimal，那我们调用的其实是 TestAnimalClass 的第一个成员那个 GObjectClass 的 set_property，这个已经被我们改成自己的了，于是就实现了一个 不那么直观的 重载。\n当然假如我们不想写一个纯虚函数，可以在 项目名_类型名_class_init 里面设置一个初值，这样假如子类没有重载，调用的就是这个。\n那你可能要问假如我有一个不想被重载的函数呢，那你就不要跳虚函数表了，直接写逻辑就可以了。\n接下来我们终于可以写子类的，首先就是 test-cat.h:\n\n#include &quot;test-animal.h&quot;\n\nG_BEGIN_DECLS\n\n#define TEST_TYPE_CAT test_cat_get_type()\n// Final 类型就不用写类结构体啦，反正那个最大的用处是用来写可以继承的虚函数。\nG_DECLARE_FINAL_TYPE(TestCat, test_cat, TEST, CAT, TestAnimal);\n\nTestCat *test_cat_new(gchar *animal_name, gchar *cat_name);\n\nG_END_DECLS\n\n#endif\n\" data-info=\"language-c\" data-lang=\"c\" class=\"code-block\">#ifndef __TEST_CAT_H__\n#define __TEST_CAT_H__\n\n#include &lt;glib-object.h&gt;\n#include \"test-animal.h\"\n\nG_BEGIN_DECLS\n\n#define TEST_TYPE_CAT test_cat_get_type()\n// Final 类型就不用写类结构体啦，反正那个最大的用处是用来写可以继承的虚函数。\nG_DECLARE_FINAL_TYPE(TestCat, test_cat, TEST, CAT, TestAnimal);\n\nTestCat *test_cat_new(gchar *animal_name, gchar *cat_name);\n\nG_END_DECLS\n\n#endif\n\nTestCat 是继承 TestAnimal 的，并且我们不想让它被继承，所以它就不需要写类结构体了（因为虚函数表是用来重载的，没有继承当然也没有重载）。\n所以接下来就直接到实现部分了，在 test-cat.c:\ncat_name)\n\t\t\tg_free(cat->cat_name);\n\t\tcat->cat_name = g_value_dup_string(value);\n\t\tbreak;\n\tdefault:\n\t\t/* We don't have any other property... */\n\t\tG_OBJECT_WARN_INVALID_PROPERTY_ID(object, property_id, pspec);\n\t\tbreak;\n\t}\n}\n\nstatic void get_property_impl(\n\tGObject *object,\n\tguint property_id,\n\tGValue *value,\n\tGParamSpec *pspec\n)\n{\n\tTestCat *cat = TEST_CAT(object);\n\n\tswitch (property_id) {\n\tcase PROP_CAT_NAME:\n\t\tg_value_set_string(value, cat->cat_name);\n\t\tbreak;\n\tdefault:\n\t\t/* We don't have any other property... */\n\t\tG_OBJECT_WARN_INVALID_PROPERTY_ID(object, property_id, pspec);\n\t\tbreak;\n\t}\n}\n\n// 那我们不想要默认的实现就可以覆盖掉自己的。\nstatic void print_impl(TestAnimal *animal)\n{\n\tTestCat *cat = TEST_CAT(animal);\n\tg_message(&quot;I am a Cat called %s.&quot;, cat->cat_name);\n\t// 这个自动生成的 parent 变量指向的是真正的父类！\n\t// 因为我们自己带的父类结构体被我们覆盖了，所以这里给你一个访问正主的机会。\n\t// 它是个 gpointer 所以你得手动设置类型。\n\t// 其实你的子类如果是完全覆盖父类的功能其实我觉得不用写这一句了。\n\t// 但是有时候你的子类是在父类上面增加功能就得写。\n\t// 这不就是 super 指针嘛！\n\tTEST_ANIMAL_CLASS(test_cat_parent_class)->print(animal);\n}\n\nstatic void test_cat_class_init(TestCatClass *cat_class)\n{\n\tGObjectClass *object_class = G_OBJECT_CLASS(cat_class);\n\tobject_class->get_property = get_property_impl;\n\tobject_class->set_property = set_property_impl;\n\n\t// 干掉我们自己的这个父类结构体里面带的内置实现，\n\t// 这样父类的函数调用的其实是我们自己的这个实现。\n\t// 那你看你一会让父类调用自己一会又让自己调用父类可真是麻烦。\n\tTestAnimalClass *animal_class = TEST_ANIMAL_CLASS(cat_class);\n\tanimal_class->print = print_impl;\n\n\tobj_properties[PROP_CAT_NAME] = g_param_spec_string(\n\t\t&quot;cat-name&quot;,\n\t\t&quot;Cat Name&quot;,\n\t\t&quot;Name of Cat&quot;,\n\t\tNULL,\n\t\tG_PARAM_CONSTRUCT_ONLY | G_PARAM_READWRITE\n\t);\n\n\tg_object_class_install_properties(\n\t\tobject_class,\n\t\tN_PROPERTIES,\n\t\tobj_properties\n\t);\n}\n\nstatic void test_cat_init(TestCat *cat)\n{\n}\n\nTestCat *test_cat_new(gchar *animal_name, gchar *cat_name)\n{\n\tg_return_val_if_fail(animal_name != NULL &amp;&amp; cat_name != NULL, NULL);\n\treturn g_object_new(\n\t\tTEST_TYPE_CAT,\n\t\t&quot;animal-name&quot;, animal_name,\n\t\t&quot;cat-name&quot;, cat_name,\n\t\tNULL\n\t);\n}\n\" data-info=\"language-c\" data-lang=\"c\" class=\"code-block\">#include \"test-cat.h\"\n\n// Final 类型也没有 Private，但是它自己本身就是容器嘛！\n// 所以实例结构体就给你自己写了。\n// 当然别的文件不需要知道你有什么私货所以不要写在头文件里。\nstruct _TestCat {\n\tTestAnimal parent_instance;\n\tgchar *cat_name;\n};\n\nG_DEFINE_TYPE(TestCat, test_cat, TEST_TYPE_ANIMAL)\n\nenum {\n\tPROP_0,\n\tPROP_CAT_NAME,\n\tN_PROPERTIES\n};\n\nstatic GParamSpec *obj_properties[N_PROPERTIES] = {NULL};\n\nstatic void set_property_impl(\n\tGObject *object,\n\tguint property_id,\n\tconst GValue *value,\n\tGParamSpec *pspec\n)\n{\n\tTestCat *cat = TEST_CAT(object);\n\n\tswitch (property_id) {\n\tcase PROP_CAT_NAME:\n\t\tif (cat-&gt;cat_name)\n\t\t\tg_free(cat-&gt;cat_name);\n\t\tcat-&gt;cat_name = g_value_dup_string(value);\n\t\tbreak;\n\tdefault:\n\t\t/* We don't have any other property... */\n\t\tG_OBJECT_WARN_INVALID_PROPERTY_ID(object, property_id, pspec);\n\t\tbreak;\n\t}\n}\n\nstatic void get_property_impl(\n\tGObject *object,\n\tguint property_id,\n\tGValue *value,\n\tGParamSpec *pspec\n)\n{\n\tTestCat *cat = TEST_CAT(object);\n\n\tswitch (property_id) {\n\tcase PROP_CAT_NAME:\n\t\tg_value_set_string(value, cat-&gt;cat_name);\n\t\tbreak;\n\tdefault:\n\t\t/* We don't have any other property... */\n\t\tG_OBJECT_WARN_INVALID_PROPERTY_ID(object, property_id, pspec);\n\t\tbreak;\n\t}\n}\n\n// 那我们不想要默认的实现就可以覆盖掉自己的。\nstatic void print_impl(TestAnimal *animal)\n{\n\tTestCat *cat = TEST_CAT(animal);\n\tg_message(\"I am a Cat called %s.\", cat-&gt;cat_name);\n\t// 这个自动生成的 parent 变量指向的是真正的父类！\n\t// 因为我们自己带的父类结构体被我们覆盖了，所以这里给你一个访问正主的机会。\n\t// 它是个 gpointer 所以你得手动设置类型。\n\t// 其实你的子类如果是完全覆盖父类的功能其实我觉得不用写这一句了。\n\t// 但是有时候你的子类是在父类上面增加功能就得写。\n\t// 这不就是 super 指针嘛！\n\tTEST_ANIMAL_CLASS(test_cat_parent_class)-&gt;print(animal);\n}\n\nstatic void test_cat_class_init(TestCatClass *cat_class)\n{\n\tGObjectClass *object_class = G_OBJECT_CLASS(cat_class);\n\tobject_class-&gt;get_property = get_property_impl;\n\tobject_class-&gt;set_property = set_property_impl;\n\n\t// 干掉我们自己的这个父类结构体里面带的内置实现，\n\t// 这样父类的函数调用的其实是我们自己的这个实现。\n\t// 那你看你一会让父类调用自己一会又让自己调用父类可真是麻烦。\n\tTestAnimalClass *animal_class = TEST_ANIMAL_CLASS(cat_class);\n\tanimal_class-&gt;print = print_impl;\n\n\tobj_properties[PROP_CAT_NAME] = g_param_spec_string(\n\t\t\"cat-name\",\n\t\t\"Cat Name\",\n\t\t\"Name of Cat\",\n\t\tNULL,\n\t\tG_PARAM_CONSTRUCT_ONLY | G_PARAM_READWRITE\n\t);\n\n\tg_object_class_install_properties(\n\t\tobject_class,\n\t\tN_PROPERTIES,\n\t\tobj_properties\n\t);\n}\n\nstatic void test_cat_init(TestCat *cat)\n{\n}\n\nTestCat *test_cat_new(gchar *animal_name, gchar *cat_name)\n{\n\tg_return_val_if_fail(animal_name != NULL &amp;&amp; cat_name != NULL, NULL);\n\treturn g_object_new(\n\t\tTEST_TYPE_CAT,\n\t\t\"animal-name\", animal_name,\n\t\t\"cat-name\", cat_name,\n\t\tNULL\n\t);\n}\n\nFinal 类型没有 Private，因为它自己就是 Private，所以这个定义实例结构体的权力交给了你，直接在里面写私有成员即可，但是不要忘了套娃（指第一个成员一定要是父实例结构体）。\n然后就是设置我们自己的 property 并重载 GObject 的虚函数，当然你可能会说这样父类型的 property 不就丢了吗？其实 property 是放在 GObject 系统单独的一个表里，需要的时候它去查询，并且会考虑父类型的继承链，所以我们不需要考虑重载时候覆盖掉父类型的问题。\n接下来就是重载父类型提供给我们的虚函数也就是 TestCatClass.print 了！当然首先是把我们自己的类结构体里面的父类结构体的虚函数换掉。\n在写我们自己重载的函数的时候，一个比较重要的事情是 chain up，也就是最后一行那个 TEST_ANIMAL_CLASS(test_cat_parent_class)-&gt;print(animal);，有时候你重载只是做了一些子类自己的处理，然后还是需要父类提供的处理函数的，这该怎么办？你可能会说类结构体里面不是有个父类结构体了吗？但那个已经被我们覆盖了！你这样不就是咬自己尾巴的猫吗？\n当我们自己解决不了的时候就得 GObject 解决了，G_DEFINE_TYPE 会提供一个 项目名_类型名_parent_class 的指针定义，这个指向的是那个真正的唯一的 不是我们自己包含的 那个父类结构体！也就是说对于子类和父类继承而言，有两个父类结构体，一个是父类自己作为一个类的那个，另一个是子类为了实现重载包含的那个，这个访问的就是前者。\n当然这个类型其实是个 gpointer 所以我们还得自己转换一下类型，然后只要调用方法就可以了（这不就是 super 指针嘛）。\n最后我们写一个测试的程序 main.c:\n#include \"test-animal.h\"\n#include \"test-cat.h\"\n\nint main(int argc, char *argv[])\n{\n\tTestAnimal *animal = test_animal_new(\"animal1\");\n\tTestCat *cat = test_cat_new(\"animal2\", \"cat1\");\n\ttest_animal_print(animal);\n\ttest_animal_print(TEST_ANIMAL(cat));\n\treturn 0;\n}\n\n然后用这个 Makefile:\nCC := cc\nOBJECTS := main.o test-animal.o test-cat.o\nCFLAGS := `pkg-config --cflags gobject-2.0`\nLIBS := `pkg-config --libs gobject-2.0`\n\nall: ${OBJECTS}\n\t${CC} -o test ${OBJECTS} ${LIBS}\n\nmain.o: test-animal.h test-cat.h\n\t${CC} -c main.c ${CFLAGS}\n\ntest-animal.o:\n\t${CC} -c test-animal.c ${CFLAGS}\n\ntest-cat.o: test-animal.h\n\t${CC} -c test-cat.c ${CFLAGS}\n\n.PHONY: clean\nclean:\n\t-rm test ${OBJECTS}\n\n运行的结果是这样的：\n** Message: 11:23:25.727: I am an Animal called animal1.\n** Message: 11:23:25.729: I am a Cat called cat1.\n** Message: 11:23:25.729: I am an Animal called animal2.\n\n你可以看到第一行是父类也就是 TestAnimalClass 的函数的输出。第二行是 TestCatClass 重载的函数的输出，第三行则是 TestCatClass 重载的函数 chain up 到 TestAnimalClass 的输出，但是输出的是子类继承的父类的属性！\n最后有关我到底该声明成子类还是父类以及传参时候要不要类型转换：其实在这个 GObject 的样例里面看起来是无所谓的，类型转换可行不可行其实取决于具体 new 的是什么，而不是声明的指针是什么，所以其实声明成父类和子类都没有关系，类型转换也主要是为了满足 C 语言对指针类型和函数声明的要求罢了。\n"},{"title":"基于 GitHub Issue 的前端评论框","url":"/posts/Frontend-GitHub-Issue-Based-Comment/","content":"造轮子是病，得治。\n自从造了 前端博客搜索引擎 的轮子之后，我对自己的能力有了极大的信心，同时也掌握了一些有趣的用法，于是把枪口瞄准了下一个我看着不顺眼的地方——评论框。\n（这标题怎么写的和毕业论文似的！）\n轮子也不是白造的。\n从我建站以来我的评论框就命途多舛，Disqus 虽然是最著名的评论系统，但是在国内访问不太顺畅。多说火了一段时间之后便关门大吉，HyperComments 则在我用了一段时间后发邮件提示要收费了，于是我之前的评论便华丽流失。而对于 Valine 这种基于 LeanCloud 的评论系统，我对 LeanCloud 不甚了解所以也不想尝试（而且 Valine 现在似乎转向闭源了，当初也许是个正确的决定）。然后赶上 Gitment 和 Gitalk 火了起来，大家意识到 GitHub Issue 正是个放评论的好地方。但是由于 Gitment 和 Gitalk 采用他们自己的服务器实现博客评论框提交——转发到 GitHub API 的实现，以及 采用的 OAuth App 权限过高，有人怀疑这不太安全，于是我也没太敢参与。后来遇到 comment.js 这个项目，它绕过了提交评论的问题——直接给一个到 GitHub Issue 的评论框的链接就可以了。于是我就开始用这个，至于什么 utterances 这种用 GitHub App 降低权限的评论系统，我也懒得尝试了。\n但我为什么决定替换掉 comment.js 我已经记不清楚了，可能是为了对主题的显示有更好的掌握吧，毕竟它带有自己的 CSS 样式而且经常和我的冲突，也可能是因为它迟迟没提供像 Disqus 一样查找每篇文章评论数目的功能，不过它现在已经不再维护了，所以我也算是未雨绸缪。\n事情本该很简单。\n研究了一下原理其实并不是很难，首先就是从 GitHub 的 API 上 ajax 获取数据，然后操作 DOM 添进去就可以了，所以我就先阅读了 GitHub API 的文档，总之还算容易，只要先获取一个仓库的 issue 列表，然后按照某种方法在里面查找相关的 issue，如果没有就渲染一个到新建 issue 的链接，否则获取该 issue 的全部评论并显示就可以了。\n查找 issue 的 key 也算容易，我给它做成了函数参数，然后在主题模板里填一个每个页面唯一的字符串就行了，比如文章标题，然后新生成 issue 页面时候就把这个作为 issue 的标题，这样查找 GitHub Issue 页面时候也比较容易看。然后 GitHub 把 PR 也视为 issue，这个不要紧，收到数组之后过滤一下就好了。而显示数目我琢磨了一下其实也没什么难的嘛！我都获取到所有 issue 的信息了。做一个新的函数，主题作者在页面上放置一系列空的元素并设置好 class 属性，然后同样地把每个页面唯一的字符串设置成这些元素的属性（Disqus 也是这样的），加载函数时候把 class 作为参数传进去，分别从 issue 列表里查找对应的标题就可以了。还有一个奇葩问题是 GitHub issue 的 comments 指的是除了顶楼以外的评论，但很显然看起来不是这样的，这个也简单，直接把顶楼加到数组里就成了嘛。剩下就是艰苦的在浏览器里刀耕火种写 JS 拼 HTML 字符串发 ajax 请求写嵌套回调（没有 async/await 也太痛苦了吧！。\n一切正常工作了一段时间一后我发现不太对劲，怎么评论太多的时候显示不全呢？仔细查了一下发现 GitHub API 是强制分页的，也就是说不管你怎么设置，最多一次只能获取 100 条评论，默认则是 30 条，本来我不太想给博客加评论分页功能的，现在看来是 GitHub 被迫让我加啊。当然这个并不是最痛苦的，最痛苦的是它查找仓库的 issue 列表的时候也是强制分页的！这就麻烦了，还记得我们之前说要获取到列表之后查找标题吗？获取不到完整列表还怎么查找啊！\n当然你可以说按顺序多查几页不就行了嘛，这就是它分页难受的地方了！ajax 是异步的啊亲！你不会想让我一个 for 循环几个 ajax 还指望优雅的等他们结束了再跑查找吧！我知道能用 Promise.all() 解决，但是由于我大发慈悲的支持使用 IE11 的用户（微软我&amp;A%￥S&amp;……省略一千字儒雅随和），所以我的函数是基于回调的，那也没什么办法。而且这样首先你得读一下仓库信息才能知道有多少个 open 的 issue（没错只能算 open 的不能算 close 的！所以在后面查 issue 的时候我也不得不筛选掉 close 的，不过这大概也许是个 feature？），然后自己计算有多少页。最后我只能造了一个不那么优雅的尾递归回调（反正就那个意思），不过它工作的不错，这样我就可以获取到全部的 issue 列表了。\n然后后面其实还是差不多，至于评论分页又不一样了，既然 GitHub 只有分页 API，我也就半推半就啊不是将计就计吧。我才不要继续获取全部评论了，我也每次直接获取 GitHub 那边的一页就行了，每页个数则由主题作者传参进来。至于如何确定我要哪一页呢？和搜索功能一样，继续前端解析 query string 呗。根据 issue 评论总数计算一下有几页，然后生成几个链接放在页尾，每次加载时候解析一下参数确定当前页是哪个即可。当然，不要忘了 issue 顶楼不算评论，计算分页的时候也不要给它加进去！而且既然是这么分页，我也懒得把顶楼算在里面了（不然真的麻烦的要死啊后面分页和每页个数全乱了），所以假如你设置每页 10 个评论，那第一页其实有 11 个，别烦我，代码在那，不满意自己改……然后继续刀耕火种……\n为了减轻负担，我没有实现太多的功能，比如时间戳我没有搞成什么几分钟几小时前，这种东西又不清晰又浪费带宽，我只搞了基于 marked 的 Markdown 渲染（必须的）和语法高亮，Markdown 渲染不是必须的，因为你可以 设置 header 让 GitHub 直接返回 HTML。为了保证效果统一，这个 JS 只是建立了 HTML 布局，给每个元素添加了 class，具体的样式则完全是主题自己编写的，所以配合起来也比较好看。\n最后的效果其实还可以，完整的脚本就是 这个网站在用的 JS，具体和主题的整合方法就慢慢翻 ARIA 的模板吧。唯一的缺点是 GitHub API 的频率限制太低，按照这个弱智的 issue 列表分页的话又不得不多一次读取仓库有多少 issue 的请求，假如你的 issue 太多估计也是问题？不过应该不会有那么多博文吧！我只有调试脚本的时候遇到过被 GitHub 提示等会的问题，所以对于访问者应该没什么影响。有影响以后再想解决办法（或者没办法）。\n也许最好的办法是解决掉需求——要什么评论框？不就是破事水？如果有问题想联系作者电子邮件又不是不能用！（逃\n其实你知道，烦恼（bug）会解决烦恼（bug）。\n更新（2022-03-21 16:46:46）：我终于发现 `fetch` 不会主动帮我做缓存的原因是我一直习惯性的在 DevTools 里面勾上 Disable Cache……所以下面这些其实都没有必要了，只要取消勾掉 Disable Cache，调试的时候就不会很快撞到查询频率次数限制了……这一部分更新于 2020-08-17 10:34:00。GitHub API 推荐用户 缓存之前的请求响应，然后根据缓存的响应的 Header 里面的 ETag 发送请求查询是否过期，若未过期则返回一个不消耗频率限制次数的 304 状态码。我心想这也简单，那就在前端搞一个缓存就可以了。\n然后我搜索了一番找到了 CacheStorage，看起来它是唯一一个跨标签页的基于 Session 的正宗的前端缓存。但是很显然 IE 又不支持，而且这个 API 基于 Promise 并且只能缓存 Response 对象，也就是说没办法简单的通过在 XHR 的时候判断一下跳过不支持的情况，要 Polyfill 则需要引入完整的 Promise 和 fetch/Response，所以我们做了一个艰难容易的决定——是时候去掉 IE 支持了！\n于是我把请求 API 的函数改成了如下操作：\n {\n  let cachedResponse = null;\n  return cachePromise.then((cache) => {\n    return cache.match(path);\n  }).then((response) => {\n    // No cache or no ETag, just re-fetch;\n    if (response == null || !response.headers.has(&quot;ETag&quot;)) {\n      return window.fetch(path, opts);\n    }\n    // Ask GitHub API whether cache is outdated.\n    cachedResponse = response;\n    opts[&quot;headers&quot;] = opts[&quot;headers&quot;] || {};\n    opts[&quot;headers&quot;][&quot;If-None-Match&quot;] = cachedResponse.headers.get(&quot;ETag&quot;);\n    return window.fetch(path, opts);\n  }).then((response) => {\n    if (response.status === 200) {\n      // No cache or cache outdated and succeed.\n      // Update cache.\n      cachePromise.then((cache) => {\n        return cache.put(path, response);\n      });\n      // Cache needs an unconsumed response,\n      // so we clone respone before consume it.\n      return response.clone().json();\n    } else if (response.status === 304 &amp;&amp; cachedResponse != null) {\n      // Not modified so use cache.\n      return cachedResponse.clone().json();\n    } else {\n      // fetch does not reject on HTTP error, so we do this manually.\n      throw new Error(&quot;Unexpected HTTP status code &quot; + response.status);\n    }\n  });\n};\n\" data-info=\"language-JavaScript\" data-lang=\"JavaScript\" class=\"code-block\">let cachePromise = window.caches.open(\"cacheName\");\n\n// Fetching JSON with cache for GitHub API.\nconst cachedFetchJSON = (path, opts = {}) =&gt; {\n  let cachedResponse = null;\n  return cachePromise.then((cache) =&gt; {\n    return cache.match(path);\n  }).then((response) =&gt; {\n    // No cache or no ETag, just re-fetch;\n    if (response == null || !response.headers.has(\"ETag\")) {\n      return window.fetch(path, opts);\n    }\n    // Ask GitHub API whether cache is outdated.\n    cachedResponse = response;\n    opts[\"headers\"] = opts[\"headers\"] || {};\n    opts[\"headers\"][\"If-None-Match\"] = cachedResponse.headers.get(\"ETag\");\n    return window.fetch(path, opts);\n  }).then((response) =&gt; {\n    if (response.status === 200) {\n      // No cache or cache outdated and succeed.\n      // Update cache.\n      cachePromise.then((cache) =&gt; {\n        return cache.put(path, response);\n      });\n      // Cache needs an unconsumed response,\n      // so we clone respone before consume it.\n      return response.clone().json();\n    } else if (response.status === 304 &amp;&amp; cachedResponse != null) {\n      // Not modified so use cache.\n      return cachedResponse.clone().json();\n    } else {\n      // fetch does not reject on HTTP error, so we do this manually.\n      throw new Error(\"Unexpected HTTP status code \" + response.status);\n    }\n  });\n};\n\n当然理想很丰满现实很骨感，在不支持 CacheStorage 的浏览器里要 fallback 到不带缓存的版本，本来我以为很简单，但是……（下面开启吐槽时间。）\n支持 IE 的前端的痛苦都是相似的，不支持 IE 的前端则各有各的痛苦。为什么非 HTTPS + localhost 不能用 CacheStorage 啊，难道他们没考虑过在电脑上开发然后手机访问测试移动版吗？还是说他们打算在手机上起一个开发服务器？为什么 Firefox 在非 HTTPS 时限制 CacheStorage 的方法是在 Promise 里 reject 一个 Error 从而导致这个过程变成了异步的？为什么 CacheStorage 只能缓存 Response 而不是任意数据结构？Safari 不能完整支持 Response 对象也就算了，为什么移动版 Chrome 和 Firefox 也不支持？合着你们 fetch 返回的 Response 还不是 Response？这世界到底怎么了……\n所以最后需要一个长长的基于 Promise 的判断加载函数：\n {\n  // Unlike in .then(),\n  // we must explicit resolve and reject in a Promise's execuator.\n  return new Promise((resolve, reject) => {\n    if (cachePromise != null &amp;&amp; fetchJSON !== uncachedFetchJSON) {\n      return reject(new Error(&quot;Cache is already loaded!&quot;));\n    }\n    // Old version browsers does not support Response.\n    if (window.Response == null) {\n      return reject(\n        new Error(&quot;Old version browsers does not support Response.&quot;)\n      );\n    }\n    const testResponse = new window.Response();\n    // Safari and most mobile browsers do not support `Response.clone()`.\n    if (testResponse.headers == null || testResponse.clone == null) {\n      return reject(new Error(\n        &quot;Safari and most mobile browsers do not support `Response.clone()`.&quot;\n      ));\n    }\n    // Chromium and Safari set `window.caches` to `undefined` if not HTTPS.\n    if (window.caches == null) {\n      return reject(new Error(\n        &quot;Chromium and Safari set `window.caches` to `undefined` if not HTTPS.&quot;\n      ));\n    }\n    window.caches.open(&quot;CacheStorageTest&quot;).then((cache) => {\n      fetchJSON = cachedFetchJSON;\n      cachePromise = window.caches.open(name);\n      return window.caches.delete(&quot;CacheStorageTest&quot;);\n    }).then(() => {\n      return resolve();\n    }).catch((error) => {\n      // Firefox throws `SecurityError` if not HTTPS.\n      console.error(error);\n      return reject(new Error(&quot;Firefox throws `SecurityError` if not HTTPS.&quot;));\n    });\n  }).catch((error) => {\n    console.error(error);\n  });\n};\n\" data-info=\"language-JavaScript\" data-lang=\"JavaScript\" class=\"code-block\">// 加载评论的时候才加载缓存。\nlet cachePromise = null;\n\nlet fetchJSON = uncachedFetchJSON;\n\nconst loadCache = (name) =&gt; {\n  // Unlike in .then(),\n  // we must explicit resolve and reject in a Promise's execuator.\n  return new Promise((resolve, reject) =&gt; {\n    if (cachePromise != null &amp;&amp; fetchJSON !== uncachedFetchJSON) {\n      return reject(new Error(\"Cache is already loaded!\"));\n    }\n    // Old version browsers does not support Response.\n    if (window.Response == null) {\n      return reject(\n        new Error(\"Old version browsers does not support Response.\")\n      );\n    }\n    const testResponse = new window.Response();\n    // Safari and most mobile browsers do not support `Response.clone()`.\n    if (testResponse.headers == null || testResponse.clone == null) {\n      return reject(new Error(\n        \"Safari and most mobile browsers do not support `Response.clone()`.\"\n      ));\n    }\n    // Chromium and Safari set `window.caches` to `undefined` if not HTTPS.\n    if (window.caches == null) {\n      return reject(new Error(\n        \"Chromium and Safari set `window.caches` to `undefined` if not HTTPS.\"\n      ));\n    }\n    window.caches.open(\"CacheStorageTest\").then((cache) =&gt; {\n      fetchJSON = cachedFetchJSON;\n      cachePromise = window.caches.open(name);\n      return window.caches.delete(\"CacheStorageTest\");\n    }).then(() =&gt; {\n      return resolve();\n    }).catch((error) =&gt; {\n      // Firefox throws `SecurityError` if not HTTPS.\n      console.error(error);\n      return reject(new Error(\"Firefox throws `SecurityError` if not HTTPS.\"));\n    });\n  }).catch((error) =&gt; {\n    console.error(error);\n  });\n};\n\n不管怎么样现在这个网站在支持 CacheStorage 和 Response 的浏览器上（似乎也就桌面版 Chrome/Firefox……）是缓存 GitHub API 的结果了，打开 DevTools 切到 Network 面板可以看到 GitHub API 返回的是 304 而不是 200，其他浏览器则 fallback 到无缓存的 fetch。当然其他浏览器不包含 IE 咯。\n由俭入奢易，由奢入俭难。\n这一部分更新于 2020-08-18 12:25:00。我后来又仔细想了想，其实要兼容 IE 还是有办法的，首先 fetch 和 Promise 都有成熟的 polyfill，甚至 URLSearchParams 也有，只要写一段脚本在不支持的时候加载他们就可以了。然后去掉所有 IE 不支持的 ES6 特性，比如箭头函数、模板字符串、for…of… 循环以及 MapReduce（IE 竟然支持 const 和 let 真是惊到我了）。但是能做到并不意味着一定要做，人总是还要向前看的，现在是 2020 年，连罪魁祸首始作俑者微软都放弃了 IE，就算是照顾用户量，IE 用户也是可以忽略的那一部分了。既然我已经用 ES6 重写了，就不要想再让我为这种历史垃圾放弃我得到的好处了，从我开始写主题到现在丢掉 IE 支持也算是仁至义尽了，所以为什么不让这些用户支持一下 Firefox 呢？\n"},{"title":"和 cheerio 说再见！","url":"/posts/Drop-Cheerio/","content":"我早就想把 cheerio 从 Hikaru 的依赖里移出去了，倒不是我对他的功能有什么不满，但是一年不更新 NPM 上的包也太恶心了吧！\n\n我的生成器使用 cheerio 的地方主要有两个，一是给标题生成喵点并依次生成 TOC，二是检查文章里相对路径的图片和链接引用并改成绝对路径，否则如果截取之后放在首页的摘要包含图片的话，会因为当前页面的地址变化而不工作。后者也是我自己编写生成器的原因之一，就是因为像 Hexo 或者 Hugo 这种已有的生成器内部并没有考虑，只能通过插件解决，我在写 Hexo 主题时嵌入了一个使用正则表达式的脚本，但相比之下我觉得还是一个能理解 HTML 的库更加可靠。所以后来我选了 cheerio 来做解析和修改以实现这些功能。\n但是 cheerio 有一个 很古怪的 bug，存在于它目前在 NPM 上最新的版本 1.0.0-rc3 上。假如你使用 cheerio 解析并编辑包含中文的 HTML 的话，导出字符串时所有的中文都被编码成了 HTML 实体，导致进行下一步处理比如用 substring 截断的话，字符串长度变化了，而且可能会在文字中间截断。假如你使用 decodeEntities: false 的话，原本文档里的 &amp;lt; 一类的字符反而会被 cheerio 导出成 &lt;，变成一团乱麻。\n这个 bug 非常古怪并且我花了一段时间来研究它，上一个版本 0.22.0 使用 decodeEntities: false 是没有这个问题的。最后发现原因是 cheerio 在 1.0.0-rc1 开始引入 parse5 代替原本的 htmlparser2 作为默认的 HTML 解析器，而 parse5 在解析时并不会使用 decodeEntities 这个参数，比如你输入 &amp;lt;，parse5 解析时会在节点里存储原本的值 &lt;，但 cheerio 在序列化的时候还是会依赖这个参数进行编码，所以假如传递 false 进去，cheerio 就不会主动进行 encode，导致最后出来的是 &lt;。而 htmlparser2 会按照这个参数选择解不解码，所以也不会发生这种错误。\n那这么看起来是 parse5 的问题？并不！parse5 有自己的序列化函数，和自己的解析函数是配套的，所以只要使用 parse5 的序列化函数就不会存在这个问题了。\n最后总结起来修复的办法也有很多：首先其实序列化的时候并不需要将所有元素都编码成 HTML 实体，只要对几个字符进行转义即可，我提交了这样的 PR，但因为 cheerio 和它自己的序列化库 dom-serializer 是两个仓库，不是很好处理，而且其实也没有从根源上解决 htmlparser2 和 parse5 表现不一致的问题。cheerio 后来的提交中采用的是简单办法，假如使用 parse5 解析就继续使用 parse5 序列化就好了。\n但是这个提交之后并没有发布到 NPM 上，NPM 挂的一直是有问题的 1.0.0-rc3 版本，至于原因呢很简单，他们打算释出第一个稳定版本 1.0.0，所以要等到所有 TODO 都解决了再发新版本！\n搞毛啊老哥，你这样放着有 bug 的版本是把用户做宝搞吗？而且你是个函数库，下面好多人依赖你处理 DOM 呢，NodeJS 上提供 jQuery 模式的函数库大概也没什么别的替代品了，一堆项目在 issue 里问你就给个这破理由？\n我选择的是锁死 0.22.0 版本，其他下游的项目也都各自做了 workaround，要么安装 GitHub 上的 1.0.0 分支要么回退版本。但总之都让强迫症很不爽啊！\n大家等他发稳定版就这么等了一年，在这一年间有些项目比如 Hexo 直接抛弃了 cheerio 用正则处理 HTML，虽然他们主要是为了性能。我倒不是那么在乎生成器的性能（真的在乎的话我就该去用 Hugo，而且我也不敢说我自己的代码写的很好）。\n晚上睡觉前我突然想到假如只是过滤链接和图片并检查他们的属性的话其实不需要 jQuery 一样的 API，只需要能理解 HTML，那找个简单的解析器就可以了。而且第二天正好读了 卷老师的这篇博文，发现和我想的也差不多。于是就开始动手。\n卷老师用的 sanitize-html 对我来说不太合适，因为我的静态生成器并不需要过滤内容——都是 Markdown 生成的，而且不安全也是使用者自己故意写的，不是我的责任，生成器也没有不安全的 HTML 的运行环境。sanitize-html 和 cheerio 用的都是 htmlparser2 作为解析器，虽然它号称自己是跑得最快的 HTML 解析器，但经过之前那个问题我还是心有余悸。而且 htmlparser2 是非常简单的基于事件回调的解析器（就像 Python 自带的那个，我太久不写 Python 不记得叫什么了），不会给你构建树状结构也不包含序列化，自己写序列化就容易像之前那个 cheerio bug 一样出问题。所以我考察了一下 parse5 发现还不错，会构建树状结构，数据结构都写在文档里，同时就是简单的可以直接操作的 Object，至于速度虽然慢一点但我并不太在乎那 10 毫秒。\n于是我就先手动造了一些 wrapper 函数比如递归前置遍历一颗树来代替 cheerio 的 $.each()，前置遍历正好是 HTML 文档自上往下的顺序，生成 TOC 的时候也是按照这个顺序来的，然后就是比如获取结点内文本的函数、获取属性和设置属性的函数之类的。至于插入结点我想了个巧妙的办法，因为 parse5 把最终解析到的文本作为单独的结点，我就直接在插入文本的函数里让 parse5 解析输入的 HTML，然后用得到的子结点替换被插入结点的子结点即可。\n随后我重写了生成 TOC，生成标题 ID 和检查文章里相对路径的图片和链接引用并改成绝对路径的函数。然后写了点简单的测试样例跑了一下发现没问题，就用它们替换掉 Hikaru 的 utils.js 里面用 cheerio 的版本，然后修改了 process 代码。我造的这几个简单的 wrapper 完全符合我的要求，于是提交打版本号发布一气呵成，一年不更新的 cheerio 就从我的生成器里拜拜了。\n虽然嘴上说着不追求速度，但是凭我肉体的感觉还是多少快了一点儿，不过我也并不是很在乎生成的时间，感知真的不强，无所谓了。但我真的不能理解这种为了憋个大的不发版本把用户做宝搞的行为，就算是发个 1.0.0-rc4 也比一年不发强吧！就算只是 make user happy 也好，何况面对的不是 user 而是其它项目的 dev 呢，都是同行，这行为算不算托大？反正我是习惯做了点改动就打个新版本，即使我自己安装的都是开发版本，我也希望能把我最新的修改送到用户手上，又不是 breaking change 或者什么不能随便更新的软件，打个小版本号至于那么难吗？？？\n"},{"title":"给你的主题来点暗色！","url":"/posts/Dark-Mode-in-Theme/","content":"我自己对暗色模式其实是没什么兴趣的，因为设计一种配色就已经让我绞尽脑汁了，还要我设计另一种。但是我也确实意识到暗色模式在晚上玩手机实在是很方便，而且做这个也很流行，于是我也做了一个，只是因为我能做到。\n\n跟着系统变色就行了吗？\n现在的系统大概都支持暗色模式（Linux 的桌面环境早就有这种设置了，Firefox 可以直接读取我的系统设置，Android/iOS 也都有暗色模式开关），浏览器也紧跟潮流提供了 @media 的 查询属性（IE：那……是……什……么……）。理论上来说只要简单地在 CSS 里面查询然后编写修改颜色的代码就可以了，问题只是如何修改颜色比较轻松。\nCSS 变量是好东西！\n使用 CSS 变量 当然是最简单的解决方案了，就像我们平时编程一样使用变量作为 color 和 background 的值，然后在查询到暗色模式的代码块里给这些变量重新赋值，一切都十分简单有条理，而且最重要的是你不需要一个一个选择器查找有哪些需要变色的属性，所有的颜色变量都是放在一起的。\n真是恨死 IE 这废物了！\n虽然 IE 有很多不支持的选项，但是不支持 CSS 变量真是让我工作量剧增的一件事情。虽然我从不测试我的网站在 IE 上能否运行，但是在最新版 IE 上一般都是没问题的，因此我也会放弃一些最新版 IE 不支持的新特性，CSS 变量就是其中之一。\n既然没有办法用 CSS 变量，那就只能自己一个一个找选择器下面和颜色相关的属性，然后给它们重新设置属性了，真是找的人头晕眼花啊。\n可能有人会说你不是用 CSS 预处理器吗，预处理器不是也有变量吗？但是预处理器是在生成阶段把变量编译掉了啊！不方便到运行时（浏览器）里去替换变量。\n我就是想在暗色浏览器里用亮色啊！\n在全部重新调整过颜色并能看之后（其实就是把浅色的色块换成深色，颜色层次基本不变，背景图搞个反色，至于那些彩色的按钮标签我实在没精力重新配色了，把透明度调低一点就好了），我自己还是比较喜欢自己一开始设计的样子，但我又是个习惯电脑全局暗色的人，这怎么能忍！\nCSS 的媒体属性不像一般的属性，只能是浏览器设置我们读取，没有办法用 JS 控制，于是也就没法简单地利用这个添加切换按钮。上网搜了半天也只有曲线救国的方案。\n曲线救国\n如果并不是想那么和系统的设置同步而只是给自己的网站添加切换的话，并不需要媒体查询。只要设计一个按钮给 &lt;html&gt; 或 &lt;body&gt; 添加删除 class/attribute 就行了。然后如果要和系统同步，在 JavaScript 里也有 相关的 API 可以做到查询和监听，在检测到变化的时候也修改 class/attribute 即可。\n我选择的是给 &lt;html&gt; 加 data-theme=\"dark\" 或 data-theme=\"light\" 属性，不选 &lt;body&gt; 是因为 WebKit 那些该死的不遵循标准的 scrollbar 伪类，文档没有说他们到底依附哪个元素，我尝试得到的是 &lt;html&gt;。接下来只要把之前的 CSS 里面的媒体查询选择器改成 html[data-theme=\"dark\"] 就行了。\n不过还要注意继承关系，这样写的话有些属性并不是继承外面的，而是在这个选择器里面就近继承。比如假如修改了 html[data-theme=\"dark\"] a 的边框，那 html[data-theme=\"dark\"] a.cls 的边框会优先继承这个，而不是 a.cls。我知道有些人可能会笑我半懂不懂了，但是我确实遇到了这个问题，并且思考了一下找到了原因。\n还有一个比较尴尬的事情，我给一些元素设置了 transition 用于 :hover 之后加一个渐变颜色的效果，现在暗色模式也是修改颜色，导致这些元素会比其他元素慢变一下，没什么好办法因为你分不开两种 color 变化。我的解决方案是一个一个找切换暗色模式时候会变属性的选择器，给它们也添加 transition。效果还不错，不过 Chrome 在处理这种 CSS 动画时候竟然会掉帧？？？\n不管了，反正我用 Firefox，Firefox 效果好得很，完全不掉帧。\n更新（2020-08-07 10:50:00）：我怀疑 Chrome 想做新时代的 IE，其实并不是性能问题导致掉帧，WebKit 对于继承来的属性的 transition 存在问题，会导致不是同时变换而是有延迟的变换，效果糟透了，StackOverflow 上也有人遇到这个问题，看起来是 bug 并且不打算解决，而 Firefox 就没有这个问题。使用 CSS 变量在 WebKit 下效果会好一点，不过也不能给所有变色的元素加 transition，还是会卡，只能给 &lt;body&gt; 加一个，因为我的链接有 :hover 时变色的 transition，都是 color 就没办法在那时 transition 而暗色模式时候不 transition。总之由于 WebKit 的存在导致没法让全部元素同步 transition，只能近似。两权相害取其轻，还是让 IE 用户只能用亮色吧，我最后还是选择了 CSS 变量。\n怎么你这破网站换个页面还要重新点一次？\n一切看起来都十分美好，直到我把一个暗色页面切成亮色然后点了个链接，下一个页面并不会理会我们上一个页面设置了什么主题，又变成了暗色。每个页面点一下切换按钮也太烦人了，我们得来点持久化。\n某个域名想在用户的浏览器里存点是完全可行的，使用 localStorage 就行了，就是简单的键值对。但是这样我们就有了多种可能切换亮暗的动作：localStorage 里面存的选项，网页加载时浏览器媒体查询的结果，用户点了网页上的切换按钮，用户点了系统切换亮暗的设置。这些的判断顺序要好好处理一下，不然某些就会被忽视掉变成“我点了怎么不动啊！！！”。\n经过我考虑一下之后，这个玩意的逻辑应该是这样的：\n\n假如 localStorage 里面没有值，用户是首次打开网站，此时读取媒体查询按照系统的主题设置。\n否则说明用户之前打开过网站，已经有他自己的喜好了，按照 localStorage 里面的值设置。\n上两个步骤结束之后注册一个媒体查询监听器，用于响应用户修改系统设置。\n注册一个按钮监听器，用于响应用户点击网页切换按钮设置。\n\n以上 1 和 2 的顺序不能反了，并且每一个设置动作里都要把这次设置的值写入 localStorage 用于后续加载用户的选择。\n现在看起来一切都很满意，是时候发布了！\n用户：啊！我的眼睛！\n我躺在床上用手机测试的时候又发现了一个问题。因为网页在生成的时候总有一个初始状态（JS 要等到 document 加载完成才开始处理 DOM，不像那些单页应用），假如我设置成暗色然后切换到别的页面，网页就会以亮色加载然后变成暗色，用户在晚上看起来就像是个闪光弹（伏拉什棒！）。\n其实没什么好的解决办法，因为这是传统 HTML 页面的限制之一，我也不想找有没有什么新的东西能解决这些问题，最后的方案其实相当简单，既然亮色到暗色会让人受不了，我搞成暗色到亮色不就行了。\n于是就是把模板里按钮的初始状态修改一下，渲染的时候出来的是 data-theme=\"dark\"，假如用户选择亮色，加载页面时会有一个暗色到亮色的变化。反正我自己都不在意。\n更新（2020-08-06 18:23:00）：我后来阅读了一些其它主题的代码，看它们是怎么在不使用单页应用的前提下解决这个问题的，结果方法相当简单，我自己也想得到：不用等到 DOMContentLoaded 事件之后，反正只是修改 &lt;html&gt; 标签，直接在 &lt;script&gt; 标签里面编辑 document.documentElement 是可以的，因为反正加载到 &lt;script&gt; 的时候肯定也已经加载到 &lt;html&gt; 了。所以就把修改这个属性和修改按钮的 DOM 分开了两部分，并且添加了一个 storage 的监听器，这样假如打开了多个页面，一个页面切换其它页面也会跟随切换。\n不要过度设计！\n经过这么一大堆折腾（代码行数++++++++），我甚至在想要不要支持每个页面单独设置亮暗初值，反正只要添加一个 front matter 然后在模板里判断一下嘛。不过后来想了想，就算有这么个功能又有什么用？实际意义几乎为零，徒增复杂度，所以还是不要过度设计了。\n"},{"title":"修复更换 PCI-E 插槽后 Windows 下无线网卡的名称问题","url":"/posts/Fix-Windows-Wireless-Adapter-Name/","content":"这是一篇其实没什么逻辑也没什么技术的文章，但踩到这个坑又很头痛，所以记录一下。\n我用的是一张 PCI-E 的网卡，占据一个 PCI-Ex1 的插槽，对于一个主流的 ATX 主板来说有三个选择，第二条 x16 长度的插槽上面的一个和下面的两个，由于显卡一般会插在第一条 x16 上，所以如果插在第二条 x16 长度上面的那个，会挡住一部分显卡风扇，所以我一开始插在了第二条 x16 长度插槽下面的第一个插槽。这样也是有点问题的，因为这里 x16 和 x1 之间并没有留出一个槽的空位，一般主流显卡又都是双槽的，虽然我不太可能在机箱里塞第二张显卡，但是这么装直接就干掉了这种可能性，所以我一直想把无线网卡换到最底下的插槽。（这一部分比较乱，但是装过机的直接上网搜一下主流 ATX 主板的图就能理解了。）\n之前由于最后一个插槽挡板的螺丝拧的太紧我一直没换，今天买了一个柄比较粗比较省力的螺丝刀，终于拧下来螺丝把网卡换了个插槽。开机之后 Linux 下其实没什么问题，检测设备和持久化命名都正常工作了。不过 Network Manager 似乎认为更换了设备，所以建议删掉之前的连接配置重新连接网络，之后一切都正常。\n切到 Windows 发现不太对劲，首先就是我的 WiFi 适配器名字变成了 WLAN 2，想要重命名成 WLAN 又提示已经存在，但是我又看不到，点进去发现它选择的无线网卡名字后面多了个 #2 的后缀，总之我也不知道怎么回事，但是强迫症觉得很难受，明明我只有一张网卡啊。\n上网搜索了一下，首先是在设备管理器的查看菜单里选择显示隐藏的设备，这样就会发现一个灰色的没有后缀的网卡，我也不知道 Windows 为什么要存一个隐藏的之前存在现在不存在的 PCI-E 设备，首先按照网上的说法卸载掉这个设备然后重启，再开机就没有这个隐藏设备了。\n接下来需要去掉 #2 的后缀，让 Windows 把这个插槽里的卡认成唯一的一张，这里就十分晦涩，Windows 试图隐藏起这个逻辑，但导致了一个令强迫症十分不爽的问题。我搜索了一下，发现了一个 百度文库文档 提供的方案。\n首先打开注册表编辑器定位到 HKEY_LOCAL_MACHINE\\SYSTEM\\_ControlSet001\\Control\\Network\\{4D36E972-E325-11CE-BFC1-08002BE10318}\\Descriptions 这一项，然后在右侧找到你无线网卡的名称，里面的值应该是 2，改成 1，然后卸载网卡设备，然后重启，应该就解决了，而且因为设备名字恢复了，WLAN 2 也应该变回 WLAN 了。\n所以其实这篇文章并没有什么逻辑和技术，但是 Windows 自作主张非要保存一个隐藏的不存在的设备，实在是令人头痛，又没什么明显的解决方式，所以也只好记下来以防再次被恶心到。\n更新：Arch Linux CN offtopic Telegram 群组里面叫 Give Way 的网友表示并不需要这么复杂，只要把两个无线网卡的设备都卸载掉重启就可以了。理论上确实是如此，但我没机会再试了，遇到相同问题的朋友可以试试。\n"},{"title":"让你们的主题商店离我远点！","url":"/posts/Taking-Your-Theme-Store-Away/","content":"我不觉得我是个刻薄的人，但对于一些实在令我讨厌的人，不把他们的名字挂出来就是我最后的怜悯。\n已经有不止一个人询问过我有关把博客主题移植到其他生成器上的问题了，这实际上是超出我范围的工作，因为我并不熟悉他们的生成器。但如果有人这样询问我（当然最好的方式是发 issue），我一般会先了解一下他说的生成器，然后解释是因为我没有时间或者我不熟悉目标技术栈。\n我还记得的最远范围是某个 Gridea 的用户发邮件问我怎么把我的主题用到他这个生成器上。某种程度上我觉得他要么是真的无知要么就是装成无知然后提一些在技术上很显而易见的问题来气人（我今天新学的词叫 troll），于是我去查了一下 Gridea，是个一条龙的基于 Vue 的图形化博客系统。对我足够熟悉的人可能知道我不喜欢把博客做成单页应用的样子，而且在我理解里博客生成器就应该是一个比较简单的命令行工具，而不是一个图形化数据库管理工具。尽管我不太喜欢这个项目和它的宣传方式，但万一对方真的是个并不了解这些的小白用户呢？我回复的语气可能并不是十分客气，但我还是解释了一下博客主题和生成器的关系，以及我不了解 Gridea 的技术栈也不是很喜欢这个项目，所以没办法写一个 收费/免费 的主题给这个项目。当然最后结果相对还不错，他也理解我给他的解释。\n但是我最近又收到一封标题为“诚邀合作事宜”的邮件，为了不破坏我第一句的承诺，我修改了部分名词：\n\n很喜欢你博客的风格，可否为Cxxxxx-CMS开发一套（免费/付费）主题，然后发布到Cxxxxx商店里面？\nCxxxxxJS：是一款顶级NodeJS全栈业务开发框架, 基于KoaJS + EggJS + VueJS + Framework7\nCxxxxx-CMS：是基于CxxxxxJS全栈业务开发框架开发的动静结合的CMS，可以快速构建企业网站、博客、社区、商城等Web应用\nCxxxxx商店：包括品类广泛的各类模块，满足不同场景的开发需求\nCxxxxxJS介绍：https://cxxxxx.com/zh-cn/articles/introduce.html\nCxxxxx-CMS介绍：https://cxxxxx.com/zh-cn/articles/cms-introduce.html\nCxxxxx商店运营模式：https://cxxxxx.com/zh-cn/articles/store.html\n\n我也不是个专业的前端工程师，现在不是，将来大概也不是前端“全栈”工程师，但我还是能懂这封邮件的内容。俗话说“文无第一，武无第二”，程序员的圈子里这句话应该也十分受用，所以我通读全文之后，目光就落在了“顶级”两个字上面。毕竟去掉套话之后，满足这类泛泛功能的项目也不少，所以还是先看看“顶级”比较有意思。\n在 Google 搜索这个 Cxxxxx 之后，还是有一些相关度比较高的内容的，出于个人习惯，我直接点开了他在 GitHub 上的仓库。既然是“顶级”框架，想必受益者众多，star 数量高达……108 个（截止昨日 18:00）。\n这就十分可笑了，虽然我也不是什么大牛，但是这个数量级 star 的仓库也有，虽然 star 不能说明代码质量，但是说明“顶级”这种人气形容词拿来正合适，如果我有这么个仓库，我肯定不会腆着脸在宣传语里加“顶级”二字。然后在大概阅读了一下文档和官网之后，我觉得就是个普通的 CMS，而且我也不认为静态博客生成器需要和 CMS 结合起来，我更希望静态博客生成器像编译器。而说到 JS 框架的话，既然都基于 KoaJS、EggJS、VueJS 了，我想不出还能在这些套娃外面再套什么娃（禁止套娃！）。\n于是我就回了一封有点嘲讽意味的邮件（我知道可能换个人不会说这种话，不过这种情况稍微嘲讽一下也没什么错吧）：\n\n前几天有个意思和你差不多的，但他没你这么能吹，他就是另一个博客生成器的普通用户。\n可能我这话有点刻薄，但假如我自己有一个 100 个 star 的项目，我不会在宣传语里用 顶级 这种形容词。\n写博客是我个人爱好，换句话说就是我也没打算靠这个玩意赚钱，就是自己用着爽然后开源出来有人喜欢随便用。我也没觉得自己技术强到可以靠这个吃饭了。\n既然是“诚邀”，那我觉得应该先看看我博客主题吧，不然容易让人觉得你是爬个 Hexo 主题作者列表然后群发邮件的哎。如果真看了就知道我这个主题的技术栈和你这个项目完全不搭。我的定位是静态博客生成器哎，我也不是很懂这种 CMS 或者 Koa Egg Vue，对这种快速批量构建网站没兴趣，毕竟个人博客嘛，随便折腾折腾才好玩。\n因此我建议你另请高明，或者真有兴趣，Fork 我的主题随便改。\n\n然后我就去忙别的去了，手头正好有个奇葩 Bug。我想这下大概清净了，没想到过一会又来了一封邮件。\n\n这就是一般大学生傲慢的地方了。如果是业务爱好者，我不好说什么。如果是一位志于软件开发的同行，就会一个静态博客生成器有什么可炫耀的呢？只怕你还真没有好好看看CxxxxxJS的文档了。\n\n这话说的很讨人厌哎。我脾气还算好，实在懒得一点点找出这话里有问题的地方反驳他，就随便回了一句：\n\n我再说一遍，我可没有给我自己的哪个项目加上“顶级”两个字\n\n然后他又来：\n\n你可以细细思考这个问题：我用顶级二字就把你伤到了吗？\n\nArch Linux CN 群里的群友建议我直接拉黑算了，我也这么想的，但是晚上回去闲的没事翻了翻，发现了其他的东西：\n\n好啊！你还敢说你是“诚邀”！还真被我说中了，明明就是爬了 Hexo 主题列表挨个作者群发！一开始邮件是我略有刻薄，不想再逐条反驳他，但看到这个我实在是 口区。\n首先对于这类“主题商店”，细细想想就知道不对劲，不仅仅这一个项目，还有别的没什么用户又跟你说邀请你驻入商店也是，他说他是诚邀作者设计一款主题放在他商店收费，可是主题作者真的拿到好处了吗？主题做的好，作者拿到的钱是用户掏的，而用户的使用习惯和黏性是落在他的项目上的！图里的有些主题我都看过，真的是非常优秀的作者，从作者角度想不得到任何好处地加入某一项目，也会希望这一项目的人气能给自己带来一些用户口碑，而这个项目很显然不满足！**好一招空手套白狼！**用户的钱三七分成，豪绅的钱……豪绅现在连钱都不用掏了，全靠一张嘴吹一吹？\n比如 Hexo、Jekyll、Hugo、Pelican 的主题页面（你愿意叫商店也行），都是作者自己主动提交的，为什么？因为这些广受好评的项目，可以为主题作者吸引人气，所以作者才会主动“合作”！\n其次说句不客气的，这类 CMS 项目说到底是拼工作量的东西，我也不是没写过。如果作者就是谦虚的说“我写了个 CMS，希望大家能给点意见使用一下”，我一个一般大学生哪里来的毛病非要嘲讽你？我最起码还得心里叨咕一句作者做这种项目坚持下来不容易，先支持一下劳动量再说。可是既然您把自己放在“顶级”的位子上，而且还看不起我，我也就不说什么客套话了。\n这种项目 V2EX 上一天能刷个十来个，有些就是很诚恳用来练手刷简历的，有些则是这种没有痛点制造痛点也要上的。我还再仔细搜了一遍这个项目，发现了好几篇什么掘金知乎甚至还真有 V2EX 上作者发的宣传帖，什么“动静结合”、“全栈开发利器”、“开发业务实战”，这种套话一套一套的。V2EX 帖子就 6 个回帖，有人表示不理解，本来 Hexo 是个静态博客生成器，为啥非得搞个动静结合解决痛点呢？然后作者复读机上身又复制了一下这类套话，换我我也不接着看了。类似我这种静态博客用户就是不想要“后台管理系统”才用静态博客生成器的，这个“痛点”我怎么理解呢？\n“一般大学生傲慢的地方”，这话说的真是让人恶心啊，一般大学生怎么了呢？一般大学生哪里傲慢了呢？一般大学生是不会在自己项目上用“顶级”二字做宣传的。“就会一个静态博客生成器有什么可炫耀的”，我倒是想问问这位同行（呕，请别叫我前端全栈工程师啊各位，我知道自己还不合格呢），您在哪里看到我炫耀啦？我也不在知乎掘金 V2EX 发宣传帖，我甚至还没来得及在我自己博客上介绍一下，我还经常说我这个有写地方写的还不够好，一边学习一边继续编写代码解决我自己的问题，请问您在哪听见我炫耀的？**莫非是我在隔壁宿舍和同学聊天吹水时候跟他吹了一下您听见了？如果真是这样，我还得告您搞窃听呢。**哦对了，我也不是什么就会一个静态博客生成器，我写生成器是为了解决我自己遇到的痛点的，这只是我涉及领域的一小部分，如果您真的仔细看了我的博客我的 GitHub，就知道我不是只搞前端，反而我看您 GitHub 都是 EggJS 的插件来着，真是令人眼花缭乱啊。\n什么你说让我看文档？文档有什么好看的，你想写什么就写什么，我也能把文档写出花来，那就能欺骗我自己项目本来没解决别人什么痛点嘛？\n让我细细思考？我看是您用了“顶级”二字，被我伤到了吧。都是写点胶水代码的玩意，我生成器还没用 Koa、Egg、Vue 这种框架呢，库是别人写的，框架逻辑都是我自己的，我 细细思考 一下真的很好奇在三大框架（甚至还有个企业级框架）上面能套出什么框架呢？如果你愿意一层一层一层的剥开我的心？本来我不想扯技术方面的，可是别的方面也没看到这个项目对主题作者有什么优势和好处啊！\n我要时时刻刻提醒我自己，我就是个一般大学生，不懂的东西还有很多，也不能学这种说话语气，不然会被人当成知乎上那种讨人厌的家长，就是自己本来也没什么见识还在年轻人面前的死要面子那种，年轻人说点什么就贬低他什么。\n我个人是不习惯在知乎掘金 V2EX 上写帖子骗眼球的，所以就写到我自己博客上了，想必“一般大学生”的博客，估计也没什么人看吧！如果您真的介意，还是那句话，“文无第一，武无第二”，评论区或者在您的博客上，放马过来，反驳我这篇文章，我不介意的。但是，我真的很厌恶往别人仓库刷这种“诚邀合作事宜”的 issue，issue 是用来说代码相关问题的也算是程序员的自我修养之一了吧。我写这个也是希望万一有主题作者看到了别上当了，这不是“诚邀”，是“白嫖”作者人气。\n"},{"title":"装机小记","url":"/posts/My-Desktop-PC/","content":"由于笔记本散热和性能实在是不适合打游戏（有一说一，Optimus 双显卡还是没有直接单卡来得爽），所以很早就想组装一台台式机。特别是最近一直和高中同桌玩 Dota2，我的笔记本如果直播 Dota2，直播推流就会十分卡顿，而我又不想像 CSGO 一样降低画质玩。\n作为计算机专业的学生，当然不打算直接淘宝整机啦。经过一番挑选和参考别人意见，最后成套配置是下面这样：\n\nCPU：AMD Ryzen 7 5800X 3199（之前是 AMD Ryzen 5 3600X）\n主板：ROG STRIX X570-E GAMING 1999（特价）（之前是 微星 X570-A PRO 套装 2349）\n显卡：NVIDIA GeForce RTX 2070 SUPER 4300（之前是 七彩虹 RTX 2060 战斧 6G 2199）\n显卡支架：酷冷至尊 显卡支撑架 70\n内存：铂胜 C9BJZ 颗粒 白马甲 DDR4 3000 8G x4 1000\n网卡：Intel AX200（主板内置，之前是单独购买的花了 150）\n固态硬盘：三星 970 EVO Plus 512G 769\n固态硬盘：英睿达 MX500 2TB 1299\n机械硬盘：东芝 P300 2TB 400\n机械硬盘：西数 紫盘 2TB 400\n电源：海韵 CORE GX650 650W 金牌 549（特价）\nCPU 散热：利民 AK120 145（之前是 超频三 东海x5 89）\nM.2 散热：主板自带（之前是利民 M.2 硬盘散热片 39）\n机箱：Corsair 4000D 白色 629 （之前是 先马 鲁班1 209）\n机箱风扇：利民 TL-C12R-S x2 + 利民 TL-C12S 418（TL-C12R-S 只有 299 的三联包）（之前是 Arctic F12 PWM 温控 x3 120）\n\n总计 15177。（有些具体价格我记不清了，而且京东价格总是波动。）性价比一般，因为我在其它人觉得没必要的地方分了很多预算比如主板风扇和硬盘，选 B450M 和西数的 NVMe 以及随便买个不带温控但是带灯的风扇的大有人在。\n选这样一套配置其实有原因，可能很多人觉得为什么要给 R5 配 X570 这种高端主板，不如换成 B450 然后把预算加到别的上面。但按照我朋友的使用经验，4 代和以前的 AMD 芯片组存在 USB 兼容性问题，而且我装好机器后就在 Arch Linux CN 群里看到有人遇到这种问题，5 代似乎重新设计了 USB 控制器，目前一切正常。虽然这大概是最丐的 X570，连前面板 USB C 的接口都不支持，但是带 3900X 以下的 CPU 还是没什么压力的，而且我的机箱也没前面板 USB C。唯一的问题是微星不太厚道，今年 B550 出来之后又搞了个 X570 Tomahawk，大概只贵了几百块但是却有比肩旗舰的供电能力和前面板 USB C 支持，所以不再推荐购买 X570-A PRO 和 X570 Gaming Edge 这两个低端款了。有这种好东西为什么不一开始就吐出来？？？\n\n更新（2020 年 11 月 16 日）：AMD 发布了 Zen 3 系列的 CPU，游戏性能全面反超 Intel，对于我这种玩的都是吃 CPU 的游戏的用户来说实在是太香了，而且我一直想要 8 核心的 R7，于是买了首发的 R7-5800X，果然性能提升明显。然后正好有朋友的朋友打算装机买我换下来的 CPU 和主板，于是双十一为了 更好的 RGB 效果 更好的供电支持换了 ROG 的 X570-E（东哥疯狂耍猴，本来我是想 Tomahawk 的，但是微星的几款真香主板都无货，而且我对微星土里土气的设计也审美疲劳了，1999 的打人国度带眼睛它不香吗）。\n\n如果仅仅是打游戏也可以把 R5-3600X + X570 换成 i5-9600KF + Z390，大概可以便宜一点然后升级一下显卡？我觉得差价是不够 2060 换 2060S 的。或者如果要求不高换成 R5-3600 也行。但 R5-3600(X) 是 12 线程而 i5-9600KF 只有 6 线程，所以我还是选择了 Ryzen，反正 AMD CPU 最近表现都很不错，没必要给 Intel 掏更多的钱。如果你在京东买，建议自己翻翻店铺，一般买主板 CPU 套装更便宜，但是它不会摆出来……\n显卡一开始我是打算买个 1999 的 GTX 1660Ti 的，反正我玩的游戏也不需要光线追踪（难道真相不是本来就没几个游戏支持光线追踪嘛？），然而看到七彩虹最便宜的 2060 正在特价 2199，果断少买一条内存，加钱上这个。反正 1999 的 1660Ti 也是便宜货，而且看了一下视频似乎这块卡也不是丐中丐中丐，质量还可以（同价位索泰铭瑄也不能说是什么高端货吧？），反正内存可以再插新的，而显卡二手不值钱。什么你说 A 卡？虽然 A 卡对于 Linux 的驱动支持更好一点，但是 N 卡也不是不能用，而且我要开直播的话 NVENC 编码挺香的，而且对我的 Steam 库存来说 N 卡也更友好一点（但是骂老黄还是要骂的）。另外虽然我不做机器学习，但万一室友毕设需要的话，反正我上班也不用台式机，装个 CUDA 让他 SSH 上去用岂不是你好我好大家好。\n内存其实没什么特别要求，不过由于 Ryzen 的设计，频率越高越好，但按照这一代的设计，最佳频率是 3600 左右，再高反而会下降，我是买不起那么奢侈的内存，甚至 3200 的都买不起，但这款 3000 的也不错，据说颗粒是镁光创下超频记录的那一批，所以就买了四条。我也不需要 RGB，这款的马甲还挺好看的。当然 3000 是 XMP 频率，需要在 BIOS 里打开 XMP，不然默认只有 2400。最近抄了个 3600 16-19-19-36 的作业，结果四条内存轻轻松松就上去了，2K 下面 PUBG 大概提升了 20 FPS，还是挺爽的。\n硬盘不多说了，买 MX500 还是看中一个稳，我还是不放心买同价位国产白片……而且作为一个 2016 年就在笔记本上使用 NVMe SSD 的人，表示并没感觉出 NVME 和 SATA 有什么使用差别……我也不渲染视频，写写代码打打游戏都不卡。但是后来打脸了，公司发了新年福利于是还是上了个 NVMe 硬盘。顺便还配了个无线网卡用来接蓝牙和 WiFi，似乎 Intel AX200 是对 Linux 支持比较好的，就随便买了个 PCI-E 的插上了（御三家集齐了！）（ATX 的好处终于用上了，多出来的 PCI-E 随便插）。\n电源有点买亏了，买完了发现长城同系列 550W 的电源当时也是 329，都怪京东迷一样的定价策略，好在我这一套功耗并不是很高，当然最后趁着福利 + 打折还是换成了海韵一元一瓦的金牌全模组，带我这套绰绰有余。开始我听说原装散热器也能压住 3600X，所以就没打算换，但后来发现刀法还是精准啊，3600X 带的散热器竟然不是铜芯的，再加上 Ryzen 三代的电压控制比较激进，待机温度有时候超过 50，打游戏时候机箱上方有点烤腿，所以还是换了个塔式散热器，风道科学了不少，温度控制也更好了。而且 AMD 原装风扇转速太高，3000rpm 的时候机箱都在震，换了之后安静了很多。顺便安原装散热器时候拆下来的螺丝卡扣不要扔，万一换塔式散热器，好多都是需要装在这个卡扣上的。\n说到机箱和风扇我就一肚子气！本着对京东自营品质和速度的信赖，我全套都是在京东买的，结果拿到手全部安装上之后发现机箱开机跳线是短路的，插上电源就开机，开一会因为短路主板以为你在长按电源又关了！开始我还以为是主板坏了，结果发现螺丝刀手动碰一下开机跳线开关是可以正常开机的！于是又费了九牛二虎之力拆下来退货，主板散热器显卡都在盒子里椅子上放了两天，还好新机箱没问题，但是我买风扇的时候又给我发了个断轴的！东哥呀东哥，我拿你当兄弟，你拿我当代价？你是盯上我了？虽然我平时说你两句坏话但是对京东的服务还是好评的，但是经过这次之后我还是得重新考虑考虑了……顺便据说启航者 S5 这个机箱前面板音频口有的是 HD Audio 有的是老版 AC97，涉嫌虚假宣传，如果你遇到 AC97 的大概可以换货，麻烦一点，不过反正便宜货就这样子，我的抽奖抽到 HD Audio 了。但是这个机箱设计的很抠门，比如主板装上了就没法在上面走 CPU 电源线了，有两个走线口直接被 ATX 主板盖住，所以主板和显卡的电源线只能从一个口里挤出去，然后固态硬盘就在这个口下面所以也很难接线……而且机箱侧板是个黑色半透明的亚克力（那就别宣传透明啊！），金属外壳感觉也不是很厚实。所以最后还是换了先马的鲁班 1，各种设计都宽松了许多，装起来也很好看。\n其他的外设我自己都有，显示器 就是之前买的优派 XG2402，1080p@144Hz 并且自带扬声器 换成了 DELL S2721DGF，27 寸的 2K@165Hz 屏幕，除了通病漏光以外都还不错，音箱外接了一个 JBL 的蜗牛一代（因为我觉得二代没有一代好看），鼠标就卓威 EC1-B CSGO 特别版（这个版本已经停产了，而且有偶尔指天/指地的 bug，建议买新的 EC1/2 或者 DIVINA 版本），键盘则是前段时间买的 ikbc C87 红轴，便宜还好用。\n顺便由于我手残以及力气小和室友跃跃欲试，很多东西都是他装的，非常感谢。话说回来装这东西还真是个力气活，毕竟接口都有防呆设计仔细看看不会装错，但是真的很紧很难拔……非常担心把主板搞坏了。\n\n更新：\n\n\n更新：避免你们说我灵魂走线，重新整理了一下，线太硬了。\n\n\n\n更新：内存插满。\n\n\n更新：NVMe + 蓝牙无线网卡的完全体。\n\n这个机箱 CPU 线走上面是要把主板拿下来才能穿过去的，而且右边两个有硅胶垫的孔 ATX 完全不能用，只能用一个孔，而且不能把线固定在机箱中间，很难盖上。\n\n再更新：新机箱比原来的好看多了也宽敞多了。\n\n\n公司的蜥蜴（明明是变色龙！）玩偶太高了，显卡下面放不开，挂着我又不放心，盖盖子之前还是拿出来了。\n\n更新：换上了公版 2070 SUPER 和利民 AK120。\n\n\n\n更新（2020 年 10 月 12 日）：冬天到了，还是换了几个支持 ARGB 的风扇，我个人不喜欢蓝光紫光夜店土嗨风，所以就弄了点温暖的颜色假装是个电暖气。前面是利民 TL-C12R-S，后面是利民 TL-C12S，虽然是三联包，但是似乎螺丝有问题，有一个风扇螺纹被拧花了，而且后面的风扇竟然少一个角上的橡胶减震垫，于是我就没装拧花的那个，并且把它的减震垫安装到后面了。反正考虑到我最下面是机械硬盘，装上这个风扇风道也不畅通，而且那里并没有什么需要散热的设备，电源风道是独立的。每把风扇两根线，ARGB 线要串联，PWM 线要一分三，而且有前有后，风扇线还有编织保护套，还要防止线材打到风扇扇叶。理线花了好久，最后 把他们用扎带固定到下面和前面空的风扇挂架上 还是走背线了，刚好够长。而且我还有三个 SATA 硬盘打算换一下 SATA 线……我真的想不出来那些水冷排且上下左右全都是 ARGB 风扇的人怎么理线的。\n\n\n\n\n我不会告诉你其实我是想调出我 TB 至宝的颜色：\n\n一开始我以为需要用 OpenRGB 这个项目才能在 Linux 下控制颜色，但是这种 ARGB 风扇好像有存储机制，会自动记住上次的设置。于是就在 Windows 下面安装了一个有一大堆乱七八糟组件和功能的 MSI Dragon Center，其实我只需要调成长亮，然后重启进 Linux 颜色就一直是我设置的，然后我想关掉 Dragon Center 的自启动因为反正也用不到，但是微星的软件自己拉跨，重启进 Linux 灯光不变，重启进 Windows 又变呼吸彩虹灯光了。最后发现 Dragon Center 里面有一个类似“覆盖第三方RGB软件”的选项……好像他把他自己上次存储的结果也当第三方软件了，关掉就好了。原理我猜因为 Dragon Center 只是个 Client，真正控制颜色的是他某个 SDK 里面的 Daemon，这个选项的意思其实就是 Daemon 每次启动都按照 Dragon Center 设置的颜色重新设置风扇就实现覆盖功能了……但是 Dragon Center 的启动被我关了所以就默认了，不管了，统统关掉就 OK。\n\n更新（2020 年 11 月 16 日）：有了打人国度的眼睛感觉瞬间高贵了起来（大误）。\n老黄卡只有绿色一种颜色，于是只能整个机箱调个老黄绿来配合。\n\n但是我自己平时还是喜欢红色的。\n\n我发现主板的装饰灯正好在公版 SUPER 卡上产生倒影，很有意思。\n\n这个主板可以完美使用 OpenRGB，在 AUR 里安装之后把 /usr/lib/udev/rules.d/60-openrgb.rules 复制到 /etc/udev/rules.d 重启即可支持，不过对于 Addressable Header 上的设备也就是风扇或者灯条需要手动 resize zone 也就是填写灯珠个数，我用的利民 TL-C12S 系列经过尝试是 8 个灯珠。\n\n更新（2020 年 12 月 1 日）：我之前有考虑过换个机箱，倒不是先马鲁班不符合我的需求了，而是一些细节问题，比如我这个玻璃侧板不那么平，有一个角翘起来一点。然后我的前面板角落里虽然我十分注意还是被我磕了一下。以及当初从宿舍搬出来的时候没有把机箱包装拿出来，以后搬家可能不方便……我一开始本来打算再买一个同款然后把侧板和前面板换了得了，然后那天发现前面 USB 2.0 接口接声卡会破音……于是打算换一个别的机箱。\n我个人其实比较喜欢白色的机箱，于是初步考察了一下，我很喜欢NZXT H1，但我又不可能装 ITX，然后 NZXT H710 看起来不错，我觉得很漂亮，而且我在 NGA 上看到过有人给它纯白色的前面板贴了个黑色的两仪式剪影贴纸很帅气，但是太大太贵了。为什么没选择 H510 呢，因为我有几个硬性需求，我日常使用总结出来的，是一般机箱评测和用户不会注意到的，比如最好不要显卡竖装（我个人肯定是不会竖装的），PCIE的螺丝锁孔也不要做成从机箱尾部凸出来的，其实这里的实际意思是机箱不要有太大的空洞，很多机箱的显卡竖装PCIE螺丝孔上面就那么开着口，凸出来的横着的PCIE螺丝孔虽然有一个覆盖物，但它总是不那么好用，于是这样排除掉了 NZXT H510， NZXT H510i 和 NZXT H710i。\n然后我也不想要钢化玻璃打孔固定的，这个大部分人应该也都不想要。我个人而言还不想要附带一个只有某些软件才能控制的控制器的机箱（点名 NZXT CAM），虽然 NZXT 的水冷确实非常漂亮，但我不放心把电脑这些基础的硬件交给一个需要联网还经常不一定能不能连得上而且只有进了 Windows 才能启动的废物控制软件——我是 Linux 用户。同理我也讨厌雷蛇和罗技难用的鼠标驱动程序。明明我可以用 BIOS 控制风扇/水泵转速，用主板的 ARGB 插针控制风扇，为什么非要用那么难用的软件？所以肯定只考虑 H710 不考虑 H710i。\n如果光是这些可能我就下单 H710 了，虽然贵一点，但是能满足需求也就不在乎了。可是我翻了翻各种帖子都提到了这个机箱的一个缺点：它的硬盘架需要用螺丝刀拧螺丝才能拆除。虽然现在两百块钱的机箱都能免工具调整硬盘仓了但我觉得理完线也没什么人总动机械硬盘所以这不是个问题，但是更严重的是许多人都说固定硬盘仓的螺丝非常难拧，甚至螺丝都花了也拧不下来。作为之前被笔记本内部拧花了的螺丝支配的恐惧的人，我可不要买个这样的样子货，于是只好放弃 NZXT。可能它的设计师平时是不用机械硬盘没有软件更新服务器连不上的问题也不在乎机箱开一大堆没有保护网的口的人吧！\n然后我又开始研究买什么，看了一下联力的鬼斧似乎很不错，虽然造型上比起 NZXT 要难看很多，但是和其它的机箱比起来也算是鹤立鸡群了。而且相比于 NZXT 只是好看来说，它在设计上就考虑到了更多的功能性的细节。比如有单独的重启键（可怜的硬盘灯已经被时代抛弃了）和 LED 控制键而且 LED 控制器可以设置为转发主板的 ARGB 信号（看看人家！），还有磁吸的合页式的玻璃侧板方便随时打开（对我这种强迫症太友好了，拧螺丝搬侧板好累），而且它的电源仓设置了单独的合页门！你可以从前面打开，然后硬盘仓设计成了 NAS 那种可以从前面拿出来的样式（甚至官方还有热插拔配件，彻底变身 NAS）（看看人家！），对折腾型用户不要太友好。但我最后还是没选它，因为我发现这个机箱第一个 PCIE 挡板和它上面的边框之间的缝隙太太太太太大了——你们厂家在这里多加两毫米宽度会赔本吗？而且后来我又想了想，合页式多半不能完全贴合（要给合页留出空间，所以还是算了）。\n然后我看到了海盗船新出的 4000D，纯白色的前面板 + 非外凸的 PCIE 螺丝孔 + 独立的重启键 + 滑动抽拉的硬盘仓 + 有卡口的侧板，虽然它只有一个前置 USB 3.0 和一个 Type-C，不过也不是不能接受，而且有独立的重启键（硬盘灯：四个人的接头我却不能拥有姓名）。虽然它也支持显卡竖装，但它显卡竖装的上面有一个封得比较严实的挡板，它的 PCIE 槽相对也没有太大缝隙（相对）。颜值虽然没有 NZXT H710 好看，但在简洁上也是吊打鬼斧了，于是决定入手这个。\n到手了也发现这个箱子还不算完美，主要的问题在于以下几个：电源仓上边的挡板强度不够，虽然没什么人会拿这个地方承重，但是比我之前的先马鲁班软得多也太差劲了吧！好在机箱其它承重的组件都过得去。然后就是 PCIE 的螺丝孔和显卡的孔有错位！需要很大力气按着才能勉强拧上螺丝，我用的可是公版显卡，你不能说公版卡孔位不标准吧！最后最后，这个机箱底板并没有多延伸出一块盖住侧板的底部！虽然侧板不需要这个位置辅助固定（它有很多卡口固定住），但是明明你顶板是有延伸的，为什么底板要在这里留一条缝隙？？？总而言之就是搬机箱时候要注意手不要抠到侧板和机箱这里的小缝隙（挺窄的，手多往中间伸一点就好了），但是还是会让人担心抠这里把侧板抠变形了。不过总之看下去也没有更合适的选择了，所以就将就吧。送的两个风扇是 3 pin 的，不能 PWM 调速，真是抠门啊海盗船。\n顺便还有个对我不是问题但对大部分人可能是问题的地方，这个机器的前面板里面有一大块金属防尘网，但是根据一些国外的评测，假如你在前面板装上风扇，可能会因为风扇吸气导致这个防尘网向里面贴而蹭到风扇扇叶。但是我装了三个反向进风的风扇在前边，风扇框架在防尘网一侧所以并不担心这个问题。我也推荐所有前面板不是透明玻璃的人在前面使用反向风扇，毕竟正常来说通过侧板看到机箱内部反向风扇的灯光才是最合适的（风扇框架不会出现在机箱内部）。并且说实话，前面板通风比美观更重要，真的没必要买玻璃前面板。\n于是装好了以后正面和背面就是这样的，不要和我说机箱不是留了理线槽了吗为什么不把主板电源线理进去，海韵的主板电源线又粗又硬，连用这块机箱的盖线板盖住都很勉强了，扭来扭去塞到那个理线槽里根本不可能，我也不想让它接头的部分受太大的力。这块主板 Type-C 的接头在内存附近，虽然这是主流位置，但对于直插的 Type-C 线缆来说弯线同样也很困难，别的理线倒没遇到什么大问题。\n\n\n白色机箱里面反光要亮一些，但是有灰尘也更明显了……\n\n前面侧板的灯光效果很好看，没买 Airflow 版一个是因为它挖孔太多太乱，另一个原因是以后我也打算贴上贴纸。\n\n今天看到 NGA 上有人是风扇白光配红色 ROG LOGO，我也试了一下蛮好看的，就是感觉屋子里又冷了 10 度……\n\n\n\n接下来是喜闻乐见的 debug 时间，首先是 Linux 下的，相对比较好调：\n三代 Ryzen 有一个 每次都返回 0xFFFFFFFF 作为随机数的 BUG，在我这主要影响 wireguard，巧的是购买前几天我刚读过这篇文章，AMD 已经发了新的固件修正错误，建议更新到主板厂家提供的最新 BIOS 版本一般即可解决，如果主板厂家最新的 BIOS 还没更新固件建议联系售后催一下。\n在我这不知道为什么 GDM 有时候没法自动启动，但是手动切 tty start 又可以显示，查了一下 ArchWiki 的 GDM 页面 发现有解决方案，但是并没有原因，搜索了一下也没发现原因是什么。\n我还发现有时候刚开机没多久很快就关机会卡在什么 systemd-udevd 进程没结束，最后 event loop failed + timed out，大概要卡好几分钟才关机，但如果你用一会再关机就没问题。检查好几次关机日志没发现问题，后来群友火眼金睛对比了完整日志发现有个叫 ucsi_ccg 的模块开机加载了两分钟，猜测是这个的问题，搜索一下发现是 5.3 内核里 NVIDIA 添加的相关代码，用来控制 NVIDIA 显卡上的 Type-C 接口的，可是我这块显卡根本就没提供 Type-C 接口！（以及很多笔记本内置的有输出的 N 卡也有这个问题。）临时在 /etc/modprobe.d/ 里加了个 blacklist ucsi_ccg 的 conf 屏蔽了这个，好像没什么不良影响……\n然后是奇怪的 Windows 的问题，我一开始装的 LTSC，不知道怎么回事输入法没了……折腾无果只能重装。以及现在除了不要联网装 Windows 之外（否则会强制你登录微软账户然后用你名字拼音前五位做用户名），还得不要联网装 NVIDIA 驱动，否则 Win10 自动更新驱动会给你安装 DCH 版的，虽然没什么影响（只是在 NVIDIA 官网升级驱动时候不能选标准选 DCH），但是就是让人很不爽。声卡驱动要装主板厂商的，Win10 自带的只能输出，不能接麦克风录音，然后如果麦克风声音很小就打开 Realtek 的声音控制程序，在右上角齿轮里取消掉什么把所有输入结合到一起的设定（什么乱七八糟的玩意！）。还有要关掉快速启动，不然直接开机会卡在黑屏一个鼠标光标……反正就很烦人。\n更新：还有一个奇怪的问题是 Win10 关机重启也要卡很久，上网搜了一下全是一些忽悠小白的办法，经过我不懈搜索发现了一个熟悉的名字 UCSI！微软承认存在 UCSI 问题，既然又是这个 UCSI，多半还是 NVIDIA Type-C 的问题！反正我没这个接口，果断重新安装 N 卡驱动，选择清洁安装（删除旧驱动）并不勾选 Type-C Driver，问题解决……\n最后既然设备到位了，大概就每周一三四五晚上八点半在 Bilibili 4312991 直播间 播一个半小时游戏，反正我玩什么播什么，大概就 CSGO Dota2 PUBG 什么的吧，尼尔也有可能，反正不是恶心反胃的就可以。周末随缘直播，如果没什么事情白天就播一会，周二可能晚一点开播因为有课。\n"},{"title":"X 和 Wayland 的主要区别","url":"/posts/Difference-between-X-and-Wayland/","content":"最近在写一个 Wayland Compositor，虽然我以前大概知道这是什么，但是并不是很清楚它和 Xserver 有什么区别，虽然 fc 老师的这篇文章 写的相当不错，但我一点也不懂 X 所以看的迷迷糊糊。偶然读了 这篇文章 发现十分不错，但因为是英文文章读起来很累，打算把一些理解的内容记下来。顺便说一下，原文是带示例的，效果非常不错，建议有时间的人慢慢看一遍。\n我相信很多人在搜索 Wayland 的时候都看过这两张著名的图片：\n\n\n但我觉得可能看了也不明白，怎么把 Xserver 砍掉就提高性能了？为什么没有见到 window manager？为什么桌面环境需要去支持 Wayland？\nWikipedia 上还有一些奇奇怪怪的图片比如这种：\n\n在加上所有介绍 Wayland 的文章都会扯上 DRM，EGL，GLES 之类的，如果不懂这些就完全绕进去了，比如我就看到过有人问 Wayland 用 OpenGL ES 那用 OpenGL 的游戏是不是就跑不了了之类的。\n这一切都得从头说起。\nX 协议设计于 1980 年代，那时候窗口界面刚刚起步，人们还没什么 3D 特效一类的想法，而且机能也不允许，放到今天任何一个有点桌面基础的人都能理解应该给每个窗口一个 buffer 然后把这些 buffer 里的 texture 贴出来的设计逻辑，但 那时候可没那么多内存让你给每个窗口一个 buffer。所以就如同各种常见的领域一样，有个小天才一拍脑子想，反正 最后显示到桌面上的都是一个屏幕大小，我们只要准备这么大一块内存就行了嘛！换句话说，每个窗口的 buffer 要去掉自己被覆盖住的内存，整体内存大小就是可控的，在内存里是直接没有被覆盖的部分的。\n不得不说这位的思路在当时还是很有意义的，加上窗口大小都是矩形的假设，计算实际显示区域并不难，于是 Xserver 就是这样设计的。它维护一个屏幕大小的 buffer 自己计算窗口的实际位置，发生了变化（比如一个窗口盖住另一个），就给变化的窗口发一个重绘信号，这个窗口再发出绘图申请，直接绘制到 Xserver 的 buffer 里。由于 Xserver 知道所有窗口的位置，它就可以重定向鼠标键盘输入过去。然后具体的窗口移动缩放交给 window manager，它再把改过的位置回报给 Xserver。\n一切事情都变得很美好，直到某天一个人提出一个问题：我想要半透明的终端！\n程序员是不屑于解决提出问题的人的，如果用现代程序员的思维，做一下 alpha 混合其实很简单，只要 Xserver 能读取上层窗口的颜色和下层窗口的颜色就行了，可是你还记得当年的小天才吗？Xserver 说不太好意思，对于 一个像素我内存里只有一层，就是最上面显示的那个窗口……并且积习难改，我们不能改这个 feature……你想上下混合？死宅不要整天做梦，这是老祖宗留下来的不能改啊。\n如果你用过没开启 混成 Compositing 的 XFCE 和半透明的 XFCE-Terminal，你会发现它是可以透出窗口下面的壁纸的，先别着急喊我骗子，你在终端下面放一个别的窗口试试？神奇的现象发生了，终端显示的还是壁纸……\n上有政策下有对策嘛，一些终端作者想到反正壁纸总是固定的，我读取壁纸图片，取出终端所在的位置，然后在我窗口做个 alpha 混合不就行了？但是人不能自己骗自己啊，这是个客户端的假透明，我们要学习 GNOME 开发者的思维：不够好就砍掉。（大雾\nX 一看不行啊，那我们改一下协议吧，加一个叫 混成器 compositor 的东西，如果你写了这个东西，X 就不是直接更新他自己的内存 buffer 了，而是交给混成器。混成器可以获取一系列窗口的内容，让它们都画到不同的 buffer 里面（内存终于够大了啊！），再把它们处理了丢到屏幕上去。Xserver 只是要求 compositor 最后返回一整个屏幕（通常是，也可以小一点）然后给这个直接贴到最上面去（可以简单理解为混成器画好一整张就行了，别的都不管了）。\n理论上来说，混成器想怎么放这些窗口就怎么放这些窗口，就算想把他们丢到屏幕外边都行，反正你最后返回一个图就行了，Xserver 也不管是不是涩图。\n但是有个尴尬的事情，输出给你管了，输入你管不着啊！之前说了 Xserver 自己按照自己存的窗口位置分配输入，你把窗口挪走了，Xserver 可还是按照自己记录的窗口位置分配输入的，如果你想自己处理窗口位置分配输入，好嘛，Xserver 里面的代码你再复制到你混成器里一份吧！\n所以实际上 compositor 里面怎么做的呢？我最后处理完的窗口，位置和 Xserver 记录的位置还得一样，然后 Xserver 给我那个返回整张屏幕的顶层窗口，我就不接收输入了，这样直接透过顶层点到下面，就和窗口位置对应了（什么乱七八糟的破玩意啊！）。如果你要做个动画呢？你做动画的时候，Xserver 那边记录的位置可不跟你变（要是变，按照 X 的设计你这数据得跑好几个来回），你的鼠标点击在这时候是不准的，传不到窗口里面。\n这些 compositor 都是各个桌面环境做的，他们一般都集成到自己的 window manager 里了。\n而且随着时间发展，越来越多的 Xserver 和 client 是跑在同一个机器上的，很多 client 想自己利用显卡处理图形，就有人在协议上打洞，Xserver 做的越来越少了，更多的事情 client 自己都做了。\n你还想再往 X 协议上糊一层吗？别糊了兄弟，你这也太挫了！一开始 X 的设计是提供机制而非策略，Xserver 自己是符合了（然后除了 Xorg 其他实现都死了），和 Xserver 配合的可是被他绑的死死的比如 compositor，性能翻了几倍还得拉着这些历史垃圾跑，Xserver 终于活成了自己最讨厌的样子。\nWayland 做的事情很简单，反正 compositor 都做这么多了，那直接把 Xserver 的功能也丢给 compositor 吧！Compositor 下层接的是 DRM 控制渲染，libinput 控制输入，GBM/EGLStreams 控制内存管理，上层更简单了，每个窗口丢给我一个 texture，我负责安排你们这些 texture 放在哪里，然后我按照我安排的位置，告诉你们鼠标进谁窗口了键盘进谁窗口了。然后因为要把这些 texture 混合到屏幕上，一般都用 OpenGL 的硬件加速，但在 Linux 下创建 OpenGL Context 的 GLX 库是和 X 绑定的，所以一般大家都用 OpenGL ES Context 的库 EGL。这个库只干扰最后合成图片时候的事情，至于你的 texture 用的是 OpenGL 还是 OpenGL ES 还是 CPU 画的都没有关系，现在 Wayland 一概不管了，你自己客户端处理自己的内容吧，我又不是 Xserver！\n所以对于 GNOME 和 KDE 等等而言，现在他们打交道的一个是内核一个是窗口，而不是以前一样和 Xserver 打交道了，省了数据交换，同时自己可以自由控制窗口输入输出面积。Xorg 应该被淘汰不是因为功能性原因，而是因为它的设计在当时很聪明，但现在只是历史包袱，实在太挫了。\n看完这篇文章建议回去阅读开头的两个链接，可以了解更多的实现细节，现在再看就不会那么混乱了。\n"},{"title":"蜗牛星际安装 Arch Linux 作为 NAS","url":"/posts/SnailSpace-as-NAS/","content":"需求\n以前我一直使用树莓派 + 移动硬盘做 Samba 服务器，好处是完全静音，功耗很低，但是树莓派 2 的网络性能差强人意，并且 USB 2.0 的速度也赶不上移动硬盘的速度，看电影什么的只是能看，要花好长时间缓冲，并且我一直开着，对树莓派的电源也不是很放心。特别是我还拆下来一块笔记本上的 1TB 机械硬盘，完全没法装在树莓派上。\n有点想买一台 NAS，但是看看群晖的价格实在接受不能，然后又看到说群晖把系统放在用户的磁盘里加速读写一类的，其实这些对我来说不太重要，但群晖是个定制化程度很高的 Linux，虽然用起来有很多便利的功能，比如官方的内网穿透（服务器似乎在台湾，速度不快），比较成熟的 btrfs 使用之类的，但比起这些我更想要一台全功能的 Linux 服务器，这样不仅可以作为存储，还可以 SSH 连接上去做一些简单工作。作为一个比较熟练的 Linux 用户，我不需要什么 Webui 或者 App 来访问文件，Solid Explorer 和 VLC 组合起来访问 Samba 好用得很。\n机缘巧合我看到隔壁的同学弄了台 D 款的蜗牛星际矿渣，研究一下之后觉得可以上车，正好 Arch CN 群里 @NickCao 打算出掉买了回来没时间玩的蜗牛 A 款，于是坐地铁到五道口一起喝了杯咖啡然后当面交易了一下。\n介绍\n蜗牛星际的矿渣是怎么来的呢？简单说就是忽悠别人说这个机器可以挖矿，打着区块链旗号骗人买机器，当然这个机器其实并不需要挖矿，所谓的挖矿就是简单的 App，让你以为自己挖出矿了（毕竟宣传说不用插电也能挖），为了降低成本机器全部都是用现成的山寨货，所以机箱造型是 NAS，而且还带硬盘背板，就是因为这个很成熟。成本不到一千块的机器五千块一台卖给受骗者，受骗者为了挖矿都是十几台几十台的买，然后放小道消息说要涨价到八千块榨干受骗者钱包，最后国际惯例圈钱跑路，受骗者血本无归。\n做人还是得懂点计算机。这个机器有好多型号，细节差距也有很多，网上有现成的帖子分析，我就不分析了，反正我这一台是 A 款单网口绿主板，妥妥的千兆。而且似乎背板有 PCI 槽，不过主板又没有 PCI 接口就不管了。A 款还有个特点是硬盘架侧面可以放个 2.5 寸硬盘，正好装上我拆下来的笔记本硬盘。\n硬件处理\n更换电源\n手头这一台运气比较好，是 zumax 的 150W 电源，虽然都说这个是所有各种电源里最好的，但是接口只是刚刚够，没有多余的，没办法给侧面硬盘供电。再说都是山寨货谁又比谁强到哪里。按照隔壁老司机的推荐淘宝买了益衡 250W 的 Flex 电源（也叫小 1u 电源）替换掉，花了 250 软妹币左右，感觉车速稳了很多，而且多出了许多接口。安装时候需要把原来电源上后面用来装到侧面的铁片拆掉，然后上下翻转才能装上蜗牛里面的支撑片，然后主板有一长一方两个供电口都插上，背板用的大 4pin 接口，不太够长，把卖家赠送的两条大 4pin 延长线都接上就可以了，然后取一个 SATA 供电线给侧面的硬盘。\n更换风扇\n蜗牛自带的风扇声音很大，因此需要更换一个静音风扇，A 款用的是 8cm 的 3pin 风扇，不支持调速，BCD 都是 12cm 的。8cm 不太好买，开始我在京东找到两个超频三的风扇，一个 2200 转一个 1400 转，标定噪音都是 18dB，于是就买了 2200 转，没想到转速上来了噪音也更大，正好之前在淘宝买了另一个超频三皓月（下面上图），1500 转应该问题不大，装上发现声音小了，但是蜗牛背板有个铆钉，这个 LED 风扇尺寸稍微大一点，正好顶着铆钉。没办法还是换了京东 1400 转的那款，噪音合适，尺寸也合适，蜗牛本来发热也不严重。因此 建议大家购买 1500 转左右不带 LED 灯效正常尺寸的。\n更换时候记得把原来蜗牛风扇上用来防止线缆伸进风扇里的塑料防护片拧下来装到新风扇没有阻拦的一侧，这一侧朝里，安装之后蜗牛向外吹风。这个防护片不要拧太紧，以免把它拧裂了，这种是和机箱配套卖的，淘宝上有卖那种金属的防护网，但是都是厂家批发，不太好买。你问我怎么知道的？当然是大力出悲剧拧裂了然后没办法只能 502 直接粘风扇边上了。\n再次提醒各位注意不要买下图的带灯条的风扇。\n\n\n安装侧面 2.5 寸硬盘\n蜗牛自带一个 16G 的 mSATA 硬盘，速度比 U 盘还慢，而且据说用着用着就坏了，很多人都拿这个做引导盘放系统，但我不打算用，直接拧下四个螺丝把主板抽出来，然后拧掉 mSATA 杂牌硬盘。\n因为四个硬盘托连接的 SATA 口是扩展的，不支持引导，但是 实际上蜗牛主板正中间上还有一个 SATA2 的口可以引导，正好连接我的笔记本硬盘，自己插上一根 SATA 线把主板装回去即可。\n硬盘本体安装时候需要两个小螺丝固定硬盘一侧，另外一侧是卡住的，不需要螺丝。然后把电源线和刚才插的 SATA 线插到硬盘上面。\n\n\n仓库盘安装\n我还在京东购买了一块 2TB 的西数紫盘和一块 2TB 的东芝 P300 硬盘，没有买 NAS 专用盘是因为太贵了。而其他便宜的比如西数蓝盘和希捷酷鱼只便宜了十几块，但是却是大缓存叠瓦式（SMR）硬盘，性能非常差，紫盘和 P300 则是标准的 64M 缓存垂直式（PMR）硬盘，价格也比较实惠。建议自行搜索叠瓦式硬盘了解不推荐的原因。考虑了一下还是不做 RAID1 了，经济条件不允许，而且本来 RAID 是为了高可用不是为了备份，如果是硬件损坏估计两块盘一起炸了，不如冷备份 还是做了 RAID1（真香！）。\n往硬盘架安装硬盘需要四个沉孔螺丝分别锁在两侧，当然我这种硬件渣渣其实是不知道啥是沉孔螺丝的，具体多大多粗我也不懂，我装 2.5 硬盘的螺丝是同学买大疆编程小车多出来的内六角，尺寸正好，至于 3.5 硬盘是隔壁老司机送给我的装机剩下的，反正车速很稳，如果自己买的话京东淘宝好像都有装机用的成套螺丝，建议顺手买一套，不要螺丝钉难倒程序员。\n\n\n\n\n\n\n\n\n\n软件处理\n分区\n软件部分就十分舒服自在了，接显示器网线键盘开机按 F7 进入 BIOS 设置 UEFI 模式从 U 盘启动，然后进入 Archiso 按照 安装指南 安装系统（据说他们最近在处理 base 组，建议随时跟进 wiki，我就不复制了），分区的时候我是在能引导的 2.5 寸硬盘上建立了 ESP，然后其他空间全部用作 LVM，1T 硬盘的剩余空间作为一个 pv，并且给 2T 的硬盘分了三个区做 pv（万一我哪天脑子抽了想缩个分区出来呢？），然后建立 vg，建立给根目录和家目录的 lv，然后创建 ext4 文件系统。这一段参照 wiki 对应页面就行了，命令十分简明直观，我第一次用就成功了。配置好的效果如图：\n\n至于 2T 的硬盘考虑了一下还是打算再加一块硬盘做 RAID1，不然连续跨设备 lvm 坏了一个就全坏了，由于 mdadm 需要两块设备都在才能建立，但我手头只有一块 决定的时候手头只有一块，于是在 fc 老师建议下决定上 btrfs（而且这货似乎支持多块不同容量硬盘的 RAID1，保证每个文件都在两块盘上有备份）。按照 fc 老师多年使用经验似乎 btrfs 也没那么不可靠。\n然后我在某东买了一块东芝 2T 64M 7200 转的机械硬盘，到手之后打算加入 btrfs 里面，建立 RAID1，btrfs 的好处就在于这个可以先弄一块之后渐进修改，而且看起来目前没什么大问题的样子（按我的理解并行读取的性能问题大概是用来并行加速的，但是实际上 RAID1 不并行读取也可以读出数据吧）。 虽然我本来也可以把数据移出来然后建 mdadm，但是不排除以后再加盘做 RAID 10，似乎 mdadm 没法直接转换的样子？\n买回来硬盘之后想想反正两块都没啥数据（搬出去了），索性直接格式化成 btrfs，不然也可以在已有一块 btrfs 设备之后 add 然后 balance，就可以修改策为 RAID1，格式化命令很简单就这样：\n# mkfs.btrfs -m raid1 -d raid1 /dev/sdXY /dev/sdZV\n\n-m raid1 是指 metadata 要做 RAID1 备份，-d raid1 是指 data 也要 RAID1 备份，然后需要跟两个设备，推荐是一样大的，挂载时候就随便挂一个设备就行了，btrfs 会自己执行 RAID1 策略的。\n启动配置\n/etc/mkinitcpio.conf 里需要给 HOOKS 添加 lvm2，然后运行 mkinitcpio -p linux，这样生成的 initramfs 才能支持 LVM。我还给 MODULES 添加了和 intel 显卡有关的部分（虽然可能用不到了），具体修改的位置如下：\nMODULES=(intel_agp i915)\nHOOKS=(base udev autodetect modconf block lvm2 filesystems keyboard fsck)\n\n安装 bootloader 的时候我直接用了 systemd-boot，我在自己笔记本上也用的这个，功能够用了就没考虑安装 GRUB，编写 entry 时候内核参数里的 root 可以写 /dev/vg0/rootlv 也可以写 /dev/mapper/vg0-rootlv（这里中文 wiki 和英文 wiki 分别是这俩，亲测都可以），比如我的就是下面这样：\ntitle\tArch Linux\nlinux\t/vmlinuz-linux\ninitrd\t/intel-ucode.img\ninitrd\t/initramfs-linux.img\noptions\troot=/dev/vg0/rootlv rw add_efi_memmap\n\n最近 systemd-boot 添加了什么校验设置，在蜗牛上会报错，直接 chroot 进去安装根本装不上，临时解决方案是退出 chroot，然后用 Archiso 里的 bootctl 安装到 /mnt/boot，虽然有点 dirty，但是能用？不过还是会报个错，但是启动需要的文件已经安装上了，不管它也能用，只不过每次重启还是会说校验失败，目测是个 bug。\n网络配置\n然后由于 NAS 的网络不会经常变化，所以联网的部分也直接用 systemd-networkd 管理了，没必要上 NetworkManager 我装桌面了所以还是用了 NetworkManager！不过这部分也留着好了。直接创建 /etc/systemd/network/wired.network，写入如下内容：\n[Match]\nName=en*\n\n[Network]\nDHCP=ipv4\n\n意思是对于所有以 en 开头的网卡使用 DHCPv4，然后 systemctl enable --now systemd-networkd systemd-resolved 就可以了。\nNetworkManager 就很简单，直接安装然后 systemctl enable NetworkManager --now 应该就行了！\nSamba 配置\nbtrfs 的子卷看起来很像普通目录，但是又可以分别直接挂载，不需要先挂载设备然后再用 mount --rbind。于是建了 /data 和 /data/alynx 两个子卷，/data 就挂载到 /data，/data/alynx 挂载到我家目录下面。\n在 /etc/samba/smb.conf 添加如下配置，就可以分别通过 home 和 data 访问每个用户在 /home 和 /data 下面的同名文件夹了，%u 代表的就是连接的用户名。（当然权限要你自己处理一下。）\n[home]\n   comment = Home Directories\n   browseable = yes\n   writeable = yes\n   path = /home/%u\n\n[data]\n   comment = Data Directories\n   browseable = yes\n   writeable = yes\n   path = /data/%u\n\n其它的配置基本都是正常操作参照 Arch Wiki，我打算以后插新硬盘直接加进 LVM 加给 /home，省得还得操心挂载到哪里 决定单独安排 btrfs 数据区了，参见上面。在外面访问我选择配置 WireGuard，参照我 这篇文章 就可以了，如果只是 SSH 远程控制一下效果还可以。\n效果\n笔记本插了有线之后可以测试千兆对拷，我的路由器还是大一时候买的 WNDR3800，虽然很旧，但四个有线全部都是千兆口（当时价格和配置差不多的极路由 3 只有一个内网千兆好像），samba 拷贝速度轻松达到 110M/s 的满速，甚至比蜗牛的 USB3.0 还快 用了 btrfs 之后似乎速度只有 80M/s 左右了，或者 Windows 和 Linux 下面 Samba 对拷速度显示有区别？110M/s 是我在 Windows 下面测出来的。\n蜗牛后面两个 USB3.0 只有下面的那个是真的，上面的也就 2.0 速度，下面的跑满了也就 80 到 90M/s，我的 U 盘是 CZ80，放笔记本上比这快多了，而且有时候上面那个也可以跑到 70M/s，具体原因不知道，如果是真的土豪建议直接换主板算了，非土豪完全可以插笔记本然后网络拷进蜗牛，比这奇葩接口还快。\n"},{"title":"前端博客搜索引擎","url":"/posts/Frontend-Blog-Search/","content":"本来我的博客有一个前端搜索框，当输入文字时就在侧边栏展开搜索结果，虽然看起来很时髦，但也不能算是什么食用啊不实用的设计方式。而且一开始我觉得既然没有后端处理请求，也就没法单独打开一个专门搜索页面了。\n但是这个设计引发了 @依云 的吐槽，有一个单独的搜索页面并且对每个搜索结果有专门的 URL 看起来还是个挺合理的需求。但第一我不知道怎么实现第二我一直觉得能用就行所以开始并没有改。不过依云给我发了 Python 文档的搜索实现，让我突然想明白其实按照标准的 HTML 和 JavaScript 是可以实现无后端的搜索引擎的，然后就动手实现了一个。\n首先既然要搜索那还是得有个索引或者数据库，比较简单的方案就是把所有文章的标题 URL 和内容丢到一个 json 文件里面，这个功能在 Hexo 里可以使用 hexo-generator-search 实现，我也给我的 Hikaru 添加了这个生成器，用来生成 JSON。\n首先第一件事是实现搜索跳转页面，这一步只要简单的使用 HTML 表单就能实现，首先将我的搜索框改造为如下格式：\n\n  \n    \n  \n  \n\n\" data-info=\"language-html\" data-lang=\"html\" class=\"code-block\">&lt;form action=\"{{ getPath(\"search.html\") }}\" method=\"get\"&gt;\n  &lt;button type=\"submit\" class=\"search-submit\" aria-label=\"{{ __(\"search\") }}\"&gt;\n    &lt;i class=\"fas fa-search\"&gt;&lt;/i&gt;\n  &lt;/button&gt;\n  &lt;input type=\"search\" id=\"search-input\" class=\"search-input\" name=\"q\" results=\"0\" placeholder=\"{{ __(\"search\") }}\" aria-label=\"{{ __(\"search\") }}\"&gt;\n&lt;/form&gt;\n\n使用 button 而不是 input 的原因是我想用我的图标做搜索按钮，反正 &lt;input type=\"submit\"&gt; 只是个特化的 button。理论上来说不放按钮靠回车提交也是 OK，但是觉得这样又会被某些用户批评不友好了……\n搜索框就很简单，name=\"q\" 表示生成的 query string 里 keywords 的 key 是 q，然后按照表单写法会被提交到 search.html，理论上来说搜索应该发 GET 请求所以就是 method=\"get\"，当然 POST 就实现不了复制链接查看搜索结果了。\n这个表单就是标准的 HTML 表单，不需要用 JavaScript 处理。生成的 GET 请求的 URL 类似于 /search.html?q=xxx\n然后接下来是处理请求了，既然是发送到 search.html，对于静态后端肯定是要返回这个页面的，那就得先创建页面，然后对这个页面进行特殊处理，这里我同样利用生成器生成一个 layout 设置成 search 的页面，然后就可以单独给它编写模板添加处理部分了。\nsearch.html 加载之后是可以通过 window.location.search 获取到 query string 的，然后我加载我修改过的 search.js（这里），用它处理搜索过程。同时在页面里添加了一个 container 用来放置检索结果。\nsearch.js 其实没什么黑科技，毕竟我们的难点就是在无后端情况下处理关键词和数据库，关键词已经用 window.location.search 拿到了，解析一下然后只要通过 ajax/fetch 请求数据就行了，封装 ajax 的代码网上到处都是，实在不行用 jQuery 也成。\n然后剔除重复关键词主要是为了优化一下性能，接下来我的解决方案就是简单粗暴 indexOf()，不要跟我提什么算法什么优化，短平快实现效果，我是个实用主义者，目前这一步其实还没有成为瓶颈。\n然后对于原版文件我的改进主要是按匹配次数排序，文章出现关键词越多则排序越靠前，相对可以提高效率。\n接下来使用正则表达式给关键词加上 &lt;strong&gt;，这样显示起来比较显眼。最后把字符串拼起来显示就好了。其实这里的算法还有点意思，比如假如两个关键词出现的位置中间大于多少个字符则插入省略号，否则合并两个的上下文，具体实现也可以参照代码。\n如果有性能瓶颈的话，多半也会先出现在 ajax，不过目前我文章还没有多到加载不出来的情况，也许可以靠分块加载解决？\n更新（2020-08-15 18:25:00）：我给代码添加了简单的分块支持。由于这里需要主题和生成器约定好路径，不太适合让生成器自动生成路径，所以采用了一个简单的方法就是让用户在设置文件里手动指定几个 JSON 文件的路径，然后生成器只是读取一下配置，假如是数组就分块写到指定好的路径里面。然后前端查找的时候分别异步查找每个文件并合并排序结果，理论来说大概会有性能提升？\n最后我加了个简单的 SpinKit 动画，在查询结束之前先跑一下提升用户体验。\n"},{"title":"Linux 用户的月饼食用手册","url":"/posts/Linux-Mooncake-Jokes/","content":"中秋节到了，Arch Linux CN 社区里富有生活情趣（？）的用户们聚在一起，开始讨论起如何食用月饼。\n\nGentoo 用户需要自己编译月饼，并且可以设置不同的 USE 编译不同口味。（farseerfc，outvi 和 AlynxZhou）\nDebian 用户习惯将月饼拆成月饼馅、月饼皮和月饼油分别打包食用。（AlynxZhou）\nArch Linux 用户喜欢购买 Debian 的月饼，然后将月饼馅、月饼皮和月饼油拆开自行组装并打包。（AlynxZhou）\nManjaro 用戶買來 Arch 組裝好的 Debian 月餅然後敲了個花紋上去重新裝了起來。（farseerfc）\nManjaro 用户经常吃了 Arch Linux 的月饼然后抱怨这个月饼为什么不是 Manjaro 牌的。（AlynxZhou）\nCentOS 的月饼过期了。（KRyuJo）但总有人喜欢这个传统口味，不想换。甚至有人资助 RedHat 厨师学校传承老字号。（whyme）\nRedHat：我们将通过电话联系在线指导您服用月饼。（ArielAxionL）\nopenSUSE 用户也喜欢将月饼拆成月饼馅、月饼皮和月饼油分别打包食用，但他们通常编写一个 月饼.spec 文件，然后将买来的月饼原料和此文件一同上传到 Open Build Service 而不是自己打包月饼。（AlynxZhou）\n随后 openSUSE 的月饼被冻结了，存放到 SUSE Linux Enterprise 的冰箱里。（AlynxZhou）\nUbuntu 的月饼馅过期了，但是依旧没有更新。（wuliao00）\nUbuntu 的月饼包装写着豆沙，用户咬了一口发现是五仁，大喊“内部错误！”。（AlynxZhou）\nDeepin 用户表示吃月饼时应该配点红酒（wine）。（AlynxZhou）\nLFS 用戶發現需要先自己製作用於製作月餅的工具。（YHNdnzj）然后开始阅读“如何开办一家月饼工厂”。（AlynxZhou）\nAntergos 用户收到了一个不幸的消息“此品牌已破产”。（AlynxZhou）\n\n随后话题变得奇怪了起来，不再仅限于常见的 Linux 发行版。\n\nWindows 10 用户您好！感谢您选购本公司最新发行的月饼，不过要敬告您的是，您的月饼马上就要过期了。（lilydjwg）\nWindows 用户的冰箱被锁死了，因为“Windows 正在等待月饼”。（AlynxZhou）\niOS 用户只能购买苹果自家的月饼，买别家月饼苹果抽成 30%。（outvi）\nGoogle 表示对于月饼生产商收取 25 美元的生产费。（AlynxZhou）除非你只吃没有馅料的 YOSP。（oldherl）\nPython 用户用 pip 安装了一个月饼，然后发现自己安装的月饼明年就过期了。（AlynxZhou）\nNodeJS 用户用 npm 安装了一个月饼，然后发现整个月饼流水线出现在了自己的厨房里。（AlynxZhou）\nDocker 用户发现自己的月饼比别家重很多，但没什么东西，而且吃月饼需要 root。（outvi）\nNintendo Switch 用户购买了月饼卡带然后由于太苦无法下咽。（AlynxZhou）\nJava 用户发现自己需要先安装一个月饼食用环境来食用月饼。（AlynxZhou）特别是还不知道装哪个版本。（outvi）更要命的是还区分 OpenMooncake 和 OracleMooncake。（Rocket1184）\nSteam 用户发现自己的月饼上多了绿色的 -40% 标签。（AlynxZhou）\nEpic：月饼独占一年！（KRyuJo）\nGo 用户把所有月饼当作一种月饼（interface {}）。（Sherlock_Holo）\nRust 用户表示月饼的保质期很短（离开视线就被 drop 了）。（Sherlock_Holo）Rust 家的月饼可以多人一起看着，但是吃的时候不仅只能有一个人吃，而且别人不能看。有人不信邪，使用 unsafe 魔法看到了正在吃月饼的人，然后整个世界崩塌了。（lilydjwg）\nsystemd 用户正在策划将月饼改名为 systemd-月饼 的投票。（AlynxZhou）\nC++ 用户表示月饼馅还不在厨房里。（wuliao00）建议食用 boost::月饼。（Rocket1184）\nfish 用户正在重写他们的 /etc/profile，没有时间吃月饼。（AlynxZhou）\nTensorFlow 用户依赖他们的 NVIDIA GPU 来识别月饼皮上的文字。（AlynxZhou）\nC# 用户的月饼做起来像 Java，吃起来像 Java，区别则是用户嘴里一直喊“我们的月饼比java好，而且终于开源了（一部分）！”。（AlynxZhou）\nVala 用户发现自己的月饼是 GLib 承包给 C 牌月饼的贴牌产品。（AlynxZhou）\nC 用戶不仅要自己制作月饼，还要在月饼制作过程中关注加工机器的操作问题，例如如何防止月饼馅料溢出。更重要的是这些操作都是纯手动完成的。（wnereiz）\n\n"},{"title":"可能只适合我自己的 RIME 配置","url":"/posts/My-RIME/","content":"为什么我要折腾这个\n在第 n 次忍受不了 RIME 的奇怪操作逻辑之后，我终于决定彻底教育一下这个不听话的输入法，考虑到已经有 n - 1 次失败的前提，做这个决定并不容易。\n首先说明我是 Linux 用户，使用 ibus-rime 做输入引擎，所以使用什么小狼毫鼠须管或者 fcitx-rime 的如果发现不好用最好别烦我，那些我都没用过。（不好意思因为配置这玩意有点暴躁。）\n然后我要吐槽一下 RIME 的文档，完全没有一致的类似 API 手册的东西，而且全繁体加上佛振独特的说话风格读起来真的很累，然后这个项目习惯又是起一大堆奇奇怪怪的名字（虽然我有时候也这样）。假如你想修改一点配置，读了文档里的示例“我懂了，巴拉巴拉”，打开配置一看一堆乱七八糟的就懵了。\n然后就是网上“致第一次用 RIME 的你”、“也致第一次用 RIME 的你”，我都看过了，首先他们不用 ibus-rime，其次文章内容毕竟有限，每个人需求不一样，有些我需要的地方他们一笔带过了。\n\n更新（2021-07-23）：另外我是被 felixonmars 惯坏了的 Arch Linux 用户，他已经把各种乱七八糟的 RIME 的配置打包到官方仓库了，所以我不需要和那个什么用来配置 RIME 配置的什么什么东风破打交道。如果你不是 Arch Linux 用户的话，我假设你有能力搞明白那个东风破，因为我搞不明白，没办法告诉你怎么用。\n\nRIME 的拼音功能确实很好用（虽然有时候它和我对于词组的想法不太一样），我的需求其实只有几项：\n\n对于一些 RIME 默认的中国人几乎都用不到的中文标点直接改成英文标点，这个在书写 Markdown 的时候简直折磨死我了，有几个人会输入全角井号？？？打个标题真的很麻烦。\n有些符号键在其他输入法里约定俗成的就直接输出特定的中文符号，RIME 的默认操作是弹出选择菜单，完全没这个必要，弹出菜单还需要二次选择而且打断了我的按键操作，需要英文标点时候直接切为英文就好了。\n关掉 RIME 的中英混输功能，在候选框输入英文字母真的很打断思路，我要输入英文要么就是打代码要么就是打单词，反正都不需要输入法，RIME 来就是给我找麻烦。\n还有一些奇奇怪怪的 RIME 的键位设置，偏偏要和其他输入法不一样，我已经习惯了那些操作，RIME 的键位只会降低输入速度。\n\n下面介绍一下我的配置。当然如果你嫌麻烦，最后我会加上我的配置的 GitHub Repo。\n首先建立一个干净的 RIME 配置环境，直接移走 ~/.config/ibus/rime 然后执行 ibus-daemon -rdx 重新生成（就是它文档里扯的部署部署部署）一套配置，由于我用的都是内置输入法所以也不需要什么乱七八糟的东风破 RIME Kit 地球拼音之类的。\n然后你进去 ~/.config/ibus/rime 新版大概有以下几个东西：\n\n目录 build：里面放了各种 RIME 的默认配置，我们不需要动这个。 很好，我搞错了，原来它是从 /usr/share/rime-data/ 和 ~/.config/ibus/rime/ 下面加载不带 custom 的文件，然后再读取 custom 文件给之前的文件打 patch，最后生成到 build 目录下面。\n目录 luna_pinyin.userdb：看起来像是朙月拼音的词库，当然也不用修改。\n文件 installation.yaml：我猜不用管。\n文件 user.yaml：我猜也不用管。\n\n各种乱七八糟操作逻辑的配置\n按照 RIME 打 patch 的配置方式我们需要在这个目录下创建一个叫 default.custom.yaml 的文件，这样就可以给 /usr/share/rime-data/default.yaml 这个文件 patch 辣，当然你得先会写 YAML。第一行首先写个叫 patch: 的 key，RIME 要求这样，所有的自定义配置都是在 patch 字段下面。\n怎么确定要修改的 key 名字呢？我这里的都是在 build/default.yaml 下面找到的， 因为 patch 的是 /usr/share/rime-data/default.yaml 所以就去看这个辣，之前又写错了，那个其实是生成的文件。你也可以试试其他的 YAML 文件。\nRIME 的文档说什么要用 / 把不同层次的 key 折叠成一个比如 ascii_composer/switch_key，亲测无所谓，我就爱展开了写完整的 YAML，这样更规范。 我说佛老师对不起对不起，我不懂规矩。a/b/c 是只 patch c，展开了则变成了 patch a。\n然后首先第一步我要修改输入法列表，我只用朙月拼音简化字模式就行了：\npatch:\n  schema_list:\n    - schema: \"luna_pinyin_simp\"\n\n然后就是改掉那个自作聪明的英文输入模式：\n  # 按 CapsLock 输出大写英文字母。\n  ascii_composer/good_old_caps_lock: true\n  # `inline_ascii` 在输入框内插入英文。\n  # `commit_text` 候选文字上屏并切换至英文。\n  # `commit_code` 输入拼音上屏并切换至英文。\n  # `clear` 清除拼音并切换至英文。\n  # `noop` 屏蔽此按键。\n  # 如果你设置 `Caps_Lock` 为 `noop`，\n  # 一个奇怪的问题是退格键不能用了，除非取消大写锁定。\n  # 所以我直接设置文字上屏了。\n  ascii_composer/switch_key/Caps_Lock: \"commit_text\"\n  ascii_composer/switch_key/Shift_L: \"commit_code\"\n  ascii_composer/switch_key/Shift_R: \"commit_code\"\n\n大部分坑我都写在注释里了可以自己看。\n然后我看那个设置选单也不是很爽，我习惯简体字，这个也可以自己改：\n  # 改掉原来的繁体字标题。\n  switcher/caption: \"【设置菜单】\"\n  # 用半角斜线而不是奇丑无比的全角斜线做分隔符。\n  switcher/option_list_separator: \"/\"\n  # 屏蔽 Ctrl-s 开启菜单，只允许 Ctrl-` 和 F4。\n  switcher/hotkeys:\n    # - \"Control+s\"\n    - \"Control+grave\"\n    - \"F4\"\n\n然后就是改掉它奇怪的键位，Emacs 键位挺好的，但是有几个不知道为什么用不了，再者就是为什么按向左是跳一个字拼音向右是跳一个字母？\n# 这里修改的是整个输入法全局的键位，某些输入方案有自己的键位可以单独覆盖。\n# 但我暂时不需要。\n# Emacs 键位，我喜欢。\n# 可是谁给我解释一下为什么 Left 是按字拼音跳而 Right 是按字母跳？\nkey_binder/bindings:\n  - accept: \"Control+p\"\n    send: \"Up\"\n    when: \"composing\"\n  - accept: \"Control+n\"\n    send: \"Down\"\n    when: \"composing\"\n  - accept: \"Control+b\"\n    send: \"Left\"\n    when: \"composing\"\n  - accept: \"Control+f\"\n    send: \"Right\"\n    when: \"composing\"\n  - accept: \"Alt+b\"\n    send: \"Shift+Left\"\n    when: \"composing\"\n  - accept: \"Alt+f\"\n    send: \"Shift+Right\"\n    when: \"composing\"\n  - accept: \"Control+a\"\n    send: \"Home\"\n    when: \"composing\"\n  - accept: \"Control+e\"\n    send: \"End\"\n    when: \"composing\"\n  - accept: \"Control+d\"\n    send: \"Delete\"\n    when: \"composing\"\n  # 这个用不了，不过估计也用不到。\n  # - accept: \"Control+k\"\n  #   send: \"Shift+Delete\"\n  #   when: \"composing\"\n  - accept: \"Control+h\"\n    send: \"BackSpace\"\n    when: \"composing\"\n  - accept: \"Alt+h\"\n    send: \"Shift+BackSpace\"\n    when: \"composing\"\n  - accept: \"Control+g\"\n    send: \"Escape\"\n    when: \"composing\"\n  - accept: \"Control+bracketleft\"\n    send: \"Escape\"\n    when: \"composing\"\n  - accept: \"Alt+v\"\n    send: \"Page_Up\"\n    when: \"composing\"\n  - accept: \"Control+v\"\n    send: \"Page_Down\"\n    when: \"composing\"\n\n还没完，我觉得正常人不会用 Tab 在拼音之间切换，除非你一次输入一句话（那你不觉得候选框太小了看着累吗？？？），设置 Tab 为跳候选词更自然一点，但我也不知道为什么 Shift-Tab 用不了：\n      # 正常人不会用 Tab 切换拼音光标的，相信我。用它切换选项更快。\n      # - accept: \"ISO_Left_Tab\"\n      #   send: \"Shift+Left\"\n      #   when: \"composing\"\n      # - accept: \"Shift+Tab\"\n      #   send: \"Shift+Left\"\n      #   when: \"composing\"\n      # - accept: \"Tab\"\n      #   send: \"Shift+Right\"\n      #   when: \"composing\"\n      - accept: \"Tab\"\n        send: \"Down\"\n        when: \"has_menu\"\n      - accept: \"ISO_Left_Tab\"\n        send: \"Up\"\n        when: \"has_menu\"\n      # 鬼知道为什么这个也用不了！\n      - accept: \"Shift+Tab\"\n        send: \"Up\"\n        when: \"has_menu\"\n\n以及我觉得正常人不用逗号和句号翻页，毕竟下面的默认设置是逗号句号直接上屏，你设置了翻页也没啥卵用，反正我用减号等号或者上下，不过方括号也不错就是了：\n  # 这里修改的是整个输入法全局的键位，某些输入方案有自己的键位可以单独覆盖。\n  # 但我暂时不需要。\n  # Emacs 键位，我喜欢。\n  # 可是谁给我解释一下为什么 Left 是按字拼音跳而 Right 是按字母跳？\n  key_binder/bindings:\n    - accept: \"Control+p\"\n      send: \"Up\"\n      when: \"composing\"\n    - accept: \"Control+n\"\n      send: \"Down\"\n      when: \"composing\"\n    - accept: \"Control+b\"\n      send: \"Left\"\n      when: \"composing\"\n    - accept: \"Control+f\"\n      send: \"Right\"\n      when: \"composing\"\n    - accept: \"Alt+b\"\n      send: \"Shift+Left\"\n      when: \"composing\"\n    - accept: \"Alt+f\"\n      send: \"Shift+Right\"\n      when: \"composing\"\n    - accept: \"Control+a\"\n      send: \"Home\"\n      when: \"composing\"\n    - accept: \"Control+e\"\n      send: \"End\"\n      when: \"composing\"\n    - accept: \"Control+d\"\n      send: \"Delete\"\n      when: \"composing\"\n    # 这个用不了，不过估计也用不到。\n    # - accept: \"Control+k\"\n    #   send: \"Shift+Delete\"\n    #   when: \"composing\"\n    - accept: \"Control+h\"\n      send: \"BackSpace\"\n      when: \"composing\"\n    - accept: \"Alt+h\"\n      send: \"Shift+BackSpace\"\n      when: \"composing\"\n    - accept: \"Control+g\"\n      send: \"Escape\"\n      when: \"composing\"\n    - accept: \"Control+bracketleft\"\n      send: \"Escape\"\n      when: \"composing\"\n    - accept: \"Alt+v\"\n      send: \"Page_Up\"\n      when: \"composing\"\n    - accept: \"Control+v\"\n      send: \"Page_Down\"\n      when: \"composing\"\n    # 正常人不会用 Tab 切换拼音光标的，相信我。用它切换选项更快。\n    # - accept: \"ISO_Left_Tab\"\n    #   send: \"Shift+Left\"\n    #   when: \"composing\"\n    # - accept: \"Shift+Tab\"\n    #   send: \"Shift+Left\"\n    #   when: \"composing\"\n    # - accept: \"Tab\"\n    #   send: \"Shift+Right\"\n    #   when: \"composing\"\n    - accept: \"Tab\"\n      send: \"Down\"\n      when: \"has_menu\"\n    - accept: \"ISO_Left_Tab\"\n      send: \"Up\"\n      when: \"has_menu\"\n    # 鬼知道为什么这个也用不了！\n    - accept: \"Shift+Tab\"\n      send: \"Up\"\n      when: \"has_menu\"\n    - accept: \"minus\"\n      send: \"Page_Up\"\n      when: \"has_menu\"\n    - accept: \"equal\"\n      send: \"Page_Down\"\n      when: \"has_menu\"\n    - accept: \"bracketleft\"\n      send: \"Page_Up\"\n      when: \"has_menu\"\n    - accept: \"bracketright\"\n      send: \"Page_Down\"\n      when: \"has_menu\"\n    # 我觉得正常人不应该用逗号和句号翻页。\n    # - accept: \"comma\"\n    #   send: \"Page_Up\"\n    #   when: \"paging\"\n    # - accept: \"period\"\n    #   send: \"Page_Down\"\n    #   when: \"has_menu\"\n\n最后就是那一堆乱七八糟的快捷键了，鬼才记得住，有那时间直接翻菜单就行了，那个 Shift+Space 就是我动不动就变成全角的罪魁祸首，全部不要：\n    # 鬼才记得住这么多乱七八糟的快捷键，我翻菜单比背这玩意快多了。\n    # - accept: \"Control+Shift+1\"\n    #   select: \".next\"\n    #   when: \"always\"\n    # - accept: \"Control+Shift+2\"\n    #   toggle: \"ascii_mode\"\n    #   when: \"always\"\n    # - accept: \"Control+Shift+3\"\n    #   toggle: \"full_shape\"\n    #   when: \"always\"\n    # - accept: \"Control+Shift+4\"\n    #   toggle: simplification\n    #   when: \"always\"\n    # - accept: \"Control+Shift+5\"\n    #   toggle: \"extended_charset\"\n    #   when: \"always\"\n    # - accept: \"Control+Shift+exclam\"\n    #   select: \"\".next\"\n    #   when: \"always\"\n    # - accept: \"Control+Shift+at\"\n    #   toggle: \"ascii_mode\"\n    #   when: \"always\"\n    # - accept: \"Control+Shift+numbersign\"\n    #   toggle: \"full_shape\"\n    #   when: \"always\"\n    # - accept: \"Control+Shift+dollar\"\n    #   toggle: \"simplification\"\n    #   when: \"always\"\n    # - accept: \"Control+Shift+percent\"\n    #   toggle: \"extended_charset\"\n    #   when: \"always\"\n    # 你就是那个经常害我变成全角的罪魁祸首！\n    # - accept: \"Shift+space\"\n    #   toggle: \"full_shape\"\n    #   when: \"always\"\n    # - accept: \"Control+period\"\n    #   toggle: \"ascii_punct\"\n    #   when: \"always\"\n\n我自己是不习惯写 inline 的字典和列表，都写的展开的。\n然后是符号设置了，一开始我以为改 default 里面的符号表就行了，但是最近（2021-03-01）发现不行了，翻了一下代码，发现朙月拼音现在不读 default 了，而是加载 symbols 里面的。然后文档建议的是在输入法方案里面添加自定义的符号表，所以需要建立 luna_pinyin_simp.custom.yaml 并修改。\n因为我们上面取消了逗号句号翻页，所以这里也就不用显式写 commit 直接上屏了。然后我去掉了一大堆菜单，我输入井号星号波浪线百分号就是想要英文标点，你给我弹个菜单我还得多确认好麻烦的。以及我觉得真的没人用那个巨长的全角斜杠，输入斜杠就是为了斜杠，什么通过朙月拼音命令输入假名有意义吗？我为什么不直接切日语输入法呢？另一些标点在中文语境下直接输出中文标点就好了，需要英文标点时候我敲一下 Shift 比看菜单选容易多了！比如书名号，竖线输出人名中间的点，反斜杠输出顿号之类的。\n由于我不是金融行业的，我就把一些英文标点常见但对应中文标点也可能会用到的都丢到了 $ 的菜单里面：\n&quot;: &quot;》&quot;\n    &quot;^&quot;: &quot;……&quot;\n    &quot;_&quot;: &quot;——&quot;\n    &quot;`&quot;: &quot;`&quot;\n    &quot;|&quot;: &quot;·&quot;\n    &quot;~&quot;: &quot;~&quot;\n\" data-info=\"language-yaml\" data-lang=\"yaml\" class=\"code-block\">patch:\n  # 现在朙月拼音加载标点候选是加载 symbols 里面的，根本不加载 default。\n  # 然后文档建议的是自己修改的标点符号表放在输入法方案配置里面。\n  # 为了方便编写 Markdown，把一些奇怪的写中文根本用不到的符号弹出菜单改成直接输出英文符号。\n  # 另一些直接默认输出中文符号，需要英文符号可以切换英文输入。\n  # 有关 `\"!\": {commit: \"！\"}` 的写法含义是你设置这个键为翻页按键了，\n  # 但是你又想在输入拼音出现选单之后输入这个按键直接上屏（常见的逗号句号问号叹号），\n  # 我觉得这是多此一举，为什么你非要拿这几个符号翻页？反正我不用。\n  # 乱七八糟的符号都塞给 `$` 就好了反正我不是会计不用天天输入 `￥`。\n  # 我不会使用全角英文的，我觉得其他程序员也不会。\n  # 但是中文的标点又是全角的，所以我就只改半角。\n  punctuator/half_shape:\n    \"!\": \"！\"\n    \"\\\"\":\n      pair:\n        - \"“\"\n        - \"”\"\n    \"#\": \"#\"\n    \"$\":\n      - \"￥\"\n      - \"$\"\n      - \"€\"\n      - \"～\"\n      - \"×\"\n      - \"÷\"\n      - \"°\"\n      - \"℃\"\n      - \"‰\"\n      - \"‱\"\n      - \"℉\"\n      - \"©\"\n      - \"®\"\n    \"%\": \"%\"\n    \"&amp;\": \"&amp;\"\n    \"'\":\n      pair:\n        - \"‘\"\n        - \"’\"\n    \"*\": \"*\"\n    \"+\": \"+\"\n    \",\": \"，\"\n    \"-\": \"-\"\n    \".\": \"。\"\n    \"/\": \"/\"\n    \"\\\\\": \"、\"\n    \":\": \"：\"\n    \";\": \"；\"\n    \"=\": \"=\"\n    \"?\": \"？\"\n    \"@\": \"@\"\n    \"(\": \"（\"\n    \")\": \"）\"\n    \"[\": \"【\"\n    \"]\": \"】\"\n    \"{\": \"「\"\n    \"}\": \"」\"\n    \"&lt;\": \"《\"\n    \"&gt;\": \"》\"\n    \"^\": \"……\"\n    \"_\": \"——\"\n    \"`\": \"`\"\n    \"|\": \"·\"\n    \"~\": \"~\"\n\n最近的朙月拼音添加了反查笔画的功能，按下反引号并输入候选字来启动，但是反引号对于写 Markdown 的人很常用，所以我要关掉这个恼人的功能：\n  # 反查占据了宝贵的反引号，导致 Markdown 用户非常痛苦，所以关掉。\n  recognizer/patterns/reverse_lookup:\n\n总之写完这些配置 之后要手动移除 ~/.config/ibus/rime/build/ 这个生成目录 再执行 ibus-daemon -rdx 就可以应用了，现在 RIME 用起来就更让我愉快了，接下来就是慢慢养词库就行了。\n有关为什么 ibus-rime 总是竖着的\nibus-rime 是读取 rime 配置而不是 ibus 配置来设置横竖这一点本身就很离谱了，然后由于 bug 啦其他奇奇怪怪的原因啦好像很难搞清楚，我最近终于搞清楚啦！其实也不是很麻烦。\nibus-rime 会读一个叫做 ibus_rime.yaml 的配置文件，有这么一个配置可以让他变成横着的：\nstyle:\n  horizontal: true\n\n可能看了之前的你会和我一样想那就打个 patch 到 ibus_rime.custom.yaml 不就行了嘛！但是不行，为什么呢？因为不管是 rime 还是 librime 还是 ibus-rime 都没有提供 /usr/share/rime-data/ibus_rime.yaml 的文件，所以你的 patch 找不到被打的文件，那就不会被生成到 build 目录里。\n不要忘了之前说 rime 会读取 ~/.config/ibus/rime/ 下面的 yaml，所以其实只要自己建立 ~/.config/ibus/rime/ibus_rime.yaml 写入那段配置就可以啦，因为本来也没有所以就不用打 patch 了，或者你在那两个位置建立一个空的 ibus_rime.yaml 然后再打 patch 也行……\n\n更新（2021 年 1 月 26 日）：Arch 的 ibus-rime 现在打包了一个 /usr/share/rime-data/ibus_rime.yaml 文件，所以上面手动创建一个 ibus_rime.yaml 的办法会被覆盖，所以现在建议创建 ~/.config/ibus/rime/ibus_rime.custom.yaml 然后对照着打patch，比如我写的是：\npatch:\n  # 舒服不如倒着。\n  style/horizontal: true\n  # 有些软件的行内预测支持有 bug，所以我一般不开。\n  style/inline_preedit: false\n\n\n不要忘了删掉 build 目录再 ibus-daemon -rdx。\n\n更新（2021-07-23）：最近研究了一下如何扩展 RIME 的词库，发现还是稍微复杂的，我尝试导入了肥猫打包的 rime-pinyin-zhwiki。如果你要给某个输入法导入词库，首先你得自己创建一个扩展词库文件让他继承这个输入法本来的词库和你想要的词库，因为输入法配置里面只能指定一个词库配置文件。\n所以对于朙月拼音简化字版本，先创建一个叫 luna_pinyin_simp.extended.dict.yaml，.dict.yaml 之前的名字其实是随便取的，内容如下：\n# 原来要结合默认词库和第三方词库，\n# 需要自己编写一个词库让它 fallback 到朙月拼音和第三方词库。\n# 我说佛老师对不起对不起，我不懂规矩。\n---\nname: luna_pinyin_simp.extended\nversion: \"0.1\"\n# `by_weight`（按词频高低排序）或 `original`（保持原码表中的顺序）。\nsort: by_weight\n# 因为导入的朙月拼音词库是繁转简，所以这里不能导入简化字八股文。\n# 导入简化字八股文。\n# vocabulary: essay-zh-hans\n# 选择是否导入预设词汇表【八股文】。\nuse_preset_vocabulary: true\n\nimport_tables:\n  - luna_pinyin\n  - zhwiki\n\n应该很容易懂，我就不多唠叨了，记得里面名字和外面文件名要一致。\n然后在 luna_pinyin_simp.custom.yaml 的 patch 里面加一行：\n  translator/dictionary: luna_pinyin_simp.extended\n\n我还研究了一下如何添加 emoji 功能，也是靠肥猫打的 rime-emoji 包，只要在 luna_pinyin_simp.custom.yaml 的 patch 里面加一行：\n  __include: emoji_suggestion:/patch\n\n如果你用的不是 Arch，可能需要自己复制 patch 文件内容而不是简单地使用 include，参见官方说明。\n当然不要忘了安装这两个依赖的包，我这个配置在 Arch Linux 下面一共需要下面几个包：\n# pacman -S librime ibus-rime rime-luna-pinyin rime-emoji rime-pinyin-zhwiki\n\n当然你要有配置好能显示的 emoji 字体。这也是个坑，等我有时间写一下我的 /etc/fonts/local.conf 吧。\n\n下载\n更新（2021-07-23）：因为加了词库和 emoji 之后文件变多了，请直接去 GitHub Repo 获取配置。\n"},{"title":"翻译：我很幸运，但你不是。","url":"/posts/I-Am-Lucky-You-Are-Not/","content":"这是一篇 Jérôme Gamez 的文章 I am lucky, you are not. 的简体中文翻译。\n你好，我的名字是杰罗姆（Jérôme）！对于分享名字我感觉很自在，因为我住在德国，并且我也不总是对我们国家的政府感到高兴。\n对于我能公开谈论此类事情，而不需要感到对我的言论产生的反响感到恐惧，我感觉很幸运。并且我拥有 甚至都不需要考虑 需要考虑我有多么幸运的奢侈的权利（这里的重复是有意为之）。\n通过最近在 Discord 社区 有关 PHP 的 Firebase Admin SDK 的讨论，我才意识到以上这些事情。\n某位成员试图通过这个 SDK 去访问 Firebase API，但是却触发了一个既没有详细信息也没有说明错误来源的异常。事实上原因是因为他们在中国而且忘记了给代码运行的环境设置代理。是个可以修复的问题。\n当我在忙于在 SDK 里实现连接错误处理的时候（这是一件我以往甚至没有考虑过去做的事情，因为我在一个很幸运的位置，处于一个拥有普及且无限制的互联网连接的国家里），另一位社区成员补充说到：\n\n@jeromegamez：看看我处在一个多么令人愉悦的情形：由于美国贸易管制法律限制，您的 GitHub 账户已被限制。对于个人账户，您可能仅仅拥有用于个人交流的对于免费的 GitHub 公开仓库服务的受限访问权限。请阅读有关 GitHub 和贸易管制的文章以获取更多信息。如果您确信您的账户是被错误地标为受限，请发起申诉。\n\n他们曾经有某次在自己祖国不通过 VPN 或代理直接访问 GitHub，然后立刻被标为受限，现在无法访问自己的私有仓库。\n\n这太差劲了……你们会为这个申请判决吗？\n\n我是这样幼稚地回复的，没意识到他们的申诉当然无法成功。\n\n@jeromegamez：非常不幸，你没法选择在哪出生！在大概 200 来个国家里面我就处在一个疯狂的国家里。在内部的审查制度和制裁开始之后我通常使用 VPN，拿 whatsapp 举例，有些时候我没法上传图片于是我需要使用代理或 VPN。正常来说你可以通过使用 VPN 来使用像 Nvidia.com 或者 Android.developers.com 等等国际服务，但是这次不太一样，如果你曾经用某些地方的 IP 地址访问过他们的服务，他们直接阻拦你自己。至少我仍然可以使用公开仓库，并且希望我能备份我的私有仓库！\n至于申请判决，作为这些国家的公民我没法做这件事情。\n\n我们在私有频道继续了我们的对话，他们和我聊了他们出身的国家（这是个通常让他们感到不适的话题），他们为此努力并且在公开频道展示了这些以便引起注意。\n人们像我一样（特别来说像我：白人、男性、欧洲人、有一份薪水不错的工作并且可以访问任何我需要和大部分想要的事物）看待这个世界并且对在一些令人不那么舒适的地方正在发生的事情感到不满和恐惧。\n但是还有可能成为更令人不适、敌对或者危险的地方，并且像你或我一样的人正在那生活。\n当我在为了躲避酷暑而努力时，有些人在努力谋生。\n当我因为不能在互联网平台上使用我中意的用户名而感到气愤时，有些人在确保他们的用户名不会透露太多他们的个人信息。\n当政府 A 在制裁国家 B，在制裁导致的后果中挣扎的是那些国家的平民百姓，而不是领导者。\n当我在尽力唤起人们注意的时候，有些人在尽力活下去。\n这不代表我们的个人问题是不相干的，但我认为，认识到我们中的大多数是幸运地出生在一个好地方，并且也有好多人不像我们这么幸运，是十分重要的。\n最起码我们可以做的是以尊敬、礼貌和同情对待他人，而不在乎他们的出身和背景。\n"},{"title":"在 Pixel C 上安装 Lineage OS","url":"/posts/Pixel-C-Lineage/","content":"Pixel C 刚出来的时候我就很喜欢，但无奈价格太贵，同时 Android M 不能说是很好的利用平板电脑硬件。由于咕鸽战略失误，现在 Pixel C 已经变成热门洋垃圾了，所以在咸鱼捡了一台玩玩。\n官方系统升级只支持到 8.1（真的好意思起名叫 Pixel），Lineage OS 支持也是到 8.1（LOS 版本号就是 15.1，没有 Bug），所有的 9.0 都是 Unofficial 的，不过反正我就拿来看看文档，所以直接刷 LOS 15.1 Official 了（为什么不刷官方？因为我手机不想刷第三方，就拿平板过过瘾）。\n由于刷机时候没拍照片我就不上图了，到手时候已经解锁了，不过既然是亲儿子想必也不会像一些国内厂商一样恶心你。按照 Lineage OS 的说法就是打开开发者选项里 允许 OEM 解锁 和 允许 USB 调试，按住 音量下 + 电源 直到重启进入 bootloader，然后连接电脑，fastboot devices，如果检测到了就 fastboot oem unlock，然后结束之后重启。（会清除所有数据，并且解锁之后每次开机会在 bootloader 停止 30 秒）。\n首先是去 Lineage OS 官网 下载给 Pixel C（代号 dragon）的 nightly build ZIP。\n然后去 这个 Google Drive 下载开发者提供的 vendor.img。（务必要下载这个，如果有不止一个就选择最新的，因为 ROM 里不包含 vendor，其他下载的版本又和 ROM 包不匹配。）\n然后去 TWRP 官网 下载给 Pixel C 用的 Recovery。\n需要 Google Apps 就去 Open GApps 下一个 ZIP，我用的是 ARM64 -&gt; 8.1 -&gt; stock。\n如果你需要 Magisk 就自己去下载一个。\n把这些都放好，在自己电脑上安装 adb 和 fastboot，然后打开 允许 USB 调试。按住 音量下 + 电源 直到重启进入 bootloader，然后 fastboot flash recovery TWRP-IMG-FILE 刷入 TWRP，然后再按住 音量下 + 电源 直到重启进入 bootloader，选择 Recovery Mode 确认 TWRP 已经刷进去了。\n接下来在 TWRP 里面全部 wipe（syste, data, cache, sdcard），然后接上电脑用 adb push FILE /sdcard/ 把下好的 ROM ZIP、verdor.img 和 OpenGApps ZIP 都 push 到 sdcard 里面。\n然后选择 Install 刷入 Lineage 的 ZIP。接下来选择右侧 Install IMG，刷入下好的 vendor.img，然后刷入 OpenGApps 和 Magisk，重启。如果刷入 Magisk 的话会自动重启两次再进入向导。\n如果你日后某次更新提示 vendor 不匹配，那就去上面的咕鸽硬盘看看有没有新的刷进去就行了。\n接下来就是熟悉的开机向导了。\n大致来说作为洋垃圾这个平板还很划算，除了屏幕下端因为接键盘的磁铁的缘故会发黄（通病）、太多磕碰容易花屏（设计缺陷，购买要买少磕碰的），屏幕素质很好尺寸也很大，看文档看 PPT 看五线谱都是绝配（我期末就是用这个看 PPT 复习的超级爽）。但是毕竟是 3 年前的产品，3 GB RAM 不是很够多任务，同时 CPU 性能也不是特别高，甚至窗口特效都会卡（老黄的芯片优化好烂），但是这台机器是 NVIDIA 的芯片，GPU 性能强劲，打开开发者选项里 禁用 HW 叠加层，使用 GPU 进行窗口混成特效就不卡了。\n游戏性能很弱，大部分游戏都没有对这台机器 GPU 优化，CPU 和内存以及 IO 性能也不高（毕竟三年前），但是玩 FGO 意外的不卡，而且还让我用 维护送的石头 单抽 连续 出了 老福 和 心心念念的蓝呆！！！要知道就算把买平板的钱都氪进去也才不到两单，要靠这些石头来双黄蛋简直是白日做梦啊！\n除了垃圾 B 站 APP 不支持横屏以外，大部分对我影响都不是很大，所谓生态烂其实对我这种资深 Android 用户也没什么大影响，毕竟我对它分工很明确。日常用起来 Lineage OS 也没什么问题，升级只要傻瓜化点击就 OK 了。\n"},{"title":"雲雀","url":"/posts/Hibari/","content":"ロード・エルメロイⅡ世の事件簿 -魔眼蒐集列車 Grace note- ED\n五线谱（带歌词）点击链接下载：PDF 格式、MSCZ 格式（需使用 MuseScore3 打开）。\n歌词参考自微博用户 @小蟲子的覺魂。曲谱由我自己听写。\n说实话好久没听到这么有梶浦由记风格的曲子了。花の唄 和 I beg you 都不算是我特别能接受的风格，二世事件簿大概是一月份放了第一集，但我那时候坚守“不看新番”的原则错过了……不过还好，第二集才算正式的放出 OP 和 ED。无人声OP对于梶浦由记来说根本不算什么难题，相对来说 ED 就更有惊喜了，不管是对小灰还是对韦伯都很好体现出了人物身上的故事感。\n当然令人欣喜的绝对不是这个番又多了一个呆毛。\n刷了两次 HF2 之后听到这个真是治愈啊啊啊啊啊，HF2 黑呆和 B 叔打戏看出了 EVA 旧剧场版明日香大战量产机的感觉，明明画面很热血，但是就是给人绝望感。\n下面是数字谱：\n[6 #1 #43#4 #5 3] [#4#56 #1 #4 #56 6#5#4 3]\n\n67 [#1 #1 #1 2 #1] 7 [3] 67 [#1 #1 #1 #4#4 33] 7\n67 [#1 #1 #1 #4#56#5#4#43 #4 3 #1] 67\n[#1]7 6 6 7 67[#13] [#1#23 #23#4 #2#1]\n[#4#56 #5#4#5 3#4323 #1#2#24 #4#44#4#5]\n\n[#4#56#4 #4#56#4 #4#5676 #5#4#5 #566]\n[6#5 #4#56#4 #4#56#4 #4#5676 #5#4#5 676]\n[6#5 #4#566 #4#566 #4#5676 #5#4#5 676]\n[6#5#4 #43 3#1] [6#5#4 #43 3#4#4]\n\n[6 #1 #43#4 #5 3] [#4#56 #1 #4 #56 6#5#4 3]\n\n67 [#1 #1#1 2 #1] 77[3] 67 [#1 #1 #1 #4 33] 7\n67 [#1 #1 #1 #4#56#5#4#43 #4 3 #1] 67\n[#1]7 6 6 7 67[#13] [#1#23 #23#4 #2#1]\n[#4#56 #5#4#5 3#4323 #1#2#24 #4#44#4#5 6#5#4]\n\n[#4#56 7]{#1}[#4] [#4#56 7]{32#1}[76 #4#56 7]{#1}[#4 76#5 3 #4#56]\n[6#5 #4#56 7]{#1}[#4] {#43 #1}[7]{#1} [76#5#4] [76#5#5] [#567]{#1}\n\n[#4#566#4 #4#566#4 #4#5676 #5#4#5 #566]\n[6#5 #4#566#4 #4#566#4 #4#5676 #5#4#5 #566]\n[6#5 #4#566 #4#566 #4#5676 #5#4#5 676]\n[6#5#4 #43 3#1] [6#5#4 #4#43 3]\n[#4#56#4 #4#56#4 #4#5676 #5#4#5 #566]\n[6#5 #4#56#4 #4#56#4 #4#5676 #5#4#5 676]\n[6#5 #4#566 #4#566 #4#5676 #5#4#5 676]\n[6#5#4 #43 3#1] [6#5#4 #43 3#4#4]\n[6#5#4 #43 3#1] [6#5#4 #43 3#4#4]\n\n"},{"title":"六月，如梦一般的日子","url":"/posts/June-or-Dream/","content":"\nOne day I wake up,\nfinding that I am on one of the most crazy trips in my life.\nIt has been into my dream for many times,\nbut now it comes into reality,\nwhich becomes the best birthday present.\n\n距离上次更新大概一个半月了，主要是因为考试周太忙所以没心情写博客。实际上有好多东西可以写。\n首先是端午节去了沈阳，不过很遗憾的是我不太喜欢拍景点照片（当然这也不是什么重点内容），所以只有一些稀松平常的照片。\n\n\n\n然后是有关实习的内容了，很幸运的拿到了 SUSE 北京的实习，环境超级好，而且最关键的还是找到一个自己比较感兴趣又有了解的实习。\n接下来就是 FGO 抽卡！盼了一年之后终于如愿以偿抽到了蓝呆！而且还是先出老福然后连续出蓝呆！没有看到金卡面之前完全没有意识到是什么，就算看到金卡还以为又是兰斯洛特或者式姐（兰斯洛特四宝的我），结果翻过来的时候完全呆住了不知道该怎么反应（抽到老福的快乐还没过去）。\n\n\n以及买到了 Yuki Kajiura Live 15 上海的票（因此变得超级贫穷），虽然对我这个纯 Kalafina 粉来说性价比不是很高，但由于没去过 Kalafina 的 Live（并且再也没有了）而十分后悔，因此抱着这样的想法毅然决然的自己买了票，也算是圆多年的梦吧，毕竟可以说我整个的音乐审美都来自于梶浦由纪和 Kalafina。\n最后附上沈阳故宫墙里奇怪的石头照片。\n\n"},{"title":"Arch Linux 安装 UnityHub 的临时方案","url":"/posts/Archlinux-UnityHub/","content":"如果你直接使用 AUR 里 UnityHub 的 PKGBUILD 安装会出一些问题。解决方案也很简单。\n首先似乎 PKGBUILD 下载的版本很奇怪，不管你在 Unity 论坛里哪个链接下载的版本其实都是一个，并且和 PKGBUILD 里面的不一样，解决方法就是自己计算一下 md5 然后替换掉 PKGBUILD 里面的 md5sums。\n第二个就是 PKGBUILD 里面最后有这几句：\n# Fix 7z permissions\nchmod +x \"${pkgdir}/opt/${pkgname}/resources\" \\\n         \"${pkgdir}/opt/${pkgname}/resources/app.asar.unpacked\" \\\n         \"${pkgdir}/opt/${pkgname}/resources/app.asar.unpacked/external\" \\\n         \"${pkgdir}/opt/${pkgname}/resources/app.asar.unpacked/external/7z\" \\\n         \"${pkgdir}/opt/${pkgname}/resources/app.asar.unpacked/external/7z/linux64\" \\\n         \"${pkgdir}/opt/${pkgname}/resources/app.asar.unpacked/external/7z/linux64/7z\"\n\n但解压出来实际上没这些文件，然后就会报错，注释掉就好了。\n"},{"title":"不要拿愚蠢的广告来污染我的邮箱","url":"/posts/Do-Not-Fill-My-Email-with-Silly-Ads/","content":"一个多月没更新了，这次要写的不是什么技术问题，而是技术的附属问题。\n一切问题的起源是邮箱里出现的这两封邮件：\n\n\n当你使用的邮箱服务是按某种数字顺序批量注册用户名的时候，你就没办法避免这种情况，不止我自己，而是很多人都收到这种邮件，没办法，谁叫邮箱都是连号的，甚至不需要写脚本，只要用 Excel 拖一下就能生成一大串邮箱地址，毫无技术含量。\n从哪里开始呢？今天我不打算讨论写这么个 app 的技术问题，因为不管怎么说写这么一个系统也不是个简单的活，还是要尊重别人的劳动成果。但除了技术问题，别的问题就一大堆了。\n我不想下载\n没错，最大的问题其实是我看了这个东西以后根本没有下载的欲望，甚至连让我下载下来找找里面有哪些技术缺陷的欲望都没有。我也不是什么专业的传播学者或广告设计者，但从一个用户的角度而言就没有使用欲望——而且这种现象也不止我自己。\n你的情怀和我有什么关系\n\n今年春半，凝聚了团队无数努力和心血的项目终于开启了测试阶段。这对于我们整个团队来说都是一个饱含着拼搏意义和激动情绪的时刻。\n\n上一个想要靠卖弄情怀来宣传聊天软件的人叫罗永浩，结果如何呢？提起罗永浩大家还是首先想到 TNT、打脸，有几个想到聊天宝（原子弹短信）的？罗老师的原子弹短信一开始可是给用户发红包的，这年头光卖情怀可不行，大家都喜欢红包。\n我知道你们实现了目标非常开心，但能不能冷静下来再发广告，这句话放在这里除了浪费几 Byte 流量之外，对用户了解你的应用有什么帮助嘛？\nAI 不是万能形容词\n\nDreamua 是首款 AI 社交应用。\n\n如果你做了个图片识别程序，你打个标签叫 AI 识图，那我觉得你的可信度有 90%，毕竟有很多现成的例子了。\n如果你做了个文本翻译程序，你打个标签叫 AI 翻译，那我觉得你的可信度有 70%，因为有几家财大气粗的公司在做，虽然效果不一定太好。\n如果你做了个自动驾驶程序，你打个标签叫 AI 驾驶，那你的可信度取决于你是不是在特斯拉上班。\n如果你非得叫 AI 社交……AI 社交了，还要我干什么？社交是人与人之间的互动哎，你这个 AI 社交，AI 在里面扮演什么角色呢？AI 能猜出来“今晚月色真美”的潜台词嘛？要是能猜出来，全世界的翻译官都失业了。\n写广告的人不懂的话可以问问程序员，最起码程序员应该懂。\n求你别搞假大空\n\n我们想了很久的slogan，最后把它决定为“Dreamua—遇见宇宙中最契合的TA”。\n“宇宙”、“契合”是很宏大的词，但是我们希望可以和你一起，实现这个slogan的意义。\n\n如果语文老师没教你避免假大空，那软件工程老师应该教过你怎么写文档，请问你们这个软件是想让我和外星生物配种嘛？\nedu 邮箱就很靠谱吗\n\nDreamua是一个仅对edu邮箱用户开放的高校社交App，其核心和灵魂是AI社交。注册Dreamua 账号无需填写复杂简介，仅需一个edu邮箱，一张头像，一个昵称。\n\n废话，edu 邮箱要是那么靠谱，我就不会收到这个邮件了——毕竟发件人也是 edu 邮箱。\n一个 edu 邮箱能说明什么呢？学号？学校？这个邮箱的主人可能已经毕业了，比如发邮件的这位 14xxxxxx，你是留级了在读大五？\n知道为什么我用 edu 邮箱申请 GitHub 学生认证不给过吗？就是因为国内的 edu 邮箱管理太松散了，完全没办法辨别真伪。那既然邮箱不能验证我是不是学生，再加一张头像一个昵称就能验证我是不是学生了吗？\n如果你们下次还想发广告，我觉得可以去淘宝搜索一下“教育 邮箱”，多买几个马甲，别总用一个，添加黑名单很容易的，大家只是太懒而已。\n说话能不能说准了\n\nDreamua是一个仅对edu邮箱用户开放的高校社交App。\nDreamua 测试版仅面向 edu 邮箱用户开放。\n\n第一封邮件告诉我这个社交 app 只对高校学生开放。第二封就告诉我是测试版仅面向 edu 邮箱——那以后呢？\n生人社交本身就是伪命题\n求求你了我们真的不是没社交就会孤独到死。不是每个人每个晚上都睡不着觉拿着微信疯狂摇一摇。我有自己认识的人，我可以认识我认识的人认识的人，我甚至看到有趣的人也可以直接上去要联系方式——或者我干脆就不想和别人说话。\n从小到大不爱和人说话的同学我见得多了，他们活得好好的，也没见谁需要下个 app“寻找志趣相投的伙伴”。实在不行的话，我还有沙雕网友呢。\n我为什么非要用你的\n我倒不是说微信微博 QQ 做的比你们技术好，但每个人都或多或少的被这些或者其他 app 黏着。本身这些东西已经够用了，我看不到需要切换到第 N + 1 个 app 来和别人说话的任何理由，特别是对于我们这些生活在地球外面的人，想切换就更艰难了。\n原子弹短信的尸体还在墙头上挂着呢，支付宝也有聊天功能，除了聊天框里的“转账”按钮，你按过别的吗？\nPlay 商店搜不到\n这一条可能有些不接地气，但对 Android 用户来说，Play 商店是最可靠的程序来源，就如同苹果的 App Store 一样。只有 Play 会定期检查手机里的 app 是不是有安全问题，其它的国内商店根本没有安全保证——拍个身份证照片注册就安全了吗？\n我看到你们的 app 提交了 App Store，所以应该不差那 25 美元提交 Play Store 吧，毕竟我自己的开源 app 也掏了 25 上了 Play 呢。\n官网没有可用信息\n还好我能够在 Google 搜索到 https://www.dreamua.com/，但这个页面逻辑和我自己的 app 页面 https://livewallpaper.alynx.xyz/ 逻辑差不多嘛——就一个简单的静态页面，分几个段落，写一些半通不通的话（“年轻有朝气的初创团队，致力于打造一家有影响力、有社会责任感的互联网科技公司。”这话怎么读怎么奇怪）。既然你都注册公司了，多写几个页面问题不大吧。你们是公司，我可是一个人单打独斗写的 app，不至于和我一样套开源 bootstrap 主题吧！\n顺便，上面写诞生于高校宿舍，下面又写有 Uber、Facebook、Amazon、腾讯等知名互联网公司工作经验……大概能是真的，就是会让人觉得开发之后已经换了一波人一样，再说，腾讯出来的人写个前端页面不难吧。\n化再浓的妆也没用\n说实在的，我对用户群体的评估可能有点失误，真的有人告诉我他们的室友下载了这个 app，后一句就是喜闻乐见的“想通过这玩意脱单”。\n你看看你看看，人家用户都摆明了自己的需求了，就不要写什么“将宇宙中的一座座孤岛连成一片星海”了。单身直男们也该思索一下了，为什么夏目漱石说“今晚月色真美”是表白，你说“今晚月色真美”就是华为 P30 Pro 的高端黑了，你看王跃琨一下子就被开除了！太晦涩难懂是没法脱单的！\n真的别再卖情怀了\n\n首先谢谢你打开这封信。\n我们是交大的一个学生团队，我们和你们一样在学活吃渔粉，在球场踢足球，在傍晚看明湖鸭。\nDreamua 诞生于高校宿舍。\n\n说真的，你这样会让别人对我们宿舍里这些同学的真实水平产生怀疑——我觉得我们学校的教学还是偏重于踏踏实实讲理论老老实实学技术的，而不是选个厮杀的你死我活的行业（你们的对手不止有探探陌陌 Soul 其实还有微信微博 QQ 甚至是 Bilibili 和网易云音乐）来创业，写一堆乱七八糟的文案卖情怀。\n以前好像有个公众号，文案风格如出一辙，也挺喜欢卖情怀的，结果清华评特奖的时候从简历上被人挖出来婊说实际什么都没做。我觉得这种事情给人留的印象挺差的，所以还是别卖情怀了，能好好写文案就好好写，写不出来吃点渔粉踢会足球看看鸭鸭可能就有灵感了。\n百度没收录我的网站\n最近那个《旗帜鲜明地反对李彦宏当选工程院院士》挺出名的。我先声明一下，我的网站放在地球上，谷歌必应收录了我的网站而百度搜狗 360 这种辣鸡引擎没有收录，特别是百度还把山寨我的站点放在了搜索结果首页上。所以百度和我没什么利害关系，我也就不担心这种问题。\n至于腾讯封了《旗帜鲜明》我也不太担心，毕竟我这人不喜欢用微信，连公众号都没有，生平最烦举报党。\n我恰好想起来今天是我建站的三周年零一个月整，114777 个点击量不算太多，但从第一篇文章开始我的目标就没变过——“网站是属于自己的，我可以记录我自己的想法，喜不喜欢则是你的自由”。\n\n更新（2020-10-23）：https://www.dreamua.com/ 已经打不开了，看来应该是创业失败了吧！而我的网站依然活着，是不是可以大声说出那句“你死我都未死！”了。\n"},{"title":"Magnolia","url":"/posts/Magnolia/","content":"我不是很能欣赏花。\n我的鼻子并不懂得花的芬芳，我的眼睛也不是很了解花的鲜艳，大部分情况下，我连分辨花种类的兴趣都没有。但我今天还是认出了一株玉兰树，就像我高中楼门口两侧的玉兰树一样，开着白色花瓣。\n为什么认得玉兰花，说起来是个很奇怪的理由，有一段时间我非常喜欢一款包装上印着玉兰花的纸巾，我很喜欢那款包装的着色效果，虽然只是塑料制品，但是有着独特的温润感觉。另外大概也有原因是它味道并不是很浓，太浓的味道让我觉得不舒服。如果我没记错的话，我现在应该还留着一份包装。\n当然这件事并没有一个好的结尾，就像所有烂俗的青春文学一样。后来那款纸巾更换了新的包装，虽然还是玉兰为主题，但配色完全没有了之前的灵性。我以为我自己已经很缺乏审美了，但这个新包装的设计者不但缺乏审美，还能跑出来危害大家的眼睛。\n\n我总是记不住玉兰的英文该怎么拼，说错了，我大概对计算机之外的一切英文名词都不怎么敏感。多半是因为我并不会读这个单词，Mangolia，不对，Magnolia，有时候我会分不清楚“玉兰”和“蒙古”。\n我们先不谈有着伤感结局的玉兰纸巾，先谈真正的玉兰花。大概没有太多人注意高中校园里的两颗玉兰树，毕竟高中生每天有太多无聊的事情要考虑了，谁也没有那个闲情逸致，况且大多数人也没兴趣了解植物的区别，也包括我。\n虽然春天刮风是这个城市主要的气候，但是也并不是十分的没有人情味。通常来说，玉兰花开放的季节，也是会无缘无故落下一场春雨的时候。总是能有一些日子，可以在操场上的水洼里看见夕阳的倒影。也能在树下看到随着雨点一并落下的花瓣。什么时候树上的花瓣都落光了，什么时候有人情味的春天也就结束了。\n\n我不知道玉兰的花语是什么，因此很遗憾，我也没有办法围绕玉兰花的花语写出一篇小说。不过反正这些东西都是人附加的含义而已，就算某种花的花语是遇见幸福，那就真的能让人遇见幸福了吗？\n有些时候还是不要太相信那些本来是讲给小孩子听的话比较好，毕竟童话其实是大人写给大人看的。\n曾经我以为好搭档就应该永不分离，曾经我以为故事就应该十全十美，但后来我才知道，不完美的才更真实，大家都是成年人，就应该尊重每个人不同的决定，哪有那么多地方可以耍小孩子气。\n\n我最近在学日语，也算是终于开始实现很久以来的心愿了吧。\n老师说日语里面并不会经常重复主语，比如第一句话里已经说过主语是“我”，第二句就没必要再重复了。\n有时候我写东西也是这样，不太愿意在句子里说“我”，宁愿直接把主语省略掉，大概是欺骗自己，删掉主语的话，就可以当成是在说另一个人，写出来就不会觉得那么不好意思。\n\n“灵气”并不是什么容易获得的东西。有时候写了很多却完全不知道自己在说什么，我好像没有能把自己若无其事地叙述出来的能力。逐渐地就开始理解，物质是多么拖累精神的东西。反正喜欢的话总是被别人说出口了，电影台词也好歌词也罢，阅读总是比自己创作要舒服得多。\n只是很想听别人的故事而已，仿佛是身处不知何处的梦境中一样。身边的事物太吵闹了，生活太无聊，也太寒冷。\n四月只是春天的谎言吧，这样寒冷的日子，春天真的来了吗？还是说开放的 Magnolia，只是春天的假象而已呢？\n"},{"title":"如何编写 Android 视频壁纸","url":"/posts/Android-Video-Wallpaper-Implementation/","content":"最近我编写了一个 Android 的视频壁纸应用（GitHub Repo），一开始觉得并没有什么难写的地方，应该很快就可以写出来，但是后来发现我想的太简单了。或许你也看过许多写视频壁纸的教程，但我发现他们都有一些问题，写出来的程序基本不能用，所以我打算在这里写一下如何编写一个可以发布的 Android 视频壁纸，而不是一个 demo。\n首先你需要新建一个项目，通常应该都是使用 Android Studio，我就忽略你如何与网络搏斗让 Android Studio 可用了。什么程序模板可以随便选，反正你还是要自己新建 class。最低 API 版本不要太低就行，如果你只是写一个动态壁纸，应该不需要很高的 API，因为 Android 很早就支持动态壁纸了（但为了让开发方便一点，建议只考虑 5.0 以上的就行了，反正现在即使有 5.0 以下的机器多半也成垃圾了）。\n首先让系统知道你是个壁纸……\n在开始写逻辑代码之前，我们还得先让系统知道你写的这是个壁纸，这需要一些起声明作用的代码。先把你 Android Studio 左侧侧栏调成文件树模式（忘了什么诡异的分类模式吧，不然你连文件在哪都找不到，试图隐藏细节只会让事情变的更复杂）。\n应该有一个自动生成的 AndroidManifest.xml 文件，我觉得你应该比我更了解这玩意是干嘛的，因为实际上我没系统地研究过 Android，我只是会一点点 Java 而已——然后靠不停的查看手册来自学。加上这两行：\n\n\n\" data-info=\"language-xml\" data-lang=\"xml\" class=\"code-block\">&lt;uses-permission android:name=\"android.permission.SET_WALLPAPER\" /&gt;\n&lt;uses-feature\n  android:name=\"android.software.live_wallpaper\"\n  android:required=\"true\" /&gt;\n\n你要是能把这一行加到 &lt;manifest&gt; 标签外面，我觉得你也没必要写代码了……但这个要放在 &lt;application&gt; 标签的外面，具体放在哪直接翻我仓库里的文件好了，比我用文字描述清晰的多。\n但是下面这个要加到 &lt;application&gt; 标签的里面：\n\n  \n    \n  \n  \n\n\" data-info=\"language-xml\" data-lang=\"xml\" class=\"code-block\">&lt;service\n  android:name=\".GLWallpaperService\"\n  android:permission=\"android.permission.BIND_WALLPAPER\"&gt;\n  &lt;intent-filter&gt;\n    &lt;action android:name=\"android.service.wallpaper.WallpaperService\" /&gt;\n  &lt;/intent-filter&gt;\n  &lt;meta-data\n    android:name=\"android.service.wallpaper\"\n    android:resource=\"@xml/wallpaper\" /&gt;\n&lt;/service&gt;\n\n看到那个叫 android:name 的属性了吗，. 不能少，后面的名字则是一会你的 class 叫什么名字这里就什么名字。然后别的都是套路，可以不管，除了那个 @xml/wallpaper，这玩意要自己写。\n在你侧栏找到 res 目录，如果底下没有叫 xml 的目录的话，你就新建一个。然后你刚才那里斜杠后面是什么你就建立一个叫什么的 xml 文件，比如我写了 @xml/wallpaper，那就建立一个 wallpaper.xml 的文件，内容如下：\n\n\n\" data-info=\"language-xml\" data-lang=\"xml\" class=\"code-block\">&lt;?xml version=\"1.0\" encoding=\"UTF-8\"?&gt;\n&lt;wallpaper\n  xmlns:android=\"http://schemas.android.com/apk/res/android\"\n  android:thumbnail=\"@drawable/ic_icon\"\n  android:description=\"@string/wallpaper_description\"\n  android:settingsActivity=\"xyz.alynx.livewallpaper.MainActivity\" /&gt;\n\n这里面也有些地方需要改，首先那个 @drawable/ic_icon 是你这个程序在系统的动态壁纸列表里面的图标（然而大部分定制的系统都忽略了这玩意），你可以用 Android Studio 在 res/drawable 目录下面生成一个，这个可以自己去查官方文档，ic_icon 要换成你生成的文件的名字。@string/wallpaper_description 则是你的程序在列表里的名字或介绍，这个格式的意思是去 res/values/strings.xml 或者其他语言的 strings.xml 里面找字符串（写过点复杂程序的人应该都懂吧！不懂自学去！我这不是教你怎么写代码！），你可以用 Android Studio 的字符串编辑器添加一条，然后把这里的键改成你添加的。最后一个 settingsActivity 是干嘛的呢？在预览动态壁纸的页面会有个设置按钮，如果你的动态壁纸可以设置参数，点击那个按钮会跳进动态壁纸设置页面，设置页面就是在这里指定的类，因为我设置项不多直接放菜单里了，所以我写的是直接跳进 MainActivity，你写的时候也可以这样，但记得换成你的包名（不换的人我怀疑你没写过 Java，那你学个毛 Android App 开发？）。\n废话终于说完了可以写代码了好开心啊……\n然后你看见网上一堆胶水代码……\n如果你找过网上的教程，你会觉得视频壁纸是很简单的，他们都告诉你播放视频有现成的组件，你只要写几行胶水代码把它和壁纸连起来就行了……\n行个头！要是那么简单我就不用写这一篇文章了好吗？\n我们先从最基础的知识开始，动态壁纸实际上是一个 Service，你去实现一个 Service，然后系统来调用，你需要继承一个叫做 WallpaperService 的类，实现里面的一些方法。但实际上这个类只是个包装，真正的工作代码是里面一个叫 Engine 的类，具体的动态壁纸实现都是在它里面。\n所以你需要建立一个随便你爱叫什么的 java 文件（我不会用 Kotlin，但你愿意的话也可以用）来存放你自己的 Service……或者用 Android Studio 的向导也不错。比如我的就叫 GLWallpaperService，里面的代码看起来大概像这样：\npackage xyz.alynx.livewallpaper;\n\nimport android.service.wallpaper.WallpaperService;\n\npublic class GLWallpaperService extends WallpaperService {\n    public class GLWallpaperEngine extends Engine {\n\n    }\n\n    @Override\n    public Engine onCreateEngine() {\n        return new GLWallpaperEngine();\n    }\n}\n\n所以你看，你只需要实现 WallpaperService 的一个方法，就是在系统需要 Engine 的时候，返回你自己的 Engine 实例。\n然后你需要在你的 Engine（我的是 GLWallpaperEngine）里面实现一些系统要求的方法，我们先说最基础的几个：\n        @Override\n        public void onSurfaceCreated(SurfaceHolder surfaceHolder) {\n            super.onSurfaceCreated(surfaceHolder);\n        }\n\n        @Override\n        public void onVisibilityChanged(boolean visible) {\n            super.onVisibilityChanged(visible);\n        }\n\n        @Override\n        public void onSurfaceDestroyed(SurfaceHolder holder) {\n           super.onSurfaceDestroyed(holder);\n        }\n        @Override\n        public void onOffsetsChanged(\n            float xOffset, float yOffset,\n            float xOffsetStep, float yOffsetStep,\n            int xPixelOffset, int yPixelOffset\n        ) {\n            super.onOffsetsChanged(\n                xOffset, yOffset, xOffsetStep,\n                yOffsetStep, xPixelOffset, yPixelOffset\n            );\n        }\n\nAndroid 的动态壁纸实际上是一个 Surface（Android 的 Surface 你可以理解为画布……大概吧），系统调用你的 Engine，然后给你一个可以绘制的 SurfaceHolder，你只要往这个 SurfaceHolder 里面的 Surface 画东西就行了。onSurfaceCreated() 表示画布准备好了，onVisibilityChanged() 则表示画布看不见了或者又出来了，onSurfaceDestroyed() 表示画布没了（我不是你英语老师！），onOffsetsChanged() 则是桌面通知壁纸偏移量，通常是实现壁纸随桌面滚动的效果。\n不要把他们和 onCreate() 和 onDestoryed() 搞混了，那俩说的是 Engine，但我们做绘制工作时候操心的是画布。\n为什么说是个胶水代码呢……因为 Android 内建了一个播放器叫 MediaPlayer，它有个方法叫 setSurface()，可以把视频渲染到画布上，然后我们就美滋滋地看……到 bug。\n怎么设置 MediaPlayer 我就不讲了，因为网上有很多现成的，总之就是实例化设置静音设置来源再 setSurface(getSurfaceHolder().getSurface())（getSurfaceHolder() 是 Engine 的，可以获取当前的 SurfaceHolder），然后开始播放，你需要在 onSurfaceCreated() 的时候弄出个播放器，onSurfaceDestroyed() 的时候 release() 掉播放器，然后在由可见变成不可见的时候保存进度删掉播放器（其实暂停也行，但是暂停仍然占据着资源），在可见时候建立播放器并回复到之前的进度（如果看不见了还一直播，你手机的电池估计坚持不了几小时）。反正代码网上一大堆，我就不贴了。\n然后你就可以运行这个程序啦，如果你用的模拟器，你就美滋滋的发现，你的视频被硬生生压缩或拉伸成了桌面比例，视频比例没了。大部分视频应该都是横向更长一点，而手机竖向长，所以……\n如果你开始翻文档，你会发现一个 setVideoScalingMode() 的方法，可以设置为 FIT_WITH_CROPPING，你想着这不就是我们要的效果嘛：把视频放屏幕中间，然后裁出正中间最大的一块……当然不行啦！不然我写什么！\n很高兴告诉你这个其实是包装了系统的 MediaCodec 的参数，但这个实现及其不靠谱，比如在模拟器上这个功能根本就没实现！在我的 Galaxy S9+ 上虽然实现了，但在切换界面的时候（比如锁屏啦，打开应用界面啦），会突然变成没有剪裁的，总之你会看到一个奇怪的切换（这哪个程序员写的出来祭天）。\n所以如果你想正确剪裁视频的话……当然要自己动手！Android 的 View 是可以设置尺寸缩放没错，但是别忘了，这里只有 Surface，没有 View，所以没戏。用 CPU 剪裁视频的话（剪裁不是剪辑！）怕是你一帧剪完那边 30 帧都播完了，解决方案就是用 OpenGL ES 来进行这个操作。\n明明是杀鸡，还是得掏牛刀！\n如果你比较熟悉 OpenGL 的话，其实这个剪裁本身的难度比构建一个 OpenGL 环境的难度低的多……只要算一下比例做个缩放矩阵就行了，视频是矩形所以我们就先弄个正方形出来然后把每一帧解码的画面当成纹理贴上去。\n解码的工作我们可以交给 MediaPlayer，然后因为性能需要，我们不能每一帧都保存为图片然后再转读为 OpenGL 的纹理，Android 有一个 OpenGL 的扩展，可以把视频解码到的 Surface 作为一个纹理，大部分机型都支持，不支持的机型其实也可以告别动态壁纸了，啥都不支持，你还不给我钱，我为啥要给你写代码？\n但是这个过程需要一些 hack。\n显然系统不给你现成的 GLSurfaceView，而只是一个 SurfaceHolder，所以正常的思路是自己弄 GLThread 白手起家……并不，我们在 Engine 里插入下面的代码：\n        private class GLWallpaperSurfaceView extends GLSurfaceView {\n            private static final String TAG = \"GLWallpaperSurface\";\n\n            public GLWallpaperSurfaceView(Context context) {\n                super(context);\n            }\n\n            @Override\n            public SurfaceHolder getHolder() {\n                return getSurfaceHolder();\n            }\n\n            public void onDestroy() {\n                super.onDetachedFromWindow();\n            }\n        }\n\n看到那个 getHolder() 了吗？没错！GLSurfaceView 似乎内部自己有个 SurfaceHolder，我们 hack 一下它，扩展出一个 GLWallpaperSurfaceView，因为它是个内部类，当它要往自己的 SurfaceHolder 上渲染时，我们偷梁换柱，返回它 Engine.getSurfaceHolder()，可怜的家伙还蒙在鼓里，自己内部的 SurfaceHolder 已经没用了，现在它画的实际上是桌面壁纸的 Surface。\n我也不知道谁先想出的这个套路，但第一个想出这个办法的老哥可真是个天才。\n至于你问 Context 从哪来？Service 就是个 Context，所以你改写一下 Engine 的构造函数把 Service 传进去，然后创建 GLWallpaperSurfaceView 时传给它就行了，基本操作基本操作。\n然后我们就可以实现 GLSurfaceView.Renderer 了，这是个已经包装好的 OpenGL Renderer，我们只要负责在里面画就行了。至于 GLES 的版本，如果你想省事，只用 v2 就好了，除非像我一样对 v3 很熟悉，当然我 v2 v3 都写了 Renderer，能支持 v3 的先调用 v3（除了模拟器不支持，手机基本都支持了）。我这里用 v3 做例子，你应该自己也会写 v2 吧？如果你只会写固定管线，请你不要说自己会 OpenGL。\n当然最好在 AndroidManifest.xml 声明一下你这个应用需要的 GLES 版本：\n\n\" data-info=\"language-xml\" data-lang=\"xml\" class=\"code-block\">&lt;uses-feature\n  android:glEsVersion=\"0x00020000\"\n  android:required=\"true\" /&gt;\n\n如果你只支持 v3，就把 2 改成 3。\n（已经进入这个阶段了，打算拿这个当“我的第一个 Android App”的同学就自己退出吧。）\n我直接帖完整代码解释好了，因为实在是不好一点点讲了，反正会写 OpenGL 的人应该也能理解这种程度的代码了。\n我先把 Shader 代码贴上来，我是把它们放在 res/raw 下面的，Android 会自动生成 R.raw. 的 ID。\n\nvertex shader\n\n#version 300 es\n\n// Some Android phone driver needs to put pre-processor in the first line. (e.g. Huawei Kirin)\n\nlayout(location = 0) in vec2 in_position;\nlayout(location = 1) in vec2 in_tex_coord;\n\nuniform mat4 mvp;\n\nout vec2 tex_coord;\n\nvoid main() {\n    gl_Position = mvp * vec4(in_position, 1.0f, 1.0f);\n    tex_coord = in_tex_coord;\n}\n\n应该在你画三角形的时候就学会这玩意了吧？注意有些手机的实现要求第一行不能是注释，否则会报错。\n\nfragment shader\n\n#version 300 es\n#ifdef GL_OES_EGL_image_external_essl3\n#extension GL_OES_EGL_image_external_essl3 : require\n#else\n#extension GL_OES_EGL_image_external : require\n#endif\n\n// Some Android phone driver needs to put pre-processor in the first line. (e.g. Huawei Kirin)\n\nprecision mediump float;\n\nuniform samplerExternalOES frame;\n\nin vec2 tex_coord;\nout vec4 frag_color;\n\nvoid main() {\n    frag_color = texture(frame, tex_coord);\n}\n\n注意开头的判断！本来在 GLESv3 里面，对这种 Android 直接拿 Surface 当纹理的 flag 是 GL_OES_EGL_image_external_essl3，GL_OES_EGL_image_external 已经被废弃了，但有些支持 GLESv3 的手机只支持旧的表达方法，所以我们判断一下就好。GLESv2 则直接用后面那个，不要用前面的。\n同样，纹理类型也要换成 samplerExternalOES，别的都没关系了。\n编译和链接的过程我直接写成工具函数了，连同加载 res/raw 的代码，我贴上来：\nstatic int compileShaderResourceGLES30(\n    @NonNull Context context,\n    final int shaderType,\n    final int shaderRes\n) throws RuntimeException {\n    final InputStream inputStream = context.getResources().openRawResource(shaderRes);\n    final BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream));\n    String line;\n    final StringBuilder stringBuilder = new StringBuilder();\n    try {\n        while ((line = bufferedReader.readLine()) != null) {\n            stringBuilder.append(line);\n            stringBuilder.append('\\n');\n        }\n    } catch (IOException e) {\n        e.printStackTrace();\n        return 0;\n    }\n    final String shaderSource = stringBuilder.toString();\n    int shader = GLES30.glCreateShader(shaderType);\n    if (shader == 0) {\n        throw new RuntimeException(\"Failed to create shader\");\n    }\n    GLES30.glShaderSource(shader, shaderSource);\n    GLES30.glCompileShader(shader);\n    final int[] status = new int[1];\n    GLES30.glGetShaderiv(shader, GLES30.GL_COMPILE_STATUS, status, 0);\n    if (status[0] == 0) {\n        final String log = GLES30.glGetShaderInfoLog(shader);\n        GLES30.glDeleteShader(shader);\n        throw new RuntimeException(log);\n    }\n    return shader;\n}\n\nstatic int linkProgramGLES30(\n    final int vertShader,\n    final int fragShader\n) throws RuntimeException {\n    int program = GLES30.glCreateProgram();\n    if (program == 0) {\n        throw new RuntimeException(\"Failed to create program\");\n    }\n    GLES30.glAttachShader(program, vertShader);\n    GLES30.glAttachShader(program, fragShader);\n    GLES30.glLinkProgram(program);\n    final int[] status = new int[1];\n    GLES30.glGetProgramiv(program, GLES30.GL_LINK_STATUS, status, 0);\n    if (status[0] == 0) {\n        final String log = GLES30.glGetProgramInfoLog(program);\n        GLES30.glDeleteProgram(program);\n        throw new RuntimeException(log);\n    }\n    return program;\n}\n\n当然还是那个令人头疼的问题，你需要传个 Context，这个问题不大，Engine 创建 Renderer 的时候传给它就好了。\nRenderer 则是按下面的写：\npackage xyz.alynx.livewallpaper;\n\nimport android.content.Context;\nimport android.opengl.GLSurfaceView;\nimport android.support.annotation.NonNull;\n\npublic class GLWallpaperRenderer implements GLSurfaceView.Renderer {\n    private static final int BYTES_PER_FLOAT = 4;\n    private static final int BYTES_PER_INT = 4;\n    private Context context;\n    private FloatBuffer vertices;\n    private FloatBuffer texCoords;\n    private IntBuffer indices;\n    private float[] mvp;\n    private int program = 0;\n    private int mvpLocation = 0;\n    private int[] textures;\n    private SurfaceTexture surfaceTexture = null;\n    private int screenWidth = 0;\n    private int screenHeight = 0;\n    private int videoWidth = 0;\n    private int videoHeight = 0;\n    private int videoRotation = 0;\n    private float xOffset = 0;\n    private float yOffset = 0;\n    private long updatedFrame = 0;\n    private long renderedFrame = 0;\n\n上面这部份的变量都是一会要用到的，一会再说。\n    GLWallpaperRenderer(@NonNull final Context context) {\n        this.context = context;\n\n        // Those replaced glGenBuffers() and glBufferData().\n        final float[] vertexArray = {\n            // x, y\n            // bottom left\n            -1.0f, -1.0f,\n            // top left\n            -1.0f, 1.0f,\n            // bottom right\n            1.0f, -1.0f,\n            // top right\n            1.0f, 1.0f\n        };\n        vertices = ByteBuffer.allocateDirect(\n            vertexArray.length * BYTES_PER_FLOAT\n        ).order(ByteOrder.nativeOrder()).asFloatBuffer();\n        vertices.put(vertexArray).position(0);\n\n        final float[] texCoordArray = {\n            // u, v\n            // bottom left\n            0.0f, 1.0f,\n            // top left\n            0.0f, 0.0f,\n            // bottom right\n            1.0f, 1.0f,\n            // top right\n            1.0f, 0.0f\n        };\n        texCoords = ByteBuffer.allocateDirect(\n            texCoordArray.length * BYTES_PER_FLOAT\n        ).order(ByteOrder.nativeOrder()).asFloatBuffer();\n        texCoords.put(texCoordArray).position(0);\n\n        final int[] indexArray = {\n            0, 1, 2,\n            3, 2, 1\n        };\n        indices = ByteBuffer.allocateDirect(\n            indexArray.length * BYTES_PER_INT\n        ).order(ByteOrder.nativeOrder()).asIntBuffer();\n        indices.put(indexArray).position(0);\n\n        textures = new int[1];\n\n        mvp = new float[] {\n            1.0f, 0.0f, 0.0f, 0.0f,\n            0.0f, 1.0f, 0.0f, 0.0f,\n            0.0f, 0.0f, 1.0f, 0.0f,\n            0.0f, 0.0f, 0.0f, 1.0f\n        };\n    }\n\n    @NonNull\n    public Context getContext() {\n        return context;\n    }\n\n这部份很好懂吧？先处理 Context，然后我们准备 OpenGL 需要的坐标，正方形的模型坐标，纹理坐标，以及一个索引 buffer，不同的是在 Android 里面不用写 glGenBuffers、glBindBuffer 了，我们用 java 的 nio 准备数组，然后 Android 可以直接加载这里面的 buffer。（但是 mvp 矩阵仍然是数组。）\n当然，这里是没有 OpenGL 的上下文的，所以我们现在不能处理 OpenGL 的设置，需要实现下面这些方法：\n    @Override\n    public void onSurfaceCreated(GL10 gl10, EGLConfig eglConfig) {\n        // No depth test for 2D video.\n        GLES30.glDisable(GLES30.GL_DEPTH_TEST);\n        GLES30.glDepthMask(false);\n        GLES30.glDisable(GLES30.GL_CULL_FACE);\n        GLES30.glDisable(GLES30.GL_BLEND);\n\n        GLES30.glActiveTexture(GLES30.GL_TEXTURE0);\n        GLES30.glGenTextures(textures.length, textures, 0);\n        GLES30.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textures[0]);\n        GLES30.glTexParameteri(\n            GLES11Ext.GL_TEXTURE_EXTERNAL_OES,\n            GLES30.GL_TEXTURE_MIN_FILTER,\n            GLES30.GL_LINEAR\n        );\n        GLES30.glTexParameteri(\n            GLES11Ext.GL_TEXTURE_EXTERNAL_OES,\n            GLES30.GL_TEXTURE_MAG_FILTER,\n            GLES30.GL_LINEAR\n        );\n        GLES30.glTexParameteri(\n            GLES11Ext.GL_TEXTURE_EXTERNAL_OES,\n            GLES30.GL_TEXTURE_WRAP_S,\n            GLES30.GL_CLAMP_TO_EDGE\n        );\n        GLES30.glTexParameteri(\n            GLES11Ext.GL_TEXTURE_EXTERNAL_OES,\n            GLES30.GL_TEXTURE_WRAP_T,\n            GLES30.GL_CLAMP_TO_EDGE\n        );\n\n        program = Utils.linkProgramGLES30(\n            Utils.compileShaderResourceGLES30(\n                context, GLES30.GL_VERTEX_SHADER, R.raw.vertex_30\n            ),\n            Utils.compileShaderResourceGLES30(\n                context, GLES30.GL_FRAGMENT_SHADER, R.raw.fragment_30\n            )\n        );\n        mvpLocation = GLES30.glGetUniformLocation(program, \"mvp\");\n        // Position is set in shader sources.\n        GLES30.glVertexAttribPointer(\n            0, 2, GLES30.GL_FLOAT,\n            false, 0, vertices\n        );\n        GLES30.glVertexAttribPointer(\n            1, 2, GLES30.GL_FLOAT,\n            false, 0, texCoords\n        );\n\n        GLES30.glEnableVertexAttribArray(0);\n        GLES30.glEnableVertexAttribArray(1);\n    }\n\n    @Override\n    public void onSurfaceChanged(GL10 gl10, int width, int height) {\n        GLES30.glViewport(0, 0, width, height);\n    }\n\n    @Override\n    public void onDrawFrame(GL10 gl10) {\n        if (surfaceTexture == null) {\n            return;\n        }\n\n        if (renderedFrame &lt; updatedFrame) {\n            surfaceTexture.updateTexImage();\n            ++renderedFrame;\n            // Utils.debug(\n            //     TAG, \"renderedFrame: \" + renderedFrame + \" updatedFrame: \" + updatedFrame\n            // );\n        }\n\n        GLES30.glClearColor(0.0f, 0.0f, 0.0f, 1.0f);\n        GLES30.glClear(GLES30.GL_COLOR_BUFFER_BIT);\n        GLES30.glUseProgram(program);\n        GLES30.glUniformMatrix4fv(mvpLocation, 1, false, mvp, 0);\n        GLES30.glDrawElements(GLES30.GL_TRIANGLES, 6, GLES30.GL_UNSIGNED_INT, indices);\n    }\n\n这部份方法看名字就能看出来，需要注意的是那个 gl10 是 GLESv1 的用法，至于 v2 和 v3 都改成静态类了，所以我们用不到它。既然是 2D 贴图，就不需要深度测试和背面剔除了。然后设置纹理参数时需要用 GLES11Ext.GL_TEXTURE_EXTERNAL_OES，这个和 shader 里面是关联的。（哪个家伙把 GLESv2 和 v3 都支持的变量归属到 GLES11Ext 的，出来挨打！），别的地方会 OpenGL 的人都能看懂吧？最后，只有这三个函数有 OpenGL 上下文，其它的里面不能执行 OpenGL 操作。\n绘图时候 设置 mvp 矩阵不要用 nio 的 ByteBuffer，虽然有一个多态函数可以用，但似乎那个函数不能工作，所以我直接使用了 float 数组。\n下面是一些针对这个程序用到的方法。\n    @Override\n    void setSourcePlayer(@NonNull final SimpleExoPlayer exoPlayer) {\n        // Re-create SurfaceTexture when getting a new player.\n        // Because maybe a new video is loaded.\n        createSurfaceTexture();\n        exoPlayer.setVideoSurface(new Surface(surfaceTexture));\n    }\n\n这里是让播放器把解码的目标位置设置成 Renderer 里面和 OpenGL 纹理绑定的 SurfaceTexture 上，这个类是 Android 专门设置用来干这个的，可以阅读手册。\n为什么是把播放器给 Renderer 让 Renderer 设置呢？因为 Engine 的生命周期里只用了一个 Renderer 但会经常释放重建 Player，每次都要重新建立对应视频大小的 SurfaceTexture，所以不能让 Renderer 暴露 SurfaceTexture 出去。\n我这里没有用 MediaPlayer，而是用了 Google 的 ExoPlayer 这个项目，具体原因一会再说。\n    @Override\n    void setScreenSize(int width, int height) {\n        if (screenWidth != width || screenHeight != height) {\n            screenWidth = width;\n            screenHeight = height;\n            Utils.debug(TAG, String.format(\n                Locale.US, \"Set screen size to %dx%d\", screenWidth, screenHeight\n            ));\n            updateMatrix();\n        }\n    }\n\n    @Override\n    void setVideoSizeAndRotation(int width, int height, int rotation) {\n        // MediaMetadataRetriever always give us raw width and height and won't rotate them.\n        // So we rotate them by ourselves.\n        if (rotation % 180 != 0) {\n            final int swap = width;\n            width = height;\n            height = swap;\n        }\n        if (videoWidth != width || videoHeight != height || videoRotation != rotation) {\n            videoWidth = width;\n            videoHeight = height;\n            videoRotation = rotation;\n            Utils.debug(TAG, String.format(\n                Locale.US, \"Set video size to %dx%d\", videoWidth, videoHeight\n            ));\n            Utils.debug(TAG, String.format(\n                Locale.US, \"Set video rotation to %d\", videoRotation\n            ));\n            updateMatrix();\n        }\n    }\n\n因为要计算缩放，所以我这里设置了两个方法，分别获取屏幕尺寸和视频尺寸。然后只要在每次尺寸变化时候重新计算矩阵就行了，因为不是经常变化，所以就不用每次绘制都重新计算。\n maxXOffset) {\n            xOffset = maxXOffset;\n        }\n        if (xOffset  maxYOffset) {\n            yOffset = maxYOffset;\n        }\n        if (yOffset     @Override\n    void setOffset(float xOffset, float yOffset) {\n        final float maxXOffset = (1.0f - (\n            (float)screenWidth / screenHeight) / ((float)videoWidth / videoHeight)\n        ) / 2;\n        final float maxYOffset = (1.0f - (\n            (float)screenHeight / screenWidth) / ((float)videoHeight / videoWidth)\n        ) / 2;\n        if (xOffset &gt; maxXOffset) {\n            xOffset = maxXOffset;\n        }\n        if (xOffset &lt; -maxXOffset) {\n            xOffset = -maxXOffset;\n        }\n        if (yOffset &gt; maxYOffset) {\n            yOffset = maxYOffset;\n        }\n        if (yOffset &lt; -maxXOffset) {\n            yOffset = -maxYOffset;\n        }\n        if (this.xOffset != xOffset || this.yOffset != yOffset) {\n            this.xOffset = xOffset;\n            this.yOffset = yOffset;\n            Utils.debug(TAG, String.format(\n                Locale.US, \"Set offset to %fx%f\", this.xOffset, this.yOffset\n            ));\n            updateMatrix();\n        }\n    }\n\n桌面在滑动的时候会调用 Engine 的方法告诉它滑动的值，为了实现视频随桌面平移的效果，我们可以在 Engine 里把值传给 Renderer，但我们要限制一下，偏移的位置不能超出视频尺寸，否则就出黑边了。\n    private void createSurfaceTexture() {\n        if (surfaceTexture != null) {\n            surfaceTexture.release();\n            surfaceTexture = null;\n        }\n        updatedFrame = 0;\n        renderedFrame = 0;\n        surfaceTexture = new SurfaceTexture(textures[0]);\n        surfaceTexture.setDefaultBufferSize(videoWidth, videoHeight);\n        surfaceTexture.setOnFrameAvailableListener(new SurfaceTexture.OnFrameAvailableListener() {\n            @Override\n            public void onFrameAvailable(SurfaceTexture surfaceTexture) {\n                ++updatedFrame;\n            }\n        });\n    }\n\n需要注意这个函数里面的回调，为什么要设置一个计数器，而不是直接在这里执行 SurfaceTexture.updateTexImage() 呢？因为后者要求必须有 OpenGL 上下文……有上下文的只有之前的几个方法，其它是没有的。所以为什么这里用了两个计数器而不是一个 bool 变量呢（请自己回看 onDrawFrame() 函数）？因为这个 SurfaceTexture 内部似乎有排队机制，然后如果队列里有内容的话是不会调回调的……假如某一次传来了多于一帧，用 bool 变量翻转的时候就只能解决第一帧，后续视频就会卡住（这都是什么神仙写的 bug 啊喂！）。\n= screenRatio) {\n            Utils.debug(TAG, &quot;X-cropping&quot;);\n            // Treat video and screen width as 1, and compare width to scale.\n            Matrix.scaleM(\n                mvp, 0,\n                ((float)videoWidth / videoHeight) / ((float)screenWidth / screenHeight),\n                1, 1\n            );\n            // Some video recorder save video frames in direction differs from recoring,\n            // and add a rotation metadata. Need to detect and rotate them.\n            if (videoRotation % 360 != 0) {\n                Matrix.rotateM(mvp, 0, -videoRotation, 0,0, 1);\n            }\n            Matrix.translateM(mvp, 0, xOffset, 0, 0);\n        } else {\n            Utils.debug(TAG, &quot;Y-cropping&quot;);\n            // Treat video and screen height as 1, and compare height to scale.\n            Matrix.scaleM(\n                mvp, 0, 1,\n                ((float)videoHeight / videoWidth) / ((float)screenHeight / screenWidth), 1\n            );\n            // Some video recorder save video frames in direction differs from recoring,\n            // and add a rotation metadata. Need to detect and rotate them.\n            if (videoRotation % 360 != 0) {\n                Matrix.rotateM(mvp, 0, -videoRotation, 0,0, 1);\n            }\n            Matrix.translateM(mvp, 0, 0, yOffset, 0);\n        }\n        // This is a 2D center crop, so we only need model matrix, no view and projection.\n    }\n}\n\" class=\"code-block\">    private void updateMatrix() {\n        // Players are buggy and unclear, so we do crop by ourselves.\n        // Start with an identify matrix.\n        for (int i = 0; i &lt; 16; ++i) {\n            mvp[i] = 0.0f;\n        }\n        mvp[0] = mvp[5] = mvp[10] = mvp[15] = 1.0f;\n        // OpenGL model matrix: scaling, rotating, translating.\n        final float videoRatio = (float)videoWidth / videoHeight;\n        final float screenRatio = (float)screenWidth / screenHeight;\n        if (videoRatio &gt;= screenRatio) {\n            Utils.debug(TAG, \"X-cropping\");\n            // Treat video and screen width as 1, and compare width to scale.\n            Matrix.scaleM(\n                mvp, 0,\n                ((float)videoWidth / videoHeight) / ((float)screenWidth / screenHeight),\n                1, 1\n            );\n            // Some video recorder save video frames in direction differs from recoring,\n            // and add a rotation metadata. Need to detect and rotate them.\n            if (videoRotation % 360 != 0) {\n                Matrix.rotateM(mvp, 0, -videoRotation, 0,0, 1);\n            }\n            Matrix.translateM(mvp, 0, xOffset, 0, 0);\n        } else {\n            Utils.debug(TAG, \"Y-cropping\");\n            // Treat video and screen height as 1, and compare height to scale.\n            Matrix.scaleM(\n                mvp, 0, 1,\n                ((float)videoHeight / videoWidth) / ((float)screenHeight / screenWidth), 1\n            );\n            // Some video recorder save video frames in direction differs from recoring,\n            // and add a rotation metadata. Need to detect and rotate them.\n            if (videoRotation % 360 != 0) {\n                Matrix.rotateM(mvp, 0, -videoRotation, 0,0, 1);\n            }\n            Matrix.translateM(mvp, 0, 0, yOffset, 0);\n        }\n        // This is a 2D center crop, so we only need model matrix, no view and projection.\n    }\n}\n\n最后是计算矩阵，这里也很简单，只要注意计算时候，要分别以视频和屏幕最长的一条边作为单位一，然后分别缩放两者的另一侧，只是数学计算而已。然后为什么会有旋转呢？如果你不写旋转而又使用手机拍摄的竖屏视频做壁纸，就会发现方向是旋转了 90 度的，所以宽和高也都错了。原因是一些设备录像时候不会旋转帧内容，而是以传感器原生的方向存放像素，然后在视频 Metadata 里面记录一下旋转角度，由播放器做旋转。MediaPlayer 自己直接输出是会旋转的，但由于我们这里用它做解码器，所以它传过来的帧是原样的。\n解决方法是在 Engine 加载视频的时候先读取一下元数据，从元数据里获取视频的尺寸旋转量设置给 Renderer：\n        private void getVideoMetadata() throws IOException {\n            final MediaMetadataRetriever mmr = new MediaMetadataRetriever();\n            switch (wallpaperCard.getType()) {\n            case INTERNAL:\n                final AssetFileDescriptor afd = getAssets().openFd(wallpaperCard.getPath());\n                mmr.setDataSource(\n                    afd.getFileDescriptor(),\n                    afd.getStartOffset(),\n                    afd.getDeclaredLength()\n                );\n                afd.close();\n                break;\n            case EXTERNAL:\n                mmr.setDataSource(context, wallpaperCard.getUri());\n                break;\n            }\n            final String rotation = mmr.extractMetadata(\n                MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION\n            );\n            final String width = mmr.extractMetadata(\n                MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH\n            );\n            final String height = mmr.extractMetadata(\n                MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT\n            );\n            mmr.release();\n            videoRotation = Integer.parseInt(rotation);\n            videoWidth = Integer.parseInt(width);\n            videoHeight = Integer.parseInt(height);\n        }\n\n当然 WallpaperCard 是我自己封装的类，关系不大。总之是使用 MediaMetadataRetriever 就可以了。至于你说视频元数据和实际内容不符怎么办？那是用户问题，用户自己处理不好视频为什么要我来解决？随便让他拿电脑上修改一下好了。\n这些实际上是这个应用的核心内容，其它的播放之类的都大同小异，总之如果你实现了 OpenGL 的剪裁功能，应该就可以正常播放壁纸了。其它部分直接参考我的代码或者网上的样例就可以了。\n顺便说一下，SurfaceTexture 似乎会给一个纹理矩阵，但我试着用了一下发现并不是需要的效果……所以还是自己处理吧。\n然后声音又不对劲了……\n就算你给 MediaPlayer 设置了静音，还是不太对劲！正常来说在桌面按音量键设置的是铃声音量，为什么变成了媒体音量！\n这当然是因为虽然 MediaPlayer 虽然静音了，但还是占据着音频通道，我们都知道 Android 分为媒体、铃声、系统等音量通道，如果 MediaPlayer 一直占着媒体通道，其它的媒体 App 比如音乐播放器一类的就没有办法播放。\n使用 MediaPlayer 是没有解决办法的，因为它没提供关闭音轨的接口，一开始我差点基于 MediaCodec 自己编写一个视频播放器，但始终没办法把解码器从主线程转移开，而且 CPU 占用率很高。后来发现 ExoPlayer 有办法关闭音轨，所以我换成了 ExoPlayer。\n具体怎么引入 ExoPlayer 请参照官方文档，只要引入 core 部分就可以了。\n我这里给出一段用于关闭音轨的代码，网上不太好找：\n            trackSelector = new DefaultTrackSelector();\n            exoPlayer = ExoPlayerFactory.newSimpleInstance(context, trackSelector);\n            exoPlayer.setVolume(0.0f);\n            // Disable audio decoder.\n            final int count = exoPlayer.getRendererCount();\n            for (int i = 0; i &lt; count; ++i) {\n                if (exoPlayer.getRendererType(i) == C.TRACK_TYPE_AUDIO) {\n                    trackSelector.setParameters(\n                        trackSelector.buildUponParameters().setRendererDisabled(i, true)\n                    );\n                }\n            }\n\n主要是创建 Player 时候单独给它一个 DefaultTrackSelector。然后关掉音轨。\n这样大概就可以运行了，剩下的一些问题都和视频渲染无关。\n写 UI 也很麻烦！\n虽然我知道你肯定不满足只使用内嵌视频，但如果你像我一样写了个可以设置其它视频的 UI，就会遇到各种各样的问题。写 UI 的时候，你不是在编写回调函数，就是在调用回调函数的路上。\n最令人恶心的是 Context 管理，很多地方都需要 Context，但很多地方都没法传 Context，比如 Android 推荐你用 Uri 而不是直接用文件路径，但所有使用 Uri 的地方都要求你传 Context 来解析 Uri！比如我添加视频，需要解析 Uri 并生成缩略图，为了不阻塞线程，必须开启一个 AsyncTask，但是，AsyncTask 不能直接拿走 MainActivity 作为自己的 Context！必须要传弱引用，总之没有一个优雅的解决方案。\n再比如你需要一些在各个位置都能访问到的数据，你可以自己写一个 Application 类并给它静态变量，但是静态和动态不能互相交叉，也就是说你不能封装一个操作数据的同时需要访问 Context 的方法，所以很多地方就只能拆开写，没办法封装。\n以及列表和数据的绑定写起来真的很麻烦，说实在的，HTML 和 JavaScript 写 UI 真的比 Android 和 Java 舒服的多……\n顺便，你可能会想在 UI 里调用系统的动态壁纸预览，然后根据它返回的结果来设置选中的视频，但这个真的很不靠谱，比如某些版本的 MIUI 修改的预览器即使你点击了应用，它仍然会返回一个取消的结果。明明是 MIUI 的问题，却需要我来解决，我大概可以理解为什么 sway 的开发者大骂 NVIDIA 用户了。你们给了小米钱买它的手机和系统，然后小米造成的问题要我来解决，我一分钱都没有拿到，哪里来的义务和责任解决？？？\n当然我最后还是想办法绕开了，添加了一个单独的应用按钮，用户点击这个按钮就是忽略预览，直接设置为壁纸。\n动态壁纸预览器调用你的壁纸服务的时候，你的 Engine 里 isPreview() 方法会返回 true，可以根据这个判断是不是在预览状态。因为我只有一个壁纸服务，所以我做了一些判断，对预览和桌面分别做了一些不同了逻辑，以便防止预览的结果影响到本来桌面上的壁纸效果。\n最后要注意：你可以在 Service 里面访问 Application 的静态方法，但似乎你不能一直依靠这个。比如当你设置壁纸以后重启了，Service 会被启动，但这时似乎没办法访问到 Application 里的变量。我解决的办法是将数据写入到 SharedPreference，如果从 Application 加载失败就去读取 SharedPreference，但总之我没找到有关 Service 和 Application 会不会一起创建的说明，所以这部份的代码写的很混乱（也包含上面说的不能封装的原因）。\n一杯咖啡都没有……\n如果你只是觉得市面上所有的视频动态壁纸程序都太臃肿太不可靠（包括抖音和火萤）而想自己写一个，我建议你还是放弃，因为遇到的坑比你想象的多，解决起来也很麻烦。而且实际上，你完全可以使用我写好的程序。\n这个程序只有 3.06M，支持加载本地壁纸，支持平板电脑，支持壁纸滑动，不显示时自动暂停播放节约电量，严格遵循 Android 规范，没有任何敏感权限（比如联网和操作外置存储），不请求文件真实路径，测试下来功能基本没有问题。如果你还不放心，所有的代码都在 GitHub 上，我不怕你说我代码写的不好，你大可以看一遍有没有后门。\n你可以分别从 GitHub Release，Google Play 和 酷安网 下载这个应用。\n为了上架 Google Play 我支付了 25 美元申请了开发者账户，应用是免费的，但我放置了捐赠页面。不过已经有几百个下载量（大部分是酷安）之后，我还没有从这个应用里得到一点回报，如果你觉得应用不错并且手头宽裕，可以打开 App 赞助我一杯咖啡。毕竟解决上面提到的所有的坑并不容易。\n"},{"title":"在中国 Android 环境下传个 APK 有多难","url":"/posts/Hard-to-Transfer-APK-with-Chinese-Android/","content":"事情的起因是这样的，我写了 一个 Android App，打算把它发给更多的人试用，发给同学什么的都好说，但我想发给我妈用的时候遇到了一系列问题。我觉得恰好可以说一下在国内用 Android 环境是多么痛苦。\n首先尝试最简单的方法，不是所有人都有 QQ，但差不多所有人都有微信，特别是一些中年的亲戚，所以直接在微信里传送 APK 文件，然后失败了。微信自作聪明地给 APK 加了 .1 的后缀名，不用想都知道理由：“哎呀我们的用户都是可爱的啥都不会的小宝宝，要为了他们安全考虑呢”。\n微信失败了，那就换个方式吧，发个网址去下载 APK，应该就不会被改名了，所以我直接复制了 GitHub Release 的链接，然后大家一概回复：“这个网址打不开”。\n行吧，开个 GitHub 都要折腾来折腾去的地方学习写代码有多艰难我就不哭诉了，那我们换个网站，我记得两个月前酷安网还是可以在电脑上下载 APK 的，我就把我上架之后的链接发了过去。不一会我妈说：“你这个链接让我 下载他的那个市场，然后 微信里要下载还得装 QQ 浏览器”，我妈还是有在这个艰难环境下使用手机电脑的知识的，所以看到要装这些乱七八糟的就放弃了。\n打开电脑一看，没想到啊没想到，酷安你这个浓眉大眼的家伙也叛变革命了。我不知道是哪个闲鱼用多了自己脑袋都咸了的产品经理一拍脑袋删掉了网站的搜索框，然后又关掉了 APK 的下载渠道。为了秉持优雅，我就不在这里问候这个经理的家人了。\n事已至此，总不能用百度云吧？靠百度云的限速估计能下载到猴年，我觉得百度除了竞价排名技术，网盘限速技术也是独步全球，而且似乎百度云稍微大一点的文件就让你下客户端？虽然我对我应用的体积很有信心，但也不想再趟混水了。\n我不知道别的开发者怎么分发他们的安装包，但我不想备案，也不想为了 APK 一直掏钱供着一个国内服务器，而且 要想在微信能访问域名，还得给腾讯交保护费，否则说你不安全，去哪说理去？\n后来我还是把文件传到服务器上然后用 IP 做了个链接，我自己的电脑能下载，但 微信内置浏览器就是不行。\n我可以教我妈怎么找到微信的下载文件然后修改后缀名，但是别人呢？我妈想把这个应用介绍给身边人试试，有些人连文件管理都没开过，总不能一个一个教他们吧？打包更不现实了，什么是 zip 什么是 tar 和你七大姑八大姨的工作生活可能一点关系都没有，当然也不是所有人工作都用 Email，大家都要恰饭，生活已经很艰难了。\n别说多提交几个商店，每次发新版本 GitHub Google Play 和酷安三个一起更新填表就已经花了好多时间了，每个品牌的手机都非得做一个自己的商店，用户量不大要求还不少，谁有那好心一个个提交去。\n这肯定不是 Android 的问题，毕竟如果能用 Google Play 问题就不存在了，或者如果能用 Telegram 这种聊天软件也行，但如果你是鸡群里的鹤，怕不是会被鸡群起而攻之。至于 Firefox Send 什么的估计在国内也没戏。这个时候我甚至开始怀念 QQ 了，虽然流氓，但是还是能传文件的。\n但说到 QQ，我又想起了几年前的事情，我相当佩服 QQ 的开发者这种在一个软件里打两个包的技术，当你用 QQ 点开一个网页链接，然后点菜单选“用 QQ 浏览器打开”的时候，你会发现 QQ 内嵌了一个 QQ 浏览器 —— 怪不得 QQ 浏览器有脸说自己装机量第一，以前我以为是很多人蠢瞎了用这种垃圾，后来发现我失算了 —— 当然最魔幻的不止于此，这个内嵌的 QQ 浏览器版本当然是很旧的，所以你打开了之后它第一件事是让你下载安装最新的 QQ 浏览器 —— 你看一个人能装两个 QQ 浏览器，发明这个技术的产品经理真是个小天才，他推动了手机存储空间上升！跟他比起来苹果算个什么东西！\n现在 QQ 的体积还是巨无霸，这个按钮也依然在那里，大概 QQ 浏览器仍然嵌在里面吧，而且估计版本一直都没变过，毕竟能变成俩鸡蛋的双黄蛋大家都喜欢。祝 QQ 未来能把其他所有腾讯的应用都嵌进去，早日突破 1 GB 大关。\n"},{"title":"GNOME 的修改开发与测试","url":"/posts/GNOME-Develop/","content":"由于时间久远，记录的事物可能已经改变，作者不能保证此时页面内容完全正确，请不要完全参考。对于一些 GNOME 比较核心的程序比如 GNOME Shell，调试的时候没办法简单的运行，需要构建一个隔离的环境然后替代系统的 WM。GNOME 项目使用 JHBuild 构建这个环境。我的系统是 Arch Linux，介绍一下中间遇到的一些问题。\ngitlab.gnome.org 这个网站国内访问很慢，而且 jhbuild 使用 git clone 有时候不会遵循 git 的代理设置（指 git config --global http.proxy 'socks5://127.0.0.1:1080' &amp;&amp; git config --global https.proxy 'socks5://127.0.0.1:1080'），经常不好用，我家里的网又不太好。我改 GNOME Shell 代码只用了一小时，但下载和编译环境来测试用了两天一夜。\n我调试的是 GNOME Shell，首先按照 说明页面 安装 JHBuild，将安装到的 ~/.local/bin 加入 PATH，然后建立 ~/jhbuild 目录，运行 jhbuild build gnome-shell 会自动下载编译所有需要的依赖（大概 80 个）。\nJHBuild 编译下载一些包的时候经常会出问题，一般编译一个包有四个阶段：\n\ncheckout：clone 仓库，设置 origin，然后 fetch。\nconfigure：运行 autogen.sh，configure 或者 meson。\nbuild：make 或者 ninja。\ninstall：就是 install，目的地是 JHBuild 的环境。\n\n按了 Ctrl+C 之后会给几个选项，第一个通常是继续当前阶段，第二个是进行下一阶段，第三个是放弃当前的包，第四个是开一个 shell 子程序，第五个是刷新配置文件，对于 JHBuild 不走代理 clone 没速度的时候可以先 Ctrl+C，然后开启 shell，自己手动 clone，然后 Ctrl+D 退出 Shell 回到 JHBuild，这时候会继续让你选择，如果是 checkout 的话选择继续当前阶段，它会发现你同步好了。\n如果是某个包需要特殊操作，默认的参数配置不过去的话，建议也是 Ctrl+C 开 shell 手动运行 ./autogen.sh 之类的，然后选择下一阶段（configure 的下一阶段是 build），如果继续的话它会再 configure 一遍，就覆盖了你的。虽然可以在 JHBuild 配置文件里添加某个包的参数，但是似乎我也搞不清楚 JHBuild 什么时候重新加载配置，经常选了 5 它没变化，把配置文件对应的行删掉它又加上了之前的参数。\n反正 JHBuild 搞不定的都手动搞定然后切换下一阶段基本都可以。\ncolord 这个包的 man page 构建时候依赖 docbook-xsl-ns，这个包在 AUR 里，但是装上了它也检测不到，所以建议添加 -Dman=false 到 autogen.sh 的参数，关闭 man page 的生成，反正也用不到（所以 Arch 官方的打包姬怎么打包的啊！）。\nwebkitgtk 不负众望是编译起来最慢的，几年前用 Gentoo 的时候轻松两小时（我当时 Skylake 移动版 Xeon E3-1505M 可是最强的笔记本 CPU），最近有改善了，但还是要半个小时多。\nibus 配置的时候需要加 --with-ucd-dir=/usr/share/unicode/ 不然它默认去 /usr/share/unicode/ucd/ 找文件但 Arch 打包的路径不是这个。\nlibgdata 不支持 automake-1.16，需要降级到 1.15 来 configure，但是生成的 Makefile 有一句没展开的宏（我这里是 4141 行的 @CODE_COVERAGE_RULES@）（开发者出来修 bug ！），需要注释掉，然后 make 就可以过了，似乎没啥不利影响。\nuhttpmock 的来源 URL 搞错了，现在指向的旧的是被废弃的仓库，新仓库是 这个，需要修改 ~/.config/jhbuildrc 添加 branches['uhttpmock'] = ('https://gitlab.com/uhttpmock/uhttpmock.git', 'master')。\n都编译完就可以用 jhbuild run gnome-shell --replace 启动你编译的版本了，其他的 GNOME 程序编译的也可以 run 比如 jhbuild run dconf-editor。但是用 wayland 会话似乎既不能直接运行 gnome-shell --replace 也不能 gnome-shell --nested （像 weston 小窗口一样，但 gnome-shell 有 lock），所以我用的 x11 测试。测试时候不要锁屏不要看 lookingGlass，锁屏了回去就会没法输密码解锁，看了 lookingGlass 之后我卡住了……测试完最好重启，不然似乎没啥简单办法回到你系统的程序里。\n没搞清楚怎么修改 jhbuild 的仓库来源，也就是说它只会去 GNOME/gnome-shell clone 但没法改成你 fork 的仓库，我只能把改过的文件复制到 ~/jhbuild/checkout 目录下面对应的仓库里，然后 jhbuild buildone -n gnome-shell，buildone 的意思是不管其他依赖，只重新编译后面的（用 build 会依次检查依赖，很慢的！），-n 是就算仓库被改了也不要重新 checkout，不然你的文件就被覆盖回去了……\n哦对了，GNOME Shell 有个单独的 gnome-shell-sass 的仓库存放样式表（CSS/SASS），但看说明这是个只读仓库，是从 gnome-shell 里分离出来给别的扩展用的，所以直接修改 gnome-shell 里面的就好了。还有一般跟顶栏有关的 GSettings 选项都在 org.gnome.desktop.interface 下面，但是这些 schema 对应的 XML 文件都在一个叫 gsettings-desktop-schemas 的仓库里，所以需要去那边改。\n最后狠狠地吐槽一下砍掉 GNOME Shell 顶栏透明的老哥，为了一点点边缘情况（有人说高对比度颜色壁纸下看不清顶栏文字，但大部分人都看得清并且实在不行换壁纸呗！）就砍掉这个好评如潮的功能。我提了个添加一个 GSettings 选项，让顶栏根据选项决定是透明还是纯黑（壁纸看不清就设成纯黑呗），结果被这个老哥拒绝了，还说添加个选项只会阻止其他人修 bug……大部分人都不觉得这是个 bug 好吗？我不觉得程序识别壁纸颜色改变顶栏会比用户自己调更准确，Android 的变色顶栏过了这么久，在许多壁纸上也不能正确识别颜色，添加一个让用户自己选择的功能才会更方便。但我又不想因为一个奇葩老哥就放弃我这么久的 GNOME 使用习惯，看来还是等等看能折腾出什么名堂吧！\n等到 GNOME 3.32 释出稳定版之后我尽量生成一个透明顶栏的 patch 就好了，反正主要的代码都是前人做的，大概 Arch Linux CN 的源愿意接受一个新包。不要和那个奇葩老哥一样说为啥不写扩展，就算是程序员家里养的猫也该知道 GNOME Shell 扩展 API 经常变还经常把整个桌面搞崩，真亏他们好意思提出来！\n"},{"title":"Linux 下面常见的代理设置","url":"/posts/Proxy-Settings/","content":"通常情况下在 Linux 下面配置好的是 socks5 代理（你懂的），但是这个代理并不能让所有程序自动走它，需要手动的做一些转换。\nproxychains\nproxychains 通过 hook libc 里面网络访问的函数实现劫持流量经过代理，配置时首先编辑 /etc/proxychains.conf 添加一行有关自己的代理的配置比如\nsocks5 [IP 地址] [端口号]\n\n然后就可以将你需要走代理的程序跟在 proxychains 后面比如\n$ proxychains wget google.com\n\n环境变量\n有些奇葩的语言设计时完全静态链接，不会调用 libc 的运行时库（说的就是那个叫 go 的），因此没办法使用 proxychains 进行 hook。但对于命令行程序，通常有个约定，如果用户设置了 http_proxy 和 https_proxy，程序就会使用这个作为代理。可以在这两个里设置 socks5 代理，比如 export http_proxy=socks5://127.0.0.1:1080，但问题不是所有程序都支持 socks5 代理，所以需要一些方案将 socks5 转换成 http 代理。当然如果你遇到一个 go 程序不读取这两个变量怎么办？当然是在心里问候一下开发者先人。\nprivoxy\n我个人比较习惯使用 privoxy 做代理转换，当然能实现这个功能的程序非常多，privoxy 是其中一个，安装好之后修改配置文件，添加一行\nforward-socks5   /               [IP 地址]:[端口号] .\n\n然后使用 systemctl start privoxy 启动即可。\n桌面环境\n诸如 GNOME 和 KDE Plasma 等都在网络设置里提供了代理选项，按表格填写即可，对于一些内建代理支持的也可以同理比如 Telegram Desktop，本地的 socks5 代理是不需要填写用户名和密码的。对于另外一些同学想要使用 Automatic 的选项的，你需要生成一个 pac 文件，这个文件里记录了哪些网站需要走代理，可以搜索 你用的 socks5 代理协议的名字 + pac 得到更多相关内容，然后在这个选项里填写以 file:// 开头的 URL，支持的程序就会使用这个 pac。\n浏览器\n通常大家使用插件来调用代理，但如果你上一步设置了桌面环境的代理比如 pac，可以直接走系统代理。\n端口转发\n有时候你的网络很奇葩，连接所有代理服务器都很慢，可以通过一个国内服务器做端口转发，需要在国内服务器执行下列命令。\n开启流量转发\n> /etc/sysctl.conf\n# sysctl -p\n\" class=\"code-block\"># echo \"net.ipv4.ip_forward=1\" &gt;&gt; /etc/sysctl.conf\n# sysctl -p\n\n配置转发规则\n# iptables -t nat -A PREROUTING -p tcp --dport [端口号] -j DNAT --to-destination [代理服务器地址]\n# iptables -t nat -A PREROUTING -p udp --dport [端口号] -j DNAT --to-destination [代理服务器地址]\n# iptables -t nat -A POSTROUTING -p tcp -d [代理服务器地址] --dport [端口号] -j SNAT --to-source [国内服务器地址]\n# iptables -t nat -A POSTROUTING -p tcp -d [代理服务器地址] --dport [端口号] -j SNAT --to-source [国内服务器地址]\n\n然后修改你本地的 socks5 配置，IP 改为国内的服务器的 IP。如果国内服务器默认有防火墙（安全组），添加规则放行该端口（TCP + UDP）。\n"},{"title":"WireGuard 配置与使用","url":"/posts/WireGuard-Usage/","content":"Google 反应还是挺快的，现在这篇博客已经不是精选摘要了。可能有人在 Google 搜索 “WireGuard 原理” 然后发现精选摘要是这篇博文，这给我带来了很多流量。但我还是要摸着良心说这篇博文并没有涉及到什么深层次具体的 WireGuard 原理，只是简单的配置使用记录。我不知道为什么 Google 会依据文章里唯一的一个“原理”就认为这篇文章是讲述 WireGuard 原理的，我不想误人子弟，所以已经删掉了唯一的一处关键词。我个人建议直接访问 WireGuard 的官网或者 Wikipedia 页面来查找有关它的原理的资料。由于时间久远，记录的事物可能已经改变，作者不能保证此时页面内容完全正确，请不要完全参考。需求\n本来想给内网的树莓派做 frp 一类的公网映射以便能够在外面访问 Samba 服务器，但是后来想想似乎把设备暴露在公网不太安全。换个角度想，其实没有必要把树莓派映射出去，只要能够连接进来就好了。所以选用 VPN 建虚拟专用网就好了，这里使用的 WireGuard 将我的各个设备连到一个子网内部。\nWireGuard 和大部分 VPN 类似，是使用一个公网服务器做 Endpoint，内网的数据通过该服务器进行转发，因此子网的各个设备可以没有固定的 IP，只要都能连接公网服务器就好了。\n安装\n由于不同的系统安装方法不一样，建议参照 WireGuard 官网的安装说明 和各个系统的 Wiki，Android 手机可以搜索下载 WireGuard 的 APK 进行安装。\n配置\n以下部分内容参考自 WireGuard - ArchWiki。首先需要生成公钥和私钥，建议将其设置为只有拥有者才能操作，我这里放在 /etc/wireguard 这个目录里面，这个目录应该是只有 root 才可以访问的。服务器和客户端都需要分别进行这一步。\n publickey\n\" class=\"code-block\"># cd /etc/wireguard\n# wg genkey | tee privatekey | wg pubkey &gt; publickey\n\n服务端\n然后需要手写配置文件，这一步对于服务端和客户端不一样，先进行服务端的配置。\n首先在 /etc/wireguard 目录下面建立 wg0.conf（如果你已经有一个了就 wg1，依此类推），服务端的配置文件内容看起来像这个样子：\n[Interface]\nAddress = 10.0.0.1\nListenPort = 7456\nPrivateKey = [服务器的私钥]\nPostUp   = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE\nPostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE\nDNS = 8.8.8.8\n\n[peer]\nPublicKey = [客户端 1 的公钥]\nAllowedIPs = 10.0.0.2/32\n\n[peer]\nPublicKey = [客户端 2 的公钥]\nAllowedIPs = 10.0.0.3/32\n\n[peer]\nPublicKey = [客户端 3 的公钥]\nAllowedIPs = 10.0.0.4/32\n\n然后我们依次分析每个字段的内容，首先是 Interface，这个部分指的是 WireGuard 在本机上面的接口，它是以内核模块的形式工作的，Address 指本机在子网内的私有 IP 地址，我这里分配了保留段 10.0.0.0/24 作为子网网段（看不懂的自己去复习计网），你也可以用 10.0.0.0/8 这个网段再进行划分，这个主机地址是你自己随意分配的，只要不冲突就行。ListenPort 是监听的服务器 UDP 端口，这个也是自己设置，只要不冲突就行。PrivateKey 则是刚刚 服务端 生成的 privatekey 这个文件的内容。PostUp 和 PostDown 是配置 iptables 的命令，建议直接复制即可，如果你服务器的公网网卡不是 eth0 则需要自己修改。DNS 则是解析域名用的 DNS，这里直接使用 8.8.8.8。\n然后每一个 Peer 字段对应了一个客户端（或其他服务端，WireGuard 实际上是 P2P 的），需要分别将该客户端的 publickey 文件的内容贴到 PublicKey，以及需要转发到该客户端的流量的 IP 地址范围贴到 AllowedIPs，我这里直接给每个客户端设置固定的 IP，因此就写了 10.0.0.2/32（看不懂的自己去复习计网），意思是该客户端对应的 IP 就是这个，所有向这个 IP 发送的流量都会被转发到这个客户端。\n每添加一个新的客户端，就要在这个文件里添加一个 Peer 字段，然后设置 PublicKey 和 AllowedIPs。\n然后在服务器的 /etc/sysctl.conf 最后加上一句 net.ipv4.ip_forward=1，开启 IPv4 流量转发，然后运行下面的命令加载新设置：\n# sysctl -p\n\n客户端\n客户端也需要在 /etc/wireguard 目录下面建立 wg0.conf（如果你已经有一个了就 wg1，依此类推），内容看起来是这样：\n[Interface]\nAddress = 10.0.0.4\nPrivateKey = [客户端的私钥]\n\n[Peer]\nPublicKey = [服务端 1 的公钥]\nAllowedIPs = 10.0.0.0/24\nEndpoint = [服务端 1 的公网 IP]:[服务端 1 的端口]\nPersistentKeepalive = 25\n\nInterface 字段自然是客户端本机的设置，Address 还是给本机分配的 IP，这里要和服务器在同一个子网，并且要和服务器配置文件里允许的 IP 段对上。PrivateKey 则是刚刚 客户端 生成的 privatekey 这个文件的内容。\n然后通过添加 Peer 字段来添加服务端，服务端作为这个网络的出口和转发点，首先需要填写对应的 服务端 的公钥，然后这里的 AllowedIPs 设置的是 该客户端有哪些 IP 段的流量要通过该服务端进行转发，由于我只希望内网的流量进行转发，自然设置的是内网的 IP 段 10.0.0.0/24（看不懂的你计网挂科了！），然后需要设置 Endpoint 以便能正确的在公网上找到服务器（虚拟专用网，连不上网还哪来的虚拟网啊？），格式就是 服务器公网 IP:你设置的监听端口。PersistentKeepalive 则是用来保持连接的参数，直接复制即可。\n如果你有多个服务器，就在这里添加多个 Peer，分别设置不同的 AllowedIPs 让他们转发，但 不需要让客户端之间互相添加 Peer，本来我们要解决的问题就是客户端之间不能互相连接，所有的流量是经由公网的服务端进行转发才可以到达，公网的服务器是各个不固定的客户端的接头地点。所以只需要保证服务端和所有的客户端认识就可以了。\n启动\n在分别配置好客户端和服务端之后就可以启动了，这一步两边都一样。注意把 wg0 换成你配置文件的名字。\n# wg-quick up wg0\n\n然后使用 wg 命令可以查看状态，由于这些涉及到网络配置，都需要 root 权限。服务端和客户端输出的结果是不一样的，下面这个是我的笔记本输出的结果。\n# wg\ninterface: wg0\n  public key: [不给你看]\n  private key: (hidden)\n  listening port: 51676\n\npeer: [不给你看]\n  endpoint: [不给你看]\n  allowed ips: 10.0.0.0/24\n  latest handshake: 1 minute, 41 seconds ago\n  transfer: 38.36 KiB received, 141.71 KiB sent\n  persistent keepalive: every 25 seconds\n\n建立成功的话，子网之间是可以使用内网地址互相 ping 的：\n$ ping 10.0.0.11\nPING 10.0.0.11 (10.0.0.11) 56(84) bytes of data.\n64 bytes from 10.0.0.11: icmp_seq=1 ttl=64 time=30.1 ms\n64 bytes from 10.0.0.11: icmp_seq=2 ttl=64 time=30.4 ms\n64 bytes from 10.0.0.11: icmp_seq=3 ttl=64 time=30.5 ms\n64 bytes from 10.0.0.11: icmp_seq=4 ttl=64 time=30.4 ms\n64 bytes from 10.0.0.11: icmp_seq=5 ttl=64 time=30.5 ms\n^C\n--- 10.0.0.11 ping statistics ---\n5 packets transmitted, 5 received, 0% packet loss, time 11ms\nrtt min/avg/max/mdev = 30.133/30.393/30.521/0.209 ms\n\n如果你确定没有问题，就可以使用 Systemd 管理连接，首先关闭刚才的连接：\n# wg-quick down wg0\n\n然后启动现成的 Unit：\n# systemctl enable wg-quick@wg0 --now\n\n后记\n如果你需要在内网之间传递数据，建议选择一个访问较快的服务器，因为数据转发要通过这个服务器。\n但如果你想使用 WireGuard 访问一些被河蟹的内容也可以，首先需要一个在海外的服务器，然后设置客户端该服务器的 AllowedIPs 为 0.0.0.0/0（意思就是所有 IP 都经过这个服务器转发啦！或者你知道该设置某些网段也行！或者避开内网和本地地址！我不建议用这个番茄！所以就写个最蠢的！），就可以了！记得不用时候关掉，不然一些国内 App 也会走海外的！\n"},{"title":"使用 Moment 处理 JavaScript 与 YAML 的时区转换","url":"/posts/JavaScript-YAML-Timezone-Moment/","content":"这篇文章记录的内容已经过时了，现在 Hikaru 不再使用 js-yaml 和 moment-timezone 并且仅仅使用 Date 对象解析时间，不进行任何处理。用户应该使用 ISO 6801 的日期时间格式指定时区，主题应该自己解决格式化输出的需求。开始之前先来一段惊喜：我是在中国标准时间 21 点写的这篇文章，但我在 front matter 写的是 21:00:00 并解析为东京时间。\n今天想给 Hikaru 添加文章时间显示时区的功能，本来以为只要无脑调用 Moment.js 就好了，但发现其中坑有很多，本来我就很不擅长计算时区转换，难度变得很大，好在后来处理完了，记录一下遇到的坑。\n首先 Moment 是不支持地区解析和时区显示的，需要安装 moment-timezone 这个扩展包才可以处理，文档在这里。\n然后就是 js-yaml 开发者认为是标准但我觉得是 Bug 的问题。我在 front matter 里写的时间是不包含时区的，比如 2018-12-31 16:00:00，按照正常的解析方式（就是直接 new Date(\"2018-12-31 16:00:00\")），应当把这个时间当作 localtime 来看待，也就是 UTC+8h 的时间，但偏偏 js-yaml 的开发者认为按照 YAML 标准应当把这个时间当作 UTC 来处理（问题是他 根本解析不了附加时区的表示方式啊！）然后时间就错乱了，不过还好，JS Date() 对象内置的 getTimezoneOffset() 方法可以获取到 UTC 与 localtime 的分钟差（和标注的 UTC+8h 正好相反，这个是 -8h，在这里正好把这个错误的 UTC 换算回正确的 UTC）。具体的代码如下。\n 2018-12-31T16:00:00.000Z\nnew Date(&quot;2018-12-31 16:00:00&quot;) // -> 2018-12-31T08:00:00.000Z\nd.getTimezoneOffset() // -> -480\nnew Date(d.getTime() + d.getTimezoneOffset() * 60 * 1000) // -> 2018-12-31T08:00:00.000Z\n\" data-info=\"language-javascript\" data-lang=\"javascript\" class=\"code-block\">const yaml = require(\"js-yaml\")\nconst d = yaml.safeLoad(\"2018-12-31 16:00:00\")  // -&gt; 2018-12-31T16:00:00.000Z\nnew Date(\"2018-12-31 16:00:00\") // -&gt; 2018-12-31T08:00:00.000Z\nd.getTimezoneOffset() // -&gt; -480\nnew Date(d.getTime() + d.getTimezoneOffset() * 60 * 1000) // -&gt; 2018-12-31T08:00:00.000Z\n\n接下来遇到另一个问题，Nunjucks 似乎对传递参数有很大限制，比如 moment 既是函数也有成员，我没办法在 Nunjucks 里面调用它的 moment.tz.guess() 成员，所以只能把设置地区单独做成文章参数传进去。\n然后发现这样有一个问题，比如我在日本写文章的时间是 21 点，回到中国发布，它解析时会当成东 8 区的 21 点来解析，然后使用 moment(\"2019-01-01 21:00\").tz(\"Asia/Tokyo\").format() 时进行转换，将东 8 区（中国）的 21 点转换成东 9 区（日本）的 22 点。然而实际上我觉得更常见的是一个人在国外写了一篇文章，自然标注的是国外时间（谁叫 js-yaml 不支持时区记号呢！），然后用另一个参数注明自己现在的地区，这样回到自己的国家之后，发布时仍然以外国时区解析时间。\n很显然解决方案是要改变解析时间的时区，但似乎 JavaScript 并没有什么好的修改时区的办法，使用 moment.tz(时间，地区) 似乎可以限定解析的时区，但是直接像下面这样做完全没用。\n 2019-01-01T21:00:00.000Z\nconst newd = new Date(d.getTime() + d.getTimezoneOffset() * 60 * 1000) // -> 2019-01-01T13:00:00.000Z\nmoment.tz(newd, &quot;Asia/Tokyo&quot;).toISOString() // -> 2019-01-01T13:00:00.000Z\nmoment.tz(newd, &quot;Asia/Tokyo&quot;).format(&quot;YYYY-MM-DD HH:mm:ss z&quot;)  // -> 2019-01-01 22:00:00 JST\n\" data-info=\"language-javascript\" data-lang=\"javascript\" class=\"code-block\">const yaml = require(\"js-yaml\")\nconst moment = require(\"moment-timezone\")\nconst d = yaml.safeLoad(\"2019-01-01 21:00:00\")  // -&gt; 2019-01-01T21:00:00.000Z\nconst newd = new Date(d.getTime() + d.getTimezoneOffset() * 60 * 1000) // -&gt; 2019-01-01T13:00:00.000Z\nmoment.tz(newd, \"Asia/Tokyo\").toISOString() // -&gt; 2019-01-01T13:00:00.000Z\nmoment.tz(newd, \"Asia/Tokyo\").format(\"YYYY-MM-DD HH:mm:ss z\")  // -&gt; 2019-01-01 22:00:00 JST\n\n它仍然是将 21 点当作东 8 区转换为东 9 区的 22 点了，时区完全没变，不然应该是有一小时的变化（从东 8 到 东 9），原因是如果一个时间已经标注了时区（各种合法的格式，比如 JST、CST、+0800 等，单独的 Z 在 ISO 标准里就表示 UTC，因此也算标定），Moment 就不会用程序员给定的地区做解析，而是用作输出时的转换（等价于 moment(时间).tz(地区)）（这么设计 API 的人你出来我保证不打死你）（我觉得我应该去看一眼那个叫 You-Dont-Need-Momentjs）的项目。\n解决方案似乎没什么特别优雅的，既然你不肯去掉，那我自己造一个没时区的字符串然后再解析一次呗。\n 2019-01-01T21:00:00.000Z\nconst newd = new Date(d.getTime() + d.getTimezoneOffset() * 60 * 1000) // -> 2019-01-01T13:00:00.000Z\nmoment.tz(moment(newd).format(&quot;YYYY-MM-DD HH:mm:ss&quot;), &quot;Asia/Tokyo&quot;).toISOString() // -> 2019-01-01T12:00:00.000Z\nmoment.tz(moment(newd).format(&quot;YYYY-MM-DD HH:mm:ss&quot;), &quot;Asia/Tokyo&quot;).format(&quot;YYYY-MM-DD HH:mm:ss z&quot;) // -> 2019-01-01 21:00:00 JST\n\" data-info=\"language-javascript\" data-lang=\"javascript\" class=\"code-block\">const yaml = require(\"js-yaml\")\nconst moment = require(\"moment-timezone\")\nconst d = yaml.safeLoad(\"2019-01-01 21:00:00\")  // -&gt; 2019-01-01T21:00:00.000Z\nconst newd = new Date(d.getTime() + d.getTimezoneOffset() * 60 * 1000) // -&gt; 2019-01-01T13:00:00.000Z\nmoment.tz(moment(newd).format(\"YYYY-MM-DD HH:mm:ss\"), \"Asia/Tokyo\").toISOString() // -&gt; 2019-01-01T12:00:00.000Z\nmoment.tz(moment(newd).format(\"YYYY-MM-DD HH:mm:ss\"), \"Asia/Tokyo\").format(\"YYYY-MM-DD HH:mm:ss z\") // -&gt; 2019-01-01 21:00:00 JST\n\n这样结果就对了。至于接下来你想用什么地区的时间输出无所谓，反正内部的时间已经改变了，不再是你本机的时间。\n新年新 Bug：当我在研究为什么我的生成器没有在首页生成这篇新文章半小时之后，我发现问题的源头是我把 2019-01-01 写成了 2018-01-01。\n"},{"title":"StackHarbor 的 2018 尾记","url":"/posts/2018-Tail/","content":"\n还没有到月亮升起的时候呢。\n不是所有特殊的日子都能让人开心，特别是对于一个记性不太好的人来说。虽然距离上一个这样标题的已经过了一年，但我似乎想不起来这一年有什么值得开心的事情。我是指那种能维持很久的开心，但是很遗憾似乎让人难过的事情总是能持续的更久一点。\n今年大概保证了一个月一篇 post 的频率，虽然有些月份就懒的什么也不想干，但回头翻一翻之前还有存货，让人变懒的原因不是无事可做，往往是由于各种没什么意义还不得不做的事情，一旦沉迷在这些事情里太久，就容易让人忘记自己应该做什么事情。\n似乎这一年来就一直不清楚自己该做什么，虽然对于自己不想做什么有清晰的了解，但是说到做什么，似乎每一件事都不是很容易完成，特别是一些没接触过的东西。而且糟糕的是我觉得这个状态还会继续存在下去——我还没搞到该死的创新创业学分！\n说句实在话，我不想当韭菜，也没当韭菜的成本。我还有好多事情没有完成呢！大概对于一棵韭菜来说，长得和其他韭菜不一样就是错误。\n所以说实话，最近没有什么值得开心的事情，更远一些也没有。\n如果从最早开始说的话，今年大概从 空之境界 开始，然后是 Fate/Stay Night，然后是 Fate/Zero。第一遍看空之境界的时候还不知道整个在说什么，只是模糊地觉得十分喜欢，最近又拿起来重新看了一遍，才发现一些不一样的意思。\n话说回来，虽然是第一次看，但是却有十分熟悉的感觉，大概是因为里面一些插曲早就不知道循环了多少个深夜的原因。总有一些旋律是停留在记忆里面的，理解它们从来就没有什么困难。\n然后是 Fate，最开始只是因为把 卫宫家今天的饭 当成泡面番来看的，没想到一下子就点开了一年霸权（真·年番）。当然看过 Stay Night 之后和没看之前看这部的感觉是不一样的——一同经历过很多，怎么能不留下些痕迹呢——大概和所有人一样，只要看到他们还在一起就满足了。\n然后大概 Unlimited Blade Works 线的动画从音乐上整个就输给了 Fate 线动画，除了 LAST STARDUST 是确确实实的超水平，believe 和 ring your bell 表现实际很一般（这里一般的意思就是指没什么第一遍就吸引人的地方）。Fate/Zero 也是完完全全的超出 Unlimited Blade Works 的音乐（只要把满天拉出来就可以吊打了），至于 2019 年 1 月 11 日的 Heaven's Feel……我并不是樱厨，所以大概这条线不适合我吧。还有 幻想嘉年华 大概和卫宫家今天的饭是同一水平的作品，太严肃了也是很累的。至于 Fate/Apocrypha？这不是讲的莫德雷德和狮子劫界离的故事嘛？写的挺好的！就是 23 集就完结有点奇怪……后面还做两集多余的骗收视率嘛？（摸着良心说，阿福这个配角也挺可爱的。）\nFate 系列其他的作品就没看过了，我觉得也没有看的必要，FGO 从看完 Stay Night 开始玩，到现在也差不多没什么动力了（主要是懒），反正一年过去了，我还是没有抽到蓝呆毛。（93 连抽！279 个石头！一个五星都没有！）虽然金卡有了一大堆一般的本也都轻轻松松，但是我还是很想要蓝呆。（无独有偶，信用卡我也只拿到武藏的，没有蓝呆。）明年有第六七章的动画化，第七章我只是觉得故事性很好，但第六章的动画是我最期待的。\n导致不开心的大概是在我重新开始听 Kalafina 的时候听见了 Keiko 退出的消息，完全不愿意回想这件事发生的前后过程。无论希望还是失望也都已经过去了，那剩下的复杂的感情又是什么呢？与其空怀希望，不如让时间回到一切还没发生的时候，重来一遍的话，我应该不会错过那些不该错过的机会了吧。与已经得不到的相比，每天都在重复的无聊事情有什么意义吗？\n顺便一提，假期之前通关了 NieR: Automata，但现在的我恐怕没有再来一次的勇气。\n也许我可以有更多的事情可以做，但是实际上我只有两个作品，一个是现在正在用的主题 ARIA，某种程度上它和我的想法一样简单，本来就是非常简单的逻辑嘛。这个东西写的非常直白，样式也全部是我自己完成的，不会出现什么奇怪的乱七八糟的调用。也没有对一些历史垃圾进行没必要的适配，直接采用最合适的技术，去掉那些对内容没什么帮助还消耗资源的东西就好了。\n还有一个是 Hikaru，基于和上面类似的原因，我对 Hexo 也有一定的不满，虽然看起来其它的让我更没什么兴趣。于是大致按照逻辑设计了自己的生成器，并且给它起了我最喜欢的歌手的名字。这个程序也是一样的直白，反正只是生成一遍页面而已，没有必要搞什么乱七八糟的数据库嘛，就算有必要也可以直接上 SQLite（当然我没有，而是直接用对象引用了）。包括一些乱七八糟的模板里的工具，总之是把所有不符合自己习惯的地方都改了一遍。截至目前一切工作的都很良好，甚至一些我开始不知道怎么实现的地方也都工作了。我也并不觉得需要有多少多少人来用它，只要满足我自己用就行了，至于别人，随意。\n然后大概就是换手机，一加 3T 使用了两年已经是伤痕累累，但是看起来又没什么符合要求的手机，一加的后几代产品完全就是“不讲究”，MIX2S 看起来不错但没有耳机孔也没有防水，而且我并不是很喜欢 MIUI，Pixel 3 的性价比完全就是冲着“有种别买”去的，何况这个价格一旦出了问题没保修就要了命了。反正我都是晚上充电，充的太快反而让我不放心，快充完全没什么需求，所以最后考虑加钱买 Galaxy S9（因为 128G 的 S9+ 实在是超出预算了），但是看到 S9+ 有 64G 的版本（反正可以插存储卡），同时 4G 内存实在是太小，咬咬牙就 S9+ 了，反正有奖学金和上个月的结余。别问我 6k+ 为什么不买 iPhone，我考虑了一下，这东西我拿来完全不能用（没文件管理 怎么愉快的用 Samba 在 Arch 和手机之间交换文件，我也不可能因为一个 iPhone 迁移我整个生态和习惯，顺便还 没有耳机孔，以及 被限制的 NFC，也 不能随便外接 U 盘……），所以还不如当它不存在，不存在的东西就不考虑了，这样一来 Galaxy S9+ 就变成了第一档配置的选项。\n拿到 S9+ 以后才发现多出来的价格完全没有白花。对我来说这个手机唯一的缺点就是这个曲屏太难贴膜以及担心摔碎（所以想办法搞了碎屏险）。至于其它的完全 OK，防水是一个平时我不会故意去用（我不洗手机！）但总能让人很安心的功能，双摄也是到手了之后发现 2X 变焦在拍猫的时候很有用（不用担心追的太近猫猫跑掉了），指纹识别在后面总比没有要强（毕竟我很讨厌实体按压 Home 键），有 NFC，屏幕水平中规中矩（手机居然可以切换分辨率你敢信！平时用 1080P 就很清晰了。），可以插存储卡，耳机孔绝对是加分项目，特别是 随包装送了 AKG 耳机、USB-C 到 MicroUSB 的转接头，USB-C 到 USB-A 的转接头（本来是换机助手用的，但完全可以用来接 U 盘）、透明手机壳甚至还有无线充电器（聊胜于无，对我来说只是多了一个充电的位置，角度什么的并不十分合适），甚至似乎还可以一边接收 WiFi 一边开热点（！！！）。另外软件上也和当初的 TouchWiz 有天壤之别，整个系统的动画很复杂，可配置项多到根本翻不完（对我来说这是优点），比如我喜欢返回键在左侧（OK），还可以设置桌面下拉通知栏，不但可以设置是否显示二级菜单（二级菜单爱好者手动吐槽一众国产和 iPhone），甚至可以设置是手势开启二级菜单还是单独图标（可惜图标不能换位置，不过反正我用手势），桌面可以横屏……以及 Android P 的更新……顺便还有能解决毒瘤但是又不过分突出的智能管理器（真的好用，不是假的，实名推荐），连我这种原生党也非常满意。甚至在 Note 9 上用任意线缆就能开启 DeX 的特性也在 Android P 通过软件更新就实现了（在 S9 上 不是硬件限制！三星也 没有强行要求你买他自家底座 的意思！亲测完全没有阉割。），手动 @ 某没良心配件厂一下。\n最后一个月稍微有点安慰的就是又看了一部 9.8 分的番（上一个 9.8 的是卫宫家今天的饭），青春猪头少年不会梦到兔女郎学姐，真正的高情商男主和真正的高情商女主以及不狗血的爱情故事让人实名羡慕啊，顺便樱岛麻衣真可爱！回头再看看某些废物后宫男，整个的质量已经是在恶心人了，要不是为了明年的电磁炮，谁会花钱看这种东西啊喂！都不是小学生了，天天比这个强比那个强算战斗力有意思吗？\n总而言之，幸福的事情都是相似的，不幸的事情则各有各的不幸，某种程度上来说，幸福的事情并不在于事情本身，而是在等待这件事情到来的过程中，就像一个慢慢升起的气球，当它上升到看不见的时候，反而会有难以掩盖的失落感。反正天下没有不散的宴席，幸福的事情也总会结束，也许是如此人们才会给自己一个又一个理由让平凡的日子变的不平凡，好有下一个盼头支撑自己活下去。但是回过头来看，这些事情又留下了什么呢？\n已经是月亮升起的时候了，何等愚蠢而幸福的人啊！\n"},{"title":"C 语言学习 1","url":"/posts/Learning-C-1/","content":"这个是给 2018 级 C 语言辅导准备的文档，写来写去写了好多，索性在网站也发一份。有很多是我觉得老师不会讲，但一开始很难理解，不理解又听不懂的，有学 C 的可以看一看。\n基础中的基础\n计算机只能识别机器码，机器码对程序员不友好，肯·汤普森和丹尼斯·里奇为了能更容易编写 UNIX，设计和实现了 C 语言。\n从代码到程序\nC 语言本质上是纯文本（就是所谓的 txt 文本文件），使用扩展名 .c 和 .h 主要是为了能够让人从名字上识别这是 C 源码或头文件，你可以使用任何能够编辑文本的程序编辑 C 语言。\n编写 C 程序主要有下面几个步骤：\n\n用任何你喜欢的文本 编辑器 编写代码（记事本也行，但很难用）。\n调用 编译器 将程序员能看懂的代码翻译成机器码组成的程序。\n运行程序，得到结果。\n\n名词解释\n\n编辑器：用来编写代码，可以是任何能编写纯文本文件的程序（Word 这类程序生成的并不是纯文本）。\n编译器：用来翻译代码到机器码，属于比较复杂的程序，通用的有微软的 MSVC，GNU 的 GCC 或者 LLVM 配套的 clang。\n集成开发环境（IDE）：一种集合了文本编辑和编译代码功能的程序，通常还会集合一些附加的功能比如调试等。其中编译代码的功能通常是调用上面提到的那几种编译器进行。\n\n注意：IDE 并不是编写代码的必需品，只要能编写文本又能调用编译器就可以写程序，因此不要问诸如“要用 VS 写 C 语言，那 VS 是用什么写的？写 VS 的公司是神吗？”这种 弱智 问题。没有 C 或者 C++ 很可能就不会有 VS，但是没有 VS 依然可以写程序。\n注意：不建议使用 VC++ 6.0 编写 C 程序，这个程序的年龄比在座各位都大，当时 C 语言还没有完整的标准，和我们现在的 C 相差很多。并且实际上这是个 C++ 的 IDE，而 C 和 C++ 并不完全一样，应该视为两种语言。\n可以使用 VS 或者 Code::Blocks 编写 C 程序，苹果 macOS 用户可以使用 Xcode，这些程序对于最新的 C 标准有着非常好的支持。\n注意：对于 VS 用户需要注意默认 VS 会要求你使用它私有的一些函数而不是标准的 C 函数，这个需要在代码开始添加一行 #define _CRT_SECURE_NO_WARNINGS 来关闭。对于 Code::Blocks 用户请下载集成了编译器的安装包（简而言之，体积最大的），否则你下载下来以后会发现自己并没有编译器，没法编译代码。\n学习 C 语言的正确方法\n多写，多练，光动脑不动手什么语言都学不会，可以从模仿别人的代码开始：\n\n首先对照别人的代码，自己完整的输入一遍程序，这里不是要你复制粘贴，当你能正确的输入程序的时候，说明你已经记住了 C 的结构，不会漏掉分号或者括号等。\n然后阅读代码，并推测这段程序会得到什么结果。然后运行代码\n如果和你预测的结果不一样，研究为什么不一样。\n修改代码中的某一部分，然后重新进行 2 3 4 步，直到你已经完全掌握了这个程序的功能。\n接下来你可以尝试自己从头写一份代码解决问题了。\n\n语法基础\n对于计算机最基础的功能自然是做计算，所以 C 语言需要有 数字 和 符号（必须是英文符号）两种组成成分。\n数字\n没什么好说的，你可以直接写十进制的数字，但是如果你想写十六进制的数字，只要以 0x 或 0X 开头就行，比如 0x1F 就是 31，八进制则是以 0 开头，比如 076 就是 62。\n运算符\n最基础的运算符自然是加减乘除，即 +、-、*、/。（除号这个方向叫斜杠，\\ 这个叫反斜杠。）\n求余数又叫做取模，符号是 %，比如 5 % 2 结果是 1。\n还有位运算 按位与 &amp;，按位或 |，按位异或 ^，左移 &lt;&lt;，右移 &gt;&gt;，它们处理的是内存中的二进制。\n然后是赋值运算符 =，这个符号 将右边的值赋给左边，因此左边必须是变量，右边必须是有值的表达式（表达式的概念往后看），比如 2 = 1 + 1; 很显然不行，2 不是变量，这里也不是比较相等。\n还有一些整合的运算符，比如 +=、-=、*=、/=，x += 1 就等价于 x = x + 1。取模和位运算也可以这样和赋值结合起来。\n然后是一些用来比较关系的符号，比如 大于 &gt;，小于 &lt;，等于 ==（判断相等用两个等号），不等于 !=，大于等于 &gt;=，小于等于 &lt;=。还有逻辑运算符号 与 &amp;&amp;（同真则真），或 ||（同假则假），非 !（反转）。\n在说明之前，需要先了解在 C 语言里什么是真，什么是假，简单来说，0 和所有等于 0 的值都是假，其他所有值都是真。对于关系运算，真返回 1，假返回 0。\n需要注意比如数学上的 1 &lt; x &lt; 2，在 C 语言中是不成立的，因为按照 C 的处理顺序，会先处理 1 &lt; x，这个式子只可能是 0 或者 1，最后就变成 1 &lt; 2 或者 0 &lt; 2，永远是真的了。正确写法是 x &gt; 1 &amp;&amp; x &lt; 2，使用逻辑运算。\na != b 就等价于 !(a == b)。\n, 逗号运算符表示按顺序进行用逗号分隔的子表达式。\n() 小括号就如同数学中的括号一般改变运算顺序。\n[] 中括号用于取数组元素，比如取 数组 arr 的第五个元素（如果有）就是 arr[5]。\n&amp; 用于获取一个变量在内存中的地址，而 * 用于通过地址获取该位置的值。\n. 用于通过结构体变量名字访问结构体内容，比如对于 struct point 变量 point 取 point.position_x。而 * 用于通过结构体指针取结构体内容，比如有指向 struct point 变量 point 的指针 ptr，可以 ptr-&gt;position_x。\n++ 和 -- 分别是让变量自增 1 和自减 1。\nsizeof 这个运算符会给出后面变量的类型或类型本身的字节数，比如 sizeof char 或者 sizeof(char) 值为 1。\n?: 是唯一一个三元运算符，使用类似于 条件 ? 表达式1 : 表达式2，如果条件为真，运行表达式1，否则运行表达式2。\n运算符有非常复杂的优先级顺序，在这里列出表格，建议经常查阅，如果你在写程序的时候遇到不确定的地方，给你想先算的式子加上小括号总没错。\n\n\n\n类别\n运算符\n结合顺序\n\n\n\n后缀\n() [] -&gt; . ++ --\n从左到右\n\n\n一元\n+ - ! ~ ++ -- (type) * &amp; sizeof\n从右到左\n\n\n乘除\n* / %\n从左到右\n\n\n加减\n+ -\n从左到右\n\n\n移位\n&lt;&lt; &gt;&gt;\n从左到右\n\n\n关系\n&lt; &lt;= &gt; &gt;=\n从左到右\n\n\n相等\n== !=\n从左到右\n\n\n位与\n&amp;\n从左到右\n\n\n位异或\n^\n从左到右\n\n\n位或\n|\n从左到右\n\n\n逻辑与\n&amp;&amp;\n从左到右\n\n\n逻辑或\n`\n\n\n\n条件\n?:\n从右到左\n\n\n赋值\n= += -= *= /= %=&gt;&gt;= &lt;&lt;= &amp;= ^= |=\n从右到左\n\n\n逗号\n,\n从左到右\n\n\n注意：C 并没有用于计算类似于 xy 这种乘方的运算符，而是需要调用函数计算，这里没有 **，^ 也不是用来算这个的。\n标识符\n除数字和符号之外，我们还需要更多的词语来给 C 中的东西起名字，这些词语被称为标识符，合法（说人话就是有效）的标识符需要满足以下两条要求：\n\n组成成分是 字母 A-Za-z（区分大小写），数字 0-9，下划线 _（下划线不是连字符 -，因为这个是加减乘除的减）。\n第一个字符必须是字母或者下划线，不能是数字（如果以数字开头，判断这个词到底是数字还是标识符就会变得很麻烦，所以不支持）。\n\n比如 if，areYouOK，Areyouok，Dota2_357 甚至是 _（单个下划线）都是合法且 不同 的标识符。\n注意：尽量不要在程序里使用中文，如果你实在不知道某个词怎么翻译成英语，用拼音也可以勉强接受。千万不要在程序里使用中文标点符号，它们和英文的标点符号是不同的字符，编译器不认识它们。如果你经常分不清中文和英文标点符号，考虑更换你编辑器的字体。\n在大部分情况下，代码中连续的空白符（制表符 tab，换行符 newline 和空格 space）会被当作一个空白处理，因此写几个空格都是无关紧要的。\n关键字\n在合法的标识符的基础上，C 语言规定如下的标识符是它自己 本身 用到的词，这些词 不能被用户或者其他的库用作名字。最开始的 C 语言只有如下 32 个关键字：\nchar        short       int        unsigned\nlong        float       double     struct\nunion       void        enum       signed\nconst       volatile    typedef    auto\nregister    static      extern     break\ncase        continue    default    do\nelse        for         goto       if\nreturn      switch      while      sizeof\n\nC99 标准（就是 1999 年出来的标准）添加了这几个：\n_Bool        _Complex        _Imaginary        inline        restrict\n\nC11 标准（就是 2011 年出来的标准）添加了这几个：\n_Alignas        _Alignof        _Atomic        _Generic        _Noreturn\n_Static_assert  _Thread_local\n\n除去这些，其他都是用户可用的标识符，你可以给你的变量或者函数起任意满足标识符要求并且不是关键字的名字。\n注意：考试的时候看清楚题干要求，如果问的是 标识符，那么关键字也是正确的，如果问的是 用户标识符、函数名或者变量名，那就不能是关键字。\n关键字说明\n这次只说明一些基础的关键字，对于存储类别关键字暂时不做说明。\n数据类型关键字\n除了 void 这个特别的词用来表示“没有类型”，其它的类型关键字都有具体的指代。这些关键字用来标记内存中存储的数据类型是什么，基础的类型有下面几个:\n\nchar：长度为 1 字节，通常用来存储字符。\nshort：长度为 2 字节，用来存储短整数。\nint：长度不确定，考试时候通常认为是 4 字节，通常用来存储整数。\nlong：长度不确定，通常用来存储长整数（大部分现代机器上，int 和 long 一样长）。\nlong long：长度为 8 字节，是 C 里最长的整数类型。\nfloat：长度为 4 字节，存储单精度浮点数，通常精确度较低。\ndouble：长度为 8 字节，存储双精度浮点数，精确度较高，开销略大。\n\n还有一些修饰这些类型用的关键字：\n\nsigned：这个数据可以表示带符号的数字，通常第一位用作符号位，总的数据范围一半正数一半负数（大部分数据类型默认就是带符号的）。\nunsigned：这个数据表示的是不带符号位的数字，即只有 0 和正数，没有负数。例如 unsigned int。\n\n声明变量参照下面的 结构 部分。\n有关数据范围，对于整数，先把长度转成 bit 数，然后有多少 bit 范围就是 2 的多少次方，有符号数正负分别一半一半，负数比正数多一个数，无符号数则就是 0 到这个数。\n还有一个定义结构类型的关键字 struct，这个关键字用来定义结构体，结构体可以把一些数据打包在一起，构成一个独立的类型，比如\nstruct point {\n        int position_x;\n        int position_y;\n};\n\n定义了一个结构体，以后就可以使用 struct point 作为一个新的数据类型。访问结构体使用 . 和 -&gt;。\n还有一个类型别名关键字 typedef，它用来给一个现有的数据定义一个别名，例如 typedef float scalar;，然后就可以用 scalar 作为一个类型，这个类型和 float 是一样的。\n有关数据类型还要注意一点，相同的数据类型进行运算，得到的类型还是原来的类型，如果是不同的数据，会向表示范围更大的数据类型转变。比如整数和短整数进行计算得到整数，整数和浮点数运算得到浮点数，双精度浮点数和单精度浮点数运算得到双精度浮点数。\n在将一个值赋给另一个值的时候类型会被转换成被赋值的数据类型，比如 int a = 3.0; 实际上 a 的值为 3。但上述的运算规则发生在赋值之前，因此就算你写 float b = 5 / 2;，b 的值也是 2.0f 而不是 2.5，因为首先 5 / 2 是整数除整数，得到的就是整数 2，2 转换成浮点数也是 2.0f，因此正确的写法是把 5 或者 2 中的一个改成小数比如 float b = 5.0 / 2;，此时是浮点数与整数运算，首先将整数 2 变成浮点数 2.0，然后得到 2.5。\n流程控制关键字\n条件语句\nif else 的格式如下，如果条件为真，执行语句1，否则执行语句2，也可以不写 else 和语句2。\nif (条件)\n        语句1;\nelse\n        语句2;\n\n嵌套的 else 会和最近的 if 匹配，除非使用大括号显式限制范围。\nif (条件)\n        if (条件)\n                语句1;\nelse\n        语句2;\n\n上面这个 else 会和第二个 if 匹配，而不是第一个。\nif (条件) {\n        if (条件)\n                语句1;\n} else\n        语句2;\n\n这个就和第一个匹配了。\nswitch case 也可以用作条件判断，switch 后面必须接一个条件变量而不是条件语句（这个变量的值必须可以直接用 == 判断相等，很抱歉字符串不可以哈哈哈）。形式如下。\nswitch (条件变量) {\ncase 值1:\n        语句1;\ncase 值2:\ncase 值3:\n        语句2;\n        break;\ndefault:\n        语句3;\n        break;\n}\n\n根据条件变量值的不同，会跳到不同的 case 向下执行，重点是向下，也就是说如果匹配了值1，运行语句1后会向下运行语句2，只有在遇到 break 的时候才会跳出 switch 语句，同样不管匹配的是值2还是值3，都会运行语句2，如果没有匹配，会运行 default 标签下面的内容，当然也可以不写 default。\n循环语句\n有三种循环语句，首先是 while 语句，语法如下。\nwhile (条件)\n        语句;\n\n该循环会在条件为真时执行语句，条件为假则退出循环，每次循环都会先判断一次条件再决定是否循环。\n然后是 for 语句，语法如下。\nfor (循环开始前做一次; 条件; 每次循环结束都做)\n        语句;\n\n该语句在循环开始前执行第一个分号前的内容 一次，然后判断条件决定是否进入循环，在每次循环中的语句结束后，都会做第二个分号之后的内容。如果不想做某一个部分，直接空着就可以，但要保留分号。比如下面的 for 循环就和 while 循环等价。\nfor (; 条件;)\n        语句;\n\n最后是 do while 循环，用于需要先做一次循环内容再做判断的情况。\ndo\n        语句;\nwhile (条件);\n\ndo {\n        语句1;\n        语句2;\n        语句3;\n} while (条件);\n\n跳转语句\n有四种跳转。\ngoto 是无条件的基于标签的跳转。标签就是在某一句前写一个标识符然后加冒号。\n标签: 语句；\ngoto 标签;\n\n这段代码会造成一个死循环，用于在这两句之间跳来跳去。不建议使用 goto 语句，因为在程序里跳来跳去很容易让人逻辑混乱，从而无法调试。\nreturn 语句通常用于在函数中返回一个值，比如 return 0; 返回整数 0，当函数返回之后，该函数就结束了，即使后面还有语句也会被忽略，如果不想返回值，直接 return;。\n剩余两种都是主要用于循环中的语句。\nbreak 用于打断一个循环，从循环中跳出来，比如下面的循环。\nfor (; 1; 语句)\n        break;\n\n如果没有 break 这个语句是一个死循环并且每次都运行语句，但是有 break，第一次进入循环之后就跳了出来，不会执行语句。\ncontinue 用于跳过本次循环中的剩余部分，直接进行下次循环。\nfor (开始; 1; 语句1) {\n        语句2;\n        continue;\n        语句3;\n}\n\n这个程序在执行完开始之后，每次循环都执行语句1，然后 continue 直接结束这次循环进入下一次，因此语句3就不会被执行，但对于 for 循环语句1会被执行。\n通常情况下，break 和 continue 都和 if 搭配使用，它们可以解决大部分情况而不需要 goto。\n结构\nC 程序主要包含以下部分：\n\n预处理器指令\n函数\n变量\n语句和表达式\n注释\n\n注释\n注释用于表示“这部份是给我自己看的，编译器并不需要这部份”，通常我们用它写一些提示自己的语句，编译器会忽略它们。写注释是个好习惯。\n第一种注释使用 /* 和 */，凡是在这两个符号之间的都是注释，这种注释可以跨越多行。\n/*\n我是注释\n我是注释\n我是注释\n*/\n/*我是注释*/\n\n第二种注释使用 //，它的范围是从 // 到这一行的结尾（换行符）。\n// 我是注释\n我不是注释\n\n调试程序的时候也可以临时注释掉一部分问题代码，相比删除，这样可以随时恢复它们。\n语句和表达式\n表达式通常做一个动作并得到一个值（注意没有值也算是一种特殊的值），比如 1 + 1 是一个表达式，这个表达式的值为 2，表达式可以互相组合，比如 (1 + 1) * 2，这个表达式的结果为 4。\n需要注意一些特别的运算符表达式的值，比如我们令 int i = 1;。不管是 i++ 还是 ++i，执行结束后 i 都会变成 2，但是从表达式的值的角度来说，i++ 这个式子的值是 1，而 ++i 值是 2，-- 同理。\n也就是说， i++ == 1 是 真的，++i == 1 是 假的，++ 在前就先加后值，++ 在后就先值后加。\n不要自作聪明，对于 C 语言标准，并没有规定诸如 y=i+++++i（其实就是 y = i++ + ++i）的运算过程。编译器可以认为这两个式子里的 i 都是 1，也可以认为一个是 1 一个是 2，还可以认为都是 2，那 y 就可能是 2 3 4 甚至是奇奇怪怪的不可预测的值。如果考试出现这种问题，建议直接告诉老师题有问题。\nC 语言规定语句以分号 ; 结束而不是以换行符结束，因此你可以在一行里写多条语句，也可以在多行里写一条语句，编译器会阅读其中的分号。一个单独的分号也是一个语句——它什么也不做，我们叫它空语句。\n比如\n1 + 1; 2 + 2;\n\n是两条语句。但是\n(\n        1 + 1\n) * 2;\n\n是一条语句。\n对于 C 关键字中的流程控制关键字，通常它们的作用 只能控制它们下面的一条语句，大括号 {} 可以在文法上将几个语句结合成一个语句（说人话就是如果你想在这些关键字后面接两条或以上的语句，就要加大括号），例如\nif (true) {\n        do_one();\n        do_another();\n}\n\n当然，一个空的大括号 {} 也是空语句。\n函数\n函数是 C 语言中最主要的组成部分，C 语言程序主要就是由各个函数组成。函数类似于数学中的函数，它们接受一些参数，然后做一些操作，最后返回 一个 值（C 限定函数只能返回单个结果或者不返回结果）。\n一个函数的结构通常是像下面这样\n返回值的类型 函数名(参数类型 参数变量名, 参数类型 参数变量名)\n{\n        函数的内容\n}\n\n返回值的类型限制了函数返回的数据的类型（说人话就是你前面写啥类型后面就得返回啥），如果没有返回值，这里使用 void。\n函数名则是一个用户标识符，然后在小括号 () 里放接受的参数列表，按照 参数类型 参数变量名 的格式，中间用逗号连接，如果不接受参数，这里使用 void。\n上面这一部分通常叫做 函数头。\n函数的内容放在大括号里面，这一部分也通常叫 函数体，在这里你可以写 C 语句。\n如果你指定了函数类型，函数内容里必须用 return 表达式返回一个对应类型的值，否则你会得到一个 Error。\n比如有一个限制为处理整数的数学函数 f(x) = x * 2，写成 C 的函数就是下面这样。\nint f(int x)\n{\n        return x * 2;\n}\n\n变量\n变量对应着内存里的一块空间，里面存储着一些数据，必须 先声明一个变量才能使用，声明指定了变量的类型和名字。\n声明变量的时候使用类似这样的格式。\n变量类型 变量名 = 初始值, 变量名 = 初始值;\n\n首先是变量的类型，然后跟着变量名组成的列表，列表之间用逗号分割，也可以同时提供初始值，只需要使用 = 赋值，可以省略 = 和初始值，这时这个变量的值是 不确定的。\n例如 int a, b, c = 2;。\n题外话：建议给变量起个容易理解它是什么的名字，而不是一堆乱七八糟的字母，比如 my_age 就比 x3 更容易理解。在变量名里写上变量类型倒不是个好主意，因为稍微高级点的编辑器都能推导类型，这样写纯粹是浪费空间。\n变量是有作用域的，你不能在作用域外面调用作用域里面的变量，但反过来是可以的，也就是说作用域满足嵌套关系。\n同一个作用域里变量不可以重复声明，否则会得到一个 Error。但如果内层作用域声明了一个和外层重名的变量，内层的会暂时覆盖外层的，也就是说你暂时没办法访问外层的那个。\n通常来说，函数是一层作用域，然后则是对于每个判断或循环语句，它们的条件和它们控制的代码块（就是大括号里面）有一个单独的作用域。也就是说你在循环体里面声明的变量在外面是不能访问的。比如这段代码。\n// 你应该能看懂这个函数没有返回值也没有参数。\nvoid func(void)\n{\n        int x = 1; // 函数作用域。\n        int y = 2;\n        for (int x = 2; x &lt; 10; ++x)\n                printf(\"%d %d\\n\", x, y);        // 这里会输出 2 2 到 9 2 共八行数。\n        printf(\"%d\\n\", x);        // 这里的 x 还是 1！\n}\n\n如你所见，第一次我们声明的变量 x 和 y 属于函数作用域，随后 for 语句中声明的 x 就是循环代码块作用域了，这个 x 暂时掩盖了外面的 x，当离开循环之后，我们又访问到了函数作用域的 x。\n我们还可以一次批量分配多个元素，这种方式被称为数组，数组的声明方式通常是下面这样：\n数据类型 变量名[数组长度] = {元素, 元素, 元素};\n\n= 和后面的部分是为了进行初始化，当然你也可以不进行初始化。\n数组中的元素在内存中是连续的，当你想访问其中的某个元素，只需要使用 数组名[元素序号]。这里的序号是从 0 开始的！也就是说长度为 5 的数组，元素序号分别是 0、1、2、3、4。\n你可以写超出数组长度的序号，C 并不会阻止你这样做，但通常这样会触发一个错误，因为那个位置的内存并不一定让你访问。换句话说，C 语言相信你会限制访问的长度，所以它不进行限制。\n如果你在声明的时候没有进行初始化，那你以后就不能再像初始化元素一样给整个数组直接用 = 赋值了，具体的原因需要等讲过指针之后再进行说明。这个时候你可以给 数组名[元素序号] 进行单个的赋值。比如把 arr 的三个元素都设置成 0。\nint arr[3];\narr[0] = 0;\narr[1] = 0;\narr[2] = 0;\n\n相比于在代码里书写多个变量如 int a1, a2, a3; 使用类似 int arr[3]; 让我们有了在循环中处理它们的办法，你不能在循环中处理变量的名字（这可不是简单地拼字），但你却可以循环处理数组的下标。比如把 arr 的三个元素都设置成 0 还可以这样做。\nint arr[3];\nfor (int i = 0; i &lt; 3; ++i)\n        arr[i] = 0;\n\n预处理器指令\n严格来说，预处理器指令并不是 C 语言的一部分，它们在编译器最开始处理代码的时候进行工作，然后当编译器开始将代码翻译成机器码的时候，预处理器已经工作完毕了。\n预处理指令以 # 开头，每一行为一个语句，大部分预处理指令都是与文本替换有关。\n这里介绍几个简单的例子，复杂的后面会单独说明。\n#include &lt;文件名&gt; 表示将这个文件的内容插入到这一行的位置，一般用来引用头文件，&lt; &gt; 括起来的是系统的头文件名，如果引用的是自己项目里的头文件，则需要用 #include \"文件名\"。\n#define 标识符1 标识符2 表示在文件里所有出现的标识符2都会被替换成标识符1，比如下面的代码。\n#define PI 3.14\nint c = 2 * PI * 5;\nint s = PI * 5 * 5;\n\n在编译器翻译代码到机器码的时候，它看到的代码其实是\nint c = 2 * 3.14 * 5;\nint s = 3.14 * 5 * 5;\n\n示范\nHello world!\n好了，在介绍完这些复杂的东西之后，终于可以进行一个简单的程序示范了，现在书写一个 hello world 就不会遇到有哪里说不明白的情况。\n\nint main(void)\n{\n        printf(&quot;Hello world!\\n&quot;);\n        return 0;\n}\n\" data-info=\"language-c\" data-lang=\"c\" class=\"code-block\">#include &lt;stdio.h&gt;\nint main(void)\n{\n        printf(\"Hello world!\\n\");\n        return 0;\n}\n\n首先第一行的 #include &lt;stdio.h&gt; 表示我们把 stdio.h 这个头文件里的内容全部插入进来，stdio 表示 standard input output 即标准输入输出（这里的输入输出是从程序的角度）。C 语言自身标准规定了许多有用的内置函数给我们使用，但我们首先要在代码里声明它们，这个头文件就包含了一些函数的声明，后续会继续说明头文件的作用与内容。\n然后我们声明一个名称为 main，返回类型为 int，不接受参数的函数，这个函数是 C 语言规定的程序入口，也就是说操作系统运行你的程序，实际上是从调用 main() 开始的，这里需要注意的是在 C 语言标准中，main() 的返回值必须为 int，你可能见到过 void main() 或者干脆连 void 这种返回类型都不写的，这些都是错误写法，某些不标准的编译器可能支持，但实际上标准里没有它们，你在写代码的时候不能这么写。\n这个函数的内容只有两句，第一句 printf(\"Hello world!\\n\"); 会在你的终端界面输出一句 Hello world! 并换行，在这个过程中我们调用了这个叫做 printf() 的函数，它是 C 标准库规定的格式化输出函数（f 可以理解成 format），但我们这次只是用它输出一条字符串，\\n 是一个转义字符，我们用它表示“回车”这个无法在代码里打出来的符号，如果你去掉它，然后再输出点什么，你会发现这两次的输出会出现在同一行里面——C 不会乱做任何你没让它做的事情。有关 printf() 和转义字符的更多内容，下次会进行介绍。\n对于字符串，你可以简单的理解为小说或剧本里面人物说的话，如果不用双引号，剧本里的文字就是叙述性的语句，并不会被观众得知，程序也是一样，不加双引号的均是程序代码，当你想跟用户交流，使用字符串吧。有关字符串的存储方式，也会在后续讲解。\n最后，main() 函数返回整数 0，这个返回值会被它上层的程序捕获（可以简单理解为操作系统），0 在这里约定是程序运行成功的意思，一个复杂的程序可能有多种多样的失败原因，这个时候我们可以用无数的非零值代表各种错误。\n还记得之前关于 return 的说明吗？如果我们把 main() 改写成下面这样。\nint main(void)\n{\n        printf(\"Hello world!\\n\");\n        return 0;\n        printf(\"老师永远也不会看见这句话。\");\n}\n\n你有可能会得到编译器的警告，但它只是提醒你最后这句 printf() 不会被执行，如果你的老师不看源码，他/她永远也不知道这句话。（另外这句话输出之后并不会换行。）\n有关缩进\n缩进这个词有点陌生，程序员用它指代代码的排版格式，尽管 C 给了你把所有程序写在同一行里的能力，但我估计看过这种代码的人都想把作者打一遍出气。\n\nint main(void){printf(&quot;Hello world!\\n&quot;);printf(&quot;Hello world!\\n&quot;);printf(&quot;Hello world!\\n&quot;);printf(&quot;Hello world!\\n&quot;);printf(&quot;Hello world!\\n&quot;);printf(&quot;Hello world!\\n&quot;);printf(&quot;Hello world!\\n&quot;);printf(&quot;Hello world!\\n&quot;);printf(&quot;Hello world!\\n&quot;);printf(&quot;Hello world!\\n&quot;);printf(&quot;Hello world!\\n&quot;);printf(&quot;Hello world!\\n&quot;);printf(&quot;Hello world!\\n&quot;);printf(&quot;Hello world!\\n&quot;);printf(&quot;Hello world!\\n&quot;);printf(&quot;Hello world!\\n&quot;);printf(&quot;Hello world!\\n&quot;);printf(&quot;Hello world!\\n&quot;);printf(&quot;Hello world!\\n&quot;);return 0;}\n\" data-info=\"language-c\" data-lang=\"c\" class=\"code-block\">#include &lt;stdio.h&gt;\nint main(void){printf(\"Hello world!\\n\");printf(\"Hello world!\\n\");printf(\"Hello world!\\n\");printf(\"Hello world!\\n\");printf(\"Hello world!\\n\");printf(\"Hello world!\\n\");printf(\"Hello world!\\n\");printf(\"Hello world!\\n\");printf(\"Hello world!\\n\");printf(\"Hello world!\\n\");printf(\"Hello world!\\n\");printf(\"Hello world!\\n\");printf(\"Hello world!\\n\");printf(\"Hello world!\\n\");printf(\"Hello world!\\n\");printf(\"Hello world!\\n\");printf(\"Hello world!\\n\");printf(\"Hello world!\\n\");printf(\"Hello world!\\n\");return 0;}\n\n当你写了一个复杂一点的程序的时候，有必要修理一下程序格式，让它变的更容易理解，我们在前面说过一个分层次的东西叫作用域，刚好可以利用它。我们可以在每一个作用域的内部语句的行首增加一定数量的空白，来突出层次感。比如在这段代码里指出一个错误\nvoid func(void)\n{\n        for (int i = 0; i &lt; 10; ++i) {\n                for (int j = 0; j &lt; 10; ++j) {\n                        for (int k = 0; k &lt; 10; ++k) {\n                                if (0 = k % 2) {\n                                        printf(\"老师会看见很多次这句话。\");\n                                }\n                        }\n                        for (int k = 0; k &lt; 10; ++k) {\n                                for (int l = 0; l &lt; 10; ++l) {\n                                        if (l % 2 == 0) {\n                                                printf(\"老师会看见很多次这句话。\");\n                                        }\n                                }\n                        }\n                }\n        }\n}\n\n就比在这段代码里\nvoid func(void){for(int i=0; i&lt;10; ++i){for(int j=0; j&lt;10; ++j){for(int k=0; k&lt;10; ++k){if(0=k%2){printf(\"老师会看见很多次这句话。\");}}for(int k=0; k&lt;10; ++k){for(int l=0; l&lt;10; ++l){if(l%2==0){printf(\"老师会看见很多次这句话。\");}}}}}}\n\n要容易的不止一点半点。\n通常这个空白是一个 Tab 按键，或者八个空格，或者四个空格，只要你的一份代码里选择其中一个就可以了，不要搞得参差不齐就行。\n通常还建议你只在一行里书写一个语句，因为你所看见的代码可能有误导性，比如\nif (条件) do_one();\n        do_another();\n\n究竟哪个函数受到条件控制呢？现在也许你分得清，当你写了几百行代码后，可能你就感觉它开始辣眼睛了，建议你写成下面这样：\nif (条件)\n        do_one();\ndo_another();\n\n还有一个建议是，虽然人们在左大括号放在上一行行尾还是单起一行的行首争论不清，但右大括号单独放一行总是没错的。左大括号的两种风格都是好的，你只需要坚持某一个风格就好了。\nif (条件) {\n        do_one(); }\n        do_another();\n\n就不如\nif (条件) {\n        do_one();\n}\ndo_another();\n\n二元运算符两侧建议加空格，比如 int a = 1 + 1 * 3; 就比 int a=1+1*3; 清晰。\n这些都只是建议，但当你写了一定数目的代码，它们能帮助你少犯乱七八糟的错误，别人读你的代码也会更容易。\n提问的艺术\n有时候你自己实在搞不清楚一个问题了，需要问别人。且慢！别人可能很忙，时间宝贵，在一大片代码里找出错的位置就像大海捞针一样愚蠢，特别是许多时候，编译器已经可以告诉你代码哪里错了的情况下。\n在 Google 上搜索“提问的艺术”能找到好多有用的东西，这里只说一条最重要的：当你提问时，请贴上你完整的代码、编译器编译过程中输出的警告和错误（通常在 IDE 下面的小窗口里）以及你代码运行时的输出截图（如果程序通过了编译但运行出错了）。\n最愚蠢的是只说一句没头没脑的“我代码错了！怎么办！”，你就是说一千遍，它还是错的。第二愚蠢的是说一句“我代码错了！我先干了什么什么后干了什么什么又怎么怎么样！”，你是要自己重新描述一遍？还是打算自己从嘴里重新发明个 C 语言？万一你犯的错误是漏了个分号或者括号，我不信你能用嘴描述一遍你代码里的所有分号的位置。\n贴上完整代码保证别人帮你除错的时候不需要回头问你然后等待回复，贴上编译器输出可以直接看到它提示的可能错误的位置（而不是一句一句去读去想），输出截图也是同样的道理。\n别人帮你是自愿花费他的时间，做到以上这一点至少能让别人帮你找错误更容易。\n"},{"title":"我们如何哭泣","url":"/posts/Too-Busy-for-Tears/","content":"\n九月的最后一天，我的网站突破了八万访问量。\n本来有好多可以随便写一点来掩盖自己“九月就要过去了我什么也没写”的恐慌感，比如接着写一篇有关怎么画立方体的 OpenGL 教程，比如还没有文档的 Hikaru 代码里有哪些奇怪的解决方案和奇怪的设定，比如我最近又写了什么乱七八糟的代码（没有）。\n但与其随便说说外界的事情，不如把自己随便想的东西写出来更加严肃。\n有时候常常因为太忙而忘记如何哭泣。尽管眼角的泪水会随着萧瑟的秋风流下，那里面有整个世界和它的影子，但却少了一些流泪的理由。\n是因为天边那抹如血般赤红而哭泣吗？\n我觉得自己常常陷入不该发生的慌乱之中。如果我有在事情伊始就洞察结局的能力，可能就不会如此慌乱。至少有些人的平静是因为他们预料到了所有可能的发展，而我不是，面对事情的平静往往只是外表而已，尽管有时候已经疲惫到无力，还是要装作一副一切尽在掌握的样子。\n能拯救自己的唯一途径也不过是不去回想而已，“随便你怎么样，过去了就好了”，是个万能的替代品。\n“如果一开始就能做到最好就好了。”\n“如果一开始就能发现问题及时退出就好了。”\n“如果一开始就能有最顺利的情况就好了。”\n反正这些都不可能，那有能够及时分辨现状并放弃的能力也是解药。与其在不擅长的事情里周旋，不如拿出勇气放弃掉，反正得不到什么，只要不失去什么就行了。\n所以你看我又在说一些没用的东西。它们完全没有让事情变的更清晰，反而让我的心情变的更佳糟糕。\n独居一室真是令人舒适。\n我又熬过了一个所谓“分别的季节”，真是万幸，不过话说回来，连分别都没有才是最难过的事情吧。想起那句话叫“十七岁有着七十岁的忧愁”，我已经过了这个年纪了啊。\n该如何才能有勇气在清晨的冷风中拥抱太阳。\n在晚上九点强说愁的人，只能写出这样糟糕的文章，那就是我。让人越来越忙是让他们闭嘴的好办法，因为他们闲下来就忘记了如何说话。反正什么都不能说，自然也没必要思考。\n技术文章会有的。\n希望在逐渐到来的冬季再次相遇，我们还有着尽情哭泣的能力。\nTears are our hardest word.\n"},{"title":"学习 OpenGL 1","url":"/posts/Learn-OpenGL-1/","content":"我花了好久来阅读和实践 Learn OpenGL CN 上面的 OpenGL 教程，尽管它是大家推荐的最容易懂也比较新的教程，里面一些东西还是会让人觉得一头雾水。我用了很长的时间来尝试那些教程没有写成文字的部分并确定它们是如何工作的。也许对于一个有足够经验的人来说这些看起来并不是什么大问题，但我觉得新人知道这些以后可以更快的继续学习，而不是花费时间在诸如“这段代码干了什么？”“为什么我的黑屏了？”“如果我想编写一个更复杂的图形程序，该怎么把这些复制过来的代码拆开？”的问题上。\n话先说在前面，虽然标题我写了 OpenGL，但是我实际上使用的是 OpenGL ES —— 你可以把它当作是 OpenGL 的一个子集，只包含基于可编程管线的部分 API，去掉了那些过时的旧模式。虽然 OpenGL ES 主要为移动设备设计，但是在电脑上也能正常使用。Wayland 和 Weston —— Linux 的下一代桌面协议以及它的简单实现 —— 为了去掉 Xorg 的依赖就使用了 OpenGL ES（Linux 下的 libGL 总会引入 X）。\n我使用的是 OpenGL ES 3，最新版本，而且我觉得这里的 OpenGL ES 程序只要做一点简单的改动就可以变成 OpenGL 的程序 —— 毕竟 OpenGL ES 使用的函数就是 OpenGL 的一部分。\nOpenGL 是什么？\n简单来说，OpenGL 是一套 API（废话），它把你传输的三维坐标交给显卡运算并映射到显示器的二维平面上。实际上它仅仅做了这些 —— 和你的显卡交流。使用 OpenGL ES 3 的好处是它没有早期版本中 OpenGL 的立即渲染模式，通常来说这个模式很容易使用（就像 Canvas 绘图）但是效率低下。一些人不会使用核心模式（可编程管线）而是使用旧的模式也导致了所谓的 OpenGL 性能差，但实际上 OpenGL 只是一个规范，具体的性能很容易受到不同厂家实现不同编写人员的影响（给猴子一把磨得很快的斧头的直接后果很可能是猴子砍了自己的脚）。如果打开一份教程上面出现 glBegin()、glEnd() 我建议你立即关掉。\n创建窗口\n首先既然我们要绘图，需要先有窗口。窗口创建之后才可以创建一个 OpenGL 的 Context。对于这个需求不同的平台有不同的方案，比如 Windows 的 WGL，Xorg 的 GLX，以及为了独立于各个平台使用的 EGL（Wayland 为了摆脱 Xorg 使用的就是 EGL），但我们在这里使用一个简单高效的封装库 GLFW。\n首先需要你自己安装 GLFW 库到你的系统中，Linux 用户可以使用包管理解决这个问题，Windows 用户就需要自己下载并放置到你工具指定的位置了（注意头文件的位置并修改 IDE 的搜寻路径 —— 异常麻烦的事情）。\n对于 OpenGL 本身而言，如果你安装了 Mesa（Linux），你应该有一个开源的实现了。Windows 用户可能需要一些处理以保证自己用的是最新版本（据说微软为了推广 D3D 打压 OGL Windows 系统只包含旧的 OpenGL 库，不知道是不是真的），我没有在 Windows 下面编写过，不能给你建议。\n对于 OpenGL 大部分函数地址是运行时确定的（各个驱动的实现不一样），所以需要类似于 GLEW 或者 GLAD 之类的库来在代码中搜寻保存函数指针，但我这里使用 OpenGL ES 似乎是可以直接链接的，所以我没有使用（有错误请告诉我，我好改正）。\n然后我们打开一个 C 文件 —— OpenGL 库是用 C 编写再封装到其它语言的。\n\n#include \n#include \n#include \n#include \n\" data-info=\"language-c\" data-lang=\"c\" class=\"code-block\">#include &lt;stdio.h&gt;\n#include &lt;stddef.h&gt;\n#include &lt;stdbool.h&gt;\n#include &lt;GLFW/glfw3.h&gt;\n#include &lt;GLES3/gl3.h&gt;\n\n这里很明显了。OpenGL 只是一个绘图 API，GLFW 同时帮你封装了有关键盘鼠标输入的部分，但是你需要给它实现一个回调函数。\nvoid key_callback(GLFWwindow *window, int key, int scancode, int action, int mode)\n{\n\tif (key == GLFW_KEY_ESCAPE &amp;&amp; action == GLFW_PRESS)\n\t\tglfwSetWindowShouldClose(window, true);\n}\n\n只是简单实现了按 Esc 之后发送关闭窗口事件。然后我们编写 main 函数。\nint main(int argc, char *argv[])\n{\n\tglfwInit();\n\tunsigned int width = 800;\n\tunsigned int height = 600;\n\tGLFWwindow *window = glfwCreateWindow(width, height, \"learn-gles\", NULL, NULL);\n\tglfwMakeContextCurrent(window);\n\tglfwSetKeyCallback(window, key_callback);\n\n\t/* Code Here */\n\n\tglfwDestroyWindow(window);\n\tglfwTerminate();\n\treturn 0;\n}\n\n先别急着编译它，因为你还没有正确链接。如果你编译好了，这个程序运行之后窗口一闪而过就会消失，但你已经完成了窗口创建。\n如果是 macOS 请在 glfwInit() 之后添加 glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);，并咒骂苹果为什么去掉了 OpenGL 支持。\n接下来我们进行 OpenGL 代码的编写。\n绘制三角形\n三角形是计算机图形学里基本的图元，更复杂的模型通常都是用三角形拼合的，这也是为什么人们会用 每秒渲染多少个三角形 来表示显卡性能。\n创建视口（Viewport）\n首先我们创建 OpenGL 的 Viewport，事实上不管你的窗口分辨率是多少，OpenGL 都会把这个平面映射到 -1.0f 到 +1.0f，你使用时候的坐标都是在这个范围内，而不是屏幕上的像素点数目。\n下面的代码需要插入到上面 /* Code Here */ 的上面。\n\tglViewport(0, 0, width, height);\n\n因为我们的程序太过简单，就不需要开启其它特性了，否则在这里你可以开启一些 OpenGL 的特性比如背面剔除（如果你玩 CS，你会发现死亡画面你的摄像机经常会穿进敌人体内，这时候敌人的后背变成了透明的，这就是背面剔除，可以减少绘制的图形的数量。\n大循环\n如果你对游戏引擎有所了解，应该知道整个流程就是一个大循环：引擎不断地绘制图案并把它更新到屏幕上，每一帧我们更新一些数据，就会形成动画。我们这里也是这样进行，在 glViewport() 和 glfwTerminate() 之间插入这个。\n\twhile (!glfwWindowShouldClose(window)) {\n\t\tglfwPollEvents();\n\n\t\tglClearColor(0.0f, 0.0f, 0.0f, 0.0f);\n\t\tglClear(GL_COLOR_BUFFER_BIT);\n\n\t\t/* Draw Here */\n\n\t\tglfwSwapBuffers(window);\n\t}\n\n现在我们有一个大循环了，这个循环里首先拉取 GLFW 的事件以便响应（目前只有 Esc 退出），然后用黑色清除整个 Viewport（四个参数分别是 rgba，0.0f 是无色，1.0f 是满色），最后 GLFW 把绘制好的缓冲区交换到窗口上。\n现在你可以使用下面的命令编译并运行它了。会出现一个全黑的窗口。\n$ cc -std=c11 -lglfw -lGLESv2 yourcode.c &amp;&amp; ./a.out\n\n\nMesa 把 GLES 3 当作 GLES 2 的扩展，所以这里链接的是 libGLESv2。\n着色器（Shader）\n什么是 Shader？Shader 是一种运行在显卡上的小程序。首先你知道 CPU 一般是多核的，每个核心可以单独运行不同的程序。然而你的屏幕是由多个像素组成的，在运行游戏的时候每个像素的数据都需要单独计算，即使每个都是简单的乘法，CPU 也很难在短期内全部处理完毕（几十万个像素是很难由八个核心在 16ms 之内解决掉的），所以 GPU 为了解决这个问题，带有许多互相独立的小核心，每个核心的性能是远远不如 CPU 的，但它们处理的问题也没有 CPU 复杂，所以可以很快的处理掉屏幕上全部的像素。\n一般你的数据只会包含几个顶点的位置，这时候你可以编写小程序去处理这些顶点的数据，比如位移或者从数据中获取颜色，这一步的程序叫做 Vertex Shader（顶点着色器），然后 OpenGL 会把首先把这些顶点连接起来，再计算出中间的范围，对中间的部分进行插值，然后把它们映射到一个一个的像素点上，这个过程叫做 光栅化，然后接下来 GPU 中的每个小核心就开始单独处理一部分像素了，为了增加效率和创造更好地效果（光照效果之类的影响最终颜色的），你可以编写小程序交给这些小核心运行，这一步的程序叫做 Fragment Shader（片段着色器）（在 Direct3D 里面叫 Pixel Shader 像素着色器）。OpenGL 使用一种叫 GLSL 的语言编写着色器，在这一步你需要建立两个文件，首先是 triangle.v.glsl（名字可以随意）的顶点着色器。\n#version 300 es\nlayout (location = 0) in vec3 pos;\nlayout (location = 1) in vec3 color;\nout vec3 pass_color;\nvoid main()\n{\n\tgl_Position = vec4(pos, 1.0f);\n\tpass_color = color;\n}\n\n第一行的预处理指令表明了我们使用的是 3.00 版本的 OpenGL ES，如果是 OpenGL 可以使用 #version 330 core，然后二三行声明了输入变量，前面的 layout (location = 0) 指定了它们的位置，也可以省略，但是就需要在程序里手动查找位置了。后面的 in 表明是输入变量，vec3 表明是由三个数字组成的向量，后面是变量名，我们用到了位置和颜色。然后下面 out 表明是输出变量，很明显 Vertex Shader 是不能控制颜色的，Fragment Shader 才控制颜色，所以我们在这里把传进来的颜色再输出，下一步传给 Fragment Shader。\n然后程序从 main 开始执行，这里 不需要返回值，gl_Position 是内置的变量，你需要把顶点的最终变换完的位置赋给它，这里我们没有变化，直接把三元坐标转换成四元的齐次坐标。然后把输入进来的颜色传出去。总之，如果你保证 Vertex Shader 输出的变量和 Fragment Shader 输入的变量名字一样，OpenGL 就会把它们连接起来。\n接下来我们建立 triangle.f.glsl，如下。\n#version 300 es\nprecision mediump float;\nin vec3 pass_color;\nout vec4 frag_color;\nvoid main()\n{\n\tfrag_color = vec4(pass_color, 1.0f);\n}\n\n第二行表明的是设置精度为 float，OpenGL ES 要求 Fragment Shader 必须有这一条，标准 OpenGL 可以去掉，与 gl_Position 不同，为了满足多样化的需求，Fragment Shader 的输出变量需要自己声明，然后我们给输入的颜色加上 1.0f 的 Alpha 值输出。\n接下来类似于我们编译 C 程序的过程，不同的是我们要编写 C 代码读取文件，分别编译并链接为一个程序。我在这里直接给出我封装好的过程来简化代码量，具体说明参见注释。你可以把它们添加到 main 函数外面（上面）。\n\n#include \n#define LINE_LENGTH 512\n\nGLuint make_shader(GLenum type, const char *const shader_src)\n{\n\tGLuint shader = 0;\n\tGLint compiled = 0;\n\t// 创建 shader。\n\tif (!(shader = glCreateShader(type))) {\n\t    fprintf(stderr, &quot;Shader Create Error.\\n&quot;);\n\t    return 0;\n\t}\n\t// 加载 shader 内容。\n\tglShaderSource(shader, 1, &amp;shader_src, NULL);\n\t// 开始编译。\n\tglCompileShader(shader);\n\t// 检查编译结果。\n\tglGetShaderiv(shader, GL_COMPILE_STATUS, &amp;compiled);\n\t// 编译失败的话读取错误输出。\n\tif (!compiled) {\n\t    GLint info_len = 0;\n\t    glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &amp;info_len);\n\t    if (info_len) {\n\t\tchar *info = malloc(info_len * sizeof(*info));\n\t\tif (!info) {\n\t\t\tfprintf(stderr, &quot;Malloc Error.\\n&quot;);\n\t\t\tglDeleteShader(shader);\n\t\t\treturn 0;\n\t\t}\n\t\tglGetShaderInfoLog(shader, info_len, NULL, info);\n\t\tfprintf(stderr, &quot;Compile Error: %s\\n&quot;, info);\n\t\tfree(info);\n\t    }\n\t    glDeleteShader(shader);\n\t    return 0;\n\t}\n\treturn shader;\n}\n\nGLuint load_shader(GLenum type, const char *const shader_path)\n{\n\tGLuint result = 0;\n\tFILE *fp = NULL;\n\tsize_t file_length = 0;\n\tchar *file_content = NULL;\n\tchar temp_line[LINE_LENGTH];\n\t// 读取文件不用解释了吧……\n\tif (!(fp = fopen(shader_path, &quot;r&quot;))) {\n\t\tfprintf(stderr, &quot;Open file %s failed.\\n&quot;, shader_path);\n\t\treturn 0;\n\t}\n\tfseek(fp, 0l, SEEK_END);\n\tfile_length = ftell(fp);\n\trewind(fp);\n\tif (!(file_content = malloc(file_length))) {\n\t\tfprintf(stderr, &quot;Malloc Error.\\n&quot;);\n\t\tfclose(fp);\n\t\treturn 0;\n\t}\n\tfile_content[0] = '\\0';\n\twhile (fgets(temp_line, LINE_LENGTH, fp))\n\t\tstrncat(file_content, temp_line, LINE_LENGTH);\n\tfclose(fp);\n\tresult = make_shader(type, file_content);\n\tfree(file_content);\n\treturn result;\n}\n\nGLuint load_program(const char *const vshader_path, const char *const fshader_path)\n{\n\tGLint linked = 0;\n\tGLuint vshader = 0;\n\tGLuint fshader = 0;\n\tGLuint program = 0;\n\t// 加载类型为 GL_VERTEX_SHADER。\n\tvshader = load_shader(GL_VERTEX_SHADER, vshader_path);\n\t// 加载类型为 GL_FRAGMENT_SHADER。\n\tfshader = load_shader(GL_FRAGMENT_SHADER, fshader_path);\n\tif (!vshader || !fshader)\n\t\treturn 0;\n\t// 创建 Shader Program。\n\tif (!(program = glCreateProgram())) {\n\t\tfprintf(stderr, &quot;Program Create Error.\\n&quot;);\n\t\treturn 0;\n\t}\n\t// 给 Program 添加 Shader。\n\tglAttachShader(program, vshader);\n\tglAttachShader(program, fshader);\n\t// 将两个 Shader 链接成 Program。\n\tglLinkProgram(program);\n\t// 链接完成可以删除 Shader 了。\n\tglDeleteShader(vshader);\n\tglDeleteShader(fshader);\n\t// 检查链接状态。\n\tglGetProgramiv(program, GL_LINK_STATUS, &amp;linked);\n\t// 链接失败读取错误输出。\n\tif (!linked) {\n\t\tGLint info_len = 0;\n\t\tglGetProgramiv(program, GL_INFO_LOG_LENGTH, &amp;info_len);\n\t\tif (info_len) {\n\t\t\tchar *info = malloc(info_len * sizeof(*info));\n\t\t\tif (!info) {\n\t\t\t\tfprintf(stderr, &quot;Malloc Error.\\n&quot;);\n\t\t\t\tglDeleteProgram(program);\n\t\t\t\treturn 0;\n\t\t\t}\n\t\t\tglGetProgramInfoLog(program, info_len, NULL, info);\n\t\t\tfprintf(stderr, &quot;Link Error: %s\\n&quot;, info);\n\t\t\tfree(info);\n\t\t}\n\t\tglDeleteProgram(program);\n\t\treturn 0;\n\t}\n\treturn program;\n}\n\" data-info=\"language-c\" data-lang=\"c\" class=\"code-block\">#include &lt;stdlib.h&gt;\n#include &lt;string.h&gt;\n#define LINE_LENGTH 512\n\nGLuint make_shader(GLenum type, const char *const shader_src)\n{\n\tGLuint shader = 0;\n\tGLint compiled = 0;\n\t// 创建 shader。\n\tif (!(shader = glCreateShader(type))) {\n\t    fprintf(stderr, \"Shader Create Error.\\n\");\n\t    return 0;\n\t}\n\t// 加载 shader 内容。\n\tglShaderSource(shader, 1, &amp;shader_src, NULL);\n\t// 开始编译。\n\tglCompileShader(shader);\n\t// 检查编译结果。\n\tglGetShaderiv(shader, GL_COMPILE_STATUS, &amp;compiled);\n\t// 编译失败的话读取错误输出。\n\tif (!compiled) {\n\t    GLint info_len = 0;\n\t    glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &amp;info_len);\n\t    if (info_len) {\n\t\tchar *info = malloc(info_len * sizeof(*info));\n\t\tif (!info) {\n\t\t\tfprintf(stderr, \"Malloc Error.\\n\");\n\t\t\tglDeleteShader(shader);\n\t\t\treturn 0;\n\t\t}\n\t\tglGetShaderInfoLog(shader, info_len, NULL, info);\n\t\tfprintf(stderr, \"Compile Error: %s\\n\", info);\n\t\tfree(info);\n\t    }\n\t    glDeleteShader(shader);\n\t    return 0;\n\t}\n\treturn shader;\n}\n\nGLuint load_shader(GLenum type, const char *const shader_path)\n{\n\tGLuint result = 0;\n\tFILE *fp = NULL;\n\tsize_t file_length = 0;\n\tchar *file_content = NULL;\n\tchar temp_line[LINE_LENGTH];\n\t// 读取文件不用解释了吧……\n\tif (!(fp = fopen(shader_path, \"r\"))) {\n\t\tfprintf(stderr, \"Open file %s failed.\\n\", shader_path);\n\t\treturn 0;\n\t}\n\tfseek(fp, 0l, SEEK_END);\n\tfile_length = ftell(fp);\n\trewind(fp);\n\tif (!(file_content = malloc(file_length))) {\n\t\tfprintf(stderr, \"Malloc Error.\\n\");\n\t\tfclose(fp);\n\t\treturn 0;\n\t}\n\tfile_content[0] = '\\0';\n\twhile (fgets(temp_line, LINE_LENGTH, fp))\n\t\tstrncat(file_content, temp_line, LINE_LENGTH);\n\tfclose(fp);\n\tresult = make_shader(type, file_content);\n\tfree(file_content);\n\treturn result;\n}\n\nGLuint load_program(const char *const vshader_path, const char *const fshader_path)\n{\n\tGLint linked = 0;\n\tGLuint vshader = 0;\n\tGLuint fshader = 0;\n\tGLuint program = 0;\n\t// 加载类型为 GL_VERTEX_SHADER。\n\tvshader = load_shader(GL_VERTEX_SHADER, vshader_path);\n\t// 加载类型为 GL_FRAGMENT_SHADER。\n\tfshader = load_shader(GL_FRAGMENT_SHADER, fshader_path);\n\tif (!vshader || !fshader)\n\t\treturn 0;\n\t// 创建 Shader Program。\n\tif (!(program = glCreateProgram())) {\n\t\tfprintf(stderr, \"Program Create Error.\\n\");\n\t\treturn 0;\n\t}\n\t// 给 Program 添加 Shader。\n\tglAttachShader(program, vshader);\n\tglAttachShader(program, fshader);\n\t// 将两个 Shader 链接成 Program。\n\tglLinkProgram(program);\n\t// 链接完成可以删除 Shader 了。\n\tglDeleteShader(vshader);\n\tglDeleteShader(fshader);\n\t// 检查链接状态。\n\tglGetProgramiv(program, GL_LINK_STATUS, &amp;linked);\n\t// 链接失败读取错误输出。\n\tif (!linked) {\n\t\tGLint info_len = 0;\n\t\tglGetProgramiv(program, GL_INFO_LOG_LENGTH, &amp;info_len);\n\t\tif (info_len) {\n\t\t\tchar *info = malloc(info_len * sizeof(*info));\n\t\t\tif (!info) {\n\t\t\t\tfprintf(stderr, \"Malloc Error.\\n\");\n\t\t\t\tglDeleteProgram(program);\n\t\t\t\treturn 0;\n\t\t\t}\n\t\t\tglGetProgramInfoLog(program, info_len, NULL, info);\n\t\t\tfprintf(stderr, \"Link Error: %s\\n\", info);\n\t\t\tfree(info);\n\t\t}\n\t\tglDeleteProgram(program);\n\t\treturn 0;\n\t}\n\treturn program;\n}\n\n有了这个之后只需要在 glViewport() 和 /* Code Here */ 之间调用 load_program() 生成 Shader Program。\n\tunsigned int program = load_program(\"triangle.v.glsl\", \"triangle.f.glsl\");\n\n顶点数据\n接下来我们需要准备绘制的数据了，对于一个三角形来说，需要三个顶点来确定它的位置，每个顶点需要三个坐标，同时我们还要给它们创建不同的颜色，让我们的三角形变的多彩一点，所以需要像下面一样组织数据。把这段插入到之前 /* Code Here */ 和 glViewport(0, 0, width, height); 之间。对于 3D 程序来说 float 的精度足够了，double需要更多的运算时间。\n\tconst float vertices[] = {\n\t\t// 坐标\t       颜色\n\t\t-0.5f, -0.5f, 0.0f, 1.0f, 0.0f, 0.0f,\n\t\t0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f,\n\t\t0.0f,  0.5f, 0.0f, 0.0f, 0.0f, 1.0f\n\t};\n\nOpenGL 的坐标是这样安排的：\n\n从左到右对应 X 坐标 -1.0f 到 1.0f。\n从下到上对应 Y 坐标 -1.0f 到 1.0f。\n从外到里对应 Z 坐标从负到正，需要注意这个可能会被以后的数学变换改变。\n\n我们这里是平面的所以 Z 坐标都是一样的。\n现在我们把数据存储到了内存里面，但是很显然我们需要把数据传给显卡。这时候我们需要创建一个 OpenGL 的 Buffer 对象，并把数据写进去。也有人管这个叫 VBO，但是我觉得这个缩写还没有 Buffer 来的直观。\n\tunsigned int buffer;\n\tglGenBuffers(1, &amp;buffer);\n\tglBindBuffer(GL_ARRAY_BUFFER, buffer);\n\tglBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);\n\tglBindBuffer(GL_ARRAY_BUFFER, 0);\n\n这里的 buffer 只是一个符号，真正的空间分配我们是不能干预的。glGenBuffers()可以生成多个 Buffer，这里我们只生成一个，所以第一个参数设置为 1，第二个则是一个指针，我们这里只有一个所以没有使用数组，而是直接取了地址。\n接下来我们把这个 buffer 用 glBindBuffer() 绑定到 GL_ARRAY_BUFFER 表示这里是放置顶点的数组。glBufferData() 则会把数据写入到刚才绑定好的 buffer，所以要先进行绑定，这里第一个参数表示写入的目标，就是刚才的 GL_ARRAY_BUFFER，第二个则是写入数据的大小，第三个是写入数据的指针，第四个 GL_STATIC_DRAW 表示写入的数据是用来绘图的，不会经常改变。做完这一切我们取消绑定，把 GL_ARRAY_BUFFER 绑定到默认的 0。\n题外话：经常有人吐槽 OpenGL 这个状态机的设定，觉得绑来绑去很麻烦，但是你可以封装一下让它变的好用点。关键要记得处理完就解绑，避免状态混乱。至于 D3D 是什么样子的，我不知道，我也并不关心，就算 D3D 再好，不能让我在 Linux 下使用就是致命的，OpenGL 也许不完美，但它能用，我觉得也挺好。\n接下来还有个关键问题：显卡不知道我们传进去的数据是什么，怎么解释它。它不知道前三列是坐标后三列是颜色，甚至不知道这些数据是整数还是浮点数。所以我们需要使用 VertexArray 来解释 Buffer 中数据的含义，继续插入下面这部份。\n\tunsigned int vertex_array;\n\tglGenVertexArrays(1, &amp;vertex_array);\n\tglUseProgram(program);\n\tglBindVertexArray(vertex_array);\n\tglBindBuffer(GL_ARRAY_BUFFER, buffer);\n\tglVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void *)0);\n\tglEnableVertexAttribArray(0);\n\tglVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void *)(3 * sizeof(float)));\n\tglEnableVertexAttribArray(1);\n\tglUseProgram(0);\n\tglBindVertexArray(0);\n\tglBindBuffer(GL_ARRAY_BUFFER, 0);\n\n首先我们还是创建一个标记符。然后使用 glGenVertexArrays() 生成，因为这里的数据要输入给 Program，在对 Program 操作之前先要绑定当前 Program，所以调用 glUseProgram(program);，接下来把 vertex_array 绑定到当前状态，然后我们要告诉 OpenGL 这个 VertexArray 对应的是哪个 Buffer，所以也要把刚才的 buffer 绑定到当前状态让它们关联。\n然后调用 glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void *)0); 传递坐标的信息，第一个参数表明这里的数据会被传递到 Vertex Shader 里面 location = 0 的地方，第二个参数表示一次传递三个数据（对应 vec3），第三个表示传进去的是 float 类型，第四个表示是否进行标准化（缩放到 0 和 1 之间），很显然我们不需要，第五个是步长，表示传递完一组数据之后 从这一组数据开头跳过多长会到下一组数据的开头，我们这里是三个坐标三个颜色，所以是六个 float，最后一个是偏移量 Offset，因为我们坐标是从第一个开始，所以是 (void *)0。最后用 glEnableVertexAttribArray(0); 启用位置 0。\n接下来用 glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void *)(3 * sizeof(float))); 传递颜色的信息，这次是位置 1，区别在于第一个颜色是在第四个数据开始的，所以偏移量设置为 (void *)(3 * sizeof(float)))。最后用 glEnableVertexAttribArray(1); 启用位置 1。\n最后不要忘记解绑。\n绘制三角形\n一切准备就绪，终于可以开始和绘制三角形了，下面我们进入循环，在 /* Draw Here */ 那里插入如下代码。\n\tglUseProgram(program);\n\tglBindVertexArray(vertex_array);\n\tglDrawArrays(GL_TRIANGLES, 0, 3);\n\tglUseProgram(0);\n\tglBindVertexArray(0);\n\n简单多了对不对！因为我们已经准备好了一切，所以这里只要加载你的 Shader Program，然后绑定你的 VertexArray，用 glDrawArrays(GL_TRIANGLES, 0, 3); 告诉 OpenGL 绘制当前 Array 即可，第一个参数 GL_TRIANGLES 表示我们要画三角形（还记得吗，大部分复杂图形都可以用三角形近似拼出来，所以这里有复数），第二个表示从第几个顶点开始，第三个则表示画几个顶点。然后 OpenGL就会按你的要求去画图。最后不要忘记解绑。\n清理\n在退出程序之前最好清理掉你分配的空间，所以在循环后面和 glfwDestroyWindow(window); 之间插入如下部分。\n\tglDeleteBuffers(1, &amp;buffer);\n\tglDeleteVertexArrays(1, &amp;vertex_array);\n\tglDeleteProgram(program);\n\n\n还有一件事……\n你可能会问“我还是不知道这些代码怎么拆分成各个部分！”，实际上我文章里的代码片段基本都是互相独立的部分，也就是说它们可以按照顺序放在程序的不同位置，而不必完全紧邻。产生 Buffer，产生 VertexArray 和 Shader Program 不是需要放在大循环里面的，你大可以把这些数据封装起来。\n虽然你成功画出了三角形，但是你可能还是不知道怎样画更复杂的图形，实际上你不需要修改你的顶点数据和 Shader 来改变图形位置。大部分复杂的模型由相应的建模软件导出顶点数据和贴图坐标，你也不需要手动安排这些模型在 OpenGL 坐标系中的位置，后面我会给出使用矩阵和向量运算处理坐标系统的样例。\n如果你觉得这样看代码只是管中窥豹，下面我给出完整的程序链接。\n\nmain.c\ntriangle.v.glsl\ntriangle.f.glsl\n\n"},{"title":"Automata: A Story of Love and Lives","url":"/posts/Automata/","content":"\n  \n我没有办法评价这款游戏因为我觉得我被结结实实地折腾了一回\nEverything that lives is designed to end.\n一切活着的事物，都注定要终结。\nWe are perpetually trapped ...\n我们被永远地囚禁……\n... in a never-ending spiral of life and death.\n……于永无止境的生死轮回之中。\nIs this a curse?\n这是一种诅咒？\nOr some kind of punishment?\n还是某种惩罚？\nI often think about the god who blessed us with this cryptic puzzle ...\n我时常想起那用模糊的谜团祝福我们的神……\n... and wonder if we'll ever have the chance to kill him.\n……并猜想我们是否终有弑神的机会。\n\n我大概对动作游戏不感冒。在我最开始对 NieR: Automata 感兴趣的时候，是把它当作剧情游戏来看的，动作只是买剧情附赠的而已。\n一周目结束的时候，我以为这是个讲生命和进化的故事。\n二周目结束的时候，我以为它要说的是爱情。\n三周目开始，我才发现作者是要给玩家一个彻头彻尾的绝望。\n能写出这样的开场白的人，不可能做出一款糟糕的游戏。尽管整个游戏的游玩方式实在是逊色于剧情，整个游戏的玩法总结起来大概就是无穷无尽的跑打跑打跑打，伴随着优美或者说是诡异的配乐。然而排除掉动作和开放世界元素仍然可以维持形式完整，以视觉小说的形式，开放世界大概是由于经费限制并没有什么玩法，我又不是有闲情逸致跑来跑去做任务的人。\n以机械构成昆虫或者战车的形式确实很好的在震撼和恶心之间取得了平衡，随时出现的视角锁定也可以说是独树一帜的特色。\n原本以为一二周目就完整地讲述了进化与爱情，但是和后面的故事比起来，前面的打斗只是岁月静好罢了。\n前两周目是一个玩的人很累但看的人很爽的游戏，当然 PC 版就算了，毕竟 bug 太多，PS4 的优化和画质还是很不错的，特别是与巨型兵器战斗时的满天阴霾。至于配音，英语和日语一样优秀，有时反而是中文翻译失去了英文的特殊味道，第一次在 PC 上启动英文版之后许多表达方式就印在了脑海里。\n三周目则是让看的人和玩的人都感到痛苦。失去 Bunker，失去爱人，足以让人疯狂。机械都进化出了感情，人造人怎么会没有呢？\n故事有多绝望，剧情就可以有多荒谬。\n凌晨三点，经历过所有优秀的战斗场面和恰到好处的音乐之后，还是删掉了存档。一切的一切好像终于结束了，没有办法评价这个故事，没有办法评论其中各种可以说是鬼才的设计，没有办法评价这个游戏。它不像是会让你流出眼泪，只是会让你觉得，这次是结结实实地被横尾太郎折腾了一把呢。\n2B，9S 和 A2 都留下来了，还有什么需要留恋的吗？可惜缺乏的是再来一次的勇气。\n“如果没有战争，我们也不是士兵，是不是也能在这里像人类一样购物呢？”\n但愿是吧。\n\nEverything that lives is designed to end.\n一切活着的事物，都注定要终结。\nThey are perpetually trapped ...\n我们被永远地囚禁……\n... in a never-ending spiral of life and death.\n……于永无止境的生死轮回之中。\nHowever ... life is all about the struggle within this cycle.\n但是……生命的全部意义就在于这轮回中的挣扎。\nThat is what we believe.\n那正是我们所相信的。\n\n\n后记：在 NieR: Automata 原生音乐会“人形们的记忆”上的朗读剧里交待了 Pod 们修复好 2B 和 9S 身体后发生的事情，也就是 E 结局之后的真正结局。按照 The Ark 这个网站的记录，原本的台本是这样的（可靠性未知）（我在这里附上翻译）（看完伤心欲绝的往后看）：\n\n（前情：2B 磨损了自己的手在「塔」的遗迹 40 米深处挖掘方舟留下来的结晶直到由于过载自动关机，然后使用结晶制作疫苗尝试修复 9S 但却失败了。）\nPod 042: Hypothesis: Unit 9S's personal data has been lost.\n辅助机 042: 猜测：机体 9S 的个人数据已经丢失。\n2B: His personal data ... is lost ...?\n2B：他的个人数据……丢失了……？\nPod 042: His personal data was wiped of his own accord, but there is the possibility it was moved to a different location.\n辅助机 042：他的个人数据被从他的个人记录中擦除了，但是也有可能是被移动到了别处。\nPod 042: According to unit A2's records, it is possible his data evacuated using the machine lifeforms' Ark.\n辅助机 042：按照机体 A2 的记录，他的数据有可能是通过机械生命体的方舟疏散到了别处。\nPod 042: Report: Possibility of recovering 9S's personal data extremely low.\n辅助机 042：报告：9S 的数据恢复可能性极低。\nS.P.\n（这个不知道是什么的缩写了……有人知道请教教我。）\n2B: No ...\n2B：不……\n(Three beeps suddenly sound.)\n（突然响起了三声蜂鸣。）\nPod 153: Report: Memory components discovered within traces of personal data.\n辅助机 153：报告：内存组件发现了个人数据的痕迹。\nPod 153: Partial playback possible of memory containing the reason behind his personal data's reparation failure.\n辅助机 153：可用的内存局部回放包含有他个人数据修理失败的原因。\nPod 153: Query: Does unit 2B feel it necessary to repair this memory component?\n辅助机 153：询问：机体 2B 是否认为有必要修复该内存组件？\n2B: ... Repair it.\n2B：……修复。\nPod 153: Understood.\n辅助机 153：了解。\nPod 153: Data repair complete.\n辅助机 153：数据修复成功。\nPod 153: Starting playback.\n辅助机 153：开始回放。\n(9S's voice begins to play back with the sound quality of a radio.)\n（9S 的声音开始以无线设备的音质回放。）\n(He seems to be suffering.)\n（他听起来很痛苦。）\n9S: I didn't ... get on the Ark created by the machine lifeforms.\n9S：我没有……乘上机械生物制造的方舟。\n9S: We YoRHa ... We don't deserve to be loved by this world ... We're just soldiers created to die ...\n9S：我们寄叶部队……我们不值得得到这个世界的爱……我们只是为了死去而制造出来的士兵而已。\n9S: But I ... I think I'm glad ... that I was born as myself ...\n9S：但是我……我很高兴……能够作为我自己而生……\n9S: My personal data, it ... it can't keep it's shape any longer.\n9S：我的个人数据…，它……它没办法再保持它的形式了。\n9S: I wonder if I'm about to disappear ...\n9S：我想我是该离去了……\n9S: But ... I'm okay with that ...\n9S：但是……我觉得还好……\n(2B begins crying, without making a sound.)\n（2B 开始无声地哭泣。）\n9S: Hey, 2B ... We're ... we're not immortal or anything.\n9S：嘿，2B……我们……我们不是不朽的也不是无所不在的。\n9S: Someday, we'll break down and return to the Earth.\n9S：终有一天，我们会损坏并归于尘土。\n9S: But, my existence ... It wasn't meaningless ...\n9S：但是，我的存在……并不是毫无意义……\n9S: Meeting you ... gave meaning to my birth ...\n9S：遇见你……给了我出生的意义……\n9S: Thank you ...\n9S：谢谢你……\n9S: Thank you ...\n9S：谢谢你……\n(9S's voice becomes hidden behind static.)\n（9S 的声音逐渐消失在静电声里。）\n(2B begins weeping.)\n（2B 开始抽泣。）\n2B: Nines ...\n2B：Nines……（抱歉我觉得“奈兹”这个翻译还不如不翻译……太糟糕了……）\nPod 042 (narration): 2B didn't move from where she was.\n辅助机 042（叙述）：2B 没有从她所在的地方移动过。\nPod 042 (narration): She remained there, unmoving ... forever.\n辅助机 042（叙述）：她留在那里没有移动……永远地。\nPod 042 (narration): It was a completely irrational act.\n辅助机 042（叙述）：这是个十分不合理的举动。\nPod 042 (narration): However, \"we\" can now understand the reason behind it.\n辅助机 042（叙述）：然而，“我们”现在可以理解这样做的原因。\nPod 042 (narration): Why it was that 2B didn't move.\n辅助机 042（叙述）：为什么 2B 不再移动。\nPod 042 (narration): Why it was that she didn't speak.\n辅助机 042（叙述）：为什么她不再说话。\nPod 042 (narration): Because \"we\" are now in that same place, feeling the same thing.\n辅助机 042（叙述）：因为“我们”如今也在相同的位置，感受着相同的事情。\n\n然而不知出于什么原因，最终公开的剧本里是 9S “不知出于什么原因” 在疫苗修复失败后醒过来了（“你还知道醒过来啊喂！”），推测可能是横尾老贼害怕继续虐 2B 小姐姐被玩家打死吧。下面附上 B 站带中文字幕的视频，一定要看完啊！\n\n"}]}