「静读」开发心得 · 篇五。篇二把状态图当整体讲,这一篇放大到其中最细的颗粒——书签。为什么是四种类型、数据模型留哪些字段,以及时间线在真实使用里会撞上的三个边界:日期表头的切日、删除之后的计数、筛选的空组合。三个都有实验数字(套件 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两种空共用一套文案枚举组合,筛选空说"换筛选",无数据空说"怎么产生"

三条原则可以带走:

  1. 规则要全局,不要逐屏。柱状图修好了、表头还在说谎,等于没修;同一个常量被三处引用,才叫修好。
  2. 能派生的数不要存。存一个数就要维护它的每一处投影,派生则天然一致;新增投影位之前先问一句"它是派生的还是存的"。
  3. 空状态是产品语义,不是兜底。空组合的数量是有限的,为每一种空写对文案,比加一个通用空态图更省事也更有效。

书签是静读记忆里最细的颗粒,颗粒的边界最诚实——它不会因为功能变多而变宽容,只会因为纪律到位而变可靠。

结语

书签是静读记忆里最细的颗粒,而颗粒的边界最诚实:切日考验你的时区,删除考验你的一致性,空组合考验你的文案。把这三个边界处理完,时间线才配得上"可信的记录"四个字。

系列用到的 5 张截图,在篇一至篇五里全部出场。下一篇是收束篇:《实验台先于正文:「静读」系列心得的交付流水线与三层守卫》——不用截图,拆生产这五篇的那条流水线本身。


#鸿蒙 #HarmonyOS #ArkUI #数据模型 #开发心得

Logo

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

更多推荐