第3章 Room数据库实体设计:Entity字段、主键、外键、索引、类型转换器
3.1 核心原理
Room 将 Kotlin / Java 数据类映射为 SQLite 表。一个 Entity 对应一张表,每个属性(字段)默认对应一列。设计实体时必须明确:
- 主键:唯一标识一行,不可为空。必须至少指定一个字段,否则编译报错。
- 外键:关联其他表的列,保证引用完整性。在
@ForeignKey中定义父表、子表列及级联行为。 - 索引:加速查询,避免全表扫描。可在单列或多列上创建唯一或普通索引。
- 类型转换器:处理 Room 无法直接存储的类型(如
Date、List、枚举等),通过@TypeConverter实现双向转换。
下图概括了实体设计与各元素的关系:
3.2 实现流程
定义实体需要以下步骤:
- 创建数据类并用
@Entity注解标识表名。 - 指定主键字段(可复合)并使用
@PrimaryKey。 - 如需关联,添加
@ForeignKey注解在@Entity中。 - 为常用查询列添加
@Index(可内联在@Entity的indices属性中)。 - 定义类型转换器类,用
@TypeConverter修饰方法,并在@Database注解的typeConverters中注册。
3.3 代码示例:完整的 Entity 设计
以下展示了两个关联实体及其转换器:
// --- 类型转换器 ---
class Converters {
@TypeConverter
fun fromTimestamp(value: Long?): Date? = value?.let { Date(it) }
@TypeConverter
fun dateToTimestamp(date: Date?): Long? = date?.time
}
// --- User 实体 ---
@Entity(
tableName = "user",
indices = [
Index(value = ["email"], unique = true),
Index(value = ["last_name", "first_name"])
]
)
data class User(
@PrimaryKey(autoGenerate = true)
val id: Int = 0,
val firstName: String?,
val lastName: String?,
val email: String,
val birthday: Date? // 将被 Converters 转换
)
// --- Book 实体,外键引用 User ---
@Entity(
tableName = "book",
foreignKeys = [
ForeignKey(
entity = User::class,
parentColumns = ["id"],
childColumns = ["user_id"],
onDelete = ForeignKey.CASCADE // 用户删除时级联删除其书籍
)
],
indices = [Index(value = ["user_id"])] // 为外键列建索引加速JOIN
)
data class Book(
@PrimaryKey(autoGenerate = true)
val id: Int = 0,
val title: String,
val userId: Int // 外键列
)
// --- 在数据库类中注册转换器 ---
@Database(entities = [User::class, Book::class], version = 1)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
abstract fun bookDao(): BookDao
}
3.4 调试与常见错误
| 错误情况 | 原因 | 解决方案 |
|---|---|---|
编译错误:Entity must have a primary key |
实体类未标注 @PrimaryKey |
为至少一个字段添加 @PrimaryKey |
运行时异常:Cannot find getter for field |
字段未声明 getter(通常因属性私有且无暴露方法) | 使用 data class 或手动编写 getter |
外键约束失败:FOREIGN KEY constraint failed |
插入子表记录时父表无对应主键,或删除/更新违反级联规则 | 检查插入顺序,或使用 onDelete = CASCADE + 正确索引 |
| 类型转换器不生效 | 转换器类未在 @Database 注解中注册 |
添加 @TypeConverters(YourConverter::class) 到数据库类 |
| 索引冲突 | 唯一索引插入重复值 | 验证业务逻辑或移除 unique = true |
调试技巧:开启 Room 的 SQL 日志输出(QueryInterceptor 或 SupportSQLiteOpenHelper)可查看实际执行的建表语句和索引创建。
// 在构建 Database 时启用日志
Room.databaseBuilder(context, AppDatabase::class.java, "db-name")
.openHelperFactory(RoomOpenHelperFactory())
.build()
// 或在 Application 中配置
if (BuildConfig.DEBUG) {
SupportSQLiteOpenHelper.Configuration.builder(context)
.callback(SupportSQLiteOpenHelper.Callback(1) { db -> db.execSQL("PRAGMA foreign_keys = ON") })
}
3.5 性能与稳定性优化
- 索引选型:仅为频繁出现在
WHERE、JOIN、ORDER BY子句中的列创建索引;避免在写密集型表上建过多索引。 - 复合索引:将选择性高的列放在左侧,例如
Index(value = ["last_name", "first_name"])优于["first_name", "last_name"]。 - 外键索引:为每个外键列手动创建索引(Room 不会自动创建),否则
DELETE/UPDATE父表时会触发全表扫描。 - 类型转换器复用:将通用转换器(如
Date ↔ Long,List<String> ↔ JSON)抽取为公共类,避免重复定义。 - 迁移与版本:修改实体时(如添加索引、更改外键),必须使用
Migration或fallbackToDestructiveMigration(),否则运行时崩溃。
常见陷阱:不要忘记在 Build.gradle 中添加 Room 的 KSP/KAPT 插件,否则 @Entity 等注解不会被处理。
// build.gradle (Module: app)
plugins {
id 'com.google.devtools.ksp' version '1.9.0-1.0.13' // 或 kapt
}
dependencies {
implementation "androidx.room:room-runtime:2.6.1"
ksp "androidx.room:room-compiler:2.6.1"
}
3.6 验证与部署建议
在单元测试中编写数据库测试,确保实体定义正确:
@RunWith(AndroidJUnit4::class)
class EntityMigrationTest {
@Test
fun createDatabase_entitiesExist() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build()
val userDao = db.userDao()
val bookDao = db.bookDao()
val user = User(firstName = "Alice", lastName = "Wang", email = "alice@test.com")
val userId = userDao.insert(user)
assertThat(userId).isGreaterThan(0)
val book = Book(title = "Room Guide", userId = userId.toInt())
bookDao.insert(book)
db.close()
}
}
部署前运行 ./gradlew lint 检查 Room 警告(如未命名的索引、缺失外键索引等),并使用 RoomDatabase.Callback 在打开数据库时启用外键约束:
Room.databaseBuilder(context, AppDatabase::class.java, "mydb")
.addCallback(object : RoomDatabase.Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
db.execSQL("PRAGMA foreign_keys = ON")
}
})
.build()