Maeiee系统搭建迭代1
转眼《Maeiee的成长周报》已经来到第 5 周。在第3、4周中,我坚持啃完《「Let's build GPT:from scratch, in code, spelled out.」课程笔记》,发到网上,引起了一些关注与阅读,很有成就感。在本周中,我放慢脚步,对自己的个人系统进行迭代完善,磨刀不误砍柴工。
『我的个人系统』指什么?为了方便自学与知识获取,我给自己开发了一套软件。比如对 qutebrowser 进行二次开发,使之成为可编程浏览器,又如使用 Flutter 开发了一套跨平台的 App,具备资讯、个人管理等功能。个人系统并不意味着,所有软件都要自己重头开发,一些优秀的软件也可以直接纳入,比如 Obsidian。
在本周中,我围绕这些软件进行维护、迭代。首先,这套系统还处于早期探索阶段,谈产品化或开源还太早。那作为读者,能从这篇文章中获得什么?如果你具备编程能力,我希望能向你传递“为自己开发软件,工欲善其事,必先利其器”的信念,这将让个人受益终身。另外,这些瞎折腾本身也是很有意思的,我从中也获取到一些感悟,将他们分享出来。
最后,就像在《Maeiee的自我介绍》中所说:
交友,对我来说,不仅仅是增长见识,更是一种精神上的慰藉。在这个信息爆炸的时代,真正的理解和深入的交流变得尤为珍贵。我相信,每一次的对话和分享,都有可能成为我们认识世界和自我更深一层的契机。
请不要犹豫,如果您对我的任一兴趣点有自己的见解或是有任何问题,都可以通过我的社交媒体账号联系我,或是在我的数字花园留言。我期待着从您那里学习新知识,同时也愿意分享我所了解的信息。在知识的海洋中,我们可以一起航行,探索未知的领域。
您可在《Maeiee首页》中查看我的联系方式。
1 新的浏览器自动化脚本
我将 qutebrowser 视作一个可编程浏览器,与 puppeteer 这种受控浏览器有什么区别呢?区别在于,前者我自己也能日常使用,并且手动与自动可以融合。总体来说,qutebrowser 就像浏览器版本的(低配)Emacs,可定制性超高!
qutebrowser 底层基于 PySide/PyQt,它们在对 WebView 的操作上,是基于异步回调。这种代码写起来乱,读起来也乱,打破了从上到下的先后顺序。写了几个自动化脚本之后,我就受不了了。于是,我我封装了一套流式调用,要好得多,示例代码如下:
OperationLoadUrlAndWait(url=QUrl(f"https://www.example.com"))
.then(OperationScrollTo(times=4, perc=100, timeout=3000))
.then(OperationFetchHtml())
.then(OperationExtractInfos())
.then(OperationScrollTo(times=1, perc=0, timeout=3000))
这段代码,模仿了 Promise 的 then 流式调用。相较于 Puppeteer 的 await...async
还方便性上还是差一些。后者在复杂逻辑上更加边界,比如条件、循环。而我这里,未来只能靠加算子(thenRepeat
, thenIf
)来实现,非常繁琐。不过,我已经满足了,比之前回调满天飞要好太多。
未来还可以添加录制功能,我在 qutebrowser 中演示一遍,然后自动生成上述脚本。
这让我想到 qutebrowser 本身就带有 Macro Mode!可以录制宏,回头研究下这部分功能!
2 自动化脚本应用:自动化搜索
自动化脚本的用途有很多,概括来说,把重复的事情交给机器去做。举一个例子:基于搜索引擎,实现自动化搜索功能。
有什么用呢?列举两例:
- 突然想查一个东西,通过一行命令
search something
即可,节省大量时间 - 对关注的东西,可以定期自动搜索,实现进展跟踪
除了自动化搜索外,有很多应用场景,与 RPA 的理念不谋而合。
3 资讯浏览页卡顿优化
我用 Flutter 开发了一个资讯阅读器(跟 RSS 阅读器差不多)。随着信息的增多,启动时越来越卡。在本节中进行优化。
同步阻塞优化
有一处逻辑:首先拉取所有站点,然后一个一个查询站点未读资讯的数量。(我在 Flutter 中直接访问 MongoDB,省去了后端 API 这一层)。
查询数据库本身是一个异步操作,同时,mongo_dart 也是支持一定的并发量的。但是,看下面这段 Dart 代码:
loadData(List<String> crawlers) async {
for (final crawler in crawlers) {
final count =
await getIt.get<RayInfoApp>().getCrawlerUnreadCount(crawler);
_unreadCountMap[crawler] = count;
notifyListeners();
}
}
有什么问题?把所有源的异步转成了顺序同步,导致没有并发了。假设一个请求 1s,200 个源就是 200s,所以打开耗时才会这么慢。
更好的实现是,利用 async...await
的阻塞释放控制权的机制,允许 N 个并行操作。代码优化如下:
Future<void> loadData(List<String> crawlers, {int concurrency = 10}) async {
// 分批处理爬虫列表
for (var i = 0; i < crawlers.length; i += concurrency) {
// 获取当前批次的爬虫列表
final batch = crawlers.skip(i).take(concurrency);
// 使用 Future.wait 并行处理当前批次
final results = await Future.wait(batch.map(
(crawler) => getIt.get<RayInfoApp>().getCrawlerUnreadCount(crawler)));
// 处理每个结果
int j = 0;
for (final count in results) {
_unreadCountMap[batch.elementAt(j)] = count;
j++;
}
}
// 更新未读消息总数等其他操作
notifyListeners();
}
同时,我在 MongoDB 中添加索引进行加速。这样,在“拉取所有站点,查询站点未读资讯数量”这一步上实现了秒开。
拉取所有未读资讯优化
目前,我有 7w 多条未读资讯。在拉取这个列表时,请求需要好几秒才能返回数据。经过优化后,也实现了秒开。其中,找到最大的性能瓶颈是联表操作没有放到最后。
在拉取未读资讯时,包含两个操作:一个是过滤 isNew=True
,第二个是联表,为资讯拼接上一些信息。这两个操作的先后顺序,耗时差异是巨大的:
- 先联表再过滤:会对所有资讯数据进行一次联表操作,然后在过滤。耗时长,都耗在联表上。
- 先过滤再联表:过滤出实际需要的资讯,针对检索出来的资讯进行联表,耗时大大缩短。
一开始我采用前者,后来改为了后者。同时通过在 MongoDB 中添加索引进行加速。
经过一顿优化之后,最终实现了资讯 App 的完全秒开!
4 Flutter Fluent UI 组件库
今天要坐火车返回北京,路上四、五个小时,得找点事干。我打算研究 fluent_ui | Flutter package,因为我的自用 App 界面实在太丑了。一直想弄却也没时间,趁这个机会,把这个组件库换上。
这个库我之前也有研究过,它的难点主要有两个:
- 仅有一个 Example,缺少入门教程
- Example 中的最佳实践非常出色,但涉及到大量的库,必须掌握这些库,才能全面掌握 fluent_ui
总结来说,问题不出在 fluent_ui,而是自己太菜。正好,趁这个机会,掌握 Flutter 最佳实践。
有两个自学路径,一是把 Example 跑起来,每个展示用例都提供了单一组件如何使用,相对简单。另一条路径是,看 Example 自身实现源码,这就是我说的最佳实践之所在,相对难,但更有价值,我选择这一条。我的目标是:把 Example 中的架构搬运到我的自用 App 中去。
对 fluent_ui 的自学体会,我记录在《Flutter fluent_ui》这篇文章中。
5 在长列表中持久化阅读状态
在资讯列表中,我使用了一个 ListView,列表中每个元素是一个信息卡片,组件为 InfoCard,是一个 StatefulWidget。我在 InfoCard 内存储了一些状态(isNew、isMark、isHub)。
这里的问题是,当我在 InfoCard 内修改状态,比如点击收藏,此时我在 InfoCard 内通过 setState 更新了状态,这样卡片上的 UI 会变化。(但是资讯列表中的 Model 数据并没有变化)。
这样,当这个卡片离开屏幕后(InfoCard 组件会被其它资讯复用),再切回来,StatefulWidget 中本地修改的状态会丢失。
一种成本比较低的方法是,以 mutable 的方式,直接修改 Model。本次我尝试采用这一低成本方案。
6 收藏资讯关联 Obsidian
我在 App 中收藏的资讯,会在 MongoDB 的资讯 Model 中添加一个 mark 标识。但是,我的知识管理系统是 Obsidian,“收藏资讯”在这两个系统间造成割裂。这种割裂,需要我通过手动操作,在 Obsidian 进行添加,操作成本太高,严重影响效率。
于是,我想的是,点击收藏,自动到 Obsidian 中去创建一篇笔记。我在 Obsidian 中已经搭建了一个网页书签库,因此我的资讯软件只需要按照已有的模板、约定,进行添加即可。
自用的资讯系统升级,成功与 Obsidian 打通,在资讯 App 中点击收藏后,将自动在 Obsidian 中创建对应书签!
理想中的资讯目录:
- 600.互联网收藏
- A(以 A 为开头的站点)
- ASite.com/
- ASite.com:站点的笔记
- info/:站点下的内容
- 这里,插入一个 hook,不同站点下,内容可以不同
- 简单站点,比如博客,把文章列在下面
- 每篇文章,以 title 作为目录/
- 以 title 作为笔记:存放笔记的元信息
- 以 title 作为笔记==.html==:未来存放离线 HTML
- 以 title 作为笔记==.md==:存放离线 MD
- images/:存放离线资源
- 每篇文章,以 title 作为目录/
- ASite.com/
- B
- C
- ……
- A(以 A 为开头的站点)
脑中构想点击收藏(未收藏 -> 收藏):
- 创建 site 的笔记
- 是否已有该 site 的笔记,如果没有则创建
- 命名规则是,去掉
www
后的 host,按照首字母划分二级目录 - 注:我会为每个站点创建一个笔记,里面用 DataView 索引站点下所有笔记
- 创建网页的笔记
- 首先看站点,是否有特殊处理 hook
- 如果没有,采用默认策略:
- 在 site 目录下创建,以 title 作为目录
- 以 title 作为笔记:存放笔记的元信息
- 完成,弹窗通知,点击确定后,通过 URL 触发 Obsidian 打开该网页笔记。
- 注:如果网页也已经存在,则弹窗已经存在,问是否需要覆盖,如果需要覆盖,则重走『创建网页的笔记』。
脑中构想点击收藏(收藏 -> 未收藏):
- site 创建后不再删除
- 弹窗问是否删除文件对应目录?
- 点击是,则删除 site 下以 title 作为目录
- 不再校验以 title 作为目录内的内容,一并删除
下面开始正式实现,首先创建一个 ObsidianProvider
,统一提供与 Obsidian 相关的所有功能。之后内部提供两个 Manager:ObsidianSiteManager 和 ObsidianInfoManager。还需要 ObsidianVaultManager、ObsidianTemplateManager 管理与路径相关的内容。
ObsidianSiteManager 功能:首先是对 site host 名称的清理,如果是 www.
开头的,去掉之。接下来是返回站点首字母。获取站点目录。
ObsidianInfoManager 功能:对外 API 是 addInfo
,通过参数传入所有需要的内容(url、description、host、created)。
7 Feed 流卡片布局优化
基于 Flutter fluent_ui 做了一些简单适配。整体跟 Flutter fluent_ui 比较和谐了,甚至还支持了夜间模式。至此,这样简单的适配也足够我用的了,等以后有更多需求,再升级。