diff --git a/Doc/index.md b/Doc/index.md index 6de9e04..dc30931 100644 --- a/Doc/index.md +++ b/Doc/index.md @@ -1,6 +1,6 @@ # ReadViewSDK 文档索引 -> 最后更新:2026-06-09 +> 最后更新:2026-06-12 --- @@ -25,6 +25,7 @@ | 文档 | 说明 | |------|------| +| [当前阅读器问题修复开发清单.md](当前阅读器问题修复开发清单.md) | 基于当前代码实现整理的逐任务开发计划:10 个任务、修改位置、实施步骤、风险点与验收标准 | | [大书后台解析优化实施清单_30秒目标.md](大书后台解析优化实施清单_30秒目标.md) | 《凡人修仙传》后台解析性能优化方案,目标从 70s 压缩到 30-50s | ## 项目信息 diff --git a/Doc/当前阅读器问题修复开发清单.md b/Doc/当前阅读器问题修复开发清单.md new file mode 100644 index 0000000..802eaf6 --- /dev/null +++ b/Doc/当前阅读器问题修复开发清单.md @@ -0,0 +1,757 @@ +# 当前阅读器问题修复开发清单 + +> 最后更新:2026-06-12 +> 依据说明:本清单仅基于当前仓库代码实现整理,不以现有说明文档是否准确为前提。 + +--- + +## 1. 文档目标 + +本文将“当前阅读器还存在的问题”展开为可执行的逐任务开发清单,目标是把工作从“方向建议”落到“具体改哪里、怎么改、如何验收”。 + +本文聚焦以下四类问题: + +1. 核心交互链路不稳定:选区、高亮、书签、工具栏状态分散。 +2. 默认安全策略偏弱:外链、解压、日志、持久化默认行为需要收口。 +3. 资源与分页稳定性不足:大资源内存峰值、离屏分页 WebView 边界控制不足。 +4. SDK 契约与回归手段不足:默认 no-op 容易误用,自动化观测点不够结构化。 + +--- + +## 2. 总体实施顺序 + +建议按以下顺序执行,减少返工: + +1. `bookmark-chrome-state-unification` +2. `selection-state-refactor` +3. `external-link-policy-hardening` +4. `epub-archive-path-validation` +5. `webview-debug-sanitization` +6. `persistence-defaults-hardening` +7. `resource-scheme-streaming-and-limits` +8. `pagination-webview-hardening` +9. `cache-hardening` +10. `sdk-contract-and-test-hardening` + +原因: + +- 任务 2 是已确认的重复 UI 状态写入问题,收益最高、风险相对低,应优先处理。 +- 任务 1 更偏“状态模型收口优化”,问题真实存在,但严重度低于任务 2。 +- 第 3-6 项收口默认安全行为,改动局部、收益高。 +- 第 7-9 项涉及资源与分页底层,副作用更大,放在前面稳定后更容易验证。 +- 最后一项负责把前面改动固化为稳定契约和测试基线。 + +--- + +## 3. 任务清单 + +## 任务 1:`selection-state-refactor` + +### 3.1 目标 + +在不破坏当前清晰分层的前提下,收口选区相关状态的冗余表达,降低 view 层与 controller 层各持有一份选区状态所带来的维护成本和时序问题。 + +### 3.2 主要问题代码 + +- `Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextSelectionController.swift` +- `Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift` +- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift` +- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift` + +### 3.3 修改方案 + +当前代码并不是“混乱散写”,而是相对清晰的分层流: + +- gesture +- view +- coordinator +- context/controller + +其中真正值得优化的点主要有两个: + +1. view 层和 controller 层各自持有一份 `currentSelection` +2. `menuSelection` 是为 `UIMenuController` 时序保底引入的 workaround,属于合理存在,但可以尝试被更明确的状态模型替代 + +新增一个统一的选区状态模型仍然是可行方案,例如: + +```swift +enum RDEPUBSelectionState: Equatable { + case idle + case selecting(anchor: Int) + case selected(RDEPUBSelection) + case committingAction(RDEPUBSelection, action: RDEPUBAnnotationMenuAction) +} +``` + +建议新增文件: + +- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBSelectionState.swift` + +建议在 `RDEPUBReaderAnnotationCoordinator` 内新增或抽出一个单一入口: + +- `applySelectionState(_ state: RDEPUBSelectionState)` +- `currentSelectionState` + +具体修改: + +1. `RDEPUBTextSelectionController` + - 移除“自己就是最终状态源”的职责。 + - 保留字符范围计算与 `RDEPUBSelection?` 构建。 + - `isSelecting` 可保留为手势内部临时状态,但不再作为 UI 的最终依据。 + +2. `RDEPUBTextContentView` + - 不再直接通过 `currentSelection != nil` 作为所有 UI 行为的唯一判断条件。 + - 长按、拖拽、结束只上报“开始选择 / 更新选择 / 结束选择 / 取消选择”事件。 + - `menuSelection` 不建议直接删除。 + - 第一版应先保留 `menuSelection`,并把它明确标注为菜单时序缓冲状态。 + - 待状态模型稳定后,再评估是否能安全移除。 + +3. `RDEPUBReaderAnnotationCoordinator.updateCurrentSelection(_:)` + - 改造成 `applySelectionState(_:)`。 + - 在 `.selected` 时统一: + - 更新 `controller.currentSelection` + - 决定是否展示工具栏 + - 更新底部高亮按钮可用性 + - 通知 delegate + - 在 `.idle` 时统一清理: + - 清空 `controller.currentSelection` + - 关闭菜单 + - 刷新按钮状态 + +4. `RDEPUBReaderController` + - `currentSelection` 继续保留为公开只读语义,但底层来源改为 `selectionState` 推导,而不是多处直接赋值。 + +### 3.4 实施步骤 + +1. 新增 `RDEPUBSelectionState.swift` +2. 在 `RDEPUBReaderAnnotationCoordinator` 中接管选区状态 +3. 重写 `RDEPUBTextContentView` 中选区相关回调 +4. 先收口零散的 `currentSelection != nil` 控制逻辑 +5. 保留 `menuSelection`,待验证不再需要后再移除 +6. 统一 delegate 通知路径 + +### 3.5 风险点 + +- 选区菜单弹出时机可能变化。 +- `menuSelection` 是现有时序补丁,若贸然删除,菜单动作可能拿不到正确 selection。 +- Web 路径和 Native Text 路径都要走统一状态流,避免只修一条。 + +### 3.6 验收标准 + +- 长按后必然进入“选区中”状态。 +- 松手后有有效文本时必然进入“已选中”状态。 +- 复制/高亮/批注后必然回到空闲状态。 +- `selection=1` 的 Demo 状态与按钮启用状态始终一致。 + +### 3.7 优先级修正 + +本任务应视为“结构优化 + 降低后续维护成本”,不是当前代码库里最严重的缺陷。建议优先级下调到与任务 2 同级,排在任务 2 之后实施。 + +--- + +## 任务 2:`bookmark-chrome-state-unification` + +### 3.8 目标 + +统一顶部书签按钮、底部书签按钮、高亮按钮、添加高亮按钮的可用状态计算,避免不同协调器各自改一部分 UI。 + +### 3.9 主要问题代码 + +- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderChromeCoordinator.swift` +- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift` +- `Sources/RDReaderView/EPUBUI/RDEPUBReaderTopToolView.swift` +- `Sources/RDReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift` + +### 3.10 修改方案 + +新增统一 UI 状态模型: + +```swift +struct RDEPUBReaderUIState { + let canToggleBookmark: Bool + let hasBookmarkAtCurrentLocation: Bool + let canShowBookmarks: Bool + let canAddHighlight: Bool + let canShowHighlights: Bool +} +``` + +建议新增文件: + +- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderUIState.swift` + +具体修改: + +1. `RDEPUBReaderChromeCoordinator` + - 新增 `makeUIState()` 和 `applyUIState(_:)` + - `updateReaderChrome()` 内不再直接散写多个 `setXxxEnabled` + +2. `RDEPUBReaderAnnotationCoordinator` + - `updateBookmarkChrome()` 不再直接写 view,改为触发 `context.updateReaderChrome()` + +3. `RDEPUBReaderTopToolView` / `RDEPUBReaderBottomToolView` + - 保持 dumb view,只接收状态,不参与规则判断 + +### 3.11 实施步骤 + +1. 新增 `RDEPUBReaderUIState.swift` +2. 改造 `updateReaderChrome()` +3. 不要简单删除 `AnnotationCoordinator` 内的按钮状态刷新 +4. 区分两类触发时机: + - `ChromeCoordinator` 负责整页/整轮 chrome 同步 + - `AnnotationCoordinator` 负责用户操作后的即时反馈触发 +5. 将二者统一收口到同一个状态计算入口,例如 `context.updateReaderChrome()` +6. 调整书签新增/删除/跳转后的刷新入口为统一 `updateReaderChrome()` + +### 3.12 风险点 + +- 如果简单删除 `AnnotationCoordinator` 内的调用,可能丢失“用户操作后立即反馈”的刷新时机。 +- 如果某些按钮当前依赖副作用显示,需要顺便梳理显示时机。 + +### 3.13 验收标准 + +- 添加第一个书签后,底部书签列表按钮立即可用。 +- 删除最后一个书签后,底部书签列表按钮立即禁用。 +- 当前位置已加书签时,顶部书签按钮始终是选中态。 + +--- + +## 任务 3:`external-link-policy-hardening` + +### 3.14 目标 + +把“外链点击后直接打开系统应用”改成受控策略,避免 SDK 默认行为过于激进。 + +### 3.15 主要问题代码 + +- `Sources/RDReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift` +- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift` +- `Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderConfiguration.swift` + +### 3.16 修改方案 + +在 `RDEPUBReaderConfiguration` 新增: + +- `allowedExternalURLSchemes: Set` +- `requiresExternalLinkConfirmation: Bool` +- 可选:`allowedExternalURLHosts: Set?` + +默认值建议: + +- scheme 仅允许 `https` +- 需要确认框 + +具体修改: + +1. `RDEPUBWebView+JavaScriptBridge.swift` + - 继续识别外链,但不再表示“允许直接打开”。 + - 只负责把外链事件上抛。 + +2. `RDEPUBReaderController+ContentDelegates.swift` + - 新增私有方法: + - `shouldAllowExternalURL(_:)` + - `presentExternalLinkConfirmation(for:)` + - `openExternalURLIfAllowed(_:)` + - `didActivateExternalLink` 改为: + - 先 delegate 回调 + - 再走配置校验 + - 再弹确认框 + - 最后调用 `UIApplication.shared.open` + +3. 可选扩展 `RDEPUBReaderDelegate` + - 增加可选拦截钩子: + - `epubReader(_:shouldOpenExternalURL:) -> Bool` + +### 3.17 实施步骤 + +1. 改配置模型与默认值 +2. 改控制器外链处理逻辑 +3. 增加确认对话框 +4. 增加测试用 mock URL 覆盖 `https`、`mailto`、未知 scheme` + +### 3.18 风险点 + +- 某些 Demo 书可能依赖 `mailto` 或 `tel`,要明确它们现在默认不再直接打开。 + +### 3.19 验收标准 + +- `https` 外链在确认后打开。 +- 默认配置下 `mailto` 和 `tel` 不直接打开。 +- 未知 scheme 一律拒绝。 + +--- + +## 任务 4:`epub-archive-path-validation` + +### 3.20 目标 + +为 EPUB 解压路径增加显式安全校验,防止恶意压缩包通过相对路径写出解压根目录。 + +### 3.21 主要问题代码 + +- `Sources/RDReaderView/EPUBCore/RDEPUBParser+Archive.swift` + +### 3.22 修改方案 + +新增私有方法: + +- `validatedExtractionDestination(for:extractionRoot:) -> URL?` + +校验规则: + +1. 拒绝绝对路径。 +2. 拒绝包含 `..` 的路径段。 +3. `standardizedFileURL.path` 必须位于 `extractionRoot.standardizedFileURL.path` 下。 + +具体修改: + +1. `extractArchiveIfNeeded(epubURL:)` + - 在循环内先调用 `validatedExtractionDestination` + - 无效 entry 可: + - 直接抛错终止解析,或 + - 记日志并跳过 + - 建议优先抛错,行为更清晰 + +2. 增加自定义错误: + - 若当前 `RDEPUBParserError` 里没有合适 case,可新增 `invalidArchiveEntryPath(String)` + +### 3.23 实施步骤 + +1. 增加路径校验方法 +2. 替换原始 `destinationURL` 生成逻辑 +3. 增加针对恶意 entry path 的单元测试 + +### 3.24 风险点 + +- 少数历史 EPUB 可能带奇怪路径分隔符,需要兼容性验证。 + +### 3.25 验收标准 + +- 正常 EPUB 仍可正常解压。 +- 带 `../` 的恶意 entry 会被拒绝。 + +--- + +## 任务 5:`webview-debug-sanitization` + +### 3.26 目标 + +减少默认调试日志泄露阅读内容的风险,并关闭不必要的 inspectable 默认值。 + +### 3.27 主要问题代码 + +- `Sources/RDReaderView/EPUBCore/RDEPUBWebViewDebug.swift` +- `Sources/RDReaderView/EPUBCore/RDEPUBPaginator.swift` +- `Sources/RDReaderView/EPUBCore/RDEPUBWebView+Configuration.swift` + +### 3.28 修改方案 + +1. `RDEPUBWebViewDebug` + - `logMessage` 改为只打印: + - message name + - 字段名列表 + - 文本长度 + - URL 摘要 + - 不再打印完整 message body + +2. 新增更细粒度配置 + - `RDEPUBWebViewDebugEnabled` + - `RDEPUBVerboseWebViewDebugEnabled` + +3. `RDEPUBPaginator` 与正常阅读 WebView + - `isInspectable` 改为受配置控制 + - 默认关闭 + +4. `RDEPUBReaderConfiguration` + - 增加: + - `allowsInspectableWebViews` + - `enablesVerboseWebViewLogging` + +### 3.29 实施步骤 + +1. 改日志输出格式 +2. 改 inspectable 的默认逻辑 +3. 为 verbose 模式保留调试后门 + +### 3.30 风险点 + +- 调试某些 JS 问题时信息会变少,因此要保留显式 verbose 开关。 + +### 3.31 验收标准 + +- 默认 DEBUG 包不输出完整选中文本。 +- 默认分页 WebView 不可 inspect。 +- 打开 verbose 开关后仍能深度调试。 + +--- + +## 任务 6:`persistence-defaults-hardening` + +### 3.32 目标 + +降低默认 `UserDefaults` 持久化的误用风险和数据膨胀风险,同时不破坏当前 API 可用性。 + +### 3.33 主要问题代码 + +- `Sources/RDReaderView/EPUBUI/RDEPUBReaderPersistence.swift` +- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift` +- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift` + +### 3.34 修改方案 + +短期目标: + +1. 明确 `RDEPUBUserDefaultsPersistence` 是轻量实现。 +2. 高亮持久化优先依赖: + - `location` + - `rangeInfo` + - `style` + - `note` +3. `text` 字段作为冗余缓存,而不是唯一恢复依据。 + +中期可选目标: + +新增文件持久化实现: + +- `Sources/RDReaderView/EPUBUI/RDEPUBFilePersistence.swift` + +### 3.35 具体修改 + +1. `RDEPUBReaderPersistence.swift` + - 为默认实现增加 DEBUG 警告,提示 bookmarks/settings 默认实现是 no-op + +2. `RDEPUBUserDefaultsPersistence` + - 增加大小保护,例如当高亮集合序列化后超阈值时打印告警 + - 可选增加版本字段,便于后续迁移 + +3. `RDEPUBReaderController` + - 初始化默认 persistence 时,在注释与命名上明确这是默认轻量实现 + +### 3.36 实施步骤 + +1. 增加 warning/assert 机制 +2. 优化高亮持久化字段策略 +3. 视时间增加 `RDEPUBFilePersistence` + +### 3.37 风险点 + +- 如果外部代码依赖 `text` 永远存在,需要做好兼容。 + +### 3.38 验收标准 + +- 未实现书签持久化时,DEBUG 下能看到明确提示。 +- 高亮即使 `text` 丢失,也可基于范围信息恢复定位。 + +--- + +## 任务 7:`resource-scheme-streaming-and-limits` + +### 3.39 目标 + +降低 `WKURLSchemeHandler` 对大资源的内存冲击,避免每次都整块 `Data(contentsOf:)` 读入。 + +### 3.40 主要问题代码 + +- `Sources/RDReaderView/EPUBCore/RDEPUBResourceURLSchemeHandler.swift` + +### 3.41 修改方案 + +分两步做: + +第一步,先加大小阈值和告警: + +- 读取文件属性获取大小 +- 超过阈值时走分块发送 + +第二步,再补流式发送: + +- 使用 `FileHandle` 或 `InputStream` +- 按固定 chunk size 反复 `didReceive(data)` + +建议新增私有方法: + +- `resourceMetadata(for:)` +- `respondWithStreaming(fileURL:requestURL:taskID:urlSchemeTask:)` +- `respondWithInMemoryData(...)` + +### 3.42 实施步骤 + +1. 先加文件大小检测 +2. 小文件保留内存直读 +3. 大文件切换到分块发送 +4. 增加取消任务时的中断判断 + +### 3.43 风险点 + +- `WKURLSchemeHandler` 分块发送要注意 stop 之后不继续回调。 + +### 3.44 验收标准 + +- 大图片/字体加载时内存峰值下降。 +- 取消任务不会继续发送数据。 + +--- + +## 任务 8:`pagination-webview-hardening` + +### 3.45 目标 + +加强离屏分页 WebView 的边界控制,减少外部资源波动和调试副作用。 + +### 3.46 主要问题代码 + +- `Sources/RDReaderView/EPUBCore/RDEPUBPaginator.swift` + +### 3.47 修改方案 + +1. 为分页 WebView 增加严格导航限制 + - 仅允许 `file://` + - 仅允许 `ss-reader://` + - 拒绝 `http/https` + +2. 将 `isInspectable` 受配置控制 + +3. 增加测量稳定性日志 + - 记录三轮测量结果 + - 当三轮差异过大时告警 + +建议新增私有方法: + +- `shouldAllowPaginatorNavigation(url:)` +- `recordMeasurement(pass:value:)` + +### 3.48 实施步骤 + +1. 扩展 `WKNavigationDelegate` +2. 加入导航 allowlist +3. 记录多轮测量差异 + +### 3.49 风险点 + +- 某些 EPUB 样式若依赖外链资源,分页结果可能与当前行为不同,但这本身就是更安全的策略。 + +### 3.50 验收标准 + +- 分页 WebView 不发起外部导航。 +- 三轮测量波动可观测。 + +--- + +## 任务 9:`cache-hardening` + +### 3.51 目标 + +提升章节摘要磁盘缓存的健壮性、可观测性和可清理能力。 + +### 3.52 主要问题代码 + +- `Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterSummaryDiskCache.swift` + +### 3.53 修改方案 + +1. 原子写入 + - 先写 `.tmp` + - 再 replace 到正式文件 + +2. 读失败分类 + - 文件不存在 + - 读取失败 + - 解码失败 + +3. 精细化清理 + - `removeAll()` + - `removeAll(forBookID:)` + - `removeAll(forRenderSignature:)` + +4. 增加统计接口 + - 缓存文件数 + - 总大小 + +### 3.54 实施步骤 + +1. 改写入策略 +2. 增加读失败日志 +3. 增加清理接口 +4. 补测试覆盖损坏文件、半写文件 + +### 3.55 风险点 + +- 清理策略接口若公开,需要评估是否作为 public API。 + +### 3.56 验收标准 + +- 缓存文件损坏不会影响整本书重新打开。 +- 能按需清理缓存,而不是只能全删。 + +--- + +## 任务 10:`sdk-contract-and-test-hardening` + +### 3.57 目标 + +把前面所有修复固化为更清晰的 SDK 契约和更稳定的测试观测点。 + +### 3.58 主要问题代码 + +- `Sources/RDReaderView/EPUBUI/RDEPUBReaderPersistence.swift` +- `Sources/RDReaderView/EPUBUI/RDURLReaderController.swift` +- `ReadViewDemo/ReadViewDemoUITests/` + +### 3.59 修改方案 + +1. 强化 persistence 契约 + - 继续保留默认实现,但在 DEBUG 下对 no-op 行为给出明确信号 + +2. Demo 状态能力修正 + - 当前仓库并非完全没有结构化状态。 + - `ReadViewDemo/ReadViewDemoUITests/Helpers/DemoReaderState.swift` 已经提供了状态解析器和 `waitForDemoReaderState(...)` 结构化等待能力。 + - 真正需要推进的不是再造一个 `DemoReaderSnapshot`,而是把仍然大量存在的 `waitForReaderState(containing:)` 迁移到结构化断言。 + +建议方向: + +- 继续保留隐藏 label 输出 +- 保持 `key=value` 格式 +- 统一测试侧只通过 `DemoReaderState` 解析和断言 +- 逐步淘汰 `containing:` 子串匹配,避免 `highlights=1` 命中 `highlights=10` 之类误判 + +3. 补测试 + - 单元测试: + - `RDEPUBParserArchiveSafetyTests` + - `RDEPUBExternalLinkPolicyTests` + - `RDEPUBChapterSummaryDiskCacheTests` + - UI 测试: + - 选区状态流 + - 书签启用/禁用 + - 外链确认框 + +### 3.60 实施步骤 + +1. 改 Demo 状态串生成逻辑 +2. 优先将 `waitForReaderState(containing:)` 迁移为 `waitForDemoReaderState(...)` +3. 调整 UI 测试断言 +4. 增加单元测试 target 或现有测试目录内的新文件 + +### 3.61 风险点 + +- 现有 UI 测试如果强依赖旧状态串,需要同步迁移。 + +### 3.62 验收标准 + +- UI 测试失败时能明确知道是选区状态、书签状态还是导航状态失配。 +- persistence 的默认 no-op 行为在开发期不再“静默”。 + +--- + +## 4. 补充清理项 + +以下问题已在代码中可见,但未纳入前 10 个主任务,建议作为补充任务或穿插清理项处理。 + +### 4.1 `pageBreakBefore/pageBreakAfter` 相关死代码审计 + +涉及代码: + +- `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift` +- `Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBSemanticMarkerInjector.swift` +- `Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBPageBreakPolicy.swift` +- `Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBChapterPageCounter.swift` + +建议动作: + +- 审计 `pageBreakBefore/pageBreakAfter` 从注入到消费的完整链路 +- 判断是否有“已定义、已注入、但实际无有效触发”的死路径 +- 若确认无效,删除或补测试锁定其真实行为 + +### 4.2 `PAGINATION-DEBUG` 输出清理 + +涉及代码: + +- `Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBCoreTextPageFrameFactory.swift` +- `Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBChapterPageCounter.swift` + +建议动作: + +- 将裸 `print` 改为受控调试开关 +- 默认关闭 +- 避免分页细节和正文片段直接进入控制台 + +### 4.3 `ChapterRuntime` 旧代码与遗留路径清理 + +建议动作: + +- 梳理 `ChapterRuntime` 下是否存在已不再被主流程使用的辅助类型或过渡实现 +- 对“仍被引用但语义已经过时”的代码先补注释标记 +- 对“完全无调用”的代码建立删除候选清单 + +--- + +## 5. 推荐里程碑切分 + +### 里程碑 A:核心交互稳定 + +包含任务: + +- `selection-state-refactor` +- `bookmark-chrome-state-unification` + +完成标准: + +- 选区、高亮、书签相关 UI 测试恢复稳定 + +### 里程碑 B:默认安全行为收口 + +包含任务: + +- `external-link-policy-hardening` +- `epub-archive-path-validation` +- `webview-debug-sanitization` +- `persistence-defaults-hardening` + +完成标准: + +- 默认配置下不再直接打开高风险外链 +- 解压路径具备显式校验 +- 默认调试日志不泄露完整阅读内容 + +### 里程碑 C:底层稳定性优化 + +包含任务: + +- `resource-scheme-streaming-and-limits` +- `pagination-webview-hardening` +- `cache-hardening` + +完成标准: + +- 大资源加载和分页更稳定 +- 缓存更健壮、可清理 + +### 里程碑 D:契约与测试固化 + +包含任务: + +- `sdk-contract-and-test-hardening` + +完成标准: + +- SDK 默认行为更清晰 +- 回归测试对关键状态具备稳定观测能力 + +--- + +## 6. 建议的开发节奏 + +若按 2 周一个小迭代,可参考: + +1. 第 1 周:`bookmark-chrome-state-unification` + `selection-state-refactor` +2. 第 2 周:`external-link-policy-hardening` + `epub-archive-path-validation` + `webview-debug-sanitization` +3. 第 3 周:`persistence-defaults-hardening` + `resource-scheme-streaming-and-limits` + `pagination-webview-hardening` +4. 第 4 周:`cache-hardening` + `sdk-contract-and-test-hardening` 与回归收口 + +如果只想先做最值的部分,建议最小交付集合为: + +1. `bookmark-chrome-state-unification` +2. `selection-state-refactor` +3. `external-link-policy-hardening` +4. `epub-archive-path-validation` + +这四项完成后,阅读器的“主观可用性”和“默认安全性”会先提升一个台阶。 diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+DataSource.swift b/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+DataSource.swift index 5127551..123336d 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+DataSource.swift +++ b/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+DataSource.swift @@ -31,7 +31,7 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate { totalPages: pageCountOfReaderView(readerView: readerView), configuration: configuration, highlights: textHighlights(for: resolvedPage.page), - searchState: searchState + searchState: searchState(for: resolvedPage.page) ) return contentView } @@ -46,7 +46,7 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate { totalPages: textBook.pages.count, configuration: configuration, highlights: textHighlights(for: page), - searchState: searchState + searchState: searchState(for: page) ) return contentView } @@ -91,6 +91,57 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate { } } + /// 返回当前文本页需要展示的搜索状态。 + /// 这里会先按页过滤命中结果,并把 currentMatchIndex 重映射到当前页内的局部索引, + /// 这样渲染层不需要再关心 href 规范化差异。 + private func searchState(for page: RDEPUBTextPage) -> RDEPUBSearchState? { + guard let globalSearchState = searchState else { return nil } + + let matches = globalSearchState.matches.filter { searchMatch in + searchMatchBelongsToPage(searchMatch, page: page) + } + guard !matches.isEmpty || globalSearchState.currentMatch != nil else { + return globalSearchState.matches.isEmpty ? globalSearchState : nil + } + + let currentMatchIndex = globalSearchState.currentMatch.flatMap { currentMatch in + matches.firstIndex(of: currentMatch) + } + return RDEPUBSearchState( + keyword: globalSearchState.keyword, + matches: matches, + currentMatchIndex: currentMatchIndex + ) + } + + private func searchMatchBelongsToPage(_ searchMatch: RDEPUBSearchMatch, page: RDEPUBTextPage) -> Bool { + if let textBook, + let chapterData = textBook.chapterData(for: page.href), + let range = chapterData.absoluteRange(for: searchMatch) { + return NSIntersectionRange(range, page.contentRange).length > 0 + } + + let pageHref = normalizedPageHref(for: page) + let matchHref = normalizedSearchHref(searchMatch.href) + guard pageHref == matchHref else { return false } + + guard let rangeLocation = searchMatch.rangeLocation else { + return false + } + let range = NSRange(location: rangeLocation, length: searchMatch.rangeLength) + return NSIntersectionRange(range, page.contentRange).length > 0 + } + + private func normalizedPageHref(for page: RDEPUBTextPage) -> String { + guard let publication else { return page.href } + return publication.resourceResolver.normalizedHref(page.href) ?? page.href + } + + private func normalizedSearchHref(_ href: String) -> String { + guard let publication else { return href } + return publication.resourceResolver.normalizedHref(href) ?? href + } + /// 根据规范化 href 获取文本章节数据 func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? { readerContext.textChapterData(forNormalizedHref: href) diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+RuntimeBridge.swift b/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+RuntimeBridge.swift index 3ab5087..689ea13 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+RuntimeBridge.swift +++ b/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+RuntimeBridge.swift @@ -138,11 +138,6 @@ extension RDEPUBReaderController { runtime.updateReaderChrome() } - /// 更新书签按钮状态 - func updateBookmarkChrome() { - runtime.updateBookmarkChrome() - } - /// 展示书签管理界面 func presentBookmarksManager() { runtime.presentBookmarksManager() diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift index ea12b6c..10203e8 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift @@ -38,9 +38,7 @@ final class RDEPUBReaderAnnotationCoordinator { controller.readerView.isShowToolView == false { controller.readerView.tapCenter() } - controller.bottomToolView.setAddHighlightEnabled( - controller.configuration.allowsHighlights && controller.currentSelection != nil - ) + controller.updateReaderChrome() controller.delegate?.epubReader(controller, didChangeSelection: controller.currentSelection) } @@ -321,13 +319,6 @@ final class RDEPUBReaderAnnotationCoordinator { return controller.restoreReadingLocation(bookmark.location, animated: animated) } - /// 同步顶部书签按钮和底部书签列表按钮的 UI 状态。 - func updateBookmarkChrome() { - guard let controller else { return } - controller.topToolView.setBookmarkSelected(currentBookmark() != nil) - controller.bottomToolView.setBookmarksEnabled(!controller.activeBookmarks.isEmpty) - } - /// 弹出书签管理器,支持查看、跳转和删除书签。 func presentBookmarksManager() { guard let controller else { return } @@ -468,10 +459,10 @@ final class RDEPUBReaderAnnotationCoordinator { guard let currentBookIdentifier = controller.currentBookIdentifier else { return } controller.persistence?.saveBookmarks(controller.activeBookmarks, for: currentBookIdentifier) controller.delegate?.epubReader(controller, didUpdateBookmarks: controller.activeBookmarks) - updateBookmarkChrome() + controller.updateReaderChrome() } - private func currentBookmark() -> RDEPUBBookmark? { + func currentBookmark() -> RDEPUBBookmark? { guard let controller else { return nil } guard let location = scopedBookmarkLocation(controller.currentVisibleLocation()) else { return nil diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderChromeCoordinator.swift b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderChromeCoordinator.swift index 460db0c..4dccc7c 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderChromeCoordinator.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderChromeCoordinator.swift @@ -57,6 +57,29 @@ final class RDEPUBReaderChromeCoordinator { /// 同步更新顶部和底部工具栏的主题、标题、按钮可用性等状态。 func updateReaderChrome() { + guard let controller else { return } + let uiState = makeUIState() + applyUIState(uiState) + updateSearchBar() + } + + /// 构建当前 UI 状态快照 + func makeUIState() -> RDEPUBReaderUIState { + guard let controller else { return .empty } + return RDEPUBReaderUIState( + canToggleBookmark: controller.currentBookIdentifier != nil, + hasBookmarkAtCurrentLocation: hasBookmarkAtCurrentLocation(), + canShowBookmarks: !controller.activeBookmarks.isEmpty, + canAddHighlight: controller.configuration.allowsHighlights && controller.currentSelection != nil, + canShowHighlights: controller.configuration.allowsHighlights && !controller.activeHighlights.isEmpty, + showsTableOfContents: controller.configuration.showsTableOfContents, + allowsHighlights: controller.configuration.allowsHighlights, + showsSettingsPanel: controller.configuration.showsSettingsPanel + ) + } + + /// 将 UI 状态应用到顶部和底部工具栏 + func applyUIState(_ state: RDEPUBReaderUIState) { guard let controller else { return } controller.topToolView.apply(theme: controller.configuration.theme) controller.topToolView.setTitle( @@ -64,22 +87,23 @@ final class RDEPUBReaderChromeCoordinator { ?? controller.parser?.metadata.title ?? controller.epubURL.deletingPathExtension().lastPathComponent ) - controller.topToolView.setBookmarkEnabled(controller.currentBookIdentifier != nil) + controller.topToolView.setBookmarkEnabled(state.canToggleBookmark) + controller.topToolView.setBookmarkSelected(state.hasBookmarkAtCurrentLocation) controller.bottomToolView.apply(theme: controller.configuration.theme) controller.bottomToolView.updateVisibility( - showsTableOfContents: controller.configuration.showsTableOfContents, - allowsHighlights: controller.configuration.allowsHighlights, - showsSettingsPanel: controller.configuration.showsSettingsPanel + showsTableOfContents: state.showsTableOfContents, + allowsHighlights: state.allowsHighlights, + showsSettingsPanel: state.showsSettingsPanel ) - controller.bottomToolView.setBookmarksEnabled(!controller.activeBookmarks.isEmpty) - controller.bottomToolView.setAddHighlightEnabled( - controller.configuration.allowsHighlights && controller.currentSelection != nil - ) - controller.bottomToolView.setHighlightsEnabled( - controller.configuration.allowsHighlights && !controller.activeHighlights.isEmpty - ) - controller.updateBookmarkChrome() - updateSearchBar() + controller.bottomToolView.setBookmarksEnabled(state.canShowBookmarks) + controller.bottomToolView.setAddHighlightEnabled(state.canAddHighlight) + controller.bottomToolView.setHighlightsEnabled(state.canShowHighlights) + } + + /// 判断当前位置是否有书签 + private func hasBookmarkAtCurrentLocation() -> Bool { + guard let controller else { return false } + return context.runtime?.annotationCoordinator.currentBookmark() != nil } /// 弹出阅读设置面板(字号、字体、行距、分栏、主题、亮度等)。 diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift index 3745099..37a1774 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift @@ -369,9 +369,4 @@ final class RDEPUBReaderContext { func applyReaderViewConfiguration() { controller?.applyReaderViewConfiguration() } - - /// 同步书签按钮的 UI 状态。 - func updateBookmarkChrome() { - controller?.updateBookmarkChrome() - } } diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderLocationCoordinator.swift b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderLocationCoordinator.swift index 37da2e7..97e2c3c 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderLocationCoordinator.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderLocationCoordinator.swift @@ -87,6 +87,6 @@ final class RDEPUBReaderLocationCoordinator { controller.persistence?.saveLocation(location, for: currentBookIdentifier) controller.delegate?.epubReader(controller, didUpdateLocation: location) controller.delegate?.epubReader(controller, didUpdateCurrentTableOfContentsItem: controller.currentTableOfContentsItem) - controller.updateBookmarkChrome() + controller.updateReaderChrome() } } diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift index 3a3d1de..b72a232 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift @@ -193,10 +193,6 @@ final class RDEPUBReaderRuntime { annotationCoordinator.go(toBookmarkID: id, animated: animated) } - func updateBookmarkChrome() { - annotationCoordinator.updateBookmarkChrome() - } - func presentBookmarksManager() { annotationCoordinator.presentBookmarksManager() } diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderUIState.swift b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderUIState.swift new file mode 100644 index 0000000..82bea10 --- /dev/null +++ b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderUIState.swift @@ -0,0 +1,38 @@ +import Foundation + +/// 阅读器 UI 状态模型:统一管理顶部/底部工具栏按钮的可用性和显示状态。 +/// +/// 解决问题:之前书签、高亮等按钮的状态分散在 ChromeCoordinator 和 AnnotationCoordinator 中, +/// 导致状态更新入口不一致,容易出现不同步的情况。 +struct RDEPUBReaderUIState { + /// 顶部书签按钮是否可用(当前有书籍标识时可用) + let canToggleBookmark: Bool + /// 顶部书签按钮是否选中(当前位置已加书签时选中) + let hasBookmarkAtCurrentLocation: Bool + /// 底部书签列表按钮是否可用(有书签数据时可用) + let canShowBookmarks: Bool + /// 底部新建标注按钮是否可用(有选中文本时可用) + let canAddHighlight: Bool + /// 底部高亮列表按钮是否可用(有高亮数据时可用) + let canShowHighlights: Bool + /// 是否显示目录按钮 + let showsTableOfContents: Bool + /// 是否显示高亮相关按钮(新建标注和高亮列表) + let allowsHighlights: Bool + /// 是否显示设置按钮 + let showsSettingsPanel: Bool +} + +extension RDEPUBReaderUIState { + /// 默认的空状态 + static let empty = RDEPUBReaderUIState( + canToggleBookmark: false, + hasBookmarkAtCurrentLocation: false, + canShowBookmarks: false, + canAddHighlight: false, + canShowHighlights: false, + showsTableOfContents: true, + allowsHighlights: true, + showsSettingsPanel: true + ) +} diff --git a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextAnnotationOverlay.swift b/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextAnnotationOverlay.swift index ad1cc1f..a8cbf5b 100644 --- a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextAnnotationOverlay.swift +++ b/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextAnnotationOverlay.swift @@ -64,7 +64,7 @@ final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView { let pageStart = pageRange.lowerBound let pageEndExclusive = pageRange.upperBound - for match in searchState.matches where match.href == page.href { + for match in searchState.matches { guard let matchStart = match.rangeLocation else { continue } let matchEnd = matchStart + match.rangeLength let overlapStart = max(matchStart, pageStart) @@ -100,7 +100,7 @@ final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView { let normalColor = UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.55) let activeColor = UIColor(red: 255 / 255, green: 159 / 255, blue: 67 / 255, alpha: 0.75) - for match in searchState.matches where match.href == page.href { + for match in searchState.matches { guard let matchStart = match.rangeLocation else { continue } let matchEnd = matchStart + match.rangeLength let overlapStart = max(matchStart, pageStart) diff --git a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift b/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift index 13a9c18..35b24dc 100644 --- a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift +++ b/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift @@ -40,6 +40,8 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate { let view = RDEPUBTextPageRenderView() view.backgroundColor = .clear view.isOpaque = false + view.isAccessibilityElement = true + view.accessibilityTraits = .staticText return view }() @@ -118,6 +120,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate { } else { self.hideSelectionMenu() } + self.updateAccessibilityDecorationSummary() self.delegate?.textContentView(self, didChangeSelection: selection) } selectionController.pageProvider = { [weak self] in self?.currentPage } @@ -250,6 +253,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate { backgroundOverlayView.applyDecorations(bgDecorations) overlayView.applyDecorations(fgDecorations) #endif + updateAccessibilityDecorationSummary() delegate?.textContentView(self, didChangeSelection: nil) setNeedsLayout() @@ -262,6 +266,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate { selectionController.clearSelection(renderView: coreTextRenderView) overlayView.clearSelection() backgroundOverlayView.clearSelection() + updateAccessibilityDecorationSummary() UIMenuController.shared.setMenuVisible(false, animated: true) } @@ -570,13 +575,21 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate { #endif } + private func updateAccessibilityDecorationSummary() { +#if canImport(DTCoreText) + coreTextContentView.accessibilityValue = [ + backgroundOverlayView.decorationSummary(), + overlayView.decorationSummary() + ].joined(separator: " | ") +#endif + } + private func highlight(at point: CGPoint) -> RDEPUBHighlight? { guard let page = currentPage else { return nil } let absoluteRange = backgroundOverlayView.absoluteRange(at: point) ?? overlayView.absoluteRange(at: point) guard let absoluteRange else { return nil } let matches = currentHighlights.filter { highlight in - guard highlight.location.href == page.href, - let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else { + guard let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else { return false } return NSIntersectionRange(range, absoluteRange).length > 0