# ReadViewSDK 代码审查报告 > 审查范围:`Sources/RDReaderView/` 全部 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()`,无 `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 · 依赖版本未锁定 `RDReaderView.podspec` 中 DTCoreText、SnapKit、SSAlertSwift 无版本约束: ```ruby s.dependency 'ZIPFoundation', '~> 0.9' # ✅ 已锁定 s.dependency 'DTCoreText' # ❌ 无约束 s.dependency 'SnapKit' # ❌ 无约束 s.dependency 'SSAlertSwift' # ❌ 无约束 ``` **位置:** `RDReaderView.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 为 nil;signal 前必定赋值 | | #21 | 搜索协调器 fallback 空 store | `?? RDEPUBChapterRuntimeStore()` 前的可选链若为 nil 则整个调用返回 nil,fallback 不会生效 | | #23 | init 时加载所有 HTML 到内存 | 逐章取 HTML 算哈希后只保存 digest,HTML 字符串不长期保留 | | #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` |