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
co-authored by Claude Opus 4.7
parent 1c6108061c
commit 488350d956
4 changed files with 3748 additions and 7 deletions
@@ -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 MB),WXRead 的策略将其控制在个位数 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*