DataStore vs SharedPreferences 迁移指南:告别 ANR,拥抱类型安全
一句话收益:掌握从 SharedPreferences 迁移到 Jetpack DataStore 的完整路径,彻底消除主线程 I/O 阻塞与类型安全隐患。
适用版本:Android API 21+,DataStore 1.1.x,Kotlin 1.9+
阅读时长:约 18 分钟
1. 从一次线上 ANR 说起
某电商 App 的启动链路中有一段看似无害的代码:
// 在 Application.onCreate() 中读取用户配置valprefs=getSharedPreferences("user_config",Context.MODE_PRIVATE)valisLoggedIn=prefs.getBoolean("is_logged_in",false)valuserId=prefs.getString("user_id","")?:""上线后,Crash 平台开始收到 ANR 报告,堆栈始终指向SharedPreferencesImpl.getBoolean()。原因很简单:SharedPreferences 在首次加载时会将整个 XML 文件同步读入内存,若文件较大(存储了用户历史 SKU 缓存等),主线程在等待 I/O 完成前会被阻塞。
这个场景催生了今天的主题——Jetpack DataStore,以及如何安全地完成迁移。
2. SharedPreferences 的三个根本缺陷
2.1 主线程 I/O 阻塞
Application.onCreate() └─ getSharedPreferences() ← 触发磁盘读取 └─ SharedPreferencesImpl() └─ startLoadFromDisk() ← 异步加载,但 get*() 会等待完成 └─ awaitLoadedLocked() ← 主线程 wait(),ANR 风险AOSP 源码路径:frameworks/base/core/java/android/app/SharedPreferencesImpl.java
关键方法:SharedPreferencesImpl#awaitLoadedLocked()
2.2 apply() 的隐式延迟提交
// 看起来异步,实则有坑prefs.edit().putString("key","value").apply()apply()会将写操作提交到内存并安排异步磁盘写入,但Activity.onStop() 会等待所有 apply() 完成(通过QueuedWork.waitToFinish())。高频写入场景下,这里同样是 ANR 温床。
AOSP 路径:frameworks/base/core/java/android/app/QueuedWork.java
2.3 无类型安全保证
// 编译时不报错,运行时 ClassCastExceptionprefs.putInt("threshold",100)// 某处误读为 Stringvalthreshold=prefs.getString("threshold","0")// crash!3. DataStore 架构总览
DataStore 提供两种实现:
DataStore(接口) ├── Preferences DataStore ← 无 Schema,迁移 SP 的首选 │ └── PreferencesDataStore(单文件 Proto 序列化) └── Proto DataStore ← 强类型,需定义 .proto Schema └── ProtoDataStore(Protobuf) 调用链路(Preferences DataStore): Caller └── DataStore.data: Flow<Preferences> └── SingleProcessDataStore └── FileStorage(读:协程 IO 线程) └── PreferencesSerializer(Proto/JSON 序列化) 写入链路: Caller └── DataStore.edit { prefs -> prefs[key] = value } └── 在 Dispatchers.IO 上执行,返回 suspend 结果关键差异对比:
| 维度 | SharedPreferences | Preferences DataStore |
|---|---|---|
| 线程模型 | 同步(含主线程风险) | 协程 + Flow,完全异步 |
| 类型安全 | 无(String/Int/Boolean 混用) | 通过Preferences.Key<T>保障 |
| 事务性 | apply/commit 非原子 | edit {} 块原子提交 |
| 错误处理 | 无(静默失败) | Flow 异常传播 |
| 跨进程 | MODE_MULTI_PROCESS(已废弃) | 不支持(需 Proto + ContentProvider) |
| 迁移 | — | 内置 SharedPreferencesMigration |
4. Preferences DataStore 实战
4.1 依赖引入
// build.gradle.kts (Module)dependencies{implementation("androidx.datastore:datastore-preferences:1.1.1")// Proto DataStore(可选)// implementation("androidx.datastore:datastore:1.1.1")}4.2 创建 DataStore 单例
// ❌ 错误写法:每次调用都创建新实例,导致多实例写入冲突fungetDataStore(context:Context):DataStore<Preferences>=PreferenceDataStoreFactory.create{context.preferencesDataStoreFile("user_prefs")}// ✅ 正确写法:顶层委托属性,保证全局唯一实例valContext.userPrefsDataStore:DataStore<Preferences>bypreferencesDataStore(name="user_prefs")问题说明:DataStore 的文件操作依赖单一SingleProcessDataStore实例维护写入队列。多实例会导致并发写入覆盖,数据损坏。
原理:preferencesDataStore委托内部使用DataStoreSingletonDelegate,通过synchronized+HashMap保证每个文件名只有一个实例。
4.3 定义类型安全的 Key
objectUserPrefsKeys{valIS_LOGGED_IN=booleanPreferencesKey("is_logged_in")valUSER_ID=stringPreferencesKey("user_id")valTHEME_MODE=intPreferencesKey("theme_mode")// 0=跟系统, 1=浅色, 2=深色valLAST_SYNC_TIMESTAMP=longPreferencesKey("last_sync_ts")}4.4 读取数据(Flow)
classUserPrefsRepository(privatevaldataStore:DataStore<Preferences>){// 读取单个值,带默认值valisLoggedIn:Flow<Boolean>=dataStore.data.catch{e->// 处理 IOException(文件损坏等)if(eisIOException){emit(emptyPreferences())}elsethrowe}.map{prefs->prefs[UserPrefsKeys.IS_LOGGED_IN]?:false}// 读取多个值,组合成数据类dataclassUserConfig(valisLoggedIn:Boolean,valuserId:String,valthemeMode:Int)valuserConfig:Flow<UserConfig>=dataStore.data.catch{if(itisIOException)emit(emptyPreferences())elsethrowit}.map{prefs->UserConfig(isLoggedIn=prefs[UserPrefsKeys.IS_LOGGED_IN]?:false,userId=prefs[UserPrefsKeys.USER_ID]?:"",themeMode=prefs[UserPrefsKeys.THEME_MODE]?:0)}}4.5 写入数据
suspendfunsetLoggedIn(userId:String){dataStore.edit{prefs->// edit {} 块是事务性的:要么全部成功,要么全部回滚prefs[UserPrefsKeys.IS_LOGGED_IN]=trueprefs[UserPrefsKeys.USER_ID]=userId prefs[UserPrefsKeys.LAST_SYNC_TIMESTAMP]=System.currentTimeMillis()}}suspendfunlogout(){dataStore.edit{prefs->prefs.remove(UserPrefsKeys.USER_ID)prefs[UserPrefsKeys.IS_LOGGED_IN]=false}}// ❌ 错误写法:在非挂起上下文中调用,阻塞线程funsetThemeSync(mode:Int){runBlocking{dataStore.edit{it[UserPrefsKeys.THEME_MODE]=mode}}// runBlocking 在主线程调用会 ANR,与使用 SP 无本质区别}// ✅ 正确写法:在 ViewModel 协程中调用funsetTheme(mode:Int){viewModelScope.launch{userPrefsRepository.setThemeMode(mode)}}5. 从 SharedPreferences 迁移的完整方案
5.1 内置迁移器
DataStore 提供SharedPreferencesMigration,只在首次访问 DataStore 时执行一次,完成后自动删除旧 SP 文件(可配置保留)。
valContext.userPrefsDataStore:DataStore<Preferences>bypreferencesDataStore(name="user_prefs",produceMigrations={context->listOf(SharedPreferencesMigration(context=context,sharedPreferencesName="user_config",// 旧 SP 文件名// 可选:仅迁移指定 keykeysToMigrate=setOf("is_logged_in","user_id","theme_mode"),// 可选:迁移后保留旧 SP 文件(默认 false,即删除)// deleteEmptyPreferences = true))})迁移流程:
首次调用 dataStore.data └── DataStoreMigrationUtils.runMigrations() └── SharedPreferencesMigration.shouldMigrate() // 检查 SP 文件是否存在 └── migrate(currentData, spData) // 合并数据 └── 写入 DataStore,标记迁移完成 └── deleteEmptyPreferences → 删除旧 SP 文件5.2 多 SP 文件场景
若项目中存在多个 SP 文件,建议分批迁移,或合并到单一 DataStore:
produceMigrations={context->listOf(SharedPreferencesMigration(context,"user_config"),SharedPreferencesMigration(context,"app_settings"),SharedPreferencesMigration(context,"feature_flags"))}注意:多个迁移器按顺序执行,每个都是独立事务。
5.3 自定义 Key 映射(旧 Key → 新 Key)
旧 SP 使用不规范 key 名(如驼峰、带空格),迁移时可重命名:
SharedPreferencesMigration(context=context,sharedPreferencesName="user_config",migrate={spData,currentData->valmutablePrefs=currentData.toMutablePreferences()// 旧 key: "UserIsLoggedIn" → 新 key: "is_logged_in"spData.getBoolean("UserIsLoggedIn",false).let{mutablePrefs[UserPrefsKeys.IS_LOGGED_IN]=it}spData.getString("UserId","")?.let{mutablePrefs[UserPrefsKeys.USER_ID]=it}mutablePrefs.toPreferences()})5.4 迁移验证策略
// 在 Debug 构建中,迁移后对比两端数据if(BuildConfig.DEBUG){valoldPrefs=context.getSharedPreferences("user_config",Context.MODE_PRIVATE)valnewPrefs=context.userPrefsDataStore.data.first()check(oldPrefs.getBoolean("is_logged_in",false)==(newPrefs[UserPrefsKeys.IS_LOGGED_IN]?:false)){"Migration verification failed for IS_LOGGED_IN"}}6. 在 ViewModel 与 Hilt 中集成
6.1 Hilt 注入 DataStore
@Module@InstallIn(SingletonComponent::class)objectDataStoreModule{@Provides@SingletonfunprovideUserPrefsDataStore(@ApplicationContextcontext:Context):DataStore<Preferences>=context.userPrefsDataStore}@HiltViewModelclassSettingsViewModel@Injectconstructor(privatevaluserPrefsRepo:UserPrefsRepository):ViewModel(){valthemeMode:StateFlow<Int>=userPrefsRepo.themeMode.stateIn(scope=viewModelScope,started=SharingStarted.WhileSubscribed(5_000),initialValue=0)funonThemeSelected(mode:Int){viewModelScope.launch{userPrefsRepo.setThemeMode(mode)}}}6.2 在 Compose 中消费
@ComposablefunSettingsScreen(viewModel:SettingsViewModel=hiltViewModel()){valthemeModebyviewModel.themeMode.collectAsStateWithLifecycle()ThemeSelector(selected=themeMode,onSelect=viewModel::onThemeSelected)}7. 常见坑点
坑 1:在 Application.onCreate() 中同步读取 DataStore
现象:升级 DataStore 后启动崩溃,LogCat 报IllegalStateException: Cannot invoke suspend function from non-suspend context。
原因:DataStore 所有读写均为 suspend 函数,不能在非协程上下文同步调用。
复现:
// ❌ 在 Application.onCreate() 中同步读取classMyApp:Application(){overridefunonCreate(){super.onCreate()valprefs=runBlocking{userPrefsDataStore.data.first()}// 主线程 blockif(prefs[UserPrefsKeys.IS_LOGGED_IN]==true){...}}}解决:将逻辑移到首个 Activity/Fragment 的协程中,或使用Application级CoroutineScope:
classMyApp:Application(),CoroutineScopebyMainScope(){overridefunonCreate(){super.onCreate()launch{valprefs=userPrefsDataStore.data.first()// 异步处理}}}坑 2:迁移后旧 SP 文件仍被某处代码访问
现象:用户数据在 DataStore 和 SP 之间出现不一致。
原因:代码中仍有旧的getSharedPreferences("user_config", ...)调用,绕过了 DataStore。
复现:迁移后的老代码路径(如 WebView Bridge、旧 Fragment)未同步更新。
解决:用 Lint 规则强制拦截 SP 调用:
// 在 lint.xml 中禁用 SharedPreferences 用法<issue id="CommitPrefEdits"severity="error"/>// 或自定义 Lint 规则检测 getSharedPreferences() 调用坑 3:DataStore 文件损坏后无法恢复
现象:极少数情况下(存储空间不足、进程被 kill)导致 Proto 文件损坏,dataFlow 持续抛出异常。
原因:DataStore 的 CorruptionHandler 未配置,默认重抛异常。
解决:配置ReplaceFileCorruptionHandler:
valContext.userPrefsDataStore:DataStore<Preferences>bypreferencesDataStore(name="user_prefs",corruptionHandler=ReplaceFileCorruptionHandler{emptyPreferences()}// 损坏时重置为空(数据丢失,但不崩溃))坑 4:跨进程场景直接使用 Preferences DataStore
现象:多进程 App(如有 :push 进程)同时读写 DataStore,数据丢失或 IOException。
原因:SingleProcessDataStore不支持多进程并发写入(文件锁基于 JVM 实例)。
解决:使用ContentProvider封装 DataStore,或改用 Room(支持 WAL 模式多进程安全)。
8. 最佳实践
8.1 Repository 模式封装,隔离 DataStore 细节
做法:通过 Repository 接口暴露 Flow 和 suspend 函数,ViewModel 不直接持有 DataStore 引用。
原因:便于单元测试(mock Repository),且迁移到 Proto DataStore 或其他存储时无需修改 ViewModel 层。
对比:若 ViewModel 直接调用dataStore.edit {},测试时需启动真实文件系统,测试速度慢 10 倍以上。
8.2 使用stateIn缓存 Flow,避免多次订阅重复读文件
做法:
valisLoggedIn:StateFlow<Boolean>=repo.isLoggedIn.stateIn(viewModelScope,SharingStarted.Eagerly,false)原因:Flow 默认是冷流,每次collect都会重新读取。stateIn将其转为热流,多个 Composable 订阅共享同一数据,减少 I/O。
对比:不用stateIn时,3 个 Composable 订阅同一配置项 = 3 次磁盘读取触发。
8.3 批量写入时使用单个edit {}块
做法:
// ✅ 一个 edit 块 = 一次磁盘写入dataStore.edit{prefs->prefs[KEY_A]=valueA prefs[KEY_B]=valueB prefs[KEY_C]=valueC}// ❌ 三次独立 edit = 三次磁盘写入dataStore.edit{it[KEY_A]=valueA}dataStore.edit{it[KEY_B]=valueB}dataStore.edit{it[KEY_C]=valueC}原因:每次edit {}都是一次完整的读-改-写流程,合并写入降低 IOPS,延长闪存寿命。
8.4 大数据量不适合 DataStore
做法:超过 100KB 的数据(如缓存列表、图片路径集合)改用 Room 或文件存储。
原因:DataStore 每次写入都会全量序列化整个 Preferences 对象,大数据量时 CPU 开销显著。
对比:Room 支持增量更新,1000 条记录更新单条耗时 <1ms;DataStore 全量写入同等数据需 20~50ms。
9. 总结
- SharedPreferences 的主线程 I/O 与非原子提交是 ANR 的根源,DataStore 从架构层面消除了这两个风险。
preferencesDataStore委托属性是创建单例的唯一正确姿势,多实例会导致数据损坏。- 内置
SharedPreferencesMigration提供零代码迁移路径,支持 Key 重映射与数据过滤。 - DataStore 不支持跨进程,多进程场景需借助 ContentProvider 或改用 Room。
- 配置
ReplaceFileCorruptionHandler是生产环境的必选项,防止文件损坏导致 App 不可用。
核心结论:DataStore 不是 SharedPreferences 的简单替换,而是对持久化 KV 存储的重新设计——将 I/O 安全性、类型安全、错误处理的责任从调用者转移到了框架本身。
参考资料
- DataStore 官方文档
- 从 SharedPreferences 迁移到 DataStore
- DataStore 设计文档(Medium)
- AOSP 源码:
frameworks/base/core/java/android/app/SharedPreferencesImpl.java - AOSP 源码:
frameworks/base/core/java/android/app/QueuedWork.java - DataStore 源码:
androidx/datastore/core/SingleProcessDataStore.kt