Просмотр исходного кода

refactor(D1b): ReadView.CallBack 拆成瞬时副作用面,业务项改走 ReaderEvent

Track D·D1 第二批。CallBack 从 12 个成员砍到 4 个:

**留下(瞬时 UI 副作用 + 首帧门闩)**:screenOffTimerStart、showTextActionMenu、
upSystemUiVisibility、isInitFinish。按 §Track D 的忠实边界,前三项不是业务状态,
不该事件化;isInitFinish 是 DataSource 的首帧放行门闩,属入站数据面(D2)。

**改走 ReaderEvent**:showActionMenu、autoPageStop、openChapterList、
openContentEdit、addBookmark、changeReplaceRuleState、openSearchActivity。
controller 侧对应实现降为 private,只由 onEvent 调用;showActionMenu 另有
图片长按菜单的 "menu" 分支在用,保留为 controller 内部方法。

**删除 sureNewProgress**:D1a 把 action 12 的进度同步迁进 controller 后,
ReadView 已无调用点。ReadBookLoadDelegate.Host 的同名方法是另一个接口,不受影响。

**删除 `activity as CallBack` 兜底**:callBack 与 eventListener 同为非空构造参数、
并降为 private。ReadView 不再要求「宿主必须是实现了该接口的 Activity」——这是
D1c 让阅读器核心在 JVM 下可构造、可测的前置条件。

**断开 PageDelegate 的穿透**:readView.callBack.autoPageStop() →
readView.requestAutoPageStop()。翻页委托不再够到宿主接口,改由 ReadView 转发事件。

护栏 ReadViewOutboundBoundaryTest 加两条,各自单独注入回归反证过会红:
③ CallBack 成员白名单,混进业务方法即红(注入 toggleMenu() 验证——注入
   openChapterList() 不成立,controller 端已 private,编译就先挂了);
④ callBack 必须构造期注入且非空、无 as CallBack 兜底。
全量单测 432 → 434。

回归清单:点击区动作 0/7–11(呼出菜单、加书签、编辑正文、替换规则开关、
打开目录、打开搜索);无障碍 ACTION_CLICK 呼出菜单;自动翻页翻到最后一页时
自动停止;朗读中/搜索结果态/自动翻页态下点击呼菜单的三个分支各走一次。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5151561 3 месяцев назад
Родитель
Сommit
5a1fdd0a9a

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

@@ -324,14 +324,16 @@ class ReadBookController(
         }
     }
 
-    // ── ReadView.CallBack ─────────────────────────────────────────────
+    // ── ReadView 协作面 ────────────────────────────────────────────────
+    // override 的四项是 ReadView.CallBack(瞬时 UI 副作用 + 首帧门闩);
+    // private 的几项是 ReaderEvent 的落点,只由下面的 onEvent 调用。
 
     // initData 还没跑完时, 只要 ReadBook 里已有本书可用的章节就让 ReadView 直接画正文,
     // 否则首帧必然是 "加载数据中" 占位页
     override val isInitFinish: Boolean
         get() = viewModel.uiState.value.isInitFinish || viewModel.isCachedChapterUsable()
 
-    override fun showActionMenu() {
+    private fun showActionMenu() {
         val state = viewModel.uiState.value
         when {
             BaseReadAloudService.isRun -> viewModel.onIntent(
@@ -379,19 +381,19 @@ class ReadBookController(
         }
     }
 
-    override fun autoPageStop() {
+    private fun autoPageStop() {
         viewModel.onIntent(ReadBookIntent.StopAutoPage)
     }
 
-    override fun openChapterList() {
+    private fun openChapterList() {
         viewModel.onIntent(ReadBookIntent.OpenChapterList)
     }
 
-    override fun openContentEdit() {
+    private fun openContentEdit() {
         viewModel.onIntent(ReadBookIntent.OpenContentEdit)
     }
 
-    override fun addBookmark() {
+    private fun addBookmark() {
         val book = ReadBook.book
         val page = ReadBook.curTextChapter?.getPage(ReadBook.durPageIndex)
         if (book != null && page != null) {
@@ -405,11 +407,11 @@ class ReadBookController(
         }
     }
 
-    override fun changeReplaceRuleState() {
+    private fun changeReplaceRuleState() {
         viewModel.onIntent(ReadBookIntent.MenuEnableReplace)
     }
 
-    override fun openSearchActivity(searchWord: String?) {
+    private fun openSearchActivity(searchWord: String?) {
         viewModel.onIntent(ReadBookIntent.OpenSearch(searchWord))
     }
 
@@ -418,7 +420,7 @@ class ReadBookController(
         upSystemUiVisibility(isInMultiWindowModeCompat, !state.menuVisible)
     }
 
-    override fun sureNewProgress(progress: BookProgress) {
+    private fun sureNewProgress(progress: BookProgress) {
         viewModel.onIntent(ReadBookIntent.SureNewProgress(progress))
     }
 
@@ -426,6 +428,13 @@ class ReadBookController(
 
     override fun onEvent(event: ReaderEvent) {
         when (event) {
+            ReaderEvent.ShowActionMenu -> showActionMenu()
+            ReaderEvent.AutoPageStop -> autoPageStop()
+            ReaderEvent.OpenChapterList -> openChapterList()
+            ReaderEvent.OpenContentEdit -> openContentEdit()
+            ReaderEvent.OpenSearch -> openSearchActivity(null)
+            ReaderEvent.AddBookmark -> addBookmark()
+            ReaderEvent.ChangeReplaceRuleState -> changeReplaceRuleState()
             ReaderEvent.NextChapter -> viewModel.onIntent(ReadBookIntent.NextChapter)
             ReaderEvent.PrevChapter -> viewModel.onIntent(ReadBookIntent.PrevChapter)
             ReaderEvent.ReadAloudPrevParagraph ->

+ 22 - 19
app/src/main/java/io/legado/app/ui/book/read/page/ReadView.kt

@@ -16,7 +16,6 @@ import android.widget.FrameLayout
 import io.legado.app.BuildConfig
 import io.legado.app.R
 import io.legado.app.constant.PageAnim
-import io.legado.app.data.entities.BookProgress
 import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.model.ReadBook
 import io.legado.app.model.ReadSessionState
@@ -54,15 +53,13 @@ import kotlin.math.abs
 class ReadView(
     context: Context,
     attrs: AttributeSet? = null,
-    callBack: CallBack? = null,
+    private val callBack: CallBack,
     contentCallBack: ContentTextView.CallBack? = null,
     private val eventListener: ReaderEventListener,
 ) :
     FrameLayout(context, attrs),
     DataSource, LayoutProgressListener {
 
-    private var injectedCallBack: CallBack? = callBack
-    val callBack: CallBack get() = injectedCallBack ?: activity as CallBack
     var pageFactory: TextPageFactory = TextPageFactory(this)
     var pageDelegate: PageDelegate? = null
         private set(value) {
@@ -322,7 +319,7 @@ class ReadView(
             AccessibilityNodeInfo.ACTION_CLICK -> {
                 performClick()
                 pageDelegate?.dismissSnackBar()
-                callBack.showActionMenu()
+                eventListener.onEvent(ReaderEvent.ShowActionMenu)
                 true
             }
 
@@ -501,7 +498,7 @@ class ReadView(
         when (action) {
             0 -> {
                 pageDelegate?.dismissSnackBar()
-                callBack.showActionMenu()
+                eventListener.onEvent(ReaderEvent.ShowActionMenu)
             }
 
             1 -> pageDelegate?.nextPageByAnim(defaultAnimationSpeed)
@@ -510,11 +507,11 @@ class ReadView(
             4 -> eventListener.onEvent(ReaderEvent.PrevChapter)
             5 -> eventListener.onEvent(ReaderEvent.ReadAloudPrevParagraph)
             6 -> eventListener.onEvent(ReaderEvent.ReadAloudNextParagraph)
-            7 -> callBack.addBookmark()
-            8 -> callBack.openContentEdit()
-            9 -> callBack.changeReplaceRuleState()
-            10 -> callBack.openChapterList()
-            11 -> callBack.openSearchActivity(null)
+            7 -> eventListener.onEvent(ReaderEvent.AddBookmark)
+            8 -> eventListener.onEvent(ReaderEvent.OpenContentEdit)
+            9 -> eventListener.onEvent(ReaderEvent.ChangeReplaceRuleState)
+            10 -> eventListener.onEvent(ReaderEvent.OpenChapterList)
+            11 -> eventListener.onEvent(ReaderEvent.OpenSearch)
             12 -> eventListener.onEvent(ReaderEvent.SyncProgress)
             13 -> eventListener.onEvent(ReaderEvent.ToggleReadAloudPause)
         }
@@ -783,6 +780,14 @@ class ReadView(
         }
     }
 
+    /**
+     * 供 [io.legado.app.ui.book.read.page.delegate.PageDelegate] 在翻到尽头时出站——
+     * 翻页委托不持有 eventListener,经这里转发(Track D·D1)。
+     */
+    internal fun requestAutoPageStop() {
+        eventListener.onEvent(ReaderEvent.AutoPageStop)
+    }
+
     fun onScrollAnimStart() {
         autoPager.pause()
     }
@@ -834,18 +839,16 @@ class ReadView(
         return ReadBook.durChapterIndex > 0
     }
 
+    /**
+     * 与宿主的**瞬时 UI 副作用**协作面(Track D·D1)。
+     *
+     * 业务意图一律走 [ReaderEvent];这里只留不属于业务状态、由 View 直接驱动宿主的那几项,
+     * 外加首帧放行门闩 `isInitFinish`(入站状态查询,随 D2 数据面一起处理)。
+     */
     interface CallBack {
         val isInitFinish: Boolean
-        fun showActionMenu()
         fun screenOffTimerStart()
         fun showTextActionMenu()
-        fun autoPageStop()
-        fun openChapterList()
-        fun openContentEdit()
-        fun addBookmark()
-        fun changeReplaceRuleState()
-        fun openSearchActivity(searchWord: String?)
         fun upSystemUiVisibility()
-        fun sureNewProgress(progress: BookProgress)
     }
 }

+ 14 - 0
app/src/main/java/io/legado/app/ui/book/read/page/ReaderEvent.kt

@@ -11,6 +11,20 @@ package io.legado.app.ui.book.read.page
  */
 sealed interface ReaderEvent {
 
+    data object ShowActionMenu : ReaderEvent
+
+    data object AutoPageStop : ReaderEvent
+
+    data object OpenChapterList : ReaderEvent
+
+    data object OpenContentEdit : ReaderEvent
+
+    data object OpenSearch : ReaderEvent
+
+    data object AddBookmark : ReaderEvent
+
+    data object ChangeReplaceRuleState : ReaderEvent
+
     data object NextChapter : ReaderEvent
 
     data object PrevChapter : ReaderEvent

+ 1 - 1
app/src/main/java/io/legado/app/ui/book/read/page/delegate/PageDelegate.kt

@@ -174,7 +174,7 @@ abstract class PageDelegate(protected val readView: ReadView) {
     fun hasNext(): Boolean {
         val hasNext = readView.pageFactory.hasNext()
         if (!hasNext) {
-            readView.callBack.autoPageStop()
+            readView.requestAutoPageStop()
             if (!snackBar.isShown) {
                 snackBar.setText(R.string.no_next_page)
                 snackBar.show()

+ 53 - 0
app/src/test/java/io/legado/app/ui/book/read/page/ReadViewOutboundBoundaryTest.kt

@@ -70,7 +70,60 @@ class ReadViewOutboundBoundaryTest {
         )
     }
 
+    @Test
+    fun `ReadView CallBack 只剩瞬时 UI 副作用`() {
+        val source = stripComments(readViewSource())
+        val body = Regex("""interface\s+CallBack\s*\{([\s\S]*?)\n\s{4}\}""")
+            .find(source)
+            ?.groupValues
+            ?.get(1)
+            ?: error("找不到 ReadView.CallBack 的声明")
+
+        val members = Regex("""\b(?:fun|val|var)\s+(\w+)""")
+            .findAll(body)
+            .map { it.groupValues[1] }
+            .toList()
+
+        val businessLeaks = members.filterNot { it in CALLBACK_UI_SIDE_EFFECTS }
+        assertTrue(
+            "ReadView.CallBack 又混进了非瞬时副作用的成员:${businessLeaks.joinToString()}。\n" +
+                "业务/导航意图请走 ReaderEvent;CallBack 只留 " +
+                "${CALLBACK_UI_SIDE_EFFECTS.joinToString()}——" +
+                "前三者是 View 直接驱动宿主的瞬时副作用,isInitFinish 是首帧放行门闩。",
+            businessLeaks.isEmpty(),
+        )
+    }
+
+    @Test
+    fun `callBack 也必须构造期注入且无 activity 兜底`() {
+        val source = stripComments(readViewSource())
+        val violations = buildList {
+            if (Regex("""as\s+CallBack""").containsMatchIn(source)) {
+                add("出现了 activity as CallBack 兜底")
+            }
+            if (!Regex("""private\s+val\s+callBack\s*:\s*CallBack\s*(?![?=])""")
+                    .containsMatchIn(source)
+            ) {
+                add("callBack 不再是构造期注入的非空 private val")
+            }
+        }
+        assertTrue(
+            "${violations.joinToString()}。\n" +
+                "宿主协作面漏接线必须是编译错误;activity as CallBack 还会把 ReadView " +
+                "钉死在「宿主必须是实现了该接口的 Activity」上,JVM 里就构造不出来。",
+            violations.isEmpty(),
+        )
+    }
+
     private companion object {
+        /** CallBack 允许保留的成员:三项瞬时 UI 副作用 + 首帧放行门闩 */
+        val CALLBACK_UI_SIDE_EFFECTS = listOf(
+            "isInitFinish",
+            "screenOffTimerStart",
+            "showTextActionMenu",
+            "upSystemUiVisibility",
+        )
+
         /** D2(入站数据面)的范围,D1 不动 */
         val READ_BOOK_DATA_PLANE_ALLOWLIST = listOf(
             "textChapter",