Browse Source

refactor(D2): 取页器的页位置命令收进 ReaderPageSource,排版取图改构造期定参

D2 此前只做完 ReadView.kt 一个文件,同包的 TextPageFactory 还在直接向 ReadBook 下达
6 处业务命令(setPageIndex ×4 / moveToNextChapter / moveToPrevChapter)外加 3 处读
msg —— 渲染层下达业务命令,性质同 D1,只是当时文档把 D1 划在一个文件里。

命令口刻意没做成 ReaderEvent:那是即发即忘的出站意图,而这三个命令必须**同步**生效
(moveToNext 下达后要立即用新位置取页、返回布尔给翻页委托决定动画)。所以扩的是
ReaderPageSource,接口 KDoc 写明了理由,免得后来人「顺手统一」成事件。

顺带把两处运行期读全局改成构造期定参,都修掉真实的陈旧读:

- TextChapterLayout 原先在排版协程运行中读 ReadBook.bookSource,换源时一个还在跑的
  旧章节协程会拿新源去下旧书的图;现在由调用方在建这一章时传入。
- ImageColumn 原先在 draw() 里读 ReadBook.book,换书那一瞬间旧页面重绘会拿新书目录
  去找旧页的图;现在构造期持有。

护栏:TextPageFactory 与上面两个文件纳入 ReadViewBoundaryTest;原先只认
`ReadBook.` 限定访问的正则加固成同时抓成员 import 与局部别名——旧写法对
`import ...ReadBook.durChapterIndex` 之后的裸写完全看不见。三处注入均反证过会红。

ChapterProvider 的 requestWholeBookPageEstimate() 刻意不动:合并进紧邻下一行的
ReadConfigUpdateBus 看似一行改动,但那条总线是 tryEmit,且 action 循环被
`refs ?: return` 挡着——首次布局 refs 未就绪时请求会被丢,比现在的直调更差。
同文件的 pageAnim() 是配置,归 E5。两者根因都是 ChapterProvider 是 object、没有注入口。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5151561 3 tháng trước cách đây
mục cha
commit
daee4e4d2d

+ 2 - 2
app/src/main/java/io/legado/app/model/ReadBook.kt

@@ -456,7 +456,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
                 layoutGeneration = layoutGeneration,
             )
             val textChapter = ChapterProvider.getTextChapterAsync(
-                this, book, chapter, displayTitle, processed, chapters.size,
+                this, book, bookSource, chapter, displayTitle, processed, chapters.size,
             )
             try {
                 var pageCount = 0
@@ -1445,7 +1445,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
             layoutGeneration = pageEstimateGeneration,
         )
         val textChapter = ChapterProvider.getTextChapterAsync(
-            this, book, chapter, displayTitle, contents, simulatedChapterSize
+            this, book, bookSource, chapter, displayTitle, contents, simulatedChapterSize
         ).apply {
             this.pageEstimateGeneration = pageEstimateGeneration
         }

+ 3 - 0
app/src/main/java/io/legado/app/service/HttpReadAloudService.kt

@@ -375,6 +375,9 @@ class HttpReadAloudService : BaseReadAloudService(),
         val textChapter = ChapterProvider.getTextChapterAsync(
             CoroutineScope(currentCoroutineContext()),
             book,
+            // 与本函数其余处一致地跟随 ReadBook 当前会话;这里传的就是改造前排版协程
+            // 自己去读的那个值,行为不变
+            ReadBook.bookSource,
             chapter,
             displayTitle,
             processedContent,

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

@@ -427,7 +427,7 @@ class ReadBookController(
         viewModel.onIntent(ReadBookIntent.SureNewProgress(progress))
     }
 
-    // ── ReaderPageSource(Track D·D2:喂给 ReadView 的只读页数据)────────
+    // ── ReaderPageSource(Track D·D2:喂给渲染层的页数据 + 接下页位置命令)────
 
     override val durChapterIndex: Int get() = ReadBook.durChapterIndex
 
@@ -437,9 +437,22 @@ class ReadBookController(
 
     override val pageAnim: Int get() = ReadBook.pageAnim()
 
+    override val msg: String? get() = ReadBook.msg
+
     override fun textChapter(chapterOnDur: Int): TextChapter? =
         ReadBook.textChapter(chapterOnDur)
 
+    override fun setPageIndex(index: Int) = ReadBook.setPageIndex(index)
+
+    // upContentInPlace = false:取页器在命令返回后自己决定要不要 upContent,别刷两遍
+    override fun moveToNextChapter(upContent: Boolean) {
+        ReadBook.moveToNextChapter(upContent, upContentInPlace = false)
+    }
+
+    override fun moveToPrevChapter(upContent: Boolean) {
+        ReadBook.moveToPrevChapter(upContent, upContentInPlace = false)
+    }
+
     // ── ReaderEventListener(Track D·D1:ReadView 的出站业务意图)─────────
 
     override fun onEvent(event: ReaderEvent) {

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

@@ -60,7 +60,7 @@ class ReadView(
     FrameLayout(context, attrs),
     DataSource, LayoutProgressListener {
 
-    var pageFactory: TextPageFactory = TextPageFactory(this)
+    var pageFactory: TextPageFactory = TextPageFactory(this, pageSource)
     var pageDelegate: PageDelegate? = null
         private set(value) {
             field?.onDestroy()

+ 8 - 1
app/src/main/java/io/legado/app/ui/book/read/page/entities/TextChapter.kt

@@ -5,6 +5,7 @@ import androidx.annotation.Keep
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookContentProcess
+import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.ReplaceRule
 import io.legado.app.help.book.BookContent
 import io.legado.app.ui.book.read.page.provider.LayoutProgressListener
@@ -281,7 +282,12 @@ data class TextChapter(
         }
     }
 
-    fun createLayout(scope: CoroutineScope, book: Book, bookContent: BookContent) {
+    fun createLayout(
+        scope: CoroutineScope,
+        book: Book,
+        bookSource: BookSource?,
+        bookContent: BookContent,
+    ) {
         if (layout != null) {
             throw IllegalStateException("已经排版过了")
         }
@@ -290,6 +296,7 @@ data class TextChapter(
             this,
             textPages,
             book,
+            bookSource,
             bookContent,
         )
         layout = textLayout

+ 8 - 3
app/src/main/java/io/legado/app/ui/book/read/page/entities/column/ImageColumn.kt

@@ -3,8 +3,8 @@ package io.legado.app.ui.book.read.page.entities.column
 import android.graphics.Canvas
 import android.graphics.RectF
 import androidx.annotation.Keep
+import io.legado.app.data.entities.Book
 import io.legado.app.model.ImageProvider
-import io.legado.app.model.ReadBook
 import io.legado.app.ui.book.read.page.ContentTextView
 import io.legado.app.ui.book.read.page.entities.TextLine
 import io.legado.app.ui.book.read.page.entities.TextLine.Companion.emptyTextLine
@@ -20,13 +20,18 @@ data class ImageColumn(
     override var start: Float,
     override var end: Float,
     var src: String,
+    /**
+     * 解析图片缓存路径要用的书,由排版时定下(Track D·D2)。
+     *
+     * 以前是 `draw()` 里读 `ReadBook.book`:换书那一瞬间旧页面重绘会拿**新书**的目录
+     * 去找**旧页**的图。持有创建时那一本才是对的。
+     */
+    val book: Book,
     var click: String? = null
 ) : BaseColumn {
 
     override var textLine: TextLine = emptyTextLine
     override fun draw(view: ContentTextView, canvas: Canvas) {
-        val book = ReadBook.book ?: return
-
         val height = textLine.height
 
         val bitmap = ImageProvider.getImage(

+ 3 - 1
app/src/main/java/io/legado/app/ui/book/read/page/provider/ChapterProvider.kt

@@ -13,6 +13,7 @@ import androidx.core.net.toUri
 import androidx.core.os.postDelayed
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
+import io.legado.app.data.entities.BookSource
 import io.legado.app.help.book.BookContent
 import io.legado.app.domain.gateway.ReadStyleGateway
 import io.legado.app.help.config.ReadBookConfig
@@ -251,6 +252,7 @@ object ChapterProvider {
     fun getTextChapterAsync(
         scope: CoroutineScope,
         book: Book,
+        bookSource: BookSource?,
         bookChapter: BookChapter,
         displayTitle: String,
         bookContent: BookContent,
@@ -267,7 +269,7 @@ object ChapterProvider {
             bookContent.effectiveReplaceRules,
             bookContent.effectiveContentProcesses,
         ).apply {
-            createLayout(scope, book, bookContent)
+            createLayout(scope, book, bookSource, bookContent)
         }
 
         return textChapter

+ 21 - 7
app/src/main/java/io/legado/app/ui/book/read/page/provider/TextChapterLayout.kt

@@ -23,6 +23,7 @@ import io.legado.app.constant.PageAnim
 import io.legado.app.data.appDb
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
+import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.HighlightRule
 import io.legado.app.data.repository.HighlightRuleRepository
 import io.legado.app.help.book.BookContent
@@ -31,7 +32,6 @@ import io.legado.app.help.book.getBookSource
 import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.model.ImageProvider
-import io.legado.app.model.ReadBook
 import io.legado.app.model.analyzeRule.AnalyzeUrl.Companion.paramPattern
 import io.legado.app.ui.book.read.page.entities.TextChapter
 import io.legado.app.ui.book.read.page.entities.TextLine
@@ -71,6 +71,13 @@ class TextChapterLayout(
     private val textChapter: TextChapter,
     private val textPages: ArrayList<TextPage>,
     private val book: Book,
+    /**
+     * 取图要用的书源,由调用方在**建这一章时**定下(Track D·D2)。
+     *
+     * 以前是排版协程运行中读 `ReadBook.bookSource`:换源/换书时一个还在跑的旧章节协程
+     * 会拿**新源**去下**旧书**的图。构造期定参把这个窗口关掉。
+     */
+    private val bookSource: BookSource?,
     private val bookContent: BookContent,
 ) {
 
@@ -429,7 +436,7 @@ class TextChapterLayout(
                         val imgSrc = matcher.group(1)!!
                         var iStyle: String? = null
                         var click: String? = null
-                        var imgSize = ImageProvider.getImageSize(book, imgSrc, ReadBook.bookSource)
+                        var imgSize = ImageProvider.getImageSize(book, imgSrc, bookSource)
                         val urlMatcher = paramPattern.matcher(imgSrc)
                         if (urlMatcher.find()) {
                             var width: String? = null
@@ -625,7 +632,12 @@ class TextChapterLayout(
                 Pair(0f, width)
             }
             textLine.addColumn(
-                ImageColumn(start = absStartX + start.toFloat(), end = absStartX + end.toFloat(), src = src)
+                ImageColumn(
+                    start = absStartX + start.toFloat(),
+                    end = absStartX + end.toFloat(),
+                    src = src,
+                    book = book,
+                )
             )
             calcTextLinePosition(textPages, textLine, stringBuilder.length)
             stringBuilder.append(" ") // 确保翻页时索引计算正确
@@ -720,7 +732,7 @@ class TextChapterLayout(
                             val width = style["width"]
                             val click = style["click"]
                             var imgSize =
-                                ImageProvider.getImageSize(book, source, ReadBook.bookSource)
+                                ImageProvider.getImageSize(book, source, bookSource)
                             width?.let {
                                 if (width.endsWith("%")) {
                                     width.dropLast(1).toIntOrNull()?.let { percentage ->
@@ -744,12 +756,13 @@ class TextChapterLayout(
                             }
                             when (iStyle?.uppercase()) {
                                 "TEXT" -> {
-                                    ImageProvider.cacheImage(book, source, ReadBook.bookSource)
+                                    ImageProvider.cacheImage(book, source, bookSource)
                                     columns.add(
                                         ImageColumn(
                                             start = absStartX + charX,
                                             end = absStartX + charRight,
                                             src = source,
+                                            book = book,
                                             click = click
                                         )
                                     )
@@ -768,7 +781,7 @@ class TextChapterLayout(
                             }
                         } else {
                             val imgSize =
-                                ImageProvider.getImageSize(book, source, ReadBook.bookSource)
+                                ImageProvider.getImageSize(book, source, bookSource)
                             setTypeImage(
                                 book,
                                 source,
@@ -1395,11 +1408,12 @@ class TextChapterLayout(
             !srcList.isNullOrEmpty() && (char == srcReplaceChar || char == reviewChar) -> {
                 val src = srcList.removeFirst()
                 val click = clickList?.removeFirst()
-                ImageProvider.cacheImage(book, src, ReadBook.bookSource)
+                ImageProvider.cacheImage(book, src, bookSource)
                 ImageColumn(
                     start = absStartX + xStart,
                     end = absStartX + xEnd,
                     src = src,
+                    book = book,
                     click = click
                 )
             }

+ 22 - 13
app/src/main/java/io/legado/app/ui/book/read/page/provider/TextPageFactory.kt

@@ -1,13 +1,22 @@
 package io.legado.app.ui.book.read.page.provider
 
 import io.legado.app.R
-import io.legado.app.model.ReadBook
+import io.legado.app.ui.book.read.page.ReaderPageSource
 import io.legado.app.ui.book.read.page.api.DataSource
 import io.legado.app.ui.book.read.page.api.PageFactory
 import io.legado.app.ui.book.read.page.entities.TextPage
 import splitties.init.appCtx
 
-class TextPageFactory(dataSource: DataSource) : PageFactory<TextPage>(dataSource) {
+/**
+ * 分页取页器。
+ *
+ * Track D·D2:页位置命令与加载消息经 [pageSource] 出入,本类不认识 `ReadBook`——
+ * 渲染层不直接向业务单例下达命令。护栏见 `ReadViewBoundaryTest`。
+ */
+class TextPageFactory(
+    dataSource: DataSource,
+    private val pageSource: ReaderPageSource,
+) : PageFactory<TextPage>(dataSource) {
 
     private val keepSwipeTip = appCtx.getString(R.string.keep_swipe_tip)
 
@@ -24,17 +33,17 @@ class TextPageFactory(dataSource: DataSource) : PageFactory<TextPage>(dataSource
     }
 
     override fun moveToFirst() {
-        ReadBook.setPageIndex(0)
+        pageSource.setPageIndex(0)
     }
 
     override fun moveToLast() = with(dataSource) {
         currentChapter?.let {
             if (it.pageSize == 0) {
-                ReadBook.setPageIndex(0)
+                pageSource.setPageIndex(0)
             } else {
-                ReadBook.setPageIndex(it.pageSize.minus(1))
+                pageSource.setPageIndex(it.pageSize.minus(1))
             }
-        } ?: ReadBook.setPageIndex(0)
+        } ?: pageSource.setPageIndex(0)
     }
 
     override fun moveToNext(upContent: Boolean): Boolean = with(dataSource) {
@@ -44,12 +53,12 @@ class TextPageFactory(dataSource: DataSource) : PageFactory<TextPage>(dataSource
                 if ((currentChapter == null || isScroll) && nextChapter == null) {
                     return@with false
                 }
-                ReadBook.moveToNextChapter(upContent, false)
+                pageSource.moveToNextChapter(upContent)
             } else {
                 if (pageIndex < 0 || currentChapter?.isLastIndexCurrent(pageIndex) == true) {
                     return@with false
                 }
-                ReadBook.setPageIndex(pageIndex.plus(1))
+                pageSource.setPageIndex(pageIndex.plus(1))
             }
             if (upContent) upContent(resetPageOffset = false)
             true
@@ -66,12 +75,12 @@ class TextPageFactory(dataSource: DataSource) : PageFactory<TextPage>(dataSource
                 if (prevChapter != null && prevChapter?.isCompleted == false) {
                     return@with false
                 }
-                ReadBook.moveToPrevChapter(upContent, upContentInPlace = false)
+                pageSource.moveToPrevChapter(upContent)
             } else {
                 if (currentChapter == null) {
                     return@with false
                 }
-                ReadBook.setPageIndex(pageIndex.minus(1))
+                pageSource.setPageIndex(pageIndex.minus(1))
             }
             if (upContent) upContent(resetPageOffset = false)
             true
@@ -81,7 +90,7 @@ class TextPageFactory(dataSource: DataSource) : PageFactory<TextPage>(dataSource
 
     override val curPage: TextPage
         get() = with(dataSource) {
-            ReadBook.msg?.let {
+            pageSource.msg?.let {
                 return@with TextPage(text = it).format()
             }
             currentChapter?.let {
@@ -93,7 +102,7 @@ class TextPageFactory(dataSource: DataSource) : PageFactory<TextPage>(dataSource
 
     override val nextPage: TextPage
         get() = with(dataSource) {
-            ReadBook.msg?.let {
+            pageSource.msg?.let {
                 return@with TextPage(text = it).format()
             }
             currentChapter?.let {
@@ -115,7 +124,7 @@ class TextPageFactory(dataSource: DataSource) : PageFactory<TextPage>(dataSource
 
     override val prevPage: TextPage
         get() = with(dataSource) {
-            ReadBook.msg?.let {
+            pageSource.msg?.let {
                 return@with TextPage(text = it).format()
             }
             currentChapter?.let {

+ 74 - 16
app/src/test/java/io/legado/app/ui/book/read/page/ReadViewBoundaryTest.kt

@@ -21,22 +21,7 @@ class ReadViewBoundaryTest {
 
     @Test
     fun `ReadView 不引用业务单例`() {
-        val source = stripComments(readViewSource())
-
-        val readBookViolations = Regex("""\bReadBook\.(\w+)""")
-            .findAll(source)
-            .map { it.groupValues[1] }
-            .distinct()
-            .map { "ReadBook.$it" }
-            .toList()
-
-        val aloudViolations = Regex("""\b(?:ReadAloud|BaseReadAloudService)\.(\w+)""")
-            .findAll(source)
-            .map { it.value }
-            .distinct()
-            .toList()
-
-        val violations = readBookViolations + aloudViolations
+        val violations = businessSingletonViolations(readViewSource())
         assertTrue(
             "ReadView 又直接摸业务单例了:${violations.joinToString()}。\n" +
                 "业务意图请加进 ReaderEvent 由 ReadBookController.onEvent 翻译;\n" +
@@ -45,6 +30,53 @@ class ReadViewBoundaryTest {
         )
     }
 
+    @Test
+    fun `TextPageFactory 不引用业务单例`() {
+        val violations = businessSingletonViolations(
+            mainSourceFile(
+                "io/legado/app/ui/book/read/page/provider/TextPageFactory.kt"
+            ).readText()
+        )
+        assertTrue(
+            "TextPageFactory 又直接摸业务单例了:${violations.joinToString()}。\n" +
+                "取页器是渲染层,不下达业务命令:翻页/换章请经 ReaderPageSource 的 " +
+                "setPageIndex/moveToNextChapter/moveToPrevChapter,加载消息读 pageSource.msg。\n" +
+                "这三个命令是**同步**的(下达后立即取新位置的页并返回布尔给翻页委托)," +
+                "所以不能改走即发即忘的 ReaderEvent。",
+            violations.isEmpty(),
+        )
+    }
+
+    @Test
+    fun `排版器与图片列不在运行期读业务单例`() {
+        // 这两处以前是「排版协程/绘制期去读 ReadBook」,换书换源时会拿新会话的值配旧内容:
+        // 排版协程用新源下旧书的图、旧页面重绘用新书目录找图。现在都由构造期定参钉住。
+        val files = listOf(
+            "io/legado/app/ui/book/read/page/provider/TextChapterLayout.kt",
+            "io/legado/app/ui/book/read/page/entities/column/ImageColumn.kt",
+        )
+        val violations = files.flatMap { path ->
+            businessSingletonViolations(mainSourceFile(path).readText())
+                .map { "${path.substringAfterLast('/')}: $it" }
+        }
+        assertTrue(
+            "又在运行期读业务单例了:${violations.joinToString()}。\n" +
+                "排版/绘制要用的 book、bookSource 请由构造方在**建这一章时**传进来——" +
+                "它们是那一次排版的输入,不是随时可变的全局。",
+            violations.isEmpty(),
+        )
+    }
+
+    @Test
+    fun `pageSource 必须由构造期注入喂给 TextPageFactory`() {
+        val source = stripComments(readViewSource())
+        assertTrue(
+            "TextPageFactory 不再由 ReadView 用构造期注入的 pageSource 建出来。\n" +
+                "若改成让取页器自己找数据源(单例/GlobalContext),D2 的入站边界就绕过去了。",
+            Regex("""TextPageFactory\(\s*this\s*,\s*pageSource\s*\)""").containsMatchIn(source),
+        )
+    }
+
     @Test
     fun `eventListener 必须构造期注入且不可为空`() {
         val source = stripComments(readViewSource())
@@ -159,6 +191,32 @@ class ReadViewBoundaryTest {
         fun readViewSource(): String =
             mainSourceFile("io/legado/app/ui/book/read/page/ReadView.kt").readText()
 
+        /**
+         * 渲染层文件对业务单例的引用。
+         *
+         * 三种写法都算违规,不只是 `ReadBook.xxx` 限定访问——**成员 import 之后成员可以裸写**
+         * (`import …ReadBook.durChapterIndex` 后直接用 `durChapterIndex`),
+         * 局部别名(`val rb = ReadBook`)同理。只按 `ReadBook\.` 找,这两种一个都看不见。
+         */
+        fun businessSingletonViolations(rawSource: String): List<String> {
+            val source = stripComments(rawSource)
+            val singletons = "ReadBook|ReadAloud|BaseReadAloudService"
+
+            val qualified = Regex("""\b($singletons)\.(\w+)""")
+                .findAll(source)
+                .map { "${it.groupValues[1]}.${it.groupValues[2]}" }
+
+            val imports = Regex("""(?m)^\s*import\s+[\w.]*\b($singletons)\b[\w.]*""")
+                .findAll(source)
+                .map { "import: ${it.value.trim()}" }
+
+            val aliases = Regex("""=\s*($singletons)\s*$""", RegexOption.MULTILINE)
+                .findAll(source)
+                .map { "别名: ${it.value.trim()}" }
+
+            return (qualified + imports + aliases).distinct().toList()
+        }
+
         fun stripComments(text: String): String = text
             .replace(Regex("""/\*[\s\S]*?\*/"""), "")
             .replace(Regex("""//[^\n]*"""), "")