diff --git a/Doc/WXRead/内存优化方案_WXRead策略.md b/Doc/WXRead/内存优化方案_WXRead策略.md new file mode 100644 index 0000000..3c008b1 --- /dev/null +++ b/Doc/WXRead/内存优化方案_WXRead策略.md @@ -0,0 +1,459 @@ +# WXRead 阅读器内存优化方案 + +> 基于读书 v10.0.3 逆向代码分析,提炼 WXRead 处理大书内存的核心策略。 +> 创建日期:2026-06-02 + +--- + +## 1. 核心设计原则 + +WXRead 的内存管理建立在一个关键架构决策之上:**永远不持有全书数据模型**。 + +与"先构建整本 TextBook 再进入阅读器"不同,WXRead 的数据流是: + +``` +单章 XHTML + → WREpubTypesetter.attributeStringWithFilePath: (单章排版) + → WRChapterData (单章数据模型) + → WRChapterPageCount (单章分页) + → WRPageView.drawRect: (单页渲染) +``` + +每一步都是章节级或页面级操作,从不需要同时持有全书的 `NSAttributedString`。 + +--- + +## 2. 章节级缓存模型 + +### 2.1 缓存结构 + +```objc +// WRReaderViewController () +@property (nonatomic, strong) NSMutableDictionary *chapterDataCache; +@property (nonatomic, strong) NSMutableDictionary *pageCountCache; +``` + +关键设计决策: + +| 设计点 | 选择 | 原因 | +|--------|------|------| +| 缓存类型 | `NSMutableDictionary` 而非 `NSCache` | 需要精确控制淘汰时机,NSCache 的自动淘汰不可预测 | +| 缓存粒度 | 按章节索引(`NSNumber *`) | 每章独立加载、独立释放 | +| 分页缓存 | 按缓存键(含排版设置) | 设置变更时整批失效 | + +### 2.2 缓存内容 + +每个 `WRChapterData` 持有: + +```objc +@interface WRChapterData : NSObject +@property (nonatomic, strong) NSMutableAttributedString *typesetAttributedString; // 排版后富文本 +@property (nonatomic, strong) WRCoreTextLayouter *layouter; // 排版器 +@property (nonatomic, strong) NSArray *pageRanges; // 页范围数组 +@property (nonatomic, strong) NSArray *highlights; // 高亮 +@property (nonatomic, strong) NSArray *underlines; // 下划线 +@property (nonatomic, strong) NSSet *bookmarkedPages; // 书签 +@property (nonatomic, strong) NSAttributedString *sourceAttributedString; // 源文本 +@end +``` + +**注意**:`WRChapterData` 不持有页面视图、不持有其他章节的引用、不持有全书索引表。章节之间完全解耦。 + +--- + +## 3. 内存警告处理 + +### 3.1 监听注册 + +```objc +// WRReaderViewController.initWithBook:progress:... +[[NSNotificationCenter defaultCenter] + addObserver:self + selector:@selector(_handleMemoryWarning:) + name:UIApplicationDidReceiveMemoryWarningNotification + object:nil]; +``` + +在 `initWithBook:` 中注册,确保从阅读器创建之初就监听。 + +### 3.2 处理逻辑 + +```objc +- (void)_handleMemoryWarning:(NSNotification *)note { + NSLog(@"[WRReader] Memory warning received, purging chapter cache."); + + // 1. 保留当前章节 + NSUInteger currentIdx = self.readingProgress.chapterIndex; + WRChapterData *currentData = self.chapterDataCache[@(currentIdx)]; + + // 2. 清空全部缓存 + [self.chapterDataCache removeAllObjects]; + + // 3. 恢复当前章节 + if (currentData) { + self.chapterDataCache[@(currentIdx)] = currentData; + } + + // 4. 清空分页缓存(可在需要时重新计算) + [self.pageCountCache removeAllObjects]; +} +``` + +策略总结: + +| 步骤 | 操作 | 目的 | +|------|------|------| +| 1 | 保存当前章节引用 | 当前页不能中断 | +| 2 | `removeAllObjects` | 一次性释放所有非当前章 | +| 3 | 恢复当前章节 | 保证阅读不中断 | +| 4 | 清空分页缓存 | `pageCountCache` 可重建,释放额外内存 | + +### 3.3 为什么不用 NSCache + +WXRead 选择 `NSMutableDictionary` + 手动淘汰而非 `NSCache`,原因: + +1. **确定性**:内存警告时必须立即释放,NSCache 的淘汰时机不可控 +2. **当前章保护**:NSCache 无法 pin 住当前章不被淘汰 +3. **可预测性**:开发和调试时行为一致,不会因系统内存压力变化而变化 + +--- + +## 4. 章节加载与预取 + +### 4.1 串行后台队列 + +```objc +// 初始化 +_chapterLoadQueue = dispatch_queue_create("com.weread.chapterload", DISPATCH_QUEUE_SERIAL); + +// 加载章节 +- (void)_loadChapterAtIndex:(NSUInteger)index + completion:(void (^)(WRChapterData *, NSError *))completion { + // 先查缓存 + WRChapterData *cached = self.chapterDataCache[@(index)]; + if (cached) { + if (completion) completion(cached, nil); + return; + } + + // 防止重复加载 + if (self.isLoadingChapter) return; + self.isLoadingChapter = YES; + + dispatch_async(self.chapterLoadQueue, ^{ + // 后台:获取内容 → 排版 → 生成 WRChapterData + NSError *error = nil; + WRChapterData *chapterData = [self _fetchChapterDataForIndex:index error:&error]; + + dispatch_async(dispatch_get_main_queue(), ^{ + self.isLoadingChapter = NO; + if (chapterData) { + self.chapterDataCache[@(index)] = chapterData; + if (completion) completion(chapterData, nil); + } else { + // 重试逻辑 + if (self.loadRetryCount < self.maxRetryCount) { + self.loadRetryCount++; + [self _loadChapterAtIndex:index completion:completion]; + } else { + self.loadRetryCount = 0; + if (completion) completion(nil, error); + } + } + }); + }); +} +``` + +设计要点: + +| 要点 | 实现 | 目的 | +|------|------|------| +| 串行队列 | `com.weread.chapterload` | 避免并发加载导致内存峰值叠加 | +| 防重复 | `isLoadingChapter` 标志 | 同时只加载一章,控制内存瞬时占用 | +| 先查缓存 | `chapterDataCache[@(index)]` | 命中则直接返回,不触发后台任务 | +| 重试机制 | `maxRetryCount = 3` | 网络异常时自动重试 | + +### 4.2 相邻章节预取 + +```objc +- (void)_prefetchAdjacentChaptersForIndex:(NSUInteger)index { + // 预取下一章 + if (index + 1 < self.totalChapters) { + NSUInteger nextIdx = index + 1; + if (!self.chapterDataCache[@(nextIdx)]) { + dispatch_async(self.chapterLoadQueue, ^{ + [self _fetchChapterDataForIndex:nextIdx error:NULL]; + }); + } + } + + // 预取上一章 + if (index > 0) { + NSUInteger prevIdx = index - 1; + if (!self.chapterDataCache[@(prevIdx)]) { + dispatch_async(self.chapterLoadQueue, ^{ + [self _fetchChapterDataForIndex:prevIdx error:NULL]; + }); + } + } +} +``` + +**触发时机**: +1. `renderPageView:` 渲染完成后(第 282 行) +2. `didFlipPage` 翻页完成后(第 409 行) + +**预取窗口**:仅当前章 ± 1 章,不做全书预加载。这保证内存占用与用户实际阅读位置绑定。 + +--- + +## 5. 图片缓存 + +### 5.1 NSCache 限制 + +```objc +// WRCoreTextLayouter.commonInit +_imageCache = [[NSCache alloc] init]; +_imageCache.countLimit = 50; // 最多缓存 50 张缩放后的图片 +``` + +图片是内存大户,WXRead 对图片缓存的处理策略: + +| 策略 | 实现 | 原因 | +|------|------|------| +| 使用 `NSCache` | 自动在内存压力时淘汰 | 图片可以重新生成,丢失代价低 | +| `countLimit = 50` | 限制数量 | 防止图片无限累积 | +| 按章节归属 | 每个 `WRCoreTextLayouter` 独立持有 | 章节释放时图片一起释放 | + +### 5.2 CoreText 对象释放 + +```objc +// WRCoreTextLayouter.dealloc +- (void)dealloc { + if (_typesetter) { + CFRelease(_typesetter); + _typesetter = NULL; + } + if (_framesetter) { + CFRelease(_framesetter); + _framesetter = NULL; + } +} +``` + +`CTTypesetter` 和 `CTFramesetter` 是 C 对象,不会被 ARC 自动释放。WXRead 在 `dealloc` 中显式释放,防止内存泄漏。 + +--- + +## 6. 预加载缓存管理 + +### 6.1 预加载场景 + +```objc +typedef NS_ENUM(NSInteger, WRPreloadScene) { + WRPreloadSceneNone = 0, + WRPreloadSceneShelf = 1, // 书架页预加载 + WRPreloadSceneReading = 2, // 阅读时预加载后续章节 + WRPreloadSceneWiFi = 3, // WiFi 下激进预加载 + WRPreloadSceneManual = 4, // 用户手动触发 +}; +``` + +### 6.2 缓存清理接口 + +```objc +// 清理指定书籍的预加载数据 ++ (void)removeKVWithBookId:(NSString *)bookId; + +// 清理全部预加载缓存 ++ (void)clearKV; + +// 计算并可选清理预加载缓存 +- (void)calcAndClearPreloadBookWithCompletion:(void (^)(NSUInteger totalSize))completion + onlyCalc:(BOOL)onlyCalc; +``` + +预加载数据(已下载但未阅读的章节)存储在磁盘,不占用运行时内存。清理接口用于管理磁盘空间。 + +--- + +## 7. 排版重排的内存影响 + +### 7.1 设置变更时的缓存处理 + +```objc +- (void)recomposeCurrentPageViewWithSource:(NSString *)source { + self.isRecomposing = YES; + + // 清空分页缓存(排版参数变了,旧分页无效) + [self.pageCountCache removeAllObjects]; + + // 重新排版当前章节 + WRChapterData *chapterData = self.currentChapterData; + if (chapterData) { + [self _reTypesetChapterData:chapterData]; + } + + // 重新渲染 + [self renderPageView:self.activePageViews.firstObject + progressData:self.readingProgress + source:source]; + + self.isRecomposing = NO; +} +``` + +### 7.2 全局设置变更 + +```objc +- (void)reloadPageViewsWithProgressData:(WRReadingProgress *)progressData + source:(NSString *)source { + // 清空所有缓存(字号/行距变化影响所有章节的排版) + [self.chapterDataCache removeAllObjects]; + [self.pageCountCache removeAllObjects]; + + // 重新加载当前章 + [self _loadChapterAtIndex:progressData.chapterIndex + completion:^(WRChapterData *chapterData, NSError *error) { + if (chapterData) { + [self _displayChapter:chapterData + atPageIndex:progressData.pageIndex + animated:NO]; + } + }]; +} +``` + +全局设置变更时清空全部缓存,因为排版参数影响所有章节。这是一次性内存释放,后续按需重新加载。 + +--- + +## 8. 位置持久化 + +### 8.1 多字段模型 + +```objc +@interface WRReadingProgress : NSObject +@property (nonatomic, copy) NSString *bookId; +@property (nonatomic, assign) NSUInteger chapterIndex; // 章节索引 +@property (nonatomic, assign) NSUInteger pageIndex; // 章内页码 +@property (nonatomic, assign) NSUInteger charIndex; // 章内字符偏移 +@property (nonatomic, assign) CGFloat scrollOffset; // 滚动偏移 +@property (nonatomic, copy) NSString *chapterId; +@property (nonatomic, assign) double readPercentage; // 阅读百分比 +@end +``` + +### 8.2 保存时机 + +```objc +// 1. 每次翻页 +- (void)didFlipPage { + // ... + [self _saveReadingProgressAndIsAsync:YES]; +} + +// 2. 30 秒定时器 +_progressSaveTimer = [NSTimer scheduledTimerWithTimeInterval:30.0 + target:self + selector:@selector(_periodicProgressSave) + userInfo:nil + repeats:YES]; + +// 3. 退出时同步保存 +- (void)_periodicProgressSave { + [self _saveReadingProgressAndIsAsync:YES]; +} +``` + +### 8.3 保存格式 + +```objc +- (void)_saveReadingProgressAndIsAsync:(BOOL)isAsync { + NSDictionary *dict = @{ + @"bookId": progress.bookId ?: @"", + @"chapterIndex": @(progress.chapterIndex), + @"pageIndex": @(progress.pageIndex), + @"charIndex": @(progress.charIndex), + @"scrollOffset": @(progress.scrollOffset), + @"chapterId": progress.chapterId ?: @"", + @"readPercentage": @(progress.readPercentage), + @"timestamp": @([[NSDate date] timeIntervalSince1970]), + }; + + if (isAsync) { + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + [[NSUserDefaults standardUserDefaults] setObject:dict forKey:key]; + [[NSUserDefaults standardUserDefaults] synchronize]; + }); + } else { + [[NSUserDefaults standardUserDefaults] setObject:dict forKey:key]; + [[NSUserDefaults standardUserDefaults] synchronize]; + } +} +``` + +**位置恢复精度**:`charIndex` 是主锚点。页面变化(字号、屏幕尺寸)不影响恢复,因为字符偏移量是稳定的。 + +--- + +## 9. 内存占用模型 + +### 9.1 单章内存估算 + +假设一章 EPUB 约 5000 字符: + +| 组件 | 估算大小 | 说明 | +|------|----------|------| +| `typesetAttributedString` | ~200-500 KB | 含字体、段落样式、图片附件 | +| `WRCoreTextLayouter` | ~50-100 KB | CTTypesetter + CTFramesetter | +| `pageRanges` | ~1-5 KB | NSRange 数组 | +| 高亮/下划线/书签 | ~1-10 KB | 取决于标注数量 | +| 图片缓存 | ~0-2 MB | 取决于章内图片数量 | +| **单章合计** | **~0.3-3 MB** | 图片是主要变量 | + +### 9.2 全书内存估算 + +WXRead 的内存占用 = 当前章 + 预取章(±1)+ 系统开销: + +| 场景 | 缓存章节数 | 估算内存 | +|------|------------|----------| +| 正常阅读 | 1(当前)+ 2(预取)= 3 章 | ~1-9 MB | +| 内存警告后 | 1 章(仅当前) | ~0.3-3 MB | +| 快速连续翻页 | 最多 3 章(串行加载限制) | ~1-9 MB | + +**对比**:如果持有全书数据(500 章 × 1 MB/章 = 500 MB),WXRead 的策略将其控制在个位数 MB。 + +--- + +## 10. 策略总结 + +| 策略 | 实现 | 效果 | +|------|------|------| +| **章节级缓存** | `NSMutableDictionary` | 内存与阅读位置绑定,不随全书增长 | +| **手动淘汰** | 内存警告时清空非当前章 | 确定性释放,当前章不中断 | +| **串行加载** | `com.weread.chapterload` 队列 | 避免并发加载内存峰值叠加 | +| **±1 预取** | 翻页后异步预取相邻章 | 平衡流畅性与内存占用 | +| **图片 NSCache** | `countLimit = 50` | 图片自动淘汰,章节释放时一起释放 | +| **C 对象显式释放** | `dealloc` 中 `CFRelease` | 防止 CoreText 内存泄漏 | +| **不分页全书** | 按章节独立分页 | 避免持有全书页范围数组 | +| **不持有全书索引** | 无全局 `RDEPUBTextIndexTable` 等价物 | 按需查询,非常驻 | + +--- + +## 附录:WXRead 关键文件索引 + +| 文件 | 职责 | 内存相关 | +|------|------|----------| +| `WRReaderViewController.m` | 阅读器主控制器 | `chapterDataCache`、内存警告处理、预取 | +| `WRChapterData.h/m` | 章节数据模型 | 持有 `typesetAttributedString`、`layouter` | +| `WRCoreTextLayouter.h/m` | CoreText 排版器 | 图片缓存 `NSCache(countLimit=50)`、C 对象释放 | +| `WRChapterPageCount.h/m` | 章节分页计算 | `pageRanges` 数组 | +| `WRPreloadBookManager.h/m` | 预加载管理 | 磁盘缓存管理,不占运行时内存 | +| `WREpubTypesetter.m` | EPUB 排版器 | 单章排版,不持有全书状态 | + +--- + +*基于读书 v10.0.3 逆向分析* +*文档创建:2026-06-02* diff --git a/Doc/大书优化方案_内存与主线程.md b/Doc/大书优化方案_内存与主线程.md new file mode 100644 index 0000000..bcb3d66 --- /dev/null +++ b/Doc/大书优化方案_内存与主线程.md @@ -0,0 +1,2827 @@ +# 大书优化方案:章节级缓存运行时与二次打开加速 + +> 适用对象:`textReflowable` 路径下的超大 EPUB,例如《凡人修仙传》精校版全本。 +> 文档目标:把当前”整书分页元数据磁盘缓存 + 打开时重建整本模型”的实现,迁移为**以 WXRead 为基线**的章节级运行时;同时明确哪些部分是 SDK 为了工程化和二次打开体验做的增强。 +> 更新时间:2026-06-02 + +### 作用域边界 + +**本方案所有改造(含 DataSource 适配、窗口快照、章节缓存运行时)仅适用于 `textReflowable` 路径。** + +具体边界: + +- `RDEPUBReaderController+DataSource` 的窗口快照直连改造,仅在 `textReflowable` 类型书籍的控制器实例上启用 +- `RDReaderView` 消费的数据源切换为窗口快照,仅限 textReflowable 路径 +- 固定版式(`fixed-layout`)、PDF 等其他路径维持原有 DataSource 实现不变 +- `RDEPUBReaderController` 需在初始化时判断书籍类型,选择对应的 DataSource 策略 + +实现要求: + +- 不允许将窗口快照 DataSource 无条件覆盖到所有 `RDReaderView` 消费者 +- 书籍类型判断必须在控制器层完成,不能下沉到 `RDReaderView` 内部 +- 非 textReflowable 路径继续使用整书 `pages` 数组供页,不受本方案影响 + +--- + +## 1. 范围与结论 + +这份方案的核心方向与 WXRead 一致: + +- 正文运行时不再以全书 `RDEPUBTextBook` 为真值 +- 正文缓存不再以整书分页文件为主路径 +- 运行时只围绕“当前章 + 相邻章”工作 +- 章节加载串行化 +- 内存回收与阅读窗口绑定 + +但这份方案**不是逐字逐句复刻 WXRead 的类拆分**,而是: + +- `WXRead` 的核心缓存与生命周期策略:保持一致 +- `ReadViewSDK` 的工程化拆分类、轻量磁盘摘要层:作为 SDK 增强 + +一句话总结: + +**主缓存与运行时语义复刻 WXRead;工程结构与二次打开加速允许做 SDK 增强。** + +--- + +## 2. 当前问题 + +当前实现的主要问题不是“完全没有缓存”,而是缓存重心与运行时真值都放错了位置: + +- `RDEPUBTextBookCache` 缓存的是整书分页元数据 +- 打开书时仍然会重新读取 HTML、重新生成富文本、重新组装整书模型 +- `context.textBook`、全书 `pages`、全书索引表仍然参与正文主链路 +- 大书场景下,CPU、内存、主线程压力都容易被整书路径放大 + +这和 WXRead 的关键差异是: + +- WXRead 主缓存是章节运行时缓存 +- WXRead 主路径不依赖整书分页文件 +- WXRead 主运行时不以“后台补齐整书再切换”为中心 + +--- + +## 3. 与 WXRead 对齐的部分 + +以下部分要求与 WXRead 保持同方向: + +### 3.1 章节级主缓存 + +- 章节缓存粒度为单章 +- 当前章与相邻章构成运行时窗口 +- 窗口外章节必须可确定性淘汰 + +### 3.2 串行章节加载 + +- 所有章节构建都通过单一串行队列 +- 同一时刻只允许一章实际构建 + +### 3.3 内存警告策略 + +- 当前章必须保住 +- 非当前窗口章节优先释放 + +### 3.4 正文真值退化为章节级 + +- 不再以全书 `TextBook` 为正文主真值 +- 不再把整书分页完成视为阅读器唯一稳定态 + +--- + +## 4. SDK 增强项 + +下面这些设计是**相对 WXRead 的 SDK 增强**,不应表述为“原样复刻”: + +### 4.1 轻量章节摘要磁盘缓存 + +`chapterSummaryDiskCache` 是 SDK 增强,不是 WXRead 原生能力。 + +定位: + +- 只用于二次打开加速 +- 只缓存轻量摘要 +- 不是正文主缓存 + +### 4.2 协调器拆分 + +以下类拆分是 SDK 工程化改进: + +- `RDEPUBChapterLoader` +- `RDEPUBChapterWindowCoordinator` +- `RDEPUBChapterLocationCoordinator` + +WXRead 把类似职责大量内联在 `WRReaderViewController` 内;ReadViewSDK 不必复制这种类膨胀结构。 + +### 4.3 旧链路处理边界 + +本稿按“章节运行时路径为唯一主路径”组织,不再保留旧整书主路径的并行设计。 + +要求: + +- 文档中的数据源、分页、位置持久化均只描述章节运行时方案 +- 旧整书 `TextBook` 路径只作为历史背景,不再作为本方案的一部分 +- 实施时如需过渡脚手架,可单独记录在迁移任务中,但不写入主设计文档 + +--- + +## 5. 设计原则 + +### 5.1 正文主缓存坚持 WXRead + +- 正文主缓存以章节级内存缓存为主 +- 章节淘汰必须由阅读窗口和内存策略显式决定 +- 不能把整书磁盘分页缓存重新扶正成正文主路径 + +### 5.2 图片与轻量摘要层可参考 SDWebImage + +可以借鉴 SDWebImage 的部分: + +- `memory -> disk -> rebuild` 分层思路 +- cache key 设计 +- 版本化与失效策略 +- 图片 `NSCache` + +但以下对象不能按 SDWebImage 思路长期磁盘化: + +- `RDEPUBRuntimeChapter` +- `NSAttributedString` +- `RDEPUBTextLayouter` +- 完整 `pages` + +### 5.3 章节生命周期优先于历史命中率 + +缓存目标不是“尽量记住所有历史章节”,而是: + +- 当前章立即可读 +- 相邻章尽量无感 +- 窗口外尽快释放 + +### 5.4 位置真值改为章节语义 + +主位置语义改成: + +- `spineIndex` +- `chapterOffset` +- `fragmentID` + +全书页码不再作为稳定真值,只能是派生展示值。 + +--- + +## 6. 运行时架构 + +### 6.1 核心分层 + +建议分成 4 层: + +1. `RDEPUBChapterRuntimeStore` +2. `RDEPUBChapterLoader` +3. `RDEPUBChapterWindowCoordinator` +4. `RDEPUBChapterLocationCoordinator` + +### 6.2 类型安全缓存封装 + +虽然缓存策略等价于 WXRead 的 `NSMutableDictionary + 手动淘汰`,但在 Swift 代码库里不建议直接把 `NSMutableDictionary` 暴露为主接口。 + +建议使用类型安全封装: + +```swift +final class RDEPUBChapterDataCache { + private var storage: [Int: RDEPUBRuntimeChapter] = [:] + private let lock = NSLock() +} + +final class RDEPUBPageCountCache { + private var storage: [RDEPUBChapterCacheKey: RDEPUBRuntimePageCount] = [:] + private let lock = NSLock() +} +``` + +要求: + +- 对外暴露 typed API +- 内部保持确定性淘汰能力 +- 不把 Objective-C 容器直接扩散到全链路 + +### 6.3 线程安全规则 + +缓存不是天然线程安全的,文档必须明确: + +- 章节构建统一在 `chapterLoadQueue` 发生 +- 缓存写入统一通过 store 的同步接口完成 +- 缓存读取也必须通过 store 的同步接口 +- 不允许主线程裸读缓存、后台线程裸写缓存 + +推荐实现: + +- `chapterLoadQueue` 负责串行构建 +- cache wrapper 内部用 `NSLock` 或专用串行队列保护读写 + +--- + +## 7. 缓存设计 + +### 7.1 一级缓存:章节运行时缓存 + +```swift +final class RDEPUBRuntimeChapter { + let spineIndex: Int + let href: String + let title: String + + let sourceAttributedString: NSAttributedString? + let typesetAttributedString: NSAttributedString + let layouter: RDEPUBTextLayouter + let pages: [RDEPUBTextPage] + let pageRanges: [NSRange] + + let chapterOffsetMap: RDEPUBChapterOffsetMap +} +``` + +#### 关于 `sourceAttributedString` + +这里不再强制“缓存中永远同时保留 source 和 typeset 两份”。 + +建议策略: + +- 首次构建后可同时保留两份 +- 当章节进入稳定缓存态后,可按策略释放 `sourceAttributedString` +- 需要重新排版时,再从 HTML 或轻量中间态重建 source + +原因: + +- `source + typeset` 双持会显著放大内存 +- WXRead 这么做不代表 ReadViewSDK 必须照搬内存代价 + +文档结论: + +- `sourceAttributedString` 在类型上允许存在 +- 但不是必须长期常驻的字段 + +### 7.2 二级缓存:页数与页范围缓存 + +`pageCountCache` 的定位要收紧: + +- 它不是独立于 `chapterDataCache` 的第二真值 +- 它是“轻量分页结构缓存”的内存映像 +- 它主要服务于: + - 二次打开加速 + - 章节未命中时的快速分页结构恢复 + +一致性原则: + +- `chapterDataCache` 命中时,以章节对象内部 `pageRanges` 为准 +- `pageCountCache` 不能覆盖章节对象真值 +- 章节淘汰时,应同步清理与该章关联的 `pageCountCache` + +### 7.3 三级缓存:轻量章节摘要磁盘缓存 + +`chapterSummaryDiskCache` 是 SDK 增强层。 + +只允许缓存轻量字段: + +- `pageRanges` +- `pageCount` +- `fragmentOffsets` 摘要 +- `renderSignature` +- `schemaVersion` +- `chapterContentHash` +- `pageMetadataList`(每页的 breakReason / attachmentKinds / blockKinds / semanticHints / attachmentPlacements / trailingFragmentID 摘要) + +不允许缓存: + +- 完整 `RDEPUBRuntimeChapter` +- `NSAttributedString` +- `RDEPUBTextLayouter` +- 完整 `pages` + +### 7.4 四级缓存:解压缓存 + +EPUB 解压目录仍然保留在 `Caches`,但它只负责避免重复解压,不参与正文真值。 + +--- + +## 8. 关键缺失定义补齐 + +### 8.1 `RDEPUBChapterOffsetMap` + +需要明确定义: + +```swift +struct RDEPUBChapterOffsetMap { + let fragmentOffsets: [String: Int] + let pageStartOffsets: [Int] + let pageEndOffsets: [Int] +} +``` + +职责: + +- `fragmentID -> chapterOffset` +- `chapterOffset -> pageIndex` +- 章内命中测试与位置恢复 + +### 8.2 `renderSignature` + +`renderSignature` 必须包含: + +- `fontName` +- `fontSize` +- `lineHeightMultiple` +- `contentInsets` +- `pageSize` +- `layoutConfigSignature` +- `schemaVersion` + +它是分页结构与轻量摘要层的核心失效依据。 + +### 8.2.1 `layoutConfig.cacheSignature` 字段组成 + +`cacheSignature` 是 `RDEPUBTextLayoutConfig` 的签名摘要,必须覆盖所有影响分页结果的排版参数。任何参数变化都必须导致签名不同,从而使旧缓存自然失效。 + +必须包含的字段: + +```swift +extension RDEPUBTextLayoutConfig { + /// 缓存签名:覆盖所有影响分页结果的排版参数 + /// 任何字段变化都必须导致签名变化,否则会出现缓存命中但分页结果不一致的 bug + var cacheSignature: String { + let fields: [String] = [ + "columnCount:\(columnCount)", // 分栏数 + "columnSpacing:\(columnSpacing)", // 栏间距 + "paragraphSpacing:\(paragraphSpacing)", // 段间距 + "paragraphFirstLineIndent:\(paragraphFirstLineIndent)", // 首行缩进 + "hyphenationEnabled:\(hyphenationEnabled)", // 连字开关 + "textAlignment:\(textAlignment.rawValue)", // 对齐方式 + "wordSpacing:\(wordSpacing)", // 字间距 + "letterSpacing:\(letterSpacing)", // 字母间距 + "imageMaxScale:\(imageMaxScale)", // 图片最大缩放比 + "imageInlineMaxHeight:\(imageInlineMaxHeight)", // 内联图片最大高度 + "attachmentPlacement:\(attachmentPlacement.rawValue)", // 附件放置策略 + "breakStrategy:\(breakStrategy.rawValue)", // 分页策略 + ] + return fields.joined(separator: "|") + } +} +``` + +设计原则: + +- **只包含影响分页结果的字段**:纯展示参数(如高亮颜色、选中样式)不进签名 +- **不包含 `edgeInsets`**:`edgeInsets` 已在 `renderSignature` 中独立编码,避免重复 +- **不包含 `lineHeightMultiple`**:同上,已在 `renderSignature` 中独立编码 +- **字段顺序固定**:签名拼接顺序必须稳定,避免因字段顺序变化导致无意义的缓存失效 +- **新增字段时必须追加到签名末尾**:并在 `schemaVersion` 中递增,确保旧缓存不被错误命中 + +不包含的字段(及其原因): + +| 字段 | 原因 | +|------|------| +| `edgeInsets` | 已在 `renderSignature` 独立编码 | +| `lineHeightMultiple` | 已在 `renderSignature` 独立编码 | +| `highlightColor` | 不影响分页结果 | +| `selectionColor` | 不影响分页结果 | +| `debugShowPageBounds` | 调试开关,不影响分页结果 | + +### 8.3 `RDEPUBChapterCacheKey` + +建议定义: + +```swift +struct RDEPUBChapterCacheKey: Hashable { + let bookID: String + let spineIndex: Int + let renderSignature: String + let chapterContentHash: String +} +``` + +### 8.4 `RDEPUBRuntimePageCount` + +`pageCountCache` 依赖的轻量分页结构类型需要明确: + +```swift +struct RDEPUBRuntimePageCount { + let cacheKey: RDEPUBChapterCacheKey + let spineIndex: Int + let pageRanges: [NSRange] + let pageCount: Int + let renderSignature: String +} +``` + +### 8.5 排版参数变化时的缓存失效 + +排版参数变化后必须同时处理: + +- 清理 `chapterDataCache` +- 清理 `pageCountCache` +- 使 `chapterSummaryDiskCache` 对应 key 自然失效 + +不要做部分失效,否则最容易出现页范围与正文不一致。 + +--- + +## 9. 二次打开加速方案 + +### 9.1 目标 + +第二次打开要更快,但不允许把完整章节运行时对象长期磁盘化。 + +### 9.2 命中链路 + +第二次打开推荐链路: + +1. 命中 EPUB 解压缓存 +2. 定位目标 `spineIndex` +3. 先查 `chapterDataCache` +4. 未命中时查 `pageCountCache` +5. 仍未命中时查 `chapterSummaryDiskCache` +6. 命中轻量分页结构后跳过最重的分页计算 +7. 重建当前章必需的富文本和页面对象 +8. 当前章先进入阅读器 +9. 再按 `±1` 规则预取相邻章 + +### 9.3 能省掉什么 + +能明显减少: + +- 重新分页计算 +- 页范围生成 +- 部分 fragment offset 计算 + +仍然需要: + +- 读取目标章 HTML +- 生成必要的富文本 +- 构建当前章页面对象 + +### 9.4 结果预期 + +这不是“零重建秒进”,而是“跳过最重步骤后的明显加速”。 + +--- + +## 10. 位置模型与跨章能力 + +### 10.1 新位置结构 + +```swift +public struct RDEPUBChapterLocation: Codable { + public var spineIndex: Int + public var chapterOffset: Int + public var fragmentID: String? + public var progressionInChapter: Double? +} +``` + +### 10.2 存量数据迁移 + +必须考虑旧数据兼容: + +- 旧版页码位置 +- 旧版全局偏移位置 +- 旧版书签 +- 旧版高亮 + +建议: + +1. 位置持久化增加版本字段 +2. 先实现 `legacy -> chapterLocation` 转换器 +3. 新写入统一用 `RDEPUBChapterLocation` +4. 旧数据转换成功后覆盖为新格式 + +### 10.3 跨章功能替代方案 + +停止依赖全书 `RDEPUBTextIndexTable` 后,需要明确替代设计: + +- 全书搜索结果 + - 搜索索引层仍可维护轻量章节级倒排或章节命中列表 + - 展示位置以“章节标题 + 章内片段”替代全书绝对页码真值 +- 书签 / 高亮 + - 统一持久化为 `spineIndex + chapterOffset + rangeLength` + - 当章节不在窗口内时,按章节级锚点懒加载恢复 +- 目录跳转 + - 直接定位 `spineIndex` / `fragmentID` + +文档结论: + +- 可以放弃全书绝对页码真值 +- 不能放弃跨章功能 + +--- + +## 11. RDReaderView 适配策略 + +当前 `RDReaderView` 更习惯消费连续页数组;迁移到章节窗口后,需要加一层适配。 + +### 11.1 建议方案 + +引入窗口快照: + +```swift +struct RDEPUBChapterWindowSnapshot { + let chapters: [RDEPUBRuntimeChapter] + let flattenedPages: [RDEPUBTextPage] + let anchorChapterIndex: Int +} +``` + +说明: + +- 运行时真值仍然是章节窗口 +- `RDReaderView` 只消费当前窗口展开后的局部连续页数组 +- 不再消费整书连续页数组 + +### 11.2 切窗策略 + +当跨章时: + +1. 先构造新的窗口快照 +2. 再切换 `RDReaderView` 数据源 +3. 保持当前阅读锚点不抖动 + +--- + +## 12. 快速翻章体验 + +`±1` 窗口 + 串行队列意味着快速翻章一定存在等待风险,方案必须定义 UI 行为。 + +建议: + +- 下一章未就绪时,展示章节级 loading 状态 +- loading 必须是页内轻提示,不要整屏阻塞 +- 若用户连续快速翻章,只保留最后一次目标章节请求 +- 非当前目标章节的排队请求可取消或降级 +- 已经开始执行中的章节构建默认不强行中断 +- 当前任务结束后只允许最后一次目标章节请求进入显示链路 + +目标: + +- 不追求无限预读 +- 追求在内存可控前提下的稳定体验 + +--- + +## 13. 内存警告与淘汰策略 + +### 13.1 章节缓存策略 + +收到 `UIApplication.didReceiveMemoryWarningNotification` 时: + +1. 保存当前章 +2. 清空非当前章缓存 +3. 恢复当前章 +4. 清理图片缓存 + +### 13.2 `pageCountCache` 策略 + +这里需要明确和之前版本不同的结论: + +- `pageCountCache` 不是独立真值 +- 当前章的页范围已经包含在 `RDEPUBRuntimeChapter` + +因此两种实现都可接受,但必须文档化: + +1. 更保守方案 + - 保留当前章对应的 `pageCountCache` +2. 更简化方案 + - 直接清空全部 `pageCountCache` + - 因为当前章显示不依赖它 + +推荐: + +- 默认采用“保守方案”,保留当前章对应的 `pageCountCache` +- 章节淘汰时同步淘汰对应 `pageCountCache` + +--- + +## 14. 具体开发方案 + +### 14.1 目标分层 + +后续代码建议拆成: + +1. `RDEPUBChapterRuntimeStore` +2. `RDEPUBChapterLoader` +3. `RDEPUBChapterWindowCoordinator` +4. `RDEPUBChapterLocationCoordinator` +5. `RDEPUBChapterSummaryDiskCache` + +### 14.2 与现有模块的替换关系 + +- `RDEPUBReaderPaginationCoordinator` + - 从整书分页协调器改成章节窗口分页入口 +- `RDEPUBTextBookBuilder` + - 保留单章构建能力 + - 整书构建不再作为大书正文主路径 +- `RDEPUBReaderContext` + - 降低 `textBook` 真值地位 + - 挂入 `chapterRuntimeStore` +- `RDEPUBReaderController+DataSource` + - 改为基于窗口快照供页 +- `RDEPUBReaderRuntime` + - `go(toPageNumber:)` 改成章内语义 + +### 14.3 标准章节加载链路 + +1. 输入 `spineIndex` +2. 查 `chapterDataCache` +3. miss 后查 `pageCountCache` +4. 仍未命中时查 `chapterSummaryDiskCache` +5. 在 `chapterLoadQueue` 中读取 HTML +6. 构建 source/typeset +7. 构建 layouter +8. 若命中 `pageCountCache` 或 `chapterSummaryDiskCache`,优先复用 `pageRanges` +9. 若未命中轻量分页结构,则执行完整分页 +10. 生成 `pages` +11. 生成 `chapterOffsetMap` +12. 回填章节缓存 +13. 回填 `pageCountCache` +14. 回填轻量摘要层 +15. 更新窗口快照 +16. 回主线程驱动显示 + +### 14.4 串行队列中的取消语义 + +串行队列下的取消分为两类: + +1. 尚未开始执行的排队请求 +2. 已经进入章节构建中的请求 + +文档结论: + +- 对于尚未开始执行的请求:允许取消,只保留最后一次目标章节请求 +- 对于已经开始执行的请求:默认不强行中断 CoreText / 分页过程 + +原因: + +- 当前工程没有安全的“可中断分页事务”机制 +- 强中断会放大半完成状态、缓存污染和 UI 状态错乱的风险 + +推荐实现: + +- 采用“可取消排队,不中断执行中任务”的策略 +- 当前任务完成后,立刻检查最后一次目标章节是否变化 +- 若目标已变化,则丢弃不再需要的结果,不更新窗口 +- 只把最后一次目标章节推进到显示链路 + +快速跳章体验要求: + +- 如果用户从目录直接跳到较远章节,例如第 50 章: + - 旧的排队请求可以取消 + - 当前正在构建的章节允许自然完成 + - 完成后立即转向最新目标章节请求 +- UI 必须提供轻量 loading 提示,明确当前正在打开目标章节 + +延迟约束: + +- 单章构建耗时必须可观测 +- 如果快速跳章的等待不可接受,优先优化单章构建耗时 +- 不应先引入危险的强中断机制 + +--- + +## 15. 迁移策略 + +### 15.1 单一路径切换 + +本方案不再维护“新旧两套正文链路并存”。 + +要求: + +- `RDEPUBChapterRuntimeStore + RDEPUBChapterLoader + RDEPUBChapterWindowCoordinator` 组成唯一正文主路径 +- `RDReaderView` 的数据源直接消费窗口快照 +- 位置持久化、翻章、搜索结果定位统一落到章节语义 + +### 15.2 P0 风险控制 + +P0 改成: + +- `P0-1` 建立章节 store 与 loader +- `P0-2` 建立窗口数据源适配 +- `P0-3` 打通打开书、翻章、持久化三条核心链路 +- `P0-4` 验证通过后移除旧整书主路径相关依赖 + +这样可以避免“主设计仍在描述双路径”,让实现和文档保持一致。 + +--- + +## 16. 可直接开发的实施清单 + +### P0:建立章节真值主路径 + +1. 新建 `RDEPUBChapterRuntimeStore` +2. 新建 `RDEPUBChapterLoader` +3. 新建 `RDEPUBChapterWindowSnapshot` +4. 让 `RDEPUBReaderController+DataSource` 直接消费窗口快照 +5. 打通打开书、翻章、位置持久化 + +### P1:建立完整章节缓存运行时 + +1. 实现 `chapterDataCache` +2. 实现 `pageCountCache` +3. 实现 `chapterOffsetMap` +4. 建立 `±1` 预取窗口 +5. 建立内存警告清理 + +### P2:接入二次打开加速层 + +1. 新建 `RDEPUBChapterSummaryDiskCache` +2. 定义 `renderSignature` +3. 定义 `RDEPUBChapterCacheKey` +4. 当前章优先命中轻量摘要层 + +### P3:完成位置与跨章能力迁移 + +1. 新建 `RDEPUBChapterLocation` +2. 实现 legacy 位置转换器 +3. 搜索/书签/高亮改成章节级锚点 +4. `go(toPageNumber:)` 改为章内语义 + +### P4:移除旧整书主路径依赖 + +1. 移除大书场景下的整书 `TextBook` 主路径依赖 +2. 降级 `RDEPUBTextBookCache` +3. 清理 staged/full apply 相关状态 + +--- + +## 17. 验收标准 + +- 大书正文主路径不再依赖整书 `RDEPUBTextBook` +- 当前章与相邻章构成运行时真值 +- 所有章节构建始终串行 +- 缓存读写线程安全规则明确且已落地 +- 参数变化后缓存整体一致失效,不出现页范围错配 +- 内存警告后当前阅读不中断 +- 二次打开能明显减少分页计算时间 +- 旧书签、高亮、阅读位置能够迁移到章节级位置模型 +- 搜索、目录、书签、高亮等跨章能力在无全书索引真值下仍可正常工作 +- **位置迁移降级验收**:`schemaVersion == 1` 的粗估降级结果偏差 ≤ ±15%,且下次打开同一章时必须被精确值覆盖(详见 §19.4.1 粗估降级验收标准) + +--- + +## 18. 结论 + +这份方案的最终边界是: + +- **WXRead 对齐部分** + - 章节级主缓存 + - 串行章节加载 + - `±1` 窗口 + - 当前章优先的内存回收 + - 章节级位置真值 + +- **SDK 增强部分** + - 协调器拆分 + - 类型安全缓存包装 + - 轻量章节摘要磁盘缓存 + - 单一路径分阶段切换 + +只要实现时始终坚持”正文主缓存是章节内存缓存、磁盘层只是辅助加速层”,这条路线就是正确的。 + +--- + +## 19. 伪代码级别落实方案 + +以下按 P0→P4 阶段给出每个核心类型的伪代码,精确到属性、方法签名和关键逻辑分支,可直接作为开发参照。 + +### 19.1 P0:引入章节真值但不破坏旧链路 + +#### 19.1.1 RDEPUBChapterRuntimeStore + +```swift +// ============ 新增文件:RDEPUBChapterRuntimeStore.swift ============ + +final class RDEPUBChapterRuntimeStore { + + // MARK: - 子缓存 + + /// 章节运行时主缓存(等价 WXRead chapterDataCache) + private let chapterDataCache = RDEPUBChapterDataCache() + + /// 轻量分页结构缓存(等价 WXRead pageCountCache) + private let pageCountCache = RDEPUBPageCountCache() + + /// 图片缓存(独立 NSCache,等价 WXRead imageCache) + let imageCache = NSCache() + + /// 串行加载队列(等价 WXRead com.weread.chapterload) + let chapterLoadQueue = DispatchQueue(label: “com.rdreader.chapterload”, qos: .utility) + + // MARK: - 窗口状态 + + /// 当前章 spineIndex + private(set) var currentSpineIndex: Int? + + /// 当前窗口内的 spineIndex 集合(当前 + prev + next) + private(set) var windowSpineIndices: [Int] = [] + + // MARK: - 请求通道(前台导航 vs 后台预取,语义独立,互不抢占) + + /// 前台导航目标(用户主动跳章:目录/书签/搜索/翻章) + /// 仅保留最后一次目标,旧的排队请求可被取消 + private var pendingNavigationTarget: Int? + private let navigationLock = NSLock() + + /// 后台预取目标集合(±1 相邻章预取) + /// 预取不抢占前台导航通道,预取完成后仅刷新快照,不触发跳章 + private var pendingPrefetchTargets: Set = [] + private let prefetchLock = NSLock() + + /// 是否有章节正在构建中 + private(set) var isBuilding: Bool = false + private let buildingLock = NSLock() + + // MARK: - 初始化 + + init() { + imageCache.countLimit = 50 + } + + // MARK: - 缓存查询(线程安全,通过 cache wrapper 的 lock 保护) + + func chapterData(for spineIndex: Int) -> RDEPUBRuntimeChapter? { + return chapterDataCache[spineIndex] + } + + func pageCount(for key: RDEPUBChapterCacheKey) -> RDEPUBRuntimePageCount? { + return pageCountCache[key] + } + + // MARK: - 缓存插入 + + func insertChapter(_ chapter: RDEPUBRuntimeChapter) { + chapterDataCache[spineIndex: chapter.spineIndex] = chapter + } + + func insertPageCount(_ pc: RDEPUBRuntimePageCount, for key: RDEPUBChapterCacheKey) { + pageCountCache[key] = pc + } + + // MARK: - 窗口管理 + + /// 设定当前章,自动计算 ±1 窗口 + func setCurrentChapter(spineIndex: Int, totalSpineCount: Int) { + currentSpineIndex = spineIndex + var window = [spineIndex] + if spineIndex > 0 { window.append(spineIndex - 1) } + if spineIndex < totalSpineCount - 1 { window.append(spineIndex + 1) } + windowSpineIndices = window + } + + /// 返回窗口外、应该淘汰的 spineIndex + func evictableSpineIndices() -> [Int] { + let windowSet = Set(windowSpineIndices) + return chapterDataCache.storedSpineIndices.filter { !windowSet.contains($0) } + } + + // MARK: - 淘汰 + + func evict(spineIndex: Int) { + chapterDataCache.remove(spineIndex: spineIndex) + // 同步淘汰对应的 pageCountCache + pageCountCache.remove(forSpineIndex: spineIndex) + } + + func evictAllExceptCurrent() { + guard let current = currentSpineIndex else { + chapterDataCache.removeAll() + pageCountCache.removeAll() + return + } + let currentChapter = chapterDataCache[current] + chapterDataCache.removeAll() + if let ch = currentChapter { + chapterDataCache[spineIndex: current] = ch + } + // pageCountCache 保守方案:保留当前章的 + let currentPageCounts = pageCountCache.entriesForSpineIndex(current) + pageCountCache.removeAll() + for (key, value) in currentPageCounts { + pageCountCache[key] = value + } + } + + // MARK: - 内存警告 + + func handleMemoryWarning() { + evictAllExceptCurrent() + imageCache.removeAllObjects() + } + + // MARK: - 前台导航请求管理(§14.4 取消语义) + + /// 注册前台导航目标(用户主动跳章时调用) + /// 仅保留最后一次目标,旧的排队请求可被取消 + func setNavigationTarget(spineIndex: Int) { + navigationLock.lock() + pendingNavigationTarget = spineIndex + navigationLock.unlock() + } + + /// 消费前台导航目标(章节构建完成后调用,检查是否有更新的目标) + func consumeNavigationTarget() -> Int? { + navigationLock.lock() + let target = pendingNavigationTarget + pendingNavigationTarget = nil + navigationLock.unlock() + return target + } + + // MARK: - 后台预取请求管理 + + /// 注册后台预取目标(±1 相邻章预取时调用) + /// 预取不抢占前台导航通道 + func addPrefetchTarget(_ spineIndex: Int) { + prefetchLock.lock() + pendingPrefetchTargets.insert(spineIndex) + prefetchLock.unlock() + } + + /// 标记预取目标已完成 + func removePrefetchTarget(_ spineIndex: Int) { + prefetchLock.lock() + pendingPrefetchTargets.remove(spineIndex) + prefetchLock.unlock() + } + + /// 清空所有预取目标(切章时调用,旧预取结果不再需要) + func clearPrefetchTargets() { + prefetchLock.lock() + pendingPrefetchTargets.removeAll() + prefetchLock.unlock() + } + + /// 检查是否有待处理的预取目标 + func hasPrefetchTarget(_ spineIndex: Int) -> Bool { + prefetchLock.lock() + let has = pendingPrefetchTargets.contains(spineIndex) + prefetchLock.unlock() + return has + } + + func markBuilding(_ building: Bool) { + buildingLock.lock() + isBuilding = building + buildingLock.unlock() + } +} +``` + +#### 19.1.2 RDEPUBChapterDataCache / RDEPUBPageCountCache + +```swift +// ============ 新增文件:RDEPUBChapterDataCache.swift ============ + +final class RDEPUBChapterDataCache { + private var storage: [Int: RDEPUBRuntimeChapter] = [:] + private let lock = NSLock() + + subscript(spineIndex: Int) -> RDEPUBRuntimeChapter? { + get { + lock.lock() + defer { lock.unlock() } + return storage[spineIndex] + } + set { + lock.lock() + defer { lock.unlock() } + storage[spineIndex] = newValue + } + } + + var storedSpineIndices: [Int] { + lock.lock() + defer { lock.unlock() } + return Array(storage.keys) + } + + func remove(spineIndex: Int) { + lock.lock() + defer { lock.unlock() } + storage.removeValue(forKey: spineIndex) + } + + func removeAll() { + lock.lock() + defer { lock.unlock() } + storage.removeAll() + } +} + +// ============ 新增文件:RDEPUBPageCountCache.swift ============ + +final class RDEPUBPageCountCache { + private var storage: [RDEPUBChapterCacheKey: RDEPUBRuntimePageCount] = [:] + private let lock = NSLock() + + subscript(key: RDEPUBChapterCacheKey) -> RDEPUBRuntimePageCount? { + get { + lock.lock() + defer { lock.unlock() } + return storage[key] + } + set { + lock.lock() + defer { lock.unlock() } + storage[key] = newValue + } + } + + func entriesForSpineIndex(_ spineIndex: Int) -> [(RDEPUBChapterCacheKey, RDEPUBRuntimePageCount)] { + lock.lock() + defer { lock.unlock() } + return storage.filter { $0.value.spineIndex == spineIndex }.map { ($0.key, $0.value) } + } + + func remove(forSpineIndex spineIndex: Int) { + lock.lock() + defer { lock.unlock() } + storage = storage.filter { $0.value.spineIndex != spineIndex } + } + + func removeAll() { + lock.lock() + defer { lock.unlock() } + storage.removeAll() + } +} +``` + +#### 19.1.3 RDEPUBRuntimeChapter + +```swift +// ============ 新增文件:RDEPUBRuntimeChapter.swift ============ + +final class RDEPUBRuntimeChapter { + let spineIndex: Int + let href: String + let title: String + + /// 原始富文本(可按策略释放,不强制常驻) + var sourceAttributedString: NSAttributedString? + + /// 排版后富文本 + let typesetAttributedString: NSAttributedString + + /// 排版器 + let layouter: RDEPUBTextLayouter + + /// 页范围 + let pageRanges: [NSRange] + + /// 页面数组 + let pages: [RDEPUBTextPage] + + /// 章节偏移映射 + let chapterOffsetMap: RDEPUBChapterOffsetMap + + init( + spineIndex: Int, + href: String, + title: String, + sourceAttributedString: NSAttributedString?, + typesetAttributedString: NSAttributedString, + layouter: RDEPUBTextLayouter, + pageRanges: [NSRange], + pages: [RDEPUBTextPage], + chapterOffsetMap: RDEPUBChapterOffsetMap + ) { + self.spineIndex = spineIndex + self.href = href + self.title = title + self.sourceAttributedString = sourceAttributedString + self.typesetAttributedString = typesetAttributedString + self.layouter = layouter + self.pageRanges = pageRanges + self.pages = pages + self.chapterOffsetMap = chapterOffsetMap + } + + /// 释放 sourceAttributedString 以降低内存 + func releaseSourceText() { + sourceAttributedString = nil + } +} +``` + +#### 19.1.4 RDEPUBChapterOffsetMap / RDEPUBRuntimePageCount / RDEPUBChapterCacheKey / RDEPUBChapterLocation + +```swift +// ============ 新增文件:RDEPUBChapterOffsetMap.swift ============ + +struct RDEPUBChapterOffsetMap { + let fragmentOffsets: [String: Int] + let pageStartOffsets: [Int] + let pageEndOffsets: [Int] + + /// fragmentID -> 章内字符偏移 + func chapterOffset(forFragmentID fragmentID: String) -> Int? { + return fragmentOffsets[fragmentID] + } + + /// 章内字符偏移 -> 章内页码(从 0 开始) + func pageIndex(forChapterOffset offset: Int) -> Int? { + for i in 0..= pageStartOffsets[i] && offset <= pageEndOffsets[i] { + return i + } + } + return nil + } +} + +// ============ 新增文件:RDEPUBRuntimePageCount.swift ============ + +struct RDEPUBRuntimePageCount { + let cacheKey: RDEPUBChapterCacheKey + let spineIndex: Int + let pageRanges: [NSRange] + let pageCount: Int + let renderSignature: String +} + +// ============ 新增文件:RDEPUBChapterCacheKey.swift ============ + +struct RDEPUBChapterCacheKey: Hashable { + let bookID: String + let spineIndex: Int + let renderSignature: String + let chapterContentHash: String +} + +// ============ 新增文件:RDEPUBChapterLocation.swift ============ + +public struct RDEPUBChapterLocation: Codable { + public var spineIndex: Int + public var chapterOffset: Int + public var fragmentID: String? + public var progressionInChapter: Double? + public var schemaVersion: Int = 2 // v1 = 旧全局模型, v2 = 章节模型 +} +``` + +#### 19.1.5 RDEPUBChapterLoader + +```swift +// ============ 新增文件:RDEPUBChapterLoader.swift ============ + +final class RDEPUBChapterLoader { + private unowned let context: RDEPUBReaderContext + private var summaryDiskCache: RDEPUBChapterSummaryDiskCache? + + init(context: RDEPUBReaderContext) {} + + func setSummaryDiskCache(_ cache: RDEPUBChapterSummaryDiskCache) { + summaryDiskCache = cache + } + + // MARK: - 主入口:加载单个章节 + + /// 请求优先级 + enum LoadPriority { + case navigation // 前台导航:用户主动跳章,完成后检查导航目标队列 + case prefetch // 后台预取:±1 相邻章,完成后仅回填缓存 + 刷新快照 + } + + /// 在 chapterLoadQueue 上构建单章,完成后回调到主线程 + func loadChapter( + spineIndex: Int, + store: RDEPUBChapterRuntimeStore, + priority: LoadPriority = .navigation, + completion: @escaping (Result) -> Void + ) { + // 1. 查内存缓存(统一回主线程,保证 completion 线程语义一致) + if let cached = store.chapterData(for: spineIndex) { + DispatchQueue.main.async { + completion(.success(cached)) + } + return + } + + // 2. 构建缓存键 + let cacheKey = makeCacheKey(spineIndex: spineIndex) + + // 3. 查内存级 pageCountCache(轻量,无磁盘 I/O) + let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges + + // 4. 全部后续操作(含磁盘 I/O)统一放到串行队列 + // 避免磁盘读取阻塞主线程 + store.markBuilding(true) + store.chapterLoadQueue.async { + // 4a. 仅当内存级 pageCountCache 未命中时才查磁盘摘要 + // pageCountCache 命中 → 已有 pageRanges,不需要磁盘 I/O + // pageCountCache 未命中 → 查磁盘拿 pageRanges + metadata + let diskSummary: RDEPUBChapterSummary? + if precomputedPageRanges == nil { + diskSummary = self.summaryDiskCache?.read(for: cacheKey) + } else { + diskSummary = nil + } + let diskPageRanges = diskSummary?.pageRanges.map { $0.nsRange } + let availablePageRanges = precomputedPageRanges ?? diskPageRanges + + do { + let chapter = try self.buildChapter( + spineIndex: spineIndex, + availablePageRanges: availablePageRanges, + diskSummary: diskSummary + ) + + // 5. 回填缓存 + store.insertChapter(chapter) + let pc = RDEPUBRuntimePageCount( + cacheKey: cacheKey, + spineIndex: spineIndex, + pageRanges: chapter.pageRanges, + pageCount: chapter.pages.count, + renderSignature: cacheKey.renderSignature + ) + store.insertPageCount(pc, for: cacheKey) + + // 6. 按优先级处理完成逻辑 + switch priority { + case .navigation: + // 前台导航:检查是否有更新的导航目标(§14.4 取消语义) + let nextTarget = store.consumeNavigationTarget() + if let target = nextTarget, target != spineIndex { + // 当前结果不再是用户目标,丢弃,转而加载新目标 + store.markBuilding(false) + self.loadChapter(spineIndex: target, store: store, priority: .navigation, completion: completion) + return + } + store.markBuilding(false) + DispatchQueue.main.async { + completion(.success(chapter)) + } + + case .prefetch: + // 后台预取:仅回填缓存,标记预取目标完成 + // 不触发跳章,不检查导航目标队列 + store.removePrefetchTarget(spineIndex) + store.markBuilding(false) + DispatchQueue.main.async { + completion(.success(chapter)) + } + } + } catch { + store.markBuilding(false) + DispatchQueue.main.async { + completion(.failure(error)) + } + } + } + } + + // MARK: - 单章构建(支持轻量缓存命中后跳过分页) + + private func buildChapter( + spineIndex: Int, + availablePageRanges: [NSRange]?, + diskSummary: RDEPUBChapterSummary? = nil + ) throws -> RDEPUBRuntimeChapter { + guard let parser = context.parser, + let publication = context.publication else { + throw RDEPUBChapterLoadError.missingParser + } + + let pageSize = context.currentTextPageSize() + let style = context.currentTextRenderStyle() + let layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize) + + if let pageRanges = availablePageRanges { + // ---- 轻量路径:pageCountCache 或 chapterSummaryDiskCache 命中 ---- + // 只需渲染 HTML → NSAttributedString,跳过完整分页计算 + return try buildChapterFromCachedPageRanges( + spineIndex: spineIndex, + pageRanges: pageRanges, + parser: parser, + publication: publication, + pageSize: pageSize, + style: style, + layoutConfig: layoutConfig, + diskSummary: diskSummary + ) + } + + // ---- 完整路径:无缓存,走全量渲染 + 分页 ---- + let builder = context.makeTextBookBuilder(layoutConfig: layoutConfig) + guard let result = try builder.buildChapter( + parser: parser, + publication: publication, + spineIndex: spineIndex, + pageSize: pageSize, + style: style + ) else { + throw RDEPUBChapterLoadError.emptyChapter(spineIndex: spineIndex) + } + + return try assembleRuntimeChapter( + from: result.chapter, + spineIndex: spineIndex, + pageSize: pageSize, + layoutConfig: layoutConfig + ) + } + + // MARK: - 轻量路径:复用已有 pageRanges,跳过完整分页 + + private func buildChapterFromCachedPageRanges( + spineIndex: Int, + pageRanges: [NSRange], + parser: RDEPUBParser, + publication: RDEPUBPublication, + pageSize: CGSize, + style: RDEPUBTextRenderStyle, + layoutConfig: RDEPUBTextLayoutConfig, + diskSummary: RDEPUBChapterSummary? = nil + ) throws -> RDEPUBRuntimeChapter { + let spineItem = publication.spine[spineIndex] + let href = spineItem.href + let title = spineItem.title ?? "" + let baseURL = parser.baseURL(for: href) + + // 1. 只做 HTML → NSAttributedString 渲染,不做分页 + let request = RDEPUBTextRendererSupport.makeChapterRenderRequest( + href: href, + title: title, + rawHTML: try parser.htmlContent(for: href), + baseURL: baseURL, + style: style, + pageSize: pageSize, + layoutConfig: layoutConfig + ) + let renderer = context.resolvedTextRenderer() + let rendered = try renderer.renderChapter(request: request) + + let typesetString = NSMutableAttributedString(attributedString: rendered.attributedString) + RDEPUBTextRendererSupport.normalizeReadingAttributes( + in: typesetString, style: style, layoutConfig: layoutConfig + ) + + // 2. metadata 来源策略: + // - diskSummary 非空(磁盘路径命中):从摘要恢复完整 metadata + // - diskSummary 为空(pageCountCache 命中但没走磁盘):从 attributedString 属性推断 + // 不再在轻量路径内做额外磁盘 I/O + let metadataSource = diskSummary?.pageMetadataList + + // 3. 直接用缓存的 pageRanges 构建 pages(跳过 CoreText 分页) + let pages = buildPagesFromRanges( + pageRanges: pageRanges, + typesetString: typesetString, + spineIndex: spineIndex, + href: href, + title: title, + metadataSource: metadataSource + ) + + // 3. 构建 layouter(用于后续可能的重新分页场景) + let layouter = RDEPUBTextLayouter( + attributedString: typesetString, + pageSize: pageSize, + config: layoutConfig + ) + + // 4. 构建 chapterOffsetMap + let offsetMap = RDEPUBChapterOffsetMap( + fragmentOffsets: rendered.fragmentOffsets, + pageStartOffsets: pages.map { $0.pageStartOffset }, + pageEndOffsets: pages.map { $0.pageEndOffset } + ) + + return RDEPUBRuntimeChapter( + spineIndex: spineIndex, + href: href, + title: title, + // 轻量路径特殊语义:sourceAttributedString 此时不是"HTML 原始文本", + // 而是"经过 normalizeReadingAttributes 归一化后的当前排版文本", + // 与 typesetAttributedString 相同。如果后续需要重新排版(如字号变化), + // 需要从 HTML 重新渲染,不能依赖此字段作为 source。 + sourceAttributedString: nil, // 轻量路径不保留原始 source,降低内存 + typesetAttributedString: typesetString, + layouter: layouter, + pageRanges: pageRanges, + pages: pages, + chapterOffsetMap: offsetMap + ) + } + + /// 从缓存的 pageRanges 直接构建 RDEPUBTextPage 数组 + /// metadataSource: 轻量摘要中的页元数据;为 nil 时从 attributedString 属性推断 + private func buildPagesFromRanges( + pageRanges: [NSRange], + typesetString: NSAttributedString, + spineIndex: Int, + href: String, + title: String, + metadataSource: [RDEPUBChapterSummary.PageMetadataSummary]? = nil + ) -> [RDEPUBTextPage] { + let totalPageCount = pageRanges.count + return pageRanges.enumerated().map { (pageIndex, range) in + let pageContent = typesetString.attributedSubstring(from: range) + let metadata: RDEPUBTextPageMetadata + if let metaList = metadataSource, pageIndex < metaList.count { + // 从摘要缓存恢复完整 metadata + metadata = metaList[pageIndex].toPageMetadata() + } else { + // 无缓存 metadata,从 attributedString 属性推断 + metadata = inferPageMetadata( + from: typesetString, + range: range, + isLastPage: pageIndex == totalPageCount - 1 + ) + } + return RDEPUBTextPage( + absolutePageIndex: -1, // 全书绝对页码:章节模式下不赋值,保持 -1;由上层按需回填 + windowPageIndex: 0, // 窗口内页码:在 RDEPUBChapterWindowSnapshot.from() 中重新编号 + chapterIndex: 0, // 所属章节在窗口 chapters 中的索引:在快照构建时重设 + spineIndex: spineIndex, + href: href, + chapterTitle: title, + pageIndexInChapter: pageIndex, + totalPagesInChapter: totalPageCount, + chapterContent: typesetString, + content: pageContent, + contentRange: range, + pageStartOffset: range.location, + pageEndOffset: range.location + range.length - 1, + metadata: metadata + ) + } + } + + /// 从 attributedString 的自定义属性推断页 metadata + /// 复用分页阶段注入的 rdPageBlockKind / rdPageAttachmentKind 等属性 + private func inferPageMetadata( + from string: NSAttributedString, + range: NSRange, + isLastPage: Bool + ) -> RDEPUBTextPageMetadata { + // 从自定义属性中提取分页语义信息 + var attachmentRanges: [NSRange] = [] + var attachmentKinds: [RDEPUBTextAttachmentKind] = [] + var blockKinds: [RDEPUBTextBlockKind] = [] + var semanticHints: [RDEPUBTextSemanticHint] = [] + var attachmentPlacements: [RDEPUBTextAttachmentPlacement] = [] + var trailingFragmentID: String? = nil + + string.enumerateAttribute(.rdPageAttachmentKind, in: range, options: []) { value, attrRange, _ in + if let kind = value as? RDEPUBTextAttachmentKind { + attachmentRanges.append(attrRange) + attachmentKinds.append(kind) + } + } + string.enumerateAttribute(.rdPageBlockKind, in: range, options: []) { value, _, _ in + if let kind = value as? RDEPUBTextBlockKind, !blockKinds.contains(kind) { + blockKinds.append(kind) + } + } + string.enumerateAttribute(.rdPageSemanticHints, in: range, options: []) { value, _, _ in + if let hints = value as? [RDEPUBTextSemanticHint] { + for hint in hints where !semanticHints.contains(hint) { + semanticHints.append(hint) + } + } + } + string.enumerateAttribute(.rdPageAttachmentPlacement, in: range, options: []) { value, _, _ in + if let placement = value as? RDEPUBTextAttachmentPlacement, !attachmentPlacements.contains(placement) { + attachmentPlacements.append(placement) + } + } + string.enumerateAttribute(.rdPageFragmentID, in: range, options: [.reverse]) { value, _, stop in + if let fid = value as? String { + trailingFragmentID = fid + stop.pointee = true + } + } + + return RDEPUBTextPageMetadata( + breakReason: isLastPage ? .chapterEnd : .frameLimit, + blockRange: nil, + attachmentRanges: attachmentRanges, + attachmentKinds: attachmentKinds, + blockKinds: blockKinds, + semanticHints: semanticHints, + attachmentPlacements: attachmentPlacements, + trailingFragmentID: trailingFragmentID, + diagnostics: [] + ) + } + + /// 从完整构建结果组装 RDEPUBRuntimeChapter + private func assembleRuntimeChapter( + from chapter: RDEPUBTextChapter, + spineIndex: Int, + pageSize: CGSize, + layoutConfig: RDEPUBTextLayoutConfig + ) throws -> RDEPUBRuntimeChapter { + let layouter = RDEPUBTextLayouter( + attributedString: chapter.attributedContent, + pageSize: pageSize, + config: layoutConfig + ) + + let offsetMap = RDEPUBChapterOffsetMap( + fragmentOffsets: chapter.fragmentOffsets, + pageStartOffsets: chapter.pages.map { $0.pageStartOffset }, + pageEndOffsets: chapter.pages.map { $0.pageEndOffset } + ) + + let pageRanges = chapter.pages.map { $0.contentRange } + + // 回填磁盘摘要(P2 阶段生效) + let cacheKey = makeCacheKey(spineIndex: spineIndex) + let summary = RDEPUBChapterSummary( + pageRanges: pageRanges.map { .init(location: $0.location, length: $0.length) }, + pageCount: chapter.pages.count, + fragmentOffsets: chapter.fragmentOffsets, + renderSignature: cacheKey.renderSignature, + schemaVersion: 6, + chapterContentHash: cacheKey.chapterContentHash, + pageMetadataList: chapter.pages.map { .from($0.metadata) } + ) + summaryDiskCache?.write(summary: summary, for: cacheKey) + + return RDEPUBRuntimeChapter( + spineIndex: spineIndex, + href: chapter.href, + title: chapter.title, + sourceAttributedString: chapter.attributedContent, + typesetAttributedString: chapter.attributedContent, + layouter: layouter, + pageRanges: pageRanges, + pages: chapter.pages, + chapterOffsetMap: offsetMap + ) + } + + // MARK: - 缓存键 + + private func makeCacheKey(spineIndex: Int) -> RDEPUBChapterCacheKey { + let style = context.currentTextRenderStyle() + let layoutConfig = context.currentTextLayoutConfig(pageSize: context.currentTextPageSize()) + let pageSize = context.currentTextPageSize() + + // renderSignature 必须覆盖 §8.2 定义的全部参数 + // 任何排版参数变化都必须导致 key 不同,从而自然失效旧缓存 + // 注意:直接使用配置层的 lineHeightMultiple 参数值,不通过换算派生 + // 避免换算关系变更导致 key 与文档定义漂移 + let lineHeightMultiple = style.lineHeightMultiple + ?? (style.font.lineHeight + style.lineSpacing) / style.font.lineHeight + + let renderSignature = [ + style.font.fontName, // fontName + “\(style.font.pointSize)”, // fontSize + “\(lineHeightMultiple)”, // lineHeightMultiple(直接编码,不换算) + “\(layoutConfig.edgeInsets)”, // contentInsets + “\(pageSize.width)x\(pageSize.height)”, // pageSize + layoutConfig.cacheSignature, // layoutConfigSignature + “\(6)” // schemaVersion + ].joined(separator: “|”) + + let contentHash = contentHashForSpineIndex(spineIndex) + + return RDEPUBChapterCacheKey( + bookID: context.currentBookIdentifier ?? “”, + spineIndex: spineIndex, + renderSignature: renderSignature, + chapterContentHash: contentHash + ) + } + + private func contentHashForSpineIndex(_ spineIndex: Int) -> String { + guard let parser = context.parser, + let publication = context.publication else { return “” } + let href = publication.spine[spineIndex].href + let html = try? parser.htmlContent(for: href) + return html?.sha256Prefix ?? “” + } +} + +enum RDEPUBChapterLoadError: Error { + case missingParser + case emptyChapter(spineIndex: Int) +} +``` + +#### 19.1.6 RDEPUBChapterWindowSnapshot + +```swift +// ============ 新增文件:RDEPUBChapterWindowSnapshot.swift ============ + +struct RDEPUBChapterWindowSnapshot { + /// 窗口中的章节(有序:prev, current, next) + let chapters: [RDEPUBRuntimeChapter] + + /// 展平后的连续页数组(供 RDReaderView 消费) + /// 每页携带独立的 windowPageIndex(窗口内连续编号), + /// 与 RDEPUBTextPage.absolutePageIndex(全书绝对页码)严格区分。 + let flattenedPages: [RDEPUBTextPage] + + /// 当前章在 chapters 数组中的索引 + let anchorChapterIndex: Int + + /// 当前章在 flattenedPages 中的起始页码(从 0 开始,窗口内编号) + let anchorPageOffset: Int + + /// 当前窗口首章的 spineIndex,用于调试日志和跨窗口映射 + let windowStartSpineIndex: Int + + // MARK: - 构建 + + /// 从章节窗口构建快照 + static func from( + currentChapter: RDEPUBRuntimeChapter, + previousChapter: RDEPUBRuntimeChapter?, + nextChapter: RDEPUBRuntimeChapter? + ) -> RDEPUBChapterWindowSnapshot { + var chapters: [RDEPUBRuntimeChapter] = [] + var anchorIndex = 0 + var pageOffset = 0 + + if let prev = previousChapter { + chapters.append(prev) + anchorIndex = 1 + pageOffset = prev.pages.count + } + + chapters.append(currentChapter) + + if let next = nextChapter { + chapters.append(next) + } + + // 展平页数组,编号规则: + // windowPageIndex — 窗口内连续编号(从 0 开始),供 RDReaderView 消费 + // chapterIndex — 当前页所属章节在 chapters 数组中的索引 + // absolutePageIndex — 保持原值不动(全书绝对页码,章节模式下通常为 -1 或由上层按需赋值) + // 这三个编号语义严格独立,不可混用。 + var allPages: [RDEPUBTextPage] = [] + var windowPageIdx = 0 + for (chIdx, ch) in chapters.enumerated() { + for var page in ch.pages { + page.windowPageIndex = windowPageIdx + page.chapterIndex = chIdx + // absolutePageIndex 不在这里赋值,保持章节构建时的原始值 + allPages.append(page) + windowPageIdx += 1 + } + } + + let windowStartSpineIndex = chapters.first?.spineIndex ?? currentChapter.spineIndex + + return RDEPUBChapterWindowSnapshot( + chapters: chapters, + flattenedPages: allPages, + anchorChapterIndex: anchorIndex, + anchorPageOffset: pageOffset, + windowStartSpineIndex: windowStartSpineIndex + ) + } + + // MARK: - 查询 + + /// 窗口内页码(windowPageIndex)-> 所属章节 + func chapterForPage(windowPageIndex: Int) -> RDEPUBRuntimeChapter? { + var offset = 0 + for ch in chapters { + if windowPageIndex < offset + ch.pages.count { + return ch + } + offset += ch.pages.count + } + return nil + } + + /// 窗口内页码(windowPageIndex)-> 所属章节的 spineIndex + func spineIndexForPage(windowPageIndex: Int) -> Int? { + return chapterForPage(windowPageIndex: windowPageIndex)?.spineIndex + } + + /// 总页数(窗口内) + var pageCount: Int { flattenedPages.count } +} +``` + +#### 19.1.7 RDEPUBChapterWindowCoordinator + +```swift +// ============ 新增文件:RDEPUBChapterWindowCoordinator.swift ============ + +final class RDEPUBChapterWindowCoordinator { + private unowned let context: RDEPUBReaderContext + private let store: RDEPUBChapterRuntimeStore + private let loader: RDEPUBChapterLoader + + /// 当前窗口快照 + private(set) var currentSnapshot: RDEPUBChapterWindowSnapshot? + + /// 窗口切换回调 + var onSnapshotChanged: ((RDEPUBChapterWindowSnapshot) -> Void)? + + init(context: RDEPUBReaderContext, store: RDEPUBChapterRuntimeStore, loader: RDEPUBChapterLoader) { + self.context = context + self.store = store + self.loader = loader + } + + // MARK: - 打开书籍 + + func openBook(at targetSpineIndex: Int) { + let totalSpineCount = context.publication?.spine.count ?? 0 + store.setCurrentChapter(spineIndex: targetSpineIndex, totalSpineCount: totalSpineCount) + + // 标记切章进行中(与 flipToChapter 一致,阻止预取回调在构建期间刷新快照) + isSwitchingChapter = true + + // 注册前台导航目标 + store.setNavigationTarget(spineIndex: targetSpineIndex) + // 清空旧预取目标(打开新书时旧预取不再需要) + store.clearPrefetchTargets() + + // 加载目标章(前台导航优先级) + loader.loadChapter(spineIndex: targetSpineIndex, store: store, priority: .navigation) { [weak self] result in + guard let self = self else { return } + self.isSwitchingChapter = false + switch result { + case .success(let chapter): + self.buildSnapshotAroundCurrent(chapter: chapter) + case .failure(let error): + self.context.handle(error: error) + } + } + } + + // MARK: - 构建窗口快照 + + private func buildSnapshotAroundCurrent(chapter: RDEPUBRuntimeChapter) { + guard let current = store.currentSpineIndex else { return } + let prev = current > 0 ? store.chapterData(for: current - 1) : nil + let next = store.chapterData(for: current + 1) + + let snapshot = RDEPUBChapterWindowSnapshot.from( + currentChapter: chapter, + previousChapter: prev, + nextChapter: next + ) + currentSnapshot = snapshot + onSnapshotChanged?(snapshot) + + // 预取 ±1 + prefetchAdjacent(current: current) + } + + // MARK: - 预取(后台优先级,不抢占前台导航通道) + + private func prefetchAdjacent(current: Int) { + let totalSpineCount = context.publication?.spine.count ?? 0 + + // 预取 prev(后台优先级) + if current > 0 && store.chapterData(for: current - 1) == nil { + let prevIndex = current - 1 + store.addPrefetchTarget(prevIndex) + loader.loadChapter(spineIndex: prevIndex, store: store, priority: .prefetch) { [weak self] result in + guard let self = self, case .success = result else { return } + // 预取成功后刷新窗口快照,使相邻章纳入 RDReaderView 消费范围 + // 仅在阅读器空闲时刷新,不触发跳章 + self.refreshSnapshot() + } + } + + // 预取 next(后台优先级) + if current < totalSpineCount - 1 && store.chapterData(for: current + 1) == nil { + let nextIndex = current + 1 + store.addPrefetchTarget(nextIndex) + loader.loadChapter(spineIndex: nextIndex, store: store, priority: .prefetch) { [weak self] result in + guard let self = self, case .success = result else { return } + self.refreshSnapshot() + } + } + } + + // MARK: - 翻章 + + /// 到达章末,翻到下一章 + func flipToNextChapter(completion: @escaping (Result) -> Void) { + guard let current = store.currentSpineIndex else { return } + let next = current + 1 + let totalSpineCount = context.publication?.spine.count ?? 0 + guard next < totalSpineCount else { return } + + flipToChapter(spineIndex: next, completion: completion) + } + + /// 到达章首,翻到上一章 + func flipToPreviousChapter(completion: @escaping (Result) -> Void) { + guard let current = store.currentSpineIndex, current > 0 else { return } + flipToChapter(spineIndex: current - 1, completion: completion) + } + + /// 跳转到指定章节(目录/书签/搜索) + func flipToChapter( + spineIndex: Int, + completion: @escaping (Result) -> Void + ) { + let totalSpineCount = context.publication?.spine.count ?? 0 + + // 注册前台导航目标(§14.4 取消语义:排队请求只保留最后一次) + store.setNavigationTarget(spineIndex: spineIndex) + // 清空后台预取目标(切章时旧预取结果不再需要) + store.clearPrefetchTargets() + // 标记切章进行中(阻止预取回调在切章期间刷新快照) + isSwitchingChapter = true + + // 先淘汰旧窗口外章节 + store.setCurrentChapter(spineIndex: spineIndex, totalSpineCount: totalSpineCount) + let evictable = store.evictableSpineIndices() + for idx in evictable { + store.evict(spineIndex: idx) + } + + // 如果目标章已在缓存中,直接构建快照 + if let cached = store.chapterData(for: spineIndex) { + buildSnapshotAroundCurrent(chapter: cached) + isSwitchingChapter = false + if let snap = currentSnapshot { + completion(.success(snap)) + } + return + } + + // 未命中缓存,走加载链路(前台导航优先级) + loader.loadChapter(spineIndex: spineIndex, store: store, priority: .navigation) { [weak self] result in + guard let self = self else { return } + self.isSwitchingChapter = false + switch result { + case .success(let chapter): + self.buildSnapshotAroundCurrent(chapter: chapter) + if let snap = self.currentSnapshot { + completion(.success(snap)) + } + case .failure(let error): + completion(.failure(error)) + } + } + } + + // MARK: - 刷新快照(预取完成后调用,把新的相邻章纳入快照) + + /// 预取成功后刷新窗口快照 + /// 硬性约束:仅在阅读器完全空闲时才允许刷新快照 + /// 空闲定义 = 非构建中 + 非切章中 + readerView 无翻页动画 + 无人机交互进行中 + /// 不满足条件时延后重试,绝不打断当前阅读状态 + func refreshSnapshot() { + guard let current = store.currentSpineIndex, + let currentChapter = store.chapterData(for: current) else { return } + + // 空闲门槛检查 + guard isReaderIdle() else { + // 推迟到空闲后再刷新 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in + self?.refreshSnapshot() + } + return + } + + let prev = current > 0 ? store.chapterData(for: current - 1) : nil + let next = store.chapterData(for: current + 1) + + // 只在快照内容确实变化时才更新和通知 + let newSnapshot = RDEPUBChapterWindowSnapshot.from( + currentChapter: currentChapter, + previousChapter: prev, + nextChapter: next + ) + + if snapshotContentChanged(old: currentSnapshot, new: newSnapshot) { + currentSnapshot = newSnapshot + onSnapshotChanged?(newSnapshot) + } + } + + /// 判断新快照是否与旧快照内容不同(避免无变化时触发不必要的 UI 刷新) + /// 判定条件(满足任一即认为变化): + /// 1. 章节数量不同 + /// 2. 章节 spineIndex 序列不同(相邻章换了) + /// 3. 总页数不同 + /// 4. 任一章的页数不同(排版参数变化导致页重排) + /// 5. 锚点章在窗口中的位置不同 + /// 仅比较"章节数量 + 总页数"不够:相邻章换了但页数巧合相同时会漏检, + /// 导致窗口边界内容陈旧。 + private func snapshotContentChanged( + old: RDEPUBChapterWindowSnapshot?, + new: RDEPUBChapterWindowSnapshot + ) -> Bool { + guard let old = old else { return true } + + // 1. 章节数量不同 + if old.chapters.count != new.chapters.count { return true } + + // 2. spineIndex 序列不同(相邻章换了) + let oldSpines = old.chapters.map { $0.spineIndex } + let newSpines = new.chapters.map { $0.spineIndex } + if oldSpines != newSpines { return true } + + // 3. 总页数不同 + if old.pageCount != new.pageCount { return true } + + // 4. 任一章的页数不同 + for (oldCh, newCh) in zip(old.chapters, new.chapters) { + if oldCh.pages.count != newCh.pages.count { return true } + } + + // 5. 锚点位置不同 + if old.anchorChapterIndex != new.anchorChapterIndex + || old.anchorPageOffset != new.anchorPageOffset { return true } + + return false + } + + /// 空闲判断:必须全部满足才允许刷新快照 + private func isReaderIdle() -> Bool { + // 1. 没有章节正在后台构建 + guard !store.isBuilding else { return false } + // 2. 没有切章操作正在进行 + guard !isSwitchingChapter else { return false } + // 3. readerView 没有正在执行的翻页动画 + // CATransaction 仍在执行时说明页面切换动画未完成 + if let readerView = context.readerView { + guard !readerView.isAnimating else { return false } + } + return true + } + + /// 切章进行中标记(flipToChapter 开始设 true,completion 回调后设 false) + private var isSwitchingChapter: Bool = false +} +``` + +#### 19.1.8 DataSource 适配(窗口快照直连) + +> **作用域**:以下 DataSource 改造仅适用于 `textReflowable` 路径。`RDEPUBReaderController` 需在初始化时根据书籍类型选择 DataSource 策略:textReflowable 走窗口快照路径,其他类型(fixed-layout、PDF 等)维持原有整书 `pages` 数组供页。 + +```swift +// ============ 修改文件:RDEPUBReaderController+DataSource.swift ============ + +// 重要:此扩展仅在 textReflowable 路径启用。 +// RDEPUBReaderController 初始化时需判断书籍类型: +// if publication.metadata.layout == .reflowable { +// setupChapterWindowDataSource() // 走本扩展 +// } else { +// setupLegacyBookDataSource() // 走原有整书 pages 路径 +// } + +extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate { + + public func pageCountOfReaderView(readerView: RDReaderView) -> Int { + guard let snapshot = windowCoordinator?.currentSnapshot else { return 0 } + return snapshot.pageCount + } + + public func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView { + guard let snapshot = windowCoordinator?.currentSnapshot else { + return RDEPUBTextContentView() + } + return textContentViewFromSnapshot(snapshot, pageNum: pageNum, containerView: containerView) + } + + // MARK: - 页面内容构建 + + private func textContentViewFromSnapshot( + _ snapshot: RDEPUBChapterWindowSnapshot, + pageNum: Int, + containerView: UIView? + ) -> UIView { + guard pageNum >= 0, pageNum < snapshot.flattenedPages.count else { + return RDEPUBTextContentView() // 空 page + } + let page = snapshot.flattenedPages[pageNum] + let contentView = RDEPUBTextContentView() + contentView.configure(with: page, highlights: highlightsForPage(page)) + return contentView + } + + // MARK: - 翻章检测 + + public func pageNum(readerView: RDReaderView, pageNum: Int) { + handleChapterAwarePageChange(pageNum: pageNum) + } + + /// 检测是否到达章节边界,触发翻章 + private func handleChapterAwarePageChange(pageNum: Int) { + guard let snapshot = windowCoordinator?.currentSnapshot else { return } + + // 到达窗口末尾 → 翻到下一章 + if pageNum >= snapshot.pageCount - 1 { + windowCoordinator?.flipToNextChapter { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let newSnapshot): + self.applyNewSnapshot(newSnapshot, landing: .chapterStart) + case .failure: + break // 停在当前页 + } + } + return + } + + // 到达窗口开头 → 翻到上一章 + if pageNum <= 0 { + windowCoordinator?.flipToPreviousChapter { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let newSnapshot): + self.applyNewSnapshot(newSnapshot, landing: .chapterEnd) + case .failure: + break + } + } + return + } + + // 普通页移动:当前位置已稳定,可直接持久化 + persistCurrentChapterLocation(pageNum: pageNum) + } + + /// 应用新窗口快照 + private enum ChapterLanding { + case chapterStart + case chapterEnd + } + + private func applyNewSnapshot(_ snapshot: RDEPUBChapterWindowSnapshot, landing: ChapterLanding) { + // 通知 RDReaderView 刷新数据源 + readerView?.reloadData() + + // 跳到新章的正确位置 + let targetPage: Int + switch landing { + case .chapterStart: + targetPage = snapshot.anchorPageOffset + case .chapterEnd: + let currentChapter = snapshot.chapters[snapshot.anchorChapterIndex] + targetPage = snapshot.anchorPageOffset + currentChapter.pages.count - 1 + } + + readerView?.transitionToPage(pageNum: targetPage, animated: false) + + // 位置持久化必须发生在新快照和新页码确定之后,避免把旧边界页写回去 + persistCurrentChapterLocation(pageNum: targetPage) + } +} +``` + +#### 19.1.9 Context 和 Runtime 挂入 + +```swift +// ============ 修改文件:RDEPUBReaderContext.swift ============ + +final class RDEPUBReaderContext { + // ... 保留所有现有属性 ... + + // MARK: - 新增章节运行时 + + /// 章节运行时缓存 + var chapterRuntimeStore: RDEPUBChapterRuntimeStore? + + /// 章节加载器 + var chapterLoader: RDEPUBChapterLoader? + + /// 窗口协调器 + var chapterWindowCoordinator: RDEPUBChapterWindowCoordinator? +} + +// ============ 修改文件:RDEPUBReaderRuntime.swift ============ + +final class RDEPUBReaderRuntime { + // ... 保留所有现有子协调器 ... + + // 新增:章节运行时初始化 + func setupChapterRuntimeIfNeeded() { + guard context.chapterRuntimeStore == nil else { return } + + let store = RDEPUBChapterRuntimeStore() + let loader = RDEPUBChapterLoader(context: context) + let coordinator = RDEPUBChapterWindowCoordinator( + context: context, + store: store, + loader: loader + ) + + context.chapterRuntimeStore = store + context.chapterLoader = loader + context.chapterWindowCoordinator = coordinator + + // 监听内存警告 + NotificationCenter.default.addObserver( + forName: UIApplication.didReceiveMemoryWarningNotification, + object: nil, queue: .main + ) { [weak store] _ in + store?.handleMemoryWarning() + } + } + + // 初始化章节运行时基础设施,然后委托给 PaginationCoordinator + // PaginationCoordinator.paginatePublication() 是唯一启动入口 + func loadPublication() { + setupChapterRuntimeIfNeeded() + // 启动入口统一由 paginationCoordinator 承担 + paginationCoordinator.paginatePublication(restoreLocation: /* 从持久化取 */) + } +} +``` + +说明: + +- **唯一启动点**是 `RDEPUBReaderPaginationCoordinator.paginatePublication()` +- `loadPublication()` 只负责初始化基础设施,然后委托给 `paginationCoordinator` +- `paginatePublication()` 内部调用 `context.chapterWindowCoordinator?.openBook(at:)`,是章节路径的唯一入口 +- P4 阶段对 PaginationCoordinator 的改造只是删掉旧整书路径代码,不改变启动入口(委托结构改造已在 P0 完成,见文件修改清单) + +--- + +### 19.2 P1:建立完整章节缓存运行时 + +#### 19.2.1 ±1 预取窗口完善 + +```swift +// ============ 扩展:RDEPUBChapterWindowCoordinator.swift ============ + +extension RDEPUBChapterWindowCoordinator { + + /// 翻章后维护窗口:当前章常驻,预取新的相邻章 + private func maintainWindow(afterMovingTo spineIndex: Int) { + let totalSpineCount = context.publication?.spine.count ?? 0 + + // 淘汰窗口外章节 + store.setCurrentChapter(spineIndex: spineIndex, totalSpineCount: totalSpineCount) + for idx in store.evictableSpineIndices() { + store.evict(spineIndex: idx) + } + + // 预取 prev(后台优先级,不抢占前台导航通道) + if spineIndex > 0 && store.chapterData(for: spineIndex - 1) == nil { + let prevIndex = spineIndex - 1 + store.addPrefetchTarget(prevIndex) + loader.loadChapter(spineIndex: prevIndex, store: store, priority: .prefetch) { [weak self] result in + guard let self = self, case .success = result else { return } + self.refreshSnapshot() + } + } + + // 预取 next(后台优先级,不抢占前台导航通道) + if spineIndex < totalSpineCount - 1 && store.chapterData(for: spineIndex + 1) == nil { + let nextIndex = spineIndex + 1 + store.addPrefetchTarget(nextIndex) + loader.loadChapter(spineIndex: nextIndex, store: store, priority: .prefetch) { [weak self] result in + guard let self = self, case .success = result else { return } + self.refreshSnapshot() + } + } + } +} +``` + +#### 19.2.2 排版参数变化后整体失效 + +```swift +// ============ 扩展:RDEPUBChapterRuntimeStore.swift ============ + +extension RDEPUBChapterRuntimeStore { + + /// 排版参数变化后整体失效(§8.5) + func invalidateAllForSettingsChange() { + chapterDataCache.removeAll() + pageCountCache.removeAll() + imageCache.removeAllObjects() + } +} +``` + +#### 19.2.3 CoreText / CF 释放审计 + +需要在以下类型中确认 CF 对象的成对释放: + +- `RDEPUBTextLayouter` → 检查内部 `RDEPUBChapterPageCounter` 是否持有 `CTTypesetter` +- `RDEPUBCoreTextPageFrameFactory` → 检查是否持有 `CTFramesetter` / `CTFrame` / `CGPath` +- `RDEPUBDTCoreTextRenderer` → 检查 `DTCoreTextLayoutFrame` 的生命周期 + +规则: + +- 谁创建,谁释放 +- `deinit` / `dealloc` 中成对处理 +- 章节淘汰时 `RDEPUBRuntimeChapter` 释放会级联释放 `layouter`,需确认其 `deinit` 链完整 + +--- + +### 19.3 P2:接入二次打开加速层 + +#### 19.3.1 RDEPUBChapterSummaryDiskCache + +```swift +// ============ 新增文件:RDEPUBChapterSummaryDiskCache.swift ============ + +final class RDEPUBChapterSummaryDiskCache { + private let cacheDirectory: URL + private let fileManager = FileManager.default + private let queue = DispatchQueue(label: “com.rdreader.summarydiskcache”, qos: .utility) + + init(cacheDirectory: URL) { + self.cacheDirectory = cacheDirectory + try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true) + } + + // MARK: - 写入(异步) + + func write(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) { + queue.async { + let fileURL = self.fileURL(for: key) + let data = try? JSONEncoder().encode(summary) + try? data?.write(to: fileURL) + } + } + + // MARK: - 读取(同步,因为 loadChapter 已在串行队列上) + + func read(for key: RDEPUBChapterCacheKey) -> RDEPUBChapterSummary? { + let fileURL = self.fileURL(for: key) + guard let data = try? Data(contentsOf: fileURL) else { return nil } + return try? JSONDecoder().decode(RDEPUBChapterSummary.self, from: data) + } + + // MARK: - key -> 文件路径 + + /// 使用确定性字符串拼接生成文件名,不依赖 Hashable.hashValue + /// hashValue 跨进程不稳定,会导致二次打开缓存失效 + private func fileURL(for key: RDEPUBChapterCacheKey) -> URL { + // 用 SHA256 对完整 key 内容取摘要,保证跨进程稳定且无文件名冲突 + let rawKey = “\(key.bookID)_\(key.spineIndex)_\(key.renderSignature)_\(key.chapterContentHash)” + let digest = rawKey.sha256Hex + return cacheDirectory.appendingPathComponent(“\(digest).json”) + } +} + +struct RDEPUBChapterSummary: Codable { + let pageRanges: [RangeData] // NSRange 不 Codable,需包装 + let pageCount: Int + let fragmentOffsets: [String: Int] + let renderSignature: String + let schemaVersion: Int + let chapterContentHash: String + /// 每页的元数据摘要,轻量路径恢复时使用 + let pageMetadataList: [PageMetadataSummary] + + struct RangeData: Codable { + let location: Int + let length: Int + var nsRange: NSRange { NSRange(location: location, length: length) } + } + + /// 轻量化的每页 metadata(只保留跨章功能依赖的字段) + struct PageMetadataSummary: Codable { + let breakReason: String // RDEPUBTextPageBreakReason.rawValue + let attachmentRanges: [RangeData] + let attachmentKinds: [String] // RDEPUBTextAttachmentKind.rawValue + let blockKinds: [String] // RDEPUBTextBlockKind.rawValue + let semanticHints: [String] // RDEPUBTextSemanticHint.rawValue + let attachmentPlacements: [String] // RDEPUBTextAttachmentPlacement.rawValue + let trailingFragmentID: String? + + func toPageMetadata() -> RDEPUBTextPageMetadata { + RDEPUBTextPageMetadata( + breakReason: RDEPUBTextPageBreakReason(rawValue: breakReason) ?? .frameLimit, + blockRange: nil, + attachmentRanges: attachmentRanges.map { $0.nsRange }, + attachmentKinds: attachmentKinds.compactMap { RDEPUBTextAttachmentKind(rawValue: $0) }, + blockKinds: blockKinds.compactMap { RDEPUBTextBlockKind(rawValue: $0) }, + semanticHints: semanticHints.compactMap { RDEPUBTextSemanticHint(rawValue: $0) }, + attachmentPlacements: attachmentPlacements.compactMap { RDEPUBTextAttachmentPlacement(rawValue: $0) }, + trailingFragmentID: trailingFragmentID, + diagnostics: [] + ) + } + + static func from(_ metadata: RDEPUBTextPageMetadata) -> PageMetadataSummary { + PageMetadataSummary( + breakReason: metadata.breakReason.rawValue, + attachmentRanges: metadata.attachmentRanges.map { .init(location: $0.location, length: $0.length) }, + attachmentKinds: metadata.attachmentKinds.map { $0.rawValue }, + blockKinds: metadata.blockKinds.map { $0.rawValue }, + semanticHints: metadata.semanticHints.map { $0.rawValue }, + attachmentPlacements: metadata.attachmentPlacements.map { $0.rawValue }, + trailingFragmentID: metadata.trailingFragmentID + ) + } + } +} +``` + +#### 19.3.2 集成到 RDEPUBChapterLoader + +已在 §19.1.5 的 `buildChapter` 中预留了 `summaryDiskCache?.write(summary:for:)` 调用和 `chapterSummaryDiskCachePageRanges(for:)` 查询。 + +P2 阶段只需: + +1. 在 `RDEPUBReaderRuntime.setupChapterRuntimeIfNeeded()` 中创建 `RDEPUBChapterSummaryDiskCache` 并注入 loader +2. 确认 `buildChapter` 中已回填磁盘摘要 + +--- + +### 19.4 P3:位置与跨章能力迁移 + +#### 19.4.1 Legacy 位置转换器 + +```swift +// ============ 新增文件:RDEPUBLocationConverter.swift ============ + +struct RDEPUBLocationConverter { + + // MARK: - 主路径:先构建目标章,拿到真实长度后再精确转换 + + /// 旧版 RDEPUBLocation -> 新版 RDEPUBChapterLocation + /// 主迁移路径:要求先构建目标章,用真实 chapterLength 做精确转换 + /// 仅在无法获取章节长度时才降级到粗估 fallback + static func convert( + legacy location: RDEPUBLocation, + parser: RDEPUBParser, + publication: RDEPUBPublication, + chapterLengthProvider: ((Int) -> Int?)? = nil + ) -> RDEPUBChapterLocation? { + // 1. 从 href 找到 spineIndex + guard let spineItem = publication.spine.first(where: { + $0.href == location.href || $0.href.contains(location.href) + }) else { return nil } + + let spineIndex = publication.spine.firstIndex(of: spineItem) ?? 0 + + // 2. 优先用 fragmentID 定位(最精确,不受 progression 精度影响) + if let fragmentID = location.fragment { + return RDEPUBChapterLocation( + spineIndex: spineIndex, + chapterOffset: 0, // fragmentID 由 chapterOffsetMap 精确解析 + fragmentID: fragmentID, + progressionInChapter: location.progression + ) + } + + // 3. 有 chapterLength 时做精确转换 + if let provider = chapterLengthProvider, + let chapterLength = provider(spineIndex), chapterLength > 0 { + return convert( + legacy: location, + spineIndex: spineIndex, + chapterLength: chapterLength + ) + } + + // 4. Fallback:无法获取章节长度时的粗估(仅作临时降级,不应作为主路径) + // 粗估对大章/短章/带 fragment 场景误差明显 + // 正式迁移应在上层先构建目标章再调用精确版本 + let estimatedOffset = Int(location.progression * 10000) + return RDEPUBChapterLocation( + spineIndex: spineIndex, + chapterOffset: estimatedOffset, + fragmentID: nil, + progressionInChapter: location.progression, + schemaVersion: 1 // 标记为降级结果,后续可被精确值覆盖 + ) + } + + // MARK: - 粗估降级验收标准 + + /// schemaVersion == 1 的降级结果必须满足以下验收标准: + /// + /// 1. **允许误差范围** + /// - 粗估 chapterOffset 与真实 chapterOffset 的偏差不超过章节总长度的 ±15% + /// - 偏差超过 15% 时,用户可见的位置偏移会被感知(例如跳到错误段落) + /// - 验收测试:对比 schemaVersion == 1 与 schemaVersion == 2 的 chapterOffset, + /// 偏差 = |estimated - exact| / chapterLength,必须 < 0.15 + /// + /// 2. **必须回填精确值的场景** + /// - 用户下次打开同一章节时,必须走主路径(先构建目标章)获取精确值 + /// - 精确值获取成功后,立即覆盖 schemaVersion == 1 的降级结果 + /// - 不允许降级结果永久驻留在持久化层 + /// + /// 3. **禁止降级的场景** + /// - 带 fragmentID 的位置:fragmentID 是精确锚点,粗估会完全丢失语义 + /// (已在 step 2 优先处理 fragmentID,此处不再重复) + /// - 首次打开时的"当前阅读位置":这是用户最关心的位置,必须精确 + /// (调用方必须保证主路径先构建目标章,见 §19.4.2 调用示例) + /// + /// 4. **监控与告警** + /// - 生产环境应统计 schemaVersion == 1 的出现频率 + /// - 如果降级率 > 5%,说明主路径保证未落地,需要排查调用链路 + /// - 降级结果应在日志中标记,便于问题定位 + /// + /// 5. **验收测试用例** + /// ```swift + /// // 测试:粗估偏差必须在 ±15% 以内 + /// func testFallbackOffsetAccuracy() { + /// let legacy = RDEPUBLocation(href: "chapter10.xhtml", progression: 0.5, fragment: nil) + /// let fallback = RDEPUBLocationConverter.convert(legacy: legacy, spineIndex: 10, chapterLength: nil) + /// let exact = RDEPUBLocationConverter.convert(legacy: legacy, spineIndex: 10, chapterLength: 50000) + /// + /// let deviation = abs(fallback.chapterOffset - exact.chapterOffset) / Double(exact.chapterLength) + /// XCTAssertLessThan(deviation, 0.15, "粗估偏差超过 15%,用户可感知位置偏移") + /// } + /// + /// // 测试:schemaVersion == 1 必须被精确值覆盖 + /// func testFallbackOverwrittenByExact() { + /// let fallback = RDEPUBChapterLocation(spineIndex: 10, chapterOffset: 5000, schemaVersion: 1) + /// persistence.saveChapterLocation(fallback, for: "book123") + /// + /// // 模拟下次打开时走主路径 + /// let exact = RDEPUBChapterLocation(spineIndex: 10, chapterOffset: 25000, schemaVersion: 2) + /// persistence.saveChapterLocation(exact, for: "book123") + /// + /// let loaded = persistence.loadChapterLocation(for: "book123") + /// XCTAssertEqual(loaded.schemaVersion, 2, "降级结果未被精确值覆盖") + /// XCTAssertEqual(loaded.chapterOffset, 25000) + /// } + /// ``` + + /// 精确转换:已知章节实际长度 + static func convert( + legacy location: RDEPUBLocation, + spineIndex: Int, + chapterLength: Int + ) -> RDEPUBChapterLocation? { + let offset = Int(location.progression * Double(chapterLength)) + return RDEPUBChapterLocation( + spineIndex: spineIndex, + chapterOffset: offset, + fragmentID: location.fragment, + progressionInChapter: location.progression, + schemaVersion: 2 // 精确结果 + ) + } + + /// 从已构建的 RDEPUBRuntimeChapter 做精确转换(推荐迁移路径) + static func convert( + legacy location: RDEPUBLocation, + chapter: RDEPUBRuntimeChapter + ) -> RDEPUBChapterLocation? { + // 优先用 fragmentID + if let fragmentID = location.fragment, + let fragmentOffset = chapter.chapterOffsetMap.chapterOffset(forFragmentID: fragmentID) { + return RDEPUBChapterLocation( + spineIndex: chapter.spineIndex, + chapterOffset: fragmentOffset, + fragmentID: fragmentID, + progressionInChapter: nil, + schemaVersion: 2 + ) + } + + // 用 progression + 真实长度 + let chapterLength = chapter.typesetAttributedString.length + return convert( + legacy: location, + spineIndex: chapter.spineIndex, + chapterLength: chapterLength + ) + } + + /// 新版 -> 旧版(兼容外部接口) + static func toLegacy( + chapterLocation: RDEPUBChapterLocation, + href: String, + chapterLength: Int + ) -> RDEPUBLocation { + let progression = chapterLength > 0 + ? Double(chapterLocation.chapterOffset) / Double(chapterLength) + : 0 + return RDEPUBLocation( + href: href, + progression: min(max(progression, 0), 1), + fragment: chapterLocation.fragmentID + ) + } +} +``` + +#### 19.4.2 持久化迁移 + +```swift +// ============ 修改:RDEPUBUserDefaultsPersistence ============ + +extension RDEPUBUserDefaultsPersistence { + + /// 加载章节级位置,同时完成历史格式的一次性迁移 + func loadChapterLocation( + for bookIdentifier: String, + legacyMigrator: ((RDEPUBLocation) -> RDEPUBChapterLocation?)? = nil + ) -> RDEPUBChapterLocation? { + let key = locationPrefix + bookIdentifier + + // 1. 尝试直接读取新格式 + if let data = defaults.data(forKey: key), + let chapterLoc = try? JSONDecoder().decode(RDEPUBChapterLocation.self, from: data) { + // 新格式已存在,直接返回 + return chapterLoc + } + + // 2. 新格式不存在,尝试读取旧格式并迁移 + guard let migrator = legacyMigrator else { return nil } + + if let legacyData = defaults.data(forKey: key), + let legacyLoc = try? JSONDecoder().decode(RDEPUBLocation.self, from: legacyData), + let migrated = migrator(legacyLoc) { + // 迁移成功,立即覆盖为新格式 + saveChapterLocation(migrated, for: bookIdentifier) + return migrated + } + + return nil + } + + func saveChapterLocation(_ location: RDEPUBChapterLocation, for bookIdentifier: String) { + let key = locationPrefix + bookIdentifier + if let data = try? JSONEncoder().encode(location) { + defaults.set(data, forKey: key) + } + } +} +``` + +调用方在打开书时传入 migrator 闭包(主路径保证:先构建目标章,再用真实长度精确转换): + +```swift +// 典型调用点:RDEPUBReaderLocationCoordinator 或 RDEPUBReaderRuntime +// +// 主路径保证: +// migrator 闭包内先尝试从缓存取真实章节长度;如果目标章尚未加载(首次打开), +// 则同步调用 chapterLoader 构建目标章再取长度。 +// 只有构建失败时才降级到 fallback(progression * 10000 粗估)。 +let chapterLoc = persistence.loadChapterLocation(for: bookID) { legacyLoc in + // 1. 从 href 解析 spineIndex(与 converter 内部逻辑一致) + guard let spineItem = publication.spine.first(where: { + $0.href == legacyLoc.href || $0.href.contains(legacyLoc.href) + }), let spineIndex = publication.spine.firstIndex(of: spineItem) else { + return RDEPUBLocationConverter.convert( + legacy: legacyLoc, parser: parser, publication: publication + ) + } + + // 2. 优先从已加载的章节缓存取真实长度(二次打开,章节已在缓存) + let chapterLength: Int? + if let cached = context.chapterRuntimeStore?.chapterData(for: spineIndex) { + chapterLength = cached.typesetAttributedString.length + } else { + // 3. 缓存未命中(首次打开)→ 主路径:先构建目标章,再用真实长度 + // 这是保证”主路径一定先拿到真实章节长度”的关键步骤 + let pageSize = context.resolvedPageSize() + let style = context.currentStyle + let layoutConfig = context.currentLayoutConfig + let runtimeChapter = try? context.chapterLoader?.buildChapter( + spineIndex: spineIndex, + parser: parser, + publication: publication, + pageSize: pageSize, + style: style, + layoutConfig: layoutConfig + ) + // 构建成功后放入缓存,后续章节加载可复用 + if let chapter = runtimeChapter { + context.chapterRuntimeStore?.set(chapter, for: spineIndex) + } + chapterLength = runtimeChapter?.typesetAttributedString.length + } + + // 4. 用精确路径或降级 fallback + return RDEPUBLocationConverter.convert( + legacy: legacyLoc, + parser: parser, + publication: publication, + chapterLengthProvider: { _ in chapterLength } + ) +} +``` + +说明: + +- 主持久化格式只保留 `RDEPUBChapterLocation` +- 历史格式在首次加载时自动迁移并覆盖,不需要单独的迁移脚本 +- 本方案不维护”运行期双格式并存”或”新旧链路双写” +- **主路径保证**:`migrator` 闭包内显式处理了”缓存未命中时先构建目标章”的逻辑,确保 `chapterLengthProvider` 在首次打开时也能返回真实章节长度,而不是直接掉到 `progression * 10000` 粗估 fallback +- 构建目标章的开销在首次迁移时只发生一次(迁移后立即覆盖为新格式,后续打开走新格式直接读取) + +#### 19.4.3 跨章功能适配 + +搜索适配: + +```swift +// ============ 修改:RDEPUBReaderSearchCoordinator.swift ============ + +extension RDEPUBReaderSearchCoordinator { + + /// 章节模式下搜索:逐章搜索,结果携带章节语义定位 + func searchInChapterMode(keyword: String) { + guard let publication = context.publication else { return } + + var allMatches: [RDEPUBSearchMatch] = [] + + for (spineIndex, item) in publication.spine.enumerated() { + guard let html = try? context.parser?.htmlContent(for: item.href) else { continue } + + // 搜索必须在渲染后纯文本空间进行,不能直接在原始 HTML 上匹配。 + // 原因:正文链路的 chapterOffset 基于 typesetAttributedString(经 HTML → 渲染 → 去标签), + // chapterOffsetMap 的偏移也在同一空间。如果直接在原始 HTML 上搜索, + // matchRange.location 是含标签/实体的 HTML 偏移,与 chapterOffset 语义不一致, + // 会导致搜索结果点击后恢复位置、高亮范围、预览命中全部偏移。 + let plainText = stripHTMLTags(html) + + // 在渲染后纯文本中查找所有匹配位置 + let matches = findKeywordRanges(in: plainText, keyword: keyword) + for (localIndex, matchRange) in matches.enumerated() { + // matchRange.location 现在是纯文本偏移,与 chapterOffsetMap 语义一致 + let chapterOffset = matchRange.location + let rangeAnchor = RDEPUBTextRangeAnchor( + start: RDEPUBTextAnchor( + fileIndex: spineIndex, + row: 0, + column: 0, + chapterOffset: chapterOffset, + fragmentID: nil + ), + end: RDEPUBTextAnchor( + fileIndex: spineIndex, + row: 0, + column: 0, + chapterOffset: chapterOffset + matchRange.length, + fragmentID: nil + ) + ) + + let chapterLength = plainText.count + let progression = chapterLength > 0 + ? Double(chapterOffset) / Double(chapterLength) : 0 + + // 截取预览文本(基于纯文本偏移,与正文一致) + let previewStart = max(0, chapterOffset - 20) + let previewEnd = min(plainText.count, chapterOffset + keyword.count + 20) + let previewText = String(plainText[plainText.index(plainText.startIndex, offsetBy: previewStart).. String { + // 去除 ", + with: "", options: .regularExpression) + .replacingOccurrences(of: "]*>[\\s\\S]*?", + with: "", options: .regularExpression) + // 去除所有标签 + let noTags = strippedBlocks + .replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) + // 解码常见 HTML 实体 + return noTags + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: " ", with: " ") + .replacingOccurrences(of: """, with: "\"") + } + + /// 在纯文本(非原始 HTML)中查找关键词的所有 NSRange。 + /// 调用方必须先对 HTML 做 stripHTMLTags 再传入,确保返回的偏移与 + /// chapterOffsetMap / typesetAttributedString 的纯文本偏移语义一致。 + private func findKeywordRanges(in text: String, keyword: String) -> [NSRange] { + var ranges: [NSRange] = [] + let nsText = text as NSString + var searchRange = NSRange(location: 0, length: nsText.length) + while searchRange.location + searchRange.length <= nsText.length { + let found = nsText.range(of: keyword, options: [.caseInsensitive], range: searchRange) + if found.location == NSNotFound { break } + ranges.append(found) + searchRange.location = found.location + found.length + searchRange.length = nsText.length - searchRange.location + } + return ranges + } +} +``` + +搜索结果定位到具体章节的方式: + +- 点击搜索结果 → 取出 `rangeAnchor.start.spineIndex` → 调用 `flipToChapter(spineIndex:)` → 章节加载完成后用 `chapterOffsetMap` 或 `rangeAnchor.start.chapterOffset` 恢复精确位置 +- 不再依赖全书绝对页码 +- **轻量去标签 vs 完整渲染对齐**:`stripHTMLTags` 是轻量级标签剥离,不走完整 HTML → `NSAttributedString` 渲染管线,与 `typesetAttributedString` 之间可能存在白空格归一化等微小差异。搜索场景可以接受此精度(搜索结果是入口锚点,不是精确排版锚点);如果需要严格对齐,可以改为在搜索结果点击后、章节加载完成时,用 `chapterOffsetMap` 对 `chapterOffset` 做一次精化映射 + +书签/高亮适配要点: + +- 高亮和书签的 `location` 字段已经是 `RDEPUBLocation`,包含 `href + progression + fragment + rangeAnchor` +- `rangeAnchor` 已经是 `spineIndex + chapterOffset` 语义(`RDEPUBTextAnchor`) +- 核心适配点: + 1. 写入时确保 `rangeAnchor` 被正确填充 + 2. 读取时从 `rangeAnchor` 恢复,而不是从全局页码 + +--- + +### 19.5 P4:清理旧整书主路径 + +#### 19.5.1 RDEPUBReaderPaginationCoordinator 改造 + +`RDEPUBReaderPaginationCoordinator` 是章节路径的**唯一启动入口**。 + +> **阶段说明**:委托结构改造(`paginatePublication()` 委托 `ChapterWindowCoordinator.openBook(at:)`)在 **P0** 阶段完成,与 `loadPublication()` 的委托调用同步落地。P4 阶段在此基础上**仅删除旧整书分页路径代码**(`paginateTextPublication`、`buildQuickTextBook`、`IncrementalChapterStore`、`StagedBookRequest`、`stageBookRequest`、`scheduleStagedIncrementalTextBookApplication` 等),不改变启动入口。 + +```swift +// ============ 修改:RDEPUBReaderPaginationCoordinator.swift ============ + +final class RDEPUBReaderPaginationCoordinator { + private unowned let context: RDEPUBReaderContext + + init(context: RDEPUBReaderContext) + + /// 唯一启动入口:解析恢复位置,委托给 ChapterWindowCoordinator + func paginatePublication(restoreLocation: RDEPUBLocation?) { + let targetSpineIndex = resolveTargetSpineIndex(from: restoreLocation) + context.chapterWindowCoordinator?.openBook(at: targetSpineIndex) + } + + private func resolveTargetSpineIndex(from location: RDEPUBLocation?) -> Int { + guard let location = location, + let publication = context.publication else { return 0 } + + let href = location.href + if let idx = publication.spine.firstIndex(where: { $0.href == href }) { + return idx + } + return 0 + } +} +``` + +#### 19.5.2 RDEPUBTextBookCache 降级 + +```swift +// ============ 修改:RDEPUBReaderContext.swift ============ + +extension RDEPUBReaderContext { + + func makeTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder { + return RDEPUBTextBookBuilder( + renderer: resolvedTextRenderer(), + cache: nil, + layoutConfig: layoutConfig + ) + } +} +``` + +--- + +### 19.6 文件新增与修改清单 + +#### 新增文件 + +| 文件 | 阶段 | 职责 | +|------|------|------| +| `RDEPUBChapterRuntimeStore.swift` | P0 | 章节缓存中心 | +| `RDEPUBChapterDataCache.swift` | P0 | 类型安全章节缓存 wrapper | +| `RDEPUBPageCountCache.swift` | P0 | 类型安全页数缓存 wrapper | +| `RDEPUBRuntimeChapter.swift` | P0 | 章节运行时对象 | +| `RDEPUBChapterOffsetMap.swift` | P0 | 章内偏移映射 | +| `RDEPUBRuntimePageCount.swift` | P0 | 轻量分页结构 | +| `RDEPUBChapterCacheKey.swift` | P0 | 缓存键 | +| `RDEPUBChapterLocation.swift` | P0 | 章节级位置模型 | +| `RDEPUBChapterLoader.swift` | P0 | 单章构建器 | +| `RDEPUBChapterWindowSnapshot.swift` | P0 | 窗口快照 | +| `RDEPUBChapterWindowCoordinator.swift` | P0 | 窗口协调器 | +| `RDEPUBChapterSummaryDiskCache.swift` | P2 | 轻量摘要磁盘缓存 | +| `RDEPUBLocationConverter.swift` | P3 | 新旧位置格式转换 | + +#### 修改文件 + +| 文件 | 阶段 | 改动 | +|------|------|------| +| `RDEPUBReaderContext.swift` | P0 | 挂入 store / loader / coordinator | +| `RDEPUBReaderRuntime.swift` | P0 | setupChapterRuntime + 单一路径接入 | +| `RDEPUBReaderController+DataSource.swift` | P0 | 窗口快照数据源 | +| `RDEPUBReaderPaginationCoordinator.swift` | P0 | 委托结构改造:`paginatePublication()` 改为委托 `ChapterWindowCoordinator.openBook(at:)`,成为唯一启动入口 | +| `RDEPUBReaderPaginationCoordinator.swift` | P4 | 清理旧整书分页代码(P0 已完成委托结构,P4 只删除不再使用的旧路径) | diff --git a/Doc/大书快速进入阅读器方案_凡人修仙传.md b/Doc/大书快速进入阅读器方案_凡人修仙传.md new file mode 100644 index 0000000..c416009 --- /dev/null +++ b/Doc/大书快速进入阅读器方案_凡人修仙传.md @@ -0,0 +1,438 @@ +# 《凡人修仙传》快速进入阅读器方案 + +> 适用场景:`textReflowable` 路径打开超大正文 EPUB,典型样本为《凡人修仙传》精校版全本。 +> 目标:把“进入阅读器前必须等全书分页完成”改成“先快速可读,再后台补齐全书能力”。 +> 结论先行:当前首屏慢的主因不是单章分页太慢,而是 **打开流程要求先完成全书 `RDEPUBTextBookBuilder.build()`**。 + +--- + +## 1. 当前慢在哪里 + +结合当前代码,打开 reflowable EPUB 的主链路是: + +```text +RDEPUBReaderController.viewDidLoad + -> RDEPUBReaderLoadCoordinator.loadPublication() + -> applyParsedPublication(...) + -> RDEPUBReaderPaginationCoordinator.paginatePublication() + -> if publication.readingProfile == .textReflowable + -> RDEPUBTextBookBuilder.build(...) + -> 遍历全部 spine item + -> 每章 render + paginate + -> 汇总成完整 RDEPUBTextBook + -> applyTextBook(...) + -> readerView.reloadData() + -> restoreReadingLocation(...) +``` + +关键事实: + +- [RDEPUBReaderPaginationCoordinator.swift](/Users/shenlei/Work/ReadViewSDK/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift) 在 `textReflowable` 路径里会先 `showLoading()`,然后后台执行 `builder.build(...)`,构建完成前不会进入正文。 +- [RDEPUBTextBookBuilder.swift](/Users/shenlei/Work/ReadViewSDK/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift) 的 `build()` 会遍历全部 `publication.spine`,逐章完成: + - HTML 读取 + - `DTCoreText` 渲染 + - CoreText 分页 + - 页面模型组装 + - 全书 `RDEPUBTextBook` 汇总 +- 也就是说,对《凡人修仙传》这种章节数多、正文长的大书,当前实际是“**全书构建完成后才能看到第一页**”。 + +这条链路的体验问题是: + +- 首屏等待时间和“全书总字数/总章节数”线性相关,而不是和“当前阅读位置附近内容规模”相关。 +- 即使用户只想看第一页,也要先支付整本书的 render + paginate 成本。 +- 恢复到历史位置时也是同样问题,因为当前恢复逻辑依赖完整 `RDEPUBTextBook` 的页码与位置映射。 + +--- + +## 2. 方案目标 + +### 用户目标 + +- 点击书籍后,阅读器应尽快进入正文页,而不是长时间停留在 loading。 +- 即使全书尚未构建完成,也至少能: + - 看到当前章节 + - 翻当前章节内的页 + - 恢复到“接近上次阅读位置”的章节 + +### 技术目标 + +- 首屏进入从“全书 ready”改成“当前章节 ready”。 +- 全书构建改为后台增量完成。 +- 不破坏现有 `RDEPUBReaderController` / `RDReaderView` 的主公开 API。 +- 保留当前 `RDEPUBTextBookCache` 的价值,但把缓存粒度从“整本书一次命中”扩展到“单章可命中”。 + +### 首期验收指标 + +- 大书首次打开时,进入正文的等待时间显著短于当前实现。 +- 首屏进入只依赖“当前章节”或“当前位置附近章节”构建完成。 +- 后台继续构建剩余章节时,不阻塞阅读。 + +--- + +## 3. 推荐方案:两阶段进入 + 章节级增量构建 + +## 阶段 A:快速进入 + +打开书后只做这些事情: + +1. 解析 EPUB 基础元数据、spine、TOC +2. 确定恢复位置对应的 `spineIndex` +3. 只构建当前章节,必要时附带相邻 `±1` 章 +4. 先生成一个“局部 TextBook / 局部 Snapshot” +5. 立即进入阅读器并恢复到该章节内位置 + +这一阶段的原则是: + +- 先解决“能进入” +- 不要求立刻具备全书搜索、全书目录页码、全书绝对页码精度 + +## 阶段 B:后台补全 + +进入正文后,再后台串行完成: + +1. 当前章节相邻章节预构建 +2. 剩余章节逐步构建 +3. 持续补齐全书: + - `RDEPUBTextBook.chapters` + - `RDEPUBTextBook.pages` + - `RDEPUBTextIndexTable` + - 全书页码/位置映射 +4. 构建完成后静默替换到完整模型 + +这样用户的体感是: + +- 很快进书 +- 越读越完整 +- 而不是“先等很久,之后一次性全有” + +--- + +## 4. 为什么这是最快可落地的方案 + +相比继续优化单次 `build()` 的 CPU 细节,这个方案收益更直接: + +- 《凡人修仙传》的核心问题是“全书串行工作量太大”,不是“当前章节单章慢到不可接受”。 +- 当前代码已经天然按“章节”组织: + - `publication.spine` + - `RDEPUBTextChapter` + - `RDEPUBChapterData` + - 每章 `render + paginate` +- [阅读器功能开发计划.md](/Users/shenlei/Work/ReadViewSDK/Doc/阅读器功能开发计划.md) 里也已经明确把“增量构建”列为方向,说明这条路和现有架构一致。 + +换句话说,这不是推翻重做,而是把现有 `RDEPUBTextBookBuilder.build()` 从“必须一次性跑完整本书”拆成“可按章节单独执行”。 + +--- + +## 5. 具体改造点 + +## 5.1 构建层:把全书构建拆成章节级能力 + +当前: + +- [RDEPUBTextBookBuilder.swift](/Users/shenlei/Work/ReadViewSDK/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift) 只有全书 `build(...)` + +建议新增: + +```swift +func buildChapter( + parser: RDEPUBParser, + publication: RDEPUBPublication, + spineIndex: Int, + pageSize: CGSize, + style: RDEPUBTextRenderStyle +) throws -> RDEPUBTextChapterBuildResult +``` + +建议返回: + +- `chapter: RDEPUBTextChapter` +- `paginationDiagnostic` +- `resourceDiagnostics` +- `performanceSample` + +这样做的好处: + +- 章节构建逻辑可以和现有 `build()` 共享 +- 后续“首屏只构建一章”和“后台补全整本书”都走同一套实现 + +## 5.2 数据层:允许 TextBook 从“不完整”逐步变完整 + +当前: + +- `RDEPUBTextBook` 默认假设 `chapters/pages/indexTable` 已经是完整全书 + +建议新增一个运行时模型,例如: + +```swift +final class RDEPUBIncrementalTextBookStore +``` + +职责: + +- 保存已完成构建的章节 +- 维护 `spineIndex -> chapter` 映射 +- 动态生成当前可用的 pages snapshot +- 在“部分章节可用”时提供章节内阅读支持 + +建议暴露能力: + +- `chapter(forSpineIndex:)` +- `availablePagesSnapshot()` +- `merge(chapter:)` +- `isChapterReady(_:)` +- `readyChapterRange(around:)` + +原因: + +- `RDEPUBTextBook` 更适合“完整产物” +- 增量加载需要一个“半成品但可读”的状态容器 + +## 5.3 分页协调层:把一次性 loading 改成两阶段 loading + +当前: + +- [RDEPUBReaderPaginationCoordinator.swift](/Users/shenlei/Work/ReadViewSDK/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift) 中 `paginatePublication()` 直接把全书构建作为进入阅读器前置条件 + +建议改成: + +### Step 1:首次只构建目标章节 + +- 根据 `restoreLocation` 算出目标 `spineIndex` +- 若无恢复位置,默认 `spineIndex = 0` +- 只构建该章,必要时加 `±1` 章 + +### Step 2:先应用局部 snapshot + +- `readerView.reloadData()` +- 允许用户开始阅读 +- tool chrome 可先显示,但某些依赖全书的能力先降级 + +### Step 3:后台继续全书补建 + +- 串行队列逐章构建剩余章节 +- 每完成一章,就 merge 进 store +- 必要时再刷新目录页码、搜索索引、页码总数 + +## 5.4 位置恢复:首期优先恢复“章节”,二期补齐“页内精度” + +当前: + +- 恢复逻辑大量依赖完整 `RDEPUBTextBook` 的 pageNumber / location 映射 + +首期建议: + +- 打开时先把恢复目标收敛到 `spineIndex + fragment/rangeAnchor` +- 只要目标章节 ready,就先进入该章节 +- 若该章节内页内恢复信息尚未完整,先恢复到该章节接近位置 + +这样能显著减少“为了恢复精确页码,必须先构建全书”的耦合。 + +## 5.5 缓存层:从整书缓存扩展到单章缓存 + +当前: + +- `RDEPUBPaginationCacheCoordinator` / `RDEPUBTextBookCache` 更偏整书结果 + +建议: + +- 缓存 key 增加 `spineIndex` +- 支持单章 page ranges 命中 +- 首次打开大书时,优先读取: + - 当前章节缓存 + - 相邻章节缓存 + +收益: + +- 同一本大书二次打开时,可直接秒开到当前章节 +- 不用再等待整本书缓存完全重建 + +--- + +## 6. 首期最小可交付版本 + +为了最快解决《凡人修仙传》慢启动,建议首期只做下面这些: + +1. 为 `RDEPUBTextBookBuilder` 抽出章节级构建接口 +2. `RDEPUBReaderPaginationCoordinator` 首次打开时只构建目标章节 +3. 用“局部 pages snapshot”驱动 `RDReaderView` +4. 后台串行构建剩余章节 +5. 当前章节缓存命中优先 + +首期明确不做: + +- 全书搜索实时可用 +- 全书总页数一开始就准确 +- 目录面板一开始就显示所有章节页码 + +这些能力可以在后台补建完成后逐步恢复。 + +--- + +## 7. UI 与交互建议 + +为了让“快速进入但后台仍在准备”体验自然,建议增加轻量提示: + +### 阅读器内状态提示 + +- 首屏进入后不再是全屏 loading +- 改为顶部或底部轻提示: + - `正在准备后续章节...` + - `已进入阅读,可继续翻页` + +### 未就绪章节翻页策略 + +当用户快速翻到尚未构建的章节时: + +- 优先命中后台预构建结果 +- 如果还未就绪: + - 显示章节级 loading skeleton + - 不要退回全屏 blocking loading + +### 目录与搜索降级 + +- 目录先显示标题,不强依赖页码 +- 搜索面板可在全书索引未完成时显示: + - `正文已可阅读,全文搜索仍在准备中` + +--- + +## 8. 风险与应对 + +## 风险 1:现有很多 API 默认依赖完整 TextBook + +例如: + +- `pageNumber(for:)` +- `location(forPageNumber:)` +- `chapterData(forPageNumber:)` + +应对: + +- 首期不要强行让这些 API 在“半本书”状态下也完整成立 +- 先给增量模式增加“可用性边界” +- 在运行时根据 `isFullyBuilt` / `isChapterReady` 分流 + +## 风险 2:局部 snapshot 和完整 snapshot 切换时页码跳动 + +应对: + +- 局部模式优先用章节内位置恢复,不强调绝对页码稳定 +- 后台切换到完整模型时,按 `location` 而不是按 `pageNumber` 恢复 + +## 风险 3:后台补建打断当前交互 + +应对: + +- 章节构建使用串行后台队列 +- UI 合并更新节流,例如“每完成 N 章再刷新一次目录状态” +- 当前可见章节不重复重建 + +--- + +## 9. 推荐实施顺序 + +### Phase 1:快速进入 MVP + +- 抽 `buildChapter(...)` +- 首次打开只构建目标章节 +- 局部 snapshot 驱动阅读器 +- 后台补建剩余章节 + +### Phase 2:缓存加速 + +- 单章分页缓存 +- 恢复位置附近章节优先命中 +- 二次打开大书进一步提速 + +### Phase 3:全书能力渐进恢复 + +- 全书搜索索引后台构建 +- TOC 页码后台补齐 +- 全书总页数在构建完成后更新 + +--- + +## 10. 建议验收方式 + +建议拿《凡人修仙传》精校版全本做专项验证,记录以下指标: + +- 点击书籍到首屏可读的耗时 +- 点击书籍到全书构建完成的耗时 +- 首屏进入时用户是否已经可以翻当前章节 +- 翻到下一章节时是否出现明显阻塞 +- 二次打开同一本书时是否明显快于首次 + +重点不是只看“总构建时长”,而是看: + +- `Time To First Readable Page` +- `Time To Full Book Ready` + +这两个指标在大书场景里要分开看。 + +--- + +## 11. 最终建议 + +对《凡人修仙传》这类超大正文书,最快见效的方案不是继续压榨单次全书分页性能,而是: + +**把阅读器打开流程从“全书先构建完”改成“当前章节先可读,剩余章节后台补齐”。** + +这是当前代码架构下收益最高、侵入性也相对可控的做法,因为: + +- 现有数据天然按章节组织 +- `RDEPUBTextBookBuilder` 已经具备章节级循环结构 +- `RDEPUBReaderPaginationCoordinator` 也已经是集中调度入口 + +如果只允许做一件事来解决《凡人修仙传》打开慢的问题,我建议优先做: + +**Phase 1:章节级增量构建 + 两阶段进入阅读器。** + +--- + +## 12. 当前落地状态(2026-06-02) + +本轮已按 Phase 1 做了首期快速进入优化,当前实现状态如下。 + +### 已完成 + +- `RDEPUBTextBookBuilder` 已抽出章节级构建接口 `buildChapter(...)`,单章构建复用原有 render、分页、尾页规范化、诊断和分页缓存逻辑。 +- `RDEPUBReaderPaginationCoordinator` 的 `textReflowable` 路径已改为两阶段: + - 第一阶段:按恢复位置优先构建可用章节,并立即应用局部 `RDEPUBTextBook` 进入阅读器。 + - 第二阶段:后台继续按章节增量构建,但增量结果会先暂存,只在用户空闲时再合并到当前可读内容,最终补齐为完整全书模型。 +- 快速进入阶段不只尝试单个 spine,而是按离恢复位置最近的可构建 HTML/XHTML spine 逐个尝试,避免封面、版权页、空白扉页导致首屏快速路径落空。 +- 已修正阅读路径误判:普通静态 SVG 封面不再把整本小说误判为 `webInteractive`,避免错误掉回 `WKWebView` 分页路径。 +- 首包策略已调整为“目标章节 + 后续 2 章”,默认先提供 3 章连续可读内容。 +- 后台补齐策略已调整为“每新增 20 章生成一份新的局部结果”,并优先补当前可读窗口之后的章节,再回补前文。 +- 增量结果不再一生成就立即 `applyTextBook`,而是只保留最近一份 staged `RDEPUBTextBook`,等用户停止翻页且阅读器回到 idle 后再统一合并,减少翻页过程中的 UI reload 和主线程抖动。 +- 已对后台分页任务做降干扰处理:章节补建切到较低优先级队列,用户刚翻页时后台任务会短暂停让,减少 pageCurl 翻页时的 CPU 抢占。 +- 后台完整构建失败时,如果局部章节已经成功进入阅读器,则不再把用户退回阻塞式错误流程,只结束 loading;如果局部章节也未成功,则按原错误处理。 + +### 仍未完成 + +- 还没有引入独立的 `RDEPUBIncrementalTextBookStore`,当前首期实现采用“局部 `RDEPUBTextBook` 先应用,完整 `RDEPUBTextBook` 后替换”的 MVP 路径。 +- 目录页码、全文搜索、全书总页数仍依赖后台完整构建完成后恢复。 +- 尚未加入可视化的“后续章节准备中”轻提示。 + +### 当前预期效果 + +对《凡人修仙传》精校版全本这类章节多、正文长的大书,首屏进入不再等待整本书逐章 render + paginate 全部完成,而是先等待目标正文附近 3 章构建完成。全书分页仍会继续执行,但它从“进入阅读器前置条件”变成了“阅读器内后台补齐任务”;后台每补齐 20 章会生成一份新的局部结果,不过这份结果会先暂存,等用户空闲时再并入当前可读内容。 + +### 验证状态 + +已通过 CocoaPods workspace 构建 Demo,确认本轮快速进入优化可编译: + +```text +xcodebuild -workspace ReadViewDemo/ReadViewDemo.xcworkspace \ + -scheme ReadViewDemo \ + -configuration Debug \ + -derivedDataPath /private/tmp/ReadViewDemoDerivedData \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + build +``` + +结果:`BUILD SUCCEEDED`。 + +注意:直接构建 `ReadViewDemo.xcodeproj` 会绕开 Pods,导致 `import RDReaderView` 模块解析失败;验证时应使用 `ReadViewDemo/ReadViewDemo.xcworkspace`。 + +后续仍需要在真机或模拟器上用《凡人修仙传》精校版全本做实际打开耗时对比,重点记录 `Time To First Readable Page` 和 `Time To Full Book Ready`。 diff --git a/Doc/架构对比分析_WXRead_vs_ReadViewSDK.md b/Doc/架构对比分析_WXRead_vs_ReadViewSDK.md index 2ae9cc9..e188c5c 100644 --- a/Doc/架构对比分析_WXRead_vs_ReadViewSDK.md +++ b/Doc/架构对比分析_WXRead_vs_ReadViewSDK.md @@ -1,6 +1,6 @@ # 架构对比分析:读书 vs ReadViewSDK -> 基于读书 v10.0.3 (Build 79) 逆向文档,与当前 ReadViewSDK 代码在 2026-05-24 的核查结果整合。 +> 基于读书 v10.0.3 (Build 79) 逆向文档,与当前 ReadViewSDK 代码在 2026-06-02 的核查结果整合。 > 本文档已吸收原 [WXRead剩余问题修复计划.md](/Users/shen/Work/Code/ReadViewSDK/Doc/WXRead剩余问题修复计划.md) 的阶段方案,后续以本文档作为单一真值。 > 标注说明: @@ -22,10 +22,11 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链 - 页面级 hit test / 选区 / 高亮 / 批注 - 字符锚点与 `fileIndex/row/column` 语义的完整位置模型 -核查结论:在当前约定范围内,ReadViewSDK 已经完成 EPUB 阅读器对 WXRead 文档主链路的复刻。当前文档里不再保留主链路级 `⚠️` 项,剩余仅有两类: +核查结论:ReadViewSDK 已经完成 EPUB 阅读器主链路的大部分复刻,但当前代码里仍有少量“能力面已接入、实现路径未完全等价”的差异。和 2026-05-24 版本文档相比,当前最需要重新标注的是: -1. `replaceForMPChapter.css` 这类公众号文章专用资源,对 EPUB 主链路不适用 -2. 繁简转换、TTS / DRM / Pencil 这类已明确排除在本次复刻范围之外的外围能力 +1. 文本分页主路径现已真正消费多栏 path,并补上 `avoidOrphans` / `avoidWidows` / `hyphenation` 行为 +2. 代码仍保留若干 fallback / degrade 路径,与 WXRead 的单一主链路实现方式不同 +3. `replaceForMPChapter.css`、繁简转换、TTS / DRM / Pencil 仍属于不适用或明确不实现范围 --- @@ -43,11 +44,14 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链 - 分页器已经补上 inline footnote attachment 不整段挪页、标题 `keepWithNext`、`weread-page-relate` 页首借行这几类 WXRead 风格规则。 - 章节尾部“仅空白/段落分隔符”的尾页丢弃,以及极短尾页回并已经落地,`宝山辽墓材料与释读` 第 31 页空白问题已修复。 - `RDEPUBTextLayoutConfig` 已补齐到 WXRead 同级配置面:`frameWidth/frameHeight/edgeInsets/numberOfColumns/columnGap/avoidOrphans/avoidWidows/hyphenation`,并已接入分页入口与缓存键。 +- CoreText 分页路径现已消费多栏 layout path,不再把 `numberOfColumns` 只停留在配置层。 +- `avoidOrphans`、`avoidWidows`、`hyphenation` 现已接入实际排版/分页行为,而不是仅存在于配置模型。 - `RDEPUBChapterData` 已统一承载章节分页结果、位置查询、搜索/高亮回查与目录语义,章节模型主链路已经收口。 ### ⚠️ 有差异/有问题 -- 无主链路遗留项;当前仅保留明确不适用或明确不实现的范围说明。 +- 仍保留降级链路:`RDEPUBDTCoreTextRenderer` 在 `DTCoreText` 不可用或构建失败时会退回 HTML 纯文本渲染;`RDURLReaderController` 在纯文本分页失败时会退回 `UITextView` 展示。这说明 ReadViewSDK 仍不是 WXRead 那种完全单一路径的生产态实现。 +- `RDReaderGestureController` 仍是未接入的占位组件,实际点击分区逻辑直接落在 `RDReaderView`,与 WXRead 更完整的翻页/手势控制器拆分相比,宿主层职责仍稍偏重。 ### ❌ 未实现 @@ -81,10 +85,10 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链 | 标题 keep-with-next | 标题不能孤悬页尾 | 已补 `keepWithNext` 语义与页尾回退 | ✅ 已实现 | | `weread-page-relate` | 页首关联块需要借上一页一行 | 已补页首 `pageRelate` 借行规则 | ✅ 已实现 | | 章节尾页收口 | 丢弃空白尾页、合并极短尾页 | 已补尾页空白丢弃与超短尾页回并 | ✅ 已实现 | -| 分页配置 | `WRCoreTextLayoutConfig`(含多栏等) | `RDEPUBTextLayoutConfig` 已补齐同级参数,并接入分页入口/缓存键/列路径构建 | ✅ 已实现 | +| 分页配置 | `WRCoreTextLayoutConfig`(含多栏等) | `RDEPUBTextLayoutConfig` 已补齐同级参数,并已接入多栏 path、孤行/寡行保护与 hyphenation 行为 | ✅ 已实现 | | 缓存 | 按书籍 + 排版设置缓存 | 已有 `RDEPUBTextBookCache` 磁盘缓存 | ✅ 已实现 | -**结论:** 分页主链路已经按 WXRead 收口,当前差异不再落在分页引擎或分页配置能力面上。 +**结论:** 分页主链路已经按 WXRead 的主要思路收口;当前剩余差异不再集中在分页配置或分页算法本身,而更多落在 fallback 路径和宿主层实现细节。 --- @@ -175,6 +179,8 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链 | 页面几何模型完全等价 `WRCoreTextLayoutFrame` | ✅ 已实现 | - | | `WRBookmark` 统一标注模型 | ✅ 已实现 | - | | 多栏排版 | ✅ 已实现 | - | +| `avoidOrphans / avoidWidows / hyphenation` 行为落地 | ✅ 已实现 | - | +| 纯文本/渲染失败 fallback 清理 | ⚠️ 仍保留纯文本 fallback 路径 | 低 | | 字体动态加载 | ✅ 已实现 | - | | 繁简转换 | ❌ 明确不实现 | - | | TTS / DRM / Pencil | ❌ 明确不实现 | - | @@ -216,6 +222,17 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链 --- +## 补充说明:本次复核新增结论(2026-06-02) + +和上一篇版本相比,当前最重要的修正不是“新增缺很多能力”,而是把原先写得过满的结论收回来: + +- `RDEPUBTextLayoutConfig` 这一轮已经从“配置面补齐”推进到“行为面落地”,`avoidOrphans`、`avoidWidows`、`hyphenation` 不再只是模型字段。 +- 多栏能力这一轮已经接到文本 `CoreText` 分页主路径,`numberOfColumns` 不再只影响 Web/CSS 与设置入口。 +- 文本阅读主链路虽然已经不再依赖旧 `UITextView`,但仓库中仍保留 `DTCoreText` 不可用时的纯文本 fallback,以及纯文本分页失败时的 `UITextView` fallback,应视作与 WXRead 的实现差异,而不是主链路能力。 +- 公众号文章专用 `replaceForMPChapter.css` 仍不属于当前 EPUB 主链路缺口,应继续按“不适用”记录,而不是“待补齐”。 + +--- + ## 10. 读书关键类职责速查 | 类名 | 职责 | ReadViewSDK 对应 | 核查 |