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

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

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

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

为什么 Room 需要迁移

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

举个简单例子,原来的用户表只有 idname

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、完整的迁移链路、能跑起来的迁移测试。做到这些,本地数据库升级就不会变成每次发版前的隐患。

相关推荐
阿巴斯甜1 小时前
Compose NavHost的使用:
android
徐小夕1 小时前
倒腾2天,我开源了一款AI驱动的实时 3D CAD / 参数化建模工作台
前端·架构·github
雁鸣零落1 小时前
浏览器扩展 CaptionGo,在网页视频上显示双语字幕,支持 PC 和手机端使用
android·chrome·edge·firefox·安卓
少年白马醉春风丶1 小时前
任务分解的经典范式 · 和 ReAct「走一步看一步」到底差在哪
前端
是烨笙啊1 小时前
从 npm 与 Bun 的选择看 AI 时代的“最佳实践”焦虑
前端·人工智能·npm
lichenyang4532 小时前
从 Web 容器到本地 HAR:我做了一个 HarmonyOS JSBridge 运行时框架 Demo
前端
超神熊猫2 小时前
🧀🧀🧀前端仔如何快速上手python
前端·python
阿黎梨梨2 小时前
AI 推理太慢用户要退钱?前端流式输出(Stream)究竟是何方神圣?
前端·vue.js
2501_915106322 小时前
iOS 软件测试工具性能监控、日志分析 KeyMob、Instruments等
android·ios·小程序·https·uni-app·iphone·webview