第3章 Room数据库实体设计:Entity字段、主键、外键、索引、类型转换器

3.1 核心原理

Room 将 Kotlin / Java 数据类映射为 SQLite 表。一个 Entity 对应一张表,每个属性(字段)默认对应一列。设计实体时必须明确:

  • 主键:唯一标识一行,不可为空。必须至少指定一个字段,否则编译报错。
  • 外键:关联其他表的列,保证引用完整性。在 @ForeignKey 中定义父表、子表列及级联行为。
  • 索引:加速查询,避免全表扫描。可在单列或多列上创建唯一或普通索引。
  • 类型转换器:处理 Room 无法直接存储的类型(如 Date、List、枚举等),通过 @TypeConverter 实现双向转换。

下图概括了实体设计与各元素的关系:

Room实体设计核心元素关系图 Room 实体设计核心元素 User 实体 @Entity(tableName = "user") Book 实体 @Entity(foreignKeys = ...) @PrimaryKey autoGenerate @ForeignKey parent / child columns @Index unique / multi-column @TypeConverter Date ↔ Long 外键引用 User 主键

3.2 实现流程

定义实体需要以下步骤:

  1. 创建数据类并用 @Entity 注解标识表名。
  2. 指定主键字段(可复合)并使用 @PrimaryKey。
  3. 如需关联,添加 @ForeignKey 注解在 @Entity 中。
  4. 为常用查询列添加 @Index(可内联在 @Entity 的 indices 属性中)。
  5. 定义类型转换器类,用 @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()