Ver código fonte

Merge pull request #5 from 5151561/claude/mad-modernization-next-steps-468621

收 R4 的尾:绘制热路径快照化、配置并发收紧、清上游护栏账单
5151561 3 meses atrás
pai
commit
3cd5092709

+ 10 - 7
app/src/main/java/io/legado/app/data/repository/ReadStyleConfigStore.kt

@@ -25,7 +25,7 @@ class ReadStyleConfigStore(private val readStyleRepository: ReadStyleRepository)
     val shareConfigFilePath: String get() = readStyleRepository.shareConfigFilePath
 
     /** 共享排版那一份。 */
-    val shareConfig: ReadBookConfig.Config get() = shareConfigRef
+    val shareConfig: ReadBookConfig.Config get() = synchronized(lock) { shareConfigRef }
 
     fun initConfigs() {
         val configs = readStyleRepository.readConfigs()
@@ -37,7 +37,8 @@ class ReadStyleConfigStore(private val readStyleRepository: ReadStyleRepository)
 
     fun initShareConfig() {
         val fallback = synchronized(lock) { configList.getOrNull(5) } ?: ReadBookConfig.Config()
-        shareConfigRef = readStyleRepository.readShareConfig(fallback)
+        val config = readStyleRepository.readShareConfig(fallback)
+        synchronized(lock) { shareConfigRef = config }
     }
 
     /** 按下标取用。配置文件缺斤少两时先恢复默认,保证 5 份预设始终在。 */
@@ -52,9 +53,9 @@ class ReadStyleConfigStore(private val readStyleRepository: ReadStyleRepository)
     fun replaceConfigAt(index: Int, config: ReadBookConfig.Config, alsoShare: Boolean) {
         synchronized(lock) {
             configList[index] = config
-        }
-        if (alsoShare) {
-            shareConfigRef = config
+            if (alsoShare) {
+                shareConfigRef = config
+            }
         }
     }
 
@@ -93,7 +94,7 @@ class ReadStyleConfigStore(private val readStyleRepository: ReadStyleRepository)
         configList.toList()
     }
 
-    fun shareConfigSnapshot(): ReadBookConfig.Config = shareConfigRef
+    fun shareConfigSnapshot(): ReadBookConfig.Config = synchronized(lock) { shareConfigRef }
 
     fun addConfig(config: ReadBookConfig.Config): Int = synchronized(lock) {
         configList.add(config)
@@ -132,8 +133,10 @@ class ReadStyleConfigStore(private val readStyleRepository: ReadStyleRepository)
     }
 
     private fun save() {
+        // 列表与共享配置在同一临界区内取快照,落盘的两份内容彼此自洽
+        val (configs, share) = synchronized(lock) { configList.toList() to shareConfigRef }
         Coroutine.async {
-            readStyleRepository.save(configsSnapshot(), shareConfigRef)
+            readStyleRepository.save(configs, share)
         }
     }
 }

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

@@ -537,17 +537,17 @@ object ReadBookConfig {
         val bgType: Int = 0,//白天背景类型 0:颜色, 1:assets图片, 2其它图片
         val bgTypeNight: Int = 0,//夜间背景类型
         val bgTypeEInk: Int = 0,//EInk背景类型
-        private var darkStatusIcon: Boolean = true,//白天是否暗色状态栏
-        private var darkStatusIconNight: Boolean = false,//晚上是否暗色状态栏
-        private var darkStatusIconEInk: Boolean = true,
-        private var textColor: String = "#3E3D3B",//白天文字颜色
-        private var textColorNight: String = "#ADADAD",//夜间文字颜色
-        private var textColorEInk: String = "#000000",
-        private var textAccentColor: String = "#834E00",//白天强调文字颜色
-        private var textAccentColorNight: String = "#FE4D55",//夜间强调文字颜色
-        private var textAccentColorEInk: String = "#000000",
-        private var pageAnim: Int = 0,//翻页动画
-        private var pageAnimEInk: Int = 4,
+        private val darkStatusIcon: Boolean = true,//白天是否暗色状态栏
+        private val darkStatusIconNight: Boolean = false,//晚上是否暗色状态栏
+        private val darkStatusIconEInk: Boolean = true,
+        private val textColor: String = "#3E3D3B",//白天文字颜色
+        private val textColorNight: String = "#ADADAD",//夜间文字颜色
+        private val textColorEInk: String = "#000000",
+        private val textAccentColor: String = "#834E00",//白天强调文字颜色
+        private val textAccentColorNight: String = "#FE4D55",//夜间强调文字颜色
+        private val textAccentColorEInk: String = "#000000",
+        private val pageAnim: Int = 0,//翻页动画
+        private val pageAnimEInk: Int = 4,
         val textFont: String = "",//字体
         val titleFont: String = "",//标题字体
         val headerFont: String = "",//页眉字体
@@ -562,8 +562,8 @@ object ReadBookConfig {
         val shadowRadius: Float = 16f,// 阴影模糊半径
         val shadowDx: Float = 1f,// 阴影x偏移
         val shadowDy: Float = 1f,// 阴影y偏移
-        private var shadowColor: String = "#3E3D3B",
-        private var shadowColorN: String = "#3E3D3B",
+        private val shadowColor: String = "#3E3D3B",
+        private val shadowColorN: String = "#3E3D3B",
         val letterSpacing: Float = 0.1f,//字间距
         val lineSpacingExtra: Int = 12,//行间距
         val paragraphSpacing: Int = 2,//段距

+ 13 - 0
app/src/main/java/io/legado/app/help/storage/Restore.kt

@@ -35,6 +35,8 @@ import io.legado.app.data.entities.readRecord.ReadRecordDetail
 import io.legado.app.data.entities.readRecord.ReadRecordSession
 import io.legado.app.domain.gateway.AppLocaleGateway
 import io.legado.app.domain.gateway.ReadStyleGateway
+import io.legado.app.ui.book.read.ConfigUpdateAction
+import io.legado.app.ui.book.read.ReadConfigUpdateBus
 import io.legado.app.help.DirectLinkUpload
 import io.legado.app.help.LauncherIconHelp
 import io.legado.app.help.book.isLocal
@@ -355,6 +357,17 @@ object Restore : KoinComponent {
             // 两个文件都落地后再整体重读:分开重读会让 shareConfig 的兜底
             // (configList[5])取到还没被覆盖的旧列表。refresh 顺带发布 state。
             get<ReadStyleGateway>().refresh()
+            // refresh 只重建 Compose 侧 state;阅读器开着时渲染层的两份快照(RenderStyle/
+            // TipStyle)与已排版内容不会跟着刷新,得走配置总线让 controller 重建并重排。
+            // 阅读器没开时无人消费,重开由 ReadView.init 的重建入口兜底。
+            ReadConfigUpdateBus.post(
+                setOf(
+                    ConfigUpdateAction.UpdateBackground,
+                    ConfigUpdateAction.UpdateStyle,
+                    ConfigUpdateAction.ReloadContent,
+                    ConfigUpdateAction.RebuildWholeBookPageIndex,
+                )
+            )
         }
         // 恢复配置文件 (手动解析 XML,替代反射逻辑)
         val configFile = File(path, "config.xml")

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

@@ -1006,12 +1006,13 @@ class ReadBookController(
             // ── Already migrated (View-layer) ──
             is ReadBookEffect.Finish -> closeReadBook()
             is ReadBookEffect.UpdateReadViewConfig -> {
-                val r = refs ?: return
                 // 两份快照都是配置的纯派生,在分发具体 action 之前先重建:下划线/虚线/下划线颜色
                 // 等项的 action 集里并没有 UpdateStyle,靠 upStyle() 顺带重建会漏;而且
                 // actions 是集合,无法保证「重建」排在 InvalidateTextPage/SubmitRenderTask 之前。
+                // 重建必须排在 refs 判空之前——view 未挂载期收到的配置变更,至少快照不能滞留。
                 ChapterProvider.upRenderStyle()
                 TipStyleProvider.upTipStyle()
+                val r = refs ?: return
                 effect.actions.forEach { action ->
                     when (action) {
                         ConfigUpdateAction.UpdateSystemUi -> upSystemUiVisibility()

+ 34 - 100
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -459,6 +459,7 @@ class ReadBookViewModel(
     private val buttonConfigDelegate = ReadButtonConfigDelegate(
         context = context,
         scope = viewModelScope,
+        readSettingsRepository = readSettingsRepository,
         host = object : ReadButtonConfigDelegate.Host {
             override fun updateMenuConfig(transform: (ReadMenuConfig) -> ReadMenuConfig) {
                 _uiState.update { it.copy(menuConfig = transform(it.menuConfig)) }
@@ -470,6 +471,24 @@ class ReadBookViewModel(
         },
     )
 
+    // --- 净化规则域(无自持状态,规则列表仍在 uiState.allReplaceRules)---
+
+    private val replaceRuleDelegate = ReadReplaceRuleDelegate(
+        scope = viewModelScope,
+        replaceRuleRepository = replaceRuleRepository,
+        host = object : ReadReplaceRuleDelegate.Host {
+            override fun updateAllReplaceRules(rules: List<ReplaceRuleItemUi>) {
+                _uiState.update { it.copy(allReplaceRules = rules.toImmutableList()) }
+            }
+
+            override fun updateUseReplaceRule(enabled: Boolean) {
+                _uiState.update {
+                    it.copy(useReplaceRule = enabled, replaceRuleEnabled = enabled)
+                }
+            }
+        },
+    )
+
     private val sysEngines: List<TextToSpeech.EngineInfo> by lazy {
         val tts = TextToSpeech(context, null)
         val engines = tts.engines
@@ -538,31 +557,10 @@ class ReadBookViewModel(
         collectEventBus()
         collectReaderSession()
         collectReadStyle()
-        collectReplaceRules()
+        replaceRuleDelegate.start()
         execute { readAloudDelegate.syncConfiguredTtsVoices() }
     }
 
-    private fun collectReplaceRules() {
-        viewModelScope.launch {
-            replaceRuleRepository.flowAll().collect { rules ->
-                _uiState.update { state ->
-                    state.copy(
-                        allReplaceRules = rules.map { rule ->
-                            ReplaceRuleItemUi(
-                                id = rule.id,
-                                name = rule.name,
-                                group = rule.group,
-                                pattern = rule.pattern,
-                                replacement = rule.replacement,
-                                enabled = rule.isEnabled,
-                            )
-                        }.toImmutableList()
-                    )
-                }
-            }
-        }
-    }
-
     /**
      * 消费 [ReadStyleGateway.state](Track E · E2):排版配置的唯一变更通知。
      *
@@ -785,23 +783,13 @@ class ReadBookViewModel(
             is ReadBookIntent.RefreshCurrentChapter -> refreshCurrentChapter()
             is ReadBookIntent.RefreshAllChapters -> refreshAllChapters()
             is ReadBookIntent.RefreshContentAfter -> refreshContentAfter()
-            is ReadBookIntent.ChangeReplaceRule -> changeReplaceRule(intent.enabled)
-            is ReadBookIntent.SetReplaceRuleEnabled -> viewModelScope.launch {
-                replaceRuleRepository.setEnabled(intent.id, intent.enabled)
-                replaceRuleChanged()
-            }
-
-            is ReadBookIntent.MoveReplaceRule -> viewModelScope.launch {
-                replaceRuleRepository.moveReplaceRule(
-                    intent.draggedId,
-                    intent.anchorId,
-                    intent.afterAnchor
-                )
-                replaceRuleChanged()
-            }
-            is ReadBookIntent.DisableEffectiveReplace -> viewModelScope.launch {
-                replaceRuleRepository.insert(intent.rule.copy(isEnabled = false))
-            }
+            is ReadBookIntent.ChangeReplaceRule ->
+                replaceRuleDelegate.changeUseReplaceRule(intent.enabled)
+            is ReadBookIntent.SetReplaceRuleEnabled ->
+                replaceRuleDelegate.setEnabled(intent.id, intent.enabled)
+            is ReadBookIntent.MoveReplaceRule ->
+                replaceRuleDelegate.move(intent.draggedId, intent.anchorId, intent.afterAnchor)
+            is ReadBookIntent.DisableEffectiveReplace -> replaceRuleDelegate.disable(intent.rule)
             ReadBookIntent.DisableChineseConverter -> {
                 configUpdateDelegate.handle(ConfigUpdate.ChineseConverterType(0))
             }
@@ -849,7 +837,7 @@ class ReadBookViewModel(
             is ReadBookIntent.AddSourceAsNewBook -> addToBookshelf(intent.book, intent.toc)
             is ReadBookIntent.OpenChapterResult -> openChapter(intent.index, intent.chapterPos)
             is ReadBookIntent.SourceEditResult -> upBookSource()
-            is ReadBookIntent.ReplaceRuleResult -> replaceRuleChanged()
+            is ReadBookIntent.ReplaceRuleResult -> replaceRuleDelegate.rulesChanged()
             is ReadBookIntent.BookInfoResult -> {
                 if (intent.bookDeleted) {
                     _effects.tryEmit(ReadBookEffect.Finish)
@@ -1082,12 +1070,7 @@ class ReadBookViewModel(
                     val enabled = !it.getUseReplaceRule(
                         otherSettingsGateway.currentSettings.replaceEnableDefault
                     )
-                    it.setUseReplaceRule(enabled)
-                    ReadBook.saveRead()
-                    _uiState.update { state ->
-                        state.copy(useReplaceRule = enabled, replaceRuleEnabled = enabled)
-                    }
-                    replaceRuleChanged()
+                    replaceRuleDelegate.changeUseReplaceRule(enabled)
                 }
             }
 
@@ -1173,22 +1156,8 @@ class ReadBookViewModel(
                 buttonConfigDelegate.saveMenuButtons(intent.items)
             is ReadBookIntent.SaveTitleBarButtonConfig ->
                 buttonConfigDelegate.saveTitleBarButtons(intent.items)
-            is ReadBookIntent.SaveMoreActionsConfig -> viewModelScope.launch {
-                val seen = mutableSetOf<String>()
-                val normalized = intent.items.mapNotNull { item ->
-                    item.takeIf { it.id in MoreActionIds && seen.add(it.id) }
-                }.toMutableList()
-                MoreActionIds.forEach { id ->
-                    if (seen.add(id)) normalized.add(ReadBookButtonConfigItem(id, true))
-                }
-                readSettingsRepository.update { settings ->
-                    settings.copy(
-                        moreActionsConfig = normalized.joinToString(";") {
-                            "${it.id},${it.enabled}"
-                        }
-                    )
-                }
-            }
+            is ReadBookIntent.SaveMoreActionsConfig ->
+                buttonConfigDelegate.saveMoreActions(intent.items)
 
             is ReadBookIntent.KeepLightChanged -> {
                 _readPreferences.update { it.copy(keepLight = intent.value) }
@@ -1303,7 +1272,7 @@ class ReadBookViewModel(
                 )
             )
 
-            is ReadBookIntent.ReplaceRuleChanged -> replaceRuleChanged()
+            is ReadBookIntent.ReplaceRuleChanged -> replaceRuleDelegate.rulesChanged()
             is ReadBookIntent.OpenFontFolderPicker -> _effects.tryEmit(ReadBookEffect.OpenFontFolderPicker)
             is ReadBookIntent.OpenReadStyleImagePicker -> {
                 _effects.tryEmit(ReadBookEffect.OpenReadStyleImagePicker)
@@ -1935,8 +1904,8 @@ class ReadBookViewModel(
                 readMenuCustomIcons = ReadBookConfig.readMenuCustomIcons.toImmutableMap(),
                 titleBarButtons = current.menuConfig.titleBarButtons,
                 bottomBarButtons = current.menuConfig.bottomBarButtons,
-                moreActionItems = readSettingsRepository.currentSettings.moreActionsConfig
-                    .toMoreActionConfig()
+                moreActionItems = buttonConfigDelegate
+                    .parseMoreActions(readSettingsRepository.currentSettings.moreActionsConfig)
                     .toImmutableList(),
                 showBrightnessView = ReadBookConfig.showBrightnessView,
                 brightnessVwPos = ReadBookConfig.brightnessVwPos,
@@ -1948,21 +1917,6 @@ class ReadBookViewModel(
         )
     }
 
-    private fun String.toMoreActionConfig(): List<ReadBookButtonConfigItem> {
-        if (isBlank()) return MoreActionIds.map { ReadBookButtonConfigItem(it, true) }
-        val seen = mutableSetOf<String>()
-        val items = split(";").mapNotNull { token ->
-            val parts = token.split(",")
-            val id = parts.getOrNull(0)?.takeIf { it in MoreActionIds && seen.add(it) }
-            val enabled = parts.getOrNull(1)?.toBooleanStrictOrNull()
-            if (id != null && enabled != null) ReadBookButtonConfigItem(id, enabled) else null
-        }.toMutableList()
-        MoreActionIds.forEach { id ->
-            if (seen.add(id)) items.add(ReadBookButtonConfigItem(id, true))
-        }
-        return items
-    }
-
     private fun observeCurrentTranslation(
         book: Book?,
         chapterIndex: Int,
@@ -2298,26 +2252,6 @@ class ReadBookViewModel(
         }
     }
 
-    fun replaceRuleChanged() {
-        execute {
-            ReadBook.book?.let {
-                ContentProcessor.get(it.name, it.origin).upReplaceRules()
-                ReadBook.loadContent(resetPageOffset = false)
-            }
-        }
-    }
-
-    private fun changeReplaceRule(enabled: Boolean) {
-        ReadBook.book?.let {
-            it.setUseReplaceRule(enabled)
-            ReadBook.saveRead()
-            _uiState.update { state ->
-                state.copy(useReplaceRule = enabled, replaceRuleEnabled = enabled)
-            }
-            replaceRuleChanged()
-        }
-    }
-
     private fun openReadMenuRoute(route: ReadBookMenuRoute) {
         _uiState.update {
             it.copy(

+ 49 - 3
app/src/main/java/io/legado/app/ui/book/read/ReadButtonConfigDelegate.kt

@@ -2,22 +2,25 @@ package io.legado.app.ui.book.read
 
 import android.content.Context
 import android.net.Uri
+import io.legado.app.data.repository.ReadSettingsRepository
 import io.legado.app.help.coroutine.Coroutine
 import kotlinx.collections.immutable.toImmutableList
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
 import java.io.File
 
 /**
  * 阅读菜单按钮配置域(R2.2 续批)。
  *
- * 管顶栏/底栏按钮的排序与开关,以及两处自定义图标的落盘。按钮列表本身仍住在
- * [ReadMenuConfig] 里(菜单栏各处直读,搬出去要改一大片 UI),故本 delegate
- * **无自持状态**,和 [ReadConfigUpdateDelegate] 同形:一切读写经 [Host]。
+ * 管顶栏/底栏按钮与「更多操作」菜单项的排序与开关,以及两处自定义图标的落盘。
+ * 按钮列表本身仍住在 [ReadMenuConfig] 里(菜单栏各处直读,搬出去要改一大片 UI),
+ * 故本 delegate **无自持状态**,和 [ReadConfigUpdateDelegate] 同形:一切读写经 [Host]。
  */
 class ReadButtonConfigDelegate(
     private val context: Context,
     private val scope: CoroutineScope,
+    private val readSettingsRepository: ReadSettingsRepository,
     private val host: Host,
 ) {
 
@@ -52,6 +55,49 @@ class ReadButtonConfigDelegate(
         host.updateMenuConfig { it.copy(bottomBarButtons = normalized.toImmutableList()) }
     }
 
+    /**
+     * 「更多操作」菜单项配置。与顶栏/底栏不同:存 DataStore(`moreActionsConfig`),
+     * 未出现在配置串里的项默认**开启**(顶栏/底栏是默认关闭)。
+     */
+    fun saveMoreActions(items: List<ReadBookButtonConfigItem>) {
+        val normalized = normalizeMoreActions(items)
+        scope.launch {
+            readSettingsRepository.update { settings ->
+                settings.copy(
+                    moreActionsConfig = normalized.joinToString(";") { "${it.id},${it.enabled}" }
+                )
+            }
+        }
+    }
+
+    fun parseMoreActions(raw: String): List<ReadBookButtonConfigItem> {
+        if (raw.isBlank()) return MoreActionIds.map { ReadBookButtonConfigItem(it, true) }
+        val seen = mutableSetOf<String>()
+        val items = raw.split(";").mapNotNull { token ->
+            val parts = token.split(",")
+            val id = parts.getOrNull(0)?.takeIf { it in MoreActionIds && seen.add(it) }
+            val enabled = parts.getOrNull(1)?.toBooleanStrictOrNull()
+            if (id != null && enabled != null) ReadBookButtonConfigItem(id, enabled) else null
+        }.toMutableList()
+        MoreActionIds.forEach { id ->
+            if (seen.add(id)) items.add(ReadBookButtonConfigItem(id, true))
+        }
+        return items
+    }
+
+    private fun normalizeMoreActions(
+        items: List<ReadBookButtonConfigItem>,
+    ): List<ReadBookButtonConfigItem> {
+        val seen = mutableSetOf<String>()
+        val normalized = items.mapNotNull { item ->
+            item.takeIf { it.id in MoreActionIds && seen.add(it.id) }
+        }.toMutableList()
+        MoreActionIds.forEach { id ->
+            if (seen.add(id)) normalized.add(ReadBookButtonConfigItem(id, true))
+        }
+        return normalized
+    }
+
     fun saveMenuCustomIcon(id: String, uri: Uri) {
         copyCustomIcon(id, uri, "read_menu_icons") { path ->
             ConfigUpdate.MenuCustomIcon(id, path)

+ 93 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadReplaceRuleDelegate.kt

@@ -0,0 +1,93 @@
+package io.legado.app.ui.book.read
+
+import io.legado.app.data.entities.ReplaceRule
+import io.legado.app.data.repository.ReplaceRuleRepository
+import io.legado.app.help.book.ContentProcessor
+import io.legado.app.help.coroutine.Coroutine
+import io.legado.app.model.ReadBook
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+/**
+ * 净化规则域(R2.2 续批)。
+ *
+ * 管阅读页内嵌的净化规则列表(TextProcessingSheet):全量订阅、开关、拖拽排序、
+ * 停用生效中的规则,以及本书「是否启用净化」的切换。规则列表仍住在
+ * `ReadBookUiState.allReplaceRules`(sheet 直读,搬出去要改 UI 入参),
+ * 故本 delegate **无自持状态**,一切读写经 [Host]。
+ */
+class ReadReplaceRuleDelegate(
+    private val scope: CoroutineScope,
+    private val replaceRuleRepository: ReplaceRuleRepository,
+    private val host: Host,
+) {
+
+    interface Host {
+        fun updateAllReplaceRules(rules: List<ReplaceRuleItemUi>)
+
+        /** 本书「是否启用净化」切换后同步 UiState 的开关字段。 */
+        fun updateUseReplaceRule(enabled: Boolean)
+    }
+
+    /** 订阅全量规则表,刷进 UiState。VM 构造时调一次。 */
+    fun start() {
+        scope.launch {
+            replaceRuleRepository.flowAll().collect { rules ->
+                host.updateAllReplaceRules(
+                    rules.map { rule ->
+                        ReplaceRuleItemUi(
+                            id = rule.id,
+                            name = rule.name,
+                            group = rule.group,
+                            pattern = rule.pattern,
+                            replacement = rule.replacement,
+                            enabled = rule.isEnabled,
+                        )
+                    }
+                )
+            }
+        }
+    }
+
+    fun setEnabled(id: Long, enabled: Boolean) {
+        scope.launch {
+            replaceRuleRepository.setEnabled(id, enabled)
+            rulesChanged()
+        }
+    }
+
+    fun move(draggedId: Long, anchorId: Long, afterAnchor: Boolean) {
+        scope.launch {
+            replaceRuleRepository.moveReplaceRule(draggedId, anchorId, afterAnchor)
+            rulesChanged()
+        }
+    }
+
+    /** 「停用这条生效中的规则」:落一条禁用副本。 */
+    fun disable(rule: ReplaceRule) {
+        scope.launch {
+            replaceRuleRepository.insert(rule.copy(isEnabled = false))
+        }
+    }
+
+    /** 切换本书是否启用净化。 */
+    fun changeUseReplaceRule(enabled: Boolean) {
+        ReadBook.book?.let {
+            it.setUseReplaceRule(enabled)
+            ReadBook.saveRead()
+            host.updateUseReplaceRule(enabled)
+            rulesChanged()
+        }
+    }
+
+    /** 规则集变化后让当前书重建净化管线并重载正文。外部规则编辑器返回时也会调。 */
+    fun rulesChanged() {
+        Coroutine.async(scope, Dispatchers.IO) {
+            ReadBook.book?.let {
+                ContentProcessor.get(it.name, it.origin).upReplaceRules()
+                ReadBook.loadContent(resetPageOffset = false)
+            }
+        }
+    }
+}

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

@@ -120,6 +120,13 @@ class ReadView(
     val isAutoPage get() = autoPager.isRunning
 
     init {
+        if (!isInEditMode) {
+            // 重开阅读器的唯一快照重建入口:ReadConfigUpdateBus 无 replay,上一会话关闭后改的
+            // 配置(如设置页的「隐藏状态栏」)没有任何 effect 会补发;PageView 构造期就要读
+            // TipStyleProvider.style,所以必须在 addView 之前重建。两份快照纯派生、幂等。
+            ChapterProvider.upRenderStyle()
+            TipStyleProvider.upTipStyle()
+        }
         addView(nextPage)
         addView(curPage)
         addView(prevPage)

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

@@ -20,7 +20,6 @@ import io.legado.app.ui.book.read.page.entities.column.BaseColumn
 import io.legado.app.ui.book.read.page.entities.column.TextBaseColumn
 import io.legado.app.ui.book.read.page.entities.column.TextColumn
 import io.legado.app.ui.book.read.page.provider.ChapterProvider
-import io.legado.app.ui.config.readConfig.ReadConfig
 import io.legado.app.utils.canvasrecorder.CanvasRecorderFactory
 import io.legado.app.utils.canvasrecorder.recordIfNeededThenDraw
 import io.legado.app.utils.dpToPx
@@ -77,7 +76,7 @@ data class TextLine(
     var textPage: TextPage = emptyTextPage
     var isLeftLine = true
     val useUnderline: Boolean
-        get() = ReadConfig.useUnderline
+        get() = ChapterProvider.renderStyle.useUnderline
 
     fun addColumn(column: BaseColumn) {
         if (column !is TextColumn) {
@@ -160,7 +159,7 @@ data class TextLine(
     }
 
     fun draw(view: ContentTextView, canvas: Canvas) {
-        if (ReadConfig.optimizeRender) {
+        if (ChapterProvider.renderStyle.optimizeRender) {
             canvasRecorder.recordIfNeededThenDraw(canvas, view.width, height.toInt()) {
                 drawTextLine(view, this)
             }
@@ -246,7 +245,7 @@ data class TextLine(
         paint.color = renderStyle.underlineColor
         paint.strokeWidth = renderStyle.underlineHeight.toFloat()
         paint.style = Paint.Style.STROKE
-        paint.pathEffect = if (renderStyle.dottedLine && !ReadConfig.isEInkMode)
+        paint.pathEffect = if (renderStyle.dottedLine && !renderStyle.isEInkMode)
             ChapterProvider.dashEffect
         else
             null
@@ -832,7 +831,7 @@ data class TextLine(
     }
 
     fun checkFastDraw(): Boolean {
-        if (!ReadConfig.optimizeRender || exceed || !onlyTextColumn || textPage.isMsgPage) {
+        if (!ChapterProvider.renderStyle.optimizeRender || exceed || !onlyTextColumn || textPage.isMsgPage) {
             return false
         }
         if (wordSpacing != 0f && (!atLeastApi26 || !wordSpacingWorking)) {

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

@@ -14,7 +14,6 @@ import io.legado.app.ui.book.read.page.entities.TextChapter.Companion.emptyTextC
 import io.legado.app.ui.book.read.page.entities.column.TextBaseColumn
 import io.legado.app.ui.book.read.page.entities.column.TextColumn
 import io.legado.app.ui.book.read.page.provider.ChapterProvider
-import io.legado.app.ui.config.readConfig.ReadConfig
 import io.legado.app.utils.canvasrecorder.CanvasRecorderFactory
 import io.legado.app.utils.canvasrecorder.recordIfNeeded
 import io.legado.app.utils.dpToPx
@@ -298,7 +297,7 @@ data class TextPage(
     }
 
     fun draw(view: ContentTextView, canvas: Canvas, relativeOffset: Float) {
-        if (ReadConfig.optimizeRender) {
+        if (ChapterProvider.renderStyle.optimizeRender) {
             render(view)
             canvas.withTranslation(0f, relativeOffset) {
                 canvasRecorder.draw(this)

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

@@ -138,6 +138,12 @@ object ChapterProvider {
         val underlineHeight: Int = 1,
         val underlinePadding: Int = 10,
         val textBottomJustify: Boolean = false,
+        // 下面三项来自 ReadConfig 而不是 ReadBookConfig,但对绘制路径是同一回事:
+        // 每次经门面读都要全量构造一份 ReadSettings(几十个 preferences 查找),
+        // 放在 draw() 里比 getConfig 的监视器锁还贵。
+        val useUnderline: Boolean = false,
+        val optimizeRender: Boolean = false,
+        val isEInkMode: Boolean = false,
     )
 
     @Volatile
@@ -155,8 +161,11 @@ object ChapterProvider {
             underlineExtend = ReadBookConfig.underlineExtend,
             underlineColor = ReadBookConfig.durConfig.curUnderlineColor(),
             underlineHeight = ReadBookConfig.underlineHeight,
-            underlinePadding = ReadBookConfig.durConfig.underlinePadding,
+            underlinePadding = ReadBookConfig.underlinePadding,
             textBottomJustify = ReadBookConfig.textBottomJustify,
+            useUnderline = ReadConfig.useUnderline,
+            optimizeRender = ReadConfig.optimizeRender,
+            isEInkMode = ReadConfig.isEInkMode,
         )
     }
 

+ 7 - 0
app/src/main/java/io/legado/app/ui/config/readConfig/ApplyReadSettingUseCase.kt

@@ -41,6 +41,13 @@ class ApplyReadSettingUseCase {
 
             is ReadConfigIntent.NoAnimScrollPageChanged -> ReadBook.renderCallBack?.upPageAnim()
             is ReadConfigIntent.OptimizeRenderChanged -> updateStyle()
+
+            // useUnderline 进了 RenderStyle 快照,改完必须重建并重绘,否则朗读/搜索
+            // 高亮线要等下一次样式变更才生效
+            is ReadConfigIntent.UseUnderlineChanged -> {
+                ChapterProvider.upRenderStyle()
+                ReadConfigUpdateBus.post(setOf(ConfigUpdateAction.InvalidateTextPage))
+            }
             else -> Unit
         }
     }

+ 4 - 2
app/src/test/java/io/legado/app/data/repository/ReadSettingsMappingTest.kt

@@ -14,11 +14,11 @@ import kotlin.reflect.full.primaryConstructor
 class ReadSettingsMappingTest {
 
     @Test
-    fun `gateway 持久化映射覆盖 ReadSettings 全部 103 个字段`() {
+    fun `gateway 持久化映射覆盖 ReadSettings 全部 104 个字段`() {
         val actualKeys = ReadSettings().toGatewayPrefMap().keys
         val expectedKeys = ReadSettings().expectedGatewayPrefMap().keys
 
-        assertEquals(103, actualKeys.size)
+        assertEquals(104, actualKeys.size)
         assertEquals(expectedKeys, actualKeys)
     }
 
@@ -214,6 +214,7 @@ private fun readSettingsMappingSamples(): List<ReadSettings> {
         base.copy(readUrlInBrowser = true),
         base.copy(showMenuIcon = true),
         base.copy(titleBarCompact = true),
+        base.copy(moreActionsConfig = "change_source,refresh"),
     )
 }
 
@@ -321,4 +322,5 @@ private fun ReadSettings.expectedGatewayPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.chineseConverterType to chineseConverterType,
     PreferKey.showMenuIcon to showMenuIcon,
     PreferKey.titleBarCompact to titleBarCompact,
+    PreferKey.moreActionsConfig to moreActionsConfig,
 )

+ 20 - 4
app/src/test/java/io/legado/app/help/config/ReadConfigJsonRoundTripTest.kt

@@ -84,14 +84,30 @@ class ReadConfigJsonRoundTripTest {
 
     @Test
     fun `copy 会重置颜色记忆化缓存`() {
-        // curTextColor() 把解析结果记在 @Transient 字段里。copy 只复制构造参数,
-        // 缓存回到未初始化——改了颜色的那份必须重新解析,不能沿用旧值。
+        // curTextColor() 把解析结果记在 @Transient 字段里,并用 initColorInt 挡住重复解析。
+        // copy 只复制构造参数,两个字段都应回到未初始化——否则改了颜色的那份会沿用旧解析值。
+        // curTextColor() 本身要经 Koin 取主题网关,JVM 单测起不来,所以用反射模拟
+        // 「ensureColorInts 已经跑过」的状态,直接断言 copy 出来的实例缓存是空的。
         val config = ReadBookConfig.Config(name = "x")
-        val before = config.getTextColor()
+        val cacheField = ReadBookConfig.Config::class.java.getDeclaredField("textColorInt")
+            .apply { isAccessible = true }
+        val initFlagField = ReadBookConfig.Config::class.java.getDeclaredField("initColorInt")
+            .apply { isAccessible = true }
+        cacheField.setInt(config, 0x123456)
+        initFlagField.setBoolean(config, true)
 
         val changed = config.copy(name = "y")
 
-        assertEquals(before, changed.getTextColor())
         assertEquals("y", changed.name)
+        assertEquals(
+            "copy 若带走了 initColorInt,改色后 ensureColorInts 不会重算,永远沿用旧解析值",
+            false,
+            initFlagField.getBoolean(changed),
+        )
+        assertEquals(
+            "copy 若带走了记忆化缓存,改色后会沿用旧解析值",
+            -1,
+            cacheField.getInt(changed),
+        )
     }
 }

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

@@ -143,6 +143,8 @@ class ConfigUpdateActionsInvariantTest {
             "AutoSuggestDayNight",
             "ShowReadTitleAddition",
             "AutoReadSpeed",
+            // 只决定点击目录时开新 Sheet 还是旧 Activity,取用时读设置,无渲染副作用
+            "UseNewTocSheet",
         )
     }
 }

+ 17 - 2
app/src/test/java/io/legado/app/ui/book/read/ReadBookDomainSplitBoundaryTest.kt

@@ -227,12 +227,27 @@ class ReadBookDomainSplitBoundaryTest {
                 ),
             ),
             // 按钮配置域无自持状态:按钮列表仍在 menuConfig 里,靠 stateTypes 守
-            // 「SharedPreferences 读写和归一化逻辑不回流 VM」
+            // 「SharedPreferences 读写和归一化逻辑不回流 VM」。上游曾把「更多操作」的
+            // 归一化/解析直接长在 VM(MoreActionIds 是它的标记),已并回本域。
             DomainSplit(
                 name = "菜单按钮配置",
                 delegateFile = "io/legado/app/ui/book/read/ReadButtonConfigDelegate.kt",
                 stateFields = emptySet(),
-                stateTypes = listOf("ReadBookButtonIds", "getSharedPreferences"),
+                stateTypes = listOf("ReadBookButtonIds", "getSharedPreferences", "MoreActionIds"),
+            ),
+            // 净化规则域无自持状态:allReplaceRules 被 TextProcessingSheet 直读,仍在
+            // UiState;靠 stateTypes 守「规则读写与净化管线刷新不回流 VM」
+            DomainSplit(
+                name = "净化规则",
+                delegateFile = "io/legado/app/ui/book/read/ReadReplaceRuleDelegate.kt",
+                stateFields = emptySet(),
+                stateTypes = listOf(
+                    "replaceRuleRepository.flowAll",
+                    "replaceRuleRepository.setEnabled",
+                    "replaceRuleRepository.moveReplaceRule",
+                    "replaceRuleRepository.insert",
+                    "upReplaceRules",
+                ),
             ),
         )
 

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

@@ -22,12 +22,15 @@ import java.io.File
 class RenderStyleSnapshotBoundaryTest {
 
     @Test
-    fun `渲染实体不直读 ReadBookConfig`() {
+    fun `渲染实体不直读 ReadBookConfig 或 ReadConfig`() {
+        // ReadConfig 门面同罪:它的每个属性都要全量构造一份 Settings 快照
+        // (几十个 preferences 查找),放在 draw() 里比 getConfig 的监视器锁还贵。
         val violations = RENDER_ENTITIES.filter { path ->
-            Regex("""\bReadBookConfig\b""").containsMatchIn(stripComments(mainSource(path)))
+            Regex("""\b(?:ReadBookConfig|ReadConfig)\b""")
+                .containsMatchIn(stripComments(mainSource(path)))
         }
         assertTrue(
-            "以下渲染实体又直接读 ReadBookConfig 了:\n" +
+            "以下渲染实体又直接读 ReadBookConfig / ReadConfig 了:\n" +
                 violations.joinToString("\n") { "  - $it" } + "\n" +
                 "绘制期要用的排版项请加进 ChapterProvider.RenderStyle," +
                 "由 upRenderStyle() 统一重建后读快照。",