# 章节运行时详解 > 最后更新:2026-06-18 本文档详细描述 ReadViewSDK 章节运行时子系统的架构、缓存策略、加载流程和优化机制。 --- ## 1. 概述 章节运行时是 EPUBUI 层的核心子系统,负责章节的按需加载、缓存管理和页码映射。它位于 `RDEPUBReaderContext` → `RDEPUBReaderRuntime` 架构中,是实现大书(如 1000+ 章的网络小说)流畅阅读的关键。 **核心设计目标**: - 快速打开:用户点击书籍后 1-2 秒内可开始阅读 - 按需加载:只加载当前窗口内的章节,内存占用可控 - 渐进补全:后台逐步补全所有章节的页码信息 **关键文件**: - `Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/` 目录 - `Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift` --- ## 2. 三级缓存架构 ``` ┌─────────────────────────────────────────────────────────┐ │ Tier 1: 内存缓存 (RDEPUBChapterRuntimeStore) │ │ ├─ chapterDataCache: [Int: RDEPUBRuntimeChapter] │ │ ├─ pageCountCache: [CacheKey: RDEPUBRuntimePageCount] │ │ ├─ imageCache: NSCache (50 上限) │ │ └─ 窗口驱逐:只保留当前章节 ± windowRadius 的章节 │ └──────────────────────────┬──────────────────────────────┘ │ miss ┌──────────────────────────▼──────────────────────────────┐ │ Tier 2: 磁盘摘要缓存 (RDEPUBChapterSummaryDiskCache) │ │ ├─ 路径: ~/Caches/RDEPUBChapterSummaryCache/{bookID}/ │ │ ├─ 文件名: SHA256(bookID_spineIdx_renderSig_contentHash)│ │ ├─ 格式: JSON (RDEPUBChapterSummary) │ │ ├─ 写入: 异步(serial DispatchQueue) │ │ └─ 读取: 同步(readAll 批量读取) │ └──────────────────────────┬──────────────────────────────┘ │ miss ┌──────────────────────────▼──────────────────────────────┐ │ Tier 3: 全书分页缓存 (RDEPUBTextBookCache) │ │ ├─ 路径: ~/Caches/RDEPUBTextBookCache/ │ │ ├─ 格式: NSSecureCoding archive │ │ ├─ 内容: 每章的 pageRanges + breakReasons + semanticHints│ │ └─ 失效: schemaVersion 变更时全部失效 │ └─────────────────────────────────────────────────────────┘ ``` ### 2.1 Tier 1: RDEPUBChapterRuntimeStore **文件**:`RDEPUBChapterRuntimeStore.swift` 内存缓存,存储当前窗口内的章节数据。 ```swift final class RDEPUBChapterRuntimeStore { // 章节数据缓存(完整 RuntimeChapter) private let chapterDataCache = RDEPUBChapterDataCache() // 页数缓存(仅页范围,不含 NSAttributedString) private let pageCountCache = RDEPUBPageCountCache() // 图片缓存(NSCache,上限 50 张) let imageCache = NSCache() // 章节加载队列(串行,userInitiated QoS) let chapterLoadQueue = DispatchQueue(label: "com.rdreader.chapterload", qos: .userInitiated) } ``` **窗口驱逐策略**: ```swift func setCurrentChapter(spineIndex: Int, totalSpineCount: Int, windowRadius: Int = 1) { currentSpineIndex = spineIndex let lowerBound = max(0, spineIndex - radius) let upperBound = min(totalSpineCount - 1, spineIndex + radius) windowSpineIndices = Array(lowerBound...upperBound) } ``` - 保留范围:`[spineIndex - windowRadius, spineIndex + windowRadius]` - `evictableSpineIndices()` 返回窗口外的已缓存章节索引 - 章节切换时调用 `setCurrentChapter` 更新窗口,然后驱逐窗口外章节 **内存警告处理**: ```swift func handleMemoryWarning() { evictAllExceptCurrent() // 驱逐除当前章节外的所有缓存 imageCache.removeAllObjects() // 清空图片缓存 } ``` **导航优先级抢占**: 当用户快速翻页时,新的导航请求可以抢占正在构建的章节: ```swift func setNavigationTarget(spineIndex: Int) // 设置导航目标 func consumeNavigationTarget() -> Int? // 消费导航目标(构建完成后检查) ``` `RDEPUBChapterLoader` 在完成当前章节构建后,会检查是否有新的导航目标,如有则立即切换到新目标。 ### 2.2 Tier 2: RDEPUBChapterSummaryDiskCache **文件**:`RDEPUBChapterSummaryDiskCache.swift` 磁盘摘要缓存,存储章节的分页元数据(不含完整 NSAttributedString)。 **存储路径**:`~/Library/Caches/RDEPUBChapterSummaryCache/{bookID}/` **文件命名**:`SHA256(bookID_spineIndex_renderSignature_contentHash).json` **缓存内容**(RDEPUBChapterSummary): - `pageRanges: [NSRange]` — 每页的文本范围 - `pageCount: Int` — 总页数 - `fragmentOffsets: [String: Int]` — fragment ID 到偏移量的映射 - `cfiMap: RDEPUBCFIMap?` — CFI 映射 - `renderSignature: String` — 渲染参数签名 - `chapterContentHash: String` — 章节 HTML 的 SHA-256 - `pageMetadataList: [PageMetadataSummary]` — 每页的语义元数据 **写入策略**: - 异步写入(serial DispatchQueue) - 原子写入(先写临时文件,再 rename) **批量读取**: ```swift func readAll(keys: [RDEPUBChapterCacheKey]) -> ( summaries: [Int: RDEPUBChapterSummary], partialBuilder: RDEPUBBookPageMap.Builder ) ``` 一次读取所有章节的摘要,同时构建 `BookPageMap.Builder`,避免重复遍历。 ### 2.3 Tier 3: RDEPUBTextBookCache 全书分页缓存,使用 `NSSecureCoding` 归档。包含每章的完整分页信息(pageRanges、breakReasons、semanticHints)。当 `schemaVersion` 变更时全部失效。 --- ## 3. RDEPUBChapterCacheKey — 缓存键设计 **文件**:`RDEPUBChapterCacheKey.swift` ```swift struct RDEPUBChapterCacheKey: Hashable { let bookID: String // 书籍唯一标识 let spineIndex: Int // 章节索引 let renderSignature: String // 渲染参数签名 let chapterContentHash: String // 章节 HTML 的 SHA-256 } ``` **renderSignature 组成**: ```swift let renderSignature = [ style.font.fontName, // 字体名称 "\(style.font.pointSize)", // 字号 "\(lineHeightMultiple)", // 行距倍数 "\(style.lineSpacing)", // 行间距 layoutConfig.cacheSignature, // 布局参数签名 "\(schemaVersion)" // 缓存 schema 版本 ].joined(separator: "|") ``` **缓存失效语义**: - 换字体/字号 → renderSignature 变化 → 缓存失效 - EPUB 内容更新 → contentHash 变化 → 缓存失效 - 不同书籍 → bookID 不同 → 互不干扰 - schemaVersion 变更 → 全部失效 --- ## 4. RDEPUBChapterLoader — 章节加载器 **文件**:`RDEPUBChapterLoader.swift` ### 4.1 加载流程 ```swift func loadChapter( spineIndex: Int, store: RDEPUBChapterRuntimeStore, priority: LoadPriority = .navigation, completion: @escaping (Result) -> Void ) ``` **三级缓存串联**: 1. **Tier 1 命中**:`store.chapterData(for: spineIndex)` → 直接返回 2. **Tier 1 页数缓存命中**:`store.pageCount(for: cacheKey)` → 轻量路径(跳过分页计算) 3. **Tier 2 命中**:`summaryDiskCache?.read(for: cacheKey)` → 轻量路径 4. **全部未命中**:完整路径(渲染 + 分页 + 写缓存) ### 4.2 轻量路径 vs 完整路径 **轻量路径**(有缓存页范围时): 1. 读取 HTML 并渲染为 NSAttributedString 2. 使用缓存的 pageRanges 直接构建页面 3. 跳过分页计算(最耗时的步骤) **完整路径**(无缓存时): 1. 使用 `RDEPUBTextBookBuilder.buildChapter()` 完整构建 2. 包含 HTML→NSAttributedString→分页→尾页规范化全流程 3. 构建完成后写入磁盘摘要缓存 ### 4.3 加载优先级 ```swift enum LoadPriority { case navigation // 用户导航触发(最高优先级,可抢占) case preview // 预览触发 case prefetch // 预取触发(最低优先级) } ``` **导航优先级抢占**:当 `priority == .navigation` 时,完成构建后检查 `consumeNavigationTarget()`,如有新目标则立即切换。 ### 4.4 同步加载 ```swift func loadChapterSynchronouslyForMigration( spineIndex: Int, store: RDEPUBChapterRuntimeStore? ) throws -> RDEPUBRuntimeChapter ``` 使用信号量阻塞当前线程,等待章节加载完成。用于快速打开路径中加载首个可渲染章节。 --- ## 5. RDEPUBBookPageMap — 轻量页码映射 **文件**:`RDEPUBBookPageMap.swift` 轻量级全书页码映射,不持有 NSAttributedString,内存占用约 100KB/1000 章。 ### 5.1 数据结构 ```swift struct RDEPUBBookPageMapEntry { let spineIndex: Int let href: String let title: String let pageCount: Int let absolutePageStart: Int // 该章节的起始绝对页码 let fragmentOffsets: [String: Int] } struct RDEPUBBookPageMap { let entries: [RDEPUBBookPageMapEntry] let totalPages: Int var totalChapters: Int { entries.count } } ``` ### 5.2 页码查询 ```swift // 绝对页码 → 章节索引(二分查找,O(log n)) func spineIndex(forAbsolutePage absolutePage: Int) -> Int? // 绝对页码 → 章节内本地页码 func localPageIndex(forAbsolutePage absolutePage: Int) -> Int? // (spineIndex, localPageIndex) → 绝对页码 func absolutePageIndex(spineIndex: Int, localPageIndex: Int) -> Int? ``` ### 5.3 Builder 增量构建 ```swift struct Builder { mutating func add( spineIndex: Int, href: String, title: String, pageCount: Int, fragmentOffsets: [String: Int] ) func build() -> RDEPUBBookPageMap } ``` Builder 模式支持增量添加章节信息,`build()` 时自动按 spineIndex 排序并计算绝对页码。 --- ## 6. RDEPUBPageResolver — 页码解析器 **文件**:`RDEPUBPageResolver.swift` 将绝对页码解析为章节和本地页码的组合。 ```swift struct RDEPUBResolvedPage { let spineIndex: Int let localPageIndex: Int let page: RDEPUBTextPage? } func resolvePage(absolutePageIndex: Int) -> RDEPUBResolvedPage? ``` 解析流程: 1. 从 `BookPageMap` 查找 spineIndex 和 localPageIndex 2. 从 `ChapterRuntimeStore` 获取已加载的章节数据 3. 如果章节未加载,触发按需加载 --- ## 7. 后台元数据解析优化 **文件**:`RDEPUBReaderPaginationCoordinator.swift` ### 7.1 整体流程 ``` RDEPUBMetadataParseWorker.start(token) │ ├─ 预计算所有章节 contentHash(串行) ├─ readAll(keys:) 批量读取磁盘缓存 ├─ refreshBookPageMapInPlace(缓存部分) ├─ waitForReadingInteractionToSettle(0.8s 冷却) │ ├─ OperationQueue (并发 N=CPU核心数): │ ├─ buildChapter(spineIndex) // 渲染+分页 │ ├─ chapterCacheKey(spineIndex) // 复用预计算 hash │ ├─ summary.write() // 异步写盘 │ └─ 每 32 章刷新 BookPageMap │ └─ 最终 refreshBookPageMapInPlace ``` ### 7.2 预计算 contentHash **问题**:每个章节在构建缓存键时需要读取 HTML 并计算 SHA-256,重复 I/O 开销大。 **优化**:在后台解析开始时,串行预计算所有章节的 contentHash: ```swift var contentHashBySpineIndex: [Int: String] = [:] for spineIndex in allBuildableIndices { let html = parser.htmlString(forRelativePath: href) contentHashBySpineIndex[spineIndex] = html?.rd_sha256Hex ?? "" } ``` 后续所有 `chapterCacheKey` 调用都使用预计算值。 ### 7.3 冻结 renderSignature **问题**:用户在后台解析进行中更改字号/行距,会导致部分章节用旧签名、部分用新签名写入缓存。 **解决**:在 `RDEPUBMetadataParseWorker` 初始化时冻结签名: ```swift let renderSignature = context.currentRenderSignature() // 后续所有 chapterCacheKey 调用使用此固定值 ``` token 机制确保设置变更会触发新的解析任务(新 token),旧任务自动废弃。 ### 7.4 锁区瘦身 **原始实现**:`resultLock` 内调用 `buildPageMap()`,遍历全量 catalog 和 summaries。 **优化**:锁内只做写入和计数,快照数据后锁外构建 pageMap: ```swift var snapshot: [Int: RDEPUBChapterSummary]? resultLock.lock() summariesBySpineIndex[spineIndex] = renderResult totalResolvedCount += 1 if shouldRefresh { snapshot = summariesBySpineIndex // 快照 } resultLock.unlock() if let snapshot { let partialMap = buildPageMap(from: catalog, summaries: snapshot) // 锁外 } ``` ### 7.5 可配置刷新间隔 ```swift static var pageMapRefreshInterval: Int = 32 // 默认 32 章 ``` 可实测调优:32 / 48 / 64。值越大,UI 刷新频率越低,后台解析吞吐越高。 ### 7.6 用户交互冷却 等待用户操作冷却 0.8 秒后再开始后台解析,避免与用户翻页操作竞争资源: ```swift try await Task.sleep(nanoseconds: 800_000_000) ``` --- ## 8. 关键配置参数 | 参数 | 位置 | 默认值 | 说明 | |------|------|--------|------| | `onDemandChapterWindowSize` | `RDEPUBReaderConfiguration` | `3` | 按需加载窗口大小(奇数,3-15) | | `chapterWindowRadius` | 计算属性 | `1` | 内存缓存窗口半径(windowSize / 2) | | `metadataParsingConcurrency` | `RDEPUBReaderConfiguration` | CPU 核心数 | 后台解析并发数 | | `pageMapRefreshInterval` | 静态变量 | `32` | 每 N 章刷新一次 UI | | `imageCache.countLimit` | `RDEPUBChapterRuntimeStore` | `50` | 图片缓存上限 | | 冷却时间 | 硬编码 | `0.8s` | 用户交互冷却时间 | | `schemaVersion` | `RDEPUBChapterSummary` | - | 缓存 schema 版本 |