老刘
发布于 2026-10-14 / 1 阅读

三端一套算法——旦阅的整体架构

先从一个具体场景说起。

你在手机上读到某本小说的第 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。