Room 数据库迁移:让本地数据升级更稳

Room 数据库迁移:让本地数据升级更稳

很多 Android 应用都会把用户设置、缓存、离线内容或业务草稿放在本地数据库里。功能早期,表结构通常很简单;产品迭代几轮后,就会遇到新增字段、拆表、合表、索引优化、默认值补齐等问题。

如果这些变化没有迁移方案,用户一升级 App 就可能遇到崩溃、数据丢失,或者页面展示出一堆不符合预期的旧数据。Room 的迁移能力就是为了解决这类问题:既保留 SQLite 的可控性,又让版本升级过程更容易测试和管理。

为什么 Room 需要迁移

Room 会根据 @Database(version = x) 中的版本号管理数据库结构。当应用安装后,设备上会保存某个版本的数据库文件。下一次 App 升级,如果代码里的数据库版本号变大,Room 就需要知道如何把旧结构升级到新结构。

举个简单例子,原来的用户表只有 id 和 name:

kotlin 复制代码
@Entity(tableName = "user")
data class UserEntity(
    @PrimaryKey val id: Long,
    val name: String
)

后来业务需要展示头像,于是新增 avatarUrl 字段:

kotlin 复制代码
@Entity(tableName = "user")
data class UserEntity(
    @PrimaryKey val id: Long,
    val name: String,
    val avatarUrl: String
)

这时如果只是修改实体类并提升数据库版本,Room 并不知道旧设备上的 user 表应该如何新增列。没有迁移逻辑时,常见结果是启动时报错:

text 复制代码
A migration from 1 to 2 was required but not found.

这不是 Room 麻烦,而是在提醒你:线上用户的数据不能靠猜。

最小可用迁移

Room 的迁移通过 Migration 定义,核心是写清楚从旧版本到新版本要执行哪些 SQL。

kotlin 复制代码
val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL(
            "ALTER TABLE user ADD COLUMN avatarUrl TEXT NOT NULL DEFAULT ''"
        )
    }
}

然后在创建数据库时注册:

kotlin 复制代码
val database = Room.databaseBuilder(
    context,
    AppDatabase::class.java,
    "app.db"
)
    .addMigrations(MIGRATION_1_2)
    .build()

这里有一个很容易忽略的点:新增非空字段时必须给默认值。因为旧表里已经有历史数据,数据库需要知道这些旧行的 avatarUrl 应该填什么。如果直接新增 TEXT NOT NULL 但不写 DEFAULT,迁移通常会失败。

迁移不只是加字段

真实项目里,迁移经常比新增一列更复杂。常见场景包括:

  • 新增表:用 CREATE TABLE 建表,并补充必要索引。
  • 新增索引:用 CREATE INDEX 优化查询性能。
  • 字段改名:SQLite 对部分结构修改支持有限,通常需要建新表、拷贝数据、删旧表、重命名。
  • 字段类型调整:同样建议通过临时表完成,顺便做数据清洗。
  • 表拆分:先创建新表,再从旧表中按规则迁移数据。

例如把 user 表里的昵称字段从 name 改成 nickname,更稳的做法是创建新表并拷贝:

kotlin 复制代码
val MIGRATION_2_3 = object : Migration(2, 3) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL("""
            CREATE TABLE user_new (
                id INTEGER NOT NULL,
                nickname TEXT NOT NULL,
                avatarUrl TEXT NOT NULL DEFAULT '',
                PRIMARY KEY(id)
            )
        """.trimIndent())

        db.execSQL("""
            INSERT INTO user_new (id, nickname, avatarUrl)
            SELECT id, name, avatarUrl FROM user
        """.trimIndent())

        db.execSQL("DROP TABLE user")
        db.execSQL("ALTER TABLE user_new RENAME TO user")
    }
}

这种写法看起来比 ALTER TABLE 绕一些,但它的好处是结果明确:新表结构完全由你控制,历史数据如何映射也写得很清楚。

自动迁移适合什么场景

Room 支持 AutoMigration,可以处理一些简单结构变化,例如新增表、新增列等。

kotlin 复制代码
@Database(
    entities = [UserEntity::class],
    version = 2,
    autoMigrations = [
        AutoMigration(from = 1, to = 2)
    ]
)
abstract class AppDatabase : RoomDatabase()

自动迁移适合规则明确、没有复杂数据变换的场景。但只要涉及字段改名、删除字段、数据清洗、默认值需要业务判断,就不要完全依赖自动迁移。数据库迁移的本质不是"让编译通过",而是"让用户的旧数据正确变成新数据"。

我的建议是:

  • 简单新增表、新增字段,可以考虑自动迁移。
  • 涉及业务含义变化,优先手写迁移。
  • 涉及线上关键数据,手写迁移并补测试。

fallbackToDestructiveMigration 要慎用

Room 提供了 fallbackToDestructiveMigration(),含义是找不到迁移路径时直接删库重建。

kotlin 复制代码
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
    .fallbackToDestructiveMigration()
    .build()

这个方法在 Demo 或纯缓存库里很方便,但在正式业务数据里风险很高。它会让问题看起来"解决了",代价是用户旧数据被清空。

更稳妥的判断方式是:

  • 数据只是可重新拉取的缓存,可以接受删库重建。
  • 数据包含用户草稿、离线内容、本地状态,不应该使用破坏性迁移。
  • 数据是否可恢复不明确时,按不可恢复处理。

很多线上事故不是因为开发不知道迁移,而是为了赶进度先用了破坏性迁移,后来业务把缓存变成了重要状态,却没人回头改。

迁移测试怎么写

数据库迁移一定要测旧版本升级到新版本。Room 提供了 MigrationTestHelper,可以创建旧版本数据库,再执行迁移校验。

kotlin 复制代码
@RunWith(AndroidJUnit4::class)
class AppDatabaseMigrationTest {

    @get:Rule
    val helper = MigrationTestHelper(
        InstrumentationRegistry.getInstrumentation(),
        AppDatabase::class.java
    )

    @Test
    fun migrateFrom1To2() {
        helper.createDatabase("test.db", 1).apply {
            execSQL("INSERT INTO user (id, name) VALUES (1, 'Lulu')")
            close()
        }

        val db = helper.runMigrationsAndValidate(
            "test.db",
            2,
            true,
            MIGRATION_1_2
        )

        val cursor = db.query("SELECT avatarUrl FROM user WHERE id = 1")
        assertThat(cursor.moveToFirst()).isTrue()
        assertThat(cursor.getString(0)).isEqualTo("")
        cursor.close()
    }
}

这个测试能验证两件事:

  • 迁移后的表结构和 Room 期望一致。
  • 历史数据在迁移后仍然符合业务预期。

对于多版本应用,还要关注跨版本升级。用户不一定每个版本都安装过,可能直接从旧版本升级到最新版本。因此迁移链路必须完整,例如 1 -> 2 -> 3 -> 4 都要注册,Room 才能从旧库一步步走到最新结构。

项目里的落地建议

在实际项目里,我会把数据库迁移当成一类明确的工程资产,而不是随手写在数据库初始化旁边。

比较稳的组织方式是:

kotlin 复制代码
object DatabaseMigrations {
    val MIGRATION_1_2 = object : Migration(1, 2) { ... }
    val MIGRATION_2_3 = object : Migration(2, 3) { ... }

    val ALL = arrayOf(
        MIGRATION_1_2,
        MIGRATION_2_3
    )
}

数据库创建时统一注册:

kotlin 复制代码
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
    .addMigrations(*DatabaseMigrations.ALL)
    .build()

每次修改实体或 DAO 前,建议先问三个问题:

  • 这次结构变化会影响已有用户的数据吗?
  • 旧数据需要补默认值、清洗或迁移到新表吗?
  • 有没有测试覆盖从旧版本升级到新版本?

如果答案不清楚,先不要急着改版本号。数据库迁移一旦发到线上,修复成本通常比普通 UI 问题高得多。

常见踩坑

只改实体类,不写迁移

这是最典型的问题。Room 的实体类代表新结构,但用户设备上保存的是旧结构。两者之间必须有迁移路径。

新增非空字段忘记默认值

旧数据没有这个字段,数据库无法凭空生成值。除非字段可空,否则要提供 DEFAULT。

测试只覆盖全新安装

全新安装只能证明新库能创建,不能证明旧库能升级。迁移测试要从旧版本数据库开始。

直接修改历史迁移

已经发布过的迁移不要随便改。线上用户可能已经执行过这段迁移,再改历史逻辑会让不同用户的数据库状态变得不可预测。更好的方式是新增后续迁移修正问题。

过度依赖破坏性迁移

缓存库可以谨慎使用,业务库尽量避免。只要数据对用户有价值,就应该保留。

小结

Room 迁移的关键不是记住 API,而是建立一种意识:数据库结构是线上契约,不能只照顾新安装用户,也要照顾已经在使用旧版本的用户。

一个可靠的迁移方案通常包含四件事:清楚的版本变化、明确的 SQL、完整的迁移链路、能跑起来的迁移测试。做到这些,本地数据库升级就不会变成每次发版前的隐患。

相关推荐
deli00737 分钟前
32K 上下文塞满后,AI 最先忘掉什么?120 轮对话实测
前端
deli0072 小时前
手机拍灯为什么有一条条黑纹?PWM 240Hz 实测
前端
wangruofeng6 小时前
AI Models 1.1.0 发布,查大模型价格、对比模型和找 API 地址
前端·aigc·ai编程
wangruofeng6 小时前
给前端开发者的 100 个设计参考网站
前端·设计
LinXunFeng10 小时前
拿竞品 App 一扫,技术栈全暴露了
android·flutter·开源
星栈10 小时前
视频即代码,Remotion 能做什么、不能做什么
前端·音视频开发
广州华水科技10 小时前
单北斗GNSS位移监测系统的应用与技术解析
前端
用户479492835691510 小时前
从一句话或代码仓到可交互 HTML:开源 Agent 画图技能 Archify v3.2 实测与选型边界
前端·后端·面试
风骏时光牛马12 小时前
生成一个AI多模态标题,仅输出一条标题,无额外文字、问句、备选、配图、文案等任何附加内容,不需要其他的任务服务 只要标题。
前端
Gracker12 小时前
AIW:从系统机制到性能排查,一本可以对照源码的 Android 电子书
android