做 Android 几年,本地存储这块我算是把所有方案都折腾过一遍:最早是 SharedPreferences,后面项目大了、数据多了,明显感觉它越来越吃力。两年前我们团队把主力 App 的存储层从 SharedPreferences 整体迁到了 Jetpack DataStore,这中间踩了不少坑,也总结出了一套比较成熟的使用方式。这篇就把 DataStore 从基础用法到实战细节完整讲一遍,包括 Preferences DataStore 和 Proto DataStore 两种实现、旧数据迁移方案、ViewModel 集成、单元测试写法,以及那些不实际跑一遍根本不会发现的诡异问题。不管是刚接触 DataStore 想选型的开发,还是已经接入但被各种报错卡住的朋友,这篇文章都能直接给你答案。
1. 先想清楚:DataStore 到底解决了什么问题
1.1 SharedPreferences 在项目膨胀之后的真实痛点
先说 SharedPreferences。我刚接手项目那会儿,没少被它坑。启动页要做首启判断、用户配置读取、登录状态恢复,这些逻辑全部挤在几个 SP 文件的 get 调用上。表面看很方便,但一看线上卡顿数据,卡点全在主线程的同步读取和 commit() 上。尤其到了低端机型,一个上百 KB 的 XML 文件从磁盘加载再解析,几十毫秒的耗时随随便便就出来了。
这个问题不是个例。SharedPreferences 有四个老毛病:
- 主线程阻塞风险:
commit()是同步写磁盘,直接在调用线程做 I/O,数据量大一点就会卡;apply()虽然异步落盘,但系统在页面销毁和进程退出时仍然可能等待它,处理不当照样卡。 - 数据丢失场景多:
apply()之后进程被系统杀掉,内存里还没落盘的数据就丢了。官方文档也承认这一点,所以对数据可靠性要求高的场景,它天生不合格。 - 类型约束形同虚设:所有值都是通过泛型
getString()、getInt()取出来的,存进去是什么类型全靠程序员自己记住,取错类型直接ClassCastException。 - 没有可靠的观察机制:虽然有
OnSharedPreferenceChangeListener,但用起来别扭,没法像响应式框架那样把多个数据源组合起来做 UI 驱动。
这还不提测试有多难写。SP 是静态缓存式设计,mocker 还要处理单例状态残留问题。总而言之,当项目规模上来之后,SP 属于那种“用着不难受,出事才难受”的方案。
1.2 DataStore 的设计思路和核心优势
DataStore 解决的就是上面那堆问题。它是 Jetpack 推出的异步数据存储方案,核心逻辑基于 Kotlin 协程和 Flow,整体设计思路可以概括成四句话:
- 所有读写都是异步的,不占用主线程,从底层消除 ANR 风险。
- 所有更新都在事务中执行,要么全部成功,要么全部不生效,不会出现写到一半的中间状态。
- 崩溃安全:DataStore 在每次操作结束后才把新数据提交到磁盘,读取时永远不会读到“半截”文件。
- 响应式接口:
data属性暴露一个Flow<T>,数据一变所有订阅者都能收到通知,配合 Jetpack Compose 或者 ViewModel 的StateFlow用起来非常顺手。
打个比方,SharedPreferences 像是一本活页账本,笔迹潦草、随时可能被撕页,你翻的时候常看到半行字;DataStore 更像流水账系统,每一笔都核完再入账,哪一笔不完整就整笔作废,账面上永远干净。
不过要提前说清楚:DataStore 也不是完全没有成本。它需要你理解 Kotlin 协程和 Flow 的基本用法,文件读取同样会在首次访问时占一定内存,并且目前官方明确不支持多进程同时读同一个文件。所以它不是“无条件替代一切”,而是一个在大多数单进程场景下比 SP 可靠得多的选择。
2. Preferences DataStore 实战:从创建到读写一篇讲完
2.1 依赖引入与初始化
如果你只需要存 key-value 形式的配置数据,用datastore-preferences就够。它本质上还是键值对,但底层存储格式换成了 protobuf 文件,性能和一致性都比 XML 好得多。
先加依赖:
// app/build.gradle.kts dependencies { implementation("androidx.datastore:datastore-preferences:1.1.1") }截至我写这篇,1.1.1 是稳定的正式版。建议直接用这个版本,因为它修复了迁移时机等一系列问题,后面迁移动词里我会详细讲。
创建 DataStore 实例的方式是调用顶层委托属性:
private val Context.userSettingsDataStore by preferencesDataStore( name = "user_settings" )这里有个关键规矩:同一个文件名的 DataStore 必须保证全局单例。上面这种顶层val写法,首次访问时会创建实例,之后一直复用,所以是安全的。但如果你在 Activity、Fragment 里各写一套,或者多个模块自己PreferenceDataStoreFactory.create()了同一个文件名的实例,运行时就等着报IllegalStateException吧,这个坑我放到最后一章专门讲。
2.2 读数据:把 data 当作一个 Flow 来消费
DataStore 对外暴露的核心是data属性,类型是Flow<Preferences>。每次文件内容发生变化,它都会重新发射一次最新的Preferences对象。读取某个 key 最常用的写法是这样:
val userName: Flow<String> = context.userSettingsDataStore.data .map { preferences -> preferences[stringPreferencesKey("user_name")] ?: "未登录" }注意stringPreferencesKey("user_name")用来创建一个类型与 key 都绑定的偏好键。DataStore 支持的基础类型都有对应的 Key 构造函数:
| Key 构造函数 | 值类型 |
|---|---|
stringPreferencesKey(name) | String |
intPreferencesKey(name) | Int |
booleanPreferencesKey(name) | Boolean |
doublePreferencesKey(name) | Double |
floatPreferencesKey(name) | Float |
longPreferencesKey(name) | Long |
stringSetPreferencesKey(name) | Set<String> |
我习惯把项目里所有 Key 统一定义成顶层常量,避免散落在各个类里导致 key 名称拼写错误:
val KEY_USER_NAME = stringPreferencesKey("user_name") val KEY_LOGIN_COUNT = intPreferencesKey("login_count") val KEY_DARK_MODE = booleanPreferencesKey("dark_mode")这样每次读写都复用同一个 Key 实例,既省内存,也减少字符串硬编码带来的坑。
2.3 写数据:edit 是一个事务性的挂起操作
写入数据用edit方法,它是suspend函数,所以必须在协程作用域中调用:
suspend fun saveUserName(name: String) { context.userSettingsDataStore.edit { preferences -> preferences[KEY_USER_NAME] = name } }edit接收一个 lambda,lambda 里拿到的是MutablePreferences,你可以同时修改多个 key。关键是这个 lambda 整体是一个事务:lambda 执行完之前不会碰磁盘,里面任何一步抛异常,整次修改都不会落盘,旧数据原样保留。
我一开始写代码的时候习惯在edit里做耗时操作,比如再调一个网络请求,这是完全错误的。edit的设计目标就是快速修改内存对象并提交,不应该在里面做任何 I/O 之外的复杂逻辑。如果你要保存的是网络请求的结果,先把结果拿回来,再进edit。
2.4 删除、清空与一次性读取
删除某个 key 用remove:
suspend fun clearUserName() { context.userSettingsDataStore.edit { preferences -> preferences.remove(KEY_USER_NAME) } }清空整个 DataStore 文件用clear():
suspend fun clearAll() { context.userSettingsDataStore.edit { preferences -> preferences.clear() } }还有一类场景只需要读一次值,比如判断用户有没有登录再决定跳转逻辑。可以用first():
val userName = context.userSettingsDataStore.data.first()[KEY_USER_NAME] ?: "未登录"这里要注意first()同样是挂起函数,而且首次调用会真正触发一次文件读取。千万别图省事在外面套一个runBlocking放到主线程里跑,那就把 DataStore 的异步优势全浪费了。在 Activity 里可以用lifecycleScope.launch,在 ViewModel 里用viewModelScope.launch。
2.5 数据文件到底存在哪
DataStore 不像 SharedPreferences 那样把文件放在shared_prefs/xxx.xml,而是放在files/datastore/目录下。比如上面的例子,最终文件路径是:
/data/data/包名/files/datastore/user_settings.preferences_pb后缀是.preferences_pb,本质是一个 protobuf 格式文件。调试的时候如果怀疑存储有问题,可以把这个文件 pull 出来看一眼大小和最后修改时间,但不要试图直接当成文本打开,里面是二进制。
3. Proto DataStore 实战:结构化数据应该用这一套
3.1 什么时候该上 Proto DataStore
Preferences DataStore 再强也只是 key-value,遇到结构化数据就会很尴尬。比如你要存用户信息,包含昵称、年龄、标签列表,用 Preferences DataStore 就得自己把对象序列化成 JSON 再塞进 String,取出来再反序列化。字段一多,代码全是样板,类型全靠写的时候自己记,完全没有编译期保障。
DataStore 还有另外一套实现 Proto DataStore,基于 Protocol Buffers 定义数据模型,读写都是强类型对象,字段变化在编译期就能暴露问题。但它也有成本:需要配置 protobuf 插件、定义.proto文件、理解 schema 版本兼容规则。所以我通常按这个标准选型:
| 维度 | Preferences DataStore | Proto DataStore |
|---|---|---|
| 数据形态 | 零散的 key-value | 结构化对象 |
| 类型保障 | 运行时依赖 Key 的泛型 | 编译期强类型 |
| 引入成本 | 一个依赖即可 | 需要 protobuf 插件和额外构建配置 |
| 升级兼容 | key 自行管理 | proto 字段编号 + reserved 规则 |
| 适用场景 | 开关、缓存、简单配置 | 用户信息、复杂嵌套对象、跨端共享 |
一句话:只有一项两项设置,用 Preferences;超过三四个字段而且它们之间还有关联关系,直接上 Proto。我见过不少项目在 Preferences 里塞 JSON 字符串,最后解析逻辑写了一大堆,这种场景换成 Proto 会舒服非常多。
3.2 定义 .proto 文件与构建配置
先创建 proto 文件。我在app/src/main/proto/user_preferences.proto里定义用户偏好:
syntax = "proto3"; option java_package = "com.example.app"; option java_multiple_files = true; message UserPreferences { string user_name = 1; int32 login_count = 2; bool dark_mode = 3; repeated string tags = 4; }java_multiple_files = true表示每个 message 生成独立的 Java 类,否则所有类都挤在一个UserPreferences.java里,用起来很别扭。
然后在app/build.gradle.kts里配置 protobuf 插件:
plugins { id("com.google.protobuf") version "0.9.4" } android { sourceSets { getByName("main").proto.srcDirs("src/main/proto") } } dependencies { implementation("androidx.datastore:datastore:1.1.1") implementation("com.google.protobuf:protobuf-javalite:3.25.3") } protobuf { protoc { artifact = "com.google.protobuf:protoc:3.25.3" } generateProtoTasks { all().forEach { task -> task.builtins { create("java") { option("lite") } } } } }注意一点:我这里用的是protobuf-javalite,并且生成代码时加了option("lite")。移动端要的就是小体积低开销,完整版 protobuf 会引入反射和缓存机制,对 DataStore 这种轻量存储场景没必要。lite 模式生成的类实现了MessageLite接口,writeTo和parseFrom用起来完全够。
配置好之后同步工程,重新构建,会自动生成UserPreferences类和内部的UserPreferences.newBuilder()构建器。
3.3 自定义 Serializer:DataStore 与对象之间的桥梁
Proto DataStore 不像 Preferences DataStore 那样直接给你一个编辑接口,它必须指定一个Serializer<T>,负责把对象序列化成字节、把字节解析回对象:
object UserPreferencesSerializer : Serializer<UserPreferences> { override val defaultValue: UserPreferences = UserPreferences.getDefaultInstance() override suspend fun readFrom(input: InputStream): UserPreferences { return try { UserPreferences.parseFrom(input) } catch (e: Exception) { throw CorruptionException("无法解析用户偏好数据", e) } } override suspend fun writeTo(t: UserPreferences, output: OutputStream) { t.writeTo(output) } }这里面有几个细节值得注意:
defaultValue会在文件不存在或者尚未初始化时作为初始值返回,所以必须是一个合法实例,不能返回 null。readFrom里如果解析失败,一定要抛CorruptionException。这个异常会被 DataStore 识别为文件损坏,后续才可能触发恢复机制。直接扔别的异常会导致 Flow 直接报错,看起来特别像不明网络异常。writeTo做的是同步OutputStream写入,由于 DataStore 内部已经处理了线程调度,不需要自己再包协程。
接着创建 DataStore 实例:
private val Context.userPreferencesDataStore by dataStore( fileName = "user_prefs.pb", serializer = UserPreferencesSerializer )这一步用的是androidx.datastore:datastore核心库提供的顶层委托dataStore(),不是 Preferences 库里的preferencesDataStore(),两个函数同名但作用不同,IDE 自动提示时要看仔细。
3.4 updateData:原子的局部更新
读取数据就是收集dataFlow 并拿到UserPreferences对象:
val userPrefsFlow: Flow<UserPreferences> = context.userPreferencesDataStore.data更新数据用updateData,它的 lambda 返回一个新的UserPreferences实例,整个更新过程同样是事务性的:
suspend fun updateDarkMode(enabled: Boolean) { context.userPreferencesDataStore.updateData { prefs -> prefs.toBuilder() .setDarkMode(enabled) .build() } }每次都要toBuilder().build(),看起来啰嗦,但这是 protobuf 不可变对象的标准更新方式。好处是无论你想改几个字段,都在同一个 lambda 里完成,最后统一提交,不存在中间状态。
有个容易被忽略的规则:proto 字段编号一旦上线就永远不能改。比如user_name现在是 1,以后想换成别的字段,只能新增字段编号为 5 的新字段,并把旧字段标记为reserved。如果你在新版本里给user_name重新赋值或者改类型,老用户存储文件里的数据解析出来就会错乱,而且这种错乱是静默发生的,排查起来非常痛苦。
4. 迁移、测试与应用层集成
4.1 从 SharedPreferences 平滑迁移到 DataStore
官方提供了现成的迁移工具SharedPreferencesMigration,它的作用是把旧 SP 文件里的所有 key-value 自动复制到新的 DataStore 文件里。用法是在创建 DataStore 时指定:
private val Context.userSettingsDataStore by preferencesDataStore( name = "user_settings", produceMigrations = { context -> listOf( SharedPreferencesMigration( context, "legacy_user_settings" // 旧 SharedPreferences 文件的名字 ) ) } )旧文件名是context.getSharedPreferences("legacy_user_settings", MODE_PRIVATE)里传的那个字符串,不是.xml路径。迁移只执行一次,第一次成功之后就不会再跑。
这里要特别提醒版本问题。DataStore 1.0.x 时代迁移时机有缺陷,第一次读取可能发生在迁移之前,导致首屏读到的是默认值,这是线上最容易感觉“像没迁移一样”的原因。1.1.0 之后官方把迁移调整到了首次读取数据之前完成,所以只要你有迁移需求,就把datastore-preferences升到 1.1.1 以上,别用老版本。
另外,迁移并不会自动删除旧的 SP 文件。等你确认线上数据都正常后,可以手动在合适时机删掉旧 XML 文件,省一点存储空间,也让“旧方案存在感”彻底消失。
4.2 在 ViewModel 里把 DataStore 暴露成状态
直接在各界面 collect DataStore 的 Flow 会带来生命周期管理问题。我通常会在 ViewModel 里把 DataStore 的数据转换成StateFlow,一个 ViewModel 只存一份状态,界面只订阅状态。
class SettingsViewModel(private val dataStore: DataStore<Preferences>) : ViewModel() { val userName: StateFlow<String> = dataStore.data .catch { exception -> if (exception is IOException) { emit(emptyPreferences()) } else { throw exception } } .map { preferences -> preferences[KEY_USER_NAME] ?: "" } .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), initialValue = "" ) }.catch这行是必须的。磁盘文件可能因为各种原因读不出来,DataStore 在这种情况下会往外抛IOException,不 catch 的话 Flow 直接终止,后面的stateIn也跟着废掉。我的策略是:文件读不了的场景返回空数据,其他异常继续上抛让崩溃上报系统记录。
在 Compose 或者 View 层订阅时,推荐用repeatOnLifecycle包一下,避免界面不可见时还在后台做无意义的收集:
lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.userName.collect { name -> // 更新界面 } } }4.3 单元测试怎么写
DataStore 的可测试性比 SharedPreferences 好很多,因为它支持自己指定存储文件。Preferences DataStore 的测试可以用PreferenceDataStoreFactory.create()指向一个临时文件:
@OptIn(ExperimentalCoroutinesApi::class) @Test fun testReadWrite() = runTest { val tmpFolder = TemporaryFolder() val dataStore = PreferenceDataStoreFactory.create( scope = this ) { tmpFolder.newFile("test.preferences_pb").asSynchronousFile() } dataStore.edit { preferences -> preferences[KEY_USER_NAME] = "tester" } val userName = dataStore.data.first()[KEY_USER_NAME] assertEquals("tester", userName) }asSynchronousFile()是 DataStore 提供的一个测试扩展,把普通File包装成同步文件实现,配合测试协程的作用域来避免文件句柄竞争。生产环境里 DataStore 内部是异步文件与协程协调,测试里用这个同步版本更可控。
Proto DataStore 的核心测试就是 Serializer 的序列化和反序列化:
@Test fun testProtoSerializer() = runTest { val preferences = UserPreferences.newBuilder() .setUserName("tester") .setLoginCount(3) .build() val serializer = UserPreferencesSerializer val restored = serializer.readFrom(preferences.toByteArray().inputStream()) assertEquals(preferences, restored) }这样即使后续改了 proto 字段,也能在测试阶段尽早发现兼容性破坏。
5. 实战踩坑记录:这些报错你迟早会碰到
5.1 There are multiple DataStores active for the same file
这是我见过最多的启动崩溃,没有之一。报错信息大概是:
IllegalStateException: There are multiple DataStores active for the same file: xxxx.preferences_pb. You should either maintain your DataStore as a single singleton or convert DataStore file to be used with DataStoreFactory.原因很简单:同一个 DataStore 文件被创建了多个实例。顶层委托preferencesDataStore()看起来很安全,但如果你在多处写by preferencesDataStore(name = "user_settings"),而且每处都是不同的顶层val,它们实际上指向同一个文件,就会互相冲突。
排查思路:全局搜索所有preferencesDataStore(和PreferenceDataStoreFactory.create(,确保同一个name只对应一个单例。如果项目是多模块的,最好由 app 模块或统一存储模块负责创建,其他模块通过依赖注入拿到同一个实例。
5.2 Process crashed while writing data
日志里看到java.io.IOException: Process crashed while writing data to file,很多时候会被当成随机抖动忽略掉,但它背后是协程和文件的竞争问题。
这个报错的常见触发场景是:页面还挂着协程在跑edit,进程就被用户杀掉了,系统把这一次写入当作崩溃处理。如果反复出现,先自查是不是在非 UI 生命周期作用域里调用了edit。比如在 Application 里初始化时启动一个全局协程去写数据,进程一退写一半就会留个坏文件。
规范做法是:写入操作只发生在确定的生命周期内,启动阶段的写入放在onCreate里用一个受控的协程完成,并确保 DataStore 实例是单例。另外,第一次写入前的文件不存在不会造成问题,倒不用担心。
5.3 迁移之后读出来全是默认值
如果你按 4.1 配了迁移,但迁移完成后第一次 collect 到的仍然是默认值,编码上最可能是旧 SharedPreferences 文件名写错了。还有一个隐蔽点是旧文件里存的 key 类型和新 DataStore 里读的类型不一致,比如旧代码存的是int,新代码用stringPreferencesKey去读,结果是 null 再走默认值。
处理这类问题,我建议在迁移上线前先在内部测试包写一个读取旧 SP 文件的日志输出,确认 key 和类型都匹配再推线上。另外,迁移完成后最好强制重启一次 App 再验证数据,因为有些低版本系统在迁移后仍可能缓存旧 XML,导致新逻辑读到旧数据。
5.4 多进程场景不能依赖 DataStore
如果你项目里存在:remote进程,并且两个进程要共享同一份本地数据,DataStore 目前不提供跨进程同步支持。官方文档也明确说了,同一个文件只能被一个进程访问。
我遇到过的情况是推送进程需要读取主进程写入的登录状态。早期用 SharedPreferences 的MODE_MULTI_PROCESS还能凑合,迁到 DataStore 后直接暴露问题。最后只能把这类跨进程共享数据放到数据库或者独立接口里,DataStore 只保留在主进程使用。这是架构层面的取舍,做技术选型时要提前确认。
5.5 DataStore 不加密
DataStore 只是存储方案,不带任何加密能力。存放普通配置没问题,但如果要存登录 token、支付信息、用户隐私字段,必须在写库前自行加密,或者使用加密文件系统,否则应用被 root 设备拉出文件,数据就是明文的。
我在项目里对于 token 这类敏感信息先用 AES-GCM 加密成密文,再存进 DataStore;对于可以重登的普通用户名,就直接明文,性能优先。
6. 最后分享一点个人的工程体会
从 SharedPreferences 迁到 DataStore 之后,最直观的变化是“启动不会再因为读取配置卡住”。但比性能更重要的是心智模型变了:存储不再是一堆散落的 get/set 调用,而是一条可以组合、可以测试、可以响应式驱动的数据流。
我实际操作中最推荐的模式是:全局只保留一个存储模块,统一封装读写的入口;简单配置用 Preferences DataStore,结构化对象用 Proto DataStore;所有 key 和 proto schema 定义集中在同一个模块里;界面层永远只跟 ViewModel 暴露的StateFlow打交道,不直接触碰 DataStore 实例。这套结构上线之后,新成员接手成本极低,有问题也好定位。
DataStore 后续还可以扩展的方向很多,比如配合依赖注入做存储层的单元测试替身、调整 DataStore 内部 scope 的并发策略、或者把 proto schema 抽成独立模块供其它端复用。如果你们项目还在被 SharedPreferences 折磨,不妨按这篇文章的步骤先跑一个 POC,实测一下启动时间和代码量,应该会和我当初一样,第一周就决定全量替换。