mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-13 06:20:54 +08:00
服务端仍在做排版:`chapter-html:<n>` 自己断段、自己提升标题、自己吐 `<p>`。于是 reader 设置里的缩进和段间距只能作用于服务端已经渲染好的那一份, 流式那条路(`chapter:<n>`)拿不到段落,同一本书因为走哪条传输而看起来不一样。 客户端的 `retypePlainText` 于是把服务端断好的段落再拆一遍——同一个判断被两个 地方执行,两次的结果就会分歧。 `POST /api/v1/sync` 的响应本来就是合并后的状态(服务端在两个接口上返回同一份 `pull`),但客户端把它丢掉,然后又为同一份数据发一个 `GET`。读者每翻一页都发 一个 POST 加一个 GET,网络面板里成对出现。另外 `setState` 每次调用都广播,而 阅读页的状态监听会重绘整棵 chrome 树,一个同步循环通知三次。 现在服务端只把整章字符交出来(`text/plain`,`chapter-html:` 不截断),断段、 去段前空格、提升标题全部由 `web/src/formats/segments.ts` 一处决定;push 的返回 值直接喂给 `applyPulled`,空闲轮询才真的发 GET;`setState` 值未变就不广播。 「这一章是不是纯文本」改由 manifest 声明(item 的 `format: 'html'` → `Section.plainText` → `isPlainText()`),不再靠 `.txt-body` 嗅探:服务端现在发 裸字符,嗅探对一章节全是纯文本的章节会答「不是」,读者会看到一本没有段落、 没有缩进、样式表也没挂上的小说。`.txt-body` 标记降级为旧服务端/旧缓存的兜底。 放弃的方案:保留服务端渲染、只在客户端覆盖样式。缩进和段间距是阅读偏好,服务端 渲染就等于把每次调整变成一次往返,而这个往返正是问题本身。也考虑过让 `chapter-html:` 直接返回服务端渲染的裸 `<p>`(不带 wrapper),但那仍然有两个 地方在判断「一段是什么」。 Ref: #34
657 lines
37 KiB
Markdown
657 lines
37 KiB
Markdown
# 架构与数据边界
|
||
|
||
本文记录**为什么这样设计**,尤其是那些一旦写错就很难回头的决策。
|
||
产品设计文档(v0.2)里的约束在这里落成了实现,逐条对应。
|
||
|
||
---
|
||
|
||
## 1. 数据边界
|
||
|
||
| 位置 | 内容 | 读写 |
|
||
| --- | --- | --- |
|
||
| `BOOKS_DIR` (`/books`) | 用户自己的电子书 | **只读** |
|
||
| `DATA_DIR` (`/data`) | SQLite、封面缓存、令牌密钥 | 读写 |
|
||
| 客户端本地 | 离线书文件缓存、本地进度 | 读写 |
|
||
|
||
服务端是唯一真相源。客户端只承担「渲染 + 缓存」。
|
||
|
||
### 只读挂载的强制手段
|
||
|
||
`src/config/index.ts` 在启动时断言 `DATA_DIR` 不在 `BOOKS_DIR` 内,否则拒绝启动。
|
||
这不是文档里的君子协定,是运行时会拦下来的硬约束。
|
||
|
||
扫描器与所有文件访问都经过 `src/lib/paths.ts` 的 `resolveInside()`——
|
||
唯一的路径解析出口,`../` 逃逸会抛 `PATH_TRAVERSAL`。
|
||
扫描时也**不跟随符号链接**:指向挂载点外的软链会读到用户没打算共享的文件。
|
||
|
||
封面缓存写在 `DATA_DIR/covers/`,不写在书旁边。
|
||
|
||
---
|
||
|
||
## 2. 书籍主键
|
||
|
||
```ts
|
||
id = sha256( (dc:identifier ?? '').toLowerCase() + '\0' + sha256(fileBytes) )
|
||
```
|
||
|
||
### 为什么不用文件路径
|
||
|
||
用户的真实行为是**持续整理书库**:改名、分目录、加前缀。
|
||
|
||
| 场景 | 路径做主键 | 当前设计 |
|
||
| --- | --- | --- |
|
||
| 重命名 `三体.epub` → `刘慈欣 - 三体 (修订版).epub` | 进度全丢 | 进度保留 |
|
||
| 移动到另一个目录 | 进度全丢 | 进度保留 |
|
||
| 同一本书放两份 | 书架出现两条 | 合并成一条 |
|
||
| 重新编码的同名文件 | 误认为同一本 | 视为不同副本 |
|
||
|
||
第二列是自建书库产品最常见的差评来源,所以这条必须在上线前定死。
|
||
|
||
### 为什么哈希也要参与
|
||
|
||
只用 `dc:identifier` 不行:很多自制/盗版 EPUB 没有 identifier,或者多本书共用
|
||
一个占位 identifier。只用哈希也不行:同一本书不同来源的副本哈希不同,无法合并。
|
||
|
||
两者结合:identifier 提供稳定的锚点,哈希保证「字节不同就是不同的副本」。
|
||
`identifier` 缺失时退化为纯哈希,行为仍然正确(只是无法跨副本合并)。
|
||
|
||
### 文件表与书表的分离
|
||
|
||
`book_files` 记录磁盘上的文件(`rel_path`、`size`、`mtime`),`books` 记录书。
|
||
多对一:一本书可以有多份副本。
|
||
|
||
文件被删时 `missing=1`,不立即删记录——用户可能只是临时拔了移动硬盘。
|
||
但一旦这本书已经有了别的可用文件,旧的行就是纯噪声,会被清掉,
|
||
否则反复整理书库会让文件表无限增长。
|
||
|
||
只有「没有任何可用文件」且「用户没手动加进书架」的书才会被删除。
|
||
宁可显示一本暂时不可用的书,也不能删掉读者精心整理过的书架条目。
|
||
|
||
---
|
||
|
||
## 3. 增量扫描
|
||
|
||
两阶段变更检测(`Scanner.indexFile`):
|
||
|
||
1. **`mtime + size`**:便宜,跳过绝大多数文件
|
||
2. **内容哈希**:权威,确认字节真的变了
|
||
|
||
只有第一阶段判断"可能变了"才读文件算哈希。所以每 60 秒轮询一次也很轻。
|
||
|
||
`Scanner.scan()` 对并发调用做去重(共享同一个 Promise),
|
||
避免定时器和手动触发同时砸磁盘。
|
||
|
||
---
|
||
|
||
## 4. 元数据优先级链
|
||
|
||
```
|
||
1. 手动修改(metadata_overrides 表)
|
||
2. EPUB 内嵌(dc:* + calibre meta)
|
||
3. provider 补丁(只填空缺)
|
||
4. 文件名解析
|
||
```
|
||
|
||
### 关键实现细节
|
||
|
||
`books` 表存的是第 2/3/4 层的结果。第 1 层单独一张表,**读取时**叠加
|
||
(`ShelfService.toDto` → `applyOverrides`)。
|
||
|
||
这样做的原因:扫描器可以放心地刷新 `books` 表,因为手动修改根本不在那张表里,
|
||
不可能被覆盖。
|
||
|
||
`books.source` 记录的是**基础值的来源**(`embedded` / `filename` / `provider:x`),
|
||
不记录 `manual`。手动修改过的字段由 DTO 的 `manualFields` 单独返回。
|
||
保持来源干净,撤销单个字段时才能正确回落到原始值。
|
||
|
||
### 文件名解析的保守策略
|
||
|
||
中文电子书里 `书名 - 作者` 和 `作者 - 书名` 都极常见,没有标记时**无法区分**。
|
||
|
||
因此只在有证据时才切分:
|
||
|
||
- `《书名》` → 这一侧是书名
|
||
- `著` / `编` / `译` → 这一侧是作者
|
||
- 一侧是中文人名(2-4 字且首字为常见姓氏)→ 另一侧是书名
|
||
- 一侧是拉丁文人名 → 另一侧是书名
|
||
- **都不满足 → 整串保留为书名,作者留空**
|
||
|
||
最后一条是刻意的:漏一个作者,读者手动补一下就好;猜错一个作者,
|
||
书就被归到错误的人名下,而且他未必会发现。
|
||
|
||
其余正则也踩过坑,值得记下来:
|
||
|
||
- 系列正则 `[【\[]...[】\]]` 里 `\[` 在字符类中不转义,会把分隔符 `' - '` 吃掉,
|
||
导致 `金庸 - 射雕英雄传` 变成 `title=金庸, author=射雕英雄传`。改成显式括号对匹配。
|
||
- 「(第1卷)」这种卷标,书名是作品本身,不是「第」。要先剥离再切分作者,
|
||
且剥离要按完整括号组做,否则会留下 `射雕英雄传(第1卷` 这样的半截括号。
|
||
|
||
---
|
||
|
||
## 5. 同步与冲突合并
|
||
|
||
### 为什么是 last-writer-wins
|
||
|
||
一台设备属于一个读者。多设备之间不存在"编辑冲突",只存在"某台设备落后了"。
|
||
所以 LWW 是正确语义,不需要 CRDT 或向量时钟的复杂度。
|
||
|
||
### 墓碑
|
||
|
||
笔记删除写 `deleted=1` 而不是 `DELETE`。
|
||
|
||
否则:手机离线时删了一条笔记 → 平板(也离线,数据更旧)重连 → 把笔记推回来 →
|
||
已删除的笔记复活。墓碑让这个时序变得无害。
|
||
|
||
### 批次原子性
|
||
|
||
`SyncService.push` 整个批在**一个事务**里。断网不会留下写了一半的进度。
|
||
|
||
### 增量拉取
|
||
|
||
`GET /api/v1/sync?since=<上次的 serverTime>`。客户端记住上次返回的 `serverTime`,
|
||
每次只拿增量。移动网络下重连很便宜。
|
||
|
||
### 一次写入只发一个请求
|
||
|
||
`POST /api/v1/sync` 的响应**就是合并后的状态**(服务端在两个接口上返回同一份
|
||
`pull`)。所以推完之后**不要再拉一次**——`SyncEngine` 直接拿 push 的返回值喂给
|
||
`applyPulled`,没有东西可推(空闲轮询)时才真的发 `GET`。
|
||
|
||
之前不是这样:push 的返回值被丢掉,然后为了同一份数据再发一个 `GET`。于是读者每
|
||
翻一页都发一个 POST **加** 一个 GET,网络面板里就是成对出现的 `sync` ——这正是
|
||
「sync 接口在不停重复」的来源。
|
||
|
||
### 状态只在变化时广播
|
||
|
||
`setState` 先比对新旧 state/message,相同就直接返回。阅读页的状态监听会重绘整棵
|
||
chrome 树,而一个同步循环本来会通知三次(进门 `syncing`、出门 `idle`、再加一次
|
||
多余的 `emit()`),30 秒轮询让这件事在读者什么都不做时也持续发生。
|
||
|
||
---
|
||
|
||
## 6. 认证与安全
|
||
|
||
- **scrypt** 做口令哈希(内置模块,无原生依赖)。比较用 `timingSafeEqual`。
|
||
- 登录时即便用户不存在也跑一次哈希比较,避免用时间差枚举用户名。
|
||
- 访问令牌是 HS256 签名的无状态令牌;刷新令牌是不透明随机串存库,可按设备撤销。
|
||
- 改口令 / 停用账号会撤销该用户**所有**会话。
|
||
- 每个请求都从库里重读用户,不信任令牌里的 role——停用账号立即失效。
|
||
- 刷新令牌**轮转**:用过即废,降低泄露窗口。
|
||
|
||
### 不要重新引入原生依赖
|
||
|
||
镜像构建的稳定性直接决定部署成功率,而部署门槛是最大的获客阻力。
|
||
`better-sqlite3`、`argon2`、`bcrypt` 都需要构建时的 `make`/`gcc`,
|
||
在部分 NAS 环境会直接装不上。
|
||
|
||
`node:sqlite` 和 `node:crypto` 的 `scrypt` 都是内置的,所以当前依赖树里
|
||
**没有任何需要编译的包**。
|
||
|
||
---
|
||
|
||
## 7. 向后兼容承诺
|
||
|
||
自部署用户不会及时升级。所以:
|
||
|
||
- API 只做加法:加字段、加端点。不改已有字段语义、不删端点。
|
||
- 数据库 schema 用 `CREATE TABLE IF NOT EXISTS`,升级时新增列/表,不动旧的。
|
||
- 错误响应格式全站统一,客户端可以一直用同一套解析逻辑。
|
||
|
||
---
|
||
|
||
## 8. 客户端渲染
|
||
|
||
> 界面层的外观规范(图标、设计令牌、焦点、状态表达、文案)在
|
||
> [`ui.md`](ui.md)。§8 这一章讲的是**渲染**:分页、注入、量测、以及哪些界面
|
||
> 该用组件描述、哪些必须保持命令式。两者的边界是刻意分开的——排版层对书不动手
|
||
> (见 §8 与 §9),界面层的外观则必须统一。
|
||
|
||
已落地。产品设计里的关键决策:**不要直接套 Readium Mobile**。
|
||
Readium 用 Readium CSS 主动接管排版、覆盖出版方样式,这与本产品
|
||
「忠实还原精排书设计」的核心差异化直接冲突。
|
||
|
||
实际方案:
|
||
|
||
- 原生外壳(Kotlin)只负责:文件与缓存、手势、账号、连通性
|
||
- 渲染层是 **WebView + 自研 CSS 干预策略**,保留作者样式、只做最小覆盖
|
||
- 参考 readium-css 的变量组织方式,但**不引入它的默认接管行为**
|
||
|
||
### UI 框架选型:Preact,而且只用在「没有布局要量测」的那一半
|
||
|
||
界面原来是纯命令式 DOM:每个屏幕在构造函数里建好全部节点、把它们存成实例字段,
|
||
再由若干 `render*()` 方法在状态变化时挑选该改哪个。它有两个具体的坏味道:
|
||
|
||
1. **同一份状态有多个副本。** 分段控件的选中态既在 `settings` 里也在
|
||
`aria-pressed` 上,两者靠点击处理器手工同步;`LoginScreen` 的通知、错误、
|
||
注册开关、按钮文案是四个节点被五个地方改。改一处漏一处不会报错。
|
||
2. **重建与状态的矛盾。** 朗读引擎行必须在 HTTP 能力探测回来时重新生成,
|
||
而整块面板重建会丢掉读者在面板里的滚动位置,于是长出一套
|
||
「只替换 `[data-speech=group]`」的定点重建。
|
||
|
||
选型的硬约束有三个,都来自「一份产物两个宿主」:
|
||
|
||
- **单文件 bundle、`base: './'`**:WebView 从 `https://appassets...` 加载,
|
||
不能按需拉分片,所以框架必须能被 tree-shake 进一个文件。
|
||
- **体积**:这是要打进 APK、也要由 NAS 上的 Node 托管的资源。
|
||
- **不认识的东西不能碰布局**:阅读舞台的分页依赖 `getBoundingClientRect()`,
|
||
虚拟 DOM 不能在它和布局之间。
|
||
|
||
对比过的四个:
|
||
|
||
| 方案 | gzip 净成本 | 结论 |
|
||
| --- | --- | --- |
|
||
| Preact + `preact/hooks` | 最小 | **选用**(无 `preact/compat`、无 `react-dom`,仅这两个模块) |
|
||
| React + react-dom | ~45KB | 一个阅读器不该为 `react-dom` 付这个价钱 |
|
||
| Vue 3 runtime | ~34KB | 需要 SFC 预编译或运行时模板编译,产物形状与单文件 bundle 相冲 |
|
||
| 纯 Web Components / lit | ~5KB / ~7KB | 手写 `render()` 等于自建一个更窄的框架;lit 又把模板塞进字符串,失去类型检查 |
|
||
|
||
**边界写在 `ReaderScreen` 里,而不是靠自觉**:舞台(`stage`)由类持有、
|
||
交给 `ReaderView`,并通过一个 ref 回调挂进组件树;组件只决定它**放在哪**
|
||
(状态行与页脚之间,stacking context 来自这里),内容一概不碰。于是:
|
||
|
||
- 排版、分页、shadow root、手势、量测:**零框架**。
|
||
- 顶栏、页脚进度、目录、设置面板、朗读条、状态行、书架、登录、书库管理:
|
||
`ChromeState` 的函数。
|
||
|
||
副作用也是可见的:`settingsPanel` 那套「只重建朗读行」的定点更新没有了,
|
||
探测答案回来时只是改状态,Preact 的 diff 让读者的滚动位置天然保住。
|
||
|
||
组件只用了 `preact` 与 `preact/hooks`(`useState` / `useRef` / `useEffect`),
|
||
没有引入 `@preact/signals` 之类的额外状态层——屏幕自己就是状态的持有者,
|
||
`update()` 把整份状态交给树。少一个依赖,就少一处「值在哪」的歧义。
|
||
|
||
### 路由:URL 就是屏幕
|
||
|
||
界面的第二个结构性问题不是「怎么画」,而是「在哪」。最初的 shell 用
|
||
`showShelf()` / `openBook()` / `showManager()` 加一个 `pendingBook` 字段来换屏,
|
||
URL 从头到尾不变。手机上这一点几乎看不见——直到读者按返回键:Android 的返回手势
|
||
交给 Activity,Activity 看到 WebView 没有历史,于是**直接从书架上退出应用**。
|
||
浏览器上则更糟,地址栏是自托管书库唯一的分发方式,而它既不能分享也不能收藏:
|
||
一本书链接不出去,刷新永远回到书架。
|
||
|
||
所以路由是**值**,不是调用:`ui/router.ts` 把 fragment 解析成
|
||
`{ name: 'shelf' } | { name: 'library', path } | { name: 'book', bookId }`,
|
||
shell 渲染 router 报上来的那个 route,导航就是改 URL、让变化回流。
|
||
|
||
**为什么用 fragment 而不是 `history.pushState` 的路径。** 同一份产物要被两个宿主加载,
|
||
而两个宿主都服务不了任意路径:Android 从 `https://appassets.androidplatform.net/`
|
||
加载(那里没有服务端,除文档本身以外的路径全是 404),H5 由 reader 服务端托管,
|
||
而它没有「客户端路由回落到 `index.html`」这条规则。路径式路由会在 `vite dev` 里一切正常,
|
||
在两个生产宿主里 404。fragment 是 URL 里唯一保证属于客户端的一段,所以它是唯一
|
||
三处都成立的方案——代价是一个 `#`。
|
||
|
||
**返回走应用自己的轨迹,不是 `history.back()`。** 应用里的「返回」意思是「上一个屏幕」,
|
||
而浏览器历史里可能有登录表单、`vite dev` 的页面、甚至别的站点。`Router` 自己维护一条
|
||
屏幕轨迹,`back()` 从它上面弹;轨迹为空时(新标签页里粘贴的链接)就**替换**成父级路由,
|
||
而不是压入——否则那个粘贴的链接会永远压在书架上,返回键会一次次把读者送回书里。
|
||
|
||
**进入文件夹是 `replace`,不是 `push`。** 书库是一个带面包屑的屏幕,走进子目录是同一个
|
||
屏幕改自己的参数,所以它覆写当前条目而不是新增。压栈的话,读者走过四个文件夹再按返回,
|
||
会一个一个退出来——而面包屑存在的意义正是为了不用这样做。
|
||
|
||
**登录不是路由。** 「登录页」是一个瞬时状态,给它一个 URL 等于给门一个门牌。真正需要
|
||
活下来的东西是读者**本来要去的地方**,所以它存在 `App.pending` 里:带着 `#/book/<id>`
|
||
进来而尚未登录时,这个 route 会等在登录页后面,登录完成后直接落到那本书上,
|
||
而不是落到一个他从未见过的书架。
|
||
|
||
各屏仍然是有 `element` 和 `dispose()` 的类——路由不认识框架,屏幕也不认识 hash。
|
||
两者之间唯一的交汇点是 `RouteLocation`,屏幕通过它的 `back()` 请求离开。
|
||
|
||
### `mountUI`:类与组件之间唯一的接缝
|
||
|
||
屏幕仍然是类,仍然有 `element` 和 `dispose()`——路由不需要知道框架,
|
||
`ReaderScreen` 这种以命令式为主的屏幕也不必假装自己不是。
|
||
`mountUI(container, tree, initial)` 返回 `{ element, update, unmount }`,
|
||
`update` 传入「整棵树应该是什么状态的函数」的那个值。测试保持原样:
|
||
断言仍然打在真实 DOM 上(`test/manager.test.ts` 一行没改逻辑,只改了 import 后缀)。
|
||
|
||
**unmount 之后的 update 会被丢弃,这一点是有意的。** 每个屏幕都是异步加载的,
|
||
每个屏幕也都可能在加载完成前被离开——读者点开一本书又马上按返回,深链在书架还在取数据
|
||
时就把它换掉了。`dispose()` 取消不掉一个已经在飞的 promise,所以那个延续会跑在一个
|
||
已经不在屏幕上的屏幕上并调用 `patch()`。没有这道闸,它会往一个刚刚被 Preact 清空的容器
|
||
里重新画**整棵树**:书架那条迟到的响应会把自己一整屏封面画进书库管理器里,而之后的
|
||
`render(null)` 也清不掉它们,因为在 Preact 看来它们从来不是自己的孩子。
|
||
所以一个标志位就顶得上每个屏幕各写一个取消令牌,而且没法在哪一个屏幕里漏掉。
|
||
|
||
### 只有一份渲染层
|
||
|
||
Android 不重写渲染,它加载的是同一份 `web/` 产物。这不是为了省事,
|
||
而是因为排版策略一旦有第二份实现就一定会分叉,而排版正是差异点所在。
|
||
|
||
两边共用的抽象在 `web/src/core/platform.ts`:传输、键值存储、二进制存储、连通性。
|
||
Android 只覆盖它真正能做得更好的部分——连通性和设备名——其余全部复用。
|
||
|
||
### 有一条退出通道:整页图与文档可以由原生画
|
||
|
||
「只有一份渲染层」是默认,不是教条。有一类内容在 WebView 里是不划算的:
|
||
**整页就是一整张图**。渲染一页漫画,WebView 要付:字节跨进渲染进程、在渲染进程里
|
||
解码成位图、跑一次样式与布局、再合成一次。对一章文字这些都是必要的;对
|
||
「把这张图画满屏幕」则全是白付的,在手机上就是一次翻页卡顿。
|
||
|
||
所以有一条明确划出的通道:固定版式的整页图由原生画。
|
||
规则写死在契约里,也是它能成立的原因:
|
||
|
||
- **只画整页图。** 漫画页、单张扫描图。任何带文字排版的内容留在 WebView,
|
||
因为忠于精排是差异点,而它只有一份实现。
|
||
- **全有或全无。** 要么解出位图并显示,要么返回 `false` 让 WebView 照常渲染。
|
||
没有中间态,也不会出现「该有图的地方是空白」。宿主拒绝、格式不支持、
|
||
payload 坏了、桥接抛异常,四种情况都落到同一条回退路径上。
|
||
- **不在 UI 线程解码,位图有上限。** 先读文件头拿真实尺寸,再按视口下采样分配。
|
||
一张 4000 万像素的扫描图如果先分配再缩放,就是把目标机型 OOM 掉。
|
||
|
||
契约横跨三种语言,所以谁也无法用类型系统保证它们一致,`android/scripts/check-shell.sh`
|
||
里加了静态校验:渲染模式的词表(`RenderMode`)必须覆盖壳里分支的那几个值,
|
||
原生侧的可解码媒体类型必须包含客户端会产出的那些。词表写错一边,
|
||
结果不是编译失败,而是**所有页都静默走慢路径**——所以这条检查不是锦上添花。
|
||
|
||
壳版本 2 引入这套接口。客户端把「壳版本 < 2」当作「没有原生渲染器」处理,
|
||
照常在 WebView 里画一切:为了一点性能就拒绝在旧 APK 上运行是错的交换。
|
||
|
||
### Android 用 https 资产源,不用 file://
|
||
|
||
把打包好的 Web 应用塞进 assets,最直觉的加载方式是 `file:///android_asset/...`。
|
||
这条路对**这个**产品是走不通的:从 `file://` 源发出的 `fetch()` 是不透明的跨源请求,
|
||
所有访问服务端的调用都会被拦,而通常的绕法是把 web security 关掉——
|
||
那就等于把 WebView 变成一扇敞开的门。
|
||
|
||
用 `WebViewAssetLoader` 从 `https://appassets.androidplatform.net/` 提供同样的文件,
|
||
拿到的是一个正常的安全源:请求按普通 CORS 走,正好和服务端的 CORS 策略对上;
|
||
`isSecureContext` 为真,IndexedDB 等 API 与浏览器里行为一致。
|
||
这一个选择就是「一份 bundle 两个宿主」能成立的前提。
|
||
|
||
### 精排 CSS:干预策略
|
||
|
||
按重要程度排序的三条原则,写在 `web/src/styles/reader.css` 里:
|
||
|
||
1. **不设任何属性,除非不设会坏。**
|
||
2. **每个可调项都是默认 `inherit` 的可继承自定义属性**——「关」的意思是「不动它」,
|
||
不是「用我们的默认值」。这一点很关键:给行距写死一个具体值,会把一本按章节
|
||
变行距的书压平。
|
||
3. **任何改变作者意图的行为都是用户主动开启,且持久化**,绝不是默认。
|
||
|
||
实际 override 只有七处,每处都在 CSS 注释里写了为什么:图片/表格/pre 在窄屏会撑破
|
||
布局、ruby 振假名在 16px 下按默认 50% 太小、长中文标题不换行会溢出。除此之外不动。
|
||
|
||
对比一下被删掉的做法:reset 样式表、统一的「阅读主题」、给 `p` 加边距。
|
||
它们都能让排版「看起来一致」,而一致性恰恰是这里要避免的东西。
|
||
|
||
### 书的样式隔离在 shadow root 里
|
||
|
||
书里一条 `p { color: red }` 不能改到书架,App 的 `.topbar` 也不能被书撞上。
|
||
iframe 同样能隔离,但每次换章要重建文档:滚动位置丢失、Android 上内存翻几倍。
|
||
shadow root 拿到完整样式隔离且没有文档边界,App 还能进去做分页和取选中。
|
||
关键是**不往 shadow root 里注入任何 reset**。
|
||
|
||
但隔离是双向的:**文档里的选择器进不去 shadow root**。这不是风格问题,
|
||
它决定了两条规则各自该写在哪儿,搞错了不会报错、只会静默失效:
|
||
|
||
- `.book-flow`(正文列:版心宽度、页边距、字号缩放、分页列)、`.fixed-page`、
|
||
朗读高亮、翻页动画——这些元素在 shadow root **里**,所以规则必须注入到
|
||
shadow root **里**(见 `ui/shadow.ts` 的 `FLOW_STYLESHEET`)。
|
||
写在 `styles/reader.css` 里等于删除:正文列会以浏览器默认样式渲染,
|
||
没有版心、没有边距、翻页模式没有列。而外围 chrome 在文档里、样式完全正常,
|
||
所以看上去不像样式表丢了,只像「阅读器没有页边距这个功能」。
|
||
- `.book-host`、亮度过场(`::after` 伪元素)、`book-content` 的 `display`
|
||
——这些是 shadow **host** 本身,属于文档树,所以规则写在 `styles/reader.css`。
|
||
- host 上的状态属性(`data-paginated`、`data-animating`)只能用 `:host(...)`
|
||
选择:从 shadow root 内部无法用类选择器匹配 host。
|
||
写成 `.book-host[data-paginated] .book-flow` 永远不会匹配,
|
||
于是翻页模式静静地不产生任何列,翻页也就无处可翻。
|
||
|
||
两条规则里的**值**不重复:全部是自定义属性,而属性本身定义在 `:root` 上,
|
||
shadow root 会继承。
|
||
|
||
### 窗口化的三个接缝
|
||
|
||
「一份产物两个宿主」和「一千二百章的书不能先下载整本」是同一件事的两面,
|
||
而窗口是这件事的传输边界。跨过它有三个必须写死的地方:
|
||
|
||
- **换窗口和落到章节是一次操作。** 只换窗口会留下*上一章*在屏幕上:
|
||
宿主内容没动、`currentSectionIndex` 还指着旧窗口的下标、页脚还在报读者刚离开的章节。
|
||
这正是「点击章节没有反应」的症状。
|
||
- **窗口接口只有一个名字。** 客户端建的 `StagedDoc` 叫 `setWindow`,
|
||
`StagedBook` 接口叫 `loadWindow`,屏幕曾经用鸭子类型去猜——猜错了,
|
||
于是每次跨窗口跳转都是静默的空操作。现在由 `adoptWindow()` 一处识别。
|
||
- **窗口由 manifest 的 `groups` 算出来,不是由「40 章」算出来。**
|
||
漫画按*卷*开窗,而卷不是 40 页。`spine / 40` 会请求一个不存在的组,
|
||
跳转就落到空处。
|
||
|
||
另外,位置必须按**整书坐标**判断,不能按窗口内的下标:
|
||
在窗口最后一章时下标是 39,拿它和窗口长度 40 比会得出「已经是最后一章」,
|
||
而后面还有八十章。窗口长度是传输细节,不该决定书在哪里结束。
|
||
|
||
### 阅读位置格式
|
||
|
||
### 书不能联网
|
||
|
||
加载时把章节里所有本地引用改写成内部 `reader-res:` 协议,渲染层从内存资源表解出;
|
||
绝对 URL 原样保留,然后被 CSP 挡掉。这样一本来路不明的书无法把阅读行为报到远端。
|
||
资源按需解出而不是预展开——不然打开一本带大图的 EPUB 会先把整个包塞进内存。
|
||
|
||
### 阅读位置格式
|
||
|
||
`r1:<偏移>:<章节 id>`。三点都是踩过之后定的:
|
||
|
||
- **版本前缀**:服务端把 locator 当不透明字符串长期保存,客户端将来改格式时必须
|
||
能认出「这不是我认识的格式」。`r1` 给了这个能力。
|
||
- **章节 id 放最后**:章节 id 是文件路径,会含冒号。只从前两个冒号切、剩下全保留,
|
||
是唯一不会丢数据的解析方式。
|
||
- **偏移是章节内的比例**,不是像素、不是 CSS 列号。列号会随字号变化,
|
||
于是读者一调字号,位置就跳到另一个句子上——阅读 App 能犯的最让人恼火的错误之一。
|
||
|
||
### 待发送队列,而不是「每次都全发」
|
||
|
||
离线队列记的是**脏集合**:哪些记录还没送到服务端,而不是「当前全部状态」。
|
||
|
||
两者在一种情况下结果不同,而这种情况一定会发生:记录在请求发出之后、响应到达之前
|
||
被修改了(读者在同步进行时翻了一页)。脏集合下,这次更新仍然是脏的,要等**它自己**
|
||
的确认才清除;「全发」模式下,它会被那个更早的响应顺手清掉,然后永远送不到服务端——
|
||
一个丢失的阅读位置,是这个产品最不能有的失败。
|
||
|
||
### 离线队列的抗崩
|
||
|
||
队列在本地镜像里,不在内存里。所以读到一半直接杀进程,位置不会丢——
|
||
下一次启动接着送。笔记删除同样写墓碑后再入队,避免离线设备让已删笔记复活。
|
||
|
||
### TXT 的编码与章节
|
||
|
||
本地书库里 TXT 占比很大,而它既没有结构也没有编码声明。
|
||
|
||
编码:先严格校验 UTF-8(自己实现,不用 `TextDecoder` 的宽松模式——宽松模式永远
|
||
「成功」,于是你永远发现不了自己解错了)。校验失败**不是**按顺序试
|
||
GB18030 再试 Big5,而是两种都解一遍再挑更干净的那个。原因值得写下来:
|
||
|
||
这两个编码的字节范围重叠得很厉害,同一串字节通常**两边都能解出合法字符**,
|
||
所以「先试 A 再试 B」永远不会走到 B,繁体 Big5 的书会以简体码表解出一整本乱码。
|
||
真正的证据在各自码表的空洞里——解出来变成私用区字符、替换字符、或者一片汉字里
|
||
冒出的假名。按这个打分选优,才能既救回繁体,也不牺牲占多数的简体。
|
||
|
||
结果里带 `guessed` 标记,界面上告诉用户选了哪个并允许手动覆盖。
|
||
|
||
章节:TXT 没有目录,只能从标题推断。策略是**宁可少认,不要错认**——
|
||
一个 800 字的段落以「第三章」开头时不能当标题,而正文里提到「他在第二章读过」
|
||
更不能当标题。两条都有测试固定住。推断不出章节时按长度分段:
|
||
一本五千章的网文如果变成一整节,分页会直接卡死。
|
||
|
||
### 漫画的页序
|
||
|
||
按自然序排(`1, 2, 10` 而不是 `1, 10, 2`)。排错不是观感问题,是故事被静默打乱。
|
||
文件名无信息量时用页序当标题,因为 `a1b2c3.png` 对读者没有意义。
|
||
|
||
### 目录书:什么算一页,谁说了算
|
||
|
||
一个目录变成一本书之后,「这本书有哪些文件」不能由 API 层临时拼:manifest 告诉客户端
|
||
哪些路径构成这本书,`/books/:id/file` 就服务哪些路径。两边必须是**同一份清单**。
|
||
|
||
这一条是被一个真实缺陷定出来的。此前 API 层自己拼路径,用的是 `items[].title`——
|
||
对散图恰好等于文件名,对**压缩包里的页**则是包内条目名(`001.png`),那不是一个库内路径。
|
||
于是这样一个卷的每一页都 404,漫画打开就是空白,而且服务端日志里只有 404,看不出原因。
|
||
|
||
现在清单由页面遍历给出,并且**一个压缩包算一页**:它的路径是 `第01卷.cbz`,
|
||
是客户端真能拿来发请求的东西;包内页码由 `/assets?ref=page:卷:页` 寻址,不是路径,
|
||
也就不该出现在 `files` 里。清单是契约,不是清单式的描述——多一条会被拒,少一条会 404。
|
||
|
||
顺带一个同类缺陷:`第01卷.cbz` 与 `第02卷/` 并列时,整卷系列在书架上会**整体消失**。
|
||
`.cbz` 本身注册为一种书,于是这个目录被判成「书架」而不再交给漫画目录处理器;
|
||
卷子目录里只有图片、不构成书,所以也没有留下任何条目。两边都不认,书就不见了。
|
||
判据因此改成按**上下文**问:一个 zip 在书架语境里是书,在漫画目录候选里是页材料。
|
||
|
||
同一个布局还有两处更细的坑,都会让整卷系列消失或读错页,值得一并记下:
|
||
|
||
- **「卷」的判据不能在最后加一句 `images === 0`。** 整卷系列里放一张封面扫描是常态,
|
||
而 `images === 0` 会把这种目录判成「不是漫画」——它就是上面那个消失缺陷的另一条路径。
|
||
判据改成逐个来源问:卷压缩包的页就在包里,**一个压缩包本身就是一卷**;卷子目录要往下看
|
||
才知道有没有页。一张散图既不能否定同级存在的整卷,也不该在只有它自己的时候被当成书
|
||
(那条由 `MIN_IMAGES` 管)。
|
||
- **页的引用是 `page:<卷>:<页>`,不是 `page:<下标>`。** manifest 与 `/file` 都从同一次
|
||
页面遍历取引用(`files[].ref`),而不是让 API 层从路径或下标反推。「一个压缩包算一页」
|
||
意味着它在列表里的下标和它在卷里的位置无关:按 `page:1` 去问一卷,格式处理器解不出这个
|
||
引用,异常回退到「当普通文件发」的分支,于是客户端请求一页、拿到的是那个压缩包——
|
||
状态码还是 200,只有翻页时才会发现内容不对。
|
||
|
||
### 一页一个 section
|
||
|
||
漫画和 PDF 是 `layout: 'fixed'`,每页一个 section,由分页器整屏推进。
|
||
固定布局的宿主不做滚动容器——在横向分页器里嵌一个滚动区会产生
|
||
Android 上经典的「页面自己往边上飘」bug。
|
||
|
||
---
|
||
|
||
## 9. 界面设置与朗读
|
||
|
||
### 为什么设置不跟着账号同步
|
||
|
||
同一份「设置」里其实住着两类东西:
|
||
|
||
| 类别 | 例子 | 真相源 |
|
||
| --- | --- | --- |
|
||
| 屏幕与环境的属性 | 字号、主题、亮度、页边距 | 本机 |
|
||
| 读者的属性 | 阅读位置、笔记、高亮 | 账号 |
|
||
|
||
把第一类推到服务端,结果就是把手机上的字号搬到平板上打开——这是所有人都讨厌的行为。
|
||
所以 `SettingsStore` 存在设备本地(`reader.settings.v1`),同步层完全不知道它存在。
|
||
|
||
### 新增的每一个排版开关,默认值都是「不动」
|
||
|
||
这条不是风格问题,是 §8 那个承诺的直接推论。「忠于精排」在实现上只有一种写法:
|
||
**除非不设就会坏,否则不设**。
|
||
|
||
所以行距的默认值是字符串 `inherit`,字体和文本对齐同样;它们经 CSS 自定义属性
|
||
继承进 shadow root,书里写死的值自然胜出。字号改的是容器的基准字号,不是每段的大小,
|
||
所以 `font-size: 14px` 的段落保持原样,`1.2em` 的段落跟着滑块走。
|
||
|
||
页边距是唯一一个「用户值必然生效」的排版项。它不是给书的,是给**行宽**的:
|
||
一行 40 个汉字在手机上没法读,而约束行宽不能靠改 `p` 的 margin——那正是要避免的编辑。
|
||
改成给容器加内边距,作者的样式一条都不用动。
|
||
|
||
亮度做成了 `mix-blend-mode: multiply` 的覆盖层而不是调色板:换主题应该保留
|
||
纸与字的对比关系,直接改颜色会把文字一起压暗,那正是「亮度」滑杆最常见的失败。
|
||
|
||
### 三个朗读引擎,一个队列
|
||
|
||
朗读有三条来源,而客户端的其余部分不需要知道正在用哪一条:
|
||
|
||
| 引擎 | 声音来自 | 什么时候用 |
|
||
| --- | --- | --- |
|
||
| `native` | Android 外壳的 `TextToSpeech` | 有 `speech` 接口的外壳(版本 ≥ 3) |
|
||
| `system` | WebView / 浏览器的 `speechSynthesis` | 默认。免费、离线、无往返 |
|
||
| `http` | 服务端的 `TTS_URL`,经 `/api/v1/tts` 代理 | 前两者都不可用时 |
|
||
|
||
`auto` 的优先级是 native → system → http,理由不是「谁更好」而是**谁知道得更多**:
|
||
|
||
- native 排在 system 前面,尽管 WebView 自己也有合成器。因为那是**两个不同的引擎**:
|
||
手机系统装好的中文语音经常不在 WebView 的 `getVoices()` 列表里,
|
||
于是读者听到英文或听不到声音,而 Web 层拿到的列表本身就是缺的,修不了。
|
||
这是「朗读没有中文声音」最常见的原因,唯一的解法是直接问平台。
|
||
- http 排最后,因为它的每一句都要一次请求,而且它需要一个配了 `TTS_URL` 的服务端。
|
||
|
||
三者的**暂停语义真的不同**,所以它们不是同一个基类:
|
||
|
||
- `speechSynthesis` 能暂停,`TextToSpeech` 不能(只能 stop),`<audio>` 能暂停但要重播整句。
|
||
- 改语速:`<audio>` 直接改 `playbackRate`(不用重读),另两个必须重读当前句。
|
||
- 音调:`<audio>` 根本没有,所以设置面板在 HTTP 引擎下**不显示这一行**,
|
||
而不是显示一个按了没反应的滑块。
|
||
|
||
派生的差异一并被显式处理:HTTP 引擎的单句上限是 800 字,而一段没有标点的中文段落
|
||
可能有两千字——切分发生在 `rememberSpeechChunks()` 里,只对引擎生效,
|
||
高亮仍然锚定在原来的整句上(碎片带着原句的 `start` 偏移)。
|
||
|
||
### Android 的原生朗读接口为什么被拆成第二个对象
|
||
|
||
`addJavascriptInterface` 只能注册**对象**,不能注册某个对象上的属性,
|
||
所以 `window.ReaderAndroid.speech` 实际上是 `window.ReaderAndroidSpeech` 这个独立注册的接口,
|
||
由 `promoteSpeechInterface()` 在 Web 层重新挂回第一个对象上。
|
||
契约(`android-bridge.d.ts`)对外只有一个嵌套对象,接线方式不出现在这一层之上。
|
||
|
||
和原生整页渲染一样,它按**外壳版本**判断而不是按方法存在与否判断:
|
||
版本低于 3 的外壳就是没有这个接口,客户端退回 `speechSynthesis`,
|
||
而不是到播放时才发现方法不存在。
|
||
|
||
### 朗读为什么按句排队
|
||
|
||
`speechSynthesis` 本身只有一行 API,难的是决定**下一句说什么**。按句而不是按段:
|
||
|
||
- 高亮能跟着声音走。`boundary` 事件(词级位置)在 Android WebView 上不存在,
|
||
在桌面 Chrome 上也不可靠,所以依赖它做高亮等于在目标平台上不做高亮。
|
||
- 一章拆成几十句,语速、音调、语音的修改只需重读**当前句**;按段修改则要重读整段。
|
||
- 章节切换发生在「一句读完」的回调里,是引擎唯一明确告诉我们「这句话说完了」
|
||
的时刻——除此之外的推进都可能把两句同时说出去。
|
||
|
||
切句规则刻意保守:`.` 后面不是空白就不算句末,`Mr.` / `Dr.` / `3.14` / `e.g.`
|
||
全部被这一条挡住。代价是 `end.Next`(句号后不加空格)只剩一句,这比把 `Mr.`
|
||
和它的名字切开要小得多。没有标点的长段落按 300 字硬切——Android 的合成器
|
||
遇到两千字的单条 utterance 会直接沉默。
|
||
|
||
### 高亮用 Range 画,不改书的 DOM
|
||
|
||
把正在朗读的句子包进 `<span>` 是最直觉的做法,也是最糟的:它改写了出版方的标记,
|
||
会让作者针对 `p > span` 的样式失效,并且因为行盒度量变化而**重新分页**——
|
||
读者一按朗读,页面就跳。所以高亮是 flow 里的一个绝对定位覆盖层,
|
||
位置来自 `Range.getBoundingClientRect()`,文字本身一个字节都没动。
|
||
|
||
代价是高亮必须自己跟随滚动、自己处理换行(一个跨行的句子只有一个矩形),
|
||
这比包 `<span>` 麻烦,但换来的是「按朗读不会动版」。
|
||
|
||
### 朗读队列与章节的边界
|
||
|
||
引擎只知道一件事:给我一批句子,说我读到哪里了。章节的事由屏幕层负责——
|
||
队列耗尽时引擎再问一次,屏幕层决定是「取下一章」还是「到此为止」。
|
||
这个分工让「读到章末停下」成为设置项而不是引擎里的分支。
|
||
|
||
队列的推进由事件驱动,绝不靠计时器:`speechSynthesis` 的 `onend`、`<audio>` 的 `ended`、
|
||
原生外壳的 `done`。计时器会漂移,而一次漂移就意味着两句同时被说出来或一句被吞掉。
|
||
每个引擎还各有一个看门狗,只在**引擎自己彻底安静了**(长时间熄屏后的
|
||
Android WebView 是真实会发生的)才推进,不是为了给朗读计时。
|
||
|
||
原生引擎的事件带一个**客户端自己生成的 utterance id**,外壳原样回传。
|
||
没有它,就分不清「我正在读的这句读完了」和「读者已经跳过的那句读完了」——
|
||
后者会让光标多走一格,也就是跳一句。
|
||
|
||
屏幕层还有一个必须走的公共路径:所有翻页都经过 `turnPage()`。原因是引擎锚定在
|
||
**当前章节的 DOM 节点**上,绕过这个方法的翻页会让引擎对着一个已经脱离文档的节点
|
||
继续朗读——听起来就像客户端在自言自语。
|
||
|
||
### 分窗是传输边界,目录是目录
|
||
|
||
`/manifest` 与 `/items` 都接受 `?group=N`,一次一个窗口(epub 40 章、漫画一卷)。
|
||
省略 `group` 等价于 `group=0`——这个默认值是后来改的,值得记一笔:最初省略参数
|
||
意味着「发全部条目」,于是一本 1200 章的书在它**最需要快的那个请求**(打开书)里
|
||
收到 1200 条记录。分窗如果只对显式传参的客户端生效,等于没做。
|
||
|
||
目录则相反:`/toc` 必须完整。两者要的性质相反,合成一个接口必然做错一个——
|
||
第一版就是这么做的,结果是目录面板显示「第 1 章 – 第 40 章」,服务端的分页边界
|
||
漏进了 UI。
|
||
|
||
### 章节的读取代价
|
||
|
||
一次章节读取 = 一次中央目录读(打开时)+ 一次条目的局部头 + 该条目的数据。
|
||
这是 `zip-reader.ts` 只解析中央目录、`openStream` 按偏移读单个条目的原因;
|
||
整包 `loadAsync` 会让「翻到下一章」变成「重新解压整本书」。
|
||
|
||
客户端的对等物是 `formats/remote-zip.ts`:同样的中央目录解析,走 HTTP Range。
|
||
它存在的意义是让**浏览器端**也能不下整本书——否则「服务端分章」只对原生端成立。
|
||
|
||
(注意:`check-shell.sh` 里新增的渲染模式词表校验,以及 `colors.xml` 里
|
||
`--` 在 XML 注释中非法、Kotlin 侧 `Uint8Array` 不存在这两处,
|
||
都是**第一次真正编译 Android 壳**时才暴露的。写不能编译的代码等于没写。)
|