11、数据库迁移:@Migration注解、迁移策略、测试迁移、破坏性迁移fallbackToDestructiveMigration
数据库迁移,说白了就是你的App升级了,数据库表结构也得跟着变。我刚开始做Room开发时,觉得这玩意儿挺简单——不就是加个字段嘛。结果第一次上线就翻车了,用户升级后直接闪退。嗯,从那以后我再也不敢小看数据库迁移了。
今天咱们就把Room的迁移机制彻底聊透。你想想看,一个App迭代个三五年,数据库版本从1升到20多很正常。如果迁移没写好,用户数据说丢就丢,那可就真成事故了。
11.1 @Migration注解:迁移的最小单元
Room里做迁移,核心就是@Migration注解。它需要你提供三个信息:起始版本号、目标版本号、以及迁移执行的SQL语句。
@Migration(version1 = 1, version2 = 2)
class Migration1To2 : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
// 添加一个"年龄"字段
database.execSQL("ALTER TABLE user ADD COLUMN age INTEGER NOT NULL DEFAULT 0")
}
}
这里有个细节我特别想强调:迁移版本号必须是递增的。比如从1到2,从2到3,不能跳版本。为什么?因为Room会按顺序依次执行迁移,跳版本会导致中间状态的数据不一致。
我在项目中遇到过一个问题:有个同事直接写了从1到3的迁移,跳过了版本2。结果版本2里有个字段重命名操作,版本3的迁移里没处理,上线后用户的老数据全乱了。所以,老老实实一步一步来,别偷懒。
11.2 迁移策略:单步迁移 vs 多步迁移
迁移策略其实就两种:单步迁移和多步迁移。咱们用个表格对比一下:
| 策略 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 单步迁移 | 版本跨度小(如1→2) | 代码简单,容易理解 | 版本多了维护麻烦 |
| 多步迁移 | 版本跨度大(如1→5) | 逻辑清晰,可单独测试 | 类文件会增多 |
实际开发中,我推荐用多步迁移。每个迁移只做一件事,比如加字段、建新表、改索引。这样出了问题也好排查。
// 多步迁移示例:从版本1到3
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE user ADD COLUMN age INTEGER NOT NULL DEFAULT 0")
}
}
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE TABLE IF NOT EXISTS `address` (`id` INTEGER, `userId` INTEGER, PRIMARY KEY(`id`))")
}
}
// 在Room.databaseBuilder中注册
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
.build()
你可能会问:那用户从版本1直接升到3怎么办?Room会自动把MIGRATION_1_2和MIGRATION_2_3串起来执行,你完全不用操心。
11.3 测试迁移:别等上线才发现问题
迁移代码写完了,怎么保证它是对的?我见过太多人直接跑App测,结果数据丢了才慌。其实Room提供了专门的测试方案。
核心思路是:创建一个旧版本的数据库,插入测试数据,然后执行迁移,最后验证数据是否完整。
@RunWith(AndroidJUnit4::class)
class MigrationTest {
private val TEST_DB_NAME = "migration-test"
@Test
fun migrate1To2_containsCorrectData() {
// 创建版本1的数据库
val db = Room.databaseBuilder(
ApplicationProvider.getApplicationContext(),
AppDatabase::class.java,
TEST_DB_NAME
).build()
// 插入测试数据
db.userDao().insert(User(1, "张三", 25))
db.close()
// 执行迁移到版本2
val migratedDb = Room.databaseBuilder(
ApplicationProvider.getApplicationContext(),
AppDatabase::class.java,
TEST_DB_NAME
).addMigrations(MIGRATION_1_2)
.build()
// 验证数据
val users = migratedDb.userDao().getAll()
assertEquals(1, users.size)
assertEquals("张三", users[0].name)
assertEquals(25, users[0].age) // 新字段有默认值
migratedDb.close()
}
}
我曾经因为没测迁移,导致用户升级后所有时间字段都变成了1970-01-01。那叫一个惨。所以现在我的习惯是:每次数据库版本变更,必须写对应的迁移测试。CI流水线里也加上了这个步骤。
allowMainThreadQueries()或者用runBlockingTest,否则Room会报错。另外,测试完记得删掉测试数据库文件。
11.4 破坏性迁移:fallbackToDestructiveMigration
有些场景下,你不想写迁移代码。比如开发阶段频繁改表结构,或者旧数据已经没用了。这时候可以用fallbackToDestructiveMigration()。
这个方法的意思是:如果Room找不到合适的迁移路径,就直接删掉旧表重建。说白了就是暴力重置。
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.fallbackToDestructiveMigration()
.build()
但这里有个大坑:生产环境千万别用。我见过有人上线前忘了去掉这行代码,结果用户升级后数据全没了,直接被骂上热搜。
那什么时候可以用?我个人觉得就两个场景:
- 开发调试阶段:频繁改表结构,懒得写迁移
- 测试环境:数据不重要,快速验证功能
如果你非要保留一些数据,Room还提供了fallbackToDestructiveMigrationOnVersion(int... versions),可以指定哪些版本允许破坏性迁移。
// 只在从版本1升级时允许破坏性迁移
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.fallbackToDestructiveMigrationOnVersion(1)
.build()
这样至少能控制风险范围,不至于一棍子打死所有用户。
11.5 知识体系总览
说了这么多,咱们用一张图把整个迁移体系串起来:
从图上可以看得很清楚:迁移的核心是@Migration注解,策略上分单步和多步,实在不行才用破坏性迁移兜底。但不管哪种方式,最后都得靠测试来验证。
fallbackToDestructiveMigration(),原因是赶工期没时间写迁移。结果用户数据全丢了,被产品经理追着骂了一个月。所以我的建议是:永远不要在Release版本里用破坏性迁移。如果实在没办法,至少加个版本限制。
好了,数据库迁移这块就聊到这儿。记住一句话:迁移写得好,用户数据保;迁移写不好,上线就挨刀。下回咱们聊聊Room里的类型转换器,那个也挺有意思的。
公众号:蓝海资料掘金营,微信deep3321