新增大书优化实施方案(内存与主线程、快速进入阅读器)和 WXRead 内存策略分析文档, 更新架构对比分析文档,完善章节级缓存运行时、串行加载、轻量磁盘摘要等设计细节。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
460 lines
15 KiB
Markdown
460 lines
15 KiB
Markdown
# 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*
|