30 KiB
EPUB 大书内存优化方案:首开分页落盘,二开恢复分页,运行时仅保留少量章节
适用场景:
textReflowable路径打开超大正文 EPUB,典型样本为《凡人修仙传》精校版全本(~1000 章节)。 目标:在保留全书完整页码(总页数、绝对页号、进度条、目录跳页)的前提下,将内存占用从“全书内容常驻”降至“仅当前少量章节内容常驻”。 主线:首开:分页并落盘;二开:直接恢复分页结果,不再重新分页;运行时:只加载当前少量章节内容。
1. 结论先行
当前大书内存问题的根因,不是“必须维护完整页码”,而是当前实现把两件事绑死了:
RDEPUBTextBook.pages.count负责全书总页数RDEPUBTextBook.pages同时又挂着整本书的内容页对象
最终效果是:
- 打开大书时先构建整本
RDEPUBTextBook - 快速进入后后台继续补齐整本书
- 完整
NSAttributedString和RDEPUBTextPage链长期常驻
本方案的核心调整是把“页码索引”和“内容缓存”拆开:
-
全书页码索引常驻
- 保存总页数
- 保存
absolutePageIndex <-> spineIndex/localPageIndex - 保存每章
pageRanges、fragmentOffsets
-
章节内容按需加载
- 只保留当前章 ±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 当前已有可复用基础
当前项目并不是从零开始,已经有三类非常关键的基础设施:
-
分页缓存
RDEPUBTextBookCacheRDEPUBPaginationCacheCoordinator- 已可缓存每章
pageRanges
-
章节摘要磁盘缓存
RDEPUBChapterSummaryDiskCache- 已可保存
pageRanges、pageCount、fragmentOffsets、pageMetadataList
-
章节运行时加载基础设施
RDEPUBChapterRuntimeStoreRDEPUBChapterLoaderRDEPUBChapterWindowCoordinator
所以本方案不是“推倒重来”,而是把这些能力串成一条主路径。
3. 目标方案总览
3.1 一句话流程
首开:
当前章先分页、显示、并把分页结果落盘
后台继续为其余章节生成分页结果并落盘
二开:
直接恢复分页结果,不再重新分页
只重建当前目标章节的内容对象
运行时:
RDReaderView 仍使用全书总页数
页面内容按绝对页号映射到当前章节内容
内存中只保留当前章 ±1 章
3.2 三层缓存结构
本方案明确区分三层缓存。
A. 磁盘分页缓存
职责:
- 保存每章分页结果
- 支撑二次打开“跳过重新分页”
内容:
pageRangespageCountfragmentOffsets- 轻量
pageMetadata renderSignatureschemaVersionchapterContentHash
对应当前类:
RDEPUBTextBookCacheRDEPUBChapterSummaryDiskCache
B. 内存轻量索引
职责:
- 提供完整页码能力
- 不持有整章文本
内容:
totalPagesabsolutePageIndex -> (spineIndex, localPageIndex)(spineIndex, localPageIndex) -> absolutePageIndex- 每章
pageRanges - 每章
fragmentOffsets
新增类:
RDEPUBBookPageMap
C. 内存内容缓存
职责:
- 支持当前阅读页渲染
- 支持选区、高亮、搜索跳转后的实际页面显示
内容:
RDEPUBRuntimeChaptertypesetAttributedStringRDEPUBRuntimePagelayouter
对应当前类:
RDEPUBChapterRuntimeStoreRDEPUBChapterLoader
4. 首开 / 二开 / 运行时详细流程
4.1 首开流程
目标
- 尽快看到正文
- 当前章节分页结果立即落盘
- 后台异步补齐全书分页结果
- 不再构建完整
RDEPUBTextBook
详细步骤
Step 1:恢复阅读位置
入口:
RDEPUBReaderLoadCoordinator.applyParsedPublication()RDEPUBReaderLocationCoordinator.persistenceLocation()
恢复结果至少确定:
targetSpineIndextargetChapterOffset或targetRangeAnchor
如果本地已有 bookPageMap,还可以进一步计算:
targetAbsolutePageIndex
Step 2:快速构建目标章节
入口:
RDEPUBReaderPaginationCoordinator.paginateTextPublication()
行为:
- 不调用
builder.build()全书构建 - 只调用
builder.buildChapter(...)或chapterLoader.loadChapter(...) - 构建目标章节
- 如有需要,顺手构建前后相邻章
输出:
RDEPUBRuntimeChapter- 当前章
pageRanges - 当前章
fragmentOffsets
Step 3:当前章节分页结果立刻落盘
当前项目已经有两个方向的缓存设施,建议统一如下:
-
RDEPUBTextBookCache- 继续作为每章
pageRanges的主缓存 - 负责高效读写分页范围
- 继续作为每章
-
RDEPUBChapterSummaryDiskCache- 保存更完整的章节摘要
- 包含:
pageRangespageCountfragmentOffsetspageMetadataListchapterContentHashrenderSignature
首开时,当前章节一旦分页完成,必须立刻写入这两层缓存中的统一主来源。
Step 4:立刻显示目标页
当前页展示不依赖全书完成,只依赖:
- 当前章节
RDEPUBRuntimeChapter - 当前章节分页结果
页面展示路径改为:
RDEPUBReaderController+DataSource.pageContentView(pageNum:)
-> pageResolver.resolvePage(absolutePageIndex:)
-> 命中当前运行时章节
-> 交给 RDEPUBTextContentView
Step 5:后台补齐全书分页结果
后台任务:
- 顺序遍历剩余可渲染章节
- 对每章执行:
- 构建单章分页
- 提取
pageRanges/pageCount/fragmentOffsets/pageMetadata - 写入磁盘缓存
- 立即释放章节内容
重要约束:
- 后台任务优先级低于用户交互
- 用户跳章/搜索跳转时,后台任务应暂停或让路
- 后台不要累积
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.totalPagespageContentView(pageNum:)通过pageResolver映射到目标章节页
二开最终状态
二开完成后:
- 全书页码立即可用
- 当前章节内容已加载
- 不需要整本重新分页
4.3 运行时流程
目标
- 全书页码不变
- 内容只围绕当前阅读位置保留
- 翻页时尽量无感
运行时规则
规则 1:RDReaderView 始终使用全书总页数
pageCountOfReaderView = bookPageMap.totalPages
不采用“窗口页数模式”。
规则 2:页面内容按绝对页号解析
新增:
RDEPUBPageResolver
职责:
- 输入:
absolutePageIndex - 输出:目标页的
RDEPUBResolvedPage
解析步骤:
bookPageMap.pageRef(forAbsolutePage:)- 得到
spineIndex + localPageIndex - 查看
chapterRuntimeStore是否已有该章 - 无则加载该章
- 返回运行时页和渲染上下文
规则 3:运行时只保留当前章 ±1 章
继续利用:
RDEPUBChapterRuntimeStoreRDEPUBChapterWindowCoordinator
策略:
- 当前章常驻
- 前后章预取
- 窗口外章节淘汰
规则 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 上的字段:
chapterContentpageEndOffsetabsolutePageIndexchapterIndexchapterTitletotalPagesInChapter
这些都应外置到渲染上下文或章节对象。
5.4 渲染上下文:RDEPUBResolvedPage
新增:
RDEPUBResolvedPageRDEPUBPageRenderContext
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
新增懒属性:
chapterRuntimeStorechapterLoadersummaryDiskCachepageResolver
新增方法:
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 列表
renderSignatureschemaVersionbookID
这样二开时才能可靠判断“是否可以完全跳过重新分页”。
6.5 RDEPUBTextBookCache.swift
当前已经能缓存章节分页范围。
建议职责收敛为:
- 高效保存 / 加载章节
pageRanges - 作为
ChapterSummaryDiskCache的底层快路径或兼容层
需要明确:
- 它缓存的是分页结果
- 不是整章富文本缓存
6.6 RDEPUBChapterLoader.swift
当前已经有关键能力:
- 缓存命中时跳过完整分页
- 轻量路径使用已有
pageRanges
这正是二开所需主路径。
建议增强:
- 统一从
summaryDiskCache获取章节摘要 - 加载章节时优先走:
- 读摘要
- 渲染 HTML
- 按缓存
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
这是第一优先级改造文件。
先删什么
- 删除或停用以下“后台补齐整本书”的结构:
QuickBuildStateStagedBookRequestIncrementalChapterStorestagedIncrementalApplyWorkItemstageBookRequest(...)consumeStagedBookRequest()scheduleStagedIncrementalTextBookApplication()applyStagedIncrementalTextBookIfPossible()textBook(from:orderedBy:)
- 删除“每 20 章 merge 成整本
RDEPUBTextBook”的后台路径:incrementalMergeChapterThresholdpendingIncrementalCount相关逻辑
- 删除“快速进入后仍然 apply 整本 TextBook”的后续逻辑
加什么
- 新增
restoreBookPageMapIfPossible(...)- 二开时优先恢复
BookPageMap - 命中则直接走缓存分页路径
- 二开时优先恢复
- 新增
paginateMetadataOnly(...)- 首开后台只生成章节摘要并落盘
- 不创建整本
RDEPUBTextBook
- 新增
buildInitialRuntimeChapters(...)- 只构建目标章节和前后章节
- 新增“前台优先”调度逻辑:
- 用户跳页 / 搜索跳转时,后台摘要任务暂停或让路
保留什么
paginatePublication(restoreLocation:)- 仍负责按阅读类型分发
applyPaginationSnapshot(...)- Fixed Layout / Web 内容仍可继续使用
finishPagination(...)- 作为 reloadData + restore 的统一收尾逻辑继续保留
rebuildExternalTextBook()- 外部 TXT 路径不动
waitForReadingInteractionToSettle()- 可继续作为后台任务让路机制
改造后的职责
改造后它只负责三件事:
- 首开时快速构建目标章节
- 后台补齐分页摘要并落盘
- 二开时恢复分页索引并切换到按需内容模式
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的强依赖要逐步降级
加什么
- 新增懒属性:
chapterRuntimeStorechapterLoadersummaryDiskCachepageResolver
- 新增
switchToOnDemandPageMode()- 将 context 中的 runtime 组件连起来
- 新增
clearOnDemandPageModeState()reloadBook()时清理bookPageMap/ runtime chapter 缓存
保留什么
loadCoordinatorlocationCoordinatorsearchCoordinatorannotationCoordinatorviewportMonitor
改造后的职责
- Runtime 继续做 façade
- 但主数据入口从
textBook切到:bookPageMappageResolverchapterRuntimeStore
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/bottomToolViewreaderViewOrientationWillChange
特别注意
当前 pageNum(...) 中有:
- “到达末页通知”
resolvedTextLocation(forPageNumber:)reconcileTextPaginationSizeIfNeeded
这些都默认“当前 pageNum 可直接映射到 textBook page”,改造时必须把它们切到:
absolutePageIndexresolvedPage.contextbookPageMap
7.5 RDEPUBChapterLoader.swift
先删什么
- 不删主结构
- 不删当前的轻量路径
- 但要避免继续把 loader 理解成“只服务章节窗口”
加什么
- 新增“优先恢复摘要”的统一路径:
- 先查
pageCountCache - 再查
summaryDiskCache - 最后才完整分页
- 先查
- 新增“构建后立即落盘摘要”的回调或委托接口
- 新增“输出轻量运行时页”的路径
- 减少
RDEPUBTextPage的冗余字段复制
- 减少
保留什么
loadChapter(...)loadChapterSynchronouslyForMigration(...)buildChapterFromCachedPageRanges(...)buildChapter(...)
特别注意
当前轻量路径已经具备二开主能力:
- 重新渲染 HTML
- 复用缓存
pageRanges - 跳过完整分页
这一点不要推翻,应该直接作为二开的标准实现路径。
7.6 RDEPUBChapterRuntimeStore.swift
先删什么
- 不删章节窗口模型
- 不删
chapterLoadQueue
加什么
- 增加“当前绝对页附近章节”的窗口维护辅助
- 增加“只保留当前章”的强制裁剪方法(虽然已有
evictAllExceptCurrent(),但要作为主策略写进流程) - 如有需要,增加“摘要已命中但内容未加载”的状态标记
保留什么
chapterDataCachepageCountCacheimageCachechapterLoadQueuesetCurrentChapter(...)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 - 第三阶段:
absolutePageIndex、chapterIndex、chapterTitle、totalPagesInChapter外置
- 第一阶段:
加什么
- 新增轻量运行时页模型(如果不想立刻重命名,可先新增 parallel model)
- 新增用于
resolvedPage的渲染上下文
保留什么
RDEPUBTextBook- 仍保留给外部文本书籍或兼容路径
RDEPUBTextChapter- 仍保留给 builder 输出
改造后的职责
- 从“全书常驻模型”退化为:
- builder 产物
- 兼容层
- 非大书路径使用
7.10 RDEPUBTextContentView.swift
先删什么
- 不立刻删当前渲染链
- 不立刻删对
RDEPUBTextPage的支持
加什么
- 新增接收
RDEPUBResolvedPage的configure(...)重载 - 页码显示读取
renderContext.absolutePageIndex chapterContentProvider用于:- 续段判断
- 选区提取
- 高亮定位
保留什么
- CoreText 渲染
- 封面判断
- 高亮叠加
- 搜索高亮
改造后的职责
- UI 层不再假设“page 自己携带全部上下文”
- 改为“page + renderContext”联合渲染
7.11 RDEPUBReaderLocationCoordinator.swift
先删什么
- 删除“位置恢复完全依赖
controller.pageNumber(for:)查textBook”的假设
加什么
- 新增:
location -> absolutePageIndexabsolutePageIndex -> 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:让当前项目先具备“首开落盘”
目标:
- 打开当前章后,确保分页结果立刻落盘
具体工作:
- 统一
RDEPUBTextBookCache与RDEPUBChapterSummaryDiskCache的写入时机 - 每次
buildChapter()完成后立即写缓存 - 摘要里必须包含:
pageRangespageCountfragmentOffsetspageMetadataListchapterContentHashrenderSignature
完成标志:
- 首开后退出,再打开同一本书,当前章能命中分页缓存
Phase 2:让项目具备“二开不再重新分页”
目标:
- 直接恢复 BookPageMap
- 只重建目标章节内容
具体工作:
- 增加
readAll(...) - 增加 BookPageMap 构建器
- 在
applyParsedPublication()时优先尝试恢复 BookPageMap chapterLoader优先走“缓存 pageRanges + 重新渲染当前章”的轻量路径
完成标志:
- 二开时不再执行全书
builder.build() - 日志可明确区分
paginate skipped (cache restored)
Phase 3:切断后台整本累积
目标:
- 后台只补齐摘要,不再补齐完整
RDEPUBTextBook
具体工作:
- 移除
IncrementalChapterStore的“整本累积职责” - 后台任务改成:
- 构建章节
- 提取摘要
- 落盘
- 释放
- 不再创建完整
textBook(from:orderedBy:)
完成标志:
- 后台补齐完成后,内存不会再继续线性上升
Phase 4:接通按绝对页号解析
目标:
- RDReaderView 继续显示全书总页数
- 页面内容按需加载
具体工作:
- 新增
RDEPUBBookPageMap - 新增
RDEPUBPageResolver DataSource改走 resolverTextContentView改为接受轻页 + 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. 最终落地建议
如果只按收益 / 风险排序,建议实际开发顺序为:
-
先打通首开落盘
- 当前章分页后立即缓存
-
再打通二开跳过重新分页
- 恢复 BookPageMap
- chapterLoader 走缓存 pageRanges 路径
-
最后切到按绝对页号按需加载
- 用 resolver 替代
textBook.page(at:)
- 用 resolver 替代
不要反过来。
原因很简单:
- “先改数据源,再补缓存”风险高
- “先把分页结果缓存用起来”收益最大、回归最小
本文档基于 2026-06-03 的代码状态编写,已调整为“首开分页落盘、二开恢复分页、运行时仅保留少量章节”的详细实施方案。