HapeLee 1 месяц назад
Родитель
Сommit
9eb6f2301b

+ 47 - 8
app/src/main/java/io/legado/app/feature/reader/core/layout/ReaderChapterBlockMeasurer.kt

@@ -86,6 +86,42 @@ sealed interface ReaderChapterMeasureResult {
     data class Unsupported(val reason: String) : ReaderChapterMeasureResult
     data class Unsupported(val reason: String) : ReaderChapterMeasureResult
 }
 }
 
 
+/** Optional clock supplied by the platform while profiling; core pagination stays platform-free. */
+class ReaderChapterMeasureMetrics(private val nanoTime: () -> Long) {
+    var shapingNs: Long = 0
+        private set
+    var styleLookupNs: Long = 0
+        private set
+    var shapingCalls: Long = 0
+        private set
+    var styleLookups: Long = 0
+        private set
+
+    fun shape(shaper: ReaderTextShaper, text: String): GlyphClusters {
+        val start = nanoTime()
+        return try {
+            shaper.shape(text)
+        } finally {
+            shapingNs += nanoTime() - start
+            shapingCalls++
+        }
+    }
+
+    fun resolveStyle(
+        ranges: List<ReaderStyleRange>,
+        position: Int,
+        isTitle: Boolean
+    ): ReaderCharacterStyle? {
+        val start = nanoTime()
+        return try {
+            ReaderCharacterStyleResolver.resolve(ranges, position, isTitle)
+        } finally {
+            styleLookupNs += nanoTime() - start
+            styleLookups++
+        }
+    }
+}
+
 class ReaderChapterBlockMeasurer(
 class ReaderChapterBlockMeasurer(
     bodyShaper: ReaderTextShaper,
     bodyShaper: ReaderTextShaper,
     titleShaper: ReaderTextShaper,
     titleShaper: ReaderTextShaper,
@@ -93,6 +129,7 @@ class ReaderChapterBlockMeasurer(
     private val textShaperFactory: ReaderTextShaperFactory = ReaderTextShaperFactory { bodyShaper },
     private val textShaperFactory: ReaderTextShaperFactory = ReaderTextShaperFactory { bodyShaper },
     private val htmlSourceResolver: ReaderHtmlSourceResolver = ReaderHtmlSourceResolver { _, _ -> null },
     private val htmlSourceResolver: ReaderHtmlSourceResolver = ReaderHtmlSourceResolver { _, _ -> null },
     private val imageOptionsResolver: ReaderImageOptionsResolver = ReaderImageOptionsResolver { null },
     private val imageOptionsResolver: ReaderImageOptionsResolver = ReaderImageOptionsResolver { null },
+    private val metrics: ReaderChapterMeasureMetrics? = null,
 ) {
 ) {
     /**
     /**
      * 测量整章。给出 [onBlock] 时每产出一个 block 就立即回调——旧 View
      * 测量整章。给出 [onBlock] 时每产出一个 block 就立即回调——旧 View
@@ -117,7 +154,9 @@ class ReaderChapterBlockMeasurer(
         }
         }
         val bodyIndentText = style.bodyIndentText ?: " ".repeat(style.bodyIndentCharacters.coerceAtLeast(0))
         val bodyIndentText = style.bodyIndentText ?: " ".repeat(style.bodyIndentCharacters.coerceAtLeast(0))
         val bodyIndentWidth by lazy {
         val bodyIndentWidth by lazy {
-            val shaped = shaper(style.bodyStyle).shape(bodyIndentText)
+            val bodyShaper = shaper(style.bodyStyle)
+            val shaped =
+                metrics?.shape(bodyShaper, bodyIndentText) ?: bodyShaper.shape(bodyIndentText)
             shaped.widthsPx.sum() + (style.letterSpacingEm ?: 0f) * style.bodyStyle.fontSizePx * shaped.text.size
             shaped.widthsPx.sum() + (style.letterSpacingEm ?: 0f) * style.bodyStyle.fontSizePx * shaped.text.size
         }
         }
         suspend fun addStyledParagraph(
         suspend fun addStyledParagraph(
@@ -201,18 +240,17 @@ class ReaderChapterBlockMeasurer(
                 when (item) {
                 when (item) {
                     is ReaderChapterInlineSource.Text -> {
                     is ReaderChapterInlineSource.Text -> {
                         val htmlStyle = baseStyle.merge(item.style)
                         val htmlStyle = baseStyle.merge(item.style)
-                        val initiallyShaped = shaper(htmlStyle).shape(item.value)
+                        val initialShaper = shaper(htmlStyle)
+                        val initiallyShaped = metrics?.shape(initialShaper, item.value)
+                            ?: initialShaper.shape(item.value)
                         var offset = 0
                         var offset = 0
                         initiallyShaped.text.forEachIndexed { clusterIndex, cluster ->
                         initiallyShaped.text.forEachIndexed { clusterIndex, cluster ->
                             val position = item.chapterPosition + offset
                             val position = item.chapterPosition + offset
                             val rangeStyle = style.styleRanges
                             val rangeStyle = style.styleRanges
                                 .takeIf(List<ReaderStyleRange>::isNotEmpty)
                                 .takeIf(List<ReaderStyleRange>::isNotEmpty)
                                 ?.let {
                                 ?.let {
-                                    ReaderCharacterStyleResolver.resolve(
-                                        it,
-                                        position,
-                                        isTitle
-                                    )
+                                    if (metrics != null) metrics.resolveStyle(it, position, isTitle)
+                                    else ReaderCharacterStyleResolver.resolve(it, position, isTitle)
                                 }
                                 }
                             val textStyle = htmlStyle.merge(rangeStyle)
                             val textStyle = htmlStyle.merge(rangeStyle)
                             val textShaper = shaper(textStyle)
                             val textShaper = shaper(textStyle)
@@ -222,7 +260,8 @@ class ReaderChapterBlockMeasurer(
                             val width = if (textStyle == htmlStyle) {
                             val width = if (textStyle == htmlStyle) {
                                 initiallyShaped.widthsPx.getOrElse(clusterIndex) { 0f }
                                 initiallyShaped.widthsPx.getOrElse(clusterIndex) { 0f }
                             } else {
                             } else {
-                                textShaper.shape(cluster).widthsPx.firstOrNull() ?: 0f
+                                (metrics?.shape(textShaper, cluster) ?: textShaper.shape(cluster))
+                                    .widthsPx.firstOrNull() ?: 0f
                             }
                             }
                             // The paragraph already owns the base line box (including the special
                             // The paragraph already owns the base line box (including the special
                             // subtitle bounds). Style overrides and baseline-shift spans need
                             // subtitle bounds). Style overrides and baseline-shift spans need

+ 17 - 6
app/src/main/java/io/legado/app/feature/reader/core/style/ReaderCharacterStyle.kt

@@ -1,7 +1,7 @@
 package io.legado.app.feature.reader.core.style
 package io.legado.app.feature.reader.core.style
 
 
-import io.legado.app.feature.reader.core.model.ReaderUnderline
 import io.legado.app.feature.reader.core.model.ReaderTextBackgroundImage
 import io.legado.app.feature.reader.core.model.ReaderTextBackgroundImage
+import io.legado.app.feature.reader.core.model.ReaderUnderline
 
 
 enum class ReaderStyleTarget { ALL, TITLE, BODY }
 enum class ReaderStyleTarget { ALL, TITLE, BODY }
 
 
@@ -33,9 +33,20 @@ data class ReaderStyleRange(
 }
 }
 
 
 object ReaderCharacterStyleResolver {
 object ReaderCharacterStyleResolver {
-    fun resolve(ranges: List<ReaderStyleRange>, position: Int, isTitle: Boolean): ReaderCharacterStyle? =
-        ranges.withIndex().asSequence()
-            .filter { it.value.contains(position, isTitle) }
-            .maxWithOrNull(compareBy<IndexedValue<ReaderStyleRange>> { it.value.priority }.thenBy { it.index })
-            ?.value?.style
+    fun resolve(
+        ranges: List<ReaderStyleRange>,
+        position: Int,
+        isTitle: Boolean
+    ): ReaderCharacterStyle? {
+        var winner: ReaderStyleRange? = null
+        for (range in ranges) {
+            if (range.contains(position, isTitle) &&
+                (winner == null || range.priority >= winner.priority)
+            ) {
+                // Equal priority keeps the later range, matching the original index tie-break.
+                winner = range
+            }
+        }
+        return winner?.style
+    }
 }
 }

+ 48 - 1
app/src/main/java/io/legado/app/feature/reader/legacy/LegacyReaderChapterPaginator.kt

@@ -5,6 +5,7 @@ import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.HighlightRule
 import io.legado.app.data.entities.HighlightRule
 import io.legado.app.feature.reader.core.layout.ReaderChapterBlockMeasurer
 import io.legado.app.feature.reader.core.layout.ReaderChapterBlockMeasurer
+import io.legado.app.feature.reader.core.layout.ReaderChapterMeasureMetrics
 import io.legado.app.feature.reader.core.layout.ReaderChapterMeasureResult
 import io.legado.app.feature.reader.core.layout.ReaderChapterMeasureResult
 import io.legado.app.feature.reader.core.layout.ReaderChapterMeasureStyle
 import io.legado.app.feature.reader.core.layout.ReaderChapterMeasureStyle
 import io.legado.app.feature.reader.core.layout.ReaderImageDimensions
 import io.legado.app.feature.reader.core.layout.ReaderImageDimensions
@@ -122,6 +123,9 @@ object LegacyReaderChapterPaginator {
         if (viewportWidthPx <= 0 || viewportHeightPx <= 0) {
         if (viewportWidthPx <= 0 || viewportHeightPx <= 0) {
             return LegacyReaderChapterPaginationResult.Unsupported("viewport")
             return LegacyReaderChapterPaginationResult.Unsupported("viewport")
         }
         }
+        val tracing = ReaderPerfTrace.isEnabled()
+        val preparationStartNs = if (tracing) System.nanoTime() else 0L
+        val measureMetrics = if (tracing) ReaderChapterMeasureMetrics(System::nanoTime) else null
         val imageLayoutMode = when (book.getImageStyle()?.uppercase()) {
         val imageLayoutMode = when (book.getImageStyle()?.uppercase()) {
             Book.imgStyleText -> ReaderImageLayoutMode.INLINE
             Book.imgStyleText -> ReaderImageLayoutMode.INLINE
             Book.imgStyleFull -> ReaderImageLayoutMode.FULL_WIDTH
             Book.imgStyleFull -> ReaderImageLayoutMode.FULL_WIDTH
@@ -164,6 +168,7 @@ object LegacyReaderChapterPaginator {
                 density = appCtx.resources.displayMetrics.density,
                 density = appCtx.resources.displayMetrics.density,
             ),
             ),
             imageOptionsResolver = LegacyReaderImageOptionsResolver,
             imageOptionsResolver = LegacyReaderImageOptionsResolver,
+            metrics = measureMetrics,
         )
         )
         // 分页会话先于测量建立:块一到就推进排版游标,页成型即经 [onPage] 流出
         // 分页会话先于测量建立:块一到就推进排版游标,页成型即经 [onPage] 流出
         // (旧 View `TextChapterLayout` 也是边排版边 `channel.trySend`)。
         // (旧 View `TextChapterLayout` 也是边排版边 `channel.trySend`)。
@@ -205,6 +210,16 @@ object LegacyReaderChapterPaginator {
         )
         )
         val paginationSession = ReaderPaginationSession(paginationConfig)
         val paginationSession = ReaderPaginationSession(paginationConfig)
         paginationSession.onPage = onPage
         paginationSession.onPage = onPage
+        if (tracing) {
+            ReaderPerfTrace.counter(
+                "pagination.prepare.us",
+                (System.nanoTime() - preparationStartNs) / 1_000
+            )
+            ReaderPerfTrace.counter("pagination.style-ranges", styleRanges.size.toLong())
+        }
+        var acceptNs = 0L
+        var acceptedBlocks = 0L
+        val measureStartNs = if (tracing) System.nanoTime() else 0L
         val measured = ReaderPerfTrace.section("pagination.measure") {
         val measured = ReaderPerfTrace.section("pagination.measure") {
             measurer.measure(
             measurer.measure(
             layoutSource,
             layoutSource,
@@ -238,8 +253,40 @@ object LegacyReaderChapterPaginator {
                 letterSpacingEm = bodyPaint.letterSpacing,
                 letterSpacingEm = bodyPaint.letterSpacing,
                 styleRanges = styleRanges,
                 styleRanges = styleRanges,
             ),
             ),
-                onBlock = { block -> paginationSession.accept(block) },
+                onBlock = { block ->
+                    if (tracing) {
+                        val startNs = System.nanoTime()
+                        try {
+                            paginationSession.accept(block)
+                        } finally {
+                            acceptNs += System.nanoTime() - startNs
+                            acceptedBlocks++
+                        }
+                    } else {
+                        paginationSession.accept(block)
+                    }
+                },
+            )
+        }
+        if (tracing) {
+            val measureNs = System.nanoTime() - measureStartNs
+            ReaderPerfTrace.counter("pagination.chapter-index", chapter.index.toLong())
+            ReaderPerfTrace.counter("pagination.accept.us", acceptNs / 1_000)
+            ReaderPerfTrace.counter(
+                "pagination.measure-other.us",
+                (measureNs - acceptNs).coerceAtLeast(0) / 1_000
+            )
+            ReaderPerfTrace.counter("pagination.blocks", acceptedBlocks)
+            ReaderPerfTrace.counter(
+                "pagination.shape.us",
+                measureMetrics?.shapingNs?.div(1_000) ?: 0
+            )
+            ReaderPerfTrace.counter(
+                "pagination.style-lookup.us",
+                measureMetrics?.styleLookupNs?.div(1_000) ?: 0
             )
             )
+            ReaderPerfTrace.counter("pagination.shape-calls", measureMetrics?.shapingCalls ?: 0)
+            ReaderPerfTrace.counter("pagination.style-lookups", measureMetrics?.styleLookups ?: 0)
         }
         }
         if (measured is ReaderChapterMeasureResult.Unsupported) {
         if (measured is ReaderChapterMeasureResult.Unsupported) {
             // 测量失败说明这一章的页不可信:已流出的部分页由调用方按 Unsupported 丢弃。
             // 测量失败说明这一章的页不可信:已流出的部分页由调用方按 Unsupported 丢弃。

+ 7 - 0
app/src/main/java/io/legado/app/feature/reader/platform/ReaderPerfTrace.kt

@@ -34,4 +34,11 @@ internal object ReaderPerfTrace {
         Trace.beginSection("reader.$name")
         Trace.beginSection("reader.$name")
         Trace.endSection()
         Trace.endSection()
     }
     }
+
+    fun isEnabled(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && Trace.isEnabled()
+
+    /** Aggregate costs from an interleaved operation without tracing every paragraph. */
+    fun counter(name: String, value: Long) {
+        if (isEnabled()) Trace.setCounter("reader.$name", value)
+    }
 }
 }

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

@@ -22,6 +22,7 @@ import io.legado.app.feature.reader.legacy.LegacyReaderChapterPaginator
 import io.legado.app.feature.reader.legacy.LegacyReaderPageDecorationFactory
 import io.legado.app.feature.reader.legacy.LegacyReaderPageDecorationFactory
 import io.legado.app.feature.reader.platform.AndroidReaderHtmlSemanticTextResolver
 import io.legado.app.feature.reader.platform.AndroidReaderHtmlSemanticTextResolver
 import io.legado.app.feature.reader.platform.ReaderAndroidPaginationStyle
 import io.legado.app.feature.reader.platform.ReaderAndroidPaginationStyle
+import io.legado.app.feature.reader.platform.ReaderPerfTrace
 import io.legado.app.help.AppWebDav
 import io.legado.app.help.AppWebDav
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.ContentProcessor
 import io.legado.app.help.book.ContentProcessor
@@ -1292,7 +1293,9 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         if (BaseReadAloudService.isRun) return
         if (BaseReadAloudService.isRun) return
         if (book?.bookUrl == bookUrl && readerChapterInputWindow.current != null) return
         if (book?.bookUrl == bookUrl && readerChapterInputWindow.current != null) return
         ioScope.launch {
         ioScope.launch {
-            val book = appDb.bookDao.getBook(bookUrl) ?: return@launch
+            val book = ReaderPerfTrace.suspendSection("prefetch.book-lookup") {
+                appDb.bookDao.getBook(bookUrl)
+            } ?: return@launch
             // 需要联网补目录、本地文件缺失或已改动的, 都交给阅读页的完整初始化流程
             // 需要联网补目录、本地文件缺失或已改动的, 都交给阅读页的完整初始化流程
             if (!book.isLocal && book.tocUrl.isEmpty()) return@launch
             if (!book.isLocal && book.tocUrl.isEmpty()) return@launch
             if (book.isLocal) {
             if (book.isLocal) {
@@ -1302,12 +1305,17 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
                 }.isSuccess
                 }.isSuccess
                 if (!readable) return@launch
                 if (!readable) return@launch
             }
             }
-            if (appDb.bookChapterDao.getChapterCount(bookUrl) == 0) return@launch
+            val chapterCount = ReaderPerfTrace.suspendSection("prefetch.chapter-check") {
+                appDb.bookChapterDao.getChapterCount(bookUrl)
+            }
+            if (chapterCount == 0) return@launch
             if (callBack != null) return@launch
             if (callBack != null) return@launch
-            resetData(book)
+            ReaderPerfTrace.section("prefetch.reset") { resetData(book) }
             // 不能走 loadContent: Coroutine.async 会先派发到主线程,
             // 不能走 loadContent: Coroutine.async 会先派发到主线程,
             // 而导航动画和阅读页组合正把主线程占满, 预加载会排到几百毫秒之后.
             // 而导航动画和阅读页组合正把主线程占满, 预加载会排到几百毫秒之后.
-            loadContentAwait(durChapterIndex, resetPageOffset = true)
+            ReaderPerfTrace.suspendSection("prefetch.content") {
+                loadContentAwait(durChapterIndex, resetPageOffset = true)
+            }
         }
         }
     }
     }
 
 
@@ -1449,19 +1457,26 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         if (addLoading(index)) {
         if (addLoading(index)) {
             try {
             try {
                 val book = book!!
                 val book = book!!
-                val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, index)!!
-                val content = if (book.getTranslationMode()) {
-                    TranslationManager.getCachedTranslation(book, chapter)
-                        ?: run {
-                            TranslationManager.startTranslation(book, chapter)?.let { taskFlow ->
-                                startTranslationObserver(taskFlow, book, chapter)
+                val chapter = ReaderPerfTrace.suspendSection("content.chapter-lookup") {
+                    appDb.bookChapterDao.getChapter(book.bookUrl, index)
+                }!!
+                val content = ReaderPerfTrace.suspendSection("content.resolve") {
+                    if (book.getTranslationMode()) {
+                        TranslationManager.getCachedTranslation(book, chapter)
+                            ?: run {
+                                TranslationManager.startTranslation(book, chapter)
+                                    ?.let { taskFlow ->
+                                        startTranslationObserver(taskFlow, book, chapter)
+                                    }
+                                BookHelp.getContent(book, chapter) ?: downloadAwait(chapter)
                             }
                             }
-                            BookHelp.getContent(book, chapter) ?: downloadAwait(chapter)
-                        }
-                } else {
-                    BookHelp.getContent(book, chapter) ?: downloadAwait(chapter)
+                    } else {
+                        BookHelp.getContent(book, chapter) ?: downloadAwait(chapter)
+                    }
+                }
+                ReaderPerfTrace.suspendSection("content.layout-and-publish") {
+                    contentLoadFinishAwait(book, chapter, content, upContent, resetPageOffset)
                 }
                 }
-                contentLoadFinishAwait(book, chapter, content, upContent, resetPageOffset)
                 success?.invoke()
                 success?.invoke()
             } catch (e: Exception) {
             } catch (e: Exception) {
                 AppLog.put("加载正文出错\n${e.localizedMessage}")
                 AppLog.put("加载正文出错\n${e.localizedMessage}")
@@ -1646,8 +1661,9 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
             book.getUseReplaceRule(otherSettingsGateway.currentSettings.replaceEnableDefault),
             book.getUseReplaceRule(otherSettingsGateway.currentSettings.replaceEnableDefault),
             chineseConverterType = readSettingsGateway.currentSettings.chineseConverterType,
             chineseConverterType = readSettingsGateway.currentSettings.chineseConverterType,
         )
         )
-        val contents = contentProcessor
-            .getContent(book, chapter, content, includeTitle = false)
+        val contents = ReaderPerfTrace.section("content.process") {
+            contentProcessor.getContent(book, chapter, content, includeTitle = false)
+        }
         ensureActive()
         ensureActive()
         wholeBookPageCoordinator.updateChapterContent(
         wholeBookPageCoordinator.updateChapterContent(
             chapterIndex = chapter.index,
             chapterIndex = chapter.index,
@@ -1662,14 +1678,16 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
             },
             },
             layoutGeneration = pageEstimateGeneration,
             layoutGeneration = pageEstimateGeneration,
         )
         )
-        val readerSource = ReaderChapterSourceParser.parse(
-            chapterIndex = chapter.index,
-            title = displayTitle,
-            paragraphs = contents.textList,
-            includeTitle = ReadBookConfig.titleMode != 2 || chapter.isVolume || contents.textList.isEmpty(),
-            adaptSpecialStyle = readSettingsGateway.currentSettings.adaptSpecialStyle,
-            htmlSemanticTextResolver = AndroidReaderHtmlSemanticTextResolver,
-        )
+        val readerSource = ReaderPerfTrace.section("content.source-parse") {
+            ReaderChapterSourceParser.parse(
+                chapterIndex = chapter.index,
+                title = displayTitle,
+                paragraphs = contents.textList,
+                includeTitle = ReadBookConfig.titleMode != 2 || chapter.isVolume || contents.textList.isEmpty(),
+                adaptSpecialStyle = readSettingsGateway.currentSettings.adaptSpecialStyle,
+                htmlSemanticTextResolver = AndroidReaderHtmlSemanticTextResolver,
+            )
+        }
         val readerChapterInput = ReaderChapterInput(
         val readerChapterInput = ReaderChapterInput(
             book = book,
             book = book,
             bookSource = bookSource,
             bookSource = bookSource,

+ 2 - 1
app/src/main/java/io/legado/app/ui/book/audio/AudioPlayScreen.kt

@@ -114,6 +114,7 @@ fun AudioPlayScreenContent(
     state: AudioPlayUiState,
     state: AudioPlayUiState,
     onIntent: (AudioPlayIntent) -> Unit,
     onIntent: (AudioPlayIntent) -> Unit,
     onBack: () -> Unit,
     onBack: () -> Unit,
+    modifier: Modifier = Modifier,
 ) {
 ) {
     val horizontalPagerState = rememberPagerState(
     val horizontalPagerState = rememberPagerState(
         initialPage = 1,
         initialPage = 1,
@@ -133,7 +134,7 @@ fun AudioPlayScreenContent(
         bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 216.dp,
         bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 216.dp,
     )
     )
     AppScaffold(
     AppScaffold(
-        modifier = Modifier.fillMaxSize(),
+        modifier = modifier.fillMaxSize(),
         alwaysDrawBehindBars = true,
         alwaysDrawBehindBars = true,
         disableContentSampling = true,
         disableContentSampling = true,
         contentWindowInsets = WindowInsets(0),
         contentWindowInsets = WindowInsets(0),

+ 20 - 0
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderRouteScreen.kt

@@ -4,11 +4,16 @@ import android.view.KeyEvent
 import android.view.WindowManager
 import android.view.WindowManager
 import androidx.activity.compose.LocalActivity
 import androidx.activity.compose.LocalActivity
 import androidx.activity.compose.rememberLauncherForActivityResult
 import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.compose.animation.AnimatedVisibilityScope
+import androidx.compose.animation.ExperimentalSharedTransitionApi
+import androidx.compose.animation.SharedTransitionScope
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.DisposableEffect
 import androidx.compose.runtime.DisposableEffect
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalDensity
 import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.LifecycleEventObserver
 import androidx.lifecycle.LifecycleEventObserver
 import androidx.lifecycle.compose.LocalLifecycleOwner
 import androidx.lifecycle.compose.LocalLifecycleOwner
@@ -20,13 +25,16 @@ import io.legado.app.receiver.NetworkChangedListener
 import io.legado.app.ui.book.read.sheet.ReaderBookSheetRoute
 import io.legado.app.ui.book.read.sheet.ReaderBookSheetRoute
 import io.legado.app.ui.book.read.sheet.ReaderBookSheetTab
 import io.legado.app.ui.book.read.sheet.ReaderBookSheetTab
 import io.legado.app.ui.book.toc.TocActivityResult
 import io.legado.app.ui.book.toc.TocActivityResult
+import io.legado.app.ui.main.AndroidPlatformCapabilities
 import io.legado.app.ui.main.MainActivity
 import io.legado.app.ui.main.MainActivity
+import io.legado.app.ui.main.readerSharedBounds
 import io.legado.app.utils.NetworkUtils
 import io.legado.app.utils.NetworkUtils
 import io.legado.app.utils.openUrl
 import io.legado.app.utils.openUrl
 import io.legado.app.utils.share
 import io.legado.app.utils.share
 import io.legado.app.utils.toggleSystemBar
 import io.legado.app.utils.toggleSystemBar
 import kotlinx.coroutines.flow.collectLatest
 import kotlinx.coroutines.flow.collectLatest
 
 
+@OptIn(ExperimentalSharedTransitionApi::class)
 @Composable
 @Composable
 fun MangaReaderRouteScreen(
 fun MangaReaderRouteScreen(
     bookUrl: String?,
     bookUrl: String?,
@@ -35,6 +43,9 @@ fun MangaReaderRouteScreen(
     openRequestId: Long,
     openRequestId: Long,
     viewModel: MangaReaderViewModel,
     viewModel: MangaReaderViewModel,
     restoreSystemBarsVisible: Boolean,
     restoreSystemBarsVisible: Boolean,
+    sharedTransitionScope: SharedTransitionScope? = null,
+    animatedVisibilityScope: AnimatedVisibilityScope? = null,
+    sharedCoverKey: String? = null,
     onFinish: (bookshelfChanged: Boolean) -> Unit,
     onFinish: (bookshelfChanged: Boolean) -> Unit,
     onOpenBookInfo: (name: String, author: String, bookUrl: String) -> Unit,
     onOpenBookInfo: (name: String, author: String, bookUrl: String) -> Unit,
     onOpenSourceLogin: (sourceUrl: String) -> Unit,
     onOpenSourceLogin: (sourceUrl: String) -> Unit,
@@ -48,6 +59,8 @@ fun MangaReaderRouteScreen(
     ) -> Unit,
     ) -> Unit,
 ) {
 ) {
     val activity = LocalActivity.current as MainActivity
     val activity = LocalActivity.current as MainActivity
+    val density = LocalDensity.current.density
+    val platformCapabilities = remember(activity) { AndroidPlatformCapabilities(activity) }
     val lifecycleOwner = LocalLifecycleOwner.current
     val lifecycleOwner = LocalLifecycleOwner.current
     val state by viewModel.uiState.collectAsStateWithLifecycle()
     val state by viewModel.uiState.collectAsStateWithLifecycle()
     val networkChangedListener = remember(activity) { NetworkChangedListener(activity) }
     val networkChangedListener = remember(activity) { NetworkChangedListener(activity) }
@@ -204,6 +217,13 @@ fun MangaReaderRouteScreen(
         state = state,
         state = state,
         onIntent = viewModel::onIntent,
         onIntent = viewModel::onIntent,
         hazeState = if (useMenuHaze) menuHazeState else null,
         hazeState = if (useMenuHaze) menuHazeState else null,
+        modifier = Modifier.readerSharedBounds(
+            sharedTransitionScope,
+            animatedVisibilityScope,
+            sharedCoverKey,
+            platformCapabilities.displayCornerRadiusPx,
+            density,
+        ),
     )
     )
     if (state.activeSheet == MangaReaderSheet.Catalog && state.bookUrl.isNotEmpty()) {
     if (state.activeSheet == MangaReaderSheet.Catalog && state.bookUrl.isNotEmpty()) {
         ReaderBookSheetRoute(
         ReaderBookSheetRoute(

+ 11 - 3
app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt

@@ -662,6 +662,7 @@ class ReadBookController(
         density: Float,
         density: Float,
         contentPadding: ReaderPadding,
         contentPadding: ReaderPadding,
     ) {
     ) {
+        ReaderPerfTrace.marker("viewport.received")
         val viewport = ReaderViewport(
         val viewport = ReaderViewport(
             widthPx = widthPx,
             widthPx = widthPx,
             heightPx = heightPx,
             heightPx = heightPx,
@@ -694,6 +695,7 @@ class ReadBookController(
             paginationStyle = viewportPaginationStyle,
             paginationStyle = viewportPaginationStyle,
             paginationEnvironmentPublished = viewportChanged,
             paginationEnvironmentPublished = viewportChanged,
         )
         )
+        ReaderPerfTrace.marker("viewport.published")
     }
     }
 
 
     private fun publishReaderPageWindow(
     private fun publishReaderPageWindow(
@@ -1043,12 +1045,14 @@ class ReadBookController(
     }
     }
 
 
     override fun readerChapterInputChanged() {
     override fun readerChapterInputChanged() {
+        ReaderPerfTrace.marker("input.changed")
         // Current/previous/next chapter inputs are published independently during opening.
         // Current/previous/next chapter inputs are published independently during opening.
         // Coalesce that short burst so an arriving adjacent chapter does not repeatedly cancel
         // Coalesce that short burst so an arriving adjacent chapter does not repeatedly cancel
         // the expensive current-chapter measurement before its first page can be committed.
         // the expensive current-chapter measurement before its first page can be committed.
         readerChapterInputPublishJob?.cancel()
         readerChapterInputPublishJob?.cancel()
         readerChapterInputPublishJob = activity.lifecycleScope.launch {
         readerChapterInputPublishJob = activity.lifecycleScope.launch {
             delay(80)
             delay(80)
+            ReaderPerfTrace.marker("input.coalesced")
             pendingSearchNavigation?.let { navigation ->
             pendingSearchNavigation?.let { navigation ->
                 ReadBook.readerChapterInputWindow.current
                 ReadBook.readerChapterInputWindow.current
                     ?.takeIf { it.chapter.index == navigation.result.chapterIndex }
                     ?.takeIf { it.chapter.index == navigation.result.chapterIndex }
@@ -1409,14 +1413,17 @@ class ReadBookController(
         // 旧任务作废:它流出一半的页可能是按旧正文/旧几何排的,必须先撤掉,否则新任务排出的
         // 旧任务作废:它流出一半的页可能是按旧正文/旧几何排的,必须先撤掉,否则新任务排出的
         // 同 id 页会被"已经存在"挡掉,页表里反而留下旧内容的那几页。
         // 同 id 页会被"已经存在"挡掉,页表里反而留下旧内容的那几页。
         clearStreamedReaderChapter(chapterIndex)
         clearStreamedReaderChapter(chapterIndex)
+        // This method is called on Main. Register the stream before launching the IO job so
+        // its first page does not wait for a Main dispatcher round trip after rule loading.
+        beginStreamedReaderChapter(chapterIndex, streamGeneration)
         // 先登记、后启动:任务收尾要判断"表里的还是不是自己",若先启动,快速跑完的任务会在
         // 先登记、后启动:任务收尾要判断"表里的还是不是自己",若先启动,快速跑完的任务会在
         // 主线程登记之前就把自己摘掉,随后又被登记回去,留下一个永不清理的残留条目。
         // 主线程登记之前就把自己摘掉,随后又被登记回去,留下一个永不清理的残留条目。
         val job = activity.lifecycleScope.launch(IO, start = CoroutineStart.LAZY) {
         val job = activity.lifecycleScope.launch(IO, start = CoroutineStart.LAZY) {
-            val highlightRules =
+            ReaderPerfTrace.marker("pagination.job-start")
+            val highlightRules = ReaderPerfTrace.suspendSection("pagination.highlight-rules") {
                 HighlightRuleRepository().loadEnabled(ReadBookConfig.durConfig.name)
                 HighlightRuleRepository().loadEnabled(ReadBookConfig.durConfig.name)
-            withContext(Main) {
-                beginStreamedReaderChapter(chapterIndex, streamGeneration)
             }
             }
+            ReaderPerfTrace.marker("pagination.stream-ready")
             val result = ReaderPerfTrace.suspendSection("pagination.chapter") {
             val result = ReaderPerfTrace.suspendSection("pagination.chapter") {
                 paginateLegacyReaderChapterSafely {
                 paginateLegacyReaderChapterSafely {
                     LegacyReaderChapterPaginator.paginate(
                     LegacyReaderChapterPaginator.paginate(
@@ -1460,6 +1467,7 @@ class ReadBookController(
             }
             }
         }
         }
         readerChapterPaginationJobs[chapterIndex] = ReaderChapterPaginationTask(identity, job)
         readerChapterPaginationJobs[chapterIndex] = ReaderChapterPaginationTask(identity, job)
+        ReaderPerfTrace.marker("pagination.scheduled")
         job.start()
         job.start()
     }
     }
 
 

+ 14 - 63
app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt

@@ -19,7 +19,6 @@ import androidx.compose.animation.AnimatedVisibilityScope
 import androidx.compose.animation.EnterExitState
 import androidx.compose.animation.EnterExitState
 import androidx.compose.animation.ExperimentalSharedTransitionApi
 import androidx.compose.animation.ExperimentalSharedTransitionApi
 import androidx.compose.animation.SharedTransitionScope
 import androidx.compose.animation.SharedTransitionScope
-import androidx.compose.animation.core.animateFloat
 import androidx.compose.animation.core.tween
 import androidx.compose.animation.core.tween
 import androidx.compose.animation.fadeIn
 import androidx.compose.animation.fadeIn
 import androidx.compose.animation.fadeOut
 import androidx.compose.animation.fadeOut
@@ -29,7 +28,6 @@ import androidx.compose.foundation.background
 import androidx.compose.foundation.clickable
 import androidx.compose.foundation.clickable
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.material3.SnackbarDuration
 import androidx.compose.material3.SnackbarDuration
 import androidx.compose.material3.SnackbarHost
 import androidx.compose.material3.SnackbarHost
 import androidx.compose.material3.SnackbarHostState
 import androidx.compose.material3.SnackbarHostState
@@ -54,8 +52,6 @@ import androidx.compose.ui.platform.LocalView
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.semantics.Role
 import androidx.compose.ui.semantics.Role
 import androidx.compose.ui.text.style.TextAlign
 import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.unit.Dp
-import androidx.compose.ui.unit.dp
 import androidx.core.view.ViewCompat
 import androidx.core.view.ViewCompat
 import androidx.core.view.WindowInsetsCompat
 import androidx.core.view.WindowInsetsCompat
 import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.Lifecycle
@@ -78,6 +74,7 @@ import io.legado.app.feature.reader.core.model.readerBackgroundAlpha
 import io.legado.app.feature.reader.core.transition.ReaderPageTurnSpeed
 import io.legado.app.feature.reader.core.transition.ReaderPageTurnSpeed
 import io.legado.app.feature.reader.core.transition.ReaderTransitionMode
 import io.legado.app.feature.reader.core.transition.ReaderTransitionMode
 import io.legado.app.feature.reader.core.transition.ReaderViewportLayerPolicy
 import io.legado.app.feature.reader.core.transition.ReaderViewportLayerPolicy
+import io.legado.app.feature.reader.platform.ReaderPerfTrace
 import io.legado.app.help.IntentHelp
 import io.legado.app.help.IntentHelp
 import io.legado.app.model.ReadBook
 import io.legado.app.model.ReadBook
 import io.legado.app.model.SourceCallBack
 import io.legado.app.model.SourceCallBack
@@ -92,11 +89,11 @@ import io.legado.app.ui.book.toc.TocActivityResult
 import io.legado.app.ui.login.SourceLoginType
 import io.legado.app.ui.login.SourceLoginType
 import io.legado.app.ui.main.AndroidPlatformCapabilities
 import io.legado.app.ui.main.AndroidPlatformCapabilities
 import io.legado.app.ui.main.MainActivity
 import io.legado.app.ui.main.MainActivity
+import io.legado.app.ui.main.readerSharedBounds
 import io.legado.app.ui.replace.ReplaceEditRoute
 import io.legado.app.ui.replace.ReplaceEditRoute
 import io.legado.app.ui.replace.ReplaceRuleActivity
 import io.legado.app.ui.replace.ReplaceRuleActivity
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LocalAppUiConfiguration
 import io.legado.app.ui.theme.LocalAppUiConfiguration
-import io.legado.app.ui.widget.components.image.cover.sharedCoverSourceRadius
 import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.utils.StartActivityContract
 import io.legado.app.utils.StartActivityContract
 import io.legado.app.utils.takePersistablePermissionSafely
 import io.legado.app.utils.takePersistablePermissionSafely
@@ -646,13 +643,11 @@ fun ReadBookRouteScreen(
     // 真正的排版失败仍要让位给可重试的错误态,不能留着过期正文。
     // 真正的排版失败仍要让位给可重试的错误态,不能留着过期正文。
     val hasReadablePage = displayedReaderPageWindow?.current != null &&
     val hasReadablePage = displayedReaderPageWindow?.current != null &&
             readerPaginationError == null
             readerPaginationError == null
-    var readerContentRevealAllowed by remember(sharedCoverKey) {
-        mutableStateOf(sharedCoverKey == null || animatedVisibilityScope == null)
-    }
-    LaunchedEffect(sharedCoverKey, animatedVisibilityScope) {
-        if (!readerContentRevealAllowed) {
-            delay(240)
-            readerContentRevealAllowed = true
+    var firstReadablePageTraced by remember { mutableStateOf(false) }
+    LaunchedEffect(hasReadablePage) {
+        if (hasReadablePage && !firstReadablePageTraced) {
+            firstReadablePageTraced = true
+            ReaderPerfTrace.marker("surface.page-ready")
         }
         }
     }
     }
     // 阅读页 sharedBounds 的裁剪圆角动画:从封面源圆角渐变到设备屏幕圆角,
     // 阅读页 sharedBounds 的裁剪圆角动画:从封面源圆角渐变到设备屏幕圆角,
@@ -660,35 +655,15 @@ fun ReadBookRouteScreen(
     val platformCapabilities = remember(controller) { AndroidPlatformCapabilities(controller.activity) }
     val platformCapabilities = remember(controller) { AndroidPlatformCapabilities(controller.activity) }
     val displayConfiguration = LocalConfiguration.current
     val displayConfiguration = LocalConfiguration.current
     val displayCornerRadiusPx = remember(displayConfiguration) { platformCapabilities.displayCornerRadiusPx }
     val displayCornerRadiusPx = remember(displayConfiguration) { platformCapabilities.displayCornerRadiusPx }
-    val readerClipRadiusDp = rememberReaderSharedClipRadiusDp(
-        sharedCoverKey = sharedCoverKey,
-        animatedVisibilityScope = animatedVisibilityScope,
-        targetRadiusPx = displayCornerRadiusPx,
-        density = density,
-    )
     Box(
     Box(
         Modifier
         Modifier
             .fillMaxSize()
             .fillMaxSize()
-            .then(
-                with(sharedTransitionScope) {
-                    if (this != null &&
-                        animatedVisibilityScope != null &&
-                        sharedCoverKey != null &&
-                        readerClipRadiusDp != null
-                    ) {
-                        Modifier.sharedBounds(
-                            sharedContentState = rememberSharedContentState(sharedCoverKey),
-                            animatedVisibilityScope = animatedVisibilityScope,
-                            enter = fadeIn(animationSpec = tween(600)),
-                            exit = fadeOut(animationSpec = tween(600)),
-                            clipInOverlayDuringTransition = OverlayClip(
-                                RoundedCornerShape(readerClipRadiusDp)
-                            ),
-                        )
-                    } else {
-                        Modifier
-                    }
-                }
+            .readerSharedBounds(
+                sharedTransitionScope = sharedTransitionScope,
+                animatedVisibilityScope = animatedVisibilityScope,
+                sharedCoverKey = sharedCoverKey,
+                displayCornerRadiusPx = displayCornerRadiusPx,
+                density = density,
             )
             )
             .background(readerSurfaceColor)
             .background(readerSurfaceColor)
     ) {
     ) {
@@ -707,7 +682,7 @@ fun ReadBookRouteScreen(
             // 滚动模式的背景由画布内的固定层绘制(画布还要当菜单 haze 的源),根层再画一遍
             // 滚动模式的背景由画布内的固定层绘制(画布还要当菜单 haze 的源),根层再画一遍
             // 会让半透明背景图叠加两次、比设置值更浓,且与分页模式(页面自绘不透明底色挡住
             // 会让半透明背景图叠加两次、比设置值更浓,且与分页模式(页面自绘不透明底色挡住
             // 根层,实际只画一次)观感不一致。画布可见时让出根层,其它状态仍由根层兜底。
             // 根层,实际只画一次)观感不一致。画布可见时让出根层,其它状态仍由根层兜底。
-            val readerCanvasVisible = readerContentRevealAllowed && hasReadablePage
+            val readerCanvasVisible = hasReadablePage
             val readerTransitionMode = ReaderTransitionMode.fromPageAnim(controller.pageAnim)
             val readerTransitionMode = ReaderTransitionMode.fromPageAnim(controller.pageAnim)
             if (!(ReaderViewportLayerPolicy.usesFixedBackground(readerTransitionMode) &&
             if (!(ReaderViewportLayerPolicy.usesFixedBackground(readerTransitionMode) &&
                         readerCanvasVisible)
                         readerCanvasVisible)
@@ -998,30 +973,6 @@ private fun sampleReaderSystemBarInsets(
     )
     )
 }
 }
 
 
-/**
- * 阅读页 sharedBounds 转场期的裁剪圆角:起点 = 封面在源页面的圆角
- * (sharedCoverSourceRadius,与封面端动画同源),终点 = 设备屏幕圆角;
- * 非转场返回 null,不参与裁剪。镜像 CoilBookCover.rememberSharedCoverTransitionRadius。
- */
-@OptIn(ExperimentalSharedTransitionApi::class)
-@Composable
-private fun rememberReaderSharedClipRadiusDp(
-    sharedCoverKey: String?,
-    animatedVisibilityScope: AnimatedVisibilityScope?,
-    targetRadiusPx: Float,
-    density: Float,
-): Dp? {
-    if (sharedCoverKey == null || animatedVisibilityScope == null) return null
-    val targetRadius = (targetRadiusPx / density).dp
-    val startRadius = sharedCoverSourceRadius(sharedCoverKey) ?: targetRadius
-    val animatedRadius by animatedVisibilityScope.transition.animateFloat(
-        label = "reader-clip-corner-radius",
-    ) { state ->
-        if (state == EnterExitState.Visible) targetRadius.value else startRadius.value
-    }
-    return animatedRadius.dp
-}
-
 @Composable
 @Composable
 private fun ReaderTextSelectionOverlay(
 private fun ReaderTextSelectionOverlay(
     controller: ReadBookController,
     controller: ReadBookController,

+ 54 - 20
app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt

@@ -26,7 +26,9 @@ import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.saveable.rememberSaveable
 import androidx.compose.runtime.saveable.rememberSaveable
 import androidx.compose.runtime.setValue
 import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
 import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.LifecycleEventObserver
 import androidx.lifecycle.LifecycleEventObserver
 import androidx.lifecycle.compose.LocalLifecycleOwner
 import androidx.lifecycle.compose.LocalLifecycleOwner
@@ -219,6 +221,21 @@ private fun webViewEntryMetadata(predictiveBackEnabled: Boolean) = metadata {
     }
     }
 }
 }
 
 
+/** Full-screen book destinations share the same scene fade as the text reader. */
+private fun readerEntryMetadata(predictiveBackEnabled: Boolean) = metadata {
+    put(NavDisplay.TransitionKey) {
+        fadeIn(animationSpec = tween(600)) togetherWith fadeOut(animationSpec = tween(600))
+    }
+    put(NavDisplay.PopTransitionKey) {
+        fadeIn(animationSpec = tween(600)) togetherWith fadeOut(animationSpec = tween(600))
+    }
+    if (predictiveBackEnabled) {
+        put(NavDisplay.PredictivePopTransitionKey) { _ ->
+            fadeIn(animationSpec = tween(600)) togetherWith fadeOut(animationSpec = tween(600))
+        }
+    }
+}
+
 /**
 /**
  * 听书播放页的转场:整页纵向位移,无淡入淡出。
  * 听书播放页的转场:整页纵向位移,无淡入淡出。
  *
  *
@@ -445,9 +462,19 @@ fun MainActivity.mainEntryProvider(
             },
             },
             onOpenBookshelfBook = { book, sharedCoverKey ->
             onOpenBookshelfBook = { book, sharedCoverKey ->
                 if (book.isAudio) {
                 if (book.isAudio) {
-                    this@mainEntryProvider.startActivityForBook(book)
+                    onNavigateToRoute(
+                        MainRouteAudioPlay(
+                            bookUrl = book.bookUrl,
+                            sharedCoverKey = sharedCoverKey
+                        )
+                    )
                 } else if (!book.isLocal && book.isImage && showMangaUi) {
                 } else if (!book.isLocal && book.isImage && showMangaUi) {
-                    onNavigateToRoute(MainRouteReadManga(bookUrl = book.bookUrl))
+                    onNavigateToRoute(
+                        MainRouteReadManga(
+                            bookUrl = book.bookUrl,
+                            sharedCoverKey = sharedCoverKey
+                        )
+                    )
                 } else {
                 } else {
                     onNavigateToRoute(
                     onNavigateToRoute(
                         MainRouteReadBook(
                         MainRouteReadBook(
@@ -716,22 +743,7 @@ fun MainActivity.mainEntryProvider(
     }
     }
 
 
     entry<MainRouteReadBook>(
     entry<MainRouteReadBook>(
-        metadata = metadata {
-            put(NavDisplay.TransitionKey) {
-                fadeIn(animationSpec = tween(600)) togetherWith
-                        fadeOut(animationSpec = tween(600))
-            }
-            put(NavDisplay.PopTransitionKey) {
-                fadeIn(animationSpec = tween(600)) togetherWith
-                        fadeOut(animationSpec = tween(600))
-            }
-            if (configuration.appShell.predictiveBackEnabled) {
-                put(NavDisplay.PredictivePopTransitionKey) { _ ->
-                    fadeIn(animationSpec = tween(600)) togetherWith
-                            fadeOut(animationSpec = tween(600))
-                }
-            }
-        }
+        metadata = readerEntryMetadata(configuration.appShell.predictiveBackEnabled)
     ) { route ->
     ) { route ->
         // 私密闸门:阅读记录 / 通知栏 / 深链等直开阅读器的路径全都在这里被拦一次,
         // 私密闸门:阅读记录 / 通知栏 / 深链等直开阅读器的路径全都在这里被拦一次,
         // 未授权时阅读器根本不会被组合(也就没有加载正文的机会)
         // 未授权时阅读器根本不会被组合(也就没有加载正文的机会)
@@ -899,7 +911,9 @@ fun MainActivity.mainEntryProvider(
         }
         }
     }
     }
 
 
-    entry<MainRouteReadManga> { route ->
+    entry<MainRouteReadManga>(
+        metadata = readerEntryMetadata(configuration.appShell.predictiveBackEnabled)
+    ) { route ->
         val mangaViewModel = koinViewModel<MangaReaderViewModel>(
         val mangaViewModel = koinViewModel<MangaReaderViewModel>(
             key = "ReadManga:${route.bookUrl ?: "last-read"}",
             key = "ReadManga:${route.bookUrl ?: "last-read"}",
         )
         )
@@ -910,6 +924,9 @@ fun MainActivity.mainEntryProvider(
             openRequestId = route.openRequestId,
             openRequestId = route.openRequestId,
             viewModel = mangaViewModel,
             viewModel = mangaViewModel,
             restoreSystemBarsVisible = configuration.appShell.showStatusBar,
             restoreSystemBarsVisible = configuration.appShell.showStatusBar,
+            sharedTransitionScope = sharedTransitionScope,
+            animatedVisibilityScope = LocalNavAnimatedContentScope.current,
+            sharedCoverKey = route.sharedCoverKey,
             onFinish = { onNavigateBack() },
             onFinish = { onNavigateBack() },
             onOpenBookInfo = { name, author, bookUrl ->
             onOpenBookInfo = { name, author, bookUrl ->
                 onNavigateToRoute(MainRouteBookInfo(name, author, bookUrl))
                 onNavigateToRoute(MainRouteBookInfo(name, author, bookUrl))
@@ -928,11 +945,17 @@ fun MainActivity.mainEntryProvider(
         )
         )
     }
     }
 
 
-    entry<MainRouteAudioPlay> { route ->
+    entry<MainRouteAudioPlay>(
+        metadata = readerEntryMetadata(configuration.appShell.predictiveBackEnabled)
+    ) { route ->
         val audioPlayViewModel = koinViewModel<AudioPlayViewModel>(
         val audioPlayViewModel = koinViewModel<AudioPlayViewModel>(
             key = "AudioPlay:${route.bookUrl}",
             key = "AudioPlay:${route.bookUrl}",
         )
         )
         val lifecycleOwner = LocalLifecycleOwner.current
         val lifecycleOwner = LocalLifecycleOwner.current
+        val density = LocalDensity.current.density
+        val platformCapabilities = remember(this@mainEntryProvider) {
+            AndroidPlatformCapabilities(this@mainEntryProvider)
+        }
         val uiState by audioPlayViewModel.uiState.collectAsStateWithLifecycle()
         val uiState by audioPlayViewModel.uiState.collectAsStateWithLifecycle()
         var showAudioChangeSource by remember { mutableStateOf(false) }
         var showAudioChangeSource by remember { mutableStateOf(false) }
         val imageLoader: ImageLoader = koinInject()
         val imageLoader: ImageLoader = koinInject()
@@ -1046,6 +1069,13 @@ fun MainActivity.mainEntryProvider(
                 state = uiState,
                 state = uiState,
                 onIntent = audioPlayViewModel::onIntent,
                 onIntent = audioPlayViewModel::onIntent,
                 onBack = { audioPlayViewModel.onIntent(AudioPlayIntent.BackPressed) },
                 onBack = { audioPlayViewModel.onIntent(AudioPlayIntent.BackPressed) },
+                modifier = Modifier.readerSharedBounds(
+                    sharedTransitionScope,
+                    LocalNavAnimatedContentScope.current,
+                    route.sharedCoverKey,
+                    platformCapabilities.displayCornerRadiusPx,
+                    density,
+                ),
             )
             )
         }
         }
         val audioBook = AudioPlay.book
         val audioBook = AudioPlay.book
@@ -1294,6 +1324,8 @@ fun MainActivity.mainEntryProvider(
                         inBookshelf = inBookshelf,
                         inBookshelf = inBookshelf,
                         chapterChanged = chapterChanged,
                         chapterChanged = chapterChanged,
                         openRequestId = System.nanoTime(),
                         openRequestId = System.nanoTime(),
+                        sharedCoverKey = route.sharedCoverKey
+                            ?: bookCoverSharedElementKey(route.bookUrl),
                     )
                     )
                 )
                 )
             },
             },
@@ -1302,6 +1334,8 @@ fun MainActivity.mainEntryProvider(
                     MainRouteAudioPlay(
                     MainRouteAudioPlay(
                         bookUrl = bookUrl,
                         bookUrl = bookUrl,
                         inBookshelf = inBookshelf,
                         inBookshelf = inBookshelf,
+                        sharedCoverKey = route.sharedCoverKey
+                            ?: bookCoverSharedElementKey(route.bookUrl),
                     )
                     )
                 )
                 )
             },
             },

+ 2 - 0
app/src/main/java/io/legado/app/ui/main/MainNavKey.kt

@@ -145,12 +145,14 @@ data class MainRouteReadManga(
     val chapterChanged: Boolean = false,
     val chapterChanged: Boolean = false,
     /** Distinguishes repeated open requests for the same book after an external TOC selection. */
     /** Distinguishes repeated open requests for the same book after an external TOC selection. */
     val openRequestId: Long = 0L,
     val openRequestId: Long = 0L,
+    val sharedCoverKey: String? = null,
 ) : MainRoute
 ) : MainRoute
 
 
 @Serializable
 @Serializable
 data class MainRouteAudioPlay(
 data class MainRouteAudioPlay(
     val bookUrl: String? = null,
     val bookUrl: String? = null,
     val inBookshelf: Boolean = true,
     val inBookshelf: Boolean = true,
+    val sharedCoverKey: String? = null,
 ) : MainRoute
 ) : MainRoute
 
 
 /**
 /**

+ 2 - 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.app.Activity
 import android.content.Intent
 import android.content.Intent
 import androidx.navigation3.runtime.NavKey
 import androidx.navigation3.runtime.NavKey
+import io.legado.app.feature.reader.platform.ReaderPerfTrace
 import io.legado.app.model.ReadBook
 import io.legado.app.model.ReadBook
 import io.legado.app.ui.rss.article.MainRouteRssSort
 import io.legado.app.ui.rss.article.MainRouteRssSort
 import io.legado.app.ui.rss.read.MainRouteRssRead
 import io.legado.app.ui.rss.read.MainRouteRssRead
@@ -67,6 +68,7 @@ object MainNavigator {
             }
             }
         }
         }
 
 
+        if (route is MainRouteReadBook) ReaderPerfTrace.marker("open.request")
         // 导航动画和阅读页组合要花几百毫秒, 这段时间足够把正文读出来并排版好
         // 导航动画和阅读页组合要花几百毫秒, 这段时间足够把正文读出来并排版好
         if (route is MainRouteReadBook && !route.chapterChanged) {
         if (route is MainRouteReadBook && !route.chapterChanged) {
             route.bookUrl?.let { ReadBook.prefetchForOpen(it) }
             route.bookUrl?.let { ReadBook.prefetchForOpen(it) }

+ 47 - 0
app/src/main/java/io/legado/app/ui/main/ReaderSharedBounds.kt

@@ -0,0 +1,47 @@
+package io.legado.app.ui.main
+
+import androidx.compose.animation.AnimatedVisibilityScope
+import androidx.compose.animation.EnterExitState
+import androidx.compose.animation.ExperimentalSharedTransitionApi
+import androidx.compose.animation.SharedTransitionScope
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import io.legado.app.ui.widget.components.image.cover.sharedCoverSourceRadius
+
+/** Connects a book cover to a full-screen reading destination, including the overlay corners. */
+@OptIn(ExperimentalSharedTransitionApi::class)
+@Composable
+fun Modifier.readerSharedBounds(
+    sharedTransitionScope: SharedTransitionScope?,
+    animatedVisibilityScope: AnimatedVisibilityScope?,
+    sharedCoverKey: String?,
+    displayCornerRadiusPx: Float,
+    density: Float,
+): Modifier {
+    if (sharedTransitionScope == null || animatedVisibilityScope == null || sharedCoverKey == null) {
+        return this
+    }
+    val targetRadius = displayCornerRadiusPx / density
+    val startRadius = sharedCoverSourceRadius(sharedCoverKey)?.value ?: targetRadius
+    val radius by animatedVisibilityScope.transition.animateFloat(
+        label = "reader-clip-corner-radius",
+    ) { state ->
+        if (state == EnterExitState.Visible) targetRadius else startRadius
+    }
+    return this.then(with(sharedTransitionScope) {
+        Modifier.sharedBounds(
+            sharedContentState = rememberSharedContentState(sharedCoverKey),
+            animatedVisibilityScope = animatedVisibilityScope,
+            enter = fadeIn(animationSpec = tween(600)),
+            exit = fadeOut(animationSpec = tween(600)),
+            clipInOverlayDuringTransition = OverlayClip(RoundedCornerShape(radius.dp)),
+        )
+    })
+}

+ 22 - 0
app/src/test/java/io/legado/app/feature/reader/core/layout/ReaderChapterBlockMeasurerTest.kt

@@ -16,6 +16,28 @@ import org.junit.Assert.assertTrue
 import org.junit.Test
 import org.junit.Test
 
 
 class ReaderChapterBlockMeasurerTest {
 class ReaderChapterBlockMeasurerTest {
+    @Test
+    fun profilingKeepsMeasuredBlocksIdenticalWhenMostCharactersHaveNoStyle() = runBlocking {
+        val source = ReaderChapterSourceParser.parse(0, "", listOf("甲乙丙丁"), false, false)
+        val measuredStyle = style.copy(
+            styleRanges = listOf(
+                ReaderStyleRange(1, 2, ReaderStyleTarget.BODY, ReaderCharacterStyle(colorArgb = 9)),
+            )
+        )
+        val regular = ReaderChapterBlockMeasurer(
+            bodyShaper = shaper, titleShaper = shaper, imageDimensionsResolver = { null },
+        ).measure(source, measuredStyle) as ReaderChapterMeasureResult.Success
+        val metrics = ReaderChapterMeasureMetrics(System::nanoTime)
+        val profiled = ReaderChapterBlockMeasurer(
+            bodyShaper = shaper, titleShaper = shaper, imageDimensionsResolver = { null },
+            metrics = metrics,
+        ).measure(source, measuredStyle) as ReaderChapterMeasureResult.Success
+
+        assertEquals(regular.blocks, profiled.blocks)
+        assertTrue(metrics.shapingCalls > 0)
+        assertTrue(metrics.styleLookups > 0)
+    }
+
     @Test
     @Test
     fun cachesShaperPerStyleAndRetainsInheritedFontProperties() = runBlocking {
     fun cachesShaperPerStyleAndRetainsInheritedFontProperties() = runBlocking {
         var creations = 0
         var creations = 0

+ 30 - 0
app/src/test/java/io/legado/app/feature/reader/core/style/ReaderCharacterStyleTest.kt

@@ -15,4 +15,34 @@ class ReaderCharacterStyleTest {
         assertEquals("m", ReaderCharacterStyleResolver.resolve(ranges, 2, false)?.markingId)
         assertEquals("m", ReaderCharacterStyleResolver.resolve(ranges, 2, false)?.markingId)
         assertNull(ReaderCharacterStyleResolver.resolve(ranges, 2, true))
         assertNull(ReaderCharacterStyleResolver.resolve(ranges, 2, true))
     }
     }
+
+    @Test
+    fun equalPriorityUsesLastMatchingRangeWithoutCrossingTarget() {
+        val ranges = listOf(
+            ReaderStyleRange(
+                0,
+                4,
+                ReaderStyleTarget.ALL,
+                ReaderCharacterStyle(colorArgb = 1),
+                priority = 3
+            ),
+            ReaderStyleRange(
+                1,
+                3,
+                ReaderStyleTarget.BODY,
+                ReaderCharacterStyle(colorArgb = 2),
+                priority = 3
+            ),
+            ReaderStyleRange(
+                1,
+                3,
+                ReaderStyleTarget.TITLE,
+                ReaderCharacterStyle(colorArgb = 3),
+                priority = 2
+            ),
+        )
+        assertEquals(2, ReaderCharacterStyleResolver.resolve(ranges, 2, false)?.colorArgb)
+        assertEquals(1, ReaderCharacterStyleResolver.resolve(ranges, 2, true)?.colorArgb)
+        assertNull(ReaderCharacterStyleResolver.resolve(ranges, 5, false))
+    }
 }
 }