七、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,崩溃得一塌糊涂。

⚠️ 注意: 外键约束会带来额外的性能开销。每次插入或更新时,Room都要检查关联表。如果你的表数据量很大(比如百万级),建议谨慎使用外键,改由业务层保证数据一致性。

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高得多。

💡 小技巧: 如果嵌套对象里有字段名冲突,可以用prefix参数加前缀。比如 @Embedded(prefix = "addr_"),生成的列名就是addr_province、addr_city。

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。

📌 经验之谈: 我曾经在一个日活百万的App里,给一个几乎不查询的字段加了索引。结果插入性能下降了30%,被DBA骂了一顿。从那以后,我加索引前都会先问问自己:「这个字段真的经常被查吗?」

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字段自动计算偏移量,处理数据变化时自动刷新。你想想看,要是自己写,得处理多少边界情况?

⚠️ 注意: Paging3的Room集成有一个限制——PagingSource不支持包含@Relation的查询。如果你需要分页展示关联数据,建议先查主表,再在内存中组装关联数据。或者,干脆用flatMap手动处理。

本章小结

这一章内容不少,我们来捋一捋:

  • @ForeignKey:保证数据完整性,但别滥用
  • @Relation:优雅地查关联数据,记得加@Transaction
  • @Embedded:把嵌套对象平铺到表里,查询效率高
  • 索引:查得快,写得慢,权衡着用
  • 事务+批量操作:性能提升5-10倍,必须掌握
  • Paging3集成:一行代码搞定分页,但注意关联查询的限制

嗯,Room的核心内容基本就这些了。下一章我们换个话题,聊聊WorkManager——后台任务的正确打开方式。


公众号:蓝海资料掘金营,微信deep3321