Procházet zdrojové kódy

fix: 修复快照回滚竞态与读路径 asMap 放大,Restore 改走 overlay 批量写入

- rawPrefValue 弃用 asMap()(每次调用全量复制 map),改回单 key 直取,读路径恢复 O(1)
- PendingOverlayCore 落盘成功后不再立即清 pending,改为回灌确认后移除,
  消除旧回灌导致的读值短暂回滚(observe 幻影变更 / UI 闪烁)
- 新增 AppConfigStore.putAll,Restore 恢复配置改走 overlay 批量写入,
  恢复后立即读不再依赖 collector 回灌时机
- 语言偏好迁移改为 LocalConfig 一次性标记,避免 API<33 每次冷启动重复执行;
  zh/tw/en 与 Locale 映射抽取为 appLocaleListFor 供三处复用
- 清理:删除 DsSync 旁路写入 API、prefDelegate 失效的 sync 参数、putPrefStringSync 别名
5151561 před 3 měsíci
rodič
revize
a3fbb7c734

+ 13 - 18
app/src/main/java/io/legado/app/App.kt

@@ -1,9 +1,5 @@
 package io.legado.app
 
-import androidx.appcompat.app.AppCompatDelegate
-import androidx.core.os.LocaleListCompat
-import io.legado.app.ui.config.otherConfig.OtherConfig
-import java.util.Locale
 import android.app.Application
 import android.app.Notification
 import android.app.NotificationChannel
@@ -13,6 +9,7 @@ import android.content.pm.ApplicationInfo
 import android.content.res.Configuration
 import android.graphics.BitmapFactory
 import android.os.Build
+import androidx.appcompat.app.AppCompatDelegate
 import androidx.core.graphics.scale
 import coil.ImageLoader
 import coil.ImageLoaderFactory
@@ -50,6 +47,7 @@ import io.legado.app.help.RuleBigDataHelp
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.config.AppConfig
 import io.legado.app.help.config.AppConfigStore
+import io.legado.app.help.config.LocalConfig
 import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.help.config.ThemeConfigStore
 import io.legado.app.help.config.ThemeConfigStore.applyDayNightInit
@@ -63,10 +61,11 @@ import io.legado.app.help.storage.Backup
 import io.legado.app.lib.theme.primaryColor
 import io.legado.app.model.BookCover
 import io.legado.app.ui.book.read.page.entities.TextLine
+import io.legado.app.ui.config.otherConfig.OtherConfig
+import io.legado.app.ui.config.otherConfig.appLocaleListFor
 import io.legado.app.utils.ChineseUtils
 import io.legado.app.utils.FirebaseManager
 import io.legado.app.utils.LogUtils
-import io.legado.app.utils.defaultSharedPreferences
 import io.legado.app.utils.getPrefBoolean
 import io.legado.app.utils.getPrefString
 import io.legado.app.utils.isDebuggable
@@ -94,19 +93,15 @@ class App : Application(), ImageLoaderFactory {
         // 首行初始化设置快照层:同步预加载 DataStore(触发 SP 迁移),
         // 之后所有 getPref* 门面读取均为纯内存查找,须先于一切主题/配置读取
         AppConfigStore.init(this)
-        val storedLanguage = OtherConfig.language
-        if (storedLanguage != "auto") {
-            val appCompatLocales = AppCompatDelegate.getApplicationLocales()
-            if (appCompatLocales.isEmpty) {
-                val localeList = when (storedLanguage) {
-                    "zh" -> LocaleListCompat.create(Locale.SIMPLIFIED_CHINESE)
-                    "tw" -> LocaleListCompat.create(Locale.TRADITIONAL_CHINESE)
-                    "en" -> LocaleListCompat.create(Locale.ENGLISH)
-                    else -> null
-                }
-                if (localeList != null) {
-                    AppCompatDelegate.setApplicationLocales(localeList)
-                }
+        // 一次性迁移:把旧版语言偏好写入 AppCompat per-app locales,之后交由
+        // autoStoreLocales 持久化。不能每次启动都执行——API 33+ 上会覆盖用户在
+        // 系统设置里选择的应用语言,API <33 上此时 AppCompat 存储尚未加载、
+        // getApplicationLocales() 恒为空,isEmpty 守卫会形同虚设
+        if (!LocalConfig.appLocaleMigrated) {
+            LocalConfig.appLocaleMigrated = true
+            val localeList = appLocaleListFor(OtherConfig.language)
+            if (!localeList.isEmpty && AppCompatDelegate.getApplicationLocales().isEmpty) {
+                AppCompatDelegate.setApplicationLocales(localeList)
             }
         }
         startKoin {

+ 53 - 16
app/src/main/java/io/legado/app/help/config/AppConfigStore.kt

@@ -28,7 +28,7 @@ import io.legado.app.utils.stackTraceStr
  * 三个职责(不做其他事,防止长成 AppConfig 2.0):
  * 1. snapshot:App.onCreate 首行同步预加载一次(同时触发 SharedPreferencesMigration),
  *    之后由常驻 collector 跟随 DataStore 变化回灌,保证外部写入(Restore 批量写等)可见;
- * 2. pending overlay:写入先进内存立即对读侧生效,异步串行落盘后移除,
+ * 2. pending overlay:写入先进内存立即对读侧生效,异步串行落盘、回灌确认后移除,
  *    保证"写后立即读"永远读到新值,collector 携带旧状态回灌也不会闪烁;
  * 3. observe:按 key 订阅变化,替代 SP OnSharedPreferenceChangeListener。
  *
@@ -61,6 +61,11 @@ object AppConfigStore {
             initial = initial,
             launchWrite = { DsSync.launchWrite(it) },
             persist = { key, value -> dataStore.edit { it.setPrefValue(key, value) } },
+            persistAll = { values ->
+                dataStore.edit { prefs ->
+                    values.forEach { (key, value) -> prefs.setPrefValue(key, value) }
+                }
+            },
         )
         core = newCore
         scope.launch {
@@ -95,6 +100,9 @@ object AppConfigStore {
     fun putStringSet(key: String, value: Set<String>) = requireCore().put(key, value)
     fun remove(key: String) = requireCore().put(key, null)
 
+    /** 批量写入(Restore 恢复备份等):整批立即对读侧生效,单次 edit 落盘 */
+    fun putAll(values: Map<String, Any?>) = requireCore().putAll(values)
+
     // 订阅:替代 SP 监听器的统一入口,值不存在时发 null
     fun observeString(key: String): Flow<String?> = observe { it.compatDsString(key) }
     fun observeInt(key: String): Flow<Int?> = observe { it.compatDsInt(key) }
@@ -110,14 +118,17 @@ object AppConfigStore {
  * 快照 + pending overlay 的核心状态机,与 Android/DataStore 解耦以便 JVM 单测。
  *
  * 不变式:[preferencesFlow] = snapshot 叠加所有 pending 写入,pending 中的值永远优先。
- * pending 条目在对应落盘完成后移除;落盘返回的 Preferences 是该次 edit 后的完整状态,
- * 必然新于此前收到的任何回灌,因此落盘完成时直接采纳为 snapshot——即使 collector
- * 随后才处理一条落盘前的旧回灌,下一轮 collect 读到的也是最新状态,旧值不会驻留。
+ * snapshot 只由 collector 回灌([onEmission])推进,回灌按落盘提交顺序到达,因此单调不回退;
+ * pending 条目要等到回灌中该 key 的值已等于写入目标值时才移除——落盘完成后若先处理到
+ * 一条落盘前的旧回灌,该 key 仍被 overlay 保护,读值不会短暂回滚(回滚会让 observe
+ * 订阅方收到"新→旧→新"的幻影变更,如触发多余的 Activity recreate / WebDav refresh)。
+ * 落盘失败时移除对应 pending 实现回滚,读取回退到已落盘状态。
  */
 internal class PendingOverlayCore(
     initial: Preferences,
     private val launchWrite: (suspend () -> Unit) -> Unit,
     private val persist: suspend (key: String, value: Any?) -> Preferences,
+    private val persistAll: suspend (values: Map<String, Any?>) -> Preferences,
 ) {
 
     private val lock = Any()
@@ -135,21 +146,44 @@ internal class PendingOverlayCore(
             rebuild()
             // 在锁内提交写任务,保证落盘顺序与 pending 覆盖顺序一致
             launchWrite {
-                val result = runCatching { persist(key, value) }
-                synchronized(lock) {
-                    result.onSuccess { 
-                        snapshot = it 
-                    }.onFailure { e ->
-                        val isOverridden = pending[key] !== write
-                        if (isOverridden) {
-                            LogUtils.e("AppConfigStore", "保存设置失败且已被新写入覆盖: key=$key, value=$value\n${e.stackTraceStr}")
-                        } else {
+                // 成功时不动 snapshot/pending:等 collector 回灌到含新值的状态时(onEmission)
+                // 再移除 overlay,避免"落盘完成后才处理到旧回灌"导致读值短暂回滚
+                runCatching { persist(key, value) }.onFailure { e ->
+                    synchronized(lock) {
+                        if (pending[key] === write) {
+                            // 失败回滚:移除 overlay,读取回退到已落盘状态
+                            pending.remove(key)
+                            rebuild()
                             LogUtils.e("AppConfigStore", "保存设置失败: key=$key, value=$value\n${e.stackTraceStr}")
+                        } else {
+                            // 同 key 已有更新的写入,继续由新条目覆盖
+                            LogUtils.e("AppConfigStore", "保存设置失败且已被新写入覆盖: key=$key, value=$value\n${e.stackTraceStr}")
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    fun putAll(values: Map<String, Any?>) {
+        if (values.isEmpty()) return
+        synchronized(lock) {
+            val writes = values.mapValues { PendingWrite(it.value) }
+            pending.putAll(writes)
+            rebuild()
+            launchWrite {
+                runCatching { persistAll(values) }.onFailure { e ->
+                    synchronized(lock) {
+                        var removed = false
+                        writes.forEach { (key, write) ->
+                            if (pending[key] === write) {
+                                pending.remove(key)
+                                removed = true
+                            }
                         }
+                        if (removed) rebuild()
+                        LogUtils.e("AppConfigStore", "批量保存设置失败: keys=${values.keys}\n${e.stackTraceStr}")
                     }
-                    // 同 key 已有更新的写入时不移除,继续由新条目覆盖
-                    if (pending[key] === write) pending.remove(key)
-                    rebuild()
                 }
             }
         }
@@ -158,6 +192,9 @@ internal class PendingOverlayCore(
     fun onEmission(prefs: Preferences) {
         synchronized(lock) {
             snapshot = prefs
+            // 回灌已包含某个 pending 写入的目标值时,说明该写入已落盘且对 collector 可见,
+            // 此时移除 overlay 才不会被更早的旧回灌回滚
+            pending.entries.removeAll { (key, write) -> prefs.rawPrefValue(key) == write.value }
             rebuild()
         }
     }

+ 0 - 32
app/src/main/java/io/legado/app/help/config/DsSync.kt

@@ -1,12 +1,5 @@
 package io.legado.app.help.config
 
-import androidx.datastore.preferences.core.booleanPreferencesKey
-import androidx.datastore.preferences.core.edit
-import androidx.datastore.preferences.core.floatPreferencesKey
-import androidx.datastore.preferences.core.intPreferencesKey
-import androidx.datastore.preferences.core.longPreferencesKey
-import androidx.datastore.preferences.core.stringPreferencesKey
-import io.legado.app.data.repository.dataStore
 import io.legado.app.utils.LogUtils
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
@@ -15,9 +8,7 @@ import kotlinx.coroutines.Job
 import kotlinx.coroutines.SupervisorJob
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
 import kotlinx.coroutines.withTimeoutOrNull
-import splitties.init.appCtx
 import java.util.concurrent.atomic.AtomicInteger
 import kotlin.time.Duration.Companion.milliseconds
 import kotlin.time.measureTime
@@ -69,27 +60,4 @@ object DsSync {
             }
         }
     }
-
-    suspend fun putString(key: String, value: String?) {
-        appCtx.dataStore.edit { prefs ->
-            val dsKey = stringPreferencesKey(key)
-            if (value == null) prefs.remove(dsKey) else prefs[dsKey] = value
-        }
-    }
-
-    suspend fun putInt(key: String, value: Int) {
-        appCtx.dataStore.edit { it[intPreferencesKey(key)] = value }
-    }
-
-    suspend fun putBoolean(key: String, value: Boolean) {
-        appCtx.dataStore.edit { it[booleanPreferencesKey(key)] = value }
-    }
-
-    suspend fun putLong(key: String, value: Long) {
-        appCtx.dataStore.edit { it[longPreferencesKey(key)] = value }
-    }
-
-    suspend fun putFloat(key: String, value: Float) {
-        appCtx.dataStore.edit { it[floatPreferencesKey(key)] = value }
-    }
 }

+ 7 - 0
app/src/main/java/io/legado/app/help/config/LocalConfig.kt

@@ -69,6 +69,13 @@ by appCtx.getSharedPreferences("local", Context.MODE_PRIVATE) {
             putBoolean("navExtended", value)
         }
 
+    /** 旧版语言偏好是否已迁移到 AppCompat per-app locales(App.onCreate 一次性迁移) */
+    var appLocaleMigrated: Boolean
+        get() = getBoolean("appLocaleMigrated")
+        set(value) {
+            putBoolean("appLocaleMigrated", value)
+        }
+
     val readHelpVersionIsLast: Boolean
         get() = isLastVersion(1, "readHelpVersion", "firstRead")
 

+ 4 - 2
app/src/main/java/io/legado/app/help/config/PreferencesDsCompat.kt

@@ -21,9 +21,11 @@ import androidx.datastore.preferences.core.stringSetPreferencesKey
 
 /**
  * 取 key 对应的原始存储值(不做类型断言)。
- * 声明为 Any? 避免泛型擦除后的 checkcast,存储类型不匹配也不会抛 ClassCastException。
+ * Key 按 name 判等,用 string 类型的 key 即可取到该 name 下任意类型的原始值;
+ * 返回位置声明为 Any? 使编译器不插入 checkcast,存储类型不匹配也不会抛 ClassCastException。
+ * 不可用 asMap() 实现:datastore 的 asMap() 每次调用都会防御性复制整个 map,读是热路径。
  */
-fun Preferences.rawPrefValue(key: String): Any? = asMap()[stringPreferencesKey(key)]
+fun Preferences.rawPrefValue(key: String): Any? = this[stringPreferencesKey(key)]
 
 fun Preferences.compatDsString(key: String): String? = when (val raw = rawPrefValue(key)) {
     is String -> raw

+ 5 - 14
app/src/main/java/io/legado/app/help/storage/Restore.kt

@@ -5,7 +5,6 @@ import android.database.sqlite.SQLiteConstraintException
 import android.net.Uri
 import androidx.appcompat.app.AppCompatDelegate
 import androidx.core.content.edit
-import androidx.core.os.LocaleListCompat
 import androidx.documentfile.provider.DocumentFile
 import io.legado.app.BuildConfig
 import io.legado.app.R
@@ -34,15 +33,16 @@ import io.legado.app.data.entities.TxtTocRule
 import io.legado.app.data.entities.readRecord.ReadRecord
 import io.legado.app.data.entities.readRecord.ReadRecordDetail
 import io.legado.app.data.entities.readRecord.ReadRecordSession
-import io.legado.app.data.repository.SettingsRepository
 import io.legado.app.help.DirectLinkUpload
 import io.legado.app.help.LauncherIconHelp
 import io.legado.app.help.book.isLocal
 import io.legado.app.help.book.upType
+import io.legado.app.help.config.AppConfigStore
 import io.legado.app.help.config.LocalConfig
 import io.legado.app.help.config.ThemeConfigStore
 import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.ui.config.otherConfig.OtherConfig
+import io.legado.app.ui.config.otherConfig.appLocaleListFor
 import io.legado.app.model.BookCover
 import io.legado.app.model.localBook.LocalBook
 import io.legado.app.utils.ACache
@@ -61,7 +61,6 @@ import kotlinx.coroutines.Dispatchers.Main
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.withContext
 import org.koin.core.component.KoinComponent
-import org.koin.core.component.inject
 import org.xmlpull.v1.XmlPullParser
 import org.xmlpull.v1.XmlPullParserFactory
 import splitties.init.appCtx
@@ -73,7 +72,6 @@ import java.io.FileInputStream
  */
 object Restore : KoinComponent {
 
-    private val settingsRepository: SettingsRepository by inject()
     private const val TAG = "Restore"
 
     suspend fun restore(context: Context, uri: Uri) {
@@ -338,14 +336,7 @@ object Restore : KoinComponent {
         appCtx.toastOnUi(R.string.restore_success)
         withContext(Main) {
             delay(100)
-            val language = OtherConfig.language
-            val localeList = when (language) {
-                "zh" -> LocaleListCompat.create(java.util.Locale.SIMPLIFIED_CHINESE)
-                "tw" -> LocaleListCompat.create(java.util.Locale.TRADITIONAL_CHINESE)
-                "en" -> LocaleListCompat.create(java.util.Locale.ENGLISH)
-                else -> LocaleListCompat.getEmptyLocaleList()
-            }
-            AppCompatDelegate.setApplicationLocales(localeList)
+            AppCompatDelegate.setApplicationLocales(appLocaleListFor(OtherConfig.language))
             if (!BuildConfig.DEBUG) {
                 LauncherIconHelp.changeIcon(appCtx.getPrefString(PreferKey.launcherIcon))
             }
@@ -466,8 +457,8 @@ object Restore : KoinComponent {
                 }
             }
         }
-        // 同步恢复到 DataStore
-        settingsRepository.batchPutFromMap(finalMap)
+        // 经快照层批量恢复:立即对读侧生效(onRestoreFinish 的读取不再依赖回灌时机),异步单次 edit 落盘
+        AppConfigStore.putAll(finalMap)
     }
 
     private fun readXmlToMap(file: File): Map<String, Any?> {

+ 1 - 4
app/src/main/java/io/legado/app/ui/config/PrefDelegate.kt

@@ -23,7 +23,6 @@ import io.legado.app.utils.putPrefFloat
 import io.legado.app.utils.putPrefInt
 import io.legado.app.utils.putPrefLong
 import io.legado.app.utils.putPrefString
-import io.legado.app.utils.putPrefStringSync
 import io.legado.app.utils.removePref
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
@@ -48,7 +47,6 @@ fun <T> prefDelegate(
     key: String,
     defaultValue: T,
     lifecycleOwner: LifecycleOwner? = null,
-    sync: Boolean = false,
     onValueChange: ((T) -> Unit)? = null
 ): PrefDelegate<T> {
     return object : PrefDelegate<T>, DefaultLifecycleObserver {
@@ -148,9 +146,8 @@ fun <T> prefStateDelegate(
     key: String,
     defaultValue: T,
     lifecycleOwner: LifecycleOwner? = null,
-    sync: Boolean = false,
     onValueChange: ((T) -> Unit)? = null
 ): PrefStateDelegate<T> {
-    val delegate = prefDelegate(key, defaultValue, lifecycleOwner, sync, onValueChange)
+    val delegate = prefDelegate(key, defaultValue, lifecycleOwner, onValueChange)
     return PrefStateDelegate(delegate)
 }

+ 11 - 2
app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfig.kt

@@ -1,15 +1,24 @@
 package io.legado.app.ui.config.otherConfig
 
+import androidx.core.os.LocaleListCompat
 import io.legado.app.constant.PreferKey
 import io.legado.app.ui.config.downloadCacheConfig.DownloadCacheConfig
 import io.legado.app.ui.config.prefDelegate
+import java.util.Locale
+
+/** 语言偏好值 → per-app locale 列表;"auto" 及未知值返回空列表(跟随系统) */
+fun appLocaleListFor(language: String?): LocaleListCompat = when (language) {
+    "zh" -> LocaleListCompat.create(Locale.SIMPLIFIED_CHINESE)
+    "tw" -> LocaleListCompat.create(Locale.TRADITIONAL_CHINESE)
+    "en" -> LocaleListCompat.create(Locale.ENGLISH)
+    else -> LocaleListCompat.getEmptyLocaleList()
+}
 
 object OtherConfig {
 
     var language by prefDelegate(
         PreferKey.language,
-        "auto",
-        sync = true
+        "auto"
     )
 
     var updateToVariant by prefDelegate(

+ 1 - 9
app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigScreen.kt

@@ -23,7 +23,6 @@ import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.R
-import androidx.core.os.LocaleListCompat
 import io.legado.app.service.WebService
 import io.legado.app.ui.config.readMangaConfig.ReadMangaConfig
 import io.legado.app.ui.theme.LegadoTheme
@@ -42,7 +41,6 @@ import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
 import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
 import io.legado.app.utils.takePersistablePermissionSafely
 import org.koin.androidx.compose.koinViewModel
-import java.util.Locale
 
 @OptIn(ExperimentalMaterial3Api::class)
 @Composable
@@ -106,13 +104,7 @@ fun OtherConfigScreen(
                     entryValues = stringArrayResource(R.array.language_value),
                     onValueChange = { newValue ->
                         OtherConfig.language = newValue
-                        val localeList = when (newValue) {
-                            "zh" -> LocaleListCompat.create(Locale.SIMPLIFIED_CHINESE)
-                            "tw" -> LocaleListCompat.create(Locale.TRADITIONAL_CHINESE)
-                            "en" -> LocaleListCompat.create(Locale.ENGLISH)
-                            else -> LocaleListCompat.getEmptyLocaleList()
-                        }
-                        AppCompatDelegate.setApplicationLocales(localeList)
+                        AppCompatDelegate.setApplicationLocales(appLocaleListFor(newValue))
                     }
                 )
 

+ 4 - 6
app/src/main/java/io/legado/app/utils/ContextExtensions.kt

@@ -43,6 +43,7 @@ import io.legado.app.constant.AppConst
 import io.legado.app.data.entities.Book
 import io.legado.app.help.IntentHelp
 import io.legado.app.help.config.AppConfigStore
+import io.legado.app.help.config.DsSync
 import io.legado.app.help.book.isAudio
 import io.legado.app.help.book.isImage
 import io.legado.app.help.book.isLocal
@@ -52,6 +53,7 @@ import io.legado.app.ui.book.audio.AudioPlayActivity
 import io.legado.app.ui.book.manga.ReadMangaActivity
 import io.legado.app.ui.main.MainActivity
 import io.legado.app.ui.main.bookshelf.BookShelfItem
+import kotlinx.coroutines.runBlocking
 import splitties.systemservices.clipboardManager
 import splitties.systemservices.connectivityManager
 import splitties.systemservices.uiModeManager
@@ -288,10 +290,6 @@ fun Context.putPrefString(key: String, value: String?) {
     AppConfigStore.putString(key, value)
 }
 
-fun Context.putPrefStringSync(key: String, value: String?) {
-    putPrefString(key, value)
-}
-
 fun Context.getPrefStringSet(
     key: String,
     defValue: MutableSet<String>? = null,
@@ -339,8 +337,8 @@ fun Context.restart() {
                     or Intent.FLAG_ACTIVITY_CLEAR_TASK
                     or Intent.FLAG_ACTIVITY_CLEAR_TOP
         )
-        kotlinx.coroutines.runBlocking {
-            io.legado.app.help.config.DsSync.awaitPendingWrites()
+        runBlocking {
+            DsSync.awaitPendingWrites()
         }
         startActivity(intent)
         //杀掉以前进程

+ 44 - 3
app/src/test/java/io/legado/app/help/config/PendingOverlayCoreTest.kt

@@ -28,6 +28,12 @@ class PendingOverlayCoreTest {
                 dsState = mutable.toPreferences()
                 dsState
             },
+            persistAll = { values ->
+                val mutable = dsState.toMutablePreferences()
+                values.forEach { (key, value) -> mutable.setPrefValue(key, value) }
+                dsState = mutable.toPreferences()
+                dsState
+            },
         )
 
         /** 执行队列中下一个落盘任务(模拟 DsSync 串行队列跑完一个 Job) */
@@ -67,9 +73,9 @@ class PendingOverlayCoreTest {
         val h = Harness(mutablePreferencesOf(intPreferencesKey("k") to 1))
         h.core.put("k", 2)
         h.completeNextWrite()
-        // 落盘完成即采纳 edit 结果,读取已是新值
+        // 落盘完成后新值仍由 overlay 保护,读取已是新值
         assertEquals(2, h.read("k"))
-        // collector 随后携带落盘后的新状态回灌
+        // collector 携带落盘后的新状态回灌,确认后 overlay 移除
         h.core.onEmission(h.dsState)
         assertEquals(2, h.read("k"))
         // overlay 已清空:此后外部写入(如 Restore)的回灌可正常覆盖
@@ -77,6 +83,39 @@ class PendingOverlayCoreTest {
         assertEquals(5, h.read("k"))
     }
 
+    @Test
+    fun `落盘完成后处理到旧回灌-读值不回滚`() {
+        val h = Harness(mutablePreferencesOf(intPreferencesKey("k") to 1))
+        h.core.put("k", 2)
+        h.completeNextWrite()
+        // collector 此时才处理一条落盘前排队的旧回灌,读值不得回滚(否则 observe
+        // 订阅方会收到 2→1→2 的幻影变更)
+        h.core.onEmission(mutablePreferencesOf(intPreferencesKey("k") to 1))
+        assertEquals(2, h.read("k"))
+        // 含新值的回灌到达后 overlay 才移除,外部写入随后可正常覆盖
+        h.core.onEmission(h.dsState)
+        assertEquals(2, h.read("k"))
+        h.core.onEmission(mutablePreferencesOf(intPreferencesKey("k") to 7))
+        assertEquals(7, h.read("k"))
+    }
+
+    @Test
+    fun `批量写入立即生效-单次落盘-回灌确认后清空 overlay`() {
+        val h = Harness(mutablePreferencesOf(intPreferencesKey("a") to 1))
+        h.core.putAll(mapOf("a" to 2, "b" to "x"))
+        // 写入内存立即可读,且只排一个落盘任务
+        assertEquals(2, h.read("a"))
+        assertEquals("x", h.core.preferencesFlow.value.compatDsString("b"))
+        assertEquals(1, h.writeQueue.size)
+        h.completeNextWrite()
+        assertEquals(2, h.dsState.compatDsInt("a"))
+        assertEquals("x", h.dsState.compatDsString("b"))
+        // 回灌确认后 overlay 清空,外部回灌可覆盖
+        h.core.onEmission(h.dsState)
+        h.core.onEmission(mutablePreferencesOf(intPreferencesKey("a") to 9))
+        assertEquals(9, h.read("a"))
+    }
+
     @Test
     fun `同 key 连续写入-旧写入落盘不移除新写入的 overlay`() {
         val h = Harness(mutablePreferencesOf())
@@ -130,6 +169,7 @@ class PendingOverlayCoreTest {
                 dsState = mutable.toPreferences()
                 dsState
             },
+            persistAll = { error("unused") },
         )
         core.put("k", 2)
         assertEquals(2, core.preferencesFlow.value.compatDsInt("k"))
@@ -163,6 +203,7 @@ class PendingOverlayCoreTest {
                 dsState = mutable.toPreferences()
                 dsState
             },
+            persistAll = { error("unused") },
         )
         // 第一次写入 2 (失败)
         core.put("k", 2)
@@ -185,7 +226,7 @@ class PendingOverlayCoreTest {
         // 执行第二个任务(成功)
         runBlocking { writeQueue.removeFirst().invoke() }
 
-        // 第二个成功,快照更新为 3,并且 overlay 彻底清空,读取为 3
+        // 第二个成功落盘,读取为 3(overlay 待回灌确认后清空)
         assertEquals(3, core.preferencesFlow.value.compatDsInt("k"))
         assertEquals(3, dsState.compatDsInt("k"))
     }