Room 数据库迁移:版本管理、Migration 类、测试迁移

数据库迁移这事吧,说实话,我最早做 Android 项目时根本没当回事。那时候本地数据就几个配置项,大不了清掉重来。后来产品经理说“用户的历史记录不能丢”,数据量一上来,我才意识到——迁移不只是改表结构,更是对用户数据的承诺。今天咱们就把 Room 的版本管理、Migration 类怎么写、以及怎么自动化测试,一次性讲透。

版本管理:从 1 到 N 的进化路线

Room 的版本号用 @Database(version = ...) 声明。每次表结构变化,比如加了字段、改类型、拆表,版本号必须 +1。如果只改版本号不写迁移代码,Room 会抛异常。

我个人习惯在 build.gradle 里加上 schema 导出配置,这样能看到每次版本变更的 JSON 差异文件:

room {
    schemaLocation "$projectDir/schemas"
}

导出后你会得到一个 1.json、2.json…… 对比两份 JSON,就能明确知道改了哪些列。这在多人协作时尤其有用——谁动表结构,谁就得更新版本号并写 Migration。

💡 小技巧: 版本号建议从 1 开始,每次迭代加 1。不要跳版本,除非你明确知道旧用户全部覆盖了。我在一个老旧项目里见过从 3 直接跳到 7 的,结果中间的用户数据全丢了。

如果你实在不想写迁移代码,可以用 fallbackToDestructiveMigration()。但注意,这个方法会 删除所有数据。我只在 debug 版本里用它,线上不用。

⚠️ 警告: fallbackToDestructiveMigration() 会清空表再重建。如果用户本地有重要数据,比如草稿箱、离线消息,用户反馈会很“热情”的。

Migration 类:写一次,管一到多

Room 的迁移通过 Migration(startVersion, endVersion) 实现。你只需要在 migrate() 方法里执行 SQL 语句。比如从版本 1 到 2,给 User 表加一个 age 字段:

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

然后在 Room.databaseBuilder 里添加:

Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
    .addMigrations(MIGRATION_1_2, MIGRATION_2_3)
    .build()

这里我踩过一个坑:SQLite 的 ALTER TABLE 能力很弱,不支持删列、改列类型、改约束。如果需要做这些操作,步骤是:新建临时表 → 复制数据 → 删旧表 → 重命名。我曾经在迁移时没处理 NOT NULL 约束,结果线上用户数据全为 null 导致崩溃。后来我养成了一个习惯:写完迁移后先用真实数据库手动执行一遍 SQL。

测试迁移:让自动化替你兜底

迁移代码如果不测试,等于裸奔。Room 官方提供了 MigrationTestHelper,结合 JUnit 和 Room.inMemoryDatabaseBuilder 可以模拟从旧版本升级的过程。

看一个完整测试用例:

@RunWith(AndroidJUnit4::class)
class MigrationTest {
    private val TEST_DB_NAME = "migration-test"

    @Test
    fun migrateFrom1To2() {
        val helper = MigrationTestHelper(
            InstrumentationRegistry.getInstrumentation(),
            AppDatabase::class.java.canonicalName,
            FrameworkSQLiteOpenHelperFactory()
        )

        // 创建版本 1 的数据库
        var db = helper.createDatabase(TEST_DB_NAME, 1)
        db.execSQL("INSERT INTO User (id, name) VALUES (1, 'OldUser')")
        db.close()

        // 执行迁移到版本 2
        db = helper.runMigrationsAndValidate(
            TEST_DB_NAME, 2, true, MIGRATION_1_2
        )

        // 验证新字段非空且默认值正确
        val cursor = db.query("SELECT * FROM User WHERE id = 1")
        cursor.moveToFirst()
        assertEquals(0, cursor.getInt(cursor.getColumnIndex("age")))
        cursor.close()
        db.close()
    }
}

为什么我要强调测试?因为 Room 的迁移验证器只会检查 schema 结构,不会校验数据一致性。你自己写的 SQL 如果在复杂的拼接中漏了个逗号,生产环境就要出大问题。我曾经就犯过这种错——迁移时把 INTEGER 写成了 INT,虽然能跑但是主键类型变成了 INTEGER,后续查询速度慢了三倍。

迁移流程图

Room数据库迁移流程 Room 数据库迁移核心流程 用户当前版本 (vN) 编写 Migration 类 (vN→vN+1) 多版本连续迁移 多个 Migration 对象 数据迁移 & 结构校验 使用 MigrationTestHelper 测试 验证数据 & 结构正确 回退 & 修复 关键原则 • 版本号严格递增 • 每个迁移必须线程安全 • 避免 destructive 回退 • 测试覆盖数据完整性

常见版本迁移场景表

场景SQL 操作注意事项
新增列ALTER TABLE ... ADD COLUMN需提供默认值或允许 NULL
删除列新建表 + 复制 + 重命名注意外键关联
重命名表直接 ALTER TABLE RENAME TO更新所有引用该表的查询
合并表新建临时表 + 多步复制处理冲突主键
改列类型只能重建表SQLite 不支持 ALTER COLUMN

✅ 我的建议: 每次发布新版本前,用 Beta 渠道跑一遍迁移测试。如果团队有自动化 CI,把 MigrationTest 加入 pipeline。我曾看到过一个线上崩溃是因为迁移时在多线程环境下写入了重复数据——Room 的 migrate 方法本身不是线程安全的,但如果你在迁移中调用了 Room 操作,就需要小心。

避坑指南

  • 不要信任默认约束:SQLite 的 ALTER TABLE 不会帮你检查已有的行的默认值是否符合新约束。自己手动更新已有数据。
  • 版本跳跃迁移:如果用户从 v1 直接升到 v3,Room 会依次执行 v1→v2、v2→v3。但是如果 v1→v2 的 SQL 被废弃了,你会遇到旧用户升级失败。我建议保留所有历史 Migration 对象,不要删除。
  • 预迁移与后迁移:如果只是加索引或改配置,不需要动数据,直接执行 SQL。但如果涉及计算字段(如年龄根据生日计算),迁移完成后最好用 @Query 校验数据一致性。