DataStore 工程化实践:迁移、并发更新与异常恢复
很多项目把 DataStore 当成 SharedPreferences 的替代品:定义几个 key,读取 Flow,调用 edit 写入,看起来就完成了升级。真正进入复杂业务后,问题往往出现在迁移一致性、并发更新、异常恢复、生命周期收集和多进程访问这些边界上。本文从一个用户设置模块出发,梳理 DataStore 的可靠落地方式,并给出可测试、可观测的工程结构。
DataStore 解决了什么问题
SharedPreferences 使用简单,但它的同步读取容易阻塞主线程,apply() 的异步落盘也不代表所有调用都具备清晰的一致性语义。多个业务模块直接操作同一份偏好文件后,类型约束、默认值和迁移规则还会散落在各处。
DataStore 的核心价值不只是异步 API:
- 读取结果通过
Flow持续暴露; - 更新操作串行执行,适合表达原子读改写;
- Preferences DataStore 保留键值模型,迁移成本较低;
- Proto DataStore 使用强类型 schema,更适合长期演进;
- 迁移、损坏处理和异常策略可以集中配置。
它适合保存用户偏好、功能开关、轻量配置和本地状态标记。大量结构化数据、关联查询、分页数据仍应交给 Room;需要跨进程共享的数据也不能直接套用普通 DataStore。
Preferences 还是 Proto
Preferences DataStore 不需要 schema,适合从 SharedPreferences 平滑迁移:
val Context.settingsDataStore by preferencesDataStore(
name = "user_settings"
)
object SettingsKeys {
val darkMode = booleanPreferencesKey("dark_mode")
val fontScale = floatPreferencesKey("font_scale")
val lastSyncAt = longPreferencesKey("last_sync_at")
}
它仍然依赖字符串 key,重命名和类型变更需要开发者自己维护。配置字段逐渐增多、结构需要明确版本演进时,Proto DataStore 更稳妥:
syntax = "proto3";
option java_package = "com.example.settings";
option java_multiple_files = true;
message UserSettings {
bool dark_mode = 1;
float font_scale = 2;
int64 last_sync_at = 3;
}
Proto 字段编号一旦使用就不要复用。删除字段时应保留编号,避免旧数据被新的字段错误解释。
本文使用 Preferences DataStore 展示工程边界,因为它更容易接入现有项目;同样的仓库分层、异常处理和测试思路也适用于 Proto DataStore。
保证单实例
同一进程内,同一个文件只能维护一个 DataStore 实例。不要在 Repository、Activity 或不同依赖注入模块中重复调用 DataStoreFactory.create()。
简单项目可以使用顶层委托:
val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(
name = "user_settings"
)
使用 Hilt 时,可以显式提供单例,便于注入和测试替换:
@Module
@InstallIn(SingletonComponent::class)
object StorageModule {
@Provides
@Singleton
fun provideSettingsDataStore(
@ApplicationContext context: Context
): DataStore<Preferences> = PreferenceDataStoreFactory.create(
corruptionHandler = null,
migrations = emptyList(),
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
produceFile = {
context.dataStoreFile("user_settings.preferences_pb")
}
)
}
这里的作用域属于应用级存储组件,不应绑定 Activity 或 ViewModel。SupervisorJob 可以避免某个子任务失败后取消整个存储作用域。
用 Repository 收口读写协议
UI 层不应知道 key 名称,也不应直接调用 edit。集中封装后,默认值、约束和错误策略才不会分散。
data class UserSettings(
val darkMode: Boolean = false,
val fontScale: Float = 1f,
val lastSyncAt: Long = 0L
)
class SettingsRepository @Inject constructor(
private val dataStore: DataStore<Preferences>
) {
private object Keys {
val darkMode = booleanPreferencesKey("dark_mode")
val fontScale = floatPreferencesKey("font_scale")
val lastSyncAt = longPreferencesKey("last_sync_at")
}
val settings: Flow<UserSettings> = dataStore.data
.catch { error ->
if (error is IOException) {
emit(emptyPreferences())
} else {
throw error
}
}
.map { preferences ->
UserSettings(
darkMode = preferences[Keys.darkMode] ?: false,
fontScale = preferences[Keys.fontScale]
?.coerceIn(0.85f, 1.4f)
?: 1f,
lastSyncAt = preferences[Keys.lastSyncAt] ?: 0L
)
}
suspend fun setDarkMode(enabled: Boolean) {
dataStore.edit { preferences ->
preferences[Keys.darkMode] = enabled
}
}
suspend fun setFontScale(scale: Float) {
dataStore.edit { preferences ->
preferences[Keys.fontScale] = scale.coerceIn(0.85f, 1.4f)
}
}
}
catch 应放在 map 之前,并且只吞掉可以降级处理的 IOException。如果映射代码出现空指针、类型错误或业务异常,直接返回默认值会掩盖程序缺陷。
原子更新避免丢失
并发写入最常见的错误是先读取当前值,再在另一个调用中写回:
suspend fun unsafeIncreaseLaunchCount() {
val current = dataStore.data.first()[launchCountKey] ?: 0
dataStore.edit { preferences ->
preferences[launchCountKey] = current + 1
}
}
两个协程可能同时读到相同值,最终只增加一次。正确方式是在 edit 的事务块内完成读改写:
suspend fun increaseLaunchCount() {
dataStore.edit { preferences ->
val current = preferences[launchCountKey] ?: 0
preferences[launchCountKey] = current + 1
}
}
DataStore 会串行处理更新函数。对于多个有关联的字段,也应在同一个 edit 中维护不变量:
suspend fun markSyncSucceeded(timestamp: Long) {
dataStore.edit { preferences ->
preferences[lastSyncAtKey] = timestamp
preferences[syncFailureCountKey] = 0
preferences[pendingSyncKey] = false
}
}
不要在 edit 中执行网络请求、数据库查询或长时间计算。更新函数执行越久,后续写操作等待越久;外部副作用还可能因为重试或取消而产生难以推断的结果。
从 SharedPreferences 安全迁移
迁移的目标不是“把值复制过去”,而是确保旧版本升级后只执行一次,并正确处理默认值、字段改名和非法历史数据。
val Context.settingsDataStore by preferencesDataStore(
name = "user_settings",
produceMigrations = { context ->
listOf(
SharedPreferencesMigration(
context = context,
sharedPreferencesName = "legacy_settings"
)
)
}
)
如果新旧 key 不一致,可以自定义迁移逻辑:
SharedPreferencesMigration(
context = context,
sharedPreferencesName = "legacy_settings",
keysToMigrate = setOf("night_mode", "text_size")
) { sharedPrefs, currentData ->
currentData.toMutablePreferences().apply {
if (!contains(darkModeKey)) {
this[darkModeKey] = sharedPrefs.getBoolean("night_mode", false)
}
if (!contains(fontScaleKey)) {
val legacySize = sharedPrefs.getFloat("text_size", 1f)
this[fontScaleKey] = legacySize.coerceIn(0.85f, 1.4f)
}
}.toPreferences()
}
迁移代码需要遵守几个原则:
- 新存储已有值时,不要被旧值覆盖;
- 对历史非法值进行校正,而不是原样搬运;
- 迁移完成前不要让其他模块继续写旧文件;
- 发布后保留一段兼容周期,再删除旧读写代码;
- 对迁移成功率和回退情况增加日志或埋点。
灰度发布时尤其要考虑版本回退。新版本迁移并删除旧值后,用户退回旧版本可能丢失设置。对于重要配置,可以在兼容窗口内保留旧数据,或者明确评估应用商店是否允许回退到仍依赖旧格式的版本。
损坏处理不能等同于读取异常
文件损坏与普通 I/O 失败含义不同。Proto DataStore 可以通过 ReplaceFileCorruptionHandler 在反序列化失败时提供替代数据:
val dataStore = DataStoreFactory.create(
serializer = UserSettingsSerializer,
corruptionHandler = ReplaceFileCorruptionHandler {
UserSettings.getDefaultInstance()
},
produceFile = { context.dataStoreFile("user_settings.pb") }
)
恢复默认值能保证应用继续运行,但也意味着原数据被放弃。涉及登录态、付费权益或安全配置时,静默清空可能造成更严重的业务问题。此类数据应有服务端真源、重新认证流程或单独的恢复策略。
建议记录不包含敏感内容的诊断信息:应用版本、文件类型、异常类别、是否执行替换。不要把 token、用户输入或完整偏好内容写入日志。
在 ViewModel 中转成页面状态
Repository 暴露冷 Flow 后,ViewModel 可以使用 stateIn 形成稳定状态:
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val repository: SettingsRepository
) : ViewModel() {
val uiState: StateFlow<SettingsUiState> = repository.settings
.map { settings ->
SettingsUiState.Content(settings)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = SettingsUiState.Loading
)
fun onDarkModeChanged(enabled: Boolean) {
viewModelScope.launch {
repository.setDarkMode(enabled)
}
}
}
在 Compose 中使用生命周期感知收集:
@Composable
fun SettingsRoute(viewModel: SettingsViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
SettingsScreen(
state = uiState,
onDarkModeChanged = viewModel::onDarkModeChanged
)
}
传统 View 页面使用 repeatOnLifecycle:
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect(::render)
}
}
这样页面停止可见后会取消内部收集,重新可见时再恢复,避免无效渲染和生命周期泄漏。
高频交互需要合并写入
滑块、文本输入和拖拽排序可能在短时间产生大量事件。每次变化都立即落盘,会增加写放大,还可能让 UI 事件队列堆积。
一种方式是在 ViewModel 中保留即时 UI 状态,停止操作后再提交:
private val fontScaleChanges = MutableSharedFlow<Float>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
init {
fontScaleChanges
.debounce(300)
.distinctUntilChanged()
.onEach(repository::setFontScale)
.launchIn(viewModelScope)
}
fun onFontScaleChanged(value: Float) {
_previewScale.value = value
fontScaleChanges.tryEmit(value)
}
需要注意,debounce 适合允许短暂延迟的偏好设置,不适合支付确认、协议授权等必须立即持久化的操作。页面退出前是否必须强制提交,也要由业务语义决定。
多进程是明确边界
普通 DataStore 不支持多个进程同时访问同一文件。如果应用包含独立进程的 Service、ContentProvider 或小组件更新进程,不能让它们各自创建普通 DataStore 指向同一路径。
可选方案包括:
- 尽量把存储访问收口到主进程;
- 通过 Binder 或 ContentProvider 向其他进程提供受控接口;
- 使用支持多进程场景的 DataStore 能力,并确认当前依赖版本和限制;
- 对复杂共享数据使用 Room,再设计清晰的跨进程并发策略。
判断应用是否存在多进程,不能只看业务代码,还要检查合并后的 Manifest。第三方 SDK 可能声明带 android:process 的组件。
测试迁移与并发行为
DataStore 测试应使用独立临时文件和测试作用域,避免多个用例共享状态:
class SettingsRepositoryTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var tempDir: Path
private lateinit var dataStore: DataStore<Preferences>
@Before
fun setUp() {
tempDir = Files.createTempDirectory("settings-test")
dataStore = PreferenceDataStoreFactory.create(
scope = CoroutineScope(testDispatcher + SupervisorJob()),
produceFile = { tempDir.resolve("settings.preferences_pb").toFile() }
)
}
@After
fun tearDown() {
tempDir.toFile().deleteRecursively()
}
}
并发更新测试应验证最终值,而不是只验证函数没有抛异常:
@Test
fun concurrentUpdates_doNotLoseChanges() = runTest(testDispatcher) {
val repository = CounterRepository(dataStore)
coroutineScope {
repeat(100) {
launch { repository.increase() }
}
}
assertEquals(100, repository.count.first())
}
迁移测试至少覆盖:旧数据存在、新数据已存在、旧数据非法、迁移中断后重试。Proto schema 演进还应使用历史版本生成的数据文件进行兼容性测试。
可观测性与排查顺序
线上出现“设置自动恢复默认值”时,建议按以下顺序排查:
- 是否创建了多个指向同一文件的实例;
- 是否存在独立进程访问同一文件;
- key 是否改名、类型是否变化;
catch是否错误吞掉了非 I/O 异常;- 迁移逻辑是否覆盖了新值;
- 用户是否清理数据、恢复备份或发生版本回退;
- 损坏处理器是否执行了默认值替换。
日志中可以记录更新来源、字段名、结果和耗时,但不要记录字段原值。对于敏感配置,字段名本身也应做分级处理。
常见误区
把 DataStore 当数据库
DataStore 每次更新面向完整数据对象或偏好集合,不适合大量记录、条件查询和局部行更新。数据规模和查询复杂度上升时应使用 Room。
在主线程同步等待
不要用 runBlocking 把异步读取包装成同步 getter。启动阶段确实依赖某个配置时,可以设计 Splash 状态、内存缓存或明确的初始化协调器。
到处读取 data.first()
一次性读取并非错误,但页面状态长期依赖设置时,持续收集 Flow 才能响应后续变化。频繁 first() 还会让状态组合和测试变得零散。
用默认值掩盖所有异常
无条件 catch { emit(default) } 会把程序错误伪装成正常状态。只处理明确可恢复的异常,并让未知异常进入监控系统。
修改 key 后直接上线
Preferences 的 key 重命名就是数据格式变更。没有迁移时,用户设置会悄悄回到默认值。字段删除、类型调整同样需要兼容方案。
落地检查清单
- 同一文件是否只有一个 DataStore 实例;
- 数据模型是否明确区分偏好、数据库数据和服务端真源;
- 所有写入是否通过 Repository 收口;
- 关联字段是否在同一个更新事务中修改;
- 是否只捕获可恢复的 I/O 异常;
- SharedPreferences 迁移是否覆盖改名、非法值和版本回退;
- 高频交互是否需要防抖或批量提交;
- 页面是否使用生命周期感知的 Flow 收集;
- 是否检查了多进程组件和第三方 SDK;
- 迁移、并发和损坏恢复是否有自动化测试;
- 日志与埋点是否避免泄露敏感数据。
总结
DataStore 的 API 并不复杂,工程难点在于为数据定义清晰边界。单实例保证访问秩序,Repository 统一默认值和约束,事务式更新避免并发丢失,迁移与损坏策略保障版本演进,生命周期收集和高频写入治理则决定页面体验。
当这些规则被纳入架构和测试后,DataStore 才不只是“更现代的 SharedPreferences”,而是一个行为可预测、问题可追踪、能够长期演进的轻量配置存储层。