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 io.legado.app.constant.PreferKey
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 kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
@@ -35,9 +29,8 @@ val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
/**
* 设置仓储
* 采用 DataStore + SharedPreferences 桥接模式
* 写入时同时写入两者,读取时以 DataStore 为准
* 这样可以保持现有的基于 SP 的备份恢复功能正常工作
* DataStore 为唯一写入源,读取以 DataStore 为准。
* SP 同步由 PrefDelegate 双写保证(阶段 1),此处不再回写 SP。
*/
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) {
// 1. 写入 DataStore (UI 响应式来源)
dataStore.edit { preferences ->
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 类型的快捷访问
@@ -94,9 +69,6 @@ class SettingsRepository(private val context: Context) {
preferences[stringPreferencesKey(key)] = value
}
}
values.forEach { (key, value) ->
syncToSharedPrefs(key, value)
}
}
// Int 类型的快捷访问
@@ -176,7 +148,7 @@ class SettingsRepository(private val context: Context) {
val editor = mutableMapOf<String, String>()
for (key in themeKeys) {
if (!sp.contains(key)) {
val dsValue = prefs[stringPreferencesKey(key)]
val dsValue = runCatching { prefs[stringPreferencesKey(key)] }.getOrNull()
if (dsValue != null) {
editor[key] = dsValue
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.compress.ZipUtils
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 kotlinx.coroutines.flow.first
import io.legado.app.utils.getFile
import io.legado.app.utils.isContentScheme
import io.legado.app.utils.normalizeFileName
@@ -177,7 +178,9 @@ object Backup {
.writeText(GSON.toJson(it))
}
currentCoroutineContext().ensureActive()
val configMap = appCtx.defaultSharedPreferences.all
val configMap = appCtx.dataStore.data.first()
.asMap()
.mapKeys { it.key.name }
val xmlBuilder = StringBuilder()
xmlBuilder.append("<?xml version='1.0' encoding='utf-8' standalone='yes' ?>\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.putPrefString
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 java.io.IOException
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
@@ -41,6 +52,7 @@ fun <T> prefDelegate(
return object : PrefDelegate<T>, SharedPreferences.OnSharedPreferenceChangeListener, DefaultLifecycleObserver {
private var _value: MutableState<T> = mutableStateOf(readInitialValue())
override val state: State<T> get() = _value
private var dsObserverJob: Job? = null
init {
if (lifecycleOwner != null) {
@@ -48,6 +60,40 @@ fun <T> prefDelegate(
} else {
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) {
@@ -60,6 +106,7 @@ fun <T> prefDelegate(
override fun dispose() {
appCtx.defaultSharedPreferences.unregisterOnSharedPreferenceChangeListener(this)
dsObserverJob?.cancel()
}
@Suppress("UNCHECKED_CAST")
@@ -92,6 +139,16 @@ fun <T> prefDelegate(
is Long -> appCtx.putPrefLong(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
onValueChange?.invoke(value)
}