Forráskód Böngészése

docs(Track E): 新增阅读设置 SSOT/UDF 改造计划

根因不是"设置到处都乱":排版底座 ReadBookConfig.Config(readConfig.json)是
可变全局、无 flow,而 ReadStyleGateway.state 只暴露 items/selectedIndex/shareLayout,
不含任何排版字段。于是 UDF 只能靠 VM 每次写入后手工重建 styleConfig/sheetConfig
两份快照(从可变全局裸读 75 个字段)——漏一处就是"弹层显示旧值",2026-07-24 修的
那例在结构上必然复发。

阶段:E0 防回归不变式测试 → E1 按通道拆 ConfigUpdate(漏填 actions 编译不过)→
E2 给排版底座补全量不可变快照 flow(核心)→ E3 sheet 去全局直读 →
E4 收敛 EventBus 整数码 → E5 排版引擎去全局读(并入 Track D2,不单独做)。

写入面不动:排版写入已收敛到 ReadStyleMutation 类型化键,全库仅
ReadBookStyleConfigRepository 一处直写。

同时更新轨道模型。
5151561 3 hónapja
szülő
commit
78fd39ddac

+ 30 - 4
docs/dev/mad-modernization-plan.md

@@ -100,8 +100,9 @@
 
 Track A  所有权与业务状态       —— 已落地(A1–A5)
 Track B  遗留渲染边界下沉       —— 已落地(B1/B2),端游剩余
-Track C  声明式渲染 / Compose   —— C0–C5 已落地,2026-07-21 起暂停冻结为可选渲染器
+Track C  声明式渲染 / Compose   —— C0–C5 已落地,2026-07-21 起暂停;2026-07-25 已删除
 Track D  ReadView 自身业务解耦   —— 近期主线,不依赖 Compose,依赖 A(ReaderSession)
+Track E  阅读设置的 SSOT 与 UDF —— 与 D 正交、可并行,无前置依赖
 ```
 
 > **方向修订(2026-07-21)**:原路线把 Track C(Compose 替换阅读器)当作阅读渲染的终点。基于 C3 真机性能基线(Compose 帧耗时/jank 明显劣于旧 View)与「仿真卷曲/选择/滚动/自动翻页全量重写=高回归风险、低用户收益」的判断,**改为**:保留成熟的 `ReadView` 作为渲染核心,把它从「业务+状态+渲染全包」重构成**只做绘制/手势/动画的专业渲染岛**;Compose 用于阅读器外围 UI(工具栏/菜单/设置/弹窗)与「可选可插拔渲染器」实验。**MAD ≠ Compose**:分层、UDF、状态所有权、`ViewModel+StateFlow`、生命周期感知、可测试的数据/业务层,均可在 View 上成立(Android 官方至 2026 仍维护专门的 View 架构指南)。Track C 是否最终成为默认渲染器,留待专门的性能调查后重估,不作为当前目标。
@@ -312,9 +313,13 @@ ReadBook ──legacy render callback──▶ LegacyReaderRenderController
 
 ---
 
-### Track C —— 声明式渲染 / Compose 迁移(**2026-07-21 起暂停,冻结于 C5**)
+### Track C —— 声明式渲染 / Compose 迁移(**2026-07-21 暂停,2026-07-25 已删除**)
 
-> **状态(2026-07-21)**:C0–C5 已落地并真机验收;此后**暂停**。Compose overlay 冻结为 lab flag(`LabSettings.composeRenderer`,默认关)下的**可选可插拔渲染器** + parity 实验台,**不推进 C6–C9、不删旧栈**。是否让 Compose 成为默认渲染器,待专门的性能调查后重估。当前阅读渲染的近期主线是 **Track D(保留 `ReadView`、解其自身业务耦合)**。暂停边界与 C0–C5 处置见 `docs/dev/track-c-compose-reader-plan.md` 顶部状态。
+> **状态(2026-07-25)**:C0–C5 的 Compose 正文渲染实现**已从代码库删除**——`ComposeReaderSurface`、`ComposeReaderRenderCache`、`ReaderPageSnapshot`、`ReaderRenderModel`/`ReaderRenderStateStore`、`ReaderRenderer` 契约、lab flag `LabSettings.composeRenderer` 及其字符串与 DataStore 键,连同两个对应单测一并移除。冻结状态维护成本(每次翻页都在 `publishStructure` 里做一遍无消费方的快照投影)大于其 parity 实验台价值。
+>
+> **保留下来的 C 期产物**:`ReaderViewport`/`ReaderLayoutCoordinator`(C4 的 viewport 解耦接缝,`ContentTextView` 与 `ReadBookController` 在用)、`ReaderFirstFrame.kt`(debug 首帧探针,`tools/` 采集脚本依赖)。
+>
+> **原暂停依据(2026-07-21)**:C3 真机帧基线 Compose 明显劣于旧 View(jank 50% vs 4.13%);仿真卷曲/选择/滚动/自动翻页全量重写 = 高回归风险、低用户收益。若将来重启,从下面的历史设计重新实现,不必复活已删代码。
 
 > **执行计划见 `docs/dev/track-c-compose-reader-plan.md`**(已按两轮外部审阅重排:C0 立 `ReaderRenderer` 契约〔随 C3 补〕 → C1 抽只读 `ReaderRenderModel`〔已落地〕 → C2 flag 下 Compose 静态页 overlay〔已落地〕 → C3 稳住 overlay + parity/性能基线 → **C4 抽 `ReaderViewport`(含 `mode` 预留)让 Compose 驱动分页、旧 `ReadView` 可关、并开始拆 `upContentAwait`〔核心里程碑〕** → C5 不可变 `ReaderPageSnapshot` → C6 无动画阅读闭环〔图片/双页/点击/选择/滚动〕 → C7 停止双绘 + 删 `upContentAwait` → C8 翻页动画、仿真卷曲垫底 → C9 翻默认、删旧栈;排版引擎 `ChapterProvider` 全程不动)。**关键调整:把 viewport 解耦与不可变快照提到动画/复杂交互之前,避免在 overlay 上堆功能形成永久双栈。**
 
@@ -377,6 +382,27 @@ fun interface ReaderEventListener { fun onEvent(event: ReaderEvent) }
 
 ---
 
+### Track E —— 阅读设置的单一数据源与 UDF(与 D 正交,可立即开始)
+
+> **执行计划见 `docs/dev/track-e-reader-settings-udf-plan.md`**。
+
+Track D 管「`ReadView` 怎么把业务意图**发出去**」,Track E 管「设置怎么**流进来**」。两者不互相阻塞。
+
+**核心问题(2026-07-25 核实)**:排版底座 `ReadBookConfig.Config`(`readConfig.json`)是**可变全局、无 flow**,
+而 `ReadStyleGateway.state` 只暴露 items/selectedIndex/shareLayout,不含任何排版字段。于是 UDF 靠 VM
+在每次写入后**手工重建** `styleConfig`/`sheetConfig` 快照来模拟——漏一处就是「弹层显示旧值」
+(2026-07-24 已修一例,但下一个新站点仍会复发)。另有 157 个 `ConfigUpdate` 成员各自**手写**
+渲染副作用集(已查出 2 例错配),以及 4 个 sheet 文件直读可变全局并 seed 本地镜像状态。
+
+**阶段**:E0 防回归不变式测试 → E1 按通道把 `ConfigUpdate` 拆成两族(漏填 actions 编译不过)→
+**E2 给排版底座补全量不可变快照 flow(核心,单独就能消灭整个 bug 类别)** → E3 sheet 去全局直读 →
+E4 收敛 EventBus 整数码 → E5 排版引擎去全局读(**不单独做,并入 Track D2**)。
+
+**写入面不动**:排版写入已收敛到 `ReadStyleMutation` 类型化键,全库仅 `ReadBookStyleConfigRepository`
+一处直写 `ReadBookConfig`。Track E 只补「写完之后怎么让所有人看到新值」。
+
+---
+
 ### ViewModel 拆分(不整体等待 Session)
 
 拆分按依赖归类,**与渲染无关的部分现在就能并行拆**:
@@ -436,6 +462,6 @@ AI 清理与改写、翻译、TTS 引擎查询与配置、换源、上传/同步
 
 ## 7. 推荐执行顺序(一句话)
 
-**先用编译器冻结写入 → 集中约 7 个 mutator → 建立权威 ReaderSession(Track A,已落地);同时把渲染 Effect 从 VM 下沉到 UI 渲染控制器(Track B,已落地)→ 并行拆分 VM 中与渲染无关的业务 → 把 `ReadView` 自身的业务耦合解掉、让 View 阅读器符合 UDF(Track D,近期主线,不依赖 Compose)。Compose 阅读器(Track C)暂停冻结为可选渲染器,是否默认化留待专门的性能重估。** 全程由 F1 的 CI 门禁与 F2 的 DAO 去注入护栏兜底。
+**先用编译器冻结写入 → 集中约 7 个 mutator → 建立权威 ReaderSession(Track A,已落地);同时把渲染 Effect 从 VM 下沉到 UI 渲染控制器(Track B,已落地)→ 并行拆分 VM 中与渲染无关的业务 → 把 `ReadView` 自身的业务耦合解掉、让 View 阅读器符合 UDF(Track D,近期主线,不依赖 Compose),并行推进阅读设置的 SSOT 收敛(Track E,无前置依赖)。Compose 阅读器(Track C)已于 2026-07-25 删除,若重启需重新实现。** 全程由 F1 的 CI 门禁与 F2 的 DAO 去注入护栏兜底。
 
 严格 MAD 下 Effect 仍扣分;但准确的表述是——**问题从来不是"指令式 View 无解",而是"指令式渲染协议被错误地穿过了 ViewModel"**。本计划的每一步都在把那条线拉回 UI 层,而不需要等待 Compose。

+ 289 - 0
docs/dev/track-e-reader-settings-udf-plan.md

@@ -0,0 +1,289 @@
+# Track E —— 阅读正文界面「设置 + UDF」现代化改造计划
+
+> **定位**:本轨道**不改渲染方式**(不 Compose 化正文、不动 `ReadView` 的绘制/手势/动画),只解决
+> 阅读正文界面**配置的数据流**问题。与 [Track D](mad-modernization-plan.md#track-d)(`ReadView`
+> 出站业务解耦)正交、可并行:D 管「View 怎么把业务意图发出去」,E 管「设置怎么流进来」。
+>
+> **前置背景**:[reader-settings-apply-channels]、[reader-config-flow-audit](两条 memory)已记录
+> 三条应用通道与 2026-07-24 的最小修复。本计划把那些点状修复升级成结构性收敛。
+
+---
+
+## 1. 事实基线(本轮逐条核实,均带行号)
+
+### 1.1 两个存储底座,语义完全不同
+
+| 底座 | 内容 | 存储 | 是否响应式 |
+|---|---|---|---|
+| **A. `ReadSettings`** | 101 个字段(手势/亮度/菜单外观/键位/朗读…) | DataStore | ✅ `preferencesFlow` → 真 `StateFlow`([ReadSettingsRepository.kt:29-33](../../app/src/main/java/io/legado/app/data/repository/ReadSettingsRepository.kt#L29)) |
+| **B. `ReadBookConfig.Config`** | 排版预设(字号/行距/标题/页眉页脚/下划线/背景…),即 `readConfig.json` | JSON 文件 + 内存 `ArrayList<Config>` | ❌ **可变全局单例,无 flow**([ReadBookConfig.kt:31,77,103](../../app/src/main/java/io/legado/app/help/config/ReadBookConfig.kt#L31)) |
+
+`ReadBookConfig` 目前是**混合门面**:A 类字段是 `get() = readSettings.x` 只读转发(:182-243,干净),
+B 类字段是 `var x get/set config.x`(:310+,可变)。同一个对象两种语义,是"乱"的第一层来源。
+
+### 1.2 底座 B 无 flow ⇒ UDF 是手工模拟的
+
+`ReadStyleGateway.state` 只暴露 `items / selectedIndex / shareLayout` 三项
+([ReadStyleState.kt](../../app/src/main/java/io/legado/app/domain/model/settings/ReadStyleState.kt)),
+**不含任何排版字段**。于是 VM 只能在每次写入后**手动重建**两份快照:
+
+- `buildStyleConfig()`([ReadBookViewModel.kt:2290](../../app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt#L2290))—— 从可变全局裸读 ~25 字段
+- `buildSheetConfig()`(:2322)—— 从可变全局裸读 ~50 字段
+
+**失败模式是结构性的**:任何新增的写入路径只要忘了重建,弹层就显示旧值。2026-07-24 的
+`sheetConfig 不重建` 就是这个类别的实例——修了 5 个站点,但**下一个新站点仍然会犯同样的错**,
+因为编译器管不着。
+
+### 1.3 渲染副作用靠 157 条手写映射表
+
+`ConfigUpdate` 有 **157 个成员**,每个手写 `actions: Set<ConfigUpdateAction>`(13 种动作)
+([ReadBookContract.kt:1006-1558](../../app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt#L1006))。
+
+- 手写 ⇒ 错配即「改了不生效」。已确认两例并已修:`StatusIconDark`(缺 `UpdateSystemUi`)、
+  `UnderlineColor`(缺 `UpdateContent+InvalidateTextPage+SubmitRenderTask`)。
+- **57+ 个成员 `actions = emptySet()`**(菜单外观类:`MenuBlurRadius`/`MenuIconStyle`/
+  `FloatingBottomBar`…)。它们写 DataStore、走 Compose 重组通道,和排版类成员**通道完全不同**,
+  却混在同一个 sealed interface 里,靠"手写空集"表达"我不走这条路"。
+
+### 1.4 UI 层直读可变全局
+
+| 文件 | `ReadBookConfig.*` 直读 | `remember { mutableXStateOf(ReadBookConfig.x) }` 本地镜像 |
+|---|---:|---:|
+| `sheet/HeaderFooterPage.kt` | 69 | 29 |
+| `sheet/TextTitleSheet.kt` | — | 18 |
+| `sheet/SystemMenuPage.kt` | 4 | 7 |
+| `sheet/CustomTipTarget.kt` | 13 | — |
+
+这是**真正的 UDF 断链源**:Compose 从可变全局 seed 本地状态,重开弹层重新 seed。上游一旦
+不经这些控件改值(预设、导入、日夜切换、另一处弹层),控件就显示陈旧值。
+
+### 1.5 EventBus 整数码仍在
+
+`postEvent(EventBus.UP_CONFIG, arrayListOf(整数码))` —— 8 个生产者,VM 在
+[:2084](../../app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt#L2084) 把整数翻回
+`ConfigUpdateAction`。生产者:`ApplyReadSettingUseCase`×2、`ChapterProvider:1111`、`ReadBook:304`、
+`ThemeConfigStore`×2、`ReadAloudPlayerCoordinator:146`。
+
+### 1.6 写入面已经不差(重要,别推倒)
+
+排版写入**已经**收敛到 `ReadStyleGateway.updateCurrentStyle(ReadStyleMutation)` 的类型化键
+(`ReadStyleIntKey`/`FloatKey`/`BooleanKey`/`StringKey`/`ColorKey`,
+[ReadStyleMutation.kt](../../app/src/main/java/io/legado/app/domain/gateway/ReadStyleMutation.kt)),
+全库对 `ReadBookConfig.x = v` 的直接写入**只剩** `ReadBookStyleConfigRepository` 一个文件。
+**Track E 不重做写入面**,只补"写完之后怎么让所有人看到新值"。
+
+### 1.7 附带确认的两个缺陷
+
+- `ReadSettingsRepository.update{}` 的 `toGatewayPrefMap` 只覆盖 **45/101** 字段(:550-596)
+  ⇒ 通用 `update{}` 路径对其余 56 个字段**静默丢写**。(另有 91 个专用 setter 是安全路径。)
+- 排版引擎 `ChapterProvider`(62 处)/`PageView`(54)/`TextChapterLayout`(11)/`TextLine`(12)
+  在排版与绘制时**直接裸读** `ReadBookConfig` ——渲染输入不是参数,是全局。
+
+### 1.8 规模参考
+
+`ReadBookViewModel.kt` 6546 行;`ReadBookContract.kt` 1558 行(Intent 265 / UiState 91 字段 / Effect 74)。
+
+---
+
+## 2. 目标与反目标
+
+**目标**:排版设置有**唯一可信来源 + 一条派生链**,"改了不生效 / 弹层显示旧值"从
+「靠人记得补一行」降级为「编译期或测试期挡住」。
+
+**反目标(明确不做)**
+
+- ❌ 不把正文渲染 Compose 化(Track C 已冻结,见 [track-c-compose-reader-plan.md](track-c-compose-reader-plan.md))。
+- ❌ 不为 UDF 把瞬时控件状态(触摸/动画进度/每帧 invalidate)塞进 `StateFlow`。
+- ❌ 不重做已经干净的写入面(§1.6)。
+- ❌ 不做"一次性大重构":157 个 `ConfigUpdate` 成员必须能分批迁移,每批可单独真机验收。
+- ❌ 不追求把 `ReadBookConfig` 一次性删掉——它是 27 个文件的读入口,E5 之前保留门面。
+
+---
+
+## 3. 阶段划分
+
+### E0 —— 防回归地基(先做,成本最低,无行为改动)
+
+纯新增测试,不改产品代码。
+
+1. **actions 完备性测试**:反射遍历 `ConfigUpdate` 全部 157 个成员,断言每个成员的 `actions`
+   要么非空,要么其类名在显式白名单里。新增成员忘了填 actions ⇒ 测试红。
+   > 这条直接封死 `UnderlineColor` 那类错配的复发。
+2. **快照完备性测试**:断言 `ReadSheetConfigUiState` / `ReadBookStyleConfig` 的每个字段都能在
+   `buildSheetConfig()` / `buildStyleConfig()` 里找到赋值(反射比对构造参数名)。
+3. **DataStore 覆盖率测试**:断言 `ReadSettings` 的字段数 == `toGatewayPrefMap` 的键数
+   (当前 101 vs 45,**测试会先红**)。
+
+**验收**:三条测试进 CI;第 3 条允许先以 `@Ignore` + issue 落地,在 E1 修复后解禁。
+
+---
+
+### E1 —— 按通道拆分 `ConfigUpdate`(结构性除错)
+
+把 157 个成员按**写入目标**拆成两族:
+
+```kotlin
+// 写 readConfig.json 排版 → 必须驱动渲染副作用
+sealed interface ReadStyleUpdate : ConfigUpdate {
+    val actions: Set<ConfigUpdateAction>   // 抽象成员,编译器强制每个实现填
+}
+
+// 写 DataStore → 由 readPreferences StateFlow 反应式生效,天然无 actions
+sealed interface ReadPreferenceUpdate : ConfigUpdate
+```
+
+- 57+ 个 `actions = emptySet()` 的菜单外观成员迁到 `ReadPreferenceUpdate`,**从类型上不再有
+  actions 字段**——不是"填了空集",是"没这个概念"。
+- 剩余排版成员的 `actions` 变成抽象成员的实现,漏填 = 编译不过(比 E0 的测试更强)。
+
+**风险**:`handleConfigUpdate` 的巨型 `when` 需相应分成两个。改动面大但**机械**、无逻辑判断。
+建议分 3–4 个 commit(按 §1.3 的注释分组:文本/标题/页眉页脚/菜单)。
+
+**验收**:`grep 'actions = emptySet()'` 归零;`handleConfigUpdate` 拆成
+`handleStyleUpdate` + `handlePreferenceUpdate`,各自 ≤ 原来一半。
+
+---
+
+### E2 —— 给排版底座补 flow(**本轨道的核心一步**)
+
+让 `ReadStyleGateway.state` 携带**完整排版快照**,而不是只有 items/selectedIndex/shareLayout:
+
+```kotlin
+// domain/model/settings/ReadStyleState.kt
+data class ReadStyleState(
+    val items: List<ReadStyleItem> = emptyList(),
+    val selectedIndex: Int = 0,
+    val shareLayout: Boolean = false,
+    val current: ReadStyleSnapshot = ReadStyleSnapshot(),   // 新增:不可变全量排版快照
+)
+```
+
+`ReadStyleSnapshot` 是纯值对象(字号/行距/间距/标题各项/页眉页脚/下划线/阴影/内边距/背景…),
+由 `ReadBookStyleConfigRepository.buildState()` 从 `ReadBookConfig.durConfig`/`shareConfig` 投影。
+**`publishState()` 已经在每个 mutation 后被调用**(updateCurrentStyle/applyPreset/import/delete/save
+全都调),所以这一步**不需要新增触发点**——只需把 `buildState()` 多填一个字段。
+
+然后 VM 侧:
+
+```kotlin
+init {
+    viewModelScope.launch {
+        readStyleGateway.state.collect { style ->
+            _uiState.update {
+                it.copy(
+                    styleConfig = style.toStyleConfig(readSettings),
+                    sheetConfig = style.toSheetConfig(readSettings),
+                )
+            }
+        }
+    }
+}
+```
+
+`buildStyleConfig()` / `buildSheetConfig()` 从「裸读可变全局」改为「从不可变快照派生」,
+`handleConfigUpdate` 尾部那两行手动 `_uiState.update { copy(styleConfig = …) }` **整段删除**。
+
+> 这一步之后,「忘记重建 sheetConfig」这个 bug 类别**不再存在**——写入必经 gateway,
+> gateway 必发 state,VM 必然收到。
+
+**同时修**:`_effects.tryEmit` → `emitEffectWhenSubscribed`([reader-config-flow-audit] 已列,
+tryEmit 在无订阅者时会丢渲染 effect)。
+
+**验收**:
+- `ReadBookViewModel` 中 `ReadBookConfig.` 的直读从 93 处降到 ≤10(只剩 A 类只读转发);
+- 真机 parity:编辑排版 → 关弹层 → 重开,值正确(覆盖 2026-07-24 修复的 5 个站点 + 预设/导入/日夜切换);
+- E0 的快照完备性测试仍绿。
+
+---
+
+### E3 —— UI 层去全局直读
+
+把 §1.4 的四个文件改成 `state + onIntent` 纯受控:
+
+1. `HeaderFooterPage.kt`(69 直读 / 29 镜像)—— 最大,建议单独一轮。
+2. `TextTitleSheet.kt`(18 镜像)
+3. `SystemMenuPage.kt`(4 / 7)
+4. `CustomTipTarget.kt`(13 直读)
+
+镜像状态的处理原则:
+- 纯展示值 → 直接读 `state.sheetConfig.x`,删掉 `remember`;
+- 拖动中的滑块等确需本地暂存的 → 保留 `remember`,但**必须带 key**(`remember(config.x)`),
+  上游变化能重新 seed。
+
+**验收**:`grep -rn 'ReadBookConfig\.' app/src/main/java/io/legado/app/ui/book/read/sheet/` 归零;
+弹层重开显示新值的真机用例通过。
+
+---
+
+### E4 —— 收敛 EventBus 整数码(可与 E3 并行)
+
+`EventBus.UP_CONFIG` 的 `ArrayList<Int>` 载荷改为直接投递 `Set<ConfigUpdateAction>`
+(或干脆让 8 个生产者改调 gateway/VM 的具名方法)。整数码 0–12 的翻译表随之删除。
+
+优先级低于 E1–E3:它是**可读性**问题,不是正确性问题(翻译表本身没查出错配)。
+
+**验收**:`grep -rn 'EventBus.UP_CONFIG'` 只剩事件定义;VM:2084 的整数 `when` 删除。
+
+---
+
+### E5 —— 渲染引擎去全局读(**可后置 / 与 Track D2 同源**)
+
+`ChapterProvider`(62)/`PageView`(54)/`TextChapterLayout`(11)/`TextLine`(12) 改为吃传入的
+`ReadStyleSnapshot`(E2 已经造好),而非排版时裸读全局。
+
+**明确标为可选**:
+- 收益是"排版可测试 + 渲染输入显式",不是修 bug;
+- 成本高、触及热路径(`ChapterProvider` 在排版每一行时读配置,改成对象传参需注意分配开销);
+- 与 Track D2(`ReadView` 入站只读输入)目标重合,**应合并评估,不要各做一遍**。
+
+建议:E2 落地后**先停**,等 Track D1 完成、D2 立项时把 E5 并入 D2 一起做。
+
+---
+
+## 4. 推荐顺序与依赖
+
+```
+E0 ──► E1 ──► E2 ──┬──► E3
+ (测试)  (类型)  (核心)  └──► E4
+                          
+                    E5 ← 并入 Track D2 评估(不单独做)
+```
+
+- **E0 → E1 → E2 是主线**,三步做完,「设置乱」的结构性成因基本消除。
+- E3/E4 是主线之后的清理,可并行、可分批。
+- E5 不在本轨道单独推进。
+
+**单步价值排序(若只能做一步)**:做 **E2**。它单独就能消灭"弹层显示旧值"整个 bug 类别;
+E0/E1 是为了让 E2 之后不再退化。
+
+---
+
+## 5. 验收总表
+
+| 阶段 | 可机器验证的判据 |
+|---|---|
+| E0 | 3 条不变式测试进 CI |
+| E1 | `grep 'actions = emptySet()'` == 0;漏填 actions 编译不过 |
+| E2 | VM 中 `ReadBookConfig.` 直读 93 → ≤10;`handleConfigUpdate` 无手动 `styleConfig` 重建;`tryEmit` 归零 |
+| E3 | `sheet/` 下 `ReadBookConfig.` 直读 == 0 |
+| E4 | `EventBus.UP_CONFIG` 生产者 == 0 |
+| E5 | (并入 D2) |
+
+**真机 parity 用例**(每阶段跑,用 `tools/android` 调试工具):
+编辑字号/行距/页眉页脚 → 关弹层重开值正确;切预设;导入配置;日夜切换;分享排版开关;
+以上每项后正文立即重排且不闪白。
+
+---
+
+## 6. 与其他轨道的边界
+
+| | Track D | Track E |
+|---|---|---|
+| 方向 | `ReadView` **出站**业务意图 | 设置 **入站**数据流 |
+| 触碰 | `ReadView.kt` 的 `ReadBook.*`/`ReadAloud.*` 直调 | `ReadBookConfig` / `ConfigUpdate` / sheet UI |
+| 依赖 | Track A(ReaderSession) | 无(可立即开始 E0/E1) |
+| 交汇 | D2(入站只读输入) == E5(引擎去全局读)→ **合并做一次** |
+
+---
+
+*创建于 2026-07-25。基线数据(行号/计数)截至同日 HEAD。*