feat: unify reader chrome state management

- Add RDEPUBReaderUIState struct for centralized UI state model
- Refactor RDEPUBReaderChromeCoordinator with makeUIState() and applyUIState()
- Remove direct UI updates from RDEPUBReaderAnnotationCoordinator
- Consolidate updateBookmarkChrome() into updateReaderChrome()
- Update RDEPUBReaderContext, Runtime, and LocationCoordinator
- Add reader problem fix development checklist documentation

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
shenlei 2026-06-12 17:51:08 +08:00
parent d15187b730
commit ed390da147
12 changed files with 908 additions and 47 deletions

View File

@ -1,6 +1,6 @@
# ReadViewSDK 文档索引 # ReadViewSDK 文档索引
> 最后更新2026-06-09 > 最后更新2026-06-12
--- ---
@ -25,6 +25,7 @@
| 文档 | 说明 | | 文档 | 说明 |
|------|------| |------|------|
| [当前阅读器问题修复开发清单.md](当前阅读器问题修复开发清单.md) | 基于当前代码实现整理的逐任务开发计划10 个任务、修改位置、实施步骤、风险点与验收标准 |
| [大书后台解析优化实施清单_30秒目标.md](大书后台解析优化实施清单_30秒目标.md) | 《凡人修仙传》后台解析性能优化方案,目标从 70s 压缩到 30-50s | | [大书后台解析优化实施清单_30秒目标.md](大书后台解析优化实施清单_30秒目标.md) | 《凡人修仙传》后台解析性能优化方案,目标从 70s 压缩到 30-50s |
## 项目信息 ## 项目信息

View File

@ -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<String>`
- `requiresExternalLinkConfirmation: Bool`
- 可选:`allowedExternalURLHosts: Set<String>?`
默认值建议:
- 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`
这四项完成后,阅读器的“主观可用性”和“默认安全性”会先提升一个台阶。

View File

@ -31,7 +31,7 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
totalPages: pageCountOfReaderView(readerView: readerView), totalPages: pageCountOfReaderView(readerView: readerView),
configuration: configuration, configuration: configuration,
highlights: textHighlights(for: resolvedPage.page), highlights: textHighlights(for: resolvedPage.page),
searchState: searchState searchState: searchState(for: resolvedPage.page)
) )
return contentView return contentView
} }
@ -46,7 +46,7 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
totalPages: textBook.pages.count, totalPages: textBook.pages.count,
configuration: configuration, configuration: configuration,
highlights: textHighlights(for: page), highlights: textHighlights(for: page),
searchState: searchState searchState: searchState(for: page)
) )
return contentView 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 /// href
func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? { func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? {
readerContext.textChapterData(forNormalizedHref: href) readerContext.textChapterData(forNormalizedHref: href)

View File

@ -138,11 +138,6 @@ extension RDEPUBReaderController {
runtime.updateReaderChrome() runtime.updateReaderChrome()
} }
///
func updateBookmarkChrome() {
runtime.updateBookmarkChrome()
}
/// ///
func presentBookmarksManager() { func presentBookmarksManager() {
runtime.presentBookmarksManager() runtime.presentBookmarksManager()

View File

@ -38,9 +38,7 @@ final class RDEPUBReaderAnnotationCoordinator {
controller.readerView.isShowToolView == false { controller.readerView.isShowToolView == false {
controller.readerView.tapCenter() controller.readerView.tapCenter()
} }
controller.bottomToolView.setAddHighlightEnabled( controller.updateReaderChrome()
controller.configuration.allowsHighlights && controller.currentSelection != nil
)
controller.delegate?.epubReader(controller, didChangeSelection: controller.currentSelection) controller.delegate?.epubReader(controller, didChangeSelection: controller.currentSelection)
} }
@ -321,13 +319,6 @@ final class RDEPUBReaderAnnotationCoordinator {
return controller.restoreReadingLocation(bookmark.location, animated: animated) 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() { func presentBookmarksManager() {
guard let controller else { return } guard let controller else { return }
@ -468,10 +459,10 @@ final class RDEPUBReaderAnnotationCoordinator {
guard let currentBookIdentifier = controller.currentBookIdentifier else { return } guard let currentBookIdentifier = controller.currentBookIdentifier else { return }
controller.persistence?.saveBookmarks(controller.activeBookmarks, for: currentBookIdentifier) controller.persistence?.saveBookmarks(controller.activeBookmarks, for: currentBookIdentifier)
controller.delegate?.epubReader(controller, didUpdateBookmarks: controller.activeBookmarks) 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 controller else { return nil }
guard let location = scopedBookmarkLocation(controller.currentVisibleLocation()) else { guard let location = scopedBookmarkLocation(controller.currentVisibleLocation()) else {
return nil return nil

View File

@ -57,6 +57,29 @@ final class RDEPUBReaderChromeCoordinator {
/// ///
func updateReaderChrome() { 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 } guard let controller else { return }
controller.topToolView.apply(theme: controller.configuration.theme) controller.topToolView.apply(theme: controller.configuration.theme)
controller.topToolView.setTitle( controller.topToolView.setTitle(
@ -64,22 +87,23 @@ final class RDEPUBReaderChromeCoordinator {
?? controller.parser?.metadata.title ?? controller.parser?.metadata.title
?? controller.epubURL.deletingPathExtension().lastPathComponent ?? 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.apply(theme: controller.configuration.theme)
controller.bottomToolView.updateVisibility( controller.bottomToolView.updateVisibility(
showsTableOfContents: controller.configuration.showsTableOfContents, showsTableOfContents: state.showsTableOfContents,
allowsHighlights: controller.configuration.allowsHighlights, allowsHighlights: state.allowsHighlights,
showsSettingsPanel: controller.configuration.showsSettingsPanel showsSettingsPanel: state.showsSettingsPanel
) )
controller.bottomToolView.setBookmarksEnabled(!controller.activeBookmarks.isEmpty) controller.bottomToolView.setBookmarksEnabled(state.canShowBookmarks)
controller.bottomToolView.setAddHighlightEnabled( controller.bottomToolView.setAddHighlightEnabled(state.canAddHighlight)
controller.configuration.allowsHighlights && controller.currentSelection != nil controller.bottomToolView.setHighlightsEnabled(state.canShowHighlights)
) }
controller.bottomToolView.setHighlightsEnabled(
controller.configuration.allowsHighlights && !controller.activeHighlights.isEmpty ///
) private func hasBookmarkAtCurrentLocation() -> Bool {
controller.updateBookmarkChrome() guard let controller else { return false }
updateSearchBar() return context.runtime?.annotationCoordinator.currentBookmark() != nil
} }
/// ///

View File

@ -369,9 +369,4 @@ final class RDEPUBReaderContext {
func applyReaderViewConfiguration() { func applyReaderViewConfiguration() {
controller?.applyReaderViewConfiguration() controller?.applyReaderViewConfiguration()
} }
/// UI
func updateBookmarkChrome() {
controller?.updateBookmarkChrome()
}
} }

View File

@ -87,6 +87,6 @@ final class RDEPUBReaderLocationCoordinator {
controller.persistence?.saveLocation(location, for: currentBookIdentifier) controller.persistence?.saveLocation(location, for: currentBookIdentifier)
controller.delegate?.epubReader(controller, didUpdateLocation: location) controller.delegate?.epubReader(controller, didUpdateLocation: location)
controller.delegate?.epubReader(controller, didUpdateCurrentTableOfContentsItem: controller.currentTableOfContentsItem) controller.delegate?.epubReader(controller, didUpdateCurrentTableOfContentsItem: controller.currentTableOfContentsItem)
controller.updateBookmarkChrome() controller.updateReaderChrome()
} }
} }

View File

@ -193,10 +193,6 @@ final class RDEPUBReaderRuntime {
annotationCoordinator.go(toBookmarkID: id, animated: animated) annotationCoordinator.go(toBookmarkID: id, animated: animated)
} }
func updateBookmarkChrome() {
annotationCoordinator.updateBookmarkChrome()
}
func presentBookmarksManager() { func presentBookmarksManager() {
annotationCoordinator.presentBookmarksManager() annotationCoordinator.presentBookmarksManager()
} }

View File

@ -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
)
}

View File

@ -64,7 +64,7 @@ final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
let pageStart = pageRange.lowerBound let pageStart = pageRange.lowerBound
let pageEndExclusive = pageRange.upperBound 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 } guard let matchStart = match.rangeLocation else { continue }
let matchEnd = matchStart + match.rangeLength let matchEnd = matchStart + match.rangeLength
let overlapStart = max(matchStart, pageStart) 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 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) 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 } guard let matchStart = match.rangeLocation else { continue }
let matchEnd = matchStart + match.rangeLength let matchEnd = matchStart + match.rangeLength
let overlapStart = max(matchStart, pageStart) let overlapStart = max(matchStart, pageStart)

View File

@ -40,6 +40,8 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
let view = RDEPUBTextPageRenderView() let view = RDEPUBTextPageRenderView()
view.backgroundColor = .clear view.backgroundColor = .clear
view.isOpaque = false view.isOpaque = false
view.isAccessibilityElement = true
view.accessibilityTraits = .staticText
return view return view
}() }()
@ -118,6 +120,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
} else { } else {
self.hideSelectionMenu() self.hideSelectionMenu()
} }
self.updateAccessibilityDecorationSummary()
self.delegate?.textContentView(self, didChangeSelection: selection) self.delegate?.textContentView(self, didChangeSelection: selection)
} }
selectionController.pageProvider = { [weak self] in self?.currentPage } selectionController.pageProvider = { [weak self] in self?.currentPage }
@ -250,6 +253,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
backgroundOverlayView.applyDecorations(bgDecorations) backgroundOverlayView.applyDecorations(bgDecorations)
overlayView.applyDecorations(fgDecorations) overlayView.applyDecorations(fgDecorations)
#endif #endif
updateAccessibilityDecorationSummary()
delegate?.textContentView(self, didChangeSelection: nil) delegate?.textContentView(self, didChangeSelection: nil)
setNeedsLayout() setNeedsLayout()
@ -262,6 +266,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
selectionController.clearSelection(renderView: coreTextRenderView) selectionController.clearSelection(renderView: coreTextRenderView)
overlayView.clearSelection() overlayView.clearSelection()
backgroundOverlayView.clearSelection() backgroundOverlayView.clearSelection()
updateAccessibilityDecorationSummary()
UIMenuController.shared.setMenuVisible(false, animated: true) UIMenuController.shared.setMenuVisible(false, animated: true)
} }
@ -570,13 +575,21 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
#endif #endif
} }
private func updateAccessibilityDecorationSummary() {
#if canImport(DTCoreText)
coreTextContentView.accessibilityValue = [
backgroundOverlayView.decorationSummary(),
overlayView.decorationSummary()
].joined(separator: " | ")
#endif
}
private func highlight(at point: CGPoint) -> RDEPUBHighlight? { private func highlight(at point: CGPoint) -> RDEPUBHighlight? {
guard let page = currentPage else { return nil } guard let page = currentPage else { return nil }
let absoluteRange = backgroundOverlayView.absoluteRange(at: point) ?? overlayView.absoluteRange(at: point) let absoluteRange = backgroundOverlayView.absoluteRange(at: point) ?? overlayView.absoluteRange(at: point)
guard let absoluteRange else { return nil } guard let absoluteRange else { return nil }
let matches = currentHighlights.filter { highlight in let matches = currentHighlights.filter { highlight in
guard highlight.location.href == page.href, guard let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else {
let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else {
return false return false
} }
return NSIntersectionRange(range, absoluteRange).length > 0 return NSIntersectionRange(range, absoluteRange).length > 0