4. Room 实体与数据库:给数据找个家

好,我们开始真正盖房子了。前面几章你知道了 Room 是什么、为什么选它。这章咱们亲手定义实体、建数据库,把代码跑起来。

我个人习惯先把数据模型画清楚,再去写结构。所以先聊 @Entity,再说 @Database,最后用 Room.databaseBuilder 把实例拿到手。这三步走完,你的数据层就稳了。

4.1 @Entity 实体类 —— 你的数据“户口本”

说白了,每个 @Entity 对应数据库里的一张表。你定义一个 Kotlin 数据类,加个注解,Room 就帮你建表。

@Entity(tableName = "tasks")
data class TaskEntity(
    @PrimaryKey(autoGenerate = true)
    val id: Long = 0,
    val title: String,
    val description: String? = null,
    val isCompleted: Boolean = false,
    val createdAt: Long = System.currentTimeMillis()
)

这里我用 @PrimaryKey(autoGenerate = true),自增主键,省心。id 默认 0,Room 会帮你填上。

⚠️ 注意: 实体类必须是 Kotlin 的 data class 或普通 class,字段必须有 getter/setter。如果你用 Kotlin,data class 自动生成,省事。Java 的话要手写 getter/setter。我建议统一用 Kotlin。

字段类型要跟 SQLite 类型匹配。Room 会自动映射:Int→INTEGER,String→TEXT,Long→INTEGER,Boolean→INTEGER(0/1)。支持 List<String> 吗?没有,需要 TypeConverter。这个后面章节细聊。

4.1.1 表名、列名、索引

如果你不想用类名当表名,tableName 搞定。列名默认就是字段名,想改可以用 @ColumnInfo(name = "xxx")。

@Entity(
    tableName = "tasks",
    indices = [
        Index(value = ["createdAt"], unique = false),
        Index(value = ["title"], unique = false)
    ]
)
data class TaskEntity(
    @PrimaryKey(autoGenerate = true)
    val id: Long = 0,
    @ColumnInfo(name = "task_title")
    val title: String,
    ...
)

索引能加速查询,但别滥用。我见过有人全字段加索引,结果写入变慢。挑常用 where 字段加就行。

4.1.2 外键 —— 表与表的“父子关系”

如果任务属于某个用户,你可以加外键:

@Entity(
    foreignKeys = [
        ForeignKey(
            entity = UserEntity::class,
            parentColumns = ["id"],
            childColumns = ["userId"],
            onDelete = ForeignKey.CASCADE
        )
    ]
)
data class TaskEntity(
    ...
    val userId: Long
)

这里 onDelete = CASCADE 表示用户删除时,所有任务也跟着删。我曾经踩坑没写 CASCADE,结果用户删了,任务表里一堆孤儿数据。别学我。

4.2 @Database —— 数据库的入口类

实体有了,现在要告诉 Room “哪些表属于这个数据库”。

@Database(
    entities = [TaskEntity::class, UserEntity::class],
    version = 1,
    exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
    abstract fun taskDao(): TaskDao
    abstract fun userDao(): UserDao

    companion object {
        @Volatile
        private var INSTANCE: AppDatabase? = null

        fun getInstance(context: Context): AppDatabase {
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    AppDatabase::class.java,
                    "app_database.db"
                ).build()
                INSTANCE = instance
                instance
            }
        }
    }
}

@Database 注解里列出所有实体类。version 从 1 开始,升级时递增。exportSchema = true 会生成 schema 文件,方便检查数据库结构变化。

💡 小技巧: 如果你用 Kotlin,把 getInstance 放在 companion object 里,并加 @Volatile 和 synchronized,确保多线程安全。这是标准的单例写法,记住它。

4.3 创建数据库实例 —— 什么时候?在哪创建?

数据库实例只需要一个,全局共享。我一般在 Application 类里初始化,但为了测试方便,很多人改用依赖注入(Dagger/Hilt)。这节咱先手写单例。

class MyApplication : Application() {
    lateinit var database: AppDatabase
        private set

    override fun onCreate() {
        super.onCreate()
        database = AppDatabase.getInstance(this)
    }
}

然后在 Activity 或 ViewModel 里通过 Application 获取:

val db = (application as MyApplication).database
val tasks = db.taskDao().getAllTasks()

4.3.1 迁移策略 —— 数据库升级别慌

如果你的 app 已经在用户手机上了,你改实体类(加字段、删表)后,Room 会抛异常“数据库版本不匹配”。这时你需要写迁移:

val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(database: SupportSQLiteDatabase) {
        database.execSQL("ALTER TABLE tasks ADD COLUMN dueDate INTEGER DEFAULT NULL")
    }
}

// 在 builder 里加上
Room.databaseBuilder(...)
    .addMigrations(MIGRATION_1_2)
    .build()

简单的 ALTER TABLE 就行。复杂场景比如合并表,要写多条 SQL,甚至用事务。我建议一定在发布前用测试设备跑一遍 migration 流程,我曾经线上没测试,结果用户数据全丢了……

🔑 核心原则
  • 实体类等于表结构,修改后版本号+1
  • 每次升级写对应的 Migration 对象
  • 加字段用 ALTER TABLE,删字段只能新建表再复制数据
  • 测试 migration 用 .fallbackToDestructiveMigration() 做开发期降级,但不要发布时用

4.4 一张图看懂 Room 数据库结构

下面这个 SVG 把本章核心知识点串起来了。你看看实体、数据库、DAO 是怎么配合的。

Room 数据库架构 App / UI ViewModel DAO 接口 Room Database Entity / 表 Room.databaseBuilder 核心流程:UI → ViewModel → DAO / Database → Entity,由 Room.databaseBuilder 创建实例

4.5 本章小结 & 避坑清单

知识点 注意事项
@Entity 实体 用 data class,主键自增默认 0,字段类型要匹配 SQLite
@Database 入口 列出所有实体,version 从 1 开始,exportSchema 打开
数据库实例 单例 + @Volatile + synchronized,用 applicationContext 避免泄漏
数据库迁移 版本升级必须写 Migration,测试设备验证,别用 fallbackToDestructiveMigration
🚨 我曾遇到的问题: 在 getInstance 里传了 Activity 的 Context,结果 Activity 销毁后数据库还持有引用,导致内存泄漏。记住:永远用 applicationContext。