# 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 *chapterDataCache; @property (nonatomic, strong) NSMutableDictionary *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 *pageRanges; // 页范围数组 @property (nonatomic, strong) NSArray *highlights; // 高亮 @property (nonatomic, strong) NSArray *underlines; // 下划线 @property (nonatomic, strong) NSSet *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` | 内存与阅读位置绑定,不随全书增长 | | **手动淘汰** | 内存警告时清空非当前章 | 确定性释放,当前章不中断 | | **串行加载** | `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*