数据库(Database)类:创建、版本迁移、回调

说实话,Room 的 Database 类是整个本地存储的入口。你用 DAO 写的那些 SQL,最后都得靠这个抽象类来启动。我最早接触 Room 时,以为它就是个简单的 SQLite 封装,结果在版本迁移上摔了好几跤……所以这一节,咱们把 Database 类的三个关键点掰开看:如何创建、怎么迁移、回调里能做什么。

一句话总结: Room 的 Database 类是一个抽象类,你只需要写一个继承它的类,加几个注解,Room 就会自动帮你管理数据库的创建、升级和回调。

4.1 创建数据库——三个核心要素

先看最基础的写法。我习惯把 Database 类定义成一个单例,避免数据库实例重复打开造成内存泄漏。代码如下:

@Database(
    entities = [User::class, Book::class],
    version = 1,
    exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
    abstract fun bookDao(): BookDao

    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,
                    "myapp.db"
                ).build()
                INSTANCE = instance
                instance
            }
        }
    }
}

这里面有几点我特别想强调:

  • entities:所有实体类都要列出来。我见过有人漏写一个实体,结果运行时直接崩——Room 找不到对应的表。
  • version:每改一次表结构,version 必须加 1。否则 Room 会抛 IllegalStateException。
  • exportSchema:设为 true 会在编译期生成 schema 文件(JSON格式),方便你对比版本差异。我记得有一次线上数据库出了 bug,就是靠这个 schema 文件定位到字段类型变了。
  • 单例 + @Volatile:确保多线程环境下只创建一个实例。别问我为什么不用 Dagger Hilt 单例——那是另一个话题,但基础写法你得会。

4.2 版本迁移——三种策略,看你选哪个

数据库版本升级是 Room 里最容易翻车的地方。我归纳了三种策略,按推荐程度排个序:

策略 适用场景 优点 风险点
⚡ AutoMigration(自动迁移) 简单修改:加字段、改字段名(带@ColumnInfo) 零代码,编译期自动处理 复杂操作(合并表、删除字段)不支持
🔧 Migration 对象(手动迁移) 任意 SQL 操作:改表、迁移数据 完全可控,可写复杂 SQL 代码量稍大,要自己写 ALTER TABLE
🚨 fallbackToDestructiveMigration(放弃数据) 开发调试、数据不重要、表结构大改 无迁移逻辑,直接用新表 用户数据全丢,生产环境慎用

我个人建议: 生产环境优先用 Migration 对象。虽然样板代码多一点,但你能清楚每一步做了什么。AutoMigration 我一般只用于开发中快速迭代的小改。

先看一个手动迁移的例子。比如我在 v1 时有个 User 表,v2 要加一个 age 字段:

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

// 构建时传入 migration
Room.databaseBuilder(context, AppDatabase::class.java, "myapp.db")
    .addMigrations(MIGRATION_1_2)
    .build()

这里要注意:ALTER TABLE 在 SQLite 里只支持加字段,不能直接加 NOT NULL、PRIMARY KEY。如果想改约束,得用重命名旧表+创建新表+复制数据的套路。我以前就是踩了“想给已有字段加 NOT NULL”的坑,后来老老实实写了 10 行 SQL 才搞定。

再说 AutoMigration。从 Room 2.4 开始支持,用法很简单:

@Database(
    entities = [User::class],
    version = 2,
    autoMigrations = [
        AutoMigration(from = 1, to = 2)
    ]
)
abstract class AppDatabase : RoomDatabase() { ... }

// 还需要一个自动迁移规范类(可选,用于复杂规则)
@AutoMigration(from = 1, to = 2, spec = MyAutoMigrationSpec::class)
class MyAutoMigrationSpec : AutoMigrationSpec { }

但你得理解,自动迁移只处理“安全”的变化——比如新增字段(且实体里定义了 @ColumnInfo(defaultValue = "xxx"))。如果包含删除表、合并表等操作,Room 编译就会报错。所以它适合简单场景,我不建议在生产环境依赖它处理大版本升级。

4.3 回调——Callback 能帮你做什么

RoomDatabase.Callback 提供了三个生命周期钩子:onCreate、onOpen、onDestructiveMigration。我通常用它们来做以下事情:

  • onCreate:数据库首次创建时执行。适合插入预设数据,比如国家列表、配置参数。
  • onOpen:每次打开数据库时执行。可以用来做数据校验、清理缓存,或者记录日志。
  • onDestructiveMigration:当启用了 fallbackToDestructiveMigration 且版本不匹配时触发。你可以在这里清理外部缓存、标记需要重新同步等。

举个实际例子。有一次我需要给新用户预置一些默认分类数据,我就在 onCreate 中调用 DAO 批量插入:

val callback = object : RoomDatabase.Callback() {
    override fun onCreate(db: SupportSQLiteDatabase) {
        super.onCreate(db)
        // 注意:此时数据库已创建,但 DAO 实例还不能直接用
        // 需要先获取 Database 再拿到 DAO
        INSTANCE?.let { database ->
            val defaultCategories = listOf(
                Category(0, "工作"),
                Category(0, "学习"),
                Category(0, "生活")
            )
            database.categoryDao().insertAll(defaultCategories)
        }
    }

    override fun onOpen(db: SupportSQLiteDatabase) {
        super.onOpen(db)
        Log.d("Room", "数据库已打开,当前版本 ${db.version}")
    }

    override fun onDestructiveMigration(db: SupportSQLiteDatabase) {
        super.onDestructiveMigration(db)
        // 数据被清空,可以通知后台清除远程缓存
        Log.w("Room", "发生了 destructive migration,本地数据已丢失")
    }
}

Room.databaseBuilder(context, AppDatabase::class.java, "myapp.db")
    .addCallback(callback)
    .build()

注意: 在 onCreate 里直接操作 DAO 有个坑——如果数据库还没完全初始化完就插入,可能出现死锁。我建议要么在 onCreate 中手动执行 SQL(通过 SupportSQLiteDatabase),要么在数据库创建完成后延迟执行。我自己更倾向于用后一种:在单例 getInstance 返回后,再通过协程异步插入预设数据。

4.4 知识结构图:Database 类全貌

下面这张图把 Database 类的三大模块串起来,你可以照着它梳理自己的代码:

Room Database 类核心结构 RoomDatabase 抽象类 ① 创建(单例+Builder) entities + version + exportSchema @Database 注解 + 抽象 DAO 方法 ② 版本迁移(3种策略) AutoMigration (自动处理简单改字段) Migration 对象 (手动写 SQL 迁移) fallbackToDestructive (放弃旧数据重建) ③ 回调(Callback) onCreate (插入预设数据) onOpen (日志/数据校验) onDestructiveMigration (数据丢失通知) 核心原则:版本号必须递增;迁移逻辑必须守恒(数据不丢,约束不破) 构建数据库的推荐流程 1. 定义实体类和 DAO 接口 2. 新建抽象类继承 RoomDatabase,填写 @Database 注解 3. 编写 Migration 对象(或用 fallbackToDestructiveMigration 调试) 4. 实现 Callback 添加预设或日志 5. 单例暴露 getInstance(context) 供全局使用

4.5 避坑指南——那些年我踩过的坑

  • 版本号忘了加:有一次我在实体里加了个字段,没改 version,Room 直接抛异常。自那以后我养成习惯:改完实体立马同步升级 version。
  • fallbackToDestructiveMigration 上了生产:我亲眼见过同事发布了一个版本,数据库从 v3 到 v4,忘记写 Migration,用户全量数据被清空……那次事故之后,我们在 CI 里加了 schema 校验,强行要求每个版本必须提供 Migration。
  • Callback 内操作 DAO 出现死锁:前面说过了,我后来改用 SupportSQLiteDatabase 执行 SQL,或者等数据库构建完成后用协程延迟执行。

总结一下: Database 类是 Room 的上帝入口。你把 entities 列全、version 写对、Migration 配好、Callback 用对,数据库层面就不会出大问题。剩下的交给 Room 自动生成的代码,它比你手写 SQLiteOpenHelper 要可靠得多。