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

fix(reader): 开书不再闪一帧加载数据中

Squash merge PR #1873
5151561 3 месяцев назад
Родитель
Сommit
3a3980097b

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

@@ -16,6 +16,7 @@ import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.ContentProcessor
 import io.legado.app.help.book.isImage
 import io.legado.app.help.book.isLocal
+import io.legado.app.help.book.isLocalModified
 import io.legado.app.help.book.isLocalTxt
 import io.legado.app.help.book.isPdf
 import io.legado.app.help.book.isSameNameAuthor
@@ -26,6 +27,7 @@ import io.legado.app.help.config.AppConfig
 import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.help.globalExecutor
+import io.legado.app.model.localBook.LocalBook
 import io.legado.app.model.localBook.TextFile
 import io.legado.app.model.translation.TranslationChapterState
 import io.legado.app.model.translation.TranslationChapterStatus
@@ -59,6 +61,7 @@ import kotlinx.coroutines.MainScope
 import kotlinx.coroutines.SupervisorJob
 import kotlinx.coroutines.TimeoutCancellationException
 import kotlinx.coroutines.cancelChildren
+import kotlinx.coroutines.currentCoroutineContext
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.ensureActive
 import kotlinx.coroutines.flow.MutableStateFlow
@@ -90,8 +93,11 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     var durChapterPos = 0
     var isLocalBook = true
     var chapterChanged = false
+    @Volatile
     var prevTextChapter: TextChapter? = null
+    @Volatile
     var curTextChapter: TextChapter? = null
+    @Volatile
     var nextTextChapter: TextChapter? = null
     var bookSource: BookSource? = null
     var msg: String? = null
@@ -200,7 +206,8 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
             durChapterPos = book.durChapterPos
             clearTextChapter()
         }
-        if (curTextChapter?.isCompleted == false) {
+        // 还在排版中的当前章不能丢: 丢了会重新加载一遍, 反而多闪一次占位页
+        if (curTextChapter?.isCompleted == false && curTextChapter?.isLayoutRunning != true) {
             curTextChapter = null
         }
         if (nextTextChapter?.isCompleted == false) {
@@ -929,6 +936,36 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         return chapter
     }
 
+    /**
+     * 导航到阅读页时提前加载, 让加载与导航动画/组合并行.
+     * 阅读页真正初始化时若章节已排版完成, 首帧直接显示正文, 不再闪占位页.
+     * 任何一个前提不满足就直接返回, 由阅读页按原有流程加载.
+     */
+    fun prefetchForOpen(bookUrl: String) {
+        if (callBack != null) return
+        if (BaseReadAloudService.isRun) return
+        if (ChapterProvider.visibleWidth <= 0 || ChapterProvider.visibleHeight <= 0) return
+        if (book?.bookUrl == bookUrl && curTextChapter?.isCompleted == true) return
+        ioScope.launch {
+            val book = appDb.bookDao.getBook(bookUrl) ?: return@launch
+            // 需要联网补目录、本地文件缺失或已改动的, 都交给阅读页的完整初始化流程
+            if (!book.isLocal && book.tocUrl.isEmpty()) return@launch
+            if (book.isLocal) {
+                if (book.isLocalModified()) return@launch
+                val readable = runCatching {
+                    LocalBook.getBookInputStream(book).close()
+                }.isSuccess
+                if (!readable) return@launch
+            }
+            if (appDb.bookChapterDao.getChapterCount(bookUrl) == 0) return@launch
+            if (callBack != null) return@launch
+            resetData(book)
+            // 不能走 loadContent: Coroutine.async 会先派发到主线程,
+            // 而导航动画和阅读页组合正把主线程占满, 预加载会排到几百毫秒之后.
+            loadContentAwait(durChapterIndex, resetPageOffset = true)
+        }
+    }
+
     /**
      * 加载当前章节和前后一章内容
      * @param resetPageOffset 滚动阅读是否重置滚动位置
@@ -1274,8 +1311,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         }
         when (val offset = chapter.index - durChapterIndex) {
             0 -> curChapterLoadingLock.withLock {
-                withContext(Main) {
-                    ensureActive()
+                publishTextChapter(textChapter) {
                     curTextChapter?.cancelLayout()
                     curTextChapter = textChapter
                 }
@@ -1305,8 +1341,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
             }
 
             -1 -> prevChapterLoadingLock.withLock {
-                withContext(Main) {
-                    ensureActive()
+                publishTextChapter(textChapter) {
                     prevTextChapter?.cancelLayout()
                     prevTextChapter = textChapter
                 }
@@ -1317,8 +1352,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
             }
 
             1 -> nextChapterLoadingLock.withLock {
-                withContext(Main) {
-                    ensureActive()
+                publishTextChapter(textChapter) {
                     nextTextChapter?.cancelLayout()
                     nextTextChapter = textChapter
                 }
@@ -1333,6 +1367,25 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         }
     }
 
+    /**
+     * 发布排版好的章节.
+     * 阅读页正在显示时切到主线程赋值, 保持原有的线程约束, 不与正在进行的帧交错;
+     * 阅读页还没显示时(导航期间的预加载)直接赋值: 此时主线程正被导航动画和
+     * 阅读页组合占满, 等一次主线程派发要几百毫秒, 预加载就白做了.
+     * 三个章节字段都是 @Volatile, 直接赋值对随后显示的阅读页可见.
+     */
+    private suspend fun publishTextChapter(textChapter: TextChapter, assign: () -> Unit) {
+        if (!isUiActive) {
+            currentCoroutineContext().ensureActive()
+            assign()
+        } else {
+            withContext(Main) {
+                currentCoroutineContext().ensureActive()
+                assign()
+            }
+        }
+    }
+
     /**
      * Safely collect pages from a TextChapterLayout's channel with timeout protection.
      * Prevents indefinite blocking if the layout job hangs without closing the channel.

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

@@ -313,7 +313,10 @@ class ReadBookController(
 
     // ── ReadView.CallBack ─────────────────────────────────────────────
 
-    override val isInitFinish: Boolean get() = viewModel.uiState.value.isInitFinish
+    // initData 还没跑完时, 只要 ReadBook 里已有本书可用的章节就让 ReadView 直接画正文,
+    // 否则首帧必然是 "加载数据中" 占位页
+    override val isInitFinish: Boolean
+        get() = viewModel.uiState.value.isInitFinish || viewModel.isCachedChapterUsable()
 
     override fun showActionMenu() {
         val state = viewModel.uiState.value

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

@@ -255,6 +255,32 @@ class ReadBookViewModel(
 
     val isInitFinish: Boolean get() = _uiState.value.isInitFinish
 
+    private var cachedChapterBookUrl: String? = null
+
+    /**
+     * ReadView 在 Compose 组合期就构造并画第一帧, 早于 initData 完成,
+     * 那时 isInitFinish 还是 false, 于是先闪一帧 "加载数据中".
+     * 记下本路由要打开的书, 供 [isCachedChapterUsable] 在绘制时判定.
+     */
+    fun prepareCachedChapterFallback(bookUrl: String?, chapterChanged: Boolean) {
+        cachedChapterBookUrl = bookUrl?.takeUnless { it.isEmpty() || chapterChanged }
+    }
+
+    /**
+     * ReadBook 里已经有本书可直接用的章节(重新进入, 或导航期间预加载排好的).
+     * 在绘制时判定而不是组合期一次性判定: 预加载的发布可能比组合晚几十毫秒.
+     */
+    fun isCachedChapterUsable(): Boolean {
+        val bookUrl = cachedChapterBookUrl ?: return false
+        if (ReadBook.book?.bookUrl != bookUrl) return false
+        if (ReadBook.msg != null) return false
+        val chapter = ReadBook.curTextChapter ?: return false
+        if (!chapter.isLayoutSizeMatch()) return false
+        // 长章节在导航窗口内排不完整章, 只要当前页已经排出来就够画第一帧了
+        return chapter.isCompleted ||
+                (chapter.isLayoutRunning && chapter.getPage(ReadBook.durPageIndex) != null)
+    }
+
     private fun isNightTheme(): Boolean =
         appUiConfigurationGateway.currentConfiguration.isDarkTheme
 

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

@@ -641,6 +641,11 @@ class ReadView(
                 // scenario runner (uiautomator does not expose ReadView on all
                 // devices). See tools/android/runner.py.
                 Log.i("LegadoDebug", "READER_PAGE " + text.replace("\n", " "))
+                if (text == context.getString(R.string.data_loading)) {
+                    // Locale-independent signal for the placeholder frame; the
+                    // scenario runner counts these. See tools/android/runner.py.
+                    Log.i("LegadoDebug", "READER_PAGE_PLACEHOLDER_FRAME")
+                }
             }
         }
         if (isScroll && !isAutoPage) {

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

@@ -297,6 +297,13 @@ data class TextChapter(
         visibleHeight = textLayout.visibleHeight
     }
 
+    /**
+     * 排版还在进行中: 已经创建了排版任务, 既没有排完也没有出错/被取消.
+     * 用来区分"还在排"和"排到一半就废了", 前者可以继续用已排出的页.
+     */
+    val isLayoutRunning: Boolean
+        get() = layout?.let { !isCompleted && it.exception == null && !it.isCanceled } ?: false
+
     fun setProgressListener(l: LayoutProgressListener?) {
         if (isCompleted) {
             // no op

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

@@ -206,7 +206,12 @@ class TextChapterLayout(
         }
     }
 
+    @Volatile
+    var isCanceled = false
+        private set
+
     fun cancel() {
+        isCanceled = true
         job.cancel()
         listener = null
     }

+ 4 - 0
app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt

@@ -411,6 +411,10 @@ fun MainActivity.mainEntryProvider(
         val controller = remember(readBookViewModel) {
             ReadBookController(this@mainEntryProvider, readBookViewModel)
         }
+        // ReadView 在首次组合时就会画一帧, 必须在它之前告诉 ViewModel 本路由要打开哪本书
+        remember(readBookViewModel, route) {
+            readBookViewModel.prepareCachedChapterFallback(route.bookUrl, route.chapterChanged)
+        }
         val lifecycleOwner = LocalLifecycleOwner.current
         val readIntent = remember(route) {
             MainActivity.createReadBookIntent(

+ 6 - 0
app/src/main/java/io/legado/app/ui/main/MainNavigator.kt

@@ -3,6 +3,7 @@ package io.legado.app.ui.main
 import android.app.Activity
 import android.content.Intent
 import androidx.navigation3.runtime.NavKey
+import io.legado.app.model.ReadBook
 import io.legado.app.ui.rss.article.MainRouteRssSort
 import io.legado.app.ui.rss.read.MainRouteRssRead
 import kotlinx.coroutines.CoroutineScope
@@ -23,6 +24,11 @@ object MainNavigator {
         val currentRoute = backStack.lastOrNull()
         if (currentRoute == route) return
 
+        // 导航动画和阅读页组合要花几百毫秒, 这段时间足够把正文读出来并排版好
+        if (route is MainRouteReadBook && !route.chapterChanged) {
+            route.bookUrl?.let { ReadBook.prefetchForOpen(it) }
+        }
+
         when (route) {
             MainRouteHome -> {
                 backStack.clear()