自我感觉还是不错的,友链的博客们有什么更新都可以实时展示在页面上,一目了然。作为博主,不用打开 RSS 阅读器就可以查看新文章;作为访客,也可以快速找到更多自己感兴趣的内容,比起原来全是链接的页面,看起来也让人更有点击欲望了。
从临时起意到开发完成总共两个晚上,最速传说就是我!(误)
前段时间看到有个博客用了这样的一个东西:
当时就感觉卧槽好高端,很有想法。
这种聚合订阅的形式有个名字,叫做 Planet(社区星球)。Planet 通常用于聚合某个领域的博客,然后展示在一个页面上,方便用户一站式阅读,比如:
这种形式在开源社区里比较常见,不过用在博客的友链上我倒还是第一次看到。
就像我在本站友链页里说的一样,独立博客之间的联系基本上就是靠的链接交换和评论互访。一个博客的访客看到了其他博客的链接,点过去看了,然后从对面的友链中,又导航到新的博客……如此往复,我们就依靠着这种从现在看来显得十分古老的方式,维系着这些信息孤岛之间的纽带。原始又浪漫。
不过这里就会涉及到一个用户点击率的问题。我自己之前在维护友链页面的时候,总感觉只放标题和链接看起来效果不怎么好。就算加上描述、头像这些元素,也总觉得差点意思。因为一个博客最重要的其实还是它的内容,仅靠一个网站标题,可能很难吸引到其他用户去点击。
而「友链朋友圈」的这种形式,就像微信朋友圈一样,作为一个聚合的订阅流,展示了列表中每个博客的最新文章。
比起干巴巴的链接,这显然会更加吸引人。虽然我写博客到现在也已经 9 年了,早就佛系了,主打一个爱看不看。不过对于和我交换了友链的博主们,还是希望他们能够获得更多的曝光和点击(虽然我这破地方也没多少流量就是啦……),也希望我的访客们也可以遇到更多有价值的博客。
然而在准备接入的时候,我发现这玩意儿不就是一个小型的 RSS 阅读器么……其实等于是自己又实现了一套订阅管理、文章爬取、数据保存之类的功能。
于是我就寻思,可能直接复用已有 RSS 阅读器 API 的思路会更好,让专业的软件做专业的事。友链的管理也可以直接复用 RSS 阅读器的订阅管理功能,这样增删改也不需要了,我们就只需要封装一下查询的 API,提供一个精简的展示界面就 OK。
作为行动力的化身,咱们自然是说干就干,下班回家马上开工!
首先是 RSS 后端的选择。
市面上的 RSS 阅读器有很多,我自己主要用的是 Inoreader。然而我看了下,Inoreader API 只面向 $9.99 一个月的 Pro Plan 开放,而且限制每天 100 个请求……这还玩个屁。Feedly 也是差不多一个尿性,可以全部 PASS 了。我也不知道该说他们什么好,也许做 RSS 真的不挣钱,只能这样扣扣搜搜了吧。
另外一个选择就是各种支持 self-host 的 RSS 阅读器,比如 Tiny Tiny RSS 和 Miniflux。我之前部署过 TTRSS,说实话感觉还是太重了。Miniflux 则是使用 Go 编写的,该有的功能都有,非常轻量级,部署也很方便。就决定是它了!
技术栈方面选择了之前一直比较心水的 Hono,部署在 Cloudflare Workers 上。前端方面没有使用任何框架,连客户端 JS 都没几行,基本上是纯服务端渲染。有时候不得不感叹技术的趋势就是个圈,以前那么流行 SPA,现在又都在搞静态生成了。
页面渲染使用了 Hono 提供的 JSX 方案,可以在服务端用类似 React 的语法返回 HTML,挺好用的。不过 CSS 没有用 Hono 的那一套 CSS-in-JS,因为要允许用户覆盖样式,所以要用语义化的类名。最后选了 Less,还是熟悉的味道。
前端文件的构建使用了 tsup,配置文件就几行,爽。
实现思路很简单,就是做一个 Proxy 层,把:
GET /v1/categories/22/entries
- Get Category EntriesGET /v1/categories/22/feeds
- Get Category Feeds这两个 Miniflux 的 API 包一下。这里要注意不能暴露实际的 API Endpoint,避免可能的恶意攻击。API 缓存也要在我们这一层做好,防止频繁刷新把服务打爆。
缓存策略上使用了 SWR (Stale-While-Revalidate):
stale
状态,依然返回前端;这样可以保证最快的响应速度,以及相对及时的更新速度,比较适合这种场景。
最后的交付形式其实就是两个 HTML 页面,通过 <iframe>
的形式嵌入到网页中。另外参考 giscus 提供了一个脚本,可以设置参数并自动完成 iframe 的初始化,用户只需要引入一个 <script>
标签即可,非常方便:
<script async data-category-id="28810" src="https://blog-friend-circle.prin.studio/app.js"></script>
当然也可以作为独立页面打开,有做双栏布局适配:
blog-friend-circle.prin.studio/category/2/entries
新版博客友链朋友圈的所有代码都开源在 GitHub 上,欢迎使用:
这个方案和 hexo-circle-of-friends 并没有孰优孰劣之分,只是侧重点和实现方式不同。不过我这个的一个好处是,如果你已经在用 Miniflux 了,那么可以直接复用已有的大部分能力,不需要再起一个 Python 服务和数据库去抓取、保存 RSS,相对来说会更轻量、稳定一些。
如果你选择使用 Miniflux 官方提供的 RSS 服务,甚至可以无需服务器,部署一下 CF Workers 就行了,像我这样的懒人最爱。
]]>function TodoList() { const [list, setList] = useState([]); const TodoItem = useCallback((props) => { return <li>{props.text}</li>; }, []); return <ul>{list.map((item, index) => <TodoItem key={index} text={item} />)}</ul>;}
有经验的 React 开发者肯定一下子就看出问题了:在组件内部嵌套定义组件,会导致子组件每次都重新挂载。因为每次渲染时,创建的函数组件其实都是不同的对象。
但是他又有包了 useCallback
让引用保持一致,好像又没什么问题……?
这波骚操作让我突然有点拿不准了,所以今天咱们一起来验证一下,用 useMemo
或者 useCallback
包裹嵌套定义的子组件,对 React 渲染会有什么影响。以及如果有影响,应该如何用更合适的方法重构。
先说结论:
永远不要在 React 组件内部嵌套定义子组件。
如果你有类似的代码,请使用以下方法替代:
为什么?请接着往下看。
来看这段代码(可以在 StackBlitz Demo 中运行):
function TodoList() { const [list, setList] = useState([]); // 嵌套定义子组件(好孩子不要学哦) const TodoItem = (props) => { return <li>{props.text}</li>; }; const handleAdd = () => setList([...list, `Item ${list.length + 1}`]); return ( <div> <button onClick={handleAdd}>Add</button> {/* 渲染刚才定义的子组件 */} <ul> {list.map((item, index) => ( <TodoItem key={index} text={item} /> ))} </ul> </div> );}
可能不少初学者都写出过类似的代码:JavaScript 语言可以嵌套定义函数,React 函数式组件就是函数,那 React 组件不也可以嵌套定义?
还真不是这么回事。我们来实际运行一下这段代码看看:
Tips: 这里使用了
useTilg
这个库来展示组件生命周期。
可以看到,每次点击 Add 按钮在 <TodoList/>
列表中添加元素时,之前旧的 <TodoItem/>
组件实例就会被卸载 (unmount)、销毁。React 会创建全新的组件实例,然后再挂载 (mount) 上去。
也就是说,这些组件实例全都变成一次性的了。
这还只是一个简单的示例,如果是实际应用场景,一个组件和它的子组件中,可能包含了成百上千个 DOM 节点。如果每次状态更新都会导致这些组件和对应的 DOM 节点被卸载、创建、重新挂载……那应用性能可就是「画面太美我不敢看」了。
更严重的是,组件的卸载还会导致其内部的状态全部丢失。
那怎么会这样呢?这要从 React 的渲染机制,以及 Reconciliation 流程说起。
我们知道 React 的渲染大致可以分为两个阶段:
Render
阶段:执行组件的渲染方法,找出更新前后节点树的变化,计算需要执行的改动;Commit
阶段:已经知道了需要执行哪些改动,于是操作真实 DOM 完成节点的修改。其中,「找出变化 + 计算改动」这个过程就被叫做 Reconciliation
(协调)。React 的协调算法可以在保证效率的同时,最大程度复用已有的 DOM,使得对 DOM 做出的修改尽量小。
▲ 图片来自:Render and Commit – React
那么问题来了,React 怎么知道一个组件对应的 DOM 需要更新呢?
简单来说,React 在比较两棵 Fiber 树时,会从根节点开始递归遍历所有节点:
上面所说的子组件被卸载再挂载、状态丢失等问题,其实都是因为它们被判断为了「节点类型有改变」。
在 JavaScript 中,比较值时有两种相等性:
举个例子:
// 两个长得一样的对象const a = { name: 'Anon Tokyo' };const b = { name: 'Anon Tokyo' };// "引用相等性" 比较 - falseconsole.log(a === b);console.log(Object.is(a, b));// "值相等性" 比较 - trueconsole.log(lodash.isEqual(a, b));console.log(a.name === b.name);
JavaScript 函数也是对象,所以这对于函数(React 组件)也成立。
到这里问题就比较明朗了。
function TodoList() { const [list, setList] = useState([]); // WARN: 这个语句每次都会创建一个全新的 TodoItem 函数组件! const TodoItem = (props) => { return <li>{props.text}</li>; }; return <ul>{list.map((item, index) => <TodoItem key={index} text={item} />)}</ul>;}
在这段代码中,每次 <TodoList/>
组件重渲染时(即 TodoList
函数被调用时),其内部创建的 TodoItem
都是一个全新的函数组件。
虽然它们长得一样,但它们的「引用相等性」是不成立的。
回到上一节介绍的渲染流程中,React 在比较节点的 type
时,使用的是 ===
严格相等。也就是说像上面那样不同的函数引用,会被视作不同的组件类型。进而导致在触发重渲染时,该组件的节点及其子节点全部被卸载,内部的状态也被全部丢弃。
到这里我们已经介绍了「在组件内部嵌套定义组件」会造成问题的原理。
这时候可能就有小机灵鬼要问了,既然组件每次都被判断为是不同 type
的原因是对象引用不同,那我用 useMemo
/ useCallback
Hooks,让它每次都返回相同的函数对象不就行了?
能考虑到这一层的都是爱动脑筋的同学,点个赞!让我们再来试验一下:
function TodoList() { const [list, setList] = useState([]); // useMemo 允许我们缓存一个值,每次重渲染时拿到的缓存是一样的 // 这里我们返回了一个函数组件,让 useMemo 把这个子组件的函数对象缓存下来 const TodoItem = useMemo( () => (props) => { return <li>{props.text}</li>; }, [] ); // 或者用 useCallback 也可以,都一样 // const TodoItem = useCallback((props) => { // return <li>{props.text}</li>; // }, []); const handleAdd = () => setList([...list, `Item ${list.length + 1}`]); return ( <div> <button onClick={handleAdd}>Add</button> <ul> {list.map((item, index) => ( <TodoItem key={index} text={item} /> ))} </ul> </div> );}
可以看到,包一层 useMemo
之后,子组件确实不会再被 unmount 了。看起来十分正常呢!
让我们再拿 React.memo
来包一下,在 props
相同时跳过不必要的重渲染:
const TodoItem = useMemo( () => memo((props) => { return <li>{props.text}</li>; }), []);
OHHHHHHHHH!!
同理,如果我们通过一系列操作可以让「嵌套定义的 React 组件」在渲染时表现得与「在外层定义的组件」一致,那是不是就意味着这种操作其实也是 OK 的呢?
嗯……答案是:没那么简单。
稍微偏个题,你可能会好奇 Hooks 和 memo 为什么也可以在嵌套定义的子组件内正常使用,因为这看起来和我们平时的用法完全不同。
实际上不管是模块顶层定义的函数组件,还是嵌套定义的函数组件,在 React Reconciler 看来都是独立的组件类型,且在渲染时都有着自己的 Fiber 节点来储存状态,而定义该函数的作用域是什么并不重要。想想看:HOC 高阶组件有时候也会返回内联定义的函数组件,其实是一个道理。
第一点,useMemo
和 useCallback
的缓存并非完全可靠。
在某些条件下,缓存的值会被 React 丢弃。如果缓存失效,函数组件就会被重新创建,同样会被判断为是不同的组件类型。React 官方肯定不会推荐你把 Hooks 用于这种歪门邪道的用途。
In the future, React may add more features that take advantage of throwing away the cache—for example, if React adds built-in support for virtualized lists in the future, it would make sense to throw away the cache for items that scroll out of the virtualized table viewport. This should be fine if you rely on useMemo solely as a performance optimization.
Ref: useMemo – React
第二点,useMemo
和 useCallback
都有依赖数组。
虽然上面的示例里嵌套组件定义的依赖数组都是空的,但是我们再想想,什么情况下会想要在组件内部定义子组件,而非将其拆成一个单独的组件呢?最主要的原因就是,这个子组件想要直接访问父组件函数作用域中的某些变量。
function TodoList() { const [list, setList] = useState([]); const TodoItem = useMemo( () => memo((props) => { // 注意看,这里子组件直接使用了父级作用域中的 list 变量 return <li>{`${props.text} of ${list.length}`}</li>; }), [list.length] ); const handleAdd = () => setList([...list, `Item ${list.length + 1}`]); return ( <div> <button onClick={handleAdd}>Add</button> <ul> {list.map((item, index) => ( <TodoItem key={index} text={item} /> ))} </ul> </div> );}
从实际测试中可以看到,有了依赖项的 useMemo
+ 嵌套组件,又退化成了最开始的样子,每次都会被当成不同的组件类型,每次都会被 unmount。之前所做的努力全部木大!(顺带一提用 useRef
也是一样的,有依赖就歇菜)
也就是说,只有你的嵌套子组件完全不依赖父组件作用域时,才能保证 useMemo
的缓存一直有效,才能做到完全不影响渲染性能。但既然都已经完全不依赖了,那么又还有什么理由一定要把它定义在父组件内部呢?
所以我再重复一遍开头的结论:永远不要在 React 组件内部嵌套定义子组件。
因为这在大部分情况下会造成渲染问题,即使对这种写法做优化也没有意义,因为一不留神就可能掉进坑里,还有可能会误导其他看到你的代码的人。
如果你的代码库中已经有了这样的 💩 代码,可以使用下面的方法重构。
第一种方法,把子组件移到最外层去。
这种方法适用于子组件依赖项不多的情况,如果有之前直接使用的父级作用域中的变量,可以将其改造为 props
传入的方式。
// 组件定义移到模块顶层const TodoItem = memo((props) => { return <li>{`${props.text} of ${props.listLength}`}</li>;});function TodoList() { const [list, setList] = useState(['Item 1']); const handleAdd = () => setList([...list, `Item ${list.length + 1}`]); return ( <div> <button onClick={handleAdd}>Add</button> <ul> {list.map((item, index) => ( // 改造后:从 props 传入原来的依赖项 <TodoItem key={index} text={item} listLength={list.length} /> ))} </ul> </div> );}
第二种方法,把子组件改为渲染函数 (Render Function)。
JSX 的本质就是 React.createElement(type)
,React 节点的本质其实就是一个 JavaScript 对象。你在组件 return
语句中直接写 JSX,和定义一个函数返回 JSX 然后再调用这个函数,本质上是一样的。
function TodoList() { const [list, setList] = useState([]); // 这不是函数组件,只是一个「返回 JSX 的函数」(函数名首字母非大写) // 所以每次渲染都重新创建也没问题,也可以直接访问作用域内的变量 const renderTodoItem = (key, text) => { return <li key={key}>{`${text} of ${list.length}`}</li>; }; const handleAdd = () => setList([...list, `Item ${list.length + 1}`]); return ( <div> <button onClick={handleAdd}>Add</button> {/* 调用的时候也和调用普通函数一样,而非组件的标签形式 */} <ul>{list.map((item, index) => renderTodoItem(index, item))}</ul> </div> );}
不过需要注意的是,在使用「渲染函数」时,一定要搞清楚和「函数组件」的区别:
render
开头,首字母小写(否则容易和组件搞混)。另外,当渲染函数作为 props
传入其他组件时,它也被叫做渲染属性 (Render Props)。这种设计模式在 React 生态中有着大量的应用,可以放心使用。
最后聊一下,如何避免这类问题的发生。
第一,配置 Lint 规则。
防范于未然,合理的 Lint 配置可以减少起码 80% 的代码规范问题。比如本文介绍的坑,其实完全可以通过 react/no-unstable-nested-components
+ react-rfc/no-component-def-in-render
规则提前规避。
最好再配合代码提交后的 CI 卡点检查,有效避免因开发者环境配置不当或者偷摸跳过检查,导致规则形同虚设的情况。
第二,定期 Code Review。
代码腐化是难以避免的,但我们可以通过流程和规范提早暴露、纠正问题,减缓腐化的速度。Code Review 同时也是一个知识共享、学习和成长的过程,对于 reviewer 和 reviewee 来说都是。
没有人一开始就什么都会,大家都是在不断的学习中成长起来的。
第三,了解一些 React 的原理与内部实现。
因为我自己就是吃这碗饭的,之前写过 React 的 Custom Renderer,也做过渲染性能优化,所以底层原理看的比较多,自然也就知道什么样的代码对性能会有影响。
我一直以来秉持的观点就是,学习框架时也要学习它「引擎盖下」的东西,知其然且知其所以然。如果你希望在这条路上一直走下去,相信这一定会对你有所帮助。
扩展阅读:
]]>前段时间在写文章时,需要一些配图,于是就使用了 TikZ 来绘制。TikZ 是一个强大的
上面的图对应的 TikZ 代码可以在这里找到。然而画是画爽了,想把它贴到博客里时却犯了难——目前竟然没有什么好办法可以直接在博客里使用 TikZ!
咱们废话不多说,直接上结果:我写了一个 Hexo 插件,可以直接把 Markdown 源码里的 TikZ 代码渲染成 SVG 矢量图,然后在博客构建时嵌入到页面 HTML 中,用起来就和 MathJax 写数学公式一样简单。
而且最重要的是渲染完全在构建时完成,浏览器上无需运行任何 JavaScript。同时构建机上也无需安装
👉 prinsss/hexo-filter-tikzjax: Server side PGF/TikZ renderer plugin for Hexo.
npm install hexo-filter-tikzjax
注意:插件安装成功后,需要运行 hexo clean
清除已有的缓存。
安装插件后,只需要在博客文章中添加 TikZ 代码块:
---title: '使用 TikZ 在 Hexo 博客中愉快地画图'tikzjax: true---Markdown text here...```tikz\begin{document} \begin{tikzpicture} % Your TikZ code here... % The graph below is from https://tikz.dev/library-3d \end{tikzpicture}\end{document}```
插件就会自动把代码渲染成对应的图片,非常方便:
不做过多介绍。贴几个链接,有兴趣的可以学学:
比如这就是我在写文章时画的图,全部用 TikZ 代码生成,画起来改起来都很方便。
在本插件之前,主流的在网页上渲染 TikZ 绘图的方式是使用 TikZJax。TikZJax 有点类似 MathJax,都是通过 JavaScript 去渲染
<link rel="stylesheet" type="text/css" href="https://tikzjax.com/v1/fonts.css"><script src="https://tikzjax.com/v1/tikzjax.js"></script><script type="text/tikz"> \begin{tikzpicture} \draw (0,0) circle (1in); \end{tikzpicture}</script>
然而这样做的问题是,太重了。在网页上动态加载 TikZJax,需要加载 955KB 的 JavaScript + 454KB 的 WebAssembly + 1.1MB 的内存数据,如果再另外安装一些宏包,最终打包产物大小甚至可以达到 5MB+。
对于一些有加载性能要求的网站,这显然是难以接受的。
那怎么办呢?答案就是 SSR / SSG,把渲染过程搬到服务端/构建时去做。这很适合博客这样的场景,尤其是静态博客,只需要构建时渲染一下,把生成的图片塞到 HTML 里就完事了,完全不需要客户端 JavaScript 参与,加载速度大幅提升。
因为 TikZJax 底层跑的是 WebAssembly,而 Node.js 也支持运行 WebAssembly,所以很自然地我就想,能不能把它的渲染流程直接搬到 Node.js 里面去做?
说干就干。于是就有了 node-tikzjax,一个 TikZJax 的移植版,可以在纯 Node.js 环境下运行,无需安装第三方依赖或者
hexo-filter-tikzjax 则是 node-tikzjax 的一个上层封装,主要就是在渲染 Hexo 博客文章时提取 Markdown 源码中的 TikZ 代码,交给 node-tikzjax 执行并渲染出 SVG 图片,然后将其内联插入到最终的 HTML 文件中。
如果是其他博客框架,也可以用类似的原理实现 TikZ 静态渲染的接入。
因为 node-tikzjax 并不是完整的 \usepackage{}
中直接使用的宏包有:
如果希望添加其他宏包,可以参考 extractTexFilesToMemory 这里的代码添加。
如果在使用插件的过程中 TikZ 代码编译失败了,可以通过 hexo s --debug
或者 hexo g --debug
开启调试模式,查看
This is e-TeX, Version 3.14159265-2.6 (preloaded format=latex 2022.5.1)**entering extended mode(input.texLaTeX2e <2020-02-02> patch level 2("tikz-cd.sty" (tikzlibrarycd.code.tex (tikzlibrarymatrix.code.tex)(tikzlibraryquotes.code.tex) (pgflibraryarrows.meta.code.tex)))No file input.aux.ABD: EveryShipout initializing macros [1] [2] (input.aux) )Output written on input.dvi (2 pages, 25300 bytes).Transcript written on input.log.
或者也可以在这个 Live Demo 中输入你的 TikZ 代码,提交后可在控制台查看报错。
首先要感谢 @kisonecat 开发的 web2js 项目,这是一个 Pascal 到 WebAssembly 的编译器,使我们可以在 JavaScript 中运行
这里有作者关于构建基于 Web 的
感谢 @drgrice1 对 TikZJax 和 dvi2html 的修改,TA 的 fork 中包含了很多有用的新功能,并且修复了一些原始代码中的问题。
感谢 @artisticat1 对 TikZJax 的修改,这是基于上述 @drgrice1 的 fork 的又一个 fork,也添加了一些有用的功能。本插件依赖的 node-tikzjax,其底层使用的 WebAssembly 二进制和其他文件就是从这个仓库中获取的。
感谢 @artisticat1 开发的 obsidian-tikzjax 插件,这是本项目的灵感来源。本项目和该插件底层共享同一套
如有任何问题,请在 GitHub 上提交 issue。祝使用愉快!
]]>最近在给一个 PixiJS 程序编写 WebGL Shader,被各种参数和坐标系搞得晕头转向。痛定思痛,整理了一下 PixiJS Filter 系统中的各种概念,以供后续参阅。
在 WebGL 中,我们可以通过编写顶点着色器 (Vertex Shader) 和片元着色器 (Fragment Shader) 来实现各种各样的渲染效果。而在 PixiJS 中,渲染引擎为我们屏蔽了绝大多数的底层实现,通常情况下用户是不需要自己调用 WebGL API 的。如果有编写自定义着色器代码的需求,一般是使用 Filter 来实现。
PIXI.Filter
其实就是一个 WebGL 着色程序,即一组顶点着色器 + 片元着色器的封装。和 Photoshop 中的滤镜功能类似,它接受一个纹理 (Texture) 输入,然后将处理后的内容输出到 Framebuffer 中。使用滤镜,可以实现模糊、扭曲、水波、烟雾等高级特效。
用户只需要定义着色器的 GLSL 代码,传入对应的参数,剩下的工作就全部交给 PixiJS 完成。如果你对这些概念不太熟悉,可以看看:WebGL 着色器和 GLSL。
在 PixiJS 中,Filter 自带了一组默认的顶点着色器和片元着色器代码。用户在定义 Filter 时,如果省略了其中一个,就会使用默认的着色器代码运行。
new Filter(undefined, fragShader, myUniforms); // default vertex shadernew Filter(vertShader, undefined, myUniforms); // default fragment shadernew Filter(undefined, undefined, myUniforms); // both default
这是 Filter 默认的顶点着色器代码:
attribute vec2 aVertexPosition;uniform mat3 projectionMatrix;varying vec2 vTextureCoord;uniform vec4 inputSize;uniform vec4 outputFrame;vec4 filterVertexPosition(void){ vec2 position = aVertexPosition * max(outputFrame.zw, vec2(0.)) + outputFrame.xy; return vec4((projectionMatrix * vec3(position, 1.0)).xy, 0.0, 1.0);}vec2 filterTextureCoord(void){ return aVertexPosition * (outputFrame.zw * inputSize.zw);}void main(void){ gl_Position = filterVertexPosition(); vTextureCoord = filterTextureCoord();}
这是默认的片元着色器代码:
varying vec2 vTextureCoord;uniform sampler2D uSampler;void main(void){ gl_FragColor = texture2D(uSampler, vTextureCoord);}
看着一脸懵逼不要紧,下面我们逐一解释。
先来复习一下 WebGL 基础知识。
▲ OpenGL 图形渲染管线的流程。插图来自 LearnOpenGL
不太熟悉的同学可能会以为 WebGL 是 3D 渲染 API,但其实 WebGL 本身只是一个光栅化引擎,并没有提供什么 3D 功能。如果想要实现 3D 渲染,你需要将模型中的三维坐标点转换为裁剪空间的二维坐标,并提供对应的颜色。WebGL 负责将这些图元绘制到屏幕上,仅此而已。
试想:任何 3D 模型,不管怎样旋转、缩放、平移,最终展示到你的屏幕上,都是 2D 的。同样,不管模型上有什么贴图、光照、阴影、反射,最终改变的其实都是这个像素的颜色值。
再来复习一下 WebGL 中的坐标系统:
[-1, 1]
范围之间,即为 NDC(0, 0)
位于裁剪空间的正中间,左下角为 (-1, -1)
,右上角为 (1, 1)
[0, 1]
(0, 0)
,右上角坐标为 (1, 1)
▲ 各种坐标与变换矩阵的关系。插图来自 LearnOpenGL
下面介绍 PixiJS Filter 中的坐标系,以及它们和 WebGL 坐标系之间的关系。
输入坐标,用于表示传入 FilterSystem 的纹理上的坐标。
Normalized Input Coordinate 是标准化之后的输入坐标,即纹理坐标,范围是 [0, 1]
。
相对于 canvas 视口的屏幕坐标,单位是 CSS 像素。
CSS 像素乘以分辨率 resolution
即为屏幕物理像素。
滤镜坐标,即被 Filter 所覆盖的范围内的局部坐标,单位是 CSS 像素。
Normalized Filter Coordinate 是标准化之后的滤镜坐标,滤镜覆盖范围的左上角坐标是 (0, 0)
,右下角坐标是 (1, 1)
。
额外的图片纹理坐标。可以用于采样其他输入中的纹理。
额外的精灵图集纹理坐标。
讲完坐标的种类,下面介绍 Filter 着色器代码中传入的各个参数(attributes、uniform、varying)分别代表什么,对应的是什么坐标系,以及它们的取值分别是多少。
⚠️ 以下参数适用于 PixiJS v7 版本,不排除后续版本中有变动的可能。
vec2
二维向量假设有一个长宽为 300x300 的矩形 A,其原点左上角位于 (100, 30) 世界坐标处。则有:
(0.0, 0.0)
对应矩形左上角(1.0, 1.0)
对应矩形右下角mat3
3x3 矩阵具体是怎么变换的,请阅读本文底部的「投影矩阵」章节。
vec4
四维向量假设有一个大小为 512x512 的 framebuffer,则 inputSize 的值为:
vec4(512, 512, 0.0020, 0.0020)
其中,前两个分量 x, y
为像素大小,后两个分量 z, w
是像素大小的倒数。倒数可用于将除法转换为乘法,因为乘以一个数的倒数等于除以这个数。
以下两个表达式是等价的:
aVertexPosition * (outputFrame.zw * inputSize.zw);aVertexPosition * (outputFrame.zw / inputSize.xy);
但是在计算机中,乘法比除法的计算速度更快。所以在 GLSL 着色器这类需要高速运行的代码中,通常会尽量避免直接使用除法,而使用倒数乘法替代。
vec4
四维向量还是以矩形 A 为例,其 outputFrame 取值为:
vec4(100, 30, 300, 300)
其中,前两个分量 x, y
为输出区域的位置,后两个分量 z, w
为输出区域的长宽。
vec4
四维向量sampler2D
纹理vec4
四维向量和 inputSize 类似,但是单位不是 CSS 像素,而是物理像素。以下两个表达式等价:
inputPixel.xyinputSize.xy * resolution
同样地,inputPixel.zw
是 inputPixel.xy
的倒数,用于转换除法为乘法。
vec4
四维向量有效范围指的是临时 framebuffer 中实际包含纹理图像的部分,其余部分可能是透明的(具体原因可阅读文章下方的注意事项)。使用示例:
vec4 color = texture2D(uSampler, clamp(modifiedTextureCoord, inputClamp.xy, inputClamp.zw));
其中,inputClamp.xy
表示有效范围的左上角,inputClamp.zw
表示有效范围的右下角。
float
devicePixelRatio
vec4
四维向量注意,filterArea 已经被标记为 legacy,你应该考虑使用其他参数替代。
// 以下语句等价于直接使用 filterArea uniformvec4 filterArea = vec4(inputSize.xy, outputFrame.xy)
vec4
四维向量Filter 中的各种坐标系直接可以通过公式转换。
参考:v5 Creating filters · pixijs/pixijs Wiki。
// Input Coord// 单位:标准化坐标vTextureCoord// Input Coord -> Filter Coord// 单位:标准化坐标 -> CSS 像素vTextureCoord * inputSize.xy// Filter Coord -> Screen Coord// 单位:CSS 像素vTextureCoord * inputSize.xy + outputFrame.xy// Filter Coord -> Normalized Filter Coord// 单位:CSS 像素 -> 标准化坐标vTextureCoord * inputSize.xy / outputFrame.zw// Input Coord -> Physical Filter Coord// 单位:标准化坐标 -> 物理像素vTextureCoord * inputPixel.xy// Filter Coord -> Physical Filter Coord// 单位:CSS 像素 -> 物理像素vTextureCoord * inputSize.xy * resolution // 与上一条语句等价
需要注意的是,在应用 Filter 之前,PixiJS 的 FilterSystem 会先把目标的 DisplayObject 渲染到一个临时的 Framebuffer 中。
这个 framebuffer 的大小不一定等于 DisplayObject 的大小,它是一个二次幂纹理 (Power-of-two Texture)。假如你有一个 300x300 的图片要应用滤镜,那么 PixiJS 会将其渲染到一个 512x512 尺寸的 framebuffer 上,然后将这个 framebuffer 作为输入传递给着色器代码。
根据这个 DisplayObject 上的 filters
属性定义,PixiJS 会依次执行数组中的 filter,前一个的输出作为后一个的输入,最后一个输出的将渲染到最终的 render target 中。
不过这个创建临时 framebuffer 的行为可能会在自定义着色器代码中导致一些问题,比如纹理坐标的偏移,有时间后续我会另外发文章讨论。
▲ 通过 Spector.js 抓取到的 PixiJS 渲染过程
好了,梳理完各种坐标系和参数后,我们再来回头看看上面的默认着色器代码:
// 标准化的「滤镜坐标」,范围是 filter 所覆盖的矩形区域attribute vec2 aVertexPosition;// 投影矩阵uniform mat3 projectionMatrix;// 纹理坐标varying vec2 vTextureCoord;// 输入 filter 的临时 framebuffer 大小uniform vec4 inputSize;// filter 所覆盖的区域在屏幕坐标中的位置与大小uniform vec4 outputFrame;vec4 filterVertexPosition(void){ // position 算出来的是 filter 所覆盖的区域的屏幕坐标 vec2 position = aVertexPosition * max(outputFrame.zw, vec2(0.)) + outputFrame.xy; // 通过投影矩阵,将屏幕坐标转换为裁剪空间 NDC 坐标 return vec4((projectionMatrix * vec3(position, 1.0)).xy, 0.0, 1.0);}vec2 filterTextureCoord(void){ // 等价于 aVertexPosition * (outputFrame.zw / inputSize.xy) // 也就是将「滤镜坐标」从 outputFrame 的范围缩放到 inputSize 的范围 // 计算出来的就是 inputSize 范围内的「纹理坐标」 return aVertexPosition * (outputFrame.zw * inputSize.zw);}void main(void){ // 裁剪空间 NDC 坐标传递给 WebGL gl_Position = filterVertexPosition(); // 纹理坐标传递给片元着色器 vTextureCoord = filterTextureCoord();}
// 纹理坐标varying vec2 vTextureCoord;// 输入 filter 的临时 framebuffer 纹理uniform sampler2D uSampler;void main(void){ // 使用纹理坐标在传入的纹理上采样得到颜色值,传递给 WebGL gl_FragColor = texture2D(uSampler, vTextureCoord);}
怎么样,是不是感觉清晰了很多呢?
如果你很好奇上面的投影矩阵是怎么做到乘一下就能把屏幕坐标转换为裁剪空间坐标的,那么这一小节就可以解答你的疑惑。
🤫 偷偷告诉你,CSS 中的
transform: matrix()
也是用了同样的矩阵变换原理。
投影矩阵的默认计算方式如下,代码来自 ProjectionSystem#calculateProjection:
// 矩阵表示:// | a | c | tx|// | b | d | ty|// | 0 | 0 | 1 |// 数组表示:// [a, b, 0, c, d, 0, tx, ty, 1]//// 主要参数:// sourceFrame - Filter 所覆盖的区域的世界坐标,长、宽、X、Y,像素单位// root - 控制 Y 轴反转,当渲染到 framebuffer 时投影为 y-flippedcalculateProjection(){ const pm = this.projectionMatrix; const sign = !root ? 1 : -1; pm.identity(); pm.a = (1 / sourceFrame.width * 2); pm.d = sign * (1 / sourceFrame.height * 2); pm.tx = -1 - (sourceFrame.x * pm.a); pm.ty = -sign - (sourceFrame.y * pm.d);}
这个投影矩阵主要做了两件事:
对于一个长宽为 300x300,原点左上角位于 (100, 30) 世界坐标处的矩形,可得:
计算出投影矩阵为:
使用矩阵乘法对世界坐标进行变换:
得到如下坐标:
即可将世界坐标转换为裁剪空间的标准化设备坐标。数学,很神奇吧!👊
更多关于矩阵变换的资料可参考:
]]>顺便测试一下我的新插件 hexo-filter-tikzjax,别在意~
自从今年初开始出来租房住,突然就感觉睡前关灯变得好麻烦好麻烦。我的房间里是有好几盏灯的,床头的开关只能控制其中的两盏,剩下的开关在另一个地方,另外还有一个总开关位于进门的门厅处。于是我就陷入了两难之境:
不爽,太不爽了!现在都讲究智能家居,我这他喵的是智障家居啊……
作为租房一族,咱们也没法对灯啊开关啥的做电气改造(不然直接换个智能开关就完事儿了),只能使用一些「非侵入式」的方案。首先想到的就是上面提到的开发板 + 舵机,搜了一下似乎已经烂大街了,有不少成熟的方案(ESP8266 居多)。
不过我还是低估了我的懒癌,连动手都不想动了,于是直接去万能的某宝搜索「关灯神器」:
(为什么不是某宝?别问,问就是消费降级)
哎呀,没想到还真有现成的,竟然还能红外 + 手机遥控,不错哦!
所谓关灯神器,其实也是一个能接收红外和蓝牙信号的主板,加上一个舵机来控制开关。
我买的这款是 🐻 卡通款,还带了个小夜灯功能,聊胜于无吧。内置锂电池供电,可以通过 micro USB 接口充电。开模挺精准,普通 86 型墙壁开关完美适配,通过无痕胶和滑槽安装,可以卸下来充电,总体还是挺满意的。
然而,这玩意最操蛋的其实是软件部分……除了附带的红外遥控器,如果想要用手机遥控它开关灯,竟然只能用微信小程序!
微信小程序……小程序……程序……序……
讲道理,我第一次知道微信小程序是还有提供蓝牙能力的,而且还真有人用,我和小伙伴们表示都孤陋寡闻,惊了个呆。
但是这我 TMD 就很不爽了,关个灯我还要打开微信,还得用你的小程序?
作为一个合格的折腾星人,自然不能如此任人宰割。不就是一个蓝牙设备嘛,小程序能遥控,我难道就不能遥控了?
这里主要用到的是 wxappUnpacker 这个工具对小程序解包、反混淆。以 Android 手机为例,小程序的包文件位于:
/data/data/com.tencent.mm/MicroMsg/{hash}/appbrand/pkg/xxxxx.wxapkg
这个目录一般需要 root 权限才能访问,但不巧的是哥已经不折腾 Magisk/Xposed 好多年,手上已经没有 root 过的机器了……不过天无绝人之路,我想起来 MIUI 有自带一个应用数据备份功能,可以备份 App 的 /data
目录。
这玩意儿备份出来的东西其实就是标准的 Android 备份格式 (.ab) 前面加了个自己的文件头,去掉头就可以吃了(划掉),用 Hex Editor 删掉文件头部 414E44
以前的部分,就可以直接当做 .ab
文件处理了。
(谢谢你,雷军!金凡!)
我这里用的是 android-backup-extractor,完整流程如下:
# MIUI 的备份目录adb pull /sdcard/MIUI/backup/AllBackup/20220501_010000/ ./# 去掉 .bak 文件的头部后另存为 .ab 文件java -jar ./abe.jar unpack '微信(com.tencent.mm).ab' mm.tar# 小程序位于 apps/com.tencent.mm/r/MicroMsg/{hash}/appbrand/pkg/*.wxapkgtar xvf mm.tar
目录下可能会有很多小程序的 .wxapkg
包,这里就只能按照时间一个一个试过去了……拿到正确的小程序包以后,使用 wxappUnpacker
解包:
./bingo.sh xxx.wxapkg
解包出来呢,大概就是这样的:
接下来就是在源码里找控制逻辑和通信值了,看看有没有加密什么的。不得不说,小程序这种前端技术做的东西,确实和裸奔没什么区别,真的能叫做逆向吗……标题党实锤了(作为一个前端仔,看到这些东西就像回家了一样)
随便看了一圈,发现这家制造商的业务线是真的广,光看里面内置的设备类型就有:风扇、茶吧机、干衣机、夜灯、颈椎按摩仪、腰部按摩器、足部按摩器、足浴器、水暖毯、灭蚊器、加湿器、电暖器、按摩椅,感觉像是专门给人生产贴牌智能硬件的,然后遥控模块和小程序用的都是同一套,十分强大。
下面贴几块处理过的关键代码:
// 遥控按钮的入口<i-btn hover bindtap="remoteIR" icon="icon-power" id="0" label="大灯" type="round-big"></i-btn>// 按钮事件处理function remoteIR(e) { var id = e.currentTarget.id; // cmd = "01" + "807F" + "12" // 每种产品都有不同配置,前两个都是固定的,最后的 "12" 代表开关大灯,"08" 为氛围灯 // 还有 "01" 定时十分钟,"03" 定时三十分钟,以及氛围灯亮度等等 var cmd = config.irType + config.irAddr + config.irCMD[id].value; this.sendCMD("3201", cmd); this.vibrateLong();}function sendCMD(e, B) { // format2Byte 函数的作用其实就是补零到 4 位,比如 6 -> 0006 // s = "fe010006320101807F12"; var s = "fe01" + format2Byte(((e.length + B.length) / 2).toString(16)) + e + B; sendData(s);}
下面的 sendData
也就是实际调用微信小程序 SDK 蓝牙能力的地方:
function sendData(n) { // ArrayBuffer(10) = FE 01 00 06 32 01 01 80 7F 12 var t = new Uint8Array( n.match(/[\da-f]{2}/gi).map(function (n) { return parseInt(n, 16); }) ).buffer; wx.writeBLECharacteristicValue({ // 蓝牙设备 ID deviceId: this.globalData.deviceInfo.deviceId, // 对应的服务 UUID serviceId: this.globalData.deviceInfo.serviceId, // 可写入的特征值 UUID characteristicId: this.globalData.deviceInfo.writeCharacteristicsId, // 写入值 value: t, success: function (n) {}, fail: function (n) {}, });}
简单来说,就是通过 BLE (Bluetooth Low Energy, 蓝牙低功耗) 协议连接开关设备,通过读写对应 Characteristic 的值与其通信,实现设备的控制(如开关灯)。
好了,所有需要的数值现在都已经到手了,下面就尝试跳过微信小程序,手动连接设备发送指令,看看能不能正常操作吧。
这里我用到的是 BLE-调试工具 这个 Android 应用,打开后扫描蓝牙设备,找到并连接「关灯神器」。如果不知道具体是哪个设备,就选看起来比较可疑的。
然后在设备的 Service 中,找到带有 WRITE
属性的特征值 (Characteristic),就是我们用来通信的特征值了。点旁边的写入按钮,把上面逆向出的值填进去……
见证奇迹的时刻,灯关上了!再次写入同样的值,灯又打开了!
欧耶✌️
还有其他的指令值也可以试一试,比如最后两位改成 08
就是开关氛围灯,等等。
想要让这个开关更“智能”,单靠手动操作手机遥控肯定是不够看的。因为手头没有开发板(听说现在树莓派都被炒上天了,不懂),所以还是让闲置的手机发挥余热吧。
好在之前学的那点 Android 开发还没有全忘光,基于 Android-BLE 这个库(其实上面我们用来测试的 App 就是这个库的 demo)和小程序里扒出来的控制逻辑糊了一个遥控 App 出来(代码放在 GitHub):
可以看到界面非常简约陋,不过比什么微信小程序可好用多了。幸福感 UP!
等以后有时间的话,再捣鼓捣鼓接入一下 Home Assistant,加几个自动化,不用动手直接喊 Siri 关灯,岂不美哉?(dreaming)
最近有几个小实验需要在 macOS 虚拟机上跑,本来以为去 Parallels Desktop 上开一个就完事了,搜了一下才发现,其实事情没那么简单……实际配置过程中也是踩了几个坑,所以顺带记录一下。
目前想要在 M1 Mac 上运行 macOS 虚拟机,有以下要求:
为什么 Big Sur 不行?因为在 Virtualization framework 中运行 macOS 虚拟机是 Monterey 才加入的功能。
那以前怎么就能虚拟呢?因为 ARM 架构的 M1 Mac 在引导上用的其实是 iOS 那一套,不是传统的 UEFI,所以苹果官方没提供 bootloader 的话自然没戏。黑苹果也是一样的道理,只能说且用且珍惜吧。
至于 .ipsw
文件,这玩意其实就是 iOS 固件的格式……真就大号 iPad 呗!
IPSW 镜像文件可以在这里下载:Apple Silicon M1 Full macOS Restore IPSW Firmware Files Database – Mr. Macintosh
这个是我目前最推荐的一种方法,所以放在第一个说。
Anka 是什么?根据官网的介绍,Anka 是一个专门用来管理 macOS 虚拟机的软件,可以与现有的基于容器的 DevOps 工作流集成,为 iOS 应用的构建与测试提供 CI/CD 自动化支持。再看下其开发者 Veertu,也是做 iOS CI 和 macOS 云这一块的。
并且今年十月发布的 Anka 3.0 (beta) 已经支持在 M1 Mac 上创建 macOS 虚拟机了,正是我们所需要的。
下载 Anka M1 beta 版,安装后打开,就可以直接通过图形界面创建虚拟机了:
或者,你也可以使用命令行创建虚拟机(相关文档在这里):
anka create --ram-size 4G --cpu-count 4 --disk-size 80G \ --app ~/Downloads/UniversalMac_12.0.1_21A559_Restore.ipsw 'macOS 12'
运行虚拟机:
anka start -uv
Anka 默认将虚拟机存储在 ~/Library/Application Support/Veertu/Anka
目录下,可以参考这里修改保存位置,或者干脆做个软链接也行。虚拟机的配置文件也在同目录下的 config.yaml
文件中,有些图形界面不提供的配置项可以在这里修改。
也可以使用命令行修改,比如修改虚拟机的分辨率和 DPI:
anka modify 'macOS 12' display -r 2560x1600 -d 220
另外,Anka 提供的 Guest Tool 会自动打开虚拟机内 macOS 的自动登录、SSH 并且阻止系统休眠(应该都是为了自动化服务的),并且提供了剪贴板共享、anka cp
文件复制,以及可以直接在虚拟机内执行命令的 anka run
等功能。
不过有一个需要顾虑的是 License 的问题,在 beta 期间可以免费使用 Anka 没问题,但不知道正式版发布以后如何。不过原本 Veertu 家面向个人开发者的 Anka Develop 就是免费的,所以或许并不需要担心。
或者,你也可以使用本文最后提到的开源方案,体验也是不错的。
毕竟是 Mac 虚拟机行业名声最响的,其实我第一个想到的也是 PD。
查了一下,macOS 作为 Guest OS 是 PD17 才支持的功能(前略,天国的 PD16 用户),然后 17.1 更新添加了 Parallels Tools 的支持,还提了一嘴「虚拟机默认磁盘大小从 32 GB 增加至 64 GB」。
我最开始还不知道这有什么好拿出来说的,后来才知道原因:你在 PD 中甚至无法调整 Mac 虚拟机的磁盘大小。不仅是磁盘,CPU 核心数、内存大小、网络连接方式都不能改,可配置项为零(至少无法在图形界面中配置),完完全全就是个半成品。
如果你确实想安装,这里是官方教程:Install macOS Monterey 12 virtual machine on a Mac with Apple M1 chips
点「新建虚拟机」以后,安装助手里就有直接下载 macOS 的选项。看起来很友好,然而……
啃哧啃哧下载了半天,最后提示「安装系统时出错」,也不知道为什么。查了下官方 Knowledge Base,貌似也不是个例:Inability to create a macOS Monterey 12 VM on Mac computers with Apple M1 chips
后来我找到了这篇文章:Customizing MacOS guest VMs in Parallels 17 on Apple Silicon,按照其中的介绍,通过命令行创建虚拟机,竟然就可以运行了……
/Applications/Parallels\ Desktop.app/Contents/MacOS/prl_macvm_create \ ~/Downloads/UniversalMac_12.0.1_21A559_Restore.ipsw \ /Volumes/xxx/Parallels/macOS\ 12.macvm \ --disksize 80000000000
我之前用 17.0.1 版本的时候也尝试用
prl_macvm_create
创建虚拟机,但是在进度到 90% 的时候失败了,提示「内部虚拟化错误。安装失败」。升级到 17.1.0 后虽然安装助手还是「安装系统时出错」,但命令行是可以正常创建虚拟机的。
命令行启动虚拟机:
/Applications/Parallels\ Desktop.app/Contents/MacOS/Parallels\ Mac\ VM.app/Contents/MacOS/prl_macvm_app \ --openvm /Volumes/xxx/Parallels/macOS\ 12.macvm
安装完成后,在 PD 控制中心可以导入 .macvm
格式的虚拟机文件,导入以后就可以从图形界面启动了。
作为一个商业虚拟机软件,且不说快照、Suspend,连最基本的 VM 管理功能都欠奉,我也是无话可说了。想知道还有哪些功能是目前还不能用的,可以查看 Known issues and limitations。
MacVM 是一个开源项目,基于 Virtualization framework(当然啦,大家都是用的这个),提供了简单的图形界面用于配置虚拟机。
因为作者并没有提供编译好的程序,所以需要自己使用 Xcode 从源码编译。
下载源码,用 Xcode 打开 MacVM.xcodeproj
,在 Signing & Capabilities 中修改为自己的开发者证书:
点击运行,会跳出来一个文件选择框,不用管先叉掉。
然后菜单栏 File -> New,新建虚拟机。输入 CPU 核心数、内存和磁盘大小后点菜单栏 File -> Save 保存,会生成一个 .macosvm
包。之后虚拟机的虚拟磁盘镜像也会保存在这个 bundle 中,所以要留意选择保存的位置。
然后点 Select IPSW and Continue 按钮,选择之前下载的镜像文件,点 Install,等它安装完就好了。(最开始的版本还要自己生成磁盘镜像,然后拷贝到应用容器中,还要用 Apple Configurator 2 手动装系统,相比起来现在已经友好很多了)
安装完成后,窗口会整个儿变黑,此时就可以点右上角的启动按钮启动虚拟机了。
用这种方法优点是开源,有啥不爽的都可以自己改,包括没有提供配置项的地方。缺点就是要自己编译,毕竟不是谁都装了 Xcode 的。
跑起来以后和上面两种基本没差别,因为实际的虚拟机创建、安装和运行都是 Virtualization framwork 实现的,整个项目的代码其实并不多。
GitHub 上还有一些类似的项目,这里也列出来供参考:
以上三种方法,其实底层大家都是一样的,就看在此之上谁做得更完善了。综合来看,目前感觉 Anka 的使用体验是最好的。
关于 M1 Mac 运行 macOS 虚拟机的一些参考链接:
UTM 有一个 dev-monterey
分支,我还没有尝试,不知道以后会不会推出支持 macOS Guest 的版本。
听说还有人使用 OSX-KVM 和 Docker-OSX 跨架构在 M1 上运行了 x86 的 macOS,但是性能很糟糕(simulation 嘛)。
另外,以上的这些虚拟机方案都不支持快照恢复,有点麻烦。不过好在我用来放虚拟机的移动硬盘是 APFS 格式的,支持写时复制 (Copy on write),所以直接把镜像整个儿复制一份就好了,很快,也不会占用多余的存储空间。
]]>想想反正我 Docker Hub 上也没上传什么东西,不如就用 GitHub 自家的 Container registry 来托管镜像吧!
这里有个小插曲:其实我挺早之前就想要改名了,但当时在忙秋招,考虑到改名后可能会有一些后续要处理(擦屁股),所以只是创建了一个 organization 把名字占住,等有时间了再正式改名。然而后来我把组织删了,想要修改 GitHub 账户的用户名时,却提示 prinsss
这个名称 unavailable(我确定它是没被占用的,因为我还能再用这个名字创建组织),不知道是不是触发了内部的什么保留机制。
最后还是发工单找客服解决了,而且等了一个多星期才回复,也是挺无语的。原来的 printempw
这个名字我也保留了,所以 printempw.github.io 这个域名还是可以访问的,目前是两边同步更新,后续再慢慢迁移。
其实最开始知道这个还是因为 Homebrew,看它每次安装软件下载 bottle 时都会从 ghcr.io 这个域名下载。好奇去查了一下,发现原来 GitHub 自己也整了一个软件包仓库,颇有一统天下的味道。
GitHub Packages 支持托管 Docker、npm、Maven、NuGet、RubyGems 等软件包,用起来比较像私有库。比起官方 registry 的好处就是其与 GitHub 完全集成,可以把源代码和软件包整合在一起,包括权限管理都可以用 GitHub 的那一套。
GitHub Packages 对于开源项目完全免费,私有仓库也有一定的免费额度。
基础用法和 Docker Hub 是一样的,只是 namespace 变为了 ghcr.io。
首先创建一个 PAT (Personal Access Token) 用于后续认证:
write:packages
权限注意:如果是在 GitHub Actions 中访问 GitHub Packages,则应该使用
GITHUB_TOKEN
而非 PAT 以提升安全性。后续章节会说明如何在 Actions 中使用GITHUB_TOKEN
。
然后我们就可以用这个 Token 登录镜像仓库了:
export CR_PAT=YOUR_TOKENecho $CR_PAT | docker login ghcr.io -u USERNAME --password-stdin
尝试一下推送镜像:
docker tag hello-world:latest ghcr.io/prinsss/hello-world:latestdocker push ghcr.io/prinsss/hello-world:latest
可以看到已经出现在 GitHub 上了:
刚上传的镜像默认都是 private,可以在 Package Settings 下方的 Change package visibility 处修改为公开镜像。
连镜像都放 GitHub 上了,那怎么好意思不用 GitHub Actions 呢!
下面就使用 Actions 实现代码更新后自动构建多架构 Docker 镜像,打 tag 并发布。
废话不多说,直接贴配置:
# yaml-language-server: $schema=https://json.schemastore.org/github-workflowname: Build Docker Image# 当 push 到 master 分支,或者创建以 v 开头的 tag 时触发,可根据需求修改on: push: branches: - master tags: - v*env: REGISTRY: ghcr.io IMAGE: prinsss/ga-hit-counterjobs: build-and-push: runs-on: ubuntu-latest # 这里用于定义 GITHUB_TOKEN 的权限 permissions: packages: write contents: read steps: - name: Checkout uses: actions/checkout@v2 # 缓存 Docker 镜像以加速构建 - name: Cache Docker layers uses: actions/cache@v2 with: path: /tmp/.buildx-cache key: ${{ runner.os }}-buildx-${{ github.sha }} restore-keys: | ${{ runner.os }}-buildx- # 配置 QEMU 和 buildx 用于多架构镜像的构建 - name: Set up QEMU uses: docker/setup-qemu-action@v1 - name: Set up Docker Buildx id: buildx uses: docker/setup-buildx-action@v1 - name: Inspect builder run: | echo "Name: ${{ steps.buildx.outputs.name }}" echo "Endpoint: ${{ steps.buildx.outputs.endpoint }}" echo "Status: ${{ steps.buildx.outputs.status }}" echo "Flags: ${{ steps.buildx.outputs.flags }}" echo "Platforms: ${{ steps.buildx.outputs.platforms }}" # 登录到 GitHub Packages 容器仓库 # 注意 secrets.GITHUB_TOKEN 不需要手动添加,直接就可以用 - name: Log in to the Container registry uses: docker/login-action@v1 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} # 根据输入自动生成 tag 和 label 等数据,说明见下 - name: Extract metadata for Docker id: meta uses: docker/metadata-action@v3 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE }} # 构建并上传 - name: Build and push uses: docker/build-push-action@v2 with: context: . file: ./Dockerfile target: production builder: ${{ steps.buildx.outputs.name }} platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=local,src=/tmp/.buildx-cache cache-to: type=local,dest=/tmp/.buildx-cache - name: Inspect image run: | docker buildx imagetools inspect \ ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ steps.meta.outputs.version }}
自动构建的效果可以在我的 GitHub 上查看(其实就是之前写的那个 使用 Google Analytics API 实现博客阅读量统计)。
另外有几个需要注意的点:
这个昨天搞得我真是一脸懵逼,报错是这样的:
#16 exporting to image#16 pushing layers 0.5s done#16 ERROR: unexpected status: 400 Bad Request------ > exporting to image:------error: failed to solve: unexpected status: 400 Bad RequestError: buildx failed with: error: failed to solve: unexpected status: 400 Bad Request
排查了好久,最后发现是我打 tag 的时候忘记加上用户名了,原本是 ghcr.io/prinsss/ga-hit-counter
的,我给打成了 ghcr.io/ga-hit-counter
,难怪推不上去(也要吐槽一下这个报错,就一个 400 鬼知道是什么啊)。
把上面那个解决了以后,心想这次总该成了吧,结果又来了个 403,我又一脸懵逼:
#16 exporting to image#16 pushing layers 0.7s done#16 ERROR: unexpected status: 403 Forbidden------ > exporting to image:------error: failed to solve: unexpected status: 403 ForbiddenError: buildx failed with: error: failed to solve: unexpected status: 403 Forbidden
再一番排查,发现是需要在 Package Settings 中的 Manage Actions access 处指定可以访问该软件包的源码仓库(也就是 Actions 所在的仓库)。好吧……
添加了仓库,这下确实可以了。
docker/metadata-action
这是一个比较有意思的 action,它可以从源码以及触发构建的 event 中获取数据,自动生成相应的 Docker 镜像 tag 以及 label。(在 GitHub 文档官方的示例中,这是由一段脚本完成的)
比如默认的效果就是:
Event | Ref | Docker Tags |
---|---|---|
pull_request | refs/pull/2/merge | pr-2 |
push | refs/heads/master | master |
push | refs/heads/releases/v1 | releases-v1 |
push tag | refs/tags/v1.2.3 | v1.2.3 , latest |
push tag | refs/tags/v2.0.8-beta.67 | v2.0.8-beta.67 , latest |
也就是我现在使用的:源码推送到 master
分支则自动构建并更新 master
tag 的镜像;在 git 上创建以 v
开头的 tag,Docker 那边也会自动创建相应的 tag 并且更新 latest
,不错不错。(不过想想我可能保留一个 tag 触发就够了)
比如我 push 了一个 v0.2.0
的 tag 上去,自动生成的元数据就是这样的:
Run docker/metadata-action@v3Context info eventName: push sha: 6071f564087d49be48dc318b89fc22ff96cf6a17 ref: refs/tags/v0.2.0 workflow: Build Docker Image action: meta actor: prinsss runNumber: 11 runId: 1495122573Docker tags ghcr.io/prinsss/ga-hit-counter:v0.2.0 ghcr.io/prinsss/ga-hit-counter:latestDocker labels org.opencontainers.image.title=google-analytics-hit-counter org.opencontainers.image.description=Page views counter that pulls data from Google Analytics API. org.opencontainers.image.url=prinsss/google-analytics-hit-counter org.opencontainers.image.source=prinsss/google-analytics-hit-counter org.opencontainers.image.version=v0.2.0 org.opencontainers.image.created=2021-11-23T14:10:35.953Z org.opencontainers.image.revision=6071f564087d49be48dc318b89fc22ff96cf6a17 org.opencontainers.image.licenses=MIT
如果想要修改为其他方案,action 也提供了丰富的配置项,可以自行修改。
用 GitHub Packages 托管 Docker 镜像,体验还是挺不错的。硬要说有什么缺点的话就是不好配置国内镜像吧,毕竟大部分国内镜像都是对应 Docker Hub 的。
另外多架构镜像的这个构建时间也是挺感人,模拟 arm64 一次要六七分钟,哈人。(所以写 Dockerfile 还是挺讲究的,怎么让缓存效率最大化,这方面还得再学习)
参考链接:
题外话,秋招后这段时间我也折腾了一些东西,有空慢慢发出来吧。
]]>配置虽然不难,但还是有点繁琐的,所以记录一下,如果忘了下次可以翻回来看。
首先来介绍一下这些工具都是啥吧。
ESLint 是一个插件化并且可配置的 JavaScript 语法规则和代码风格的检查工具,能够帮你轻松写出高质量的 JavaScript 代码。
简单来说就是可以静态分析代码中的问题(包括语法问题和代码风格问题,比如未使用的变量,if
和括号之间没有空格),给出提示,并且能够自动修复。
Prettier 是一个“有态度” (opinionated) 的代码格式化工具。它支持大量编程语言,已集成到大多数编辑器中,且几乎不需要设置参数。
什么叫 opinionated?不同于 ESLint 什么都让你配置,Prettier 是一种严格规范的代码风格工具,主张尽量降低可配置性,严格规定组织代码的方式。
Prettier 只提供少量配置项,这么做的原因很简单:既然为停止争论而生,那么为何还需要设置更多选项让人们继续纠结于如何配置呢?
规矩就是这样,不服憋着。
检查你的 Git 提交信息是否符合配置的格式要求。
相信大家或多或少都见过某些人奔放不羁的 commit message,不仅给项目管理带来困难,看着也挺难受的。使用 commitlint 可以实现在提交前检查提交信息是否符合规范,配合 commitzen 食用更佳。
看到这里你可能有些疑问,ESLint 可以自动修复代码风格问题,Prettier 也可以格式化代码,那它们两个不会打架吗?没错,确实会有冲突的情况,而这也是我们后面要解决的。
既然会冲突,那为什么要同时使用它们呢?主要有这几个原因:
--fix
自动修复虽然也可以实现一定程度的代码格式化,但没有 Prettier 效果好。当然,如果 ESLint 对你来说已经够用,那么不加入 Prettier 其实也是完全没问题的。
这里我们采用 Standard 规范。
以 Vite 新建的 Vue 3 + TS 白板项目为例:
pnpm add -D \ @typescript-eslint/eslint-plugin \ @typescript-eslint/parser \ eslint \ eslint-config-standard-with-typescript \ eslint-plugin-import \ eslint-plugin-node \ eslint-plugin-promise \ eslint-plugin-vue
如果你的项目不使用 TypeScript,可以把 eslint-config-standard-with-typescript
规则替换为 eslint-config-standard
。
"lint": "eslint \"src/**/*.{vue,ts,js}\" --fix"
.eslintrc.js
module.exports = { parser: 'vue-eslint-parser', parserOptions: { parser: '@typescript-eslint/parser', project: './tsconfig.json', extraFileExtensions: ['.vue'] }, extends: [ 'plugin:@typescript-eslint/recommended', 'standard-with-typescript', 'plugin:vue/vue3-recommended' ], root: true, env: { node: true }, rules: { 'vue/script-setup-uses-vars': 'error', 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/strict-boolean-expressions': 'off' }, globals: { defineProps: 'readonly', defineEmits: 'readonly', defineExpose: 'readonly', withDefaults: 'readonly' }}
tsconfig.json
{ "compilerOptions": { "target": "esnext", "useDefineForClassFields": true, "module": "esnext", "moduleResolution": "node", "strict": true, "jsx": "preserve", "sourceMap": true, "resolveJsonModule": true, "esModuleInterop": true, "lib": ["esnext", "dom"] }, "include": [ "src/**/*", ".eslintrc.js", "vite.config.ts" ]}
需要注意的是,Prettier 和 Standard 规范并不完全兼容。
pnpm add -D \ prettier \ eslint-config-prettier \ eslint-plugin-prettier
.eslintrc.js
extends: [ 'plugin:@typescript-eslint/recommended', 'standard-with-typescript', 'plugin:vue/vue3-recommended',+ 'plugin:prettier/recommended' ],
.prettierrc
{ "trailingComma": "none", "semi": false, "singleQuote": true}
"lint": "eslint \"src/**/*.{vue,ts,js}\" --fix","format": "prettier --write \"src/**/*.{vue,ts,js}\""
自古以来,Tab or Space 就是不曾停歇的圣战。
不同成员都有不同的喜好,使用的编辑器/IDE 也不尽相同。那么为了 codebase 的规范,在所有项目成员中使用一个统一的配置是很有必要的。
root = true[*]charset = utf-8indent_style = spaceindent_size = 2end_of_line = lfinsert_final_newline = truetrim_trailing_whitespace = true[*.{js,jsx,ts,tsx,vue}]indent_style = spaceindent_size = 2trim_trailing_whitespace = trueinsert_final_newline = true
比如很多项目都采用的 Conventional Commits 就要求提交信息必须符合以下规范:
<type>[optional scope]: <description>[optional body][optional footer(s)]
为什么要使用 Conventional Commits?
【鸽了】
反正就是让上面那些工具可以在 Git 提交时自动执行,检查不通过的就打回。
还记得很久以前别人给我发了个 Pull Request,我一看,发现有好多地方的代码风格都和我不一样,比如单双引号、分号的使用,还有我最不能忍的 if(xxx){}
之间不加空格……
但我想想再叫人家改也怪麻烦的,就默默接受了 PR,然后再默默改成自己的代码风格……
现在有了这些东西工作流程就规范多了:
当然了,规矩是死的人是活的,这一套下来也没法保证一定万无一失。不过相比以前群魔乱舞的场面,已经省心了不少。
不过说实在话,比起配置这些工具,推行一个大家都能接受的规范才更难吧(x)
]]>docker-compose.yml
配置文件和 sail
脚本,定义了 PHP、MySQL、Redis 等一系列容器,然后把程序放里面跑。至于好处嘛,主要就是使用方便、运行环境统一、不会弄乱系统。同样是 Laravel 开发,本机安装 LNMP、Valet、Homestead 这些方法我都用过,但现在我肯定首选 Laravel Sail(容器化是真滴爽)。
不过 Laravel Sail 好是挺好,想要在墙内顺利使用还是要费点功夫的。
以下操作均在国内网络环境下运行。
其实挂个全局代理这些都不是事啦
安装 Docker 和配置 Docker Hub 国内镜像的步骤可以参考这篇文章。
创建项目时,直接用 Laravel 官方的安装脚本大概率会卡在 Updating dependencies 上,为什么你懂的。不过我们还是可以借鉴下官方脚本的内容:
curl -s https://laravel.build/example-app -o install.sh
临时新建一个 composer 容器:
docker run -it --rm -v $(pwd):/opt -w /opt \ laravelsail/php80-composer:latest bash
在容器内配置 composer 镜像,创建 Laravel 项目:
composer config -g repo.packagist composer https://mirrors.aliyun.com/composer/laravel new example-app
安装 Laravel Sail 相关文件(这里可自定义需要使用的服务):
cd example-appphp ./artisan sail:install --with=mysql,redis,meilisearch,mailhog,selenium
导出底层 Dockerfile,方便后续定制:
php ./artisan sail:publish
完成后,可以看到项目下多出了一个 docker
目录和 docker-compose.yml
文件。
Laravel Sail 有一个主要的容器,用于运行 PHP 主程序。其他比如 composer
, artisan
, node
, npm
命令也是在这个容器里运行的。
后续你可以通过
sail shell
来访问这个容器。
这个主容器的 Dockerfile 就是我们上面导出的那个。可以看到它主要就是在 Ubuntu 的基础上安装了一些软件包,大部分的网络问题都是这里造成的。
使用国内镜像替换之,主要需要替换的软件源有:
修改后的 Dockerfile 如下:
FROM ubuntu:21.04LABEL maintainer="Taylor Otwell"ARG WWWGROUPWORKDIR /var/www/htmlENV DEBIAN_FRONTEND noninteractiveENV TZ=Asia/ShanghaiENV APT_MIRROR http://mirrors.ustc.edu.cnENV NVM_DIR /usr/local/nvmENV NODE_VERSION 16.9.1ENV NVM_NODEJS_ORG_MIRROR https://mirrors.ustc.edu.cn/nodeENV NVM_SOURCE https://hub.fastgit.org/nvm-sh/nvm.gitSHELL ["/bin/bash", "-o", "pipefail", "-c"]RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezoneRUN echo 'APT::Acquire::Retries "5";' > /etc/apt/apt.conf.d/80-retries \ && sed -i "s|http://archive.ubuntu.com|$APT_MIRROR|g; s|http://security.ubuntu.com|$APT_MIRROR|g" /etc/apt/sources.list \ && sed -i "s|http://ports.ubuntu.com|$APT_MIRROR|g" /etc/apt/sources.list \ && apt-get update \ && apt-get install -y gnupg gosu curl ca-certificates zip unzip git supervisor sqlite3 libcap2-bin libpng-dev python2 \ && mkdir -p ~/.gnupg \ && chmod 600 ~/.gnupg \ && echo "disable-ipv6" >> ~/.gnupg/dirmngr.conf \ && apt-key adv --homedir ~/.gnupg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys E5267A6C \ && apt-key adv --homedir ~/.gnupg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C300EE8C \ # PHP && echo "deb https://launchpad.proxy.ustclug.org/ondrej/php/ubuntu hirsute main" > /etc/apt/sources.list.d/ppa_ondrej_php.list \ && apt-get update \ && apt-get install -y php8.0-cli php8.0-dev \ php8.0-pgsql php8.0-sqlite3 php8.0-gd \ php8.0-curl php8.0-memcached \ php8.0-imap php8.0-mysql php8.0-mbstring \ php8.0-xml php8.0-zip php8.0-bcmath php8.0-soap \ php8.0-intl php8.0-readline php8.0-pcov \ php8.0-msgpack php8.0-igbinary php8.0-ldap \ php8.0-redis php8.0-swoole php8.0-xdebug \ # Composer && curl -so /usr/bin/composer https://mirrors.aliyun.com/composer/composer.phar \ && chmod a+x /usr/bin/composer \ && composer --version \ && composer config -g repo.packagist composer https://mirrors.aliyun.com/composer/ \ # Node.js && mkdir -p $NVM_DIR \ && curl -so- https://raw.fastgit.org/nvm-sh/nvm/v0.38.0/install.sh | bash \ && source $NVM_DIR/nvm.sh \ && nvm install $NODE_VERSION \ && nvm use $NODE_VERSION \ && node -v && npm -v \ && npm config set registry https://registry.npm.taobao.org \ # Yarn && npm install --global yarn \ && yarn config set registry https://registry.npm.taobao.org \ && apt-get install -y mysql-client \ && apt-get install -y postgresql-client \ && apt-get -y autoremove \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*ENV NODE_PATH $NVM_DIR/v$NODE_VERSION/lib/node_modulesENV PATH $NVM_DIR/versions/node/v$NODE_VERSION/bin:$PATHRUN setcap "cap_net_bind_service=+ep" /usr/bin/php8.0RUN groupadd --force -g $WWWGROUP sailRUN useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sailCOPY start-container /usr/local/bin/start-containerCOPY supervisord.conf /etc/supervisor/conf.d/supervisord.confCOPY php.ini /etc/php/8.0/cli/conf.d/99-sail.iniRUN chmod +x /usr/local/bin/start-containerEXPOSE 8000ENTRYPOINT ["start-container"]
目前有个不足之处就是 Node.js 的安装,原来用的 NodeSource 现在没有可用的国内镜像源,只能改用 nvm 安装。但是 nvm 在 PATH
的处理上有些问题(它是通过一个脚本修改环境变量,把当前启用的 Node 版本添加到 PATH
里去,但是 Dockerfile 里不能动态设置 ENV
),目前只能手动指定要安装的 Node 版本了。
使用 Laravel Sail 提供的脚本运行容器:
./vendor/bin/sail up
这个脚本主要的工作就是读取 .env
里配置的环境变量,然后通过 docker-compose
在容器里执行相应命令,所以基本用法和 docker-compose
是一致的:
sail up -d # 后台运行sail down # 停止运行
耐心等待镜像构建完成,Laravel Sail 就可以正常运行啦。
Creating network "example-app_sail" with driver "bridge"Creating example-app_redis_1 ... doneCreating example-app_selenium_1 ... doneCreating example-app_meilisearch_1 ... doneCreating example-app_mailhog_1 ... doneCreating example-app_mysql_1 ... doneCreating example-app_laravel.test_1 ... done
以前我对深色模式其实不怎么感冒,主要感觉开了也没啥用,就系统界面变黑了,其他 App 里还是白色的,等于没开。不过这几年大部分应用的适配都跟上来了,体验也就好起来了,晚上玩手机看着不那么刺眼,挺好的。
现在浏览器网页也支持检测用户的系统主题色,所以我也凑个热闹,给博客加上了自动切换浅色/深色主题的功能。适配过程还是挺顺利的,记录一下供参考。
就是使用 CSS 的 prefers-color-scheme
媒体查询。
@media (prefers-color-scheme: dark) { /* dark theme styles go here */}
参考文档:prefers-color-scheme - CSS | MDN
不过需要注意的是,不支持 IE。
最简单的例子:
body { background-color: white; color: black;}@media (prefers-color-scheme: dark) { body { background-color: black; color: white; }}
这样在亮色模式下是白底黑字,在暗色模式下就是黑底白字。
依样画葫芦,给原主题中颜色相关的 CSS 加上对应的深色样式就差不多了。
拿我自己写的这个主题举例,在主题中我们一般会用到很多颜色。一个常见的做法就是使用 CSS 预处理器,把这些颜色定义成变量方便后续使用(我用的是 Stylus):
$color-primary = convert(hexo-config('primary_color'));$color-background = #fff;$color-text = #333;$color-text-secondary = #999;
同样,定义这些颜色的深色版本:
$color-primary-dark = convert(hexo-config('primary_color_dark'));$color-background-dark = #181a1b;$color-text-dark = #c8c3bc;$color-text-secondary-dark = #a8a095;
引用之:
body { background-color: $color-background; color: $color-text;}a { color: $color-primary;}@media (prefers-color-scheme: dark) { body { background-color: $color-background-dark; color: $color-text-dark; } a { color: $color-primary-dark; }}
然而问题来了,这样岂不是要写很多媒体查询语句?麻烦且不说,看着都眼花。如果把不同地方的这些语句集中起来,放在一起,又会破坏模块设计,也不利于后续维护。
想要写得简洁一点,不妨利用 CSS 预处理器的 mixin 特性。
定义 mixin(可以理解为可重用的代码片段):
// 根据传入参数拼装变量名color-themed(name) { color: lookup('$color-' + name); @media (prefers-color-scheme: dark) { color: lookup('$color-' + name + '-dark'); }}
这个 mixin 的意思就是我们传一个名称进去,它会根据这个名称去查找对应的颜色变量及其深色版本,然后一起应用。
如此一来,上面的样式就可以简化为:
body { background-color-themed: 'background'; color-themed: 'text';}a { color-themed: 'primary';}
用上面那种方法,比原来的是好了不少,但感觉不太直观。
另一种方法,就是用 CSS 原生的变量机制来处理颜色。定义变量:
:root { --color-primary: #7065a3; --color-background: #fff; --color-text: #333; --color-text-secondary: #999;}@media (prefers-color-scheme: dark) { :root { --color-primary: #bb86fc; --color-background: #181a1b; --color-text: #c8c3bc; --color-text-secondary: #a8a095; }}
使用:
body { background-color: var(--color-background); color: var(--color-text);}a { color: var(--color-primary);}
是不是清爽了很多呢?
不过遗憾的是,IE 浏览器不支持 CSS 变量。(又是你!!!🙃
所以为了兼容性我还是选了预处理器 + mixin 的方法,这样在 IE 上虽然不能自动切换,但至少能保证默认的浅色主题是可以正常显示的。而如果全部使用 CSS 变量的话,在不支持的浏览器上就啥都没有了,得考虑 polyfill 和 fallback,还是算了。
如果不用考虑兼容旧浏览器的话,CSS 变量是最佳选择。
使用 link
标签加载的外部 CSS 也可以指定媒体查询。
比如本主题使用的 highlight.js 代码高亮的样式:
<link rel="stylesheet" href="atom-one-dark.min.css" media="screen and (prefers-color-scheme: dark)"><link rel="stylesheet" href="atom-one-light.min.css" media="screen and (prefers-color-scheme: light)">
这样在浅色模式下会加载 light 样式,在深色模式下会加载 dark 样式。
另外,关于深色模式下的图片要如何处理,其实也是需要考虑的。
不过我懒,就直接不管了。更详细的相关内容可以参考:
最后是自动切换的效果图(视频):
]]>要不是为了多少增加点竞争力,我又何必折腾自己去考这些证。快要大学毕业找工作了,最近这段时间明显感觉自己比以前焦虑了不少。俗话说得好,打不过就加入。虽然也不知道这些证以后能不能派上用场,不过总比没有好,万一用上那就是赚了。
这次考的几个其实我都没有花很多时间准备,不过所幸还是都拿到手了。所以记录一下感想,也顺便给博客除除草。在网上说这些其实无异于增加实名上网的风险……不过 fxck it,随他去吧。
软考是什么?
软考也叫软件水平考试,全称计算机技术与软件专业技术资格(水平)考试,是由国家人力资源和社会保障部、工业和信息化部领导的国家级考试。
有什么用?
国企评职称、积分落户、写简历上唬人。
有哪些项目?
软考有高级、中级、初级三种级别,每个级别中又有不同的资格设置。比如高级就有分为信息系统项目管理师、系统分析师、系统架构设计师、网络规划设计师、系统规划与管理师。完整列表可以看这里。
据我观察,高级里报的人最多的是信息系统项目管理师(俗称高项),中级的是软件设计师,初级都没啥人报,可能是太简单了没含金量吧。
考试安排?
每年考两次,分别安排在上半年和下半年。上半年考试时间为5月下旬,下半年考试时间为11月上旬。
考试题型?
考试分为上午场和下午场,初级、中级是上午考基础知识,下午考应用技术。高级则是上午综合知识,下午案例分析加论文。以中级为例,上午题型都是选择题,下午是主观问答题。
每科目总分都是 75 分,45 分及格,上午下午都考 45 分以上就算过了。
考试感想?
就说我报考的软件设计师(中级),感觉不难。考的东西基本都是大学计算机专业课教的那些,微机原理、程序设计语言、数据结构、操作系统、软件工程、面向对象、算法、数据库、计算机网络,其他就没啥了,范围广但不深。
说句欠揍的话,我这次考试的准备时间全部加起来可能都没有三天……倒也不是我自大,就纯粹是拖延症犯了,一直拖啊拖,直到考前一天才开始刷真题(我当时都快放弃了)。最后看了一个通宵,第二天下午的时候差点昏睡考场。
个人感觉如果大学 CS 专业课都过了的话,那过个软考中级应该还是没啥压力的。考试当天我在考场逛了一下,来考的除了几位头顶显得有些寂寥的大叔,基本上都是大学生。所以趁着专业课学的东西还没忘,直接考掉正好。
怎么准备?
首先看一下报考科目的考试大纲(比如:2021年软件设计师考试大纲),了解一下大致要考的内容。就像我说的,基本都是大学专业课里教过的东西。
教材的话有官方指定的教材,比如软件设计师就是《软件设计师教程(第5版)》。这本教材我就是大致浏览了一遍,然后等做题碰到有不会的再来翻书了。
最重要的是刷题,做历年的真题(网上资源很多)。
真题看几遍后就能发现很明显的题型规律,比如哪几题会考微机原理,哪几题会考网络、操作系统等等,还有最后 5 题是雷打不动的完形填空,考英语的。至于下午场那就更是换汤不换药了,第一题数据流图,第二题数据库设计,第三题 UML,第四题算法,第五题代码填空 Java/C++ 二选一。
考完了有用吗?
至少能证明你计算机专业还是学到点东西的,聊胜于无吧。我这次报了中级没报高级,也是寻思一口不能吃成个胖子,毕竟高级要写的论文我完全没练过,肯定是写不来的。
这次中级过了,下次有机会就挑战高级吧。
N1 是什么?
日本语能力测试(Japanese-Language Proficiency Test,简称 JLPT)是以母语非日语者为主要对象的日语能力考试。目前考试设有 N1 至 N5 级、共 5 个级别,当中以 N1 级难度最高。
据我观察,大部分人都是报的 N1、N2,N3 以下就没多少人报了。
有什么用?
JLPT 是现时最具认受性的日语能力考试。留学、进外企的时候用得到吧,大概。
反正是公认知名度、认可度最高的日语考试,蛮考一个放着。
考试安排?
每年举办两次,于 7 月和 12 月的第一个星期日实施。
然而最近因为新冠疫情导致去年 7 月的考试被取消,后来又取消了部分考点,导致现在 JLPT 报名经常「一座难求」,考场座位都得靠抢(我这次运气不错,捡漏抢到了一个)。微博上甚至有个狠人,因为附近的考点名额已满,最后报了个内蒙古的考点……大老远的坐飞机去考试,只能说是够拼的。
还有,这破考试报名费老他妈贵了,N1、N2 报名费要 550 块,你怎么不去抢!
考试题型?
分为语言知识、阅读、听力三部分,全是选择题。
N1 总分 180,100 分合格(要求每项分数都大于等于 19 分)。
考试感想?
不考察口语和写作,而且全是选择题,要及格真的挺简单的。何况我从高中开始自学日语,要是 N1 都考不过那我一头撞死算了。而且过了 N1 但实际水平堪忧的人我这几年也见过不少,所以确实没啥好拿出来说的。
考试的话,重要的就是词汇、语法和听力。词汇方面不是我吹牛逼,看了这么久的小说漫画动画片,词汇量总归是累积了不少的。语法的话,不少 N1 文法在日常生活中其实不怎么见得到,所以还是要专门看看。听力就不说了,以前看虚拟管人的那段时间算是给我锻炼出来了,就听力考试那字正腔圆的,给我两倍速都没问题好吧。
考完了有用吗?
不知道,我从来没有想过这个问题.jpg
权当自我满足了,证明下自己吧。
嗯……这个真没什么好说的了,写上来凑数的。科一到科四都是一把过,爽到。
我考的是手动挡 C1 驾照,感想的话就是汽车比想象中的要好开,挂档踩油门就走,踩刹车就停,方向盘转车也跟着转,说实话还挺爽的。以前我就喜欢看发动机、变速箱原理之类的视频,实际上手理解起来也算快。
不如说学车的时候感觉大部分时间是在学交通规则,比如左转直行右转都有专门车道、红灯也可以右转(除非有右转信号灯)这些都是我学了以后才知道的。另外一点就是发现路上不守交规的人其实挺多的,越实线超车、变道转向不打灯的看到过好多次。以前我也不知道所以没感觉,现在自己开车的时候就看的明白了。
总之,多个证多一种可能性吧,本拿到了以后有机会就能自驾游了,happy 😋
]]>不开玩笑,以我的经历在此提醒各位,一定不能小瞧中暑的危害啊!!
下午本来我舒舒服服躺在床上玩手机,突然来了一个电话,让我出门办事儿。地方远倒也不远,一公里多,我就寻思弄辆共享单车骑过去,权当锻炼身体了。
当时天气那叫一个热,估计正好是一天里最热的那段时间,才刚从空调房里出来身上就冒了一层汗,走了不到一百米感觉人都要蔫了。为了打起精神,我也特意选了点欢快的歌在路上听。
之后嘛,我也不知道吃错了什么药,上了路越骑越起劲,一辆破自行车蹬得飞快,差不多一首歌的时间就到了。就在我锁好车,准备步行过去的时候……
最开始是轻微的头晕,我也没怎么放在心上。
然后突然视野开始变得昏暗、扭曲,整个人头晕目眩,意识也恍惚起来(有点类似于蹲坑蹲久了,然后突然站起来的那种感觉)。要形容的话就是感觉天一下子变黑了,然后套了个重影 + 色差特效的滤镜,天旋地转,根本分不清到底是世界在摇晃还是我人在摇晃。我凭着剩余的思考能力,心想糟糕,八成是刚才骑太猛,现在脑部血液供应不上了。于是晃晃悠悠地想找个东西扶着缓一下。
结果我下一次睁眼的时候,面前已经是一个不认识的人了。
我分辨出他是在问我「没事吧?」,这才忽然意识到,我刚才应该是失去意识晕过去了,是这位路过的好心人叫醒了我。我赶忙向他道谢,说自己没事,缓一下就好了,于是他也好像稍微放心了的样子。
然而,我再下一次睁眼的时候,眼前又是那位好心人在问我,「你真的没事吗?要不我帮你打 120 吧」。这我真的没想到,我竟然又晕过去了一次……(梅开二度)。这次旁边的人也变多了,一位环卫阿姨和另一位应该也是路过的大姐在担心地看着这边。
不得不说失去意识的感觉很神奇。在我主观感受中,两次昏厥好像都是眼睛一闭一睁的事儿,前一秒我还在锁共享单车,下一秒人就在地上了,再下一秒他就打 120 了。我只能从他停在一旁的电瓶车推断出,他是先走远了,然后发现我又昏倒后再折回来帮我的。我却完全不知道这之间经过了多久,真的很感谢这几位好心人。🙏
在等救护车的途中,我的意识也开始逐渐恢复了:两只耳机都还挂在耳朵上,放着曲调轻快(害人)的爱抖露曲;检查了一下全身,手肘、膝盖都有不少擦伤,应该是倒地的时候留下的;呼吸急促,身体出汗量很大,衣服都湿了。
之后救护车到了,我也清醒得差不多了,不过为了以防万一还是给拉去医院挂了急诊(长这么大,人生中第一次被救护车拉走……),做了一大票检查,血常规呀 CT 啥的,先后排除了:低血压、低血糖、心脏病、脑部等问题,最后医生得出结论:
应该就是中暑了。
以前我也有所耳闻,比如军训时有某某同学晕倒了,但没想到有这么夸张。
从医院回来以后我查了点资料,感觉我这种症状可能是属于「热失神」:
搜集的一些相关资料:
在我的印象里应该是要重度中暑,比如在阳光下暴晒、高温环境长时间工作那样的人才会晕倒,没想到我这就出门骑个自行车人也倒了。人类的身体真是脆弱啊……
而且我这次算是比较幸运的,下了车走在马路牙子上才晕倒。如果是骑着骑着失去意识,给哪里磕个一下的话,那真是想想都觉得后怕。
反省这次的事件,主要问题应该就是:
早知道还不如打个车去,屁事没有。
说实在话,我以前确实没觉得中暑有多严重,这次算是给我吃了个教训。
希望各位读者也能以我这次的经历为戒,不要小看中暑,炎热天气出门做好预防呀!
]]>然而 Abema 很不爽的一点就是动态分辨率(不如说很多流媒体网站都这样),就喜欢自己判断你网速够不够,然后给你播放带宽相应的清晰度。初衷估计是为了任何网络环境条件下都能流畅播放,可是讲道理,我就没见过这个自动判断准过几次。
就算在设置里开到最高画质,还是动不动给我跳到 480p 甚至是 360p。我他喵的到底是哪里不行,就只配看你这马赛克画质?
真是叔可忍婶不可忍,于是我去网上找有没有 Abema 强制 1080p 的方法(因为也有个 Chrome 启用 Netflix 1080p 的扩展),正好找到了一个:
const open = XMLHttpRequest.prototype.open;XMLHttpRequest.prototype.open = function () { arguments[1] = arguments[1].replace('720/playlist.m3u8', '1080/playlist.m3u8'); open.apply(this, arguments);};
看了一下,没想到原理还挺简单,就是油猴脚本劫持 XHR 请求方法,把其他清晰度的 m3u8 playlist 全部重定向到 1080p 的 playlist 上。这样不管怎样请求,实际返回的都是 1080p 清晰度的源了。想想确实是这个理,高啊!
还好以前捣鼓过一段时间 HLS 相关的东西,所以这些不算陌生。不过实际使用的时候发现 XMLHttpRequest
并没有拦截到 playlist.m3u8
之类的请求,看了一下,原来是 Abema 请求的时候改用了 fetch API。反正大同小异,稍微修改一下就行了。
修改后的脚本同样放在 Gist 上:
// ==UserScript==// @name Abema 强制 1080p// @description 把其他清晰度的播放列表强制指向1080p// @version 1.1.0// @run-at document-start// @namespace Violentmonkey Scripts// @match https://abema.tv/*// @grant none// ==/UserScript==const originalFetch = window.fetchwindow.fetch = (...args) => { const regex = /\/\d+\/playlist\.m3u8/ if (typeof args[0] === 'string' && args[0].match(regex)) { args[0] = args[0].replace(regex, '/1080/playlist.m3u8') console.log(args[0]) } return originalFetch(...args)}
题外话,好久没追过新番了,但佐贺二期开播后我现在每天都想快进到周四……
啊啊啊!
好了,网页端的问题解决了,那么客户端是不是也可以用这个方法呢?
抓包看了一下,客户端请求的确实是同样的 URL,直接如法炮制。
不过要修改客户端的请求就不像 Web 浏览器那么简单了,需要用到 MitM(中间人)攻击。目前 iOS 平台上主流的代理软件应该都支持 MitM,比如 Surge/Quantumult X/Shadowrocket。下面以 Quantumult X 为例。
开启 MitM 功能,添加主机名如下:
更新:网页版和手机版请求的是
vod-abematv.akamaized.net
,iPad 上请求的是ds-vod-abematv.akamaized.net
,搞不懂,直接通配符算了。
*abematv.akamaized.net
安装并信任证书(过程略),修改配置文件,添加重写规则:
[rewrite_local]abematv\.akamaized\.net/.+/\d+/playlist\.m3u8 url request-header /\d+/playlist.m3u8 request-header /1080/playlist.m3u8
验证强制 1080p 是否成功(开启 HTTP 调试功能查看):
可以看到原本是要加载 720p 的,经过重写后实际加载的是 1080p 的 segment。
爽!
]]>上网冲浪存了好多沙雕梗图,有时候突然感觉其中一张图能派上用场,但想找却怎么也找不到,也不知道当初存哪里去了;
大事小事都喜欢截图,玩游戏截图,看动画片也截图,截了一大堆到头来也不怎么用,乱七八糟的混在一起,删了又感觉怪可惜的;
看到喜欢的图总之先右键保存(话说现在是不是已经不兴说「右键保存」了,「长按保存」可能还更普遍一点,大人时代变啦),久而久之图库越来越大,又分散在硬盘的不同角落,整理起来也愈发力不从心……
啊。没有吗。好吧。🥲
说正经的,如果你也有很多图片需要管理,那么你应该试试 Eagle 这款软件。
为什么呢?
如果单纯依靠文件系统整理(用文件夹分类),缺点还是很明显的:
使用专业的素材管理软件,可以解决绝大多数的这种问题。
当然了,这种类型的软件不只有 Eagle 一个。但是我只用过 Eagle,所以就不推荐其他的啦。如果有更好的选择,欢迎在评论区告诉我。
我目前用到的功能基本上有这些:
以我自己的体验来说,比起原来用文件管理的时候,真的舒适了太多。相见恨晚啊!
比如下面是我珍藏的(到处偷来的)小叮当图:
还有这几天在看(吹)的《佐贺偶像是传奇》:
不过有一点遗憾的是,目前在手机上还没有浏览 Eagle 资源库的办法。
Eagle 资源库的目录结构是这样的:
我的图库.library├── images│ ├── KEZAL1JG0RN32.info│ │ ├── IMG_1507.png│ │ ├── IMG_1507_thumbnail.png│ │ └── metadata.json│ ├── KEZAL1JG2YXNI.info│ │ ├── 我太菜了 对不起.jpg│ │ └── metadata.json│ ├── KEZAL1JG34XY6.info│ │ ├── 可恶 你们这些被资本主义毒惑到骨子里的猪.jpg│ │ └── metadata.json│ ├── KEZAL1JG4HFBR.info│ │ ├── illust_71093876_20191005_193032.jpg│ │ ├── illust_71093876_20191005_193032_thumbnail.png│ │ └── metadata.json...
每张图片外面都包了一个文件夹,包括分类、文件夹层级等元信息都存在里面的 metadata.json
中,从外界无法直接浏览(这也是为部分人所诟病的一点)。
而官方似乎也没有出移动端 App 的打算……
一个妥协的办法就是使用 Eagle 的导出功能,导出成正常目录结构,然后放到手机上(可以用 Documents 等应用从电脑自动同步)。
这样算是勉强能看,不过到底还是不方便。
前段时间看到 Eagle 公开了 API,本来想弄个简陋的网页客户端,写了个小 demo,倒是确实能浏览图库了。所以功能实现估计是没什么障碍的,奈何后来犯懒,一直搁置到现在,只能有缘再说了。
最后,Eagle 是收费软件,¥199 两台设备,终身授权,学生有教育优惠。
如果你有图片整理需求的话,去试用一下吧,不会亏的。
]]>第一次体验到的「主动降噪」功能确实惊艳,虽然感觉对人声的处理还是差了点意思,但开和不开真的是两种完全不同的感受。尤其是乘坐公共交通的时候,感觉是终于夺回了一点耳朵的主导权,不用再一味地被各种噪声强奸了。
用了几个月,总的来说挺满意的,就是这人声降噪实在是普通,近处的人说话大声点就挡不住了(比如室友),只能开音乐盖过去。有人说是后续固件削弱了,不过我感觉买来就这吊样。可惜市面上还是没有能达到聋子模拟器效果的降噪耳机,遗憾。
AirPods Pro 另外一个比较受关注的新功能就是「空间音频」,简单来说就是通过耳机里的各种传感器和一系列计算来调整声音,模拟出影院级(据说)的环绕声效果。到手的时候我就拿爱奇艺上的《复仇者联盟4》测试了一下,确实挺 amazing 的。
为什么要说这些呢?其实是因为我最近下了个 EVA 新剧场版的资源,里面封装的音轨是 6.1 声道的。然而就是这个视频文件,在播放到战斗激烈的场景时,我的所有设备上,无论扬声器还是耳机都会出现爆音(clipping)。
新剧场版终章真的要上映了,你庵野鸽鸽什么时候骗过你
初步排查应该是多声道音源 downmix 成双声道播放的时候出问题了,因为封装在一起的双声道粤语音轨并不会爆音。感觉挺奇怪的,难道这些播放器在 downmix 的时候默认都没有音量电平限制吗?
就在搜索解决方法时,我突然想到了 AirPods Pro 的「空间音频」功能:既然这音轨本身就是环绕声,那么如果用空间音频来播放,岂不是正好?
查了一下,目前支持为本地视频开启空间音频的播放器不多:
比如我主力使用的 nPlayer 目前就还不支持,遗憾。
另外也发现了个有意思的 App:Surround Sound Speaker Check
貌似原本是用来给家庭影院的环绕声音响系统做测试的,没想到还能拿来测试 AirPods Pro 空间音频,哈哈哈。用这个测试了一下,空间音频确实很有感觉,普通的双声道模拟环绕声的效果相形见绌,推荐各位也试试。
下面对帖子中提到的几个播放器做一下测试。
使用的视频文件如下。
测试文件①:杜比官方宣传片(H.264
+ AC-3
,MP4
封装)
Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'dolby-atmos-trailer_amaze_1080.mp4': Duration: 00:01:03.55, start: 0.000000, bitrate: 9396 kb/s Stream #0:0(und): Video: h264 (Main) (avc1 / 0x31637661), yuv420p, 1920x1080 [SAR 1:1 DAR 16:9], 8946 kb/s, 24 fps, 24 tbr, 24 tbn, 48 tbc (default) Stream #0:1(und): Audio: eac3 (ec-3 / 0x332D6365), 48000 Hz, 5.1(side), fltp, 448 kb/s (default)
测试文件②:网上摸来的多声道 AAC(H.264
+ AAC
,MP4
封装)
Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'ChID-BLITS-EBU.mp4': Duration: 00:00:46.63, start: 0.000000, bitrate: 188 kb/s Stream #0:0(und): Video: h264 (Main) (avc1 / 0x31637661), yuv420p, 800x600, 26 kb/s, 8 fps, 8 tbr, 8 tbn, 16 tbc (default) Stream #0:1(und): Audio: aac (HE-AAC) (mp4a / 0x6134706D), 44100 Hz, 5.1, fltp, 159 kb/s (default)
测试文件②:上面提到过的 EVA 新剧场版(H.264
+ FLAC
,MKV
封装)
Input #0, matroska,webm, from 'Evangelion 1.11 You Are (Not) Alone 2007 [BD 1920x1080 23.976fps AVC-yuv420p10 FLAC].mkv': Duration: 01:40:58.57, start: 0.000000, bitrate: 12663 kb/s Stream #0:0(jpn): Video: h264 (High 10), yuv420p10le(progressive), 1920x1080, SAR 1:1 DAR 16:9, 23.98 fps, 23.98 tbr, 1k tbn, 47.95 tbc (default) Stream #0:1(jpn): Audio: flac, 48000 Hz, 6.1, s16 (default) Stream #0:2(eng): Audio: flac, 48000 Hz, 6.1, s16 Stream #0:3(chi): Audio: flac, 48000 Hz, stereo, s16
测试文件④:对测试文件③进行转码(H.264
+ AC-3
,MP4
封装)
ffmpeg -i 'Evangelion 1.11 You Are (Not) Alone 2007 [BD 1920x1080 23.976fps AVC-yuv420p10 FLAC].mkv' -map 0:v:0 -map 0:a:0 -c:v h264_videotoolbox -c:a ac3 output.mp4
Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'output.mp4': Duration: 01:40:58.57, start: 0.000000, bitrate: 52 kb/s Stream #0:0(jpn): Video: h264 (High) (avc1 / 0x31637661), yuv420p, 1920x1080 [SAR 1:1 DAR 16:9], 559 kb/s, 23.98 fps, 23.98 tbr, 24k tbn, 48k tbc (default) Stream #0:1(jpn): Audio: ac3 (ac-3 / 0x332D6361), 48000 Hz, 5.1(side), fltp, 448 kb/s (default)
测试结果(✅ 表示可以激活空间音频):
文件1 | 文件2 | 文件3 | 文件4 | |
---|---|---|---|---|
PlayerXtreme | ✅ | ✅ | ❌ | ✅ |
FE File Explorer | ✅ | ✅ | ❓ * | ✅ |
Plex | ✅ | ✅ | ✅ ** | ✅ |
* FE File Explorer 播放 MKV 格式视频需要高级版,我就没测
** 需要修改 Plex Media Server 的配置,参考这篇文章
目前猜测触发空间音频的条件是:
由于有服务端转码的配合,Plex 的兼容性表现最好,推荐。
题外话:经过 Plex 转码以后,就算关闭耳机的空间音频,直接播放也不会出现爆音了,不知道是不是转码的时候做了处理。另外空间音频效果确实好,结尾主题曲响起的时候差点以为我没戴耳机,感觉声音真的像是从 iPad 那边发出来的一样,神奇。
]]>虽说在大版权时代,资源还是存到自己的硬盘里最踏实,但下载党也不是那么好当的。而且习惯了流媒体的便利以后,我是越来越不愿意花那个心思了。然而国内音乐平台版权之争使得听歌体验愈发水深火热,我也不得不面对现实……
在网易云音乐的歌单灰掉一大片后,我终于决定跑路。
这篇文章算是我个人使用了半年多日区 Apple Music 的感受,如果你恰巧也在纠结如何选择,希望对你有所帮助。
其实我涉猎挺广泛的,各种类型都听一点。真不是精日
国内的网易云、QQ 音乐啥的是靠不住了,还是放眼海外吧。
除了外区 Apple Music,还有其他几个也是我考虑过的:
最后还是选了 AM,原因下面说。
当然,每个人的音乐口味千差万别,最适合我的不一定最适合你。
Apple Music 官方说的是「7000万曲聴き放題」,在日本所有音乐流媒体服务中算是数一数二(当然也还是得看你主要听什么)。
同样是日区,Spotify 我也试用过,可以说大部分 AM 没有的,Spotify 也没有;而 Spotify 有的,AM 基本也有,属于旗鼓相当的对手。比如说ヨルシカ、YOASOBI、Official髭男dism、あいみょん这些热门音乐人的歌,就是基本每家都有。
按照我自己使用下来的体验,其实想听的大部分歌都能在 Apple Music 里找到。
(当然这只是我个人的情况,毕竟我也不知道你要听什么)
按照官方文档的描述:
如果订阅了 Apple Music 或 iTunes Match,您可以使整个 iCloud 音乐资料库在使用同一个 Apple ID 登录的所有设备上都可用。只要接入互联网,即可随时访问您的资料库。
可以理解为网易云那样的音乐云盘,曲库里没有的歌,可以自己上传。
这也是我选择 Apple Music 的最大原因:就算你曲库再大,总有些歌是没有的,比如各种特典 CD、会场限定等等,允许自己上传歌曲就很方便。
据说 Spotify 也可以播放本地音乐,但并不是上传到云端,还是没有 AM 好用。
iTunes Store 作为日本最大的音乐在线配信平台,可以说大部分的歌如果有在网上卖,那肯定有在 iTunes Store 上卖;如果 iTunes Store 上没有,那八成就是没有在网上卖。
有些音乐 Apple Music 上没有提供,但是 iTunes Store 上是有卖的,比如「宇宙よりも遠い場所」「少女終末旅行」的 OP、ED、插入歌专辑。还有 IM@S 偶像大师系列,万南不肯流媒体配信,唯一支持的 ANiUTa 还全都只有 short version,想第一时间听到新曲就只能上 iTunes Store 了。
毕竟都是水果家的服务,iTunes Store 上购买的专辑与单曲,也可以直接在 Apple Music 的应用中播放(废话)。
我实际使用下来,「Apple Music 流媒体提供的音乐」「iTunes Store 购买的音乐」和「自己上传到 iCloud 音乐资料库的音乐」都是一个待遇,播放、整理起来都没差别。比如下面这张图,如果我不说,你能分辨出它们的来源吗?
哦对,现在 iTunes 已经拆成了三个独立应用,Music、Podcast 和 TV。
虽然 iTunes 的手机管理功能屎得不行(现在集成到 Finder 里去了,还是很屎,强力推荐 iMazing),但它的音乐库管理功能还是公认很强大的,metadata 编辑、智能播放列表都很好用。
而且只要添加到了自己的 iCloud 音乐资料库中,就算是 Apple Music 提供的音乐,其 metadata 也可以自由修改(比如专辑封面、艺人等),可以说是最接近本地曲库体验的流媒体音乐服务了。
好吧,这对一部分人来说可能是缺点。不过我确实不喜欢那些花里胡哨的东西,不看评论,对「网抑云」也没有兴趣。
这是网易云音乐、QQ 音乐和 Apple Music 的首页对比,见仁见智吧。
另外,虽然没有社交,不过可以在搜索里搜到其他人分享的整理好的播放列表,比如我前段时间找《钢之炼金术师》OP、ED 时就省的自己一个一个找了,也挺方便的。
Apple Music 的一大特色。
有个人推荐歌单、推荐电台、艺人歌单、音乐回忆歌单,等等。
其中我最中意的是编辑推荐歌单,比如:
各种艺人歌单也是入坑的好机会:
更多可以参考:「用好」Apple Music,这些是你应了解的 - 少数派
在换 iPhone 之前,我在 Android 上也用过一段时间的 Apple Music(没错,竟然有 Android 客户端……),出乎意料地体验还不错,让人难以相信是那个以「自家生态圈以外的软件都做成一坨屎」闻名的🍎。
Windows 上的 iTunes,体验也马马虎虎,但感觉没人喜欢用这个听音乐吧。
虽然其他平台上也有支持,但 Apple Music 体验最好的肯定还是水果全家桶。如果你手头一部 Apple 设备都没有,那我是不推荐你用 Apple Music 的,因为很可能光是如何付款就得折腾半天……
至于网页版的 Apple Music,说实话我就没有成功打开过。🌚
说完优点说缺点。
Apple Music 的账号是跟着 iTunes Store/App Store 账号走的,不能独立设置。
也就是说如果你想要一直听日区 AM,那么就得保证 iTunes Store/App Store 的账号一直是日区(相信这对经常切外区账号下载 App 的 iOS 用户并不陌生)。
如果你临时需要下载一个只有国区 App Store 才有的 App,切了一下账号,那 Apple Music 的已下载音乐就会被清空。虽然资料库不会受影响,再登回日区账号就可以了,但又要再下载一遍还是很不爽。
看到有人说切账号会清空资料库,但我实际没有遇到过,只是清空了下载。
所以我现在一般是在 iPad 上切国区账号,下载完需要的 App,然后在电脑上通过 iMazing 安装 App 到手机,这样手机上就不需要切换账号了。
想要使用 Apple Music,必须开启音乐资料库同步。
一旦开了资料库同步,你的设备里的音乐就会被同步成和 iCloud 中的音乐资料库一样。如果想要把一首歌传到 iPhone 里,以前是通过 iTunes 直接同步过去就行了,现在你必须先在电脑上把歌添加到资料库中,等待它上传至 iCloud,然后再等它从 iCloud 下载到手机上,而且你的所有设备里都会出现这首歌。
也就是说,用了 Apple Music,你的所有水果设备就全都共享一个音乐资料库了。想听什么就往里面加,会自动同步,所有设备上都能听。方便是挺方便,但你如果想把一些音乐 locally 放到设备上,而不上传到云端的话,那就不好意思了。
我之前想把《异度神剑 2》的 OST 放到手机里,就因为这个问题,最后只能另寻他法 —— 我可不想把 7GB 的 OST 全部上传到 iCloud 资料库里去,更何况还会被压缩。
一个解决方法就是不要用自带的音乐 App,下个其他的音乐播放器就可以了。我是在手机上下载了 VOX,然后把 OST 传到了 App 对应的文稿存储空间里解决的,手机版 foobar2000 和 Flacbox/Evermusic 也可以。
iCloud 音乐资料库不能上传无损音乐,ALAC 会被压缩成 256kbps AAC。
如果源文件就是 MP3/AAC 格式,那么上传后不会被压缩。
Apple Music 支持滚动歌词,而且也挺好看的。不过这似乎只能是音乐发行人自己添加,用户是没办法自己添加滚动歌词的,添加的只能是静态歌词。搞不懂。
目前我感觉是 Apple Music 流媒体提供的歌大部分都有滚动歌词(听说有专门的团队负责?),iTunes Store 里买的歌,人气高的那些基本都有,但也有其他很多没有的(没错我说的就是你万代南梦宫)。
对于这些没有滚动歌词的音乐,想自己添加就只能添加静态歌词咯。
姑且放到缺点里来吧。下面是我正在使用的代理规则片段:
# 播放自己上传的歌曲DOMAIN-SUFFIX,blobstore.apple.com# iTunes Store 音乐试听DOMAIN-SUFFIX,audio-ssl.itunes.apple.com# iTunes Store 购买后播放DOMAIN-SUFFIX,streamingaudio.itunes.apple.comDOMAIN-SUFFIX,itsliveradio.apple.comDOMAIN-SUFFIX,aodp-ssl.apple.comDOMAIN-SUFFIX,video-ssl.itunes.apple.comDOMAIN-SUFFIX,mvod.itunes.apple.comDOMAIN-SUFFIX,hls-amt.itunes.apple.comDOMAIN-SUFFIX,audio.itunes.apple.comDOMAIN-SUFFIX,genius.itunes.apple.comDOMAIN-SUFFIX,genius-upload.itunes.apple.comDOMAIN-SUFFIX,genius-download.itunes.apple.com
参考:提升国内 Apple Music 体验的代理规则 - 丁丁の店
最后说一下日区 Apple Music 如何上车。目前的 plan 有:
其中最划算的就是家庭订阅,最多可以 6 个人共享,算下来一个人每月只要 250 日元左右。如果加点钱上 Apple One 家庭订阅(1,850円/月),每个月 300 日元,还能另外多出 Apple Arcade 和 200GB iCloud 存储空间。
如果想找人组队家庭订阅的话,推荐去蹲一下 Telegram 上的大型合租群。
总的来说缺点也有,瑕不掩瑜吧。
反正我用得是挺爽的,其他人咋样就不关我事了。
]]>写完 Dockerfile 测试好,正准备 push 上去时,我才突然想起来:
我现在用的是 M1 MacBook,丫的默认 build 出来的镜像是 arm64
架构的呀!
好在 M1 Mac 上的 Docker Tech Preview 也支持使用 buildx 构建多架构的镜像,稍微设置一下就可以了。
题外话,M1 MacBook Air 真的很好用,建议早买早享受(
Docker 的 buildx
还是实验性功能,需要在 Docker Desktop 设置中开启,具体位于 Preferences > Experimental Features > Enable CLI experimental features。
Docker 默认的 builder 不支持同时指定多个架构,所以要新建一个:
docker buildx create --use --name m1_builder
查看并启动 builder 实例:
docker buildx inspect --bootstrap
Name: m1_builderDriver: docker-containerNodes:Name: m1_builder0Endpoint: unix:///var/run/docker.sockStatus: runningPlatforms: linux/arm64, linux/amd64, linux/riscv64, linux/ppc64le, linux/s390x, linux/arm/v7, linux/arm/v6
其中 platforms 就是支持的架构,跨平台构建的底层是用 QEMU 实现的。
使用 buildx
构建:
docker buildx build \ --platform linux/amd64,linux/arm64 --push -t prinsss/google-analytics-hit-counter .
其中 -t
参数指定远程仓库,--push
表示将构建好的镜像推送到 Docker 仓库。如果不想直接推送,也可以改成 --load
,即将构建结果加载到镜像列表中。
--platform
参数就是要构建的目标平台,这里我就选了本机的 arm64
和服务器用的 amd64
。最后的 .
(构建路径)注意不要忘了加。
构建完 push 上去以后,可以查看远程仓库的 manifest:
docker buildx imagetools inspect prinsss/google-analytics-hit-counter
Name: docker.io/prinsss/google-analytics-hit-counter:latestMediaType: application/vnd.docker.distribution.manifest.list.v2+jsonDigest: sha256:a9a8d097abb4fce257ae065365be19accebce7d95df58142d6332270cb3e3478Manifests: Name: docker.io/prinsss/google-analytics-hit-counter:latest@sha256:bb7f3a996b66a1038de77db9289215ef01b18e685587e2ec4bb0a6403cc7ce78 MediaType: application/vnd.docker.distribution.manifest.v2+json Platform: linux/amd64 Name: docker.io/prinsss/google-analytics-hit-counter:latest@sha256:94ea08ac45f38860254e5de2bae77dee6288dd7c9404d8da8a3578d6912e68e7 MediaType: application/vnd.docker.distribution.manifest.v2+json Platform: linux/arm64
可以看到已经是支持多架构的镜像了。
两年多没碰前端,感觉自己已经完全与时代脱节了。作为一个从 jQuery 一把梭时代过来的选手,对于现在那些「大前端」的玩意儿基本都是只懂个皮毛的状态(指看过 Hello world)。四年前我引用过这么一篇文章,《在 2016 年学 JavaScript 是一种什么样的体验?》,放到现在要是出个 2021 版本,估计会更唬人吧。
不过嘛,主要的技术其实还是那么几个:
对于初学者来说可能是有些复杂,但应该还没到「求不要更新了,老子学不动了」的程度。
举个例子,最开始,大家都用原生 HTML、CSS、JavaScript 三板斧来开发网页。后来有人觉得原生的那些方法操作 DOM 啥的太麻烦了,于是弄出了 jQuery 和大家喜闻乐见的 $('#id')
;觉得代码组织不方便,于是弄出了各种模块化解决方案;觉得 Callback Hell 太傻X了,于是有了 Promise 和 async/await。觉得 JS 没有类型系统太容易出 BUG,于是有了 TS;觉得手动监听事件、操作 DOM 太麻烦了,于是有了数据绑定和三大框架;因为上面这些乱七八糟的最后都得拿去浏览器跑,所以有了 Babel、Webpack 和各种工具链;还有人觉得前端技术这么牛逼,光写网页怎么够,于是有了各种大前端、跨端开发技术……
如此种种,其实没有哪个技术是凭空蹦出来的,都是有需求才有人去做。如果你在实际开发中体会到了这个需求,那么这些新技术的出现也就变得理所当然了。而这也是我推荐新人学一门编程语言不要从框架学起的原因:比如从 Vue 开始学前端,从 Laravel 开始学 PHP,从 Spring 开始学 Java,很容易知其然而不知其所以然。
好了废话就到这里,今天咱们来用 React 实现一个基本的登录注册系统。
【后面的内容鸽了】
]]>今天更新了一下博客,寻思着好歹也改一下页脚的 Copyright 年份,改完 push 上去以后却发现 GitHub Pages 迟迟没有更新。进去 Travis CI 一看,发现任务一直处于 Queued 状态,半小时了都没开始构建。
查了一下,并不是只有我遇到了类似情况(似乎是因为 Travis CI 正在将 travis-ci.org 迁移至 travis-ci.com):
看了一圈感觉有点悬,干脆换成 GitHub Actions 吧。
我的博客完全托管在 GitHub 上:prinsss.github.io,其中 source
分支放的是源码,master
分支(即 GitHub Pages)是 Hexo 生成的静态博客页面。
要做的也和之前 Travis CI 差不多,当 source
分支有更新时,自动使用 Hexo 构建新页面并更新 GitHub Pages 就可以了。
生成一个新的 SSH 密钥,用于 push 至 GitHub Pages 所在的 repo:
ssh-keygen -f hexo-deploy-key -C "prinsss.github.io"
将公钥 hexo-deploy-key.pub
设置为仓库的部署密钥(Settings > Deploy keys):
然后在 Settings > Secrets 中新增一个 secret,命名为 DEPLOY_KEY
,把私钥 hexo-deploy-key
的内容复制进去,供后续使用。
Workflow 就是 GitHub Actions 的配置文件,类似于 .travis.yml
。
首先新建文件:
mkdir -p .github/workflowstouch .github/workflows/deploy.yml
编辑 deploy.yml
:
name: Hexo Deploy# 只监听 source 分支的改动on: push: branches: - source# 自定义环境变量env: POST_ASSET_IMAGE_CDN: truejobs: build-and-deploy: runs-on: ubuntu-latest steps: # 获取博客源码和主题 - name: Checkout uses: actions/checkout@v2 - name: Checkout theme repo uses: actions/checkout@v2 with: repository: prinsss/hexo-theme-murasaki ref: master path: themes/murasaki # 这里用的是 Node.js 14.x - name: Set up Node.js uses: actions/setup-node@v1 with: node-version: '14' # 设置 yarn 缓存,npm 的话可以看 actions/cache@v2 的文档示例 - name: Get yarn cache directory path id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn cache dir)" - name: Use yarn cache uses: actions/cache@v2 id: yarn-cache with: path: ${{ steps.yarn-cache-dir-path.outputs.dir }} key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} restore-keys: | ${{ runner.os }}-yarn- # 安装依赖 - name: Install dependencies run: | yarn install --prefer-offline --frozen-lockfile # 从之前设置的 secret 获取部署私钥 - name: Set up environment env: DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} run: | sudo timedatectl set-timezone "Asia/Shanghai" mkdir -p ~/.ssh echo "$DEPLOY_KEY" > ~/.ssh/id_rsa chmod 600 ~/.ssh/id_rsa ssh-keyscan github.com >> ~/.ssh/known_hosts # 生成并部署 - name: Deploy run: | npx hexo deploy --generate
当然,具体步骤还是得根据自己的需求进行相应的修改。
GitHub Pages 相关的具体配置放在了 Hexo 的 _config.yml
里:
deploy: type: git repo: git@github.com:prinsss/prinsss.github.io.git branch: master name: prinsss email: prinsss@gmail.com
更新 source
分支,push 后 GitHub Actions 就会自动执行。
不到半分钟就 build 完了,只能说微软爸爸还是牛逼。
其实吧,学校有的是办法搞你。除了强制一人一号、恶心的专用拨号客户端防共享以外,还能通过 IPID、TTL、时钟偏移检测,甚至是 DPI 深度包检测的方法来防私接,就看校方做得够不够绝了。当然破解的方法也有,但基本也不会让你好受。如果碰到这样的校园网,推荐你直接躺平,给这种垃圾网络交钱还不如开个无限流量套餐呢。
免责声明:日后你惹出祸来,不把师父说出来就行了.jpg
先说一下我们学校校园网恶心的地方。接入网线后,你需要:
而且这两个软件只能在 Windows 上跑,macOS、Linux 用户就干瞪眼吧。
其实说到这里,有些人应该都心里有数这俩软件是啥。不过我这里就不明说了,懂的都懂(x
接下来来看一下网上常见的破解方案,也就是路由器拨号。
据说网上有很多人在卖所谓的「校园网路由器」,其实说白了就是刷了 OpenWRT 的路由器 + 破解校园网的插件。如果有现成的插件能用,那自然是最好,刷个固件也不是什么难事。但不幸的是,目前网上并没有针对【软件A】新版本的拨号脚本,而旧版本的已经不再适用于我们学校的网络了。除非另有大神愿意开发新版本的拨号脚本,否则这条路是行不通的。更何况我们这还要二次拨号,更是难上加难。
而如果想要在电脑上直接分享热点,第一会被检测多网卡,第二可能会被检测进程。
emmmmm……🤔
那么,让我们的网卡不被检测到不就行了?
这个方法其实是以前我偶然发现的。
当时我在用虚拟机捣鼓 Kali Linux,用了 VirtualBox 的 USB Passthrough 功能把 USB 外接网卡穿透进虚拟机给 Kali 使用。此时,宿主机操作系统里是看不见这个 USB 网卡的,设备管理器、网络适配器里都没有,就像不存在一样。不存在……嗯?
于是我翻箱倒柜找出了几年前凑单买的小米随身 WiFi,在虚拟机里一通操作:
嘿,成了!
如何配置,搜索「VirtualBox USB 网卡」即可。
看来虚拟机的 USB 直通确实可以避开校园网认证客户端的多网卡检测,而且客户端也并没有对 VirtualBox 做什么手脚(后来查了一下,据说【软件A】会干扰 VMware 的 NAT 网络共享服务……)。那么,能做的事情可就多了。
上面的方案能用是能用,但效率过于低下。
既然要用得爽,那肯定得把这些问题解决了。
首先,把 USB 无线网卡换成 USB 有线网卡,下联硬路由作为 AP。同时,把 Guest OS 换成轻量级的 Alpine Linux 并实现开机启动。完成后的网络拓扑图类似这样(综合考虑最后还是选择了两层 NAT):
首先在虚拟机内安装 Alpine Linux 和对应的网卡驱动(注意不要用 virt 版本的内核,很多驱动都被精简掉了):
ip link show
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:002: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast master br0 state UP qlen 1000 link/ether [mac addr] brd ff:ff:ff:ff:ff:ff3: eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast master br0 state UP qlen 1000 link/ether [mac addr] brd ff:ff:ff:ff:ff:ff4: br0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP qlen 1000 link/ether [mac addr] brd ff:ff:ff:ff:ff:ff
添加网桥,把 USB 网卡和虚拟机的虚拟网卡桥接到一起:
brctl addbr br0brctl addif br0 eth0brctl addif br0 eth1brctl show
启动网络:
ip link set dev eth1 upip link set dev br0 upip link show
删除之前分配给虚拟网卡 eth0 的 IP,并启动 DHCP 客户端为 br0 获取 IP 地址:
ip addr flush dev eth0udhcpc -i br0ip addr show
此时应该虚拟机内、有线网卡端都能访问网络了,可以通过 ping
测试一下。
可以用的话就永久保存网络配置:
vi /etc/network/interfaces
auto loiface lo inet loopbackauto br0iface br0 inet dhcp hostname alpine-vm bridge-ports eth0 eth1 bridge-stp 0
接下来把 USB 网卡和路由器的 WAN 口用网线连接起来,测试是否工作正常。如果想省一层 NAT,可以连到 LAN 口上并关闭路由器 DHCP 功能,就当个单纯的 AP 使用。不过我为了相对稳定的内网环境,还是选择了前者。
一切正常的话,就可以愉快地使用 WiFi 啦。
如果想要让 Alpine 虚拟机开机后台运行,可以使用 VBoxHeadlessTray 这个程序。
至此,我们的校园网网络共享方案已经算是比较完善了。
幸运的是,我校似乎并没有部署其他什么防私接技术,像这样用了半年多也一直相安无事,省下我不少流量费。
然而,这个方案还是有些不爽的地方。
在这套方案下,路由器下联的设备对于主机是几乎不可见的(不然也绕不过校园网分享限制了)。你可以在其他设备上访问主机上的网络服务(VirtualBox 的 NAT 网络里宿主机的 IP 一般为 10.0.2.2
,子网下的设备可以直接访问,效果和主机上访问 localhost
基本一致),但无法反过来访问子网里的其他设备。
虽然你也可以通过 VirtualBox 的端口映射实现一些变通的解决,比如把路由器的 22、80 端口映射到宿主机上方便访问,但 SMB 这类服务就不行了(Windows 访问 SMB 服务器时强制端口为 445,无法手动指定,要改只能改注册表),所以我完全无法在主机上访问子网下的 NAS 设备。
这也太难受了,继续改进!
既然这些软件都需要跑在一台 Windows 机器上,那我专门弄一台机器来跑校园网相关的东西不就好了吗?
于是我把目光投向了最近几年很火的软路由。
就像计算机有软件和硬件的区别,路由器也有「软」「硬」之分。通常我们在各个电商平台上搜索「路由器」这三个关键词所得到的几乎所有商品都属于硬路由,它是由路由器厂商基于自行开发或是开源的嵌入式设备操作系统,根据特定的硬件设备,设计出来的传统硬件设备。
而与之对应的软路由,是基于软件工具在普通的硬件上来实现传统路由器的功能。我们可以在旧电脑、工控机、开发板、服务器甚至是硬件虚拟机中安装软路由系统,然后通过强大的软件实现各种各样的功能。
一番比较后,我在某鱼上入手了一台二手的双网口工控机,安装 Windows 7 系统后,将上面的所有校园网相关的软件都转移到了这台低功耗小主机上。
至于为什么买双网口的机器,虽然 VirtualBox 只能直通 USB 网卡所以还是得外接,不过考虑到以后不用校园网了还可以原地变身软路由,所以不如直接一步到位买个好点的。毕竟现在 CPU 差不多的也就几百块,没必要省那点钱买个电子垃圾。
这套方案我用了一年多,基本上没啥问题。24 小时开机、低功耗、子网设备无感知,爽到。
另外,不要怪我写得这么笼统,毕竟每个学校的校园网都不太一样,很难写出一篇普适性的教程。这篇文章充其量算个 PoC,证明一下只要能折腾,还是能捣鼓出舒适的宿舍网络环境的。
如果你是大佬,甚至可以写个软件实现一样的功能,隐藏网卡、软件 NAT 啥的。不过我是菜鸡,也不想在这上面花太多心思,所以就这样吧。又不是不能用.jpg
至于这么折腾值不值得,那就见仁见智了。至少我是愿意的:你不让我开热点我就不开,那我岂不是很没有面子。老子又不是没交钱,凭什么?
最后,祝各位早日摆脱傻逼校园网。
]]>