ReadViewSDK/Doc/LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md
shenlei e4e629a06c 长章节内存优化 P0:每页子串改按需构造并新增内存打点
- RDEPUBTextPage.content 由存储属性改为基于 chapterContent+contentRange
  的计算属性,三处构造点不再生成每页子串,章节缓存窗口内每章少一份
  整章文本常驻副本
- 新增 RDEPUBMemoryProbe(--demo-memory-probe 开启),在章节插入、
  每 20 次翻页、设置失效、转屏完成时输出 phys_footprint
- shouldAvoidReaderPageCaching 补注释,锁定放开前置条件为 P1-1
- 新增实施清单文档 LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md 并勾选 P0

验证:Demo 编译通过;UI 回归 9/9 通过(PageNavigation/
ReaderOpenClose/LargeBookOnDemand)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 15:34:45 +09:00

9.6 KiB
Raw Blame History

长章节内存优化 — 代码实施清单

背景DTCoreText 文本页已采用"整章上下文布局",分页与显示边界一致(不可回退)。现状代价:每次翻页/预加载都整章重拷贝 + 重排版后丢弃,且章节缓存里每页常驻一份子串副本。

总原则:保留"整章参与排版",去掉"整章按页复制、按页可变、按页缓存"。不改分页器,不上窗口化。


P0立即可做

P0-1 削减每页 content 子串常驻副本

现状:分页产物为每页保存整段 attributedSubstring全章加总约等于又一份整章副本随邻接窗口radius 1共 3 章)常驻。

生成点:

  • Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLoader.swift:494buildPagesFromRanges
  • Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift:294
  • Sources/RDReaderView/EPUBTextRendering/RDPlainTextBookBuilder.swift:78

现存全部消费点(已盘点确认,仅 3 处):

  • RDEPUBTextContentView.swift:562 — 封面检测 coverImage(from: page.content),仅 pageIndexInChapter == 0 且 href 含 "cover"
  • RDEPUBTextContentView.swift:641normalizedPageContent,仅非 DTCoreText 的 #else 回退路径调用
  • RDEPUBTextBookBuilder.swift:93 — 构建期 debug 日志 preview

任务

  • RDEPUBTextPage.content 改为按需构造:已改为基于 chapterContent + contentRange 的计算属性(带范围钳制),公开读取 API 不变
  • 三个构造点(RDEPUBChapterLoader / RDEPUBTextBookBuilder / RDPlainTextBookBuilder)不再生成每页子串;消费点经计算属性透明按需构造
  • Equatable 确认:content 为派生值,由 contentRange + chapterContent 判等覆盖,语义不变

API 兼容性注意

  • RDEPUBTextPage 当前是 public structcontent 也是公开存储属性;如果 SDK 已存在外部接入方直接读取 page.content,则不应直接移除该字段
  • 优先方案是保留 content 对外访问语义,但将其改为按需构造的计算属性或受控访问器,避免对外 API 断裂
  • 若确认当前版本尚无稳定外部依赖,才可以评估把 content 从存储属性调整为内部实现细节
  • 在实施前应补做一次全仓库与接入侧检索,确认是否有外部调用依赖 page.content 的“可变存储”特性

验收:打开 3 章窗口后常驻内存下降约"一整章文本规模 × 窗口章数";封面页显示正常;非 DTCoreText 回退路径回归通过;--demo-pagination-validate 命中数不回升。

P0-2 增加内存打点

  • 已新增 RDEPUBMemoryProbe--demo-memory-probe 启动参数开启,输出 phys_footprint挂载点RDEPUBChapterRuntimeStore.insertChapterpageNum 回调每 20 次翻页、invalidateAllForSettingsChangeviewWillTransition 转屏完成
  • --demo-memory-probe 运行 Demo 即可采集对比基线

P0-3 保持现有缓存限制,防止错误复用

  • shouldAvoidReaderPageCachingRDEPUBTextContentView.swift:319)保持现状,已补注释说明放开的前置条件是 P1-1 落地
  • 本阶段未改 RDReaderPreloadController 行为

P1第二波P1-1 与 P1-2 必须同批落地)

P1-1 章节级共享 displayContent

现状RDEPUBTextContentView.configure 每次创建整章可变副本(RDEPUBTextContentView.swift:443并按页写入三类属性页范围主题色453、页范围暗黑图附件替换448、按页裁剪的高亮属性459 applyHighlightsToContent)。

约束:共享字符串一旦被任一页的 DTCoreTextLayouter/framesetter 持有,就不得再修改——所有属性必须在共享内容构建时一次性注入,或转为 overlay见 P1-2

任务

  • 在章节运行时侧(建议挂在 RDEPUBRuntimeChapter 或新建 RDEPUBChapterDisplayCache)新增:
    • chapterDisplayContenttypesetAttributedString 的一次性拷贝)
    • displaySignature(组成:主题、暗黑图配置、字体设置、本章高亮集合版本)
  • 构建共享内容时一次性注入:全章范围主题色;全章范围高亮/下划线属性(若 P1-2 决定转 overlay 则此项取消)
  • 确定暗黑图策略(二选一):
    • a. 构建时全章一次性替换(依赖 RDEPUBDarkImageAdjuster 的 NSCache 兜底解码成本)
    • b. 移到渲染时按可见附件替换,不触碰共享字符串
  • RDEPUBTextContentView.configure 改为接收"共享内容引用 + page.contentRange",删除 443 行的整章拷贝
  • 高亮增删、搜索激活、主题/设置变化 → 比对 displaySignature,不一致则重建共享内容(禁止原地修改)
  • 章节淘汰(RDEPUBChapterRuntimeStore.evict / handleMemoryWarning / invalidateAllForSettingsChange)同步释放 display cache

验收连续翻页不再出现整章拷贝打点确认翻页延迟下降高亮显示、高亮点击菜单、underline、搜索 currentMatch、附件点击、选区拖拽全部回归通过。

P1-2 高亮/下划线剥离为 overlay与 P1-1 绑定)

现状:搜索高亮已走 overlayRDEPUBTextContentView.swift:759buildDecorationshighlights: [],注释明确为避免双画);普通高亮/下划线仍靠写 kRDEPUBHighlightAttributeName / kRDEPUBUnderlineAttributeName 属性由 render view 绘制。

任务

  • buildDecorations 改为传入真实 highlights高亮/下划线由 decoration 层绘制
  • 移除 applyHighlightsToContentRDEPUBTextContentView.swift:617)及 render view 对两个自定义属性的绘制路径,确认无双画
  • 高亮命中(highlight(at:))与菜单锚点继续走 interactionController.selectionRects,坐标保持 chapter-absolute

验收:高亮/下划线视觉与改前一致(含跨页高亮的页内裁剪);高亮点击弹菜单正常;与搜索高亮叠加时无双画。

P1-3 章节级共享 display layouter

现状:每个页面视图各建一个 DTCoreTextLayouterRDEPUBTextContentView.swift:465);章节运行时已有分页用 RDEPUBTextLayouterRDEPUBRuntimeChapter.layouterdisplay layouter 向同一模式靠拢。

任务

  • DTCoreTextLayouterchapterDisplayContent 一起放入章节级 display cache与 signature 同生命周期
  • 页面按 bounds + page.contentRangelayoutFrameRDEPUBTextPageVerticalJustifier.justify 会修改 layoutFrame因此 保持 shouldCacheLayoutFrames = falselayoutFrame 永远按页新建、不共享
  • bounds 变化(横竖屏/双页)纳入失效条件;确认旧 layoutFrame 随页面视图释放
  • RDEPUBTextPageBoundaryValidator 校验路径不变

验收:翻页时不再重建 framesetter打点确认横竖屏切换后布局正确--demo-pagination-validate 不回升。

P1-4 评估重新放开文本页预加载缓存

前置P1-1P1-3 全部落地后,页面视图不再持有整章副本。

  • 调整 shouldAvoidReaderPageCaching 判定,允许轻量化后的文本页进入 RDReaderPreloadController 缓存
  • 对比放开前后的翻页流畅度与峰值内存,数据不佳则回退此项

P2视 P0/P1 打点数据决定

P2-1 章节级 LRU 分层淘汰

现状RDEPUBChapterRuntimeStore 已有邻接窗口(windowSpineIndicesradius 1evictableSpineIndices / evict / evictAllExceptCurrent / handleMemoryWarning / invalidateAllForSettingsChange;磁盘侧有 RDEPUBChapterSummaryDiskCache(分页结果 + cfiMap + metadata。当前邻接章节仍持有完整 RDEPUBRuntimeChaptertypesetString + pages + layouter

任务

  • 邻接章节降级:保留 page map / chapter summary / 分页结果,释放 typesetAttributedString、display content、layouter
  • 进入邻接章节时按 summary 重建,测量重建延迟;延迟不可接受则维持现状
  • 远距章节仅保留磁盘缓存 + 轻量 metadata现有 evict 已覆盖,确认即可)

P2-2 图片附件深化

现状:暗黑图已走 NSCache50 MB / 100 张,RDEPUBDarkImageAdjuster.swift:9)且仅处理当前页范围;章节运行时另有 100 MB imageCache

任务

  • 大图按显示尺寸下采样后再参与 attachment
  • 页面离屏后释放 attachment 强引用的已解码大图;避免共享 content 长期持有
  • 回归:图片点击查看、暗黑模式切换、附件位置与命中

P3仅在 P0P2 之后数据仍不达标时

  • 上下文窗口化布局(当前页所在段落块 + 前后足以影响断行的文本窗口)。风险最高:易重新引入分页/显示边界不一致CTTypesetter 断行上下文敏感,为已知已解 bug 的根因),选区/搜索/高亮/附件索引需全部重验。默认不做。

明确不做

  1. 回退"页内子串重新布局"(重新引入显示不全/页末孤字)
  2. 取消 chapter-absolute 索引(破坏点击/选区/高亮定位一致性)
  3. 先改分页器(正确性与性能问题混杂,无法回归)
  4. 未落地 P1 前放开文本页 reader 级缓存(会驻留多份整章副本)

各阶段统一验收指标

正确性--demo-pagination-validate 命中数不回升;长章节页末无孤字/缺字;高亮显示、搜索高亮、附件点击、选区拖拽行为一致。

内存(对比 P0-2 基线):打开长章节后常驻;连续翻页 20 页峰值;设置变更前后峰值;横竖屏切换峰值。

性能:首次打开章节耗时;连续翻页帧稳定性;设置切换恢复时间。