ReadViewSDK/Doc/EPUB_MAINTENANCE.md
2026-05-21 19:40:51 +08:00

348 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# EPUB 阅读模块维护与排查指南
## 1. 文档目的
本文档说明 EPUB 阅读模块的关键文件职责、主要数据流、常见问题排查和后续扩展建议,供维护时快速建立上下文。
完整架构设计请参考 [ARCHITECTURE.md](ARCHITECTURE.md)。
编码规范请参考 [CODING_STYLE.md](CODING_STYLE.md)。
---
## 2. 关键文件速查
### 2.1 EPUBCore 层
| 文件 | 职责 |
|------|------|
| `RDEPUBParser.swift` + 扩展 | EPUB 解压、container.xml、OPF、manifest、spine、TOC/NCX 解析 |
| `RDEPUBPublication.swift` | 解析结果的唯一聚合入口,外部不直接访问 parser 字段 |
| `RDEPUBModels.swift` | metadata / manifest / spine / TOC 基础模型 |
| `RDEPUBReadingModels.swift` | RDEPUBLocation、RDEPUBViewport、RDEPUBHighlight、选区模型 |
| `RDEPUBResourceResolver.swift` | href → fileURL / schemeURL 的统一入口,`normalizedHref` 标准化路径 |
| `RDEPUBPaginator.swift` | 离屏 WKWebView 分页,计算每个 spine 资源被视口切成多少页 |
| `RDEPUBReadingSession.swift` | 状态机 + 会话协调,管理 staged/active/pending 页模型 |
| `RDEPUBNavigatorState.swift` | 状态枚举initializing/loading/idle/jumping/moving/repaginating |
| `RDEPUBWebView.swift` + 扩展 | 承载 spine 资源的 WKWebView分页 CSS 注入、JS bridge、fixed wrapper |
| `RDEPUBResourceURLSchemeHandler.swift` | `ss-reader://` 协议,从本地解压目录提供所有 EPUB 资源 |
| `RDEPUBStyleSheetBuilder.swift` | 生成注入 WebView 的 CSS |
| `RDEPUBJavaScriptBridge.swift` | JS ↔ Swift 消息定义和编解码 |
### 2.2 EPUBTextRendering 层
| 文件 | 职责 |
|------|------|
| `RDEPUBTextRenderer.swift` | 渲染引擎协议 + 错误类型 + 样式 / 内容模型 |
| `RDEPUBDTCoreTextRenderer.swift` | DTCoreText 实现HTML → NSAttributedString |
| `RDEPUBTextRendererSupport.swift` | fragment 标记注入、fragment offset 提取、属性标准化 |
| `RDEPUBTextPaginationSupport.swift` | NSAttributedString 切页算法 |
| `RDEPUBTextBookBuilder.swift` | 驱动逐章节渲染和分页,生成 RDEPUBTextBook |
### 2.3 EPUBUI 层
| 文件 | 职责 |
|------|------|
| `RDEPUBReaderController.swift` | 开箱即用读者控制器,整合 session / readerView / UI |
| `RDEPUBReaderConfiguration.swift` | 外部配置项(字号、主题、翻页模式等) |
| `RDEPUBReaderPersistence.swift` | 阅读位置和高亮的本地持久化 |
| `RDEPUBReaderSettings.swift` | 运行时设置状态 |
| `RDEPUBWebContentView.swift` | WKWebView 内容视图,供 RDReaderView 渲染 WebView 类 spine |
| `RDEPUBTextContentView.swift` | 文本内容视图,供 RDReaderView 渲染文本类 spine |
---
## 3. 资源服务协议说明
加载 spine 资源统一使用 `ss-reader://` 协议,不直接使用 `file://`
```swift
// 正确方式
let schemeURL = publication.resourceResolver.schemeURL(forHref: spineItem.href)
webView.load(URLRequest(url: schemeURL))
// 禁止方式relative 资源、图片、CSS 会失效)
webView.loadHTMLString(htmlString, baseURL: nil)
```
这样 HTML 内部的相对 CSS、图片、字体和 iframe 资源都会通过同一个 schemeHandler 解析,避免 `loadHTMLString``allowingReadAccessTo` 导致的资源失效问题。
### 3.1 DTCoreText 实现逻辑textReflowable 路径)
`readingProfile == .textReflowable` 时,渲染链路不再走 WebView 列分页,而是走 `EPUBTextRendering/` 的 DTCoreText 文本渲染链。
**端到端流程:**
1. `RDEPUBTextBookBuilder` 按 spine 顺序读取章节 HTML。
2. `RDEPUBTextRendererSupport.injectFragmentMarkers(into:)` 注入 fragment 标记,保证目录锚点可映射到文本偏移。
3. `RDEPUBDTCoreTextRenderer.renderChapter(html:baseURL:style:)` 执行章节渲染。
4. 渲染成功时,内部通过 `DTHTMLAttributedStringBuilder` 将 HTML 转为 `NSAttributedString`
5. `RDEPUBTextRendererSupport.extractFragmentOffsets(from:)` 提取 `fragment -> 字符偏移` 映射。
6. `RDEPUBTextRendererSupport.normalizeReadingAttributes(in:style:)` 统一字体、行距、颜色等阅读属性。
7. `RDEPUBTextPaginationSupport` 将富文本按可视区域切页,构建页模型。
8. `RDEPUBTextBookBuilder` 汇总章节与页映射,交给 `RDEPUBTextContentView` 呈现。
**关键实现点:**
1. 条件编译:`#if canImport(DTCoreText)`
- 可用时走 DTCoreText 路径。
- 不可用时走 fallback`NSAttributedString` 降级),确保工程可编译可运行。
2. `dtOptions(baseURL:style:)` 会注入:
- 默认字体族 / 字体名 / 字号
- 行高倍数(由字体行高与行距折算)
- `NSBaseURLDocumentOption`(保证相对资源解析)
- 默认文本颜色
3. fragment 偏移在“属性标准化前后”都要保持稳定,避免目录跳转漂移。
4. 文本分页以字符范围为准,不以 WebView 像素滚动位置为准。
**失败与降级策略:**
1. HTML 转 UTF-8 失败:抛出 `htmlEncodingFailed`
2. DTCoreText builder 失败:进入 fallback 渲染路径。
3. fallback 仍会执行 fragment 提取与阅读属性标准化,保证目录跳转和阅读样式能力不丢失。
**与定位恢复的关系:**
1. 仍使用 `RDEPUBLocation``href + progression`)作为跨会话恢复模型。
2. textReflowable 模式下,`fragmentOffsets` 用于将目录锚点映射到分页后的字符区间,再换算到页索引。
3. 字号/行距变化触发重分页后,优先按 `href + progression` 回落恢复,再用 fragment 做细化定位。
**推荐日志点DTCoreText 专用):**
1. `RDEPUBDTCoreTextRenderer.renderChapter(...)`:记录章节 href、输入 HTML 长度、渲染耗时。
2. `makeAttributedString(from:baseURL:style:)`:记录 DTCoreText 是否成功返回 attributed string。
3. `extractFragmentOffsets(from:)`:记录 fragment 数量与关键锚点是否命中。
4. `RDEPUBTextPaginationSupport`:记录总页数、每页字符范围边界。
---
## 4. 常见问题排查
### 4.1 EPUB 打不开或解析失败
**优先检查:**
1. `RDEPUBParser.parse(epubURL:)` 是否抛出异常,打印具体错误
2. `RDEPUBParser+Archive.swift` — 解压是否成功
3. `META-INF/container.xml` 是否存在
4. OPF 中 manifest / spine 是否完整
**常见原因:**
- 文件不是标准 EPUB ZIP 结构
- `container.xml` 找不到 OPF 路径
- OPF manifest 或 spine 格式异常
- 文件路径包含特殊字符导致解压目录路径错误
**排查顺序:**
1. 确认 EPUB 已成功复制到沙盒
2. 确认解压目录存在(`extractionRootURL`
3. 确认 `opfDirectoryURL` 不为 nil
4. 确认 `spine.count > 0`
---
### 4.2 EPUB 显示空白页
**优先检查:**
1. `RDEPUBWebContentView.loadPage(...)` 传入的 spineIndex 是否在范围内
2. `RDEPUBWebView+Reflowable` — 分页 CSS 注入是否成功
3. `WKNavigationDelegate.didFinish` 是否正常触发
4. `RDEPUBResourceURLSchemeHandler` 是否正确返回资源数据
**常见原因:**
1. scheme URL 拼装错误,资源请求 404
2. 注入的 CSS 破坏了原始布局(尤其是 `column-width` 样式冲突)
3. fixed-layout 书籍走了 reflowable 渲染路径readingProfile 判定错误)
**排查顺序:**
1. 确认 `schemeURL(forHref:)` 返回的 URL 对应文件真实存在
2.`RDEPUBWebViewDebug` 打开 WebView inspector 查看网络请求
3. 确认 readingProfile 判定正确webInteractive / webFixedLayout / textReflowable
---
### 4.3 修改字号后位置恢复不准
**原因:** 字号变化触发重新分页repaginating页号失效。
**正确做法:**
当前实现依赖 `href + progression` 恢复位置,不依赖纯页号。排查重点:
1. `RDEPUBLocation.href` 是否已通过 `normalizedHref` 标准化
2. `progression` 是否在字号变化后重新从 WebView 读取
3. `RDEPUBReadingSession.navigatorState` 是否在重分页后回到 `.idle` 并消费了 staged snapshot
---
### 4.4 目录点击后跳错位置
**优先检查:**
1. `RDEPUBParser+TOC.swift` — TOC href 是否正确标准化为相对 OPF 路径
2. `RDEPUBResourceResolver.normalizedHref(_:)` — 跳转时是否使用了标准化 href
3. `fragment` 是否正确传入 WebView 锚点滚动
**常见原因:**
1. TOC 链接是相对 Nav 或 NCX 文件目录,未转为相对 OPF
2. `href#fragment` 只命中了资源,未命中锚点
3. 同一资源中多个 TOC 条目被折叠成同一页
**排查步骤:**
1. 打印原始 TOC href 和标准化后 href 进行比较
2. 确认 `fragment` 字段被保留在 RDEPUBLocation 中
3. 确认 WebView 收到 `fragment` 后执行了 `scrollToFragment(fragment:)` JS 调用
---
### 4.5 正文内部链接不工作
**优先检查:**
1. `RDEPUBWebView+JavaScriptBridge` 中内部链接拦截逻辑
2. `RDEPUBReaderController.navigateToLocation(_:)` — 跨资源跳转路径
**常见原因:**
1. 链接是空 `#fragment`,但当前 DOM 中找不到对应元素
2. 跨资源链接 href 未被 normalizedHref 处理
3. 对应 spine 资源没有分页结果pageCounts[spineIndex] == 0
---
### 4.6 图片多时位置跳动
**原因:** 图片异步加载导致 DOM 高度变化,进度报告时机早于实际布局稳定。
**排查:**
1. 检查 JS `ResizeObserver` 是否在图片加载后触发了进度重同步
2. 确认 `didUpdateLocation` 不在 pending navigation 未消费前覆盖目标位置
3. 确认 `scrollToLocation()` JS 调用时序在 `didFinish` 之后
---
### 4.7 高亮未显示或恢复失败
**优先检查:**
1. `RDEPUBHighlight` 是否成功写入 persistence
2. `RDEPUBReaderController.activeHighlights` 是否正确过滤出当前 spine 的高亮
3. `RDEPUBWebView+JavaScriptBridge``setHighlights()` JS 调用是否触发
4. JS 中 `rangeFromInfo()` 是否能找到对应 DOM 节点(嵌套节点恢复容易失败)
**排查顺序:**
1. 确认有效选区(`currentSelection` 不为 nil
2. 确认 `addHighlight()` 调用后 persistence 中数据已写入
3. 确认当前页传入的 highlights 数组不为空
4. 确认 JS 收到了 highlights 数据并尝试了 rangeFromInfo 恢复
---
### 4.8 fixed-layout 书籍显示异常
**优先检查:**
1. `RDEPUBPublication.readingProfile` 是否判定为 `.webFixedLayout`
2. `RDEPUBFixedLayoutTemplate` — wrapper HTML 是否正确生成
3. viewport meta 尺寸是否与书籍 OPF 中声明一致
4. spread 模式是否正确(单页 vs 双页)
---
## 5. 推荐日志点
遇到复杂问题时,建议先在以下位置加日志:
1. `RDEPUBParser.parse(epubURL:)` — 打印解压路径、OPF 路径、spine 数量
2. `RDEPUBReadingSession.transition(to:)` — 打印状态跃迁
3. `RDEPUBPaginator` — 打印每个 spine 资源的分页结果
4. `RDEPUBReaderController.restoreLocation(_:)` — 打印恢复位置的 href / progression
5. `RDEPUBWebView+JavaScriptBridge` — 打印所有 JS 消息收发
6. `RDEPUBResourceURLSchemeHandler` — 打印资源请求 URL 和响应状态
---
## 6. 标准排查顺序
当 EPUB 功能出问题时,建议按以下顺序排查:
1. **解析是否成功** — parser.spine.count > 0metadata 正确?
2. **readingProfile 是否正确** — 用了对的渲染路径?
3. **TOC href 是否标准化** — 与 spine href 格式一致?
4. **分页结果是否合理** — 每个 spine 资源的 pageCount > 0
5. **资源是否能正常加载** — ss-reader:// 请求是否 200
6. **JS bridge 是否正常** — progression 上报是否到达 Swift 侧?
7. **状态机是否流转正确** — session.navigatorState 是否符合预期?
8. **控制器是否正确映射页号** — EPUBPage 列表与 RDReaderView 页号对应是否正确?
---
## 7. 后续扩展建议
1. **拆分 demo 层 RDReaderController**:约 1900+ 行EPUB 加载、UI、数据源职责仍混在一起建议继续向 library 层迁移
2. **基线验证**:用四本样书跑一轮分页耗时、目录命中率、末页事件、设置恢复稳定性数据
3. **横竖屏支持回归矩阵**:控制器级 `viewWillTransition()` 与 viewport 去重已接入,下一步重点是补齐 pageCurl / scroll、三条正文路径和 iPad 视口变化场景的固定回归
4. **Pod 分层**:评估是否将 EPUBCore / EPUBTextRendering / EPUBUI 拆为独立 subspec
5. **RDEPUBParser 继续收敛**OPF metadata 细节处理仍偏重,可考虑按 OPFMetadataParser 单独拆出
---
## 8. 阅读器能力现状与下一阶段规划
### 8.1 当前已具备的核心能力
当前 SDK 已具备一套可用的 EPUB 阅读器主链路,包含:
1. **EPUB 打开与解析**:支持 ZIP 解压、OPF / spine / TOC 解析,以及三类 readingProfile 分流
2. **正文渲染与分页**:支持 WebView 路径和 DTCoreText 文本路径,覆盖固定版式、交互式可重排和纯文本可重排 EPUB
3. **完整阅读交互**:支持目录跳转、翻页、阅读位置恢复、主题切换、字号和行高调整、亮度调整
4. **标注能力**:支持高亮、划线、批注、标注列表、编辑、删除、跳转与持久化恢复
5. **书签能力**:支持当前位置书签添加/取消、书签列表、删除、跳转与持久化恢复
6. **自动化测试基础能力**:已接入 `ReadViewSDKDemoTests` / `ReadViewSDKDemoUITests`、样书夹具定位工具,以及 parser / resolver / persistence 首批 XCTest 和阅读器启动 smoke 用例
7. **搜索能力**:支持全文搜索、匹配项跳转、当前匹配高亮
### 8.2 高优先级待补能力
从“可用 MVP”走向“稳定可交付阅读器”当前最值得优先完成的是
1. **样书基线验证**
需要基于四本样书补齐分页耗时、目录命中率、末页事件、设置恢复稳定性等基线数据
### 8.3 面向完整商业阅读器的下一阶段能力
若目标是继续向完整阅读产品演进,建议后续补齐以下能力:
1. **阅读进度展示**
包括全书进度、章节进度、页码信息、剩余页数或剩余章节等
2. **更完整的标注管理**
包括按章节分组、排序、筛选、批量删除、标注导出、复制批注内容等
3. **更成熟的搜索体验**
包括搜索结果列表页、章节聚合、上下文预览、搜索历史等
4. **更多阅读增强能力**
包括字体切换、自定义主题、翻页手势自定义、TTS 朗读、无障碍支持等
### 8.4 工程侧持续建设建议
除了阅读器功能本身,还建议并行推进以下工程项:
1. **控制器继续拆分**
`RDEPUBReaderController` 与 demo 层控制器都偏大,继续叠加功能会增加维护成本
2. **Parser 与模块职责继续收敛**
`RDEPUBParser` 仍承担较多 OPF metadata 解析细节,后续可继续按职责拆分
3. **回归验收矩阵固化**
建议把样书、渲染路径、主题、字号、翻页模式、标注恢复等整理为固定回归清单
### 8.5 推荐下一阶段实施顺序
如果以“最小投入换最大稳定性提升”为目标,建议按下面顺序推进:
1. **样书基线验证**