refactor: SP/DS 统一迁移 — PrefDelegate 双写 + DS 观察者 + 备份从 DS 读取

Phase 1: PrefDelegate.setValue 写入 SP 后异步同步到 DataStore (DsSync)
Phase 2: PrefDelegate.init 启动 DS 观察者,以 DS 为读取权威源,含类型回退
Phase 3: SettingsRepository 移除 syncToSharedPrefs,DS 为唯一写入源
Phase 4: Backup.kt 从 DataStore 读取配置(替代 defaultSharedPreferences)

修复:
- postMigrationSync 读取 DS String key 时 ClassCastException (runCatching)
- DS 观察者类型回退: typed key 读不到时从 stringPreferencesKey 转换
- DS 观察者安全读取: runCatching 避免类型不匹配崩溃
This commit is contained in:
HapeLee
2026-06-08 01:21:50 +08:00
parent d43cf79274
commit 2d4372f968
4 changed files with 107 additions and 33 deletions
@@ -8,12 +8,6 @@ import androidx.datastore.preferences.core.*
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import io.legado.app.constant.PreferKey import io.legado.app.constant.PreferKey
import io.legado.app.utils.defaultSharedPreferences import io.legado.app.utils.defaultSharedPreferences
import io.legado.app.utils.putPrefBoolean
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.putPrefStringSet
import io.legado.app.utils.removePref import io.legado.app.utils.removePref
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
@@ -35,9 +29,8 @@ val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
/** /**
* 设置仓储 * 设置仓储
* 采用 DataStore + SharedPreferences 桥接模式 * DataStore 为唯一写入源,读取以 DataStore 为准。
* 写入时同时写入两者,读取时以 DataStore 为准 * SP 同步由 PrefDelegate 双写保证(阶段 1),此处不再回写 SP。
* 这样可以保持现有的基于 SP 的备份恢复功能正常工作
*/ */
class SettingsRepository(private val context: Context) { class SettingsRepository(private val context: Context) {
@@ -58,27 +51,9 @@ class SettingsRepository(private val context: Context) {
} }
suspend fun <T> updatePreference(key: Preferences.Key<T>, value: T) { suspend fun <T> updatePreference(key: Preferences.Key<T>, value: T) {
// 1. 写入 DataStore (UI 响应式来源)
dataStore.edit { preferences -> dataStore.edit { preferences ->
preferences[key] = value preferences[key] = value
} }
// 2. 桥接:写回 SharedPreferences (保证备份功能正常)
syncToSharedPrefs(key.name, value)
}
private fun syncToSharedPrefs(key: String, value: Any?) {
when (value) {
is String -> context.putPrefString(key, value)
is Int -> context.putPrefInt(key, value)
is Boolean -> context.putPrefBoolean(key, value)
is Long -> context.putPrefLong(key, value)
is Float -> context.putPrefFloat(key, value)
is Set<*> -> {
@Suppress("UNCHECKED_CAST")
context.putPrefStringSet(key, value as MutableSet<String>)
}
null -> context.removePref(key)
}
} }
// String 类型的快捷访问 // String 类型的快捷访问
@@ -94,9 +69,6 @@ class SettingsRepository(private val context: Context) {
preferences[stringPreferencesKey(key)] = value preferences[stringPreferencesKey(key)] = value
} }
} }
values.forEach { (key, value) ->
syncToSharedPrefs(key, value)
}
} }
// Int 类型的快捷访问 // Int 类型的快捷访问
@@ -176,7 +148,7 @@ class SettingsRepository(private val context: Context) {
val editor = mutableMapOf<String, String>() val editor = mutableMapOf<String, String>()
for (key in themeKeys) { for (key in themeKeys) {
if (!sp.contains(key)) { if (!sp.contains(key)) {
val dsValue = prefs[stringPreferencesKey(key)] val dsValue = runCatching { prefs[stringPreferencesKey(key)] }.getOrNull()
if (dsValue != null) { if (dsValue != null) {
editor[key] = dsValue editor[key] = dsValue
needsWrite = true needsWrite = true
@@ -0,0 +1,42 @@
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 splitties.init.appCtx
/**
* DataStore 写入辅助对象。
*
* 让 [io.legado.app.ui.config.PrefDelegate] 在写入 SP 的同时同步写入 DataStore
* 保证两边数据一致,避免 DataStore 迁移/读取时出现值缺失。
*/
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 }
}
}
@@ -22,8 +22,9 @@ import io.legado.app.utils.GSON
import io.legado.app.utils.LogUtils import io.legado.app.utils.LogUtils
import io.legado.app.utils.compress.ZipUtils import io.legado.app.utils.compress.ZipUtils
import io.legado.app.utils.createFolderIfNotExist import io.legado.app.utils.createFolderIfNotExist
import io.legado.app.utils.defaultSharedPreferences import io.legado.app.data.repository.dataStore
import io.legado.app.utils.externalFiles import io.legado.app.utils.externalFiles
import kotlinx.coroutines.flow.first
import io.legado.app.utils.getFile import io.legado.app.utils.getFile
import io.legado.app.utils.isContentScheme import io.legado.app.utils.isContentScheme
import io.legado.app.utils.normalizeFileName import io.legado.app.utils.normalizeFileName
@@ -177,7 +178,9 @@ object Backup {
.writeText(GSON.toJson(it)) .writeText(GSON.toJson(it))
} }
currentCoroutineContext().ensureActive() currentCoroutineContext().ensureActive()
val configMap = appCtx.defaultSharedPreferences.all val configMap = appCtx.dataStore.data.first()
.asMap()
.mapKeys { it.key.name }
val xmlBuilder = StringBuilder() val xmlBuilder = StringBuilder()
xmlBuilder.append("<?xml version='1.0' encoding='utf-8' standalone='yes' ?>\n") xmlBuilder.append("<?xml version='1.0' encoding='utf-8' standalone='yes' ?>\n")
xmlBuilder.append("<map>\n") xmlBuilder.append("<map>\n")
@@ -18,7 +18,18 @@ import io.legado.app.utils.putPrefInt
import io.legado.app.utils.putPrefLong import io.legado.app.utils.putPrefLong
import io.legado.app.utils.putPrefString import io.legado.app.utils.putPrefString
import io.legado.app.utils.putPrefStringSync import io.legado.app.utils.putPrefStringSync
import io.legado.app.help.config.DsSync
import androidx.datastore.preferences.core.*
import io.legado.app.data.repository.dataStore
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.map
import kotlinx.coroutines.launch
import splitties.init.appCtx import splitties.init.appCtx
import java.io.IOException
import kotlin.properties.ReadWriteProperty import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty import kotlin.reflect.KProperty
@@ -41,6 +52,7 @@ fun <T> prefDelegate(
return object : PrefDelegate<T>, SharedPreferences.OnSharedPreferenceChangeListener, DefaultLifecycleObserver { return object : PrefDelegate<T>, SharedPreferences.OnSharedPreferenceChangeListener, DefaultLifecycleObserver {
private var _value: MutableState<T> = mutableStateOf(readInitialValue()) private var _value: MutableState<T> = mutableStateOf(readInitialValue())
override val state: State<T> get() = _value override val state: State<T> get() = _value
private var dsObserverJob: Job? = null
init { init {
if (lifecycleOwner != null) { if (lifecycleOwner != null) {
@@ -48,6 +60,40 @@ fun <T> prefDelegate(
} else { } else {
appCtx.defaultSharedPreferences.registerOnSharedPreferenceChangeListener(this) appCtx.defaultSharedPreferences.registerOnSharedPreferenceChangeListener(this)
} }
// 观察 DataStore,以 DS 为读取权威源
dsObserverJob = CoroutineScope(Dispatchers.IO).launch {
appCtx.dataStore.data
.catch { if (it is IOException) emit(emptyPreferences()) else throw it }
.map { prefs ->
// 安全读取 String 值,用于类型回退(类型不匹配时返回 null)
val strVal = runCatching { prefs[stringPreferencesKey(key)] }.getOrNull()
@Suppress("UNCHECKED_CAST")
when {
defaultValue is String || defaultValue == null ->
strVal as T?
defaultValue is Int ->
(prefs[intPreferencesKey(key)]
?: strVal?.toIntOrNull()) as T?
defaultValue is Boolean ->
(prefs[booleanPreferencesKey(key)]
?: strVal?.toBooleanStrictOrNull()) as T?
defaultValue is Long ->
(prefs[longPreferencesKey(key)]
?: strVal?.toLongOrNull()) as T?
defaultValue is Float ->
(prefs[floatPreferencesKey(key)]
?: strVal?.toFloatOrNull()) as T?
else -> null
}
}
.distinctUntilChanged()
.collect { dsValue ->
if (dsValue != null && _value.value != dsValue) {
_value.value = dsValue
onValueChange?.invoke(dsValue)
}
}
}
} }
override fun onCreate(owner: LifecycleOwner) { override fun onCreate(owner: LifecycleOwner) {
@@ -60,6 +106,7 @@ fun <T> prefDelegate(
override fun dispose() { override fun dispose() {
appCtx.defaultSharedPreferences.unregisterOnSharedPreferenceChangeListener(this) appCtx.defaultSharedPreferences.unregisterOnSharedPreferenceChangeListener(this)
dsObserverJob?.cancel()
} }
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
@@ -92,6 +139,16 @@ fun <T> prefDelegate(
is Long -> appCtx.putPrefLong(key, value) is Long -> appCtx.putPrefLong(key, value)
is Float -> appCtx.putPrefFloat(key, value) is Float -> appCtx.putPrefFloat(key, value)
} }
// 同步写入 DataStore,保持 SP/DS 一致
CoroutineScope(Dispatchers.IO).launch {
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)
}
}
_value.value = value _value.value = value
onValueChange?.invoke(value) onValueChange?.invoke(value)
} }