本文档是一份执行计划,不是一次架构评分。它把此前审阅收敛出的结论,转化为可分批落地、可验证、可回滚的工程步骤。
范围:阅读器核心(
ReadBook全局单例、ReadBookViewModel、ReadBook.CallBack渲染协议)以及全应用范围内的 UI→DAO 直连问题。不包含外围已完成 MAD 化的 Compose 屏幕。
本节是给新开窗口的接续点,描述当前 HEAD 的真实状态。两条横切地基(F1、F2 主线)已完成; Track A/B/E 已落地,Track C 已于 2026-07-25 删除,Track D 走完 D1a/D1b/D2;在此之上又做了一轮 R 系列(结构止血 → VM 拆分 → 出站解耦 → 配置底座),自 E0 起 main 上共 132 个提交。 下方数字均已核实。剩余结构性工作只有三项:D1c(阅读器核心 JVM 测试)、D2 残余(
page/包)、 Track B 端游 ②(Effect 投递握手,需先改状态式,建议最后评估);另有 R5 三项体验/安全项与一份 2026-07-30 全量审查的遗留清单(含两条 P1),全部列在「待续」节。
提交信息里 refactor(R4.7) 这类编号来自实施期的分批计划,与本文档轨道的对应关系如下——
没有这张表,读提交历史的人无法把 R4.x 接回本计划:
| R 编号 | 内容 | 对应轨道 |
|---|---|---|
| R1.1–R1.5 | 结构止血:排版快照重建站点收敛到三条通道、弹层去全局直读、EventBus.UP_CONFIG 整数码退役、排版度量收进不可变快照、update{} 键映射补全(56 字段静默丢写) |
Track E(E2 续 / E3 / E4)+ 新增项 |
| R2.1–R2.3 | ReadBookViewModel 拆分:33 处 DAO 直连溶解进 BookRepository、七个业务域摘出 delegate、VM 不再实现 ReadBook.CallBack(6671 → 2494 行) |
「ViewModel 拆分」节 + Track B 端游 ① |
| R3(= D1a/D1b/D2) | ReadView 出入站解耦:ReaderEvent 出站、CallBack 12→4 拆成瞬时副作用面、ReaderPageSource 入站 |
Track D |
| R4.1–R4.7 | 配置底座:渲染层排版直读收进快照、写盘原子化、解双向依赖、写面收进 gateway、状态本体外迁 ReadStyleConfigStore、Config 值字段不可变(ReadBookConfig 1405 → 982 行) |
Track E·E5 + Track D2 的配置侧 |
| R5 | 体验/包容性/测量:baseline profile journey、TalkBack 段落节点、WebView bridge DTO 化 | 本计划范围外的新增项,见「待续 ④」 |
dependsOn assemble/compile 在 CI 生效(build.gradle.kts)。
legacyDaoInjectionBaseline —— ViewModel 主线legacyUiDaoAccessBaseline —— 非 ViewModel 的 UI 层文件(第二条线)app/lint-baseline.xml 已建并接入 lint {};.github/workflows/verify.yml:44-51 的
verify job 显式跑 testAppDebugUnitTest + lintAppDebug + verifyConfigArchitecture + assembleAppDebug
四道门并上传报告。单测 272 → 455 全绿(含 R 系列新增的护栏测试)。verifyConfigArchitecture 从「DAO 直连 + 旧偏好」扩到管排版配置写入(ReadBookConfig
写必须经 ReadStyleGateway、ReadStyleConfigStore 只许 4 个文件引用);单测侧新增 8 个边界/不变式测试类。
R4.7 后 Config 的值字段是 val,配置写入的主要约束已从正则升级为编译器。77d38b8…04161fa);随后清完 8 个可清 VM(Web/BookInfoEdit/ChangeCover/TagGroupRule/SourceLogin/AudioPlay/ReadManga/BookInfo)+ ReadBookViewModel 非阅读态子集。主线基线从 ~247 → 130 → 36 → 0(ReadBookViewModel 的 book/chapter 阅读态访问已随 R2.1 溶解进 BookRepository)。replaceChaptersAndUpdateBook)、换序 move(BookSourceDao.moveToTop/Bottom)已改原子事务。下面 ①–⑥ 是各轨道已落地内容的明细与踩坑记录,保留作查证用。真正的待做事项见后面的「待续」节。
① F2 主线已清零(build.gradle.kts 的 legacyDaoInjectionBaseline):
| VM | 直连数 | 备注 |
|---|---|---|
ReadBookViewModel |
0 | 原 24× bookChapterDao + 12× bookDao 已于 R2.1 全部改走 BookRepository;棘轮保留 0 值条目继续盯这个文件 |
R2.1 已把余下 33 处改走
BookRepository。计划书原文写的是「随ReaderSession溶解」,实际没这么做:ReaderSession的契约明写「调用方只拿到只读快照,拿不到可变领域对象(Book/TextChapter)」,往里加getChapter(): BookChapter?正好破掉这条不变式,而BookRepository早已具备所需的全部方法。清 8 个可清 VM 时给若干仓库补了无聊方法:
BookRepository(getAll/getLastReadBook/getChapter/delete)、BookSourceRepository(has/getAllTextEnabledPart)、SearchRepository(getEnableHasCover/getSearchBook)、BookGroupRepository(getIdsSum/getGroupNames)、HighlightTagRuleRepository(getEnabled,并在 Koin 注册)、HttpTtsRepository(getAll/getAllSync/getNameSync/delete)。宿主by viewModels()→ Koinby viewModel()已同步改(Web/BookInfoEdit/ChangeCover/SourceLogin/AudioPlay)。
② F2 第二条线(非 VM UI 层):legacyUiDaoAccessBaseline 已冻结 26 文件 / 62 处,只冻不修。等主线清完、仓库齐备、部分 Activity 随 Compose 迁移消失后再回头清(大头 BookSourceActivity=10、RssJsExtensions=8、KeyboardAssistsConfig=7)。
③ F1 收尾 ✅ 已关上:.github/workflows/verify.yml 的 verify job 显式跑四道门(test / lint / 架构检查 / 构建),失败即红,报告无条件上传。F1 全部完成。
④ Track A:A1–A5 已全部落地。A1–A3:ReadBook.book/durChapterIndex/durChapterPos 已 private set,外部 6 处写入改走 replaceCurrentBook/clearCurrentBook/updateReadingPosition + isCurrentBook 谓词,编译器证明外部写入点为 0。A4:LegacyReaderSnapshot + ReadBook.snapshot: StateFlow,publishSnapshot() 落在 14 个内部 mutator + 3 个命令的原子末尾。A5:ReaderSession 接口 + LegacyReaderSession 桥接(model/ReaderSession.kt,Koin 注册),ReadBookViewModel 已注入并 collectReaderSession() 反应式消费。
⑤ Track B:B1/B2 已落地(渲染子集下沉),端游剩余项见下。Track C:C0–C5 已落地后按 2026-07-21 决策暂停(Compose overlay 冻结为 flag 下的可选渲染器,不推进 C6–C9),详见下方 ⑥ 与 docs/dev/track-c-compose-reader-plan.md 顶部状态。
ReadBook.CallBack 切成 ReaderRenderCallback(渲染子集:upContent/upContentAwait/pageChanged/contentLoadFinish/upPageAnim/cancelSelect + LayoutProgressListener)与状态子集 CallBack(upMenuView/loadChapterList/notifyBookChanged/sureNewProgress)。ReadBook.renderCallBack: ReaderRenderCallback 独立槽位;30+ 处渲染调用点由 callBack?. 改走 renderCallBack?.。ReadBookController 实现 ReaderRenderCallback,onRefsReady 注册 / clearTts 注销,渲染方法复用既有 handleEffect 分支(零渲染逻辑漂移)。线程关键点:ReadBook 在 Coroutine.async(默认 Dispatchers.IO) 里回调渲染,旧路径靠 VM 的 SharedFlow→生命周期收集器切主线程;B2 用 handler.post/withContext(Main) 保留同样的异步-切主线程语义。业务状态刷新改由 collectReaderSession() 收集快照驱动——为内容加载路径(contentLoadFinish/contentLoadFinishAwait 布局完成、loadOrUpContent、upMsg)补 publishSnapshot(),补齐旧 VM 渲染回调顺带做的 syncFromReadBook(尤其 seekMax 依赖 curTextChapter.pages.size)。isInitFinish 作为纯业务标志留在 VM,由 controller.contentLoadFinish 幂等置位。ReadBook.CallBack00373ffa6 R2.3):状态子集迁进 LegacyReaderSession,全仓唯一 : ReadBook.CallBack 声明现在是 model/ReaderSession.kt:86,VM 改为反应式消费会话事件。护栏 ReadBookCallbackBoundaryTest 盯住不回退。emitEffectWhenSubscribed 的 subscriptionCount 握手仍在(一处,服务 UpdateReadViewConfig 的 init 时序)。判读修正(2026-07-30):曾以为「R4 做完配置所有权就能顺带关掉它」——不成立。这是效果投递问题(_effects 是 replay=0 的 SharedFlow,ReadView 订阅前 emit 会被丢),与配置所有权正交。要关掉它得让配置应用改成状态式(ReadView 拉快照而非收一次性 effect),属 Track E·E2 的续作,是剩余项里唯一的「大改造」,建议排在最后并先评估收益(它现在只护一处时序)。00373ffa6 R2.3):PageChanged/ContentLoadFinish/LayoutPageCompleted 已内联进 controller 的渲染方法,对应 Effect 类删除。pages.size,B2 改为布局完成后单次 publishSnapshot 刷新,非逐页)、首帧渲染(registerRender 已在有内容时立即同步一次)、朗读中翻页、upContentAwait 时序(B2 下 withContext(Main) 会在 await 内同步跑完视图更新,比旧实现「只 await emit」更靠后完成,需确认换章无闪烁/错序)。⑥ 方向调整(2026-07-21):Compose 阅读器暂停于 C5,新增 Track D(ReadView 自身业务解耦)为近期主线。
依据:C3 真机帧基线显示 Compose 渲染劣于旧 View(jank 50% vs 4.13%;50/90/95/99 = 30/42/44/53ms vs 5/8/21/65ms),且仿真卷曲/选择/滚动/自动翻页等成熟能力全量重写为「高回归风险、低用户收益」的改造。故:
ReaderRenderer 契约、ReaderRenderModel、ComposeReaderSurface、不可变 ReaderPageSnapshot)保留在 lab flag 后,冻结为可选可插拔渲染器(默认关),作为「将来真要换渲染器」的逃生舱与 parity 实验台。C6–C9 不再是当前主线,待后续专门的性能调查后再重估「Compose 默认 + 删 View」还是「View 永久渲染岛」。ReadView 自身的出站业务耦合解掉。核实(ui/book/read/page/ReadView.kt):ReadView 仍直呼 ReadBook.moveToNextChapter/moveToPrevChapter/moveToNextPage/moveToNextChapterAwait/syncProgress、ReadAloud.pause/resume(509–750 行),并经 callBack.* 直接驱动书签/内容编辑/替换规则/目录/搜索/进度确认(504、513–519 行)。这是 A/B/C 都未触及的一面——A 收敛 ReadBook 所有权、B 把渲染回调移出 VM、C 让 Compose 面消费只读快照,都没动 ReadView 的出站调用。Track D 用 ReaderEvent 出站接口把这些业务调用路由回 ReaderSession/命令,让 View 阅读器本身符合 UDF——不依赖 Compose。设计见下方 Track D 节。ComposeReaderSurface.kt/ReaderRenderModel.kt/ReaderRenderStateStoreTest.kt/ReaderPageSnapshot.kt),作为冻结状态;可单独提交 feat(Track C): C5 不可变快照(冻结为可选渲染器)。建议顺序:真机验证 R4.5–R4.7 → ③ 收尾包(小步快合)→ ① D1c 测试专项 → ④ R5 按空档插队 → ② D2 残余 → 最后评估 Track B 端游 ②。
① D1c —— 阅读器核心 JVM 测试(回报最大,阻塞链刚解开)
Track D 的既定下一步。ReadView 现在无 Activity 依赖、三个协作面(callBack/eventListener/pageSource)
构造期非空注入,可在 JVM/robolectric 下构造。此前的五级阻塞全在 ReadBookConfig 一侧,R4 做完才真正解开。
项目已有 robolectric 4.16.1(app/build.gradle.kts:180)却零阅读器核心测试。第一批目标:
手势 action 路由表(fake listener 断言)、翻页边界 hasNext/hasPrev、pageFactory 换章。
做完翻页回归不必再全压真机,并能顺带兜住 ③ 收尾包的回归面。
② D2 残余(page/ 包,可后置)
D2 只做了 ReadView.kt 一个文件的入站解耦。同包另有 8 个文件直接摸 ReadBook,其中
TextPageFactory 6 处是写操作(setPageIndex/moveToNextChapter/moveToPrevChapter)——
渲染层下达业务命令,性质同 D1,只是当时文档把 D1 划在一个文件里。其余是 PageView/ContentTextView/
ChapterProvider/TextChapterLayout 读 ReadBook.book/bookSource。另有两项在 LayoutMetrics 的
KDoc 里已认领为 D2 范围:TextChapterLayout 构造期 8 项配置逐项读(无组内原子性)、
setTypeHtml 在 IO 线程就地改共享 contentPaint.color。做 ① 的测试时会自然摸到这些。
③ R4.1 快照体系收尾包(2026-07-30 全量审查发现,小步快合)
审查结论:R4.4–R4.7 的语义等价性成立、无 P0;问题集中在一条缝——快照重建入口只覆盖了经
ReadBookController 的那条路。按性价比排序:
| 项 | 位置 | 说明 |
|---|---|---|
| P1 设置页改「底部对齐」不生效 | ui/config/readConfig/ApplyReadSettingUseCase.kt:48 |
updateLayout() 不调 upRenderStyle(),而 textBottomJustify 是 RenderStyle 快照字段且消费点在排版路径。R4.1 引入的回归,一行修复 |
P1 绘制热路径经 ReadConfig 读可变全局 |
page/entities/TextLine.kt:79,162,243、TextPage.kt:301 |
ReadConfig.useUnderline 每行每帧走 GlobalContext.get() + 全量构造 ReadSettings(上百次 preferences 查找),比 R4.1 消灭的 @Synchronized getConfig 锁更贵。三项(useUnderline/optimizeRender/isEInkMode)进 RenderStyle,护栏正则从 \bReadBookConfig\b 扩到 ReadConfig——「渲染层零直读」目前对它完全不设防 |
| P2 补两个快照重建入口 | ReadBookController.onRefsReady、help/storage/Restore.kt:357 |
前者治「跨会话改隐藏状态栏等滞留整个会话」(ReadConfigUpdateBus 无 replay,无收集者时事件即丢);后者治「恢复备份后正文排版不变」。另注意 controller 的 val r = refs ?: return(:996)排在预循环重建之前,view 未挂载期连重建也跳过 |
| P2 纯收紧一提交 | data/repository/ReadStyleConfigStore.kt:40,57,85、ReadBookConfig.kt:536-546 |
shareConfigRef 三个写点只一个在锁内且非 @Volatile;Config 构造器残留 13 个 private var(现无写点,但「写入由编译器把守」对文件内部不成立)。两者都是零行为变化 |
| P2 护栏三个绕过口 | build.gradle.kts、ChapterProviderMetricsInvariantTest |
成员 import 后裸写 durConfig = 绕过写入正则(R4.6 的堵法只堵在 sheet 单测、未回移植);ReadStyleRepository 是 Koin 单例却无引用限制(注入即可绕 store 直写盘,磁盘与内存分叉);度量护栏显式 filterNot { startsWith("import ") } |
| P2 祖传 bug 顺手修 | ChapterProvider.kt:157 |
共享排版开启时下划线间距写 shareConfig(mutateEffective)读 durConfig,永不生效。R4.1 把它收敛到一处后可修 |
| P2 换书误杀新会话预取 | model/ReadBook.kt:1753-1767 |
unregister(cb) 只有 callBack = null 受 === cb 身份保护,后面的 preDownloadTask?.cancel()/cancelChildren()/ImageProvider.clear() 无条件执行。A→B 快速换书时旧 VM 清场会掐掉新会话的预取。pre-existing,非 R 系列回归 |
| P3 杂项 | — | ReaderSessionEvent.NewProgressAvailable 是死通道(ReadBook 无 callBack?.sureNewProgress 调用点),可连同 CallBack 成员一起删;BookChanged 事件建议带 book 载荷(现消费端读全局 ReadBook.book,正确性依赖两个隐式时序前提);ReadConfigJsonRoundTripTest:86-96「copy 重置颜色缓存」是空断言(从未预热缓存)且文件头注释与事实相反;highlightRules 是 ArrayList<全 var 实体>,是「不可变 Config」唯一的洞(现零写点、无护栏) |
④ R5 —— 体验/包容性/测量(互不阻塞,可随时插队)
ContentTextView 接 ExploreByTouchHelper:段落级虚拟节点,让 TalkBack 能逐段读正文。BaseSource 实体直接注入远程页面 → DTO 化;dataExtractionRules 排除书源库/Cookie。⑤ 长尾账本(记着,不排期)
legacyUiDaoAccessBaseline 仍冻结 26 文件 / 62 处,按既定纪律等 Compose 迁移消化。ReadBookController 的 clickImg(:586-589、609-612、636)三处 appDb.bookChapterDao 直连不在任何棘轮账本里——两条基线只覆盖 ui/** 的 VM 与非 VM 文件,controller 漏网。ReadBookViewModel 仍是 BaseViewModel(application)(:114-130)——「ViewModel 持有 Application」这条 MAD 扣分项未消除。app/src/debug、.java 文件、app/src/main/kotlin(若被创建)、RENDER_ENTITIES 死名单外的新渲染实体文件。无——工作区干净,上述 R 系列全部已入 main。R4.5/R4.6/R4.7 尚未真机验证(写盘/不可变化改动,回归面见各自提交说明), 建议与 ③ 的修复合成一次真机验证:换排版预设、导入配置、共享排版开关下各字段写入、页眉页脚全项、 备份恢复后排版生效、底部对齐。
用两个维度描述现状,而不是一个小数分:
Application、ViewModel→UI 存在指令式 Effect 通道、CI 无测试/lint 质量门禁。verifyConfigArchitecture)、领域抽象(Gateway/UseCase/Repository)真实存在、迁移方向正确。这两个分数并不矛盾:一次高质量的遗留迁移,可以同时只是中等程度符合目标架构。本计划的目标是把第一个分数往上推,而不破坏第二个分数所代表的迁移纪律。
第一个分数的五条扣分项里三条已消除:UI→DAO 系统性直连(主线基线 0)、CI 无质量门禁(verify.yml 四道门)、
阅读器核心由不受控的全局可变单例驱动(仍是单例,但会话字段 private set、外部写入点 0、有权威 snapshot
StateFlow、配置侧写入收进 gateway 且值字段 val)。仍在的两条:ReadBookViewModel 仍是
BaseViewModel(application);ViewModel→UI 的一次性 Effect 通道仍在(但渲染类 Effect 已全部下沉出 VM,
剩余面是可辩护的最小集)。第二个分数所代表的迁移纪律没有被破坏——每一批都带护栏与反证、零全量重写。
重要边界(回答「R 系列做完是不是就 MAD 完成了」):本计划的范围是阅读器核心 + 全应用 UI→DAO, 不是整个应用的 MAD 成熟度。R 系列走完后,下列全应用维度从未进入本计划,它们不属于「阅读器现代化」 但确实是 MAD 画像的一部分:
:app 模块(:modules:book/:modules:rhino 只是库封装)。单模块下 internal
几乎不设防——这也是本计划多处只能靠正则护栏、而非语言可见性的根因。这是计划撰写时(2026-07-21 前)的基线,不是当前状态。「现状」列记录 2026-07-30 的实际值—— 保留原始基线是为了让后来者看清每条债是怎么被消掉的;要看当前状态请读上面的进度快照。
| 事实(撰写时基线) | 证据 | 现状(2026-07-30) |
|---|---|---|
ReadBook 是进程级全局单例 |
object ReadBook : CoroutineScope by MainScope(), KoinComponent(model/ReadBook.kt:73) |
仍是单例,但会话字段 private set、外部写入点 0、有权威 snapshot: StateFlow(Track A) |
| 阅读器真实所有权在单例,VM 是回调适配器 | ReadBookViewModel(...) : BaseViewModel(application), ReadBook.CallBack(ui/book/read/ReadBookViewModel.kt:203) |
VM 不再实现 CallBack(R2.3);仍是 BaseViewModel(application),「VM 持有 Application」未消除 |
ReadBookViewModel 体量失控 |
6484 行 | 2494 行(R2 拆出 10 个 delegate,护栏钉住 2500 行验收线) |
ReadBook 被外部广泛引用 |
44 个非 VM 文件引用;写入约 7 处;引用逃逸式写入近 0;其余为读取/身份比较 | ReadView.kt 引用归零(D1/D2);page/ 包另 8 个文件仍直读,含 TextPageFactory 6 处写操作,见待续 ② |
Book 是可变实体 |
data class Book(... var durChapterIndex ... var durChapterPos ...)(data/entities/Book.kt:41) |
未变(Room 实体,A3 完整形态「绝不外递可变 Book」未做) |
CallBack 是混合协议(渲染 + 业务状态) |
11 个方法,interface CallBack : LayoutProgressListener(model/ReadBook.kt:1330) |
B1 切成 ReaderRenderCallback(渲染)+ CallBack(状态 4 项);其中 sureNewProgress 现已是死通道(无调用点),可删 |
upContentAwait 是 suspend,模型反向等待视图 |
model/ReadBook.kt(CallBack 定义内) |
未变(B3 已说明:能搬不能删,删它属重写渲染器) |
VM 是 CallBack 唯一实现者 |
全仓仅 ReadBookViewModel 声明 : ReadBook.CallBack |
已换成 LegacyReaderSession(model/ReaderSession.kt:86),VM 不再实现(R2.3) |
| Effect 通道带订阅握手 | _effects.subscriptionCount.first { it > 0 }(ReadBookViewModel.kt:210-223) |
仍在,一处(ReadBookViewModel.kt:167-170),服务 UpdateReadViewConfig 的 init 时序;退役属 Track E·E2 续作,见端游 ② |
| UI→DAO 直连是系统性模式 | 计划撰写时约 33 个 VM 构造注入 DAO | 主线基线 0(R2.1 收尾);第二条线 26 文件 / 62 处只冻不修;ReadBookController 三处漏在账本外,见待续 ⑤ |
| CI 门禁 | lint 基线与架构护栏已就位;仍缺显式 CI test/lint job | ✅ 已补齐:verify.yml:44-51 四道门,单测 455 全绿 |
| 已有架构护栏 + 基线机制 | VerifyConfigArchitectureTask + 三条基线(legacyPreferenceCallBaseline / legacyDaoInjectionBaseline / legacyUiDaoAccessBaseline),已 dependsOn assemble/compile |
扩到管排版配置写入 + ReadStyleConfigStore owner 名单;单测侧 8 个边界/不变式测试类;R4.7 后配置写入主要由编译器(val)约束。已知三个绕过口见待续 ③ |
ReadBook 6000 行不横向切成几十个文件——那只是"分文件的 God Object"。upPageAnim/cancelSelect 之类指令式渲染命令塞进 UiState(shouldAnimatePage: Boolean)。那是把门铃伪装成恒温器。private set)约束,而不是先写 Detekt/lint 规则。改造被拆成若干相互解耦、可并行的轨道,外加两条横切地基。
横切地基:
F1 CI 质量门禁(测试/lint 基线/架构检查) —— 已完成 ✅
F2 UI→DAO 去注入(全应用,非阅读器专属) —— 主线清零 ✅,第二条线只冻不修
Track A 所有权与业务状态 —— 已落地(A1–A5)
Track B 遗留渲染边界下沉 —— 已落地(B1/B2)+ R2.3 关掉端游 ①③,只剩握手 ②
Track C 声明式渲染 / Compose —— C0–C5 已落地,2026-07-21 起暂停;2026-07-25 已删除
Track D ReadView 自身业务解耦 —— D1a/D1b/D2 已落地(= R3);剩 D1c 测试 + D2 残余(page 包)
Track E 阅读设置的 SSOT 与 UDF —— E0–E5 全部达成(E1 撤销、E5 并入 R4.1);配置底座另做了 R4.1–R4.7
状态汇总(2026-07-30):五条轨道 + 两条地基只剩三处开口——D1c(阅读器核心 JVM 测试)、 D2 残余(
page/包 8 个文件,含TextPageFactory6 处写)、Track B 端游 ②(Effect 投递握手, 需先把配置应用改成状态式)。加上范围外新增的 R5 三项与一份审查遗留清单,见进度快照「待续」。方向修订(2026-07-21):原路线把 Track C(Compose 替换阅读器)当作阅读渲染的终点。基于 C3 真机性能基线(Compose 帧耗时/jank 明显劣于旧 View)与「仿真卷曲/选择/滚动/自动翻页全量重写=高回归风险、低用户收益」的判断,改为:保留成熟的
ReadView作为渲染核心,把它从「业务+状态+渲染全包」重构成只做绘制/手势/动画的专业渲染岛;Compose 用于阅读器外围 UI(工具栏/菜单/设置/弹窗)与「可选可插拔渲染器」实验。MAD ≠ Compose:分层、UDF、状态所有权、ViewModel+StateFlow、生命周期感知、可测试的数据/业务层,均可在 View 上成立(Android 官方至 2026 仍维护专门的 View 架构指南)。Track C 是否最终成为默认渲染器,留待专门的性能调查后重估,不作为当前目标。
没有它,后续拆 DAO、拆单例、拆 VM 都没有安全网。
当前状态(2026-07-30):F1 全部完成 ✅。单测 455 全绿;
app/lint-baseline.xml已建并接入lint {};.github/workflows/verify.yml的verifyjob 显式跑全部四道门。
任务
app/lint-baseline.xml07e7a8a3f 清掉 9 个既存 lint 错误,让门禁真正可用)。CI 增加一个 ✅ 已落地(verify jobverify.yml:44-51):
./gradlew testAppDebugUnitTest
./gradlew lintAppDebug # 仅新增违规会 fail(基线已冻结历史债)
./gradlew verifyConfigArchitecture
./gradlew assembleAppDebug
注:
verifyConfigArchitecture已通过dependsOn在 assemble/compile 前运行,此处显式列出是为了让门禁意图清晰、失败信息更早。
无论成败都上传 test/lint 报告。
基线纪律(防止"CI 变绿垃圾压缩机")
验收:任一新 PR 若新增 lint 违规、破坏单测或触碰架构规则,CI 红。历史债务不阻塞。
系统性问题。按功能逐批,不预造大仓库。
冻结已落地 ✅:VerifyConfigArchitectureTask 里实际统计 daoImport + appDbDaoAccess 两种形式,双向棘轮 + 陈旧检测,覆盖两条线:
legacyDaoInjectionBaseline —— ui/** 里含 ViewModel 的文件(主线,当前 0;ReadBookViewModel 保留 0 值条目防回退,另有 CloudTtsViewModel 13 处为护栏缺席期冻结的历史债)legacyUiDaoAccessBaseline —— ui/** 里非 ViewModel 的文件(第二线,当前 26 文件 / 62 处,只冻不修)第二条线是后加的:清 VM 时发现护栏只看
*ViewModel*,而BookSourceActivity这类兄弟文件里还有直连 DAO。故把范围扩到整个ui/**、给非 VM 文件建了第二基线,防止"清了半个 UI 层还以为清干净了"。
逐批清零:每批一个功能,DAO → 薄 Repository → VM。仓库刻意无聊:
class ReplaceRuleRepository(private val dao: ReplaceRuleDao) {
fun observeAll() = dao.flowAll()
suspend fun save(rule: ReplaceRule) = dao.insert(rule)
suspend fun delete(rule: ReplaceRule) = dao.delete(rule)
}
它的价值是边界,不是业务:VM 不再认识 Room、测试可替身、实体不再向上泄漏、依赖方向可强制。清一批,就从基线里删一批条目。
逐批操作手册(已跑通 5 批,新窗口照此做):
appDb.xxxDao / import data.dao.* → 注入 XxxRepository(优先复用已有:BookRepository / BookSourceRepository / SearchRepository / BookmarkRepository 等)。suspend fun … = withContext(Dispatchers.IO) { dao.… };VM 原本非 suspend 暴露的同步读就保持同步,别硬加 withContext 改签名。by viewModels() / by activityViewModels() 必须改成 Koin by viewModel() / by activityViewModel()——否则运行时崩,compile 不报。singleOf(::Xxx) 注册,新增构造参数自动解析;否则要在 appModule 补 get()。legacyDaoInjectionBaseline 删该 VM 条目(清到 0 删行;部分清就下调数字——护栏强制"减少了必须下调")。verifyConfigArchitecture + :app:compileAppDebugKotlin + testAppDebugUnitTest,全绿。已踩过的坑:
isEmpty() 改 isBlank()、别改条件、别重排逻辑(换源那批出现过一次,已改回)。runInTransaction(Kotlin 直调),或做成 DAO 的 @Transaction 方法。验证事务真生成:查 app/build/generated/ksp/appDebug/java/.../Xxx_Impl.java 里是否 performBlocking(…, inTransaction=true, …)。@Transaction 是否真生成事务,要分别查 appModule 注册方式和生成的 *_Impl.java。验收:两条基线均单调递减;ui/** 任意文件(VM 或非 VM)新增 DAO 直连,CI 红。
进度:A1–A5 已完成并提交(见本节末「A4/A5 落地说明」)。
目标:阅读状态有唯一写入入口,Snapshot 可信。不受 View/Compose 进度影响。
A1. 编译器冻结写入 ✅。把 ReadBook 的会话字段改为 private set:
var book: Book? = null
private set
var durChapterIndex = 0
private set
var durChapterPos = 0
private set
用
private set而非internal set——单体:app内internal几乎不设防。
A2. 为外部写入点提供语义化命令 ✅。核实后外部写入面只有 6 处(不是估的 7):BookInfoViewModel×2、BookInfoEditViewModel×1、Book.delete×1(置空 book)、ReadBookController×2(章内位置)。实际落地的命令集(忠实最小实现,命令体只做原代码做的事,不做计划示例里那种「顺手重置 index/pos」的行为漂移):
fun replaceCurrentBook(book: Book) // 仅替换引用,不重置章节/进度
fun clearCurrentBook() // 仅置空 book
fun updateReadingPosition(position: Int)
计划原稿的
moveToChapter/clearCurrentSession没有实现——现有外部写入点没有这两种语义;章节跳转本就走ReadBook.openChapter/setProgress等内部方法,不是外部裸赋值。避免造未使用的命令。
A3. 意图化谓词替代裸实体身份比较 ✅(部分)。已落地:
fun isCurrentBook(bookUrl: String): Boolean = book?.bookUrl == bookUrl
fun isCurrentBook(other: Book): Boolean = book?.isSameNameAuthor(other) == true
6 处写入点的身份判定已改走谓词。
A3 的完整形态(「绝不向外递出可变
Book」)未做——ReadBook.book仍在 ~44 个文件被读取,是 Track B/C 与 VM 拆分的范畴,不在本轮。已核实当前「引用逃逸写入」(ReadBook.book?.durChapterIndex = x)为 0(grep 无命中),private set也堵住了其经由ReadBook.*字段复发的路径。
A4. 权威不可变快照 ✅。仅在写入受控后引入:
data class LegacyReaderSnapshot(
val bookId: String?,
val chapterIndex: Int,
val chapterPosition: Int,
val chapterCount: Int,
val isLoading: Boolean,
val isReadingAloud: Boolean,
// 注意:不直接暴露可变 Book / TextChapter / List / Map
)
private val _snapshot = MutableStateFlow(buildSnapshot())
val snapshot: StateFlow<LegacyReaderSnapshot> = _snapshot.asStateFlow()
private fun publishSnapshot() { _snapshot.value = buildSnapshot() }
每个受控 mutator 在一次原子状态转移完成后(而非每次单字段赋值后)调用 publishSnapshot():
fun replaceCurrentBook(book: Book) {
this.book = book
durChapterIndex = book.durChapterIndex
durChapterPos = book.durChapterPos
clearTextChapter()
publishSnapshot() // 只发一次,不暴露中间态
}
性能红线:快照区分高频与低频。
chapterPosition/viewport 随滚动高频变化,bookId/chapterIndex/结构信息低频变化。不要在每个滚动 tick 深拷贝TextChapter进快照。高频 viewport 状态与低频结构状态应分开,避免把 60fps 热路径挂到 equality-based 快照上。
A5. ReaderSession 面向所有者的 API ✅(写入全部受控后引入):
interface ReaderSession {
val state: StateFlow<ReaderSessionState>
suspend fun open(bookId: String)
suspend fun moveToChapter(index: Int, position: Int = 0)
suspend fun nextChapter()
suspend fun previousChapter()
suspend fun updateViewport(position: Int)
}
首个实现仍可 LegacyReaderSession → ReadBook,但必须保证:所有 mutation 经过它;state 投影真实遗留状态、不维护竞争副本;调用方拿不到可变领域对象。
验收:ReadBook 会话字段全部 private set ✅;外部写入点为 0(全部走命令)✅(编译器保证);snapshot/state 在任何路径的状态变更后都能正确发射 ✅(见下「发布覆盖」);VM 可从 ReaderSession.state 取业务状态 ✅(ReadBookViewModel.collectReaderSession())。
实际实现与计划示例的差异(忠实优先):
ReadBook 自己拥有、并在受控 mutator 中重新发布的廉价标量——bookUrl/bookName/chapterIndex/chapterPos/chapterCount/simulatedChapterCount/isLocalBook。刻意排除计划示例里的 isLoading / isReadingAloud:isReadingAloud 真实来源是 BaseReadAloudService.isRun(独立服务,ReadBook 变更不触发其重发),isLoading 只有 msg/loadingChapters 间接信号;塞进 ReadBook 发布的快照会得到静默陈旧字段。要做需让来源方参与发布,另立字段。replaceCurrentBook 只替换引用、不像计划示例那样顺手 clearTextChapter/重置 index/pos(那是行为漂移)。resetData/upData/setProgress/moveToNextPage/moveToPrevPage/moveToNextChapter(+Await)/moveToPrevChapter/skipToPage/setPageIndex/openChapter/syncReadAloudPage/upToc/onChapterListUpdated)+ 3 个语义命令,每处在原子转移末尾 publishSnapshot()(grep 已逐一核对覆盖)。过发无害(StateFlow 对相等值去重),漏发才会陈旧。ReadBookViewModel 用反应式收集 readerSession.state.collect { syncFromReadBook } 消费,而非让 syncFromReadBook 同步读 snapshot.value。原因:upMenuView()/pageChanged() 会在 mutator 方法体内、publishSnapshot() 之前同步调用 syncFromReadBook;若那里读 snapshot.value 会拿到发布前的旧值(一帧滞后)。反应式收集在 mutator 返回后异步触发,此时 ReadBook 字段已是最终态,syncFromReadBook 直读 ReadBook 即新值;该刷新与遗留 CallBack 路径叠加、幂等,无回归、无滞后。性能:快照全是标量、buildSnapshot() 为 O(1),发布频率是用户级(翻页/换章/位置 settle),非 60fps 滚动 tick,未把热路径挂上 equality 快照。updateReadingPosition 目前仅 ReadBookController 朗读暂停/恢复 2 处调用,非逐帧。
仍未做(Track A 之外):A3 完整形态「绝不外递可变 Book」——ReadBook.book 仍在 ~44 文件被读,属 Track B/C 与 VM 拆分。快照的「高频 viewport / 低频结构」二流拆分本轮未做(当前单流即够,因无 60fps 发布源);若将来位置更新变高频再拆。
验证边界:编译 + 272 单测全绿,但都覆盖不到「阅读器实时状态在每条路径正确发射」。需在真机/模拟器验证的路径:翻页/换章时章节号与进度、保存书籍信息时正在读该书、删除正在读的书、朗读中翻页。
进度:B1(接口切分)+ B2(渲染子集下沉到
ReadBookController)已落地并提交,端游剩余项见进度快照 ⑤。
关键结论:把指令式渲染协议从 ViewModel 移到 UI 层的渲染控制器,不需要 Compose。Compose 只用于让渲染声明式(Track C)。
B1. 先拆分 CallBack 接口,而不是整体搬迁。CallBack 是两套协议穿了一件外套:
| 子集 | 方法 | 归属 |
|---|---|---|
| 指令式渲染 | upContent、upContentAwait、pageChanged、contentLoadFinish、upPageAnim、cancelSelect、LayoutProgressListener.* |
Track B 渲染控制器 |
| 业务/UI 状态 | upMenuView、loadChapterList(book)、notifyBookChanged、sureNewProgress(BookProgress) |
Track A 会话 / VM UiState |
陷阱:若让渲染控制器实现整个
CallBack,就把sureNewProgress/notifyBookChanged这些真业务事件塞进了渲染层——正是当前"业务状态骑在渲染总线上"的镜像翻版。Track B 第一步是把接口切成ReaderRenderCallback(渲染子集)与状态子集,后者进会话/VM。
B2. 渲染子集下沉到 UI 层控制器:
ReadBook ──legacy render callback──▶ LegacyReaderRenderController
├─ ContentTextView.invalidate()
├─ PageProvider.update()
├─ startPageAnimation()
└─ clearSelection()
LegacyReaderRenderController 是普通 UI 层对象,生命周期绑定 Activity/View。它合法地调用指令式 View API——因为它就是 UI 实现层,知道 View 是否 attached/laid out。
成果:删掉一整层无价值中转——
旧:ReadBook → VM → SharedFlow Effect → View → ContentTextView
新:ReadBook → UI 渲染适配器 → ContentTextView
subscriptionCount 握手随之从 VM 消失;渲染时序由真正拥有 View 生命周期的对象管理;VM 不再是渲染总线。
B3. upContentAwait 是 B/C 边界的绊线。它是 suspend——ReadBook 挂起自己的协程等待视图完成排版,是一次 model→UI→model 往返。Track B 能把这个 await 搬到渲染控制器后面,但删不掉它。当你试图消除这个 suspend await(而不是搬迁它)时,就已跨出"重新归位协议"、进入"重写渲染器"(Track C)。
Track B 中期目标不是"完全声明式",而是:
指令式渲染只能存在于 UI 层,不能穿过 ViewModel 或业务层。
验收:VM 不再实现 ReadBook.CallBack;_effects 中的渲染类 Effect(ToggleReadAloud/ToggleAutoPage 等指令)全部由渲染控制器承接;subscriptionCount.first { it > 0 } 握手删除。剩余 _effects 仅保留真正的一次性 UI 消息/导航(严格 MAD 下仍扣分,但已是可辩护的最小面)。
完整验收进度(2026-07-30):「VM 不再实现
ReadBook.CallBack」✅ 已达成(R2.3 把状态子集迁进LegacyReaderSession);渲染类 Effect 全部由渲染控制器承接 ✅;只剩subscriptionCount握手未删 (一处,见进度快照端游 ②——它是效果投递问题,不随所有权收敛自动消失)。
状态(2026-07-25):C0–C5 的 Compose 正文渲染实现已从代码库删除——
ComposeReaderSurface、ComposeReaderRenderCache、ReaderPageSnapshot、ReaderRenderModel/ReaderRenderStateStore、ReaderRenderer契约、lab flagLabSettings.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 上堆功能形成永久双栈。
Legacy Canvas View
→ 抽取稳定 RenderModel(viewport 尺寸、分页结果流、动画进度、手势、朗读高亮、预加载反馈)
→ Compose Canvas 或新渲染组件
→ 用状态 + 本地动画状态替代大部分渲染命令(含反转 upContentAwait 的往返)
→ 删除 LegacyReaderRenderController
Compose 不会自动蒸发分页排版复杂度,只是让"状态↔绘制"的关系更好表达。它是最自然、但非唯一的声明式实现。
ReadView 自身业务解耦(D1a/D1b/D2 已落地,剩 D1c)进度(2026-07-30):D1a(
d38de8d5d)7 处业务直呼改ReaderEvent出站;D1b(c0a8bc308)CallBack12 → 4 个成员(只留screenOffTimerStart/showTextActionMenu/upSystemUiVisibility/isInitFinish四项瞬时 UI 副作用),activity as CallBack兜底删除,三个协作面全部构造期非空注入;D2(89b756ae4) 新增ReaderPageSource喂入页数据,DataSource.pageIndex的默认实现改抽象。ReadView.kt对业务单例的引用归零。实现选择(重要,与下方设计草案的差异):
ReaderEvent走同步fun interface直调 (ReadView.click()→eventListener.onEvent()→ReadBookController.onEvent()),不走 SharedFlow—— 全程主线程同步,与改造前的直呼零派发时序差异。sealed interface+ 无else的when让漏分支变成编译错误。 D2 刻意不做快照:取页由pageFactory在绘制期驱动,发布期快照会改时序,本步零时序变化。剩余:D1c(测试,见进度快照待续 ①)、D2 在
page/包的残余(待续 ②)。由来(2026-07-21):Track A 收敛了
ReadBook所有权、Track B 把渲染回调移出 VM、Track C 让 Compose 面消费只读快照——但三者都没动ReadView自己的出站耦合。既然决定「保留 View 作为渲染核心」,就必须把 View 阅读器本身拉回 UDF:ReadView只做绘制/手势/动画,不认识ReadBook/ReadAloud/Activity 导航。
已核实的耦合(ui/book/read/page/ReadView.kt):
ReadBook.moveToNextChapter/moveToPrevChapter/moveToNextPage/moveToNextChapterAwait/syncProgress、ReadAloud.pause/resume(509、510、518、524、526、749、750 行)。callBack 直驱业务/导航:addBookmark、openContentEdit、changeReplaceRuleState、openChapterList、openSearchActivity、sureNewProgress、showActionMenu(504、513–519 行)。ReadBook 取页数据:ReadBook.textChapter(0/1/-1)(823–833 行,经 pageFactory)。三类状态纪律(呼应反目标「不做伪状态」)——Track D 必须守住这条线,避免为 UDF 把每个触点塞进 StateFlow:
| 类别 | 归属 | 例 |
|---|---|---|
| 业务状态 | ReaderSession/VM |
书籍、章节、进度、配置、加载/错误、朗读态 |
| 渲染状态 | 分页引擎产出 → View | 前/当前/后页、文本布局、图片布局、页面样式 |
| 瞬时控件状态 | ReadView 自持,绝不上浮 |
触摸坐标、Path/Matrix、Scroller、翻页动画进度、卷曲 Bitmap、阴影、选择手柄拖动、每帧 invalidate |
尤其卷曲手势、动画进度、每帧
invalidate是每秒几十上百次变化、且只属于ReadView的状态——不进UiState。把它们塞进StateFlow是「把门铃伪装成恒温器」的镜像错误。
D1(出站解耦,先做、低风险、不依赖 Compose):定义 ReaderEvent 出站接口,ReadView 把「业务意图」以事件发出,由外层(Activity/VM)翻译成 ReaderSession/命令调用:
sealed interface ReaderEvent {
data object RequestPreviousPage : ReaderEvent
data object RequestNextPage : ReaderEvent
data object OpenMenu : ReaderEvent
data class SyncProgress(/* … */) : ReaderEvent
data class ToggleReadAloud(/* … */) : ReaderEvent
data object AddBookmark : ReaderEvent
data object OpenContentEdit : ReaderEvent
data object OpenChapterList : ReaderEvent
data object OpenSearch : ReaderEvent
// …翻页完成 / 选择变化 / 链接点击按需补
}
fun interface ReaderEventListener { fun onEvent(event: ReaderEvent) }
ReadView 的 ReadBook.*/ReadAloud.* 直调与 callBack 的业务项改为 eventListener?.onEvent(...);外层 readView.eventListener = ReaderEventListener { viewModel.onIntent(it.toReaderIntent()) }。验收:grep 证明 ReadView.kt 不再为 mutation 引用 ReadBook/ReadAloud,业务调用全部经事件出站。保留 ReadView 对 pageFactory 的只读页数据拉取(属 D2,D1 不强改签名)。
忠实边界:D1 只搬「谁下达业务命令」,不改翻页/排版/动画行为。
callBack里真正属于「瞬时 UI 副作用」的项(screenOffTimerStart/upSystemUiVisibility/showTextActionMenu)可留在 View↔宿主的直接协作里,不必强行事件化——它们不是业务状态。
D2(入站解耦,较大、可后置):把 ReadView 对 ReadBook.textChapter(...) 的直接页数据拉取,改为消费一个由会话/渲染模型派生的只读输入(报告里的 ReaderRenderState——与 Track C1/C5 的 ReaderRenderModel/ReaderPageSnapshot 同源,只是这次喂给旧 ReadView 而非 Compose 面)。这一步更大(涉及 pageFactory 取页时序),可在 D1 稳定后再评估,不与 D1 捆绑。
最终形态(可选,视需要):把「渲染岛」收敛到 ReaderRenderer 契约背后(Track C0 已立),当前实现 ViewReaderRenderer(包 ReadView)为规范实现;ComposeReaderRenderer(C0–C5 的冻结产物)为可选。业务层不因渲染器切换而重做。
验收:ReadView 不再直接写 ReadBook/调 ReadAloud/驱动 Activity 业务导航(D1,编译器/grep 可证);瞬时控件状态全部留在 View、未上浮到 UiState;翻页/仿真卷曲/选择/滚动/自动翻页行为零漂移(真机 parity)。
执行计划见
docs/dev/track-e-reader-settings-udf-plan.md(该文档 §5 验收总表已同步到 2026-07-30)。进度(2026-07-30):E0 ✅ → E1 ❌ 撤销(前提不成立)→ E2 ✅ → E3 ✅(
sheet/下ReadBookConfig.归零,R4.6 连白名单一并取消)→ E4 ✅(UP_CONFIG生产者归零)→ E5 ✅(并入 R4.1)。本轨道最初没预料到的部分:配置底座本身的所有权反转(R4.1–R4.7)。Track E 原定「写入面不动、 只补写完之后怎么让所有人看到新值」,但 E5 要让渲染引擎吃只读快照时暴露出更深的问题——
ReadBookConfig既是状态本体又是写入口,getConfig还是@Synchronized(每行每列每帧抢一次全局锁)。于是追加了 R4: 写面收进 gateway 实现 → 状态本体搬进ReadStyleConfigStore→Config值字段改val。 结果是写入约束从正则护栏升级为编译器,ReadBookConfig降为只读投影(1405 → 982 行)。
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 只补「写完之后怎么让所有人看到新值」。
拆分按依赖归类,与渲染无关的部分现在就能并行拆:
可立即抽离(进 UseCase / Coordinator / Repository / 普通 scoped controller): AI 清理与改写、翻译、TTS 引擎查询与配置、换源、上传/同步进度、阅读设置、备份与缓存策略。
依赖 Track A(ReaderSession)后再拆: 当前书籍、当前章节与位置、前后章缓存、进度保存、章节切换、会话生命周期。
依赖 Track B(渲染边界)后再拆:
upContentAwait、页面排版完成、页面动画、选择取消、局部重绘、page recorder 回收——进渲染控制器/分页引擎,不进 ReaderSession 或 VM。
抽出的类多数应是普通 Kotlin state holder / scoped service,不必都是 Android
ViewModel。
顺序纪律:先移除 CallBack 归属,再激进拆 VM。否则抽出的 ReaderXxxController 全都还是围绕同一个全局单例的回调适配器——那是"分布式单体",文件更多,所有权问题照旧。
┌──────────────────────────┐
│ ReaderSession │ 权威业务状态 + 业务命令(Track A)
└────────────┬─────────────┘
│ StateFlow
┌────────────▼─────────────┐
│ ReadBookViewModel │ 屏幕业务状态、用户动作协调(瘦身后)
└────────────┬─────────────┘
│ UiState
┌────────────▼─────────────┐
│ Activity / Fragment UI │
└────────────┬─────────────┘
│
┌──────────────────▼──────────────────┐
│ LegacyReaderRenderController │ View 生命周期、分页、重绘、动画、选择(Track B)
└──────────────────┬──────────────────┘
│ imperative calls
┌──────────────────▼──────────────────┐
│ ContentTextView / PageProvider … │ → 最终由 Track C 替换为声明式渲染
└─────────────────────────────────────┘
迁移期,旧 ReadBook.CallBack(渲染子集)由 LegacyReaderRenderController 实现;最终从 ReadBook 抽走渲染职责。
2026-07-21 修订:底部「→ 最终由 Track C 替换为声明式渲染」改为长期可选。当前目标是
ReadView/ContentTextView作为稳定的规范渲染岛保留,Track D 补一条ReadView → ReaderEvent → VM/ReaderSession的出站回边(替代ReadView直呼ReadBook/ReadAloud/callBack业务项)。Compose 渲染器(ComposeReaderRenderer)冻结在ReaderRenderer契约后,作为可选实现,非默认终点。2026-07-30 更新:那条出站回边已建成(D1a/D1b +
ReaderEvent),入站页数据也已走ReaderPageSource(D2)。 Compose 渲染器于 2026-07-25 连同ReaderRenderer契约一并删除(见 Track C 节),所以上图底部的 「最终由 Track C 替换」在当前代码里没有对应物——ReadView/ContentTextView就是唯一渲染实现。
| 约束 | 机制 | 强度 |
|---|---|---|
ReadBook 会话字段不可外部写 |
Kotlin private set(book/durChapterIndex/durChapterPos) |
编译期,不可绕过 ✅ |
| ViewModel 不新增 DAO 直连 | VerifyConfigArchitectureTask + legacyDaoInjectionBaseline |
CI,双向棘轮 ✅ |
| 非 VM UI 层不新增 DAO 直连 | 同上 + legacyUiDaoAccessBaseline |
CI,双向棘轮 ✅ |
| 不新增旧偏好直读 | 现有 legacyPreferenceCallBaseline |
CI(已存在) |
ReadBook 读取收敛 |
lint/Detekt(读取难以编码约束,且低风险,用扫描器足够) | CI 提示 |
| 单测/新增 lint 违规 | testAppDebugUnitTest / lintAppDebug + 基线 |
CI job 已接入 ✅(verify.yml) |
排版 Config 的值字段不可外部写 |
Kotlin val(92 个构造参数,R4.7)+ copy/withCurXxx |
编译期,不可绕过 ✅ |
ReadBookConfig 写入必须经 ReadStyleGateway |
verifyConfigArchitecture 正则 |
CI,有绕过口(成员 import 裸写,见待续 ③) |
| 排版状态本体只对 gateway 开放 | configStoreOwners 4 文件名单(纯子串检查,较结实) |
CI ✅(但下层 ReadStyleRepository 无限制,见待续 ③) |
ReadView 不认识业务单例 / 三面非空注入 |
ReadViewBoundaryTest 六条断言 |
单测,只扫 ReadView.kt 一个文件 |
| 渲染实体不直读排版全局 | RenderStyleSnapshotBoundaryTest |
单测,对 ReadConfig 不设防 + 实体名单是死的 |
| 域状态不回流 / VM ≤2500 行 / delegate 无 DAO | ReadBookDomainSplitBoundaryTest |
单测(stateFields 走反射,较结实)✅ |
原则:能路由的写入用可见性(语言级)约束;难封装的读取用扫描器(工具级)约束。
护栏强度排序(2026-07-30 审查结论,写新护栏时参考):反射/行为类(枚举 sealed 子类、JSON round-trip) > 全词子串类(
\bReadBookConfig\b、owner 名单)> 限定访问正则类(ReadBook\.、configStore\.xxx() > 缩进锚定声明类(^ var——internal var、同行注解、嵌套 object 全盲)。 「注入反证会红」只证明护栏能抓它自己想象的那一种写法,不等于强制了它声称的不变式。 Kotlin 表达同一件事有五种写法(成员 import、alias import、FQN、with/apply接收者、局部别名), 正则类护栏默认只挡住一种。
已走完的路径:先用编译器冻结写入 → 集中约 7 个 mutator → 建立权威 ReaderSession(Track A);
同时把渲染 Effect 从 VM 下沉到 UI 渲染控制器(Track B)→ 拆分 VM 中与渲染无关的业务(R2,6671 → 2494 行)
→ 把 ReadView 自身的业务耦合解掉(Track D = R3)→ 阅读设置 SSOT 收敛(Track E)→ 追加配置底座
所有权反转(R4,写入约束升级为编译器)。Compose 阅读器(Track C)已于 2026-07-25 删除,若重启需重新实现。
全程由 F1 的 CI 门禁与 F2 的 DAO 去注入护栏兜底。
剩余顺序(2026-07-30):真机验证 R4.5–R4.7 → 快照体系收尾包(审查遗留,小步快合)→ D1c 阅读器核心
JVM 测试(阻塞链刚解开,回报最大,且能兜住前一步的回归面)→ R5 三项按空档插队(baseline profile 那项
不碰阅读器结构,随时可插)→ D2 残余(page/ 包)→ 最后再评估 Track B 端游 ②(Effect 投递握手,
需先把配置应用改成状态式,是剩余项里唯一的大改造)。明细见进度快照「待续」。
做完这些之后:阅读器核心的 MAD 收敛即告完成,主线转入 Compose 迁移轨道(175 XML / 47 非 Compose Activity)。那条轨道与本计划刻意分开——见 §0「重要边界」,以及本计划反目标「不用 Compose 迁移当作解耦前置」。
严格 MAD 下 Effect 仍扣分;但准确的表述是——问题从来不是"指令式 View 无解",而是"指令式渲染协议被错误地穿过了 ViewModel"。本计划的每一步都在把那条线拉回 UI 层,而不需要等待 Compose。