Răsfoiți Sursa

feat(Track E · E2): 排版快照改由 gateway 统一驱动,修复弹层显示旧值

根因:排版底座 ReadBookConfig.Config 是可变全局、无 flow,UiState 里的
styleConfig/sheetConfig 只能靠各写入站点手工重建——13 处重建 styleConfig,
而 sheetConfig **只有 1 处**(syncFromReadBook)。于是编辑排版后重开弹层显示旧值。

关键前提(外部审计当初看漏的点):ReadStyleState 只投影 items/selectedIndex/
shareLayout,「只改了字号」不会让这三项变化,MutableStateFlow 会把发射当重复值
丢掉——所以单纯 collect gateway.state 是无效的。故先加 revision 破解判等。

改动:
- ReadStyleState 新增 revision,ReadBookStyleConfigRepository 每次 buildState()
  递增(AtomicLong)。附 ReadStyleStateRevisionTest 固定该前提。
- ReadBookViewModel.collectReadStyle():collect gateway.state,统一重建两份快照。
- ReadStyleGateway 新增 notifyModeChanged():日夜/墨水屏切换时排版值没变但解析后的
  生效值变了(curTextColor/curUnderlineColor/curTextShadowColor 按模式取值),
  经 gateway 重新发布。
- 删除 6 处已被 collector 覆盖的手工重建:DeleteCurrentReadStyleConfig、
  ApplyPresetTheme、背景图 x2、导入配置、ProgressBarBehavior。
- handleConfigUpdate 尾部:捕获 styleMutation,只在「没走 gateway」时手工重建,
  且两份一起重建。

顺带修复同类别的另外两个活 bug:
- 日夜切换只重建 styleConfig,sheetConfig 里的 textColor/titleColor/underlineColor/
  textShadowColor 全是模式相关的 -> 切换后弹层仍显示白天色。
- ChineseConverterType 本身就在 sheetConfig 里,却只重建 styleConfig -> 一直显示旧值。
- EventBus.UP_CONFIG 路径(主题变更会改变解析后的颜色)同样只重建 styleConfig。

已知遗留(未动):ReadBookIntent.OnConfigUpdated 全项目无人 dispatch,是死代码。
5151561 3 luni în urmă
părinte
comite
5292643044

+ 7 - 0
app/src/main/java/io/legado/app/data/repository/ReadBookStyleConfigRepository.kt

@@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.launch
 import java.io.InputStream
 import java.io.InputStream
+import java.util.concurrent.atomic.AtomicLong
 
 
 class ReadBookStyleConfigRepository(
 class ReadBookStyleConfigRepository(
     private val readStyleRepository: ReadStyleRepository,
     private val readStyleRepository: ReadStyleRepository,
@@ -41,6 +42,7 @@ class ReadBookStyleConfigRepository(
             AppLog.put("保存排版配置文件出错", error)
             AppLog.put("保存排版配置文件出错", error)
         },
         },
     )
     )
+    private val stateRevision = AtomicLong(0L)
     private val _state = MutableStateFlow(buildState())
     private val _state = MutableStateFlow(buildState())
     override val state: StateFlow<ReadStyleState> = _state.asStateFlow()
     override val state: StateFlow<ReadStyleState> = _state.asStateFlow()
     override val currentState: ReadStyleState get() = _state.value
     override val currentState: ReadStyleState get() = _state.value
@@ -51,6 +53,10 @@ class ReadBookStyleConfigRepository(
         publishState()
         publishState()
     }
     }
 
 
+    override fun notifyModeChanged() {
+        publishState()
+    }
+
     override fun save() {
     override fun save() {
         publishState()
         publishState()
         saveQueue.submit(
         saveQueue.submit(
@@ -267,6 +273,7 @@ class ReadBookStyleConfigRepository(
     }
     }
 
 
     private fun buildState(): ReadStyleState = ReadStyleState(
     private fun buildState(): ReadStyleState = ReadStyleState(
+        revision = stateRevision.incrementAndGet(),
         items = ReadBookConfig.configsSnapshot().map { config ->
         items = ReadBookConfig.configsSnapshot().map { config ->
             ReadStyleItem(
             ReadStyleItem(
                 name = config.name,
                 name = config.name,

+ 8 - 0
app/src/main/java/io/legado/app/domain/gateway/ReadStyleGateway.kt

@@ -10,6 +10,14 @@ interface ReadStyleGateway {
 
 
     fun refresh()
     fun refresh()
     fun save()
     fun save()
+
+    /**
+     * 日夜 / 墨水屏模式切换后调用。
+     *
+     * 排版值本身没变,但**解析后的生效值**变了(`curTextColor()`/`curUnderlineColor()`/
+     * `curTextShadowColor()` 等都按当前模式取值),订阅方必须重新派生快照。
+     */
+    fun notifyModeChanged()
     fun updateCurrentStyle(mutation: ReadStyleMutation)
     fun updateCurrentStyle(mutation: ReadStyleMutation)
     fun applyPreset(index: Int): Boolean
     fun applyPreset(index: Int): Boolean
     fun addStyle(): Int
     fun addStyle(): Int

+ 9 - 0
app/src/main/java/io/legado/app/domain/model/settings/ReadStyleState.kt

@@ -4,6 +4,15 @@ data class ReadStyleState(
     val items: List<ReadStyleItem> = emptyList(),
     val items: List<ReadStyleItem> = emptyList(),
     val selectedIndex: Int = 0,
     val selectedIndex: Int = 0,
     val shareLayout: Boolean = false,
     val shareLayout: Boolean = false,
+    /**
+     * 排版配置的变更序号,每次发布递增。
+     *
+     * 排版底座 `ReadBookConfig.Config` 是可变全局、无 flow,本状态只投影了
+     * items/selectedIndex/shareLayout 三项。没有本字段时,「只改了字号/行距/下划线」这类
+     * 变更不会让 data class 的相等性发生变化,`StateFlow` 会把发射当成重复值丢掉,
+     * 订阅方永远收不到通知。
+     */
+    val revision: Long = 0L,
 )
 )
 
 
 data class ReadStyleItem(
 data class ReadStyleItem(

+ 46 - 37
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -310,9 +310,34 @@ class ReadBookViewModel(
         collectReadAloudPreferences()
         collectReadAloudPreferences()
         collectEventBus()
         collectEventBus()
         collectReaderSession()
         collectReaderSession()
+        collectReadStyle()
         execute { syncConfiguredTtsVoices() }
         execute { syncConfiguredTtsVoices() }
     }
     }
 
 
+    /**
+     * 消费 [ReadStyleGateway.state](Track E · E2):排版配置的唯一变更通知。
+     *
+     * 排版底座 `ReadBookConfig.Config` 是可变全局、无 flow,此前 UiState 里的
+     * [ReadBookStyleConfig] / [ReadSheetConfigUiState] 只能靠各写入站点手工重建——
+     * 13 处重建 `styleConfig`、**只有 1 处**重建 `sheetConfig`(`syncFromReadBook`),
+     * 于是编辑排版后重开弹层显示的是旧值。
+     *
+     * 改由 gateway 的 `publishState()` 统一驱动后,「新增写入路径忘了重建快照」这个
+     * 失效类别不再存在:写入必经 gateway,gateway 必发 state,这里必然重建两份快照。
+     */
+    private fun collectReadStyle() {
+        viewModelScope.launch {
+            readBookStyleConfigRepository.state.collect {
+                _uiState.update { state ->
+                    state.copy(
+                        styleConfig = buildStyleConfig(),
+                        sheetConfig = buildSheetConfig(),
+                    )
+                }
+            }
+        }
+    }
+
     /**
     /**
      * 消费 [ReaderSession.state](Track A A5):会话快照在任意受控 mutator 完成后发射,
      * 消费 [ReaderSession.state](Track A A5):会话快照在任意受控 mutator 完成后发射,
      * 据此驱动 UiState 刷新。与遗留 CallBack 刷新路径叠加、幂等(相同结果 StateFlow 不再发),
      * 据此驱动 UiState 刷新。与遗留 CallBack 刷新路径叠加、幂等(相同结果 StateFlow 不再发),
@@ -1538,13 +1563,7 @@ class ReadBookViewModel(
             }
             }
             is ReadBookIntent.DeleteCurrentReadStyleConfig -> {
             is ReadBookIntent.DeleteCurrentReadStyleConfig -> {
                 if (readBookStyleConfigRepository.deleteCurrentStyle()) {
                 if (readBookStyleConfigRepository.deleteCurrentStyle()) {
-                    _uiState.update {
-                        it.copy(
-                            styleConfig = buildStyleConfig(),
-                            sheetConfig = buildSheetConfig(),
-                            activeSheet = null,
-                        )
-                    }
+                    _uiState.update { it.copy(activeSheet = null) }
                     _effects.tryEmit(ReadBookEffect.UpdateReadViewConfig(
                     _effects.tryEmit(ReadBookEffect.UpdateReadViewConfig(
                         setOf(
                         setOf(
                             ConfigUpdateAction.UpdateBackground,
                             ConfigUpdateAction.UpdateBackground,
@@ -1559,12 +1578,6 @@ class ReadBookViewModel(
                 if (!readBookStyleConfigRepository.applyPreset(intent.presetIndex)) {
                 if (!readBookStyleConfigRepository.applyPreset(intent.presetIndex)) {
                     return@onIntent
                     return@onIntent
                 }
                 }
-                _uiState.update {
-                    it.copy(
-                        styleConfig = buildStyleConfig(),
-                        sheetConfig = buildSheetConfig(),
-                    )
-                }
                 _effects.tryEmit(
                 _effects.tryEmit(
                     ReadBookEffect.UpdateReadViewConfig(
                     ReadBookEffect.UpdateReadViewConfig(
                         setOf(
                         setOf(
@@ -2170,7 +2183,10 @@ class ReadBookViewModel(
         viewModelScope.launch {
         viewModelScope.launch {
             eventFlow<ArrayList<Int>>(EventBus.UP_CONFIG).collect { values ->
             eventFlow<ArrayList<Int>>(EventBus.UP_CONFIG).collect { values ->
                 _uiState.update {
                 _uiState.update {
-                    it.copy(styleConfig = buildStyleConfig())
+                    it.copy(
+                        styleConfig = buildStyleConfig(),
+                        sheetConfig = buildSheetConfig(),
+                    )
                 }
                 }
                 // Convert legacy integer codes to ConfigUpdateAction set
                 // Convert legacy integer codes to ConfigUpdateAction set
                 val actions = values.mapNotNull { code ->
                 val actions = values.mapNotNull { code ->
@@ -4943,7 +4959,8 @@ class ReadBookViewModel(
 
 
     @Suppress("LongMethod")
     @Suppress("LongMethod")
     private fun handleConfigUpdate(update: ConfigUpdate) {
     private fun handleConfigUpdate(update: ConfigUpdate) {
-        update.toReadStyleMutation()?.let(readBookStyleConfigRepository::updateCurrentStyle)
+        val styleMutation = update.toReadStyleMutation()
+        styleMutation?.let(readBookStyleConfigRepository::updateCurrentStyle)
         when (update) {
         when (update) {
             // --- Text style ---
             // --- Text style ---
             is ConfigUpdate.TextSize,
             is ConfigUpdate.TextSize,
@@ -5495,7 +5512,6 @@ class ReadBookViewModel(
                 viewModelScope.launch {
                 viewModelScope.launch {
                     readSettingsRepository.setProgressBarBehavior(update.value)
                     readSettingsRepository.setProgressBarBehavior(update.value)
                 }
                 }
-                _uiState.update { it.copy(styleConfig = buildStyleConfig()) }
             }
             }
             is ConfigUpdate.MouseWheelPage -> {
             is ConfigUpdate.MouseWheelPage -> {
                 viewModelScope.launch {
                 viewModelScope.launch {
@@ -5600,14 +5616,16 @@ class ReadBookViewModel(
             }
             }
         }
         }
 
 
-        // Notify rendering layer. styleConfig and sheetConfig are both derived
-        // from ReadBookConfig, so keep them rebuilt together — otherwise the
-        // settings sheet re-seeds its controls from a stale sheetConfig on reopen.
-        _uiState.update {
-            it.copy(
-                styleConfig = buildStyleConfig(),
-                sheetConfig = buildSheetConfig(),
-            )
+        // 走了 gateway 的更新由 collectReadStyle 重建快照;只写 DataStore 的更新
+        // 不经 gateway,需在此手工重建——且必须两份一起,
+        // 否则像 ChineseConverterType 这种本就在 sheetConfig 里的项会一直显示旧值。
+        if (styleMutation == null) {
+            _uiState.update {
+                it.copy(
+                    styleConfig = buildStyleConfig(),
+                    sheetConfig = buildSheetConfig(),
+                )
+            }
         }
         }
         if (update.actions.isNotEmpty()) {
         if (update.actions.isNotEmpty()) {
             _effects.tryEmit(ReadBookEffect.UpdateReadViewConfig(update.actions))
             _effects.tryEmit(ReadBookEffect.UpdateReadViewConfig(update.actions))
@@ -6078,12 +6096,11 @@ class ReadBookViewModel(
             } else {
             } else {
                 it.activeReminder
                 it.activeReminder
             }
             }
-            it.copy(
-                activeReminder = newActiveReminder,
-                styleConfig = buildStyleConfig(),
-                sheetConfig = buildSheetConfig(),
-            )
+            it.copy(activeReminder = newActiveReminder)
         }
         }
+        // 排版值没变但解析后的生效值变了(颜色按模式取),经 gateway 统一重新发布,
+        // 由 collectReadStyle 重建 styleConfig + sheetConfig。
+        readBookStyleConfigRepository.notifyModeChanged()
         reminderQueue.removeAll { it.type is ReminderType.DayNightReminder }
         reminderQueue.removeAll { it.type is ReminderType.DayNightReminder }
         _effects.tryEmit(ReadBookEffect.UpdateReadViewConfig(
         _effects.tryEmit(ReadBookEffect.UpdateReadViewConfig(
             setOf(
             setOf(
@@ -6105,7 +6122,6 @@ class ReadBookViewModel(
                     readBookStyleConfigRepository.saveBackgroundImage(it, name)
                     readBookStyleConfigRepository.saveBackgroundImage(it, name)
                 } ?: throw FileNotFoundException(uri.toString())
                 } ?: throw FileNotFoundException(uri.toString())
                 readBookStyleConfigRepository.setCurrentBackgroundImage(path)
                 readBookStyleConfigRepository.setCurrentBackgroundImage(path)
-                _uiState.update { it.copy(styleConfig = buildStyleConfig()) }
                 _effects.tryEmit(ReadBookEffect.UpdateReadViewConfig(
                 _effects.tryEmit(ReadBookEffect.UpdateReadViewConfig(
                     setOf(ConfigUpdateAction.UpdateBackground)
                     setOf(ConfigUpdateAction.UpdateBackground)
                 ))
                 ))
@@ -6127,7 +6143,6 @@ class ReadBookViewModel(
                     readBookStyleConfigRepository.saveBackgroundImage(it, name)
                     readBookStyleConfigRepository.saveBackgroundImage(it, name)
                 } ?: throw FileNotFoundException(uri.toString())
                 } ?: throw FileNotFoundException(uri.toString())
                 readBookStyleConfigRepository.setCurrentBackgroundImageForMode(path, isNight)
                 readBookStyleConfigRepository.setCurrentBackgroundImageForMode(path, isNight)
-                _uiState.update { it.copy(styleConfig = buildStyleConfig()) }
                 _effects.tryEmit(ReadBookEffect.UpdateReadViewConfig(
                 _effects.tryEmit(ReadBookEffect.UpdateReadViewConfig(
                     setOf(ConfigUpdateAction.UpdateBackground)
                     setOf(ConfigUpdateAction.UpdateBackground)
                 ))
                 ))
@@ -6147,12 +6162,6 @@ class ReadBookViewModel(
                 val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
                 val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
                     ?: throw FileNotFoundException(uri.toString())
                     ?: throw FileNotFoundException(uri.toString())
                 readBookStyleConfigRepository.importCurrentStyle(bytes)
                 readBookStyleConfigRepository.importCurrentStyle(bytes)
-                _uiState.update {
-                    it.copy(
-                        styleConfig = buildStyleConfig(),
-                        sheetConfig = buildSheetConfig(),
-                    )
-                }
                 _effects.tryEmit(ReadBookEffect.UpdateReadViewConfig(
                 _effects.tryEmit(ReadBookEffect.UpdateReadViewConfig(
                     setOf(
                     setOf(
                         ConfigUpdateAction.UpdateBackground,
                         ConfigUpdateAction.UpdateBackground,

+ 51 - 0
app/src/test/java/io/legado/app/domain/model/settings/ReadStyleStateRevisionTest.kt

@@ -0,0 +1,51 @@
+package io.legado.app.domain.model.settings
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import org.junit.Assert.assertNotSame
+import org.junit.Assert.assertSame
+import org.junit.Test
+
+/**
+ * Track E · E2 —— `ReadStyleState.revision` 的存在理由。
+ *
+ * 排版底座 `ReadBookConfig.Config` 是可变全局、无 flow,[ReadStyleState] 只投影了
+ * items/selectedIndex/shareLayout 三项。「只改了字号/行距/下划线」这类变更不会让这三项
+ * 发生变化——而 `MutableStateFlow` 在新值与旧值相等时**不更新、不通知订阅方**。
+ *
+ * 所以只把 `ReadBookViewModel` 改成 collect gateway 的 state 是不够的:没有 [revision],
+ * 绝大多数排版编辑根本发不出去。本测试把这个前提固定住——若将来有人把 `revision` 从
+ * 主构造函数里挪走(或标成不参与相等性),这里会红。
+ */
+class ReadStyleStateRevisionTest {
+
+    @Test
+    fun `三项投影字段不变时 StateFlow 会丢掉这次发射`() {
+        val flow = MutableStateFlow(ReadStyleState(revision = 7L))
+        val before = flow.value
+
+        // 模拟「只改了字号」:items/selectedIndex/shareLayout 全都没变
+        flow.value = ReadStyleState(revision = 7L)
+
+        assertSame(
+            "内容相等时 MutableStateFlow 保留旧实例、不通知订阅方——" +
+                "这正是排版编辑需要 revision 的原因",
+            before,
+            flow.value,
+        )
+    }
+
+    @Test
+    fun `revision 递增后同样内容会被当作新值发射`() {
+        val flow = MutableStateFlow(ReadStyleState(revision = 7L))
+        val before = flow.value
+
+        flow.value = ReadStyleState(revision = 8L)
+
+        assertNotSame(
+            "revision 递增后必须被 StateFlow 当作新值,否则 ReadBookViewModel " +
+                "收不到排版变更通知,弹层会显示旧值",
+            before,
+            flow.value,
+        )
+    }
+}