13、关系型数据:@ForeignKey外键、@Relation注解、嵌套关系、一对一、一对多、多对多

说实话,Room 如果只能存单表,那跟 SharedPreferences 也没啥区别了。真正让它强大的,是处理表与表之间的关系。这一章,我们就来啃这块硬骨头。

我记得刚接触 Room 时,最困惑的就是「外键到底要不要加?」、「加了会不会影响性能?」。后来踩过坑才明白——外键是数据库层面的约束,而 @Relation 是代码层面的便利工具。两者各司其职,配合起来才顺手。

核心知识点速览:
  • @ForeignKey:在数据库层面建立约束,保证数据完整性
  • @Relation:在代码层面简化嵌套对象的查询
  • 一对一、一对多、多对多:三种常见关系的建模方式

13.1 为什么需要关系型数据?

你想想看,一个用户有多条订单,一条订单包含多个商品。如果全塞在一张表里,字段会爆炸,数据冗余到怀疑人生。关系型数据库就是用来解决这个问题的——把数据拆成多张表,通过「关系」把它们连起来。

Room 作为 ORM 框架,提供了两种手段来管理关系:

  • 数据库层:用 @ForeignKey 声明外键,让 SQLite 帮你检查数据一致性
  • 代码层:用 @Relation 注解,把关联查询的结果自动映射成嵌套对象

我个人习惯是:关键业务数据必加外键,非核心数据(比如日志)可以不加,省点性能开销。

13.2 @ForeignKey:数据库层面的约束

先看一个最简单的例子——用户和订单的一对多关系。

@Entity
data class User(
    @PrimaryKey val userId: Long,
    val name: String
)

@Entity(
    foreignKeys = [
        ForeignKey(
            entity = User::class,
            parentColumns = ["userId"],
            childColumns = ["userId"],
            onDelete = ForeignKey.CASCADE,
            onUpdate = ForeignKey.CASCADE
        )
    ]
)
data class Order(
    @PrimaryKey val orderId: Long,
    val userId: Long,  // 外键字段
    val amount: Double
)

这里有几个关键点:

  • parentColumns:父表的主键(或唯一键)
  • childColumns:子表的外键字段
  • onDelete / onUpdate:级联操作策略
级联策略怎么选?
  • CASCADE:删除用户时,自动删除他的所有订单。适合强关联场景。
  • SET_NULL:删除用户时,订单的 userId 置为 null。适合弱关联。
  • RESTRICT:如果用户还有订单,禁止删除。适合需要手动清理的场景。
  • NO_ACTION:不自动处理,由开发者自己维护。

我曾经在一个项目中用了 RESTRICT,结果上线后发现用户注销时一直报外键冲突。后来改成 CASCADE 才解决。嗯,这里要注意——级联策略一定要跟业务逻辑匹配,别想当然。

13.3 @Relation:代码层面的便利工具

加了外键之后,怎么查询用户和他的所有订单?传统做法是写两个查询,然后在代码里手动组装。Room 提供了 @Relation 注解,可以自动完成这个组装过程。

首先,定义一个包含关系的 POJO 类:

data class UserWithOrders(
    @Embedded val user: User,
    @Relation(
        parentColumn = "userId",
        entityColumn = "userId"
    )
    val orders: List<Order>
)

然后,在 DAO 中写一个查询:

@Transaction
@Query("SELECT * FROM User")
suspend fun getUsersWithOrders(): List<UserWithOrders>

注意这里加了 @Transaction 注解。为什么?因为 Room 需要执行两条 SQL(先查 User,再查 Order),为了保证数据一致性,必须放在一个事务里。

重要提醒:
  • @Relation 只能用在 POJO 类中,不能直接用在 Entity 上
  • 查询方法必须加 @Transaction,否则会报错
  • parentColumn 和 entityColumn 的名称必须跟数据库字段一致

13.4 一对一关系

一对一关系比较少见,但确实存在。比如一个用户对应一个身份证信息。

@Entity
data class IdCard(
    @PrimaryKey val idCardId: Long,
    val userId: Long,  // 外键
    val cardNumber: String
)

data class UserWithIdCard(
    @Embedded val user: User,
    @Relation(
        parentColumn = "userId",
        entityColumn = "userId"
    )
    val idCard: IdCard?  // 注意这里是单个对象,不是 List
)

查询方式跟一对多几乎一样,只是返回的字段类型从 List 变成了单个对象(或可空对象)。

13.5 一对多关系

这就是我们前面一直在讲的——一个用户有多条订单。这是最常用的关系类型。

我再补充一个实际场景:分类和商品。一个分类下有多个商品,这是典型的「一对多」。

data class CategoryWithProducts(
    @Embedded val category: Category,
    @Relation(
        parentColumn = "categoryId",
        entityColumn = "categoryId"
    )
    val products: List<Product>
)

我个人习惯在查询时加上排序:

@Transaction
@Query("SELECT * FROM Category ORDER BY sortOrder ASC")
suspend fun getCategoriesWithProducts(): List<CategoryWithProducts>

这样拿到的数据直接就能展示,不用再在 UI 层排序。

13.6 多对多关系

多对多关系需要一张中间表。比如「订单和商品」——一个订单包含多个商品,一个商品也出现在多个订单中。

先定义中间表:

@Entity(
    primaryKeys = ["orderId", "productId"],
    foreignKeys = [
        ForeignKey(entity = Order::class, parentColumns = ["orderId"], childColumns = ["orderId"]),
        ForeignKey(entity = Product::class, parentColumns = ["productId"], childColumns = ["productId"])
    ]
)
data class OrderProductCrossRef(
    val orderId: Long,
    val productId: Long,
    val quantity: Int  // 中间表可以带额外字段
)

然后定义关系 POJO:

data class OrderWithProducts(
    @Embedded val order: Order,
    @Relation(
        parentColumn = "orderId",
        entityColumn = "productId",
        associateBy = Junction(
            value = OrderProductCrossRef::class,
            parentColumn = "orderId",
            entityColumn = "productId"
        )
    )
    val products: List<Product>
)

这里的关键是 associateBy 参数,它告诉 Room 通过哪张中间表来关联。

多对多的查询同样需要 @Transaction:
@Transaction
@Query("SELECT * FROM `Order`")
suspend fun getOrdersWithProducts(): List<OrderWithProducts>

13.7 嵌套关系:三层甚至更多

有时候我们需要嵌套多层关系。比如「用户 -> 订单 -> 商品」。Room 支持这种嵌套,但需要逐层定义 POJO。

data class OrderWithProducts(
    @Embedded val order: Order,
    @Relation(
        parentColumn = "orderId",
        entityColumn = "orderId",
        associateBy = Junction(
            value = OrderProductCrossRef::class,
            parentColumn = "orderId",
            entityColumn = "productId"
        )
    )
    val products: List<Product>
)

data class UserWithOrdersAndProducts(
    @Embedded val user: User,
    @Relation(
        parentColumn = "userId",
        entityColumn = "userId"
    )
    val orders: List<OrderWithProducts>  // 嵌套使用
)

查询时只需要查最外层:

@Transaction
@Query("SELECT * FROM User")
suspend fun getUsersWithOrdersAndProducts(): List<UserWithOrdersAndProducts>

Room 会自动递归地组装所有嵌套关系。不过要注意——嵌套层数越多,查询性能越差。我建议最多嵌套三层,超过三层就考虑用其他方式(比如手动查询组装)。

13.8 避坑指南

最后,分享几个我踩过的坑:

  • 外键字段必须建立索引:否则关联查询会全表扫描。用 @ColumnInfo(index = true) 即可。
  • @Relation 不支持懒加载:所有关联数据会一次性查出来。如果数据量大,考虑分页。
  • 中间表的主键:多对多的中间表,主键通常是两个外键的组合。用 primaryKeys 声明。
  • @Transaction 不是可选的:忘了加会直接 crash,别问我怎么知道的。
总结一下:
  • @ForeignKey 是数据库层面的约束,保证数据完整性
  • @Relation 是代码层面的便利,简化嵌套查询
  • 一对一用单个对象,一对多用 List,多对多用中间表 + Junction
  • 嵌套关系最多三层,多了性能扛不住
Room 关系型数据核心结构 User 表 userId (PK) name Order 表 orderId (PK) userId (FK) amount OrderProduct orderId (FK) productId (FK) quantity Product 表 productId (PK) name price 一对多 多对多 多对多 图例: 主表 从表(含外键) 中间表 关联表

好了,这一章的内容就到这里。关系型数据是 Room 的核心能力,掌握了它,你就能应对绝大多数业务场景。下一章我们会聊聊数据库迁移和版本管理——嗯,那又是一个容易踩坑的地方。

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