Room 数据库迁移:让本地数据升级更稳
很多 Android 应用都会把用户设置、缓存、离线内容或业务草稿放在本地数据库里。功能早期,表结构通常很简单;产品迭代几轮后,就会遇到新增字段、拆表、合表、索引优化、默认值补齐等问题。
如果这些变化没有迁移方案,用户一升级 App 就可能遇到崩溃、数据丢失,或者页面展示出一堆不符合预期的旧数据。Room 的迁移能力就是为了解决这类问题:既保留 SQLite 的可控性,又让版本升级过程更容易测试和管理。
为什么 Room 需要迁移
Room 会根据 @Database(version = x) 中的版本号管理数据库结构。当应用安装后,设备上会保存某个版本的数据库文件。下一次 App 升级,如果代码里的数据库版本号变大,Room 就需要知道如何把旧结构升级到新结构。
举个简单例子,原来的用户表只有 id 和 name:
@Entity(tableName = "user")
data class UserEntity(
@PrimaryKey val id: Long,
val name: String
)
后来业务需要展示头像,于是新增 avatarUrl 字段:
@Entity(tableName = "user")
data class UserEntity(
@PrimaryKey val id: Long,
val name: String,
val avatarUrl: String
)
这时如果只是修改实体类并提升数据库版本,Room 并不知道旧设备上的 user 表应该如何新增列。没有迁移逻辑时,常见结果是启动时报错:
A migration from 1 to 2 was required but not found.
这不是 Room 麻烦,而是在提醒你:线上用户的数据不能靠猜。
最小可用迁移
Room 的迁移通过 Migration 定义,核心是写清楚从旧版本到新版本要执行哪些 SQL。
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 ''"
)
}
}
然后在创建数据库时注册:
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,更稳的做法是创建新表并拷贝:
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,可以处理一些简单结构变化,例如新增表、新增列等。
@Database(
entities = [UserEntity::class],
version = 2,
autoMigrations = [
AutoMigration(from = 1, to = 2)
]
)
abstract class AppDatabase : RoomDatabase()
自动迁移适合规则明确、没有复杂数据变换的场景。但只要涉及字段改名、删除字段、数据清洗、默认值需要业务判断,就不要完全依赖自动迁移。数据库迁移的本质不是“让编译通过”,而是“让用户的旧数据正确变成新数据”。
我的建议是:
- 简单新增表、新增字段,可以考虑自动迁移。
- 涉及业务含义变化,优先手写迁移。
- 涉及线上关键数据,手写迁移并补测试。
fallbackToDestructiveMigration 要慎用
Room 提供了 fallbackToDestructiveMigration(),含义是找不到迁移路径时直接删库重建。
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.fallbackToDestructiveMigration()
.build()
这个方法在 Demo 或纯缓存库里很方便,但在正式业务数据里风险很高。它会让问题看起来“解决了”,代价是用户旧数据被清空。
更稳妥的判断方式是:
- 数据只是可重新拉取的缓存,可以接受删库重建。
- 数据包含用户草稿、离线内容、本地状态,不应该使用破坏性迁移。
- 数据是否可恢复不明确时,按不可恢复处理。
很多线上事故不是因为开发不知道迁移,而是为了赶进度先用了破坏性迁移,后来业务把缓存变成了重要状态,却没人回头改。
迁移测试怎么写
数据库迁移一定要测旧版本升级到新版本。Room 提供了 MigrationTestHelper,可以创建旧版本数据库,再执行迁移校验。
@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 才能从旧库一步步走到最新结构。
项目里的落地建议
在实际项目里,我会把数据库迁移当成一类明确的工程资产,而不是随手写在数据库初始化旁边。
比较稳的组织方式是:
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
)
}
数据库创建时统一注册:
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.addMigrations(*DatabaseMigrations.ALL)
.build()
每次修改实体或 DAO 前,建议先问三个问题:
- 这次结构变化会影响已有用户的数据吗?
- 旧数据需要补默认值、清洗或迁移到新表吗?
- 有没有测试覆盖从旧版本升级到新版本?
如果答案不清楚,先不要急着改版本号。数据库迁移一旦发到线上,修复成本通常比普通 UI 问题高得多。
常见踩坑
只改实体类,不写迁移
这是最典型的问题。Room 的实体类代表新结构,但用户设备上保存的是旧结构。两者之间必须有迁移路径。
新增非空字段忘记默认值
旧数据没有这个字段,数据库无法凭空生成值。除非字段可空,否则要提供 DEFAULT。
测试只覆盖全新安装
全新安装只能证明新库能创建,不能证明旧库能升级。迁移测试要从旧版本数据库开始。
直接修改历史迁移
已经发布过的迁移不要随便改。线上用户可能已经执行过这段迁移,再改历史逻辑会让不同用户的数据库状态变得不可预测。更好的方式是新增后续迁移修正问题。
过度依赖破坏性迁移
缓存库可以谨慎使用,业务库尽量避免。只要数据对用户有价值,就应该保留。
小结
Room 迁移的关键不是记住 API,而是建立一种意识:数据库结构是线上契约,不能只照顾新安装用户,也要照顾已经在使用旧版本的用户。
一个可靠的迁移方案通常包含四件事:清楚的版本变化、明确的 SQL、完整的迁移链路、能跑起来的迁移测试。做到这些,本地数据库升级就不会变成每次发版前的隐患。