「静读」开发心得 · 篇二。上一篇讲产品判断(价值在合上书之后),这一篇讲工程:书房、书库、统计、书签时间线、成长这五个界面,其实是同一份状态的五个投影。我用两个小实验(投影一致性、崩溃点枚举)量化了"各页自存副本"到底会在什么时候撒谎。

引子:一个"数据有点旧"的 bug

做书签功能那晚我遇到一个不报错的 bug:在阅读器里划了一条线,回到书房,继续阅读卡片上还写着"已记录 0 条书签";下拉刷新,变成 1 了。

没有异常、没有崩溃,只是"数据有点旧"。这类 bug 最阴:用户不反馈你永远不知道,而它侵蚀的是读书 App 最值钱的东西——记忆层的可信度。书签数在书房是一个数、在时间线是另一个数,用户看到的那一刻,"这个 App 记得我读过什么"的承诺就破了一次。

排查结论很普通:书房页自己存了一份书签数副本,靠"通知"同步,而我漏订阅了一个事件。但我不想用"下次细心点"来解决它。我把它升级成一个架构问题:

这五个界面,到底是几份数据?

一、五个界面,其实是同一份状态

把四张截图摆在一起看,答案自己浮出来了——它们显示的是同一组数字,只是切的角度不同:
请添加图片描述
请添加图片描述
请添加图片描述请添加图片描述
请添加图片描述

“第 1 章”“0 条书签”“0 分钟”“在读 1 本”——同一个事实,在书房是进度、在书库是卡片角标、在统计是卡片、在成长是聚合。所以状态只有一张小图:

ReadingMemory {
  books[]:     { chapter, pct, minutes, shelf }      // 进度与书架
  bookmarks[]: { bookId, chapter, type, text, ts }   // 划线/想法/疑问/行动
  habit:       { streakDays, lastActiveDate, minutesByDay[7] }
}

五个界面全是这张图的投影:渲染时按需计算的纯函数,而不是需要"同步"的副本。这是全文最重要的一句话:

投影是算出来的,副本是同步出来的;凡是需要同步的,终将不同步。

二、实验一:副本实现到底有多容易分叉

光讲道理不够,我把两种实现写成可测纯函数,在边界案例实验台(lab/experiments.mjs)上跑了一遍:

  • 单一数据源:5 个投影在渲染时从状态 S 按需计算;
  • 副本实现:每页各存一份副本,靠"通知"同步,每次通知有 8% 概率丢失(模拟漏订阅、时序错位、页面重建销毁等真实原因)。

20 个随机种子、每个跑 200 个事件(加书签 / 推进度 / 加时长),结束后逐字段检查"每页副本 == 源状态",共 300 项检查:

实现出现分叉的运行字段检查失败
副本 + 通知13 / 20(65%)22 / 300(7.3%)
单一数据源0 / 200 / 300

注意 8% 不是恶劣条件,是一个"还算靠谱"的通知通道。即便如此,仍有六成半的运行会在某个界面上留下脏数据——我那个"书房显示 0 条书签"的 bug 不是意外,是这套实现的期望行为。

而单一数据源的 0 失败,不是因为我写得仔细,是因为投影按需从 S 计算,数学上没有留下分叉的位置。对应到 ArkUI:页内瞬态用 @State,跨页共享用 @Provide + @Consume 或 AppStorage,UI 永远是状态的函数——"改了就该重算"由框架保证,一行通知胶水都不用写。

三、实验二:崩溃点枚举,分两次落盘 vs 事务落盘

第二个坑在持久化。书签与进度之间有一条不变式:

存在第 c 章的书签 ⇒ 已落盘进度 ≥ c。(不可能在没读到的章里划线)

朴素实现每枚书签写两次:先 flush 书签、再 flush 进度。崩溃(杀进程 / 掉电)可能落在任何一步之后。我对 40 个会话 × 每会话 3 枚书签枚举了全部崩溃点:

落盘方式崩溃点总数违反不变式比例
分两次 flush28012042.9%
同一次事务 flush16000%

分两次落盘时,近半数崩溃点会留下"书签在第 3 章、进度停在第 1 章"的脏状态——下次打开,“继续阅读"会把用户送到书签之前的位置,比丢数据更 confusing。改成书签与进度同一次落盘(relationalStore 事务,或合并进同一写批次)后,崩溃要么"全没写"要么"全写了”,不变式恒成立。

上一篇我把这条总结为"进度与书签要讲事务性",当时是定性复盘;这一次是定量的:分两次写的脏状态概率不是边角案例,是 42.9% 的崩溃点。

四、落到 ArkUI:状态分三层,别全塞 @State

统一数据源之后是放哪里的问题。我的分层:

层内容HarmonyOS 落点
UI 瞬态弹窗开合、筛选页签、输入草稿页内 @State
会话共享当前书、当前章、计时中的时长@Provide + @Consume / AppStorage
持久态books / bookmarks / habitrelationalStore(结构化、要查询排序)+ Preferences(设置、打卡日期等轻键值)

两条原则:

  1. 能算出来的就不要存。"全书 %"由章节与偏移算、"常读方向"由 books 的分类聚合——存下来就是第二个需要同步的数据源,实验一的分叉就回来了。
  2. **持久层只有一个写入口。**所有变更走同一个 store 模块(实验二的事务落盘就住在里面),页面只读投影、只提交意图,不直接碰数据库。

五、清单(可带走)

  • 把每个界面的每个字段列出来,标注它读的是状态图哪个字段;标不出来的字段,就是状态图还缺的东西;
  • 界面之间不允许"副本 + 通知",一律改成按需计算的投影;
  • 写出跨字段不变式(如书签章节 ≤ 进度),在集成测试里断言它;
  • 对有不变式关系的写入做崩溃点枚举:问一句"写在中间崩了会怎样";
  • 能算的不入库,入库的只走唯一写入口。

结语

上一篇说"价值在合上书之后",这一篇是那个判断的工程面:所谓记忆层,技术上就是一张小状态图加五个纯函数投影。胶水代码删掉之后,bug 不是被修好的,是消失的——很多"难查的 bug",本质是为多份数据源支付的架构税。

数据与复现:lab/experiments.mjs(Node v22,确定性模拟:崩溃点全枚举 + 事件流投影检查),结果见 lab/results.json。

下一篇:《诚实的阅读计时器:切日、后台与连续天数这三处说谎》——状态图三个字段里,最容易撒谎的是时长。


#鸿蒙 #HarmonyOS #ArkUI #状态管理 #开发心得

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐