Kaynağa Gözat

feat: 性能之优化

HapeLee 1 ay önce
ebeveyn
işleme
d02f3d4aed

Dosya farkı çok büyük olduğundan ihmal edildi
+ 287 - 362
app/src/appRelease/generated/baselineProfiles/baseline-prof.txt


Dosya farkı çok büyük olduğundan ihmal edildi
+ 287 - 362
app/src/appRelease/generated/baselineProfiles/startup-prof.txt


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

@@ -246,6 +246,14 @@ class ReaderChapterBlockMeasurer(
                         val initialShaper = shaper(htmlStyle)
                         val initiallyShaped = metrics?.shape(initialShaper, item.value)
                             ?: initialShaper.shape(item.value)
+                        // 同一区间内相邻字形的解析结果是同一个实例:合并后的样式和它的
+                        // shaper 只算一次即可。否则每个字形都要分配一个 ReaderTextStyle,
+                        // 还要对 12 字段的 data class 做一次 getOrPut 哈希。
+                        var cachedRangeStyle: ReaderCharacterStyle? = null
+                        var cachedTextStyle = htmlStyle
+                        var cachedTextStyleIsPlain = true
+                        var cachedShaper = initialShaper
+                        var hasCachedStyle = false
                         var offset = 0
                         initiallyShaped.text.forEachIndexed { clusterIndex, cluster ->
                             val position = item.chapterPosition + offset
@@ -254,12 +262,19 @@ class ReaderChapterBlockMeasurer(
                                     if (metrics != null) metrics.resolveStyle(it, position, isTitle)
                                     else it.resolve(position, isTitle)
                                 }
-                            val textStyle = htmlStyle.merge(rangeStyle)
-                            val textShaper = shaper(textStyle)
+                            if (!hasCachedStyle || rangeStyle !== cachedRangeStyle) {
+                                cachedRangeStyle = rangeStyle
+                                cachedTextStyle = htmlStyle.merge(rangeStyle)
+                                cachedTextStyleIsPlain = cachedTextStyle == htmlStyle
+                                cachedShaper = shaper(cachedTextStyle)
+                                hasCachedStyle = true
+                            }
+                            val textStyle = cachedTextStyle
+                            val textShaper = cachedShaper
                             // The paragraph was already shaped with htmlStyle to obtain its
                             // grapheme clusters. For the overwhelmingly common unstyled glyph,
                             // reuse that width instead of shaping the same glyph a second time.
-                            val width = if (textStyle == htmlStyle) {
+                            val width = if (cachedTextStyleIsPlain) {
                                 initiallyShaped.widthsPx.getOrElse(clusterIndex) { 0f }
                             } else {
                                 (metrics?.shape(textShaper, cluster) ?: textShaper.shape(cluster))

+ 101 - 61
app/src/main/java/io/legado/app/feature/reader/core/layout/ReaderPaginator.kt

@@ -640,9 +640,18 @@ internal class ReaderPaginationSession(
             ?: (paragraph.baseTextSizePx + letterSpacing) * paragraph.indentCharacters
         val ideographWidth = paragraph.items.filterIsInstance<ReaderMeasuredInlineItem.Text>()
             .firstOrNull { it.widthPx > 0f }?.widthPx ?: paragraph.lineHeightPx
+        // 背景图(九宫格框、色带、气泡)通常只出现在章内少数几段。整段没有背景图时,
+        // 内缩、绘制实例、邻行探测恒为空,却仍要逐字求值——当前章 2,292 个字形每行
+        // 约 6 次这样的调用,是 `line-placement` 里最大的一块纯开销。
+        // 顺带在建 `clusters` 那一趟里判定,不额外再扫一遍段落。
+        var hasBackgroundImages = false
         val clusters = paragraph.items.map {
             when (it) {
-                is ReaderMeasuredInlineItem.Text -> it.value
+                is ReaderMeasuredInlineItem.Text -> {
+                    if (it.style.backgroundImage != null) hasBackgroundImages = true
+                    it.value
+                }
+
                 is ReaderMeasuredInlineItem.Image -> "\uFFFC"
             }
         }
@@ -655,10 +664,13 @@ internal class ReaderPaginationSession(
                 ?.style?.backgroundImage?.takeIf { it.fit == 3 }
 
         fun lineHasFrame(from: Int, until: Int): Boolean =
-            (from until until.coerceAtMost(paragraph.items.size)).any { itemFrame(it) != null }
+            hasBackgroundImages &&
+                    (from until until.coerceAtMost(paragraph.items.size)).any { itemFrame(it) != null }
 
         fun frameOf(index: Int, topBudgetPx: Float, bottomBudgetPx: Float) =
-            itemFrame(index)?.fitIntoLineBudget(topBudgetPx, bottomBudgetPx)
+            if (hasBackgroundImages) {
+                itemFrame(index)?.fitIntoLineBudget(topBudgetPx, bottomBudgetPx)
+            } else null
 
         /**
          * 元素绘制时真正使用的背景图:九宫格按行预算收紧,拉伸/裁剪/平铺按原样。
@@ -669,13 +681,14 @@ internal class ReaderPaginationSession(
          * 放行标记若只发给九宫格,fit≠3 的背景图就会逐字绘制成一条条断开的气泡。
          */
         fun drawnBackgroundOf(index: Int, topBudgetPx: Float, bottomBudgetPx: Float) =
-            (paragraph.items[index] as? ReaderMeasuredInlineItem.Text)
-                ?.style?.backgroundImage?.let { image ->
-                    if (image.fit == 3) image.fitIntoLineBudget(
-                        topBudgetPx,
-                        bottomBudgetPx
-                    ) else image
-                }
+            if (!hasBackgroundImages) null else
+                (paragraph.items[index] as? ReaderMeasuredInlineItem.Text)
+                    ?.style?.backgroundImage?.let { image ->
+                        if (image.fit == 3) image.fitIntoLineBudget(
+                            topBudgetPx,
+                            bottomBudgetPx
+                        ) else image
+                    }
 
         fun backgroundInsetBefore(
             index: Int,
@@ -744,8 +757,18 @@ internal class ReaderPaginationSession(
             // remainder of that row as an unnecessary one-character line.
             var until = breakerEnd
 
-            fun occupiedWidth(endExclusive: Int): Float =
-                (from until endExclusive).sumOf { index ->
+            fun occupiedWidth(endExclusive: Int): Float {
+                // 无背景图时内缩恒为 0,宽度单独累加即可;带背景图时保持原来「宽度+内缩」
+                // 同序累加的写法,避免断行比较出现浮点差异。
+                if (!hasBackgroundImages) {
+                    var widthSum = 0.0
+                    for (index in from until endExclusive) {
+                        widthSum += paragraph.items[index].widthPx.toDouble()
+                    }
+                    return widthSum.toFloat() +
+                            letterSpacing * (endExclusive - from - 1).coerceAtLeast(0)
+                }
+                return (from until endExclusive).sumOf { index ->
                     (paragraph.items[index].widthPx +
                             backgroundInsetBefore(index, from, topBudgetPx, bottomBudgetPx) +
                             backgroundInsetAfter(
@@ -755,6 +778,7 @@ internal class ReaderPaginationSession(
                                 bottomBudgetPx
                             )).toDouble()
                 }.toFloat() + letterSpacing * (endExclusive - from - 1).coerceAtLeast(0)
+            }
             while (until - from > 1 && occupiedWidth(until) > available) {
                 val candidate = until - 1
                 if (ChineseLineBreaker.isForbiddenBreak(
@@ -774,64 +798,84 @@ internal class ReaderPaginationSession(
             val from = starts[lineIndex]
             val until = starts[lineIndex + 1]
             val lineItems = paragraph.items.subList(from, until)
-            val textItems = lineItems.filterIsInstance<ReaderMeasuredInlineItem.Text>()
-            // Inline HTML may shrink every glyph in a row (<small>, font-size, etc.).
-            // It changes glyph drawing but not the paragraph's base line box; otherwise a
-            // small final row advances less and makes the following paragraph gap collapse.
-            val maxTextScale = maxOf(
-                1f,
-                textItems.maxOfOrNull {
-                    it.style.fontSizePx / paragraph.baseTextSizePx.coerceAtLeast(1f)
-                } ?: 1f,
-            )
+            val itemCount = until - from
+            val topBudgetPx = lineTopBudgets[lineIndex]
+            val bottomBudgetPx = lineBottomBudgets[lineIndex]
+            fun backgroundInsetBefore(index: Int) =
+                backgroundInsetBefore(from + index, from, topBudgetPx, bottomBudgetPx)
+
+            fun backgroundInsetAfter(index: Int) =
+                backgroundInsetAfter(from + index, until, topBudgetPx, bottomBudgetPx)
+
+            // 行内项每多一趟遍历,开销就乘上整章字形数:这里把字号缩放、首字颜色、
+            // 行内图高、自然宽度、内缩和空格数并为一次遍历,去掉原先各自产生的临时列表。
+            var maxTextScale = 1f
+            var maxImageHeightPx = 0f
+            var markerColorArgb: Int? = null
+            var widthSum = 0.0
+            var insetSum = 0.0
+            var wordSpaceCount = 0
+            for (itemIndex in 0 until itemCount) {
+                val item = lineItems[itemIndex]
+                widthSum += item.widthPx.toDouble()
+                if (hasBackgroundImages) {
+                    insetSum += (backgroundInsetBefore(itemIndex) +
+                            backgroundInsetAfter(itemIndex)).toDouble()
+                }
+                if (item is ReaderMeasuredInlineItem.Text) {
+                    // Inline HTML may shrink every glyph in a row (<small>, font-size, etc.).
+                    // It changes glyph drawing but not the paragraph's base line box; otherwise
+                    // a small final row advances less and makes the following paragraph gap
+                    // collapse.
+                    val scale = item.style.fontSizePx / paragraph.baseTextSizePx.coerceAtLeast(1f)
+                    if (scale > maxTextScale) maxTextScale = scale
+                    if (markerColorArgb == null) markerColorArgb = item.style.colorArgb
+                    if (item.value == " ") wordSpaceCount++
+                } else if (item is ReaderMeasuredInlineItem.Image &&
+                    item.heightPx > maxImageHeightPx
+                ) {
+                    maxImageHeightPx = item.heightPx
+                }
+            }
             val fallbackLineHeight = paragraph.lineHeightPx * maxTextScale
             val fallbackBaseline = paragraph.baselineOffsetPx * maxTextScale
             // Keep the paragraph's unshifted line box as the minimum. A line containing
             // only <sup> or only <sub> must not cancel its own visual movement by moving
             // the shared baseline in the opposite direction.
-            val lineAscent = maxOf(fallbackBaseline, textItems.maxOfOrNull {
-                (it.baselineOffsetPx ?: fallbackBaseline) - it.baselineShiftPx
-            } ?: fallbackBaseline)
+            var maxAscent = Float.NEGATIVE_INFINITY
+            var maxDescent = Float.NEGATIVE_INFINITY
+            for (itemIndex in 0 until itemCount) {
+                val item = lineItems[itemIndex]
+                if (item !is ReaderMeasuredInlineItem.Text) continue
+                val ascent = (item.baselineOffsetPx ?: fallbackBaseline) - item.baselineShiftPx
+                if (ascent > maxAscent) maxAscent = ascent
+                val descent = (item.lineHeightPx ?: fallbackLineHeight) -
+                        (item.baselineOffsetPx ?: fallbackBaseline) + item.baselineShiftPx
+                if (descent > maxDescent) maxDescent = descent
+            }
+            val lineAscent = maxOf(fallbackBaseline, maxAscent)
             val fallbackDescent = fallbackLineHeight - fallbackBaseline
-            val lineDescent = maxOf(fallbackDescent, textItems.maxOfOrNull {
-                val height = it.lineHeightPx ?: fallbackLineHeight
-                height - (it.baselineOffsetPx ?: fallbackBaseline) + it.baselineShiftPx
-            } ?: fallbackDescent)
+            val lineDescent = maxOf(fallbackDescent, maxDescent)
             val textLineHeight = lineAscent + lineDescent
-            val actualLineHeight = maxOf(
-                textLineHeight,
-                lineItems.filterIsInstance<ReaderMeasuredInlineItem.Image>()
-                    .maxOfOrNull { it.heightPx } ?: 0f,
-            )
+            val actualLineHeight = maxOf(textLineHeight, maxImageHeightPx)
             val lineBaselineOffset = lineAscent + (actualLineHeight - textLineHeight) / 2f
             if (y + actualLineHeight > config.contentBottomPx && columnHasContent()) advanceColumn()
             val indent = if (lineIndex == 0) indentWidth else paragraph.restLineIndentWidthPx
             val available = (config.contentWidthPx - indent).coerceAtLeast(0f)
-            val topBudgetPx = lineTopBudgets[lineIndex]
-            val bottomBudgetPx = lineBottomBudgets[lineIndex]
-            fun backgroundInsetBefore(index: Int) =
-                backgroundInsetBefore(from + index, from, topBudgetPx, bottomBudgetPx)
-
-            fun backgroundInsetAfter(index: Int) =
-                backgroundInsetAfter(from + index, until, topBudgetPx, bottomBudgetPx)
-
-            val naturalWidth = lineItems.sumOf { it.widthPx.toDouble() }.toFloat() +
-                    letterSpacing * (lineItems.size - 1).coerceAtLeast(0) +
-                    lineItems.indices.sumOf {
-                        (backgroundInsetBefore(it) + backgroundInsetAfter(it)).toDouble()
-                    }.toFloat()
-            val indentItems = (paragraph.leadingIndentItems - from).coerceIn(0, lineItems.size)
-            val stretchableGaps = (lineItems.size - indentItems - 1).coerceAtLeast(0)
+            val naturalWidth = widthSum.toFloat() +
+                    letterSpacing * (itemCount - 1).coerceAtLeast(0) +
+                    insetSum.toFloat()
+            val indentItems = (paragraph.leadingIndentItems - from).coerceIn(0, itemCount)
+            val stretchableGaps = (itemCount - indentItems - 1).coerceAtLeast(0)
             val shouldJustify =
                 paragraph.alignment == ReaderTextAlignment.JUSTIFY &&
                         lineIndex < starts.lastIndex - 1
             val residualWidth = if (shouldJustify) {
                 (available - naturalWidth).coerceAtLeast(0f)
             } else 0f
-            val wordSpaceCount = if (paragraph.justifyAtWordBoundaries) {
-                lineItems.count { it is ReaderMeasuredInlineItem.Text && it.value == " " }
-            } else 0
-            val wordSpaceExtra = if (wordSpaceCount > 1) residualWidth / wordSpaceCount else 0f
+            val wordSpaceExtra = if (
+                paragraph.justifyAtWordBoundaries && wordSpaceCount > 1
+            ) residualWidth / wordSpaceCount else 0f
             val justifyGap = if (wordSpaceExtra == 0f && stretchableGaps > 0) {
                 residualWidth / stretchableGaps
             } else 0f
@@ -841,8 +885,7 @@ internal class ReaderPaginationSession(
                 else -> 0f
             }
             val rowElementStart = elements.size
-            val markerColor = textItems.firstOrNull()?.style?.colorArgb
-                ?: 0xff000000.toInt()
+            val markerColor = markerColorArgb ?: 0xff000000.toInt()
             paragraph.decorations.forEach { decoration ->
                 when (decoration.kind) {
                     ReaderParagraphDecorationKind.QUOTE -> elements += ReaderElement.ParagraphMarker(
@@ -875,6 +918,7 @@ internal class ReaderPaginationSession(
             // Processed body text can retain its indentation as real leading glyphs.
             // The View reader started a non-extended underline after those glyphs.
             val underlineElementStart = elements.size + indentItems
+            var previousItemBackground: ReaderTextBackgroundImage? = null
             lineItems.forEachIndexed { itemIndex, item ->
                 x += backgroundInsetBefore(itemIndex)
                 val itemBackground =
@@ -903,12 +947,7 @@ internal class ReaderPaginationSession(
                             // 否则字间距会把它切成逐字绘制。
                             continuesBackgroundRun = itemBackground != null &&
                                     itemIndex > 0 &&
-                                    drawnBackgroundOf(
-                                        from + itemIndex - 1,
-                                        topBudgetPx,
-                                        bottomBudgetPx
-                                    ) ==
-                                    itemBackground,
+                                    previousItemBackground == itemBackground,
                             backgroundFrameTopPx = itemBackground?.contentInsetTopPx ?: 0f,
                             backgroundFrameBottomPx = itemBackground?.contentInsetBottomPx
                                 ?: 0f,
@@ -934,6 +973,7 @@ internal class ReaderPaginationSession(
                 x += item.widthPx + backgroundInsetAfter(itemIndex) + letterSpacing +
                         if (item is ReaderMeasuredInlineItem.Text && item.value == " ") wordSpaceExtra else 0f
                 x += if (itemIndex >= indentItems) justifyGap else 0f
+                previousItemBackground = itemBackground
             }
             addPageUnderline(underlineElementStart, y + actualLineHeight)
             columnRows += ReaderLayoutRow(rowElementStart, elements.size, y, y + actualLineHeight)

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

@@ -74,7 +74,18 @@ class ReaderCompiledStyleRanges internal constructor(
     private val bodyStyles: Array<ReaderCharacterStyle?>,
     private val titleStyles: Array<ReaderCharacterStyle?>,
 ) {
+    /**
+     * 上次命中的区间下标。每段内的字形位置单调递增,绝大多数查询会落回同一区间,
+     * 先做一次区间命中检查即可省掉二分。非线程安全:一个实例只服务一次 measure。
+     */
+    private var cursor = 0
+
     fun resolve(position: Int, isTitle: Boolean): ReaderCharacterStyle? {
+        if (boundaries.size < 2) return null
+        val last = cursor
+        if (position >= boundaries[last] && position < boundaries[last + 1]) {
+            return if (isTitle) titleStyles[last] else bodyStyles[last]
+        }
         var low = 0
         var high = boundaries.size - 2
         while (low <= high) {
@@ -82,7 +93,10 @@ class ReaderCompiledStyleRanges internal constructor(
             when {
                 position < boundaries[middle] -> high = middle - 1
                 position >= boundaries[middle + 1] -> low = middle + 1
-                else -> return if (isTitle) titleStyles[middle] else bodyStyles[middle]
+                else -> {
+                    cursor = middle
+                    return if (isTitle) titleStyles[middle] else bodyStyles[middle]
+                }
             }
         }
         return null

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

@@ -30,7 +30,13 @@ internal object ReaderPerfTrace {
         }
     }
 
+    /**
+     * 归因打点。`marker` 会被放在 composable 体内,每次重组都执行;未开 tracing 时必须尽早
+     * 返回,否则每条 marker 都是两次 `Trace` 静态调用乘以重组次数。
+     * 注意:API < 29 没有 `Trace.isEnabled()`,这里随之整体跳过(与 suspendSection 一致)。
+     */
     fun marker(name: String) {
+        if (!isEnabled()) return
         Trace.beginSection("reader.$name")
         Trace.endSection()
     }

+ 65 - 9
app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt

@@ -106,10 +106,12 @@ import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
 import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeoutOrNull
 import java.util.concurrent.ConcurrentHashMap
 
 
@@ -325,6 +327,7 @@ class ReadBookController(
     val textMenuState = _textMenuState.asStateFlow()
     private val _readerPageWindow = MutableStateFlow(ReaderPageWindow())
     val readerPageWindow = _readerPageWindow.asStateFlow()
+    private val readerEntranceSettled = MutableStateFlow(false)
     private val _readerPaginationError = MutableStateFlow<String?>(null)
     val readerPaginationError = _readerPaginationError.asStateFlow()
     private val _readerBackground = MutableStateFlow(
@@ -419,6 +422,15 @@ class ReadBookController(
 
     /** 重新排版自增:过期的流出回调据此丢弃(旧 View 靠取消排版任务做到同一件事)。 */
     private var directReaderStreamGeneration = 0L
+
+    /** 归因用:本次进程内是否已流过第一页,用于只标记一次首页埋点。 */
+    private var firstStreamedPageFormed = false
+
+    /** 归因用:本次进程内是否已发生过首次发布,用于只标记一次发布埋点。 */
+    private var firstStreamedPublishTraced = false
+
+    /** 归因用:含阅读位置的页是否已成形,用于只标记一次目标页埋点。 */
+    private var targetPageFormed = false
     private var directReaderPageIndex: Int? = null
     private val menuMutex = Mutex()
     @Volatile
@@ -495,6 +507,7 @@ class ReadBookController(
      * Compose state is committed only after the reader entrance transition is idle.
      */
     fun onReaderEntranceStateChanged(settled: Boolean) {
+        readerEntranceSettled.value = settled
         readerSessionViewModel.onEntranceStateChanged(settled)
     }
 
@@ -899,8 +912,14 @@ class ReadBookController(
     }
 
     /** 主线程:接收一页刚成型的页,对照旧 `ReadBook.collectLayoutPages` 的按页消费。 */
-    private fun onReaderPageStreamed(generation: Long, chapterIndex: Int, page: ReaderPage) {
+    private fun onReaderPageStreamed(
+        generation: Long,
+        chapterIndex: Int,
+        page: ReaderPage,
+        traceFirstPage: Boolean = false,
+    ) {
         activity.lifecycleScope.launch(Main) {
+            if (traceFirstPage) ReaderPerfTrace.marker("stream.first-page-main")
             if (generation != directReaderStreamGeneration) return@launch
             if (chapterIndex !in directReaderStreamingChapters) return@launch
             val streamed = directReaderStreamedPages.getOrPut(chapterIndex) { mutableListOf() }
@@ -917,7 +936,15 @@ class ReadBookController(
                 if (index >= 0) directReaderPageIndex = index
             }
             if (shouldPublishStreamedReaderPage(chapterIndex, page)) {
+                // 归因用:标记第一次真正发生的发布(不一定是本章第一页——发布规则要求
+                // "含 durChapterPos 的页成型",见 shouldPublishStreamedReaderPage)。
+                val firstPublish = !firstStreamedPublishTraced
+                if (firstPublish) {
+                    firstStreamedPublishTraced = true
+                    ReaderPerfTrace.marker("stream.first-publish.begin")
+                }
                 publishStreamedReaderWindow(chapterIndex)
+                if (firstPublish) ReaderPerfTrace.marker("stream.first-publish.end")
             }
         }
     }
@@ -946,6 +973,17 @@ class ReadBookController(
      * 旧 View 的重绘时机(`ReadBook.loadContent` 的三条 `upContent(offset)`):当前章按
      * "含 `durChapterPos` 的页成型 + 滚动模式 3 页余量",下一章只到前两页,上一章不早推。
      */
+    /** 该页是否覆盖当前阅读位置(`durChapterPos`)。 */
+    private fun pageCoversReadingPosition(chapterIndex: Int, page: ReaderPage): Boolean {
+        if (ReadBook.durChapterIndex != chapterIndex) return false
+        val pageStart = ReaderPageNavigator.pageStart(page)
+        val pageEnd = page.elements
+            .filterIsInstance<ReaderElement.Text>()
+            .maxOfOrNull { it.chapterPosition + it.value.length.coerceAtLeast(1) }
+            ?: pageStart
+        return ReadBook.durChapterPos in pageStart..pageEnd
+    }
+
     private fun shouldPublishStreamedReaderPage(chapterIndex: Int, page: ReaderPage): Boolean {
         val currentIndex = directReaderPageIndex
         val current = currentIndex?.let { directReaderPages.getOrNull(it) }
@@ -956,17 +994,11 @@ class ReadBookController(
             // "含 durChapterPos 的页成型"这条兜住,避免先闪首页再跳到目标页。
             0
         }
-        val pageStart = ReaderPageNavigator.pageStart(page)
-        val pageEnd = page.elements
-            .filterIsInstance<ReaderElement.Text>()
-            .maxOfOrNull { it.chapterPosition + it.value.length.coerceAtLeast(1) }
-            ?: pageStart
         return ReaderPartialPagePolicy.shouldPublishPage(
             chapterOffset = chapterIndex - (current?.id?.chapterIndex ?: ReadBook.durChapterIndex),
             pageIndex = page.id.pageIndex,
             currentPageIndex = currentPageIndex,
-            containsReadingPosition = ReadBook.durChapterIndex == chapterIndex &&
-                    ReadBook.durChapterPos in pageStart..pageEnd,
+            containsReadingPosition = pageCoversReadingPosition(chapterIndex, page),
             continuousScroll = ReadBook.isScroll,
         )
     }
@@ -1446,11 +1478,35 @@ class ReadBookController(
                         paginationStyle = style,
                         highlightRules = highlightRules,
                         onPage = { page ->
-                            onReaderPageStreamed(streamGeneration, chapterIndex, page)
+                            // 归因用:只标记本章第一页。三个 marker 把"第一页成形 → 主线程接管
+                            // → 发布到窗口"切开,用于分析端到端可读页时间的构成。
+                            val firstPage = !firstStreamedPageFormed
+                            if (firstPage) {
+                                firstStreamedPageFormed = true
+                                ReaderPerfTrace.marker("pagination.first-page")
+                            }
+                            // 归因用:含阅读位置的页在排版线程成形的时刻。它与
+                            // pagination.first-page 的间隔就是"为了不闪首页而多排的量"。
+                            if (!targetPageFormed && pageCoversReadingPosition(
+                                    chapterIndex,
+                                    page
+                                )
+                            ) {
+                                targetPageFormed = true
+                                ReaderPerfTrace.marker("pagination.target-page")
+                            }
+                            onReaderPageStreamed(
+                                streamGeneration, chapterIndex, page, firstPage,
+                            )
                         },
                     )
                 }
             }
+            // Keep streamed current pages responsive; defer only the complete batch's Main work.
+            // The timeout also covers direct entries whose entrance callback never settles.
+            ReaderPerfTrace.suspendSection("pagination.wait-for-entrance") {
+                withTimeoutOrNull(900L) { readerEntranceSettled.first { it } }
+            }
             withContext(Main) {
                 applyDirectReaderPaginationBatch(
                     environmentKey = environmentKey,

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

@@ -49,8 +49,11 @@ import androidx.compose.ui.platform.LocalConfiguration
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.platform.testTag
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.testTagsAsResourceId
 import androidx.compose.ui.text.style.TextAlign
 import androidx.core.view.ViewCompat
 import androidx.core.view.WindowInsetsCompat
@@ -157,6 +160,9 @@ fun ReadBookRouteScreen(
     onOpenTtsCache: () -> Unit = {},
     onOpenReadAloudPlayer: () -> Unit = {},
 ) {
+    // 归因定界:与末尾 compose.screen.end 成对。若首帧 `Compose:recompose` 里出现
+    // begin 之前的空档,说明那部分耗时在本屏之外(导航宿主 / 共享转场层)。
+    ReaderPerfTrace.marker("compose.screen.begin")
     val state by viewModel.uiState.collectAsStateWithLifecycle()
     val readPreferences by viewModel.readPreferences.collectAsStateWithLifecycle()
     val markingState by viewModel.markingState.collectAsStateWithLifecycle()
@@ -658,6 +664,7 @@ fun ReadBookRouteScreen(
     Box(
         Modifier
             .fillMaxSize()
+            .semantics { testTagsAsResourceId = true }
             .readerSharedBounds(
                 sharedTransitionScope = sharedTransitionScope,
                 animatedVisibilityScope = animatedVisibilityScope,
@@ -687,18 +694,23 @@ fun ReadBookRouteScreen(
             if (!(ReaderViewportLayerPolicy.usesFixedBackground(readerTransitionMode) &&
                         readerCanvasVisible)
             ) {
+                // 归因用:成对 marker 夹住子树,其间隔即该子树的组合耗时(都在
+                // `Compose:recompose` 之内)。子系统不用 tracing 时 marker 是空操作。
+                ReaderPerfTrace.marker("compose.background.begin")
                 ReaderBackgroundSurface(
                     backgroundImage = readerBackground.drawable,
                     backgroundImageAlpha = readerBackgroundAlpha(state.styleConfig.bgAlpha),
                     modifier = Modifier.fillMaxSize(),
                     animateAppearance = true,
                 )
+                ReaderPerfTrace.marker("compose.background.end")
             }
             AnimatedVisibility(
                 visible = readerCanvasVisible,
                 enter = fadeIn(animationSpec = tween(400)),
                 exit = fadeOut(animationSpec = tween(450)),
             ) {
+                ReaderPerfTrace.marker("compose.canvas.begin")
                 ReaderCanvasSurface(
                     hostPages = displayedReaderPageWindow ?: readerPageWindow,
                     transitionMode = readerTransitionMode,
@@ -713,6 +725,11 @@ fun ReadBookRouteScreen(
                 autoPageIndicatorColor = LegadoTheme.colorScheme.primary,
                 modifier = Modifier
                     .fillMaxSize()
+                    .then(
+                        if (displayedReaderPageWindow?.current?.isPlaceholder == false) {
+                            Modifier.testTag("reader_content")
+                        } else Modifier
+                    )
                     .then(
                         if (useMenuHazeSource) {
                             Modifier.hazeSource(menuHazeState)
@@ -765,6 +782,7 @@ fun ReadBookRouteScreen(
                     externalSelections = controller.composeSelections,
                     onVisibleBodyTextPositionProvider = controller::setComposeVisibleBodyTextPositionProvider,
                 )
+                ReaderPerfTrace.marker("compose.canvas.end")
             }
             AnimatedVisibility(
                 // 旧 View 把消息画成页(chrome 保留);只有画布无从成页(还没有窗口/视口)
@@ -808,6 +826,7 @@ fun ReadBookRouteScreen(
                 modifier = Modifier.align(Alignment.BottomCenter),
             )
         }
+        ReaderPerfTrace.marker("compose.chrome.begin")
         ReadBookColorTheme(
             styleConfig = state.styleConfig,
             preferences = readPreferences,
@@ -853,6 +872,7 @@ fun ReadBookRouteScreen(
                 )
             }
             val bookNavigationSheet = state.activeSheet as? ReadBookSheet.BookNavigation
+            ReaderPerfTrace.marker("compose.sheet.begin")
             ReaderBookSheetRoute(
                 show = bookNavigationSheet != null,
                 bookUrl = state.book?.bookUrl.orEmpty(),
@@ -898,6 +918,8 @@ fun ReadBookRouteScreen(
                     onDisable = { viewModel.onIntent(ReadBookIntent.DisableSource) },
                 ),
             )
+            ReaderPerfTrace.marker("compose.sheet.end")
+            ReaderPerfTrace.marker("compose.selection.begin")
             ReaderTextSelectionOverlay(
                 controller = controller,
                 viewModel = viewModel,
@@ -914,6 +936,7 @@ fun ReadBookRouteScreen(
                     configItems = emptyList()
                 }
             }
+            ReaderPerfTrace.marker("compose.selection.end")
             TextSelectMenuConfigSheet(
                 show = showSelectMenuConfigSheet,
                 items = configItems,
@@ -930,7 +953,9 @@ fun ReadBookRouteScreen(
                 onSaved = { items -> controller.saveMenuConfig(items) }
             )
         }
+        ReaderPerfTrace.marker("compose.chrome.end")
     }
+    ReaderPerfTrace.marker("compose.screen.end")
 }
 
 /**

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

@@ -45,6 +45,7 @@ import io.legado.app.constant.BookType
 import io.legado.app.constant.Status
 import io.legado.app.domain.model.BookSearchScope
 import io.legado.app.domain.model.settings.AppUiConfiguration
+import io.legado.app.feature.reader.platform.ReaderPerfTrace
 import io.legado.app.help.coil.CoverExtras
 import io.legado.app.model.AudioPlay
 import io.legado.app.model.Download
@@ -763,12 +764,15 @@ fun MainActivity.mainEntryProvider(
                 onNavigateToRoute(MainRouteSettingsPrivate)
             },
         ) {
+            ReaderPerfTrace.marker("nav.entry.begin")
             val readBookViewModel = koinViewModel<ReadBookViewModel>(
                 key = "ReadBook:${route.bookUrl ?: "last-read"}"
             )
+            ReaderPerfTrace.marker("nav.book-vm.ready")
             val readerSessionViewModel = koinViewModel<ReaderSessionViewModel>(
                 key = "ReaderSession:${route.bookUrl ?: "last-read"}"
             )
+            ReaderPerfTrace.marker("nav.viewmodels.ready")
             val controller = remember(readBookViewModel, readerSessionViewModel) {
                 ReadBookController(
                     this@mainEntryProvider,
@@ -776,6 +780,7 @@ fun MainActivity.mainEntryProvider(
                     readerSessionViewModel,
                 )
             }
+            ReaderPerfTrace.marker("nav.controller.ready")
             // Canvas 阅读面在首次组合时就会请求分页,必须先告诉 ViewModel 本路由要打开哪本书。
             // 刻意用 remember 而非 LaunchedEffect:后者在组合之后才跑,赶不上首帧。
             @Suppress("RememberReturnType")

+ 21 - 2
app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt

@@ -13,6 +13,7 @@ import androidx.compose.animation.AnimatedContent
 import androidx.compose.animation.AnimatedVisibility
 import androidx.compose.animation.AnimatedVisibilityScope
 import androidx.compose.animation.Crossfade
+import androidx.compose.animation.EnterExitState
 import androidx.compose.animation.ExperimentalAnimationApi
 import androidx.compose.animation.ExperimentalSharedTransitionApi
 import androidx.compose.animation.SharedTransitionScope
@@ -94,11 +95,13 @@ import androidx.compose.ui.platform.LocalClipboard
 import androidx.compose.ui.platform.LocalConfiguration
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.platform.testTag
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.semantics.Role
 import androidx.compose.ui.semantics.contentDescription
 import androidx.compose.ui.semantics.role
 import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.testTagsAsResourceId
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.window.Dialog
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -179,8 +182,20 @@ fun BookshelfRouteScreen(
 ) {
     val state by viewModel.uiState.collectAsStateWithLifecycle()
     val allGroups by viewModel.allGroupsFlow.collectAsStateWithLifecycle()
+    // 打开书籍会立刻把最后阅读时间落库,排序随之变化:退场动画期间若让书架跟着重排,
+    // 正参与共享转场的封面会和它所在的格子错位。因此只在「转场目标是离开书架」时渲染
+    // 离开前那一版列表;目标一旦回到可见(正常返回、预测性返回都算),立刻恢复用最新
+    // 排序 —— 重排发生在书架不可见的时候,回到书架时看到的已经是排好的结果。
+    val transition = animatedVisibilityScope?.transition
+    val isLeavingShelf = transition?.targetState == EnterExitState.PostExit
+    var leavingShelfState by remember { mutableStateOf<BookshelfUiState?>(null) }
+    LaunchedEffect(isLeavingShelf) {
+        if (isLeavingShelf) return@LaunchedEffect
+        // 可见期间持续跟随最新状态,快照因此不会残留成过期版本(例如点了私密书但没跳转)
+        snapshotFlow { state }.collect { leavingShelfState = it }
+    }
     BookshelfScreen(
-        uiState = state,
+        uiState = if (isLeavingShelf) leavingShelfState ?: state else state,
         onIntent = viewModel::onIntent,
         effects = viewModel.effects,
         allGroups = allGroups,
@@ -1661,7 +1676,11 @@ fun BookshelfPage(
             state = gridState,
             modifier = Modifier
                 .fillMaxSize()
-                .semantics { contentDescription = listContentDescription }
+                .semantics {
+                    contentDescription = listContentDescription
+                    testTagsAsResourceId = true
+                }
+                .testTag("bookshelf_list")
                 .then(
                     with(sharedTransitionScope) {
                         if (this != null) Modifier.skipToLookaheadSize() else Modifier

+ 8 - 2
app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt

@@ -174,8 +174,9 @@ class BookshelfViewModel(
         }
         .shareIn(viewModelScope, SharingStarted.WhileSubscribed(5000), replay = 1)
 
+    // 与 uiState 同理:常驻订阅,返回书架时首帧就是最新分组,避免补一次跳动
     val allGroupsFlow: StateFlow<List<BookGroup>> = bookGroupRepository.flowAll()
-        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
+        .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
 
     /**
      * 解锁态:进程内有效,重启应用即回到锁定。Eagerly 是为了点击时能同步读到当前值,
@@ -718,9 +719,14 @@ class BookshelfViewModel(
             themeColor = themeSettings.themeColor,
             pendingUploadUrl = pendingUploadUrl,
         )
+        // 常驻订阅:进入阅读页后 UI 停止收集,若让上游在超时后停掉,返回书架的前几帧
+        // 读到的仍是「阅读前」那一版排序,等 Room 重新查询到达再跳一次,重排就发生在
+        // 书架已经可见之后。管道挂在 viewModelScope(ViewModel 随返回栈条目存活),
+        // 因此返回首帧即是最新排序。与 komikku 的 LibraryScreenModel 同思路:
+        // 状态管道由 ScreenModel/ViewModel 自己持有,不随 UI 订阅启停。
     }.stateIn(
         viewModelScope,
-        SharingStarted.WhileSubscribed(5000),
+        SharingStarted.Eagerly,
         BookshelfUiState(
             settings = initialSettings,
             selectedGroupId = initialSettings.saveTabPosition,

+ 1 - 0
app/src/main/res/values-zh-rCN/strings.xml

@@ -2007,6 +2007,7 @@
     <string name="follow_read_background">跟随阅读背景</string>
     <string name="bookshelf_total_count">共%1$d本</string>
     <string name="bookshelf_selected_count">已选%1$d本</string>
+    <string name="bookshelf_edit_subtitle">%1$s · %2$s</string>
     <string name="bookshelf_empty_global_search">没有书籍,尝试全局搜索</string>
     <string name="global_search">全局搜索</string>
     <string name="cache_management">缓存管理</string>

+ 1 - 0
app/src/main/res/values-zh-rHK/strings.xml

@@ -1442,6 +1442,7 @@
     <string name="miuix_monet_summary">為 Miuix 使用 Monet 演算法生成配色。目前主題為動態主題且系統為 Android 12 或以上時,會優先使用系統取色;否則使用下方種子色。</string>
     <string name="bookshelf_total_count">共%1$d本</string>
     <string name="bookshelf_selected_count">已選%1$d本</string>
+    <string name="bookshelf_edit_subtitle">%1$s · %2$s</string>
     <string name="bookshelf_empty_global_search">沒有書籍,嘗試全局搜索</string>
     <string name="global_search">全局搜索</string>
     <string name="cache_management">緩存管理</string>

+ 1 - 0
app/src/main/res/values-zh-rTW/strings.xml

@@ -1446,6 +1446,7 @@
     <string name="title_spacing">間距</string>
     <string name="bookshelf_total_count">共%1$d本</string>
     <string name="bookshelf_selected_count">已選%1$d本</string>
+    <string name="bookshelf_edit_subtitle">%1$s · %2$s</string>
     <string name="bookshelf_empty_global_search">沒有書籍,嘗試全域搜尋</string>
     <string name="global_search">全域搜尋</string>
     <string name="cache_management">快取管理</string>

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

@@ -501,6 +501,44 @@ class ReaderPaginatorTest {
         assertEquals(frameBounds.right, glyphs[2].bounds.left, 0f)
     }
 
+    /** 放行标记按「绘制用实例」比较:同图连续才续接,换一张图就要断开。 */
+    @Test
+    fun backgroundRunContinuesOnlyAcrossEqualDrawnImages() {
+        val frame = ReaderTextBackgroundImage(
+            source = "frame.png",
+            fit = 3,
+            scale = 1f,
+            contentInsetLeftPx = 3f,
+            contentInsetRightPx = 4f,
+        )
+        val framed = style.copy(backgroundImage = frame)
+        val reframed = style.copy(backgroundImage = frame.copy(source = "other.png"))
+        val page = ReaderPaginator.paginateBlocks(
+            listOf(
+                ReaderMeasuredBlock.InlineParagraph(
+                    items = listOf(
+                        ReaderMeasuredInlineItem.Text("甲", 10f, style, 0),
+                        ReaderMeasuredInlineItem.Text("乙", 10f, framed, 1),
+                        ReaderMeasuredInlineItem.Text("丙", 10f, framed, 2),
+                        ReaderMeasuredInlineItem.Text("丁", 10f, reframed, 3),
+                        ReaderMeasuredInlineItem.Text("戊", 10f, reframed, 4),
+                    ),
+                    indentCharacters = 0,
+                    alignment = ReaderTextAlignment.START,
+                    lineHeightPx = 20f,
+                    baselineOffsetPx = 15f,
+                    baseTextSizePx = 10f,
+                )
+            ),
+            config.copy(viewportWidthPx = 200, viewportHeightPx = 100),
+        ).single()
+
+        assertEquals(
+            listOf(false, false, true, false, true),
+            page.elements.filterIsInstance<ReaderElement.Text>().map { it.continuesBackgroundRun },
+        )
+    }
+
     @Test
     fun nineSliceReflowDoesNotStrandTheRemainderOfAnOriginalLine() {
         val frame = ReaderTextBackgroundImage(

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

@@ -31,6 +31,34 @@ class ReaderCharacterStyleTest {
         }
     }
 
+    @Test
+    fun cachedIntervalCursorSurvivesOutOfOrderAndAlternatingTargets() {
+        val ranges = listOf(
+            ReaderStyleRange(0, 4, ReaderStyleTarget.ALL, ReaderCharacterStyle(colorArgb = 1)),
+            ReaderStyleRange(4, 8, ReaderStyleTarget.BODY, ReaderCharacterStyle(colorArgb = 2)),
+            ReaderStyleRange(8, 12, ReaderStyleTarget.TITLE, ReaderCharacterStyle(colorArgb = 3)),
+        )
+        val compiled = ReaderCharacterStyleResolver.compile(ranges)
+        // 位置来回跳、title/body 交替:游标命中不能改变结果。
+        for (position in listOf(11, 0, 6, 3, 20, 7, 1, 9, 4, 8, -1)) {
+            for (isTitle in listOf(false, true)) {
+                assertEquals(
+                    ReaderCharacterStyleResolver.resolve(ranges, position, isTitle),
+                    compiled.resolve(position, isTitle),
+                )
+            }
+        }
+    }
+
+    @Test
+    fun degenerateRangesCompileToAnEmptyIndex() {
+        val compiled = ReaderCharacterStyleResolver.compile(
+            listOf(ReaderStyleRange(5, 5, ReaderStyleTarget.ALL, ReaderCharacterStyle()))
+        )
+        assertNull(compiled.resolve(0, false))
+        assertNull(compiled.resolve(5, true))
+    }
+
     @Test
     fun higherPriorityMarkingOverridesRegexStyle() {
         val ranges = listOf(

+ 92 - 36
baselineProfile/src/main/java/io/legado/baselineprofile/BaselineProfileGenerator.kt

@@ -1,10 +1,12 @@
 package io.legado.baselineprofile
 
+import androidx.benchmark.macro.MacrobenchmarkScope
 import androidx.benchmark.macro.junit4.BaselineProfileRule
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.LargeTest
 import androidx.test.platform.app.InstrumentationRegistry
 import androidx.test.uiautomator.By
+import androidx.test.uiautomator.BySelector
 import androidx.test.uiautomator.Until
 import org.junit.Rule
 import org.junit.Test
@@ -14,11 +16,48 @@ import java.util.regex.Pattern
 /**
  * 生成 baseline profile。
  *
- * 关键:journey 必须真正打开一本书进入阅读器,否则开书热路径(Compose 组合、
- * ChapterProvider/TextChapterLayout 排版、ReadView 绘制)不会被采样进 profile,
- * release 首次开书只能主线程内联 JIT,产生明显加载卡顿。
+ * 关键:journey 必须真正打开一本书进入阅读器,否则开书热路径(Compose 组合、正文排版、
+ * Canvas 绘制)不会被采样进 profile,release 首次开书只能主线程内联 JIT,产生明显加载卡顿。
  *
- * 运行前提:目标设备书架里至少有一本书(本仓库用连接的真机生成,见 useConnectedDevices)。
+ * ## 为什么这里只有一个完整 journey
+ * 官方建议拆出「只有冷启动到书架可见」的用例(includeInStartupProfile = true 只给它),
+ * 本工程试过,**失败**:androidx benchmark 报
+ * `Generated Profile is empty, before filtering`(`BaselineProfiles.kt` 结尾的
+ * `check(!lastProfile.isNullOrBlank())`)。原因(`androidx.benchmark:benchmark-macro:1.4.1`):
+ *
+ * 1. `BaselineProfiles.collect` 每轮都是「先按 `Partial(Disable, warmupIterations=1)` 编译,
+ *    再 `extractProfile()`(`pm dump-profiles`)」,见 `BaselineProfiles.kt` 的循环;
+ * 2. `CompilationMode.Partial.compileImpl` 在「编译」这一步里**会跑一遍 journey 本身**
+ *    (`repeat(warmupIterations) { warmupBlock() }`),然后 `killProcess()` 刷 profile,
+ *    紧接着 `cmd package compile -m speed-profile` 把刚采集到的热点编译掉;
+ * 3. 于是取 profile 时,短 journey 采集到的东西**已经被这一轮自己的 speed-profile 编译消耗**,
+ *    剩下的为空。(所以传 `maxIterations = 1` 没有用——顺序就在第 1 轮内部,已实测。)
+ *
+ * 与此无关的因素(都已排除):设备是否解锁、是否调用 `reportFullyDrawn`。
+ *
+ * ## 一次 collect 只写一个文件
+ * `BaselineProfiles.kt#reportResults`:`includeInStartupProfile = true` 写
+ * `<prefix>-startup-prof.txt`,false 写 `<prefix>-baseline-prof.txt`;而官方 javadoc 写明
+ * 「startup profile 里的方法同样会用于 baseline profile」。所以像现在这样只用 `true` 跑完整
+ * journey,AGP 合并后两个文件会字节相同——这就是当前两份产物一模一样的原因。
+ *
+ * 代价:startup profile 偏大(会被整体 AOT 编译,成本落在安装/首次启动)。若以后要再拆,
+ * 请先在上面的前提下找到能让短 journey 留下非空 profile 的办法(例如明显加长启动段),
+ * 并保证重跑后 `startup-prof.txt` 非空且明显小于 `baseline-prof.txt`。
+ *
+ * 验收:生成后必须先确认 baseline profile 里真有阅读器条目,否则等于只收集了启动态:
+ * ```
+ * (Select-String -Path app/src/appRelease/generated/baselineProfiles/baseline-prof.txt `
+ *     -Pattern 'io/legado/app/ui/book/read/[A-Za-z0-9_$]+' -AllMatches).Matches.Value |
+ *     Group-Object | Sort-Object Count -Descending | Select-Object -First 10
+ * ```
+ * 真正达标时 `ReadBookController` / `ReadBookViewModel` / `ReaderPaginator` 都应有条目;
+ * 只有个位数条目就说明 journey 没进阅读器。(release 变体经过 R8,类名可能被混淆,
+ * 未混淆构建上这段抽样最直观。)
+ *
+ * 运行前提:
+ * - 目标设备书架里至少有一本书(本仓库用连接的真机生成,见 useConnectedDevices);
+ * - **解锁屏幕并保持常亮**:锁屏时 `startActivityAndWait` 之后 app 不会到前台,门禁会超时失败。
  */
 @RunWith(AndroidJUnit4::class)
 @LargeTest
@@ -29,49 +68,33 @@ class BaselineProfileGenerator {
 
     @Test
     fun generate() {
-        val packageName = InstrumentationRegistry.getArguments().getString("targetAppId")
-            ?: "io.legado.app"
-
         rule.collect(
-            packageName = packageName,
-            includeInStartupProfile = true
+            packageName = packageName(),
+            includeInStartupProfile = true,
         ) {
             pressHome()
             startActivityAndWait()
+            waitForBookshelf()
+            openFirstBook()
 
-            // 等书架加载完成。
-            val bookshelfSelector = By.desc("bookshelf_list")
-            device.wait(Until.hasObject(bookshelfSelector), 10000)
-            device.waitForIdle()
-            Thread.sleep(2000)
-
-            val w = device.displayWidth
-            val h = device.displayHeight
-
-            // 打开第一本书进入阅读器。
-            // 注意:开书前不要滑动书架——下滑会让顶栏展开、把书目挤下去,位置漂移点不中。
-            // 书目是 Compose 语义节点,uiautomator 中 clickable=false,UiObject2.click() 不可靠,
-            // 因此用「书特有的进度描述」(未读/已读/第N章)精确定位书节点,取其 bounds 中心,
-            // 用原始坐标点击(等价 input tap,最稳)。找不到时退回已验证的左上角书位坐标。
-            val bookMatcher = By.desc(Pattern.compile(".*(未读|已读|读到|第.{1,8}章).*"))
-            val bookNode = device.wait(Until.findObject(bookMatcher), 5000)
-            if (bookNode != null) {
-                val b = bookNode.visibleBounds
-                device.click(b.centerX(), b.centerY())
-            } else {
-                device.click((w * 0.18f).toInt(), (h * 0.37f).toInt())
+            // 某些入口先到详情页;以真实正文画布为门禁,不让空/占位阅读页冒充成功。
+            if (!device.wait(Until.hasObject(READER_CONTENT), 5_000)) {
+                val readButton =
+                    checkNotNull(device.wait(Until.findObject(By.text("阅读")), 5_000)) {
+                        "Neither reader content nor the book-detail read button appeared"
+                    }
+                readButton.click()
+            }
+            check(device.wait(Until.hasObject(READER_CONTENT), 15_000)) {
+                "Reader content did not appear; refusing to generate an empty baseline profile"
             }
-            device.waitForIdle()
-            Thread.sleep(3000)
-
-            // 若点书进的是书籍详情页,点“阅读”FAB 进入阅读器;已直接进阅读器则为 null,跳过。
-            device.wait(Until.findObject(By.text("阅读")), 2000)?.click()
-            device.waitForIdle()
 
             // 等阅读器首屏排版完成。
             Thread.sleep(3500)
 
             // 翻几页,顺带采样相邻章排版与翻页绘制。
+            val w = device.displayWidth
+            val h = device.displayHeight
             repeat(4) {
                 device.swipe((w * 0.85f).toInt(), h / 2, (w * 0.15f).toInt(), h / 2, 8)
                 Thread.sleep(900)
@@ -81,4 +104,37 @@ class BaselineProfileGenerator {
             Thread.sleep(1500)
         }
     }
+
+    private fun MacrobenchmarkScope.waitForBookshelf() {
+        check(device.wait(Until.hasObject(BOOKSHELF_LIST), 10_000)) {
+            "Bookshelf did not appear; cannot collect a baseline profile " +
+                    "(设备是否已解锁并保持亮屏?锁屏下 app 不会到前台)"
+        }
+    }
+
+    /**
+     * 打开书架里的第一本书。
+     *
+     * 注意:开书前不要滑动书架——下滑会让顶栏展开、把书目挤下去,位置漂移点不中。
+     * 书目是 Compose 语义节点,uiautomator 中 clickable=false,UiObject2.click() 不可靠,
+     * 因此用「书特有的进度描述」(未读/已读/第N章)精确定位书节点,取其 bounds 中心,
+     * 用原始坐标点击(等价 input tap,最稳)。找不到书时直接失败,避免生成空 profile。
+     */
+    private fun MacrobenchmarkScope.openFirstBook() {
+        val bookMatcher = By.desc(Pattern.compile(".*(未读|已读|读到|第.{1,8}章).*"))
+        val bookNode = checkNotNull(device.wait(Until.findObject(bookMatcher), 5_000)) {
+            "No visible book with a progress description; cannot collect a reader profile"
+        }
+        val bounds = bookNode.visibleBounds
+        device.click(bounds.centerX(), bounds.centerY())
+    }
+
+    private fun packageName(): String =
+        InstrumentationRegistry.getArguments().getString("targetAppId")
+            ?: "io.legato.kazusa"
+
+    private companion object {
+        val BOOKSHELF_LIST: BySelector = By.res("bookshelf_list")
+        val READER_CONTENT: BySelector = By.res("reader_content")
+    }
 }

+ 3 - 5
baselineProfile/src/main/java/io/legado/baselineprofile/StartupBenchmarks.kt

@@ -4,14 +4,10 @@ import androidx.benchmark.macro.BaselineProfileMode
 import androidx.benchmark.macro.CompilationMode
 import androidx.benchmark.macro.StartupMode
 import androidx.benchmark.macro.StartupTimingMetric
-import androidx.benchmark.macro.FrameTimingMetric
 import androidx.benchmark.macro.junit4.MacrobenchmarkRule
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.LargeTest
 import androidx.test.platform.app.InstrumentationRegistry
-import androidx.test.uiautomator.By
-import androidx.test.uiautomator.Direction
-import androidx.test.uiautomator.Until
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
@@ -51,8 +47,10 @@ class StartupBenchmarks {
         benchmark(CompilationMode.Partial(BaselineProfileMode.Require))
 
     private fun benchmark(compilationMode: CompilationMode) {
+        // 默认值必须是 applicationId(io.legato.kazusa),不是 namespace(io.legado.app);
+        // AGP 在设备上跑 Macrobenchmark 时会传 targetAppId,只有手动跑用例时才用这个兜底值。
         val packageName = InstrumentationRegistry.getArguments().getString("targetAppId")
-            ?: "io.legado.app"
+            ?: "io.legato.kazusa"
 
         rule.measureRepeated(
             packageName = packageName,

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor