新增大书优化实施方案(内存与主线程、快速进入阅读器)和 WXRead 内存策略分析文档, 更新架构对比分析文档,完善章节级缓存运行时、串行加载、轻量磁盘摘要等设计细节。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
15 KiB
WXRead 阅读器内存优化方案
基于读书 v10.0.3 逆向代码分析,提炼 WXRead 处理大书内存的核心策略。 创建日期:2026-06-02
1. 核心设计原则
WXRead 的内存管理建立在一个关键架构决策之上:永远不持有全书数据模型。
与"先构建整本 TextBook 再进入阅读器"不同,WXRead 的数据流是:
单章 XHTML
→ WREpubTypesetter.attributeStringWithFilePath: (单章排版)
→ WRChapterData (单章数据模型)
→ WRChapterPageCount (单章分页)
→ WRPageView.drawRect: (单页渲染)
每一步都是章节级或页面级操作,从不需要同时持有全书的 NSAttributedString。
2. 章节级缓存模型
2.1 缓存结构
// WRReaderViewController ()
@property (nonatomic, strong) NSMutableDictionary<NSNumber *, WRChapterData *> *chapterDataCache;
@property (nonatomic, strong) NSMutableDictionary<NSString *, WRChapterPageCount *> *pageCountCache;
关键设计决策:
| 设计点 | 选择 | 原因 |
|---|---|---|
| 缓存类型 | NSMutableDictionary 而非 NSCache |
需要精确控制淘汰时机,NSCache 的自动淘汰不可预测 |
| 缓存粒度 | 按章节索引(NSNumber *) |
每章独立加载、独立释放 |
| 分页缓存 | 按缓存键(含排版设置) | 设置变更时整批失效 |
2.2 缓存内容
每个 WRChapterData 持有:
@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 监听注册
// WRReaderViewController.initWithBook:progress:...
[[NSNotificationCenter defaultCenter]
addObserver:self
selector:@selector(_handleMemoryWarning:)
name:UIApplicationDidReceiveMemoryWarningNotification
object:nil];
在 initWithBook: 中注册,确保从阅读器创建之初就监听。
3.2 处理逻辑
- (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,原因:
- 确定性:内存警告时必须立即释放,NSCache 的淘汰时机不可控
- 当前章保护:NSCache 无法 pin 住当前章不被淘汰
- 可预测性:开发和调试时行为一致,不会因系统内存压力变化而变化
4. 章节加载与预取
4.1 串行后台队列
// 初始化
_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 相邻章节预取
- (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];
});
}
}
}
触发时机:
renderPageView:渲染完成后(第 282 行)didFlipPage翻页完成后(第 409 行)
预取窗口:仅当前章 ± 1 章,不做全书预加载。这保证内存占用与用户实际阅读位置绑定。
5. 图片缓存
5.1 NSCache 限制
// WRCoreTextLayouter.commonInit
_imageCache = [[NSCache alloc] init];
_imageCache.countLimit = 50; // 最多缓存 50 张缩放后的图片
图片是内存大户,WXRead 对图片缓存的处理策略:
| 策略 | 实现 | 原因 |
|---|---|---|
使用 NSCache |
自动在内存压力时淘汰 | 图片可以重新生成,丢失代价低 |
countLimit = 50 |
限制数量 | 防止图片无限累积 |
| 按章节归属 | 每个 WRCoreTextLayouter 独立持有 |
章节释放时图片一起释放 |
5.2 CoreText 对象释放
// 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 预加载场景
typedef NS_ENUM(NSInteger, WRPreloadScene) {
WRPreloadSceneNone = 0,
WRPreloadSceneShelf = 1, // 书架页预加载
WRPreloadSceneReading = 2, // 阅读时预加载后续章节
WRPreloadSceneWiFi = 3, // WiFi 下激进预加载
WRPreloadSceneManual = 4, // 用户手动触发
};
6.2 缓存清理接口
// 清理指定书籍的预加载数据
+ (void)removeKVWithBookId:(NSString *)bookId;
// 清理全部预加载缓存
+ (void)clearKV;
// 计算并可选清理预加载缓存
- (void)calcAndClearPreloadBookWithCompletion:(void (^)(NSUInteger totalSize))completion
onlyCalc:(BOOL)onlyCalc;
预加载数据(已下载但未阅读的章节)存储在磁盘,不占用运行时内存。清理接口用于管理磁盘空间。
7. 排版重排的内存影响
7.1 设置变更时的缓存处理
- (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 全局设置变更
- (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 多字段模型
@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 保存时机
// 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 保存格式
- (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