实体(Entity)定义与注解归纳总结
Room 的实体(Entity),说白了就是一张数据库表的映射。你用 Kotlin 数据类或者 Java 类加几个注解,Room 就能自动帮你建表、读写。我记得刚接触 Room 时,觉得它跟 ORM 差不多,但用久了才发现——注解的细节才是坑最多的地方。
@Entity 注解:一张表的基本声明
每个实体类头上必须写 @Entity,不然 Room 不认识它。你还可以指定表名,默认是类名。我个人习惯总是显式写 tableName,因为重构类名时表名不会跟着变,避免迁移麻烦。
@Entity(tableName = "users")
data class User(
@PrimaryKey val id: Long,
val name: String,
val age: Int
)
关键点: 如果你不写 tableName,Room 会用类名的下划线形式(比如 User → User),但大小写保持不变。这点跟一些 ORM 的命名策略不同。
主键:@PrimaryKey 的两种玩法
主键是实体的核心。Room 支持单个主键和复合主键。单主键最简单:
@Entity
data class Book(
@PrimaryKey val isbn: String,
val title: String,
val author: String
)
复合主键用 primaryKeys 属性:
@Entity(primaryKeys = ["userId", "productId"])
data class CartItem(
val userId: Long,
val productId: Long,
val quantity: Int
)
我在项目中遇到过一个问题:复合主键的顺序很重要。Room 在生成联合唯一索引时,会按你声明的顺序创建。如果你后续加索引,可能会跟这个顺序冲突,导致 migrate 时报错。
字段映射:@ColumnInfo 和 @Ignore
默认情况下,Room 用字段名作为列名。想改列名?用 @ColumnInfo(name = "...")。比如 Kotlin 的 is 开头的布尔字段(如 isActive),Room 会自动把 is 去掉,变成 active,你最好手动指定列名避免意外。
不需要存到数据库的字段,加 @Ignore。这个注解可以用在字段上,也可以用在构造函数参数上。我曾经踩过一个坑:在数据类主构造里加 @Ignore,结果 Room 报错说构造函数参数不匹配——因为 Room 要求所有主构造参数要么都映射到列,要么用 @Ignore 明确排除。你想想看,一个字段如果只在内存中用,比如临时计算结果,忘了加 @Ignore,建表时就会多出一列,迁移脚本还得处理它。
| 注解 | 作用 | 注意事项 |
|---|---|---|
@Entity |
声明实体,指定表名、索引、外键等 | 类必须包含一个 public 或 internal 的无参构造(Kotlin 数据类自动满足) |
@PrimaryKey |
标记主键字段 | 可配合 autoGenerate = true 自增 |
@ColumnInfo |
定义列名、类型、排序方式等 | 布尔字段建议显式指定列名避免自动重命名 |
@Ignore |
忽略该字段,不创建列 | 主构造中的 @Ignore 必须配合 class 的默认值使用 |
@Embedded |
嵌入另一个实体或类,扁平化子字段到当前表 | 可用 prefix 避免列名冲突 |
@ForeignKey |
定义外键约束 | 需要在 @Entity 的 foreignKeys 属性中使用 |
@Index |
创建索引加速查询 | 可在 @Entity(indices = [...]) 中声明 |
索引:那些你没留意的性能细节
Room 允许你在 @Entity 的 indices 属性中直接创建索引。比如按用户名搜索:
@Entity(
tableName = "users",
indices = [Index(value = ["name"], unique = false)]
)
data class User(
@PrimaryKey val id: Long,
val name: String
)
唯一索引用 unique = true,相当于建了一个 UNIQUE 约束。我建议:每个实体都至少给常用查询字段加索引,哪怕只有一列。因为 Room 的编译时校验也会利用索引信息来优化生成的 DAO 实现。
小心索引列顺序: 复合索引时,最常过滤的列放在最前面。比如 Index(["city", "age"]) 对 WHERE city = ? AND age > ? 有效,但对 WHERE age > ? 基本无效。我曾经因为顺序搞反了,线上一个查询慢了几百毫秒,排查了半天才发现是索引没被用上。
关系注解:@Embedded 与 @ForeignKey
Room 不直接支持一对多或多对多的关系注解(不像 GreenDAO 那种有 @ToMany)。它用 @Embedded 把另一个对象的所有字段展平到当前表里。比如地址对象:
data class Address(
val street: String,
val city: String
)
@Entity
data class Person(
@PrimaryKey val id: Long,
val name: String,
@Embedded val address: Address
)
这样 persons 表会有 street 和 city 两列。如果两个实体都有同名属性(比如都叫 name),用 prefix 加前缀区分:@Embedded(prefix = "home_")。
外键约束在 @Entity(foreignKeys = [...]) 中声明。说实话,我很少在生产里用外键——因为 Room 的迁移处理外键删除/更新策略挺棘手,尤其在你有复杂约束时。我更喜欢在应用层通过 DAO 手动维护一致性,虽然代码多几行,但可控性强。
你的取舍: 如果团队对数据库完整性要求极高(比如金融类应用),外键可以上。否则,别给自己添麻烦。Room 官方文档也提到,外键在某些 SQLite 版本下有性能开销。
个人经验避坑:
- 默认值与空安全: Kotlin 数据类中,可空字段必须显式声明为
?类型,且赋予null默认值。Room 在构造对象时,如果数据库返回null而字段是非空类型,会直接崩溃。 - 构造函数参数顺序: Room 要求主构造参数的顺序与创建表的列顺序一致?不,它其实不要求,但如果你在
@Entity中使用了ignoredColumns或自定义类型转换器,参数顺序异常会导致编译失败。我的习惯是:主构造参数顺序按常用的列顺序写,方便调试 SQLite 的列映射。 - @Ignore 与继承: 如果你有一个基类 BaseEntity,子类继承它。Room 无法自动忽略基类字段,你必须在每个子类上手动
@Ignore或使用@ColumnInfo处理。我曾经因为偷懒没加,导致表多了一列废弃数据,迁移时还报 schema 不匹配。
图里几个箭头代表着注解之间的依赖关系。比如 @Index 必须写在 @Entity 的 indices 属性里,不能单独在字段上声明(虽然早期版本支持,现在已经废弃)。@ForeignKey 也需要在 @Entity 中配置。记住这些节点关系,写实体时就不容易遗漏参数。
最后一点小建议:写实体时最好先画一个简单的 ER 图(或者用 Room 的数据库查看器预览),确认列名、类型、索引都符合预期。我习惯在写完实体类后立刻运行一次编译,看看生成的 _impl 类里有没有警告——Room 编译时会输出很多有用信息,比如未使用的索引、列名冲突等。