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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 21:16:50 +08:00

460 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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*