定位:本轨道不改渲染方式(不 Compose 化正文、不动
ReadView的绘制/手势/动画),只解决 阅读正文界面配置的数据流问题。与 Track D(ReadView出站业务解耦)正交、可并行:D 管「View 怎么把业务意图发出去」,E 管「设置怎么流进来」。前置背景:[reader-settings-apply-channels]、[reader-config-flow-audit](两条 memory)已记录 三条应用通道与 2026-07-24 的最小修复。本计划把那些点状修复升级成结构性收敛。
| 底座 | 内容 | 存储 | 是否响应式 |
|---|---|---|---|
A. ReadSettings |
101 个字段(手势/亮度/菜单外观/键位/朗读…) | DataStore | ✅ preferencesFlow → 真 StateFlow(ReadSettingsRepository.kt:29-33) |
B. ReadBookConfig.Config |
排版预设(字号/行距/标题/页眉页脚/下划线/背景…),即 readConfig.json |
JSON 文件 + 内存 ArrayList<Config> |
❌ 可变全局单例,无 flow(ReadBookConfig.kt:31,77,103) |
ReadBookConfig 目前是混合门面:A 类字段是 get() = readSettings.x 只读转发(:182-243,干净),
B 类字段是 var x get/set config.x(:310+,可变)。同一个对象两种语义,是"乱"的第一层来源。
ReadStyleGateway.state 只暴露 items / selectedIndex / shareLayout 三项
(ReadStyleState.kt),
不含任何排版字段。于是 VM 只能在每次写入后手动重建两份快照:
buildStyleConfig()(ReadBookViewModel.kt:2290)—— 从可变全局裸读 ~25 字段buildSheetConfig()(:2322)—— 从可变全局裸读 ~50 字段失败模式是结构性的:任何新增的写入路径只要忘了重建,弹层就显示旧值。2026-07-24 的
sheetConfig 不重建 就是这个类别的实例——修了 5 个站点,但下一个新站点仍然会犯同样的错,
因为编译器管不着。
ConfigUpdate 有 157 个成员,每个手写 actions: Set<ConfigUpdateAction>(13 种动作)
(ReadBookContract.kt:1006-1558)。
StatusIconDark(缺 UpdateSystemUi)、
UnderlineColor(缺 UpdateContent+InvalidateTextPage+SubmitRenderTask)。actions = emptySet()(菜单外观类:MenuBlurRadius/MenuIconStyle/
FloatingBottomBar…)。它们写 DataStore、走 Compose 重组通道,和排版类成员通道完全不同,
却混在同一个 sealed interface 里,靠"手写空集"表达"我不走这条路"。| 文件 | 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。上游一旦 不经这些控件改值(预设、导入、日夜切换、另一处弹层),控件就显示陈旧值。
postEvent(EventBus.UP_CONFIG, arrayListOf(整数码)) —— 8 个生产者,VM 在
:2084 把整数翻回
ConfigUpdateAction。生产者:ApplyReadSettingUseCase×2、ChapterProvider:1111、ReadBook:304、
ThemeConfigStore×2、ReadAloudPlayerCoordinator:146。
排版写入已经收敛到 ReadStyleGateway.updateCurrentStyle(ReadStyleMutation) 的类型化键
(ReadStyleIntKey/FloatKey/BooleanKey/StringKey/ColorKey,
ReadStyleMutation.kt),
全库对 ReadBookConfig.x = v 的直接写入只剩 ReadBookStyleConfigRepository 一个文件。
Track E 不重做写入面,只补"写完之后怎么让所有人看到新值"。
ReadSettingsRepository.update{} 的 toGatewayPrefMap 只覆盖 45/101 字段(:550-596)。
> 更正(E0 落地时核实):这不是现存 bug,是有意设计——ReadSettingsGateway.update
> 的 KDoc 明确写了「仅持久化 gateway 实现映射声明的 45 个配置键,其余字段仍须通过对应的
> 遗留 Repository setter 写入」。全库只有 4 个 update{} 调用点(ThemeConfigViewModel:141、
> ReadBookViewModel:265/5237、MangaMenu:284),写的都是已在 map 内的字段。
> 真实风险是latent:将来有人对那 56 个字段之一 update{ copy(x=…) } 会被静默丢弃。
> 因此 E0 不去凑满 101,而是把「走不通 update{} 的字段集合」冻成基线做双向棘轮。ChapterProvider(62 处)/PageView(54)/TextChapterLayout(11)/TextLine(12)
在排版与绘制时直接裸读 ReadBookConfig ——渲染输入不是参数,是全局。ReadBookViewModel.kt 6546 行;ReadBookContract.kt 1558 行(Intent 265 / UiState 91 字段 / Effect 74)。
目标:排版设置有唯一可信来源 + 一条派生链,"改了不生效 / 弹层显示旧值"从 「靠人记得补一行」降级为「编译期或测试期挡住」。
反目标(明确不做)
StateFlow。ConfigUpdate 成员必须能分批迁移,每批可单独真机验收。ReadBookConfig 一次性删掉——它是 27 个文件的读入口,E5 之前保留门面。纯新增测试,产品代码零改动。三个文件、8 条断言,全部采用本仓既有的双向棘轮惯例 (新增违规报红 / 基线修好了不下调也报红)。
ConfigUpdateActionsInvariantTest(ui/book/read/)—— 反射遍历 ConfigUpdate
全部 157 个成员并实例化,断言 actions 要么非空、要么类名在 NO_RENDER_EFFECT
白名单(52 条)里;反向断言白名单无失效条目;外加一条「反射确实枚举到成员」防假绿。
> 直接封死 UnderlineColor 那类错配的复发。原计划由 E1 迁族后删除白名单,
> E1 已撤销,故白名单转为长期资产——它就是「这 52 项确认不需要渲染副作用」的备案。ReaderConfigSnapshotInvariantTest(ui/book/read/)—— 反射取
ReadSheetConfigUiState(50 字段) / ReadBookStyleConfig(25 字段) 的构造参数名,
源码扫描 buildSheetConfig() / buildStyleConfig() 的具名实参,断言无遗漏。
> 用源码扫描是因为两个函数是 VM 的 private 成员、VM 需大量 Koin 依赖才能构造。
> E2 之后应改写:那时它们变成 ReadStyleSnapshot 上的纯函数,可以直接实例断言。ReadSettingsGatewayCoverageTest(data/repository/)—— 行为性判定:逐字段
变异 ReadSettings 并观察 toGatewayPrefMap() 输出是否随之变化,把「走不通
update{} 的 56 个字段」冻成基线。不断言 101 == 45(那会推翻 §1.7 的既定设计)。变异验证(每条都实测过能变红,非空跑):白名单少一条 → 测试 1 红;白名单多一条 →
测试 2 红;给 ReadSheetConfigUiState 加字段不赋值 → 测试 3 红;给 ReadSettings
加字段不接线 → 测试 4 红。四条报错都直接给出字段名与修法。
现状:全量单测 285 条绿(E0 前 277)。
ConfigUpdate撤销理由(E0 之后核实):本阶段原写的两条依据都是错的。
- 「漏填 actions 编译不过」今天就已成立——
ConfigUpdate的val actions: Set<ConfigUpdateAction>已经是抽象成员(ReadBookContract.kt:1028),每个实现 必须 override。E1 号称新增的编译期保证,是既有的。- 「按写入目标分族 ⇒ 一族天然无 actions」不成立——两条轴正交。实测交叉表:
| | 有渲染副作用 | 无渲染副作用 | |---|---:|---:| | 写 readConfig.json 排版 | 80 | 1(
StyleName) | | 写 DataStore | 25 | 51 |那 25 个(
StyleSelect/ShareLayout/MenuBgColor/HideStatusBar/TextFullJustify…) 写 DataStore 但确实需要命令式渲染副作用。按写入目标分族会把它们塞进一个 「没有 actions 概念」的族里,直接坏掉。剩余收益不足以支撑改动:只剩「少写 52 个
emptySet()字面量 + 劈开大when」, 而 157 个成员逐个归类的误判风险是实打实的(归错一个 = 悄悄丢掉它的渲染副作用)。 真正的失效模式(actions 填空/填错)已由 E0 的ConfigUpdateActionsInvariantTest覆盖。若将来仍想收敛:正确的切法不是分族,而是让 actions 可推导——把
ConfigUpdateAction与「哪些渲染资源被这项设置影响」建立声明式映射,从设置本身推出 副作用集。那是独立议题,不在 Track E 范围内。
让 ReadStyleGateway.state 携带完整排版快照,而不是只有 items/selectedIndex/shareLayout:
// 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 侧:
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 类只读转发);把 §1.4 的四个文件改成 state + onIntent 纯受控:
HeaderFooterPage.kt(69 直读 / 29 镜像)—— 最大,建议单独一轮。TextTitleSheet.kt(18 镜像)SystemMenuPage.kt(4 / 7)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/ 归零;
弹层重开显示新值的真机用例通过。
EventBus.UP_CONFIG 的 ArrayList<Int> 载荷改为直接投递 Set<ConfigUpdateAction>
(或干脆让 8 个生产者改调 gateway/VM 的具名方法)。整数码 0–12 的翻译表随之删除。
优先级低于 E2/E3:它是可读性问题,不是正确性问题(翻译表本身没查出错配)。
验收:grep -rn 'EventBus.UP_CONFIG' 只剩事件定义;VM:2084 的整数 when 删除。
ChapterProvider(62)/PageView(54)/TextChapterLayout(11)/TextLine(12) 改为吃传入的
ReadStyleSnapshot(E2 已经造好),而非排版时裸读全局。
明确标为可选:
ChapterProvider 在排版每一行时读配置,改成对象传参需注意分配开销);ReadView 入站只读输入)目标重合,应合并评估,不要各做一遍。建议:E2 落地后先停,等 Track D1 完成、D2 立项时把 E5 并入 D2 一起做。
E0 ──► ✗E1 ──► E2 ──┬──► E3
✅已落地 已撤销 (核心) └──► E4
E5 ← 并入 Track D2 评估(不单独做)
单步价值排序(若只能做一步):做 E2。它单独就能消灭"弹层显示旧值"整个 bug 类别; E0 是为了让 E2 之后不再退化。
| 阶段 | 可机器验证的判据 |
|---|---|
| E0 | ✅ 3 个测试类 / 8 条断言进 CI,四条变异实测可红 |
| ❌ 已撤销:前提不成立(actions 本就抽象;两条轴正交) | |
| E2 | VM 中 ReadBookConfig. 直读 93 → ≤10;handleConfigUpdate 无手动 styleConfig 重建;tryEmit 归零 |
| E3 | sheet/ 下 ReadBookConfig. 直读 == 0 |
| E4 | EventBus.UP_CONFIG 生产者 == 0 |
| E5 | (并入 D2) |
真机 parity 用例(每阶段跑,用 tools/android 调试工具):
编辑字号/行距/页眉页脚 → 关弹层重开值正确;切预设;导入配置;日夜切换;分享排版开关;
以上每项后正文立即重排且不闪白。
| Track D | Track E | |
|---|---|---|
| 方向 | ReadView 出站业务意图 |
设置 入站数据流 |
| 触碰 | ReadView.kt 的 ReadBook.*/ReadAloud.* 直调 |
ReadBookConfig / ConfigUpdate / sheet UI |
| 依赖 | Track A(ReaderSession) | 无 |
| 交汇 | D2(入站只读输入) == E5(引擎去全局读)→ 合并做一次 |
创建于 2026-07-25,同日完成 E0。基线数据(行号/计数)截至同日 HEAD。