Sfoglia il codice sorgente

feat: 阅读器相关问题

HapeLee 2 mesi fa
parent
commit
611706a5d3

+ 1 - 0
.gitignore

@@ -18,6 +18,7 @@ node_modules/
 .kotlin/
 /.vscode/settings.json
 /.codex/skills
+/.workbuddy/
 /.debug-runs/
 __pycache__/
 *.py[cod]

+ 4 - 0
app/src/main/AndroidManifest.xml

@@ -37,6 +37,10 @@
         android:supportsRtl="true"
         android:theme="@style/Base.AppTheme"
         tools:ignore="AllowBackup,GoogleAppIndexingWarning,UnusedAttribute">
+        <!-- Allows adb/Perfetto to inspect release-like reader startup without exposing it to apps. -->
+        <profileable
+            android:shell="true"
+            tools:targetApi="q" />
         <!-- 图标0 -->
         <activity
             android:name=".ui.main.LauncherW"

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

@@ -184,15 +184,29 @@ class ReaderChapterBlockMeasurer(
                 when (item) {
                     is ReaderChapterInlineSource.Text -> {
                         val htmlStyle = baseStyle.merge(item.style)
-                        val initiallyShaped = ReaderParagraphFactory(shaper(htmlStyle))
-                            .create(item.value, htmlStyle, item.chapterPosition)
+                        val initiallyShaped = shaper(htmlStyle).shape(item.value)
                         var offset = 0
-                        initiallyShaped.clusters.forEach { cluster ->
+                        initiallyShaped.text.forEachIndexed { clusterIndex, cluster ->
                             val position = item.chapterPosition + offset
-                            val rangeStyle = ReaderCharacterStyleResolver.resolve(style.styleRanges, position, isTitle)
+                            val rangeStyle = style.styleRanges
+                                .takeIf(List<ReaderStyleRange>::isNotEmpty)
+                                ?.let {
+                                    ReaderCharacterStyleResolver.resolve(
+                                        it,
+                                        position,
+                                        isTitle
+                                    )
+                                }
                             val textStyle = htmlStyle.merge(rangeStyle)
                             val textShaper = shaper(textStyle)
-                            val width = textShaper.shape(cluster).widthsPx.firstOrNull() ?: 0f
+                            // 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) {
+                                initiallyShaped.widthsPx.getOrElse(clusterIndex) { 0f }
+                            } else {
+                                textShaper.shape(cluster).widthsPx.firstOrNull() ?: 0f
+                            }
                             // The paragraph already owns the base line box (including the special
                             // subtitle bounds). Style overrides and baseline-shift spans need
                             // per-glyph metrics so their visual extents can expand the shared line.

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

@@ -253,7 +253,13 @@ object ReaderPaginator {
                 pages += elements
                 pageTexts += pageText
                 pageExtents += if (config.continuousScroll) {
-                    max(config.contentBottomPx - config.paddingTopPx, y - config.paddingTopPx)
+                    // Legacy TextChapterLayout records the scroll-page height from its
+                    // current layout cursor (durY). In particular, durY already contains
+                    // the final line advance and paragraph spacing. Do not expand it to a
+                    // viewport here: the next scroll page must begin immediately after
+                    // that spacing, including when a paragraph boundary is also a page
+                    // boundary.
+                    (y - config.paddingTopPx).coerceAtLeast(0f)
                 } else config.viewportHeightPx.toFloat()
                 elements = mutableListOf()
                 pageText = StringBuilder()

+ 9 - 4
app/src/main/java/io/legado/app/feature/reader/legacy/LegacyReaderChapterPaginator.kt

@@ -19,6 +19,7 @@ import io.legado.app.feature.reader.platform.AndroidReaderHtmlSourceResolver
 import io.legado.app.feature.reader.platform.AndroidReaderTextShaper
 import io.legado.app.feature.reader.platform.ReaderAndroidPaginationStyle
 import io.legado.app.feature.reader.platform.ReaderAndroidPaintFactory
+import io.legado.app.feature.reader.platform.ReaderPerfTrace
 import io.legado.app.help.book.BookContent
 import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.model.ImageProvider
@@ -161,7 +162,8 @@ object LegacyReaderChapterPaginator {
             ),
             imageOptionsResolver = LegacyReaderImageOptionsResolver,
         )
-        val measured = measurer.measure(
+        val measured = ReaderPerfTrace.section("pagination.measure") {
+            measurer.measure(
             layoutSource,
             ReaderChapterMeasureStyle(
                 bodyStyle = bodyStyle,
@@ -189,12 +191,14 @@ object LegacyReaderChapterPaginator {
                 letterSpacingEm = bodyPaint.letterSpacing,
                 styleRanges = styleRanges,
             ),
-        )
+            )
+        }
         if (measured is ReaderChapterMeasureResult.Unsupported) {
             return LegacyReaderChapterPaginationResult.Unsupported(measured.reason)
         }
         val blocks = (measured as ReaderChapterMeasureResult.Success).blocks
-        val pages = ReaderPaginator.paginateBlocks(
+        val pages = ReaderPerfTrace.section("pagination.pages") {
+            ReaderPaginator.paginateBlocks(
             blocks = blocks,
             config = ReaderPaginationConfig(
                 chapterIndex = chapter.index,
@@ -225,7 +229,8 @@ object LegacyReaderChapterPaginator {
                 letterSpacingPx = bodyPaint.letterSpacing * bodyPaint.textSize,
                 revision = revision,
             ),
-        )
+            )
+        }
         return LegacyReaderChapterPaginationResult.Success(pages)
     }
 }

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

@@ -0,0 +1,37 @@
+package io.legado.app.feature.reader.platform
+
+import android.os.Build
+import android.os.Trace
+import java.util.concurrent.atomic.AtomicInteger
+
+/**
+ * Small, reader-local Perfetto vocabulary. Keep sections coarse: the point is to explain the
+ * path to the first readable page, not to trace every Canvas draw or Compose recomposition.
+ */
+internal object ReaderPerfTrace {
+    private val nextAsyncCookie = AtomicInteger()
+    inline fun <T> section(name: String, block: () -> T): T {
+        Trace.beginSection("reader.$name")
+        return try {
+            block()
+        } finally {
+            Trace.endSection()
+        }
+    }
+
+    suspend fun <T> suspendSection(name: String, block: suspend () -> T): T {
+        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return block()
+        val cookie = nextAsyncCookie.incrementAndGet()
+        Trace.beginAsyncSection("reader.$name", cookie)
+        return try {
+            block()
+        } finally {
+            Trace.endAsyncSection("reader.$name", cookie)
+        }
+    }
+
+    fun marker(name: String) {
+        Trace.beginSection("reader.$name")
+        Trace.endSection()
+    }
+}

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

@@ -8,12 +8,12 @@ import io.legado.app.R
 import io.legado.app.constant.AppLog.putDebug
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookSource
+import io.legado.app.domain.gateway.DownloadCacheSettingsGateway
+import io.legado.app.domain.gateway.OtherSettingsGateway
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.isEpub
 import io.legado.app.help.book.isMobi
-import io.legado.app.domain.gateway.DownloadCacheSettingsGateway
-import io.legado.app.domain.gateway.OtherSettingsGateway
 import io.legado.app.help.book.isPdf
 import io.legado.app.model.localBook.EpubFile
 import io.legado.app.model.localBook.MobiFile
@@ -39,6 +39,9 @@ object ImageProvider {
         BitmapFactory.decodeResource(appCtx.resources, R.drawable.image_loading_error)
     }
 
+    /** Lets renderers keep the last good frame when a refreshed file cannot be decoded. */
+    fun isErrorBitmap(bitmap: Bitmap): Boolean = bitmap === errorBitmap
+
     /**
      * 缓存bitmap LruCache实现
      * filePath bitmap

+ 112 - 34
app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt

@@ -946,6 +946,7 @@ sealed interface ReadBookEffect {
     // Page anim changed — Activity calls readView.upPageAnim() + ReadBook.loadContent(false)
     data object PageAnimChanged : ReadBookEffect
     data class InvalidateReaderImage(val source: String) : ReadBookEffect
+    data class InvalidateReaderImages(val sources: Set<String>) : ReadBookEffect
 
     // Download chapters — Activity calls CacheBook.start()
     data class DownloadChapters(val start: Int, val end: Int) : ReadBookEffect
@@ -1041,7 +1042,10 @@ sealed interface ConfigUpdateAction {
     data object UpdateBackgroundAlpha : ConfigUpdateAction
     data object UpdatePageSlopSquare : ConfigUpdateAction
 
-    /** Re-fetch inline images after a reading-theme preset changes its text colors. */
+    /**
+     * 仅样式方案/预设切换使用:重拉当前窗口的书源图片,随后重载正文。
+     * 单项颜色修改对齐旧 View,不带本动作——旧 View 只有事件 5(重跑书源规则),没有「删缓存重下」。
+     */
     data object RefreshInlineImages : ConfigUpdateAction
     data object ReloadContent : ConfigUpdateAction
     data object RelayoutContent : ConfigUpdateAction
@@ -1063,7 +1067,20 @@ private val HEADER_FOOTER_TIP_ACTIONS = setOf(
 
 /**
  * Typed config mutations replace direct writes to the legacy ReadBookConfig facade.
- * Each variant carries [actions] that describe which UI updates are needed.
+ *
+ * `actions` 复刻旧 View 的 `EventBus.UP_CONFIG` 事件码(`4c088448c^` 的
+ * `ReadBookActivity.observeLiveBus()` 翻译表):0=UpdateSystemUi、1=UpdateBackground、
+ * 2=UpdateStyle、3=UpdateBackgroundAlpha、4=UpdatePageSlopSquare、5=ReloadContent、
+ * 6=UpdateContent、8=UpdateChapterStyle、9=InvalidateTextPage、10=UpdateLayout、
+ * 11=SubmitRenderTask。
+ *
+ * 除上述一一对应的动作外,只允许追加 Compose 渲染器必需项:
+ * - `RebuildWholeBookPageIndex`:整书页码估算(旧 View 没有这套机制);
+ * - `UpdateWholeBookPageDemand`:页眉页脚 `{FullPageIndex}` 一类占位符的求值需求;
+ * - `RefreshInlineImages`:仅样式方案/预设切换使用;
+ * - `UpdateSystemUi`:样式方案切换后刷新状态栏图标(Compose 不重建 Activity)。
+ *
+ * 新增或修改成员前,先到旧 View 对应弹窗里查它发的事件码,不要凭感觉填。
  */
 @Immutable
 sealed interface ConfigUpdate {
@@ -1091,11 +1108,25 @@ sealed interface ConfigUpdate {
     data class TextBold(val value: Int) : ConfigUpdate {
         override val actions = setOf(ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.InvalidateTextPage, ConfigUpdateAction.UpdateContent)
     }
+
+    // 正文/强调色对应旧 View 的 [2, 6, 9, 11](upStyle + upContent + invalidateTextPage +
+    // submitRenderTask)。旧 View 对这两项**不发事件 5**(loadContent),即不重新加载/解析正文,
+    // 只重绘已排好的页面;重绘由 invalidateTextPage/upContent 触发,故此处不带 ReloadContent。
     data class TextColor(val color: Int) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.ReloadContent, ConfigUpdateAction.InvalidateTextPage)
+        override val actions = setOf(
+            ConfigUpdateAction.UpdateStyle,
+            ConfigUpdateAction.UpdateContent,
+            ConfigUpdateAction.InvalidateTextPage,
+            ConfigUpdateAction.SubmitRenderTask,
+        )
     }
     data class TextAccentColor(val color: Int) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.ReloadContent, ConfigUpdateAction.InvalidateTextPage)
+        override val actions = setOf(
+            ConfigUpdateAction.UpdateStyle,
+            ConfigUpdateAction.UpdateContent,
+            ConfigUpdateAction.InvalidateTextPage,
+            ConfigUpdateAction.SubmitRenderTask,
+        )
     }
 
     // --- Title style ---
@@ -1126,23 +1157,45 @@ sealed interface ConfigUpdate {
     data class TitleBottomSpacing(val value: Int) : ConfigUpdate {
         override val actions = setOf(ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.ReloadContent)
     }
+
+    // 标题色对应旧 View 的 [8, 5](ChapterProvider.upStyle + loadContent(false))。这里的
+    // ReloadContent 就是旧事件 5:会重新加载正文并重排(章节正文缓存命中时是重新解析缓存内容,
+    // 缓存缺失时才重跑书源规则),与正文色只重绘的语义不同。
     data class TitleColor(val color: Int) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.ReloadContent, ConfigUpdateAction.InvalidateTextPage)
+        override val actions = setOf(
+            ConfigUpdateAction.UpdateChapterStyle,
+            ConfigUpdateAction.ReloadContent,
+        )
     }
     data class TitleColorNight(val color: Int) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.ReloadContent, ConfigUpdateAction.InvalidateTextPage)
+        override val actions = setOf(
+            ConfigUpdateAction.UpdateChapterStyle,
+            ConfigUpdateAction.ReloadContent,
+        )
     }
     data class TitleFont(val path: String) : ConfigUpdate {
         override val actions = setOf(ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.ReloadContent)
     }
+
+    // 旧 View 标题分段三项只发 [5](不重建 ChapterProvider 样式快照,因为分段不改变字形/画笔)。
+    // Compose 另需 RebuildWholeBookPageIndex:分段会改变整书页码估算。
     data class TitleSegType(val value: Int) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.ReloadContent)
+        override val actions = setOf(
+            ConfigUpdateAction.RebuildWholeBookPageIndex,
+            ConfigUpdateAction.ReloadContent,
+        )
     }
     data class TitleSegDistance(val value: Int) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.ReloadContent)
+        override val actions = setOf(
+            ConfigUpdateAction.RebuildWholeBookPageIndex,
+            ConfigUpdateAction.ReloadContent,
+        )
     }
     data class TitleSegFlag(val value: String) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.ReloadContent)
+        override val actions = setOf(
+            ConfigUpdateAction.RebuildWholeBookPageIndex,
+            ConfigUpdateAction.ReloadContent,
+        )
     }
 
     // --- Header / footer tips ---
@@ -1220,6 +1273,9 @@ sealed interface ConfigUpdate {
     }
 
     // --- Layout / style ---
+    // 旧 View 切样式方案 [1, 2, 5];Compose 另需 RefreshInlineImages(重拉书源图片)与
+    // RebuildWholeBookPageIndex(整书页码)以及 UpdateSystemUi(状态栏图标随样式里的
+    // darkStatusIcon 变化,Compose 不重建 Activity)。
     data class StyleSelect(val index: Int) : ConfigUpdate {
         override val actions = setOf(
             ConfigUpdateAction.UpdateBackground,
@@ -1228,45 +1284,50 @@ sealed interface ConfigUpdate {
             ConfigUpdateAction.RebuildWholeBookPageIndex,
             ConfigUpdateAction.ReloadContent,
             ConfigUpdateAction.UpdateSystemUi,
-            ConfigUpdateAction.UpdatePageAnim
         )
     }
+
+    // 旧 View shareLayout = [1, 2, 5]。
     data class ShareLayout(val value: Boolean) : ConfigUpdate {
         override val actions = setOf(
             ConfigUpdateAction.UpdateBackground,
             ConfigUpdateAction.UpdateStyle,
             ConfigUpdateAction.RebuildWholeBookPageIndex,
             ConfigUpdateAction.ReloadContent,
-            ConfigUpdateAction.UpdatePageAnim
         )
     }
+
+    // 旧 View 换翻页动画:upPageAnim() + loadContent(false)(滚动/翻页模式的页高不同,故 Compose
+    // 另需重建整书页码索引)。
     data class PageAnim(val value: Int) : ConfigUpdate {
         override val actions = setOf(
-            ConfigUpdateAction.UpdateBackground,
             ConfigUpdateAction.UpdatePageAnim,
             ConfigUpdateAction.RebuildWholeBookPageIndex,
-            ConfigUpdateAction.ReloadContent
+            ConfigUpdateAction.ReloadContent,
         )
     }
 
     // --- Menu colors ---
+    // 菜单外观是 Compose 时代新增的项(旧 View 无对应事件码)。它们只被阅读菜单读取,菜单由
+    // menuConfig 状态反应式重组,因此不驱动正文渲染副作用。唯一例外是 MenuBgColor:状态栏图标
+    // 在菜单显示时按 resolvedMenuBgColor 取反色(ReadBookController:1303),故需 UpdateSystemUi。
     data class MenuBgColor(val color: Int) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.UpdateBackground, ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.ReloadContent, ConfigUpdateAction.UpdateSystemUi)
+        override val actions = setOf(ConfigUpdateAction.UpdateSystemUi)
     }
     data class MenuAccentColor(val color: Int) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.UpdateBackground, ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.ReloadContent)
+        override val actions = emptySet<ConfigUpdateAction>()
     }
     data class MenuContainerColor(val color: Int) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.UpdateBackground, ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.ReloadContent)
+        override val actions = emptySet<ConfigUpdateAction>()
     }
     data class MenuBgColorNight(val color: Int) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.UpdateBackground, ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.ReloadContent, ConfigUpdateAction.UpdateSystemUi)
+        override val actions = setOf(ConfigUpdateAction.UpdateSystemUi)
     }
     data class MenuAccentColorNight(val color: Int) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.UpdateBackground, ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.ReloadContent)
+        override val actions = emptySet<ConfigUpdateAction>()
     }
     data class MenuContainerColorNight(val color: Int) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.UpdateBackground, ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.ReloadContent)
+        override val actions = emptySet<ConfigUpdateAction>()
     }
     data class MenuTextColor(val color: Int) : ConfigUpdate {
         override val actions = emptySet<ConfigUpdateAction>()
@@ -1282,14 +1343,15 @@ sealed interface ConfigUpdate {
     }
 
     // --- Menu bar border ---
+    // 只画在菜单栏上(ReadBookMenuBar/ReadBookMenuBarTitle 直接读 menuConfig),无正文副作用。
     data class BorderWidth(val value: Int) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.UpdateBackground, ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.ReloadContent)
+        override val actions = emptySet<ConfigUpdateAction>()
     }
     data class BorderColor(val color: Int) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.UpdateBackground, ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.ReloadContent)
+        override val actions = emptySet<ConfigUpdateAction>()
     }
     data class BorderColorNight(val color: Int) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.UpdateBackground, ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.ReloadContent)
+        override val actions = emptySet<ConfigUpdateAction>()
     }
 
     // --- Shadow ---
@@ -1306,7 +1368,13 @@ sealed interface ConfigUpdate {
         override val actions = setOf(ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.ReloadContent)
     }
     data class ShadowColor(val color: Int) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.ReloadContent, ConfigUpdateAction.InvalidateTextPage)
+        // 旧 View S_COLOR = [2, 6, 9, 11],与正文色同族(只重绘,不重载正文)。
+        override val actions = setOf(
+            ConfigUpdateAction.UpdateStyle,
+            ConfigUpdateAction.UpdateContent,
+            ConfigUpdateAction.InvalidateTextPage,
+            ConfigUpdateAction.SubmitRenderTask,
+        )
     }
 
     // --- Underline ---
@@ -1329,12 +1397,21 @@ sealed interface ConfigUpdate {
         override val actions = setOf(ConfigUpdateAction.UpdateContent, ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.UpdateLayout)
     }
     data class DottedRatio(val value: Float) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.UpdateContent, ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.UpdateLayout)
+        // 旧 View dottedRatio = [6, 9, 11](只重绘下划线)。
+        override val actions = setOf(
+            ConfigUpdateAction.UpdateContent,
+            ConfigUpdateAction.InvalidateTextPage,
+            ConfigUpdateAction.SubmitRenderTask,
+        )
     }
     data class UnderlineColor(val color: Int) : ConfigUpdate {
-        // 对齐兄弟项 Underline/DottedLine:改颜色要重绘已排好的 TextPage,
-        // 仅 UpdateStyle(readView.upStyle())不会重画下划线。
-        override val actions = setOf(ConfigUpdateAction.UpdateContent, ConfigUpdateAction.InvalidateTextPage, ConfigUpdateAction.SubmitRenderTask)
+        // 旧 View U_COLOR 连发 [2] 与 [6, 9, 11]:改颜色既要重建画笔又要重绘已排好的 TextPage。
+        override val actions = setOf(
+            ConfigUpdateAction.UpdateStyle,
+            ConfigUpdateAction.UpdateContent,
+            ConfigUpdateAction.InvalidateTextPage,
+            ConfigUpdateAction.SubmitRenderTask,
+        )
     }
 
     // --- Body padding ---
@@ -1386,23 +1463,24 @@ sealed interface ConfigUpdate {
     }
 
     // --- Background / display ---
+    // 旧 View 换背景色/背景图只发 [1](readView.upBg(),内部同时刷新 alpha)。
     data class BgStr(val value: String) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.UpdateBackground, ConfigUpdateAction.UpdateBackgroundAlpha, ConfigUpdateAction.ReloadContent, ConfigUpdateAction.UpdateSystemUi)
+        override val actions = setOf(ConfigUpdateAction.UpdateBackground)
     }
     data class BgStrNight(val value: String) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.UpdateBackground, ConfigUpdateAction.UpdateBackgroundAlpha, ConfigUpdateAction.ReloadContent, ConfigUpdateAction.UpdateSystemUi)
+        override val actions = setOf(ConfigUpdateAction.UpdateBackground)
     }
     data class BgStrEInk(val value: String) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.UpdateBackground, ConfigUpdateAction.UpdateBackgroundAlpha, ConfigUpdateAction.ReloadContent, ConfigUpdateAction.UpdateSystemUi)
+        override val actions = setOf(ConfigUpdateAction.UpdateBackground)
     }
     data class BgType(val value: Int) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.UpdateBackground, ConfigUpdateAction.UpdateBackgroundAlpha, ConfigUpdateAction.ReloadContent, ConfigUpdateAction.UpdateSystemUi)
+        override val actions = setOf(ConfigUpdateAction.UpdateBackground)
     }
     data class BgTypeNight(val value: Int) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.UpdateBackground, ConfigUpdateAction.UpdateBackgroundAlpha, ConfigUpdateAction.ReloadContent, ConfigUpdateAction.UpdateSystemUi)
+        override val actions = setOf(ConfigUpdateAction.UpdateBackground)
     }
     data class BgTypeEInk(val value: Int) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.UpdateBackground, ConfigUpdateAction.UpdateBackgroundAlpha, ConfigUpdateAction.ReloadContent, ConfigUpdateAction.UpdateSystemUi)
+        override val actions = setOf(ConfigUpdateAction.UpdateBackground)
     }
     data class BgAlpha(val value: Int) : ConfigUpdate {
         override val actions = setOf(ConfigUpdateAction.UpdateBackgroundAlpha)
@@ -1613,10 +1691,10 @@ sealed interface ConfigUpdate {
         override val actions = emptySet<ConfigUpdateAction>()
     }
     data class OptimizeRender(val value: Boolean) : ConfigUpdate {
+        // 旧 View optimizeRender:ChapterProvider.upStyle() + loadContent(false)。
         override val actions = setOf(
             ConfigUpdateAction.UpdateChapterStyle,
             ConfigUpdateAction.ReloadContent,
-            ConfigUpdateAction.SubmitRenderTask,
         )
     }
     data class ClickImgWay(val value: String) : ConfigUpdate {

+ 201 - 71
app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt

@@ -51,6 +51,7 @@ import io.legado.app.feature.reader.legacy.collectLegacyReaderPaginationBatch
 import io.legado.app.feature.reader.legacy.failureReasonFor
 import io.legado.app.feature.reader.legacy.paginateLegacyReaderChapterSafely
 import io.legado.app.feature.reader.platform.ReaderAndroidPaginationStyle
+import io.legado.app.feature.reader.platform.ReaderPerfTrace
 import io.legado.app.help.TTS
 import io.legado.app.help.book.isOnLineTxt
 import io.legado.app.help.config.ReadBookConfig
@@ -96,6 +97,7 @@ import kotlinx.coroutines.Dispatchers.Main
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.SupervisorJob
 import kotlinx.coroutines.async
+import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.asSharedFlow
@@ -145,11 +147,16 @@ class ReadBookController(
     private val readerImageLoads = ConcurrentHashMap<String, Deferred<android.graphics.Bitmap?>>()
     private val readerImageGenerations = ConcurrentHashMap<String, Long>()
     private var readerImageLoadJob = SupervisorJob()
+    private val readerImageRefreshMutex = Mutex()
+    private var readerChapterInputPublishJob: Job? = null
 
-    private fun readerImageCacheKey(element: ReaderElement.Image): String =
+    private fun readerImageCacheKey(
+        element: ReaderElement.Image,
+        generation: Long = readerImageGenerations[element.source] ?: 0L,
+    ): String =
         ReaderImageCachePolicy.withGeneration(
             ReaderImageCachePolicy.key(element),
-            readerImageGenerations[element.source] ?: 0L,
+            generation,
         )
 
     fun cachedReaderImage(element: ReaderElement.Image): android.graphics.Bitmap? =
@@ -170,39 +177,53 @@ class ReadBookController(
             }
     }
 
-    private fun invalidateReaderImages(sources: Set<String>) {
-        if (sources.isEmpty()) return
-        sources.forEach { source ->
-            readerImageGenerations.compute(source) { _, current -> (current ?: 0L) + 1L }
-        }
-        readerImageCache.snapshot().keys
-            .filter { key ->
-                sources.any { source ->
-                    ReaderImageCachePolicy.belongsToSource(
-                        key,
-                        source
-                    )
+    /**
+     * Mirrors the old View reader's order: load the replacement first, then invalidate the
+     * affected pages. Publishing a new generation before its bitmaps exist showed a placeholder
+     * frame; cancelling the previous request could also leave that placeholder stuck.
+     */
+    private suspend fun replaceReaderImages(sources: Set<String>) =
+        readerImageRefreshMutex.withLock {
+            if (sources.isEmpty()) return@withLock
+            val targetGenerations = sources.associateWith { source ->
+                (readerImageGenerations[source] ?: 0L) + 1L
+        }
+            val elementsBySource = directReaderPages
+                .asSequence()
+                .flatMap { page ->
+                    page.elements.asSequence().filterIsInstance<ReaderElement.Image>()
                 }
-            }
-            .forEach(readerImageCache::remove)
-        readerImageLoads.entries
-            .filter { (key, _) ->
-                sources.any { source ->
-                    ReaderImageCachePolicy.belongsToSource(
-                        key,
-                        source
-                    )
+                .filter { it.source in targetGenerations }
+                .distinctBy { element ->
+                    "${element.source}|${element.bounds.width.toInt()}|${element.bounds.height.toInt()}"
                 }
-            }
-            .forEach { (key, load) ->
-                if (readerImageLoads.remove(key, load)) {
-                    load.cancel()
+                .groupBy(ReaderElement.Image::source)
+            val readySources = buildSet {
+                elementsBySource.forEach { (source, elements) ->
+                    val generation = targetGenerations.getValue(source)
+                    val allLoaded = elements.all { element ->
+                        loadReaderImage(element, readerImageCacheKey(element, generation))
+                            ?.takeUnless(ImageProvider::isErrorBitmap) != null
+                    }
+                    if (allLoaded) {
+                        add(source)
+                    } else {
+                        // The target generation has not been published, so retaining its error
+                        // placeholder would make a later refresh reuse it instead of retrying.
+                        elements.forEach { element ->
+                            readerImageCache.remove(readerImageCacheKey(element, generation))
+                        }
+                    }
                 }
             }
+            if (readySources.isEmpty()) return@withLock
+            readySources.forEach { source ->
+                readerImageGenerations[source] = targetGenerations.getValue(source)
+            }
         val revisionSalt = System.nanoTime()
         var changed = false
         directReaderPages = directReaderPages.map { page ->
-            if (page.elements.any { it is ReaderElement.Image && it.source in sources }) {
+            if (page.elements.any { it is ReaderElement.Image && it.source in readySources }) {
                 changed = true
                 page.copy(revision = page.revision xor revisionSalt)
             } else {
@@ -212,8 +233,12 @@ class ReadBookController(
         if (changed) directReaderPageIndex?.let(::publishDirectReaderWindow)
     }
 
-    private fun invalidateReaderImage(source: String) = invalidateReaderImages(setOf(source))
-
+    /**
+     * Theme-style selection path, mirroring the legacy View's `[1, 2, 5]`: re-fetch the inline
+     * images of the current window, then reload the chapter so the new style's colors and page
+     * geometry replace the old ones. [replaceReaderImages] publishes the replacement bitmaps
+     * before the affected pages are invalidated, so no placeholder frame is shown.
+     */
     private fun refreshInlineImagesThenReload() {
         val sources = _readerPageWindow.value
             .let { window ->
@@ -229,21 +254,25 @@ class ReadBookController(
             .filterIsInstance<ReaderElement.Image>()
             .map(ReaderElement.Image::source)
             .toSet()
-        if (sources.isEmpty()) {
-            if (viewModel.isInitFinish) ReadBook.loadContent(resetPageOffset = false)
-            return
-        }
         activity.lifecycleScope.launch {
-            viewModel.refreshImageFiles(sources)
-            invalidateReaderImages(sources)
+            if (sources.isNotEmpty()) {
+                replaceReaderImages(viewModel.refreshImageFiles(sources))
+            }
             if (viewModel.isInitFinish) ReadBook.loadContent(resetPageOffset = false)
         }
     }
 
     /** Android image capability used by the Compose Canvas renderer. */
     suspend fun loadReaderImage(element: ReaderElement.Image): android.graphics.Bitmap? {
-        cachedReaderImage(element)?.let { return it }
-        val key = readerImageCacheKey(element)
+        return loadReaderImage(element, readerImageCacheKey(element))
+    }
+
+    private suspend fun loadReaderImage(
+        element: ReaderElement.Image,
+        key: String,
+    ): android.graphics.Bitmap? {
+        readerImageCache.get(key)?.takeUnless(android.graphics.Bitmap::isRecycled)
+            ?.let { return it }
         val candidate = CoroutineScope(readerImageLoadJob + IO).async(start = CoroutineStart.LAZY) {
             readerImageCache.get(key)?.takeUnless(android.graphics.Bitmap::isRecycled) ?: ReadBook.book?.let { book ->
                 ImageProvider.getImage(
@@ -252,7 +281,7 @@ class ReadBookController(
                     width = element.bounds.width.toInt().coerceAtLeast(1),
                     height = element.bounds.height.toInt().coerceAtLeast(1),
                 ).takeUnless(android.graphics.Bitmap::isRecycled)?.also { bitmap ->
-                    if (readerImageCacheKey(element) == key) readerImageCache.put(key, bitmap)
+                    readerImageCache.put(key, bitmap)
                 }
             }
         }
@@ -317,6 +346,7 @@ class ReadBookController(
     private var composeImageClickAt = 0L
     private var composeImageDoubleClick = false
     private var directReaderLayoutJob: Job? = null
+    private var directReaderAdjacentLayoutJob: Job? = null
     private var directReaderLayoutKey: String? = null
 
     /** Only chapter-window changes may reuse adjacent pages; a reflow invalidates their geometry. */
@@ -374,6 +404,8 @@ class ReadBookController(
         ReadBook.unregisterRender(this)
         directReaderLayoutJob?.cancel()
         directReaderLayoutJob = null
+        directReaderAdjacentLayoutJob?.cancel()
+        directReaderAdjacentLayoutJob = null
         readerImageLoads.values.forEach { it.cancel() }
         readerImageLoads.clear()
         readerImageCache.evictAll()
@@ -402,6 +434,7 @@ class ReadBookController(
     }
 
     fun onComposeRendererAttached() {
+        ReaderPerfTrace.marker("surface.attached")
         ReadBook.registerRender(this)
         publishReaderPageWindow()
     }
@@ -445,6 +478,8 @@ class ReadBookController(
 
     fun onComposeRendererDetached() {
         ReadBook.unregisterRender(this)
+        readerChapterInputPublishJob?.cancel()
+        readerChapterInputPublishJob = null
         cancelReaderImageLoadsExcept(emptySet())
         readerImageLoadJob.cancel()
         readerImageLoadJob = SupervisorJob()
@@ -644,6 +679,8 @@ class ReadBookController(
     private fun rebuildDirectReaderPages() {
         directReaderLayoutJob?.cancel()
         directReaderLayoutJob = null
+        directReaderAdjacentLayoutJob?.cancel()
+        directReaderAdjacentLayoutJob = null
         directReaderLayoutKey = null
         directReaderMayReuseAdjacentPages = false
         ReadBook.clearReaderPagination()
@@ -794,12 +831,19 @@ class ReadBookController(
     }
 
     override fun readerChapterInputChanged() {
-        pendingSearchNavigation?.let { navigation ->
-            ReadBook.readerChapterInputWindow.current
-                ?.takeIf { it.chapter.index == navigation.result.chapterIndex }
-                ?.let { resolveSearchNavigation(navigation, it) }
+        // Current/previous/next chapter inputs are published independently during opening.
+        // 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.
+        readerChapterInputPublishJob?.cancel()
+        readerChapterInputPublishJob = activity.lifecycleScope.launch {
+            delay(80)
+            pendingSearchNavigation?.let { navigation ->
+                ReadBook.readerChapterInputWindow.current
+                    ?.takeIf { it.chapter.index == navigation.result.chapterIndex }
+                    ?.let { resolveSearchNavigation(navigation, it) }
+            }
+            publishReaderPageWindow()
         }
-        publishReaderPageWindow()
     }
 
     private fun resolveSearchNavigation(
@@ -880,7 +924,10 @@ class ReadBookController(
             )
         }
         val key = buildString {
-            chapters.forEach { candidate ->
+            // The first readable page depends only on the current chapter. Including adjacent
+            // inputs here made their asynchronous arrival cancel and restart current-chapter
+            // measurement during opening. They remain in `chapters` for background prelayout.
+            listOf(chapter).forEach { candidate ->
                 append(LegacyReaderChapterLayoutIdentity(
                     chapterIndex = candidate.chapter.index,
                     chapterUrl = candidate.chapter.url,
@@ -937,6 +984,9 @@ class ReadBookController(
                 ReadBook.durChapterPos,
             ).also { directReaderPageIndex = it }
             publishDirectReaderWindow(index)
+            scheduleAdjacentReaderPagination(
+                key, chapter, chapters, width, height, contentPadding, resolvedPaginationStyle
+            )
             return true
         }
         // A neighboring chapter may already have a complete page set from the preceding
@@ -957,14 +1007,17 @@ class ReadBookController(
         if (directReaderLayoutKey != key) {
             val paginationGeneration = ReadBook.readerPaginationGeneration
             directReaderLayoutJob?.cancel()
+            directReaderAdjacentLayoutJob?.cancel()
+            directReaderAdjacentLayoutJob = null
             directReaderLayoutKey = key
             updateReaderPaginationError(null)
             ReadBook.clearReaderPagination()
             directReaderLayoutJob = activity.lifecycleScope.launch(IO) {
                 val highlightRules = HighlightRuleRepository()
                     .loadEnabled(ReadBookConfig.durConfig.name)
-                suspend fun paginate(candidate: ReaderChapterInput) =
-                    candidate.chapter.index to paginateLegacyReaderChapterSafely {
+                suspend fun paginate(candidate: ReaderChapterInput, phase: String) =
+                    candidate.chapter.index to ReaderPerfTrace.suspendSection("pagination.$phase") {
+                        paginateLegacyReaderChapterSafely {
                         LegacyReaderChapterPaginator.paginate(
                                 book = candidate.book,
                                 bookSource = candidate.bookSource,
@@ -982,8 +1035,10 @@ class ReadBookController(
                                 paginationStyle = resolvedPaginationStyle,
                                 highlightRules = highlightRules,
                             )
+                        }
                     }
-                val currentResult = paginate(chapter)
+
+                val currentResult = paginate(chapter, "current")
                 val currentBatch = collectLegacyReaderPaginationBatch(
                     currentChapterIndex = chapter.chapter.index,
                     results = listOf(currentResult),
@@ -997,28 +1052,76 @@ class ReadBookController(
                         paginationGeneration = paginationGeneration,
                         layoutComplete = chapters.size == 1 || !currentBatch.hasCurrentChapter,
                     )
+                    if (currentBatch.hasCurrentChapter) {
+                        scheduleAdjacentReaderPagination(
+                            key,
+                            chapter,
+                            chapters,
+                            width,
+                            height,
+                            contentPadding,
+                            resolvedPaginationStyle
+                        )
+                    }
                 }
-                if (!currentBatch.hasCurrentChapter || chapters.size == 1) return@launch
-                val adjacentResults = chapters
-                    .filterNot { it.chapter.index == chapter.chapter.index }
-                    .map { paginate(it) }
-                val completeBatch = collectLegacyReaderPaginationBatch(
-                    currentChapterIndex = chapter.chapter.index,
-                    results = adjacentResults + currentResult,
+            }
+        }
+        return false
+    }
+
+    private fun scheduleAdjacentReaderPagination(
+        key: String,
+        current: ReaderChapterInput,
+        chapters: List<ReaderChapterInput>,
+        width: Int,
+        height: Int,
+        padding: ReaderPadding,
+        style: ReaderAndroidPaginationStyle,
+    ) {
+        if (directReaderLayoutKey != key) return
+        val missing = chapters.filter { candidate ->
+            candidate.chapter.index != current.chapter.index &&
+                    directReaderPages.none { it.id.chapterIndex == candidate.chapter.index && !it.isPlaceholder }
+        }
+        if (missing.isEmpty()) return
+        directReaderAdjacentLayoutJob?.cancel()
+        val generation = ReadBook.readerPaginationGeneration
+        directReaderAdjacentLayoutJob = activity.lifecycleScope.launch(IO) {
+            val rules = HighlightRuleRepository().loadEnabled(ReadBookConfig.durConfig.name)
+            val results = missing.map { candidate ->
+                candidate.chapter.index to ReaderPerfTrace.suspendSection("pagination.adjacent") {
+                    paginateLegacyReaderChapterSafely {
+                        LegacyReaderChapterPaginator.paginate(
+                            book = candidate.book,
+                            bookSource = candidate.bookSource,
+                            chapter = candidate.chapter,
+                            displayTitle = candidate.displayTitle,
+                            content = candidate.content,
+                            source = candidate.source,
+                            revision = 31L * key.hashCode() + candidate.chapter.index,
+                            viewportWidthPx = width,
+                            viewportHeightPx = height,
+                            contentPaddingLeftPx = padding.left,
+                            contentPaddingTopPx = padding.top,
+                            contentPaddingRightPx = padding.right,
+                            contentPaddingBottomPx = padding.bottom,
+                            paginationStyle = style,
+                            highlightRules = rules,
+                        )
+                    }
+                }
+            }
+            withContext(Main) {
+                applyDirectReaderPaginationBatch(
+                    key, current, chapters,
+                    collectLegacyReaderPaginationBatch(current.chapter.index, results),
+                    generation, layoutComplete = true,
                 )
-                withContext(Main) {
-                    applyDirectReaderPaginationBatch(
-                        key = key,
-                        currentChapter = chapter,
-                        chapters = chapters,
-                        batch = completeBatch,
-                        paginationGeneration = paginationGeneration,
-                        layoutComplete = true,
-                    )
+                if (directReaderAdjacentLayoutJob === coroutineContext[Job]) {
+                    directReaderAdjacentLayoutJob = null
                 }
             }
         }
-        return false
     }
 
     private fun applyDirectReaderPaginationBatch(
@@ -1029,7 +1132,8 @@ class ReadBookController(
         paginationGeneration: Long,
         layoutComplete: Boolean,
     ) {
-        if (directReaderLayoutKey != key) return
+        ReaderPerfTrace.section("pagination.commit") {
+            if (directReaderLayoutKey != key) return@section
         if (layoutComplete) directReaderLayoutJob = null
         batch.unsupportedChapters.forEach { (chapterIndex, reason) ->
             AppLog.putDebug("Compose reader pagination unsupported: chapter=$chapterIndex reason=$reason")
@@ -1037,13 +1141,14 @@ class ReadBookController(
         updateReaderPaginationError(batch.failureReasonFor(currentChapter.chapter.index))
         val previousPages = directReaderPages.associateBy { it.id }
         val replacementChapterIndexes = batch.pages.mapTo(mutableSetOf()) { it.id.chapterIndex }
-        val retainedPages = if (directReaderMayReuseAdjacentPages) {
+            val retainedPages = if (directReaderMayReuseAdjacentPages ||
+                currentChapter.chapter.index !in replacementChapterIndexes
+            ) {
             directReaderPages.filterNot { it.id.chapterIndex in replacementChapterIndexes }
         } else {
             emptyList()
         }
-        val replacementPages =
-            batch.pages.takeIf { batch.hasCurrentChapter }.orEmpty().map { page ->
+            val replacementPages = batch.pages.map { page ->
             previousPages[page.id]?.takeIf(page::hasSameGeometryAs)?.let { previous ->
                 page.copy(layoutRevision = previous.layoutRevision)
             } ?: page
@@ -1085,6 +1190,7 @@ class ReadBookController(
             }
         )
         directReaderPageIndex?.let(::publishDirectReaderWindow)
+        }
     }
 
     fun onAppThemeChanged(isDarkTheme: Boolean) {
@@ -1094,6 +1200,13 @@ class ReadBookController(
         ) return
         val startedAt = System.nanoTime()
         val previous = ReadSessionState.isDarkThemeOverride
+        // ToggleDayNight installs the target override before DataStore publishes the app
+        // configuration, so a relayout started in that interval already uses the target
+        // colors. Restore the last applied mode briefly to obtain the source colors for
+        // any already-rendered pages, then apply the target mode atomically.
+        val sourceMode = appliedDarkTheme ?: previous
+        val modeChanged = appliedDarkTheme != null && appliedDarkTheme != isDarkTheme
+        if (sourceMode != null) ReadSessionState.isDarkThemeOverride = sourceMode
         val oldTextColor = ReadBookConfig.textColor
         val oldTitleColor = ReadBookConfig.resolvedTitleColor.takeIf { it != 0 } ?: oldTextColor
         val oldShadowColor = ReadBookConfig.textShadowColor
@@ -1126,6 +1239,13 @@ class ReadBookController(
         layoutController.viewport.value?.let { viewport ->
             updateComposeReaderBackground(viewport.widthPx, viewport.heightPx)
         }
+        // 旧 View 的日夜切换是重建整个阅读 Activity:`onDestroy` → `ReadBook.unregister()` →
+        // `ImageProvider.clear()`,重建后 `loadOrUpContent()` 重新加载正文。图片之所以会变,
+        // 是因为正文会被重新处理(替换规则 / 图片解码可以用 java.getThemeMode()、
+        // java.getThemeConfig() 产出与主题相关的图片),而不是因为清了解码缓存。
+        // Compose 不重建 Activity,这里走与样式方案切换相同的路径:
+        // 重下当前窗口图片 → 替换位图 → loadContent(false) 重新处理正文并重排。
+        if (modeChanged) refreshInlineImagesThenReload()
         upSystemUiVisibility()
         LogUtils.d(
             "ReadBookTheme",
@@ -1653,7 +1773,11 @@ class ReadBookController(
                         ConfigUpdateAction.UpdatePageSlopSquare,
                         ConfigUpdateAction.RefreshInlineImages -> Unit
 
-                        ConfigUpdateAction.ReloadContent -> if (!refreshInlineImages && viewModel.isInitFinish) {
+                        // 旧事件 5。带 RefreshInlineImages 的路径(样式方案/预设切换)由
+                        // refreshInlineImagesThenReload() 在图片替换完成后统一重载,避免两次重排。
+                        ConfigUpdateAction.ReloadContent -> if (
+                            !refreshInlineImages && viewModel.isInitFinish
+                        ) {
                             ReadBook.loadContent(resetPageOffset = false)
                         }
                         ConfigUpdateAction.RelayoutContent -> if (viewModel.isInitFinish) {
@@ -1707,7 +1831,13 @@ class ReadBookController(
                 _composeSelectionCancels.tryEmit(Unit)
             }
             is ReadBookEffect.MenuImageStyleChanged -> rebuildDirectReaderPages()
-            is ReadBookEffect.InvalidateReaderImage -> invalidateReaderImage(effect.source)
+            is ReadBookEffect.InvalidateReaderImage -> {
+                activity.lifecycleScope.launch { replaceReaderImages(setOf(effect.source)) }
+            }
+
+            is ReadBookEffect.InvalidateReaderImages -> {
+                activity.lifecycleScope.launch { replaceReaderImages(effect.sources) }
+            }
 
             // ── Simple Activity-API effects ──
             is ReadBookEffect.ShowToast -> activity.toastOnUi(effect.message)

+ 27 - 24
app/src/main/java/io/legado/app/ui/book/read/ReadBookLoadDelegate.kt

@@ -18,6 +18,7 @@ import io.legado.app.domain.usecase.ChangeBookSourceUseCase
 import io.legado.app.domain.usecase.GetReadingProgressUseCase
 import io.legado.app.domain.usecase.UploadReadingProgressUseCase
 import io.legado.app.exception.NoStackTraceException
+import io.legado.app.feature.reader.platform.ReaderPerfTrace
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.isLocal
 import io.legado.app.help.book.isLocalModified
@@ -110,26 +111,28 @@ class ReadBookLoadDelegate(
         success: (() -> Unit)? = null,
     ) {
         Coroutine.async(scope, Dispatchers.IO) {
-            host.syncReadPreferencesSnapshot()
-            ReadBook.inBookshelf = request.inBookshelf
-            ReadBook.chapterChanged = request.chapterChanged
-            val bookUrl = request.bookUrl
-            val book = initialBook ?: when {
-                bookUrl.isNullOrEmpty() -> bookRepository.getLastReadBook()
-                else -> bookRepository.getBook(bookUrl)
-            } ?: ReadBook.book
-            when {
-                book != null -> initBook(book)
-                else -> {
-                    ReadBook.upMsg(context.getString(R.string.no_book))
-                    AppLog.put("未找到书籍\nbookUrl:$bookUrl")
+            ReaderPerfTrace.suspendSection("open.init") {
+                host.syncReadPreferencesSnapshot()
+                ReadBook.inBookshelf = request.inBookshelf
+                ReadBook.chapterChanged = request.chapterChanged
+                val bookUrl = request.bookUrl
+                val book = initialBook ?: when {
+                    bookUrl.isNullOrEmpty() -> bookRepository.getLastReadBook()
+                    else -> bookRepository.getBook(bookUrl)
+                } ?: ReadBook.book
+                when {
+                    book != null -> initBook(book)
+                    else -> {
+                        ReadBook.upMsg(context.getString(R.string.no_book))
+                        AppLog.put("未找到书籍\nbookUrl:$bookUrl")
+                    }
+                }
+                val index = request.chapterIndex
+                val chapterPos = request.chapterPos
+                if (index >= 0 && chapterPos >= 0) {
+                    ReadBook.saveCurrentBookProgress()
+                    host.openChapter(index, chapterPos)
                 }
-            }
-            val index = request.chapterIndex
-            val chapterPos = request.chapterPos
-            if (index >= 0 && chapterPos >= 0) {
-                ReadBook.saveCurrentBookProgress()
-                host.openChapter(index, chapterPos)
             }
         }.onSuccess {
             success?.invoke()
@@ -143,7 +146,7 @@ class ReadBookLoadDelegate(
     }
 
     /** 换书/重装目录后重走开书流程。VM 的「模拟阅读切换」和目录权限回来后也调它。 */
-    suspend fun initBook(book: Book) {
+    suspend fun initBook(book: Book) = ReaderPerfTrace.suspendSection("open.book") {
         val isSameBook = ReadBook.book?.bookUrl == book.bookUrl
         if (isSameBook) {
             ReadBook.upData(book)
@@ -152,13 +155,13 @@ class ReadBookLoadDelegate(
         }
         host.setInitFinish()
         if (!book.isLocal && book.tocUrl.isEmpty() && !loadBookInfo(book)) {
-            return
+            return@suspendSection
         }
         if (book.isLocal && !checkLocalBookFileExist(book)) {
-            return
+            return@suspendSection
         }
         if ((ReadBook.chapterSize == 0 || book.isLocalModified()) && !loadChapterListAwait(book)) {
-            return
+            return@suspendSection
         }
         ReadBook.upMsg(null)
         host.checkReadRecordAlias(book)
@@ -197,7 +200,7 @@ class ReadBookLoadDelegate(
         }
         if (!book.isLocal && ReadBook.bookSource == null) {
             autoChangeSource(book.name, book.author)
-            return
+            return@suspendSection
         }
     }
 

+ 17 - 13
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -2364,24 +2364,28 @@ class ReadBookViewModel(
      */
     fun refreshImages(sources: Set<String>) {
         if (sources.isEmpty()) return
-        execute {
-            refreshImageFiles(sources)
-        }.onFinally {
-            sources.forEach { source ->
-                _effects.tryEmit(ReadBookEffect.InvalidateReaderImage(source))
+        viewModelScope.launch {
+            val refreshed = refreshImageFiles(sources)
+            if (refreshed.isNotEmpty()) {
+                _effects.tryEmit(ReadBookEffect.InvalidateReaderImages(refreshed))
             }
-            ReadBook.loadContent(false)
         }
     }
 
     /** Performs the file-cache half of image refresh before a renderer redraws it. */
-    suspend fun refreshImageFiles(sources: Set<String>) = withContext(IO) {
-        val book = ReadBook.book ?: return@withContext
-        sources.forEach { source ->
-            val vFile = BookHelp.getImage(book, source)
-            ImageProvider.bitmapLruCache.remove(vFile.absolutePath)
-            vFile.delete()
-            ImageProvider.cacheImage(book, source, ReadBook.bookSource)
+    suspend fun refreshImageFiles(sources: Set<String>): Set<String> = withContext(IO) {
+        val book = ReadBook.book ?: return@withContext emptySet()
+        buildSet {
+            sources.forEach { source ->
+                val refreshed = runCatching {
+                    val vFile = BookHelp.getImage(book, source)
+                    ImageProvider.bitmapLruCache.remove(vFile.absolutePath)
+                    vFile.delete()
+                    ImageProvider.cacheImage(book, source, ReadBook.bookSource)
+                    vFile.isFile && vFile.length() > 0L
+                }.getOrDefault(false)
+                if (refreshed) add(source)
+            }
         }
     }
 

+ 11 - 5
app/src/main/java/io/legado/app/ui/book/read/ReadStyleDelegate.kt

@@ -114,24 +114,24 @@ class ReadStyleDelegate(
     fun deleteCurrentStyle() {
         if (!readStyleGateway.deleteCurrentStyle()) return
         host.updateState { it.copy(activeSheet = null) }
+        // 旧 View deleteDur = [1, 2, 5]。
         emitConfigUpdate(
             ConfigUpdateAction.UpdateBackground,
             ConfigUpdateAction.UpdateStyle,
             ConfigUpdateAction.ReloadContent,
-            ConfigUpdateAction.UpdatePageAnim,
         )
     }
 
     fun applyPresetTheme(presetIndex: Int) {
         if (!readStyleGateway.applyPreset(presetIndex)) return
+        // 旧 View 恢复预设 = [1, 2, 5];Compose 另需 RefreshInlineImages(重拉书源图片)与
+        // UpdateSystemUi(状态栏图标随样式里的 darkStatusIcon 变化,Compose 不重建 Activity)。
         emitConfigUpdate(
             ConfigUpdateAction.UpdateBackground,
-            ConfigUpdateAction.UpdateBackgroundAlpha,
             ConfigUpdateAction.UpdateStyle,
             ConfigUpdateAction.UpdateSystemUi,
             ConfigUpdateAction.RefreshInlineImages,
             ConfigUpdateAction.ReloadContent,
-            ConfigUpdateAction.UpdatePageAnim,
         )
     }
 
@@ -167,11 +167,11 @@ class ReadStyleDelegate(
             val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
                 ?: throw FileNotFoundException(uri.toString())
             readStyleGateway.importCurrentStyle(bytes)
+            // 旧 View 导入配置 = [1, 2, 5]。
             emitConfigUpdate(
                 ConfigUpdateAction.UpdateBackground,
                 ConfigUpdateAction.UpdateStyle,
                 ConfigUpdateAction.ReloadContent,
-                ConfigUpdateAction.UpdatePageAnim,
             )
             context.getString(R.string.success)
         }
@@ -320,7 +320,13 @@ class ReadStyleDelegate(
     fun toggleDayNight() {
         lastSwitchDayNightReminderTime = System.currentTimeMillis()
         resetDayNightReminderDismissal()
-        val nextMode = if (host.isNightTheme) "1" else "2"
+        // ThemeSettings reaches AppUiConfiguration through DataStore asynchronously. The
+        // legacy View changed this mode synchronously before calling upBg()/upStyle(). Do
+        // the same for the reader resolver, otherwise the immediately-started pagination
+        // captures the old text color while Compose has already switched its background.
+        val nextIsNight = !(ReadSessionState.isDarkThemeOverride ?: host.isNightTheme)
+        val nextMode = if (nextIsNight) "2" else "1"
+        ReadSessionState.isDarkThemeOverride = nextIsNight
         scope.launch {
             appShellSettingsGateway.update { it.copy(themeMode = nextMode) }
         }

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

@@ -41,6 +41,19 @@ class ReaderLineSpacingTest {
         assertEquals(listOf(0f, 35f), page.elements.map { it.bounds.top })
     }
 
+    @Test
+    fun continuousScrollKeepsParagraphSpacingWhenItFallsAtAPageBoundary() {
+        val pages = ReaderPaginator.paginateBlocks(
+            listOf(inline("甲"), inline("乙")),
+            config.copy(continuousScroll = true, paragraphSpacingPx = 5f),
+        )
+
+        assertEquals(listOf("甲\n", "乙"), pages.map { it.text })
+        // The old View stacks the next TextPage after durY. That cursor includes the
+        // last line's advance (30px) and its paragraph gap (5px), not a whole viewport.
+        assertEquals(35f, pages.first().scrollExtentPx, 0f)
+    }
+
     @Test
     fun smallerInlineTextKeepsTheBodyLineBoxAndFollowingParagraphGap() {
         val small = style.copy(fontSizePx = 10f)

+ 8 - 0
app/src/test/java/io/legado/app/ui/book/read/ConfigUpdateActionsInvariantTest.kt

@@ -88,6 +88,14 @@ class ConfigUpdateActionsInvariantTest {
             // 阅读菜单外观
             "MenuTextColor",
             "MenuTextColorNight",
+            // 菜单强调色/容器色与菜单栏边框只被菜单读取(menuConfig 状态驱动),无正文副作用
+            "MenuAccentColor",
+            "MenuAccentColorNight",
+            "MenuContainerColor",
+            "MenuContainerColorNight",
+            "BorderWidth",
+            "BorderColor",
+            "BorderColorNight",
             "MenuIconShowText",
             "MenuIconStyle",
             "MenuIconItemsPerRow",

+ 279 - 0
app/src/test/java/io/legado/app/ui/book/read/LegacyConfigUpdateActionsTest.kt

@@ -0,0 +1,279 @@
+package io.legado.app.ui.book.read
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Test
+
+/**
+ * 锁定 [ConfigUpdate.actions] 与旧 View `EventBus.UP_CONFIG` 事件码的对应关系。
+ *
+ * 旧 View 证据:`git show 4c088448c^:app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt`
+ * 的 `observeLiveBus()` 翻译表,以及各弹窗(FontConfigDialog / TipConfigDialog /
+ * UnderlineConfigDialog / InfoConfigDialog / PaddingConfigDialog / MoreConfigDialog /
+ * BgTextConfigDialog / ReadStyleDialog / ShadowSetDialog / ReadConfigViewModel)里的
+ * `postEvent(EventBus.UP_CONFIG, arrayListOf(...))`。
+ *
+ * 事件码含义:0=UpdateSystemUi 1=UpdateBackground 2=UpdateStyle 3=UpdateBackgroundAlpha
+ * 4=UpdatePageSlopSquare 5=ReloadContent 6=UpdateContent 8=UpdateChapterStyle
+ * 9=InvalidateTextPage 10=UpdateLayout 11=SubmitRenderTask。
+ *
+ * 允许的 Compose 追加项(旧 View 无对应码):
+ * `RebuildWholeBookPageIndex`(整书页码估算)、`UpdateWholeBookPageDemand`({FullPageIndex} 类
+ * 页眉页脚)、`RefreshInlineImages`(样式方案/预设切换)、`UpdateSystemUi`(样式方案切换后刷新
+ * 状态栏图标)。
+ */
+class LegacyConfigUpdateActionsTest {
+
+    private val US = ConfigUpdateAction.UpdateStyle
+    private val UC = ConfigUpdateAction.UpdateContent
+    private val UBA = ConfigUpdateAction.UpdateBackgroundAlpha
+    private val UB = ConfigUpdateAction.UpdateBackground
+    private val USysUI = ConfigUpdateAction.UpdateSystemUi
+    private val RC = ConfigUpdateAction.ReloadContent
+    private val UCS = ConfigUpdateAction.UpdateChapterStyle
+    private val ITP = ConfigUpdateAction.InvalidateTextPage
+    private val UL = ConfigUpdateAction.UpdateLayout
+    private val SRT = ConfigUpdateAction.SubmitRenderTask
+    private val PA = ConfigUpdateAction.UpdatePageAnim
+    private val RB = ConfigUpdateAction.RebuildWholeBookPageIndex
+    private val DM = ConfigUpdateAction.UpdateWholeBookPageDemand
+    private val RII = ConfigUpdateAction.RefreshInlineImages
+
+    private data class Case(
+        val legacy: String,
+        val update: ConfigUpdate,
+        val expected: Set<ConfigUpdateAction>,
+    )
+
+    private val cases = listOf(
+        // ── 正文样式 ──
+        Case("FontConfigDialog 缩进 8,5", ConfigUpdate.ParagraphIndent(" "), setOf(UCS, RC)),
+        Case("FontConfigDialog 字重 8,9,6", ConfigUpdate.TextBold(600), setOf(UCS, ITP, UC)),
+        Case("FontConfigDialog 字距 8,5", ConfigUpdate.LetterSpacing(0.1f), setOf(UCS, RC)),
+        Case("FontConfigDialog 行距 8,5", ConfigUpdate.LineSpacing(3), setOf(UCS, RC)),
+        Case("FontConfigDialog 段距 8,5", ConfigUpdate.ParagraphSpacing(2), setOf(UCS, RC)),
+        Case("FontConfigDialog 斜体 8,5", ConfigUpdate.TextItalic(true), setOf(UCS, RC)),
+        Case("ReadStyleDialog 字号 8,5", ConfigUpdate.TextSize(22), setOf(UCS, RC)),
+        Case("TEXT_COLOR 2,6,9,11", ConfigUpdate.TextColor(0x112233), setOf(US, UC, ITP, SRT)),
+        Case(
+            "TEXT_ACCENT_COLOR 2,6,9,11",
+            ConfigUpdate.TextAccentColor(0x112233),
+            setOf(US, UC, ITP, SRT)
+        ),
+
+        // ── 阴影 ──
+        Case("FontConfigDialog 阴影开关 8,5", ConfigUpdate.TextShadow(true), setOf(UCS, RC)),
+        Case("ShadowSetDialog 阴影半径 8,5", ConfigUpdate.ShadowRadius(1f), setOf(UCS, RC)),
+        Case("ShadowSetDialog 阴影 dx 8,5", ConfigUpdate.ShadowDx(1f), setOf(UCS, RC)),
+        Case("ShadowSetDialog 阴影 dy 8,5", ConfigUpdate.ShadowDy(1f), setOf(UCS, RC)),
+        Case("S_COLOR 2,6,9,11", ConfigUpdate.ShadowColor(0x112233), setOf(US, UC, ITP, SRT)),
+
+        // ── 标题 ──
+        Case("TipConfigDialog 标题字体 8,5", ConfigUpdate.TitleFont(""), setOf(UCS, RC)),
+        Case("TipConfigDialog 标题字重 8,9,6", ConfigUpdate.TitleBold(600), setOf(UCS, ITP, UC)),
+        Case("TipConfigDialog 标题模式 5(+RB)", ConfigUpdate.TitleMode(1), setOf(RB, RC)),
+        Case("TipConfigDialog 分段类型 5(+RB)", ConfigUpdate.TitleSegType(1), setOf(RB, RC)),
+        Case("TipConfigDialog 分段距离 5(+RB)", ConfigUpdate.TitleSegDistance(1), setOf(RB, RC)),
+        Case("TipConfigDialog 分段标志 5(+RB)", ConfigUpdate.TitleSegFlag(" "), setOf(RB, RC)),
+        Case("TipConfigDialog 标题缩放 8,5", ConfigUpdate.TitleSegScaling(1f), setOf(UCS, RC)),
+        Case(
+            "TipConfigDialog 标题行距(外) 8,5",
+            ConfigUpdate.TitleLineSpacingExtra(1),
+            setOf(UCS, RC)
+        ),
+        Case(
+            "TipConfigDialog 标题行距(内) 8,5",
+            ConfigUpdate.TitleLineSpacingSub(1),
+            setOf(UCS, RC)
+        ),
+        Case("TipConfigDialog 标题字号 8,5", ConfigUpdate.TitleSize(24), setOf(UCS, RC)),
+        Case("TipConfigDialog 标题上距 8,5", ConfigUpdate.TitleTopSpacing(1), setOf(UCS, RC)),
+        Case("TipConfigDialog 标题下距 8,5", ConfigUpdate.TitleBottomSpacing(1), setOf(UCS, RC)),
+        Case("TITLE_COLOR 8,5", ConfigUpdate.TitleColor(0x112233), setOf(UCS, RC)),
+        Case("标题夜间色 同 TITLE_COLOR", ConfigUpdate.TitleColorNight(0x112233), setOf(UCS, RC)),
+
+        // ── 页眉页脚 ──
+        Case("InfoConfigDialog 页眉项 2,6(+DM)", ConfigUpdate.TipHeaderLeft(1), setOf(US, UC, DM)),
+        Case("InfoConfigDialog 页脚项 2,6(+DM)", ConfigUpdate.TipFooterRight(1), setOf(US, UC, DM)),
+        Case(
+            "InfoConfigDialog 自定义页眉项 2,6(+DM)",
+            ConfigUpdate.CustomTipHeaderLeft("{x}"),
+            setOf(US, UC, DM)
+        ),
+        Case("InfoConfigDialog 页眉模式 2", ConfigUpdate.HeaderMode(1), setOf(US)),
+        Case("InfoConfigDialog 页脚模式 2", ConfigUpdate.FooterMode(1), setOf(US)),
+        Case("InfoConfigDialog 页眉字体 2", ConfigUpdate.HeaderFont(""), setOf(US)),
+        Case("InfoConfigDialog 页眉字号 2", ConfigUpdate.HeaderFontSize(12), setOf(US)),
+        Case("TIP_HEADER_COLOR 2", ConfigUpdate.TipHeaderColor(0x112233), setOf(US)),
+        Case("TIP_FOOTER_COLOR 2", ConfigUpdate.TipFooterColor(0x112233), setOf(US)),
+        Case("TIP_DIVIDER_COLOR 2", ConfigUpdate.TipDividerColor(0x112233), setOf(US)),
+
+        // ── 下划线 ──
+        Case(
+            "UnderlineConfigDialog 下划线 6,9,11",
+            ConfigUpdate.Underline(true),
+            setOf(UC, ITP, SRT)
+        ),
+        Case(
+            "UnderlineConfigDialog 点线 6,9,11",
+            ConfigUpdate.DottedLine(true),
+            setOf(UC, ITP, SRT)
+        ),
+        Case(
+            "UnderlineConfigDialog 下划线延长 6,9,11",
+            ConfigUpdate.UnderlineExtend(true),
+            setOf(UC, ITP, SRT)
+        ),
+        Case(
+            "UnderlineConfigDialog 下划线高度 8,9,6",
+            ConfigUpdate.UnderlineHeight(2),
+            setOf(UCS, ITP, UC)
+        ),
+        Case(
+            "UnderlineConfigDialog 下划线间距 8,9,6",
+            ConfigUpdate.UnderlinePadding(2),
+            setOf(UCS, ITP, UC)
+        ),
+        Case(
+            "BgTextConfigDialog 点线基准 6,8,10",
+            ConfigUpdate.DottedBase(0.1f),
+            setOf(UC, UCS, UL)
+        ),
+        Case(
+            "BgTextConfigDialog 点线比例 6,9,11",
+            ConfigUpdate.DottedRatio(0.1f),
+            setOf(UC, ITP, SRT)
+        ),
+        Case("U_COLOR 2 + 6,9,11", ConfigUpdate.UnderlineColor(0x112233), setOf(US, UC, ITP, SRT)),
+
+        // ── 内边距 ──
+        Case("PaddingConfigDialog 上内边距 10,5", ConfigUpdate.PaddingTop(1), setOf(UL, RC)),
+        Case("PaddingConfigDialog 下内边距 10,5", ConfigUpdate.PaddingBottom(1), setOf(UL, RC)),
+        Case("PaddingConfigDialog 左内边距 10,5", ConfigUpdate.PaddingLeft(1), setOf(UL, RC)),
+        Case("PaddingConfigDialog 右内边距 10,5", ConfigUpdate.PaddingRight(1), setOf(UL, RC)),
+        Case("PaddingConfigDialog 页眉上内边距 2", ConfigUpdate.HeaderPaddingTop(1), setOf(US)),
+        Case("PaddingConfigDialog 页脚右内边距 2", ConfigUpdate.FooterPaddingRight(1), setOf(US)),
+        Case("PaddingConfigDialog 显示页眉分隔线 2", ConfigUpdate.ShowHeaderLine(true), setOf(US)),
+        Case("PaddingConfigDialog 显示页脚分隔线 2", ConfigUpdate.ShowFooterLine(true), setOf(US)),
+
+        // ── 背景与系统 UI ──
+        Case("BgTextConfigDialog 背景色/图 1", ConfigUpdate.BgStr("#112233"), setOf(UB)),
+        Case("BgTextConfigDialog 背景类型 1", ConfigUpdate.BgType(1), setOf(UB)),
+        Case("BgTextConfigDialog 背景透明度 3", ConfigUpdate.BgAlpha(80), setOf(UBA)),
+        Case(
+            "BgTextConfigDialog 深色状态栏图标 0",
+            ConfigUpdate.StatusIconDark(true),
+            setOf(USysUI)
+        ),
+        Case(
+            "MoreConfigDialog 隐藏状态栏 0,2",
+            ConfigUpdate.HideStatusBar(true),
+            setOf(USysUI, US)
+        ),
+        Case(
+            "MoreConfigDialog 隐藏导航栏 0,2",
+            ConfigUpdate.HideNavigationBar(true),
+            setOf(USysUI, US)
+        ),
+        Case(
+            "MoreConfigDialog 刘海屏内边距 2",
+            ConfigUpdate.PaddingDisplayCutouts(true),
+            setOf(US)
+        ),
+
+        // ── 排版布局 ──
+        Case(
+            "ReadStyleDialog 样式方案 1,2,5(+RII,RB,USysUI)",
+            ConfigUpdate.StyleSelect(1),
+            setOf(UB, US, RII, RB, RC, USysUI),
+        ),
+        Case(
+            "ReadStyleDialog 分享排版 1,2,5(+RB)",
+            ConfigUpdate.ShareLayout(true),
+            setOf(UB, US, RB, RC)
+        ),
+        Case(
+            "ReadStyleDialog 翻页动画 upPageAnim+5(+RB)",
+            ConfigUpdate.PageAnim(2),
+            setOf(PA, RB, RC)
+        ),
+        Case("MoreConfigDialog 两端对齐 5(+RB)", ConfigUpdate.TextFullJustify(true), setOf(RB, RC)),
+        Case(
+            "MoreConfigDialog 底部对齐 5(+RB)",
+            ConfigUpdate.TextBottomJustify(true),
+            setOf(RB, RC)
+        ),
+        Case("MoreConfigDialog 中文排版 5(+RB)", ConfigUpdate.UseZhLayout(true), setOf(RB, RC)),
+        Case(
+            "MoreConfigDialog 特殊样式适配 5(+RB)",
+            ConfigUpdate.AdaptSpecialStyle(true),
+            setOf(RB, RC)
+        ),
+        Case("MoreConfigDialog 全局下划线 5", ConfigUpdate.UseUnderlineGlobal(true), setOf(RC)),
+        Case(
+            "MoreConfigDialog 横屏双页 10,5",
+            ConfigUpdate.DoubleHorizontalPage("1"),
+            setOf(UL, RC)
+        ),
+        Case("MoreConfigDialog 优化渲染 8,5", ConfigUpdate.OptimizeRender(true), setOf(UCS, RC)),
+        Case(
+            "ReadStyleDialog 简繁转换 5(+RB)",
+            ConfigUpdate.ChineseConverterType(1),
+            setOf(RB, RC)
+        ),
+        Case(
+            "MoreConfigDialog 正文延伸至刘海 recreate(+RB)",
+            ConfigUpdate.ReadBodyToLh(true),
+            setOf(RB, RC)
+        ),
+    )
+
+    @Test
+    fun `actions mirror the legacy View UP_CONFIG event codes`() {
+        cases.forEach { case ->
+            assertEquals(
+                "${case.update.javaClass.simpleName}(旧 View ${case.legacy})",
+                case.expected,
+                case.update.actions,
+            )
+        }
+    }
+
+    @Test
+    fun `single color edits never re-download inline images`() {
+        cases.asSequence()
+            .filter { it.legacy.endsWith("COLOR 2,6,9,11") || it.legacy.startsWith("TITLE_COLOR") }
+            .forEach { case ->
+                assertFalse(
+                    case.update.javaClass.simpleName,
+                    RII in case.update.actions,
+                )
+            }
+    }
+
+    /**
+     * 菜单颜色/边框是 Compose 时代新增项(旧 View 无对应事件码),只被阅读菜单读取。
+     * 除 `MenuBgColor` 需要刷新状态栏图标外,不得驱动任何正文副作用——尤其不能带
+     * `ReloadContent`(改个菜单颜色就重载正文)。
+     */
+    @Test
+    fun `menu appearance updates never reload or repaint the reading content`() {
+        listOf(
+            ConfigUpdate.MenuBgColor(0x112233),
+            ConfigUpdate.MenuBgColorNight(0x112233),
+            ConfigUpdate.MenuAccentColor(0x112233),
+            ConfigUpdate.MenuAccentColorNight(0x112233),
+            ConfigUpdate.MenuContainerColor(0x112233),
+            ConfigUpdate.MenuContainerColorNight(0x112233),
+            ConfigUpdate.BorderWidth(1),
+            ConfigUpdate.BorderColor(0x112233),
+            ConfigUpdate.BorderColorNight(0x112233),
+        ).forEach { update ->
+            assertEquals(
+                "${update.javaClass.simpleName} 最多只允许 UpdateSystemUi",
+                emptySet<ConfigUpdateAction>(),
+                update.actions - USysUI,
+            )
+        }
+    }
+}