七、Room数据库(下):多表关联、嵌套关系与性能优化
上一章我们把Room的基础用法过了一遍,增删改查都能跑了。但真实项目里,数据之间的关系远比单表复杂。用户有订单,订单有商品,商品还有分类……这种时候,单表操作就捉襟见肘了。
这一章,我们来啃硬骨头——多表关联、嵌套对象、还有性能优化。嗯,内容不少,但都是实战中绕不开的。
7.1 外键约束:@ForeignKey
先说说外键。说白了,就是告诉数据库:「这两张表有关系,别让我插脏数据」。
举个例子。用户表和订单表,一个用户可以有多个订单。那订单表里肯定要存一个userId,指向用户表的主键。这时候,我们就可以用@ForeignKey来约束这个关系。
@Entity
data class User(
@PrimaryKey val uid: Int,
@ColumnInfo(name = "user_name") val userName: String
)
@Entity(
foreignKeys = [
ForeignKey(
entity = User::class,
parentColumns = ["uid"],
childColumns = ["user_id"],
onDelete = ForeignKey.CASCADE,
onUpdate = ForeignKey.CASCADE
)
]
)
data class Order(
@PrimaryKey val orderId: Int,
val user_id: Int,
val amount: Double
)
这里有个细节——onDelete = CASCADE。什么意思?就是用户删了,他所有的订单也自动删掉。我在项目里见过有人忘了配这个,结果用户注销了,订单表里一堆孤儿数据,查起来全是null,崩溃得一塌糊涂。
7.2 @Relation:优雅地查询关联数据
外键只是约束,真正查数据还得靠@Relation。这个注解,是我个人觉得Room最优雅的设计之一。
你想啊,一个用户和他的订单,在Java/Kotlin里就是一个对象嵌套另一个对象的集合。但数据库里是两张表。怎么把两张表的数据拼成一个对象?以前用SQLite,你得手动写join,然后自己组装。Room用@Relation帮你干了这活。
data class UserWithOrders(
@Embedded val user: User,
@Relation(
parentColumn = "uid",
entityColumn = "user_id"
)
val orders: List<Order>
)
然后写个DAO方法:
@Transaction
@Query("SELECT * FROM User")
suspend fun getUsersWithOrders(): List<UserWithOrders>
注意那个@Transaction。为什么加?因为Room要查两次——先查用户,再根据用户id查订单。不加这个注解,可能会读到不一致的数据。我刚开始用Room时就踩过这个坑,查出来的订单和用户对不上,排查了半天。
7.3 嵌套关系与@Embedded
有时候,我们不想搞那么复杂的一对多关系,只是想把一个对象的字段平铺到表里。比如地址——省、市、区、详细地址,四个字段。每次建表都写四列?太啰嗦了。
@Embedded就是干这个的。它把嵌套对象的字段「展开」到父表中。
data class Address(
val province: String,
val city: String,
val district: String,
val detail: String
)
@Entity
data class User(
@PrimaryKey val uid: Int,
val name: String,
@Embedded val address: Address
)
这样,User表里就会有province、city、district、detail四列,而不是一个address列。查询时可以直接用这些字段做条件,效率比存JSON高得多。
7.4 数据库性能优化三板斧
好了,关联讲完了。接下来是重头戏——性能优化。我见过太多App,功能没问题,就是卡。一查,数据库操作全在主线程,或者没有索引,或者一条一条插入数据。嗯,这三个坑,我们一个个填。
7.4.1 索引:查询加速器
索引是什么?说白了就是书的目录。没有索引,查数据就是全表扫描,一条一条比对。有索引,直接跳到目标位置。
在Room里加索引很简单:
@Entity(indices = [Index(value = ["user_id"], unique = false)])
data class Order(
@PrimaryKey val orderId: Int,
val user_id: Int,
val amount: Double
)
我建议,凡是WHERE条件里用到的字段,都考虑加索引。但别滥用——索引会加快查询,但会拖慢插入和更新。因为每次写数据,索引也要重建。这是个 trade-off。
7.4.2 事务:批量操作的利器
你有没有遇到过这种情况:要插入1000条数据,一条一条插,花了10秒钟。用户等得花儿都谢了。
问题出在哪?每条插入都是一个独立的事务。事务是有开销的——开启、提交、刷盘,每一步都要时间。1000条就是1000次事务开销。
解决方案?把1000条放在一个事务里。
@Transaction
suspend fun insertUsers(users: List<User>) {
users.forEach { userDao.insert(it) }
}
就这么简单。实测下来,批量插入1000条数据,加事务比不加快5-10倍。我在项目里做过压测,不加事务耗时8秒,加了事务1.2秒。差距就是这么明显。
7.4.3 批量操作:别一条一条来
除了事务,Room还提供了批量操作的API。比如insertAll:
@Insert
suspend fun insertAll(users: List<User>)
这个比在事务里循环插入还要快。为什么?因为Room内部做了优化,把多条SQL合并成一条执行。减少了解析SQL的开销。
我个人的习惯是:能用批量API就用批量API,实在不行再考虑事务+循环。两条路都比一条一条插快得多。
7.5 Room与Paging3的集成
最后,聊聊分页。列表数据无限加载,这是移动端的标配。自己写分页逻辑?太累了。要处理加载状态、重试、刷新、预加载……Paging3把这些都封装好了。
Room和Paging3的集成,简单得让人感动。只需要改一下DAO的返回类型:
@Query("SELECT * FROM User ORDER BY uid ASC")
fun getUsersPaged(): PagingSource<Int, User>
然后在ViewModel里:
val pager = Pager(
config = PagingConfig(pageSize = 20, enablePlaceholders = false),
pagingSourceFactory = { userDao.getUsersPaged() }
).flow.cachedIn(viewModelScope)
UI层直接用collectAsLazyPagingItems()(Compose)或者submitData()(RecyclerView)消费这个Flow就行了。
为什么这么简单?因为Room的PagingSource实现是自动生成的。它会根据你的ORDER BY字段自动计算偏移量,处理数据变化时自动刷新。你想想看,要是自己写,得处理多少边界情况?
PagingSource不支持包含@Relation的查询。如果你需要分页展示关联数据,建议先查主表,再在内存中组装关联数据。或者,干脆用flatMap手动处理。
本章小结
这一章内容不少,我们来捋一捋:
- @ForeignKey:保证数据完整性,但别滥用
- @Relation:优雅地查关联数据,记得加@Transaction
- @Embedded:把嵌套对象平铺到表里,查询效率高
- 索引:查得快,写得慢,权衡着用
- 事务+批量操作:性能提升5-10倍,必须掌握
- Paging3集成:一行代码搞定分页,但注意关联查询的限制
嗯,Room的核心内容基本就这些了。下一章我们换个话题,聊聊WorkManager——后台任务的正确打开方式。
公众号:蓝海资料掘金营,微信deep3321