四种书签,一条时间线:一个读书 App 的记忆颗粒与它的边界案例【鸿蒙心迹】
「静读」开发心得 · 篇五。篇二把状态图当整体讲,这一篇放大到其中最细的颗粒——书签。为什么是四种类型、数据模型留哪些字段,以及时间线在真实使用里会撞上的三个边界:日期表头的切日、删除之后的计数、筛选的空组合。三个都有实验数字(套件 C)。
摘要
「静读」的书签只有四种类型——划线、想法、疑问、行动,它们不是事后贴的标签,而是检索的索引。本文从数据模型出发,用实验台(套件 C)压测时间线在真实使用中会撞上的三个边界:日期表头按 UTC 切日会让 15 枚凌晨书签全部错挂到"昨天";删除入口的重复提交与不存在 id 会让单独维护的计数与列表长度从第 33 次操作起永久错位;4 类型 × 6 本书的 24 个筛选组合里有 6 个空,而"筛选空"与"无数据空"需要两套完全不同的文案。结论收敛成三条原则:切日规则必须是全局常量,任何能派生的数都不要单独存,每一种空都要先回答"这是哪种空"。
目录
一、四种类型对应四种阅读动作
静读的书签类型只有四种:划线、想法、疑问、行动。
我一开始想做一个通用的"笔记"类型,类型以后再加。拦住我的是一个使用场景的问题:用户什么时候会回来翻书签?考前复习只翻"疑问",写书评只翻"想法",做行动计划只翻"行动"。检索与筛选的意义由类型承载——类型不是事后贴的标签,是索引。四种类型对应阅读的四种动作:记录(这句好)、解读(我当时怎么想)、质疑(没读懂,留个问题)、行动(可以变成待办)。
字段也随之定下来:划线的价值在原文,想法/疑问/行动的价值在用户内容。所以模型同时留原文与用户内容:
Bookmark {
id, bookId, chapter,
type: 划线 | 想法 | 疑问 | 行动,
quote: string, // 原文(划线类型里 quote 即主体)
note: string, // 用户内容(划线类型可空)
ts: epoch ms, // 记录时刻
seq: 自增 // ts 同秒并列时的确定性序键
}
两个字段值得解释。ts 只记时刻,展示与分组一律走 +08:00——这是篇三柱状图切日的同一条规则,本篇实验一会给出不遵守的后果。seq 是为确定性:两枚书签 ts 同秒并列时,顺序不能赌排序实现的心情,显式序键永远比隐式的稳定性承诺可靠。
二、实验一:日期表头也有时区问题

时间线按日期分组、倒序排列。实验台上我把 120 枚演示书签,其中 15 枚落在 +08:00 的 00:00–08:00 窗口(30 天 × 每天 4 枚),过两种分组实现:
- 按 +08:00 切日:30 个日期表头,每枚书签挂在它"感觉上"的那一天;
- 按 UTC 切日:15 枚全部挂到前一天的日期表头,时间线顶端还会多出一个"昨天"——表头数 30 → 31。
凌晨 0 点半划的线,用户第二天打开时间线,发现它躺在"昨天"——而他明明记得是"今天"划的。篇三修的是柱状图的切日,这一篇修的是日期表头的切日,两个补丁共用同一个常量。切日规则必须是全局常量,而不是逐屏补丁:柱状图修好了、表头还在说谎,等于没修。
把规则落成代码,只有一个常量、一个函数:
// 全局唯一的切日规则:所有"按天"的语义都走这里
export const DAY_CUTOFF_OFFSET = '+08:00';
/**
* 把时间戳换算成"用户感觉上的那一天"的 key,形如 2026-10-25
* 关键:先按 +08:00 平移,再取 UTC 日期部分,避免本地时区参与运算
*/
export function getDayKey(ts: number): string {
const shifted = new Date(ts + offsetMs(DAY_CUTOFF_OFFSET));
return shifted.toISOString().slice(0, 10);
}
function offsetMs(offset: string): number {
const sign = offset.startsWith('-') ? -1 : 1;
const [h, m] = offset.slice(1).split(':').map(Number);
return sign * (h * 60 + m) * 60 * 1000;
}
三处"按天"的语义共用同一个函数,谁都不许自己写 toLocaleDateString():
// 1) 柱状图:按天聚合阅读量
const dailyCount = new Map<string, number>();
for (const b of bookmarks) {
const key = getDayKey(b.ts);
dailyCount.set(key, (dailyCount.get(key) ?? 0) + 1);
}
// 2) 时间线:按天分组、倒序排列
const groups = new Map<string, Bookmark[]>();
for (const b of bookmarks) {
const key = getDayKey(b.ts);
(groups.get(key) ?? groups.set(key, []).get(key)!).push(b);
}
const timeline = [...groups.entries()].sort((a, b) => (a[0] < b[0] ? 1 : -1));
// 3) 连续阅读天数:相邻两天的 key 必须能"接上"
const days = [...new Set(bookmarks.map((b) => getDayKey(b.ts)))].sort();
let streak = days.length ? 1 : 0;
for (let i = 1; i < days.length; i++) {
if (isNextDay(days[i - 1], days[i])) streak++;
else streak = 1;
}
两种实现在这 15 枚凌晨书签上的表现,可以直接摆成一张表:
| 对比项 | 按 +08:00 切日 | 按 UTC 切日 |
|---|---|---|
| 15 枚凌晨书签的挂载日期 | 挂在用户"感觉上"的那一天(当天) | 全部挂到前一天的日期表头 |
| 日期表头数量 | 30 个 | 30 → 31,顶端多出一个"昨天" |
| 用户感知 | 凌晨划的线,第二天打开就在"今天" | 明明记得是"今天"划的,却躺在"昨天" |
| 与柱状图/连续天数是否一致 | 一致(共用同一常量) | 不一致,同一枚书签在不同屏落在不同天 |
为什么按 UTC 切日会错挂 15 枚?
+08:00的 00:00–08:00 这 8 小时,在 UTC 里还停留在前一天的 16:00–24:00。若直接用new Date(ts).toISOString().slice(0,10)取日期,这 15 枚凌晨书签的 key 全部落到前一天,于是它们被挂到前一天的日期表头,时间线顶端还凭空多出一个"昨天"(表头 30 → 31)。getDayKey先平移 8 小时再取日期,等于把"用户感觉上的那一天"作为唯一口径——柱状图、日期表头、连续天数三处因此永远对得上。
三、实验二:删除之后,计数与列表必须还是同一个数
统计卡上的"书签记录 N 条"与时间线长度是同一个数的两个投影——这是篇二的结论。但删除是最容易打破这个结论的操作,因为真实的删除入口带着脏操作:连点太快产生重复提交,两台设备各删一次产生不存在的 id。
实验台跑 40 次删除操作(32 合法 + 5 重复提交 + 3 不存在 id),对比两种实现:
- 朴素实现:计数单独存、每删一次减一。结果计数 80、列表长度 88、差 8;且从第 33 次操作起(第一次重复提交),两个数就再也没对上过。
- 派生实现:计数由列表长度按需计算。全程差 0。

书库卡片上的"N 条"书签数,是这个数的第三个投影。存一个数单独维护,就要维护三处一致;改成派生,三处永远不会不一致。任何能派生的数都不要单独存——删除只是把这条原则压测了一遍。
四、实验三:筛选空 ≠ 无数据空
时间线支持按类型、按书筛选。4 类型 × 6 本书枚举 24 个组合,6 个为空——演示数据里每本书只产生 4 类中的 3 类(贴近真实:不是每本书都会留下"行动"书签)。
空组合的意义在文案。产品里有两种空,含义完全不同:
| 无数据空 | 筛选空 | |
|---|---|---|
| 含义 | 还没产生过 | 有数据,但不在这个筛选下 |
| 文案任务 | 教学 + 送回场景(篇四) | 告诉用户"换筛选" |
| 例子 | “阅读时选中正文,就能把打动你的句子留在这里。” + 去读第一章 | “这本书还没有’行动’书签,换个类型或换本书看看” |
把无数据文案用在筛选空上,会误导用户去产生一份已经存在的数据(他跑去划新线,其实切到"想法"就有一屏);把筛选文案用在无数据空上,则浪费唯一一次教学机会。6 个空组合不是 6 个 bug,是 6 次"这是哪种空"的回答。
五、清单
- 切日做成全局常量:柱状图、日期表头、连续天数判定共用同一条 +08:00 规则;
- ts 配一个确定性 seq,同秒顺序不赌排序稳定性;
- 计数一律派生;删除入口做幂等(重复提交、不存在 id 不改变状态);
- 枚举筛选组合,为每个空组合写文案:筛选空说"换筛选",无数据空说"怎么产生";
- 新增同一个数的投影位(比如未来的桌面卡片)之前,先问一句:它是派生的还是存的?
六、总结
三个实验其实在回答同一个问题:一个"可信的记录"要付出什么代价。答案不是更多功能,而是三处容易被忽略的纪律。
| 边界 | 症状 | 根因 | 修法 |
|---|---|---|---|
| 日期表头切日 | 15 枚凌晨书签挂到"昨天",表头 30 → 31 | 切日规则散落在各屏,UTC 与 +08:00 混用 | 切日抽成全局常量,柱状图、表头、连续天数共用 |
| 删除后计数 | 计数 80、列表 88,第 33 次操作起永久错位 | 计数单独存,重复提交与不存在 id 破坏一致性 | 计数一律派生,删除入口做幂等 |
| 筛选空组合 | 6 个空组合被当成 6 个 bug | 两种空共用一套文案 | 枚举组合,筛选空说"换筛选",无数据空说"怎么产生" |
三条原则可以带走:
- 规则要全局,不要逐屏。柱状图修好了、表头还在说谎,等于没修;同一个常量被三处引用,才叫修好。
- 能派生的数不要存。存一个数就要维护它的每一处投影,派生则天然一致;新增投影位之前先问一句"它是派生的还是存的"。
- 空状态是产品语义,不是兜底。空组合的数量是有限的,为每一种空写对文案,比加一个通用空态图更省事也更有效。
书签是静读记忆里最细的颗粒,颗粒的边界最诚实——它不会因为功能变多而变宽容,只会因为纪律到位而变可靠。
结语
书签是静读记忆里最细的颗粒,而颗粒的边界最诚实:切日考验你的时区,删除考验你的一致性,空组合考验你的文案。把这三个边界处理完,时间线才配得上"可信的记录"四个字。
系列用到的 5 张截图,在篇一至篇五里全部出场。下一篇是收束篇:《实验台先于正文:「静读」系列心得的交付流水线与三层守卫》——不用截图,拆生产这五篇的那条流水线本身。
#鸿蒙 #HarmonyOS #ArkUI #数据模型 #开发心得
更多推荐



所有评论(0)