ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Android 本地存储进阶:Jetpack DataStore 从原理到实战避坑指南

Android 本地存储进阶:Jetpack DataStore 从原理到实战避坑指南 做 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属性暴露一个FlowT数据一变所有订阅者都能收到通知配合 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属性类型是FlowPreferences。每次文件内容发生变化它都会重新发射一次最新的Preferences对象。读取某个 key 最常用的写法是这样val userName: FlowString context.userSettingsDataStore.data .map { preferences - preferences[stringPreferencesKey(user_name)] ?: 未登录 }注意stringPreferencesKey(user_name)用来创建一个类型与 key 都绑定的偏好键。DataStore 支持的基础类型都有对应的 Key 构造函数Key 构造函数值类型stringPreferencesKey(name)StringintPreferencesKey(name)IntbooleanPreferencesKey(name)BooleandoublePreferencesKey(name)DoublefloatPreferencesKey(name)FloatlongPreferencesKey(name)LongstringSetPreferencesKey(name)SetString我习惯把项目里所有 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接收一个 lambdalambda 里拿到的是MutablePreferences你可以同时修改多个 key。关键是这个 lambda 整体是一个事务lambda 执行完之前不会碰磁盘里面任何一步抛异常整次修改都不会落盘旧数据原样保留。我一开始写代码的时候习惯在edit里做耗时操作比如再调一个网络请求这是完全错误的。edit的设计目标就是快速修改内存对象并提交不应该在里面做任何 I/O 之外的复杂逻辑。如果你要保存的是网络请求的结果先把结果拿回来再进edit。2.4 删除、清空与一次性读取删除某个 key 用removesuspend 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 DataStorePreferences DataStore 再强也只是 key-value遇到结构化数据就会很尴尬。比如你要存用户信息包含昵称、年龄、标签列表用 Preferences DataStore 就得自己把对象序列化成 JSON 再塞进 String取出来再反序列化。字段一多代码全是样板类型全靠写的时候自己记完全没有编译期保障。DataStore 还有另外一套实现 Proto DataStore基于 Protocol Buffers 定义数据模型读写都是强类型对象字段变化在编译期就能暴露问题。但它也有成本需要配置 protobuf 插件、定义.proto文件、理解 schema 版本兼容规则。所以我通常按这个标准选型维度Preferences DataStoreProto 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 自定义 SerializerDataStore 与对象之间的桥梁Proto DataStore 不像 Preferences DataStore 那样直接给你一个编辑接口它必须指定一个SerializerT负责把对象序列化成字节、把字节解析回对象object UserPreferencesSerializer : SerializerUserPreferences { 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: FlowUserPreferences 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: DataStorePreferences) : ViewModel() { val userName: StateFlowString 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实测一下启动时间和代码量应该会和我当初一样第一周就决定全量替换。
返回列表