ReadViewSDK/Doc/大书内存优化方案_元数据缓存与按需加载.md

30 KiB
Raw Blame History

EPUB 大书内存优化方案:首开分页落盘,二开恢复分页,运行时仅保留少量章节

适用场景:textReflowable 路径打开超大正文 EPUB典型样本为《凡人修仙传》精校版全本~1000 章节)。 目标:在保留全书完整页码(总页数、绝对页号、进度条、目录跳页)的前提下,将内存占用从“全书内容常驻”降至“仅当前少量章节内容常驻”。 主线:首开:分页并落盘;二开:直接恢复分页结果,不再重新分页;运行时:只加载当前少量章节内容。


1. 结论先行

当前大书内存问题的根因,不是“必须维护完整页码”,而是当前实现把两件事绑死了:

  1. RDEPUBTextBook.pages.count 负责全书总页数
  2. RDEPUBTextBook.pages 同时又挂着整本书的内容页对象

最终效果是:

  • 打开大书时先构建整本 RDEPUBTextBook
  • 快速进入后后台继续补齐整本书
  • 完整 NSAttributedStringRDEPUBTextPage 链长期常驻

本方案的核心调整是把“页码索引”和“内容缓存”拆开:

  • 全书页码索引常驻

    • 保存总页数
    • 保存 absolutePageIndex <-> spineIndex/localPageIndex
    • 保存每章 pageRangesfragmentOffsets
  • 章节内容按需加载

    • 只保留当前章 ±1 章
    • 其余章节只保留轻量分页结果
    • 内存警告时仅保留当前章

这样可以同时满足:

  • 完整页码不丢
  • 二次打开不再重新分页
  • 运行时内存不随全书章节数线性增长

2. 当前问题

2.1 当前打开路径

当前 textReflowable 路径大致是:

RDEPUBReaderPaginationCoordinator.paginateTextPublication()
  -> RDEPUBTextBookBuilder.build()
  -> 遍历全部 spine
  -> 每章 render + paginate
  -> 汇总成完整 RDEPUBTextBook
  -> context.textBook = textBook

即便已有“快速进入 + 后台补齐”,后台仍然会继续累积整本书。

2.2 当前主要内存来源

以当前模型看,主要内存压力来自:

模型 重字段 问题
RDEPUBTextChapter attributedContent 每章完整富文本常驻
RDEPUBTextPage chapterContent 每页再次强持有整章内容
RDEPUBTextPage content 每页片段富文本也会累计
RDEPUBTextBook pages / chapters 一旦全书完成,整本内容无法下降到“阅读位置附近”

2.3 当前已有可复用基础

当前项目并不是从零开始,已经有三类非常关键的基础设施:

  1. 分页缓存

    • RDEPUBTextBookCache
    • RDEPUBPaginationCacheCoordinator
    • 已可缓存每章 pageRanges
  2. 章节摘要磁盘缓存

    • RDEPUBChapterSummaryDiskCache
    • 已可保存 pageRangespageCountfragmentOffsetspageMetadataList
  3. 章节运行时加载基础设施

    • RDEPUBChapterRuntimeStore
    • RDEPUBChapterLoader
    • RDEPUBChapterWindowCoordinator

所以本方案不是“推倒重来”,而是把这些能力串成一条主路径。


3. 目标方案总览

3.1 一句话流程

首开:
  当前章先分页、显示、并把分页结果落盘
  后台继续为其余章节生成分页结果并落盘

二开:
  直接恢复分页结果,不再重新分页
  只重建当前目标章节的内容对象

运行时:
  RDReaderView 仍使用全书总页数
  页面内容按绝对页号映射到当前章节内容
  内存中只保留当前章 ±1 章

3.2 三层缓存结构

本方案明确区分三层缓存。

A. 磁盘分页缓存

职责:

  • 保存每章分页结果
  • 支撑二次打开“跳过重新分页”

内容:

  • pageRanges
  • pageCount
  • fragmentOffsets
  • 轻量 pageMetadata
  • renderSignature
  • schemaVersion
  • chapterContentHash

对应当前类:

  • RDEPUBTextBookCache
  • RDEPUBChapterSummaryDiskCache

B. 内存轻量索引

职责:

  • 提供完整页码能力
  • 不持有整章文本

内容:

  • totalPages
  • absolutePageIndex -> (spineIndex, localPageIndex)
  • (spineIndex, localPageIndex) -> absolutePageIndex
  • 每章 pageRanges
  • 每章 fragmentOffsets

新增类:

  • RDEPUBBookPageMap

C. 内存内容缓存

职责:

  • 支持当前阅读页渲染
  • 支持选区、高亮、搜索跳转后的实际页面显示

内容:

  • RDEPUBRuntimeChapter
  • typesetAttributedString
  • RDEPUBRuntimePage
  • layouter

对应当前类:

  • RDEPUBChapterRuntimeStore
  • RDEPUBChapterLoader

4. 首开 / 二开 / 运行时详细流程

4.1 首开流程

目标

  • 尽快看到正文
  • 当前章节分页结果立即落盘
  • 后台异步补齐全书分页结果
  • 不再构建完整 RDEPUBTextBook

详细步骤

Step 1恢复阅读位置

入口:

  • RDEPUBReaderLoadCoordinator.applyParsedPublication()
  • RDEPUBReaderLocationCoordinator.persistenceLocation()

恢复结果至少确定:

  • targetSpineIndex
  • targetChapterOffsettargetRangeAnchor

如果本地已有 bookPageMap,还可以进一步计算:

  • targetAbsolutePageIndex

Step 2快速构建目标章节

入口:

  • RDEPUBReaderPaginationCoordinator.paginateTextPublication()

行为:

  • 不调用 builder.build() 全书构建
  • 只调用 builder.buildChapter(...)chapterLoader.loadChapter(...)
  • 构建目标章节
  • 如有需要,顺手构建前后相邻章

输出:

  • RDEPUBRuntimeChapter
  • 当前章 pageRanges
  • 当前章 fragmentOffsets

Step 3当前章节分页结果立刻落盘

当前项目已经有两个方向的缓存设施,建议统一如下:

  1. RDEPUBTextBookCache

    • 继续作为每章 pageRanges 的主缓存
    • 负责高效读写分页范围
  2. RDEPUBChapterSummaryDiskCache

    • 保存更完整的章节摘要
    • 包含:
      • pageRanges
      • pageCount
      • fragmentOffsets
      • pageMetadataList
      • chapterContentHash
      • renderSignature

首开时,当前章节一旦分页完成,必须立刻写入这两层缓存中的统一主来源。

Step 4立刻显示目标页

当前页展示不依赖全书完成,只依赖:

  • 当前章节 RDEPUBRuntimeChapter
  • 当前章节分页结果

页面展示路径改为:

RDEPUBReaderController+DataSource.pageContentView(pageNum:)
  -> pageResolver.resolvePage(absolutePageIndex:)
  -> 命中当前运行时章节
  -> 交给 RDEPUBTextContentView

Step 5后台补齐全书分页结果

后台任务:

  • 顺序遍历剩余可渲染章节
  • 对每章执行:
    1. 构建单章分页
    2. 提取 pageRanges/pageCount/fragmentOffsets/pageMetadata
    3. 写入磁盘缓存
    4. 立即释放章节内容

重要约束:

  • 后台任务优先级低于用户交互
  • 用户跳章/搜索跳转时,后台任务应暂停或让路
  • 后台不要累积 RDEPUBTextChapter
  • 后台不要 merge 成完整 RDEPUBTextBook

首开最终状态

首开完成后,系统应处于:

  • 当前章内容在内存
  • 相邻章可选预取在内存
  • 已完成章节的分页结果在磁盘
  • 全书 BookPageMap 可逐步构建或在后台最终生成

4.2 二开流程

目标

  • 不再重新分页
  • 直接恢复全书页码索引
  • 只重建当前阅读点附近的章节内容

详细步骤

Step 1恢复分页索引

入口:

  • RDEPUBReaderLoadCoordinator.applyParsedPublication()

行为:

  • 判断磁盘分页缓存是否完整
  • 若完整:
    • 直接从 RDEPUBChapterSummaryDiskCache.readAll(...)
    • 构建 RDEPUBBookPageMap
    • 不再重新分页

“完整”的定义建议为:

  • 当前书的所有可渲染 spine 均存在缓存摘要
  • schemaVersion 匹配
  • renderSignature 匹配
  • chapterContentHash 匹配

Step 2恢复目标绝对页

恢复位置后:

  • RDEPUBLocation 算出目标章节和页内偏移
  • 或从 BookPageMap 直接恢复绝对页号

Step 3只加载目标章节内容

二开时即使已恢复了全书分页结果,也不需要整本内容。

只做:

  • chapterLoader.loadChapter(targetSpineIndex, ...)
  • 若缓存命中分页结果:
    • 重新渲染当前章 HTML
    • 按已缓存的 pageRanges 直接切页
    • 跳过完整分页流程

Step 4显示目标页

此时:

  • numberOfPages() 直接返回 bookPageMap.totalPages
  • pageContentView(pageNum:) 通过 pageResolver 映射到目标章节页

二开最终状态

二开完成后:

  • 全书页码立即可用
  • 当前章节内容已加载
  • 不需要整本重新分页

4.3 运行时流程

目标

  • 全书页码不变
  • 内容只围绕当前阅读位置保留
  • 翻页时尽量无感

运行时规则

规则 1RDReaderView 始终使用全书总页数

pageCountOfReaderView = bookPageMap.totalPages

不采用“窗口页数模式”。

规则 2页面内容按绝对页号解析

新增:

  • RDEPUBPageResolver

职责:

  • 输入:absolutePageIndex
  • 输出:目标页的 RDEPUBResolvedPage

解析步骤:

  1. bookPageMap.pageRef(forAbsolutePage:)
  2. 得到 spineIndex + localPageIndex
  3. 查看 chapterRuntimeStore 是否已有该章
  4. 无则加载该章
  5. 返回运行时页和渲染上下文

规则 3运行时只保留当前章 ±1 章

继续利用:

  • RDEPUBChapterRuntimeStore
  • RDEPUBChapterWindowCoordinator

策略:

  • 当前章常驻
  • 前后章预取
  • 窗口外章节淘汰

规则 4内存警告时只保留当前章

行为:

  • 保留当前章
  • 清空其余章节缓存
  • 清空图片缓存
  • 视情况清空内存级分页缓存
  • 保留 BookPageMap

5. 数据模型设计

5.1 全书轻量索引:RDEPUBBookPageMap

建议新增文件:

  • Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBBookPageMap.swift
struct RDEPUBAbsolutePageRef {
    let absolutePageIndex: Int
    let spineIndex: Int
    let localPageIndex: Int
    let href: String
}

struct RDEPUBLightPageMetadata {
    let breakReason: RDEPUBTextPageBreakReason
    let trailingFragmentID: String?
    let attachmentKinds: [RDEPUBTextAttachmentKind]
    let attachmentPlacements: [RDEPUBTextAttachmentPlacement]
}

struct RDEPUBBookPageMapEntry {
    let spineIndex: Int
    let href: String
    let title: String
    let absolutePageStart: Int
    let pageCount: Int
    let fragmentOffsets: [String: Int]
    let pageRanges: [NSRange]
    let pageMetadata: [RDEPUBLightPageMetadata]
}

struct RDEPUBBookPageMap {
    let entries: [RDEPUBBookPageMapEntry]
    let totalPages: Int

    func absolutePageIndex(spineIndex: Int, localPageIndex: Int) -> Int?
    func pageRef(forAbsolutePage absolutePage: Int) -> RDEPUBAbsolutePageRef?
    func chapter(forAbsolutePage absolutePage: Int) -> RDEPUBBookPageMapEntry?
}

职责:

  • 完整页码
  • 绝对页到章节页的映射
  • 目录跳页
  • 搜索跳页
  • 位置恢复

5.2 运行时内容:RDEPUBRuntimeChapter

当前已有:

  • RDEPUBRuntimeChapter

建议保留章节级重对象,不再让 page 自己强持有整章文本多份。

final class RDEPUBRuntimeChapter {
    let spineIndex: Int
    let href: String
    let title: String

    let typesetAttributedString: NSAttributedString
    let layouter: RDEPUBTextLayouter
    let pages: [RDEPUBRuntimePage]
    let chapterOffsetMap: RDEPUBChapterOffsetMap
}

5.3 运行时页:RDEPUBRuntimePage

建议让运行时页只保存渲染必需数据:

struct RDEPUBRuntimePage {
    let spineIndex: Int
    let href: String
    let localPageIndex: Int
    let contentRange: NSRange
    let pageStartOffset: Int
    let content: NSAttributedString
    let metadata: RDEPUBTextPageMetadata
}

不建议继续常驻在 page 上的字段:

  • chapterContent
  • pageEndOffset
  • absolutePageIndex
  • chapterIndex
  • chapterTitle
  • totalPagesInChapter

这些都应外置到渲染上下文或章节对象。

5.4 渲染上下文:RDEPUBResolvedPage

新增:

  • RDEPUBResolvedPage
  • RDEPUBPageRenderContext
struct RDEPUBPageRenderContext {
    let absolutePageIndex: Int
    let totalPages: Int
    let chapterTitle: String
    let totalPagesInChapter: Int
    let chapterContentProvider: () -> NSAttributedString
}

struct RDEPUBResolvedPage {
    let page: RDEPUBRuntimePage
    let context: RDEPUBPageRenderContext
}

这样:

  • 页模型变轻
  • UI 仍可显示完整页码
  • 选区/高亮/续段判断仍能拿到整章文本

6. 与当前项目类的映射

6.1 RDEPUBReaderContext.swift

新增属性:

var bookPageMap: RDEPUBBookPageMap?
var chapterRuntimeStore: RDEPUBChapterRuntimeStore?
var chapterLoader: RDEPUBChapterLoader?
var summaryDiskCache: RDEPUBChapterSummaryDiskCache?
var pageResolver: RDEPUBPageResolver?

作用:

  • textBook 不再是 EPUB 大书主路径的单一事实来源

6.2 RDEPUBReaderRuntime.swift

新增懒属性:

  • chapterRuntimeStore
  • chapterLoader
  • summaryDiskCache
  • pageResolver

新增方法:

  • switchToOnDemandPageMode()

职责:

  • 把运行时组件串起来
  • 在加载完成后切换到“绝对页号 + 按章内容”模式

6.3 RDEPUBReaderPaginationCoordinator.swift

这是主改造点。

当前问题

  • 快速进入后后台继续补齐整本 RDEPUBTextBook

目标改造

重写 paginateTextPublication()

路径 A分页缓存完整二开

  • 读取 BookPageMap
  • 加载目标章节
  • 切到按需内容模式

路径 B分页缓存不完整首开

  • 快速构建目标章节
  • 立刻显示
  • 后台执行 paginateMetadataOnly()
  • 逐章落盘,不再 merge 整本 RDEPUBTextBook

新增方法建议:

  • paginateMetadataOnly(...)
  • restoreBookPageMapIfPossible(...)
  • buildInitialRuntimeChapters(...)

6.4 RDEPUBChapterSummaryDiskCache.swift

当前已具备单章摘要缓存。

需要增强:

  • readAll(bookID:renderSignature:)
  • containsCompleteSet(...)
  • removeAll(bookID:renderSignature:)

新增建议:

  • 为全书维护一个 manifest记录
    • 缓存完成的 spineIndex 列表
    • renderSignature
    • schemaVersion
    • bookID

这样二开时才能可靠判断“是否可以完全跳过重新分页”。

6.5 RDEPUBTextBookCache.swift

当前已经能缓存章节分页范围。

建议职责收敛为:

  • 高效保存 / 加载章节 pageRanges
  • 作为 ChapterSummaryDiskCache 的底层快路径或兼容层

需要明确:

  • 它缓存的是分页结果
  • 不是整章富文本缓存

6.6 RDEPUBChapterLoader.swift

当前已经有关键能力:

  • 缓存命中时跳过完整分页
  • 轻量路径使用已有 pageRanges

这正是二开所需主路径。

建议增强:

  • 统一从 summaryDiskCache 获取章节摘要
  • 加载章节时优先走:
    1. 读摘要
    2. 渲染 HTML
    3. 按缓存 pageRanges 构建 RDEPUBRuntimePage
  • 仅缓存未命中时才真正重新分页

6.7 RDEPUBReaderController+DataSource.swift

这是 UI 接口层的主改造点。

改造前

pageCountOfReaderView -> textBook?.pages.count
pageContentView -> textBook.page(at:)

改造后

pageCountOfReaderView -> context.bookPageMap?.totalPages ?? 0
pageContentView -> context.pageResolver?.resolvePage(absolutePageIndex: pageNum)

要求:

  • RDReaderView 继续使用全书总页数
  • pageNum 始终是绝对页号
  • 页面内容按需解析

6.8 RDEPUBTextContentView.swift

第一阶段不要推翻整套渲染链,只需改输入模型。

建议:

  • 当前仍可兼容旧 RDEPUBTextPage
  • 逐步切为接收 RDEPUBResolvedPage

需要调整的点:

  • 页码标签使用 absolutePageIndex / totalPages
  • 选区、高亮、续段判断改为使用 chapterContentProvider
  • 不再依赖 page.chapterContent 常驻

7. 按文件的具体改动清单

这一节不再讲“方向”,而是按当前项目的真实文件结构,给出先删什么、加什么、保留什么

7.1 RDEPUBReaderPaginationCoordinator.swift

这是第一优先级改造文件。

先删什么

  • 删除或停用以下“后台补齐整本书”的结构:
    • QuickBuildState
    • StagedBookRequest
    • IncrementalChapterStore
    • stagedIncrementalApplyWorkItem
    • stageBookRequest(...)
    • consumeStagedBookRequest()
    • scheduleStagedIncrementalTextBookApplication()
    • applyStagedIncrementalTextBookIfPossible()
    • textBook(from:orderedBy:)
  • 删除“每 20 章 merge 成整本 RDEPUBTextBook”的后台路径:
    • incrementalMergeChapterThreshold
    • pendingIncrementalCount 相关逻辑
  • 删除“快速进入后仍然 apply 整本 TextBook”的后续逻辑

加什么

  • 新增 restoreBookPageMapIfPossible(...)
    • 二开时优先恢复 BookPageMap
    • 命中则直接走缓存分页路径
  • 新增 paginateMetadataOnly(...)
    • 首开后台只生成章节摘要并落盘
    • 不创建整本 RDEPUBTextBook
  • 新增 buildInitialRuntimeChapters(...)
    • 只构建目标章节和前后章节
  • 新增“前台优先”调度逻辑:
    • 用户跳页 / 搜索跳转时,后台摘要任务暂停或让路

保留什么

  • paginatePublication(restoreLocation:)
    • 仍负责按阅读类型分发
  • applyPaginationSnapshot(...)
    • Fixed Layout / Web 内容仍可继续使用
  • finishPagination(...)
    • 作为 reloadData + restore 的统一收尾逻辑继续保留
  • rebuildExternalTextBook()
    • 外部 TXT 路径不动
  • waitForReadingInteractionToSettle()
    • 可继续作为后台任务让路机制

改造后的职责

改造后它只负责三件事:

  1. 首开时快速构建目标章节
  2. 后台补齐分页摘要并落盘
  3. 二开时恢复分页索引并切换到按需内容模式

7.2 RDEPUBReaderContext.swift

先删什么

  • 不删现有字段,但要降低 textBook 的中心地位
  • 不再把 textBook 当成 EPUB 大书路径的唯一事实来源

加什么

  • 新增状态:
    • var bookPageMap: RDEPUBBookPageMap?
    • var chapterRuntimeStore: RDEPUBChapterRuntimeStore?
    • var chapterLoader: RDEPUBChapterLoader?
    • var summaryDiskCache: RDEPUBChapterSummaryDiskCache?
    • var pageResolver: RDEPUBPageResolver?
  • 新增工厂方法(可选):
    • makeChapterSummaryDiskCache()
    • makePageResolver()

保留什么

  • textBookCache
    • 仍作为分页缓存入口保留
  • makeTextBookBuilder(...)
    • 仍用于单章构建
  • currentTextPageSize()
    • chapterLoader 仍需依赖
  • currentTextRenderStyle()
    • chapterLoader 仍需依赖
  • currentTextLayoutConfig(pageSize:)
    • chapterLoader 仍需依赖

改造后的职责

  • 上下文中同时持有:
    • “全书轻量索引”
    • “当前少量章节内容缓存”
  • textBook 仅作为:
    • 外部文本书籍路径
    • 或迁移期间兼容桥接

7.3 RDEPUBReaderRuntime.swift

先删什么

  • 不需要立即删除现有 coordinator
  • go(toPageNumber:) 中对 textBook 的强依赖要逐步降级

加什么

  • 新增懒属性:
    • chapterRuntimeStore
    • chapterLoader
    • summaryDiskCache
    • pageResolver
  • 新增 switchToOnDemandPageMode()
    • 将 context 中的 runtime 组件连起来
  • 新增 clearOnDemandPageModeState()
    • reloadBook() 时清理 bookPageMap / runtime chapter 缓存

保留什么

  • loadCoordinator
  • locationCoordinator
  • searchCoordinator
  • annotationCoordinator
  • viewportMonitor

改造后的职责

  • Runtime 继续做 façade
  • 但主数据入口从 textBook 切到:
    • bookPageMap
    • pageResolver
    • chapterRuntimeStore

7.4 RDEPUBReaderController+DataSource.swift

先删什么

  • 删除对 EPUB 大书主路径下 textBook.page(at:) 的直接依赖
  • 删除“页数由 textBook.pages.count 决定”的唯一入口

加什么

  • pageCountOfReaderView
    • 优先返回 readerContext.bookPageMap?.totalPages
  • pageContentView
    • 优先走 readerContext.pageResolver?.resolvePage(absolutePageIndex:)
    • 命中后组装 RDEPUBTextContentView
  • pageIdentifier
    • 允许增加基于 resolvedPage 的文本类型分支
  • pageNum
    • 保持绝对页号语义
    • 在靠近章节边界时触发预取

保留什么

  • Web/Fixed Layout 回退分支
  • topToolView / bottomToolView
  • readerViewOrientationWillChange

特别注意

当前 pageNum(...) 中有:

  • “到达末页通知”
  • resolvedTextLocation(forPageNumber:)
  • reconcileTextPaginationSizeIfNeeded

这些都默认“当前 pageNum 可直接映射到 textBook page”改造时必须把它们切到

  • absolutePageIndex
  • resolvedPage.context
  • bookPageMap

7.5 RDEPUBChapterLoader.swift

先删什么

  • 不删主结构
  • 不删当前的轻量路径
  • 但要避免继续把 loader 理解成“只服务章节窗口”

加什么

  • 新增“优先恢复摘要”的统一路径:
    • 先查 pageCountCache
    • 再查 summaryDiskCache
    • 最后才完整分页
  • 新增“构建后立即落盘摘要”的回调或委托接口
  • 新增“输出轻量运行时页”的路径
    • 减少 RDEPUBTextPage 的冗余字段复制

保留什么

  • loadChapter(...)
  • loadChapterSynchronouslyForMigration(...)
  • buildChapterFromCachedPageRanges(...)
  • buildChapter(...)

特别注意

当前轻量路径已经具备二开主能力:

  • 重新渲染 HTML
  • 复用缓存 pageRanges
  • 跳过完整分页

这一点不要推翻,应该直接作为二开的标准实现路径。

7.6 RDEPUBChapterRuntimeStore.swift

先删什么

  • 不删章节窗口模型
  • 不删 chapterLoadQueue

加什么

  • 增加“当前绝对页附近章节”的窗口维护辅助
  • 增加“只保留当前章”的强制裁剪方法(虽然已有 evictAllExceptCurrent(),但要作为主策略写进流程)
  • 如有需要,增加“摘要已命中但内容未加载”的状态标记

保留什么

  • chapterDataCache
  • pageCountCache
  • imageCache
  • chapterLoadQueue
  • setCurrentChapter(...)
  • evictableSpineIndices()
  • handleMemoryWarning()

改造后的职责

  • 继续做内存中的“少量章节内容缓存”
  • 不承担全书页码职责

7.7 RDEPUBChapterSummaryDiskCache.swift

先删什么

  • 不删现有单章读写接口

加什么

  • readAll(bookID:renderSignature:)
  • writeManifest(...)
  • readManifest(...)
  • containsCompleteSet(...)
  • removeAll(bookID:renderSignature:)

保留什么

  • write(summary:for:)
  • read(for:)

改造后的职责

  • 从“单章摘要缓存”升级为“二开恢复全书分页索引的主数据源”

7.8 RDEPUBTextBookCache.swift

先删什么

  • 不删当前归档结构
  • 不急着删 NSKeyedArchiver 路径

加什么

  • 明确每章级读取能力(若当前只有整包读写,可补章节级辅助接口)
  • 明确与 summaryDiskCache 的职责边界:
    • 它负责快速页范围缓存
    • summaryDiskCache 负责更完整摘要和全书恢复

保留什么

  • cacheKey(...)
  • load(key:)
  • save(_:key:)
  • invalidateAll()

改造后的职责

  • 继续作为分页结果缓存
  • 不扩展成富文本缓存

7.9 RDEPUBTextBookModels.swift

先删什么

  • 分阶段删减 RDEPUBTextPage 中的重字段:
    • 第一阶段:pageEndOffset
    • 第二阶段:chapterContent
    • 第三阶段:absolutePageIndexchapterIndexchapterTitletotalPagesInChapter 外置

加什么

  • 新增轻量运行时页模型(如果不想立刻重命名,可先新增 parallel model
  • 新增用于 resolvedPage 的渲染上下文

保留什么

  • RDEPUBTextBook
    • 仍保留给外部文本书籍或兼容路径
  • RDEPUBTextChapter
    • 仍保留给 builder 输出

改造后的职责

  • 从“全书常驻模型”退化为:
    • builder 产物
    • 兼容层
    • 非大书路径使用

7.10 RDEPUBTextContentView.swift

先删什么

  • 不立刻删当前渲染链
  • 不立刻删对 RDEPUBTextPage 的支持

加什么

  • 新增接收 RDEPUBResolvedPageconfigure(...) 重载
  • 页码显示读取 renderContext.absolutePageIndex
  • chapterContentProvider 用于:
    • 续段判断
    • 选区提取
    • 高亮定位

保留什么

  • CoreText 渲染
  • 封面判断
  • 高亮叠加
  • 搜索高亮

改造后的职责

  • UI 层不再假设“page 自己携带全部上下文”
  • 改为“page + renderContext”联合渲染

7.11 RDEPUBReaderLocationCoordinator.swift

先删什么

  • 删除“位置恢复完全依赖 controller.pageNumber(for:)textBook”的假设

加什么

  • 新增:
    • location -> absolutePageIndex
    • absolutePageIndex -> resolver
  • 当前可见位置改为从:
    • 当前绝对页号
    • 当前 runtime chapter 的 offset map
    • rangeAnchor 共同恢复

保留什么

  • persist(location:)
  • persistenceLocation()

7.12 RDEPUBReaderSearchCoordinator.swift

先删什么

  • 不删 HTML-based 搜索分支

加什么

  • 搜索结果导航优先映射到绝对页号
  • 如果目标章节未加载,则通过 resolver / loader 先加载再跳

保留什么

  • RDEPUBHTMLSearchEngine
  • 搜索结果状态机

7.13 RDEPUBReaderViewportMonitor.swift

先删什么

  • 不删当前视口变化检测

加什么

  • 视口变化时:
    • 清空运行时章节缓存
    • 失效 BookPageMap
    • 重新跑当前书分页摘要恢复 / 重建

保留什么

  • capturePendingPresentationRestoreLocation()
  • processPendingChangeAfterPagination()

8. 详细实施步骤

Phase 1让当前项目先具备“首开落盘”

目标:

  • 打开当前章后,确保分页结果立刻落盘

具体工作:

  1. 统一 RDEPUBTextBookCacheRDEPUBChapterSummaryDiskCache 的写入时机
  2. 每次 buildChapter() 完成后立即写缓存
  3. 摘要里必须包含:
    • pageRanges
    • pageCount
    • fragmentOffsets
    • pageMetadataList
    • chapterContentHash
    • renderSignature

完成标志:

  • 首开后退出,再打开同一本书,当前章能命中分页缓存

Phase 2让项目具备“二开不再重新分页”

目标:

  • 直接恢复 BookPageMap
  • 只重建目标章节内容

具体工作:

  1. 增加 readAll(...)
  2. 增加 BookPageMap 构建器
  3. applyParsedPublication() 时优先尝试恢复 BookPageMap
  4. chapterLoader 优先走“缓存 pageRanges + 重新渲染当前章”的轻量路径

完成标志:

  • 二开时不再执行全书 builder.build()
  • 日志可明确区分 paginate skipped (cache restored)

Phase 3切断后台整本累积

目标:

  • 后台只补齐摘要,不再补齐完整 RDEPUBTextBook

具体工作:

  1. 移除 IncrementalChapterStore 的“整本累积职责”
  2. 后台任务改成:
    • 构建章节
    • 提取摘要
    • 落盘
    • 释放
  3. 不再创建完整 textBook(from:orderedBy:)

完成标志:

  • 后台补齐完成后,内存不会再继续线性上升

Phase 4接通按绝对页号解析

目标:

  • RDReaderView 继续显示全书总页数
  • 页面内容按需加载

具体工作:

  1. 新增 RDEPUBBookPageMap
  2. 新增 RDEPUBPageResolver
  3. DataSource 改走 resolver
  4. TextContentView 改为接受轻页 + render context

完成标志:

  • numberOfPages() 始终是全书总页数
  • 打开超大书时内存仍仅与当前少量章节相关

9. 详细实施步骤

风险 1缓存完整性判断不可靠

问题:

  • 部分章节有缓存、部分没有时,二开可能误判

对策:

  • 增加 manifest
  • 二开只在“全量完整”时才完全跳过重新分页

风险 2轻量页模型改造影响选区和高亮

问题:

  • RDEPUBTextContentView 目前对 chapterContent 有依赖

对策:

  • 通过 chapterContentProvider 过渡
  • 第一阶段保留旧字段,第二阶段再彻底去掉

风险 3后台摘要任务和前台跳页抢资源

问题:

  • 可能导致首屏后翻页卡顿

对策:

  • 串行章节加载队列
  • 前台优先,后台可暂停
  • 严禁多章并发重分页

风险 4分页缓存命中但内容渲染仍偏慢

问题:

  • 二开虽然不分页,但仍需重新渲染当前章 HTML

对策:

  • 接受这是一版可行折中
  • 第一阶段先解决“重复分页”
  • 后续再评估是否缓存更轻的中间渲染结果

10. 风险与对策

9.1 首开

要求:

  • 首次打开《凡人修仙传》时1~2 秒内可见正文
  • 当前章分页结果成功落盘
  • 后台开始补齐其他章节摘要

9.2 二开

要求:

  • 不再触发全书重新分页
  • 当前章直接走缓存分页路径
  • 全书总页数立刻可用

9.3 运行时

要求:

  • 翻页时总页数不变
  • 页码显示为完整绝对页号
  • 运行时内存仅和当前章 ±1 章相关

9.4 内存警告

要求:

  • 仅保留当前章
  • 书不退回、不重开、不丢页码
  • BookPageMap 保留

11. 验证标准

参考 WXRead 的地方:

  • 永远不持有全书内容模型
  • 只缓存当前章 ±1 章
  • 章节加载串行化
  • 分页结果可缓存
  • 内存警告时只保留当前章

不直接照搬的地方:

  • WXRead 主要依赖章节级页码 / 字符偏移恢复
  • 本项目需要保留完整全书页码
  • 所以必须在 WXRead 思路上额外增加 BookPageMap

一句话概括:

WXRead 提供的是“章节级低内存阅读器”思路;本方案是在它之上补出“完整全书页码”的产品层。


12. 与 WXRead 的关系

参考 WXRead 的地方:

  • 永远不持有全书内容模型
  • 只缓存当前章 ±1 章
  • 章节加载串行化
  • 分页结果可缓存
  • 内存警告时只保留当前章

不直接照搬的地方:

  • WXRead 主要依赖章节级页码 / 字符偏移恢复
  • 本项目需要保留完整全书页码
  • 所以必须在 WXRead 思路上额外增加 BookPageMap

一句话概括:

WXRead 提供的是“章节级低内存阅读器”思路;本方案是在它之上补出“完整全书页码”的产品层。


13. 最终落地建议

如果只按收益 / 风险排序,建议实际开发顺序为:

  1. 先打通首开落盘

    • 当前章分页后立即缓存
  2. 再打通二开跳过重新分页

    • 恢复 BookPageMap
    • chapterLoader 走缓存 pageRanges 路径
  3. 最后切到按绝对页号按需加载

    • 用 resolver 替代 textBook.page(at:)

不要反过来。

原因很简单:

  • “先改数据源,再补缓存”风险高
  • “先把分页结果缓存用起来”收益最大、回归最小

本文档基于 2026-06-03 的代码状态编写,已调整为“首开分页落盘、二开恢复分页、运行时仅保留少量章节”的详细实施方案。