第三讲:实体类定义 —— 给数据上个“身份证”

说实话,我刚接触 Room 的时候,最头疼的反而不是写 SQL,而是怎么把 Java/Kotlin 的普通类变成 Room 认识的“实体”。说白了,实体类就是 Room 的一张表的结构映射 —— 你告诉它字段叫什么、什么类型、哪个是主键,它就能自动建表、帮你做增删改查。

我个人建议,写实体类之前,先想清楚这张表要存什么。拿我们这门课的例子来说 —— 我们要做缓存 + 网络请求,那一定会存一个“用户列表”或“文章列表”。我就以最常见的 User 实体来演示。

3.1 @Entity —— 标记一个类为表

先看最简单的写法:

@Entity
data class User(
    @PrimaryKey val id: Int,
    val name: String,
    val age: Int
)

嗯,就一个 @Entity 注解,Room 就知道这个类对应一张表。默认表名就是类名(首字母大写),字段名就是变量名。

如果你不喜欢默认的表名,可以指定:@Entity(tableName = "users")

我曾经犯过一个低级错误:把实体类写在了一个内部类里。Room 是不认内部类的,会直接编译报错。所以记得,实体类必须是顶层类。

3.2 @PrimaryKey —— 唯一标识每一行

每个实体都需要一个主键。我习惯把自增主键用在本地缓存表里,比如 id 字段。

@Entity
data class CacheItem(
    @PrimaryKey(autoGenerate = true)
    val localId: Int = 0,
    val serverId: String,
    val data: String
)

这里 autoGenerate = true 表示让 Room 自动生成递增的整数主键。但要注意 —— 如果你从服务器拿到的 id 是 String 类型,就不能用自增了。你可以用复合主键:

@Entity(primaryKeys = ["serverId", "type"])
data class CacheItem(
    val serverId: String,
    val type: String,
    val data: String
)

复合主键的每个字段都不能为 null,否则 Room 会报错。我之前就因为忘了加 @NonNull 而排查了半天。

3.3 字段注解:@ColumnInfo、@NonNull 等

字段注解用来进一步控制列的细节。把上面的例子补全:

@Entity(tableName = "users")
data class User(
    @PrimaryKey
    @NonNull
    @ColumnInfo(name = "user_id")
    val id: String,

    @ColumnInfo(name = "full_name")
    val name: String?,

    @ColumnInfo(defaultValue = "0")
    val age: Int,

    @ColumnInfo(typeAffinity = ColumnInfo.TEXT)
    val avatarUrl: String?
)
注解 作用 常见坑
@ColumnInfo(name = "...") 自定义列名(默认和变量名一致) 改列名后旧数据库迁移要小心
@NonNull 标记字段不可为 null Kotlin 中 String? 和 String 要对应好
@ColumnInfo(defaultValue = "...") 设置默认值(仅建表时生效) Room 不会帮你处理已有行的默认值
@ColumnInfo(typeAffinity = ...) 指定 SQLite 列类型(TEXT/INTEGER/REAL/BLOB) 一般不需要,Room 自动推断

我个人习惯把所有非主键字段都显式标注 @ColumnInfo(name = "..."),这样以后改变量名不影响数据库列名,迁移时才不会崩。

3.4 忽略字段:@Ignore

有些字段你不想存到数据库里,比如临时计算值:

@Entity
data class Article(
    @PrimaryKey val id: Int,
    val title: String,
    val content: String,
    @Ignore val isExpanded: Boolean = false   // 只用于 UI 状态,不存
)

不加 @Ignore,Room 会认为所有字段都要建列,然后报错说找不到类型转换器。你想想看,一个 Boolean 字段没加忽略,Room 就傻眼了——它不知道 Boolean 怎么存。

3.5 索引与唯一性:@Index

缓存场景里,我们通常要根据某个字段做快速查找。比如用 userId 查缓存。不加索引的话,数据多了会很慢。

@Entity(
    tableName = "user_cache",
    indices = [Index(value = ["server_id"], unique = true)]
)
data class UserCache(
    @PrimaryKey(autoGenerate = true)
    val localId: Int = 0,
    @ColumnInfo(name = "server_id")
    val serverId: String,
    val name: String
)

unique = true 表示这个字段唯一,相当于加了一个唯一约束。这样如果你重复插入相同 serverId 的数据,Room 会抛 SQLiteConstraintException。我就曾因为没处理这个异常,导致用户点击刷新时直接闪退——所以记得用 @Insert(onConflict = OnConflictStrategy.REPLACE) 来覆盖。

3.6 实体关系:@ForeignKey

有时候缓存不是一张表能搞定的。比如一个“帖子”有多个“评论”,你可能希望级联删除。我们来看一个简单的例子:

@Entity(tableName = "posts")
data class Post(
    @PrimaryKey val postId: Int,
    val title: String
)

@Entity(
    tableName = "comments",
    foreignKeys = [
        ForeignKey(
            entity = Post::class,
            parentColumns = ["postId"],
            childColumns = ["postId"],
            onDelete = ForeignKey.CASCADE
        )
    ]
)
data class Comment(
    @PrimaryKey val commentId: Int,
    val postId: Int,
    val content: String
)

这样当你删除一个帖子时,它所有的评论会自动被删掉。我个人喜欢在缓存表里也用外键,保证数据一致性。但我得提醒你:外键会带来一点点性能开销,而且 Room 默认是关闭外键约束的,需要在数据库打开时执行 PRAGMA foreign_keys = ON。我之前就踩过这个坑 —— 加了外键但没开约束,结果删除帖子后评论还在。

3.7 一张图理清实体类结构

下面这张图把今天讲的几个核心概念串在一起:

Room 实体类结构 @Entity → 数据库表 tableName = "users" @PrimaryKey(autoGenerate=true) indices / ForeignKeys 字段注解控制每列 @ColumnInfo(name="user_id") 自定义列名,避免字段名冲突 @NonNull / 可空 (String?) 控制 null 约束,Kotlin 类型要一致 @Ignore / defaultValue 忽略字段或设置默认值 牢记口诀 @Entity 定表,@PrimaryKey 定行,字段注解定细节

3.8 避坑清单(我亲手踩过的)

  • Kotlin 空安全与 @NonNull 不一致 —— 你用 String? 但没加 @Nullable,Room 会认为你允许 null,但编译不会报错,运行时插入 null 可能会闪退。我的做法:所有可空字段显式声明 val field: String? = null,并且加上 @ColumnInfo(defaultValue = "NULL")。
  • 默认值不生效 —— defaultValue 只在建表时写入 SQL 的 DEFAULT 子句。如果你添加新字段并设了默认值,老数据不会自动填充。需要用 @Migration 手动处理。
  • Boolean 怎么存? —— Room 默认把 Boolean 映射为 INTEGER(0/1)。我建议存成 Int 并自己处理转换,这样迁移时更灵活。
  • 主键不能用可变类型 —— 如果你用 var id: Int,Room 会警告。最好用 val,因为重写主键会导致数据错乱。

嗯,这节课的内容就这些。实体类看着简单,但很多坑只有写代码时才会遇到。你如果在项目里用了上面说的这些注解,基本就能避开 90% 的常见问题了。