Selaa lähdekoodia

refactor(R2.3): VM 不再实现 ReadBook.CallBack,渲染 Effect 内联进 controller

Track B 端游的两件:

**1. 状态回调迁出 VM。** `ReadBook.callBack` 原来直接指向 ReadBookViewModel。
四个回调(upMenuView / loadChapterList / notifyBookChanged / sureNewProgress)
改由 `LegacyReaderSession` 接住并转成 `ReaderSessionEvent` 事件流,VM 订阅消费。
`ReadBook.register(this)/unregister(this)` → `readerSession.attach()/detach()`。

会话在 Koin 从 `single` 改成 `factory`——`ReadBook.callBack` 的**身份**是载荷:
`prefetchForOpen`/`upData` 用 `callBack != null` 判「阅读页已挂载」,`register` 还会
给**上一个**持有者发 `notifyBookChanged`。单例会把两个 VM 的注册身份混成一个,
预取会被永久掐掉(见 memory: 开书闪烁三层根因)。factory 下 attach/detach 与
register/unregister 一一对应,身份语义与迁移前完全一致。

事件用 `tryEmit`,无订阅者会丢;故 `init` 里 `collectReaderSessionEvents()`
必须排在 `attach()` 之前——viewModelScope 是 Main.immediate、VM 在主线程构造,
launch 会同步跑到 collect 的挂起点,该行返回时订阅者已注册。

线程语义变化(真机回归重点):这四个回调原先在 ReadBook 的调用线程上**同步**执行
(含 IO 线程),现在统一在主线程上晚一个派发执行。

**2. 三个渲染 Effect 内联。** PageChanged / ContentLoadFinish / LayoutPageCompleted
只由 ReadBookController 的 postRender 产生、又只由它自己的 handleEffect 消费,
从不经过 VM 的 `_effects`——不属于 VM 的对外协议。直接内联进对应渲染方法
(handler.post 保持同样的切主线程语义),三个类型从 ReadBookEffect 删除。
仍走 postRender 的 UpContent/UpPageAnim/CancelSelect 有 VM/delegate 侧生产者,保留。

护栏:新增 `ReadBookCallbackBoundaryTest` 两条,反证均已做——
把 `ReadBook.CallBack` 超类型和四个 override 加回去→第一条红;
把 PageChanged 加回 ReadBookEffect→第二条红。全量单测 427 → 429。

**未做:`emitEffectWhenSubscribed` 的 subscriptionCount 握手保留。**
它不是冗余代码,是 4f6ee460a 修的真 bug:`_effects` 是 replay=0 的 SharedFlow,
ReadConfigUpdateBus 的配置更新在收集器未挂载时 tryEmit 会被丢弃(阅读页启动期
onEffectsReady 之前必然存在这个窗口)。删掉握手=把那个 bug 放回来。要真正去掉,
得把这条「直接 effect」通道换成对重挂载幂等的投递方式,属于 Track E 排版设置
通道的重构,不该在 R2.3 里顺手做。

回归清单:开书/换书(A→B 连开)、书架外的书退出时的加入书架提示、
换源后目录刷新、字符集切换触发的目录重载、云端进度冲突确认弹窗、
翻页(自动备份任务)、排版完成后进度条刷新、朗读进度接续。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5151561 3 kuukautta sitten
vanhempi
commit
799550eb67

+ 4 - 1
app/src/main/java/io/legado/app/di/appModule.kt

@@ -358,7 +358,10 @@ val appModule = module {
     single<ReadSettingsGateway> { get<ReadSettingsRepository>() }
     single<ReadSettingsGateway> { get<ReadSettingsRepository>() }
     singleOf(::ReadAloudSettingsRepository)
     singleOf(::ReadAloudSettingsRepository)
     singleOf(::ReadAloudSessionStore)
     singleOf(::ReadAloudSessionStore)
-    single<ReaderSession> { LegacyReaderSession() }
+    // R2.3:会话每个所有者一份。ReadBook.callBack 的身份是「阅读页已挂载」信号
+    // (prefetchForOpen / upData 判 callBack != null),register 还会给上一个持有者
+    // 发 notifyBookChanged——单例会把两个 ReadBookViewModel 的注册身份混成一个。
+    factory<ReaderSession> { LegacyReaderSession() }
     single<HttpTtsEngineGateway> { HttpTtsEngineRepository(get()) }
     single<HttpTtsEngineGateway> { HttpTtsEngineRepository(get()) }
     single<ReadAloudSettingsGateway> { get<ReadAloudSettingsRepository>() }
     single<ReadAloudSettingsGateway> { get<ReadAloudSettingsRepository>() }
     singleOf(::HttpTtsRepository)
     singleOf(::HttpTtsRepository)

+ 77 - 1
app/src/main/java/io/legado/app/model/ReaderSession.kt

@@ -1,6 +1,33 @@
 package io.legado.app.model
 package io.legado.app.model
 
 
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.BookProgress
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+
+/**
+ * 会话侧一次性事件(R2.3)。
+ *
+ * 遗留的 [ReadBook.CallBack] 四个回调原来由 `ReadBookViewModel` 直接实现,
+ * 于是 `ReadBook.callBack` 这个全局槽位指向 ViewModel。现在改由 [LegacyReaderSession]
+ * 接住并转成事件流,ViewModel 只是订阅者。
+ */
+sealed interface ReaderSessionEvent {
+
+    /** 会话派生状态(菜单/进度等)需要重新同步。对应 `upMenuView`。 */
+    data object StateInvalidated : ReaderSessionEvent
+
+    /** [ReadBook] 请求所有者去加载目录。对应 `loadChapterList`。 */
+    data class ChapterListRequested(val book: Book) : ReaderSessionEvent
+
+    /** 当前会话的书被换掉。对应 `notifyBookChanged`。 */
+    data object BookChanged : ReaderSessionEvent
+
+    /** 云端进度比本地新,需所有者确认。对应 `sureNewProgress`。 */
+    data class NewProgressAvailable(val progress: BookProgress) : ReaderSessionEvent
+}
 
 
 /**
 /**
  * 阅读会话面向所有者的 API。
  * 阅读会话面向所有者的 API。
@@ -17,6 +44,20 @@ interface ReaderSession {
     /** 权威会话快照流。 */
     /** 权威会话快照流。 */
     val state: StateFlow<LegacyReaderSnapshot>
     val state: StateFlow<LegacyReaderSnapshot>
 
 
+    /**
+     * 会话事件流。
+     *
+     * **订阅必须早于 [attach]**:事件用 `tryEmit` 投递,没有订阅者时会被丢弃。
+     * 会话是每个所有者一份(Koin `factory`),订阅者恒为 1。
+     */
+    val events: SharedFlow<ReaderSessionEvent>
+
+    /** 把本会话接到 [ReadBook] 上,成为当前的状态回调持有者。 */
+    fun attach()
+
+    /** 从 [ReadBook] 摘下本会话。 */
+    fun detach()
+
     /** 当前会话是否正指向该 URL 的书。 */
     /** 当前会话是否正指向该 URL 的书。 */
     fun isCurrentBook(bookUrl: String): Boolean
     fun isCurrentBook(bookUrl: String): Boolean
 
 
@@ -36,12 +77,29 @@ interface ReaderSession {
 /**
 /**
  * 遗留桥接实现:把 [ReaderSession] 全部委托给全局单例 [ReadBook]。
  * 遗留桥接实现:把 [ReaderSession] 全部委托给全局单例 [ReadBook]。
  * 命令一一映射到 ReadBook 既有的受控 mutator,不引入并行状态。
  * 命令一一映射到 ReadBook 既有的受控 mutator,不引入并行状态。
+ *
+ * 同时实现 [ReadBook.CallBack]——`ReadBook.callBack` 的身份既是「阅读页已挂载」信号
+ * (`prefetchForOpen` / `upData` 靠 `callBack != null` 判断),也在 `register` 时给
+ * **上一个**持有者发 `notifyBookChanged`。因此本类必须**每个所有者一份**,
+ * attach/detach 与 register/unregister 一一对应,身份语义与迁移前完全一致。
  */
  */
-class LegacyReaderSession : ReaderSession {
+class LegacyReaderSession : ReaderSession, ReadBook.CallBack {
+
+    private val _events = MutableSharedFlow<ReaderSessionEvent>(extraBufferCapacity = 16)
+
+    override val events: SharedFlow<ReaderSessionEvent> = _events.asSharedFlow()
 
 
     override val state: StateFlow<LegacyReaderSnapshot>
     override val state: StateFlow<LegacyReaderSnapshot>
         get() = ReadBook.snapshot
         get() = ReadBook.snapshot
 
 
+    override fun attach() {
+        ReadBook.register(this)
+    }
+
+    override fun detach() {
+        ReadBook.unregister(this)
+    }
+
     override fun isCurrentBook(bookUrl: String): Boolean = ReadBook.isCurrentBook(bookUrl)
     override fun isCurrentBook(bookUrl: String): Boolean = ReadBook.isCurrentBook(bookUrl)
 
 
     override fun moveToChapter(index: Int, position: Int) {
     override fun moveToChapter(index: Int, position: Int) {
@@ -59,4 +117,22 @@ class LegacyReaderSession : ReaderSession {
     override fun updateViewport(position: Int) {
     override fun updateViewport(position: Int) {
         ReadBook.updateReadingPosition(position)
         ReadBook.updateReadingPosition(position)
     }
     }
+
+    // --- ReadBook.CallBack → 事件流 ---
+
+    override fun upMenuView() {
+        _events.tryEmit(ReaderSessionEvent.StateInvalidated)
+    }
+
+    override fun loadChapterList(book: Book) {
+        _events.tryEmit(ReaderSessionEvent.ChapterListRequested(book))
+    }
+
+    override fun notifyBookChanged() {
+        _events.tryEmit(ReaderSessionEvent.BookChanged)
+    }
+
+    override fun sureNewProgress(progress: BookProgress) {
+        _events.tryEmit(ReaderSessionEvent.NewProgressAvailable(progress))
+    }
 }
 }

+ 2 - 4
app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt

@@ -17,7 +17,6 @@ import io.legado.app.domain.model.readaloud.SpeechRoleType
 import io.legado.app.domain.model.settings.ReadStyleItem
 import io.legado.app.domain.model.settings.ReadStyleItem
 import io.legado.app.model.translation.TranslationChapterStatus
 import io.legado.app.model.translation.TranslationChapterStatus
 import io.legado.app.ui.book.read.page.entities.TextChapter
 import io.legado.app.ui.book.read.page.entities.TextChapter
-import io.legado.app.ui.book.read.page.entities.TextPage
 import io.legado.app.ui.book.read.page.entities.TextPos
 import io.legado.app.ui.book.read.page.entities.TextPos
 import io.legado.app.ui.book.read.sheet.ReaderBookSheetTab
 import io.legado.app.ui.book.read.sheet.ReaderBookSheetTab
 import io.legado.app.ui.book.searchContent.SearchResult
 import io.legado.app.ui.book.searchContent.SearchResult
@@ -760,9 +759,8 @@ sealed interface ReadBookEffect {
     data object UpAloudState : ReadBookEffect
     data object UpAloudState : ReadBookEffect
     data object UpSeekBar : ReadBookEffect
     data object UpSeekBar : ReadBookEffect
     data object UpMenuView : ReadBookEffect
     data object UpMenuView : ReadBookEffect
-    data object PageChanged : ReadBookEffect
-    data object ContentLoadFinish : ReadBookEffect
-    data class LayoutPageCompleted(val index: Int, val page: TextPage) : ReadBookEffect
+    // R2.3:PageChanged / ContentLoadFinish / LayoutPageCompleted 已内联进
+    // ReadBookController 的渲染回调——它们只在 controller 内部自产自销,不是 VM 的对外协议。
     data object RefreshBookContent : ReadBookEffect
     data object RefreshBookContent : ReadBookEffect
 
 
     // Menu / UI actions
     // Menu / UI actions

+ 20 - 20
app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt

@@ -867,14 +867,27 @@ class ReadBookController(
         }
         }
     }
     }
 
 
+    // R2.3:pageChanged / contentLoadFinish / onLayoutPageCompleted 的 Effect 只有本类
+    // 自产自销(postRender → 本类 handleEffect),从不经过 ViewModel 的 _effects。
+    // 它们不属于 VM 的对外协议,故内联到渲染方法里,三个 Effect 类型随之从
+    // ReadBookEffect 删除。仍在 postRender 上的 UpContent/UpPageAnim/CancelSelect
+    // 有 VM/delegate 侧的生产者,必须留在 Effect 里。
+
     override fun pageChanged() {
     override fun pageChanged() {
-        postRender(ReadBookEffect.PageChanged)
+        handler.post {
+            this.pageChanged = true
+            refs?.readView?.onPageChange()
+            viewModel.startBackupJob()
+        }
     }
     }
 
 
     override fun contentLoadFinish() {
     override fun contentLoadFinish() {
         // isInitFinish 是纯业务/UI 标志(决定 ReadView 是否放行前后章排版),不属于渲染
         // isInitFinish 是纯业务/UI 标志(决定 ReadView 是否放行前后章排版),不属于渲染
         viewModel.markInitFinished()
         viewModel.markInitFinished()
-        postRender(ReadBookEffect.ContentLoadFinish)
+        handler.post {
+            viewModel.readAloudProgress.value?.let(::updateReadAloudProgress)
+            onStartContentLoadFinish?.invoke()
+        }
     }
     }
 
 
     override fun upPageAnim(upRecorder: Boolean) {
     override fun upPageAnim(upRecorder: Boolean) {
@@ -886,7 +899,11 @@ class ReadBookController(
     }
     }
 
 
     override fun onLayoutPageCompleted(index: Int, page: TextPage) {
     override fun onLayoutPageCompleted(index: Int, page: TextPage) {
-        postRender(ReadBookEffect.LayoutPageCompleted(index, page))
+        handler.post {
+            layoutController.publishPageLayout(index)
+            upSeekBarThrottle.invoke()
+            refs?.readView?.onLayoutPageCompleted(index, page)
+        }
     }
     }
 
 
     // ── Effect handling ───────────────────────────────────────────────
     // ── Effect handling ───────────────────────────────────────────────
@@ -994,23 +1011,6 @@ class ReadBookController(
                 ReadBook.book?.let { viewModel.refreshContentDur(it) }
                 ReadBook.book?.let { viewModel.refreshContentDur(it) }
             }
             }
 
 
-            is ReadBookEffect.PageChanged -> {
-                pageChanged = true
-                refs?.readView?.onPageChange()
-                viewModel.startBackupJob()
-            }
-
-            is ReadBookEffect.LayoutPageCompleted -> {
-                layoutController.publishPageLayout(effect.index)
-                upSeekBarThrottle.invoke()
-                refs?.readView?.onLayoutPageCompleted(effect.index, effect.page)
-            }
-
-            is ReadBookEffect.ContentLoadFinish -> {
-                viewModel.readAloudProgress.value?.let(::updateReadAloudProgress)
-                onStartContentLoadFinish?.invoke()
-            }
-
             is ReadBookEffect.UpScreenTimeOut -> {
             is ReadBookEffect.UpScreenTimeOut -> {
                 upScreenTimeOut()
                 upScreenTimeOut()
             }
             }

+ 50 - 22
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -89,6 +89,7 @@ import io.legado.app.model.ReadAloud
 import io.legado.app.model.ReadAloudSessionStore
 import io.legado.app.model.ReadAloudSessionStore
 import io.legado.app.model.ReadBook
 import io.legado.app.model.ReadBook
 import io.legado.app.model.ReaderSession
 import io.legado.app.model.ReaderSession
+import io.legado.app.model.ReaderSessionEvent
 import io.legado.app.model.ReadSessionState
 import io.legado.app.model.ReadSessionState
 import io.legado.app.model.SourceCallBack
 import io.legado.app.model.SourceCallBack
 import io.legado.app.model.activeReadAloudProgress
 import io.legado.app.model.activeReadAloudProgress
@@ -188,7 +189,7 @@ class ReadBookViewModel(
     private val bookmarkRepository: BookmarkRepository,
     private val bookmarkRepository: BookmarkRepository,
     private val bookRepository: BookRepository,
     private val bookRepository: BookRepository,
     private val readerSession: ReaderSession,
     private val readerSession: ReaderSession,
-) : BaseViewModel(application), ReadBook.CallBack {
+) : BaseViewModel(application) {
 
 
     // --- MVI State ---
     // --- MVI State ---
 
 
@@ -396,7 +397,11 @@ class ReadBookViewModel(
     }
     }
 
 
     init {
     init {
-        ReadBook.register(this)
+        // 订阅必须早于 attach():会话事件用 tryEmit 投递,没有订阅者会被丢弃。
+        // viewModelScope 是 Main.immediate,VM 在主线程构造,故 launch 会同步跑到
+        // collect 的挂起点——本行返回时订阅者已注册。
+        collectReaderSessionEvents()
+        readerSession.attach()
         refreshButtonConfigs()
         refreshButtonConfigs()
         collectReadPreferences()
         collectReadPreferences()
         collectEyeProtectionSettings()
         collectEyeProtectionSettings()
@@ -451,6 +456,37 @@ class ReadBookViewModel(
         }
         }
     }
     }
 
 
+    /**
+     * 消费 [ReaderSession.events](R2.3):遗留 [ReadBook.CallBack] 的四个回调。
+     *
+     * VM 不再实现 `ReadBook.CallBack`——`ReadBook.callBack` 现在指向本 VM 持有的
+     * [LegacyReaderSession]。回调体原样搬过来,只是从「在 ReadBook 的调用线程上同步执行」
+     * 变成「在主线程上晚一个派发执行」。
+     */
+    private fun collectReaderSessionEvents() {
+        viewModelScope.launch {
+            readerSession.events.collect { event ->
+                when (event) {
+                    is ReaderSessionEvent.StateInvalidated -> {
+                        _uiState.update { syncFromReadBook(it) }
+                    }
+
+                    is ReaderSessionEvent.ChapterListRequested -> loadChapterList(event.book)
+
+                    is ReaderSessionEvent.BookChanged -> {
+                        _uiState.update { syncFromReadBook(it) }
+                        if (!ReadBook.inBookshelf) {
+                            removeFromBookshelf { _effects.tryEmit(ReadBookEffect.Finish) }
+                        }
+                    }
+
+                    is ReaderSessionEvent.NewProgressAvailable ->
+                        sureNewProgress(event.progress)
+                }
+            }
+        }
+    }
+
     // --- MVI Intent Dispatcher ---
     // --- MVI Intent Dispatcher ---
 
 
     fun onIntent(intent: ReadBookIntent) {
     fun onIntent(intent: ReadBookIntent) {
@@ -1734,30 +1770,22 @@ class ReadBookViewModel(
         }
         }
     }
     }
 
 
-    // --- ReadBook.CallBack Implementation(状态子集)---
+    // --- ReadBook 回调(已全部离开本 ViewModel)---
     //
     //
-    // Track B2 起:渲染子集(upContent/upContentAwait/pageChanged/contentLoadFinish/
-    // upPageAnim/cancelSelect/onLayoutPageCompleted)已下沉到 UI 层渲染控制器
-    // (ReadBook.renderCallBack),不再穿过本 ViewModel。业务状态刷新改由
-    // collectReaderSession() 反应式收集 ReadBook.snapshot 驱动。
-
-    override fun upMenuView() {
-        _uiState.update { syncFromReadBook(it) }
-    }
-
-    override fun loadChapterList(book: Book) {
+    // Track B2:渲染子集(upContent/upContentAwait/pageChanged/contentLoadFinish/
+    // upPageAnim/cancelSelect/onLayoutPageCompleted)下沉到 UI 层渲染控制器
+    // (ReadBook.renderCallBack)。
+    // R2.3:状态子集(upMenuView/loadChapterList/notifyBookChanged/sureNewProgress)
+    // 迁入 LegacyReaderSession,本 VM 改为订阅 collectReaderSessionEvents()。
+    // 业务状态刷新另有 collectReaderSession() 反应式收集 ReadBook.snapshot 驱动。
+    // 下面两个不再是 override——除了会话事件,VM 自己也在若干处直接调用。
+
+    private fun loadChapterList(book: Book) {
         ReadBook.upMsg(context.getString(R.string.toc_updateing))
         ReadBook.upMsg(context.getString(R.string.toc_updateing))
         doLoadChapterList(book)
         doLoadChapterList(book)
     }
     }
 
 
-    override fun notifyBookChanged() {
-        _uiState.update { syncFromReadBook(it) }
-        if (!ReadBook.inBookshelf) {
-            removeFromBookshelf { _effects.tryEmit(ReadBookEffect.Finish) }
-        }
-    }
-
-    override fun sureNewProgress(progress: BookProgress) {
+    private fun sureNewProgress(progress: BookProgress) {
         _uiState.update {
         _uiState.update {
             it.copy(activeDialog = ReadBookDialog.ConfirmRestoreProgress(progress))
             it.copy(activeDialog = ReadBookDialog.ConfirmRestoreProgress(progress))
         }
         }
@@ -3639,7 +3667,7 @@ class ReadBookViewModel(
         if (BaseReadAloudService.isRun && BaseReadAloudService.pause) {
         if (BaseReadAloudService.isRun && BaseReadAloudService.pause) {
             ReadAloud.stop(context)
             ReadAloud.stop(context)
         }
         }
-        ReadBook.unregister(this)
+        readerSession.detach()
     }
     }
 
 
     fun addToBookshelf(book: Book, toc: List<BookChapter>, success: (() -> Unit)? = null) {
     fun addToBookshelf(book: Book, toc: List<BookChapter>, success: (() -> Unit)? = null) {

+ 97 - 0
app/src/test/java/io/legado/app/ui/book/read/ReadBookCallbackBoundaryTest.kt

@@ -0,0 +1,97 @@
+package io.legado.app.ui.book.read
+
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.io.File
+
+/**
+ * R2.3(Track B 端游)—— `ReadBook` 的回调不再穿过 `ReadBookViewModel`。
+ *
+ * 两条会悄悄失效的边界:
+ *
+ * 1. VM 重新实现 `ReadBook.CallBack`——`ReadBook.callBack` 这个全局槽位又指回 VM,
+ *    god object 通过回调面重新长回来。状态子集现在归 `LegacyReaderSession`,
+ *    VM 只订阅 `ReaderSession.events`。
+ * 2. 只在 `ReadBookController` 内部自产自销的渲染回调重新变成 `ReadBookEffect`——
+ *    `ReadBookEffect` 是 VM 的对外协议,controller 自己 post 给自己的东西不该占位。
+ */
+class ReadBookCallbackBoundaryTest {
+
+    @Test
+    fun `ReadBookViewModel 不再实现 ReadBook CallBack`() {
+        // 注释里会照常提到这些名字(说明它们搬去了哪儿),先剥掉注释再断言。
+        val source = stripComments(
+            mainSourceFile("io/legado/app/ui/book/read/ReadBookViewModel.kt").readText()
+        )
+        val violations = buildList {
+            if (CALLBACK_SUPERTYPE.containsMatchIn(source)) add("声明了 ReadBook.CallBack 超类型")
+            CALLBACK_METHODS.forEach { method ->
+                if (Regex("""\boverride\s+fun\s+$method\s*\(""").containsMatchIn(source)) {
+                    add("override fun $method")
+                }
+            }
+            if (Regex("""\bReadBook\.(?:register|unregister)\s*\(\s*this\s*\)""")
+                    .containsMatchIn(source)
+            ) {
+                add("ReadBook.register/unregister(this)")
+            }
+        }
+        assertTrue(
+            "ReadBookViewModel 又接回了 ReadBook.CallBack:${violations.joinToString()}。\n" +
+                "状态子集(upMenuView/loadChapterList/notifyBookChanged/sureNewProgress)" +
+                "归 LegacyReaderSession,VM 用 readerSession.attach()/detach() 挂载、" +
+                "订阅 ReaderSession.events 消费。",
+            violations.isEmpty(),
+        )
+    }
+
+    @Test
+    fun `controller 内部自用的渲染回调不占用 ReadBookEffect`() {
+        val source = stripComments(
+            mainSourceFile("io/legado/app/ui/book/read/ReadBookContract.kt").readText()
+        )
+        val leaked = CONTROLLER_LOCAL_EFFECTS.filter { effect ->
+            Regex("""\b$effect\b[^\n]*:\s*ReadBookEffect\b""").containsMatchIn(source)
+        }
+        assertTrue(
+            "${leaked.joinToString()} 又回到了 ReadBookEffect。\n" +
+                "这几个只由 ReadBookController 的 postRender 产生、又只由它自己的 " +
+                "handleEffect 消费,从不经过 ViewModel 的 _effects——" +
+                "请直接内联进 controller 的渲染方法。",
+            leaked.isEmpty(),
+        )
+    }
+
+    private companion object {
+        val CALLBACK_METHODS = listOf(
+            "upMenuView",
+            "loadChapterList",
+            "notifyBookChanged",
+            "sureNewProgress",
+        )
+
+        val CONTROLLER_LOCAL_EFFECTS = listOf(
+            "PageChanged",
+            "ContentLoadFinish",
+            "LayoutPageCompleted",
+        )
+
+        val CALLBACK_SUPERTYPE = Regex("""\bReadBook\.CallBack\b""")
+
+        fun stripComments(text: String): String = text
+            .replace(Regex("""/\*[\s\S]*?\*/"""), "")
+            .replace(Regex("""//[^\n]*"""), "")
+
+        fun mainSourceFile(relativePath: String): File {
+            var directory: File? = File("").absoluteFile
+            while (directory != null) {
+                for (prefix in listOf("src/main/java", "app/src/main/java")) {
+                    val candidate = File(directory, "$prefix/$relativePath")
+                    if (candidate.isFile) return candidate
+                }
+                directory = directory.parentFile
+            }
+            error("从 ${File("").absolutePath} 向上找不到 $relativePath")
+        }
+    }
+}