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

refactor(D2): ReadView 的页数据改由 ReaderPageSource 喂入

Track D·D2(docs/dev/mad-modernization-plan.md §Track D)。ReadView.kt 现在**一个
ReadBook 引用都没有了**(只剩 ReadBookConfig,属 R4.1)。

新增 `ReaderPageSource`——入站只读页数据口,与出站的 `ReaderEvent` 对称:

| 原直读 | 现 |
|---|---|
| ReadBook.textChapter(0/1/-1) | pageSource.textChapter(...) |
| ReadBook.durChapterIndex / simulatedChapterSize | pageSource.* |
| ReadBook.pageAnim() ×2 | pageSource.pageAnim |
| DataSource.pageIndex 的 `get() = ReadBook.durPageIndex` 默认实现 | 接口改抽象,ReadView 以 pageSource.durPageIndex 实现 |

`DataSource` 那个默认实现单独说一句:接口自带单例默认值,等于谁实现它谁就绑死在
全局状态上。ReadView 是它唯一的实现者,删掉安全。

**刻意的设计选择**:`ReaderPageSource` 是逐次读取的接口,不是不可变快照。文档 D2
写的是「只读输入(ReaderRenderState)」,但取页由 pageFactory 在绘制期驱动,换成
发布期快照会改变时序(文档自己也标了「涉及 pageFactory 取页时序」)。本步只做
**依赖反转、零时序变化**;快照化留作独立议题。

护栏 `ReadViewOutboundBoundaryTest` 改名 `ReadViewBoundaryTest`(现在管出站入站两个
方向),四条断言:ReadView 不引用业务单例(ReadBook 白名单清空)、CallBack 成员
白名单、三个协作面非空构造注入、DataSource 不内嵌 ReadBook。每条都注入回归反证过。
全量单测 441 → 443。

反证时再次撞到 D1b 那个模式:把 pageSource 改可空会让**编译先挂**(其余调用点是
非空用法)——结构性保证在起作用,但也说明它不能当反证手段,改用「只加一处可空
调用」(非空类型上的 ?. 只是警告)。

**未动**:page/ 包另外 8 个文件仍直接摸 ReadBook,其中 TextPageFactory 的 6 处是
**写操作**(setPageIndex/moveToNextChapter/moveToPrevChapter),性质同 D1,只是文档
把 D1 划在 ReadView.kt 一个文件里;其余是 PageView/ContentTextView/ChapterProvider/
TextChapterLayout 读 ReadBook.book/bookSource。各自独立,未排期。

回归清单:翻页与换章(含首末章边界)、翻页动画各模式切换(滚动/覆盖/仿真/滑动/
无动画)、进度条与页码显示、朗读中翻页、开书首帧不闪占位页(currentChapter 的
isInitFinish 门闩没动但取数路径换了)。

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

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

@@ -45,7 +45,9 @@ 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.ReaderPageSource
 import io.legado.app.ui.book.read.page.entities.PageDirection
+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.provider.ChapterProvider
@@ -424,6 +426,19 @@ class ReadBookController(
         viewModel.onIntent(ReadBookIntent.SureNewProgress(progress))
     }
 
+    // ── ReaderPageSource(Track D·D2:喂给 ReadView 的只读页数据)────────
+
+    override val durChapterIndex: Int get() = ReadBook.durChapterIndex
+
+    override val durPageIndex: Int get() = ReadBook.durPageIndex
+
+    override val simulatedChapterSize: Int get() = ReadBook.simulatedChapterSize
+
+    override val pageAnim: Int get() = ReadBook.pageAnim()
+
+    override fun textChapter(chapterOnDur: Int): TextChapter? =
+        ReadBook.textChapter(chapterOnDur)
+
     // ── ReaderEventListener(Track D·D1:ReadView 的出站业务意图)─────────
 
     override fun onEvent(event: ReaderEvent) {

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

@@ -56,6 +56,7 @@ 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.ReaderPageSource
 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
@@ -94,6 +95,7 @@ interface ReadBookRouteHost :
     View.OnTouchListener,
     ReadView.CallBack,
     ReaderEventListener,
+    ReaderPageSource,
     ContentTextView.CallBack {
 
     val isInMultiWindowModeCompat: Boolean
@@ -534,6 +536,7 @@ fun ReadBookRouteScreen(
                 onCursorTouch = controller,
                 readViewCallBack = controller,
                 readerEventListener = controller,
+                readerPageSource = controller,
                 contentTextViewCallBack = controller,
                 isDarkTheme = isDarkTheme,
                 onThemeChanged = controller::onAppThemeChanged,
@@ -668,6 +671,7 @@ private fun ReadBookViewLayer(
     onCursorTouch: View.OnTouchListener,
     readViewCallBack: ReadView.CallBack,
     readerEventListener: ReaderEventListener,
+    readerPageSource: ReaderPageSource,
     contentTextViewCallBack: ContentTextView.CallBack,
     isDarkTheme: Boolean,
     onThemeChanged: (Boolean) -> Unit,
@@ -683,6 +687,7 @@ private fun ReadBookViewLayer(
                     callBack = readViewCallBack,
                     contentCallBack = contentTextViewCallBack,
                     eventListener = readerEventListener,
+                    pageSource = readerPageSource,
                 ).apply {
                     layoutParams = FrameLayout.LayoutParams(
                         FrameLayout.LayoutParams.MATCH_PARENT,

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

@@ -17,7 +17,6 @@ import io.legado.app.BuildConfig
 import io.legado.app.R
 import io.legado.app.constant.PageAnim
 import io.legado.app.help.config.ReadBookConfig
-import io.legado.app.model.ReadBook
 import io.legado.app.model.ReadSessionState
 
 import io.legado.app.ui.book.read.page.api.DataSource
@@ -56,6 +55,7 @@ class ReadView(
     private val callBack: CallBack,
     contentCallBack: ContentTextView.CallBack? = null,
     private val eventListener: ReaderEventListener,
+    private val pageSource: ReaderPageSource,
 ) :
     FrameLayout(context, attrs),
     DataSource, LayoutProgressListener {
@@ -572,9 +572,9 @@ class ReadView(
      * 更新翻页动画
      */
     fun upPageAnim(upRecorder: Boolean = false) {
-        isScroll = ReadBook.pageAnim() == 3
+        isScroll = pageSource.pageAnim == 3
         ChapterProvider.upLayout()
-        when (ReadBook.pageAnim()) {
+        when (pageSource.pageAnim) {
             PageAnim.coverPageAnim -> if (pageDelegate !is CoverPageDelegate) {
                 pageDelegate = CoverPageDelegate(this)
             }
@@ -816,27 +816,29 @@ class ReadView(
         upProgressThrottle.invoke()
     }
 
+    override val pageIndex: Int get() = pageSource.durPageIndex
+
     override val currentChapter: TextChapter?
         get() {
-            return if (callBack.isInitFinish) ReadBook.textChapter(0) else null
+            return if (callBack.isInitFinish) pageSource.textChapter(0) else null
         }
 
     override val nextChapter: TextChapter?
         get() {
-            return if (callBack.isInitFinish) ReadBook.textChapter(1) else null
+            return if (callBack.isInitFinish) pageSource.textChapter(1) else null
         }
 
     override val prevChapter: TextChapter?
         get() {
-            return if (callBack.isInitFinish) ReadBook.textChapter(-1) else null
+            return if (callBack.isInitFinish) pageSource.textChapter(-1) else null
         }
 
     override fun hasNextChapter(): Boolean {
-        return ReadBook.durChapterIndex < ReadBook.simulatedChapterSize - 1
+        return pageSource.durChapterIndex < pageSource.simulatedChapterSize - 1
     }
 
     override fun hasPrevChapter(): Boolean {
-        return ReadBook.durChapterIndex > 0
+        return pageSource.durChapterIndex > 0
     }
 
     /**

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

@@ -0,0 +1,26 @@
+package io.legado.app.ui.book.read.page
+
+import io.legado.app.ui.book.read.page.entities.TextChapter
+
+/**
+ * `ReadView` 的只读页数据入口(Track D·D2,见 docs/dev/mad-modernization-plan.md §Track D)。
+ *
+ * 与出站的 [ReaderEvent] 相对:业务意图往外发、页数据往里读。`ReadView` 不认识 `ReadBook`,
+ * 由宿主(`ReadBookController`)实现本接口把数据喂进来。
+ *
+ * 这里刻意是**逐次读取**而不是不可变快照:取页由 `pageFactory` 在绘制期驱动,
+ * 换成发布期快照会改变时序。快照化是 D2 之后的独立议题,不与本步捆绑。
+ */
+interface ReaderPageSource {
+
+    val durChapterIndex: Int
+
+    val durPageIndex: Int
+
+    val simulatedChapterSize: Int
+
+    val pageAnim: Int
+
+    /** chapterOnDur: 0 当前章, 1 下一章, -1 上一章 */
+    fun textChapter(chapterOnDur: Int): TextChapter?
+}

+ 1 - 2
app/src/main/java/io/legado/app/ui/book/read/page/api/DataSource.kt

@@ -1,11 +1,10 @@
 package io.legado.app.ui.book.read.page.api
 
-import io.legado.app.model.ReadBook
 import io.legado.app.ui.book.read.page.entities.TextChapter
 
 interface DataSource {
 
-    val pageIndex: Int get() = ReadBook.durPageIndex
+    val pageIndex: Int
 
     val currentChapter: TextChapter?
 

+ 46 - 22
app/src/test/java/io/legado/app/ui/book/read/page/ReadViewOutboundBoundaryTest.kt → app/src/test/java/io/legado/app/ui/book/read/page/ReadViewBoundaryTest.kt

@@ -5,28 +5,27 @@ import org.junit.Test
 import java.io.File
 
 /**
- * Track D·D1 —— `ReadView` 只做绘制/手势/动画,不下达业务命令。
+ * Track D·D1/D2 —— `ReadView` 只做绘制/手势/动画,两个方向都不认识业务单例。
  *
- * 手势判定出的业务意图经 [ReaderEvent] 出站,由 `ReadBookController` 翻译成 Intent 或会话调用。
- * 两条会悄悄失效的边界:
+ * - 出站(D1):手势判定出的业务意图经 [ReaderEvent] 发出,由 `ReadBookController` 翻译。
+ * - 入站(D2):页数据经 [ReaderPageSource] 喂进来,不再直接问 `ReadBook`。
  *
- * 1. 有人图省事在 `ReadView` 里直呼 `ReadBook`/`ReadAloud`/`BaseReadAloudService`——
- *    出站回边被绕过,View 又长回业务层。
- * 2. `eventListener` 被改成可空或加 `activity as` 兜底——漏接线从**编译错误**退化成
- *    **静默失效的点击**(点了没反应,真机上极难归因)。
+ * 会悄悄失效的边界:
  *
- * 只读取页数据的 `ReadBook` 成员是 D2 的范围(入站数据面),D1 不动,见白名单。
+ * 1. 有人图省事在 `ReadView` 或 `DataSource` 里直接摸 `ReadBook`/`ReadAloud`——
+ *    两条回边被绕过,View 又长回业务层。
+ * 2. 三个协作面(`callBack`/`eventListener`/`pageSource`)被改成可空或加 `activity as`
+ *    兜底——漏接线从**编译错误**退化成**静默失效**(点了没反应、页面空白),真机上极难归因。
  */
-class ReadViewOutboundBoundaryTest {
+class ReadViewBoundaryTest {
 
     @Test
-    fun `ReadView 不直呼业务单例下达命令`() {
+    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()
@@ -39,9 +38,9 @@ class ReadViewOutboundBoundaryTest {
 
         val violations = readBookViolations + aloudViolations
         assertTrue(
-            "ReadView 又直接下达业务命令了:${violations.joinToString()}。\n" +
-                "业务意图请加进 ReaderEvent 并由 ReadBookController.onEvent 翻译;\n" +
-                "只读页数据(${READ_BOOK_DATA_PLANE_ALLOWLIST.joinToString()})属 Track D·D2,暂留。",
+            "ReadView 又直接摸业务单例了:${violations.joinToString()}。\n" +
+                "业务意图请加进 ReaderEvent 由 ReadBookController.onEvent 翻译;\n" +
+                "要读的页数据请加进 ReaderPageSource 由宿主喂进来。",
             violations.isEmpty(),
         )
     }
@@ -115,6 +114,39 @@ class ReadViewOutboundBoundaryTest {
         )
     }
 
+    @Test
+    fun `DataSource 接口不内嵌 ReadBook`() {
+        val source = stripComments(
+            mainSourceFile("io/legado/app/ui/book/read/page/api/DataSource.kt").readText()
+        )
+        assertTrue(
+            "DataSource 又把 ReadBook 写进默认实现了。\n" +
+                "接口带着单例默认值,等于谁实现它谁就绑死在全局状态上——" +
+                "页内位置请由实现方从 ReaderPageSource 取。",
+            !Regex("""\bReadBook\b""").containsMatchIn(source),
+        )
+    }
+
+    @Test
+    fun `pageSource 必须构造期注入且不可为空`() {
+        val source = stripComments(readViewSource())
+        val violations = buildList {
+            if (!Regex("""private\s+val\s+pageSource\s*:\s*ReaderPageSource\s*(?![?=])""")
+                    .containsMatchIn(source)
+            ) {
+                add("pageSource 不再是构造期注入的非空 val")
+            }
+            if (Regex("""pageSource\s*\?""").containsMatchIn(source)) {
+                add("pageSource 出现了可空调用")
+            }
+        }
+        assertTrue(
+            "${violations.joinToString()}。\n" +
+                "页数据入口漏接线必须是编译错误,而不是正文空白。",
+            violations.isEmpty(),
+        )
+    }
+
     private companion object {
         /** CallBack 允许保留的成员:三项瞬时 UI 副作用 + 首帧放行门闩 */
         val CALLBACK_UI_SIDE_EFFECTS = listOf(
@@ -124,14 +156,6 @@ class ReadViewOutboundBoundaryTest {
             "upSystemUiVisibility",
         )
 
-        /** 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()