我最近在帮一个工具类App做架构改造,翻代码时发现一个项目里竟然同时混用了SharedPreferences、SQLiteOpenHelper、自己写的JSON文件缓存,还有一套手撸的ContentProvider。每个模块都在各搞一套数据存储方案,读的时候在主线程做同步IO,写的时候有人用apply有人用commit,崩溃日志里隔三差五就是sqlite database lock。这种乱象在Android项目里非常典型,而Google其实早给我们圈好了两套官方答案:轻量键值对用DataStore,结构化关系型数据用Room。把它们用对、用透,项目的持久化层就能清爽一大半。
这篇文章我会从选型边界、实操落地、协作模式、测试与踩坑四个维度,把自己真实项目里的方案完整梳理一遍。适合正在重构持久化层的中级开发者,也适合刚接触DataStore和Room、想一步到位不走弯路的初学朋友。
1. 先分清楚:DataStore 和 Room 各自解决什么问题
1.1 SharedPreferences 留下的坑,DataStore 是怎么填的
很多老项目里的SharedPreferences滥用程度远超想象。最常见的问题有三个:第一,commit()是同步写磁盘,在主线程调用会导致卡顿甚至ANR,apply()虽然是异步,但它的异步只是把结果写入内存后立刻返回,磁盘写入时机完全不可控,进程被系统杀掉时很容易丢数据;第二,getString()返回的是可空类型,取出来之后到处是做空判断,时间一长代码里全是"?:"补默认值的操作;第三,SharedPreferences没有数据变化通知机制,你要监听某个key变化,只能自己包一层监听器,多个模块同时写同一个key时没有任何事务保证。
DataStore就是冲着这些痛点来的。它基于协程和Flow,所有读写操作自动切换到IO线程,调用方不需要关心线程切换;写入操作以事务方式执行,要么全部成功,要么什么都不写,不存在SharedPreferences那种写到一半进程被杀的问题;数据以Flow形式暴露,天然支持可观察性,UI层可以用collect接收最新值;Preferences DataStore的API还做了类型安全处理,你用intPreferencesKey()取出来的就是非空Int,不用再手动判空。
但必须明确一点:DataStore不是万能钥匙。它没有SQL查询能力,不支持条件过滤、排序、联表查询,它就是个高级版的键值对仓库。我看到有些项目把几十个业务字段全部塞进DataStore,每个Key是一长串字符串拼出来的,这完全是用错了场景。
1.2 Room 不是又一个 ORM,而是 SQLite 的现代封装
Room出现之前,项目里用SQLiteOpenHelper写SQL是件挺痛苦的事。表结构变更要手动改SQL语句、版本号、写迁移逻辑,稍不注意就是多个地方不一致;Cursor拿出来的字段没有人帮你校验,列名拼错只会在运行时报错;所有数据库操作都要自己管理线程,配合协程又得自己包一层。
这些问题的根源是:SQLiteOpenHelper太底层了,错误暴露得太晚。Room把问题提前到了编译期。它会在编译时检查你的@Query里的SQL语句和表结构是否匹配,字段名写错了直接编译失败,而不是上线以后崩溃。同时Room生成了一套完整的实现代码,DAO接口里的方法在编译期自动生成,你永远不需要手写ContentValues和Cursor循环。
更关键的一点是,Room对异步有天然友好的设计。DAO方法可以返回Flow<List<T>>,表里任何数据变化都会自动触发重新查询,UI层做数据刷新几乎不用写额外代码;方法也可以声明成suspend,Room会在ioDispatcher上帮你执行数据库操作,压根不需要自己在withContext(Dispatchers.IO)里包一层。
1.3 选型判断:什么进 DataStore,什么进 Room
| 数据类型 | 推荐方案 | 理由 |
|---|---|---|
| 用户偏好设置、开关项、主题色 | DataStore | 数据量小,Key-Value结构足够,写多读少 |
| 登录态、token、基础配置 | DataStore | 需要快速启动时读取,每次操作独立写入 |
| 列表数据、业务实体、订单记录 | Room | 需要分页、过滤、排序、联表查询 |
| 需要跨页面共享且频繁变化的数据 | Room + Flow | 观察数据变化是天然能力 |
| 图片、音视频等二进制大文件 | 文件系统,不要进数据库 | 数据库不适合存大文件,只存文件路径 |
| 临时缓存、可随时重新拉取的数据 | DataStore / 内存缓存均可 | 数据丢失可接受,不必要上数据库 |
判断标准其实就三条:需不需要查询?需不需要关联?需不需要变化通知?如果答案是肯定的,直接走Room;如果只是存几个key,用DataStore最舒服。非要拿着一个方案打天下的项目,后面肯定会在某个边界场景里别扭。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. DataStore 实战:轻量持久化的正确落地姿势
2.1 依赖引入与单例创建
先加依赖。Preferences DataStore的最小实现只需要一个库:
groovy复制dependencies {
implementation "androidx.datastore:datastore-preferences:1.1.1"
}
如果你用的是Proto DataStore,需要额外引入:
groovy复制dependencies {
implementation "androidx.datastore:datastore-core:1.1.1"
implementation "com.google.protobuf:protobuf-javalite:4.26.1"
}
创建一个DataStore实例最推荐的方式是顶层属性委托:
kotlin复制val Context.userDataStore: DataStore<Preferences> by preferencesDataStore(
name = "user_prefs"
)
为什么用顶层属性而不是每次调用都新建一个?因为DataStore实例内部维护了状态,包括缓存、事务、代理文件位置,如果同一个文件创建了多个DataStore实例,会抛出IllegalStateException: There are multiple DataStores active for the same file。用属性委托在Application或Context首次访问时创建一次,之后全局复用,这是最容易踩的一次坑。
2.2 写入与读取:edit 和 Flow 的配合
写入用edit扩展函数:
kotlin复制private val KEY_USER_NAME = stringPreferencesKey("user_name")
suspend fun saveUserName(name: String) {
context.userDataStore.edit { prefs ->
prefs[KEY_USER_NAME] = name
}
}
读取分两种场景。UI层做实时观察时用Flow:
kotlin复制val userNameFlow: Flow<String> = context.userDataStore.data
.map { prefs -> prefs[KEY_USER_NAME] ?: "" }
在ViewModel里:
kotlin复制val userName: StateFlow<String> = userNameFlow
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "")
一次性读取时用first(),注意这是个挂起函数,不能在主线程直接调用:
kotlin复制val userName = context.userDataStore.data.first()[KEY_USER_NAME] ?: ""
我还见过有人在Application的onCreate里用runBlocking { dataStore.edit { } }来提前写入配置。这种做法非常危险,runBlocking会把当前线程阻塞住,Application启动阶段本身就是主线程,稍有不慎就把启动耗时拖高了。真需要同步读取的话,应该把启动逻辑交给后台线程,等数据就绪后通过Flow分发。
2.3 从 SharedPreferences 平滑迁移
DataStore官方提供了迁移方案,不用手动搬数据。构建DataStore时传入迁移列表:
kotlin复制val Context.userDataStore: DataStore<Preferences> by preferencesDataStore(
name = "user_prefs",
migrations = listOf(
SharedPreferencesMigration(context, "old_prefs")
)
)
这个迁移会在DataStore第一次被访问时自动执行,把所有SharedPreferences里的key-value搬到DataStore,搬完之后旧数据也不会自动删除,方便你确认迁移无误后手动清理。
需要注意三个细节。第一,迁移只执行一次,如果用户升级前已经跑过新版本,这步不会再触发;第二,如果同一份数据在新版本里已经写入,DataStore会以新写入为准,不会拿旧数据覆盖新数据;第三,迁移逻辑必须放在首次访问DataStore之前配置好,等DataStore文件已经创建了,迁移列表再传入也不会生效。
如果旧数据的key命名比较杂乱,比如用了"user_name"和"username"两套,可以给SharedPreferencesMigration传入keysToMigrate参数,只迁移指定的key集合,其余留在旧文件里一次性清理。
2.4 Preferences 和 Proto DataStore 怎么选
Preferences DataStore处理的是松散定义的Key-Value,每个Key单独定义,适合配置项不多、结构简单、字段增删频率低的场景。Proto DataStore则要求你先把Schema定义成.proto文件,生成的代码类就是强类型对象,整个配置项作为一个整体读写。
从我的实际经验看,绝大多数项目的配置都可以用Preferences DataStore解决。什么时候才需要Proto?当你的配置项之间存在约束关系时,比如某个配置的合法值和另一个配置相关,或者有嵌套结构、枚举类型、列表字段时,Proto的价值才体现出来。
一个我踩过的例子:曾经有个项目在Preferences里存了一个JSON数组表示推送时间段,后来需要加时区字段,改的时候发现读写逻辑散落在三个文件里,每个文件都要单独解析JSON。这种情况如果一开始用Proto DataStore,改字段就是改.proto文件然后重新生成代码,全局强类型约束,不会出现漏改的地方。
Proto DataStore集成成本高一些,需要配置Protobuf插件,代码生成也稍麻烦:
protobuf复制syntax = "proto3";
message UserPreferences {
string user_name = 1;
bool dark_mode = 2;
repeated int32 notify_hours = 3;
}
kotlin复制val Context.userDataStore: DataStore<UserPreferences> by dataStore(
fileName = "user_prefs.pb",
serializer = UserPreferencesSerializer()
)
判断标准很简单:你的配置是一个"扁平map"还是"结构化对象"。前者用Preferences,后者用Proto。
2.5 怎么查看 DataStore 的落盘文件
调试时经常需要确认数据有没有真正写入,DataStore文件在/data/data/包名/files/datastore/目录下。真机调试时用:
bash复制adb shell run-as 包名 ls files/datastore/
adb shell run-as 包名 cat files/datastore/user_prefs.preferences_pb
Preferences DataStore的落盘格式是二进制protobuf,直接cat大概率是一堆乱码。我的做法是看到文件大小有变化就说明写入生效了,要细看内容的话,可以临时在代码里把读取结果打进日志:
kotlin复制viewModelScope.launch {
context.userDataStore.data.first().also { prefs ->
val value = prefs[KEY_USER_NAME]
Log.d("DataStoreDebug", "user_name=$value")
}
}
值得注意的是Android 11以后通过文件管理器已经几乎无法访问Android/data目录了,所以抓日志是最可靠的调试手段,别指望在手机里翻文件来看。
3. Room 落地:从三件套到数据外供的完整链路
3.1 Entity、DAO、Database 一条线
Room的使用模式非常固定,三件套缺一不可。实体类用来定义表结构:
kotlin复制@Entity(tableName = "user")
data class UserEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
@ColumnInfo(name = "name")
val name: String,
@ColumnInfo(name = "age")
val age: Int,
@ColumnInfo(name = "avatar")
val avatar: String? = null
)
DAO负责绑定SQL:
kotlin复制@Dao
interface UserDao {
@Query("SELECT * FROM user ORDER BY id DESC")
fun observeAll(): Flow<List<UserEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(user: UserEntity)
@Query("SELECT * FROM user WHERE id = :id")
suspend fun getUserById(id: Long): UserEntity?
@Query("DELETE FROM user WHERE id = :id")
suspend fun deleteById(id: Long)
}
Database类做汇总:
kotlin复制@Database(entities = [UserEntity::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
}
其中DAO接口不需要写实现,Room在编译期生成,所以每次改DAO方法后记得重新编译,生成的实现类在build/generated/下面,可以在那里看到Room到底做了什么。实体类的每个字段默认都会映射成列,标记@ColumnInfo可以自定义列名,不标记就用字段名;@PrimaryKey主键必须有,如果自增就加上autoGenerate = true。
3.2 异步姿势:Flow、suspend 和普通方法
Room对不同场景提供了三种返回值形式。
返回Flow<List<T>>是推荐写法,查询结果发生变化时会自动重新执行查询并推送新数据,配合collectLatest或者flatMapLatest可以实现非常顺滑的UI更新。表里任何一条数据被插入、修改、删除,Room都会感知到,你不需要手动刷新。
返回suspend的普通数据是另一种常见形式。比如拉取某个id的详情,只需要一次性结果:
kotlin复制@Query("SELECT * FROM user WHERE id = :id")
suspend fun getUserById(id: Long): UserEntity?
调用的时候Room会自动把查询放到ioDispatcher上。这里有一个很多人忽略的点:suspend fun在实现上依然会走后台Dispatcher,但如果你在DAO里返回的是LiveData或普通List,Room就需要你自己保证别在主线程调用,否则会触发IllegalStateException: Cannot access database on the main thread since the OS may be locked while accessing the database。
Room提供了allowMainThreadQueries()这个选项,但那是给单元测试用的,生产环境一旦开启,写在主线程的查询都会跑在主线程,页面卡顿是必然的。
3.3 类型转换器:让复杂字段进表
SQLite原生只支持TEXT、INTEGER、REAL、BLOB等基础类型,一个包含List的实体类不能直接存。这时候要用TypeConverter:
kotlin复制class StringListConverter {
@TypeConverter
fun fromList(list: List<String>): String = list.joinToString(",")
@TypeConverter
fun toList(data: String): List<String> =
if (data.isEmpty()) emptyList() else data.split(",")
}
在实体字段上直接引用:
kotlin复制@Entity(tableName = "user")
data class UserEntity(
@PrimaryKey val id: Long = 0,
val name: String,
val tags: List<String>
)
然后在@Database上注册:
kotlin复制@TypeConverters(StringListConverter::class)
@Database(entities = [UserEntity::class], version = 1)
abstract class AppDatabase : RoomDatabase()
注意,把大对象塞进一个字段是反模式。比如有人把一个完整的JSON字符串存在一个TEXT列里,这虽然能用TypeConverter实现,但失去了SQL的所有优势:不能过滤、不能排序、不能联表。遇到这种情况,应该拆成子表用外键关联,或者考虑是不是该换一种存储方案。TypeConverter适合存List、枚举、日期这类轻量转换,不是用来当JSON仓库的。
3.4 表结构升级:迁移而不是删表重建
这是Room最值得写进最佳实践的一环。很多开发者在表结构变更时直接用fallbackToDestructiveMigration(),图省事,结果用户升级版本后,本地数据全没了,而且毫无提示。
正确的做法是写Migration。举个实际例子,给user表加一个avatar字段,从版本1升到版本2:
kotlin复制val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE user ADD COLUMN avatar TEXT")
}
}
构建Database时传入:
kotlin复制Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.addMigrations(MIGRATION_1_2)
.build()
这里有个容易踩的坑:Migration在migrate()里执行SQL时,Room并不知道改动后的表结构和Entity是否一致,所以每次迁移后都要立即更新@Database里的version,如果版本号没变,Room会认为迁移路径不完整,直接抛异常。
另一个坑是:复杂迁移绝不能只靠一个ALTER TABLE。几年前的版本如果从版本1直接升到版本5,Room会执行MIGRATION_1_2、MIGRATION_2_3、MIGRATION_3_4、MIGRATION_4_5整个链路,任何一个迁移出错,整个升级都会失败。所以每次改动都要新写一个迁移,不要在旧的迁移方法里改来改去。
3.5 用 ContentProvider 把 Room 数据提供给外部应用
有人问"Room表和ContentProvider如何配合给外部应用提供数据",这个需求在桌面小组件、扫描工具、系统设置页等场景很常见。做法是在自定义ContentProvider的query方法里调用Room的DAO。
实现在Provider的onCreate里拿到AppDatabase实例,然后用contentResolver.query(uri)接收外部调用。关键点在权限:数据一旦通过ContentProvider暴露出去,等同让任意应用都能访问,URI的authority要起得足够独特,同时在manifest里配置readPermission或android:permission,只给授过权的应用开放。
另一个思路是使用FileProvider共享特定文件,但从数据库实时查询的角度看,ContentProvider还是更直接。实际项目中我更建议你先想清楚:是不是真的需要把数据暴露给外部?如果只是自己App内部多个进程需要访问,更优雅的方案是用同一个Room数据库加多进程支持,而不是绕一圈走ContentProvider。
4. Repository 统一收口:DataStore 与 Room 的协作分工
4.1 一个真实App的持久化分层
我手头有个健身记录App,数据量不大但结构复杂:用户信息、训练计划、历史记录、每日摄入、用户偏好设置。最初的版本全塞在几个实体表里,连"是否开启通知"这种开关都建了张表,写起来累,读起来也怪。
重构后我按数据特性分了层:用户偏好、是否新手引导、筛选条件这些轻量数据进DataStore;训练记录、计划模板、用户体重历史这些业务实体进Room;图片资源存缓存目录,数据库只存文件路径。分层之后,模块之间的依赖关系一下子清晰了,UI层只面对Repository接口,不知道背后是DataStore还是Room。
| 数据 | 持久化方案 | 原因 |
|---|---|---|
| 用户登录态、token | DataStore | 需要快速读取,不需要查询 |
| 是否首次进入引导页 | DataStore | 单个key,不需要表结构 |
| 训练计划列表 | Room | 需要分页、排序、关联历史记录 |
| 历史体重曲线 | Room | 需要按时间范围过滤、聚合统计 |
| 图片文件 | 文件系统 + 数据库存路径 | 文件本身不适合进数据库 |
4.2 边界怎么切才不用返工
判断一个数据是进DataStore还是Room,我有一条很实用的经验规则:如果你发现自己在给DataStore的Key起名字时带上了list、query、filter这种词,说明这段数据该进Room了。比如有人定义stringPreferencesKey("user_list_json"),把整个用户列表JSON序列化塞进去,还安慰自己"数据量不大"——这种数据一旦需要按name过滤,就得全文读出来再在内存里循环,毫无SQL优势可言。
反过来,如果你给Room建表的字段大多是bool、int开关,表里记录数永远不超过几十条,那这个表还不如用DataStore。我见过一个项目为了存三个开关状态建了张表,DAO写了八个方法,维护成本是DataStore的十倍,收益为零。
还有一个容易忽视的查询场景:需要跨表联查的,比如"按用户收藏状态过滤出来的训练计划列表"。这种数据天然适合放在Room里用@Relation或JOIN实现,你要是拆到两个DataStore的Key里,每次还要自己写合并逻辑,纯属造轮子。
4.3 Repository 模式把底层切换成本降到最低
Repository的核心价值是让上层不感知持久化方案的变化。定义一个接口:
kotlin复制interface UserRepository {
val userName: Flow<String>
fun observeUsers(): Flow<List<UserEntity>>
suspend fun saveUserName(name: String)
suspend fun addUser(user: UserEntity)
}
DataStore和Room分别实现各自的部分:
kotlin复制class UserRepositoryImpl(
private val context: Context,
private val userDao: UserDao
) : UserRepository {
override val userName: Flow<String> =
context.userDataStore.data.map { prefs -> prefs[KEY_USER_NAME] ?: "" }
override fun observeUsers(): Flow<List<UserEntity>> = userDao.observeAll()
override suspend fun saveUserName(name: String) {
context.userDataStore.edit { prefs ->
prefs[KEY_USER_NAME] = name
}
}
override suspend fun addUser(user: UserEntity) {
userDao.insert(user)
}
}
ViewModel只依赖UserRepository接口,不关心实现细节。将来DataStore升级成Proto,或者Room换成其他数据库,只需要换UserRepositoryImpl一个类,上层代码一行都不用改。这套模式在当前项目里帮我省掉了很多次全局搜索替换的时间。
5. 踩坑实录:迁移、混淆、测试这些事不能省
5.1 主线程读数据引发的启动卡顿
某次版本发布后,线上反馈App启动慢,抓了trace发现Application的onCreate里有一段同步读取SharedPreferences的逻辑,读取的是一个包含几百个key的配置文件,每次启动都要在主线程执行一次全量解析。后来改造时,这块配置本来打算直接迁到DataStore,结果为了"兼容老数据"还是在启动阶段用runBlocking堵了一下,启动耗时照样飙上去。
正确做法是启动时用suspend在后台读,需要首屏展示的数据进内存缓存,等DataStore的Flow发射出来后再刷新UI。DataStore在设计上已经把所有磁盘读写放到了IO线程,你只要不在启动阶段手动runBlocking,就不会有ANR风险。
5.2 R8 混淆后 Room 崩了一整片
有一个版本发出去之后,线上大量IllegalArgumentException: The columns returned by the query does not have the fields的崩溃。排查下来不是Room本身的问题,而是老项目里有人手动给Room相关的类加了keep规则,把Room生成的一些辅助类给内联/删除了,导致DAO方法在运行时拿到的列信息和表结构对不上。
现在主流版本的Room和DataStore都自带consumer rules,库内部已经配置好了混淆白名单,正常情况下不需要你手动去keep任何Room类。如果你在源码里看到了-keep class androidx.room.**这种规则,大概率是历史遗留,反而容易出问题。排查时先关掉minifyEnabled,看崩溃是否消失,如果消失,就从混淆规则入手,而不是怀疑Room本身。
5.3 版本升级把用户全量数据删了个干净
有次改表结构,把user表加了关联到plan表的外键,当时赶工期顺手写了fallbackToDestructiveMigration(),想着本地数据丢了也就丢了。结果发布后一堆用户反馈历史训练记录全不见了,支持群里炸了锅,那段时间我每天打开工单都是"我的三年训练数据呢"。
从那以后我定了三条规矩:生产环境绝不允许fallbackToDestructiveMigration();每次数据库版本变更必须配套一个Migration;每个Migration都要写迁移测试。迁移测试用MigrationTestHelper,先把旧版本的表结构创建出来,插入几条数据,然后执行迁移,最后断言新表结构和数据完整性。这个测试成本不高,但能在发布前把"升级丢数据"这个隐患彻底堵死。
kotlin复制@RunWith(AndroidJUnit4::class)
class MigrationTest {
private val testHelper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java
)
@Test
fun migrate1To2_containsAvatarColumn() {
testHelper.createDatabase(TEST_DB_NAME, 1).apply {
execSQL("INSERT INTO user (name, age) VALUES ('张三', 18)")
close()
}
testHelper.runMigrationsAndValidate(
TEST_DB_NAME,
2,
true,
MIGRATION_1_2
)
}
companion object {
private const val TEST_DB_NAME = "migration-test.db"
}
}
5.4 测试持久化层的正确姿势
Room的单元测试优先用内存数据库,速度快、测试完自动销毁:
kotlin复制@RunWith(AndroidJUnit4::class)
class UserDaoTest {
private lateinit var db: AppDatabase
private lateinit var userDao: UserDao
@Before
fun setup() {
db = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
AppDatabase::class.java
).build()
userDao = db.userDao()
}
@After
fun tearDown() {
db.close()
}
@Test
fun insertAndQuery() = runTest {
userDao.insert(UserEntity(name = "张三", age = 18))
val list = userDao.observeAll().first()
assertEquals(1, list.size)
assertEquals("张三", list[0].name)
}
}
DataStore的测试比Room麻烦一点,要用临时文件隔离测试数据。有个比较省事的办法:把DataStore实例通过构造器注入,测试时用自己的临时上下文或临时目录构造,避免污染真实数据。具体可以用tmpFolder.newFolder()配合createTempFile()。
我踩过的测试坑是:DAO测试里用了runTest没控制好协程调度器,Flow.first()等不到数据,测试超时。后来统一在DAO层测试里用runBlocking,在ViewModel层才做协程调度器的注入和替换,两边互不干扰,测试稳稳的。
5.5 DataStore 和 Room 的异常处理策略
DataStore的data流如果读取过程中出现异常,Flow会直接抛错,默认行为是异常终止整个流。所以在UI层做collect时,要记得加catch或者用catch操作符兜底:
kotlin复制val userNameFlow: Flow<String> = context.userDataStore.data
.map { prefs -> prefs[KEY_USER_NAME] ?: "" }
.catch { exception ->
exception.printStackTrace()
emit("")
}
Room的查询异常相对好处理,因为它只会在编译期检查SQL和运行时检查字段映射,但SQLiteConstraintException这类冲突异常还是要靠OnConflictStrategy兜住。我在插入数据时统一用OnConflictStrategy.REPLACE,配合唯一索引,基本不会出现插入崩溃。
两个仓库在异常处理上的策略简单说就是:DataStore异常别让它静默,至少打日志;Room冲突在DAO层就策略化,别写一堆try-catch在业务里到处补。
我在实际项目里总结出的规律是:数据持久化没有银弹,但有一个相对稳定的配方——凡是用户能感知的配置项、登录态这类轻量数据,无脑进DataStore;凡是需要列表展示、条件过滤、跨模块联查的业务数据,交给Room;至于文件、图片这些资源,不要硬塞进这两种仓库里。这套配方支撑我最近三个项目都没在持久化上返过大工,希望你也能用得上。
最后分享一个我一直在用的调试技巧:Room的数据库文件可以导出到桌面端,用SQLite工具直接看表结构和数据。线上用户反馈数据不对时,抓一份崩溃日志和数据库文件做对比,很快就能定位是数据没写入、写错了,还是被错误覆盖。数据持久化的价值,就是在出问题时能让你还原现场,而不是靠猜。
