# 架构与数据边界 本文记录**为什么这样设计**,尤其是那些一旦写错就很难回头的决策。 早期产品设计中的约束在这里落成了实现,逐条对应;当前行为以代码和 API 文档为准。 影音采用独立数据库和服务线程,详见[影音架构](media-implementation.md)及[写入隔离](media-write-isolation.md)。 --- ## 1. 数据边界 | 位置 | 内容 | 读写 | | --- | --- | --- | | `BOOKS_DIR` (`/books`) | 用户自己的电子书;管理员可执行文件管理操作 | 按挂载权限读写 | | `DATA_DIR` (`/data`) | SQLite、封面缓存、扫描记录、上传暂存、令牌密钥和影音数据 | 读写 | | 客户端本地 | 离线书文件缓存、本地进度 | 读写 | 服务端是唯一真相源。客户端只承担「渲染 + 缓存」。 ### 数据目录边界 `src/config/index.ts` 在启动时断言 `DATA_DIR` 不在 `BOOKS_DIR` 内,否则拒绝启动。这是运行时硬约束。 `BOOKS_DIR` 是否只读由部署挂载权限决定:只读挂载适合浏览和阅读;可写挂载允许管理员文件管理器上传、改名、移动、删除和新建目录。 扫描器与所有文件访问都经过 `src/lib/paths.ts` 的 `resolveInside()`—— 唯一的路径解析出口,`../` 逃逸会抛 `PATH_TRAVERSAL`。 扫描时也**不跟随符号链接**:指向挂载点外的软链会读到用户没打算共享的文件。 封面缓存写在 `DATA_DIR/covers/`,不写在书旁边;服务端也不会创建 `.calibre` 或其它书旁元数据文件。 --- ## 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` 整个批在**一个事务**里。断网不会留下写了一半的进度。 ### 「最近阅读」是书架的一个排序,不是第二排封面 `SyncService.recentlyRead` 注入 `ShelfService`,把书行的组装交给它,自己只负责 「按最近阅读时间排序」和那两个进度字段。 理由是一次已经发生过的漂移。#40 的「书架上点击继续阅读,提示书本不在书架上」 的根因就在这条缝里,而且是两层: - **可见性判据**曾经是 `hidden = 0` 就完了,而书架还要求「存在一个未丢失的文件」。 于是一本文件被删掉、或唯一副本在没插上的移动盘上的书,仍然被画在「继续阅读」 这一行里,点开必然 404 —— 那句话是真的,错的是那张卡片,而它正画在同一屏 「共 N 本」的下面。修法是把判据做成同一句话,而不是教客户端先查一遍: 先查一遍只会把「读者点不开的书」修成「读者看不见的书」。 - **字段形状**:端点返回进度行自己的命名(`bookId` / `updatedAt`),而客户端的 `ContinueReadingItem extends Book` 读 `id`。`title` 和 `coverUrl` 恰好两种拼法 同名,所以卡片画得好好的,点下去却把 `{ id: undefined }` 交给路由—— `#/book/undefined`、404、然后被报成「这本书不在书架上了」。 两个手写类型之间的字段名漂移,中间没有任何一个请求里出现过正确的那个名字。 一条规则:**一个屏幕上的同一本书,只能是同一个对象。** 所以书行只在 `ShelfService` 里组装一次(`toDto` / `getMany`),其它接口要书行就调它; 「最近更新」被覆盖层影响,卡片也得跟着变。 **那条「继续阅读」横排本身已经没有了**,而它消失的方式正是上面这条规则的另一半: 十张卡片画的是下面网格里已经有的那十本。现在同一个 `/library/continue` 端点还在被调, 但它只是**排序键**——`recent` 这个顺序需要 `lastReadAt`,而那不在 `BookDto` 上。 于是「同一本书只有一个对象」推进了一步:同一本书在一屏上也只有**一张封面**, 而“我读到哪了”由那张封面自己的进度条回答。 ### 「在不在书架上」只有一句话,两个屏幕都念它 一次报告(#40「补全完善书架的加入、移除逻辑」)把同一个问题从两端问了一遍, 两端答得不一样,而且**各自看起来都对**: - **服务端**:`/books` 说「`user_books.hidden = 0` **且**存在一个未丢失的文件」, `/library/browse` 的 `shelfState` 只读 `hidden = 0`。一行 `hidden = 0`、 文件掉了的书于是**同时**是「不在书架上」(书架)和「在书架上」(书库文件页)。 把这两种状态分开描述,等于让两个屏幕各自说一半真话,而读者没有任何办法知道 哪一半是旧的。 现在 `shelfState` 走的是同一对条件,`'off'` 就是「书架上没有它」。 - **客户端**:书库浏览页和书架页各自实现了一遍「书 ↔ 文件」的 join(按文件主名匹配), 以及各自一套动作文案。两份实现就是两次漂移机会,而漂移的形态是**同一屏上 同一本书两个名字**——这不是假设,这个 bug 在「最近阅读」那条缝里已经发生过一次 (§5)。 那个 join 后来被**整个删掉**了,而且它应该更早就被删掉:它不是一个实现得不够好的 join,它是一个**不需要存在**的 join。卡片手里有 `book.id`,服务端的 `book_files` 早就知道这个 id 的文件在哪,客户端却拿**书名**去和文件名对——而书名不等于文件名是 **常态**(扫描器从文件里读书名),列表还是分页的。「找不到「xxx」在磁盘上的路径」 就是这样来的。写入现在带 id(`docs/api.md` §`/library/browse/shelf`), 两个方向的措辞留在 `web/src/ui/shelf-membership.ts`,两个屏幕都调它, 而「书 ↔ 文件」的连接**不在客户端**。 这条值得当成一条规则:**客户端不应该重建服务端已经知道的事实。** 一个 join 只要有一端是猜的,它的失败就长得像「这本书不存在」,而不是像 「这个 join 猜错了」——读者拿到的是一句关于他自己书架的错误陈述。 **为什么「写完之后要重读」是这条规则的必然结果。** `shelfState` 是**响应字段**, 不是客户端的记忆:书库页写入之后如果只弹一句报告,卡片上的「加入书架」按钮就还在, 而它的下一次点击什么都不会发生。写入路径因此在同一个 `try` 里先重读、再报告—— 顺序不能反,反了就是「重读把报告覆盖掉」的那个老问题(§5 里 `manager-status` 已经为此改过一次)。 **「移除」这件事为什么必须在书架那一屏。** 书库**曾经**能下架,但书库是**管理员那一半** (§7 的路由按角色守),成员打不开。只有书库能下架,等于只有管理员能整理自己的书架。 所以书架上每张封面自己带一个「⋯」,写入前用一个确认框说清「磁盘上的文件一个都不会动」—— 那句话才是读者真正在问的。`docs/ui.md` §3.5.2/§3.5.3 写的是它的形状与命中区。 **两个方向现在只在读者那两屏上,而文件页只剩「写盘」。** #40 的下一轮把文件页的 「从书架拿掉」拿掉了,理由不是位置而是**语义**:文件页上其他每一个控件都改挂载盘, 而 `下架` 改的是一行 `user_books`、一个文件都不动。它因此是那个菜单里唯一后果和页面 承诺对不上的条目,而它挨着的就是「删除」。`docs/ui.md` §3.5.1。 **浏览页问的是索引,不是读者自己的书架。** 它原来读 `/books`(也就是书架), 于是返回的每一本都已经在书架上——这一页存在的那个动作(「加入书架」)**永远画不出来**: 一个商城只陈列你已经买过的东西。现在它读 `scope=library`(`docs/api.md`), 每一项带 `shelfState`,于是「这一页有 N 本还不在书架上」和封面上的角标说的是同一件事。 **离线镜像是缓存,所以「少了一本书」它自己推不出来。** `offline.replaceBooks` 只会**加**:它拿到的是一页书,它没有任何办法知道「这一页里没有的那本书」是读者刚拿掉的、 还是这一次没请求到。于是书架在**冷启动的第一帧**画的是 `offline.books()`, 而那一帧里被拿掉的书还在——在线时下一帧会被服务端的答案替换掉,离线时永远不会。 所以移除路径显式调一次 `offline.removeBooks(result.books)`,而且是在 `refresh`**之后**: 放在前面只有在「服务端已经不再返回这本书」时才正确,而那正好是把镜像写成 「一个带 bug 的缓存」的那个假设。`result.books` 是服务端自己说它改过的那几本, 两边都是它说了算。 ### 增量拉取 `GET /api/v1/sync?since=<上次的 serverTime>`。客户端记住上次返回的 `serverTime`, 每次只拿增量。移动网络下重连很便宜。 ### 一次写入只发一个请求 `POST /api/v1/sync` 的响应**就是合并后的状态**(服务端在两个接口上返回同一份 `pull`)。所以推完之后**不要再拉一次**——`SyncEngine` 直接拿 push 的返回值喂给 `applyPulled`,没有东西可推(空闲轮询)时才真的发 `GET`。 之前不是这样:push 的返回值被丢掉,然后为了同一份数据再发一个 `GET`。于是读者每 翻一页都发一个 POST **加** 一个 GET,网络面板里就是成对出现的 `sync` ——这正是 「sync 接口在不停重复」的来源。 ### 分页的位置:服务端切页,客户端算不了 两个列表的 `total` 和「这一页是哪些行」必须是**同一次行走**的结果。分开算 (第二次 `readdir`、或者一个缓存的计数)会让摘要、分页器和它下面那几行在任何一次 磁盘变化之后互相矛盾,而读者没有办法判断三个数字里哪个是旧的。所以 `BrowseService.list` 一次走完整个目录,用遍历的结果同时产出计数和这一页。 书库的分页上界(`BROWSE_PAGE_SIZE_MAX`)是刻意的:一个目录可以是文件系统里的 任何东西,把四千行发给浏览器再丢掉 3940 行是那个让屏幕显得坏掉的请求,而调用方 不应该能用一个参数换回它。 ### 状态只在变化时广播 `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 框架:完整 React 应用 前端由 React 19 负责应用页面和页面生命周期,入口是 `web/src/main.tsx` 的 `createRoot()`。启动阶段先由 `web/src/app/runtime.ts` 组装平台、API、同步、离线、播放器和设置资源,再由 `AppProviders` 把它们注入 React 树: - `react-router-dom` 的 `createHashRouter` 负责 Hash 路由、嵌套路由、参数、Outlet 和错误边界; - `@tanstack/react-query` 的 `QueryClientProvider` 负责服务端状态、缓存、加载、重试和失效; - Zustand stores 负责认证、设置、同步和播放器等跨页面客户端状态; - Mantine 提供基础组件、主题和通知,Lucide React 提供统一图标; - `RuntimeContext` 暴露 API、同步、播放、设置和平台能力,页面不自行组装这些依赖。 业务按 `features/` 拆分,每个领域按实际需要拥有 `pages`、`components`、 `queries`、`mutations`、`services` 和 `styles`。影音实现位于 `web/src/features/media`;登录、书架、书库、书源、设置和阅读页面也通过各自 feature 接入同一棵路由树。旧的 `web/src/media`、旧 Screen 页面、旧手写生产路由 和 `mountUI` 已从生产入口移除。`web/src/app.ts` 与 `app/legacy-compat.ts` 只保留 Android 诊断和旧测试需要的兼容形状,不负责页面调度。 React 组件只声明布局和状态,副作用放在 effect、query 或 service 中。这样登录、 书架、书库、书源、设置和影音页面共享同一套生命周期语义,加载、错误、空状态和 返回行为都能由路由与查询状态复现和测试。 ### 路由:URL 就是屏幕 界面的第二个结构性问题不是「怎么画」,而是「在哪」。最初的 shell 用 `showShelf()` / `openBook()` / `showManager()` 加一个 `pendingBook` 字段来换屏, URL 从头到尾不变。手机上这一点几乎看不见——直到读者按返回键:Android 的返回手势 交给 Activity,Activity 看到 WebView 没有历史,于是直接从书架上退出应用。 浏览器上则更糟,地址栏是自托管书库唯一的分发方式,而它既不能分享也不能收藏: 一本书链接不出去,刷新永远回到书架。 所以路由是**值**,不是调用:`web/src/app/router/routes.tsx` 声明 `RouteObject`,`createHashRouter()` 将 fragment 映射到嵌套 React 页面,导航就是 改 URL、让变化回流。 **为什么用 fragment 而不是 `history.pushState` 的路径。** 同一份产物要被两个宿主加载, 而两个宿主都服务不了任意路径:Android 从 `https://appassets.androidplatform.net/` 加载(那里没有服务端,除文档本身以外的路径全是 404),H5 由 reader 服务端托管, 而它没有「客户端路由回落到 `index.html`」这条规则。路径式路由会在 `vite dev` 里一切正常, 在两个生产宿主里 404。fragment 是 URL 里唯一保证属于客户端的一段,所以它是唯一 三处都成立的方案——代价是一个 `#`。 **返回由 React Router 管理。** 页面使用 `Link`、`useNavigate` 和路由层的父级关系, 不再由每个 Screen 自己维护一份 history。嵌套页面通过显式返回按钮回到父路由,浏览器 的 Back/Forward 和刷新都能重建同一页面状态。 当前路由树的稳定入口包括: | 路由 | 页面 | | --- | --- | | `#/shelf` | 书架 | | `#/library`、`#/library/books`、`#/library/files` | 书库浏览、书籍列表、文件管理 | | `#/sources` 及 `#/sources/...` | 书源中心、浏览、凭据、扩展和插件管理 | | `#/settings` | 兼容旧深链接,重定向到书架 | | `#/book/:bookId` | 阅读器 | | `#/media/:channel` | 影音频道目录 | | `#/media/:channel/items/:itemId` | 影音详情及管理子页 | | `#/media/:channel/player?item=&part=` | 播放器 | | `#/media/:channel/settings/libraries`、`.../tasks` | 媒体库管理、扫描与刮削(旧的 `#/media/:channel/settings[/:panel]` 重定向到「我的」) | `/sources/search`、旧的 `/sources/:sourceId/:pageId` 扩展页形状和 `/media`、`/media/music`、`/media/audiobook` 默认入口仍由路由表兼容; 新页面应使用当前命名空间和嵌套路由。未知路径回到 `/media/video`。影音内部按频道、 收藏、历史、队列、搜索、详情和管理功能拆成嵌套路由,分页、筛选和播放器参数放在 路由参数或 query 中。 Hash 路由适配 WebView 和静态服务器:宿主只需要返回同一个 `index.html`,客户端从 `#` 后解析页面,不依赖服务端为每个深层路径配置回退规则。登录是认证边界而不是业务 页面;未认证时由 `AuthBoundary` 显示登录表单,认证完成后 React Router 渲染目标地址。 ### 阅读器与长生命周期资源 阅读器是唯一的特殊生命周期边界:`ReaderPage` 在 effect 中取得书籍后创建 `ReaderScreen`,把它的 `screen.element` 放入 route host,并通过 `runtime.registerReader()` 注册进度刷新回调。页面离开时先注销回调,再调用 `screen.dispose()` 并清空 host。ReaderScreen 内部的章节加载、分页量测、手势、 朗读和 Shadow DOM 仍是命令式 DOM;其 chrome 使用 React,`StageHost` 只负责把 ReaderScreen 持有的舞台节点放到 chrome 树中的正确位置。React 不介入书籍正文的布局量测。 播放器是应用级资源。AppProviders 把 `runtime.player.element` 挂到 `document.body`,启动 `PlaybackService` 对 `MediaPlayer` 的监听。路由切换只卸载 控制页面,不销毁播放会话;播放器发出 `open-controls` 时由 `AuthenticatedShell` 导航到带 `item` 和 `part` 查询参数的播放器路由。Provider 卸载时停止监听并移除 播放器节点,应用隐藏或离开时统一调用 runtime flush,重新联网时续期会话。 应用主题由 `AuthenticatedShell` 创建的 `ThemeController`(`web/src/shared/theme/theme.ts`) 统一应用,作用域来自 `runtime.mediaApi.preferenceScope()`。控制器只在 `` 上写 `data-theme`(warm、blue、green)和 `data-mode`(light、dark),颜色由 `web/src/styles/theme-tokens.css` 的 `--color-*` 语义令牌给出;系统深浅色、同页保存和 其他标签页的存储变化都会重新应用。阅读器选择黑白灰时只在自己的容器上写 `data-theme="gray"`,视频和音乐播放页在自己的根节点上使用当前主题的深色取值。 ### 只有一份渲染层 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),`