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