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

refactor(D1a): ReadView 的 7 处业务直呼改 ReaderEvent 出站

Track D·D1(docs/dev/mad-modernization-plan.md §Track D)第一批:ReadView 只做
绘制/手势/动画,不认识 ReadBook/ReadAloud。

**出站的 7 处**(都在点击区 action 表与划词朗读):moveToNextChapter、
moveToPrevChapter、ReadAloud.prevParagraph/nextParagraph、syncProgress、
BaseReadAloudService.isPlay() 判断式的 pause/resume,以及 aloudStartSelect 里的
moveToNextPage/moveToNextChapterAwait/readAloud。前六处改 eventListener.onEvent(...)
由 ReadBookController.onEvent 翻译;aloudStartSelect 整体迁进 controller(它的调用方
本来就是 controller),ReadView 只留渲染面查询 posByLineColumn(line, column)。

**eventListener 是非空构造参数**,不给默认值、不加 activity as 兜底:漏接线必须是
编译错误,而不是点了没反应的静默失效手势。ReadBookRouteHost 补 ReaderEventListener
超类型,构造点一并接线。

**忠实边界**:
- 只搬「谁下达业务命令」,翻页/排版/动画行为一律不动。
- action 13 没有路由到现成的 ReadBookIntent.ReadAloudTogglePause——那条会落到
  controller.toggleReadAloud() 的完整编排(含「朗读没启动则启动」+ StopAutoPage +
  滚动模式重定位),与点击区原语义「只 pause/resume、朗读没开时是空操作」不等价。
  是否统一属产品决定,本批不做。
- CallBack 里的业务项(addBookmark/openChapterList 等)与瞬时 UI 副作用项
  (screenOffTimerStart/upSystemUiVisibility/showTextActionMenu)留待 D1b 拆分,
  避免出现两套并存的入口。
- 只读页数据(textChapter/durChapterIndex/simulatedChapterSize/pageAnim)属 D2,未动。

顺带删除死代码 ReadAloudDelegate.togglePause():它在 4f6ee460a(2026-06-18 作者把
ReadAloudTogglePause 改发 Effect.ToggleReadAloud)时就已零引用,R2.2 摘朗读域时被
原样搬进 delegate 并改名,看起来像新写的。

护栏 ReadViewOutboundBoundaryTest 两条,均注入回归反证过会红:
① ReadView 不直呼业务单例(ReadBook 仅允许 D2 白名单的四个只读成员);
② eventListener 必须构造期注入且非空、无可空调用、无 as 兜底。
全量单测 430 → 432。

回归清单:点击区动作「下一章/上一章」「朗读上一段/下一段」「同步进度」(含上传与
同步成功的 toast,宿主从 View 换成 Activity)「朗读暂停/继续」(朗读未启动时点击
无反应是原行为);划词菜单「朗读」,特别是选中文字在后面几页的情况。

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

+ 0 - 10
app/src/main/java/io/legado/app/ui/book/read/ReadAloudDelegate.kt

@@ -153,16 +153,6 @@ class ReadAloudDelegate(
         }
     }
 
-    fun togglePause() {
-        if (host.uiState.isReadAloudPaused) {
-            ReadAloud.resume(context)
-            host.updateState { it.copy(isReadAloudPaused = false) }
-        } else {
-            ReadAloud.pause(context)
-            host.updateState { it.copy(isReadAloudPaused = true) }
-        }
-    }
-
     fun stop() {
         ReadAloud.stop(context)
         host.updateState { it.copy(isReadAloudRunning = false, isReadAloudPaused = false) }

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

@@ -43,8 +43,11 @@ import io.legado.app.receiver.TimeBatteryReceiver
 import io.legado.app.service.BaseReadAloudService
 import io.legado.app.ui.book.read.page.ContentTextView
 import io.legado.app.ui.book.read.page.ReadView
+import io.legado.app.ui.book.read.page.ReaderEvent
+import io.legado.app.ui.book.read.page.ReaderEventListener
 import io.legado.app.ui.book.read.page.entities.PageDirection
 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.provider.ChapterProvider
 import io.legado.app.ui.book.read.page.provider.TextPageFactory
 import io.legado.app.ui.config.readConfig.ReadConfig
@@ -419,6 +422,54 @@ class ReadBookController(
         viewModel.onIntent(ReadBookIntent.SureNewProgress(progress))
     }
 
+    // ── ReaderEventListener(Track D·D1:ReadView 的出站业务意图)─────────
+
+    override fun onEvent(event: ReaderEvent) {
+        when (event) {
+            ReaderEvent.NextChapter -> viewModel.onIntent(ReadBookIntent.NextChapter)
+            ReaderEvent.PrevChapter -> viewModel.onIntent(ReadBookIntent.PrevChapter)
+            ReaderEvent.ReadAloudPrevParagraph ->
+                viewModel.onIntent(ReadBookIntent.ReadAloudPrevParagraph)
+
+            ReaderEvent.ReadAloudNextParagraph ->
+                viewModel.onIntent(ReadBookIntent.ReadAloudNextParagraph)
+
+            // 只做暂停/续读,与菜单栏的 toggleReadAloud()(含"朗读未启动则启动"编排)不同,
+            // 保持点击区原语义:朗读没开着时这个手势是空操作。
+            ReaderEvent.ToggleReadAloudPause -> if (BaseReadAloudService.isPlay()) {
+                ReadAloud.pause(activity)
+            } else {
+                ReadAloud.resume(activity)
+            }
+
+            ReaderEvent.SyncProgress -> ReadBook.syncProgress(
+                { progress -> sureNewProgress(progress) },
+                { activity.longToastOnUi(activity.getString(R.string.upload_book_success)) },
+                { activity.longToastOnUi(activity.getString(R.string.sync_book_progress_success)) }
+            )
+        }
+    }
+
+    /**
+     * 从选择位置开始朗读:先把阅读位置推进到所选页,再按行/列换算章内位置起读。
+     * 原先在 ReadView 内(Track D·D1 迁出——ReadView 不下达业务命令)。
+     */
+    private suspend fun aloudStartSelect(selectStartPos: TextPos) {
+        val readView = refs?.readView ?: return
+        var pagePos = selectStartPos.relativePagePos
+        while (pagePos > 0) {
+            if (!ReadBook.moveToNextPage()) {
+                ReadBook.moveToNextChapterAwait(false)
+            }
+            pagePos--
+        }
+        val startPos = readView.posByLineColumn(
+            selectStartPos.lineIndex,
+            selectStartPos.columnIndex
+        )
+        ReadBook.readAloud(startPos = startPos)
+    }
+
     // ── ContentTextView.CallBack ──────────────────────────────────────
 
     override val headerHeight: Int get() = refs?.readView?.curPage?.headerHeight ?: 0
@@ -1031,7 +1082,7 @@ class ReadBookController(
             is ReadBookEffect.StopAutoPage -> onStopAutoPage?.invoke() ?: stopAutoPage()
             is ReadBookEffect.TextActionAloudSelect -> {
                 activity.lifecycleScope.launch {
-                    refs?.readView?.aloudStartSelect(effect.selectStartPos.copy())
+                    aloudStartSelect(effect.selectStartPos.copy())
                 }
             }
 

+ 5 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt

@@ -55,6 +55,7 @@ import io.legado.app.model.SourceCallBack
 import io.legado.app.ui.book.info.BookInfoActivity
 import io.legado.app.ui.book.read.page.ContentTextView
 import io.legado.app.ui.book.read.page.ReadView
+import io.legado.app.ui.book.read.page.ReaderEventListener
 import io.legado.app.ui.book.read.page.entities.PageDirection
 import io.legado.app.ui.book.read.sheet.ReaderBookSheetRoute
 import io.legado.app.ui.book.read.sheet.TextSelectMenuConfigSheet
@@ -92,6 +93,7 @@ data class ReadBookViewRefs(
 interface ReadBookRouteHost :
     View.OnTouchListener,
     ReadView.CallBack,
+    ReaderEventListener,
     ContentTextView.CallBack {
 
     val isInMultiWindowModeCompat: Boolean
@@ -531,6 +533,7 @@ fun ReadBookRouteScreen(
                 onRefsReady = { controller.onRefsReady(it) },
                 onCursorTouch = controller,
                 readViewCallBack = controller,
+                readerEventListener = controller,
                 contentTextViewCallBack = controller,
                 isDarkTheme = isDarkTheme,
                 onThemeChanged = controller::onAppThemeChanged,
@@ -664,6 +667,7 @@ private fun ReadBookViewLayer(
     onRefsReady: (ReadBookViewRefs) -> Unit,
     onCursorTouch: View.OnTouchListener,
     readViewCallBack: ReadView.CallBack,
+    readerEventListener: ReaderEventListener,
     contentTextViewCallBack: ContentTextView.CallBack,
     isDarkTheme: Boolean,
     onThemeChanged: (Boolean) -> Unit,
@@ -678,6 +682,7 @@ private fun ReadBookViewLayer(
                     context = context,
                     callBack = readViewCallBack,
                     contentCallBack = contentTextViewCallBack,
+                    eventListener = readerEventListener,
                 ).apply {
                     layoutParams = FrameLayout.LayoutParams(
                         FrameLayout.LayoutParams.MATCH_PARENT,

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

@@ -18,10 +18,8 @@ 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.ReadAloud
 import io.legado.app.model.ReadBook
 import io.legado.app.model.ReadSessionState
-import io.legado.app.service.BaseReadAloudService
 
 import io.legado.app.ui.book.read.page.api.DataSource
 import io.legado.app.ui.book.read.page.delegate.CoverPageDelegate
@@ -44,7 +42,6 @@ import io.legado.app.ui.book.read.page.provider.TextPageFactory
 import io.legado.app.ui.config.readConfig.ReadConfig
 import io.legado.app.utils.activity
 import io.legado.app.utils.invisible
-import io.legado.app.utils.longToastOnUi
 
 import io.legado.app.utils.throttle
 import java.text.BreakIterator
@@ -59,6 +56,7 @@ class ReadView(
     attrs: AttributeSet? = null,
     callBack: CallBack? = null,
     contentCallBack: ContentTextView.CallBack? = null,
+    private val eventListener: ReaderEventListener,
 ) :
     FrameLayout(context, attrs),
     DataSource, LayoutProgressListener {
@@ -508,26 +506,17 @@ class ReadView(
 
             1 -> pageDelegate?.nextPageByAnim(defaultAnimationSpeed)
             2 -> pageDelegate?.prevPageByAnim(defaultAnimationSpeed)
-            3 -> ReadBook.moveToNextChapter(true)
-            4 -> ReadBook.moveToPrevChapter(upContent = true, toLast = false)
-            5 -> ReadAloud.prevParagraph(context)
-            6 -> ReadAloud.nextParagraph(context)
+            3 -> eventListener.onEvent(ReaderEvent.NextChapter)
+            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)
-            12 -> ReadBook.syncProgress(
-                { progress -> callBack.sureNewProgress(progress) },
-                { context.longToastOnUi(context.getString(R.string.upload_book_success)) },
-                { context.longToastOnUi(context.getString(R.string.sync_book_progress_success)) })
-            13 -> {
-                if (BaseReadAloudService.isPlay()) {
-                    ReadAloud.pause(context)
-                } else {
-                    ReadAloud.resume(context)
-                }
-            }
+            12 -> eventListener.onEvent(ReaderEvent.SyncProgress)
+            13 -> eventListener.onEvent(ReaderEvent.ToggleReadAloudPause)
         }
     }
 
@@ -753,20 +742,10 @@ class ReadView(
     }
 
     /**
-     * 从选择位置开始朗读
+     * 当前页内 行/列 对应的章节内位置(渲染面查询,翻页推进由外层负责)
      */
-    suspend fun aloudStartSelect(selectStartPos: TextPos) {
-        var pagePos = selectStartPos.relativePagePos
-        val line = selectStartPos.lineIndex
-        val column = selectStartPos.columnIndex
-        while (pagePos > 0) {
-            if (!ReadBook.moveToNextPage()) {
-                ReadBook.moveToNextChapterAwait(false)
-            }
-            pagePos--
-        }
-        val startPos = curPage.textPage.getPosByLineColumn(line, column)
-        ReadBook.readAloud(startPos = startPos)
+    fun posByLineColumn(line: Int, column: Int): Int {
+        return curPage.textPage.getPosByLineColumn(line, column)
     }
 
     /**

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

@@ -0,0 +1,29 @@
+package io.legado.app.ui.book.read.page
+
+/**
+ * `ReadView` 的出站业务意图(Track D·D1,见 docs/dev/mad-modernization-plan.md §Track D)。
+ *
+ * `ReadView` 只做绘制/手势/动画,不认识 `ReadBook`/`ReadAloud`:手势判定出的业务命令以事件发出,
+ * 由外层(`ReadBookController`)翻译成 Intent 或会话调用。
+ *
+ * 不属于业务状态的**瞬时 UI 副作用**(系统栏可见性、息屏计时、文本选择菜单等)不走这里,
+ * 仍由 `ReadView.CallBack` 与宿主直接协作。
+ */
+sealed interface ReaderEvent {
+
+    data object NextChapter : ReaderEvent
+
+    data object PrevChapter : ReaderEvent
+
+    data object ReadAloudPrevParagraph : ReaderEvent
+
+    data object ReadAloudNextParagraph : ReaderEvent
+
+    data object ToggleReadAloudPause : ReaderEvent
+
+    data object SyncProgress : ReaderEvent
+}
+
+fun interface ReaderEventListener {
+    fun onEvent(event: ReaderEvent)
+}

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

@@ -0,0 +1,101 @@
+package io.legado.app.ui.book.read.page
+
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.io.File
+
+/**
+ * Track D·D1 —— `ReadView` 只做绘制/手势/动画,不下达业务命令。
+ *
+ * 手势判定出的业务意图经 [ReaderEvent] 出站,由 `ReadBookController` 翻译成 Intent 或会话调用。
+ * 两条会悄悄失效的边界:
+ *
+ * 1. 有人图省事在 `ReadView` 里直呼 `ReadBook`/`ReadAloud`/`BaseReadAloudService`——
+ *    出站回边被绕过,View 又长回业务层。
+ * 2. `eventListener` 被改成可空或加 `activity as` 兜底——漏接线从**编译错误**退化成
+ *    **静默失效的点击**(点了没反应,真机上极难归因)。
+ *
+ * 只读取页数据的 `ReadBook` 成员是 D2 的范围(入站数据面),D1 不动,见白名单。
+ */
+class ReadViewOutboundBoundaryTest {
+
+    @Test
+    fun `ReadView 不直呼业务单例下达命令`() {
+        val source = stripComments(readViewSource())
+
+        val readBookViolations = Regex("""\bReadBook\.(\w+)""")
+            .findAll(source)
+            .map { it.groupValues[1] }
+            .filterNot { it in READ_BOOK_DATA_PLANE_ALLOWLIST }
+            .distinct()
+            .map { "ReadBook.$it" }
+            .toList()
+
+        val aloudViolations = Regex("""\b(?:ReadAloud|BaseReadAloudService)\.(\w+)""")
+            .findAll(source)
+            .map { it.value }
+            .distinct()
+            .toList()
+
+        val violations = readBookViolations + aloudViolations
+        assertTrue(
+            "ReadView 又直接下达业务命令了:${violations.joinToString()}。\n" +
+                "业务意图请加进 ReaderEvent 并由 ReadBookController.onEvent 翻译;\n" +
+                "只读页数据(${READ_BOOK_DATA_PLANE_ALLOWLIST.joinToString()})属 Track D·D2,暂留。",
+            violations.isEmpty(),
+        )
+    }
+
+    @Test
+    fun `eventListener 必须构造期注入且不可为空`() {
+        val source = stripComments(readViewSource())
+        val violations = buildList {
+            if (!Regex("""private\s+val\s+eventListener\s*:\s*ReaderEventListener\s*(?![?=])""")
+                    .containsMatchIn(source)
+            ) {
+                add("eventListener 不再是构造期注入的非空 val")
+            }
+            if (Regex("""eventListener\s*\?""").containsMatchIn(source)) {
+                add("eventListener 出现了可空调用")
+            }
+            if (Regex("""as\s+ReaderEventListener""").containsMatchIn(source)) {
+                add("eventListener 出现了 activity as 兜底")
+            }
+        }
+        assertTrue(
+            "${violations.joinToString()}。\n" +
+                "非空构造参数是结构性保证:漏接线必须是编译错误," +
+                "而不是点了没反应的静默失效手势。",
+            violations.isEmpty(),
+        )
+    }
+
+    private companion object {
+        /** D2(入站数据面)的范围,D1 不动 */
+        val READ_BOOK_DATA_PLANE_ALLOWLIST = listOf(
+            "textChapter",
+            "durChapterIndex",
+            "simulatedChapterSize",
+            "pageAnim",
+        )
+
+        fun readViewSource(): String =
+            mainSourceFile("io/legado/app/ui/book/read/page/ReadView.kt").readText()
+
+        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")
+        }
+    }
+}