18、协程与DataStore:DataStore的协程API、Preferences DataStore与Proto DataStore、Flow与DataStore的响应式读取、数据迁移与协程

说到Android本地存储,SharedPreferences 大家肯定都用过。但说实话,它在协程时代已经有点跟不上节奏了。SP 的同步读写会卡主线程,commit() 和 apply() 的行为又让人迷惑,更别提跨进程安全了。

Jetpack DataStore 就是来替代它的。它完全基于协程和 Flow 构建,天生就是响应式的。今天我就带大家把 DataStore 的协程玩法彻底捋一遍。

核心结论:DataStore = 协程 + Flow + 类型安全。它有两种实现:Preferences DataStore(键值对)和 Proto DataStore(带 Schema 的强类型存储)。

18.1 Preferences DataStore:轻量级键值存储

Preferences DataStore 用起来和 SharedPreferences 很像,但 API 完全是挂起函数和 Flow。我个人习惯用它存一些简单的配置项,比如用户是否首次启动、主题模式开关等。

先加依赖:

// build.gradle.kts (模块级)
implementation("androidx.datastore:datastore-preferences:1.1.1")

然后创建 DataStore 实例。我建议把它定义在顶层扩展属性里,方便全局访问:

// 顶层扩展
val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(
    name = "settings"
)

读写操作非常直观。写入时用 edit 挂起函数,读取时用 data 属性返回的 Flow:

// 写入(在协程中调用)
suspend fun saveThemeMode(isDark: Boolean) {
    context.settingsDataStore.edit { preferences ->
        preferences[THEME_MODE_KEY] = isDark
    }
}

// 读取(返回 Flow)
val themeModeFlow: Flow<Boolean> = context.settingsDataStore.data.map { preferences ->
    preferences[THEME_MODE_KEY] ?: false
}

// 伴生对象中定义 Key
companion object {
    private val THEME_MODE_KEY = booleanPreferencesKey("theme_mode")
}

这里有个细节:edit 是挂起函数,内部会自动处理事务。如果写入过程中抛出异常,整个事务会回滚,数据不会损坏。我在项目中遇到过因为 SP 写入一半进程被杀导致数据丢失的情况,换成 DataStore 后就没再出过这种问题。

18.2 Proto DataStore:强类型存储

如果配置项比较复杂,比如有嵌套结构、枚举、列表,Preferences DataStore 就不够用了。这时候要用 Proto DataStore,它基于 Protocol Buffers 定义 Schema,数据是强类型的。

首先定义 proto 文件:

// app/src/main/proto/user_settings.proto
syntax = "proto3";

option java_package = "com.example.app";
option java_multiple_files = true;

message UserSettings {
    bool is_dark_mode = 1;
    int32 font_size = 2;
    string display_name = 3;
}

然后在 build.gradle.kts 中配置 protobuf 插件:

plugins {
    id("com.google.protobuf")
}

protobuf {
    protoc {
        artifact = "com.google.protobuf:protoc:3.24.0"
    }
    generateProtoTasks {
        all().forEach { task ->
            task.builtins {
                create("java") {
                    option("lite")
                }
            }
        }
    }
}

dependencies {
    implementation("androidx.datastore:datastore:1.1.1")
    implementation("com.google.protobuf:protobuf-javalite:3.24.0")
}

创建 DataStore 时,需要提供序列化器和默认值:

val Context.userSettingsDataStore: DataStore<UserSettings> by dataStore(
    fileName = "user_settings.pb",
    serializer = UserSettingsSerializer
)

object UserSettingsSerializer : Serializer<UserSettings> {
    override val defaultValue: UserSettings = UserSettings.getDefaultInstance()

    override suspend fun readFrom(input: InputStream): UserSettings {
        return UserSettings.parseFrom(input)
    }

    override suspend fun writeTo(t: UserSettings, output: OutputStream) {
        t.writeTo(output)
    }
}

读写操作和 Preferences 类似,但操作的是具体的 proto 对象:

// 写入
suspend fun updateDisplayName(name: String) {
    context.userSettingsDataStore.updateData { currentSettings ->
        currentSettings.toBuilder()
            .setDisplayName(name)
            .build()
    }
}

// 读取
val displayNameFlow: Flow<String> = context.userSettingsDataStore.data.map { settings ->
    settings.displayName
}

我的建议:如果项目中有多个模块需要共享配置,或者配置项未来可能增加字段,直接用 Proto DataStore。虽然前期多写几行 proto 定义,但后期维护成本低很多。

18.3 Flow与DataStore的响应式读取

DataStore 最爽的地方就是它天然支持 Flow。你不需要手动注册监听器,也不需要担心生命周期。数据一变,Flow 就会发射新值。

来看一个实际场景:用户切换深色模式后,UI 要立刻响应。

// ViewModel 中
class SettingsViewModel(
    private val dataStoreManager: DataStoreManager
) : ViewModel() {

    val isDarkMode: StateFlow<Boolean> = dataStoreManager.themeModeFlow
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = false
        )

    fun toggleDarkMode() {
        viewModelScope.launch {
            dataStoreManager.saveThemeMode(!isDarkMode.value)
        }
    }
}

// Activity 中
lifecycleScope.launch {
    viewModel.isDarkMode.collect { isDark ->
        // 切换主题
        AppCompatDelegate.setDefaultNightMode(
            if (isDark) AppCompatDelegate.MODE_NIGHT_YES
            else AppCompatDelegate.MODE_NIGHT_NO
        )
    }
}

这里我用了 SharingStarted.WhileSubscribed(5_000),意思是当订阅者数量降到 0 后,再等 5 秒才停止上游 Flow。这样做的好处是:如果用户只是旋转屏幕导致 Activity 重建,上游 Flow 不会中断,避免重复读取文件。

曾经踩过的坑:不要在 collect 里做耗时操作。DataStore 的 Flow 是在 Dispatchers.IO 上发射的,但 collect 所在的协程上下文取决于你。如果你在 Main 线程 collect,记得用 flowOn(Dispatchers.IO) 切换上游上下文。

18.4 数据迁移与协程

从 SharedPreferences 迁移到 DataStore 是很多项目要面对的问题。DataStore 官方提供了迁移支持,而且迁移逻辑也是挂起函数,完全协程化。

迁移的核心是 SharedPreferencesMigration 类:

val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(
    name = "settings",
    produceMigrations = { context ->
        listOf(
            SharedPreferencesMigration(
                context,
                sharedPreferencesName = "old_settings"
            ) { sharedPrefs, currentData ->
                // 迁移逻辑:将 SP 中的数据写入 DataStore
                val isDark = sharedPrefs.getBoolean("is_dark_mode", false)
                currentData.toMutablePreferences().apply {
                    this[booleanPreferencesKey("is_dark_mode")] = isDark
                }
            }
        )
    }
)

迁移过程是自动的。DataStore 首次读取数据时,会检查是否有未完成的迁移任务。如果有,它会按顺序执行迁移,然后将迁移后的数据写入新文件。整个过程在协程中异步完成,不会阻塞主线程。

对于 Proto DataStore,迁移逻辑类似,但需要自己处理 proto 对象的构建:

val Context.userSettingsDataStore: DataStore<UserSettings> by dataStore(
    fileName = "user_settings.pb",
    serializer = UserSettingsSerializer,
    produceMigrations = { context ->
        listOf(
            SharedPreferencesMigration(
                context,
                sharedPreferencesName = "old_user_settings"
            ) { sharedPrefs, currentData ->
                val builder = currentData.toBuilder()
                builder.isDarkMode = sharedPrefs.getBoolean("is_dark_mode", false)
                builder.fontSize = sharedPrefs.getInt("font_size", 16)
                builder.build()
            }
        )
    }
)

重要提醒:迁移完成后,旧的 SharedPreferences 文件不会被自动删除。如果你确定迁移成功,可以手动删除 SP 文件,或者用 sharedPrefs.edit().clear().apply() 清空数据。不过我个人建议保留一段时间,万一需要回滚呢?

18.5 知识体系总览

下面这张图总结了 DataStore 在协程体系中的位置和核心流程:

DataStore 协程架构总览 Preferences DataStore 键值对存储,轻量级 Proto DataStore 强类型存储,需定义 Schema 协程 API suspend fun edit { } Flow<Preferences>.data 协程 API suspend fun updateData { } Flow<T>.data Flow 响应式读取 .map { } .stateIn() .collect { } SharedPreferencesMigration → 协程迁移

18.6 最佳实践总结

最后,我把 DataStore 在协程中的使用要点整理成表格,方便大家对照:

场景 推荐做法 避坑指南
简单键值对 Preferences DataStore Key 用 xxxPreferencesKey() 定义,不要硬编码字符串
复杂结构化数据 Proto DataStore proto 文件变更后要重新生成代码,注意向后兼容
UI 响应式更新 Flow + stateIn() WhileSubscribed 避免重复读取文件
从 SP 迁移 SharedPreferencesMigration 迁移完成后手动清理旧 SP 文件
测试 使用 DataStore<Preferences> 的 TestScope 注入 Fake DataStore,不要依赖真实文件

个人习惯:我会在项目里封装一个 DataStoreManager 单例,统一管理所有 DataStore 实例和读写操作。这样 ViewModel 只依赖 Manager 接口,方便单元测试。

嗯,DataStore 的协程玩法就这些。它不像 Room 那么重,但比 SharedPreferences 强太多了。说白了,它就是协程时代本地存储的标配。你想想看,一个挂起函数写入、一个 Flow 读取,再配合自动迁移,还有什么理由不用它呢?

公众号:蓝海资料掘金营,微信deep3321