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

refactor(R4.1): 阅读器渲染层的排版直读全部收进快照

R4「配置底座」的渲染层部分(审计 F-11)。先说一条复核结论:**路线图记的靶子数是
高估的**,逐处分类后(不是 grep 计数):

| 位置 | 路线图 | 实际 |
|---|---|---|
| PageView | 70 处 | 29 处真状态读;41 处是 `tipChapterTitle` 这类 const |
| ChapterProvider | 62 处 | 61 处里 9 处在 669 行块注释的死代码里;剩 52 处全在 `upStyle`/`upThemeColors`/`getPaints`/`withLayout`——已是「一处读全局、发布快照」的正确形态,不是债 |
| TextChapterLayout | — | 已经是对的:构造期一次 `layoutMetrics()` + 抄 5 项配置进实例字段 |

真正剩下的是**绘制路径**。代价也不只是分层:`ReadBookConfig.underline` 要走
`config → durConfig → getConfig(styleSelect)`,而 `getConfig` 是 `@Synchronized`
——放在 `draw()` 里就是每行每列每帧抢一次全局配置的监视器锁。

新增 `ChapterProvider.RenderStyle`(10 项:正文/强调/标题色、下划线 5 项、底部对齐)。
`TextLine`/`TextColumn`/`TextHtmlColumn`/`TextPage` 对 `ReadBookConfig` 的引用归零。

**关键坑**:`Underline`/`DottedLine`/`UnderlineExtend`/`UnderlineColor`/
`TextBottomJustify` 五个 `ConfigUpdate` 变体的 action 集里**没有 `UpdateStyle`**
(ReadBookContract.kt:1199-1224、:1414),靠 `upStyle()` 顺带重建必漏;而且 `actions`
是 `Set`,也保证不了「重建」排在 `InvalidateTextPage`/`SubmitRenderTask` 之前。

所以**没有新增 action**,而是在 `ReadBookController` 的 `UpdateReadViewConfig` 分支
**进入循环前**无条件重建(纯派生、幂等、十几个字段),另加进 `upStyle()`/
`upThemeColors()` 覆盖 `ApplyReadSettingUseCase` 那条不过 controller 的路径。

1. 21 个 `const val tipXxx` 从 `ReadBookConfig` 搬到 `constant/ReadTipType.kt`(仿
   `PageAnim`)。全仓仅 3 个文件 53 处引用。`tipValues` 留在配置侧——它和
   `R.array.read_tip` 的展示顺序一一对应。**这步是 PageView 护栏的前提**:不搬走
   常量,「渲染层不得引用 ReadBookConfig」根本没法断言。
2. 新增 `TipStyleProvider.TipStyle`(33 项)+ `tipValueOf/customTemplateOf`。
   `PageView` 的 `ReadBookConfig` 引用归零。

顺带修掉一处真实开销:`loadTypeface(headerFont)` 是**主线程按路径读字体文件**,而
`PageView` 有 prev/cur/next 三个实例——一次样式变更读三次。现在 provider 内按
`cachedFontPath` 缓存,只在字体路径变化时读一次。

`style` 用 `current ?: upTipStyle()` 懒初始化:`PageView.init` 就要用它,早于任何
显式重建,读默认值会让首帧的页眉页脚全错。

`RenderStyleSnapshotBoundaryTest` 四条:绘制实体不直读 / PageView 不直读 /
`upRenderStyle` 覆盖 `RenderStyle` 每个字段 / `upTipStyle` 覆盖 `TipStyle` 每个字段。
**四条都单独注入回归反证过会红**。全量单测 445 → 447。

写字段覆盖那两条时踩了个坑:`upRenderStyle()` 这个函数名**本身就含子串
`RenderStyle(`**,从函数名起点找构造调用会匹配到那对空括号,断言变成「全部字段
都没赋值」。要从签名末尾开始找。

`ChapterProvider.linePaint` 是 `by lazy`,所以改 `underlineHeight` 对朗读/搜索高亮
那条线从来不生效(既有隐患,本次未修);669 行块注释的死代码未删;
`ReadBookStyleConfigRepository` 读 `ReadBookConfig` 一百多处的所有权反转仍在,
是 R4 剩下的大头。

正文:下划线开关/虚线/延伸/颜色/粗细/间距、正文底部对齐、正文色/强调色/标题色。
页眉页脚:显示模式、六个位置各放什么、自定义模板、页眉字体与字号、页眉页脚颜色、
分隔线颜色与开关、内边距、隐藏状态栏/导航栏、背景透明度。
另加日夜切换、切换排版方案、应用排版预设、导入排版配置。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5151561 3 месяцев назад
Родитель
Сommit
f83381c10f

+ 36 - 0
app/src/main/java/io/legado/app/constant/ReadTipType.kt

@@ -0,0 +1,36 @@
+package io.legado.app.constant
+
+/**
+ * 页眉/页脚每个位置可以显示的信息类型。
+ *
+ * 这批值是编译期常量、不是配置状态——它们过去挂在 `ReadBookConfig` 上,导致
+ * 「渲染层还直读多少配置」这类统计和护栏没法写:`PageView` 里 70 处 `ReadBookConfig.`
+ * 有 41 处其实只是引用这些常量。
+ *
+ * 真正的配置项是「哪个位置放哪个类型」,即 `ReadBookConfig.tipHeaderLeft` 等六项,
+ * 它们的取值来自这里。
+ */
+@Suppress("ConstPropertyName")
+object ReadTipType {
+    const val tipNone = 0
+    const val tipChapterTitle = 1
+    const val tipTime = 2
+    const val tipBattery = 3
+    const val tipPage = 4
+    const val tipTotalProgress = 5
+    const val tipPageAndTotal = 6
+    const val tipBookName = 7
+    const val tipTimeBattery = 8
+    const val tipTimeBatteryPercentage = 9
+    const val tipBatteryPercentage = 10
+    const val tipTotalProgress1 = 11
+    const val tipChapterTitleArrow = 12
+    const val tipBatteryInside = 13
+    const val tipBatteryIcon = 14
+    const val tipBatteryClassic = 15
+    const val tipTimeBatteryClassic = 16
+    const val tipChapterTitleArrowClassic = 17
+    const val tipCustom = 18
+    const val tipWholeBookPage = 19
+    const val tipWholeBookPageAndProgress = 20
+}

+ 17 - 36
app/src/main/java/io/legado/app/help/config/ReadBookConfig.kt

@@ -7,6 +7,7 @@ import androidx.annotation.Keep
 import androidx.core.graphics.toColorInt
 import io.legado.app.R
 import io.legado.app.constant.PageAnim
+import io.legado.app.constant.ReadTipType
 import io.legado.app.data.entities.HighlightRule
 import io.legado.app.data.repository.ReadStyleRepository
 import io.legado.app.domain.gateway.ReadSettingsGateway
@@ -40,29 +41,6 @@ object ReadBookConfig {
         initShareConfig()
     }
 
-    // region Tip position constants
-    const val tipNone = 0
-    const val tipChapterTitle = 1
-    const val tipTime = 2
-    const val tipBattery = 3
-    const val tipBatteryPercentage = 10
-    const val tipPage = 4
-    const val tipTotalProgress = 5
-    const val tipPageAndTotal = 6
-    const val tipBookName = 7
-    const val tipTimeBattery = 8
-    const val tipTimeBatteryPercentage = 9
-    const val tipTotalProgress1 = 11
-    const val tipChapterTitleArrow = 12
-    const val tipBatteryInside = 13
-    const val tipBatteryIcon = 14
-    const val tipBatteryClassic = 15
-    const val tipTimeBatteryClassic = 16
-    const val tipChapterTitleArrowClassic = 17
-    const val tipCustom = 18
-    const val tipWholeBookPage = 19
-    const val tipWholeBookPageAndProgress = 20
-    // endregion
 
     const val configFileName = "readConfig.json"
     const val shareConfigFileName = "shareReadConfig.json"
@@ -769,13 +747,16 @@ object ReadBookConfig {
             config.tipDividerColor = value
         }
 
-    val tipValues = arrayOf(
-        tipNone, tipBookName, tipChapterTitle, tipChapterTitleArrow, tipChapterTitleArrowClassic,
-        tipTime, tipBattery, tipBatteryClassic, tipBatteryInside, tipBatteryIcon, tipBatteryPercentage,
-        tipPage, tipTotalProgress, tipTotalProgress1, tipPageAndTotal, tipTimeBattery,
-        tipTimeBatteryClassic, tipTimeBatteryPercentage, tipWholeBookPage,
-        tipWholeBookPageAndProgress, tipCustom
-    )
+    /** 与 `R.array.read_tip` 一一对应的展示顺序,故留在配置侧而不是 [ReadTipType]。 */
+    val tipValues = with(ReadTipType) {
+        arrayOf(
+            tipNone, tipBookName, tipChapterTitle, tipChapterTitleArrow, tipChapterTitleArrowClassic,
+            tipTime, tipBattery, tipBatteryClassic, tipBatteryInside, tipBatteryIcon,
+            tipBatteryPercentage, tipPage, tipTotalProgress, tipTotalProgress1, tipPageAndTotal,
+            tipTimeBattery, tipTimeBatteryClassic, tipTimeBatteryPercentage, tipWholeBookPage,
+            tipWholeBookPageAndProgress, tipCustom
+        )
+    }
     val tipNames get() = appCtx.resources.getStringArray(R.array.read_tip).toList()
     val tipColorNames get() = appCtx.resources.getStringArray(R.array.tip_color).toList()
     val tipDividerColorNames get() = appCtx.resources.getStringArray(R.array.tip_divider_color).toList()
@@ -966,12 +947,12 @@ object ReadBookConfig {
         var footerPaddingTop: Int = 6,
         var showHeaderLine: Boolean = false,
         var showFooterLine: Boolean = true,
-        var tipHeaderLeft: Int = tipTime,
-        var tipHeaderMiddle: Int = tipNone,
-        var tipHeaderRight: Int = tipBattery,
-        var tipFooterLeft: Int = tipChapterTitle,
-        var tipFooterMiddle: Int = tipNone,
-        var tipFooterRight: Int = tipPageAndTotal,
+        var tipHeaderLeft: Int = ReadTipType.tipTime,
+        var tipHeaderMiddle: Int = ReadTipType.tipNone,
+        var tipHeaderRight: Int = ReadTipType.tipBattery,
+        var tipFooterLeft: Int = ReadTipType.tipChapterTitle,
+        var tipFooterMiddle: Int = ReadTipType.tipNone,
+        var tipFooterRight: Int = ReadTipType.tipPageAndTotal,
         var customTipHeaderLeft: String = "",
         var customTipHeaderMiddle: String = "",
         var customTipHeaderRight: String = "",

+ 4 - 3
app/src/main/java/io/legado/app/model/FullBookPaginator.kt

@@ -1,5 +1,6 @@
 package io.legado.app.model
 
+import io.legado.app.constant.ReadTipType
 import io.legado.app.help.book.isLocal
 import io.legado.app.help.config.CustomTipPlaceholder
 import io.legado.app.help.config.ReadBookConfig
@@ -68,9 +69,9 @@ object FullBookPaginator {
                 ReadBookConfig.customTipFooterRight,
             ),
         ),
-        customTipValue = ReadBookConfig.tipCustom,
-        wholeBookPageTipValue = ReadBookConfig.tipWholeBookPage,
-        wholeBookPageAndProgressTipValue = ReadBookConfig.tipWholeBookPageAndProgress,
+        customTipValue = ReadTipType.tipCustom,
+        wholeBookPageTipValue = ReadTipType.tipWholeBookPage,
+        wholeBookPageAndProgressTipValue = ReadTipType.tipWholeBookPageAndProgress,
     )
 }
 

+ 6 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt

@@ -51,6 +51,7 @@ import io.legado.app.ui.book.read.page.entities.TextChapter
 import io.legado.app.ui.book.read.page.entities.TextPage
 import io.legado.app.ui.book.read.page.entities.TextPos
 import io.legado.app.ui.book.read.page.provider.ChapterProvider
+import io.legado.app.ui.book.read.page.provider.TipStyleProvider
 import io.legado.app.ui.book.read.page.provider.TextPageFactory
 import io.legado.app.ui.config.readConfig.ReadConfig
 import io.legado.app.ui.login.SourceLoginJsExtensions
@@ -993,6 +994,11 @@ class ReadBookController(
             is ReadBookEffect.Finish -> closeReadBook()
             is ReadBookEffect.UpdateReadViewConfig -> {
                 val r = refs ?: return
+                // 两份快照都是配置的纯派生,在分发具体 action 之前先重建:下划线/虚线/下划线颜色
+                // 等项的 action 集里并没有 UpdateStyle,靠 upStyle() 顺带重建会漏;而且
+                // actions 是集合,无法保证「重建」排在 InvalidateTextPage/SubmitRenderTask 之前。
+                ChapterProvider.upRenderStyle()
+                TipStyleProvider.upTipStyle()
                 effect.actions.forEach { action ->
                     when (action) {
                         ConfigUpdateAction.UpdateSystemUi -> upSystemUiVisibility()

+ 111 - 165
app/src/main/java/io/legado/app/ui/book/read/page/PageView.kt

@@ -10,22 +10,22 @@ import android.view.LayoutInflater
 import android.widget.FrameLayout
 import androidx.core.content.ContextCompat
 import androidx.core.graphics.drawable.toDrawable
-import androidx.core.net.toUri
 import androidx.core.view.ViewCompat
 import androidx.core.view.WindowInsetsCompat
 import androidx.core.view.isGone
 import io.legado.app.R
 import io.legado.app.constant.AppConst.timeFormat
+import io.legado.app.constant.ReadTipType
 import io.legado.app.data.entities.Bookmark
 import io.legado.app.databinding.ViewBookPageBinding
 import io.legado.app.help.config.CustomTipPlaceholder
-import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.model.ReadBook
 import io.legado.app.model.ReadSessionState
 import io.legado.app.ui.book.read.page.entities.TextLine
 import io.legado.app.ui.book.read.page.entities.TextPage
 import io.legado.app.ui.book.read.page.entities.TextPos
 import io.legado.app.ui.book.read.page.provider.ChapterProvider
+import io.legado.app.ui.book.read.page.provider.TipStyleProvider
 import io.legado.app.ui.book.read.sheet.CustomTipTarget
 import io.legado.app.ui.config.readConfig.ReadConfig
 import io.legado.app.ui.widget.BatteryView
@@ -35,12 +35,10 @@ import io.legado.app.utils.applyStatusBarPadding
 import io.legado.app.utils.canvasrecorder.CanvasRecorder
 import io.legado.app.utils.dpToPx
 import io.legado.app.utils.gone
-import io.legado.app.utils.isContentScheme
 import io.legado.app.utils.navigationBarHeight
 import io.legado.app.utils.setOnApplyWindowInsetsListenerCompat
 import io.legado.app.utils.statusBarHeight
 import splitties.views.backgroundColor
-import java.io.File
 import java.util.Date
 import io.legado.app.utils.screenshot as viewScreenshot
 
@@ -131,67 +129,45 @@ class PageView(
     }
 
     fun upStyle() = binding.run {
-        upTipStyle()
-        ReadBookConfig.let {
-            val textColor = it.textColor
-            val headerColor = with(ReadBookConfig) {
-                if (resolvedTipHeaderColor == 0) textColor else resolvedTipHeaderColor
-            }
-            val footerColor = with(ReadBookConfig) {
-                if (resolvedTipFooterColor == 0) textColor else resolvedTipFooterColor
-            }
-            val tipDividerColor = with(ReadBookConfig) {
-                when (tipDividerColor) {
-                    -1 -> ContextCompat.getColor(context, R.color.divider)
-                    0 -> textColor
-                    else -> tipDividerColor
-                }
-            }
-            tvHeaderLeft.setColor(headerColor)
-            tvHeaderMiddle.setColor(headerColor)
-            tvHeaderRight.setColor(headerColor)
-            tvFooterLeft.setColor(footerColor)
-            tvFooterMiddle.setColor(footerColor)
-            tvFooterRight.setColor(footerColor)
-            vwTopDivider.backgroundColor = tipDividerColor
-            vwBottomDivider.backgroundColor = tipDividerColor
-            upStatusBar()
-            upNavigationBar()
-            upPaddingDisplayCutouts()
-            llHeader.setPadding(
-                it.headerPaddingLeft.dpToPx(),
-                it.headerPaddingTop.dpToPx(),
-                it.headerPaddingRight.dpToPx(),
-                it.headerPaddingBottom.dpToPx()
-            )
-            llFooter.setPadding(
-                it.footerPaddingLeft.dpToPx(),
-                it.footerPaddingTop.dpToPx(),
-                it.footerPaddingRight.dpToPx(),
-                it.footerPaddingBottom.dpToPx()
-            )
-            vwTopDivider.gone(llHeader.isGone || !it.showHeaderLine)
-            vwBottomDivider.gone(llFooter.isGone || !it.showFooterLine)
-        }
+        val style = TipStyleProvider.style
+        upTipStyle(style)
+        applyTipColors(style)
+        upStatusBar()
+        upNavigationBar()
+        upPaddingDisplayCutouts()
+        llHeader.setPadding(
+            style.headerPaddingLeft.dpToPx(),
+            style.headerPaddingTop.dpToPx(),
+            style.headerPaddingRight.dpToPx(),
+            style.headerPaddingBottom.dpToPx()
+        )
+        llFooter.setPadding(
+            style.footerPaddingLeft.dpToPx(),
+            style.footerPaddingTop.dpToPx(),
+            style.footerPaddingRight.dpToPx(),
+            style.footerPaddingBottom.dpToPx()
+        )
+        vwTopDivider.gone(llHeader.isGone || !style.showHeaderLine)
+        vwBottomDivider.gone(llFooter.isGone || !style.showFooterLine)
         upTime()
         upBattery(battery)
     }
 
     /** 仅更新日夜模式相关颜色,避免主题切换时重新加载字体和重排正文。 */
     fun upThemeColors() = binding.run {
-        val textColor = ReadBookConfig.textColor
-        val headerColor = with(ReadBookConfig) {
-            if (resolvedTipHeaderColor == 0) textColor else resolvedTipHeaderColor
-        }
-        val footerColor = with(ReadBookConfig) {
-            if (resolvedTipFooterColor == 0) textColor else resolvedTipFooterColor
-        }
-        val tipDividerColor = with(ReadBookConfig) {
-            when (tipDividerColor) {
-                -1 -> ContextCompat.getColor(context, R.color.divider)
-                0 -> textColor
-                else -> tipDividerColor
-            }
+        applyTipColors(TipStyleProvider.style)
+        contentTextView.invalidate()
+    }
+
+    /** 页眉/页脚文字色与分隔线色:0 表示跟随正文色,-1(分隔线)表示用主题色。 */
+    private fun applyTipColors(style: TipStyleProvider.TipStyle) = binding.run {
+        val textColor = style.textColor
+        val headerColor = if (style.tipHeaderColor == 0) textColor else style.tipHeaderColor
+        val footerColor = if (style.tipFooterColor == 0) textColor else style.tipFooterColor
+        val dividerColor = when (style.tipDividerColor) {
+            -1 -> ContextCompat.getColor(context, R.color.divider)
+            0 -> textColor
+            else -> style.tipDividerColor
         }
         tvHeaderLeft.setColor(headerColor)
         tvHeaderMiddle.setColor(headerColor)
@@ -199,27 +175,8 @@ class PageView(
         tvFooterLeft.setColor(footerColor)
         tvFooterMiddle.setColor(footerColor)
         tvFooterRight.setColor(footerColor)
-        vwTopDivider.backgroundColor = tipDividerColor
-        vwBottomDivider.backgroundColor = tipDividerColor
-        contentTextView.invalidate()
-    }
-
-    private fun loadTypeface(fontPath: String): Typeface? {
-        return runCatching {
-            when {
-                fontPath.isContentScheme() -> {
-                    context.contentResolver
-                        .openFileDescriptor(fontPath.toUri(), "r")
-                        ?.use {
-                            Typeface.Builder(it.fileDescriptor).build()
-                        }
-                }
-                fontPath.isNotEmpty() -> {
-                    Typeface.Builder(File(fontPath)).build()
-                }
-                else -> null
-            }
-        }.getOrNull()
+        vwTopDivider.backgroundColor = dividerColor
+        vwBottomDivider.backgroundColor = dividerColor
     }
 
     /**
@@ -227,11 +184,11 @@ class PageView(
      */
     fun upStatusBar() = with(binding.vwStatusBar) {
         setPadding(paddingLeft, context.statusBarHeight, paddingRight, paddingBottom)
-        isGone = ReadBookConfig.hideStatusBar || activity?.isInMultiWindowMode == true
+        isGone = TipStyleProvider.style.hideStatusBar || activity?.isInMultiWindowMode == true
     }
 
     fun upNavigationBar() {
-        binding.vwNavigationBar.isGone = ReadBookConfig.hideNavigationBar
+        binding.vwNavigationBar.isGone = TipStyleProvider.style.hideNavigationBar
     }
 
     fun upPaddingDisplayCutouts() {
@@ -255,27 +212,27 @@ class PageView(
     /**
      * 更新阅读信息
      */
-    private fun upTipStyle() = binding.run {
+    private fun upTipStyle(style: TipStyleProvider.TipStyle) = binding.run {
         tvHeaderLeft.tag = null
         tvHeaderMiddle.tag = null
         tvHeaderRight.tag = null
         tvFooterLeft.tag = null
         tvFooterMiddle.tag = null
         tvFooterRight.tag = null
-        llHeader.isGone = when (ReadBookConfig.headerMode) {
+        llHeader.isGone = when (style.headerMode) {
             1 -> false
             2 -> true
-            else -> !ReadBookConfig.hideStatusBar
+            else -> !style.hideStatusBar
         }
-        llFooter.isGone = when (ReadBookConfig.footerMode) {
+        llFooter.isGone = when (style.footerMode) {
             1 -> true
             else -> false
         }
-        ReadBookConfig.apply {
-            tvHeaderLeft.isGone = tipHeaderLeft == tipNone
-            tvHeaderMiddle.isGone = tipHeaderMiddle == tipNone
-            if (tipHeaderRight == tipNone) {
-                if (tipHeaderMiddle == tipNone && tipHeaderLeft == tipNone) {
+        style.apply {
+            tvHeaderLeft.isGone = tipHeaderLeft == ReadTipType.tipNone
+            tvHeaderMiddle.isGone = tipHeaderMiddle == ReadTipType.tipNone
+            if (tipHeaderRight == ReadTipType.tipNone) {
+                if (tipHeaderMiddle == ReadTipType.tipNone && tipHeaderLeft == ReadTipType.tipNone) {
                     tvHeaderRight.isGone = true
                 } else {
                     tvHeaderRight.isGone = false
@@ -285,10 +242,10 @@ class PageView(
                 tvHeaderRight.isGone = false
                 tvHeaderRight.batteryMode = BatteryView.BatteryMode.NO_BATTERY
             }
-            tvFooterLeft.isGone = tipFooterLeft == tipNone
-            tvFooterMiddle.isGone = tipFooterMiddle == tipNone
-            if (tipFooterRight == tipNone) {
-                if (tipFooterLeft == tipNone && tipFooterMiddle == tipNone) {
+            tvFooterLeft.isGone = tipFooterLeft == ReadTipType.tipNone
+            tvFooterMiddle.isGone = tipFooterMiddle == ReadTipType.tipNone
+            if (tipFooterRight == ReadTipType.tipNone) {
+                if (tipFooterLeft == ReadTipType.tipNone && tipFooterMiddle == ReadTipType.tipNone) {
                     tvFooterRight.isGone = true
                 } else {
                     tvFooterRight.isGone = false
@@ -299,118 +256,118 @@ class PageView(
                 tvFooterRight.batteryMode = BatteryView.BatteryMode.NO_BATTERY
             }
         }
-        val tipTypeface = loadTypeface(ReadBookConfig.headerFont) ?: ChapterProvider.typeface
-        val tipTextSize = ReadBookConfig.headerFontSize.toFloat()
-        tvTitle = getTipView(ReadBookConfig.tipChapterTitle)?.apply {
-            tag = ReadBookConfig.tipChapterTitle
+        val tipTypeface = style.tipTypeface ?: ChapterProvider.typeface
+        val tipTextSize = style.tipTextSize
+        tvTitle = getTipView(ReadTipType.tipChapterTitle)?.apply {
+            tag = ReadTipType.tipChapterTitle
             typeface = tipTypeface
             textSize = tipTextSize
             batteryMode = BatteryView.BatteryMode.NO_BATTERY
         }
-        tvTitleArrow = getTipView(ReadBookConfig.tipChapterTitleArrow)?.apply {
-            tag = ReadBookConfig.tipChapterTitleArrow
+        tvTitleArrow = getTipView(ReadTipType.tipChapterTitleArrow)?.apply {
+            tag = ReadTipType.tipChapterTitleArrow
             typeface = Typeface.DEFAULT
             textSize = tipTextSize
             batteryMode = BatteryView.BatteryMode.ARROW
         }
-        tvTitleArrowClassic = getTipView(ReadBookConfig.tipChapterTitleArrowClassic)?.apply {
-            tag = ReadBookConfig.tipChapterTitleArrowClassic
+        tvTitleArrowClassic = getTipView(ReadTipType.tipChapterTitleArrowClassic)?.apply {
+            tag = ReadTipType.tipChapterTitleArrowClassic
             typeface = tipTypeface
             textSize = tipTextSize
             batteryMode = BatteryView.BatteryMode.ARROW
         }
-        tvTime = getTipView(ReadBookConfig.tipTime)?.apply {
-            tag = ReadBookConfig.tipTime
+        tvTime = getTipView(ReadTipType.tipTime)?.apply {
+            tag = ReadTipType.tipTime
             typeface = tipTypeface
             textSize = tipTextSize
             batteryMode = BatteryView.BatteryMode.NO_BATTERY
         }
-        tvBattery = getTipView(ReadBookConfig.tipBattery)?.apply {
-            tag = ReadBookConfig.tipBattery
+        tvBattery = getTipView(ReadTipType.tipBattery)?.apply {
+            tag = ReadTipType.tipBattery
             typeface = Typeface.DEFAULT
             textSize = tipTextSize
             batteryMode = BatteryView.BatteryMode.OUTER
         }
-        tvBatteryClassic = getTipView(ReadBookConfig.tipBatteryClassic)?.apply {
-            tag = ReadBookConfig.tipBatteryClassic
+        tvBatteryClassic = getTipView(ReadTipType.tipBatteryClassic)?.apply {
+            tag = ReadTipType.tipBatteryClassic
             textSize = tipTextSize
             batteryMode = BatteryView.BatteryMode.CLASSIC
         }
-        tvBatteryInside = getTipView(ReadBookConfig.tipBatteryInside)?.apply {
-            tag = ReadBookConfig.tipBatteryInside
+        tvBatteryInside = getTipView(ReadTipType.tipBatteryInside)?.apply {
+            tag = ReadTipType.tipBatteryInside
             typeface = Typeface.DEFAULT
             textSize = tipTextSize
             batteryMode = BatteryView.BatteryMode.INNER
         }
-        tvBatteryIcon = getTipView(ReadBookConfig.tipBatteryIcon)?.apply {
-            tag = ReadBookConfig.tipBatteryIcon
+        tvBatteryIcon = getTipView(ReadTipType.tipBatteryIcon)?.apply {
+            tag = ReadTipType.tipBatteryIcon
             typeface = Typeface.DEFAULT
             textSize = tipTextSize
             batteryMode = BatteryView.BatteryMode.ICON
         }
-        tvPage = getTipView(ReadBookConfig.tipPage)?.apply {
-            tag = ReadBookConfig.tipPage
+        tvPage = getTipView(ReadTipType.tipPage)?.apply {
+            tag = ReadTipType.tipPage
             typeface = tipTypeface
             textSize = tipTextSize
             batteryMode = BatteryView.BatteryMode.NO_BATTERY
         }
-        tvTotalProgress = getTipView(ReadBookConfig.tipTotalProgress)?.apply {
-            tag = ReadBookConfig.tipTotalProgress
+        tvTotalProgress = getTipView(ReadTipType.tipTotalProgress)?.apply {
+            tag = ReadTipType.tipTotalProgress
             batteryMode = BatteryView.BatteryMode.NO_BATTERY
             typeface = tipTypeface
             textSize = tipTextSize
         }
-        tvTotalProgress1 = getTipView(ReadBookConfig.tipTotalProgress1)?.apply {
-            tag = ReadBookConfig.tipTotalProgress1
+        tvTotalProgress1 = getTipView(ReadTipType.tipTotalProgress1)?.apply {
+            tag = ReadTipType.tipTotalProgress1
             batteryMode = BatteryView.BatteryMode.NO_BATTERY
             typeface = tipTypeface
             textSize = tipTextSize
         }
-        tvPageAndTotal = getTipView(ReadBookConfig.tipPageAndTotal)?.apply {
-            tag = ReadBookConfig.tipPageAndTotal
+        tvPageAndTotal = getTipView(ReadTipType.tipPageAndTotal)?.apply {
+            tag = ReadTipType.tipPageAndTotal
             batteryMode = BatteryView.BatteryMode.NO_BATTERY
             typeface = tipTypeface
             textSize = tipTextSize
         }
-        tvWholeBookPage = getTipView(ReadBookConfig.tipWholeBookPage)?.apply {
-            tag = ReadBookConfig.tipWholeBookPage
+        tvWholeBookPage = getTipView(ReadTipType.tipWholeBookPage)?.apply {
+            tag = ReadTipType.tipWholeBookPage
             batteryMode = BatteryView.BatteryMode.NO_BATTERY
             typeface = tipTypeface
             textSize = tipTextSize
         }
         tvWholeBookPageAndProgress =
-            getTipView(ReadBookConfig.tipWholeBookPageAndProgress)?.apply {
-                tag = ReadBookConfig.tipWholeBookPageAndProgress
+            getTipView(ReadTipType.tipWholeBookPageAndProgress)?.apply {
+                tag = ReadTipType.tipWholeBookPageAndProgress
                 batteryMode = BatteryView.BatteryMode.NO_BATTERY
                 typeface = tipTypeface
                 textSize = tipTextSize
             }
-        tvBookName = getTipView(ReadBookConfig.tipBookName)?.apply {
-            tag = ReadBookConfig.tipBookName
+        tvBookName = getTipView(ReadTipType.tipBookName)?.apply {
+            tag = ReadTipType.tipBookName
             batteryMode = BatteryView.BatteryMode.NO_BATTERY
             typeface = tipTypeface
             textSize = tipTextSize
         }
-        tvTimeBattery = getTipView(ReadBookConfig.tipTimeBattery)?.apply {
-            tag = ReadBookConfig.tipTimeBattery
+        tvTimeBattery = getTipView(ReadTipType.tipTimeBattery)?.apply {
+            tag = ReadTipType.tipTimeBattery
             typeface = Typeface.DEFAULT
             textSize = tipTextSize
             batteryMode = BatteryView.BatteryMode.TIME
         }
-        tvTimeBatteryClassic = getTipView(ReadBookConfig.tipTimeBatteryClassic)?.apply {
-            tag = ReadBookConfig.tipTimeBatteryClassic
+        tvTimeBatteryClassic = getTipView(ReadTipType.tipTimeBatteryClassic)?.apply {
+            tag = ReadTipType.tipTimeBatteryClassic
             typeface = tipTypeface
             textSize = tipTextSize
             batteryMode = BatteryView.BatteryMode.CLASSIC
         }
-        tvBatteryP = getTipView(ReadBookConfig.tipBatteryPercentage)?.apply {
-            tag = ReadBookConfig.tipBatteryPercentage
+        tvBatteryP = getTipView(ReadTipType.tipBatteryPercentage)?.apply {
+            tag = ReadTipType.tipBatteryPercentage
             batteryMode = BatteryView.BatteryMode.NO_BATTERY
             typeface = tipTypeface
             textSize = tipTextSize
         }
-        tvTimeBatteryP = getTipView(ReadBookConfig.tipTimeBatteryPercentage)?.apply {
-            tag = ReadBookConfig.tipTimeBatteryPercentage
+        tvTimeBatteryP = getTipView(ReadTipType.tipTimeBatteryPercentage)?.apply {
+            tag = ReadTipType.tipTimeBatteryPercentage
             batteryMode = BatteryView.BatteryMode.NO_BATTERY
             typeface = tipTypeface
             textSize = tipTextSize
@@ -419,9 +376,9 @@ class PageView(
         val customTypeface = tipTypeface
         val customTextSize = tipTextSize
         for (target in CUSTOM_TIP_TARGETS) {
-            if (target.currentTipValue() != ReadBookConfig.tipCustom) continue
+            if (target.currentTipValue() != ReadTipType.tipCustom) continue
             val view = customTipViewFor(target)
-            view.tag = ReadBookConfig.tipCustom
+            view.tag = ReadTipType.tipCustom
             view.typeface = customTypeface
             view.textSize = customTextSize
             view.batteryMode = BatteryView.BatteryMode.NO_BATTERY
@@ -459,13 +416,14 @@ class PageView(
      * @param tip 信息类型
      */
     private fun getTipView(tip: Int): BatteryView? = binding.run {
+        val style = TipStyleProvider.style
         return when (tip) {
-            ReadBookConfig.tipHeaderLeft -> tvHeaderLeft
-            ReadBookConfig.tipHeaderMiddle -> tvHeaderMiddle
-            ReadBookConfig.tipHeaderRight -> tvHeaderRight
-            ReadBookConfig.tipFooterLeft -> tvFooterLeft
-            ReadBookConfig.tipFooterMiddle -> tvFooterMiddle
-            ReadBookConfig.tipFooterRight -> tvFooterRight
+            style.tipHeaderLeft -> tvHeaderLeft
+            style.tipHeaderMiddle -> tvHeaderMiddle
+            style.tipHeaderRight -> tvHeaderRight
+            style.tipFooterLeft -> tvFooterLeft
+            style.tipFooterMiddle -> tvFooterMiddle
+            style.tipFooterRight -> tvFooterRight
             else -> null
         }
     }
@@ -487,7 +445,7 @@ class PageView(
      * 更新背景透明度
      */
     fun upBgAlpha() {
-        ReadSessionState.background?.alpha = (ReadBookConfig.bgAlpha / 100f * 255).toInt()
+        ReadSessionState.background?.alpha = (TipStyleProvider.style.bgAlpha / 100f * 255).toInt()
         binding.vwRoot.invalidate()
     }
 
@@ -535,7 +493,7 @@ class PageView(
     fun upCustomTip(textPage: TextPage?) {
         val page = textPage ?: return
         for (target in CUSTOM_TIP_TARGETS) {
-            if (target.currentTipValue() != ReadBookConfig.tipCustom) continue
+            if (target.currentTipValue() != ReadTipType.tipCustom) continue
             val template = target.currentCustomTemplate()
             val view = customTipViewFor(target)
             if (template.isEmpty()) {
@@ -843,26 +801,14 @@ class PageView(
 }
 
 /**
- * 渲染层按位置取当前 tip 类型 / 自定义模板。
+ * 渲染层按位置取当前 tip 类型 / 自定义模板,读的是 [TipStyleProvider] 快照。
  *
- * 直读可变全局 [ReadBookConfig]——与本文件其余渲染读取一致,属排版引擎去全局化
- * (Track E·E5 / Track D2)的范围,不在设置面 UDF 改造内。设置面读的是
- * `ReadSheetConfigUiState` 快照(`CustomTipTarget.tipValueOf` / `customTemplateOf`)。
+ * 这两个函数在 `upCustomTip` / `upTime` / `upBattery` 热路径上反复触发,过去每次都直读
+ * 可变全局 `ReadBookConfig`。设置面读的是 `ReadSheetConfigUiState`
+ * (`CustomTipTarget.tipValueOf` / `customTemplateOf`),两侧互不影响。
  */
-private fun CustomTipTarget.currentTipValue(): Int = when (this) {
-    CustomTipTarget.HEADER_LEFT -> ReadBookConfig.tipHeaderLeft
-    CustomTipTarget.HEADER_MIDDLE -> ReadBookConfig.tipHeaderMiddle
-    CustomTipTarget.HEADER_RIGHT -> ReadBookConfig.tipHeaderRight
-    CustomTipTarget.FOOTER_LEFT -> ReadBookConfig.tipFooterLeft
-    CustomTipTarget.FOOTER_MIDDLE -> ReadBookConfig.tipFooterMiddle
-    CustomTipTarget.FOOTER_RIGHT -> ReadBookConfig.tipFooterRight
-}
+private fun CustomTipTarget.currentTipValue(): Int =
+    TipStyleProvider.style.tipValueOf(this)
 
-private fun CustomTipTarget.currentCustomTemplate(): String = when (this) {
-    CustomTipTarget.HEADER_LEFT -> ReadBookConfig.customTipHeaderLeft
-    CustomTipTarget.HEADER_MIDDLE -> ReadBookConfig.customTipHeaderMiddle
-    CustomTipTarget.HEADER_RIGHT -> ReadBookConfig.customTipHeaderRight
-    CustomTipTarget.FOOTER_LEFT -> ReadBookConfig.customTipFooterLeft
-    CustomTipTarget.FOOTER_MIDDLE -> ReadBookConfig.customTipFooterMiddle
-    CustomTipTarget.FOOTER_RIGHT -> ReadBookConfig.customTipFooterRight
-}
+private fun CustomTipTarget.currentCustomTemplate(): String =
+    TipStyleProvider.style.customTemplateOf(this)

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

@@ -35,6 +35,7 @@ import io.legado.app.ui.book.read.page.entities.TextPage
 import io.legado.app.ui.book.read.page.entities.TextPos
 import io.legado.app.ui.book.read.page.entities.column.TextBaseColumn
 import io.legado.app.ui.book.read.page.provider.ChapterProvider
+import io.legado.app.ui.book.read.page.provider.TipStyleProvider
 import io.legado.app.ui.book.read.page.provider.LayoutProgressListener
 import io.legado.app.ui.book.read.page.provider.TextPageFactory
 import io.legado.app.ui.config.readConfig.ReadConfig
@@ -672,6 +673,7 @@ class ReadView(
      */
     fun upStyle() {
         ChapterProvider.upStyle()
+        TipStyleProvider.upTipStyle()
         curPage.upStyle()
         prevPage.upStyle()
         nextPage.upStyle()
@@ -683,6 +685,7 @@ class ReadView(
      */
     fun applyThemeColors() {
         ChapterProvider.upThemeColors()
+        TipStyleProvider.upTipStyle()
         invalidateTextPage()
         ReadSessionState.updateBackground(width, height)
         curPage.apply {

+ 16 - 15
app/src/main/java/io/legado/app/ui/book/read/page/entities/TextLine.kt

@@ -12,7 +12,6 @@ import android.os.Build
 import androidx.annotation.Keep
 import io.legado.app.help.PaintPool
 import io.legado.app.help.book.isImage
-import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.model.ReadBook
 import io.legado.app.ui.book.read.page.ContentTextView
 import io.legado.app.ui.book.read.page.ResourceLoadFailureCache
@@ -192,8 +191,9 @@ data class TextLine(
 
         drawStyledUnderlines(canvas)
 
-        if (ReadBookConfig.underline && !isImage && ReadBook.book?.isImage != true) {
-            drawUnderline(canvas, ReadBookConfig.dottedLine)
+        val renderStyle = ChapterProvider.renderStyle
+        if (renderStyle.underline && !isImage && ReadBook.book?.isImage != true) {
+            drawUnderline(canvas, renderStyle)
         }
     }
 
@@ -204,12 +204,13 @@ data class TextLine(
         } else {
             ChapterProvider.contentPaint
         }
+        val renderStyle = ChapterProvider.renderStyle
         val textColor = if (isReadAloud) {
-            ReadBookConfig.textAccentColor
-        } else if (isTitle && ReadBookConfig.resolvedTitleColor != 0) {
-            ReadBookConfig.resolvedTitleColor
+            renderStyle.textAccentColor
+        } else if (isTitle && renderStyle.titleColor != 0) {
+            renderStyle.titleColor
         } else {
-            ReadBookConfig.textColor
+            renderStyle.textColor
         }
         if (textPaint.color != textColor) {
             textPaint.color = textColor
@@ -238,30 +239,30 @@ data class TextLine(
     /**
      * 绘制下划线
      */
-    private fun drawUnderline(canvas: Canvas, dottedLine: Boolean) {
+    private fun drawUnderline(canvas: Canvas, renderStyle: ChapterProvider.RenderStyle) {
         val paint = PaintPool.obtain()
         paint.set(ChapterProvider.contentPaint)
         paint.clearShadowLayer()
-        paint.color = ReadBookConfig.durConfig.curUnderlineColor()
-        paint.strokeWidth = ReadBookConfig.underlineHeight.toFloat()
+        paint.color = renderStyle.underlineColor
+        paint.strokeWidth = renderStyle.underlineHeight.toFloat()
         paint.style = Paint.Style.STROKE
-        paint.pathEffect = if (dottedLine && !ReadConfig.isEInkMode)
+        paint.pathEffect = if (renderStyle.dottedLine && !ReadConfig.isEInkMode)
             ChapterProvider.dashEffect
         else
             null
 
-        val lineY = height + (ReadBookConfig.durConfig.underlinePadding - 10).dpToPx()
+        val lineY = height + (renderStyle.underlinePadding - 10).dpToPx()
         val pageOffset = if (textPage.doublePage && !isLeftLine) {
             ChapterProvider.viewWidth / 2f
         } else {
             0f
         }
-        val startX = if (ReadBookConfig.underlineExtend) {
+        val startX = if (renderStyle.underlineExtend) {
             pageOffset + ChapterProvider.paddingLeft
         } else {
             lineStart + indentWidth
         }
-        val endX = if (ReadBookConfig.underlineExtend) {
+        val endX = if (renderStyle.underlineExtend) {
             pageOffset + ChapterProvider.paddingLeft + ChapterProvider.visibleWidth
         } else {
             lineEnd
@@ -384,7 +385,7 @@ data class TextLine(
             val currentMode = textColumn?.underlineMode ?: 0
             val currentColor = textColumn?.underlineColor
                 ?: textColumn?.textColor
-                ?: ReadBookConfig.textColor
+                ?: ChapterProvider.renderStyle.textColor
             val currentWidth = textColumn?.underlineWidth ?: 1f
             val currentOffset = textColumn?.underlineOffset ?: 2f
             val currentSvgPath = textColumn?.underlineSvgPath ?: ""

+ 4 - 4
app/src/main/java/io/legado/app/ui/book/read/page/entities/TextPage.kt

@@ -9,7 +9,6 @@ import androidx.annotation.Keep
 import androidx.core.graphics.withTranslation
 import io.legado.app.R
 import io.legado.app.help.PaintPool
-import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.ui.book.read.page.ContentTextView
 import io.legado.app.ui.book.read.page.entities.TextChapter.Companion.emptyTextChapter
 import io.legado.app.ui.book.read.page.entities.column.TextBaseColumn
@@ -96,7 +95,7 @@ data class TextPage(
      * 底部对齐更新行位置
      */
     fun upLinesPosition() {
-        if (!ReadBookConfig.textBottomJustify) return
+        if (!ChapterProvider.renderStyle.textBottomJustify) return
         if (textLines.size <= 1) return
         if (leftLineSize == 0) {
             leftLineSize = lineSize
@@ -368,8 +367,9 @@ data class TextPage(
     }
 
     fun upRenderHeight() {
-        val underlineExtraHeight = if (ReadBookConfig.underline) {
-            (ReadBookConfig.durConfig.underlinePadding + ReadBookConfig.underlineHeight).dpToPx().toInt()
+        val renderStyle = ChapterProvider.renderStyle
+        val underlineExtraHeight = if (renderStyle.underline) {
+            (renderStyle.underlinePadding + renderStyle.underlineHeight).dpToPx().toInt()
         } else {
             0
         }

+ 5 - 5
app/src/main/java/io/legado/app/ui/book/read/page/entities/column/TextColumn.kt

@@ -5,7 +5,6 @@ import android.graphics.Typeface
 import android.os.Build
 import androidx.annotation.Keep
 import androidx.core.net.toUri
-import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.ui.book.read.page.ContentTextView
 import io.legado.app.ui.book.read.page.entities.TextLine
 import io.legado.app.ui.book.read.page.entities.TextLine.Companion.emptyTextLine
@@ -67,13 +66,14 @@ data class TextColumn(
         } else {
             ChapterProvider.contentPaint
         }
+        val renderStyle = ChapterProvider.renderStyle
         val drawColor = if (textLine.isReadAloud || isSearchResult) {
-            ReadBookConfig.textAccentColor
+            renderStyle.textAccentColor
         } else {
-            textColor ?: if (textLine.isTitle && ReadBookConfig.resolvedTitleColor != 0) {
-                ReadBookConfig.resolvedTitleColor
+            textColor ?: if (textLine.isTitle && renderStyle.titleColor != 0) {
+                renderStyle.titleColor
             } else {
-                ReadBookConfig.textColor
+                renderStyle.textColor
             }
         }
         val needRestoreSize = textLine.titleTextSize != null

+ 4 - 4
app/src/main/java/io/legado/app/ui/book/read/page/entities/column/TextHtmlColumn.kt

@@ -4,7 +4,6 @@ import android.graphics.Canvas
 import android.os.Build
 import android.text.TextPaint
 import androidx.annotation.Keep
-import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.ui.book.read.page.ContentTextView
 import io.legado.app.ui.book.read.page.entities.TextLine
 import io.legado.app.ui.book.read.page.entities.TextLine.Companion.emptyTextLine
@@ -72,9 +71,10 @@ data class TextHtmlColumn(
 
     override fun draw(view: ContentTextView, canvas: Canvas) {
         val y = textLine.lineBase - textLine.lineTop
+        val renderStyle = ChapterProvider.renderStyle
         if (linkUrl != null) {
             textPaint.run {
-                color = ReadBookConfig.textAccentColor
+                color = renderStyle.textAccentColor
                 isUnderlineText = true
             }
             drawText(view, canvas, y, textPaint)
@@ -82,9 +82,9 @@ data class TextHtmlColumn(
         }
         textPaint.run {
             color = if (textLine.isReadAloud || isSearchResult) {
-                ReadBookConfig.textAccentColor
+                renderStyle.textAccentColor
             } else {
-                mTextColor ?: ReadBookConfig.textColor
+                mTextColor ?: renderStyle.textColor
             }
             isUnderlineText = false
         }

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

@@ -111,6 +111,52 @@ object ChapterProvider {
      */
     internal fun layoutMetrics(): LayoutMetrics = metrics
 
+    /**
+     * 绘制期用到的排版取值快照。
+     *
+     * 这些项过去由 `TextLine` / `TextColumn` / `TextHtmlColumn` / `TextPage` 在 `draw()`
+     * 里逐个直读 [ReadBookConfig]。每次直读都要走
+     * `config → durConfig → getConfig(styleSelect)`,而 `getConfig` 是 `@Synchronized`
+     * ——等于**每行每列每帧**都去抢一次 `ReadBookConfig` 的监视器锁。收进快照后绘制路径
+     * 只剩一次 volatile 读,且一帧内的颜色/下划线参数必定同属一份配置。
+     *
+     * 由 [upRenderStyle] 重建;配置是它唯一的输入,所以只要「配置可能变了」就重建一次即可
+     * ([upStyle] / [upThemeColors] / `ReadBookController` 处理任何配置更新 effect 时)。
+     */
+    internal data class RenderStyle(
+        val textColor: Int = 0,
+        val textAccentColor: Int = 0,
+        /** 已按日夜模式解析;0 表示「未单独设置标题色,跟随正文色」。 */
+        val titleColor: Int = 0,
+        val underline: Boolean = false,
+        val dottedLine: Boolean = false,
+        val underlineExtend: Boolean = false,
+        val underlineColor: Int = 0,
+        val underlineHeight: Int = 1,
+        val underlinePadding: Int = 10,
+        val textBottomJustify: Boolean = false,
+    )
+
+    @Volatile
+    internal var renderStyle = RenderStyle()
+        private set
+
+    /** 重建 [renderStyle]。纯派生、幂等,重复调用只是多读十几个字段。 */
+    fun upRenderStyle() {
+        renderStyle = RenderStyle(
+            textColor = ReadBookConfig.textColor,
+            textAccentColor = ReadBookConfig.textAccentColor,
+            titleColor = ReadBookConfig.resolvedTitleColor,
+            underline = ReadBookConfig.underline,
+            dottedLine = ReadBookConfig.dottedLine,
+            underlineExtend = ReadBookConfig.underlineExtend,
+            underlineColor = ReadBookConfig.durConfig.curUnderlineColor(),
+            underlineHeight = ReadBookConfig.underlineHeight,
+            underlinePadding = ReadBookConfig.durConfig.underlinePadding,
+            textBottomJustify = ReadBookConfig.textBottomJustify,
+        )
+    }
+
     @JvmStatic
     val viewWidth get() = metrics.viewWidth
 
@@ -900,6 +946,7 @@ object ChapterProvider {
      * 更新样式
      */
     fun upStyle() {
+        upRenderStyle()
         val typeface = getTypeface(ReadBookConfig.textFont)
         val (titlePaint, contentPaint) = getPaints(typeface)
         dashEffect = DashPathEffect(
@@ -940,6 +987,7 @@ object ChapterProvider {
 
     /** 主题切换只更新绘制颜色,不触发字体加载或正文重排。 */
     fun upThemeColors() {
+        upRenderStyle()
         val textColor = ReadBookConfig.textColor
         titlePaint.color = textColor
         contentPaint.color = textColor

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

@@ -0,0 +1,156 @@
+package io.legado.app.ui.book.read.page.provider
+
+import android.graphics.Typeface
+import androidx.core.net.toUri
+import io.legado.app.constant.ReadTipType
+import io.legado.app.help.config.ReadBookConfig
+import io.legado.app.ui.book.read.sheet.CustomTipTarget
+import io.legado.app.utils.isContentScheme
+import splitties.init.appCtx
+import java.io.File
+
+/**
+ * 页眉/页脚与页面外框(状态栏、导航栏、分隔线、背景透明度)的配置快照。
+ *
+ * `PageView` 有三个实例(prev/cur/next),过去每个实例的 `upStyle()` 都各自把这批配置
+ * 从 [ReadBookConfig] 重读一遍——每次读都要走 `config → durConfig → getConfig(styleSelect)`,
+ * 而 `getConfig` 是 `@Synchronized`。更要命的是 `loadTypeface(headerFont)` 会**在主线程上
+ * 按路径读一次字体文件**,一次样式变更就是三次。
+ *
+ * 收进快照后:一次样式变更只解析一份配置,字体按路径缓存、只在路径变化时重新加载,
+ * 三个 `PageView` 读到的必定是同一份取值。
+ *
+ * 与 [ChapterProvider.RenderStyle] 一样,配置是唯一输入,所以「配置可能变了」时重建一次即可
+ * (`ReadView.upStyle` / `applyThemeColors`,以及 `ReadBookController` 处理任何配置更新 effect 时)。
+ */
+internal object TipStyleProvider {
+
+    data class TipStyle(
+        val headerMode: Int = 0,
+        val footerMode: Int = 0,
+        val hideStatusBar: Boolean = false,
+        val hideNavigationBar: Boolean = false,
+        val tipHeaderLeft: Int = ReadTipType.tipNone,
+        val tipHeaderMiddle: Int = ReadTipType.tipNone,
+        val tipHeaderRight: Int = ReadTipType.tipNone,
+        val tipFooterLeft: Int = ReadTipType.tipNone,
+        val tipFooterMiddle: Int = ReadTipType.tipNone,
+        val tipFooterRight: Int = ReadTipType.tipNone,
+        val customTipHeaderLeft: String = "",
+        val customTipHeaderMiddle: String = "",
+        val customTipHeaderRight: String = "",
+        val customTipFooterLeft: String = "",
+        val customTipFooterMiddle: String = "",
+        val customTipFooterRight: String = "",
+        /** 已按 `headerFont` 解析;null 表示回落到 [ChapterProvider.typeface]。 */
+        val tipTypeface: Typeface? = null,
+        val tipTextSize: Float = 0f,
+        val textColor: Int = 0,
+        /** 0 表示「跟随正文色」。 */
+        val tipHeaderColor: Int = 0,
+        /** 0 表示「跟随正文色」。 */
+        val tipFooterColor: Int = 0,
+        /** -1 表示用主题的 `R.color.divider`,0 表示跟随正文色。 */
+        val tipDividerColor: Int = 0,
+        val headerPaddingLeft: Int = 0,
+        val headerPaddingTop: Int = 0,
+        val headerPaddingRight: Int = 0,
+        val headerPaddingBottom: Int = 0,
+        val footerPaddingLeft: Int = 0,
+        val footerPaddingTop: Int = 0,
+        val footerPaddingRight: Int = 0,
+        val footerPaddingBottom: Int = 0,
+        val showHeaderLine: Boolean = false,
+        val showFooterLine: Boolean = false,
+        val bgAlpha: Int = 100,
+    ) {
+        fun tipValueOf(target: CustomTipTarget): Int = when (target) {
+            CustomTipTarget.HEADER_LEFT -> tipHeaderLeft
+            CustomTipTarget.HEADER_MIDDLE -> tipHeaderMiddle
+            CustomTipTarget.HEADER_RIGHT -> tipHeaderRight
+            CustomTipTarget.FOOTER_LEFT -> tipFooterLeft
+            CustomTipTarget.FOOTER_MIDDLE -> tipFooterMiddle
+            CustomTipTarget.FOOTER_RIGHT -> tipFooterRight
+        }
+
+        fun customTemplateOf(target: CustomTipTarget): String = when (target) {
+            CustomTipTarget.HEADER_LEFT -> customTipHeaderLeft
+            CustomTipTarget.HEADER_MIDDLE -> customTipHeaderMiddle
+            CustomTipTarget.HEADER_RIGHT -> customTipHeaderRight
+            CustomTipTarget.FOOTER_LEFT -> customTipFooterLeft
+            CustomTipTarget.FOOTER_MIDDLE -> customTipFooterMiddle
+            CustomTipTarget.FOOTER_RIGHT -> customTipFooterRight
+        }
+    }
+
+    @Volatile
+    private var current: TipStyle? = null
+
+    /**
+     * 当前快照。首次访问时按需构建——`PageView` 在 `init` 里就要用,早于任何
+     * `upTipStyle()` 调用,读默认值会让首帧的页眉页脚全错。
+     */
+    val style: TipStyle get() = current ?: upTipStyle()
+
+    /** 重建快照并返回。纯派生、幂等。 */
+    fun upTipStyle(): TipStyle = ReadBookConfig.run {
+        TipStyle(
+            headerMode = headerMode,
+            footerMode = footerMode,
+            hideStatusBar = hideStatusBar,
+            hideNavigationBar = hideNavigationBar,
+            tipHeaderLeft = tipHeaderLeft,
+            tipHeaderMiddle = tipHeaderMiddle,
+            tipHeaderRight = tipHeaderRight,
+            tipFooterLeft = tipFooterLeft,
+            tipFooterMiddle = tipFooterMiddle,
+            tipFooterRight = tipFooterRight,
+            customTipHeaderLeft = customTipHeaderLeft,
+            customTipHeaderMiddle = customTipHeaderMiddle,
+            customTipHeaderRight = customTipHeaderRight,
+            customTipFooterLeft = customTipFooterLeft,
+            customTipFooterMiddle = customTipFooterMiddle,
+            customTipFooterRight = customTipFooterRight,
+            tipTypeface = typefaceFor(headerFont),
+            tipTextSize = headerFontSize.toFloat(),
+            textColor = textColor,
+            tipHeaderColor = resolvedTipHeaderColor,
+            tipFooterColor = resolvedTipFooterColor,
+            tipDividerColor = tipDividerColor,
+            headerPaddingLeft = headerPaddingLeft,
+            headerPaddingTop = headerPaddingTop,
+            headerPaddingRight = headerPaddingRight,
+            headerPaddingBottom = headerPaddingBottom,
+            footerPaddingLeft = footerPaddingLeft,
+            footerPaddingTop = footerPaddingTop,
+            footerPaddingRight = footerPaddingRight,
+            footerPaddingBottom = footerPaddingBottom,
+            showHeaderLine = showHeaderLine,
+            showFooterLine = showFooterLine,
+            bgAlpha = bgAlpha,
+        ).also { current = it }
+    }
+
+    private var cachedFontPath: String? = null
+    private var cachedTypeface: Typeface? = null
+
+    /** 按路径缓存:字体文件解析是主线程 I/O,只有路径变了才值得重做。 */
+    private fun typefaceFor(fontPath: String): Typeface? {
+        if (fontPath != cachedFontPath) {
+            cachedFontPath = fontPath
+            cachedTypeface = loadTypeface(fontPath)
+        }
+        return cachedTypeface
+    }
+
+    private fun loadTypeface(fontPath: String): Typeface? = runCatching {
+        when {
+            fontPath.isContentScheme() -> appCtx.contentResolver
+                .openFileDescriptor(fontPath.toUri(), "r")
+                ?.use { Typeface.Builder(it.fileDescriptor).build() }
+
+            fontPath.isNotEmpty() -> Typeface.Builder(File(fontPath)).build()
+            else -> null
+        }
+    }.getOrNull()
+}

+ 147 - 0
app/src/test/java/io/legado/app/ui/book/read/page/provider/RenderStyleSnapshotBoundaryTest.kt

@@ -0,0 +1,147 @@
+package io.legado.app.ui.book.read.page.provider
+
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.io.File
+
+/**
+ * R4·R4.1a —— 绘制路径只读 `ChapterProvider.renderStyle` 快照,不直读 `ReadBookConfig`。
+ *
+ * 直读的代价不只是分层:`ReadBookConfig.underline` 这类属性会走
+ * `config → durConfig → getConfig(styleSelect)`,而 `getConfig` 是 `@Synchronized`
+ * ——放在 `draw()` 里就是**每行每列每帧**抢一次全局配置的监视器锁,还可能在一帧之内
+ * 读到跨越两份配置的取值组合。
+ *
+ * 两条会悄悄失效的边界:
+ *
+ * 1. 有人在渲染实体里写回 `ReadBookConfig.xxx`——锁和撕裂一起长回来,而且看不出问题,
+ *    因为功能是对的、只有性能和一致性退化。
+ * 2. 给 [ChapterProvider.RenderStyle] 加了字段却忘了在 `upRenderStyle()` 里赋值——
+ *    该项永远保持默认值,表现为「这个设置改了不生效」。
+ */
+class RenderStyleSnapshotBoundaryTest {
+
+    @Test
+    fun `渲染实体不直读 ReadBookConfig`() {
+        val violations = RENDER_ENTITIES.filter { path ->
+            Regex("""\bReadBookConfig\b""").containsMatchIn(stripComments(mainSource(path)))
+        }
+        assertTrue(
+            "以下渲染实体又直接读 ReadBookConfig 了:\n" +
+                violations.joinToString("\n") { "  - $it" } + "\n" +
+                "绘制期要用的排版项请加进 ChapterProvider.RenderStyle," +
+                "由 upRenderStyle() 统一重建后读快照。",
+            violations.isEmpty(),
+        )
+    }
+
+    @Test
+    fun `PageView 不直读 ReadBookConfig`() {
+        assertTrue(
+            "PageView 又直接读 ReadBookConfig 了。页眉/页脚与页面外框要用的配置请加进 " +
+                "TipStyleProvider.TipStyle,由 upTipStyle() 统一重建后读快照——" +
+                "三个 PageView 实例各读一遍全局,等于把同一份配置解析三次。",
+            !Regex("""\bReadBookConfig\b""").containsMatchIn(stripComments(mainSource(PAGE_VIEW))),
+        )
+    }
+
+    @Test
+    fun `upRenderStyle 覆盖 RenderStyle 的每个字段`() {
+        assertAllFieldsAssigned(
+            source = mainSource(CHAPTER_PROVIDER),
+            declaration = "internal data class RenderStyle(",
+            builder = "fun upRenderStyle()",
+            constructorCall = "RenderStyle(",
+        )
+    }
+
+    @Test
+    fun `upTipStyle 覆盖 TipStyle 的每个字段`() {
+        assertAllFieldsAssigned(
+            source = mainSource(TIP_STYLE_PROVIDER),
+            declaration = "data class TipStyle(",
+            builder = "fun upTipStyle()",
+            constructorCall = "TipStyle(",
+        )
+    }
+
+    /**
+     * 断言 [builder] 里的 [constructorCall] 给 [declaration] 的每个 `val` 都传了值。
+     * 漏掉的字段会静默保持默认值——「改了设置不生效」,且没有任何编译期信号。
+     */
+    private fun assertAllFieldsAssigned(
+        source: String,
+        declaration: String,
+        builder: String,
+        constructorCall: String,
+    ) {
+        val fields = Regex("""\bval\s+(\w+)\s*:""")
+            .findAll(balancedParens(source, source.requireIndex(declaration) + declaration.length - 1))
+            .map { it.groupValues[1] }
+            .toList()
+
+        // 从函数签名之后开始找构造调用:`upRenderStyle()` 本身就含有子串 `RenderStyle(`
+        val builderStart = source.requireIndex(builder) + builder.length
+        val callStart = source.indexOf(constructorCall, builderStart)
+        require(callStart >= 0) { "在 $builder 里找不到 $constructorCall——请同步本测试" }
+        val assigned = Regex("""(\w+)\s*=""")
+            .findAll(balancedParens(source, callStart + constructorCall.length - 1))
+            .map { it.groupValues[1] }
+            .toSet()
+
+        val missing = fields.filterNot { it in assigned }
+        assertTrue(
+            "${declaration.substringAfter("class ").substringBefore("(")} 的以下字段没有在 " +
+                "$builder 里被赋值,它们会永远保持默认值,表现为「改了设置不生效」:\n" +
+                missing.joinToString("\n") { "  - $it" },
+            missing.isEmpty(),
+        )
+    }
+
+    private companion object {
+        const val CHAPTER_PROVIDER =
+            "io/legado/app/ui/book/read/page/provider/ChapterProvider.kt"
+        const val TIP_STYLE_PROVIDER =
+            "io/legado/app/ui/book/read/page/provider/TipStyleProvider.kt"
+        const val PAGE_VIEW = "io/legado/app/ui/book/read/page/PageView.kt"
+
+        val RENDER_ENTITIES = listOf(
+            "io/legado/app/ui/book/read/page/entities/TextLine.kt",
+            "io/legado/app/ui/book/read/page/entities/TextPage.kt",
+            "io/legado/app/ui/book/read/page/entities/column/TextColumn.kt",
+            "io/legado/app/ui/book/read/page/entities/column/TextHtmlColumn.kt",
+        )
+
+        fun String.requireIndex(token: String): Int = indexOf(token).also {
+            require(it >= 0) { "找不到 `$token`——被改名或改结构了,请同步本测试" }
+        }
+
+        /** 取 [openIndex] 处 `(` 起、括号配平为止的文本(不含两端括号)。 */
+        fun balancedParens(source: String, openIndex: Int): String {
+            var depth = 0
+            for (i in openIndex until source.length) {
+                when (source[i]) {
+                    '(' -> depth++
+                    ')' -> if (--depth == 0) return source.substring(openIndex + 1, i)
+                }
+            }
+            error("括号没有配平,起点 $openIndex")
+        }
+
+        fun stripComments(text: String): String = text
+            .replace(Regex("""/\*[\s\S]*?\*/"""), "")
+            .replace(Regex("""//[^\n]*"""), "")
+
+        fun mainSource(relativePath: String): String {
+            var directory: File? = File("").absoluteFile
+            while (directory != null) {
+                for (prefix in listOf("src/main/java", "app/src/main/java")) {
+                    val candidate = File(directory, "$prefix/$relativePath")
+                    if (candidate.isFile) return candidate.readText()
+                }
+                directory = directory.parentFile
+            }
+            error("从 ${File("").absolutePath} 向上找不到 $relativePath")
+        }
+    }
+}