348 lines
15 KiB
Markdown
348 lines
15 KiB
Markdown
# 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 > 0?metadata 正确?
|
||
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. **样书基线验证**
|