DataStore 使用与原理:从 Flow 状态到原子更新的工程边界
前言
“把一个开关写进本地”看似只是一次 putBoolean,但线上问题通常发生在 API 之外:首次读取卡住启动、两个入口覆盖彼此的修改、设置页不断重组却反复创建存储实例、迁移期间旧值反向覆盖新值,或把日益膨胀的业务数据硬塞进偏好文件。
DataStore 的价值不是让键值 API 换一个名字,而是把状态读取、持久化结果和并发更新收敛到同一套协程模型中。本文以 AndroidX DataStore 1.2.1(2026-08-22 复查)为范围,说明如何使用 Preferences DataStore、何时升级为 Proto DataStore,以及 data 与 updateData 背后的初始化和串行化思路。
先给结论:小而整体的用户设置适合 DataStore;需要按条件查询、局部更新大量记录或建立关系的数据,应选 Room/SQLite。DataStore 的“事务性”是单个 DataStore 文件的一次读改写语义,不能扩大解释成业务数据库事务。
前置知识
- Kotlin 协程、
Flow、map、catch与结构化并发。 - ViewModel 向 UI 暴露不可变
StateFlow的常见架构。 - 已阅读 《SharedPreferences 原理与迁移》,理解历史 XML 偏好写入的异步落盘边界。
- 知道 Compose 中应从生命周期感知的状态收集 API 消费 Flow,而不是在 Composable 中自行创建长生命周期协程。
核心概念
Preferences、Proto 与 Room:先选数据形状,再选 API
| 方案 | 适合什么 | 模型与演进 | 不适合什么 |
|---|---|---|---|
| Preferences DataStore | 主题、排序、功能开关、少量用户偏好 | 类型化 key,无预定义 schema | 复杂结构、关系查询、频繁局部改动 |
| Proto DataStore | 有边界的整体配置、枚举、嵌套设置 | .proto schema,编译生成不可变对象 | 临时/高度动态键,或超大对象 |
| Room/SQLite | 列表、缓存、搜索、筛选、关联数据 | 表、索引、SQL、Migration | 只需一小组设置且不需要查询的场景 |
DataStore 的公开接口可以归结为两件事:data: Flow<T> 持续给出当前持久化状态,updateData 在一个串行的读—改—写单元中返回新状态。Preferences DataStore 在此基础上提供 edit,将 MutablePreferences 的修改转换为该单元。
这也解释了两个常见误解:
data不是“每次都从文件读”的同义词;API 文档说明它会在可能时使用缓存,并持续代表最新的持久化状态。updateData不是把某个字段原地补丁写入文件。DataStore 不支持部分持久化更新;状态改变时需要序列化并写回整个T。因此大对象和高频写入不适合它。
不可变模型与单例不是风格偏好
官方使用规则要求:同一进程中,一个文件不能有多个活跃的 DataStore 实例;DataStore<T> 的 T 必须不可变;同一文件不要混用 SingleProcessDataStore 与 MultiProcessDataStore。前两条看似严格,其实分别保护“谁拥有文件协调器”和“缓存快照会不会被外部悄悄篡改”这两个前提。
所以不要把 Context 传进每个 Repository 后随手 DataStoreFactory.create(...),也不要在 updateData 返回一个之后仍会被外部修改的 MutableList。把实例创建集中在应用级边界,把领域状态投影成不可变数据类,才能让读取和更新的语义可推断。
整体架构
flowchart TD
UI[Compose UI] --> VM[SettingsViewModel]
VM --> Repo[SettingsRepository]
Repo --> DS[DataStore Preferences]
DS --> Flow[Flow Preferences]
Flow --> Repo
Repo --> State[UserSettings StateFlow]
Repo -->|edit / updateData| Disk[datastore file]
Disk --> DS
UI 只观察 ViewModel 的状态并发出意图;Repository 负责把存储格式映射为业务模型;DataStore 是单一的本地数据源。上图中读写都要穿过 Repository,因此未来从 Preferences 升级到 Proto,或拆分出 Room 数据源时,调用方不必感知文件与 key。
工作流程
读取:首次初始化、快照发布与异常
第一次收集 data 时,DataStore 会完成初始化任务、读取磁盘并发布初始状态;随后收集者从 Flow 接收状态变化。官方 API 约定:Flow 要么发出值,要么抛出读取异常;重新收集会再次尝试读取。因此 Repository 可以把可恢复的 IOException 映射到保守默认值,但不能吞掉 CorruptionException 并伪装成“用户没有设置过”。数据损坏应通过 corruption handler 或明确的恢复策略处理。
写入:把读改写放进同一个变换
两个协程各自先读 counter 再各自写 counter + 1,即使它们使用同一个文件,也会丢失一次增量。正确做法是把“基于旧值计算新值”的逻辑放在 edit 或 updateData 内部。DataStore 将更新串行化;任一变换或写盘失败会中止该次事务并向调用者抛出异常,协程在成功返回时才表示新快照已持久化。
sequenceDiagram
participant VM as ViewModel
participant R as Repository
participant D as DataStore
participant F as File/Storage
VM->>R: setTheme(DARK)
R->>D: edit { key = DARK }
D->>D: serialized transform
D->>F: serialize and write
F-->>D: durable success / error
D-->>R: resume or throw
D-->>VM: data Flow emits new snapshot
上图的“serialized transform”是关键:它保证同一个 DataStore 的写入顺序,却不意味着变换块适合放长耗时网络请求、递归读取 data 或与别的锁交叉等待。让变换只完成内存中的纯计算,可以缩短后续更新的等待队列。
API 使用
1. 用 Preferences DataStore 实现设置数据源
截至本文复查,DataStore 1.2.1 的发布信息可在 AndroidX release notes 查询。示例使用版本目录;项目也可以按官方文档使用 datastore-preferences 依赖。
# gradle/libs.versions.toml
[versions]
datastore = "1.2.1"
[libraries]
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
顶层扩展委托以文件名为边界创建并复用实例。不要把它声明在 Activity、Composable 或每次注入都会构造的新对象中。
private const val SETTINGS_FILE = "user_settings"
private val Context.userSettingsDataStore by preferencesDataStore(
name = SETTINGS_FILE
)
以下 Repository 将存储 key 与 UI 状态隔离。它只把可预期的 IOException 降级为默认偏好;其他异常继续上抛,以免把数据格式错误、编程错误或损坏文件伪装为默认值。
data class UserSettings(
val isDarkTheme: Boolean = false,
val showCompleted: Boolean = true,
)
class SettingsRepository(context: Context) {
private val appContext = context.applicationContext
private val dataStore = appContext.userSettingsDataStore
private object Keys {
val DARK_THEME = booleanPreferencesKey("dark_theme")
val SHOW_COMPLETED = booleanPreferencesKey("show_completed")
}
val settings: Flow<UserSettings> = dataStore.data
.catch { error ->
if (error is IOException) emit(emptyPreferences()) else throw error
}
.map { preferences ->
UserSettings(
isDarkTheme = preferences[Keys.DARK_THEME] ?: false,
showCompleted = preferences[Keys.SHOW_COMPLETED] ?: true,
)
}
suspend fun setDarkTheme(enabled: Boolean) {
dataStore.edit { preferences ->
preferences[Keys.DARK_THEME] = enabled
}
}
suspend fun toggleCompletedFilter() {
dataStore.edit { preferences ->
val current = preferences[Keys.SHOW_COMPLETED] ?: true
preferences[Keys.SHOW_COMPLETED] = !current
}
}
}
toggleCompletedFilter 不能拆成 settings.first() 再 set...();那会在读取与写入之间重新引入竞争窗口。对于纯 Preferences 场景,edit 已经给出所需的原子读改写边界。
2. 在 ViewModel 和 Compose 中消费
Repository 不拥有 UI 生命周期;ViewModel 使用 stateIn 把冷 Flow 转换为面向界面的 StateFlow,Compose 使用生命周期感知的收集方式。这避免设置页离开后继续为了 UI 而收集,同时保留配置变化后的状态恢复能力。
class SettingsViewModel(
private val repository: SettingsRepository,
) : ViewModel() {
val uiState: StateFlow<UserSettings> = repository.settings
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = UserSettings(),
)
fun onDarkThemeChanged(enabled: Boolean) = viewModelScope.launch {
repository.setDarkTheme(enabled)
}
}
@Composable
fun SettingsScreen(viewModel: SettingsViewModel) {
val settings by viewModel.uiState.collectAsStateWithLifecycle()
Switch(
checked = settings.isDarkTheme,
onCheckedChange = viewModel::onDarkThemeChanged,
)
}
3. 何时选择 Proto DataStore
当设置开始有嵌套结构、受控枚举或跨版本字段演进时,Proto DataStore 比不断增长的字符串 key 更容易审查。schema 是契约,默认值、未知枚举和字段号都应在 code review 中处理。
syntax = "proto3";
option java_package = "com.example.settings";
option java_multiple_files = true;
message UserPreferences {
enum ThemeMode {
THEME_MODE_UNSPECIFIED = 0;
THEME_MODE_SYSTEM = 1;
THEME_MODE_LIGHT = 2;
THEME_MODE_DARK = 3;
}
ThemeMode theme_mode = 1;
bool show_completed = 2;
}
proto3 的第一个枚举值是默认值,因此显式保留 UNSPECIFIED 可以帮助迁移代码区分“旧数据没有值”和“用户明确选择 system”。不要复用已发布的字段号,也不要把 protobuf 当成加密容器;敏感状态仍需独立的安全与备份策略。
源码分析
版本与源码路径
源码观察基于 AndroidX frameworks/support 的 HEAD,访问于 2026-08-22;内部实现会随提交变化,下述类和调用链应以具体分支复核。
| 路径 | 类/函数 | 关注点 |
|---|---|---|
datastore/.../core/DataStore.kt | DataStore<T> | data 与 updateData 的 API 契约 |
datastore/.../core/DataStoreImpl.kt | DataStoreImpl | 初始化、内存状态、更新协调与损坏恢复 |
datastore/.../core/DataStoreFactory.kt | DataStoreFactory.create | serializer、文件、迁移、corruption handler 的装配 |
datastore/.../preferences/PreferencesDataStoreDelegate.kt | preferencesDataStore | Context 委托如何复用 DataStore |
核心类与职责
| 类 | 职责 | 线程/进程边界 |
|---|---|---|
DataStore<T> | 向上提供 Flow<T> 和原子更新接口 | 调用者协程边界 |
DataStoreImpl<T> | 执行初始化、协调读取/更新、维护已发布状态 | 使用协程与存储协调器;实现可支持多进程协调 |
Storage<T> / serializer | 把不可变模型读取/写入底层文件 | 文件 I/O 边界 |
PreferencesDataStoreDelegate | 为特定 Context/文件名建立受控实例 | app 进程内实例边界 |
DataStoreImpl 中的初始化路径先执行 initTasksList,再发布可读状态。源码中 InitDataStore 在有迁移任务时通过 coordinator 的锁把迁移作为一段初始化工作完成;初始化失败时,下次收集或更新会再次触发。这里可以解释为什么迁移必须幂等,也解释了为什么迁移里不能等待同一个 DataStore 的 data:官方源码注释明确指出那会造成死锁。
| API/方法 | 在调用链中的位置 | 工程含义 |
|---|---|---|
DataStore.data | 读取入口 | 以 Flow<T> 暴露当前状态;初始化或读取失败时向收集者报告异常 |
DataStore.updateData | 写入入口 | 将变换交给实现层执行,成功返回代表新快照已持久化 |
InitDataStore.doRun | 首次使用 | 执行初始化/迁移并建立首个可发布快照 |
readDataOrHandleCorruption | 读取与恢复 | 读取底层存储,必要时进入 corruption handler 路径 |
writeData | 持久化阶段 | 写入序列化后的新模型,再同步内存状态 |
调用链与状态模型
flowchart LR
API[data / updateData] --> Impl[DataStoreImpl]
Impl --> Init[InitDataStore]
Init --> Read[readDataOrHandleCorruption]
Read --> Cache[in-memory cache]
API --> Write[serialized transform]
Write --> Lock[coordinator lock]
Lock --> Persist[writeData]
Persist --> Cache
Cache --> Flow[data emission]
从 updateData(transform) 进入后,DataStoreImpl 读取当前快照,在协调器锁保护的更新路径中计算新值、写入存储,再更新内存状态。源码使用 Mutex 保护初始化阶段的变换,并让存储协调器处理写入锁;这比“一个全局 synchronized”更贴近真实边界:单进程与多进程实现的文件协调方式并不相同。
需要特别避免两个错误推导:
- 源码中有缓存,不代表业务层可以再叠一层无失效策略的缓存。
DataStoreAPI 明确建议需要单次快照时用data.first(),而不是自行缓存后假设一致。 - 更新被序列化,不代表每一个 DataStore 之间都有全局事务。跨文件更新、DataStore 与网络调用的组合仍需业务层的补偿和重试设计。
损坏与迁移的恢复路径
读取或解析发现 CorruptionException 时,若创建时配置 ReplaceFileCorruptionHandler,DataStore 会使用预定义默认值替换损坏文件;未配置则向收集者报告异常。这个恢复策略适合能安全回到默认设置的文件,不适合账本、登录态或不可丢失业务状态。
SharedPreferencesMigration 是初始化任务的一种。它应只负责数据转换;埋点、推送、扣费、网络请求等有外部副作用的操作不能放在其中,因为异常恢复可能导致迁移再次执行。上篇迁移策略可直接作为本篇的落地前置。
实战案例:把“设置页”做成可测试的数据流
一个设置页常同时包含主题、内容过滤和实验开关。若 UI 直接读写 SharedPreferences,测试会依赖 Android 框架对象,迁移和默认值也散落在各个点击回调里。更稳妥的拆分是:
SettingsRepository独占DataStore<Preferences>,并产出Flow<UserSettings>。SettingsViewModel组合业务状态,负责调用挂起写入函数并将失败转换成一次性 UI 事件。- Screen 只渲染
UserSettings与派发意图,不持有 key、不知道文件名。 - Repository 单测用临时 DataStore 文件,验证默认值、原子 toggle、I/O 异常与迁移后值;UI 测试只替换 Repository 接口的假实现。
对于“滑块拖动即保存”,不要每一帧调用 edit。ViewModel 先保存内存中的预览值,等拖动结束或经过去抖后再持久化;测试中记录写入次数与最终值。这样优化不是猜测,而是以写入次数、主线程帧时间和打开设置页后的 I/O trace 为验收指标。
性能优化
- 控制数据量和写频率。 每次更新涉及整个
T的序列化与写入。把消息列表、图片元数据和可搜索缓存放进 DataStore,通常会让启动、GC 与 I/O 一起变差;转用 Room 或文件数据源。 - 保持 transform 短小且无阻塞。 写入串行化意味着慢变换会让后续写入排队。网络、加解密大文件和重 CPU 计算应在进入
edit/updateData前完成,变换只合并最终状态。 - 只创建一个实例。 同一进程、同一文件的多实例是官方明确禁止的使用方式,可能导致
IllegalStateException或一致性失效。顶层委托或 DI 单例都可以,关键是文件名到实例的唯一映射。 - 按生命周期收集。 页面不可见时不应为 UI 长期收集冷 Flow。将 Repository 的 Flow 在 ViewModel 中转换为
StateFlow,UI 用collectAsStateWithLifecycle收集。 - 分离敏感与普通设置。 官方文档指出默认情况下 DataStore 文件会进入 Android Auto Backup 和设备间传输。主题、功能开关可以是一份文件;敏感本地状态应单独评估备份排除和安全设计,不能仅凭文件在私有目录就判定安全。
- 先测再调。 使用 Macrobenchmark/Perfetto 观察冷启动与设置保存路径,借助 StrictMode 排查意外主线程 I/O;记录首帧时间、写入次数和 P95 保存耗时,而不是把“换成 DataStore”当作自动性能优化。
常见问题
DataStore 能替代 Room 吗?
不能按这个思路选型。DataStore 擅长小型整体状态,不提供 SQL 查询、索引、关联或部分更新;官方 API 也明确说它不支持 partial updates。需要按条件读取大量数据时,Room 才是更合适的本地数据源。
可以每次 Repository 创建时调用 DataStoreFactory.create 吗?
不可以。对同一文件这么做会产生多个活跃实例,违反官方使用规则。将 DataStore 作为应用级依赖注入,或使用顶层 preferencesDataStore 委托。
data.catch { emit(emptyPreferences()) } 为什么不总是安全?
它会把所有异常伪装成“没有设置”,进而覆盖排查信号。常见做法是仅处理 IOException;数据损坏使用 corruption handler,其他异常继续抛出并记录。默认值是业务决策,不是通用异常吞噬策略。
DataStore 会自动加密吗?
不会。DataStore 解决的是异步、一致和事务性的本地状态持久化,不等价于端到端加密、硬件密钥保护或凭据生命周期管理。安全敏感数据需结合 Keystore、服务端会话、备份规则和威胁模型设计。
多进程该怎么做?
不能让同一文件同时被 SingleProcess 与 MultiProcess DataStore 使用。确有跨进程需求时,按官方 MultiProcess DataStore 文档及版本能力建立单一方案,并验证两进程的真实读写行为;若是共享业务数据,也应评估 ContentProvider/服务边界是否更适合。
面试考点
- 为什么 DataStore 要求同一文件只有一个进程内实例?这和缓存、文件锁分别有什么关系?
data的 Flow 何时读取文件?为什么不建议在业务层再做“永不过期”的缓存?edit如何避免read -> modify -> write的竞争窗口?它能否保证跨两个文件的一致性?- Preferences DataStore 与 Proto DataStore 的选型边界是什么?proto3 的默认枚举值为何要显式设计?
IOException、CorruptionException与业务默认值应如何分别处理?- 为什么高频滑动手势不应直接逐帧写 DataStore?你会用什么指标验证优化?
SharedPreferencesMigration为什么必须幂等?为什么不应在迁移内发网络请求?- DataStore、Room、文件缓存和 Keystore 各自解决什么问题,不能互相替代什么?
总结
DataStore 的正确打开方式是把它当成一个小型、整体、可观察状态文件的单一数据源:用 Flow 读取,用 edit/updateData 合并读改写,用不可变模型保护快照,用单例实例保护协调语义。Preferences 适合稳定的小设置,Proto 适合需要 schema 的整体配置,Room 则负责查询和关系化数据。
真正的工程收益不来自替换一行 API,而来自边界变清晰:Repository 独占存储细节,ViewModel 组织 UI 状态,UI 不直接碰文件和 key;迁移、损坏、备份和性能因此都有了明确的处理位置。
扩展阅读
- Android Developers:DataStore 指南 —— 官方使用规则、Preferences/Proto 示例、损坏恢复与备份说明。
- Android Developers:DataStore API reference ——
data、updateData、事务与异常契约。 - Android Developers:DataStore release notes —— 本文复查的
1.2.1依赖版本与变更记录。 - Android Developers:Working with Preferences DataStore —— Preferences 的 Flow、
edit与迁移练习。 - Android Developers:Working with Proto DataStore —— schema、枚举默认值和迁移示例。
- AndroidX:
DataStoreImpl.kt—— 初始化、迁移、缓存和协调器的实现入口;需以具体提交为准。 - 上一篇:《SharedPreferences 原理与迁移》;下一篇:《SQLite 基础与事务》。