ReadViewSDK/Doc/大书优化方案_内存与主线程.md
shenlei 488350d956 docs: 大书优化方案文档与架构分析更新
新增大书优化实施方案(内存与主线程、快速进入阅读器)和 WXRead 内存策略分析文档,
更新架构对比分析文档,完善章节级缓存运行时、串行加载、轻量磁盘摘要等设计细节。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 21:16:50 +08:00

100 KiB
Raw Blame History

大书优化方案:章节级缓存运行时与二次打开加速

适用对象: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 把类似职责大量内联在 WRReaderViewControllerReadViewSDK 不必复制这种类膨胀结构。

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 暴露为主接口。

建议使用类型安全封装:

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 一级缓存:章节运行时缓存

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

需要明确定义:

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 字段组成

cacheSignatureRDEPUBTextLayoutConfig 的签名摘要,必须覆盖所有影响分页结果的排版参数。任何参数变化都必须导致签名不同,从而使旧缓存自然失效。

必须包含的字段:

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: "|")
    }
}

设计原则:

  • 只包含影响分页结果的字段:纯展示参数(如高亮颜色、选中样式)不进签名
  • 不包含 edgeInsetsedgeInsets 已在 renderSignature 中独立编码,避免重复
  • 不包含 lineHeightMultiple:同上,已在 renderSignature 中独立编码
  • 字段顺序固定:签名拼接顺序必须稳定,避免因字段顺序变化导致无意义的缓存失效
  • 新增字段时必须追加到签名末尾:并在 schemaVersion 中递增,确保旧缓存不被错误命中

不包含的字段(及其原因):

字段 原因
edgeInsets 已在 renderSignature 独立编码
lineHeightMultiple 已在 renderSignature 独立编码
highlightColor 不影响分页结果
selectionColor 不影响分页结果
debugShowPageBounds 调试开关,不影响分页结果

8.3 RDEPUBChapterCacheKey

建议定义:

struct RDEPUBChapterCacheKey: Hashable {
    let bookID: String
    let spineIndex: Int
    let renderSignature: String
    let chapterContentHash: String
}

8.4 RDEPUBRuntimePageCount

pageCountCache 依赖的轻量分页结构类型需要明确:

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 新位置结构

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 建议方案

引入窗口快照:

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. 若命中 pageCountCachechapterSummaryDiskCache,优先复用 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

// ============ 新增文件RDEPUBChapterRuntimeStore.swift ============

final class RDEPUBChapterRuntimeStore {

    // MARK: - 子缓存

    /// 章节运行时主缓存(等价 WXRead chapterDataCache
    private let chapterDataCache = RDEPUBChapterDataCache()

    /// 轻量分页结构缓存(等价 WXRead pageCountCache
    private let pageCountCache = RDEPUBPageCountCache()

    /// 图片缓存(独立 NSCache等价 WXRead imageCache
    let imageCache = NSCache<NSString, UIImage>()

    /// 串行加载队列(等价 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<Int> = []
    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

// ============ 新增文件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

// ============ 新增文件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

// ============ 新增文件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.count {
            if offset >= 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

// ============ 新增文件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<RDEPUBRuntimeChapter, Error>) -> 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

// ============ 新增文件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

// ============ 新增文件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<RDEPUBChapterWindowSnapshot, Error>) -> 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<RDEPUBChapterWindowSnapshot, Error>) -> Void) {
        guard let current = store.currentSpineIndex, current > 0 else { return }
        flipToChapter(spineIndex: current - 1, completion: completion)
    }

    /// 跳转到指定章节(目录/书签/搜索)
    func flipToChapter(
        spineIndex: Int,
        completion: @escaping (Result<RDEPUBChapterWindowSnapshot, Error>) -> 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 开始设 truecompletion 回调后设 false
    private var isSwitchingChapter: Bool = false
}

19.1.8 DataSource 适配(窗口快照直连)

作用域:以下 DataSource 改造仅适用于 textReflowable 路径。RDEPUBReaderController 需在初始化时根据书籍类型选择 DataSource 策略textReflowable 走窗口快照路径其他类型fixed-layout、PDF 等)维持原有整书 pages 数组供页。

// ============ 修改文件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 挂入

// ============ 修改文件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 预取窗口完善

// ============ 扩展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 排版参数变化后整体失效

// ============ 扩展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

// ============ 新增文件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 位置转换器

// ============ 新增文件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 持久化迁移

// ============ 修改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 闭包(主路径保证:先构建目标章,再用真实长度精确转换):

// 典型调用点RDEPUBReaderLocationCoordinator 或 RDEPUBReaderRuntime
//
// 主路径保证:
// migrator 闭包内先尝试从缓存取真实章节长度;如果目标章尚未加载(首次打开),
// 则同步调用 chapterLoader 构建目标章再取长度。
// 只有构建失败时才降级到 fallbackprogression * 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 跨章功能适配

搜索适配:

// ============ 修改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)..<plainText.index(plainText.startIndex, offsetBy: previewEnd)])

                let match = RDEPUBSearchMatch(
                    href: item.href,
                    progression: progression,
                    previewText: previewText,
                    localMatchIndex: localIndex,
                    rangeLocation: chapterOffset,
                    rangeLength: matchRange.length,
                    rangeAnchor: rangeAnchor
                )
                allMatches.append(match)
            }
        }

        context.searchState = RDEPUBSearchState(
            keyword: keyword,
            matches: allMatches,
            currentMatchIndex: nil
        )
    }

    /// 剥离 HTML 标签,得到纯文本。
    /// 轻量实现,只做标签去除,不做完整渲染;保证搜索偏移与 chapterOffsetMap
    /// 所在的渲染后纯文本空间大致对齐(完整对齐见下方说明)。
    private func stripHTMLTags(_ html: String) -> String {
        // 去除 <script>/<style> 块(搜索不应命中脚本或样式内容)
        let strippedBlocks = html
            .replacingOccurrences(of: "<script[^>]*>[\\s\\S]*?</script>",
                                  with: "", options: .regularExpression)
            .replacingOccurrences(of: "<style[^>]*>[\\s\\S]*?</style>",
                                  with: "", options: .regularExpression)
        // 去除所有标签
        let noTags = strippedBlocks
            .replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)
        // 解码常见 HTML 实体
        return noTags
            .replacingOccurrences(of: "&amp;", with: "&")
            .replacingOccurrences(of: "&lt;", with: "<")
            .replacingOccurrences(of: "&gt;", with: ">")
            .replacingOccurrences(of: "&nbsp;", with: " ")
            .replacingOccurrences(of: "&quot;", 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:) → 章节加载完成后用 chapterOffsetMaprangeAnchor.start.chapterOffset 恢复精确位置
  • 不再依赖全书绝对页码
  • 轻量去标签 vs 完整渲染对齐stripHTMLTags 是轻量级标签剥离,不走完整 HTML → NSAttributedString 渲染管线,与 typesetAttributedString 之间可能存在白空格归一化等微小差异。搜索场景可以接受此精度(搜索结果是入口锚点,不是精确排版锚点);如果需要严格对齐,可以改为在搜索结果点击后、章节加载完成时,用 chapterOffsetMapchapterOffset 做一次精化映射

书签/高亮适配要点:

  • 高亮和书签的 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 阶段在此基础上仅删除旧整书分页路径代码paginateTextPublicationbuildQuickTextBookIncrementalChapterStoreStagedBookRequeststageBookRequestscheduleStagedIncrementalTextBookApplication 等),不改变启动入口。

// ============ 修改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 降级

// ============ 修改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 只删除不再使用的旧路径)