track-c-compose-reader-plan.md 29 KB

Track C —— 声明式渲染 / Compose 阅读器迁移(执行计划)

本文是 MAD 收敛计划(mad-modernization-plan.md)中 Track C 的可落地展开。 Track C 是三轨里唯一长期、且依赖 A、B 产出的一条:它把阅读器的 Canvas View 渲染栈渐进替换为 Compose,用声明式状态 + 本地动画状态取代大部分指令式渲染命令。

写作时点:Track A(A1–A5)与 Track B(B1/B2)已落地。B2 已把指令式渲染协议 从 ViewModel 下沉到 UI 层的 ReadBookController(ReadBook.renderCallBack: ReaderRenderCallback)。Track C 从这个边界继续往下走。

状态:已删除(2026-07-25)——本文自此为历史设计记录

2026-07-25:C0–C5 的实现已从代码库删除。以下全文保留为设计记录与性能基线证据, 但描述的代码不再存在,不要按文中路径去找文件。

已删除:ComposeReaderSurface.kt、ComposeReaderRenderCache.kt、ReaderPageSnapshot.kt、 ReaderRenderModel.kt(含 ReaderRenderStateStore)、ReaderRenderer.kt(契约 + 两个实现 + ReaderRenderAction)、lab flag LabSettings.composeRenderer 全链(PreferKey / DataStore 映射 / LabConfig / 三处字符串)、ReadBookController.publishRenderStructure() 及其 12 个调用点、 单测 ReaderRenderStateStoreTest/ReaderRendererVerificationTest。

保留:ReaderViewport.kt(C4 的 ReaderViewport/ReaderLayoutCoordinator 接缝—— ContentTextView 与 ReadBookController 在用,非 Compose 专属)、 ReaderFirstFrame.kt(debug 首帧探针,tools/capture_reader_c3_first_frame.py 依赖)。

理由:冻结态并非零成本——每次翻页/内容更新都在 publishStructure 里做一遍 无消费方的三页快照投影。维护成本 > parity 实验台价值。若将来性能调查结论支持重启, 按本文设计重新实现即可。

原暂停决策:暂停于 C5(2026-07-21)

C0–C5 已落地并真机验收;此后暂停,不推进 C6–C9。

依据:C3 真机帧基线显示 Compose 渲染明显劣于旧 View(jank 50% vs 4.13%;50/90/95/99 = 30/42/44/53ms vs 5/8/21/65ms,见 §3 C3),且仿真卷曲/选择/滚动/自动翻页全量重写 = 高回归风险、低用户收益。故上层 MAD 计划改为「保留 ReadView 作渲染核心、解其自身业务耦合(Track D)」,Compose 阅读器退为可选。

C6–C9:暂缓(非当前主线)。它们描述的仍是「让 Compose 成为默认并删旧栈」的路线;是否重启,取决于将来一次专门的性能调查结论(能否把 Compose 帧耗时/jank 拉到不劣于旧 View、且仿真卷曲有等价观感)。在此之前,不在 overlay 上继续堆图片/选择/动画/滚动——§4 的双栈警告在「暂停」语境下同样成立:不推进,就不该扩大双栈。

上层背景与近期主线 Track D(ReadView 出站解耦)设计见 mad-modernization-plan.md(进度快照 ⑥、Track D 节)。

0. 判定:为什么它必须是长期、分阶段、带开关的

  • 不是全量重写(呼应反目标):ChapterProvider / TextChapterLayout / ZhLayout 这套分页排版引擎不重写,其产物(TextChapter→TextPage→ TextLine→*Column)作为绘制数据被 Compose 复用。Track C 重写的是 「绘制 + 手势 + 翻页动画 + 选择」这一层,不是排版。
  • 必须带 feature flag 并行:旧 ReadView 栈与新 Compose 栈共存,逐段达成 parity 后再翻默认值、最后删旧栈。任何一步都可回滚。
  • 验证只能在真机:与 A/B 一样,「每条路径实时渲染正确」编译与单测都覆盖不到。

1. 现状渲染栈(Track C 的手术对象)

ReaderRenderCallback(B2:ReadBookController 实现)
  → ReadView (自定义 ViewGroup)
       ├─ PageView × 3(prev/cur/next)
       │     └─ ContentTextView(onDraw 里用 Canvas 逐 TextLine/TextColumn 画)
       ├─ PageDelegate(翻页动画/手势)
       │     ├─ CoverPageDelegate / SlidePageDelegate / FadePageDelegate
       │     ├─ ScrollPageDelegate / NoAnimPageDelegate
       │     └─ SimulationPageDelegate(仿真卷曲——最难)
       ├─ AutoPager(自动翻页)
       └─ 选择 / 光标 / TextActionMenu
分页排版(**不改,复用其输出**):
  ChapterProvider / TextChapterLayout / ZhLayout / TextPageFactory
  → TextChapter / TextPage / TextLine / TextParagraph /
    TextColumn·ImageColumn·ButtonColumn·ReviewColumn·TextHtmlColumn

关键文件(ui/book/read/page/):ReadView.kt、PageView.kt、 ContentTextView.kt、delegate/*.kt、entities/**、provider/**。

2. 目标数据流

                    ┌───────────── ReaderViewport(尺寸输入接缝)─────────────┐
                    │  widthPx / heightPx / density / contentPadding          │
                    │  旧 View 和 Compose 都能驱动同一个 ChapterProvider       │
                    └──────────────────────────┬─────────────────────────────┘
                                               ▼
ReaderSession.state(Track A)──┐        ChapterProvider(排版引擎,不动)
ReadBook.snapshot ─────────────┤              │ 产出可变 TextPage/TextLine/*Column
选择/朗读高亮/动画进度/viewport ┘              ▼
                                    ReaderPageSnapshot(不可变、renderer 专用)
                                               │  聚合进 ReaderRenderModel(StateFlow)
                                               ▼
                           Compose 阅读表面(Canvas + pointerInput)
                           - 分页结果 = 消费 snapshot 的只读几何,不直接读 TextPage
                           - 翻页动画 = Compose 本地动画状态(Animatable)
                           - 手势 = pointerInput
                           - 反转 upContentAwait 的 model→UI→model 往返

ReaderRenderModel 是 Track C 的地基:一个只读的渲染视图模型,聚合 A 的会话快照 + B 控制器持有的视图态(viewport 尺寸、动画进度、选择、朗读高亮、 预加载反馈)。它不持有可变领域对象,滚动高频字段与低频结构字段分流 (呼应 A4 的「高频 viewport / 低频结构」二流拆分——Track C 才真正需要它)。

两个新的地基构件(本版计划从「C2 脚注 / A4 预留」提升为独立阶段,理由见 §4):

  • ReaderViewport:把「谁给 ChapterProvider 定尺」从 ContentTextView.onSizeChanged 唯一入口,抽成一个可由旧 View 或 Compose 驱动的输入接缝。这不是重写分页引擎 ——ChapterProvider 仍是唯一排版者,只是它的尺寸/重排版触发不再是 View 的私产。 跨过它,才可能「关掉底层正文 View,Compose 仍能独立分页」。
  • ReaderPageSnapshot:当前 TextPage 是满是 var 的 data class(还内嵌 canvasRecorder 等可变绘制缓存),跨线程被 Compose 直接消费有陈旧/竞态风险。 快照是 renderer 专用的不可变投影(文本坐标 / baseline / 字体色 / 装饰 / 图片区 / 交互区 / 选择范围),第一版可为既有对象的只读视图,不要求深拷贝全部文本数据。

3. 分阶段(每阶段独立可验证、可回滚)

本版排序修订(吸收一次外部审阅):原稿 C3 直接做「手势 + 翻页动画」,但当前 C2 是一层仍由旧 ReadView 驱动分页的 overlay。在这层 overlay 上继续堆动画/图片/ 选择,会把「旧 View 定尺分页 + Compose 再画一遍」焊成永久双栈——每加一个功能都要 两边同步。故把「解除 View 尺寸依赖(ReaderViewport)」与「不可变 ReaderPageSnapshot」 提前,作为一切复杂渲染之前的闸门;翻页动画与复杂交互整体顺延。本轨的核心里程碑 因此不是「Compose 又支持了一种绘制」,而是:

关掉底层正文 ReadView 后,Compose 仍能独立完成分页与静态阅读。

跨过这个节点之后做图片/选择/动画,才是在建设新架构;否则只是在扩大双栈。 代价要如实记账:viewport 前置是「高风险、低可演示」的一步(见 §4),先啃它意味着在拿到 更多可视 parity 之前先处理最硬的接缝——这是有意的取舍,不是顺手。

C0. Renderer Contract(渲染者边界)——已随 C3 落地

  • 定义一个显式的渲染者接口,让「旧 Canvas 渲染」与「Compose 渲染」藏在同一个边界 后面,flag 只切换实现、而非叠加:

    interface ReaderRenderer {
      fun render(snapshot: ReaderPageSnapshot)   // 类型见 C5,C0 立边界时可先用占位
    }
    // LegacyRenderer(包 ContentTextView/delegate)、ComposeRenderer 各实现之
    
  • 目的:堵住 ComposeReaderSurface 直接去戳 ReadBookController 各方法、慢慢又耦合 回来的路。有了这条边界,C7「Legacy 或 Compose 二选一」才是换实现,不是拆线。

  • 诚实标注顺序:C1/C2 已落地时并未走这条边界(overlay 直连 store/controller)。 故 C0 是现在补的契约,落地时机并入 C3 稳定化——不是一个「更早做过」的阶段,而是把 既有 overlay 的隐性耦合收敛成接口。接口的 snapshot 化签名依赖 C5,C0 先立 「两个 renderer 一个边界、flag 切实现」这件事,参数类型到 C5 再定型。

  • 验收:ComposeReaderSurface 经 ReaderRenderer 实现被调用,grep 证明它不再直接 引用 controller 的渲染方法;flag 关时旧路径逐字节不变。

C1. 抽取只读 ReaderRenderModel(无行为变化)——已落地(e3f935479)

  • 在 ReadBookController / ReaderSession 之上,把当前散落在 ReadView 里的 视图态(cur/prev/next TextPage、durPageIndex、viewport 尺寸、选择区间、 朗读高亮 span、动画进度、loading)收敛成一个 StateFlow<ReaderRenderModel>。
  • 旧 ReadView 继续是唯一渲染者;C1 只是并行暴露状态,不接管绘制。
  • 验收:编译 + 无行为变化;新 flow 的值与旧视图态逐字段一致(可加临时断言/日志)。

C2. Compose 静态渲染表面(feature flag,默认关)——已落地(overlay 版)

  • 实验室开关 LabSettings.composeRenderer(默认关,DataStore 持久化)。
  • 新增 ComposeReaderSurface(Compose Canvas + drawIntoCanvas → native drawText),从 ReaderRenderModel 渲染当前页:复用排版引擎已算好的坐标 (TextColumn.start / TextLine.lineBase)与 ChapterProvider.contentPaint/ titlePaint,文字位置/字号/字色与旧 ReadView 像素一致。三区点击翻页/呼出菜单。
  • 生产者:ReadBookController 收集 ReadBook.snapshot→publishSession, 渲染回调末尾 publishStructure 当前页,喂给 C1 的 ReaderRenderStateStore。

实现取舍:overlay,而非「替换 ReadView」。 原稿设想 flag 开时用 Compose 表面 替换 ContentTextView。落地时核实到关键耦合:分页尺寸只由 ContentTextView.onSizeChanged → ChapterProvider.upViewSize 驱动,UP_CONFIG 重排版事件也由 View 层消费。若直接抽掉 ReadView,需把 ChapterProvider 定尺、 重排版触发、UP_CONFIG 处理全部改由 Compose 侧驱动——风险大且当前无法真机核。 故 C2 采用叠加:旧 ReadView 仍充当引擎(分页/内容加载/换章/手势), ComposeReaderSurface 不透明覆盖其上只负责画像素 + 点击翻页。这样 flag 关时 逐字节不变、可随时回退,且验证面最小。「真正替换 ReadView」下沉到 C4(ReaderViewport 接缝)——本版计划把它从这条脚注提升为独立的核心里程碑。

  • 已知缺口(后续阶段):翻页动画(overlay 覆盖了 ReadView 的动画)、文字选择、 ImageColumn/ButtonColumn/ReviewColumn、下划线与背景色/背景图、双页、滚动模式。
  • 验收(真机):flag 开,单页正文文字与旧栈位置/字号/字色一致;点击左右/中央 分别上一页/下一页/呼出菜单;关闭开关即回旧栈。

C3. 稳住当前 overlay + 建立 parity 基线(不加新阅读功能)——已完成

代码稳定化已落地:补齐 ReaderRenderer 边界,结构状态改为在旧 View 处理完 渲染命令后发布,并修复 pointerInput(Unit) 的陈旧回调捕获。真机证据及采集脚本见 track-c3-reader-parity-baseline.md。

2026-07-21 真机结果(Samsung SM-S9310,《斗破苍穹》,20 页往返):稳定性检查通过—— Legacy / Compose 均回到同一正文页,无空白页、旧页闪回或章节错位。但静态 parity 未通过: Compose 缺旧页眉/页脚,正文颜色/高亮与旧 Canvas 不一致;全屏 MAE 25.77、差异像素 22.72%。 帧基线:Legacy 50/90/95/99 = 5/8/21/65ms(jank 4.13%),Compose = 30/42/44/53ms (jank 50.00%)。两路径当前动画语义不同(Legacy 有翻页动画,Compose 无动画直接换页),仅作 C7 对照基线,不直接比较优劣。

结论:已完成 C3 的「采集/冻结」工作,但未满足「普通文字页基本一致」的验收。继续之前先解决静态画面差异;不跨阶段进入 C4–C8。

2026-07-21 第二轮修正:补齐按列颜色/搜索强调/字体/字号/API 35 letter spacing 与 ContentTextView 内容偏移后,单页全屏 MAE 降至 0.023、差异像素降至 0.113%,当前普通 文字测试页已基本一致。采集脚本也增加了 route 冷启动、ADB 超时、前进页变化断言和五轮往返; 完整五轮复采后,Legacy / Compose 均回到起点,终点全屏差异仅 0.0127%,未见空白页、旧页 闪回或章节错位。随后补采系统衬线字体 + 增大 3 级字号、明确的第 8→9 章边界、各 5 次 冷启动首个非空正文帧,以及 GPU overdraw;四项均已有证据,详见 track-c3-reader-parity-baseline.md §8,C3 正式放行。

  • 先不堆功能,把 C2 的 overlay 收口,为后续所有阶段建立可对比的地基:
    • 旧渲染与 Compose 渲染的截图对比(普通文字页、换字体/字号、换章、快速翻页)。
    • 清理遗漏的状态发布入口(publishSession/publishStructure 覆盖是否有漏发)。
    • 复核 @Stable 使用、pointerInput 回调捕获是否闭包了陈旧值。
    • 建立性能基线(首帧、翻页帧耗时、overdraw),供 C7 停止双绘后对照。
  • 验收(真机):flag 开时,普通文字页与旧渲染基本一致,无明显空白页、旧页闪回、 章节错位。这一步只冻结现状,不推进架构。

C4. 抽出 ReaderViewport 接缝——让 Compose 尺寸能驱动分页(已落地,真机验收通过)

2026-07-21 实施记录:已新增 ReaderViewport / ReaderPadding / ReaderLayoutMode 与 ReaderLayoutController,旧 ContentTextView 和 Compose snapshotFlow 测量统一经 ReaderLayoutCoordinator 驱动 ChapterProvider;控制器按有效正文 尺寸去重,避免 View/Compose 对同一尺寸重复触发分页。UP_CONFIG(12) 的重排版已改经该接缝, upContentAwait 先等待 viewport 就绪,并新增 layoutResults revision 流作为 C7 删除旧 suspend 往返的雏形。Kotlin 编译、debug assemble、新旧渲染状态与 viewport 接缝单测通过。

2026-07-21 真机验收(Samsung SM-S9310):Legacy / Compose 各完成两轮、每轮 5 页 往返,均回到起点;普通页全屏差异像素 0.0127%,返回页差异 0%。Compose 模式从竖屏 5/9 重排为横屏 6/11,横屏连续翻至 10/11 后恢复竖屏落在对应内容的 8/9,未见空白、 旧页闪回、错章或崩溃。另用临时验收构建把 legacy surface 约束为 0×0,Compose 仍能 独立生成正文并翻页,确认分页路径不经过 ContentTextView layout;采证后已撤销临时改动。 旧层完全不绘制时背景与页眉页脚缺失,属于 C6 静态阅读闭环的既知范围,不影响 C4 “Compose viewport 可独立驱动分页”的验收结论。

C4 回归防护补强:awaitViewport() 改为最多等待 2 秒,未测量时返回 null 并继续旧 内容更新路径,避免后台加载 / 纯 TTS / View 未测量时无限挂起;单测覆盖无 viewport 超时。 Debug Compose 同时新增 readerDetachLegacySurface=true Intent 验收入口与 tools/verify_reader_c4_detached.py,会断言 legacy surface 确实为 0×0、Compose 首个正文帧 出现且点击后页面变化。正式 committed 路径已在 SM-S9310 复测通过,0×0 结论不再依赖 一次性临时构建。

  • 把 ChapterProvider 的定尺入口从 ContentTextView.onSizeChanged → upViewSize 唯一来源,抽成一个显式接缝:

    enum class ReaderLayoutMode { PAGED, SCROLL, DOUBLE_PAGE }
    
    data class ReaderViewport(
      val widthPx: Int, val heightPx: Int,
      val density: Float, val contentPadding: ReaderPadding,
      val mode: ReaderLayoutMode,   // 预留:C6 双页/滚动会改布局,现在留位不实现
    )
    interface ReaderLayoutController { fun updateViewport(v: ReaderViewport) }
    

旧 ContentTextView 和 Compose 表面都调用它;UP_CONFIG 重排版触发同样经此接缝, 不再是 View 层私产。红线:ChapterProvider 仍是唯一排版者,这里只搬「谁定尺 / 谁触发重排版」,不重写分页内核。 mode 现在只有 PAGED 一条路径;留字段是为了 C6 的双页/滚动不必再引一个并列的 ReaderModeController + ChapterProvider 特判,避免接缝二次碎裂。

  • 在本阶段后半段就开始拆 upContentAwait(不要留到 C7 才动):一旦 Compose 驱动 viewport,若 upContentAwait 仍等旧 View 的 layout 完成回调,就埋了一个「Compose 表面 看似独立、实际仍反向等 View 排版」的隐形旧架构。C4 后半把它改成 LayoutResultFlow(排版完成信号)+ snapshotFlow 等测量完成的雏形;C7 只做最终删除。
  • 验收(真机):底层旧 ReadView 可设为不可见、甚至不参与布局,Compose 仍能正确 生成页面;屏幕旋转 / 横竖屏 / 窗口尺寸变化能重新分页;且此路径不经过 ContentTextView 的 layout 回调。 注意:这一步不删 ReadView,只解除它作为「分页生命线」的作用。跨过它,Compose 才从 overlay 变成能独立分页的渲染面。

C5. 引入不可变 ReaderPageSnapshot(renderer 只消费快照)——已落地,真机验收通过

2026-07-21 实施记录:新增 ReaderPageSnapshot / ReaderElement 及旧排版对象适配器, 在 ReaderRenderStateStore.publishStructure 边界把三页可变 TextPage/TextLine/*Column 投影为不可变绘制值;快照已包含文字坐标与完整画笔样式、装饰、图片区、交互区、选区和 revision,不携带排版逻辑、反向对象引用或 Canvas recorder。ComposeReaderSurface 只消费 快照并重建私有 TextPaint,已无 ChapterProvider、旧页面实体或旧 View 绘制状态访问。 单测覆盖旧行列对象在发布后继续突变不会污染既有快照,重新发布才生成更高 revision。

真机验收(Samsung SM-S9310):debug 构建安装后,以 legacy surface 0×0 的 committed detach 路径启动 Compose 阅读器,首个非空正文帧 1611ms 出现;点击下一页后画面变化 17.76%,正文无空白、错位或崩溃。聚焦单测、compileAppDebugKotlin 与 installAppDebug 均通过。

  • 把旧引擎输出(可变 TextPage/TextLine/*Column)转换为 renderer 专用的不可变数据: 文本位置 / baseline / 字体色 / 装饰(下划线、背景)/ 图片区 / 交互区 / 选择范围。

    data class ReaderPageSnapshot(
      val chapterIndex: Int, val pageIndex: Int,
      val width: Int, val height: Int,
      val elements: ImmutableList<ReaderElement>, val revision: Long,
    )
    sealed interface ReaderElement {
      data class Text(/* 坐标、文本、样式 */) : ReaderElement
      data class Image(/* 坐标、图片引用 */) : ReaderElement
    }
    
  • 第一版可为既有对象的只读视图,不要求深拷贝全部文本;但 renderer 不得再直接读 ChapterProvider 或可变页面对象。呼应 A4 的「高频 viewport / 低频结构」二流拆分—— 滚动位置高频、结构快照低频,避免把 60fps tick 挂到 equality 快照。

  • 护栏:别让 snapshot 长成第二个 TextPage。 snapshot 只描述绘制(坐标/样式/ 区域),不承载排版逻辑、不回挂可变对象、不塞 canvasRecorder 这类缓存。保持「排版 描述」与「绘制描述」在概念上可分(未来若真需要 Web/View 多 renderer,可再把它拆成 PageLayoutSnapshot → RenderSnapshot 两层)。当前只有一个 Compose renderer,故不预先 引入两层类型(避免为未上路的多端渲染造抽象)——只在此立「不可反向依赖排版对象」的 纪律,让将来分层仍可能。

  • 验收:Compose renderer 只消费 snapshot,grep 证明其不再访问 ChapterProvider / 旧 View 的绘制状态。

C6. 完成无动画阅读闭环(按风险从低到高)——暂缓(见顶部状态)

在 snapshot 上把静态阅读功能补齐,全程不碰翻页动画:

  1. 普通文本 + 标题
  2. 背景色 / 背景图、下划线、朗读高亮 span
  3. ImageColumn / ButtonColumn / ReviewColumn 等列类型
  4. 双页模式(提前)
  5. 三区点击 / 交互区命中
  6. 长按选择、光标、选区高亮、TextActionMenu(TextActionSelectionMenu 已是 Compose)
  7. 滚动模式(60fps 热路径,验证高频 viewport 分流) > 双页为何提前到选择之前:双页本质不是 renderer 功能,而是 page window 管理 > ——它验证 snapshot 是否支持多页、viewport(mode=DOUBLE_PAGE)是否正确、prev/cur/next > 页缓存是否合理,比长按选择/菜单更贴近架构核心。滚动仍垫底(真正的性能模式)。
  8. 验收(真机):无动画模式下,主要阅读功能可完全脱离旧 renderer 工作。

C7. 停止双重绘制 + 反转 upContentAwait——暂缓(见顶部状态)

  • 让 feature flag 变成 Legacy renderer 或 Compose renderer 二选一,而不再是 「Legacy renderer + Compose overlay」叠加。旧 renderer 仍可短期保留作回退, 但不与 Compose 同时绘制。
  • 最终删除 upContentAwait(拆解已在 C4 后半开始):C4 已把它改成 LayoutResultFlow + snapshotFlow 等测量完成的雏形;C7 在切成单栈后做收尾—— 删掉 ReaderRenderCallback.upContentAwait 这个 suspend 往返,退化为纯状态更新, 模型不再挂起协程反向等 View 排版。若 C4 后半未能完全脱开旧 View layout 回调,C7 必须 在这里补齐,不允许带着隐形往返翻默认值。
  • 验收(真机):Compose 模式下没有旧正文层参与绘制,overdraw 与帧耗时不劣于 现有 overlay(对照 C3 基线)。

C8. 翻页动画(垫底,Simulation 最后)——暂缓(见顶部状态)

  • 翻页动画用 Compose 本地动画状态(Animatable/updateTransition),只消费 snapshot 与 transition state,不再深入改分页内核:

    interface ReaderPageTransition {
      fun draw(previous: ReaderPageSnapshot?, current: ReaderPageSnapshot,
               next: ReaderPageSnapshot?, progress: Float)
    }
    
  • 逐个替换 PageDelegate 子类,从易到难:NoAnim → Cover → Slide → Fade → Scroll → Simulation(仿真卷曲,最后做,涉及曲面/阴影,最高风险)。

  • 每个 transition 迁完即在 flag 下与旧栈对比。放到最后的理由:动画同时牵涉当前/上/下页、 手势进度、页面缓存、中途取消、快速连击、章节边界、预加载、方向变化——页面数据与 viewport 未稳定前提前做,必然大量返工。

    Simulation 兜底:仿真卷曲是唯一可能「Compose 做不出等价观感」的点;若代价过高, 可保留它为唯一的旧栈回退项,而非阻塞整轨。

C9. 翻默认值 + 删旧栈——暂缓(见顶部状态)

  • Parity 全绿后把 flag 默认打开;回归窗口后删除 LegacyReaderRenderController 承接的指令式渲染方法体、ReadView/PageView/ ContentTextView/delegate/*。ReaderRenderCallback 收敛为「发布渲染状态」 而非「下达渲染命令」。

4. 顺序纪律与风险

  • 先跨 C4,再做复杂渲染:核心里程碑是「关掉旧 ReadView 后 Compose 仍能独立 分页 + 静态阅读」。在此之前,overlay 仍靠旧 View 定尺——任何在 overlay 上做的 图片/选择/动画都是双栈投资,早晚要在 C4 之后重做。这是本版把 viewport 解耦 (C4)与不可变快照(C5)提到动画前面的唯一理由。
  • C4 的代价要如实记账:viewport 解耦是「高风险、低可演示」的一步——它没有新的 可炫耀 parity,产出是「旧 View 可以关了」这种结构性成果。原稿把它推后正是因为 风险大 + 当时无法真机核;本版不否认这个风险,只是判断不先付这笔账,后面的功能 都建在流沙上。若真机验证阶段发现 UP_CONFIG/重排版触发难以脱离 View,允许把 C4 拆成更小的子步,但不允许跳过它去做 C6/C8。
  • Renderer 边界优先(C0):没有 ReaderRenderer 契约,ComposeReaderSurface 会慢慢 直连各 controller,C7 的「二选一」就退化成拆线而非换实现。C0 概念上最前,但因 C1/C2 已落地未走此边界,实际并入 C3 补齐。
  • upContentAwait 拆解不等 C7:C4 让 Compose 驱动 viewport 的同时就要开始拆这个 suspend 往返,否则「Compose 看似独立、实则反向等旧 View layout 回调」的隐形旧架构会 一直潜伏到 C7 才暴露。C7 只做最终删除与兜底。
  • 先 C1 后其余:没有稳定的 ReaderRenderModel,后面每个 Compose 组件都会 各自去戳全局单例,变成「Compose 皮的分布式单体」。(C1 已落地。)
  • 排版引擎不动:ChapterProvider 一旦被顺手重写,风险与工期都失控。C4 只搬 「谁定尺 / 谁触发重排版」的接缝,不是造新的分页引擎——ReaderViewport 是输入 层,不是 ChapterProvider 的替代。
  • 动画(C8)最后做、Simulation 垫底:仿真卷曲是唯一可能「Compose 做不出等价 观感」的点;若代价过高,可保留其为唯一的旧栈回退项,而非阻塞整轨。
  • 性能:滚动模式是 60fps 热路径,ReaderRenderModel/ReaderPageSnapshot 必须 分流高频 viewport 与低频结构(A4 预留、C5 兑现),避免把滚动 tick 挂到 equality 快照上。
  • 验证边界:全程真机 parity;每阶段留可截图对比的 flag 开关,C3 的截图/性能基线 是后续所有阶段的对照物。

5. 与 ViewModel 拆分的关系

Track C 完成前,upContentAwait/页面排版完成/翻页动画/选择取消/局部重绘/ page recorder 回收这些职责进渲染控制器/分页引擎,不进 ReaderSession 或 VM (呼应主计划「ViewModel 拆分」的第三类)。C9 删旧栈后,这些职责随之消失或变为 渲染状态发布,VM 不再与渲染有任何关系。

6. 一句话

立 ReaderRenderer 契约(C0,随 C3 补)→ 抽只读 ReaderRenderModel(C1,已落地)→ flag 下用 Compose 画静态页 overlay(C2,已落地)→ 稳住 overlay + 建 parity/性能基线 (C3)→ 抽 ReaderViewport(含 mode 预留)让 Compose 尺寸驱动分页、旧 ReadView 可关、 并开始拆 upContentAwait(C4,核心里程碑)→ 引入不可变 ReaderPageSnapshot(C5)→ 无动画阅读闭环含图片/双页/点击/选择/滚动(C6)→ 停止双绘 + 删 upContentAwait(C7)→ 翻页动画、仿真卷曲垫底(C8)→ 翻默认、删旧栈(C9)。 排版引擎全程不动,每阶段带 flag、可回滚、真机 parity。核心不是「Compose 又多画一种 东西」,而是先跨过「关掉旧正文 View、Compose 独立分页」这个节点,后面才是建设新 架构,而非扩大双栈。

注(2026-07-21):以上 C6–C9 描述的是「Compose 默认化 + 删旧栈」的完整路线,现已暂缓(见顶部状态)。当前落点是 C5:C0–C5 冻结为 flag 下可选渲染器,阅读渲染的近期主线转为 Track D(保留 ReadView、解其自身业务耦合,见 mad-modernization-plan.md)。C6–C9 仅在将来专门的性能调查支持「Compose 不劣于旧 View」后才重启。