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

15 KiB
Raw Blame History

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,原因:

  1. 确定性内存警告时必须立即释放NSCache 的淘汰时机不可控
  2. 当前章保护NSCache 无法 pin 住当前章不被淘汰
  3. 可预测性:开发和调试时行为一致,不会因系统内存压力变化而变化

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];
            });
        }
    }
}

触发时机

  1. renderPageView: 渲染完成后(第 282 行)
  2. 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;
    }
}

CTTypesetterCTFramesetter 是 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 MBWXRead 的策略将其控制在个位数 MB。


10. 策略总结

策略 实现 效果
章节级缓存 NSMutableDictionary<NSNumber *, WRChapterData *> 内存与阅读位置绑定,不随全书增长
手动淘汰 内存警告时清空非当前章 确定性释放,当前章不中断
串行加载 com.weread.chapterload 队列 避免并发加载内存峰值叠加
±1 预取 翻页后异步预取相邻章 平衡流畅性与内存占用
图片 NSCache countLimit = 50 图片自动淘汰,章节释放时一起释放
C 对象显式释放 deallocCFRelease 防止 CoreText 内存泄漏
不分页全书 按章节独立分页 避免持有全书页范围数组
不持有全书索引 无全局 RDEPUBTextIndexTable 等价物 按需查询,非常驻

附录WXRead 关键文件索引

文件 职责 内存相关
WRReaderViewController.m 阅读器主控制器 chapterDataCache、内存警告处理、预取
WRChapterData.h/m 章节数据模型 持有 typesetAttributedStringlayouter
WRCoreTextLayouter.h/m CoreText 排版器 图片缓存 NSCache(countLimit=50)、C 对象释放
WRChapterPageCount.h/m 章节分页计算 pageRanges 数组
WRPreloadBookManager.h/m 预加载管理 磁盘缓存管理,不占运行时内存
WREpubTypesetter.m EPUB 排版器 单章排版,不持有全书状态

基于读书 v10.0.3 逆向分析 文档创建2026-06-02