Преглед изворни кода

refactor(reader): 摘 ReadReplaceRuleDelegate,清上游两笔提交的护栏账单

7e435c1ea / 02459ebc4 之后 test 套件有 4 条红,与本分支改动无关:

- ReadBookViewModel 涨到 2581 行,超 R2 验收线 2520。溢出的是两块新长在 VM
  里的域逻辑,按护栏指引摘出去而不是放宽阈值:
  - 净化规则域 → 新建 ReadReplaceRuleDelegate(订阅全量规则、开关、拖拽
    排序、停用生效规则、切换本书净化开关、刷新净化管线)。无自持状态,
    allReplaceRules 仍在 UiState(TextProcessingSheet 直读)。
  - 「更多操作」菜单项的归一化与解析 → 并回 ReadButtonConfigDelegate,
    与顶栏/底栏按钮配置同域(差别只是存 DataStore 且缺省开启)。
  VM 回到 2515 行;DOMAINS 加两条边界,净化域用 repository 调用点当标记,
  按钮域加 MoreActionIds。
- ReadSettingsMappingTest:上游新增 moreActionsConfig 字段,映射已加全,
  只是靶子数与样本表没跟上,103 → 104。
- ConfigUpdateActionsInvariantTest:UseNewTocSheet 只决定点目录时开新 Sheet
  还是旧 Activity,取用时读设置,无渲染副作用 → 进 NO_RENDER_EFFECT。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5151561 пре 3 месеци
родитељ
комит
5a2579129b

+ 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)
+            }
+        }
+    }
+}

+ 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,
 )

+ 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",
+                ),
             ),
         )