一个状态,五个投影:我把读书 App 的“阅读记忆“建成了一张状态图【鸿蒙心迹】
「静读」开发心得 · 篇二。上一篇讲产品判断(价值在合上书之后),这一篇讲工程:书房、书库、统计、书签时间线、成长这五个界面,其实是同一份状态的五个投影。我用两个小实验(投影一致性、崩溃点枚举)量化了"各页自存副本"到底会在什么时候撒谎。
引子:一个"数据有点旧"的 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 / 20 | 0 / 300 |
注意 8% 不是恶劣条件,是一个"还算靠谱"的通知通道。即便如此,仍有六成半的运行会在某个界面上留下脏数据——我那个"书房显示 0 条书签"的 bug 不是意外,是这套实现的期望行为。
而单一数据源的 0 失败,不是因为我写得仔细,是因为投影按需从 S 计算,数学上没有留下分叉的位置。对应到 ArkUI:页内瞬态用 @State,跨页共享用 @Provide + @Consume 或 AppStorage,UI 永远是状态的函数——"改了就该重算"由框架保证,一行通知胶水都不用写。
三、实验二:崩溃点枚举,分两次落盘 vs 事务落盘
第二个坑在持久化。书签与进度之间有一条不变式:
存在第 c 章的书签 ⇒ 已落盘进度 ≥ c。(不可能在没读到的章里划线)
朴素实现每枚书签写两次:先 flush 书签、再 flush 进度。崩溃(杀进程 / 掉电)可能落在任何一步之后。我对 40 个会话 × 每会话 3 枚书签枚举了全部崩溃点:
| 落盘方式 | 崩溃点总数 | 违反不变式 | 比例 |
|---|---|---|---|
| 分两次 flush | 280 | 120 | 42.9% |
| 同一次事务 flush | 160 | 0 | 0% |
分两次落盘时,近半数崩溃点会留下"书签在第 3 章、进度停在第 1 章"的脏状态——下次打开,“继续阅读"会把用户送到书签之前的位置,比丢数据更 confusing。改成书签与进度同一次落盘(relationalStore 事务,或合并进同一写批次)后,崩溃要么"全没写"要么"全写了”,不变式恒成立。
上一篇我把这条总结为"进度与书签要讲事务性",当时是定性复盘;这一次是定量的:分两次写的脏状态概率不是边角案例,是 42.9% 的崩溃点。
四、落到 ArkUI:状态分三层,别全塞 @State
统一数据源之后是放哪里的问题。我的分层:
| 层 | 内容 | HarmonyOS 落点 |
|---|---|---|
| UI 瞬态 | 弹窗开合、筛选页签、输入草稿 | 页内 @State |
| 会话共享 | 当前书、当前章、计时中的时长 | @Provide + @Consume / AppStorage |
| 持久态 | books / bookmarks / habit | relationalStore(结构化、要查询排序)+ Preferences(设置、打卡日期等轻键值) |
两条原则:
- 能算出来的就不要存。"全书 %"由章节与偏移算、"常读方向"由 books 的分类聚合——存下来就是第二个需要同步的数据源,实验一的分叉就回来了。
- **持久层只有一个写入口。**所有变更走同一个 store 模块(实验二的事务落盘就住在里面),页面只读投影、只提交意图,不直接碰数据库。
五、清单(可带走)
- 把每个界面的每个字段列出来,标注它读的是状态图哪个字段;标不出来的字段,就是状态图还缺的东西;
- 界面之间不允许"副本 + 通知",一律改成按需计算的投影;
- 写出跨字段不变式(如书签章节 ≤ 进度),在集成测试里断言它;
- 对有不变式关系的写入做崩溃点枚举:问一句"写在中间崩了会怎样";
- 能算的不入库,入库的只走唯一写入口。
结语
上一篇说"价值在合上书之后",这一篇是那个判断的工程面:所谓记忆层,技术上就是一张小状态图加五个纯函数投影。胶水代码删掉之后,bug 不是被修好的,是消失的——很多"难查的 bug",本质是为多份数据源支付的架构税。
数据与复现:lab/experiments.mjs(Node v22,确定性模拟:崩溃点全枚举 + 事件流投影检查),结果见 lab/results.json。
下一篇:《诚实的阅读计时器:切日、后台与连续天数这三处说谎》——状态图三个字段里,最容易撒谎的是时长。
#鸿蒙 #HarmonyOS #ArkUI #状态管理 #开发心得
更多推荐


所有评论(0)