Forráskód Böngészése

refactor: 迁移 Manga 设置网关

5151561 3 hónapja
szülő
commit
e00e672467

+ 21 - 64
app/src/main/java/io/legado/app/data/repository/MangaSettingsRepository.kt

@@ -3,8 +3,6 @@ package io.legado.app.data.repository
 import androidx.datastore.preferences.core.Preferences
 import io.legado.app.constant.PreferKey
 import io.legado.app.domain.gateway.MangaSettingsGateway
-import io.legado.app.domain.gateway.MangaClickArea
-import io.legado.app.domain.gateway.MangaSettingsUpdate
 import io.legado.app.domain.model.settings.MangaSettings
 import io.legado.app.help.config.AppConfigStore
 import io.legado.app.help.config.compatDsBoolean
@@ -13,76 +11,35 @@ import io.legado.app.help.config.compatDsString
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.distinctUntilChanged
 import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+
+class MangaSettingsRepository internal constructor(
+    private val preferences: () -> Preferences,
+    preferencesFlow: Flow<Preferences>,
+    private val putAll: (Map<String, Any?>) -> Unit,
+) : MangaSettingsGateway {
+    constructor() : this(
+        preferences = { AppConfigStore.preferences },
+        preferencesFlow = AppConfigStore.preferencesFlow,
+        putAll = AppConfigStore::putAll,
+    )
+
+    private val updateMutex = Mutex()
 
-class MangaSettingsRepository : MangaSettingsGateway {
     override val currentSettings: MangaSettings
-        get() = AppConfigStore.preferences.toMangaSettings()
+        get() = preferences().toMangaSettings()
 
-    override val settings: Flow<MangaSettings> = AppConfigStore.preferencesFlow
+    override val settings: Flow<MangaSettings> = preferencesFlow
         .map { it.toMangaSettings() }
         .distinctUntilChanged()
 
-    override suspend fun update(update: MangaSettingsUpdate) = updateAll(listOf(update))
-
-    override suspend fun updateAll(updates: List<MangaSettingsUpdate>) {
-        AppConfigStore.putAll(updates.toMangaPreferenceValues())
-    }
-}
-
-internal fun List<MangaSettingsUpdate>.toMangaPreferenceValues(): Map<String, Any?> =
-    buildMap {
-        this@toMangaPreferenceValues.forEach { update ->
-            when (update) {
-                    is MangaSettingsUpdate.ShowMangaUi -> put(PreferKey.showMangaUi, update.value)
-                    is MangaSettingsUpdate.DisableScale -> put(PreferKey.disableMangaScale, update.value)
-                    is MangaSettingsUpdate.DisableScrollAnimation ->
-                        put(PreferKey.disableMangaScrollAnimation, update.value)
-                    is MangaSettingsUpdate.DisableCrossFade ->
-                        put(PreferKey.disableMangaCrossFade, update.value)
-                    is MangaSettingsUpdate.ScrollMode -> put(PreferKey.mangaScrollMode, update.value)
-                    is MangaSettingsUpdate.PreDownloadNum ->
-                        put(PreferKey.mangaPreDownloadNum, update.value)
-                    is MangaSettingsUpdate.AutoPageSpeed ->
-                        put(PreferKey.mangaAutoPageSpeed, update.value)
-                    is MangaSettingsUpdate.FooterConfig -> put(PreferKey.mangaFooterConfig, update.value)
-                    is MangaSettingsUpdate.DisableClickScroll ->
-                        put(PreferKey.disableClickScroll, update.value)
-                    is MangaSettingsUpdate.LongClick -> put(PreferKey.mangaLongClick, update.value)
-                    is MangaSettingsUpdate.Background -> put(PreferKey.mangaBackground, update.value)
-                    is MangaSettingsUpdate.ColorFilter -> put(PreferKey.mangaColorFilter, update.value)
-                    is MangaSettingsUpdate.HideTitle -> put(PreferKey.hideMangaTitle, update.value)
-                    is MangaSettingsUpdate.EInk -> {
-                        put(PreferKey.enableMangaEInk, update.enabled)
-                        put(PreferKey.enableMangaGray, false)
-                        put(PreferKey.mangaEInkThreshold, update.threshold)
-                    }
-                    is MangaSettingsUpdate.Gray -> {
-                        put(PreferKey.enableMangaGray, update.enabled)
-                        put(PreferKey.enableMangaEInk, false)
-                    }
-                    is MangaSettingsUpdate.WebtoonSidePadding ->
-                        put(PreferKey.webtoonSidePaddingDp, update.value)
-                    is MangaSettingsUpdate.VolumeKeyPage ->
-                        put(PreferKey.mangaVolumeKeyPage, update.value)
-                    is MangaSettingsUpdate.ReverseVolumeKeyPage ->
-                        put(PreferKey.reverseVolumeKeyPage, update.value)
-                    is MangaSettingsUpdate.ClickAction -> put(
-                        when (update.area) {
-                            MangaClickArea.TL -> PreferKey.mangaClickActionTL
-                            MangaClickArea.TC -> PreferKey.mangaClickActionTC
-                            MangaClickArea.TR -> PreferKey.mangaClickActionTR
-                            MangaClickArea.ML -> PreferKey.mangaClickActionML
-                            MangaClickArea.MC -> PreferKey.mangaClickActionMC
-                            MangaClickArea.MR -> PreferKey.mangaClickActionMR
-                            MangaClickArea.BL -> PreferKey.mangaClickActionBL
-                            MangaClickArea.BC -> PreferKey.mangaClickActionBC
-                            MangaClickArea.BR -> PreferKey.mangaClickActionBR
-                        },
-                        update.value,
-                    )
-            }
+    override suspend fun update(transform: (MangaSettings) -> MangaSettings) {
+        updateMutex.withLock {
+            putAll(currentSettings.diffPrefMap(transform, MangaSettings::toPrefMap))
         }
     }
+}
 
 internal fun MangaSettings.toPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.showMangaUi to showMangaUi,

+ 1 - 26
app/src/main/java/io/legado/app/domain/gateway/MangaSettingsGateway.kt

@@ -6,30 +6,5 @@ import kotlinx.coroutines.flow.Flow
 interface MangaSettingsGateway {
     val currentSettings: MangaSettings
     val settings: Flow<MangaSettings>
-    suspend fun update(update: MangaSettingsUpdate)
-    suspend fun updateAll(updates: List<MangaSettingsUpdate>)
+    suspend fun update(transform: (MangaSettings) -> MangaSettings)
 }
-
-sealed interface MangaSettingsUpdate {
-    data class ShowMangaUi(val value: Boolean) : MangaSettingsUpdate
-    data class DisableScale(val value: Boolean) : MangaSettingsUpdate
-    data class DisableScrollAnimation(val value: Boolean) : MangaSettingsUpdate
-    data class DisableCrossFade(val value: Boolean) : MangaSettingsUpdate
-    data class ScrollMode(val value: Int) : MangaSettingsUpdate
-    data class PreDownloadNum(val value: Int) : MangaSettingsUpdate
-    data class AutoPageSpeed(val value: Int) : MangaSettingsUpdate
-    data class FooterConfig(val value: String) : MangaSettingsUpdate
-    data class DisableClickScroll(val value: Boolean) : MangaSettingsUpdate
-    data class LongClick(val value: Boolean) : MangaSettingsUpdate
-    data class Background(val value: Int) : MangaSettingsUpdate
-    data class ColorFilter(val value: String) : MangaSettingsUpdate
-    data class HideTitle(val value: Boolean) : MangaSettingsUpdate
-    data class EInk(val enabled: Boolean, val threshold: Int) : MangaSettingsUpdate
-    data class Gray(val enabled: Boolean) : MangaSettingsUpdate
-    data class WebtoonSidePadding(val value: Int) : MangaSettingsUpdate
-    data class VolumeKeyPage(val value: Boolean) : MangaSettingsUpdate
-    data class ReverseVolumeKeyPage(val value: Boolean) : MangaSettingsUpdate
-    data class ClickAction(val area: MangaClickArea, val value: Int) : MangaSettingsUpdate
-}
-
-enum class MangaClickArea { TL, TC, TR, ML, MC, MR, BL, BC, BR }

+ 18 - 20
app/src/main/java/io/legado/app/ui/book/manga/ReadMangaActivity.kt

@@ -37,7 +37,7 @@ import io.legado.app.data.entities.BookProgress
 import io.legado.app.data.entities.BookSource
 import io.legado.app.databinding.ActivityMangaBinding
 import io.legado.app.databinding.ViewLoadMoreBinding
-import io.legado.app.domain.gateway.MangaSettingsUpdate
+import io.legado.app.domain.model.settings.MangaSettings
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.isImage
@@ -557,20 +557,18 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
     override fun onColorSelected(dialogId: Int, color: Int){
         if (dialogId == MANGA_B)
         {
-            updateMangaSetting(MangaSettingsUpdate.Background(color)) {
-                setBackground()
-            }
+            updateMangaSetting(afterUpdate = ::setBackground) { it.copy(background = color) }
         }
     }
 
     override fun onDialogDismissed(dialogId: Int) = Unit
 
     private fun updateMangaSetting(
-        update: MangaSettingsUpdate,
         afterUpdate: () -> Unit = {},
+        transform: (MangaSettings) -> MangaSettings,
     ) {
         lifecycleScope.launch {
-            viewModel.updateMangaSettings(update)
+            viewModel.updateMangaSettings(transform)
             afterUpdate()
         }
     }
@@ -773,7 +771,7 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
                     getString(R.string.pre_download),
                     viewModel.mangaSettings.value.preDownloadNum
                 ) {
-                    updateMangaSetting(MangaSettingsUpdate.PreDownloadNum(it))
+                    updateMangaSetting { settings -> settings.copy(preDownloadNum = it) }
                     item.title = getString(R.string.pre_download_m, it)
                     setRecyclerViewPreloader(it)
                 }
@@ -827,20 +825,20 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
 
     //点击滑动
     override fun onClickScrollDisabledChanged(disabled: Boolean) {
-        updateMangaSetting(MangaSettingsUpdate.DisableClickScroll(disabled))
+        updateMangaSetting { it.copy(disableClickScroll = disabled) }
     }
 
     override fun onScrollAniDisabledChanged(disabled: Boolean) {
-        updateMangaSetting(MangaSettingsUpdate.DisableScrollAnimation(disabled))
+        updateMangaSetting { it.copy(disableMangaScrollAnimation = disabled) }
     }
 
     override fun onCrossFadeDisabledChanged(disabled: Boolean) {
-        updateMangaSetting(MangaSettingsUpdate.DisableCrossFade(disabled))
+        updateMangaSetting { it.copy(disableMangaCrossFade = disabled) }
     }
 
     //双击缩放
     override fun onMangaScaleDisabledChanged(disabled: Boolean) {
-        updateMangaSetting(MangaSettingsUpdate.DisableScale(disabled))
+        updateMangaSetting { it.copy(disableMangaScale = disabled) }
         setDisableMangaScale(disabled)
     }
 
@@ -854,13 +852,15 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
 
     //墨水屏
     override fun updateEpaperMode(enabled: Boolean, threshold: Int) {
-        updateMangaSetting(MangaSettingsUpdate.EInk(enabled, threshold))
+        updateMangaSetting {
+            it.copy(enableEInk = enabled, enableGray = false, eInkThreshold = threshold)
+        }
         mAdapter.enableMangaEInk(enabled, threshold)
     }
 
     //灰度
     override fun updateGrayMode(enabled: Boolean) {
-        updateMangaSetting(MangaSettingsUpdate.Gray(enabled))
+        updateMangaSetting { it.copy(enableEInk = false, enableGray = enabled) }
         mAdapter.enableGray(enabled)
     }
 
@@ -878,27 +878,25 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
     //自动翻页速度
     override fun onAutoPageSpeedChanged(speed: Int) {
         setAutoReadEnabled(false)
-        updateMangaSetting(MangaSettingsUpdate.AutoPageSpeed(speed))
+        updateMangaSetting { it.copy(autoPageSpeed = speed) }
         mScrollTimer.setSpeed(speed)
         setAutoReadEnabled(enableScroll)
     }
 
     override fun onMangaLongClickChanged(checked: Boolean) {
-        updateMangaSetting(MangaSettingsUpdate.LongClick(checked))
+        updateMangaSetting { it.copy(longClick = checked) }
     }
 
     override fun onVolumeKeyPageChanged(enable: Boolean) {
-        updateMangaSetting(MangaSettingsUpdate.VolumeKeyPage(enable))
+        updateMangaSetting { it.copy(volumeKeyPage = enable) }
     }
 
     override fun onReverseVolumeKeyPageChanged(enable: Boolean) {
-        updateMangaSetting(MangaSettingsUpdate.ReverseVolumeKeyPage(enable))
+        updateMangaSetting { it.copy(reverseVolumeKeyPage = enable) }
     }
 
     override fun onHideMangaTitleChanged(hide: Boolean) {
-        updateMangaSetting(MangaSettingsUpdate.HideTitle(hide)) {
-            ReadManga.loadContent()
-        }
+        updateMangaSetting(afterUpdate = ReadManga::loadContent) { it.copy(hideTitle = hide) }
     }
 
     override fun openBookInfoActivity() {

+ 3 - 3
app/src/main/java/io/legado/app/ui/book/manga/ReadMangaViewModel.kt

@@ -19,7 +19,7 @@ import io.legado.app.domain.model.ReadingProgress
 import io.legado.app.domain.gateway.MangaSettingsGateway
 import io.legado.app.domain.gateway.OtherSettingsGateway
 import io.legado.app.domain.gateway.ReadSettingsGateway
-import io.legado.app.domain.gateway.MangaSettingsUpdate
+import io.legado.app.domain.model.settings.MangaSettings
 import io.legado.app.domain.usecase.GetReadingProgressUseCase
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.book.BookHelp
@@ -72,8 +72,8 @@ class ReadMangaViewModel(
         }
     }
 
-    suspend fun updateMangaSettings(update: MangaSettingsUpdate) {
-        mangaSettingsGateway.update(update)
+    suspend fun updateMangaSettings(transform: (MangaSettings) -> MangaSettings) {
+        mangaSettingsGateway.update(transform)
     }
 
     /**

+ 13 - 29
app/src/main/java/io/legado/app/ui/book/manga/config/MangaClickActionConfigDialog.kt

@@ -8,11 +8,9 @@ import android.widget.TextView
 import androidx.lifecycle.lifecycleScope
 import io.legado.app.R
 import io.legado.app.base.BaseOverlayDialogFragment
-import io.legado.app.constant.PreferKey
 import io.legado.app.databinding.DialogClickActionConfigBinding
-import io.legado.app.domain.gateway.MangaClickArea
 import io.legado.app.domain.gateway.MangaSettingsGateway
-import io.legado.app.domain.gateway.MangaSettingsUpdate
+import io.legado.app.domain.model.settings.MangaSettings
 import io.legado.app.lib.dialogs.selector
 import io.legado.app.utils.getCompatColor
 import io.legado.app.utils.toastOnUi
@@ -73,83 +71,71 @@ class MangaClickActionConfigDialog : BaseOverlayDialogFragment(R.layout.dialog_c
 
         binding.tvTopLeft.setOnClickListener {
             selectAction { action ->
-                setClickAction(PreferKey.mangaClickActionTL, action)
+                setClickAction { it.copy(clickActionTL = action) }
                 (it as? TextView)?.text = actions[action]
             }
         }
 
         binding.tvTopCenter.setOnClickListener {
             selectAction { action ->
-                setClickAction(PreferKey.mangaClickActionTC, action)
+                setClickAction { it.copy(clickActionTC = action) }
                 (it as? TextView)?.text = actions[action]
             }
         }
 
         binding.tvTopRight.setOnClickListener {
             selectAction { action ->
-                setClickAction(PreferKey.mangaClickActionTR, action)
+                setClickAction { it.copy(clickActionTR = action) }
                 (it as? TextView)?.text = actions[action]
             }
         }
 
         binding.tvMiddleLeft.setOnClickListener {
             selectAction { action ->
-                setClickAction(PreferKey.mangaClickActionML, action)
+                setClickAction { it.copy(clickActionML = action) }
                 (it as? TextView)?.text = actions[action]
             }
         }
 
         binding.tvMiddleCenter.setOnClickListener {
             selectAction { action ->
-                setClickAction(PreferKey.mangaClickActionMC, action)
+                setClickAction { it.copy(clickActionMC = action) }
                 (it as? TextView)?.text = actions[action]
             }
         }
 
         binding.tvMiddleRight.setOnClickListener {
             selectAction { action ->
-                setClickAction(PreferKey.mangaClickActionMR, action)
+                setClickAction { it.copy(clickActionMR = action) }
                 (it as? TextView)?.text = actions[action]
             }
         }
 
         binding.tvBottomLeft.setOnClickListener {
             selectAction { action ->
-                setClickAction(PreferKey.mangaClickActionBL, action)
+                setClickAction { it.copy(clickActionBL = action) }
                 (it as? TextView)?.text = actions[action]
             }
         }
 
         binding.tvBottomCenter.setOnClickListener {
             selectAction { action ->
-                setClickAction(PreferKey.mangaClickActionBC, action)
+                setClickAction { it.copy(clickActionBC = action) }
                 (it as? TextView)?.text = actions[action]
             }
         }
 
         binding.tvBottomRight.setOnClickListener {
             selectAction { action ->
-                setClickAction(PreferKey.mangaClickActionBR, action)
+                setClickAction { it.copy(clickActionBR = action) }
                 (it as? TextView)?.text = actions[action]
             }
         }
     }
 
-    private fun setClickAction(key: String, action: Int) {
-        val area = when (key) {
-            PreferKey.mangaClickActionTL -> MangaClickArea.TL
-            PreferKey.mangaClickActionTC -> MangaClickArea.TC
-            PreferKey.mangaClickActionTR -> MangaClickArea.TR
-            PreferKey.mangaClickActionML -> MangaClickArea.ML
-            PreferKey.mangaClickActionMC -> MangaClickArea.MC
-            PreferKey.mangaClickActionMR -> MangaClickArea.MR
-            PreferKey.mangaClickActionBL -> MangaClickArea.BL
-            PreferKey.mangaClickActionBC -> MangaClickArea.BC
-            PreferKey.mangaClickActionBR -> MangaClickArea.BR
-            else -> return
-        }
+    private fun setClickAction(transform: (MangaSettings) -> MangaSettings) {
         lifecycleScope.launch(start = CoroutineStart.UNDISPATCHED) {
-            mangaSettingsGateway.update(MangaSettingsUpdate.ClickAction(area, action))
+            mangaSettingsGateway.update(transform)
         }
     }
 
@@ -165,9 +151,7 @@ class MangaClickActionConfigDialog : BaseOverlayDialogFragment(R.layout.dialog_c
     override fun onDestroy() {
         if (!hasMenuClickArea()) {
             lifecycleScope.launch(start = CoroutineStart.UNDISPATCHED) {
-                mangaSettingsGateway.update(
-                    MangaSettingsUpdate.ClickAction(MangaClickArea.MC, 0)
-                )
+                mangaSettingsGateway.update { it.copy(clickActionMC = 0) }
             }
             context?.toastOnUi("当前没有配置菜单区域,自动恢复中间区域为菜单.")
         }

+ 1 - 2
app/src/main/java/io/legado/app/ui/book/manga/config/MangaColorFilterDialog.kt

@@ -10,7 +10,6 @@ import io.legado.app.R
 import io.legado.app.base.BaseBottomSheetDialogFragment
 import io.legado.app.databinding.DialogMangaColorFilterBinding
 import io.legado.app.domain.gateway.MangaSettingsGateway
-import io.legado.app.domain.gateway.MangaSettingsUpdate
 import io.legado.app.utils.GSON
 import io.legado.app.utils.fromJsonObject
 import io.legado.app.utils.invisible
@@ -120,7 +119,7 @@ class MangaColorFilterDialog : BaseBottomSheetDialogFragment(R.layout.dialog_man
     override fun onDismiss(dialog: DialogInterface) {
         super.onDismiss(dialog)
         lifecycleScope.launch {
-            mangaSettingsGateway.update(MangaSettingsUpdate.ColorFilter(mConfig.toJson()))
+            mangaSettingsGateway.update { it.copy(colorFilter = mConfig.toJson()) }
         }
     }
 

+ 2 - 3
app/src/main/java/io/legado/app/ui/book/manga/config/MangaFooterSettingDialog.kt

@@ -14,7 +14,6 @@ import io.legado.app.base.BaseBottomSheetDialogFragment
 import io.legado.app.constant.EventBus
 import io.legado.app.databinding.DialogMangaFooterSettingBinding
 import io.legado.app.domain.gateway.MangaSettingsGateway
-import io.legado.app.domain.gateway.MangaSettingsUpdate
 import io.legado.app.ui.book.manga.entities.MangaFooterConfig
 import io.legado.app.ui.widget.ReaderInfoBarView
 import io.legado.app.utils.GSON
@@ -66,7 +65,7 @@ class MangaFooterSettingDialog :
                 }
                 setOnLongClickListener {
                     lifecycleScope.launch {
-                        mangaSettingsGateway.update(MangaSettingsUpdate.ScrollMode(mode))
+                        mangaSettingsGateway.update { it.copy(scrollMode = mode) }
                     }
                     toastOnUi("已设置为全局默认模式")
                     true
@@ -263,7 +262,7 @@ class MangaFooterSettingDialog :
     override fun onDismiss(dialog: DialogInterface) {
         super.onDismiss(dialog)
         lifecycleScope.launch {
-            mangaSettingsGateway.update(MangaSettingsUpdate.FooterConfig(GSON.toJson(config)))
+            mangaSettingsGateway.update { it.copy(footerConfig = GSON.toJson(config)) }
         }
     }
 

+ 195 - 46
app/src/test/java/io/legado/app/data/repository/MangaSettingsMappingTest.kt

@@ -1,12 +1,21 @@
 package io.legado.app.data.repository
 
+import androidx.datastore.preferences.core.Preferences
 import androidx.datastore.preferences.core.stringPreferencesKey
 import androidx.datastore.preferences.core.mutablePreferencesOf
 import io.legado.app.constant.PreferKey
-import io.legado.app.domain.gateway.MangaClickArea
-import io.legado.app.domain.gateway.MangaSettingsUpdate
+import io.legado.app.domain.gateway.MangaSettingsGateway
 import io.legado.app.domain.model.settings.MangaSettings
+import io.legado.app.help.config.PendingOverlayCore
 import io.legado.app.help.config.setPrefValue
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertFalse
 import org.junit.Assert.assertTrue
@@ -15,42 +24,17 @@ import org.junit.Test
 class MangaSettingsMappingTest {
 
     @Test
-    fun `漫画设置写读映射往返恒等`() {
-        val expected = MangaSettings(
-            showMangaUi = false,
-            disableMangaScale = false,
-            disableMangaScrollAnimation = true,
-            disableMangaCrossFade = true,
-            scrollMode = 2,
-            preDownloadNum = 22,
-            autoPageSpeed = 8,
-            footerConfig = "footer",
-            disableClickScroll = true,
-            longClick = false,
-            background = 0xFF123456.toInt(),
-            colorFilter = "filter",
-            hideTitle = true,
-            enableEInk = true,
-            eInkThreshold = 188,
-            enableGray = true,
-            webtoonSidePaddingDp = 12,
-            volumeKeyPage = true,
-            reverseVolumeKeyPage = true,
-            clickActionTL = 0,
-            clickActionTC = 1,
-            clickActionTR = 2,
-            clickActionML = 3,
-            clickActionMC = 4,
-            clickActionMR = 5,
-            clickActionBL = 6,
-            clickActionBC = 7,
-            clickActionBR = 8,
-        )
-        val preferences = mutablePreferencesOf().apply {
-            expected.toPrefMap().forEach { (key, value) -> setPrefValue(key, value) }
+    fun `漫画设置 28 键写映射逐字段对应`() {
+        mangaMappingSamples().forEach { settings ->
+            assertEquals(settings.expectedPrefMap(), settings.toPrefMap())
         }
+    }
 
-        assertEquals(expected, preferences.toMangaSettings())
+    @Test
+    fun `漫画设置 28 键读映射逐字段对应`() {
+        mangaMappingSamples().forEach { expected ->
+            assertEquals(expected, expected.expectedPrefMap().toPreferences().toMangaSettings())
+        }
     }
 
     @Test
@@ -69,16 +53,96 @@ class MangaSettingsMappingTest {
     }
 
     @Test
-    fun `墨水屏与点击区域更新映射为同一批键值`() {
-        val values = listOf(
-            MangaSettingsUpdate.EInk(enabled = true, threshold = 188),
-            MangaSettingsUpdate.ClickAction(MangaClickArea.MC, 0),
-        ).toMangaPreferenceValues()
-
-        assertEquals(true, values[PreferKey.enableMangaEInk])
-        assertEquals(false, values[PreferKey.enableMangaGray])
-        assertEquals(188, values[PreferKey.mangaEInkThreshold])
-        assertEquals(0, values[PreferKey.mangaClickActionMC])
+    fun `墨水屏更新一次产生启用关闭灰度和阈值三项差量`() {
+        val diff = MangaSettings(enableGray = true).diffPrefMap(
+            transform = {
+                it.copy(enableEInk = true, enableGray = false, eInkThreshold = 188)
+            },
+            toPrefMap = MangaSettings::toPrefMap,
+        )
+
+        assertEquals(
+            mapOf(
+                PreferKey.enableMangaEInk to true,
+                PreferKey.mangaEInkThreshold to 188,
+                PreferKey.enableMangaGray to false,
+            ),
+            diff,
+        )
+    }
+
+    @Test
+    fun `灰度更新一次产生启用灰度和关闭墨水屏两项差量`() {
+        val diff = MangaSettings(enableEInk = true).diffPrefMap(
+            transform = { it.copy(enableEInk = false, enableGray = true) },
+            toPrefMap = MangaSettings::toPrefMap,
+        )
+
+        assertEquals(
+            mapOf(
+                PreferKey.enableMangaEInk to false,
+                PreferKey.enableMangaGray to true,
+            ),
+            diff,
+        )
+    }
+
+    @Test
+    fun `gateway 通过不可变快照 transform 更新设置`() = runBlocking {
+        val gateway = FakeMangaSettingsGateway(MangaSettings(enableGray = true))
+
+        gateway.update {
+            it.copy(enableEInk = true, enableGray = false, eInkThreshold = 188)
+        }
+
+        assertEquals(
+            MangaSettings(enableEInk = true, enableGray = false, eInkThreshold = 188),
+            gateway.currentSettings,
+        )
+    }
+
+    @Test
+    fun `并发启用墨水屏与灰度时完整 transform 串行且保持互斥`() = runBlocking {
+        val initial = MangaSettings()
+        val core = PendingOverlayCore(
+            initial = initial.expectedPrefMap().toPreferences(),
+            launchWrite = {},
+            persist = { _, _ -> error("不会执行落盘") },
+            persistAll = { error("不会执行落盘") },
+        )
+        val repository = MangaSettingsRepository(
+            preferences = { core.preferencesFlow.value },
+            preferencesFlow = core.preferencesFlow,
+            putAll = core::putAll,
+        )
+        val firstEntered = CompletableDeferred<Unit>()
+        val releaseFirst = CompletableDeferred<Unit>()
+        val first = launch(Dispatchers.Default) {
+            repository.update {
+                firstEntered.complete(Unit)
+                runBlocking { releaseFirst.await() }
+                it.copy(enableEInk = true, enableGray = false)
+            }
+        }
+        firstEntered.await()
+
+        val secondEntered = CompletableDeferred<Unit>()
+        val second = launch(start = CoroutineStart.UNDISPATCHED) {
+            repository.update {
+                secondEntered.complete(Unit)
+                it.copy(enableEInk = false, enableGray = true)
+            }
+        }
+        val secondRacedWithFirst = secondEntered.isCompleted
+        releaseFirst.complete(Unit)
+        first.join()
+        second.join()
+
+        assertFalse(secondRacedWithFirst)
+        assertEquals(
+            MangaSettings(enableEInk = false, enableGray = true),
+            repository.currentSettings,
+        )
     }
 
     @Test
@@ -88,4 +152,89 @@ class MangaSettingsMappingTest {
         assertTrue(settings.hasMenuClickArea())
         assertFalse(settings.enableGray)
     }
+
+    private class FakeMangaSettingsGateway(initial: MangaSettings) : MangaSettingsGateway {
+        private val state = MutableStateFlow(initial)
+
+        override val currentSettings: MangaSettings
+            get() = state.value
+        override val settings: Flow<MangaSettings> = state.asStateFlow()
+
+        override suspend fun update(transform: (MangaSettings) -> MangaSettings) {
+            state.value = transform(state.value)
+        }
+    }
+}
+
+private fun mangaMappingSamples(): List<MangaSettings> {
+    val base = MangaSettings(
+        scrollMode = 11,
+        preDownloadNum = 22,
+        autoPageSpeed = 33,
+        footerConfig = "manga-footer",
+        background = 0xFF123456.toInt(),
+        colorFilter = "manga-filter",
+        eInkThreshold = 44,
+        webtoonSidePaddingDp = 55,
+        clickActionTL = 101,
+        clickActionTC = 102,
+        clickActionTR = 103,
+        clickActionML = 104,
+        clickActionMC = 105,
+        clickActionMR = 106,
+        clickActionBL = 107,
+        clickActionBC = 108,
+        clickActionBR = 109,
+    )
+    return listOf(
+        base,
+        base.copy(showMangaUi = false),
+        base.copy(disableMangaScale = false),
+        base.copy(disableMangaScrollAnimation = true),
+        base.copy(disableMangaCrossFade = true),
+        base.copy(disableClickScroll = true),
+        base.copy(longClick = false),
+        base.copy(hideTitle = true),
+        base.copy(enableEInk = true),
+        base.copy(enableGray = true),
+        base.copy(volumeKeyPage = true),
+        base.copy(reverseVolumeKeyPage = true),
+    )
+}
+
+private fun MangaSettings.expectedPrefMap(): Map<String, Any?> = mapOf(
+    PreferKey.showMangaUi to showMangaUi,
+    PreferKey.disableMangaScale to disableMangaScale,
+    PreferKey.disableMangaScrollAnimation to disableMangaScrollAnimation,
+    PreferKey.disableMangaCrossFade to disableMangaCrossFade,
+    PreferKey.mangaScrollMode to scrollMode,
+    PreferKey.mangaPreDownloadNum to preDownloadNum,
+    PreferKey.mangaAutoPageSpeed to autoPageSpeed,
+    PreferKey.mangaFooterConfig to footerConfig,
+    PreferKey.disableClickScroll to disableClickScroll,
+    PreferKey.mangaLongClick to longClick,
+    PreferKey.mangaBackground to background,
+    PreferKey.mangaColorFilter to colorFilter,
+    PreferKey.hideMangaTitle to hideTitle,
+    PreferKey.enableMangaEInk to enableEInk,
+    PreferKey.mangaEInkThreshold to eInkThreshold,
+    PreferKey.enableMangaGray to enableGray,
+    PreferKey.webtoonSidePaddingDp to webtoonSidePaddingDp,
+    PreferKey.mangaVolumeKeyPage to volumeKeyPage,
+    PreferKey.reverseVolumeKeyPage to reverseVolumeKeyPage,
+    PreferKey.mangaClickActionTL to clickActionTL,
+    PreferKey.mangaClickActionTC to clickActionTC,
+    PreferKey.mangaClickActionTR to clickActionTR,
+    PreferKey.mangaClickActionML to clickActionML,
+    PreferKey.mangaClickActionMC to clickActionMC,
+    PreferKey.mangaClickActionMR to clickActionMR,
+    PreferKey.mangaClickActionBL to clickActionBL,
+    PreferKey.mangaClickActionBC to clickActionBC,
+    PreferKey.mangaClickActionBR to clickActionBR,
+)
+
+private fun Map<String, Any?>.toPreferences(): Preferences {
+    val preferences = mutablePreferencesOf()
+    forEach { (key, value) -> preferences.setPrefValue(key, value) }
+    return preferences
 }