Android Room 数据一致性与事务设计
Android Room 数据一致性与事务设计
本地数据库不仅是接口数据的缓存,也常用于保存用户草稿、离线任务和关键业务快照。数据量增加后,常见问题包括多表更新只成功一半、查询越来越慢、数据库升级失败,以及界面观察到短暂的不一致状态。Room 提供了编译期校验和事务能力,但数据边界仍需要开发者根据业务设计。
一、从稳定的数据模型开始
数据库实体应围绕持久化需求设计,不必与接口对象或界面模型完全相同。接口字段变化频繁,界面又包含大量格式化内容,直接复用会让数据库结构被无关变化牵动。
@Entity(
tableName = "article",
indices = [Index(value = ["categoryId", "publishTime"])]
)
data class ArticleEntity(
@PrimaryKey val id: String,
val categoryId: String,
val title: String,
val summary: String,
val publishTime: Long,
val read: Boolean
)
主键应使用稳定业务标识。索引针对真实查询条件建立,索引过多会增加写入和存储成本,不应为每个字段都创建索引。
二、明确实体、领域对象与界面模型
可以在数据层完成实体与领域对象的转换:
fun ArticleEntity.toDomain(): Article {
return Article(
id = id,
categoryId = categoryId,
title = title,
summary = summary,
publishTime = publishTime,
isRead = read
)
}
分层的价值在于隔离变化,而不是增加代码数量。如果业务模型与表结构长期一致、项目本身也采用直接映射,就应遵循现有风格,无需机械增加转换层。
三、用事务保证多步操作原子性
刷新列表时,经常需要删除旧数据、插入新数据并更新同步时间。这些步骤要么全部成功,要么全部失败。
@Dao
abstract class ArticleDao {
@Query("DELETE FROM article WHERE categoryId = :categoryId")
protected abstract suspend fun deleteByCategory(categoryId: String)
@Insert(onConflict = OnConflictStrategy.REPLACE)
protected abstract suspend fun insertAll(items: List<ArticleEntity>)
@Query("UPDATE sync_state SET updateTime = :time WHERE categoryId = :categoryId")
protected abstract suspend fun updateSyncTime(
categoryId: String,
time: Long
)
@Transaction
open suspend fun replaceCategory(
categoryId: String,
items: List<ArticleEntity>,
updateTime: Long
) {
deleteByCategory(categoryId)
insertAll(items)
updateSyncTime(categoryId, updateTime)
}
}
如果任何一步抛出异常,整个事务会回滚,界面不会看到“旧数据已删、新数据未写入”的中间状态。
四、不要在事务中执行慢速外部操作
事务会占用数据库连接和锁。网络请求、长时间计算和文件操作不应放进数据库事务。
suspend fun refresh(categoryId: String) {
val response = remoteDataSource.queryArticles(categoryId)
val entities = response.map(articleMapper::toEntity)
articleDao.replaceCategory(
categoryId = categoryId,
items = entities,
updateTime = clock.currentTimeMillis()
)
}
先在事务外获取和转换数据,再用短事务完成持久化。接口成功但本地写入失败时如何表现,应由业务一致性要求决定。
五、用 Flow 观察数据库变化
DAO 查询可以返回 Flow,数据库内容变化后重新发出结果:
@Query(
"SELECT * FROM article " +
"WHERE categoryId = :categoryId " +
"ORDER BY publishTime DESC"
)
abstract fun observeArticles(categoryId: String): Flow<List<ArticleEntity>>
仓库可以把实体流转换为领域对象:
fun observeArticles(categoryId: String): Flow<List<Article>> {
return articleDao.observeArticles(categoryId)
.map { entities -> entities.map(ArticleEntity::toDomain) }
}
界面只观察数据库这一处数据源,刷新请求完成后由数据库变化驱动更新,可以减少接口结果和本地结果相互覆盖的问题。
六、处理并发写入
同一业务对象可能被刷新任务、用户操作和后台同步同时修改。事务只能保证一组语句的原子性,不能自动决定谁的业务优先级。
例如,用户刚把文章标记为已读,随后旧接口快照覆盖本地记录,就会让状态回退。可以把服务端字段与本地字段拆表保存,或者在写入映射时明确保留本地状态。采用哪种方案取决于服务端是否拥有该字段的最终解释权。
七、为查询建立边界
一次加载全部记录会增加内存和映射成本。长列表应结合项目分页方案,查询只返回页面实际需要的列和行。
data class ArticleTitleTuple(
val id: String,
val title: String,
val publishTime: Long
)
@Query(
"SELECT id, title, publishTime FROM article " +
"WHERE categoryId = :categoryId " +
"ORDER BY publishTime DESC LIMIT :limit"
)
abstract suspend fun queryLatest(
categoryId: String,
limit: Int
): List<ArticleTitleTuple>
只查询所需列也能降低游标读取和对象创建成本。
八、认真设计数据库迁移
数据库结构升级必须描述从旧版本到新版本的确定变化,并使用真实旧库验证。正式数据不应依赖破坏性重建作为普通兜底,否则用户草稿或离线数据可能丢失。
迁移测试至少要验证:
- 旧表数据是否完整保留。
- 新增字段的默认含义是否正确。
- 索引、外键和唯一约束是否符合预期。
- 连续跨多个历史版本能否升级。
- 升级后核心查询与写入是否正常。
九、测试事务失败路径
除了成功写入,还应模拟主键冲突、约束失败和中途抛出异常,确认事务确实回滚。并发场景中,要验证用户本地修改不会被旧快照覆盖。
总结
Room 能提供可靠的类型校验和事务执行,但一致性来自清晰的数据所有权。稳定设计实体,使用短事务完成相关写入,让界面观察单一数据库数据源,并用明确迁移保护历史数据,才能让本地存储在离线、并发和升级场景下保持可信。
- 点赞
- 收藏
- 关注作者
评论(0)