docs: 大书优化方案文档与架构分析更新

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
shenlei 2026-06-02 21:16:50 +08:00
parent 1c6108061c
commit 488350d956
4 changed files with 3748 additions and 7 deletions

View File

@ -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<NSNumber *, WRChapterData *> *chapterDataCache;
@property (nonatomic, strong) NSMutableDictionary<NSString *, WRChapterPageCount *> *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<NSValue *> *pageRanges; // 页范围数组
@property (nonatomic, strong) NSArray<NSDictionary *> *highlights; // 高亮
@property (nonatomic, strong) NSArray<NSDictionary *> *underlines; // 下划线
@property (nonatomic, strong) NSSet<NSNumber *> *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 MBWXRead 的策略将其控制在个位数 MB。
---
## 10. 策略总结
| 策略 | 实现 | 效果 |
|------|------|------|
| **章节级缓存** | `NSMutableDictionary<NSNumber *, WRChapterData *>` | 内存与阅读位置绑定,不随全书增长 |
| **手动淘汰** | 内存警告时清空非当前章 | 确定性释放,当前章不中断 |
| **串行加载** | `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*

File diff suppressed because it is too large Load Diff

View File

@ -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`

View File

@ -1,6 +1,6 @@
# 架构对比分析:读书 vs ReadViewSDK # 架构对比分析:读书 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) 的阶段方案,后续以本文档作为单一真值。 > 本文档已吸收原 [WXRead剩余问题修复计划.md](/Users/shen/Work/Code/ReadViewSDK/Doc/WXRead剩余问题修复计划.md) 的阶段方案,后续以本文档作为单一真值。
> 标注说明: > 标注说明:
@ -22,10 +22,11 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
- 页面级 hit test / 选区 / 高亮 / 批注 - 页面级 hit test / 选区 / 高亮 / 批注
- 字符锚点与 `fileIndex/row/column` 语义的完整位置模型 - 字符锚点与 `fileIndex/row/column` 语义的完整位置模型
核查结论:在当前约定范围内ReadViewSDK 已经完成 EPUB 阅读器对 WXRead 文档主链路的复刻。当前文档里不再保留主链路级 `⚠️` 项,剩余仅有两类 核查结论:ReadViewSDK 已经完成 EPUB 阅读器主链路的大部分复刻,但当前代码里仍有少量“能力面已接入、实现路径未完全等价”的差异。和 2026-05-24 版本文档相比,当前最需要重新标注的是
1. `replaceForMPChapter.css` 这类公众号文章专用资源,对 EPUB 主链路不适用 1. 文本分页主路径现已真正消费多栏 path并补上 `avoidOrphans` / `avoidWidows` / `hyphenation` 行为
2. 繁简转换、TTS / DRM / Pencil 这类已明确排除在本次复刻范围之外的外围能力 2. 代码仍保留若干 fallback / degrade 路径,与 WXRead 的单一主链路实现方式不同
3. `replaceForMPChapter.css`、繁简转换、TTS / DRM / Pencil 仍属于不适用或明确不实现范围
--- ---
@ -43,11 +44,14 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
- 分页器已经补上 inline footnote attachment 不整段挪页、标题 `keepWithNext`、`weread-page-relate` 页首借行这几类 WXRead 风格规则。 - 分页器已经补上 inline footnote attachment 不整段挪页、标题 `keepWithNext`、`weread-page-relate` 页首借行这几类 WXRead 风格规则。
- 章节尾部“仅空白/段落分隔符”的尾页丢弃,以及极短尾页回并已经落地,`宝山辽墓材料与释读` 第 31 页空白问题已修复。 - 章节尾部“仅空白/段落分隔符”的尾页丢弃,以及极短尾页回并已经落地,`宝山辽墓材料与释读` 第 31 页空白问题已修复。
- `RDEPUBTextLayoutConfig` 已补齐到 WXRead 同级配置面:`frameWidth/frameHeight/edgeInsets/numberOfColumns/columnGap/avoidOrphans/avoidWidows/hyphenation`,并已接入分页入口与缓存键。 - `RDEPUBTextLayoutConfig` 已补齐到 WXRead 同级配置面:`frameWidth/frameHeight/edgeInsets/numberOfColumns/columnGap/avoidOrphans/avoidWidows/hyphenation`,并已接入分页入口与缓存键。
- CoreText 分页路径现已消费多栏 layout path不再把 `numberOfColumns` 只停留在配置层。
- `avoidOrphans`、`avoidWidows`、`hyphenation` 现已接入实际排版/分页行为,而不是仅存在于配置模型。
- `RDEPUBChapterData` 已统一承载章节分页结果、位置查询、搜索/高亮回查与目录语义,章节模型主链路已经收口。 - `RDEPUBChapterData` 已统一承载章节分页结果、位置查询、搜索/高亮回查与目录语义,章节模型主链路已经收口。
### ⚠️ 有差异/有问题 ### ⚠️ 有差异/有问题
- 无主链路遗留项;当前仅保留明确不适用或明确不实现的范围说明。 - 仍保留降级链路:`RDEPUBDTCoreTextRenderer` 在 `DTCoreText` 不可用或构建失败时会退回 HTML 纯文本渲染;`RDURLReaderController` 在纯文本分页失败时会退回 `UITextView` 展示。这说明 ReadViewSDK 仍不是 WXRead 那种完全单一路径的生产态实现。
- `RDReaderGestureController` 仍是未接入的占位组件,实际点击分区逻辑直接落在 `RDReaderView`,与 WXRead 更完整的翻页/手势控制器拆分相比,宿主层职责仍稍偏重。
### ❌ 未实现 ### ❌ 未实现
@ -81,10 +85,10 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
| 标题 keep-with-next | 标题不能孤悬页尾 | 已补 `keepWithNext` 语义与页尾回退 | ✅ 已实现 | | 标题 keep-with-next | 标题不能孤悬页尾 | 已补 `keepWithNext` 语义与页尾回退 | ✅ 已实现 |
| `weread-page-relate` | 页首关联块需要借上一页一行 | 已补页首 `pageRelate` 借行规则 | ✅ 已实现 | | `weread-page-relate` | 页首关联块需要借上一页一行 | 已补页首 `pageRelate` 借行规则 | ✅ 已实现 |
| 章节尾页收口 | 丢弃空白尾页、合并极短尾页 | 已补尾页空白丢弃与超短尾页回并 | ✅ 已实现 | | 章节尾页收口 | 丢弃空白尾页、合并极短尾页 | 已补尾页空白丢弃与超短尾页回并 | ✅ 已实现 |
| 分页配置 | `WRCoreTextLayoutConfig`(含多栏等) | `RDEPUBTextLayoutConfig` 已补齐同级参数,并接入分页入口/缓存键/列路径构建 | ✅ 已实现 | | 分页配置 | `WRCoreTextLayoutConfig`(含多栏等) | `RDEPUBTextLayoutConfig` 已补齐同级参数,并已接入多栏 path、孤行/寡行保护与 hyphenation 行为 | ✅ 已实现 |
| 缓存 | 按书籍 + 排版设置缓存 | 已有 `RDEPUBTextBookCache` 磁盘缓存 | ✅ 已实现 | | 缓存 | 按书籍 + 排版设置缓存 | 已有 `RDEPUBTextBookCache` 磁盘缓存 | ✅ 已实现 |
**结论:** 分页主链路已经按 WXRead 收口,当前差异不再落在分页引擎或分页配置能力面上 **结论:** 分页主链路已经按 WXRead 的主要思路收口;当前剩余差异不再集中在分页配置或分页算法本身,而更多落在 fallback 路径和宿主层实现细节
--- ---
@ -175,6 +179,8 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
| 页面几何模型完全等价 `WRCoreTextLayoutFrame` | ✅ 已实现 | - | | 页面几何模型完全等价 `WRCoreTextLayoutFrame` | ✅ 已实现 | - |
| `WRBookmark` 统一标注模型 | ✅ 已实现 | - | | `WRBookmark` 统一标注模型 | ✅ 已实现 | - |
| 多栏排版 | ✅ 已实现 | - | | 多栏排版 | ✅ 已实现 | - |
| `avoidOrphans / avoidWidows / hyphenation` 行为落地 | ✅ 已实现 | - |
| 纯文本/渲染失败 fallback 清理 | ⚠️ 仍保留纯文本 fallback 路径 | 低 |
| 字体动态加载 | ✅ 已实现 | - | | 字体动态加载 | ✅ 已实现 | - |
| 繁简转换 | ❌ 明确不实现 | - | | 繁简转换 | ❌ 明确不实现 | - |
| TTS / DRM / Pencil | ❌ 明确不实现 | - | | 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. 读书关键类职责速查 ## 10. 读书关键类职责速查
| 类名 | 职责 | ReadViewSDK 对应 | 核查 | | 类名 | 职责 | ReadViewSDK 对应 | 核查 |