差之毫厘谬以千里——进度锚点为什么用绝对下标
旦阅诞生记 · 10。第三辑开始,我们讲进度同步协议。协议的第一课不是握手也不是冲突裁决,而是一个更基本的问题:"读到哪儿"这个位置,用什么坐标来描述? 坐标选错了,后面所有同步逻辑都在错误的地图上运转。
一个看似没问题的方案
最初的设计很直觉:进度就是"第几章、章内第几个字"。
chapterIndex: 473 // 第 474 章
charOffset: 904 // 章内第 905 个字符
本地使用完全没问题——章节表是自己解析的,章内偏移自己算的,自己和自己永远一致。第一次跨端同步时它也"看起来能用":两端书一样、章节都能解析出来,位置换算一下似乎就对了。
崩塌发生在解析器进化之后。
为什么章内偏移是脆弱坐标
"章内第 905 个字符"这句话隐含了一个前提:"章"的定义在所有端、所有时刻完全一致。
但章节不是文件的固有属性,是解析器算出来的。旦阅的章节切分靠一条正则加三条启发式(整行匹配、120 字合并、900 字结构判定),任何一个细节的调整——新匹配上了一种章节标题写法、合并阈值从 120 改成 110——都会让同一本书切出不同的章节表。而解析器是一定会进化的:用户拿一本没切对章节的书来报 bug,你修了规则、升了 ParserVersion,这是产品的正常迭代。
章节表一变,连锁反应是:
- 章节总数变了,
chapterIndex = 473指向的可能是完全不同的一章; - 前面章节的合并、拆分改变了这一章的开头位置,章内偏移
904的参照系整体漂移; - 每一点误差都会在换算时被放大——本来只想说"这章里靠后一点的地方",落到另一端的章节表里,可能恰好跨过章边界,直接跳进下一章。
这就是标题那句话的由来:坐标差之毫厘,阅读位置谬以千里。更糟的是这种错位静默且随机——多数情况下它看起来是对的(章节表没变的书),于是 bug 只在一部分书上出现,成了最难缠的那类"偶尔复现"。
问题不在实现,在坐标本身:章内偏移是一个"相对于解析结果"的坐标,而同步需要的是"相对于正文"的坐标。
换坐标:全书绝对字符下标
思路的转折来自第二辑打下的地基。第四篇讲过一个铁律:BookKey 相同 ⇒ 两端正文逐字符完全一致。既然整本书在每个端都是同一串字符,那什么坐标是它天然的属性?——字符的下标本身。
"全书第 3 572 118 个字符"不依赖章节怎么切、屏幕多大、字号几号。章节表可以重算,排版可以重排,这串字符的顺序不会变。于是同步协议里,权威位置改成了:
charOffset: 3572118 // 全书绝对字符下标(权威)
它带来的第一层好处是精确:只要 BookKey 一致,charOffset 在两端指向物理上同一个字符,误差为零。第二层好处更隐蔽——它与解析器彻底解耦。解析器升级、章节重扫、排版重排,都不动这个数字;ParserVersion 从同步协议里退化为一个"识别错位"的诊断字段,而不再是定位依赖。
绝对下标不是扔掉章内偏移,是给它降级
读者在任何一端的界面上,看到的仍然是"第 474 章、本章第 905 字符"——章节是阅读体验的单位,不能因为同步方便就把它扔了。正确的结构是分层:
/// <summary>当前页第一行的本章字符偏移。</summary>
public int CurrentCharOffset()
{
if (_layout is null || _layout.Lines.Count == 0) return 0;
var idx = Math.Clamp(FirstLineOfPage, 0, _layout.Lines.Count - 1);
return _layout.Lines[idx].SourceStart;
}
/// <summary>当前页第一行的全书绝对字符下标。</summary>
public long CurrentAbsOffset()
{
var ch = _session?.ChapterAt(_chapterIndex);
return (ch?.Start ?? 0) + CurrentCharOffset(); // 章起始绝对下标 + 章内偏移
}
上行是本地坐标(服务于排版和界面),下行换算成绝对坐标(服务于同步)。章内偏移没有消失,它从"权威"降级成了"本地表示";chapter.Start 这个桥梁,正是第六篇讲章节索引时埋的伏笔——那份索引里每个章节的 Start 存的就是绝对下标,换算因此只是一次加法。
反方向也一样:拿到云端下发的 charOffset,在章节表上二分一次就能落回"第几章",再减去 chapter.Start 得到章内位置。两个方向都是 O(log n) 级别的便宜操作。
一张网兜住所有意外
绝对下标是权威,但同步协议不能只有权威——总有一些场景让权威失效:
| 优先级 | 坐标 | 什么时候起作用 |
|---|---|---|
| 1 | charOffset(绝对下标) |
BookKey 一致时的精确位置 |
| 2 | progress(0~1 比例) |
正文有细微差异 / 重排,绝对下标失准 |
| 3 | chapterTitle + chapterOffset |
两端章节表不一致,按名字重新对齐 |
| 4 | chapterIndex |
最后手段,基本只作展示 |
第二优先级的 progress 值得一提:它是绝对下标的"模糊版"——位置除以全书总字符数。当精校版和原版这类"同一本书的不同版本"需要互相兜底时(BookKey 不同、绝对下标无意义),比例是唯一还能讲道理的坐标。第三优先级的章节名对齐则是给"两端解析器差了几个大版本"的历史包袱准备的:名字是最抗解析规则变化的人类可读锚点。
这套四级兜底的设计哲学和整体系列一脉相承:权威路径必须精确,兜底路径必须存在。前者保证 99% 的同步体验,后者保证剩下 1% 不至于把进度弄丢。
收尾
把这篇压缩成三句话:
- 同步协议里的坐标必须是"相对内容"的,不能是"相对某个解析结果"的。 解析结果会进化,内容(在 BookKey 一致的前提下)不会。
- 本地表示和同步坐标可以不同,但要有一行加法那么简单的换算桥。 桥太复杂,说明坐标选错了。
- 权威之下永远垫着兜底。 精确坐标失效的场景不是异常,是产品的一部分——版本差异、重排、历史包袱都会造出这种场景。
下一篇讲这条协议上最激烈的部分:两台设备同时改了进度,听谁的——从"后写覆盖"到三层裁决。