ReadViewSDK/Doc/AUDIT_FINAL.md
shenlei 22e7e44220 feat: chapter runtime refactoring and related updates
- Refactor chapter runtime: replace window coordinator/snapshot with warmup orchestrator
- Update EPUB core: parser, reading session, JS bridge, navigator layout
- Update reader controller: data source, location resolution, persistence
- Update chapter runtime: data cache, loader, runtime store, disk cache, warmup orchestrator
- Remove deprecated navigation state machine and pagination state
- Update text rendering: book cache, HTML normalizer
- Update UI: text content view, dark image adjuster, text selection controller
- Update settings and reader configuration
- Add CODE_REVIEW.md and AUDIT_FINAL.md documentation
- Update pod dependencies (remove SSAlertSwift, SnapKit)
- Update podspec and pod configuration files

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-26 18:50:07 +09:00

19 KiB
Raw Blame History

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:103handleMemoryWarning() 方法存在
  • RDEPUBReaderRuntime.swift:606-620 — 只清理 backgroundCoverageStore

H-02 · 主线程信号量等待

RDEPUBChapterLoader.loadChapterSynchronouslyForMigration() 使用 DispatchSemaphore.wait() 阻塞调用线程。当从 RDEPUBPresentationRuntime.rebindVisibleLocation()allowSynchronousLoad: true 调用时,会在主线程等待。

影响: 如果章节数据未缓存,主线程被阻塞直到章节构建完成,可能导致 ANR 或死锁(如果构建过程需要主线程资源)。

位置:

  • RDEPUBChapterLoader.swift:206-268semaphore.wait()
  • RDEPUBPresentationRuntime.swift:282-286allowSynchronousLoad: true

H-03 · asyncAfter 闭包中的强制解包

RDEPUBReaderController+DataSource.swift:425-436 在 0.3 秒延迟闭包中强制解包 lastTextPaginationPageSize!。方法入口的 guard let lastTextPaginationPageSize 不保护延迟闭包——0.3 秒后该值可能已变为 nil

位置:

// 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() 逐出。每章持有完整的 typesetAttributedString50-200KB+50 章可达 5-10MB+。

位置: RDEPUBChapterDataCache.swift:5private 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 调度)上被调用,但 respondWithInMemoryDatarespondWithStreamingdidReceive/didFinish/didFailWithError 显式切到了 ioQueueline 121, 151。这至少违反了"回调应与 start 使用同一串行队列"的要求。

(b) 取消时不调用完成回调: 任务被 stop 后,isTaskActive 返回 false 时:

  • streaming 路径line 178guard self.isTaskActive(taskID) else { return } — 仅 return不调用 didFinishdidFailWithError
  • in-memory 路径line 128, 141同理

这违反了"每个 started task 必须收到完成回调"的协议要求。

位置: RDEPUBResourceURLSchemeHandler.swift:121-187


H-09 · 搜索在后台线程同步加载所有章节

RDEPUBReaderSearchCoordinatorsearchQueue 上遍历所有 buildable spine 索引,逐个同步加载章节。每章持有完整 typesetAttributedString,大型书籍搜索时内存峰值显著。

位置: RDEPUBReaderSearchCoordinator.swift:47, 173-236


H-10 · 依赖版本未锁定

RDReaderView.podspec 中 DTCoreText、SnapKit、SSAlertSwift 无版本约束:

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 中被移除

RDEPUBReaderContextmakeLayoutSnapshot()currentTextPageSize() 等方法使用 assert(Thread.isMainThread) 检查。assert-O 构建中被移除,生产环境中后台线程调用将静默访问 UIKit 属性,造成数据竞争且无诊断。

应使用: dispatchPrecondition(condition: .onQueue(.main))@MainActor 标注。

位置: RDEPUBReaderContext.swift:172, 188, 210, 215


M-05 · 磁盘缓存无自动淘汰策略

RDEPUBChapterSummaryDiskCacheRDEPUBTextBookCache 只有全量清除操作(removeAll()invalidateAll()),无 LRU/大小/年龄驱逐策略。每次设置变更字体、行距、主题产生新缓存键旧文件无限累积。写操作使用原子替换tmp + replace交错不会损坏数据但可能读到旧值或看到不一致的目录状态。

位置:

  • RDEPUBChapterSummaryDiskCache.swift:89removeAll() 全清
  • RDEPUBTextBookCache.swift:191invalidateAll() 全清

M-06 · CFI 范围分隔符可能与内容冲突

RDEPUBCFICompatibility"..""-" 分割 CFI 范围字符串,但这些字符可能出现在 CFI 文本断言内部(如 [foo..bar])。代码先匹配 ".." 再匹配 "-",无歧义消解逻辑。

位置: RDEPUBCFICompatibility.swift:18-21


M-07 · CFI 步索引对奇数输入容错不严谨

RDEPUBCFIResolversteps[1].index / 2 - 1 对奇数索引做整除截断,可能映射到错误章节。max(..., 0) 仅防负索引,不防错误映射。属于 EPUB CFI 规范的输入验证问题。

位置: RDEPUBCFIResolver.swift:38


M-08 · WKWebView 保留环需主动管理

RDEPUBWebViewwebViewconfiguration.userContentControllerRDEPUBWebView(作为 WKScriptMessageHandler)形成保留环。

主要解环路径是 reset()configureWebViewIfNeeded 中主动调用 teardownWebView()注意: deinit 虽也调用 teardownWebView(),但不能作为环会被打破的保证——保留环的存在意味着 deinit 可能永远不会触发。风险在于:若某条代码路径跳过了 teardownWebView(),环将持续且对象永远不会释放。

位置:

  • RDEPUBWebView.swift:129deinit 调用 teardownWebView()(不可靠兜底)
  • RDEPUBWebView.swift:138-160reset() 调用 teardownWebView()(主要解环路径)

M-09 · configuration didSet 在 isViewLoaded 之前持久化

RDEPUBReaderController.configurationdidSetguard isViewLoaded 之前执行了 readerContext.configuration = configurationapplyWebViewDebugPolicy()persistReaderSettingsIfNeeded()。若在 view 加载前设置 configuration这些副作用会在 UI 未就绪时触发。

位置: RDEPUBReaderController.swift:8-20


M-10 · empty catch 吞掉章节加载错误

RDEPUBReaderPaginationCoordinatorloadInitialInteractiveRuntimeChapterscatch {} 为空,章节加载失败被静默忽略,无日志、无恢复。

位置: RDEPUBReaderPaginationCoordinator.swift:283


M-11 · RDEPUBReaderFontChoice.displayName 始终返回中文

枚举的 displayName 计算属性硬编码中文字符串("系统"、"宋体"、"圆体"、"等宽"),无本地化基础设施。

位置: RDEPUBReaderConfiguration.swift:18-28


M-12 · 大量硬编码中文字符串,本地化覆盖不完整

覆盖文件:RDEPUBReaderSettingsViewControllerRDEPUBReaderSearchBarViewRDEPUBReaderHighlightsViewControllerRDEPUBReaderBookmarksViewControllerRDEPUBReaderTopToolViewRDEPUBReaderBottomToolViewRDEPUBTextContentViewRDEPUBWebView+ConfigurationRDEPUBChapterLoader(错误信息)等。

项目中存在约 88 个硬编码中文字符串(如"拷贝"、"高亮"、"批注"、"目录"、"搜索"等),但同时也有使用 NSLocalizedString 的地方(如 RDEPUBImageViewController.swift:126)。本地化基础设施存在但覆盖不完整——新添加的 UI 文本大多直接硬编码中文而未走本地化流程。


M-13 · DTCoreText 类型泄漏到 EPUBUI 层

5 个 EPUBUI 文件直接使用 DTCoreTextLayoutFrameDTCoreTextLayoutLineDTCoreTextGlyphRun 等 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:39RDEPUBTextContentView.swift:747-758 使用 UIMenuController,该 API 从 iOS 16 起废弃。应迁移至 UIEditMenuInteractioniOS 16+),并为 iOS 15 保留 fallback。当前代码在 iOS 17+ 上仍可工作但随时可能失效。


M-16 · Dynamic Type 未支持

UI 中所有字体使用硬编码大小(UIFont.systemFont(ofSize: 13/14/16/18/19)),未使用 UIFontMetricspreferredFont(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 · 页码标签硬编码偏移

RDEPUBWebContentViewRDEPUBTextContentView 的页码标签使用硬编码偏移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 关键属性加 @MainActordispatchPrecondition
P2 M-04 assert 改为 dispatchPreconditionprecondition
P2 M-08 保留环加注释说明必须通过 reset() 打破
P2 M-15 迁移至 UIEditMenuInteraction