ReadViewSDK/Doc/AUDIT_FINAL.md
shenlei d7fcda345d refactor: rename RDReaderView -> RDEpubReaderView, update pod config and docs
- Rename source module from RDReaderView to RDEpubReaderView
- Move all source files from Sources/RDReaderView/ to Sources/RDEpubReaderView/
- Update podspec: RDReaderView.podspec -> RDEpubReaderView.podspec
- Update Podfile, demo project, and CocoaPods config for new pod name
- Delete old RDReaderView pod support files from ReadViewDemo/Pods
- Add new RDEpubReaderView pod support files
- Update documentation (API ref, architecture, UML, conventions, etc.)
- Add FixedLayoutRotationTests
- Update .gitignore: exclude .DS_Store, manual unpack backups, _ssoft-output
2026-07-10 19:44:53 +09:00

412 lines
19 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.

# ReadViewSDK 代码审查报告
> 审查范围:`Sources/RDEpubReaderView/` 全部 184 个 Swift 文件(约 32,300 行)
> 审查维度并发安全、内存管理、API 正确性、Swift/iOS 平台特定、架构与依赖
> 审查日期2026-06-26
---
## 严重程度定义
| 标记 | 含义 |
|------|------|
| 🔴 高 | 有明确代码路径可导致崩溃、数据丢失或严重功能缺陷 |
| 🟡 中 | 方向正确但风险程度取决于运行时条件;或当前安全但架构上脆弱 |
| 🟢 低 | 代码异味、可维护性问题或未来风险 |
---
## 🔴 高优先级问题
### H-01 · 内存警告未连接章节缓存清理
`RDEPUBChapterRuntimeStore.handleMemoryWarning()` 已实现(清空除当前章节外的缓存 + 清除图片缓存),但从未被调用。
- `RDEPUBReaderRuntime.handleMemoryWarning()` 只清理了 `backgroundCoverageStore`,未调用 `chapterRuntimeStore.handleMemoryWarning()`
- `RDEPUBReaderController` 未重写 `didReceiveMemoryWarning`
**影响:** 系统内存压力下,`RDEPUBChapterDataCache`(无上限字典)和 `imageCache`(仅 countLimit=50无 totalCostLimit不会主动释放内存。
**位置:**
- `RDEPUBChapterRuntimeStore.swift:103``handleMemoryWarning()` 方法存在
- `RDEPUBReaderRuntime.swift:606-620` — 只清理 backgroundCoverageStore
---
### H-02 · 主线程信号量等待
`RDEPUBChapterLoader.loadChapterSynchronouslyForMigration()` 使用 `DispatchSemaphore.wait()` 阻塞调用线程。当从 `RDEPUBPresentationRuntime.rebindVisibleLocation()``allowSynchronousLoad: true` 调用时,会在主线程等待。
**影响:** 如果章节数据未缓存,主线程被阻塞直到章节构建完成,可能导致 ANR 或死锁(如果构建过程需要主线程资源)。
**位置:**
- `RDEPUBChapterLoader.swift:206-268``semaphore.wait()`
- `RDEPUBPresentationRuntime.swift:282-286``allowSynchronousLoad: true`
---
### H-03 · asyncAfter 闭包中的强制解包
`RDEPUBReaderController+DataSource.swift:425-436` 在 0.3 秒延迟闭包中强制解包 `lastTextPaginationPageSize!`。方法入口的 `guard let lastTextPaginationPageSize` 不保护延迟闭包——0.3 秒后该值可能已变为 `nil`
**位置:**
```swift
// line 432-433
let stillChanged = abs(currentSize.width - self.lastTextPaginationPageSize!.width) > 0.5
|| abs(currentSize.height - self.lastTextPaginationPageSize!.height) > 0.5
```
---
### H-04 · `unowned context` 在后台操作中的崩溃路径
以下两个类在后台操作中访问 `unowned let context: RDEPUBReaderContext`,若 controller 在操作期间释放则会崩溃:
**RDEPUBMetadataParseWorker**
- Line 117: `let context = self.context``[weak self]` guard 之前将 unowned 引用捕获为局部变量
- Lines 197-324: `BlockOperation` 闭包直接使用该局部变量unowned`[weak self]` 保护
- Lines 296-301, 372-376: `DispatchQueue.main.async` 闭包捕获 unowned 局部变量
**RDEPUBReaderLoadCoordinator**
- Line 28: 闭包只捕获 `[weak controller]`,未捕获 `[weak self]`
- Line 30: `self.context.makeParser()` 在后台队列访问 unowned 引用
- Lines 41-42, 53-54: `DispatchQueue.main.async` 闭包直接访问 `self.context`
**影响:**`RDEPUBReaderController` 在后台操作期间释放(如用户快速退出阅读器),`unowned` 引用悬挂导致 EXC_BAD_ACCESS。
**位置:**
- `RDEPUBMetadataParseWorker.swift:117, 197-324, 296-301, 372-376`
- `RDEPUBReaderLoadCoordinator.swift:28-57`
---
### H-05 · 章节数据缓存无容量限制
`RDEPUBChapterDataCache` 是无上限的 `[Int: RDEPUBRuntimeChapter]` 字典,仅通过显式 `remove(spineIndex:)``removeAll()` 逐出。每章持有完整的 `typesetAttributedString`50-200KB+50 章可达 5-10MB+。
**位置:** `RDEPUBChapterDataCache.swift:5``private var storage: [Int: RDEPUBRuntimeChapter] = [:]`
---
### H-06 · 暗色图片静态 NSCache 无 totalCostLimit
`RDEPUBDarkImageAdjuster.imageCache` 是 static `NSCache<NSString, UIImage>()`,无 `totalCostLimit`,跨越整个 App 生命周期累积调整后的图片。
**位置:** `RDEPUBDarkImageAdjuster.swift:9`
---
### H-07 · 持久化操作静默丢弃错误
`RDEPUBReaderPersistence` 中所有编解码操作均使用 `try?`,失败时:
- **保存**静默返回数据丢失无任何反馈lines 84, 98, 112, 131
- **加载**:返回 `nil` 或空数组,损坏/版本不匹配的数据无法恢复lines 80, 94, 108, 127
**影响:** 用户书签和阅读位置可能在无声中丢失。
**位置:** `RDEPUBReaderPersistence.swift:76-131`
---
### H-08 · WKURLSchemeHandler 线程违规与取消回调缺失
两个确定 bug
**(a) 不同队列回调:** `webView(_:start:)` 在某个串行队列(由 WebKit 调度)上被调用,但 `respondWithInMemoryData``respondWithStreaming``didReceive`/`didFinish`/`didFailWithError` 显式切到了 `ioQueue`line 121, 151。这至少违反了"回调应与 `start` 使用同一串行队列"的要求。
**(b) 取消时不调用完成回调:** 任务被 `stop` 后,`isTaskActive` 返回 false 时:
- streaming 路径line 178`guard self.isTaskActive(taskID) else { return }` — 仅 return不调用 `didFinish``didFailWithError`
- in-memory 路径line 128, 141同理
这违反了"每个 started task 必须收到完成回调"的协议要求。
**位置:** `RDEPUBResourceURLSchemeHandler.swift:121-187`
---
### H-09 · 搜索在后台线程同步加载所有章节
`RDEPUBReaderSearchCoordinator``searchQueue` 上遍历所有 buildable spine 索引,逐个同步加载章节。每章持有完整 `typesetAttributedString`,大型书籍搜索时内存峰值显著。
**位置:** `RDEPUBReaderSearchCoordinator.swift:47, 173-236`
---
### H-10 · 依赖版本未锁定
`RDEpubReaderView.podspec` 中 DTCoreText、SnapKit、SSAlertSwift 无版本约束:
```ruby
s.dependency 'ZIPFoundation', '~> 0.9' # ✅ 已锁定
s.dependency 'DTCoreText' # ❌ 无约束
s.dependency 'SnapKit' # ❌ 无约束
s.dependency 'SSAlertSwift' # ❌ 无约束
```
**位置:** `RDEpubReaderView.podspec:17-20`
---
### H-11 · SnapKit 和 SSAlertSwift 疑似死依赖
全仓库搜索未找到任何 `snp.``SSAlert` 的使用点。这两个依赖可能增加了不必要的二进制体积和潜在冲突。
---
## 🟡 中优先级问题
### M-01 · `RDEPUBReaderState` 无同步保护(隐性风险)
所有属性(`parser`, `publication`, `activeBookmarks`, `pendingPageMapUpdates` 等)均为 `var`,无锁、无 `@MainActor`、无 `dispatchPrecondition`。当前所有访问确实在主线程(经追踪 16 个 `pendingPageMapUpdates` 访问点确认),但缺少形式化保障——未来开发者可能无意从后台线程访问而不自知。
**位置:** `RDEPUBReaderState.swift:1-71`
---
### M-02 · `currentSpineIndex` / `windowSpineIndices` 无锁但当前安全
这两个字段无锁保护但追踪全部读写路径后确认当前均在主线程。风险是隐性的store 对象被传入 `chapterLoadQueue` 上下文,未来开发者可能在该队列中访问这些属性而不自知。同文件的其他字段(`navigationLock`, `prefetchLock`, `buildingLock`, `cfiMapLock`)有锁保护,形成不一致的模式。
**位置:** `RDEPUBChapterRuntimeStore.swift:15-17`
---
### M-03 · `warmAnchors` 跨线程访问无同步
`RDEPUBBackgroundPriorityManager.warmAnchors` 在主线程写入(`addWarmAnchor`),在 `MetadataParseWorker` 的后台线程读取(`makeMetadataPriorityOrder`),无锁保护。
**位置:** `RDEPUBBackgroundPriorityPolicy.swift:65`
---
### M-04 · `assert(Thread.isMainThread)` 在 Release 中被移除
`RDEPUBReaderContext``makeLayoutSnapshot()`、`currentTextPageSize()` 等方法使用 `assert(Thread.isMainThread)` 检查。`assert` 在 `-O` 构建中被移除,生产环境中后台线程调用将静默访问 UIKit 属性,造成数据竞争且无诊断。
**应使用:** `dispatchPrecondition(condition: .onQueue(.main))``@MainActor` 标注。
**位置:** `RDEPUBReaderContext.swift:172, 188, 210, 215`
---
### M-05 · 磁盘缓存无自动淘汰策略
`RDEPUBChapterSummaryDiskCache``RDEPUBTextBookCache` 只有全量清除操作(`removeAll()`、`invalidateAll()`),无 LRU/大小/年龄驱逐策略。每次设置变更字体、行距、主题产生新缓存键旧文件无限累积。写操作使用原子替换tmp + replace交错不会损坏数据但可能读到旧值或看到不一致的目录状态。
**位置:**
- `RDEPUBChapterSummaryDiskCache.swift:89``removeAll()` 全清
- `RDEPUBTextBookCache.swift:191``invalidateAll()` 全清
---
### M-06 · CFI 范围分隔符可能与内容冲突
`RDEPUBCFICompatibility``".."``"-"` 分割 CFI 范围字符串,但这些字符可能出现在 CFI 文本断言内部(如 `[foo..bar]`)。代码先匹配 `".."` 再匹配 `"-"`,无歧义消解逻辑。
**位置:** `RDEPUBCFICompatibility.swift:18-21`
---
### M-07 · CFI 步索引对奇数输入容错不严谨
`RDEPUBCFIResolver``steps[1].index / 2 - 1` 对奇数索引做整除截断,可能映射到错误章节。`max(..., 0)` 仅防负索引,不防错误映射。属于 EPUB CFI 规范的输入验证问题。
**位置:** `RDEPUBCFIResolver.swift:38`
---
### M-08 · WKWebView 保留环需主动管理
`RDEPUBWebView``webView``configuration.userContentController``RDEPUBWebView`(作为 `WKScriptMessageHandler`)形成保留环。
主要解环路径是 `reset()``configureWebViewIfNeeded` 中主动调用 `teardownWebView()`。**注意:** `deinit` 虽也调用 `teardownWebView()`,但不能作为环会被打破的保证——保留环的存在意味着 `deinit` 可能永远不会触发。风险在于:若某条代码路径跳过了 `teardownWebView()`,环将持续且对象永远不会释放。
**位置:**
- `RDEPUBWebView.swift:129``deinit` 调用 `teardownWebView()`(不可靠兜底)
- `RDEPUBWebView.swift:138-160``reset()` 调用 `teardownWebView()`(主要解环路径)
---
### M-09 · `configuration` didSet 在 `isViewLoaded` 之前持久化
`RDEPUBReaderController.configuration``didSet``guard isViewLoaded` 之前执行了 `readerContext.configuration = configuration`、`applyWebViewDebugPolicy()` 和 `persistReaderSettingsIfNeeded()`。若在 view 加载前设置 configuration这些副作用会在 UI 未就绪时触发。
**位置:** `RDEPUBReaderController.swift:8-20`
---
### M-10 · empty catch 吞掉章节加载错误
`RDEPUBReaderPaginationCoordinator``loadInitialInteractiveRuntimeChapters``catch {}` 为空,章节加载失败被静默忽略,无日志、无恢复。
**位置:** `RDEPUBReaderPaginationCoordinator.swift:283`
---
### M-11 · `RDEPUBReaderFontChoice.displayName` 始终返回中文
枚举的 `displayName` 计算属性硬编码中文字符串("系统"、"宋体"、"圆体"、"等宽"),无本地化基础设施。
**位置:** `RDEPUBReaderConfiguration.swift:18-28`
---
### M-12 · 大量硬编码中文字符串,本地化覆盖不完整
覆盖文件:`RDEPUBReaderSettingsViewController`、`RDEPUBReaderSearchBarView`、`RDEPUBReaderHighlightsViewController`、`RDEPUBReaderBookmarksViewController`、`RDEPUBReaderTopToolView`、`RDEPUBReaderBottomToolView`、`RDEPUBTextContentView`、`RDEPUBWebView+Configuration`、`RDEPUBChapterLoader`(错误信息)等。
项目中存在约 88 个硬编码中文字符串(如"拷贝"、"高亮"、"批注"、"目录"、"搜索"等),但同时也有使用 `NSLocalizedString` 的地方(如 `RDEPUBImageViewController.swift:126`)。本地化基础设施存在但覆盖不完整——新添加的 UI 文本大多直接硬编码中文而未走本地化流程。
---
### M-13 · DTCoreText 类型泄漏到 EPUBUI 层
5 个 EPUBUI 文件直接使用 `DTCoreTextLayoutFrame`、`DTCoreTextLayoutLine`、`DTCoreTextGlyphRun` 等 DTCoreText 类型(虽有 `#if canImport(DTCoreText)` 保护),意味着替换渲染引擎需修改 UI 层文件。
**位置:**
- `RDEPUBPageInteractionController.swift`
- `RDEPUBPageLayoutSnapshot.swift`
- `RDEPUBTextPageRenderView.swift`
- `RDEPUBTextContentView.swift`
- `RDEPUBDarkImageAdjuster.swift`
---
### M-14 · `imageCache` 有 countLimit 但无 totalCostLimit
`RDEPUBChapterRuntimeStore.imageCache` 设有 `countLimit = 50`,但无 `totalCostLimit`。50 张大尺寸书籍插图可达 150-250MB。
**位置:** `RDEPUBChapterRuntimeStore.swift:41`
---
### M-15 · `UIMenuController` 已废弃
`RDEPUBWebView+Configuration.swift:39``RDEPUBTextContentView.swift:747-758` 使用 `UIMenuController`,该 API 从 iOS 16 起废弃。应迁移至 `UIEditMenuInteraction`iOS 16+),并为 iOS 15 保留 fallback。当前代码在 iOS 17+ 上仍可工作但随时可能失效。
---
### M-16 · Dynamic Type 未支持
UI 中所有字体使用硬编码大小(`UIFont.systemFont(ofSize: 13/14/16/18/19)`),未使用 `UIFontMetrics``preferredFont(forTextStyle:)`。启用 Dynamic Type 的用户看不到文字大小变化。
---
### M-17 · URL Scheme Handler 取消时 `clearTask` 可能被跳过
`respondWithInMemoryData` line 128 的 `guard self.isTaskActive(taskID) else { return }` 在取消时直接返回,跳过了 line 146 的 `self.clearTask(taskID)`。当前因 `stop` 已调用 `clearTask` 而无害,但结构上脆弱。
**位置:** `RDEPUBResourceURLSchemeHandler.swift:128`
---
### M-18 · 硬编码颜色值未集成到主题系统
`RDEPUBReaderSearchBarView` 含 18 个硬编码颜色字面量,默认高亮颜色 `"#F8E16C"` 硬编码在 `RDEPUBReaderController+PublicAPI.swift:65,75`。这些应属于 `RDEPUBReaderTheme`
---
### M-19 · 硬编码时间常量
多个 `DispatchQueue.main.asyncAfter(deadline:)``Thread.sleep` 使用硬编码延迟值0.08s、0.1s、0.15s、0.2s、0.25s、0.3s、1.0s、3.5s),无命名常量或可配置选项。
---
## 🟢 低优先级问题
### L-01 · `RDEPUBChapterLoader` 中 `[self]` 强捕获
Line 103 的 `store.chapterLoadQueue.async { [self] in` 强捕获 self阻止 loader 在队列任务完成前释放。应改为 `[weak self]`
**位置:** `RDEPUBChapterLoader.swift:103`
---
### L-02 · MetadataParseWorker Thread.sleep 忙等待
`waitForReadingInteractionToSettle()` 使用 `Thread.sleep(forTimeInterval: 0.08)` 轮询,无上限等待。应考虑 `DispatchSource` 或条件变量。
**位置:** `RDEPUBMetadataParseWorker.swift:385-390`
---
### L-03 · `RDEPUBTextBookCache.queue.sync` 潜在死锁
`load()``save()` 均使用 `queue.sync`。若从 queue 内部调用则会死锁。当前未发现此调用路径,但模式脆弱。
**位置:** `RDEPUBTextBookCache.swift:136-189`
---
### L-04 · `pendingLoads` 字典无超时清理
`RDEPUBChapterLoader.pendingLoads` 的完成闭包数组在章节加载失败且 `resolvePendingLoad` 未被调用时可能累积。当前所有路径均调用 `resolvePendingLoad`,但无防御性超时。
**位置:** `RDEPUBChapterLoader.swift:7-10`
---
### L-05 · `RDEPUBChapterWindowCoordinator` 是死代码
该类定义了但未被任何代码实例化或引用。其中对 `currentSpineIndex`/`windowSpineIndices` 的7个访问点均为死代码。
---
### L-06 · 平台版本不一致
- `podspec`: `s.platform = :ios, "15.0"`
- `Podfile`(根和 demo: `platform :ios, '15.6'`
---
### L-07 · `RDEPUBMetadataCancellationController` 微小竞争
`attach()` 方法在 `lock.unlock()``queue.cancelAllOperations()` 之间存在窄窗口,新操作可能被添加到队列但不会被取消。实际影响极小。
**位置:** `RDEPUBMetadataParseCancellationController.swift:attach()`
---
### L-08 · 页码标签硬编码偏移
`RDEPUBWebContentView``RDEPUBTextContentView` 的页码标签使用硬编码偏移24pt, 20pt在刘海屏设备上可能与安全区域重叠。
---
## 已排除项
以下原报告条目经核实后排除或合并:
| 原编号 | 原描述 | 排除原因 |
|--------|--------|----------|
| #6 | compactMap 结果越界 | 入口有 `childViewControllers.count == expectedCount` 检查compactMap 后不会少于预期数量 |
| #7 | `result!` 强解包崩溃 | 信号量等待的真实风险是无限等待/卡住,不是 result 为 nilsignal 前必定赋值 |
| #21 | 搜索协调器 fallback 空 store | `?? RDEPUBChapterRuntimeStore()` 前的可选链若为 nil 则整个调用返回 nilfallback 不会生效 |
| #23 | init 时加载所有 HTML 到内存 | 逐章取 HTML 算哈希后只保存 digestHTML 字符串不长期保留 |
| #35 | `.nonPersistent()` 阻止 cookie/localStorage | 不持久化是有意设计,不是 bug原始报告将"不持久化"误述为"不能用" |
| #48 | `didMoveToSuperview` 每次重复 makeUI | 有 `didBuildUI` 防重入保护 |
| 原L-04 | `activeTasks` 字典 `clearTask` 设 nil 不删键 | Swift 中 `dict[key] = nil` 等同于 `removeValue(forKey:)`,不保留空洞 |
| 原M-18 | MetadataParseWorker OperationQueue 未在 deinit 取消 | 外层 `[weak self]` guard 确保 self 存活才执行 queue 操作;`cancellationController` 提供显式取消路径 |
| 原L-08 | `ss_superViewController` 前缀暗示外部来源 | 命名风格猜测,非可验证缺陷 |
---
## 建议优先级排序
| 优先级 | 编号 | 修复复杂度 | 说明 |
|--------|------|-----------|------|
| P0 | H-01 | 低 | 在 `handleMemoryWarning` 中加一行 `chapterRuntimeStore.handleMemoryWarning()`,并在 controller 中转发 `didReceiveMemoryWarning` |
| P0 | H-04 | 中 | MetadataParseWorker 和 LoadCoordinator 中 `unowned context` 改为 `weak`,闭包加 `guard` |
| P0 | H-03 | 低 | `lastTextPaginationPageSize!` 改为 `guard let` 安全解包 |
| P1 | H-08 | 中 | URLSchemeHandler 回调改到与 start 相同队列,取消路径加 `didFailWithError` |
| P1 | H-02 | 中 | 主线程同步加载改为异步回调或确保不从主线程调用 |
| P1 | H-07 | 低 | 关键持久化操作加日志,至少记录失败原因 |
| P1 | H-10 | 低 | 给三个依赖加版本约束;移除 SnapKit 和 SSAlertSwift |
| P2 | H-05 | 中 | 为 ChapterDataCache 加 LRU 逐出策略或最大条目数限制 |
| P2 | H-06 | 低 | 为 DarkImageAdjuster.imageCache 设置 `totalCostLimit` |
| P2 | H-09 | 中 | 搜索改为流式/分批加载,避免一次性持有所有章节 |
| P2 | M-01/02 | 中 | 为 State 和 Store 关键属性加 `@MainActor``dispatchPrecondition` |
| P2 | M-04 | 低 | `assert` 改为 `dispatchPrecondition``precondition` |
| P2 | M-08 | 低 | 保留环加注释说明必须通过 `reset()` 打破 |
| P2 | M-15 | 中 | 迁移至 `UIEditMenuInteraction` |