Jelajahi Sumber

feat: 建立 AppConfigStore 快照层,读门面切换到 DataStore

PR-1: 存储收敛基座 —— 新增 AppConfigStore 内存快照 + pending overlay,
getPref*/putPref* 门面读侧切到 DataStore,写侧保持 SP 双写向后兼容。

- 新增 AppConfigStore(.kt): snapshot + pending overlay + observe Flow
- 新增 PreferencesDsCompat(.kt): DataStore 类型兼容读取纯函数
- DsSync 增加串行写队列 launchWrite()
- ContextExtensions / FragmentExtensions getPref* 改读快照(DS缺值回退SP+补写)
- PrefDelegate 删除 ~80 行重复类型转换,改用共享 compatDs* 函数
- App.onCreate 首行初始化快照层
- 新增 14 个 JVM 单测(PendingOverlayCore 6 + PreferencesDsCompat 8)
5151561 3 bulan lalu
induk
melakukan
1f8cb5197b

+ 4 - 0
app/src/main/java/io/legado/app/App.kt

@@ -46,6 +46,7 @@ import io.legado.app.help.LifecycleHelp
 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.ReadBookConfig
 import io.legado.app.help.config.ThemeConfigStore
 import io.legado.app.help.config.ThemeConfigStore.applyDayNightInit
@@ -87,6 +88,9 @@ class App : Application(), ImageLoaderFactory {
     }
 
     override fun onCreate() {
+        // 首行初始化设置快照层:同步预加载 DataStore(触发 SP 迁移),
+        // 之后所有 getPref* 门面读取均为纯内存查找,须先于一切主题/配置读取
+        AppConfigStore.init(this)
         startKoin {
             androidContext(this@App)
             modules(appDatabaseModule, appModule)

+ 162 - 0
app/src/main/java/io/legado/app/help/config/AppConfigStore.kt

@@ -0,0 +1,162 @@
+package io.legado.app.help.config
+
+import android.content.Context
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.emptyPreferences
+import io.legado.app.data.repository.dataStore
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import java.io.IOException
+
+/**
+ * "settings" DataStore 的进程内内存快照层,设置读取的唯一同步入口。
+ *
+ * 三个职责(不做其他事,防止长成 AppConfig 2.0):
+ * 1. snapshot:App.onCreate 首行同步预加载一次(同时触发 SharedPreferencesMigration),
+ *    之后由常驻 collector 跟随 DataStore 变化回灌,保证外部写入(Restore 批量写等)可见;
+ * 2. pending overlay:写入先进内存立即对读侧生效,异步串行落盘后移除,
+ *    保证"写后立即读"永远读到新值,collector 携带旧状态回灌也不会闪烁;
+ * 3. observe:按 key 订阅变化,替代 SP OnSharedPreferenceChangeListener。
+ *
+ * 约束:读 API 是纯内存查找(主线程零 IO),但 onCreate 的预加载耗时与 settings 文件
+ * 大小成正比——大 value(长 JSON、图片路径列表等)不得写进 "settings" DataStore,
+ * 应走各自的文件级配置。
+ */
+object AppConfigStore {
+
+    @Volatile
+    private var core: PendingOverlayCore? = null
+    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+    val isInitialized: Boolean get() = core != null
+
+    /**
+     * 在 App.onCreate 首行调用,先于一切设置读取(主题初始化等)。
+     * runBlocking 首读会触发 DataStore 迁移,与旧版首个 PrefDelegate 构造时的行为一致,
+     * 只是显式提前且全程只做一次。
+     */
+    fun init(context: Context) {
+        if (core != null) return
+        val dataStore = context.applicationContext.dataStore
+        val initial = runBlocking {
+            dataStore.data
+                .catch { if (it is IOException) emit(emptyPreferences()) else throw it }
+                .first()
+        }
+        val newCore = PendingOverlayCore(
+            initial = initial,
+            launchWrite = { DsSync.launchWrite(it) },
+            persist = { key, value -> dataStore.edit { it.setPrefValue(key, value) } },
+        )
+        core = newCore
+        scope.launch {
+            dataStore.data
+                .catch { if (it is IOException) emit(emptyPreferences()) else throw it }
+                .collect { newCore.onEmission(it) }
+        }
+    }
+
+    private fun requireCore(): PendingOverlayCore =
+        checkNotNull(core) { "AppConfigStore 未初始化,应在 App.onCreate 首行调用 init()" }
+
+    /** 当前生效的设置快照(DataStore 落盘状态 + 未落盘写入叠加) */
+    val preferences: Preferences get() = requireCore().preferencesFlow.value
+
+    val preferencesFlow: StateFlow<Preferences> get() = requireCore().preferencesFlow
+
+    // 读取:key 不存在时返回 null,由调用方(过渡期的门面)决定回退行为
+    fun getString(key: String): String? = preferences.compatDsString(key)
+    fun getInt(key: String): Int? = preferences.compatDsInt(key)
+    fun getBoolean(key: String): Boolean? = preferences.compatDsBoolean(key)
+    fun getLong(key: String): Long? = preferences.compatDsLong(key)
+    fun getFloat(key: String): Float? = preferences.compatDsFloat(key)
+    fun getStringSet(key: String): Set<String>? = preferences.compatDsStringSet(key)
+
+    // 写入:立即对读侧生效,异步串行落盘;value 为 null 表示移除
+    fun putString(key: String, value: String?) = requireCore().put(key, value)
+    fun putInt(key: String, value: Int) = requireCore().put(key, value)
+    fun putBoolean(key: String, value: Boolean) = requireCore().put(key, value)
+    fun putLong(key: String, value: Long) = requireCore().put(key, value)
+    fun putFloat(key: String, value: Float) = requireCore().put(key, value)
+    fun putStringSet(key: String, value: Set<String>) = requireCore().put(key, value)
+    fun remove(key: String) = requireCore().put(key, null)
+
+    // 订阅:替代 SP 监听器的统一入口,值不存在时发 null
+    fun observeString(key: String): Flow<String?> = observe { it.compatDsString(key) }
+    fun observeInt(key: String): Flow<Int?> = observe { it.compatDsInt(key) }
+    fun observeBoolean(key: String): Flow<Boolean?> = observe { it.compatDsBoolean(key) }
+    fun observeLong(key: String): Flow<Long?> = observe { it.compatDsLong(key) }
+    fun observeFloat(key: String): Flow<Float?> = observe { it.compatDsFloat(key) }
+
+    private inline fun <T> observe(crossinline read: (Preferences) -> T?): Flow<T?> =
+        preferencesFlow.map { read(it) }.distinctUntilChanged()
+}
+
+/**
+ * 快照 + pending overlay 的核心状态机,与 Android/DataStore 解耦以便 JVM 单测。
+ *
+ * 不变式:[preferencesFlow] = snapshot 叠加所有 pending 写入,pending 中的值永远优先。
+ * pending 条目在对应落盘完成后移除;落盘返回的 Preferences 是该次 edit 后的完整状态,
+ * 必然新于此前收到的任何回灌,因此落盘完成时直接采纳为 snapshot——即使 collector
+ * 随后才处理一条落盘前的旧回灌,下一轮 collect 读到的也是最新状态,旧值不会驻留。
+ */
+internal class PendingOverlayCore(
+    initial: Preferences,
+    private val launchWrite: (suspend () -> Unit) -> Unit,
+    private val persist: suspend (key: String, value: Any?) -> Preferences,
+) {
+
+    private val lock = Any()
+    private var snapshot: Preferences = initial
+    private val pending = LinkedHashMap<String, PendingWrite>()
+    private val _preferences = MutableStateFlow(initial)
+    val preferencesFlow: StateFlow<Preferences> get() = _preferences
+
+    private class PendingWrite(val value: Any?)
+
+    fun put(key: String, value: Any?) {
+        synchronized(lock) {
+            val write = PendingWrite(value)
+            pending[key] = write
+            rebuild()
+            // 在锁内提交写任务,保证落盘顺序与 pending 覆盖顺序一致
+            launchWrite {
+                val result = persist(key, value)
+                synchronized(lock) {
+                    snapshot = result
+                    // 同 key 已有更新的写入时不移除,继续由新条目覆盖
+                    if (pending[key] === write) pending.remove(key)
+                    rebuild()
+                }
+            }
+        }
+    }
+
+    fun onEmission(prefs: Preferences) {
+        synchronized(lock) {
+            snapshot = prefs
+            rebuild()
+        }
+    }
+
+    private fun rebuild() {
+        if (pending.isEmpty()) {
+            _preferences.value = snapshot
+            return
+        }
+        val prefs = snapshot.toMutablePreferences()
+        pending.forEach { (key, write) -> prefs.setPrefValue(key, write.value) }
+        _preferences.value = prefs.toPreferences()
+    }
+}

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

@@ -7,6 +7,12 @@ 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 kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
 import splitties.init.appCtx
 
 /**
@@ -17,6 +23,20 @@ import splitties.init.appCtx
  */
 object DsSync {
 
+    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+    @OptIn(ExperimentalCoroutinesApi::class)
+    private val writeDispatcher = Dispatchers.IO.limitedParallelism(1)
+
+    /**
+     * 串行写队列:所有经 [AppConfigStore] 的写入按提交顺序落盘。
+     * 写入失败仅吞掉异常(与旧 PrefDelegate 行为一致),此时值仍在内存快照中,重启后丢失。
+     */
+    fun launchWrite(block: suspend () -> Unit): Job =
+        scope.launch(writeDispatcher) {
+            runCatching { block() }
+        }
+
     suspend fun putString(key: String, value: String?) {
         appCtx.dataStore.edit { prefs ->
             val dsKey = stringPreferencesKey(key)

+ 77 - 0
app/src/main/java/io/legado/app/help/config/PreferencesDsCompat.kt

@@ -0,0 +1,77 @@
+package io.legado.app.help.config
+
+import androidx.datastore.preferences.core.MutablePreferences
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+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 androidx.datastore.preferences.core.stringSetPreferencesKey
+
+/**
+ * DataStore 值的类型兼容读写。
+ *
+ * 历史数据(SP 迁移、旧版本写入)里同一 key 的存储类型可能与读取类型不一致:
+ * int 存成 string("1")、long 存成 int 等。这里统一做"按实际存储类型取值,再转换到目标类型"。
+ *
+ * 注意:依赖 [Preferences.Key] 的 equals/hashCode 只比较 name(datastore 既有行为,
+ * 也是同名不同类型 key 会互相覆盖的原因),因此用任意类型的 key 都能取到该 name 下的原始值。
+ */
+
+/**
+ * 取 key 对应的原始存储值(不做类型断言)。
+ * 声明为 Any? 避免泛型擦除后的 checkcast,存储类型不匹配也不会抛 ClassCastException。
+ */
+fun Preferences.rawPrefValue(key: String): Any? = this[stringPreferencesKey(key)]
+
+fun Preferences.compatDsString(key: String): String? = rawPrefValue(key) as? String
+
+fun Preferences.compatDsInt(key: String): Int? = when (val raw = rawPrefValue(key)) {
+    is Int -> raw
+    is String -> raw.toIntOrNull()
+    else -> null
+}
+
+fun Preferences.compatDsBoolean(key: String): Boolean? = when (val raw = rawPrefValue(key)) {
+    is Boolean -> raw
+    is String -> raw.toBooleanStrictOrNull()
+    else -> null
+}
+
+fun Preferences.compatDsLong(key: String): Long? = when (val raw = rawPrefValue(key)) {
+    is Long -> raw
+    is Int -> raw.toLong()
+    is String -> raw.toLongOrNull()
+    else -> null
+}
+
+fun Preferences.compatDsFloat(key: String): Float? = when (val raw = rawPrefValue(key)) {
+    is Float -> raw
+    is Int -> raw.toFloat()
+    is String -> raw.toFloatOrNull()
+    else -> null
+}
+
+fun Preferences.compatDsStringSet(key: String): Set<String>? = when (val raw = rawPrefValue(key)) {
+    is Set<*> -> @Suppress("UNCHECKED_CAST") (raw as Set<String>)
+    else -> null
+}
+
+/**
+ * 按 value 的运行时类型写入对应类型的 key;value 为 null 表示移除。
+ * 由于 Key 按 name 判等,写入会覆盖同名的旧类型条目,移除一次即可清掉任意类型的条目。
+ */
+fun MutablePreferences.setPrefValue(key: String, value: Any?) {
+    when (value) {
+        null -> remove(stringPreferencesKey(key))
+        is String -> this[stringPreferencesKey(key)] = value
+        is Int -> this[intPreferencesKey(key)] = value
+        is Boolean -> this[booleanPreferencesKey(key)] = value
+        is Long -> this[longPreferencesKey(key)] = value
+        is Float -> this[floatPreferencesKey(key)] = value
+        is Set<*> -> @Suppress("UNCHECKED_CAST") {
+            this[stringSetPreferencesKey(key)] = value as Set<String>
+        }
+    }
+}

+ 33 - 117
app/src/main/java/io/legado/app/ui/config/PrefDelegate.kt

@@ -4,16 +4,15 @@ import androidx.compose.runtime.MutableState
 import androidx.compose.runtime.State
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.snapshots.Snapshot
-import androidx.datastore.preferences.core.booleanPreferencesKey
-import androidx.datastore.preferences.core.emptyPreferences
-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 androidx.datastore.preferences.core.Preferences
 import androidx.lifecycle.DefaultLifecycleObserver
 import androidx.lifecycle.LifecycleOwner
-import io.legado.app.data.repository.dataStore
-import io.legado.app.help.config.DsSync
+import io.legado.app.help.config.AppConfigStore
+import io.legado.app.help.config.compatDsBoolean
+import io.legado.app.help.config.compatDsFloat
+import io.legado.app.help.config.compatDsInt
+import io.legado.app.help.config.compatDsLong
+import io.legado.app.help.config.compatDsString
 import io.legado.app.utils.getPrefBoolean
 import io.legado.app.utils.getPrefFloat
 import io.legado.app.utils.getPrefInt
@@ -26,15 +25,12 @@ import io.legado.app.utils.putPrefLong
 import io.legado.app.utils.putPrefString
 import io.legado.app.utils.putPrefStringSync
 import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Job
-import kotlinx.coroutines.flow.catch
 import kotlinx.coroutines.flow.distinctUntilChanged
-import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.launch
-import kotlinx.coroutines.runBlocking
 import splitties.init.appCtx
-import java.io.IOException
 import kotlin.properties.ReadWriteProperty
 import kotlin.reflect.KProperty
 
@@ -60,44 +56,19 @@ fun <T> prefDelegate(
 
         @Volatile
         private var currentValue: T = defaultValue
-        private val scope = CoroutineScope(kotlinx.coroutines.Dispatchers.IO)
-
-        // 单并发调度器保证同一 key 的 DataStore 写入按提交顺序落盘
-        @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
-        private val dsWriteDispatcher = kotlinx.coroutines.Dispatchers.IO.limitedParallelism(1)
+        private val scope = CoroutineScope(Dispatchers.IO)
         private var dsObserverJob: Job? = null
 
         init {
-            // 同步从 DataStore 读取初始值,确保构造完成后即为最新值
-            val initialValue = runBlocking { readFromDs() } ?: defaultValue
+            // 初值走门面:AppConfigStore 内存快照,DS 缺值时门面内回退 SP 并补写
+            val initialValue = readInitial()
             _value = mutableStateOf(initialValue)
             currentValue = initialValue
 
-            // 观察 DataStore 变化,用于跨实例同步
+            // 观察快照变化,用于跨实例同步(含未落盘的 pending 写入,写后立即可见)
             dsObserverJob = scope.launch {
-                appCtx.dataStore.data
-                    .catch { if (it is IOException) emit(emptyPreferences()) else throw it }
-                    .map { prefs ->
-                        val strVal = runCatching { prefs[stringPreferencesKey(key)] }.getOrNull()
-                        @Suppress("UNCHECKED_CAST")
-                        when {
-                            defaultValue is String || defaultValue == null ->
-                                strVal as T?
-                            defaultValue is Int ->
-                                (runCatching { prefs[intPreferencesKey(key)] }.getOrNull()
-                                    ?: strVal?.toIntOrNull()) as T?
-                            defaultValue is Boolean ->
-                                (runCatching { prefs[booleanPreferencesKey(key)] }.getOrNull()
-                                    ?: strVal?.toBooleanStrictOrNull()) as T?
-                            defaultValue is Long ->
-                                (runCatching { prefs[longPreferencesKey(key)] }.getOrNull()
-                                    ?: strVal?.toLongOrNull()) as T?
-                            defaultValue is Float ->
-                                (runCatching { prefs[floatPreferencesKey(key)] }.getOrNull()
-                                    ?: strVal?.toFloatOrNull()) as T?
-                            else -> null
-                        }
-                    }
+                AppConfigStore.preferencesFlow
+                    .map { prefs -> readTyped(prefs) }
                     .distinctUntilChanged()
                     .collect { dsValue ->
                         if (dsValue != null && currentValue != dsValue) {
@@ -127,7 +98,7 @@ fun <T> prefDelegate(
         override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
             if (currentValue != value) {
                 updateValue(value)
-                // 同步写入 SP(向后兼容:AppConfig/MainViewModel 等仍直接读 SP)
+                // 门面双写:SP(向后兼容,未迁移代码仍直接读 SP/靠 SP 监听)+ 快照/DataStore
                 when (value) {
                     is String? -> if (sync) appCtx.putPrefStringSync(key, value) else appCtx.putPrefString(key, value)
                     is Int -> appCtx.putPrefInt(key, value)
@@ -135,84 +106,29 @@ fun <T> prefDelegate(
                     is Long -> appCtx.putPrefLong(key, value)
                     is Float -> appCtx.putPrefFloat(key, value)
                 }
-                // 异步串行写入 DataStore;runBlocking 会把整文件重写 + fsync
-                // 挂在调用线程(通常是主线程的点击回调)上,造成明显掉帧
-                scope.launch(dsWriteDispatcher) {
-                    runCatching {
-                        when (value) {
-                            is String? -> DsSync.putString(key, value)
-                            is Int -> DsSync.putInt(key, value)
-                            is Boolean -> DsSync.putBoolean(key, value)
-                            is Long -> DsSync.putLong(key, value)
-                            is Float -> DsSync.putFloat(key, value)
-                        }
-                    }
-                }
                 onValueChange?.invoke(value)
             }
         }
 
-        /**
-         * 从 DataStore 读取当前值,DS 读不到时回退到 SP 并补写入 DS。
-         */
         @Suppress("UNCHECKED_CAST")
-        private suspend fun readFromDs(): T? {
-            val dsValue = try {
-                appCtx.dataStore.data
-                    .catch { if (it is IOException) emit(emptyPreferences()) else throw it }
-                    .map { prefs ->
-                        val strVal = runCatching { prefs[stringPreferencesKey(key)] }.getOrNull()
-                        when {
-                            defaultValue is String || defaultValue == null ->
-                                strVal as T?
-                            defaultValue is Int ->
-                                (runCatching { prefs[intPreferencesKey(key)] }.getOrNull()
-                                    ?: strVal?.toIntOrNull()) as T?
-                            defaultValue is Boolean ->
-                                (runCatching { prefs[booleanPreferencesKey(key)] }.getOrNull()
-                                    ?: strVal?.toBooleanStrictOrNull()) as T?
-                            defaultValue is Long ->
-                                (runCatching { prefs[longPreferencesKey(key)] }.getOrNull()
-                                    ?: strVal?.toLongOrNull()) as T?
-                            defaultValue is Float ->
-                                (runCatching { prefs[floatPreferencesKey(key)] }.getOrNull()
-                                    ?: strVal?.toFloatOrNull()) as T?
-                            else -> null
-                        }
-                    }
-                    .first()
-            } catch (e: Exception) {
-                null
-            }
-            // DS 有值,直接返回
-            if (dsValue != null) return dsValue
-            // DS 无值,回退到 SP(迁移遗漏时的补偿)
-            val spValue: T? = when {
-                defaultValue is String || defaultValue == null ->
-                    appCtx.getPrefString(key, defaultValue as String?) as T?
-                defaultValue is Int ->
-                    appCtx.getPrefInt(key, defaultValue) as T
-                defaultValue is Boolean ->
-                    appCtx.getPrefBoolean(key, defaultValue) as T
-                defaultValue is Long ->
-                    appCtx.getPrefLong(key, defaultValue) as T
-                defaultValue is Float ->
-                    appCtx.getPrefFloat(key, defaultValue) as T
-                else -> null
-            }
-            // DS 无值时,将 SP 值补写入 DS(修复迁移遗漏)
-            if (spValue != null) {
-                runCatching {
-                    when (spValue) {
-                        is String? -> DsSync.putString(key, spValue)
-                        is Int -> DsSync.putInt(key, spValue)
-                        is Boolean -> DsSync.putBoolean(key, spValue)
-                        is Long -> DsSync.putLong(key, spValue)
-                        is Float -> DsSync.putFloat(key, spValue)
-                    }
-                }
-            }
-            return spValue
+        private fun readInitial(): T = when {
+            defaultValue is String || defaultValue == null ->
+                appCtx.getPrefString(key, defaultValue as String?) as T
+            defaultValue is Int -> appCtx.getPrefInt(key, defaultValue) as T
+            defaultValue is Boolean -> appCtx.getPrefBoolean(key, defaultValue) as T
+            defaultValue is Long -> appCtx.getPrefLong(key, defaultValue) as T
+            defaultValue is Float -> appCtx.getPrefFloat(key, defaultValue) as T
+            else -> defaultValue
+        }
+
+        @Suppress("UNCHECKED_CAST")
+        private fun readTyped(prefs: Preferences): T? = when {
+            defaultValue is String || defaultValue == null -> prefs.compatDsString(key) as T?
+            defaultValue is Int -> prefs.compatDsInt(key) as T?
+            defaultValue is Boolean -> prefs.compatDsBoolean(key) as T?
+            defaultValue is Long -> prefs.compatDsLong(key) as T?
+            defaultValue is Float -> prefs.compatDsFloat(key) as T?
+            else -> null
         }
 
         private fun updateValue(value: T) {

+ 82 - 23
app/src/main/java/io/legado/app/utils/ContextExtensions.kt

@@ -42,6 +42,7 @@ import io.legado.app.R
 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.book.isAudio
 import io.legado.app.help.book.isImage
 import io.legado.app.help.book.isLocal
@@ -185,63 +186,121 @@ fun Context.startForegroundServiceCompat(intent: Intent) {
 val Context.defaultSharedPreferences: SharedPreferences
     get() = PreferenceManager.getDefaultSharedPreferences(this)
 
-fun Context.getPrefBoolean(key: String, defValue: Boolean = false) =
-    defaultSharedPreferences.getBoolean(key, defValue)
+// getPref*/putPref* 门面:读取走 AppConfigStore 内存快照(DataStore),
+// DS 缺值时回退 SP 并补写 DS(迁移遗漏补偿,过渡期逻辑,后续删除);
+// 写入仍保持 SP 双写(SP 监听器与直读 SP 的代码尚未迁移完),同时进快照保证写后立即读一致。
+// AppConfigStore 未初始化时(App.onCreate 之前,如 ContentProvider)直接走 SP 旧路径。
+
+fun Context.getPrefBoolean(key: String, defValue: Boolean = false): Boolean {
+    if (!AppConfigStore.isInitialized) return defaultSharedPreferences.getBoolean(key, defValue)
+    AppConfigStore.getBoolean(key)?.let { return it }
+    if (!defaultSharedPreferences.contains(key)) return defValue
+    val value = defaultSharedPreferences.getBoolean(key, defValue)
+    AppConfigStore.putBoolean(key, value)
+    return value
+}
 
-fun Context.putPrefBoolean(key: String, value: Boolean = false) =
+fun Context.putPrefBoolean(key: String, value: Boolean = false) {
     defaultSharedPreferences.edit { putBoolean(key, value) }
+    if (AppConfigStore.isInitialized) AppConfigStore.putBoolean(key, value)
+}
 
-fun Context.getPrefInt(key: String, defValue: Int = 0) =
-    defaultSharedPreferences.getInt(key, defValue)
+fun Context.getPrefInt(key: String, defValue: Int = 0): Int {
+    if (!AppConfigStore.isInitialized) return defaultSharedPreferences.getInt(key, defValue)
+    AppConfigStore.getInt(key)?.let { return it }
+    if (!defaultSharedPreferences.contains(key)) return defValue
+    val value = defaultSharedPreferences.getInt(key, defValue)
+    AppConfigStore.putInt(key, value)
+    return value
+}
 
-fun Context.putPrefInt(key: String, value: Int) =
+fun Context.putPrefInt(key: String, value: Int) {
     defaultSharedPreferences.edit { putInt(key, value) }
+    if (AppConfigStore.isInitialized) AppConfigStore.putInt(key, value)
+}
 
 fun Context.getPrefLong(key: String, defValue: Long = 0L): Long {
-    return try {
+    if (AppConfigStore.isInitialized) {
+        AppConfigStore.getLong(key)?.let { return it }
+        if (!defaultSharedPreferences.contains(key)) return defValue
+    }
+    val value = try {
         defaultSharedPreferences.getLong(key, defValue)
     } catch (e: ClassCastException) {
-        val value = defaultSharedPreferences.getInt(key, defValue.toInt()).toLong()
-        defaultSharedPreferences.edit { putLong(key, value) }
-        value
+        val fixed = defaultSharedPreferences.getInt(key, defValue.toInt()).toLong()
+        defaultSharedPreferences.edit { putLong(key, fixed) }
+        fixed
     }
+    if (AppConfigStore.isInitialized) AppConfigStore.putLong(key, value)
+    return value
 }
 
-fun Context.putPrefLong(key: String, value: Long) =
+fun Context.putPrefLong(key: String, value: Long) {
     defaultSharedPreferences.edit { putLong(key, value) }
+    if (AppConfigStore.isInitialized) AppConfigStore.putLong(key, value)
+}
 
 fun Context.getPrefFloat(key: String, defValue: Float = 0f): Float {
-    return try {
+    if (AppConfigStore.isInitialized) {
+        AppConfigStore.getFloat(key)?.let { return it }
+        if (!defaultSharedPreferences.contains(key)) return defValue
+    }
+    val value = try {
         defaultSharedPreferences.getFloat(key, defValue)
     } catch (e: ClassCastException) {
-        val value = defaultSharedPreferences.getInt(key, defValue.toInt()).toFloat()
-        defaultSharedPreferences.edit { putFloat(key, value) }
-        value
+        val fixed = defaultSharedPreferences.getInt(key, defValue.toInt()).toFloat()
+        defaultSharedPreferences.edit { putFloat(key, fixed) }
+        fixed
     }
+    if (AppConfigStore.isInitialized) AppConfigStore.putFloat(key, value)
+    return value
 }
 
-fun Context.putPrefFloat(key: String, value: Float) =
+fun Context.putPrefFloat(key: String, value: Float) {
     defaultSharedPreferences.edit { putFloat(key, value) }
+    if (AppConfigStore.isInitialized) AppConfigStore.putFloat(key, value)
+}
 
-fun Context.getPrefString(key: String, defValue: String? = null) =
-    defaultSharedPreferences.getString(key, defValue)
+fun Context.getPrefString(key: String, defValue: String? = null): String? {
+    if (!AppConfigStore.isInitialized) return defaultSharedPreferences.getString(key, defValue)
+    AppConfigStore.getString(key)?.let { return it }
+    if (!defaultSharedPreferences.contains(key)) return defValue
+    val value = defaultSharedPreferences.getString(key, defValue)
+    if (value != null) AppConfigStore.putString(key, value)
+    return value
+}
 
-fun Context.putPrefString(key: String, value: String?) =
+fun Context.putPrefString(key: String, value: String?) {
     defaultSharedPreferences.edit { putString(key, value) }
+    if (AppConfigStore.isInitialized) AppConfigStore.putString(key, value)
+}
 
-fun Context.putPrefStringSync(key: String, value: String?) =
+fun Context.putPrefStringSync(key: String, value: String?) {
     defaultSharedPreferences.edit(commit = true) { putString(key, value) }
+    if (AppConfigStore.isInitialized) AppConfigStore.putString(key, value)
+}
 
 fun Context.getPrefStringSet(
     key: String,
     defValue: MutableSet<String>? = null,
-): MutableSet<String>? = defaultSharedPreferences.getStringSet(key, defValue)
+): MutableSet<String>? {
+    if (!AppConfigStore.isInitialized) return defaultSharedPreferences.getStringSet(key, defValue)
+    AppConfigStore.getStringSet(key)?.let { return it.toMutableSet() }
+    if (!defaultSharedPreferences.contains(key)) return defValue
+    val value = defaultSharedPreferences.getStringSet(key, defValue)
+    if (value != null) AppConfigStore.putStringSet(key, value.toSet())
+    return value
+}
 
-fun Context.putPrefStringSet(key: String, value: MutableSet<String>) =
+fun Context.putPrefStringSet(key: String, value: MutableSet<String>) {
     defaultSharedPreferences.edit { putStringSet(key, value) }
+    if (AppConfigStore.isInitialized) AppConfigStore.putStringSet(key, value.toSet())
+}
 
-fun Context.removePref(key: String) =
+fun Context.removePref(key: String) {
     defaultSharedPreferences.edit { remove(key) }
+    if (AppConfigStore.isInitialized) AppConfigStore.remove(key)
+}
 
 
 fun Context.getCompatColor(@ColorRes id: Int): Int = ContextCompat.getColor(this, id)

+ 13 - 12
app/src/main/java/io/legado/app/utils/FragmentExtensions.kt

@@ -9,7 +9,6 @@ import android.graphics.drawable.Drawable
 import android.os.Bundle
 import androidx.annotation.ColorRes
 import androidx.annotation.DrawableRes
-import androidx.core.content.edit
 import androidx.fragment.app.DialogFragment
 import androidx.fragment.app.Fragment
 import androidx.lifecycle.Lifecycle
@@ -39,41 +38,43 @@ fun Fragment.showDialogFragment(dialogFragment: DialogFragment) {
     dialogFragment.show(childFragmentManager, dialogFragment::class.simpleName)
 }
 
+// 统一委托给 Context 门面(读快照、写双写),避免两套实现漂移
+
 fun Fragment.getPrefBoolean(key: String, defValue: Boolean = false) =
-    requireContext().defaultSharedPreferences.getBoolean(key, defValue)
+    requireContext().getPrefBoolean(key, defValue)
 
 fun Fragment.putPrefBoolean(key: String, value: Boolean = false) =
-    requireContext().defaultSharedPreferences.edit { putBoolean(key, value) }
+    requireContext().putPrefBoolean(key, value)
 
 fun Fragment.getPrefInt(key: String, defValue: Int = 0) =
-    requireContext().defaultSharedPreferences.getInt(key, defValue)
+    requireContext().getPrefInt(key, defValue)
 
 fun Fragment.putPrefInt(key: String, value: Int) =
-    requireContext().defaultSharedPreferences.edit { putInt(key, value) }
+    requireContext().putPrefInt(key, value)
 
 fun Fragment.getPrefLong(key: String, defValue: Long = 0L) =
-    requireContext().defaultSharedPreferences.getLong(key, defValue)
+    requireContext().getPrefLong(key, defValue)
 
 fun Fragment.putPrefLong(key: String, value: Long) =
-    requireContext().defaultSharedPreferences.edit { putLong(key, value) }
+    requireContext().putPrefLong(key, value)
 
 fun Fragment.getPrefString(key: String, defValue: String? = null) =
-    requireContext().defaultSharedPreferences.getString(key, defValue)
+    requireContext().getPrefString(key, defValue)
 
 fun Fragment.putPrefString(key: String, value: String) =
-    requireContext().defaultSharedPreferences.edit { putString(key, value) }
+    requireContext().putPrefString(key, value)
 
 fun Fragment.getPrefStringSet(
     key: String,
     defValue: MutableSet<String>? = null
 ): MutableSet<String>? =
-    requireContext().defaultSharedPreferences.getStringSet(key, defValue)
+    requireContext().getPrefStringSet(key, defValue)
 
 fun Fragment.putPrefStringSet(key: String, value: MutableSet<String>) =
-    requireContext().defaultSharedPreferences.edit { putStringSet(key, value) }
+    requireContext().putPrefStringSet(key, value)
 
 fun Fragment.removePref(key: String) =
-    requireContext().defaultSharedPreferences.edit { remove(key) }
+    requireContext().removePref(key)
 
 fun Fragment.getCompatColor(@ColorRes id: Int): Int = requireContext().getCompatColor(id)
 

+ 115 - 0
app/src/test/java/io/legado/app/help/config/PendingOverlayCoreTest.kt

@@ -0,0 +1,115 @@
+package io.legado.app.help.config
+
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.intPreferencesKey
+import androidx.datastore.preferences.core.mutablePreferencesOf
+import androidx.datastore.preferences.core.stringPreferencesKey
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+/**
+ * 覆盖 PR-1 合入门禁要求的 pending overlay 三个竞态时序:
+ * 1. 写入后立即读;2. 写入中 collector 回灌旧值;3. 落盘完成后回灌新值。
+ */
+class PendingOverlayCoreTest {
+
+    /** 模拟的 DataStore 落盘状态 + 可手动执行的写队列 */
+    private class Harness(initial: Preferences) {
+        var dsState: Preferences = initial
+        val writeQueue = ArrayDeque<suspend () -> Unit>()
+        val core = PendingOverlayCore(
+            initial = initial,
+            launchWrite = { writeQueue += it },
+            persist = { key, value ->
+                val mutable = dsState.toMutablePreferences()
+                mutable.setPrefValue(key, value)
+                dsState = mutable.toPreferences()
+                dsState
+            },
+        )
+
+        /** 执行队列中下一个落盘任务(模拟 DsSync 串行队列跑完一个 Job) */
+        fun completeNextWrite() = runBlocking { writeQueue.removeFirst().invoke() }
+
+        fun read(key: String): Int? = core.preferencesFlow.value.compatDsInt(key)
+    }
+
+    @Test
+    fun `写入后立即读到新值-落盘尚未发生`() {
+        val h = Harness(mutablePreferencesOf(intPreferencesKey("k") to 1))
+        h.core.put("k", 2)
+        assertEquals(2, h.read("k"))
+        assertEquals(1, h.writeQueue.size)
+    }
+
+    @Test
+    fun `写入中 collector 回灌旧值不覆盖未落盘的新值`() {
+        val h = Harness(mutablePreferencesOf(intPreferencesKey("k") to 1))
+        h.core.put("k", 2)
+        // 落盘未完成时,collector 携带旧状态回灌
+        h.core.onEmission(mutablePreferencesOf(intPreferencesKey("k") to 1))
+        assertEquals(2, h.read("k"))
+        // 其他 key 的回灌内容仍然生效
+        h.core.onEmission(
+            mutablePreferencesOf(
+                intPreferencesKey("k") to 1,
+                intPreferencesKey("other") to 9,
+            )
+        )
+        assertEquals(2, h.read("k"))
+        assertEquals(9, h.read("other"))
+    }
+
+    @Test
+    fun `落盘完成后回灌新值-读取保持新值且 overlay 已清空`() {
+        val h = Harness(mutablePreferencesOf(intPreferencesKey("k") to 1))
+        h.core.put("k", 2)
+        h.completeNextWrite()
+        // 落盘完成即采纳 edit 结果,读取已是新值
+        assertEquals(2, h.read("k"))
+        // collector 随后携带落盘后的新状态回灌
+        h.core.onEmission(h.dsState)
+        assertEquals(2, h.read("k"))
+        // overlay 已清空:此后外部写入(如 Restore)的回灌可正常覆盖
+        h.core.onEmission(mutablePreferencesOf(intPreferencesKey("k") to 5))
+        assertEquals(5, h.read("k"))
+    }
+
+    @Test
+    fun `同 key 连续写入-旧写入落盘不移除新写入的 overlay`() {
+        val h = Harness(mutablePreferencesOf())
+        h.core.put("k", 1)
+        h.core.put("k", 2)
+        assertEquals(2, h.read("k"))
+        // 第一次写入落盘完成,读取仍是第二次写入的值
+        h.completeNextWrite()
+        assertEquals(2, h.read("k"))
+        // 第二次写入落盘完成
+        h.completeNextWrite()
+        assertEquals(2, h.read("k"))
+        assertEquals(2, h.dsState.compatDsInt("k"))
+    }
+
+    @Test
+    fun `移除写入立即隐藏快照中的旧值`() {
+        val h = Harness(mutablePreferencesOf(stringPreferencesKey("k") to "v"))
+        h.core.put("k", null)
+        assertNull(h.core.preferencesFlow.value.compatDsString("k"))
+        h.completeNextWrite()
+        assertNull(h.dsState.rawPrefValue("k"))
+    }
+
+    @Test
+    fun `落盘顺序与提交顺序一致`() {
+        val h = Harness(mutablePreferencesOf())
+        h.core.put("a", 1)
+        h.core.put("b", 2)
+        h.core.put("a", 3)
+        while (h.writeQueue.isNotEmpty()) h.completeNextWrite()
+        assertEquals(3, h.dsState.compatDsInt("a"))
+        assertEquals(2, h.dsState.compatDsInt("b"))
+        assertEquals(h.dsState, h.core.preferencesFlow.value)
+    }
+}

+ 106 - 0
app/src/test/java/io/legado/app/help/config/PreferencesDsCompatTest.kt

@@ -0,0 +1,106 @@
+package io.legado.app.help.config
+
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.floatPreferencesKey
+import androidx.datastore.preferences.core.intPreferencesKey
+import androidx.datastore.preferences.core.longPreferencesKey
+import androidx.datastore.preferences.core.mutablePreferencesOf
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.core.stringSetPreferencesKey
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class PreferencesDsCompatTest {
+
+    @Test
+    fun `按目标类型直接读取`() {
+        val prefs = mutablePreferencesOf(
+            intPreferencesKey("i") to 5,
+            booleanPreferencesKey("b") to true,
+            longPreferencesKey("l") to 7L,
+            floatPreferencesKey("f") to 1.5f,
+            stringPreferencesKey("s") to "hello",
+            stringSetPreferencesKey("set") to setOf("a", "b"),
+        )
+        assertEquals(5, prefs.compatDsInt("i"))
+        assertEquals(true, prefs.compatDsBoolean("b"))
+        assertEquals(7L, prefs.compatDsLong("l"))
+        assertEquals(1.5f, prefs.compatDsFloat("f"))
+        assertEquals("hello", prefs.compatDsString("s"))
+        assertEquals(setOf("a", "b"), prefs.compatDsStringSet("set"))
+    }
+
+    @Test
+    fun `string 形式的历史值可解析为目标类型`() {
+        val prefs = mutablePreferencesOf(
+            stringPreferencesKey("i") to "5",
+            stringPreferencesKey("b") to "true",
+            stringPreferencesKey("l") to "7",
+            stringPreferencesKey("f") to "1.5",
+        )
+        assertEquals(5, prefs.compatDsInt("i"))
+        assertEquals(true, prefs.compatDsBoolean("b"))
+        assertEquals(7L, prefs.compatDsLong("l"))
+        assertEquals(1.5f, prefs.compatDsFloat("f"))
+    }
+
+    @Test
+    fun `int 存储的值可作为 long 与 float 读取`() {
+        val prefs = mutablePreferencesOf(intPreferencesKey("k") to 3)
+        assertEquals(3L, prefs.compatDsLong("k"))
+        assertEquals(3f, prefs.compatDsFloat("k"))
+    }
+
+    @Test
+    fun `缺失或无法解析的值返回 null 而不抛异常`() {
+        val prefs = mutablePreferencesOf(
+            stringPreferencesKey("garbage") to "not a number",
+            intPreferencesKey("wrongType") to 1,
+        )
+        assertNull(prefs.compatDsInt("absent"))
+        assertNull(prefs.compatDsInt("garbage"))
+        assertNull(prefs.compatDsBoolean("garbage"))
+        assertNull(prefs.compatDsBoolean("wrongType"))
+        assertNull(prefs.compatDsString("wrongType"))
+        assertNull(prefs.compatDsStringSet("garbage"))
+    }
+
+    @Test
+    fun `boolean 解析大小写严格`() {
+        val prefs = mutablePreferencesOf(stringPreferencesKey("b") to "True")
+        assertNull(prefs.compatDsBoolean("b"))
+    }
+
+    @Test
+    fun `setPrefValue 覆盖同名旧类型条目`() {
+        val prefs = mutablePreferencesOf(stringPreferencesKey("k") to "1")
+        prefs.setPrefValue("k", 2)
+        assertEquals(2, prefs.compatDsInt("k"))
+        assertNull(prefs.compatDsString("k"))
+    }
+
+    @Test
+    fun `setPrefValue null 移除任意类型条目`() {
+        val prefs = mutablePreferencesOf(intPreferencesKey("k") to 1)
+        prefs.setPrefValue("k", null)
+        assertNull(prefs.rawPrefValue("k"))
+    }
+
+    @Test
+    fun `setPrefValue 写入各类型`() {
+        val prefs = mutablePreferencesOf()
+        prefs.setPrefValue("s", "v")
+        prefs.setPrefValue("i", 1)
+        prefs.setPrefValue("b", true)
+        prefs.setPrefValue("l", 2L)
+        prefs.setPrefValue("f", 3f)
+        prefs.setPrefValue("set", setOf("x"))
+        assertEquals("v", prefs.compatDsString("s"))
+        assertEquals(1, prefs.compatDsInt("i"))
+        assertEquals(true, prefs.compatDsBoolean("b"))
+        assertEquals(2L, prefs.compatDsLong("l"))
+        assertEquals(3f, prefs.compatDsFloat("f"))
+        assertEquals(setOf("x"), prefs.compatDsStringSet("set"))
+    }
+}