# 大书优化方案:章节级缓存运行时与二次打开加速 > 适用对象:`textReflowable` 路径下的超大 EPUB,例如《凡人修仙传》精校版全本。 > 文档目标:把当前”整书分页元数据磁盘缓存 + 打开时重建整本模型”的实现,迁移为**以 WXRead 为基线**的章节级运行时;同时明确哪些部分是 SDK 为了工程化和二次打开体验做的增强。 > 更新时间:2026-06-02 ### 作用域边界 **本方案所有改造(含 DataSource 适配、窗口快照、章节缓存运行时)仅适用于 `textReflowable` 路径。** 具体边界: - `RDEPUBReaderController+DataSource` 的窗口快照直连改造,仅在 `textReflowable` 类型书籍的控制器实例上启用 - `RDReaderView` 消费的数据源切换为窗口快照,仅限 textReflowable 路径 - 固定版式(`fixed-layout`)、PDF 等其他路径维持原有 DataSource 实现不变 - `RDEPUBReaderController` 需在初始化时判断书籍类型,选择对应的 DataSource 策略 实现要求: - 不允许将窗口快照 DataSource 无条件覆盖到所有 `RDReaderView` 消费者 - 书籍类型判断必须在控制器层完成,不能下沉到 `RDReaderView` 内部 - 非 textReflowable 路径继续使用整书 `pages` 数组供页,不受本方案影响 --- ## 1. 范围与结论 这份方案的核心方向与 WXRead 一致: - 正文运行时不再以全书 `RDEPUBTextBook` 为真值 - 正文缓存不再以整书分页文件为主路径 - 运行时只围绕“当前章 + 相邻章”工作 - 章节加载串行化 - 内存回收与阅读窗口绑定 但这份方案**不是逐字逐句复刻 WXRead 的类拆分**,而是: - `WXRead` 的核心缓存与生命周期策略:保持一致 - `ReadViewSDK` 的工程化拆分类、轻量磁盘摘要层:作为 SDK 增强 一句话总结: **主缓存与运行时语义复刻 WXRead;工程结构与二次打开加速允许做 SDK 增强。** --- ## 2. 当前问题 当前实现的主要问题不是“完全没有缓存”,而是缓存重心与运行时真值都放错了位置: - `RDEPUBTextBookCache` 缓存的是整书分页元数据 - 打开书时仍然会重新读取 HTML、重新生成富文本、重新组装整书模型 - `context.textBook`、全书 `pages`、全书索引表仍然参与正文主链路 - 大书场景下,CPU、内存、主线程压力都容易被整书路径放大 这和 WXRead 的关键差异是: - WXRead 主缓存是章节运行时缓存 - WXRead 主路径不依赖整书分页文件 - WXRead 主运行时不以“后台补齐整书再切换”为中心 --- ## 3. 与 WXRead 对齐的部分 以下部分要求与 WXRead 保持同方向: ### 3.1 章节级主缓存 - 章节缓存粒度为单章 - 当前章与相邻章构成运行时窗口 - 窗口外章节必须可确定性淘汰 ### 3.2 串行章节加载 - 所有章节构建都通过单一串行队列 - 同一时刻只允许一章实际构建 ### 3.3 内存警告策略 - 当前章必须保住 - 非当前窗口章节优先释放 ### 3.4 正文真值退化为章节级 - 不再以全书 `TextBook` 为正文主真值 - 不再把整书分页完成视为阅读器唯一稳定态 --- ## 4. SDK 增强项 下面这些设计是**相对 WXRead 的 SDK 增强**,不应表述为“原样复刻”: ### 4.1 轻量章节摘要磁盘缓存 `chapterSummaryDiskCache` 是 SDK 增强,不是 WXRead 原生能力。 定位: - 只用于二次打开加速 - 只缓存轻量摘要 - 不是正文主缓存 ### 4.2 协调器拆分 以下类拆分是 SDK 工程化改进: - `RDEPUBChapterLoader` - `RDEPUBChapterWindowCoordinator` - `RDEPUBChapterLocationCoordinator` WXRead 把类似职责大量内联在 `WRReaderViewController` 内;ReadViewSDK 不必复制这种类膨胀结构。 ### 4.3 旧链路处理边界 本稿按“章节运行时路径为唯一主路径”组织,不再保留旧整书主路径的并行设计。 要求: - 文档中的数据源、分页、位置持久化均只描述章节运行时方案 - 旧整书 `TextBook` 路径只作为历史背景,不再作为本方案的一部分 - 实施时如需过渡脚手架,可单独记录在迁移任务中,但不写入主设计文档 --- ## 5. 设计原则 ### 5.1 正文主缓存坚持 WXRead - 正文主缓存以章节级内存缓存为主 - 章节淘汰必须由阅读窗口和内存策略显式决定 - 不能把整书磁盘分页缓存重新扶正成正文主路径 ### 5.2 图片与轻量摘要层可参考 SDWebImage 可以借鉴 SDWebImage 的部分: - `memory -> disk -> rebuild` 分层思路 - cache key 设计 - 版本化与失效策略 - 图片 `NSCache` 但以下对象不能按 SDWebImage 思路长期磁盘化: - `RDEPUBRuntimeChapter` - `NSAttributedString` - `RDEPUBTextLayouter` - 完整 `pages` ### 5.3 章节生命周期优先于历史命中率 缓存目标不是“尽量记住所有历史章节”,而是: - 当前章立即可读 - 相邻章尽量无感 - 窗口外尽快释放 ### 5.4 位置真值改为章节语义 主位置语义改成: - `spineIndex` - `chapterOffset` - `fragmentID` 全书页码不再作为稳定真值,只能是派生展示值。 --- ## 6. 运行时架构 ### 6.1 核心分层 建议分成 4 层: 1. `RDEPUBChapterRuntimeStore` 2. `RDEPUBChapterLoader` 3. `RDEPUBChapterWindowCoordinator` 4. `RDEPUBChapterLocationCoordinator` ### 6.2 类型安全缓存封装 虽然缓存策略等价于 WXRead 的 `NSMutableDictionary + 手动淘汰`,但在 Swift 代码库里不建议直接把 `NSMutableDictionary` 暴露为主接口。 建议使用类型安全封装: ```swift final class RDEPUBChapterDataCache { private var storage: [Int: RDEPUBRuntimeChapter] = [:] private let lock = NSLock() } final class RDEPUBPageCountCache { private var storage: [RDEPUBChapterCacheKey: RDEPUBRuntimePageCount] = [:] private let lock = NSLock() } ``` 要求: - 对外暴露 typed API - 内部保持确定性淘汰能力 - 不把 Objective-C 容器直接扩散到全链路 ### 6.3 线程安全规则 缓存不是天然线程安全的,文档必须明确: - 章节构建统一在 `chapterLoadQueue` 发生 - 缓存写入统一通过 store 的同步接口完成 - 缓存读取也必须通过 store 的同步接口 - 不允许主线程裸读缓存、后台线程裸写缓存 推荐实现: - `chapterLoadQueue` 负责串行构建 - cache wrapper 内部用 `NSLock` 或专用串行队列保护读写 --- ## 7. 缓存设计 ### 7.1 一级缓存:章节运行时缓存 ```swift final class RDEPUBRuntimeChapter { let spineIndex: Int let href: String let title: String let sourceAttributedString: NSAttributedString? let typesetAttributedString: NSAttributedString let layouter: RDEPUBTextLayouter let pages: [RDEPUBTextPage] let pageRanges: [NSRange] let chapterOffsetMap: RDEPUBChapterOffsetMap } ``` #### 关于 `sourceAttributedString` 这里不再强制“缓存中永远同时保留 source 和 typeset 两份”。 建议策略: - 首次构建后可同时保留两份 - 当章节进入稳定缓存态后,可按策略释放 `sourceAttributedString` - 需要重新排版时,再从 HTML 或轻量中间态重建 source 原因: - `source + typeset` 双持会显著放大内存 - WXRead 这么做不代表 ReadViewSDK 必须照搬内存代价 文档结论: - `sourceAttributedString` 在类型上允许存在 - 但不是必须长期常驻的字段 ### 7.2 二级缓存:页数与页范围缓存 `pageCountCache` 的定位要收紧: - 它不是独立于 `chapterDataCache` 的第二真值 - 它是“轻量分页结构缓存”的内存映像 - 它主要服务于: - 二次打开加速 - 章节未命中时的快速分页结构恢复 一致性原则: - `chapterDataCache` 命中时,以章节对象内部 `pageRanges` 为准 - `pageCountCache` 不能覆盖章节对象真值 - 章节淘汰时,应同步清理与该章关联的 `pageCountCache` ### 7.3 三级缓存:轻量章节摘要磁盘缓存 `chapterSummaryDiskCache` 是 SDK 增强层。 只允许缓存轻量字段: - `pageRanges` - `pageCount` - `fragmentOffsets` 摘要 - `renderSignature` - `schemaVersion` - `chapterContentHash` - `pageMetadataList`(每页的 breakReason / attachmentKinds / blockKinds / semanticHints / attachmentPlacements / trailingFragmentID 摘要) 不允许缓存: - 完整 `RDEPUBRuntimeChapter` - `NSAttributedString` - `RDEPUBTextLayouter` - 完整 `pages` ### 7.4 四级缓存:解压缓存 EPUB 解压目录仍然保留在 `Caches`,但它只负责避免重复解压,不参与正文真值。 --- ## 8. 关键缺失定义补齐 ### 8.1 `RDEPUBChapterOffsetMap` 需要明确定义: ```swift struct RDEPUBChapterOffsetMap { let fragmentOffsets: [String: Int] let pageStartOffsets: [Int] let pageEndOffsets: [Int] } ``` 职责: - `fragmentID -> chapterOffset` - `chapterOffset -> pageIndex` - 章内命中测试与位置恢复 ### 8.2 `renderSignature` `renderSignature` 必须包含: - `fontName` - `fontSize` - `lineHeightMultiple` - `contentInsets` - `pageSize` - `layoutConfigSignature` - `schemaVersion` 它是分页结构与轻量摘要层的核心失效依据。 ### 8.2.1 `layoutConfig.cacheSignature` 字段组成 `cacheSignature` 是 `RDEPUBTextLayoutConfig` 的签名摘要,必须覆盖所有影响分页结果的排版参数。任何参数变化都必须导致签名不同,从而使旧缓存自然失效。 必须包含的字段: ```swift extension RDEPUBTextLayoutConfig { /// 缓存签名:覆盖所有影响分页结果的排版参数 /// 任何字段变化都必须导致签名变化,否则会出现缓存命中但分页结果不一致的 bug var cacheSignature: String { let fields: [String] = [ "columnCount:\(columnCount)", // 分栏数 "columnSpacing:\(columnSpacing)", // 栏间距 "paragraphSpacing:\(paragraphSpacing)", // 段间距 "paragraphFirstLineIndent:\(paragraphFirstLineIndent)", // 首行缩进 "hyphenationEnabled:\(hyphenationEnabled)", // 连字开关 "textAlignment:\(textAlignment.rawValue)", // 对齐方式 "wordSpacing:\(wordSpacing)", // 字间距 "letterSpacing:\(letterSpacing)", // 字母间距 "imageMaxScale:\(imageMaxScale)", // 图片最大缩放比 "imageInlineMaxHeight:\(imageInlineMaxHeight)", // 内联图片最大高度 "attachmentPlacement:\(attachmentPlacement.rawValue)", // 附件放置策略 "breakStrategy:\(breakStrategy.rawValue)", // 分页策略 ] return fields.joined(separator: "|") } } ``` 设计原则: - **只包含影响分页结果的字段**:纯展示参数(如高亮颜色、选中样式)不进签名 - **不包含 `edgeInsets`**:`edgeInsets` 已在 `renderSignature` 中独立编码,避免重复 - **不包含 `lineHeightMultiple`**:同上,已在 `renderSignature` 中独立编码 - **字段顺序固定**:签名拼接顺序必须稳定,避免因字段顺序变化导致无意义的缓存失效 - **新增字段时必须追加到签名末尾**:并在 `schemaVersion` 中递增,确保旧缓存不被错误命中 不包含的字段(及其原因): | 字段 | 原因 | |------|------| | `edgeInsets` | 已在 `renderSignature` 独立编码 | | `lineHeightMultiple` | 已在 `renderSignature` 独立编码 | | `highlightColor` | 不影响分页结果 | | `selectionColor` | 不影响分页结果 | | `debugShowPageBounds` | 调试开关,不影响分页结果 | ### 8.3 `RDEPUBChapterCacheKey` 建议定义: ```swift struct RDEPUBChapterCacheKey: Hashable { let bookID: String let spineIndex: Int let renderSignature: String let chapterContentHash: String } ``` ### 8.4 `RDEPUBRuntimePageCount` `pageCountCache` 依赖的轻量分页结构类型需要明确: ```swift struct RDEPUBRuntimePageCount { let cacheKey: RDEPUBChapterCacheKey let spineIndex: Int let pageRanges: [NSRange] let pageCount: Int let renderSignature: String } ``` ### 8.5 排版参数变化时的缓存失效 排版参数变化后必须同时处理: - 清理 `chapterDataCache` - 清理 `pageCountCache` - 使 `chapterSummaryDiskCache` 对应 key 自然失效 不要做部分失效,否则最容易出现页范围与正文不一致。 --- ## 9. 二次打开加速方案 ### 9.1 目标 第二次打开要更快,但不允许把完整章节运行时对象长期磁盘化。 ### 9.2 命中链路 第二次打开推荐链路: 1. 命中 EPUB 解压缓存 2. 定位目标 `spineIndex` 3. 先查 `chapterDataCache` 4. 未命中时查 `pageCountCache` 5. 仍未命中时查 `chapterSummaryDiskCache` 6. 命中轻量分页结构后跳过最重的分页计算 7. 重建当前章必需的富文本和页面对象 8. 当前章先进入阅读器 9. 再按 `±1` 规则预取相邻章 ### 9.3 能省掉什么 能明显减少: - 重新分页计算 - 页范围生成 - 部分 fragment offset 计算 仍然需要: - 读取目标章 HTML - 生成必要的富文本 - 构建当前章页面对象 ### 9.4 结果预期 这不是“零重建秒进”,而是“跳过最重步骤后的明显加速”。 --- ## 10. 位置模型与跨章能力 ### 10.1 新位置结构 ```swift public struct RDEPUBChapterLocation: Codable { public var spineIndex: Int public var chapterOffset: Int public var fragmentID: String? public var progressionInChapter: Double? } ``` ### 10.2 存量数据迁移 必须考虑旧数据兼容: - 旧版页码位置 - 旧版全局偏移位置 - 旧版书签 - 旧版高亮 建议: 1. 位置持久化增加版本字段 2. 先实现 `legacy -> chapterLocation` 转换器 3. 新写入统一用 `RDEPUBChapterLocation` 4. 旧数据转换成功后覆盖为新格式 ### 10.3 跨章功能替代方案 停止依赖全书 `RDEPUBTextIndexTable` 后,需要明确替代设计: - 全书搜索结果 - 搜索索引层仍可维护轻量章节级倒排或章节命中列表 - 展示位置以“章节标题 + 章内片段”替代全书绝对页码真值 - 书签 / 高亮 - 统一持久化为 `spineIndex + chapterOffset + rangeLength` - 当章节不在窗口内时,按章节级锚点懒加载恢复 - 目录跳转 - 直接定位 `spineIndex` / `fragmentID` 文档结论: - 可以放弃全书绝对页码真值 - 不能放弃跨章功能 --- ## 11. RDReaderView 适配策略 当前 `RDReaderView` 更习惯消费连续页数组;迁移到章节窗口后,需要加一层适配。 ### 11.1 建议方案 引入窗口快照: ```swift struct RDEPUBChapterWindowSnapshot { let chapters: [RDEPUBRuntimeChapter] let flattenedPages: [RDEPUBTextPage] let anchorChapterIndex: Int } ``` 说明: - 运行时真值仍然是章节窗口 - `RDReaderView` 只消费当前窗口展开后的局部连续页数组 - 不再消费整书连续页数组 ### 11.2 切窗策略 当跨章时: 1. 先构造新的窗口快照 2. 再切换 `RDReaderView` 数据源 3. 保持当前阅读锚点不抖动 --- ## 12. 快速翻章体验 `±1` 窗口 + 串行队列意味着快速翻章一定存在等待风险,方案必须定义 UI 行为。 建议: - 下一章未就绪时,展示章节级 loading 状态 - loading 必须是页内轻提示,不要整屏阻塞 - 若用户连续快速翻章,只保留最后一次目标章节请求 - 非当前目标章节的排队请求可取消或降级 - 已经开始执行中的章节构建默认不强行中断 - 当前任务结束后只允许最后一次目标章节请求进入显示链路 目标: - 不追求无限预读 - 追求在内存可控前提下的稳定体验 --- ## 13. 内存警告与淘汰策略 ### 13.1 章节缓存策略 收到 `UIApplication.didReceiveMemoryWarningNotification` 时: 1. 保存当前章 2. 清空非当前章缓存 3. 恢复当前章 4. 清理图片缓存 ### 13.2 `pageCountCache` 策略 这里需要明确和之前版本不同的结论: - `pageCountCache` 不是独立真值 - 当前章的页范围已经包含在 `RDEPUBRuntimeChapter` 因此两种实现都可接受,但必须文档化: 1. 更保守方案 - 保留当前章对应的 `pageCountCache` 2. 更简化方案 - 直接清空全部 `pageCountCache` - 因为当前章显示不依赖它 推荐: - 默认采用“保守方案”,保留当前章对应的 `pageCountCache` - 章节淘汰时同步淘汰对应 `pageCountCache` --- ## 14. 具体开发方案 ### 14.1 目标分层 后续代码建议拆成: 1. `RDEPUBChapterRuntimeStore` 2. `RDEPUBChapterLoader` 3. `RDEPUBChapterWindowCoordinator` 4. `RDEPUBChapterLocationCoordinator` 5. `RDEPUBChapterSummaryDiskCache` ### 14.2 与现有模块的替换关系 - `RDEPUBReaderPaginationCoordinator` - 从整书分页协调器改成章节窗口分页入口 - `RDEPUBTextBookBuilder` - 保留单章构建能力 - 整书构建不再作为大书正文主路径 - `RDEPUBReaderContext` - 降低 `textBook` 真值地位 - 挂入 `chapterRuntimeStore` - `RDEPUBReaderController+DataSource` - 改为基于窗口快照供页 - `RDEPUBReaderRuntime` - `go(toPageNumber:)` 改成章内语义 ### 14.3 标准章节加载链路 1. 输入 `spineIndex` 2. 查 `chapterDataCache` 3. miss 后查 `pageCountCache` 4. 仍未命中时查 `chapterSummaryDiskCache` 5. 在 `chapterLoadQueue` 中读取 HTML 6. 构建 source/typeset 7. 构建 layouter 8. 若命中 `pageCountCache` 或 `chapterSummaryDiskCache`,优先复用 `pageRanges` 9. 若未命中轻量分页结构,则执行完整分页 10. 生成 `pages` 11. 生成 `chapterOffsetMap` 12. 回填章节缓存 13. 回填 `pageCountCache` 14. 回填轻量摘要层 15. 更新窗口快照 16. 回主线程驱动显示 ### 14.4 串行队列中的取消语义 串行队列下的取消分为两类: 1. 尚未开始执行的排队请求 2. 已经进入章节构建中的请求 文档结论: - 对于尚未开始执行的请求:允许取消,只保留最后一次目标章节请求 - 对于已经开始执行的请求:默认不强行中断 CoreText / 分页过程 原因: - 当前工程没有安全的“可中断分页事务”机制 - 强中断会放大半完成状态、缓存污染和 UI 状态错乱的风险 推荐实现: - 采用“可取消排队,不中断执行中任务”的策略 - 当前任务完成后,立刻检查最后一次目标章节是否变化 - 若目标已变化,则丢弃不再需要的结果,不更新窗口 - 只把最后一次目标章节推进到显示链路 快速跳章体验要求: - 如果用户从目录直接跳到较远章节,例如第 50 章: - 旧的排队请求可以取消 - 当前正在构建的章节允许自然完成 - 完成后立即转向最新目标章节请求 - UI 必须提供轻量 loading 提示,明确当前正在打开目标章节 延迟约束: - 单章构建耗时必须可观测 - 如果快速跳章的等待不可接受,优先优化单章构建耗时 - 不应先引入危险的强中断机制 --- ## 15. 迁移策略 ### 15.1 单一路径切换 本方案不再维护“新旧两套正文链路并存”。 要求: - `RDEPUBChapterRuntimeStore + RDEPUBChapterLoader + RDEPUBChapterWindowCoordinator` 组成唯一正文主路径 - `RDReaderView` 的数据源直接消费窗口快照 - 位置持久化、翻章、搜索结果定位统一落到章节语义 ### 15.2 P0 风险控制 P0 改成: - `P0-1` 建立章节 store 与 loader - `P0-2` 建立窗口数据源适配 - `P0-3` 打通打开书、翻章、持久化三条核心链路 - `P0-4` 验证通过后移除旧整书主路径相关依赖 这样可以避免“主设计仍在描述双路径”,让实现和文档保持一致。 --- ## 16. 可直接开发的实施清单 ### P0:建立章节真值主路径 1. 新建 `RDEPUBChapterRuntimeStore` 2. 新建 `RDEPUBChapterLoader` 3. 新建 `RDEPUBChapterWindowSnapshot` 4. 让 `RDEPUBReaderController+DataSource` 直接消费窗口快照 5. 打通打开书、翻章、位置持久化 ### P1:建立完整章节缓存运行时 1. 实现 `chapterDataCache` 2. 实现 `pageCountCache` 3. 实现 `chapterOffsetMap` 4. 建立 `±1` 预取窗口 5. 建立内存警告清理 ### P2:接入二次打开加速层 1. 新建 `RDEPUBChapterSummaryDiskCache` 2. 定义 `renderSignature` 3. 定义 `RDEPUBChapterCacheKey` 4. 当前章优先命中轻量摘要层 ### P3:完成位置与跨章能力迁移 1. 新建 `RDEPUBChapterLocation` 2. 实现 legacy 位置转换器 3. 搜索/书签/高亮改成章节级锚点 4. `go(toPageNumber:)` 改为章内语义 ### P4:移除旧整书主路径依赖 1. 移除大书场景下的整书 `TextBook` 主路径依赖 2. 降级 `RDEPUBTextBookCache` 3. 清理 staged/full apply 相关状态 --- ## 17. 验收标准 - 大书正文主路径不再依赖整书 `RDEPUBTextBook` - 当前章与相邻章构成运行时真值 - 所有章节构建始终串行 - 缓存读写线程安全规则明确且已落地 - 参数变化后缓存整体一致失效,不出现页范围错配 - 内存警告后当前阅读不中断 - 二次打开能明显减少分页计算时间 - 旧书签、高亮、阅读位置能够迁移到章节级位置模型 - 搜索、目录、书签、高亮等跨章能力在无全书索引真值下仍可正常工作 - **位置迁移降级验收**:`schemaVersion == 1` 的粗估降级结果偏差 ≤ ±15%,且下次打开同一章时必须被精确值覆盖(详见 §19.4.1 粗估降级验收标准) --- ## 18. 结论 这份方案的最终边界是: - **WXRead 对齐部分** - 章节级主缓存 - 串行章节加载 - `±1` 窗口 - 当前章优先的内存回收 - 章节级位置真值 - **SDK 增强部分** - 协调器拆分 - 类型安全缓存包装 - 轻量章节摘要磁盘缓存 - 单一路径分阶段切换 只要实现时始终坚持”正文主缓存是章节内存缓存、磁盘层只是辅助加速层”,这条路线就是正确的。 --- ## 19. 伪代码级别落实方案 以下按 P0→P4 阶段给出每个核心类型的伪代码,精确到属性、方法签名和关键逻辑分支,可直接作为开发参照。 ### 19.1 P0:引入章节真值但不破坏旧链路 #### 19.1.1 RDEPUBChapterRuntimeStore ```swift // ============ 新增文件:RDEPUBChapterRuntimeStore.swift ============ final class RDEPUBChapterRuntimeStore { // MARK: - 子缓存 /// 章节运行时主缓存(等价 WXRead chapterDataCache) private let chapterDataCache = RDEPUBChapterDataCache() /// 轻量分页结构缓存(等价 WXRead pageCountCache) private let pageCountCache = RDEPUBPageCountCache() /// 图片缓存(独立 NSCache,等价 WXRead imageCache) let imageCache = NSCache() /// 串行加载队列(等价 WXRead com.weread.chapterload) let chapterLoadQueue = DispatchQueue(label: “com.rdreader.chapterload”, qos: .utility) // MARK: - 窗口状态 /// 当前章 spineIndex private(set) var currentSpineIndex: Int? /// 当前窗口内的 spineIndex 集合(当前 + prev + next) private(set) var windowSpineIndices: [Int] = [] // MARK: - 请求通道(前台导航 vs 后台预取,语义独立,互不抢占) /// 前台导航目标(用户主动跳章:目录/书签/搜索/翻章) /// 仅保留最后一次目标,旧的排队请求可被取消 private var pendingNavigationTarget: Int? private let navigationLock = NSLock() /// 后台预取目标集合(±1 相邻章预取) /// 预取不抢占前台导航通道,预取完成后仅刷新快照,不触发跳章 private var pendingPrefetchTargets: Set = [] private let prefetchLock = NSLock() /// 是否有章节正在构建中 private(set) var isBuilding: Bool = false private let buildingLock = NSLock() // MARK: - 初始化 init() { imageCache.countLimit = 50 } // MARK: - 缓存查询(线程安全,通过 cache wrapper 的 lock 保护) func chapterData(for spineIndex: Int) -> RDEPUBRuntimeChapter? { return chapterDataCache[spineIndex] } func pageCount(for key: RDEPUBChapterCacheKey) -> RDEPUBRuntimePageCount? { return pageCountCache[key] } // MARK: - 缓存插入 func insertChapter(_ chapter: RDEPUBRuntimeChapter) { chapterDataCache[spineIndex: chapter.spineIndex] = chapter } func insertPageCount(_ pc: RDEPUBRuntimePageCount, for key: RDEPUBChapterCacheKey) { pageCountCache[key] = pc } // MARK: - 窗口管理 /// 设定当前章,自动计算 ±1 窗口 func setCurrentChapter(spineIndex: Int, totalSpineCount: Int) { currentSpineIndex = spineIndex var window = [spineIndex] if spineIndex > 0 { window.append(spineIndex - 1) } if spineIndex < totalSpineCount - 1 { window.append(spineIndex + 1) } windowSpineIndices = window } /// 返回窗口外、应该淘汰的 spineIndex func evictableSpineIndices() -> [Int] { let windowSet = Set(windowSpineIndices) return chapterDataCache.storedSpineIndices.filter { !windowSet.contains($0) } } // MARK: - 淘汰 func evict(spineIndex: Int) { chapterDataCache.remove(spineIndex: spineIndex) // 同步淘汰对应的 pageCountCache pageCountCache.remove(forSpineIndex: spineIndex) } func evictAllExceptCurrent() { guard let current = currentSpineIndex else { chapterDataCache.removeAll() pageCountCache.removeAll() return } let currentChapter = chapterDataCache[current] chapterDataCache.removeAll() if let ch = currentChapter { chapterDataCache[spineIndex: current] = ch } // pageCountCache 保守方案:保留当前章的 let currentPageCounts = pageCountCache.entriesForSpineIndex(current) pageCountCache.removeAll() for (key, value) in currentPageCounts { pageCountCache[key] = value } } // MARK: - 内存警告 func handleMemoryWarning() { evictAllExceptCurrent() imageCache.removeAllObjects() } // MARK: - 前台导航请求管理(§14.4 取消语义) /// 注册前台导航目标(用户主动跳章时调用) /// 仅保留最后一次目标,旧的排队请求可被取消 func setNavigationTarget(spineIndex: Int) { navigationLock.lock() pendingNavigationTarget = spineIndex navigationLock.unlock() } /// 消费前台导航目标(章节构建完成后调用,检查是否有更新的目标) func consumeNavigationTarget() -> Int? { navigationLock.lock() let target = pendingNavigationTarget pendingNavigationTarget = nil navigationLock.unlock() return target } // MARK: - 后台预取请求管理 /// 注册后台预取目标(±1 相邻章预取时调用) /// 预取不抢占前台导航通道 func addPrefetchTarget(_ spineIndex: Int) { prefetchLock.lock() pendingPrefetchTargets.insert(spineIndex) prefetchLock.unlock() } /// 标记预取目标已完成 func removePrefetchTarget(_ spineIndex: Int) { prefetchLock.lock() pendingPrefetchTargets.remove(spineIndex) prefetchLock.unlock() } /// 清空所有预取目标(切章时调用,旧预取结果不再需要) func clearPrefetchTargets() { prefetchLock.lock() pendingPrefetchTargets.removeAll() prefetchLock.unlock() } /// 检查是否有待处理的预取目标 func hasPrefetchTarget(_ spineIndex: Int) -> Bool { prefetchLock.lock() let has = pendingPrefetchTargets.contains(spineIndex) prefetchLock.unlock() return has } func markBuilding(_ building: Bool) { buildingLock.lock() isBuilding = building buildingLock.unlock() } } ``` #### 19.1.2 RDEPUBChapterDataCache / RDEPUBPageCountCache ```swift // ============ 新增文件:RDEPUBChapterDataCache.swift ============ final class RDEPUBChapterDataCache { private var storage: [Int: RDEPUBRuntimeChapter] = [:] private let lock = NSLock() subscript(spineIndex: Int) -> RDEPUBRuntimeChapter? { get { lock.lock() defer { lock.unlock() } return storage[spineIndex] } set { lock.lock() defer { lock.unlock() } storage[spineIndex] = newValue } } var storedSpineIndices: [Int] { lock.lock() defer { lock.unlock() } return Array(storage.keys) } func remove(spineIndex: Int) { lock.lock() defer { lock.unlock() } storage.removeValue(forKey: spineIndex) } func removeAll() { lock.lock() defer { lock.unlock() } storage.removeAll() } } // ============ 新增文件:RDEPUBPageCountCache.swift ============ final class RDEPUBPageCountCache { private var storage: [RDEPUBChapterCacheKey: RDEPUBRuntimePageCount] = [:] private let lock = NSLock() subscript(key: RDEPUBChapterCacheKey) -> RDEPUBRuntimePageCount? { get { lock.lock() defer { lock.unlock() } return storage[key] } set { lock.lock() defer { lock.unlock() } storage[key] = newValue } } func entriesForSpineIndex(_ spineIndex: Int) -> [(RDEPUBChapterCacheKey, RDEPUBRuntimePageCount)] { lock.lock() defer { lock.unlock() } return storage.filter { $0.value.spineIndex == spineIndex }.map { ($0.key, $0.value) } } func remove(forSpineIndex spineIndex: Int) { lock.lock() defer { lock.unlock() } storage = storage.filter { $0.value.spineIndex != spineIndex } } func removeAll() { lock.lock() defer { lock.unlock() } storage.removeAll() } } ``` #### 19.1.3 RDEPUBRuntimeChapter ```swift // ============ 新增文件:RDEPUBRuntimeChapter.swift ============ final class RDEPUBRuntimeChapter { let spineIndex: Int let href: String let title: String /// 原始富文本(可按策略释放,不强制常驻) var sourceAttributedString: NSAttributedString? /// 排版后富文本 let typesetAttributedString: NSAttributedString /// 排版器 let layouter: RDEPUBTextLayouter /// 页范围 let pageRanges: [NSRange] /// 页面数组 let pages: [RDEPUBTextPage] /// 章节偏移映射 let chapterOffsetMap: RDEPUBChapterOffsetMap init( spineIndex: Int, href: String, title: String, sourceAttributedString: NSAttributedString?, typesetAttributedString: NSAttributedString, layouter: RDEPUBTextLayouter, pageRanges: [NSRange], pages: [RDEPUBTextPage], chapterOffsetMap: RDEPUBChapterOffsetMap ) { self.spineIndex = spineIndex self.href = href self.title = title self.sourceAttributedString = sourceAttributedString self.typesetAttributedString = typesetAttributedString self.layouter = layouter self.pageRanges = pageRanges self.pages = pages self.chapterOffsetMap = chapterOffsetMap } /// 释放 sourceAttributedString 以降低内存 func releaseSourceText() { sourceAttributedString = nil } } ``` #### 19.1.4 RDEPUBChapterOffsetMap / RDEPUBRuntimePageCount / RDEPUBChapterCacheKey / RDEPUBChapterLocation ```swift // ============ 新增文件:RDEPUBChapterOffsetMap.swift ============ struct RDEPUBChapterOffsetMap { let fragmentOffsets: [String: Int] let pageStartOffsets: [Int] let pageEndOffsets: [Int] /// fragmentID -> 章内字符偏移 func chapterOffset(forFragmentID fragmentID: String) -> Int? { return fragmentOffsets[fragmentID] } /// 章内字符偏移 -> 章内页码(从 0 开始) func pageIndex(forChapterOffset offset: Int) -> Int? { for i in 0..= pageStartOffsets[i] && offset <= pageEndOffsets[i] { return i } } return nil } } // ============ 新增文件:RDEPUBRuntimePageCount.swift ============ struct RDEPUBRuntimePageCount { let cacheKey: RDEPUBChapterCacheKey let spineIndex: Int let pageRanges: [NSRange] let pageCount: Int let renderSignature: String } // ============ 新增文件:RDEPUBChapterCacheKey.swift ============ struct RDEPUBChapterCacheKey: Hashable { let bookID: String let spineIndex: Int let renderSignature: String let chapterContentHash: String } // ============ 新增文件:RDEPUBChapterLocation.swift ============ public struct RDEPUBChapterLocation: Codable { public var spineIndex: Int public var chapterOffset: Int public var fragmentID: String? public var progressionInChapter: Double? public var schemaVersion: Int = 2 // v1 = 旧全局模型, v2 = 章节模型 } ``` #### 19.1.5 RDEPUBChapterLoader ```swift // ============ 新增文件:RDEPUBChapterLoader.swift ============ final class RDEPUBChapterLoader { private unowned let context: RDEPUBReaderContext private var summaryDiskCache: RDEPUBChapterSummaryDiskCache? init(context: RDEPUBReaderContext) {} func setSummaryDiskCache(_ cache: RDEPUBChapterSummaryDiskCache) { summaryDiskCache = cache } // MARK: - 主入口:加载单个章节 /// 请求优先级 enum LoadPriority { case navigation // 前台导航:用户主动跳章,完成后检查导航目标队列 case prefetch // 后台预取:±1 相邻章,完成后仅回填缓存 + 刷新快照 } /// 在 chapterLoadQueue 上构建单章,完成后回调到主线程 func loadChapter( spineIndex: Int, store: RDEPUBChapterRuntimeStore, priority: LoadPriority = .navigation, completion: @escaping (Result) -> Void ) { // 1. 查内存缓存(统一回主线程,保证 completion 线程语义一致) if let cached = store.chapterData(for: spineIndex) { DispatchQueue.main.async { completion(.success(cached)) } return } // 2. 构建缓存键 let cacheKey = makeCacheKey(spineIndex: spineIndex) // 3. 查内存级 pageCountCache(轻量,无磁盘 I/O) let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges // 4. 全部后续操作(含磁盘 I/O)统一放到串行队列 // 避免磁盘读取阻塞主线程 store.markBuilding(true) store.chapterLoadQueue.async { // 4a. 仅当内存级 pageCountCache 未命中时才查磁盘摘要 // pageCountCache 命中 → 已有 pageRanges,不需要磁盘 I/O // pageCountCache 未命中 → 查磁盘拿 pageRanges + metadata let diskSummary: RDEPUBChapterSummary? if precomputedPageRanges == nil { diskSummary = self.summaryDiskCache?.read(for: cacheKey) } else { diskSummary = nil } let diskPageRanges = diskSummary?.pageRanges.map { $0.nsRange } let availablePageRanges = precomputedPageRanges ?? diskPageRanges do { let chapter = try self.buildChapter( spineIndex: spineIndex, availablePageRanges: availablePageRanges, diskSummary: diskSummary ) // 5. 回填缓存 store.insertChapter(chapter) let pc = RDEPUBRuntimePageCount( cacheKey: cacheKey, spineIndex: spineIndex, pageRanges: chapter.pageRanges, pageCount: chapter.pages.count, renderSignature: cacheKey.renderSignature ) store.insertPageCount(pc, for: cacheKey) // 6. 按优先级处理完成逻辑 switch priority { case .navigation: // 前台导航:检查是否有更新的导航目标(§14.4 取消语义) let nextTarget = store.consumeNavigationTarget() if let target = nextTarget, target != spineIndex { // 当前结果不再是用户目标,丢弃,转而加载新目标 store.markBuilding(false) self.loadChapter(spineIndex: target, store: store, priority: .navigation, completion: completion) return } store.markBuilding(false) DispatchQueue.main.async { completion(.success(chapter)) } case .prefetch: // 后台预取:仅回填缓存,标记预取目标完成 // 不触发跳章,不检查导航目标队列 store.removePrefetchTarget(spineIndex) store.markBuilding(false) DispatchQueue.main.async { completion(.success(chapter)) } } } catch { store.markBuilding(false) DispatchQueue.main.async { completion(.failure(error)) } } } } // MARK: - 单章构建(支持轻量缓存命中后跳过分页) private func buildChapter( spineIndex: Int, availablePageRanges: [NSRange]?, diskSummary: RDEPUBChapterSummary? = nil ) throws -> RDEPUBRuntimeChapter { guard let parser = context.parser, let publication = context.publication else { throw RDEPUBChapterLoadError.missingParser } let pageSize = context.currentTextPageSize() let style = context.currentTextRenderStyle() let layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize) if let pageRanges = availablePageRanges { // ---- 轻量路径:pageCountCache 或 chapterSummaryDiskCache 命中 ---- // 只需渲染 HTML → NSAttributedString,跳过完整分页计算 return try buildChapterFromCachedPageRanges( spineIndex: spineIndex, pageRanges: pageRanges, parser: parser, publication: publication, pageSize: pageSize, style: style, layoutConfig: layoutConfig, diskSummary: diskSummary ) } // ---- 完整路径:无缓存,走全量渲染 + 分页 ---- let builder = context.makeTextBookBuilder(layoutConfig: layoutConfig) guard let result = try builder.buildChapter( parser: parser, publication: publication, spineIndex: spineIndex, pageSize: pageSize, style: style ) else { throw RDEPUBChapterLoadError.emptyChapter(spineIndex: spineIndex) } return try assembleRuntimeChapter( from: result.chapter, spineIndex: spineIndex, pageSize: pageSize, layoutConfig: layoutConfig ) } // MARK: - 轻量路径:复用已有 pageRanges,跳过完整分页 private func buildChapterFromCachedPageRanges( spineIndex: Int, pageRanges: [NSRange], parser: RDEPUBParser, publication: RDEPUBPublication, pageSize: CGSize, style: RDEPUBTextRenderStyle, layoutConfig: RDEPUBTextLayoutConfig, diskSummary: RDEPUBChapterSummary? = nil ) throws -> RDEPUBRuntimeChapter { let spineItem = publication.spine[spineIndex] let href = spineItem.href let title = spineItem.title ?? "" let baseURL = parser.baseURL(for: href) // 1. 只做 HTML → NSAttributedString 渲染,不做分页 let request = RDEPUBTextRendererSupport.makeChapterRenderRequest( href: href, title: title, rawHTML: try parser.htmlContent(for: href), baseURL: baseURL, style: style, pageSize: pageSize, layoutConfig: layoutConfig ) let renderer = context.resolvedTextRenderer() let rendered = try renderer.renderChapter(request: request) let typesetString = NSMutableAttributedString(attributedString: rendered.attributedString) RDEPUBTextRendererSupport.normalizeReadingAttributes( in: typesetString, style: style, layoutConfig: layoutConfig ) // 2. metadata 来源策略: // - diskSummary 非空(磁盘路径命中):从摘要恢复完整 metadata // - diskSummary 为空(pageCountCache 命中但没走磁盘):从 attributedString 属性推断 // 不再在轻量路径内做额外磁盘 I/O let metadataSource = diskSummary?.pageMetadataList // 3. 直接用缓存的 pageRanges 构建 pages(跳过 CoreText 分页) let pages = buildPagesFromRanges( pageRanges: pageRanges, typesetString: typesetString, spineIndex: spineIndex, href: href, title: title, metadataSource: metadataSource ) // 3. 构建 layouter(用于后续可能的重新分页场景) let layouter = RDEPUBTextLayouter( attributedString: typesetString, pageSize: pageSize, config: layoutConfig ) // 4. 构建 chapterOffsetMap let offsetMap = RDEPUBChapterOffsetMap( fragmentOffsets: rendered.fragmentOffsets, pageStartOffsets: pages.map { $0.pageStartOffset }, pageEndOffsets: pages.map { $0.pageEndOffset } ) return RDEPUBRuntimeChapter( spineIndex: spineIndex, href: href, title: title, // 轻量路径特殊语义:sourceAttributedString 此时不是"HTML 原始文本", // 而是"经过 normalizeReadingAttributes 归一化后的当前排版文本", // 与 typesetAttributedString 相同。如果后续需要重新排版(如字号变化), // 需要从 HTML 重新渲染,不能依赖此字段作为 source。 sourceAttributedString: nil, // 轻量路径不保留原始 source,降低内存 typesetAttributedString: typesetString, layouter: layouter, pageRanges: pageRanges, pages: pages, chapterOffsetMap: offsetMap ) } /// 从缓存的 pageRanges 直接构建 RDEPUBTextPage 数组 /// metadataSource: 轻量摘要中的页元数据;为 nil 时从 attributedString 属性推断 private func buildPagesFromRanges( pageRanges: [NSRange], typesetString: NSAttributedString, spineIndex: Int, href: String, title: String, metadataSource: [RDEPUBChapterSummary.PageMetadataSummary]? = nil ) -> [RDEPUBTextPage] { let totalPageCount = pageRanges.count return pageRanges.enumerated().map { (pageIndex, range) in let pageContent = typesetString.attributedSubstring(from: range) let metadata: RDEPUBTextPageMetadata if let metaList = metadataSource, pageIndex < metaList.count { // 从摘要缓存恢复完整 metadata metadata = metaList[pageIndex].toPageMetadata() } else { // 无缓存 metadata,从 attributedString 属性推断 metadata = inferPageMetadata( from: typesetString, range: range, isLastPage: pageIndex == totalPageCount - 1 ) } return RDEPUBTextPage( absolutePageIndex: -1, // 全书绝对页码:章节模式下不赋值,保持 -1;由上层按需回填 windowPageIndex: 0, // 窗口内页码:在 RDEPUBChapterWindowSnapshot.from() 中重新编号 chapterIndex: 0, // 所属章节在窗口 chapters 中的索引:在快照构建时重设 spineIndex: spineIndex, href: href, chapterTitle: title, pageIndexInChapter: pageIndex, totalPagesInChapter: totalPageCount, chapterContent: typesetString, content: pageContent, contentRange: range, pageStartOffset: range.location, pageEndOffset: range.location + range.length - 1, metadata: metadata ) } } /// 从 attributedString 的自定义属性推断页 metadata /// 复用分页阶段注入的 rdPageBlockKind / rdPageAttachmentKind 等属性 private func inferPageMetadata( from string: NSAttributedString, range: NSRange, isLastPage: Bool ) -> RDEPUBTextPageMetadata { // 从自定义属性中提取分页语义信息 var attachmentRanges: [NSRange] = [] var attachmentKinds: [RDEPUBTextAttachmentKind] = [] var blockKinds: [RDEPUBTextBlockKind] = [] var semanticHints: [RDEPUBTextSemanticHint] = [] var attachmentPlacements: [RDEPUBTextAttachmentPlacement] = [] var trailingFragmentID: String? = nil string.enumerateAttribute(.rdPageAttachmentKind, in: range, options: []) { value, attrRange, _ in if let kind = value as? RDEPUBTextAttachmentKind { attachmentRanges.append(attrRange) attachmentKinds.append(kind) } } string.enumerateAttribute(.rdPageBlockKind, in: range, options: []) { value, _, _ in if let kind = value as? RDEPUBTextBlockKind, !blockKinds.contains(kind) { blockKinds.append(kind) } } string.enumerateAttribute(.rdPageSemanticHints, in: range, options: []) { value, _, _ in if let hints = value as? [RDEPUBTextSemanticHint] { for hint in hints where !semanticHints.contains(hint) { semanticHints.append(hint) } } } string.enumerateAttribute(.rdPageAttachmentPlacement, in: range, options: []) { value, _, _ in if let placement = value as? RDEPUBTextAttachmentPlacement, !attachmentPlacements.contains(placement) { attachmentPlacements.append(placement) } } string.enumerateAttribute(.rdPageFragmentID, in: range, options: [.reverse]) { value, _, stop in if let fid = value as? String { trailingFragmentID = fid stop.pointee = true } } return RDEPUBTextPageMetadata( breakReason: isLastPage ? .chapterEnd : .frameLimit, blockRange: nil, attachmentRanges: attachmentRanges, attachmentKinds: attachmentKinds, blockKinds: blockKinds, semanticHints: semanticHints, attachmentPlacements: attachmentPlacements, trailingFragmentID: trailingFragmentID, diagnostics: [] ) } /// 从完整构建结果组装 RDEPUBRuntimeChapter private func assembleRuntimeChapter( from chapter: RDEPUBTextChapter, spineIndex: Int, pageSize: CGSize, layoutConfig: RDEPUBTextLayoutConfig ) throws -> RDEPUBRuntimeChapter { let layouter = RDEPUBTextLayouter( attributedString: chapter.attributedContent, pageSize: pageSize, config: layoutConfig ) let offsetMap = RDEPUBChapterOffsetMap( fragmentOffsets: chapter.fragmentOffsets, pageStartOffsets: chapter.pages.map { $0.pageStartOffset }, pageEndOffsets: chapter.pages.map { $0.pageEndOffset } ) let pageRanges = chapter.pages.map { $0.contentRange } // 回填磁盘摘要(P2 阶段生效) let cacheKey = makeCacheKey(spineIndex: spineIndex) let summary = RDEPUBChapterSummary( pageRanges: pageRanges.map { .init(location: $0.location, length: $0.length) }, pageCount: chapter.pages.count, fragmentOffsets: chapter.fragmentOffsets, renderSignature: cacheKey.renderSignature, schemaVersion: 6, chapterContentHash: cacheKey.chapterContentHash, pageMetadataList: chapter.pages.map { .from($0.metadata) } ) summaryDiskCache?.write(summary: summary, for: cacheKey) return RDEPUBRuntimeChapter( spineIndex: spineIndex, href: chapter.href, title: chapter.title, sourceAttributedString: chapter.attributedContent, typesetAttributedString: chapter.attributedContent, layouter: layouter, pageRanges: pageRanges, pages: chapter.pages, chapterOffsetMap: offsetMap ) } // MARK: - 缓存键 private func makeCacheKey(spineIndex: Int) -> RDEPUBChapterCacheKey { let style = context.currentTextRenderStyle() let layoutConfig = context.currentTextLayoutConfig(pageSize: context.currentTextPageSize()) let pageSize = context.currentTextPageSize() // renderSignature 必须覆盖 §8.2 定义的全部参数 // 任何排版参数变化都必须导致 key 不同,从而自然失效旧缓存 // 注意:直接使用配置层的 lineHeightMultiple 参数值,不通过换算派生 // 避免换算关系变更导致 key 与文档定义漂移 let lineHeightMultiple = style.lineHeightMultiple ?? (style.font.lineHeight + style.lineSpacing) / style.font.lineHeight let renderSignature = [ style.font.fontName, // fontName “\(style.font.pointSize)”, // fontSize “\(lineHeightMultiple)”, // lineHeightMultiple(直接编码,不换算) “\(layoutConfig.edgeInsets)”, // contentInsets “\(pageSize.width)x\(pageSize.height)”, // pageSize layoutConfig.cacheSignature, // layoutConfigSignature “\(6)” // schemaVersion ].joined(separator: “|”) let contentHash = contentHashForSpineIndex(spineIndex) return RDEPUBChapterCacheKey( bookID: context.currentBookIdentifier ?? “”, spineIndex: spineIndex, renderSignature: renderSignature, chapterContentHash: contentHash ) } private func contentHashForSpineIndex(_ spineIndex: Int) -> String { guard let parser = context.parser, let publication = context.publication else { return “” } let href = publication.spine[spineIndex].href let html = try? parser.htmlContent(for: href) return html?.sha256Prefix ?? “” } } enum RDEPUBChapterLoadError: Error { case missingParser case emptyChapter(spineIndex: Int) } ``` #### 19.1.6 RDEPUBChapterWindowSnapshot ```swift // ============ 新增文件:RDEPUBChapterWindowSnapshot.swift ============ struct RDEPUBChapterWindowSnapshot { /// 窗口中的章节(有序:prev, current, next) let chapters: [RDEPUBRuntimeChapter] /// 展平后的连续页数组(供 RDReaderView 消费) /// 每页携带独立的 windowPageIndex(窗口内连续编号), /// 与 RDEPUBTextPage.absolutePageIndex(全书绝对页码)严格区分。 let flattenedPages: [RDEPUBTextPage] /// 当前章在 chapters 数组中的索引 let anchorChapterIndex: Int /// 当前章在 flattenedPages 中的起始页码(从 0 开始,窗口内编号) let anchorPageOffset: Int /// 当前窗口首章的 spineIndex,用于调试日志和跨窗口映射 let windowStartSpineIndex: Int // MARK: - 构建 /// 从章节窗口构建快照 static func from( currentChapter: RDEPUBRuntimeChapter, previousChapter: RDEPUBRuntimeChapter?, nextChapter: RDEPUBRuntimeChapter? ) -> RDEPUBChapterWindowSnapshot { var chapters: [RDEPUBRuntimeChapter] = [] var anchorIndex = 0 var pageOffset = 0 if let prev = previousChapter { chapters.append(prev) anchorIndex = 1 pageOffset = prev.pages.count } chapters.append(currentChapter) if let next = nextChapter { chapters.append(next) } // 展平页数组,编号规则: // windowPageIndex — 窗口内连续编号(从 0 开始),供 RDReaderView 消费 // chapterIndex — 当前页所属章节在 chapters 数组中的索引 // absolutePageIndex — 保持原值不动(全书绝对页码,章节模式下通常为 -1 或由上层按需赋值) // 这三个编号语义严格独立,不可混用。 var allPages: [RDEPUBTextPage] = [] var windowPageIdx = 0 for (chIdx, ch) in chapters.enumerated() { for var page in ch.pages { page.windowPageIndex = windowPageIdx page.chapterIndex = chIdx // absolutePageIndex 不在这里赋值,保持章节构建时的原始值 allPages.append(page) windowPageIdx += 1 } } let windowStartSpineIndex = chapters.first?.spineIndex ?? currentChapter.spineIndex return RDEPUBChapterWindowSnapshot( chapters: chapters, flattenedPages: allPages, anchorChapterIndex: anchorIndex, anchorPageOffset: pageOffset, windowStartSpineIndex: windowStartSpineIndex ) } // MARK: - 查询 /// 窗口内页码(windowPageIndex)-> 所属章节 func chapterForPage(windowPageIndex: Int) -> RDEPUBRuntimeChapter? { var offset = 0 for ch in chapters { if windowPageIndex < offset + ch.pages.count { return ch } offset += ch.pages.count } return nil } /// 窗口内页码(windowPageIndex)-> 所属章节的 spineIndex func spineIndexForPage(windowPageIndex: Int) -> Int? { return chapterForPage(windowPageIndex: windowPageIndex)?.spineIndex } /// 总页数(窗口内) var pageCount: Int { flattenedPages.count } } ``` #### 19.1.7 RDEPUBChapterWindowCoordinator ```swift // ============ 新增文件:RDEPUBChapterWindowCoordinator.swift ============ final class RDEPUBChapterWindowCoordinator { private unowned let context: RDEPUBReaderContext private let store: RDEPUBChapterRuntimeStore private let loader: RDEPUBChapterLoader /// 当前窗口快照 private(set) var currentSnapshot: RDEPUBChapterWindowSnapshot? /// 窗口切换回调 var onSnapshotChanged: ((RDEPUBChapterWindowSnapshot) -> Void)? init(context: RDEPUBReaderContext, store: RDEPUBChapterRuntimeStore, loader: RDEPUBChapterLoader) { self.context = context self.store = store self.loader = loader } // MARK: - 打开书籍 func openBook(at targetSpineIndex: Int) { let totalSpineCount = context.publication?.spine.count ?? 0 store.setCurrentChapter(spineIndex: targetSpineIndex, totalSpineCount: totalSpineCount) // 标记切章进行中(与 flipToChapter 一致,阻止预取回调在构建期间刷新快照) isSwitchingChapter = true // 注册前台导航目标 store.setNavigationTarget(spineIndex: targetSpineIndex) // 清空旧预取目标(打开新书时旧预取不再需要) store.clearPrefetchTargets() // 加载目标章(前台导航优先级) loader.loadChapter(spineIndex: targetSpineIndex, store: store, priority: .navigation) { [weak self] result in guard let self = self else { return } self.isSwitchingChapter = false switch result { case .success(let chapter): self.buildSnapshotAroundCurrent(chapter: chapter) case .failure(let error): self.context.handle(error: error) } } } // MARK: - 构建窗口快照 private func buildSnapshotAroundCurrent(chapter: RDEPUBRuntimeChapter) { guard let current = store.currentSpineIndex else { return } let prev = current > 0 ? store.chapterData(for: current - 1) : nil let next = store.chapterData(for: current + 1) let snapshot = RDEPUBChapterWindowSnapshot.from( currentChapter: chapter, previousChapter: prev, nextChapter: next ) currentSnapshot = snapshot onSnapshotChanged?(snapshot) // 预取 ±1 prefetchAdjacent(current: current) } // MARK: - 预取(后台优先级,不抢占前台导航通道) private func prefetchAdjacent(current: Int) { let totalSpineCount = context.publication?.spine.count ?? 0 // 预取 prev(后台优先级) if current > 0 && store.chapterData(for: current - 1) == nil { let prevIndex = current - 1 store.addPrefetchTarget(prevIndex) loader.loadChapter(spineIndex: prevIndex, store: store, priority: .prefetch) { [weak self] result in guard let self = self, case .success = result else { return } // 预取成功后刷新窗口快照,使相邻章纳入 RDReaderView 消费范围 // 仅在阅读器空闲时刷新,不触发跳章 self.refreshSnapshot() } } // 预取 next(后台优先级) if current < totalSpineCount - 1 && store.chapterData(for: current + 1) == nil { let nextIndex = current + 1 store.addPrefetchTarget(nextIndex) loader.loadChapter(spineIndex: nextIndex, store: store, priority: .prefetch) { [weak self] result in guard let self = self, case .success = result else { return } self.refreshSnapshot() } } } // MARK: - 翻章 /// 到达章末,翻到下一章 func flipToNextChapter(completion: @escaping (Result) -> Void) { guard let current = store.currentSpineIndex else { return } let next = current + 1 let totalSpineCount = context.publication?.spine.count ?? 0 guard next < totalSpineCount else { return } flipToChapter(spineIndex: next, completion: completion) } /// 到达章首,翻到上一章 func flipToPreviousChapter(completion: @escaping (Result) -> Void) { guard let current = store.currentSpineIndex, current > 0 else { return } flipToChapter(spineIndex: current - 1, completion: completion) } /// 跳转到指定章节(目录/书签/搜索) func flipToChapter( spineIndex: Int, completion: @escaping (Result) -> Void ) { let totalSpineCount = context.publication?.spine.count ?? 0 // 注册前台导航目标(§14.4 取消语义:排队请求只保留最后一次) store.setNavigationTarget(spineIndex: spineIndex) // 清空后台预取目标(切章时旧预取结果不再需要) store.clearPrefetchTargets() // 标记切章进行中(阻止预取回调在切章期间刷新快照) isSwitchingChapter = true // 先淘汰旧窗口外章节 store.setCurrentChapter(spineIndex: spineIndex, totalSpineCount: totalSpineCount) let evictable = store.evictableSpineIndices() for idx in evictable { store.evict(spineIndex: idx) } // 如果目标章已在缓存中,直接构建快照 if let cached = store.chapterData(for: spineIndex) { buildSnapshotAroundCurrent(chapter: cached) isSwitchingChapter = false if let snap = currentSnapshot { completion(.success(snap)) } return } // 未命中缓存,走加载链路(前台导航优先级) loader.loadChapter(spineIndex: spineIndex, store: store, priority: .navigation) { [weak self] result in guard let self = self else { return } self.isSwitchingChapter = false switch result { case .success(let chapter): self.buildSnapshotAroundCurrent(chapter: chapter) if let snap = self.currentSnapshot { completion(.success(snap)) } case .failure(let error): completion(.failure(error)) } } } // MARK: - 刷新快照(预取完成后调用,把新的相邻章纳入快照) /// 预取成功后刷新窗口快照 /// 硬性约束:仅在阅读器完全空闲时才允许刷新快照 /// 空闲定义 = 非构建中 + 非切章中 + readerView 无翻页动画 + 无人机交互进行中 /// 不满足条件时延后重试,绝不打断当前阅读状态 func refreshSnapshot() { guard let current = store.currentSpineIndex, let currentChapter = store.chapterData(for: current) else { return } // 空闲门槛检查 guard isReaderIdle() else { // 推迟到空闲后再刷新 DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in self?.refreshSnapshot() } return } let prev = current > 0 ? store.chapterData(for: current - 1) : nil let next = store.chapterData(for: current + 1) // 只在快照内容确实变化时才更新和通知 let newSnapshot = RDEPUBChapterWindowSnapshot.from( currentChapter: currentChapter, previousChapter: prev, nextChapter: next ) if snapshotContentChanged(old: currentSnapshot, new: newSnapshot) { currentSnapshot = newSnapshot onSnapshotChanged?(newSnapshot) } } /// 判断新快照是否与旧快照内容不同(避免无变化时触发不必要的 UI 刷新) /// 判定条件(满足任一即认为变化): /// 1. 章节数量不同 /// 2. 章节 spineIndex 序列不同(相邻章换了) /// 3. 总页数不同 /// 4. 任一章的页数不同(排版参数变化导致页重排) /// 5. 锚点章在窗口中的位置不同 /// 仅比较"章节数量 + 总页数"不够:相邻章换了但页数巧合相同时会漏检, /// 导致窗口边界内容陈旧。 private func snapshotContentChanged( old: RDEPUBChapterWindowSnapshot?, new: RDEPUBChapterWindowSnapshot ) -> Bool { guard let old = old else { return true } // 1. 章节数量不同 if old.chapters.count != new.chapters.count { return true } // 2. spineIndex 序列不同(相邻章换了) let oldSpines = old.chapters.map { $0.spineIndex } let newSpines = new.chapters.map { $0.spineIndex } if oldSpines != newSpines { return true } // 3. 总页数不同 if old.pageCount != new.pageCount { return true } // 4. 任一章的页数不同 for (oldCh, newCh) in zip(old.chapters, new.chapters) { if oldCh.pages.count != newCh.pages.count { return true } } // 5. 锚点位置不同 if old.anchorChapterIndex != new.anchorChapterIndex || old.anchorPageOffset != new.anchorPageOffset { return true } return false } /// 空闲判断:必须全部满足才允许刷新快照 private func isReaderIdle() -> Bool { // 1. 没有章节正在后台构建 guard !store.isBuilding else { return false } // 2. 没有切章操作正在进行 guard !isSwitchingChapter else { return false } // 3. readerView 没有正在执行的翻页动画 // CATransaction 仍在执行时说明页面切换动画未完成 if let readerView = context.readerView { guard !readerView.isAnimating else { return false } } return true } /// 切章进行中标记(flipToChapter 开始设 true,completion 回调后设 false) private var isSwitchingChapter: Bool = false } ``` #### 19.1.8 DataSource 适配(窗口快照直连) > **作用域**:以下 DataSource 改造仅适用于 `textReflowable` 路径。`RDEPUBReaderController` 需在初始化时根据书籍类型选择 DataSource 策略:textReflowable 走窗口快照路径,其他类型(fixed-layout、PDF 等)维持原有整书 `pages` 数组供页。 ```swift // ============ 修改文件:RDEPUBReaderController+DataSource.swift ============ // 重要:此扩展仅在 textReflowable 路径启用。 // RDEPUBReaderController 初始化时需判断书籍类型: // if publication.metadata.layout == .reflowable { // setupChapterWindowDataSource() // 走本扩展 // } else { // setupLegacyBookDataSource() // 走原有整书 pages 路径 // } extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate { public func pageCountOfReaderView(readerView: RDReaderView) -> Int { guard let snapshot = windowCoordinator?.currentSnapshot else { return 0 } return snapshot.pageCount } public func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView { guard let snapshot = windowCoordinator?.currentSnapshot else { return RDEPUBTextContentView() } return textContentViewFromSnapshot(snapshot, pageNum: pageNum, containerView: containerView) } // MARK: - 页面内容构建 private func textContentViewFromSnapshot( _ snapshot: RDEPUBChapterWindowSnapshot, pageNum: Int, containerView: UIView? ) -> UIView { guard pageNum >= 0, pageNum < snapshot.flattenedPages.count else { return RDEPUBTextContentView() // 空 page } let page = snapshot.flattenedPages[pageNum] let contentView = RDEPUBTextContentView() contentView.configure(with: page, highlights: highlightsForPage(page)) return contentView } // MARK: - 翻章检测 public func pageNum(readerView: RDReaderView, pageNum: Int) { handleChapterAwarePageChange(pageNum: pageNum) } /// 检测是否到达章节边界,触发翻章 private func handleChapterAwarePageChange(pageNum: Int) { guard let snapshot = windowCoordinator?.currentSnapshot else { return } // 到达窗口末尾 → 翻到下一章 if pageNum >= snapshot.pageCount - 1 { windowCoordinator?.flipToNextChapter { [weak self] result in guard let self = self else { return } switch result { case .success(let newSnapshot): self.applyNewSnapshot(newSnapshot, landing: .chapterStart) case .failure: break // 停在当前页 } } return } // 到达窗口开头 → 翻到上一章 if pageNum <= 0 { windowCoordinator?.flipToPreviousChapter { [weak self] result in guard let self = self else { return } switch result { case .success(let newSnapshot): self.applyNewSnapshot(newSnapshot, landing: .chapterEnd) case .failure: break } } return } // 普通页移动:当前位置已稳定,可直接持久化 persistCurrentChapterLocation(pageNum: pageNum) } /// 应用新窗口快照 private enum ChapterLanding { case chapterStart case chapterEnd } private func applyNewSnapshot(_ snapshot: RDEPUBChapterWindowSnapshot, landing: ChapterLanding) { // 通知 RDReaderView 刷新数据源 readerView?.reloadData() // 跳到新章的正确位置 let targetPage: Int switch landing { case .chapterStart: targetPage = snapshot.anchorPageOffset case .chapterEnd: let currentChapter = snapshot.chapters[snapshot.anchorChapterIndex] targetPage = snapshot.anchorPageOffset + currentChapter.pages.count - 1 } readerView?.transitionToPage(pageNum: targetPage, animated: false) // 位置持久化必须发生在新快照和新页码确定之后,避免把旧边界页写回去 persistCurrentChapterLocation(pageNum: targetPage) } } ``` #### 19.1.9 Context 和 Runtime 挂入 ```swift // ============ 修改文件:RDEPUBReaderContext.swift ============ final class RDEPUBReaderContext { // ... 保留所有现有属性 ... // MARK: - 新增章节运行时 /// 章节运行时缓存 var chapterRuntimeStore: RDEPUBChapterRuntimeStore? /// 章节加载器 var chapterLoader: RDEPUBChapterLoader? /// 窗口协调器 var chapterWindowCoordinator: RDEPUBChapterWindowCoordinator? } // ============ 修改文件:RDEPUBReaderRuntime.swift ============ final class RDEPUBReaderRuntime { // ... 保留所有现有子协调器 ... // 新增:章节运行时初始化 func setupChapterRuntimeIfNeeded() { guard context.chapterRuntimeStore == nil else { return } let store = RDEPUBChapterRuntimeStore() let loader = RDEPUBChapterLoader(context: context) let coordinator = RDEPUBChapterWindowCoordinator( context: context, store: store, loader: loader ) context.chapterRuntimeStore = store context.chapterLoader = loader context.chapterWindowCoordinator = coordinator // 监听内存警告 NotificationCenter.default.addObserver( forName: UIApplication.didReceiveMemoryWarningNotification, object: nil, queue: .main ) { [weak store] _ in store?.handleMemoryWarning() } } // 初始化章节运行时基础设施,然后委托给 PaginationCoordinator // PaginationCoordinator.paginatePublication() 是唯一启动入口 func loadPublication() { setupChapterRuntimeIfNeeded() // 启动入口统一由 paginationCoordinator 承担 paginationCoordinator.paginatePublication(restoreLocation: /* 从持久化取 */) } } ``` 说明: - **唯一启动点**是 `RDEPUBReaderPaginationCoordinator.paginatePublication()` - `loadPublication()` 只负责初始化基础设施,然后委托给 `paginationCoordinator` - `paginatePublication()` 内部调用 `context.chapterWindowCoordinator?.openBook(at:)`,是章节路径的唯一入口 - P4 阶段对 PaginationCoordinator 的改造只是删掉旧整书路径代码,不改变启动入口(委托结构改造已在 P0 完成,见文件修改清单) --- ### 19.2 P1:建立完整章节缓存运行时 #### 19.2.1 ±1 预取窗口完善 ```swift // ============ 扩展:RDEPUBChapterWindowCoordinator.swift ============ extension RDEPUBChapterWindowCoordinator { /// 翻章后维护窗口:当前章常驻,预取新的相邻章 private func maintainWindow(afterMovingTo spineIndex: Int) { let totalSpineCount = context.publication?.spine.count ?? 0 // 淘汰窗口外章节 store.setCurrentChapter(spineIndex: spineIndex, totalSpineCount: totalSpineCount) for idx in store.evictableSpineIndices() { store.evict(spineIndex: idx) } // 预取 prev(后台优先级,不抢占前台导航通道) if spineIndex > 0 && store.chapterData(for: spineIndex - 1) == nil { let prevIndex = spineIndex - 1 store.addPrefetchTarget(prevIndex) loader.loadChapter(spineIndex: prevIndex, store: store, priority: .prefetch) { [weak self] result in guard let self = self, case .success = result else { return } self.refreshSnapshot() } } // 预取 next(后台优先级,不抢占前台导航通道) if spineIndex < totalSpineCount - 1 && store.chapterData(for: spineIndex + 1) == nil { let nextIndex = spineIndex + 1 store.addPrefetchTarget(nextIndex) loader.loadChapter(spineIndex: nextIndex, store: store, priority: .prefetch) { [weak self] result in guard let self = self, case .success = result else { return } self.refreshSnapshot() } } } } ``` #### 19.2.2 排版参数变化后整体失效 ```swift // ============ 扩展:RDEPUBChapterRuntimeStore.swift ============ extension RDEPUBChapterRuntimeStore { /// 排版参数变化后整体失效(§8.5) func invalidateAllForSettingsChange() { chapterDataCache.removeAll() pageCountCache.removeAll() imageCache.removeAllObjects() } } ``` #### 19.2.3 CoreText / CF 释放审计 需要在以下类型中确认 CF 对象的成对释放: - `RDEPUBTextLayouter` → 检查内部 `RDEPUBChapterPageCounter` 是否持有 `CTTypesetter` - `RDEPUBCoreTextPageFrameFactory` → 检查是否持有 `CTFramesetter` / `CTFrame` / `CGPath` - `RDEPUBDTCoreTextRenderer` → 检查 `DTCoreTextLayoutFrame` 的生命周期 规则: - 谁创建,谁释放 - `deinit` / `dealloc` 中成对处理 - 章节淘汰时 `RDEPUBRuntimeChapter` 释放会级联释放 `layouter`,需确认其 `deinit` 链完整 --- ### 19.3 P2:接入二次打开加速层 #### 19.3.1 RDEPUBChapterSummaryDiskCache ```swift // ============ 新增文件:RDEPUBChapterSummaryDiskCache.swift ============ final class RDEPUBChapterSummaryDiskCache { private let cacheDirectory: URL private let fileManager = FileManager.default private let queue = DispatchQueue(label: “com.rdreader.summarydiskcache”, qos: .utility) init(cacheDirectory: URL) { self.cacheDirectory = cacheDirectory try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true) } // MARK: - 写入(异步) func write(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) { queue.async { let fileURL = self.fileURL(for: key) let data = try? JSONEncoder().encode(summary) try? data?.write(to: fileURL) } } // MARK: - 读取(同步,因为 loadChapter 已在串行队列上) func read(for key: RDEPUBChapterCacheKey) -> RDEPUBChapterSummary? { let fileURL = self.fileURL(for: key) guard let data = try? Data(contentsOf: fileURL) else { return nil } return try? JSONDecoder().decode(RDEPUBChapterSummary.self, from: data) } // MARK: - key -> 文件路径 /// 使用确定性字符串拼接生成文件名,不依赖 Hashable.hashValue /// hashValue 跨进程不稳定,会导致二次打开缓存失效 private func fileURL(for key: RDEPUBChapterCacheKey) -> URL { // 用 SHA256 对完整 key 内容取摘要,保证跨进程稳定且无文件名冲突 let rawKey = “\(key.bookID)_\(key.spineIndex)_\(key.renderSignature)_\(key.chapterContentHash)” let digest = rawKey.sha256Hex return cacheDirectory.appendingPathComponent(“\(digest).json”) } } struct RDEPUBChapterSummary: Codable { let pageRanges: [RangeData] // NSRange 不 Codable,需包装 let pageCount: Int let fragmentOffsets: [String: Int] let renderSignature: String let schemaVersion: Int let chapterContentHash: String /// 每页的元数据摘要,轻量路径恢复时使用 let pageMetadataList: [PageMetadataSummary] struct RangeData: Codable { let location: Int let length: Int var nsRange: NSRange { NSRange(location: location, length: length) } } /// 轻量化的每页 metadata(只保留跨章功能依赖的字段) struct PageMetadataSummary: Codable { let breakReason: String // RDEPUBTextPageBreakReason.rawValue let attachmentRanges: [RangeData] let attachmentKinds: [String] // RDEPUBTextAttachmentKind.rawValue let blockKinds: [String] // RDEPUBTextBlockKind.rawValue let semanticHints: [String] // RDEPUBTextSemanticHint.rawValue let attachmentPlacements: [String] // RDEPUBTextAttachmentPlacement.rawValue let trailingFragmentID: String? func toPageMetadata() -> RDEPUBTextPageMetadata { RDEPUBTextPageMetadata( breakReason: RDEPUBTextPageBreakReason(rawValue: breakReason) ?? .frameLimit, blockRange: nil, attachmentRanges: attachmentRanges.map { $0.nsRange }, attachmentKinds: attachmentKinds.compactMap { RDEPUBTextAttachmentKind(rawValue: $0) }, blockKinds: blockKinds.compactMap { RDEPUBTextBlockKind(rawValue: $0) }, semanticHints: semanticHints.compactMap { RDEPUBTextSemanticHint(rawValue: $0) }, attachmentPlacements: attachmentPlacements.compactMap { RDEPUBTextAttachmentPlacement(rawValue: $0) }, trailingFragmentID: trailingFragmentID, diagnostics: [] ) } static func from(_ metadata: RDEPUBTextPageMetadata) -> PageMetadataSummary { PageMetadataSummary( breakReason: metadata.breakReason.rawValue, attachmentRanges: metadata.attachmentRanges.map { .init(location: $0.location, length: $0.length) }, attachmentKinds: metadata.attachmentKinds.map { $0.rawValue }, blockKinds: metadata.blockKinds.map { $0.rawValue }, semanticHints: metadata.semanticHints.map { $0.rawValue }, attachmentPlacements: metadata.attachmentPlacements.map { $0.rawValue }, trailingFragmentID: metadata.trailingFragmentID ) } } } ``` #### 19.3.2 集成到 RDEPUBChapterLoader 已在 §19.1.5 的 `buildChapter` 中预留了 `summaryDiskCache?.write(summary:for:)` 调用和 `chapterSummaryDiskCachePageRanges(for:)` 查询。 P2 阶段只需: 1. 在 `RDEPUBReaderRuntime.setupChapterRuntimeIfNeeded()` 中创建 `RDEPUBChapterSummaryDiskCache` 并注入 loader 2. 确认 `buildChapter` 中已回填磁盘摘要 --- ### 19.4 P3:位置与跨章能力迁移 #### 19.4.1 Legacy 位置转换器 ```swift // ============ 新增文件:RDEPUBLocationConverter.swift ============ struct RDEPUBLocationConverter { // MARK: - 主路径:先构建目标章,拿到真实长度后再精确转换 /// 旧版 RDEPUBLocation -> 新版 RDEPUBChapterLocation /// 主迁移路径:要求先构建目标章,用真实 chapterLength 做精确转换 /// 仅在无法获取章节长度时才降级到粗估 fallback static func convert( legacy location: RDEPUBLocation, parser: RDEPUBParser, publication: RDEPUBPublication, chapterLengthProvider: ((Int) -> Int?)? = nil ) -> RDEPUBChapterLocation? { // 1. 从 href 找到 spineIndex guard let spineItem = publication.spine.first(where: { $0.href == location.href || $0.href.contains(location.href) }) else { return nil } let spineIndex = publication.spine.firstIndex(of: spineItem) ?? 0 // 2. 优先用 fragmentID 定位(最精确,不受 progression 精度影响) if let fragmentID = location.fragment { return RDEPUBChapterLocation( spineIndex: spineIndex, chapterOffset: 0, // fragmentID 由 chapterOffsetMap 精确解析 fragmentID: fragmentID, progressionInChapter: location.progression ) } // 3. 有 chapterLength 时做精确转换 if let provider = chapterLengthProvider, let chapterLength = provider(spineIndex), chapterLength > 0 { return convert( legacy: location, spineIndex: spineIndex, chapterLength: chapterLength ) } // 4. Fallback:无法获取章节长度时的粗估(仅作临时降级,不应作为主路径) // 粗估对大章/短章/带 fragment 场景误差明显 // 正式迁移应在上层先构建目标章再调用精确版本 let estimatedOffset = Int(location.progression * 10000) return RDEPUBChapterLocation( spineIndex: spineIndex, chapterOffset: estimatedOffset, fragmentID: nil, progressionInChapter: location.progression, schemaVersion: 1 // 标记为降级结果,后续可被精确值覆盖 ) } // MARK: - 粗估降级验收标准 /// schemaVersion == 1 的降级结果必须满足以下验收标准: /// /// 1. **允许误差范围** /// - 粗估 chapterOffset 与真实 chapterOffset 的偏差不超过章节总长度的 ±15% /// - 偏差超过 15% 时,用户可见的位置偏移会被感知(例如跳到错误段落) /// - 验收测试:对比 schemaVersion == 1 与 schemaVersion == 2 的 chapterOffset, /// 偏差 = |estimated - exact| / chapterLength,必须 < 0.15 /// /// 2. **必须回填精确值的场景** /// - 用户下次打开同一章节时,必须走主路径(先构建目标章)获取精确值 /// - 精确值获取成功后,立即覆盖 schemaVersion == 1 的降级结果 /// - 不允许降级结果永久驻留在持久化层 /// /// 3. **禁止降级的场景** /// - 带 fragmentID 的位置:fragmentID 是精确锚点,粗估会完全丢失语义 /// (已在 step 2 优先处理 fragmentID,此处不再重复) /// - 首次打开时的"当前阅读位置":这是用户最关心的位置,必须精确 /// (调用方必须保证主路径先构建目标章,见 §19.4.2 调用示例) /// /// 4. **监控与告警** /// - 生产环境应统计 schemaVersion == 1 的出现频率 /// - 如果降级率 > 5%,说明主路径保证未落地,需要排查调用链路 /// - 降级结果应在日志中标记,便于问题定位 /// /// 5. **验收测试用例** /// ```swift /// // 测试:粗估偏差必须在 ±15% 以内 /// func testFallbackOffsetAccuracy() { /// let legacy = RDEPUBLocation(href: "chapter10.xhtml", progression: 0.5, fragment: nil) /// let fallback = RDEPUBLocationConverter.convert(legacy: legacy, spineIndex: 10, chapterLength: nil) /// let exact = RDEPUBLocationConverter.convert(legacy: legacy, spineIndex: 10, chapterLength: 50000) /// /// let deviation = abs(fallback.chapterOffset - exact.chapterOffset) / Double(exact.chapterLength) /// XCTAssertLessThan(deviation, 0.15, "粗估偏差超过 15%,用户可感知位置偏移") /// } /// /// // 测试:schemaVersion == 1 必须被精确值覆盖 /// func testFallbackOverwrittenByExact() { /// let fallback = RDEPUBChapterLocation(spineIndex: 10, chapterOffset: 5000, schemaVersion: 1) /// persistence.saveChapterLocation(fallback, for: "book123") /// /// // 模拟下次打开时走主路径 /// let exact = RDEPUBChapterLocation(spineIndex: 10, chapterOffset: 25000, schemaVersion: 2) /// persistence.saveChapterLocation(exact, for: "book123") /// /// let loaded = persistence.loadChapterLocation(for: "book123") /// XCTAssertEqual(loaded.schemaVersion, 2, "降级结果未被精确值覆盖") /// XCTAssertEqual(loaded.chapterOffset, 25000) /// } /// ``` /// 精确转换:已知章节实际长度 static func convert( legacy location: RDEPUBLocation, spineIndex: Int, chapterLength: Int ) -> RDEPUBChapterLocation? { let offset = Int(location.progression * Double(chapterLength)) return RDEPUBChapterLocation( spineIndex: spineIndex, chapterOffset: offset, fragmentID: location.fragment, progressionInChapter: location.progression, schemaVersion: 2 // 精确结果 ) } /// 从已构建的 RDEPUBRuntimeChapter 做精确转换(推荐迁移路径) static func convert( legacy location: RDEPUBLocation, chapter: RDEPUBRuntimeChapter ) -> RDEPUBChapterLocation? { // 优先用 fragmentID if let fragmentID = location.fragment, let fragmentOffset = chapter.chapterOffsetMap.chapterOffset(forFragmentID: fragmentID) { return RDEPUBChapterLocation( spineIndex: chapter.spineIndex, chapterOffset: fragmentOffset, fragmentID: fragmentID, progressionInChapter: nil, schemaVersion: 2 ) } // 用 progression + 真实长度 let chapterLength = chapter.typesetAttributedString.length return convert( legacy: location, spineIndex: chapter.spineIndex, chapterLength: chapterLength ) } /// 新版 -> 旧版(兼容外部接口) static func toLegacy( chapterLocation: RDEPUBChapterLocation, href: String, chapterLength: Int ) -> RDEPUBLocation { let progression = chapterLength > 0 ? Double(chapterLocation.chapterOffset) / Double(chapterLength) : 0 return RDEPUBLocation( href: href, progression: min(max(progression, 0), 1), fragment: chapterLocation.fragmentID ) } } ``` #### 19.4.2 持久化迁移 ```swift // ============ 修改:RDEPUBUserDefaultsPersistence ============ extension RDEPUBUserDefaultsPersistence { /// 加载章节级位置,同时完成历史格式的一次性迁移 func loadChapterLocation( for bookIdentifier: String, legacyMigrator: ((RDEPUBLocation) -> RDEPUBChapterLocation?)? = nil ) -> RDEPUBChapterLocation? { let key = locationPrefix + bookIdentifier // 1. 尝试直接读取新格式 if let data = defaults.data(forKey: key), let chapterLoc = try? JSONDecoder().decode(RDEPUBChapterLocation.self, from: data) { // 新格式已存在,直接返回 return chapterLoc } // 2. 新格式不存在,尝试读取旧格式并迁移 guard let migrator = legacyMigrator else { return nil } if let legacyData = defaults.data(forKey: key), let legacyLoc = try? JSONDecoder().decode(RDEPUBLocation.self, from: legacyData), let migrated = migrator(legacyLoc) { // 迁移成功,立即覆盖为新格式 saveChapterLocation(migrated, for: bookIdentifier) return migrated } return nil } func saveChapterLocation(_ location: RDEPUBChapterLocation, for bookIdentifier: String) { let key = locationPrefix + bookIdentifier if let data = try? JSONEncoder().encode(location) { defaults.set(data, forKey: key) } } } ``` 调用方在打开书时传入 migrator 闭包(主路径保证:先构建目标章,再用真实长度精确转换): ```swift // 典型调用点:RDEPUBReaderLocationCoordinator 或 RDEPUBReaderRuntime // // 主路径保证: // migrator 闭包内先尝试从缓存取真实章节长度;如果目标章尚未加载(首次打开), // 则同步调用 chapterLoader 构建目标章再取长度。 // 只有构建失败时才降级到 fallback(progression * 10000 粗估)。 let chapterLoc = persistence.loadChapterLocation(for: bookID) { legacyLoc in // 1. 从 href 解析 spineIndex(与 converter 内部逻辑一致) guard let spineItem = publication.spine.first(where: { $0.href == legacyLoc.href || $0.href.contains(legacyLoc.href) }), let spineIndex = publication.spine.firstIndex(of: spineItem) else { return RDEPUBLocationConverter.convert( legacy: legacyLoc, parser: parser, publication: publication ) } // 2. 优先从已加载的章节缓存取真实长度(二次打开,章节已在缓存) let chapterLength: Int? if let cached = context.chapterRuntimeStore?.chapterData(for: spineIndex) { chapterLength = cached.typesetAttributedString.length } else { // 3. 缓存未命中(首次打开)→ 主路径:先构建目标章,再用真实长度 // 这是保证”主路径一定先拿到真实章节长度”的关键步骤 let pageSize = context.resolvedPageSize() let style = context.currentStyle let layoutConfig = context.currentLayoutConfig let runtimeChapter = try? context.chapterLoader?.buildChapter( spineIndex: spineIndex, parser: parser, publication: publication, pageSize: pageSize, style: style, layoutConfig: layoutConfig ) // 构建成功后放入缓存,后续章节加载可复用 if let chapter = runtimeChapter { context.chapterRuntimeStore?.set(chapter, for: spineIndex) } chapterLength = runtimeChapter?.typesetAttributedString.length } // 4. 用精确路径或降级 fallback return RDEPUBLocationConverter.convert( legacy: legacyLoc, parser: parser, publication: publication, chapterLengthProvider: { _ in chapterLength } ) } ``` 说明: - 主持久化格式只保留 `RDEPUBChapterLocation` - 历史格式在首次加载时自动迁移并覆盖,不需要单独的迁移脚本 - 本方案不维护”运行期双格式并存”或”新旧链路双写” - **主路径保证**:`migrator` 闭包内显式处理了”缓存未命中时先构建目标章”的逻辑,确保 `chapterLengthProvider` 在首次打开时也能返回真实章节长度,而不是直接掉到 `progression * 10000` 粗估 fallback - 构建目标章的开销在首次迁移时只发生一次(迁移后立即覆盖为新格式,后续打开走新格式直接读取) #### 19.4.3 跨章功能适配 搜索适配: ```swift // ============ 修改:RDEPUBReaderSearchCoordinator.swift ============ extension RDEPUBReaderSearchCoordinator { /// 章节模式下搜索:逐章搜索,结果携带章节语义定位 func searchInChapterMode(keyword: String) { guard let publication = context.publication else { return } var allMatches: [RDEPUBSearchMatch] = [] for (spineIndex, item) in publication.spine.enumerated() { guard let html = try? context.parser?.htmlContent(for: item.href) else { continue } // 搜索必须在渲染后纯文本空间进行,不能直接在原始 HTML 上匹配。 // 原因:正文链路的 chapterOffset 基于 typesetAttributedString(经 HTML → 渲染 → 去标签), // chapterOffsetMap 的偏移也在同一空间。如果直接在原始 HTML 上搜索, // matchRange.location 是含标签/实体的 HTML 偏移,与 chapterOffset 语义不一致, // 会导致搜索结果点击后恢复位置、高亮范围、预览命中全部偏移。 let plainText = stripHTMLTags(html) // 在渲染后纯文本中查找所有匹配位置 let matches = findKeywordRanges(in: plainText, keyword: keyword) for (localIndex, matchRange) in matches.enumerated() { // matchRange.location 现在是纯文本偏移,与 chapterOffsetMap 语义一致 let chapterOffset = matchRange.location let rangeAnchor = RDEPUBTextRangeAnchor( start: RDEPUBTextAnchor( fileIndex: spineIndex, row: 0, column: 0, chapterOffset: chapterOffset, fragmentID: nil ), end: RDEPUBTextAnchor( fileIndex: spineIndex, row: 0, column: 0, chapterOffset: chapterOffset + matchRange.length, fragmentID: nil ) ) let chapterLength = plainText.count let progression = chapterLength > 0 ? Double(chapterOffset) / Double(chapterLength) : 0 // 截取预览文本(基于纯文本偏移,与正文一致) let previewStart = max(0, chapterOffset - 20) let previewEnd = min(plainText.count, chapterOffset + keyword.count + 20) let previewText = String(plainText[plainText.index(plainText.startIndex, offsetBy: previewStart).. String { // 去除 ", with: "", options: .regularExpression) .replacingOccurrences(of: "]*>[\\s\\S]*?", with: "", options: .regularExpression) // 去除所有标签 let noTags = strippedBlocks .replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) // 解码常见 HTML 实体 return noTags .replacingOccurrences(of: "&", with: "&") .replacingOccurrences(of: "<", with: "<") .replacingOccurrences(of: ">", with: ">") .replacingOccurrences(of: " ", with: " ") .replacingOccurrences(of: """, with: "\"") } /// 在纯文本(非原始 HTML)中查找关键词的所有 NSRange。 /// 调用方必须先对 HTML 做 stripHTMLTags 再传入,确保返回的偏移与 /// chapterOffsetMap / typesetAttributedString 的纯文本偏移语义一致。 private func findKeywordRanges(in text: String, keyword: String) -> [NSRange] { var ranges: [NSRange] = [] let nsText = text as NSString var searchRange = NSRange(location: 0, length: nsText.length) while searchRange.location + searchRange.length <= nsText.length { let found = nsText.range(of: keyword, options: [.caseInsensitive], range: searchRange) if found.location == NSNotFound { break } ranges.append(found) searchRange.location = found.location + found.length searchRange.length = nsText.length - searchRange.location } return ranges } } ``` 搜索结果定位到具体章节的方式: - 点击搜索结果 → 取出 `rangeAnchor.start.spineIndex` → 调用 `flipToChapter(spineIndex:)` → 章节加载完成后用 `chapterOffsetMap` 或 `rangeAnchor.start.chapterOffset` 恢复精确位置 - 不再依赖全书绝对页码 - **轻量去标签 vs 完整渲染对齐**:`stripHTMLTags` 是轻量级标签剥离,不走完整 HTML → `NSAttributedString` 渲染管线,与 `typesetAttributedString` 之间可能存在白空格归一化等微小差异。搜索场景可以接受此精度(搜索结果是入口锚点,不是精确排版锚点);如果需要严格对齐,可以改为在搜索结果点击后、章节加载完成时,用 `chapterOffsetMap` 对 `chapterOffset` 做一次精化映射 书签/高亮适配要点: - 高亮和书签的 `location` 字段已经是 `RDEPUBLocation`,包含 `href + progression + fragment + rangeAnchor` - `rangeAnchor` 已经是 `spineIndex + chapterOffset` 语义(`RDEPUBTextAnchor`) - 核心适配点: 1. 写入时确保 `rangeAnchor` 被正确填充 2. 读取时从 `rangeAnchor` 恢复,而不是从全局页码 --- ### 19.5 P4:清理旧整书主路径 #### 19.5.1 RDEPUBReaderPaginationCoordinator 改造 `RDEPUBReaderPaginationCoordinator` 是章节路径的**唯一启动入口**。 > **阶段说明**:委托结构改造(`paginatePublication()` 委托 `ChapterWindowCoordinator.openBook(at:)`)在 **P0** 阶段完成,与 `loadPublication()` 的委托调用同步落地。P4 阶段在此基础上**仅删除旧整书分页路径代码**(`paginateTextPublication`、`buildQuickTextBook`、`IncrementalChapterStore`、`StagedBookRequest`、`stageBookRequest`、`scheduleStagedIncrementalTextBookApplication` 等),不改变启动入口。 ```swift // ============ 修改:RDEPUBReaderPaginationCoordinator.swift ============ final class RDEPUBReaderPaginationCoordinator { private unowned let context: RDEPUBReaderContext init(context: RDEPUBReaderContext) /// 唯一启动入口:解析恢复位置,委托给 ChapterWindowCoordinator func paginatePublication(restoreLocation: RDEPUBLocation?) { let targetSpineIndex = resolveTargetSpineIndex(from: restoreLocation) context.chapterWindowCoordinator?.openBook(at: targetSpineIndex) } private func resolveTargetSpineIndex(from location: RDEPUBLocation?) -> Int { guard let location = location, let publication = context.publication else { return 0 } let href = location.href if let idx = publication.spine.firstIndex(where: { $0.href == href }) { return idx } return 0 } } ``` #### 19.5.2 RDEPUBTextBookCache 降级 ```swift // ============ 修改:RDEPUBReaderContext.swift ============ extension RDEPUBReaderContext { func makeTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder { return RDEPUBTextBookBuilder( renderer: resolvedTextRenderer(), cache: nil, layoutConfig: layoutConfig ) } } ``` --- ### 19.6 文件新增与修改清单 #### 新增文件 | 文件 | 阶段 | 职责 | |------|------|------| | `RDEPUBChapterRuntimeStore.swift` | P0 | 章节缓存中心 | | `RDEPUBChapterDataCache.swift` | P0 | 类型安全章节缓存 wrapper | | `RDEPUBPageCountCache.swift` | P0 | 类型安全页数缓存 wrapper | | `RDEPUBRuntimeChapter.swift` | P0 | 章节运行时对象 | | `RDEPUBChapterOffsetMap.swift` | P0 | 章内偏移映射 | | `RDEPUBRuntimePageCount.swift` | P0 | 轻量分页结构 | | `RDEPUBChapterCacheKey.swift` | P0 | 缓存键 | | `RDEPUBChapterLocation.swift` | P0 | 章节级位置模型 | | `RDEPUBChapterLoader.swift` | P0 | 单章构建器 | | `RDEPUBChapterWindowSnapshot.swift` | P0 | 窗口快照 | | `RDEPUBChapterWindowCoordinator.swift` | P0 | 窗口协调器 | | `RDEPUBChapterSummaryDiskCache.swift` | P2 | 轻量摘要磁盘缓存 | | `RDEPUBLocationConverter.swift` | P3 | 新旧位置格式转换 | #### 修改文件 | 文件 | 阶段 | 改动 | |------|------|------| | `RDEPUBReaderContext.swift` | P0 | 挂入 store / loader / coordinator | | `RDEPUBReaderRuntime.swift` | P0 | setupChapterRuntime + 单一路径接入 | | `RDEPUBReaderController+DataSource.swift` | P0 | 窗口快照数据源 | | `RDEPUBReaderPaginationCoordinator.swift` | P0 | 委托结构改造:`paginatePublication()` 改为委托 `ChapterWindowCoordinator.openBook(at:)`,成为唯一启动入口 | | `RDEPUBReaderPaginationCoordinator.swift` | P4 | 清理旧整书分页代码(P0 已完成委托结构,P4 只删除不再使用的旧路径) |