新增大书优化实施方案(内存与主线程、快速进入阅读器)和 WXRead 内存策略分析文档, 更新架构对比分析文档,完善章节级缓存运行时、串行加载、轻量磁盘摘要等设计细节。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
100 KiB
大书优化方案:章节级缓存运行时与二次打开加速
适用对象:
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 工程化改进:
RDEPUBChapterLoaderRDEPUBChapterWindowCoordinatorRDEPUBChapterLocationCoordinator
WXRead 把类似职责大量内联在 WRReaderViewController 内;ReadViewSDK 不必复制这种类膨胀结构。
4.3 旧链路处理边界
本稿按“章节运行时路径为唯一主路径”组织,不再保留旧整书主路径的并行设计。
要求:
- 文档中的数据源、分页、位置持久化均只描述章节运行时方案
- 旧整书
TextBook路径只作为历史背景,不再作为本方案的一部分 - 实施时如需过渡脚手架,可单独记录在迁移任务中,但不写入主设计文档
5. 设计原则
5.1 正文主缓存坚持 WXRead
- 正文主缓存以章节级内存缓存为主
- 章节淘汰必须由阅读窗口和内存策略显式决定
- 不能把整书磁盘分页缓存重新扶正成正文主路径
5.2 图片与轻量摘要层可参考 SDWebImage
可以借鉴 SDWebImage 的部分:
memory -> disk -> rebuild分层思路- cache key 设计
- 版本化与失效策略
- 图片
NSCache
但以下对象不能按 SDWebImage 思路长期磁盘化:
RDEPUBRuntimeChapterNSAttributedStringRDEPUBTextLayouter- 完整
pages
5.3 章节生命周期优先于历史命中率
缓存目标不是“尽量记住所有历史章节”,而是:
- 当前章立即可读
- 相邻章尽量无感
- 窗口外尽快释放
5.4 位置真值改为章节语义
主位置语义改成:
spineIndexchapterOffsetfragmentID
全书页码不再作为稳定真值,只能是派生展示值。
6. 运行时架构
6.1 核心分层
建议分成 4 层:
RDEPUBChapterRuntimeStoreRDEPUBChapterLoaderRDEPUBChapterWindowCoordinatorRDEPUBChapterLocationCoordinator
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 增强层。
只允许缓存轻量字段:
pageRangespageCountfragmentOffsets摘要renderSignatureschemaVersionchapterContentHashpageMetadataList(每页的 breakReason / attachmentKinds / blockKinds / semanticHints / attachmentPlacements / trailingFragmentID 摘要)
不允许缓存:
- 完整
RDEPUBRuntimeChapter NSAttributedStringRDEPUBTextLayouter- 完整
pages
7.4 四级缓存:解压缓存
EPUB 解压目录仍然保留在 Caches,但它只负责避免重复解压,不参与正文真值。
8. 关键缺失定义补齐
8.1 RDEPUBChapterOffsetMap
需要明确定义:
struct RDEPUBChapterOffsetMap {
let fragmentOffsets: [String: Int]
let pageStartOffsets: [Int]
let pageEndOffsets: [Int]
}
职责:
fragmentID -> chapterOffsetchapterOffset -> pageIndex- 章内命中测试与位置恢复
8.2 renderSignature
renderSignature 必须包含:
fontNamefontSizelineHeightMultiplecontentInsetspageSizelayoutConfigSignatureschemaVersion
它是分页结构与轻量摘要层的核心失效依据。
8.2.1 layoutConfig.cacheSignature 字段组成
cacheSignature 是 RDEPUBTextLayoutConfig 的签名摘要,必须覆盖所有影响分页结果的排版参数。任何参数变化都必须导致签名不同,从而使旧缓存自然失效。
必须包含的字段:
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
建议定义:
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 命中链路
第二次打开推荐链路:
- 命中 EPUB 解压缓存
- 定位目标
spineIndex - 先查
chapterDataCache - 未命中时查
pageCountCache - 仍未命中时查
chapterSummaryDiskCache - 命中轻量分页结构后跳过最重的分页计算
- 重建当前章必需的富文本和页面对象
- 当前章先进入阅读器
- 再按
±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 存量数据迁移
必须考虑旧数据兼容:
- 旧版页码位置
- 旧版全局偏移位置
- 旧版书签
- 旧版高亮
建议:
- 位置持久化增加版本字段
- 先实现
legacy -> chapterLocation转换器 - 新写入统一用
RDEPUBChapterLocation - 旧数据转换成功后覆盖为新格式
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 切窗策略
当跨章时:
- 先构造新的窗口快照
- 再切换
RDReaderView数据源 - 保持当前阅读锚点不抖动
12. 快速翻章体验
±1 窗口 + 串行队列意味着快速翻章一定存在等待风险,方案必须定义 UI 行为。
建议:
- 下一章未就绪时,展示章节级 loading 状态
- loading 必须是页内轻提示,不要整屏阻塞
- 若用户连续快速翻章,只保留最后一次目标章节请求
- 非当前目标章节的排队请求可取消或降级
- 已经开始执行中的章节构建默认不强行中断
- 当前任务结束后只允许最后一次目标章节请求进入显示链路
目标:
- 不追求无限预读
- 追求在内存可控前提下的稳定体验
13. 内存警告与淘汰策略
13.1 章节缓存策略
收到 UIApplication.didReceiveMemoryWarningNotification 时:
- 保存当前章
- 清空非当前章缓存
- 恢复当前章
- 清理图片缓存
13.2 pageCountCache 策略
这里需要明确和之前版本不同的结论:
pageCountCache不是独立真值- 当前章的页范围已经包含在
RDEPUBRuntimeChapter
因此两种实现都可接受,但必须文档化:
- 更保守方案
- 保留当前章对应的
pageCountCache
- 保留当前章对应的
- 更简化方案
- 直接清空全部
pageCountCache - 因为当前章显示不依赖它
- 直接清空全部
推荐:
- 默认采用“保守方案”,保留当前章对应的
pageCountCache - 章节淘汰时同步淘汰对应
pageCountCache
14. 具体开发方案
14.1 目标分层
后续代码建议拆成:
RDEPUBChapterRuntimeStoreRDEPUBChapterLoaderRDEPUBChapterWindowCoordinatorRDEPUBChapterLocationCoordinatorRDEPUBChapterSummaryDiskCache
14.2 与现有模块的替换关系
RDEPUBReaderPaginationCoordinator- 从整书分页协调器改成章节窗口分页入口
RDEPUBTextBookBuilder- 保留单章构建能力
- 整书构建不再作为大书正文主路径
RDEPUBReaderContext- 降低
textBook真值地位 - 挂入
chapterRuntimeStore
- 降低
RDEPUBReaderController+DataSource- 改为基于窗口快照供页
RDEPUBReaderRuntimego(toPageNumber:)改成章内语义
14.3 标准章节加载链路
- 输入
spineIndex - 查
chapterDataCache - miss 后查
pageCountCache - 仍未命中时查
chapterSummaryDiskCache - 在
chapterLoadQueue中读取 HTML - 构建 source/typeset
- 构建 layouter
- 若命中
pageCountCache或chapterSummaryDiskCache,优先复用pageRanges - 若未命中轻量分页结构,则执行完整分页
- 生成
pages - 生成
chapterOffsetMap - 回填章节缓存
- 回填
pageCountCache - 回填轻量摘要层
- 更新窗口快照
- 回主线程驱动显示
14.4 串行队列中的取消语义
串行队列下的取消分为两类:
- 尚未开始执行的排队请求
- 已经进入章节构建中的请求
文档结论:
- 对于尚未开始执行的请求:允许取消,只保留最后一次目标章节请求
- 对于已经开始执行的请求:默认不强行中断 CoreText / 分页过程
原因:
- 当前工程没有安全的“可中断分页事务”机制
- 强中断会放大半完成状态、缓存污染和 UI 状态错乱的风险
推荐实现:
- 采用“可取消排队,不中断执行中任务”的策略
- 当前任务完成后,立刻检查最后一次目标章节是否变化
- 若目标已变化,则丢弃不再需要的结果,不更新窗口
- 只把最后一次目标章节推进到显示链路
快速跳章体验要求:
- 如果用户从目录直接跳到较远章节,例如第 50 章:
- 旧的排队请求可以取消
- 当前正在构建的章节允许自然完成
- 完成后立即转向最新目标章节请求
- UI 必须提供轻量 loading 提示,明确当前正在打开目标章节
延迟约束:
- 单章构建耗时必须可观测
- 如果快速跳章的等待不可接受,优先优化单章构建耗时
- 不应先引入危险的强中断机制
15. 迁移策略
15.1 单一路径切换
本方案不再维护“新旧两套正文链路并存”。
要求:
RDEPUBChapterRuntimeStore + RDEPUBChapterLoader + RDEPUBChapterWindowCoordinator组成唯一正文主路径RDReaderView的数据源直接消费窗口快照- 位置持久化、翻章、搜索结果定位统一落到章节语义
15.2 P0 风险控制
P0 改成:
P0-1建立章节 store 与 loaderP0-2建立窗口数据源适配P0-3打通打开书、翻章、持久化三条核心链路P0-4验证通过后移除旧整书主路径相关依赖
这样可以避免“主设计仍在描述双路径”,让实现和文档保持一致。
16. 可直接开发的实施清单
P0:建立章节真值主路径
- 新建
RDEPUBChapterRuntimeStore - 新建
RDEPUBChapterLoader - 新建
RDEPUBChapterWindowSnapshot - 让
RDEPUBReaderController+DataSource直接消费窗口快照 - 打通打开书、翻章、位置持久化
P1:建立完整章节缓存运行时
- 实现
chapterDataCache - 实现
pageCountCache - 实现
chapterOffsetMap - 建立
±1预取窗口 - 建立内存警告清理
P2:接入二次打开加速层
- 新建
RDEPUBChapterSummaryDiskCache - 定义
renderSignature - 定义
RDEPUBChapterCacheKey - 当前章优先命中轻量摘要层
P3:完成位置与跨章能力迁移
- 新建
RDEPUBChapterLocation - 实现 legacy 位置转换器
- 搜索/书签/高亮改成章节级锚点
go(toPageNumber:)改为章内语义
P4:移除旧整书主路径依赖
- 移除大书场景下的整书
TextBook主路径依赖 - 降级
RDEPUBTextBookCache - 清理 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 开始设 true,completion 回调后设 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()只负责初始化基础设施,然后委托给paginationCoordinatorpaginatePublication()内部调用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是否持有CTTypesetterRDEPUBCoreTextPageFrameFactory→ 检查是否持有CTFramesetter/CTFrame/CGPathRDEPUBDTCoreTextRenderer→ 检查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 阶段只需:
- 在
RDEPUBReaderRuntime.setupChapterRuntimeIfNeeded()中创建RDEPUBChapterSummaryDiskCache并注入 loader - 确认
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 构建目标章再取长度。
// 只有构建失败时才降级到 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 跨章功能适配
搜索适配:
// ============ 修改: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: "&", 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)- 核心适配点:
- 写入时确保
rangeAnchor被正确填充 - 读取时从
rangeAnchor恢复,而不是从全局页码
- 写入时确保
19.5 P4:清理旧整书主路径
19.5.1 RDEPUBReaderPaginationCoordinator 改造
RDEPUBReaderPaginationCoordinator 是章节路径的唯一启动入口。
阶段说明:委托结构改造(
paginatePublication()委托ChapterWindowCoordinator.openBook(at:))在 P0 阶段完成,与loadPublication()的委托调用同步落地。P4 阶段在此基础上仅删除旧整书分页路径代码(paginateTextPublication、buildQuickTextBook、IncrementalChapterStore、StagedBookRequest、stageBookRequest、scheduleStagedIncrementalTextBookApplication等),不改变启动入口。
// ============ 修改: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 只删除不再使用的旧路径) |