Procházet zdrojové kódy

refactor(Track B): B2 渲染子集下沉到 UI 层渲染控制器

指令式渲染不再穿过 ViewModel:

- ReadBook: CallBack 收敛为纯状态子集;新增 renderCallBack:
  ReaderRenderCallback 独立槽位 + registerRender/unregisterRender。
  30+ 处渲染调用点 callBack?.upX() → renderCallBack?.upX()。
- ReadBookController 实现 ReaderRenderCallback,视图就绪(onRefsReady)
  时注册、clearTts 时注销;渲染方法复用既有 handleEffect 分支,零渲染
  逻辑漂移。因 ReadBook 在 Dispatchers.IO 协程内回调(Coroutine.async),
  用 handler.post/withContext(Main) 保留旧 SharedFlow 的异步-切主线程语义。
- 业务状态刷新改由 collectReaderSession() 收集快照驱动:为内容加载路径
  (contentLoadFinish/Await 布局完成、loadOrUpContent、upMsg)补 publishSnapshot(),
  补齐旧 VM 渲染回调顺带做的 syncFromReadBook(尤其 seekMax 依赖 pages.size)。
- isInitFinish 作为纯业务标志留在 VM,由 controller.contentLoadFinish 幂等置位。
- ApplyReadSettingUseCase 外部 upPageAnim 改走 renderCallBack。

编译 + 272 单测 + verifyConfigArchitecture 全绿。渲染实时行为需真机验证
(见 mad 计划的验证边界)。
5151561 před 3 měsíci
rodič
revize
8a61a90a16

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

@@ -109,6 +109,12 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     var book: Book? = null
         private set
     var callBack: CallBack? = null
+
+    /**
+     * 渲染回调槽位(Track B2):由 UI 层渲染控制器实现,承接指令式渲染协议。
+     * 与状态槽位 [callBack] 分离,使指令式渲染不再穿过 ViewModel。
+     */
+    var renderCallBack: ReaderRenderCallback? = null
     var inBookshelf = false
     var chapterSize = 0
     var simulatedChapterSize = 0
@@ -250,9 +256,9 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         durChapterPos = book.durChapterPos
         isLocalBook = book.isLocal
         clearTextChapter()
-        callBack?.upContent()
+        renderCallBack?.upContent()
         callBack?.upMenuView()
-        callBack?.upPageAnim()
+        renderCallBack?.upPageAnim()
         upWebBook(book)
         lastBookProgress = null
         webBookProgress = null
@@ -568,7 +574,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
             publishSnapshot()
             saveRead()
             clearTextChapter()
-            callBack?.upContent()
+            renderCallBack?.upContent()
             loadContent(resetPageOffset = true)
         }
     }
@@ -791,7 +797,9 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     fun upMsg(msg: String?) {
         if (ReadBook.msg != msg) {
             ReadBook.msg = msg
-            callBack?.upContent()
+            renderCallBack?.upContent()
+            // msg 参与 syncFromReadBook;渲染已下沉后靠快照驱动 VM 状态刷新
+            publishSnapshot()
         }
     }
 
@@ -803,8 +811,8 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
                 hasNextPage = true
                 it.getPage(durPageIndex)?.removePageAloudSpan()
                 durChapterPos = nextPagePos
-                callBack?.cancelSelect()
-                callBack?.upContent()
+                renderCallBack?.cancelSelect()
+                renderCallBack?.upContent()
                 saveRead(true)
                 publishSnapshot()
             }
@@ -819,7 +827,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
             if (prevPagePos >= 0) {
                 hasPrevPage = true
                 durChapterPos = prevPagePos
-                callBack?.upContent()
+                renderCallBack?.upContent()
                 saveRead(true)
                 publishSnapshot()
             }
@@ -837,11 +845,11 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
             nextTextChapter = null
             if (curTextChapter == null) {
                 AppLog.putDebug("moveToNextChapter-章节未加载,开始加载")
-                if (upContentInPlace) callBack?.upContent()
+                if (upContentInPlace) renderCallBack?.upContent()
                 loadContent(durChapterIndex, upContent, resetPageOffset = false)
             } else if (upContent && upContentInPlace) {
                 AppLog.putDebug("moveToNextChapter-章节已加载,刷新视图")
-                callBack?.upContent()
+                renderCallBack?.upContent()
             }
             loadContent(durChapterIndex.plus(1), upContent, false)
             saveRead()
@@ -869,11 +877,11 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
             nextTextChapter = null
             if (curTextChapter == null) {
                 AppLog.putDebug("moveToNextChapter-章节未加载,开始加载")
-                if (upContentInPlace) callBack?.upContentAwait()
+                if (upContentInPlace) renderCallBack?.upContentAwait()
                 loadContentAwait(durChapterIndex, upContent, resetPageOffset = false)
             } else if (upContent && upContentInPlace) {
                 AppLog.putDebug("moveToNextChapter-章节已加载,刷新视图")
-                callBack?.upContentAwait()
+                renderCallBack?.upContentAwait()
             }
             loadContent(durChapterIndex.plus(1), upContent, false)
             saveRead()
@@ -901,10 +909,10 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
             curTextChapter = prevTextChapter
             prevTextChapter = null
             if (curTextChapter == null) {
-                if (upContentInPlace) callBack?.upContent()
+                if (upContentInPlace) renderCallBack?.upContent()
                 loadContent(durChapterIndex, upContent, resetPageOffset = false)
             } else if (upContent && upContentInPlace) {
-                callBack?.upContent()
+                renderCallBack?.upContent()
             }
             loadContent(durChapterIndex.minus(1), upContent, false)
             saveRead()
@@ -919,7 +927,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
 
     fun skipToPage(index: Int, success: (() -> Unit)? = null) {
         durChapterPos = curTextChapter?.getReadLength(index) ?: index
-        callBack?.upContent {
+        renderCallBack?.upContent {
             success?.invoke()
         }
         curPageChanged()
@@ -958,7 +966,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     ) {
         if (index < chapterSize) {
             clearTextChapter()
-            if (upContent) callBack?.upContent()
+            if (upContent) renderCallBack?.upContent()
             durChapterIndex = index
             ReadBook.durChapterPos = durChapterPos
             publishSnapshot()
@@ -976,7 +984,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         pageChanged: Boolean = false,
         preserveReadAloudPosition: Boolean = false,
     ) {
-        callBack?.pageChanged()
+        renderCallBack?.pageChanged()
         curTextChapter?.let {
             if (BaseReadAloudService.isRun && it.isCompleted) {
                 if (shouldRestartReadAloudAfterContentLoad(
@@ -1013,7 +1021,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     fun syncReadAloudPage(chapterIndex: Int, chapterPos: Int) {
         if (durChapterIndex != chapterIndex || durChapterPos == chapterPos) return
         durChapterPos = chapterPos
-        callBack?.upContent(resetPageOffset = false)
+        renderCallBack?.upContent(resetPageOffset = false)
         saveRead(pageChanged = true)
         publishSnapshot()
     }
@@ -1124,7 +1132,8 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
                 success?.invoke()
             }
         } else {
-            callBack?.upContent()
+            renderCallBack?.upContent()
+            publishSnapshot()
         }
         val nextChapter = nextTextChapter
         if (nextChapter == null || !nextChapter.isLayoutSizeMatch()) {
@@ -1436,23 +1445,26 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
                     val index = page.index
                     if (!available && page.containPos(durChapterPos)) {
                         if (upContent) {
-                            callBack?.upContent(offset, resetPageOffset)
+                            renderCallBack?.upContent(offset, resetPageOffset)
                         }
                         available = true
                     }
                     if (upContent && isScroll) {
                         if (max(index - 3, 0) < durPageIndex) {
-                            callBack?.upContent(offset, false)
+                            renderCallBack?.upContent(offset, false)
                         }
                     }
-                    callBack?.onLayoutPageCompleted(index, page)
+                    renderCallBack?.onLayoutPageCompleted(index, page)
                 }
                 if (layoutCompleted) correctWholeBookPageCount(textChapter)
-                if (upContent) callBack?.upContent(offset, !available && resetPageOffset)
+                if (upContent) renderCallBack?.upContent(offset, !available && resetPageOffset)
                 curPageChanged(
                     preserveReadAloudPosition = preserveReadAloudPosition,
                 )
-                callBack?.contentLoadFinish()
+                renderCallBack?.contentLoadFinish()
+                // 布局全部完成后发一次:seekMax 依赖 curTextChapter.pages.size,
+                // 渲染下沉后靠快照驱动 VM 的进度/章节状态刷新
+                publishSnapshot()
             }
 
             -1 -> prevChapterLoadingLock.withLock {
@@ -1463,7 +1475,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
                 if (collectLayoutPages(textChapter) {}) {
                     correctWholeBookPageCount(textChapter)
                 }
-                if (upContent) callBack?.upContent(offset, resetPageOffset)
+                if (upContent) renderCallBack?.upContent(offset, resetPageOffset)
             }
 
             1 -> nextChapterLoadingLock.withLock {
@@ -1473,7 +1485,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
                 }
                 val layoutCompleted = collectLayoutPages(textChapter) { page ->
                     if (page.index > 1) return@collectLayoutPages
-                    if (upContent) callBack?.upContent(offset, resetPageOffset)
+                    if (upContent) renderCallBack?.upContent(offset, resetPageOffset)
                 }
                 if (layoutCompleted) correctWholeBookPageCount(textChapter)
             }
@@ -1699,6 +1711,26 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     }
 
 
+    /**
+     * 注册渲染回调(UI 层渲染控制器)。视图就绪后立即同步一次当前内容,
+     * 避免在注册前已完成的内容加载导致首帧漏渲染。
+     */
+    fun registerRender(cb: ReaderRenderCallback) {
+        renderCallBack = cb
+        if (curTextChapter != null) {
+            cb.upContent()
+        }
+    }
+
+    /**
+     * 取消注册渲染回调
+     */
+    fun unregisterRender(cb: ReaderRenderCallback) {
+        if (renderCallBack === cb) {
+            renderCallBack = null
+        }
+    }
+
     /**
      * 取消注册回调
      */
@@ -1749,11 +1781,11 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     /**
      * 业务/UI 状态回调子集:菜单刷新、目录加载、换书通知、进度确认。
      *
-     * 迁移期仍**组合**渲染子集(`: ReaderRenderCallback`),使单一 `callBack`
-     * 槽位行为不变;Track B2 会把渲染子集拆到独立槽位、由渲染控制器承接,
-     * 届时本接口收敛为纯状态子集。
+     * Track B2 起本接口已与渲染子集解耦——不再穿过任何指令式渲染方法。
+     * 渲染走独立的 [renderCallBack](由 UI 层渲染控制器实现),业务状态刷新
+     * 走 [snapshot] → ViewModel 的反应式收集。由 ReadBookViewModel 实现。
      */
-    interface CallBack : ReaderRenderCallback {
+    interface CallBack {
         fun upMenuView()
 
         fun loadChapterList(book: Book)

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

@@ -44,6 +44,7 @@ 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.entities.PageDirection
+import io.legado.app.ui.book.read.page.entities.TextPage
 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
@@ -89,7 +90,8 @@ class ReadBookController(
 ) : ReadBookRouteHost,
     ReadBookInputHandler,
     ReadView.CallBack,
-    ContentTextView.CallBack {
+    ContentTextView.CallBack,
+    ReadBook.ReaderRenderCallback {
 
     var refs: ReadBookViewRefs? = null
 
@@ -160,6 +162,7 @@ class ReadBookController(
     }
 
     fun clearTts() {
+        ReadBook.unregisterRender(this)
         tts?.clearTts()
         tts = null
         dismissTextActionMenu()
@@ -203,6 +206,8 @@ class ReadBookController(
         newRefs.readView.upTime()
         newRefs.readView.upBattery(activity.sysBattery)
         refreshActionMenuItems()
+        // 视图就绪后接管渲染回调;registerRender 会在已有内容时立即同步一次,避免首帧漏渲染
+        ReadBook.registerRender(this)
     }
 
     fun onMenuVisibilityChanged(visible: Boolean) {
@@ -824,6 +829,57 @@ class ReadBookController(
         onMenuActionFinally()
     }
 
+    // ── ReadBook.ReaderRenderCallback(渲染子集,Track B2 从 ViewModel 下沉)──
+    //
+    // ReadBook 在其 IO 协程内调用这些渲染回调(见 contentLoadFinish 的 Coroutine.async
+    // 默认跑在 Dispatchers.IO),而回调最终会触碰 refs.readView。旧路径靠 VM 的
+    // SharedFlow → 生命周期收集器把渲染派发切回主线程;这里用 [postRender] 保留同样的
+    // 异步-切主线程语义,复用既有的 handleEffect 渲染分支,零渲染逻辑漂移。
+
+    private fun postRender(effect: ReadBookEffect) {
+        handler.post { handleEffect(effect) }
+    }
+
+    override fun upContent(
+        relativePosition: Int,
+        resetPageOffset: Boolean,
+        success: (() -> Unit)?
+    ) {
+        postRender(ReadBookEffect.UpContent(relativePosition, resetPageOffset, success))
+    }
+
+    override suspend fun upContentAwait(
+        relativePosition: Int,
+        resetPageOffset: Boolean,
+        success: (() -> Unit)?
+    ) {
+        withContext(Main.immediate) {
+            handleEffect(ReadBookEffect.UpContent(relativePosition, resetPageOffset, success))
+        }
+    }
+
+    override fun pageChanged() {
+        postRender(ReadBookEffect.PageChanged)
+    }
+
+    override fun contentLoadFinish() {
+        // isInitFinish 是纯业务/UI 标志(决定 ReadView 是否放行前后章排版),不属于渲染
+        viewModel.markInitFinished()
+        postRender(ReadBookEffect.ContentLoadFinish)
+    }
+
+    override fun upPageAnim(upRecorder: Boolean) {
+        postRender(ReadBookEffect.UpPageAnim(upRecorder))
+    }
+
+    override fun cancelSelect() {
+        postRender(ReadBookEffect.CancelSelect)
+    }
+
+    override fun onLayoutPageCompleted(index: Int, page: TextPage) {
+        postRender(ReadBookEffect.LayoutPageCompleted(index, page))
+    }
+
     // ── Effect handling ───────────────────────────────────────────────
 
     /**

+ 14 - 55
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -2097,7 +2097,12 @@ class ReadBookViewModel(
         }
     }
 
-    // --- ReadBook.CallBack Implementation ---
+    // --- ReadBook.CallBack Implementation(状态子集)---
+    //
+    // Track B2 起:渲染子集(upContent/upContentAwait/pageChanged/contentLoadFinish/
+    // upPageAnim/cancelSelect/onLayoutPageCompleted)已下沉到 UI 层渲染控制器
+    // (ReadBook.renderCallBack),不再穿过本 ViewModel。业务状态刷新改由
+    // collectReaderSession() 反应式收集 ReadBook.snapshot 驱动。
 
     override fun upMenuView() {
         _uiState.update { syncFromReadBook(it) }
@@ -2108,44 +2113,6 @@ class ReadBookViewModel(
         doLoadChapterList(book)
     }
 
-    override fun upContent(
-        relativePosition: Int,
-        resetPageOffset: Boolean,
-        success: (() -> Unit)?
-    ) {
-        _uiState.update { syncFromReadBook(it) }
-        _effects.tryEmit(
-            ReadBookEffect.UpContent(relativePosition, resetPageOffset, success)
-        )
-    }
-
-    override suspend fun upContentAwait(
-        relativePosition: Int,
-        resetPageOffset: Boolean,
-        success: (() -> Unit)?
-    ) {
-        withContext(Main.immediate) {
-            _uiState.update { syncFromReadBook(it) }
-            _effects.tryEmit(
-                ReadBookEffect.UpContent(relativePosition, resetPageOffset, success)
-            )
-        }
-    }
-
-    override fun pageChanged() {
-        _uiState.update { syncFromReadBook(it) }
-        _effects.tryEmit(ReadBookEffect.PageChanged)
-    }
-
-    override fun contentLoadFinish() {
-        _uiState.update { syncFromReadBook(it).copy(isInitFinish = true) }
-        _effects.tryEmit(ReadBookEffect.ContentLoadFinish)
-    }
-
-    override fun upPageAnim(upRecorder: Boolean) {
-        _effects.tryEmit(ReadBookEffect.UpPageAnim(upRecorder))
-    }
-
     override fun notifyBookChanged() {
         _uiState.update { syncFromReadBook(it) }
         if (!ReadBook.inBookshelf) {
@@ -2159,22 +2126,14 @@ class ReadBookViewModel(
         }
     }
 
-    override fun cancelSelect() {
-        _effects.tryEmit(ReadBookEffect.CancelSelect)
-    }
-
-    // LayoutProgressListener
-    override fun onLayoutPageCompleted(index: Int, page: TextPage) {
-        _uiState.update { syncFromReadBook(it) }
-        _effects.tryEmit(ReadBookEffect.LayoutPageCompleted(index, page))
-    }
-
-    override fun onLayoutCompleted() {
-        _uiState.update { syncFromReadBook(it) }
-    }
-
-    override fun onLayoutException(e: Throwable) {
-        // no-op: ReadView handles this internally
+    /**
+     * 首次内容渲染完成——由渲染控制器(renderCallBack.contentLoadFinish)幂等调用。
+     * `isInitFinish` 是纯业务/UI 标志,不属于渲染,故留在 VM。
+     */
+    fun markInitFinished() {
+        if (!_uiState.value.isInitFinish) {
+            _uiState.update { it.copy(isInitFinish = true) }
+        }
     }
 
     // --- EventBus Bridge ---

+ 2 - 2
app/src/main/java/io/legado/app/ui/config/readConfig/ApplyReadSettingUseCase.kt

@@ -35,7 +35,7 @@ class ApplyReadSettingUseCase {
                 postEvent(EventBus.UP_CONFIG, arrayListOf(4))
             }
 
-            is ReadConfigIntent.NoAnimScrollPageChanged -> ReadBook.callBack?.upPageAnim()
+            is ReadConfigIntent.NoAnimScrollPageChanged -> ReadBook.renderCallBack?.upPageAnim()
             is ReadConfigIntent.OptimizeRenderChanged -> updateStyle()
             else -> Unit
         }
@@ -48,7 +48,7 @@ class ApplyReadSettingUseCase {
 
     private fun updateStyle() {
         ChapterProvider.upStyle()
-        ReadBook.callBack?.upPageAnim(true)
+        ReadBook.renderCallBack?.upPageAnim(true)
         ReadBook.loadContent(false)
     }
 }