# 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. **样书基线验证**