新增大书优化实施方案(内存与主线程、快速进入阅读器)和 WXRead 内存策略分析文档, 更新架构对比分析文档,完善章节级缓存运行时、串行加载、轻量磁盘摘要等设计细节。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
439 lines
15 KiB
Markdown
439 lines
15 KiB
Markdown
# 《凡人修仙传》快速进入阅读器方案
|
||
|
||
> 适用场景:`textReflowable` 路径打开超大正文 EPUB,典型样本为《凡人修仙传》精校版全本。
|
||
> 目标:把“进入阅读器前必须等全书分页完成”改成“先快速可读,再后台补齐全书能力”。
|
||
> 结论先行:当前首屏慢的主因不是单章分页太慢,而是 **打开流程要求先完成全书 `RDEPUBTextBookBuilder.build()`**。
|
||
|
||
---
|
||
|
||
## 1. 当前慢在哪里
|
||
|
||
结合当前代码,打开 reflowable EPUB 的主链路是:
|
||
|
||
```text
|
||
RDEPUBReaderController.viewDidLoad
|
||
-> RDEPUBReaderLoadCoordinator.loadPublication()
|
||
-> applyParsedPublication(...)
|
||
-> RDEPUBReaderPaginationCoordinator.paginatePublication()
|
||
-> if publication.readingProfile == .textReflowable
|
||
-> RDEPUBTextBookBuilder.build(...)
|
||
-> 遍历全部 spine item
|
||
-> 每章 render + paginate
|
||
-> 汇总成完整 RDEPUBTextBook
|
||
-> applyTextBook(...)
|
||
-> readerView.reloadData()
|
||
-> restoreReadingLocation(...)
|
||
```
|
||
|
||
关键事实:
|
||
|
||
- [RDEPUBReaderPaginationCoordinator.swift](/Users/shenlei/Work/ReadViewSDK/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift) 在 `textReflowable` 路径里会先 `showLoading()`,然后后台执行 `builder.build(...)`,构建完成前不会进入正文。
|
||
- [RDEPUBTextBookBuilder.swift](/Users/shenlei/Work/ReadViewSDK/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift) 的 `build()` 会遍历全部 `publication.spine`,逐章完成:
|
||
- HTML 读取
|
||
- `DTCoreText` 渲染
|
||
- CoreText 分页
|
||
- 页面模型组装
|
||
- 全书 `RDEPUBTextBook` 汇总
|
||
- 也就是说,对《凡人修仙传》这种章节数多、正文长的大书,当前实际是“**全书构建完成后才能看到第一页**”。
|
||
|
||
这条链路的体验问题是:
|
||
|
||
- 首屏等待时间和“全书总字数/总章节数”线性相关,而不是和“当前阅读位置附近内容规模”相关。
|
||
- 即使用户只想看第一页,也要先支付整本书的 render + paginate 成本。
|
||
- 恢复到历史位置时也是同样问题,因为当前恢复逻辑依赖完整 `RDEPUBTextBook` 的页码与位置映射。
|
||
|
||
---
|
||
|
||
## 2. 方案目标
|
||
|
||
### 用户目标
|
||
|
||
- 点击书籍后,阅读器应尽快进入正文页,而不是长时间停留在 loading。
|
||
- 即使全书尚未构建完成,也至少能:
|
||
- 看到当前章节
|
||
- 翻当前章节内的页
|
||
- 恢复到“接近上次阅读位置”的章节
|
||
|
||
### 技术目标
|
||
|
||
- 首屏进入从“全书 ready”改成“当前章节 ready”。
|
||
- 全书构建改为后台增量完成。
|
||
- 不破坏现有 `RDEPUBReaderController` / `RDReaderView` 的主公开 API。
|
||
- 保留当前 `RDEPUBTextBookCache` 的价值,但把缓存粒度从“整本书一次命中”扩展到“单章可命中”。
|
||
|
||
### 首期验收指标
|
||
|
||
- 大书首次打开时,进入正文的等待时间显著短于当前实现。
|
||
- 首屏进入只依赖“当前章节”或“当前位置附近章节”构建完成。
|
||
- 后台继续构建剩余章节时,不阻塞阅读。
|
||
|
||
---
|
||
|
||
## 3. 推荐方案:两阶段进入 + 章节级增量构建
|
||
|
||
## 阶段 A:快速进入
|
||
|
||
打开书后只做这些事情:
|
||
|
||
1. 解析 EPUB 基础元数据、spine、TOC
|
||
2. 确定恢复位置对应的 `spineIndex`
|
||
3. 只构建当前章节,必要时附带相邻 `±1` 章
|
||
4. 先生成一个“局部 TextBook / 局部 Snapshot”
|
||
5. 立即进入阅读器并恢复到该章节内位置
|
||
|
||
这一阶段的原则是:
|
||
|
||
- 先解决“能进入”
|
||
- 不要求立刻具备全书搜索、全书目录页码、全书绝对页码精度
|
||
|
||
## 阶段 B:后台补全
|
||
|
||
进入正文后,再后台串行完成:
|
||
|
||
1. 当前章节相邻章节预构建
|
||
2. 剩余章节逐步构建
|
||
3. 持续补齐全书:
|
||
- `RDEPUBTextBook.chapters`
|
||
- `RDEPUBTextBook.pages`
|
||
- `RDEPUBTextIndexTable`
|
||
- 全书页码/位置映射
|
||
4. 构建完成后静默替换到完整模型
|
||
|
||
这样用户的体感是:
|
||
|
||
- 很快进书
|
||
- 越读越完整
|
||
- 而不是“先等很久,之后一次性全有”
|
||
|
||
---
|
||
|
||
## 4. 为什么这是最快可落地的方案
|
||
|
||
相比继续优化单次 `build()` 的 CPU 细节,这个方案收益更直接:
|
||
|
||
- 《凡人修仙传》的核心问题是“全书串行工作量太大”,不是“当前章节单章慢到不可接受”。
|
||
- 当前代码已经天然按“章节”组织:
|
||
- `publication.spine`
|
||
- `RDEPUBTextChapter`
|
||
- `RDEPUBChapterData`
|
||
- 每章 `render + paginate`
|
||
- [阅读器功能开发计划.md](/Users/shenlei/Work/ReadViewSDK/Doc/阅读器功能开发计划.md) 里也已经明确把“增量构建”列为方向,说明这条路和现有架构一致。
|
||
|
||
换句话说,这不是推翻重做,而是把现有 `RDEPUBTextBookBuilder.build()` 从“必须一次性跑完整本书”拆成“可按章节单独执行”。
|
||
|
||
---
|
||
|
||
## 5. 具体改造点
|
||
|
||
## 5.1 构建层:把全书构建拆成章节级能力
|
||
|
||
当前:
|
||
|
||
- [RDEPUBTextBookBuilder.swift](/Users/shenlei/Work/ReadViewSDK/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift) 只有全书 `build(...)`
|
||
|
||
建议新增:
|
||
|
||
```swift
|
||
func buildChapter(
|
||
parser: RDEPUBParser,
|
||
publication: RDEPUBPublication,
|
||
spineIndex: Int,
|
||
pageSize: CGSize,
|
||
style: RDEPUBTextRenderStyle
|
||
) throws -> RDEPUBTextChapterBuildResult
|
||
```
|
||
|
||
建议返回:
|
||
|
||
- `chapter: RDEPUBTextChapter`
|
||
- `paginationDiagnostic`
|
||
- `resourceDiagnostics`
|
||
- `performanceSample`
|
||
|
||
这样做的好处:
|
||
|
||
- 章节构建逻辑可以和现有 `build()` 共享
|
||
- 后续“首屏只构建一章”和“后台补全整本书”都走同一套实现
|
||
|
||
## 5.2 数据层:允许 TextBook 从“不完整”逐步变完整
|
||
|
||
当前:
|
||
|
||
- `RDEPUBTextBook` 默认假设 `chapters/pages/indexTable` 已经是完整全书
|
||
|
||
建议新增一个运行时模型,例如:
|
||
|
||
```swift
|
||
final class RDEPUBIncrementalTextBookStore
|
||
```
|
||
|
||
职责:
|
||
|
||
- 保存已完成构建的章节
|
||
- 维护 `spineIndex -> chapter` 映射
|
||
- 动态生成当前可用的 pages snapshot
|
||
- 在“部分章节可用”时提供章节内阅读支持
|
||
|
||
建议暴露能力:
|
||
|
||
- `chapter(forSpineIndex:)`
|
||
- `availablePagesSnapshot()`
|
||
- `merge(chapter:)`
|
||
- `isChapterReady(_:)`
|
||
- `readyChapterRange(around:)`
|
||
|
||
原因:
|
||
|
||
- `RDEPUBTextBook` 更适合“完整产物”
|
||
- 增量加载需要一个“半成品但可读”的状态容器
|
||
|
||
## 5.3 分页协调层:把一次性 loading 改成两阶段 loading
|
||
|
||
当前:
|
||
|
||
- [RDEPUBReaderPaginationCoordinator.swift](/Users/shenlei/Work/ReadViewSDK/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift) 中 `paginatePublication()` 直接把全书构建作为进入阅读器前置条件
|
||
|
||
建议改成:
|
||
|
||
### Step 1:首次只构建目标章节
|
||
|
||
- 根据 `restoreLocation` 算出目标 `spineIndex`
|
||
- 若无恢复位置,默认 `spineIndex = 0`
|
||
- 只构建该章,必要时加 `±1` 章
|
||
|
||
### Step 2:先应用局部 snapshot
|
||
|
||
- `readerView.reloadData()`
|
||
- 允许用户开始阅读
|
||
- tool chrome 可先显示,但某些依赖全书的能力先降级
|
||
|
||
### Step 3:后台继续全书补建
|
||
|
||
- 串行队列逐章构建剩余章节
|
||
- 每完成一章,就 merge 进 store
|
||
- 必要时再刷新目录页码、搜索索引、页码总数
|
||
|
||
## 5.4 位置恢复:首期优先恢复“章节”,二期补齐“页内精度”
|
||
|
||
当前:
|
||
|
||
- 恢复逻辑大量依赖完整 `RDEPUBTextBook` 的 pageNumber / location 映射
|
||
|
||
首期建议:
|
||
|
||
- 打开时先把恢复目标收敛到 `spineIndex + fragment/rangeAnchor`
|
||
- 只要目标章节 ready,就先进入该章节
|
||
- 若该章节内页内恢复信息尚未完整,先恢复到该章节接近位置
|
||
|
||
这样能显著减少“为了恢复精确页码,必须先构建全书”的耦合。
|
||
|
||
## 5.5 缓存层:从整书缓存扩展到单章缓存
|
||
|
||
当前:
|
||
|
||
- `RDEPUBPaginationCacheCoordinator` / `RDEPUBTextBookCache` 更偏整书结果
|
||
|
||
建议:
|
||
|
||
- 缓存 key 增加 `spineIndex`
|
||
- 支持单章 page ranges 命中
|
||
- 首次打开大书时,优先读取:
|
||
- 当前章节缓存
|
||
- 相邻章节缓存
|
||
|
||
收益:
|
||
|
||
- 同一本大书二次打开时,可直接秒开到当前章节
|
||
- 不用再等待整本书缓存完全重建
|
||
|
||
---
|
||
|
||
## 6. 首期最小可交付版本
|
||
|
||
为了最快解决《凡人修仙传》慢启动,建议首期只做下面这些:
|
||
|
||
1. 为 `RDEPUBTextBookBuilder` 抽出章节级构建接口
|
||
2. `RDEPUBReaderPaginationCoordinator` 首次打开时只构建目标章节
|
||
3. 用“局部 pages snapshot”驱动 `RDReaderView`
|
||
4. 后台串行构建剩余章节
|
||
5. 当前章节缓存命中优先
|
||
|
||
首期明确不做:
|
||
|
||
- 全书搜索实时可用
|
||
- 全书总页数一开始就准确
|
||
- 目录面板一开始就显示所有章节页码
|
||
|
||
这些能力可以在后台补建完成后逐步恢复。
|
||
|
||
---
|
||
|
||
## 7. UI 与交互建议
|
||
|
||
为了让“快速进入但后台仍在准备”体验自然,建议增加轻量提示:
|
||
|
||
### 阅读器内状态提示
|
||
|
||
- 首屏进入后不再是全屏 loading
|
||
- 改为顶部或底部轻提示:
|
||
- `正在准备后续章节...`
|
||
- `已进入阅读,可继续翻页`
|
||
|
||
### 未就绪章节翻页策略
|
||
|
||
当用户快速翻到尚未构建的章节时:
|
||
|
||
- 优先命中后台预构建结果
|
||
- 如果还未就绪:
|
||
- 显示章节级 loading skeleton
|
||
- 不要退回全屏 blocking loading
|
||
|
||
### 目录与搜索降级
|
||
|
||
- 目录先显示标题,不强依赖页码
|
||
- 搜索面板可在全书索引未完成时显示:
|
||
- `正文已可阅读,全文搜索仍在准备中`
|
||
|
||
---
|
||
|
||
## 8. 风险与应对
|
||
|
||
## 风险 1:现有很多 API 默认依赖完整 TextBook
|
||
|
||
例如:
|
||
|
||
- `pageNumber(for:)`
|
||
- `location(forPageNumber:)`
|
||
- `chapterData(forPageNumber:)`
|
||
|
||
应对:
|
||
|
||
- 首期不要强行让这些 API 在“半本书”状态下也完整成立
|
||
- 先给增量模式增加“可用性边界”
|
||
- 在运行时根据 `isFullyBuilt` / `isChapterReady` 分流
|
||
|
||
## 风险 2:局部 snapshot 和完整 snapshot 切换时页码跳动
|
||
|
||
应对:
|
||
|
||
- 局部模式优先用章节内位置恢复,不强调绝对页码稳定
|
||
- 后台切换到完整模型时,按 `location` 而不是按 `pageNumber` 恢复
|
||
|
||
## 风险 3:后台补建打断当前交互
|
||
|
||
应对:
|
||
|
||
- 章节构建使用串行后台队列
|
||
- UI 合并更新节流,例如“每完成 N 章再刷新一次目录状态”
|
||
- 当前可见章节不重复重建
|
||
|
||
---
|
||
|
||
## 9. 推荐实施顺序
|
||
|
||
### Phase 1:快速进入 MVP
|
||
|
||
- 抽 `buildChapter(...)`
|
||
- 首次打开只构建目标章节
|
||
- 局部 snapshot 驱动阅读器
|
||
- 后台补建剩余章节
|
||
|
||
### Phase 2:缓存加速
|
||
|
||
- 单章分页缓存
|
||
- 恢复位置附近章节优先命中
|
||
- 二次打开大书进一步提速
|
||
|
||
### Phase 3:全书能力渐进恢复
|
||
|
||
- 全书搜索索引后台构建
|
||
- TOC 页码后台补齐
|
||
- 全书总页数在构建完成后更新
|
||
|
||
---
|
||
|
||
## 10. 建议验收方式
|
||
|
||
建议拿《凡人修仙传》精校版全本做专项验证,记录以下指标:
|
||
|
||
- 点击书籍到首屏可读的耗时
|
||
- 点击书籍到全书构建完成的耗时
|
||
- 首屏进入时用户是否已经可以翻当前章节
|
||
- 翻到下一章节时是否出现明显阻塞
|
||
- 二次打开同一本书时是否明显快于首次
|
||
|
||
重点不是只看“总构建时长”,而是看:
|
||
|
||
- `Time To First Readable Page`
|
||
- `Time To Full Book Ready`
|
||
|
||
这两个指标在大书场景里要分开看。
|
||
|
||
---
|
||
|
||
## 11. 最终建议
|
||
|
||
对《凡人修仙传》这类超大正文书,最快见效的方案不是继续压榨单次全书分页性能,而是:
|
||
|
||
**把阅读器打开流程从“全书先构建完”改成“当前章节先可读,剩余章节后台补齐”。**
|
||
|
||
这是当前代码架构下收益最高、侵入性也相对可控的做法,因为:
|
||
|
||
- 现有数据天然按章节组织
|
||
- `RDEPUBTextBookBuilder` 已经具备章节级循环结构
|
||
- `RDEPUBReaderPaginationCoordinator` 也已经是集中调度入口
|
||
|
||
如果只允许做一件事来解决《凡人修仙传》打开慢的问题,我建议优先做:
|
||
|
||
**Phase 1:章节级增量构建 + 两阶段进入阅读器。**
|
||
|
||
---
|
||
|
||
## 12. 当前落地状态(2026-06-02)
|
||
|
||
本轮已按 Phase 1 做了首期快速进入优化,当前实现状态如下。
|
||
|
||
### 已完成
|
||
|
||
- `RDEPUBTextBookBuilder` 已抽出章节级构建接口 `buildChapter(...)`,单章构建复用原有 render、分页、尾页规范化、诊断和分页缓存逻辑。
|
||
- `RDEPUBReaderPaginationCoordinator` 的 `textReflowable` 路径已改为两阶段:
|
||
- 第一阶段:按恢复位置优先构建可用章节,并立即应用局部 `RDEPUBTextBook` 进入阅读器。
|
||
- 第二阶段:后台继续按章节增量构建,但增量结果会先暂存,只在用户空闲时再合并到当前可读内容,最终补齐为完整全书模型。
|
||
- 快速进入阶段不只尝试单个 spine,而是按离恢复位置最近的可构建 HTML/XHTML spine 逐个尝试,避免封面、版权页、空白扉页导致首屏快速路径落空。
|
||
- 已修正阅读路径误判:普通静态 SVG 封面不再把整本小说误判为 `webInteractive`,避免错误掉回 `WKWebView` 分页路径。
|
||
- 首包策略已调整为“目标章节 + 后续 2 章”,默认先提供 3 章连续可读内容。
|
||
- 后台补齐策略已调整为“每新增 20 章生成一份新的局部结果”,并优先补当前可读窗口之后的章节,再回补前文。
|
||
- 增量结果不再一生成就立即 `applyTextBook`,而是只保留最近一份 staged `RDEPUBTextBook`,等用户停止翻页且阅读器回到 idle 后再统一合并,减少翻页过程中的 UI reload 和主线程抖动。
|
||
- 已对后台分页任务做降干扰处理:章节补建切到较低优先级队列,用户刚翻页时后台任务会短暂停让,减少 pageCurl 翻页时的 CPU 抢占。
|
||
- 后台完整构建失败时,如果局部章节已经成功进入阅读器,则不再把用户退回阻塞式错误流程,只结束 loading;如果局部章节也未成功,则按原错误处理。
|
||
|
||
### 仍未完成
|
||
|
||
- 还没有引入独立的 `RDEPUBIncrementalTextBookStore`,当前首期实现采用“局部 `RDEPUBTextBook` 先应用,完整 `RDEPUBTextBook` 后替换”的 MVP 路径。
|
||
- 目录页码、全文搜索、全书总页数仍依赖后台完整构建完成后恢复。
|
||
- 尚未加入可视化的“后续章节准备中”轻提示。
|
||
|
||
### 当前预期效果
|
||
|
||
对《凡人修仙传》精校版全本这类章节多、正文长的大书,首屏进入不再等待整本书逐章 render + paginate 全部完成,而是先等待目标正文附近 3 章构建完成。全书分页仍会继续执行,但它从“进入阅读器前置条件”变成了“阅读器内后台补齐任务”;后台每补齐 20 章会生成一份新的局部结果,不过这份结果会先暂存,等用户空闲时再并入当前可读内容。
|
||
|
||
### 验证状态
|
||
|
||
已通过 CocoaPods workspace 构建 Demo,确认本轮快速进入优化可编译:
|
||
|
||
```text
|
||
xcodebuild -workspace ReadViewDemo/ReadViewDemo.xcworkspace \
|
||
-scheme ReadViewDemo \
|
||
-configuration Debug \
|
||
-derivedDataPath /private/tmp/ReadViewDemoDerivedData \
|
||
CODE_SIGNING_ALLOWED=NO \
|
||
CODE_SIGNING_REQUIRED=NO \
|
||
build
|
||
```
|
||
|
||
结果:`BUILD SUCCEEDED`。
|
||
|
||
注意:直接构建 `ReadViewDemo.xcodeproj` 会绕开 Pods,导致 `import RDReaderView` 模块解析失败;验证时应使用 `ReadViewDemo/ReadViewDemo.xcworkspace`。
|
||
|
||
后续仍需要在真机或模拟器上用《凡人修仙传》精校版全本做实际打开耗时对比,重点记录 `Time To First Readable Page` 和 `Time To Full Book Ready`。
|