第三讲:实体类定义 —— 给数据上个“身份证”
说实话,我刚接触 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
)
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 一张图理清实体类结构
下面这张图把今天讲的几个核心概念串在一起:
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% 的常见问题了。