先从一个具体场景说起。
你在手机上读到某本小说的第 4743 章、第 904 个字,合上手机,打开电脑。电脑上也有同一本书的 TXT,你希望打开时落在哪里?
答案只能是:第 4743 章、第 904 个字。不多不少。
这个需求看上去很简单,但它牵出一串问题:
- 电脑怎么知道这本书,和手机上那本是"同一个"?
- 如果两端的 TXT 来自不同网站,编码、行尾、BOM 都不一样,还认得出来吗?
- 要是两端的章节切分规则有细微差别,位置还能对得准吗?
旦阅的三端架构,本质上就是在回答这几个问题。而答案最后收敛成一条原则:三端必须跑同一套算法。
一、三端分工
| 端 | 技术栈 | 职责 |
|---|---|---|
安卓端 eggBookApp-wb |
uni-app x 蒸汽模式 + UTS 原生插件 | 书架、分页排版、翻页动画、听书;导入时计算 BookKey |
桌面端 eggBookWin-wb |
WPF(.NET 10) | 同一套阅读体验的桌面实现 |
服务端 eggBookServer-wb |
ASP.NET Core + EF Core + SQLite | 只记录"谁读到哪儿",不保存任何正文 |
架构上有一条硬规则:正文永远不出设备。
这条规则把所有压力都推到了"身份"和"位置"两个概念上——只要两端能认出"这是同一本书",并且对"读到哪个字符"有一致理解,同步就成立了。而一旦这个前提不牢,同步失效的方式是静默的:没有报错,没有异常,只是两台设备各读各的,进度永远对不上。这比崩溃难查得多。
所以"三端一套算法"不是洁癖,是这条架构的生存条件。
二、共享算法清单
真正跨端共享的算法有这样几项,每一项都有两种以上实现,但只有一套口径:
| 算法 | 安卓端 | 桌面端 | 一致性保障 |
|---|---|---|---|
| 编码探测 | UTS 插件 detectEncodingBySample |
EncodingDetector.cs |
同一套判据 + 同样 4KB 采样长度 |
| 章节切分 | api/chapter-parser.uts |
ChapterParser.cs |
同一条正则 + 交叉锁定测试 |
| BookKey 归一化 | UTS 插件(按行归一化) | BookKeyUtil.cs(流式字节) |
独立参照实现 + 等价性单测 |
| 章节索引扫描 | UTS 插件 scanChapterMarks |
ChapterIndexer.cs |
C# 复刻版逐条比对 |
| 简繁转换 | ScriptConverter |
ScriptConverter.cs |
同一张映射表 |
| 展示工具 | common/utils.uts |
TextFormat.cs |
同口径(书名解析、封面色) |
下面挑三个最有代表性的,看具体是怎么做到"逐字节一致"的。
三、章节切分:两端竟然是同一条正则
这是最直白、也最容易被忽略的一项。安卓端的规则长这样(api/chapter-parser.uts):
/** 章节标题:整行匹配,行首允许少量空白 */
const RE_CHAPTER = /^\s*(?:序章|序言|楔子|引子|前言|后记|尾声|终章|尾章|番外|外传|作品相关|正文|大结局|Chapter\s*[0-9IVXLCivxlc]{1,6}[^\n]{0,30}|CHAPTER\s*[0-9IVXLCivxlc]{1,6}[^\n]{0,30}|第[0-9零一二三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟两]{1,12}[章节回卷幕篇集部][^\n]{0,40}|[0-9]{1,4}[、..][^\n]{0,40})\s*$/
/** 单行最长 50 字,超过就不可能是标题 */
const MAX_TITLE_LINE = 50
/** 无章节时每节字符数 */
const FALLBACK_SIZE = 4000
桌面端的 ChapterParser.cs:
private static readonly Regex ReChapter = new(
@"^\s*(?:序章|序言|楔子|引子|前言|后记|尾声|终章|尾章|番外|外传|作品相关|正文|大结局|" +
@"Chapter\s*[0-9IVXLCivxlc]{1,6}[^\n]{0,30}|CHAPTER\s*[0-9IVXLCivxlc]{1,6}[^\n]{0,30}|" +
@"第[0-9零一二三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟两]{1,12}[章节回卷幕篇集部][^\n]{0,40}|" +
@"[0-9]{1,4}[、..][^\n]{0,40})\s*$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
public const int MaxTitleLine = 50;
public const int FallbackSize = 4000;
逐字符一致——连"壹贰叁肆伍陆柒捌玖拾佰仟两"这串大写数字都一个不差。这不是巧合,是刻意的:只要有一条规则不同,同一本书在两端就会被切成不同的章节数,chapterIndex 立刻失去意义。
规则里还有三个不那么显眼、但都是踩出来的设计:
① 只认"整行标题"。 正则两端锚定 ^ 和 $,所以正文里出现的"第三十二章"这种句子不会被误判。配合 50 字上限——一行超过 50 字,就不可能是标题。
② 过近的候选要合并。 正文里难免有漏网的短行:
/** 过近的候选合并(避免正文里的误命中把一章切碎) */
function filterMarks(marks : Chapter[]) : Chapter[] {
const out : Chapter[] = []
for (let i = 0; i < marks.length; i++) {
const m = marks[i]
if (out.length > 0) {
const prev = out[out.length - 1]
if (m.start - prev.start < 120) continue // 相邻候选间隔 < 120 字 → 丢弃后者
}
out.push(m)
}
return out
}
③ 判定"这本书有没有章节结构"。 有些 TXT 是整本无章节的:
// 章节太少或平均每章太短,说明这本书没有章节结构
if (marks.length < 2 || (marks.length < 3 && len / marks.length < 900)) {
return splitByLength(text) // 兜底:按 4000 字一节,并尽量对齐到换行
}
少于 2 个候选、或者平均每章不到 900 字,就干脆按固定字数切。兜底路径同样要两端一致——所以 splitByLength 也是对照着实现的,包括"向后最多找 300 字寻找换行"这个细节。
四、编码探测:为什么"能解码"不等于"解码对了"
中文 TXT 的编码是一笔糊涂账。UTF-8、UTF-8 带 BOM、UTF-16LE/BE、GBK、GB2312、GB18030……下载来源不同,什么都可能遇到。
一个常见但危险的做法是:试着重解码一遍,不抛异常就当成 UTF-8。问题是GBK 文本大多也能被"解码"成 UTF-8——只是解出来一片乱码。而且"不抛异常"这件事本身,在不同语言、不同实现里口径还不一样。
所以两端用的是同一套四步判据(EncodingDetector.cs / UTS 插件 detectEncodingBySample):
① 前 3 字节 BOM → UTF-8 / UTF-16LE / UTF-16BE
② 零字节占比 > 25% → 无 BOM 的 UTF-16(再看奇偶位定 LE/BE)
③ 严格 UTF-8 合法性校验(逐字节验证续字节范围)
④ 都不成立 → GB18030(GBK/GB2312 的超集,兜底最稳)
第 ③ 步是关键。它不是"试着解码",而是按 UTF-8 的编码规范逐字节校验:
public static bool IsStrictUtf8(ReadOnlySpan<byte> bytes)
{
var i = 0;
while (i < bytes.Length)
{
var b = bytes[i];
if (b < 0x80) { i++; continue; }
int extra;
byte lo;
byte hi;
// 首字节决定"后面跟几个字节"以及"第一个续字节的合法范围"
if (b is >= 0xC2 and <= 0xDF) { extra = 1; lo = 0x80; hi = 0xBF; }
else if (b == 0xE0) { extra = 2; lo = 0xA0; hi = 0xBF; }
else if (b is >= 0xE1 and <= 0xEC) { extra = 2; lo = 0x80; hi = 0xBF; }
else if (b == 0xED) { extra = 2; lo = 0x80; hi = 0x9F; } // 挡住 UTF-16 代理区
else if (b is >= 0xEE and <= 0xEF) { extra = 2; lo = 0x80; hi = 0xBF; }
else if (b == 0xF0) { extra = 3; lo = 0x90; hi = 0xBF; }
else if (b is >= 0xF1 and <= 0xF3) { extra = 3; lo = 0x80; hi = 0xBF; }
else if (b == 0xF4) { extra = 3; lo = 0x80; hi = 0x8F; }
else return false; // 0x80~0xC1、0xF5~0xFF 在 UTF-8 里非法
if (i + extra >= bytes.Length) return true; // 采样末尾被截断:放过
var first = bytes[i + 1];
if (first < lo || first > hi) return false;
for (var k = 2; k <= extra; k++)
{
var c = bytes[i + k];
if (c < 0x80 || c > 0xBF) return false;
}
i += extra + 1;
}
return true;
}
这段代码里有三个细节值得单独说:
0xC0、0xC1被判为非法:它们是过度编码(overlong encoding)的首字节,能编码出本应只用 1 字节表示的字符,属于安全漏洞级别的问题,正规编码器不会产生。0xE0和0xED的续字节范围被单独收紧:前者排除过度编码,后者排除 UTF-16 代理区(U+D800~U+DFFF)——这些码点不允许出现在合法的 UTF-8 里。- 采样末尾的截断序列视为合法:4KB 采样几乎必然把一个多字节汉字拦腰截断,如果严格报错,好的 UTF-8 文件反而会被误判成 GBK。这里选择"放过"。
判据只是第一步。真正让同步稳定的是下一步:导入时按探测结果读成文本,统一转存为 UTF-8。此后所有读写都基于这份归一化的 UTF-8 副本,源文件被移动、删除、改名都不再影响阅读。编码探测因此只在导入路径上出现一次。
五、BookKey:同一份口径,两种实现路径
这是整套架构的地基。
最初的设计是"对 txt 文件求 MD5",方向对,但直接哈希原始字节有两个坑:
| 差异来源 | 例子 |
|---|---|
| 编码 | 来源 A 是 UTF-8,来源 B 是 GBK |
| 行尾 | 来源 A 是 CRLF(Windows),来源 B 是 LF |
| BOM | 有的文件开头带 EF BB BF,有的没有 |
字节不同 → MD5 不同 → 明明是同一本书,两台设备却认为是两本,进度永远同步不上。
5.1 归一化口径(三端必须一字不差)
1. 去掉开头的 BOM(U+FEFF)
2. 行尾统一为 \n(先 \r\n → \n,再 \r → \n)
3. 去掉首尾的 ASCII 空白与换行(空格 / 制表 / 换行;不含全角空格)
4. 编码为 UTF-8(无 BOM),求 MD5 → 小写十六进制
产物形如 md5:8f14e45fceea167a5a36dedd4bea2543。
小文件(< 5MB)、粘贴导入、单元测试走整串路径,实现简单直白:
public static string Normalize(string text)
{
if (string.IsNullOrEmpty(text)) return string.Empty;
var s = text;
if (s[0] == '\uFEFF') s = s[1..]; // 去 BOM
s = s.Replace("\r\n", "\n").Replace('\r', '\n'); // 行尾归一
return s.Trim(' ', '\t', '\n'); // 去首尾空白
}
但小说 TXT 动辄几十上百 MB,整串方案在手机上直接不可行:
| 做法 | 100MB 文件的代价 |
|---|---|
| 读成 JS 字符串 → 正则替换行尾 → 纯 JS md5 | 字符串 UTF-16 后约 200MB,替换再产生一份约 200MB 新串,纯 JS md5 比原生慢 10~50 倍 → 10~60 秒,低端机 OOM |
| 原生流式:边读边归一化边喂哈希 | 额外内存 O(1)(只有读取缓冲区),约 1~2 秒 |
所以大文件走另一条路径:流式字节处理,单趟顺序读,内存占用与文件大小无关。
5.2 流式实现的两个关键状态
桌面端的 BookKeyUtil.ComputeFromUtf8Stream 核心是一个"待定 CR"状态机:
var pendingCr = false;
void Feed(ReadOnlySpan<byte> span)
{
foreach (var b in span)
{
if (pendingCr)
{
pendingCr = false;
sink.Push(0x0A); // 上一个 CR 补一个 LF
if (b == 0x0A) continue; // CRLF:当前字节已被消费
}
if (b == 0x0D) { pendingCr = true; continue; } // CR 先挂起,等下一个字节
sink.Push(b);
}
}
为什么 CR 必须挂起? 因为它可能是 CRLF 的前半个字节,也可能是孤立的 CR。而块的边界随时可能出现——最后一个字节恰好是 0x0D 时,你无法判断下一块的开头是不是 0x0A。如果当场就把它写成一个 \n,遇到 CRLF 就会多出一个换行,整本书的 BookKey 全变。(这个坑在桌面端 v1.0.2 真的踩过,后面会单独写一篇。)
第二个关键点是首尾裁剪,它同样不能"边读边吐":
public void Push(byte b)
{
if (!_started)
{
if (IsTrim(b)) return; // 首部裁剪:直接丢
_started = true;
Emit(b);
return;
}
if (IsTrim(b))
{
_pending[_pendingLen++] = b; // 尾部空白:先暂存,不知道是不是真结尾
return;
}
FlushPending(); // 后面还有正文 → 之前暂存的空白其实是正文的一部分
Emit(b);
}
读到空白时,还不能判断它是不是"真正的结尾",只能暂存;一旦后面又出现非空白字节,就把暂存的一串全部吐出。Finish() 时仍未吐出的,直接丢弃。
这里有一个很漂亮的结论:裁剪可以只在字节层做,不必先解码成字符。
原因是裁剪集合只有三个字节——空格 0x20、制表 0x09、换行 0x0A,全是 ASCII(< 0x80);而 UTF-8 多字节字符的续字节都在 0x80~0xBF 区间。两者永不相交,所以按字节裁剪不可能切坏一个汉字,中间可以省掉一整轮编解码。
5.3 手机端为什么换成了"按行"
安卓端的实现路径不同:它用 BufferedReader.readLine() 逐行读。
// readLine() 本身就把 \r\n / \r / \n 都当行分隔,行与行之间补 \n
// —— 恰好等价于「行尾归一」,不必自己写跨块回看逻辑
这一手把上面那个 pendingCr 状态机整个省掉了,因为 readLine 的语义本身就与"行尾归一"等价。代价是必须精确处理一个边界:BOM 只算"整段文本的第一个字符"。
如果实现成"还没开始正文就剥一次 BOM",那么 "\n\uFEFFabc" 这种"前导空白 + BOM"的内容会被多剥一次,两端算出的 key 就对不上。手机端实现和 BookKeyLineAlgorithmTests.cs 都按"首行首字符"处理,专门锁住了这个行为。
两条路径形态不同、语言不同,但口径完全一致——这正是单元测试存在的地方。
六、一致性靠什么保证
"三端跑同一套算法"是句正确但没用的话,除非它能被验证。旦阅用了四层手段。
① 契约里带版本号。
进度数据结构里有一个 chapterParserVersion 字段。章节解析规则一旦调整,这个版本号就变,服务端能立刻识别出"这台设备用的是旧规则",从而在定位时退回更保守的兜底策略(按百分比)。
② 一份独立参照实现。
scripts/bookkey_ref.mjs 是一份 Node 的独立实现,口径与服务端一致,用来校验手机端算得对不对:
node scripts/bookkey_ref.mjs --selftest # 先自检脚本本身
node scripts/bookkey_ref.mjs D:/books/某本书.txt # 与手机端日志里的 key 比对
③ 单元测试锁住等价性。
服务端测试项目里有三组关键测试:
| 测试 | 锁住什么 |
|---|---|
BookKeyUtilTests.cs |
流式 / 整串两条路径等价,含 1 字节分块 等极端边界 + 黄金值 |
BookKeyLineAlgorithmTests.cs |
手机端"按行归一化"算法与 C# 版对齐 + 3000 轮随机模糊测试 |
AppChapterIndexTests.cs |
手机端"章节索引扫描"算法对齐,逐条比对章节划分 |
其中"1 字节分块"是很有意思的一招——把流按 1 字节切,等于把所有跨块边界的情形全部走一遍。任何"假设块足够大"的写法都会当场暴露。
④ 拿真实文件做三方交叉校验。
最终的一致性不是靠推理,是靠一个 42MB 的真实小说文件:手机端、桌面端、服务端各自算出 BookKey,三个值必须相同;EggBook.Verify 还能把正文 POST 给服务端的 /api/progress/fingerprint 接口做在线比对。
# 桌面端自检工具
dotnet run --project tools/EggBook.Verify -- selftest # 9573 项
dotnet run --project tools/EggBook.Verify -- fingerprint 某本书.txt http://localhost:5121
七、有意不共享的部分
一致性的另一边,是明确划定不需要一致的范围。否则"统一"会无限扩张,把设备差异也一起抹平。
分页算法不共享。 分页依赖屏幕尺寸、状态栏高度、字体渲染,物理上不可能一致。但两端仍然统一了一个口径:行高的计算方式(fontSize × 倍数 + 额外行距),以及"按行填满容器"的换算规则。分页结果可以不同,但"一行有多高"的定义必须相同——因为阅读进度是按字符算的,只要字符位置一致,各端按自己的屏幕重新分页即可。
阅读设置不共享。 字号、行距、主题、翻页方式依赖屏幕尺寸和个人偏好,同步过去只会打架。它们在每台设备上独立保存。
这条边界的划分可以概括成一句话:
共享「身份」与「位置」,不共享「呈现」。
八、小结
回头看,这套架构的复杂度几乎全来自一句话:失败是静默的。
同步不上不会报错,只是两台设备各读各的;BookKey 差一个字节不会报错,只是同一本书被当成两本。所有校验、参照实现、交叉测试,都是在把这种"静默失败"变成"编译期/测试期就能发现的失败"。
下一篇会往深处走一步,专门讲 BookKey:为什么归一化要选"去 BOM + 行尾归 LF + 去首尾空白"这三步,抽样指纹为什么看起来很美却会失效,以及那个走了 42MB 才被发现的换行符 bug。