本文档是一份执行计划,不是一次架构评分。它把此前审阅收敛出的结论,转化为可分批落地、可验证、可回滚的工程步骤。
范围:阅读器核心(
ReadBook全局单例、ReadBookViewModel、ReadBook.CallBack渲染协议)以及全应用范围内的 UI→DAO 直连问题。不包含外围已完成 MAD 化的 Compose 屏幕。
用两个维度描述现状,而不是一个小数分:
Application、ViewModel→UI 存在指令式 Effect 通道、CI 无测试/lint 质量门禁。verifyConfigArchitecture)、领域抽象(Gateway/UseCase/Repository)真实存在、迁移方向正确。这两个分数并不矛盾:一次高质量的遗留迁移,可以同时只是中等程度符合目标架构。本计划的目标是把第一个分数往上推,而不破坏第二个分数所代表的迁移纪律。
| 事实 | 证据 |
|---|---|
ReadBook 是进程级全局单例 |
object ReadBook : CoroutineScope by MainScope(), KoinComponent(model/ReadBook.kt:73) |
| 阅读器真实所有权在单例,VM 是回调适配器 | ReadBookViewModel(...) : BaseViewModel(application), ReadBook.CallBack(ui/book/read/ReadBookViewModel.kt:203) |
ReadBookViewModel 体量失控 |
6484 行 |
ReadBook 被外部广泛引用 |
44 个非 VM 文件引用;写入约 7 处;引用逃逸式写入近 0;其余为读取/身份比较 |
Book 是可变实体 |
data class Book(... var durChapterIndex ... var durChapterPos ...)(data/entities/Book.kt:41) |
CallBack 是混合协议(渲染 + 业务状态) |
11 个方法,interface CallBack : LayoutProgressListener(model/ReadBook.kt:1330) |
upContentAwait 是 suspend,模型反向等待视图 |
model/ReadBook.kt(CallBack 定义内) |
VM 是 CallBack 唯一实现者 |
全仓仅 ReadBookViewModel 声明 : ReadBook.CallBack |
| Effect 通道带订阅握手 | _effects.subscriptionCount.first { it > 0 }(ReadBookViewModel.kt:210-223) |
| UI→DAO 直连是系统性模式 | 约 33 个 ViewModel 构造函数注入 DAO(如 ReplaceEditViewModel(app, replaceRuleDao),ui/replace/edit/ReplaceEditViewModel.kt:23) |
| CI 无测试/lint 门禁 | .github/workflows/* 仅执行 assemble* |
| 已有架构护栏 + 基线机制 | VerifyConfigArchitectureTask + legacyPreferenceCallBaseline(build.gradle.kts:136),已 dependsOn assemble/compile |
ReadBook 6000 行不横向切成几十个文件——那只是"分文件的 God Object"。upPageAnim/cancelSelect 之类指令式渲染命令塞进 UiState(shouldAnimatePage: Boolean)。那是把门铃伪装成恒温器。private set)约束,而不是先写 Detekt/lint 规则。改造被拆成三条相互解耦、可并行的轨道,外加两条横切地基。关键洞察:三条轨道的进度互不阻塞,除了 Track C 依赖 Track A/B 的产出。
横切地基:
F1 CI 质量门禁(测试/lint 基线/架构检查)
F2 UI→DAO 去注入(全应用,非阅读器专属)
Track A 所有权与业务状态 —— 现在就能做,最高优先级
Track B 遗留渲染边界下沉 —— 现在就能做,与 A 并行
Track C 声明式渲染 / Compose —— 长期,依赖 A、B 的产出
没有它,后续拆 DAO、拆单例、拆 VM 都没有安全网。
任务
./gradlew lintAppDebug 后提交 app/lint-baseline.xml,把历史债务冻结。CI 增加一个 verify job(与现有 assembleAppDebug 并列或前置):
./gradlew testAppDebugUnitTest
./gradlew lintAppDebug # 仅新增违规会 fail(基线已冻结历史债)
./gradlew verifyConfigArchitecture
./gradlew assembleAppDebug
注:
verifyConfigArchitecture已通过dependsOn在 assemble/compile 前运行,此处显式列出是为了让门禁意图清晰、失败信息更早。
无论成败都上传 test/lint 报告。
基线纪律(防止"CI 变绿垃圾压缩机")
验收:任一新 PR 若新增 lint 违规、破坏单测或触碰架构规则,CI 红。历史债务不阻塞。
系统性问题,约 33 个 VM。按功能逐批,不预造大仓库。
先冻结(扩展现有护栏):在 VerifyConfigArchitectureTask 中新增规则——
ui/**/*ViewModel*.kt 禁止 import io.legado.app.data.dao.*
配一个 legacyDaoInjectionBaseline(照搬 legacyPreferenceCallBaseline 的 file→count 模式),冻结现有 ~33 处,只挡新增。
再逐批清零:每批一个功能,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、测试可替身、实体不再向上泄漏、依赖方向可强制。清一批,就从基线里删一批条目。
验收:legacyDaoInjectionBaseline 单调递减;新增 DAO 注入编译期/CI 失败。
目标:阅读状态有唯一写入入口,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. 为约 7 个外部写入点提供语义化命令:
fun replaceCurrentBook(book: Book)
fun moveToChapter(index: Int, position: Int)
fun updateReadingPosition(position: Int)
fun clearCurrentSession()
把外部散落的字段赋值改为调用这些命令。编译器强制,重构工具可跟踪,无法用换行/别名绕过。
A3. 停止向外递出可变实体。绝大多数外部读取只是身份比较,用意图化 API 替代裸实体:
fun isCurrentBook(bookUrl: String): Boolean = book?.bookUrl == bookUrl
这同时堵住"引用逃逸写入"(ReadBook.book?.durChapterIndex = x)未来复发的可能——当前近 0,但接口不再暴露就永久免疫。
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 取业务状态。
关键结论:把指令式渲染协议从 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 下仍扣分,但已是可辩护的最小面)。
Legacy Canvas View
→ 抽取稳定 RenderModel(viewport 尺寸、分页结果流、动画进度、手势、朗读高亮、预加载反馈)
→ Compose Canvas 或新渲染组件
→ 用状态 + 本地动画状态替代大部分渲染命令(含反转 upContentAwait 的往返)
→ 删除 LegacyReaderRenderController
Compose 不会自动蒸发分页排版复杂度,只是让"状态↔绘制"的关系更好表达。它是最自然、但非唯一的声明式实现。
拆分按依赖归类,与渲染无关的部分现在就能并行拆:
可立即抽离(进 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 抽走渲染职责。
| 约束 | 机制 | 强度 |
|---|---|---|
ReadBook 会话字段不可外部写 |
Kotlin private set |
编译期,不可绕过 |
| UI/VM 不新增 DAO 注入 | VerifyConfigArchitectureTask + legacyDaoInjectionBaseline |
CI,冻结历史挡新增 |
| 不新增旧偏好直读 | 现有 legacyPreferenceCallBaseline |
CI(已存在) |
ReadBook 读取收敛 |
lint/Detekt(读取难以编码约束,且低风险,用扫描器足够) | CI 提示 |
| 单测/新增 lint 违规 | testAppDebugUnitTest / lintAppDebug + 基线 |
CI 门禁 |
原则:能路由的写入用可见性(语言级)约束;难封装的读取用扫描器(工具级)约束。
先用编译器冻结写入 → 集中约 7 个 mutator → 建立权威 ReaderSession(Track A);同时把渲染 Effect 从 VM 下沉到 UI 渲染控制器(Track B)→ 并行拆分 VM 中与渲染无关的业务 → 最后渐进迁移 Compose/新渲染器(Track C)。 全程由 F1 的 CI 门禁与 F2 的 DAO 去注入护栏兜底。
严格 MAD 下 Effect 仍扣分;但准确的表述是——问题从来不是"指令式 View 无解",而是"指令式渲染协议被错误地穿过了 ViewModel"。本计划的每一步都在把那条线拉回 UI 层,而不需要等待 Compose。