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

refactor(R4.7): Config 的值字段改不可变,写入口从正则护栏升级为编译器

R4.5 之后状态本体归 ReadStyleConfigStore 所有,但 ReadBookConfig.config 仍把
Config 本体交给约 500 个读点。任何一个都能写,拦着的只有 build.gradle.kts 里那条
`ReadBookConfig.x = ` 正则——`val c = ReadBookConfig.config; c.textSize = 5`
一行就绕过去了。

改造:
- Config 的 92 个构造参数 var → val。内部那批 @Transient 记忆化字段(textColorInt
  等,由 ensureColorInts 在**读**路径写)保持可变——它们是派生缓存不是配置状态,
  且 copy() 只复制构造参数,正好让副本的缓存自动失效。
- 9 个就地 mutator 改成返回新实例:setCurTextColor → withCurTextColor 等。
  顺带不用再手动同步记忆化的 int 了(copy 已经把它重置),少一类「缓存与字符串不同步」。
- ReadStyleResolver.setCurrentBackground → withCurrentBackground。
- ReadStyleConfigStore 加 updateStyleAt / updateEffective 两个写入口,分别对应
  「按样式独立的项」(背景/虚线/状态栏图标/样式名)与「当前生效那份」,
  与改造前写 durConfig.x 还是 config.x 一一对应。下标解析与 configAt 一致,
  否则「读得到、写不进」会成为一类静默失效。
- repository 的 5 个 dispatch 改成 mutateEffective/mutateCurrentStyle + copy。
- getExportConfig 的 63 行逐字段赋值 → 一次 copy。
- ReadStyleRepository 的 export/import 改成 var + copy 链;三段重复的背景导入
  逻辑收进 importBackground。
- configsSnapshot 不再深拷贝(值字段已不可变),拖动滑块时每次 publishState
  省掉 N 份 Config 复制。

护栏:
- build.gradle.kts 新增「只有 4 个文件可以提到 ReadStyleConfigStore」——它是 Koin
  单例,谁 inject 谁就能绕过 gateway 改配置且不触发 save/publishState。
- ReadBookConfigDependencyDirectionTest 新增「ReadBookConfig 只从 store 读,
  不调它的写方法」(initialize 里的两个 init* 除外)。
- 两条都注入回归反证过会红。

新增 ReadConfigJsonRoundTripTest 4 条:val 字段的 GSON 反序列化是本次唯一编译器
覆盖不到的风险(GSON 不走构造函数,靠反射往 final 字段塞值),失败模式是用户排版
全读成默认值且静默。钉死非默认值往返、private 模式字段往返、缺字段回落默认、
copy 重置颜色缓存。

单测 450 → 455 全绿。

未动:withMenuCurBg / withMenuCurAc 全仓无调用者(既有死代码,随本次一起转成了
返回值形态,未删)。

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

+ 133 - 98
app/src/main/java/io/legado/app/data/repository/ReadBookStyleConfigRepository.kt

@@ -76,7 +76,7 @@ class ReadBookStyleConfigRepository(
             is ReadStyleMutation.StringValue -> updateString(mutation.key, mutation.value)
             is ReadStyleMutation.ColorValue -> updateColor(mutation.key, mutation.value)
             is ReadStyleMutation.Background ->
-                ReadBookConfig.durConfig.setCurBg(mutation.type, mutation.value)
+                mutateCurrentStyle { it.withCurBg(mutation.type, mutation.value) }
         }
         publishState()
     }
@@ -146,17 +146,17 @@ class ReadBookStyleConfigRepository(
         readStyleRepository.saveBackgroundImage(inputStream, displayName)
 
     override fun setCurrentBackgroundImage(path: String) {
-        ReadBookConfig.durConfig.setCurBg(2, path)
+        mutateCurrentStyle { it.withCurBg(2, path) }
         save()
     }
 
     override fun setCurrentBackgroundImageForMode(path: String, isNight: Boolean) {
-        if (isNight) {
-            ReadBookConfig.durConfig.bgTypeNight = 2
-            ReadBookConfig.durConfig.bgStrNight = path
-        } else {
-            ReadBookConfig.durConfig.bgType = 2
-            ReadBookConfig.durConfig.bgStr = path
+        mutateCurrentStyle {
+            if (isNight) {
+                it.copy(bgTypeNight = 2, bgStrNight = path)
+            } else {
+                it.copy(bgType = 2, bgStr = path)
+            }
         }
         save()
     }
@@ -175,118 +175,153 @@ class ReadBookStyleConfigRepository(
         _state.value = buildState()
     }
 
+    /** 改当前**生效**的那一份(共享排版开着时是共享那份)。 */
+    private inline fun mutateEffective(
+        crossinline transform: (ReadBookConfig.Config) -> ReadBookConfig.Config,
+    ) = configStore.updateEffective(
+        index = ReadBookConfig.styleSelect,
+        useShare = ReadBookConfig.shareLayout,
+        transform = { transform(it) },
+    )
+
+    /**
+     * 改当前**样式**那一份,共享排版开着时也不动共享那份。
+     * 背景、虚线、状态栏图标、样式名这些按样式独立的项走这里——与 R4.7 之前
+     * 写 `ReadBookConfig.durConfig.x` 的那些分支一一对应。
+     */
+    private inline fun mutateCurrentStyle(
+        crossinline transform: (ReadBookConfig.Config) -> ReadBookConfig.Config,
+    ) = configStore.updateStyleAt(
+        index = ReadBookConfig.styleSelect,
+        transform = { transform(it) },
+    )
+
     private fun updateInt(key: ReadStyleIntKey, value: Int) {
-        val config = ReadBookConfig.config
         when (key) {
-            ReadStyleIntKey.TextSize -> config.textSize = value
-            ReadStyleIntKey.LineSpacing -> config.lineSpacingExtra = value
-            ReadStyleIntKey.ParagraphSpacing -> config.paragraphSpacing = value
-            ReadStyleIntKey.TextBold -> config.textBold = value
-            ReadStyleIntKey.TitleMode -> config.titleMode = value
-            ReadStyleIntKey.TitleBold -> config.titleBold = value
-            ReadStyleIntKey.TitleLineSpacingExtra -> config.titleLineSpacingExtra = value
-            ReadStyleIntKey.TitleLineSpacingSub -> config.titleLineSpacingSub = value
-            ReadStyleIntKey.TitleSize -> config.titleSize = value
-            ReadStyleIntKey.TitleTopSpacing -> config.titleTopSpacing = value
-            ReadStyleIntKey.TitleBottomSpacing -> config.titleBottomSpacing = value
-            ReadStyleIntKey.TitleSegType -> config.titleSegType = value
-            ReadStyleIntKey.TitleSegDistance -> config.titleSegDistance = value
-            ReadStyleIntKey.HeaderMode -> config.headerMode = value
-            ReadStyleIntKey.FooterMode -> config.footerMode = value
-            ReadStyleIntKey.TipHeaderLeft -> config.tipHeaderLeft = value
-            ReadStyleIntKey.TipHeaderMiddle -> config.tipHeaderMiddle = value
-            ReadStyleIntKey.TipHeaderRight -> config.tipHeaderRight = value
-            ReadStyleIntKey.TipFooterLeft -> config.tipFooterLeft = value
-            ReadStyleIntKey.TipFooterMiddle -> config.tipFooterMiddle = value
-            ReadStyleIntKey.TipFooterRight -> config.tipFooterRight = value
-            ReadStyleIntKey.HeaderFontSize -> config.headerFontSize = value
-            ReadStyleIntKey.FooterFontSize -> config.footerFontSize = value
-            ReadStyleIntKey.PageAnim -> config.setCurPageAnim(value)
-            ReadStyleIntKey.UnderlineHeight -> config.underlineHeight = value
-            ReadStyleIntKey.UnderlinePadding -> config.underlinePadding = value
-            ReadStyleIntKey.PaddingTop -> config.paddingTop = value
-            ReadStyleIntKey.PaddingBottom -> config.paddingBottom = value
-            ReadStyleIntKey.PaddingLeft -> config.paddingLeft = value
-            ReadStyleIntKey.PaddingRight -> config.paddingRight = value
-            ReadStyleIntKey.HeaderPaddingTop -> config.headerPaddingTop = value
-            ReadStyleIntKey.HeaderPaddingBottom -> config.headerPaddingBottom = value
-            ReadStyleIntKey.HeaderPaddingLeft -> config.headerPaddingLeft = value
-            ReadStyleIntKey.HeaderPaddingRight -> config.headerPaddingRight = value
-            ReadStyleIntKey.FooterPaddingTop -> config.footerPaddingTop = value
-            ReadStyleIntKey.FooterPaddingBottom -> config.footerPaddingBottom = value
-            ReadStyleIntKey.FooterPaddingLeft -> config.footerPaddingLeft = value
-            ReadStyleIntKey.FooterPaddingRight -> config.footerPaddingRight = value
-            ReadStyleIntKey.BgType -> ReadBookConfig.durConfig.bgType = value
-            ReadStyleIntKey.BgTypeNight -> ReadBookConfig.durConfig.bgTypeNight = value
-            ReadStyleIntKey.BgTypeEInk -> ReadBookConfig.durConfig.bgTypeEInk = value
-            ReadStyleIntKey.BgAlpha -> config.bgAlpha = value
+            ReadStyleIntKey.TextSize -> mutateEffective { it.copy(textSize = value) }
+            ReadStyleIntKey.LineSpacing -> mutateEffective { it.copy(lineSpacingExtra = value) }
+            ReadStyleIntKey.ParagraphSpacing -> mutateEffective { it.copy(paragraphSpacing = value) }
+            ReadStyleIntKey.TextBold -> mutateEffective { it.copy(textBold = value) }
+            ReadStyleIntKey.TitleMode -> mutateEffective { it.copy(titleMode = value) }
+            ReadStyleIntKey.TitleBold -> mutateEffective { it.copy(titleBold = value) }
+            ReadStyleIntKey.TitleLineSpacingExtra ->
+                mutateEffective { it.copy(titleLineSpacingExtra = value) }
+            ReadStyleIntKey.TitleLineSpacingSub ->
+                mutateEffective { it.copy(titleLineSpacingSub = value) }
+            ReadStyleIntKey.TitleSize -> mutateEffective { it.copy(titleSize = value) }
+            ReadStyleIntKey.TitleTopSpacing -> mutateEffective { it.copy(titleTopSpacing = value) }
+            ReadStyleIntKey.TitleBottomSpacing ->
+                mutateEffective { it.copy(titleBottomSpacing = value) }
+            ReadStyleIntKey.TitleSegType -> mutateEffective { it.copy(titleSegType = value) }
+            ReadStyleIntKey.TitleSegDistance -> mutateEffective { it.copy(titleSegDistance = value) }
+            ReadStyleIntKey.HeaderMode -> mutateEffective { it.copy(headerMode = value) }
+            ReadStyleIntKey.FooterMode -> mutateEffective { it.copy(footerMode = value) }
+            ReadStyleIntKey.TipHeaderLeft -> mutateEffective { it.copy(tipHeaderLeft = value) }
+            ReadStyleIntKey.TipHeaderMiddle -> mutateEffective { it.copy(tipHeaderMiddle = value) }
+            ReadStyleIntKey.TipHeaderRight -> mutateEffective { it.copy(tipHeaderRight = value) }
+            ReadStyleIntKey.TipFooterLeft -> mutateEffective { it.copy(tipFooterLeft = value) }
+            ReadStyleIntKey.TipFooterMiddle -> mutateEffective { it.copy(tipFooterMiddle = value) }
+            ReadStyleIntKey.TipFooterRight -> mutateEffective { it.copy(tipFooterRight = value) }
+            ReadStyleIntKey.HeaderFontSize -> mutateEffective { it.copy(headerFontSize = value) }
+            ReadStyleIntKey.FooterFontSize -> mutateEffective { it.copy(footerFontSize = value) }
+            ReadStyleIntKey.PageAnim -> mutateEffective { it.withCurPageAnim(value) }
+            ReadStyleIntKey.UnderlineHeight -> mutateEffective { it.copy(underlineHeight = value) }
+            ReadStyleIntKey.UnderlinePadding -> mutateEffective { it.copy(underlinePadding = value) }
+            ReadStyleIntKey.PaddingTop -> mutateEffective { it.copy(paddingTop = value) }
+            ReadStyleIntKey.PaddingBottom -> mutateEffective { it.copy(paddingBottom = value) }
+            ReadStyleIntKey.PaddingLeft -> mutateEffective { it.copy(paddingLeft = value) }
+            ReadStyleIntKey.PaddingRight -> mutateEffective { it.copy(paddingRight = value) }
+            ReadStyleIntKey.HeaderPaddingTop -> mutateEffective { it.copy(headerPaddingTop = value) }
+            ReadStyleIntKey.HeaderPaddingBottom ->
+                mutateEffective { it.copy(headerPaddingBottom = value) }
+            ReadStyleIntKey.HeaderPaddingLeft ->
+                mutateEffective { it.copy(headerPaddingLeft = value) }
+            ReadStyleIntKey.HeaderPaddingRight ->
+                mutateEffective { it.copy(headerPaddingRight = value) }
+            ReadStyleIntKey.FooterPaddingTop -> mutateEffective { it.copy(footerPaddingTop = value) }
+            ReadStyleIntKey.FooterPaddingBottom ->
+                mutateEffective { it.copy(footerPaddingBottom = value) }
+            ReadStyleIntKey.FooterPaddingLeft ->
+                mutateEffective { it.copy(footerPaddingLeft = value) }
+            ReadStyleIntKey.FooterPaddingRight ->
+                mutateEffective { it.copy(footerPaddingRight = value) }
+            ReadStyleIntKey.BgAlpha -> mutateEffective { it.copy(bgAlpha = value) }
+            ReadStyleIntKey.BgType -> mutateCurrentStyle { it.copy(bgType = value) }
+            ReadStyleIntKey.BgTypeNight -> mutateCurrentStyle { it.copy(bgTypeNight = value) }
+            ReadStyleIntKey.BgTypeEInk -> mutateCurrentStyle { it.copy(bgTypeEInk = value) }
         }
     }
 
     private fun updateFloat(key: ReadStyleFloatKey, value: Float) {
-        val config = ReadBookConfig.config
         when (key) {
-            ReadStyleFloatKey.LetterSpacing -> config.letterSpacing = value
-            ReadStyleFloatKey.TitleSegScaling -> config.titleSegScaling = value
-            ReadStyleFloatKey.ShadowRadius -> config.shadowRadius = value
-            ReadStyleFloatKey.ShadowDx -> config.shadowDx = value
-            ReadStyleFloatKey.ShadowDy -> config.shadowDy = value
-            ReadStyleFloatKey.DottedBase -> ReadBookConfig.durConfig.dottedBase = value
-            ReadStyleFloatKey.DottedRatio -> ReadBookConfig.durConfig.dottedRatio = value
+            ReadStyleFloatKey.LetterSpacing -> mutateEffective { it.copy(letterSpacing = value) }
+            ReadStyleFloatKey.TitleSegScaling -> mutateEffective { it.copy(titleSegScaling = value) }
+            ReadStyleFloatKey.ShadowRadius -> mutateEffective { it.copy(shadowRadius = value) }
+            ReadStyleFloatKey.ShadowDx -> mutateEffective { it.copy(shadowDx = value) }
+            ReadStyleFloatKey.ShadowDy -> mutateEffective { it.copy(shadowDy = value) }
+            ReadStyleFloatKey.DottedBase -> mutateCurrentStyle { it.copy(dottedBase = value) }
+            ReadStyleFloatKey.DottedRatio -> mutateCurrentStyle { it.copy(dottedRatio = value) }
         }
     }
 
     private fun updateBoolean(key: ReadStyleBooleanKey, value: Boolean) {
-        val config = ReadBookConfig.config
         when (key) {
-            ReadStyleBooleanKey.TextItalic -> config.textItalic = value
-            ReadStyleBooleanKey.TextShadow -> config.textShadow = value
-            ReadStyleBooleanKey.Underline -> config.underline = value
-            ReadStyleBooleanKey.DottedLine -> config.dottedLine = value
-            ReadStyleBooleanKey.UnderlineExtend -> config.underlineExtend = value
-            ReadStyleBooleanKey.ShowHeaderLine -> config.showHeaderLine = value
-            ReadStyleBooleanKey.ShowFooterLine -> config.showFooterLine = value
-            ReadStyleBooleanKey.ApplyHeaderStyle -> config.applyHeaderStyle = value
-            ReadStyleBooleanKey.StatusIconDark -> ReadBookConfig.durConfig.setCurStatusIconDark(value)
+            ReadStyleBooleanKey.TextItalic -> mutateEffective { it.copy(textItalic = value) }
+            ReadStyleBooleanKey.TextShadow -> mutateEffective { it.copy(textShadow = value) }
+            ReadStyleBooleanKey.Underline -> mutateEffective { it.copy(underline = value) }
+            ReadStyleBooleanKey.DottedLine -> mutateEffective { it.copy(dottedLine = value) }
+            ReadStyleBooleanKey.UnderlineExtend -> mutateEffective { it.copy(underlineExtend = value) }
+            ReadStyleBooleanKey.ShowHeaderLine -> mutateEffective { it.copy(showHeaderLine = value) }
+            ReadStyleBooleanKey.ShowFooterLine -> mutateEffective { it.copy(showFooterLine = value) }
+            ReadStyleBooleanKey.ApplyHeaderStyle ->
+                mutateEffective { it.copy(applyHeaderStyle = value) }
+            ReadStyleBooleanKey.StatusIconDark ->
+                mutateCurrentStyle { it.withCurStatusIconDark(value) }
         }
     }
 
     private fun updateString(key: ReadStyleStringKey, value: String) {
-        val config = ReadBookConfig.config
         when (key) {
-            ReadStyleStringKey.TextFont -> config.textFont = value
-            ReadStyleStringKey.ParagraphIndent -> config.paragraphIndent = value
-            ReadStyleStringKey.TitleFont -> config.titleFont = value
-            ReadStyleStringKey.TitleSegFlag -> config.titleSegFlag = value
-            ReadStyleStringKey.HeaderFont -> config.headerFont = value
-            ReadStyleStringKey.FooterFont -> config.footerFont = value
-            ReadStyleStringKey.CustomTipHeaderLeft -> config.customTipHeaderLeft = value
-            ReadStyleStringKey.CustomTipHeaderMiddle -> config.customTipHeaderMiddle = value
-            ReadStyleStringKey.CustomTipHeaderRight -> config.customTipHeaderRight = value
-            ReadStyleStringKey.CustomTipFooterLeft -> config.customTipFooterLeft = value
-            ReadStyleStringKey.CustomTipFooterMiddle -> config.customTipFooterMiddle = value
-            ReadStyleStringKey.CustomTipFooterRight -> config.customTipFooterRight = value
-            ReadStyleStringKey.BgStr -> ReadBookConfig.durConfig.bgStr = value
-            ReadStyleStringKey.BgStrNight -> ReadBookConfig.durConfig.bgStrNight = value
-            ReadStyleStringKey.BgStrEInk -> ReadBookConfig.durConfig.bgStrEInk = value
-            ReadStyleStringKey.StyleName -> ReadBookConfig.durConfig.name = value
+            ReadStyleStringKey.TextFont -> mutateEffective { it.copy(textFont = value) }
+            ReadStyleStringKey.ParagraphIndent -> mutateEffective { it.copy(paragraphIndent = value) }
+            ReadStyleStringKey.TitleFont -> mutateEffective { it.copy(titleFont = value) }
+            ReadStyleStringKey.TitleSegFlag -> mutateEffective { it.copy(titleSegFlag = value) }
+            ReadStyleStringKey.HeaderFont -> mutateEffective { it.copy(headerFont = value) }
+            ReadStyleStringKey.FooterFont -> mutateEffective { it.copy(footerFont = value) }
+            ReadStyleStringKey.CustomTipHeaderLeft ->
+                mutateEffective { it.copy(customTipHeaderLeft = value) }
+            ReadStyleStringKey.CustomTipHeaderMiddle ->
+                mutateEffective { it.copy(customTipHeaderMiddle = value) }
+            ReadStyleStringKey.CustomTipHeaderRight ->
+                mutateEffective { it.copy(customTipHeaderRight = value) }
+            ReadStyleStringKey.CustomTipFooterLeft ->
+                mutateEffective { it.copy(customTipFooterLeft = value) }
+            ReadStyleStringKey.CustomTipFooterMiddle ->
+                mutateEffective { it.copy(customTipFooterMiddle = value) }
+            ReadStyleStringKey.CustomTipFooterRight ->
+                mutateEffective { it.copy(customTipFooterRight = value) }
+            ReadStyleStringKey.BgStr -> mutateCurrentStyle { it.copy(bgStr = value) }
+            ReadStyleStringKey.BgStrNight -> mutateCurrentStyle { it.copy(bgStrNight = value) }
+            ReadStyleStringKey.BgStrEInk -> mutateCurrentStyle { it.copy(bgStrEInk = value) }
+            ReadStyleStringKey.StyleName -> mutateCurrentStyle { it.copy(name = value) }
         }
     }
 
     private fun updateColor(key: ReadStyleColorKey, value: Int) {
-        val config = ReadBookConfig.config
         when (key) {
-            ReadStyleColorKey.Text -> ReadBookConfig.durConfig.setCurTextColor(value)
-            ReadStyleColorKey.TextAccent -> ReadBookConfig.durConfig.setCurTextAccentColor(value)
-            ReadStyleColorKey.Title -> config.titleColor = value
-            ReadStyleColorKey.TitleNight -> config.titleColorNight = value
-            ReadStyleColorKey.TipHeader -> config.tipHeaderColor = value
-            ReadStyleColorKey.TipHeaderNight -> config.tipHeaderColorNight = value
-            ReadStyleColorKey.TipFooter -> config.tipFooterColor = value
-            ReadStyleColorKey.TipFooterNight -> config.tipFooterColorNight = value
-            ReadStyleColorKey.TipDivider -> config.tipDividerColor = value
-            ReadStyleColorKey.Shadow -> ReadBookConfig.durConfig.setCurShadColor(value)
-            ReadStyleColorKey.Underline -> ReadBookConfig.durConfig.setUnderlineColor(value)
+            ReadStyleColorKey.Title -> mutateEffective { it.copy(titleColor = value) }
+            ReadStyleColorKey.TitleNight -> mutateEffective { it.copy(titleColorNight = value) }
+            ReadStyleColorKey.TipHeader -> mutateEffective { it.copy(tipHeaderColor = value) }
+            ReadStyleColorKey.TipHeaderNight ->
+                mutateEffective { it.copy(tipHeaderColorNight = value) }
+            ReadStyleColorKey.TipFooter -> mutateEffective { it.copy(tipFooterColor = value) }
+            ReadStyleColorKey.TipFooterNight ->
+                mutateEffective { it.copy(tipFooterColorNight = value) }
+            ReadStyleColorKey.TipDivider -> mutateEffective { it.copy(tipDividerColor = value) }
+            ReadStyleColorKey.Text -> mutateCurrentStyle { it.withCurTextColor(value) }
+            ReadStyleColorKey.TextAccent -> mutateCurrentStyle { it.withCurTextAccentColor(value) }
+            ReadStyleColorKey.Shadow -> mutateCurrentStyle { it.withCurShadowColor(value) }
+            ReadStyleColorKey.Underline -> mutateCurrentStyle { it.withCurUnderlineColor(value) }
         }
     }
 

+ 34 - 6
app/src/main/java/io/legado/app/data/repository/ReadStyleConfigStore.kt

@@ -24,10 +24,7 @@ class ReadStyleConfigStore(private val readStyleRepository: ReadStyleRepository)
     val configFilePath: String get() = readStyleRepository.configFilePath
     val shareConfigFilePath: String get() = readStyleRepository.shareConfigFilePath
 
-    /**
-     * 共享排版那一份。返回的是本体不是副本——gateway 实现就地改它的字段,
-     * 发一份副本出去会让写入落到空气里。
-     */
+    /** 共享排版那一份。 */
     val shareConfig: ReadBookConfig.Config get() = shareConfigRef
 
     fun initConfigs() {
@@ -61,11 +58,42 @@ class ReadStyleConfigStore(private val readStyleRepository: ReadStyleRepository)
         }
     }
 
+    /**
+     * 改**当前样式**那一份的字段。共享排版开着时也只动这一份——背景、虚线、状态栏图标
+     * 这些按样式独立的项走这里。
+     *
+     * 下标解析与 [configAt] 保持一致(含配置文件缺斤少两时先恢复默认、越界回落到 0),
+     * 否则「读得到、写不进」会成为一类静默失效。
+     */
+    fun updateStyleAt(index: Int, transform: (ReadBookConfig.Config) -> ReadBookConfig.Config) {
+        synchronized(lock) {
+            if (configList.size < 5) {
+                resetAllLocked()
+            }
+            val target = if (index in configList.indices) index else 0
+            configList[target] = transform(configList[target])
+        }
+    }
+
+    /** 改**当前生效**那一份:共享排版开着时是共享那份,否则就是当前样式。 */
+    fun updateEffective(
+        index: Int,
+        useShare: Boolean,
+        transform: (ReadBookConfig.Config) -> ReadBookConfig.Config,
+    ) {
+        if (useShare) {
+            synchronized(lock) { shareConfigRef = transform(shareConfigRef) }
+        } else {
+            updateStyleAt(index, transform)
+        }
+    }
+
+    /** `Config` 的值字段不可变,快照直接共享实例即可。 */
     fun configsSnapshot(): List<ReadBookConfig.Config> = synchronized(lock) {
-        configList.map { it.copy() }
+        configList.toList()
     }
 
-    fun shareConfigSnapshot(): ReadBookConfig.Config = shareConfigRef.copy()
+    fun shareConfigSnapshot(): ReadBookConfig.Config = shareConfigRef
 
     fun addConfig(config: ReadBookConfig.Config): Int = synchronized(lock) {
         configList.add(config)

+ 62 - 74
app/src/main/java/io/legado/app/data/repository/ReadStyleRepository.kt

@@ -58,7 +58,7 @@ class ReadStyleRepository(
         configs: List<ReadBookConfig.Config>,
         shareConfig: ReadBookConfig.Config
     ) {
-        // 原子写:两条保存通道(ReadStyleConfigStore.save 与 ReadStyleSaveQueue)会并发落到这里,
+        // 原子写:两条保存通道(配置 store 的兜底保存与 ReadStyleSaveQueue)会并发落到这里,
         // 「删掉再写」既有杀进程丢配置的窗口,也会让两者互相看到对方写了一半的文件。
         FileUtils.writeTextAtomic(configFilePath, GSON.toJson(configs))
         FileUtils.writeTextAtomic(shareConfigFilePath, GSON.toJson(shareConfig))
@@ -117,22 +117,24 @@ class ReadStyleRepository(
     fun export(config: ReadBookConfig.Config): ByteArray {
         val exportDir = appCtx.externalCache.getFile("readConfigExport")
         exportDir.createFolderReplace()
-        val exportConfig = config.copy(
+        var exportConfig = config.copy(
             highlightRules = ArrayList(config.highlightRules.map { it.copy() })
         )
         val exportFiles = arrayListOf<File>()
 
-        addBackgroundFile(exportDir, exportConfig, 0, exportFiles)
-        addBackgroundFile(exportDir, exportConfig, 1, exportFiles)
-        addBackgroundFile(exportDir, exportConfig, 2, exportFiles)
-        exportConfig.textFont = addAssetFile(exportDir, exportConfig.textFont, exportFiles)
-        exportConfig.titleFont = addAssetFile(exportDir, exportConfig.titleFont, exportFiles)
+        exportConfig = addBackgroundFile(exportDir, exportConfig, 0, exportFiles)
+        exportConfig = addBackgroundFile(exportDir, exportConfig, 1, exportFiles)
+        exportConfig = addBackgroundFile(exportDir, exportConfig, 2, exportFiles)
+        exportConfig = exportConfig.copy(
+            textFont = addAssetFile(exportDir, exportConfig.textFont, exportFiles),
+            titleFont = addAssetFile(exportDir, exportConfig.titleFont, exportFiles),
+        )
         HighlightRuleAssetTransfer.prepareExport(
             rules = exportConfig.highlightRules,
             exportDir = exportDir,
             copyAsset = ::copyRuleAsset,
         ).let { result ->
-            exportConfig.highlightRules = ArrayList(result.rules)
+            exportConfig = exportConfig.copy(highlightRules = ArrayList(result.rules))
             exportFiles.addAll(result.files)
         }
 
@@ -155,97 +157,83 @@ class ReadStyleRepository(
         configDir.createFolderReplace()
         ZipUtils.unZipToPath(zipFile, configDir)
         val configFile = configDir.getFile(ReadBookConfig.configFileName)
-        val config: ReadBookConfig.Config =
+        var config: ReadBookConfig.Config =
             GSON.fromJsonObject<ReadBookConfig.Config>(configFile.readText()).getOrThrow()
 
-        config.textFont = importFont(configDir, config.textFont)
-        config.titleFont = importFont(configDir, config.titleFont)
+        config = config.copy(
+            textFont = importFont(configDir, config.textFont),
+            titleFont = importFont(configDir, config.titleFont),
+            bgStr = importBackground(configDir, config.bgType, config.bgStr),
+            bgStrNight = importBackground(configDir, config.bgTypeNight, config.bgStrNight),
+            bgStrEInk = importBackground(configDir, config.bgTypeEInk, config.bgStrEInk),
+        )
 
-        if (config.bgType == 2) {
-            val bgName = FileUtils.getName(config.bgStr)
-            config.bgStr = bgName
-            val bgPath = FileUtils.getPath(appCtx.externalFiles, "bg", bgName)
-            if (!FileUtils.exist(bgPath)) {
-                val bgFile = configDir.getFile(bgName)
-                if (bgFile.exists()) {
-                    bgFile.copyTo(File(bgPath))
-                }
-            }
-            config.bgStr = bgPath
-        } else if (config.bgType == 0) {
-            config.bgStr.toColorInt()
-        }
-        if (config.bgTypeNight == 2) {
-            val bgName = FileUtils.getName(config.bgStrNight)
-            config.bgStrNight = bgName
-            val bgPath = FileUtils.getPath(appCtx.externalFiles, "bg", bgName)
-            if (!FileUtils.exist(bgPath)) {
-                val bgFile = configDir.getFile(bgName)
-                if (bgFile.exists()) {
-                    bgFile.copyTo(File(bgPath))
-                }
-            }
-            config.bgStrNight = bgPath
-        } else if (config.bgTypeNight == 0) {
-            config.bgStrNight.toColorInt()
-        }
-        if (config.bgTypeEInk == 2) {
-            val bgName = FileUtils.getName(config.bgStrEInk)
-            config.bgStrEInk = bgName
-            val bgPath = FileUtils.getPath(appCtx.externalFiles, "bg", bgName)
-            if (!FileUtils.exist(bgPath)) {
-                val bgFile = configDir.getFile(bgName)
-                if (bgFile.exists()) {
-                    bgFile.copyTo(File(bgPath))
-                }
-            }
-            config.bgStrEInk = bgPath
-        } else if (config.bgTypeEInk == 0) {
-            config.bgStrEInk.toColorInt()
-        }
-        config.curTextColor()
-        config.curTextAccentColor()
-        config.curTextShadowColor()
         if (config.highlightRules.isNotEmpty()) {
-            config.highlightRules = ArrayList(
-                HighlightRuleAssetTransfer.restoreImport(
-                    rules = config.highlightRules,
-                    importDir = configDir,
-                    backgroundDir = File(appCtx.filesDir, "bg_images"),
-                    fontDir = appCtx.externalFiles.getFile("font"),
-                    isReadableBackgroundReference = appCtx::isReadableHighlightBackground,
-                    isReadableFontReference = appCtx::isReadableHighlightFont,
-                )
+            val restored = HighlightRuleAssetTransfer.restoreImport(
+                rules = config.highlightRules,
+                importDir = configDir,
+                backgroundDir = File(appCtx.filesDir, "bg_images"),
+                fontDir = appCtx.externalFiles.getFile("font"),
+                isReadableBackgroundReference = appCtx::isReadableHighlightBackground,
+                isReadableFontReference = appCtx::isReadableHighlightFont,
             )
             val targetConfigName = config.name.ifBlank { null }
-            val highlightRules = config.highlightRules.map { rule ->
+            val highlightRules = restored.map { rule ->
                 if (targetConfigName.isNullOrBlank()) {
                     rule.copy(configName = null)
                 } else {
                     rule.copyWithNewId().copy(configName = listOf(targetConfigName).toJsonArray())
                 }
             }
-            config.highlightRules = ArrayList(highlightRules)
+            config = config.copy(highlightRules = ArrayList(highlightRules))
             highlightRuleRepository.saveForConfig(highlightRules, targetConfigName)
         }
+        // 预热 Config 内部的颜色记忆化缓存,让返回的实例首次绘制时不必再解析一遍字符串。
+        // 必须排在最后一次 copy 之后——copy 会把缓存重置回未初始化。
+        config.curTextColor()
+        config.curTextAccentColor()
+        config.curTextShadowColor()
         return config
     }
 
+    /**
+     * 背景是图片(type 2)时把它落到 bg 目录并改写成绝对路径;是颜色(type 0)时
+     * 只做一次解析校验——解析不了就让导入整体失败,而不是导进一份显示不出来的配置。
+     */
+    private fun importBackground(configDir: File, bgType: Int, bgStr: String): String {
+        if (bgType != 2) {
+            if (bgType == 0) {
+                bgStr.toColorInt()
+            }
+            return bgStr
+        }
+        val bgName = FileUtils.getName(bgStr)
+        val bgPath = FileUtils.getPath(appCtx.externalFiles, "bg", bgName)
+        if (!FileUtils.exist(bgPath)) {
+            val bgFile = configDir.getFile(bgName)
+            if (bgFile.exists()) {
+                bgFile.copyTo(File(bgPath))
+            }
+        }
+        return bgPath
+    }
+
     private fun addBackgroundFile(
         exportDir: File,
         config: ReadBookConfig.Config,
         bgIndex: Int,
         exportFiles: MutableList<File>
-    ) {
-        val sourcePath = ReadStyleResolver.backgroundPath(config, bgIndex) ?: return
+    ): ReadBookConfig.Config {
+        val sourcePath = ReadStyleResolver.backgroundPath(config, bgIndex) ?: return config
         val exportedName = addAssetFile(exportDir, sourcePath, exportFiles)
         if (exportedName.isBlank()) {
-            return
+            return config
         }
-        when (bgIndex) {
-            0 -> config.bgStr = exportedName
-            1 -> config.bgStrNight = exportedName
-            2 -> config.bgStrEInk = exportedName
+        return when (bgIndex) {
+            0 -> config.copy(bgStr = exportedName)
+            1 -> config.copy(bgStrNight = exportedName)
+            2 -> config.copy(bgStrEInk = exportedName)
+            else -> config
         }
     }
 

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

@@ -449,94 +449,94 @@ object ReadBookConfig {
 
     fun getExportConfig(): Config {
         val exportConfig = durConfig.copy(highlightRules = arrayListOf())
-        if (shareLayout) {
-            exportConfig.textFont = shareConfig.textFont
-            exportConfig.titleFont = shareConfig.titleFont
-            exportConfig.headerFont = shareConfig.headerFont
-            exportConfig.footerFont = shareConfig.footerFont
-            exportConfig.headerFontSize = shareConfig.headerFontSize
-            exportConfig.footerFontSize = shareConfig.footerFontSize
-            exportConfig.applyHeaderStyle = shareConfig.applyHeaderStyle
-            exportConfig.textBold = shareConfig.textBold
-            exportConfig.textSize = shareConfig.textSize
-            exportConfig.letterSpacing = shareConfig.letterSpacing
-            exportConfig.lineSpacingExtra = shareConfig.lineSpacingExtra
-            exportConfig.paragraphSpacing = shareConfig.paragraphSpacing
-            exportConfig.titleMode = shareConfig.titleMode
-            exportConfig.titleSize = shareConfig.titleSize
-            exportConfig.titleTopSpacing = shareConfig.titleTopSpacing
-            exportConfig.titleBottomSpacing = shareConfig.titleBottomSpacing
-            exportConfig.titleColor = shareConfig.titleColor
-            exportConfig.titleColorNight = shareConfig.titleColorNight
-            exportConfig.paddingBottom = shareConfig.paddingBottom
-            exportConfig.paddingLeft = shareConfig.paddingLeft
-            exportConfig.paddingRight = shareConfig.paddingRight
-            exportConfig.paddingTop = shareConfig.paddingTop
-            exportConfig.headerPaddingBottom = shareConfig.headerPaddingBottom
-            exportConfig.headerPaddingLeft = shareConfig.headerPaddingLeft
-            exportConfig.headerPaddingRight = shareConfig.headerPaddingRight
-            exportConfig.headerPaddingTop = shareConfig.headerPaddingTop
-            exportConfig.footerPaddingBottom = shareConfig.footerPaddingBottom
-            exportConfig.footerPaddingLeft = shareConfig.footerPaddingLeft
-            exportConfig.footerPaddingRight = shareConfig.footerPaddingRight
-            exportConfig.footerPaddingTop = shareConfig.footerPaddingTop
-            exportConfig.showHeaderLine = shareConfig.showHeaderLine
-            exportConfig.showFooterLine = shareConfig.showFooterLine
-            exportConfig.tipHeaderLeft = shareConfig.tipHeaderLeft
-            exportConfig.tipHeaderMiddle = shareConfig.tipHeaderMiddle
-            exportConfig.tipHeaderRight = shareConfig.tipHeaderRight
-            exportConfig.tipFooterLeft = shareConfig.tipFooterLeft
-            exportConfig.tipFooterMiddle = shareConfig.tipFooterMiddle
-            exportConfig.tipFooterRight = shareConfig.tipFooterRight
-            exportConfig.tipHeaderColor = shareConfig.tipHeaderColor
-            exportConfig.tipHeaderColorNight = shareConfig.tipHeaderColorNight
-            exportConfig.tipFooterColor = shareConfig.tipFooterColor
-            exportConfig.tipFooterColorNight = shareConfig.tipFooterColorNight
-            exportConfig.headerMode = shareConfig.headerMode
-            // MD3专有属性
-            exportConfig.footerMode = shareConfig.footerMode
-            exportConfig.textItalic = shareConfig.textItalic
-            exportConfig.textShadow = shareConfig.textShadow
-            exportConfig.shadowRadius = shareConfig.shadowRadius
-            exportConfig.shadowDx = shareConfig.shadowDx
-            exportConfig.shadowDy = shareConfig.shadowDy
-            exportConfig.titleBold = shareConfig.titleBold
-            exportConfig.titleLineSpacingExtra = shareConfig.titleLineSpacingExtra
-            exportConfig.titleLineSpacingSub = shareConfig.titleLineSpacingSub
-            exportConfig.titleSegType = shareConfig.titleSegType
-            exportConfig.titleSegScaling = shareConfig.titleSegScaling
-            exportConfig.titleSegDistance = shareConfig.titleSegDistance
-            exportConfig.titleSegFlag = shareConfig.titleSegFlag
-            exportConfig.paragraphIndent = shareConfig.paragraphIndent
-            exportConfig.underline = shareConfig.underline
-            exportConfig.underlineHeight = shareConfig.underlineHeight
-            exportConfig.underlinePadding = shareConfig.underlinePadding
-            exportConfig.dottedLine = shareConfig.dottedLine
-            exportConfig.dottedBase = shareConfig.dottedBase
-            exportConfig.dottedRatio = shareConfig.dottedRatio
-            exportConfig.bgAlpha = shareConfig.bgAlpha
-        }
-        return exportConfig
+        if (!shareLayout) return exportConfig
+        // 共享排版开着时导出的是「当前样式的背景 + 共享那份的排版」,故只覆盖排版项。
+        return exportConfig.copy(
+            textFont = shareConfig.textFont,
+            titleFont = shareConfig.titleFont,
+            headerFont = shareConfig.headerFont,
+            footerFont = shareConfig.footerFont,
+            headerFontSize = shareConfig.headerFontSize,
+            footerFontSize = shareConfig.footerFontSize,
+            applyHeaderStyle = shareConfig.applyHeaderStyle,
+            textBold = shareConfig.textBold,
+            textSize = shareConfig.textSize,
+            letterSpacing = shareConfig.letterSpacing,
+            lineSpacingExtra = shareConfig.lineSpacingExtra,
+            paragraphSpacing = shareConfig.paragraphSpacing,
+            titleMode = shareConfig.titleMode,
+            titleSize = shareConfig.titleSize,
+            titleTopSpacing = shareConfig.titleTopSpacing,
+            titleBottomSpacing = shareConfig.titleBottomSpacing,
+            titleColor = shareConfig.titleColor,
+            titleColorNight = shareConfig.titleColorNight,
+            paddingBottom = shareConfig.paddingBottom,
+            paddingLeft = shareConfig.paddingLeft,
+            paddingRight = shareConfig.paddingRight,
+            paddingTop = shareConfig.paddingTop,
+            headerPaddingBottom = shareConfig.headerPaddingBottom,
+            headerPaddingLeft = shareConfig.headerPaddingLeft,
+            headerPaddingRight = shareConfig.headerPaddingRight,
+            headerPaddingTop = shareConfig.headerPaddingTop,
+            footerPaddingBottom = shareConfig.footerPaddingBottom,
+            footerPaddingLeft = shareConfig.footerPaddingLeft,
+            footerPaddingRight = shareConfig.footerPaddingRight,
+            footerPaddingTop = shareConfig.footerPaddingTop,
+            showHeaderLine = shareConfig.showHeaderLine,
+            showFooterLine = shareConfig.showFooterLine,
+            tipHeaderLeft = shareConfig.tipHeaderLeft,
+            tipHeaderMiddle = shareConfig.tipHeaderMiddle,
+            tipHeaderRight = shareConfig.tipHeaderRight,
+            tipFooterLeft = shareConfig.tipFooterLeft,
+            tipFooterMiddle = shareConfig.tipFooterMiddle,
+            tipFooterRight = shareConfig.tipFooterRight,
+            tipHeaderColor = shareConfig.tipHeaderColor,
+            tipHeaderColorNight = shareConfig.tipHeaderColorNight,
+            tipFooterColor = shareConfig.tipFooterColor,
+            tipFooterColorNight = shareConfig.tipFooterColorNight,
+            headerMode = shareConfig.headerMode,
+            footerMode = shareConfig.footerMode,
+            textItalic = shareConfig.textItalic,
+            textShadow = shareConfig.textShadow,
+            shadowRadius = shareConfig.shadowRadius,
+            shadowDx = shareConfig.shadowDx,
+            shadowDy = shareConfig.shadowDy,
+            titleBold = shareConfig.titleBold,
+            titleLineSpacingExtra = shareConfig.titleLineSpacingExtra,
+            titleLineSpacingSub = shareConfig.titleLineSpacingSub,
+            titleSegType = shareConfig.titleSegType,
+            titleSegScaling = shareConfig.titleSegScaling,
+            titleSegDistance = shareConfig.titleSegDistance,
+            titleSegFlag = shareConfig.titleSegFlag,
+            paragraphIndent = shareConfig.paragraphIndent,
+            underline = shareConfig.underline,
+            underlineHeight = shareConfig.underlineHeight,
+            underlinePadding = shareConfig.underlinePadding,
+            dottedLine = shareConfig.dottedLine,
+            dottedBase = shareConfig.dottedBase,
+            dottedRatio = shareConfig.dottedRatio,
+            bgAlpha = shareConfig.bgAlpha
+        )
     }
 
     @Keep
     data class Config(
-        var name: String = "",
-        var bgStr: String = "#EEEEEE",//白天背景
-        var bgStrNight: String = "#000000",//夜间背景
+        val name: String = "",
+        val bgStr: String = "#EEEEEE",//白天背景
+        val bgStrNight: String = "#000000",//夜间背景
         @Transient
-        var menuBgColor: String = "#EEEFE3",
+        val menuBgColor: String = "#EEEFE3",
         @Transient
-        var menuAcColor: String = "#EEEFE3",
+        val menuAcColor: String = "#EEEFE3",
         @Transient
-        var menuBgColorNight: String = "#BFCBAD",
+        val menuBgColorNight: String = "#BFCBAD",
         @Transient
-        var menuAcColorNight: String = "#586249",
-        var bgStrEInk: String = "#FFFFFF",//EInk背景
-        var bgAlpha: Int = 100,//背景透明度
-        var bgType: Int = 0,//白天背景类型 0:颜色, 1:assets图片, 2其它图片
-        var bgTypeNight: Int = 0,//夜间背景类型
-        var bgTypeEInk: Int = 0,//EInk背景类型
+        val menuAcColorNight: String = "#586249",
+        val bgStrEInk: String = "#FFFFFF",//EInk背景
+        val bgAlpha: Int = 100,//背景透明度
+        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,
@@ -548,96 +548,96 @@ object ReadBookConfig {
         private var textAccentColorEInk: String = "#000000",
         private var pageAnim: Int = 0,//翻页动画
         private var pageAnimEInk: Int = 4,
-        var textFont: String = "",//字体
-        var titleFont: String = "",//标题字体
-        var headerFont: String = "",//页眉字体
-        var footerFont: String = "",//页脚字体
-        var headerFontSize: Int = 12,//页眉字号
-        var footerFontSize: Int = 12,//页脚字号
-        var applyHeaderStyle: Boolean = true,//页脚是否应用页眉字体样式
-        var textBold: Int = 500,//是否粗体字 0:正常, 1:粗体, 2:细体
-        var textSize: Int = 20,//文字大小
-        var textItalic: Boolean = false,// 是否启用斜体
-        var textShadow: Boolean = false,// 是否启用阴影
-        var shadowRadius: Float = 16f,// 阴影模糊半径
-        var shadowDx: Float = 1f,// 阴影x偏移
-        var shadowDy: Float = 1f,// 阴影y偏移
+        val textFont: String = "",//字体
+        val titleFont: String = "",//标题字体
+        val headerFont: String = "",//页眉字体
+        val footerFont: String = "",//页脚字体
+        val headerFontSize: Int = 12,//页眉字号
+        val footerFontSize: Int = 12,//页脚字号
+        val applyHeaderStyle: Boolean = true,//页脚是否应用页眉字体样式
+        val textBold: Int = 500,//是否粗体字 0:正常, 1:粗体, 2:细体
+        val textSize: Int = 20,//文字大小
+        val textItalic: Boolean = false,// 是否启用斜体
+        val textShadow: Boolean = false,// 是否启用阴影
+        val shadowRadius: Float = 16f,// 阴影模糊半径
+        val shadowDx: Float = 1f,// 阴影x偏移
+        val shadowDy: Float = 1f,// 阴影y偏移
         private var shadowColor: String = "#3E3D3B",
         private var shadowColorN: String = "#3E3D3B",
-        var letterSpacing: Float = 0.1f,//字间距
-        var lineSpacingExtra: Int = 12,//行间距
-        var paragraphSpacing: Int = 2,//段距
-        var titleMode: Int = 0,//标题位置 0:居左 1:居中 2:隐藏
-        var titleSize: Int = 0,
-        var titleTopSpacing: Int = 0,
-        var titleBottomSpacing: Int = 0,
-        var titleColor: Int = 0,
-        var titleColorNight: Int = 0,
-        var titleBold: Int = 500,//是否粗体字 0:正常, 1:粗体, 2:细体
-        var titleLineSpacingExtra: Int = 12,
-        var titleLineSpacingSub: Int = 12,
-        var titleSegType: Int = 0,//分段模式
-        var titleSegScaling: Float = 1f,//分段缩放,第二段与第一段的字体大小比例
-        var titleSegDistance: Int = 4,//分段判断,第几个字符开始分段
-        var titleSegFlag: String = "",//分段判断,碰到指定值时分段
-        var paragraphIndent: String = "  ",//段落缩进
-        var underline: Boolean = false, //下划线
-        var underlinePadding: Int = 10,
-        var underlineHeight: Int = 1,
-        var underlineExtend: Boolean = false, //下划线延伸
-        var underlineColor: String = "#3E3D3B",
-        var underlineColorNight: String = "#ADADAD",
-        var dottedLine: Boolean = false, //虚线
-        var dottedBase: Float = 6f, //长度
-        var dottedRatio: Float = 6f,
-        var paddingBottom: Int = 6,
-        var paddingLeft: Int = 16,
-        var paddingRight: Int = 16,
-        var paddingTop: Int = 6,
-        var headerPaddingBottom: Int = 0,
-        var headerPaddingLeft: Int = 16,
-        var headerPaddingRight: Int = 16,
-        var headerPaddingTop: Int = 0,
-        var footerPaddingBottom: Int = 6,
-        var footerPaddingLeft: Int = 16,
-        var footerPaddingRight: Int = 16,
-        var footerPaddingTop: Int = 6,
-        var showHeaderLine: Boolean = false,
-        var showFooterLine: Boolean = true,
-        var tipHeaderLeft: Int = ReadTipType.tipTime,
-        var tipHeaderMiddle: Int = ReadTipType.tipNone,
-        var tipHeaderRight: Int = ReadTipType.tipBattery,
-        var tipFooterLeft: Int = ReadTipType.tipChapterTitle,
-        var tipFooterMiddle: Int = ReadTipType.tipNone,
-        var tipFooterRight: Int = ReadTipType.tipPageAndTotal,
-        var customTipHeaderLeft: String = "",
-        var customTipHeaderMiddle: String = "",
-        var customTipHeaderRight: String = "",
-        var customTipFooterLeft: String = "",
-        var customTipFooterMiddle: String = "",
-        var customTipFooterRight: String = "",
-        var tipHeaderColor: Int = 0,
-        var tipHeaderColorNight: Int = 0,
-        var tipFooterColor: Int = 0,
-        var tipFooterColorNight: Int = 0,
-        var tipDividerColor: Int = -1,
-        var headerMode: Int = 0,
-        var footerMode: Int = 0,
+        val letterSpacing: Float = 0.1f,//字间距
+        val lineSpacingExtra: Int = 12,//行间距
+        val paragraphSpacing: Int = 2,//段距
+        val titleMode: Int = 0,//标题位置 0:居左 1:居中 2:隐藏
+        val titleSize: Int = 0,
+        val titleTopSpacing: Int = 0,
+        val titleBottomSpacing: Int = 0,
+        val titleColor: Int = 0,
+        val titleColorNight: Int = 0,
+        val titleBold: Int = 500,//是否粗体字 0:正常, 1:粗体, 2:细体
+        val titleLineSpacingExtra: Int = 12,
+        val titleLineSpacingSub: Int = 12,
+        val titleSegType: Int = 0,//分段模式
+        val titleSegScaling: Float = 1f,//分段缩放,第二段与第一段的字体大小比例
+        val titleSegDistance: Int = 4,//分段判断,第几个字符开始分段
+        val titleSegFlag: String = "",//分段判断,碰到指定值时分段
+        val paragraphIndent: String = "  ",//段落缩进
+        val underline: Boolean = false, //下划线
+        val underlinePadding: Int = 10,
+        val underlineHeight: Int = 1,
+        val underlineExtend: Boolean = false, //下划线延伸
+        val underlineColor: String = "#3E3D3B",
+        val underlineColorNight: String = "#ADADAD",
+        val dottedLine: Boolean = false, //虚线
+        val dottedBase: Float = 6f, //长度
+        val dottedRatio: Float = 6f,
+        val paddingBottom: Int = 6,
+        val paddingLeft: Int = 16,
+        val paddingRight: Int = 16,
+        val paddingTop: Int = 6,
+        val headerPaddingBottom: Int = 0,
+        val headerPaddingLeft: Int = 16,
+        val headerPaddingRight: Int = 16,
+        val headerPaddingTop: Int = 0,
+        val footerPaddingBottom: Int = 6,
+        val footerPaddingLeft: Int = 16,
+        val footerPaddingRight: Int = 16,
+        val footerPaddingTop: Int = 6,
+        val showHeaderLine: Boolean = false,
+        val showFooterLine: Boolean = true,
+        val tipHeaderLeft: Int = ReadTipType.tipTime,
+        val tipHeaderMiddle: Int = ReadTipType.tipNone,
+        val tipHeaderRight: Int = ReadTipType.tipBattery,
+        val tipFooterLeft: Int = ReadTipType.tipChapterTitle,
+        val tipFooterMiddle: Int = ReadTipType.tipNone,
+        val tipFooterRight: Int = ReadTipType.tipPageAndTotal,
+        val customTipHeaderLeft: String = "",
+        val customTipHeaderMiddle: String = "",
+        val customTipHeaderRight: String = "",
+        val customTipFooterLeft: String = "",
+        val customTipFooterMiddle: String = "",
+        val customTipFooterRight: String = "",
+        val tipHeaderColor: Int = 0,
+        val tipHeaderColorNight: Int = 0,
+        val tipFooterColor: Int = 0,
+        val tipFooterColorNight: Int = 0,
+        val tipDividerColor: Int = -1,
+        val headerMode: Int = 0,
+        val footerMode: Int = 0,
         @Transient
-        var menuIconShowText: Boolean = true,
+        val menuIconShowText: Boolean = true,
         @Transient
-        var menuIconStyle: Int = 0,
+        val menuIconStyle: Int = 0,
         @Transient
-        var menuIconItemsPerRow: Int = 5,
+        val menuIconItemsPerRow: Int = 5,
         @Transient
-        var menuIconRowCount: Int = 1,
+        val menuIconRowCount: Int = 1,
         @Transient
-        var menuBottomCornerRadius: Int = 0,
+        val menuBottomCornerRadius: Int = 0,
         @Transient
-        var menuBottomHorizontalMargin: Int = 0,
+        val menuBottomHorizontalMargin: Int = 0,
         @Transient
-        var menuBottomBottomMargin: Int = 0,
-        var highlightRules: ArrayList<HighlightRule> = arrayListOf()
+        val menuBottomBottomMargin: Int = 0,
+        val highlightRules: ArrayList<HighlightRule> = arrayListOf()
     ) {
 
         @Transient
@@ -767,18 +767,6 @@ object ReadBookConfig {
             return ReadStyleResolver.backgroundPath(this, bgIndex)
         }
 
-        private inline fun updateCurrentMode(
-            eInk: () -> Unit,
-            night: () -> Unit,
-            day: () -> Unit
-        ) {
-            when (ReadStyleResolver.currentMode()) {
-                ReadStyleResolver.ReadStyleMode.EInk -> eInk()
-                ReadStyleResolver.ReadStyleMode.Night -> night()
-                ReadStyleResolver.ReadStyleMode.Day -> day()
-            }
-        }
-
         private inline fun <T> currentModeValue(
             eInk: () -> T,
             night: () -> T,
@@ -791,17 +779,6 @@ object ReadBookConfig {
             }
         }
 
-        private inline fun updateNightTheme(
-            night: () -> Unit,
-            day: () -> Unit
-        ) {
-            if (ReadStyleResolver.isNightTheme()) {
-                night()
-            } else {
-                day()
-            }
-        }
-
         private inline fun <T> nightThemeValue(
             night: () -> T,
             day: () -> T
@@ -845,20 +822,12 @@ object ReadBookConfig {
             initAccentColorInt = true
         }
 
-        fun setCurTextAccentColor(color: Int) {
-            updateCurrentMode(
-                eInk = {
-                    textAccentColorEInk = "#${color.hexString}"
-                    textAccentColorIntEInk = color
-                },
-                night = {
-                    textAccentColorNight = "#${color.hexString}"
-                    textAccentColorIntNight = color
-                },
-                day = {
-                    textAccentColor = "#${color.hexString}"
-                    textAccentColorInt = color
-                }
+        fun withCurTextAccentColor(color: Int): Config {
+            val hex = "#${color.hexString}"
+            return currentModeValue(
+                eInk = { copy(textAccentColorEInk = hex) },
+                night = { copy(textAccentColorNight = hex) },
+                day = { copy(textAccentColor = hex) }
             )
         }
 
@@ -871,33 +840,20 @@ object ReadBookConfig {
             )
         }
 
-        fun setCurShadColor(color: Int){
-            updateNightTheme(
-                night = {
-                    shadowColorN = "#${color.hexString}"
-                    shadowColorNightInt = color
-                },
-                day = {
-                    shadowColor = "#${color.hexString}"
-                    shadowColorInt = color
-                }
+        fun withCurShadowColor(color: Int): Config {
+            val hex = "#${color.hexString}"
+            return nightThemeValue(
+                night = { copy(shadowColorN = hex) },
+                day = { copy(shadowColor = hex) }
             )
         }
 
-        fun setCurTextColor(color: Int) {
-            updateCurrentMode(
-                eInk = {
-                    textColorEInk = "#${color.hexString}"
-                    textColorIntEInk = color
-                },
-                night = {
-                    textColorNight = "#${color.hexString}"
-                    textColorIntNight = color
-                },
-                day = {
-                    textColor = "#${color.hexString}"
-                    textColorInt = color
-                }
+        fun withCurTextColor(color: Int): Config {
+            val hex = "#${color.hexString}"
+            return currentModeValue(
+                eInk = { copy(textColorEInk = hex) },
+                night = { copy(textColorNight = hex) },
+                day = { copy(textColor = hex) }
             )
         }
 
@@ -918,13 +874,11 @@ object ReadBookConfig {
             )
         }
 
-        fun setCurStatusIconDark(isDark: Boolean) {
-            updateCurrentMode(
-                eInk = { darkStatusIconEInk = isDark },
-                night = { darkStatusIconNight = isDark },
-                day = { darkStatusIcon = isDark }
-            )
-        }
+        fun withCurStatusIconDark(isDark: Boolean): Config = currentModeValue(
+            eInk = { copy(darkStatusIconEInk = isDark) },
+            night = { copy(darkStatusIconNight = isDark) },
+            day = { copy(darkStatusIcon = isDark) }
+        )
 
         fun curStatusIconDark(): Boolean {
             return currentModeValue(
@@ -934,13 +888,11 @@ object ReadBookConfig {
             )
         }
 
-        fun setCurPageAnim(@PageAnim.Anim anim: Int) {
-            updateCurrentMode(
-                eInk = { pageAnimEInk = anim },
-                night = { pageAnim = anim },
-                day = { pageAnim = anim }
-            )
-        }
+        fun withCurPageAnim(@PageAnim.Anim anim: Int): Config = currentModeValue(
+            eInk = { copy(pageAnimEInk = anim) },
+            night = { copy(pageAnim = anim) },
+            day = { copy(pageAnim = anim) }
+        )
 
         fun curPageAnim(): Int {
             return currentModeValue(
@@ -960,9 +912,8 @@ object ReadBookConfig {
         fun getPageAnim(): Int = pageAnim
         fun getPageAnimEInk(): Int = pageAnimEInk
 
-        fun setCurBg(bgType: Int, bg: String) {
-            ReadStyleResolver.setCurrentBackground(this, bgType, bg)
-        }
+        fun withCurBg(bgType: Int, bg: String): Config =
+            ReadStyleResolver.withCurrentBackground(this, bgType, bg)
 
         fun curBgStr(): String {
             return ReadStyleResolver.currentBackground(this).value
@@ -981,16 +932,11 @@ object ReadBookConfig {
             return if (isNight) menuBgColorNightInt else menuBgColorInt
         }
 
-        fun setMenuCurBg(bg: Int) {
-            updateNightTheme(
-                night = {
-                    menuBgColorNight = "#${bg.hexString}"
-                    menuBgColorNightInt = bg
-                },
-                day = {
-                    menuBgColor = "#${bg.hexString}"
-                    menuBgColorInt = bg
-                }
+        fun withMenuCurBg(bg: Int): Config {
+            val hex = "#${bg.hexString}"
+            return nightThemeValue(
+                night = { copy(menuBgColorNight = hex) },
+                day = { copy(menuBgColor = hex) }
             )
         }
 
@@ -1007,16 +953,11 @@ object ReadBookConfig {
             return if (isNight) menuAcColorNightInt else menuAcColorInt
         }
 
-        fun setMenuCurAc(bg: Int) {
-            updateNightTheme(
-                night = {
-                    menuAcColorNight = "#${bg.hexString}"
-                    menuAcColorNightInt = bg
-                },
-                day = {
-                    menuAcColor = "#${bg.hexString}"
-                    menuAcColorInt = bg
-                }
+        fun withMenuCurAc(bg: Int): Config {
+            val hex = "#${bg.hexString}"
+            return nightThemeValue(
+                night = { copy(menuAcColorNight = hex) },
+                day = { copy(menuAcColor = hex) }
             )
         }
 
@@ -1028,16 +969,11 @@ object ReadBookConfig {
             )
         }
 
-        fun setUnderlineColor(bg: Int) {
-            updateNightTheme(
-                night = {
-                    underlineColorNight = "#${bg.hexString}"
-                    underlineColorNightInt = bg
-                },
-                day = {
-                    underlineColor = "#${bg.hexString}"
-                    underlineColorInt = bg
-                }
+        fun withCurUnderlineColor(bg: Int): Config {
+            val hex = "#${bg.hexString}"
+            return nightThemeValue(
+                night = { copy(underlineColorNight = hex) },
+                day = { copy(underlineColor = hex) }
             )
         }
 

+ 6 - 17
app/src/main/java/io/legado/app/help/config/ReadStyleResolver.kt

@@ -41,26 +41,15 @@ object ReadStyleResolver {
         return ReadSessionState.isDarkThemeOverride ?: ReadConfig.isNightTheme
     }
 
-    fun setCurrentBackground(
+    fun withCurrentBackground(
         config: ReadBookConfig.Config,
         bgType: Int,
         bg: String
-    ) {
-        when (currentMode()) {
-            ReadStyleMode.EInk -> {
-                config.bgTypeEInk = bgType
-                config.bgStrEInk = bg
-            }
-
-            ReadStyleMode.Night -> {
-                config.bgTypeNight = bgType
-                config.bgStrNight = bg
-            }
-
-            ReadStyleMode.Day -> {
-                config.bgType = bgType
-                config.bgStr = bg
-            }
+    ): ReadBookConfig.Config {
+        return when (currentMode()) {
+            ReadStyleMode.EInk -> config.copy(bgTypeEInk = bgType, bgStrEInk = bg)
+            ReadStyleMode.Night -> config.copy(bgTypeNight = bgType, bgStrNight = bg)
+            ReadStyleMode.Day -> config.copy(bgType = bgType, bgStr = bg)
         }
     }
 

+ 39 - 0
app/src/test/java/io/legado/app/help/config/ReadBookConfigDependencyDirectionTest.kt

@@ -94,9 +94,48 @@ class ReadBookConfigDependencyDirectionTest {
         )
     }
 
+    @Test
+    fun `ReadBookConfig 只从 store 读,不调它的写方法`() {
+        val source = stripComments(readBookConfigSource())
+        val called = STORE_WRITES.filter { Regex("""\bconfigStore\.$it\s*\(""").containsMatchIn(source) }
+            .filterNot { it in INITIALIZE_ONLY && initializeBody(source).contains("$it(") }
+
+        assertTrue(
+            "ReadBookConfig 调了 ReadStyleConfigStore 的写方法:${called.joinToString()}。\n" +
+                "它持有 store 只是为了把当前选中那份投影成只读属性——一旦从这里改状态," +
+                "改动既不落盘也不发 publishState,弹层和渲染都收不到通知," +
+                "而且 build.gradle.kts 那条「ReadBookConfig 写入必须经过 ReadStyleGateway」" +
+                "的正则完全看不见(它只认 `ReadBookConfig.x = `)。\n" +
+                "写请走 ReadStyleGateway 的实现(ReadBookStyleConfigRepository)。\n" +
+                "例外:durConfig 的整份替换 setter 用 replaceConfigAt,initialize 用两个 init*。",
+            called.isEmpty(),
+        )
+    }
+
     private companion object {
         val MULTILINE = RegexOption.MULTILINE
 
+        /** [ReadStyleConfigStore] 上会改状态的方法。 */
+        val STORE_WRITES = listOf(
+            "updateEffective",
+            "updateStyleAt",
+            "addConfig",
+            "deleteConfigAt",
+            "importOrReplaceConfig",
+            "initConfigs",
+            "initShareConfig",
+        )
+
+        /** 只允许出现在 `initialize()` 里的两个——首帧之前得先把配置读进来。 */
+        val INITIALIZE_ONLY = setOf("initConfigs", "initShareConfig")
+
+        fun initializeBody(source: String): String {
+            val start = source.indexOf("internal fun initialize(")
+            if (start < 0) return ""
+            val end = source.indexOf("\n    }", start)
+            return if (end < 0) source.substring(start) else source.substring(start, end)
+        }
+
         /** `durConfig` 是整份配置的替换入口(应用预设 / 导入),只有 gateway 实现用得到。 */
         val WRITABLE_ALLOWLIST = setOf("durConfig")
 

+ 97 - 0
app/src/test/java/io/legado/app/help/config/ReadConfigJsonRoundTripTest.kt

@@ -0,0 +1,97 @@
+package io.legado.app.help.config
+
+import com.google.gson.Gson
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * R4.7 把 `ReadBookConfig.Config` 的值字段全改成了 `val`。这是本次改造里
+ * **唯一编译器覆盖不到的风险**:配置是 GSON 反序列化出来的,而 GSON 不走构造函数,
+ * 靠 `Field.setAccessible(true)` 往字段里塞值。`val` 编译成 `final` 字段,
+ * 万一哪天换了序列化实现或加了 `@JvmField` 之类的改动让反射写入失效,
+ * 失败模式是**用户所有排版配置读出来全是默认值**——静默、且编译和类型都拦不住。
+ *
+ * 所以这里钉死三条:写得进(非默认值能反序列化回来)、认得全(三种模式的颜色/背景都在)、
+ * 缺字段时回落到默认值(老版本配置文件仍能读)。
+ *
+ * 用裸 `Gson()` 而不是项目的 `GSON`:后者带 appCtx 相关的注册,JVM 单测里起不来;
+ * 本测试要验的是 Kotlin `val` + 反射写入这件事本身,与自定义适配器无关。
+ */
+class ReadConfigJsonRoundTripTest {
+
+    private val gson = Gson()
+
+    @Test
+    fun `非默认值能完整往返`() {
+        val original = ReadBookConfig.Config(
+            name = "夜读",
+            textSize = 33,
+            letterSpacing = 0.42f,
+            lineSpacingExtra = 27,
+            textItalic = true,
+            underline = true,
+            underlineHeight = 3,
+            paragraphIndent = "  ",
+            textFont = "/sdcard/fonts/x.ttf",
+            tipHeaderLeft = 9,
+            tipFooterRight = 14,
+            bgType = 2,
+            bgStr = "/sdcard/bg/a.jpg",
+            bgAlpha = 66,
+        )
+
+        val restored = gson.fromJson(gson.toJson(original), ReadBookConfig.Config::class.java)
+
+        assertEquals("data class 相等性只看构造参数,正好是要持久化的那批", original, restored)
+        assertEquals(33, restored.textSize)
+        assertEquals(0.42f, restored.letterSpacing, 0f)
+        assertTrue(restored.textItalic)
+        assertEquals("/sdcard/bg/a.jpg", restored.bgStr)
+    }
+
+    @Test
+    fun `私有的模式相关字段也能往返`() {
+        // textColor / textColorNight / pageAnim / darkStatusIcon 是 private 构造参数,
+        // 只能经由 withCurXxx 写、getXxx 读——但它们同样要落盘。
+        val json = """
+            {"name":"x","textColor":"#112233","textColorNight":"#445566",
+             "textColorEInk":"#778899","pageAnim":3,"pageAnimEInk":1,
+             "darkStatusIcon":false,"darkStatusIconNight":true}
+        """.trimIndent()
+
+        val restored = gson.fromJson(json, ReadBookConfig.Config::class.java)
+
+        assertEquals("#112233", restored.getTextColor())
+        assertEquals("#445566", restored.getTextColorNight())
+        assertEquals("#778899", restored.getTextColorEInk())
+        assertEquals(3, restored.getPageAnim())
+        assertEquals(1, restored.getPageAnimEInk())
+        assertEquals(false, restored.getDarkStatusIcon())
+        assertEquals(true, restored.getDarkStatusIconNight())
+    }
+
+    @Test
+    fun `缺字段的老配置回落到默认值`() {
+        val restored = gson.fromJson("""{"name":"旧"}""", ReadBookConfig.Config::class.java)
+
+        assertEquals("旧", restored.name)
+        assertEquals("没有走构造函数的话,默认值不会生效,这里会读到 0", 20, restored.textSize)
+        assertEquals(100, restored.bgAlpha)
+        assertEquals("  ", restored.paragraphIndent)
+        assertEquals(12, restored.lineSpacingExtra)
+    }
+
+    @Test
+    fun `copy 会重置颜色记忆化缓存`() {
+        // curTextColor() 把解析结果记在 @Transient 字段里。copy 只复制构造参数,
+        // 缓存回到未初始化——改了颜色的那份必须重新解析,不能沿用旧值。
+        val config = ReadBookConfig.Config(name = "x")
+        val before = config.getTextColor()
+
+        val changed = config.copy(name = "y")
+
+        assertEquals(before, changed.getTextColor())
+        assertEquals("y", changed.name)
+    }
+}

+ 13 - 0
build.gradle.kts

@@ -52,6 +52,15 @@ abstract class VerifyConfigArchitectureTask : DefaultTask() {
         val readBookConfigMutationCall = Regex(
             """\bReadBookConfig\.durConfig\.set[A-Za-z0-9_]*\s*\("""
         )
+        // R4.7:Config 的值字段已是 val,字段写入由编译器拦;剩下的唯一写入口是
+        // ReadStyleConfigStore 的列表操作。它是 Koin 单例,谁 inject 谁就能绕过 gateway
+        // 改配置且不触发 save/publishState——所以限定只有下面这几个文件能提到这个类型。
+        val configStoreOwners = setOf(
+            "io/legado/app/data/repository/ReadStyleConfigStore.kt",
+            "io/legado/app/data/repository/ReadBookStyleConfigRepository.kt",
+            "io/legado/app/help/config/ReadBookConfig.kt",
+            "io/legado/app/di/appModule.kt",
+        )
         val settingsUpdateDeclaration = Regex(
             """\b(?:class|interface|object|typealias)\s+[A-Za-z0-9_]*SettingsUpdate\b"""
         )
@@ -97,6 +106,10 @@ abstract class VerifyConfigArchitectureTask : DefaultTask() {
             ) {
                 violations += "$displayPath: ReadBookConfig 写入必须经过 ReadStyleGateway"
             }
+            if (relativePath !in configStoreOwners && "ReadStyleConfigStore" in text) {
+                violations += "$displayPath: 排版配置的写入口只对 ReadStyleGateway 的实现开放," +
+                    "不要注入 ReadStyleConfigStore"
+            }
             if (relativePath in injectedConfigFiles && "GlobalContext" in text) {
                 violations += "$displayPath: 配置所有者必须显式注入依赖,禁止 GlobalContext"
             }