Reading view
我的 RSS 与 Follow
一个特殊的古诗网站搭建完毕
深圳10岁日本男孩上学路上被刺死的具体地点
【日本生活】切换宽带网络
小棉袄去“上学”(日本托儿所 · 其三)
没有刺的鱼——かじき(kajiki)
Telegram 领取 DOG 货币
把物理服务器的 CentOS 7 升级到 Ubuntu 24,惊心动魄
自己根据扫描 PDF 文件制作 EPUB 电子书
1 岁小棉袄生病的日记
日本的行政区划以及地图着色技术的使用
使用 Vue 开发生物信息学数据库的体会
小棉袄去“上学”(日本托儿所 · 其二)
重构博客友链页面 & 友链朋友圈开源
先来看看效果:友情链接 - PRIN BLOG。
自我感觉还是不错的,友链的博客们有什么更新都可以实时展示在页面上,一目了然。作为博主,不用打开 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):
- 拿到 API 响应后,放到 KV 中,同时把时间戳放入 metadata;
- 后续从 KV 读取缓存时,对比当前时间和 metadata 中的时间戳;
- 如果经过的时间没有超过设置的 TTL,说明缓存有效,直接返回前端;
- 如果经过的时间超过了 TTL,则标记缓存为
stale
状态,依然返回前端; - 此时,后端在后台重新请求 API,并将最新的响应写入 KV 中;
- 下一次再从 KV 读取时,拿到的缓存就是最新的了。
这样可以保证最快的响应速度,以及相对及时的更新速度,比较适合这种场景。
最后的交付形式其实就是两个 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 就行了,像我这样的懒人最爱。
把 YouTube 视频的某个片段嵌入网页中
花 5 美元用上了GPT-4 API
真的不可以在 React 组件内部嵌套定义子组件吗?
最近在 Code Review 时,看到有同事写了这样的代码:
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 渲染会有什么影响。以及如果有影响,应该如何用更合适的方法重构。
TL;DR
先说结论:
永远不要在 React 组件内部嵌套定义子组件。
如果你有类似的代码,请使用以下方法替代:
- 把子组件移到最外层去,将原有的依赖项作为 props 传入
- 把子组件改为「渲染函数」,通过调用函数插入 JSX 节点
为什么?请接着往下看。
组件重新挂载会造成的问题
来看这段代码(可以在 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 渲染机制之 Reconciliation
我们知道 React 的渲染大致可以分为两个阶段:
Render
阶段:执行组件的渲染方法,找出更新前后节点树的变化,计算需要执行的改动;Commit
阶段:已经知道了需要执行哪些改动,于是操作真实 DOM 完成节点的修改。
其中,「找出变化 + 计算改动」这个过程就被叫做 Reconciliation
(协调)。React 的协调算法可以在保证效率的同时,最大程度复用已有的 DOM,使得对 DOM 做出的修改尽量小。
▲ 图片来自:Render and Commit – React
那么问题来了,React 怎么知道一个组件对应的 DOM 需要更新呢?
简单来说,React 在比较两棵 Fiber 树时,会从根节点开始递归遍历所有节点:
- 如果节点类型和之前一致
- 对于 DOM 元素,保持元素实例不变,仅更新有变化的属性
- 对于组件元素,需要重渲染的,使用新属性调用组件渲染方法
- 如果节点类型有改变
- 卸载该节点及其子节点 ⚠️
- 将对应的 DOM 元素标记为待删除
- 创建新的节点
- 将新的 DOM 元素标记为待插入
上面所说的子组件被卸载再挂载、状态丢失等问题,其实都是因为它们被判断为了「节点类型有改变」。
引用相等性与组件重渲染
在 JavaScript 中,比较值时有两种相等性:
- 值相等性 (Value Equality),即两个值类型一致,内容也一致
- 引用相等性 (Reference Equality),即两个对象的引用在内存中指向同一块区域
举个例子:
// 两个长得一样的对象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
时,使用的是 ===
严格相等。也就是说像上面那样不同的函数引用,会被视作不同的组件类型。进而导致在触发重渲染时,该组件的节点及其子节点全部被卸载,内部的状态也被全部丢弃。
如果我用 useMemo 包一下呢
到这里我们已经介绍了「在组件内部嵌套定义组件」会造成问题的原理。
这时候可能就有小机灵鬼要问了,既然组件每次都被判断为是不同 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 的缓存失效策略
第一点,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> );}
不过需要注意的是,在使用「渲染函数」时,一定要搞清楚和「函数组件」的区别:
- 渲染函数虽然和组件一样都返回 JSX,但它不是组件;
- 渲染函数就是普通 JavaScript 函数,没有状态,也没有对应的 Fiber 节点;
- 渲染函数只是当前组件的一部分,对于 React 渲染来说没有额外开销;
- 渲染函数内部不能使用 Hooks,只有组件内部才能使用 Hooks;
- 渲染函数命名一般以
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 在 Hexo 博客中愉快地画图
一转眼就到 2024 年了!大家新年快乐!
前段时间在写文章时,需要一些配图,于是就使用了 TikZ 来绘制。TikZ 是一个强大的
如果你的阅读器看不到上面的 SVG 格式图片,可以点这里查看 PNG 格式。
上面的图对应的 TikZ 代码可以在这里找到。然而画是画爽了,想把它贴到博客里时却犯了难——目前竟然没有什么好办法可以直接在博客里使用 TikZ!
TL;DR
咱们废话不多说,直接上结果:我写了一个 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 教程
不做过多介绍。贴几个链接,有兴趣的可以学学:
- [LaTeX 绘图指南 - 001] TikZ 的简介、资源以及学习方法 - 知乎
- Hansimov/pgfmanual-zh: PGF/TikZ 中文手册
- PGF/TikZ Manual - Complete Online Documentation
- xiaohanyu/awesome-tikz: A curated list of awesome TikZ documentations, libraries and resources
比如这就是我在写文章时画的图,全部用 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{}
中直接使用的宏包有:
- chemfig
- tikz-cd
- circuitikz
- pgfplots
- array
- amsmath
- amstext
- amsfonts
- amssymb
- tikz-3dplot
如果希望添加其他宏包,可以参考 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 Filter 中的参数与坐标系
除草啦除草啦,再不更新博客就要变成热带雨林啦!🌿
最近在给一个 PixiJS 程序编写 WebGL Shader,被各种参数和坐标系搞得晕头转向。痛定思痛,整理了一下 PixiJS Filter 系统中的各种概念,以供后续参阅。
在 WebGL 中,我们可以通过编写顶点着色器 (Vertex Shader) 和片元着色器 (Fragment Shader) 来实现各种各样的渲染效果。而在 PixiJS 中,渲染引擎为我们屏蔽了绝大多数的底层实现,通常情况下用户是不需要自己调用 WebGL API 的。如果有编写自定义着色器代码的需求,一般是使用 Filter 来实现。
PixiJS Filter 是什么
PIXI.Filter
其实就是一个 WebGL 着色程序,即一组顶点着色器 + 片元着色器的封装。和 Photoshop 中的滤镜功能类似,它接受一个纹理 (Texture) 输入,然后将处理后的内容输出到 Framebuffer 中。使用滤镜,可以实现模糊、扭曲、水波、烟雾等高级特效。
用户只需要定义着色器的 GLSL 代码,传入对应的参数,剩下的工作就全部交给 PixiJS 完成。如果你对这些概念不太熟悉,可以看看:WebGL 着色器和 GLSL。
默认的 Filter 着色器代码
在 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 知识回顾
先来复习一下 WebGL 基础知识。
- WebGL - 基于 OpenGL ES 的图形渲染 API,可根据代码绘制点、线和三角形
- 着色器 (Shader) - 运行在 GPU 上的代码,一种将输入转换为输出的程序
- GLSL - 专门用于编写着色器的语言,语法类似 C 语言
- 顶点 (Vertex) - 一个 2D/3D 坐标的数据集合,表示空间中的一个点
- 顶点着色器 (Vertex Shader) - 处理顶点数据,生成裁剪空间坐标值
- 片元 (Fragment) - 也叫片段,包含了渲染一个像素所需的所有数据
- 片元着色器 (Fragment Shader) - 计算当前光栅化的像素的颜色值
- 参数 - 着色器中获取数据的方法,主要有:attribute、uniform、texture、varying
▲ OpenGL 图形渲染管线的流程。插图来自 LearnOpenGL
不太熟悉的同学可能会以为 WebGL 是 3D 渲染 API,但其实 WebGL 本身只是一个光栅化引擎,并没有提供什么 3D 功能。如果想要实现 3D 渲染,你需要将模型中的三维坐标点转换为裁剪空间的二维坐标,并提供对应的颜色。WebGL 负责将这些图元绘制到屏幕上,仅此而已。
试想:任何 3D 模型,不管怎样旋转、缩放、平移,最终展示到你的屏幕上,都是 2D 的。同样,不管模型上有什么贴图、光照、阴影、反射,最终改变的其实都是这个像素的颜色值。
再来复习一下 WebGL 中的坐标系统:
- 局部坐标 (Local Coordinate)
- 或称模型坐标,对应局部空间
- 一个物体中的点相对于该物体原点的局部坐标
- 世界坐标 (World Coordinate)
- 对应世界空间
- 局部坐标相对于世界原点的坐标,把物体放到整个世界中去看
- 观察坐标 (View Coordinate)
- 对应观察空间
- 从摄像机/人眼的角度去观察世界,所看到的物体相对于观察者的坐标
- 同一个世界坐标,从不同的距离、角度、视野去观察,得到的观察坐标也不同
- 裁剪坐标 (Clip Coordinate)
- 对应裁剪空间
- 将观察空间内超出一定范围的坐标点都裁剪掉,只保留一定范围内的坐标
- 任何超过这个范围的点都不会显示在你的屏幕上
- 从观察坐标转换为裁剪坐标的过程,称作投影变换 (Projection)
- 标准化设备坐标 (Normalized Device Coordinate, NDC)
- 将裁剪空间的坐标值范围映射到
[-1, 1]
范围之间,即为 NDC - 坐标
(0, 0)
位于裁剪空间的正中间,左下角为(-1, -1)
,右上角为(1, 1)
- 将裁剪空间的坐标值范围映射到
- 屏幕坐标 (Screen Coordinate)
- 对应屏幕空间
- 将标准化设备坐标映射到屏幕坐标的过程,称做视口变换
- 纹理坐标 (Texture Coordinates)
- 即纹理图像上的坐标
- 纹理坐标与像素坐标不同,无论纹理是什么尺寸,纹理坐标范围始终是
[0, 1]
- 纹理图像的左下角坐标为
(0, 0)
,右上角坐标为(1, 1)
▲ 各种坐标与变换矩阵的关系。插图来自 LearnOpenGL
Filter 中的几种坐标系
下面介绍 PixiJS Filter 中的坐标系,以及它们和 WebGL 坐标系之间的关系。
Input Coordinate
输入坐标,用于表示传入 FilterSystem 的纹理上的坐标。
Normalized Input Coordinate 是标准化之后的输入坐标,即纹理坐标,范围是 [0, 1]
。
Screen Coordinate
相对于 canvas 视口的屏幕坐标,单位是 CSS 像素。
CSS 像素乘以分辨率 resolution
即为屏幕物理像素。
Filter Coordinate
滤镜坐标,即被 Filter 所覆盖的范围内的局部坐标,单位是 CSS 像素。
Normalized Filter Coordinate 是标准化之后的滤镜坐标,滤镜覆盖范围的左上角坐标是 (0, 0)
,右下角坐标是 (1, 1)
。
Sprite Texture Coordinate
额外的图片纹理坐标。可以用于采样其他输入中的纹理。
Sprite Atlas Coordinate
额外的精灵图集纹理坐标。
Filter 的输入参数
讲完坐标的种类,下面介绍 Filter 着色器代码中传入的各个参数(attributes、uniform、varying)分别代表什么,对应的是什么坐标系,以及它们的取值分别是多少。
⚠️ 以下参数适用于 PixiJS v7 版本,不排除后续版本中有变动的可能。
aVertexPosition
- 类型:
vec2
二维向量 - 含义:Filter 所覆盖的范围内的标准化坐标
- 坐标系:Normalized Filter Coordinate
- 取值范围:0.0 ~ 1.0
假设有一个长宽为 300x300 的矩形 A,其原点左上角位于 (100, 30) 世界坐标处。则有:
- aVertexPosition
(0.0, 0.0)
对应矩形左上角 - aVertexPosition
(1.0, 1.0)
对应矩形右下角
projectionMatrix
- 类型:
mat3
3x3 矩阵 - 含义:投影矩阵,用于将观察空间坐标变换到裁剪空间坐标
具体是怎么变换的,请阅读本文底部的「投影矩阵」章节。
inputSize
- 类型:
vec4
四维向量 - 含义:输入 filter 的临时 framebuffer 大小
- 坐标系:Input Coordinate
- 取值范围:长宽 > 0,单位 CSS 像素
假设有一个大小为 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 着色器这类需要高速运行的代码中,通常会尽量避免直接使用除法,而使用倒数乘法替代。
outputFrame
- 类型:
vec4
四维向量 - 含义:Filter 所覆盖的区域在屏幕坐标中的位置与大小
- 坐标系:Screen Coordinate
- 取值范围:位置不限,长宽 > 0,单位 CSS 像素
还是以矩形 A 为例,其 outputFrame 取值为:
vec4(100, 30, 300, 300)
其中,前两个分量 x, y
为输出区域的位置,后两个分量 z, w
为输出区域的长宽。
vTextureCoord
- 类型:
vec4
四维向量 - 含义:用于采样输入 filter 的临时 framebuffer 的纹理坐标
- 坐标系:Normalized Input Coordinate
- 取值范围:0.0 ~ 1.0
uSampler
- 类型:
sampler2D
纹理 - 含义:输入 filter 的纹理图像,可配合 vTextureCoord 纹理坐标进行采样
inputPixel
- 类型:
vec4
四维向量 - 含义:输入 filter 的临时 framebuffer 物理像素大小
- 坐标系:Input Coordinate
- 取值范围:长宽 > 0,单位物理像素
和 inputSize 类似,但是单位不是 CSS 像素,而是物理像素。以下两个表达式等价:
inputPixel.xyinputSize.xy * resolution
同样地,inputPixel.zw
是 inputPixel.xy
的倒数,用于转换除法为乘法。
inputClamp
- 类型:
vec4
四维向量 - 含义:用于将纹理坐标钳制 (clamp) 在 framebuffer 的有效范围内
- 坐标系:Normalized Input Coordinate
- 取值范围:0.0 ~ 1.0
有效范围指的是临时 framebuffer 中实际包含纹理图像的部分,其余部分可能是透明的(具体原因可阅读文章下方的注意事项)。使用示例:
vec4 color = texture2D(uSampler, clamp(modifiedTextureCoord, inputClamp.xy, inputClamp.zw));
其中,inputClamp.xy
表示有效范围的左上角,inputClamp.zw
表示有效范围的右下角。
resolution
- 类型:
float
- 含义:分辨率,即 CSS 像素与物理像素的比率,类似
devicePixelRatio
filterArea (legacy)
- 类型:
vec4
四维向量 - 含义:Filter 所覆盖的区域在屏幕坐标中的位置与大小
注意,filterArea 已经被标记为 legacy,你应该考虑使用其他参数替代。
// 以下语句等价于直接使用 filterArea uniformvec4 filterArea = vec4(inputSize.xy, outputFrame.xy)
filterClamp (legacy)
- 类型:
vec4
四维向量 - 含义:兼容旧版本的 legacy uniform,与 inputClamp 等价
坐标系之间的转换
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);}
怎么样,是不是感觉清晰了很多呢?
Bonus: 投影矩阵
如果你很好奇上面的投影矩阵是怎么做到乘一下就能把屏幕坐标转换为裁剪空间坐标的,那么这一小节就可以解答你的疑惑。
🤫 偷偷告诉你,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);}
这个投影矩阵主要做了两件事:
- 缩放变换,从像素坐标转换到 0.x ~ 2.x 范围
- 平移变换,将坐标转换为 -1.0 ~ 1.0 范围内的标准坐标
对于一个长宽为 300x300,原点左上角位于 (100, 30) 世界坐标处的矩形,可得:
- sourceFrame.width = 300
- sourceFrame.height = 300
- sourceFrame.x = 100
- sourceFrame.y = 30
- sign = 1 (此处假设为渲染至 framebuffer)
计算出投影矩阵为:
使用矩阵乘法对世界坐标进行变换:
得到如下坐标:
- 局部坐标:(0, 0) ~ (300, 300)
- 世界坐标:(100, 30) ~ (400, 330)
- 缩放变换:(0.67, 0.20) ~ (2.68, 2.21)
- 平移变换:(-1.0, -1.0) ~ (1.0, 1.0)
即可将世界坐标转换为裁剪空间的标准化设备坐标。数学,很神奇吧!👊
更多关于矩阵变换的资料可参考:
顺便测试一下我的新插件 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 App
想要让这个开关更“智能”,单靠手动操作手机遥控肯定是不够看的。因为手头没有开发板(听说现在树莓派都被炒上天了,不懂),所以还是让闲置的手机发挥余热吧。
好在之前学的那点 Android 开发还没有全忘光,基于 Android-BLE 这个库(其实上面我们用来测试的 App 就是这个库的 demo)和小程序里扒出来的控制逻辑糊了一个遥控 App 出来(代码放在 GitHub):
可以看到界面非常简约陋,不过比什么微信小程序可好用多了。幸福感 UP!
等以后有时间的话,再捣鼓捣鼓接入一下 Home Assistant,加几个自动化,不用动手直接喊 Siri 关灯,岂不美哉?(dreaming)