DataStore 替代 SharedPreferences,迁移要注意什么

DataStore 替代 SharedPreferences,迁移要注意什么

DataStore 替代 SharedPreferences,迁移要注意什么


DataStore 替代 SharedPreferences,迁移要注意什么


Jetpack DataStore 从 1.0.0 稳定版发布到现在已经三年多了,Google 官方文档里那句 "SharedPreferences 有诸多缺点,建议迁移到 DataStore" 几乎每个 Android 开发者都看过。但真到了实际项目里,把用了七八年的 SharedPreferences 换成 DataStore,事情远没有文档上写的 createDataStore(name = "settings") 那么轻松。我自己去年主导了两个模块的迁移,一个纯 Kotlin 的偏好设置模块,一个带有复杂同步逻辑的账号配置模块,踩的坑比预期多得多。这篇文章把实际遇到的问题摊开来说,包括一些官方文档不会告诉你的细节。


那个被忽略的 `PreferenceDataStoreFactory` 创建时机


刚开始迁移的时候,我直接按照官方示例在 Application 里写:


val Context.dataStore by preferencesDataStore(name = "user_prefs")

看起来简洁,但问题很快暴露。我们项目有一个多进程场景,主进程和推送服务进程都会读取用户 ID 来做消息路由。SharedPreferences 时代用的是 MODE_MULTI_PROCESS,虽然 Google 早就标记为 deprecated,但确实能凑合用。DataStore 完全不支持多进程,这是文档里明确写的,但很多人没意识到的是:即使不是多进程并发写,单进程里多个 DataStore 实例指向同一个文件也会直接崩溃。


我遇到的错误是 IllegalStateException: There are multiple DataStores active for the same file。原因是模块 A 用了 by preferencesDataStore 委托,模块 B 里某个工具类为了省事又 createDataStore 了一次。SharedPreferences 时代 getSharedPreferences 随便调,系统层会帮你复用同一个实例,但 DataStore 是全新的文件锁机制,重复创建就会炸。这个限制在 1.0.0 版本尤其严格,1.1.0-alpha01 之后错误提示改进了一些,但根本问题没变。


正确的做法是把 DataStore 实例作为单例挂到 Application 或者专门的依赖注入模块里,确保全应用只有一个入口。我们项目最后是把所有 DataStore 创建收敛到一个 DataStoreModule 里,用 Hilt 单例注入,彻底杜绝重复创建。


`Flow` 不是免费的,冷启动要算账


迁移到 DataStore 最大的卖点是 Flow 异步 API,告别 SharedPreferences.OnSharedPreferenceChangeListener 的回调地狱。但实际测试下来,冷启动时第一次 dataStore.data.first() 的耗时比我想象的高。


我在 Pixel 6 上测了一个存了 50 个键值对的 preferences DataStore,首次读取的 first() 调用平均耗时 12-18ms。作为对比,同设备上 SharedPreferences.getString() 是 0.3ms 级别。这个差距在 Application.onCreate 里累积起来很可观,如果启动路径上要读三四个配置项,DataStore 的延迟可能直接吃掉你一帧(16ms)的预算。


更麻烦的是 first() 会触发整个文件的反序列化。DataStore 的存储格式是 protobuf,读取任何一个键都要先把整个文件解析成 Preferences 对象。SharedPreferences 的 XML 虽然也被诟病,但系统实现里有一定的惰性加载优化,而且 XML 解析在 Android 上被 native 层加速过。


我最后的 workaround 是把启动关键路径的配置拆出来:冷启动必须立即读的值保留在一个小的 SharedPreferences 文件里(或者干脆放 Bundle / DataStore 但提前预加载),非关键配置走 DataStore。Google 官方没有推荐这种混合方案,但实测这是平衡迁移成本和启动性能的现实选择。DataStore 1.1.0 开始支持 preferencesDataStorecorruptionHandlerproduceFile 参数,但核心的全文件解析机制没变。


`edit` 的事务语义和异常处理


DataStore 的 edit 块看起来是原子操作:


dataStore.edit { prefs ->
    prefs[KEY_COUNTER] = (prefs[KEY_COUNTER] ?: 0) + 1
}

但这里有个坑:edit 的 lambda 执行是同步的,真正的磁盘写入是异步的。如果 edit 块里抛异常,事务会回滚,这个没问题。但如果磁盘写入本身失败(比如存储空间不足、文件被外部清理),edit 返回的 Deferred 会抛异常,而 lambda 里的逻辑已经执行过了。


我在测试里模拟过这种情况:用 ulimit -f 限制文件大小,然后连续写入大字符串。edit 块里的 prefs[key] = value 执行成功,返回的 Deferredawait() 时抛出 IOException。此时内存里的 Preferences 和磁盘不一致,下次读取会拿到旧值,但业务逻辑可能以为写入成功了。


更隐蔽的是 DataStore.updateData 的异常处理。如果 transform 块里抛异常,DataStore 会取消这次更新,但已经创建的 DataStore 实例不会自动关闭。我们在压力测试里遇到过连续异常后实例进入奇怪状态,后续所有写入都挂起直到超时。这个行为在 1.0.0 和 1.1.0-alpha 版本都有报告,GitHub issue #339 和 #534 有相关讨论,目前没有完全修复。


我的建议是:对 edit 的返回值始终做 try/await 或者 catch,不要 fire-and-forget。尤其用户手动触发保存的场景(比如切换开关、修改设置),失败时要有降级或重试策略。SharedPreferences 的 apply() 虽然也是异步,但系统层有更强的容错和自动恢复机制。


类型系统的陷阱:`Preferences.Key` 不是普通字符串


DataStore 的类型安全是卖点,但 Preferences.Key 的创建方式很容易埋坑。官方示例里都是:


val KEY_NAME = stringPreferencesKey("name")

这个 stringPreferencesKey("name") 每次调用返回的是同一个 Key 实例吗?我查过源码,答案是:不是。Preferences.Keyequals 比较的是 name 字符串,所以 stringPreferencesKey("name") == stringPreferencesKey("name") 为 true,但 hashCode 和对象引用不同。这意味着如果你在不同模块里各自创建同名 key,用在同一个 Preferences 对象上是安全的,但用在 SetMap 里当键会有问题。


更实际的问题是迁移时的 key 命名。SharedPreferences 时代很多项目用常量类集中管理 key,比如 PREF_USER_NAME = "user_name"。DataStore 需要转成 stringPreferencesKey(PREF_USER_NAME),看起来简单,但如果某个模块漏了迁移,还在用旧的 SharedPreferences 写同一个 key,两边就会数据不同步。


我们项目遇到过一个 bug:账号退出逻辑在旧模块里用 SharedPreferences.edit().clear() 清理,但新模块的 DataStore 不知道这件事,下次启动读取 DataStore 还是旧值。解决方案是退出时两边都清,或者更彻底的是把退出逻辑也迁到 DataStore。这种渐进式迁移里的边界问题,官方文档基本没提。


还有一个类型细节:booleanPreferencesKey 存的布尔值,默认值处理比 SharedPreferences 麻烦。SharedPreferences.getBoolean(key, false) 一行搞定,DataStore 要写:


val value = prefs[KEY_ENABLED] ?: false

如果封装不当,每个读取点都写 ?: false 容易出错。我们最后封装了一个 PreferenceDataStore 的扩展函数,把常用类型和默认值绑在一起,但这也增加了封装层的维护成本。


迁移工具 `SharedPreferencesMigration` 的局限性


Jetpack 提供了 SharedPreferencesMigration 来做数据迁移,用法看起来直接:


val dataStore = PreferenceDataStoreFactory.create(
    produceFile = { context.preferencesDataStoreFile("user_prefs") },
    migrations = listOf(
        SharedPreferencesMigration(
            context = context,
            sharedPreferencesName = "legacy_prefs"
        )
    )
)

但实际用下来有几个限制。第一,迁移只在 DataStore 首次创建时触发,如果 legacy_prefs 后续还有写入,DataStore 不会自动同步。第二,迁移是"全量复制",不能选择性迁移 key,如果旧文件很大,首次启动会卡一下。第三,也是最坑的:迁移完成后旧文件不会被删除。


我们项目的 SharedPreferences 文件里存了用户搜索历史,几百条记录,迁移时把整个 XML 解析再序列化到 protobuf,耗时 200ms+,直接触发 ANR 监控。最后的方案是放弃自动迁移,写了一个手动迁移逻辑:启动后后台线程里读取旧数据,只迁移必要的 key,完成后删除旧文件。这个逻辑和 SharedPreferencesMigration 的"官方做法"完全不同,但更符合实际性能要求。


另外,SharedPreferencesMigration 在 1.0.0 版本有个已知问题:如果旧文件里存了 Set<String>,迁移到 DataStore 的 stringSetPreferencesKey 时,顺序可能不一致。SharedPreferencesgetStringSet 返回的是 Set 接口,底层实现可能是 HashSet,而 DataStore 的 Set 序列化会按插入顺序(实际测试是哈希顺序)。如果你的业务依赖 Set 的迭代顺序,这里会有行为差异。我们在 1.1.0-alpha04 上验证这个问题还在,没有修复。


Proto DataStore 的编译时成本


Preferences DataStore 是键值对,Proto DataStore 是类型化对象,适合复杂结构。但 Proto DataStore 的引入成本被很多文章低估了。


首先必须引入 protobuf 插件和运行时,我们项目用的是 com.google.protobuf:protobuf-gradle-plugin:0.9.4,配合 protobuf-java 3.25.1。这个版本和项目里已有的 gRPC 依赖有冲突,gradle 解析花了半天时间调 excludeforce。Android 上的 protobuf 用的是 protobuf-javalite 而不是完整版,这个区别新手很容易搞错,编译时会出现 Method too large 或者运行时 NoSuchMethodError


.proto 文件定义好后,每次修改都要重新生成 Java/Kotlin 代码,增量编译支持一般。我们项目里 proto 文件放在 app/src/main/proto,Android Studio 的 Gradle sync 经常不识别修改,需要手动 cleanbuild。这个开发体验比 SharedPreferences 的"改完直接跑"差很多。


生成的 UserPreferences 类是不可变的,更新时要整个对象重建:


dataStore.updateData { current ->
    current.toBuilder()
        .setUsername("new_name")
        .build()
}

这个 toBuilder() 模式对简单字段更新没问题,但嵌套对象深的时候代码很冗长。我们有个配置对象三层嵌套,更新一个最内层的布尔值要写六行 builder 链。作为对比,Kotlin data class 的 copy 一行搞定。Google 有 protobuf-kotlin 库提供 DSL 生成,但和 DataStore 的集成在 1.1.0 之前不太完善,我们试过 alpha 版本有编译问题,最后回退了。


测试环境的特殊性


DataStore 的测试比 SharedPreferences 复杂。SharedPreferences 可以用 InstrumentationRegistry.getTargetContext().getSharedPreferences() 直接操作,测试完 clear() 就行。DataStore 需要构造 DataStore<Preferences> 实例,而且 preferencesDataStore 委托在测试里会触发单例检查,多个测试用例如果共用同一个文件名,会报 IllegalStateException


官方推荐用 PreferenceDataStoreFactory.create 在测试里创建临时文件:


@Test
fun testDataStore() = runTest {
    val testDataStore = PreferenceDataStoreFactory.create(
        scope = TestScope(),
        produceFile = { File.createTempFile("test", ".preferences_pb") }
    )
    // test code
}

runTestTestScope 的调度器行为和生产环境不同,我们遇到过测试通过但生产环境 Flow 不发射的问题,原因是 TestDispatcher 立即执行,掩盖了真实的异步时序。最后测试和生产环境统一用 Dispatchers.IO 跑 DataStore 操作,测试时间变长但可靠性提高。


还有一个坑:DataStore 文件在测试结束后不会自动清理,如果 File.createTempFile 的路径在 /tmp,某些 CI 环境会定期清理,本地开发机可能累积大量临时文件。我们写了一个 TestRule 在 tearDown 里删除匹配 *.preferences_pb 的文件,这个脏活官方示例没提。


版本演进中的行为变化


DataStore 的版本迭代里有些行为变化需要注意。1.0.0 到 1.1.0-alpha 期间,DataStore.dataFlow 在文件不存在时的行为变了:1.0.0 会抛 FileNotFoundException(虽然被内部捕获转成空 Preferences),1.1.0-alpha 改为直接发射空 Preferences。这个变化对依赖异常处理的代码有影响。


1.1.0 开始支持 DataStorecorruptionHandler,可以自定义文件损坏时的恢复逻辑。默认是抛异常,但生产环境我们更倾向返回空数据让应用能继续启动:


PreferenceDataStoreFactory.create(
    corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() }
) { context.preferencesDataStoreFile("settings") }

这个 handler 在 1.0.0 不存在,文件损坏时直接崩溃。我们线上监控到过几次 CorruptionException,原因不明,可能是用户手动清理存储时打断了写入,或者低端设备的存储问题。1.1.0 的 ReplaceFileCorruptionHandler 是刚需,但升级到 alpha 版本需要评估稳定性风险。


和现有架构的集成摩擦


DataStore 的 Flow API 和现代 Android 架构(MVVM、MVI)配合很好,但和遗留代码集成时有摩擦。我们项目有个 2018 年写的配置管理类,用 volatile + synchronized 做线程安全,所有读取都是同步阻塞的。迁移到 DataStore 后,这个类的方法签名要改成 suspend 或者返回 Flow,调用点全要改。


更麻烦的是跨层调用。UI 层 Activity 里原来直接 ConfigManager.getInstance().getXxx(),现在要 lifecycleScope.launch { viewModel.xxxFlow.collect {...} },或者 runBlocking 强行同步。runBlocking 在 DataStore 上能工作,但会阻塞 Dispatchers.IO 的线程池,高并发时有风险。我们最后是把配置读取全推到 ViewModel 层,用 StateFlow 暴露给 UI,但这是一个不小的重构量。


对于还不能全量改调用的模块,我们写了一个桥接层:DataStoreBackedPreferences 实现了一个类似 SharedPreferences 的同步接口,内部用 runBlocking(Dispatchers.IO) { dataStore.data.first() } 做读取,写入用 GlobalScope.launch fire-and-forget。这个桥接不是长久之计,但让迁移可以渐进进行,而不是一次性推翻重写。


存储格式的可见性和调试


SharedPreferences 的 XML 文件可以直接 pull 出来用文本编辑器看,调试方便。DataStore 的 .preferences_pb 是 protobuf 二进制,需要工具解析。Android Studio 的 App Inspection 工具从 Bumblebee 开始支持 DataStore 查看,但功能很基础,只能看不能改,而且有时候连接不稳定。


命令行调试可以用 protoc 解码:


adb shell run-as com.example.app cat files/datastore/user_prefs.preferences_pb | protoc --decode_raw

但这个要求设备有 protoc 或者把文件 pull 到本地。我们写了一个内部调试工具,在 debug 构建里暴露一个 ContentProvider,把 DataStore 内容转成 JSON 输出,方便 QA 和开发查问题。这个成本在 SharedPreferences 时代是不存在的。


另一个细节:DataStore 文件名是固定的 name.preferences_pb,不能自定义扩展名。如果项目有多个 DataStore,文件都堆在 files/datastore/ 目录下,没有子目录组织。1.1.0 的 produceFile 参数允许自定义路径,但默认行为还是扁平的。


最后的现实建议


说这么多,不是劝退 DataStore。Flow 异步流、类型安全、事务语义,这些在复杂场景下确实比 SharedPreferences 强。但迁移不是改几行 API 的事,要算清楚这些成本:


  • 启动路径上的首次读取延迟,可能需要预加载或保留双写
  • 多进程场景必须重构,不能简单替换
  • 全量迁移的停机时间,考虑渐进式桥接
  • 测试基础设施要配套更新
  • 团队学习成本,Flowsuspend 的普及度

  • 我们项目最后的迁移节奏是:新模块直接用 DataStore,旧模块按优先级逐个迁,关键路径保留性能监控,遇到问题回退到 SharedPreferences 的兜底方案。两年过去了,还有一小部分代码在双写,但主体迁移完成,整体稳定性可以接受。


    Google 对 SharedPreferences 的 deprecation 是方向性的,不是明天就删 API。DataStore 1.1.0 还在 alpha,正式稳定后可能还有变化。我自己的判断是:新项目无脑 DataStore,老项目按需迁移,别为了迁移而迁移。那些"几行代码搞定 SharedPreferences 迁移"的教程,看看就好,真到生产环境,上面这些坑大概率要踩一遍。

    Layout Inspector 的进阶:3D 视图和属性检查 2026-07-13

    评论区