1. Room升级到底在升什么
做Android开发的朋友大概率都经历过这样的场景:App第一版上线时数据库表结构设计得挺满意,结果产品迭代两轮下来,要加字段、要改类型、要拆表,甚至要把旧数据迁移到全新的表结构里。这时候如果处理不当,用户升级App之后直接闪退,或者数据丢失,那真是灾难现场。
Room作为Jetpack架构组件里的持久化库,本质上是对SQLite做了一层ORM封装。它把SQLiteOpenHelper那套繁琐的版本管理、Cursor解析、表结构映射都接管了,让开发者用注解就能定义实体和DAO。但封装得越好,升级时踩坑就越隐蔽——因为很多细节被Room藏起来了,你不主动去了解,出了问题连日志都看不懂。
这篇文章要聊的就是Room数据库升级这件事。具体来说,包括版本号怎么管理、Migration怎么写、Schema导出是干什么的、自动迁移和手动迁移怎么选、升级过程中数据怎么保证不丢、测试怎么做。适合已经用过Room但还没认真处理过升级的开发者,也适合正在设计第一版数据库、想提前把升级路径规划好的朋友。
我自己的经验是,Room升级这件事,第一次遇到时觉得很简单——不就是改个版本号加个Migration吗?但真正在线上跑过几个版本之后才发现,里面门道不少。有些坑一旦踩了,用户数据就回不来了,所以值得花时间彻底搞明白。
2. 先搞清楚Room的版本管理机制
2.1 版本号到底代表什么
Room的数据库版本号定义在@Database注解里,比如version = 1。这个数字和SQLite内部的PRAGMA user_version是对应的。每次你修改了实体类(增加字段、删除字段、改表名、改索引等),都需要把版本号加1,否则Room在运行时会检测到Schema不一致,直接抛异常。
这里有个容易被忽略的点:版本号是整数,只能递增,不能回退。如果你把版本号从3改回2,已经升级到3的用户再装这个包,Room会认为数据库版本比代码版本高,同样会抛异常。所以版本号一旦发出去,就只能往上走。
另外,版本号的管理要和Git分支策略配合好。我见过团队在多个feature分支上各自加了版本号,合并时冲突,最后发出去的包版本号乱掉。建议的做法是:版本号统一在主干上分配,每个准备发版的版本对应一个明确的数据库版本号,记录在案。
2.2 Schema文件的作用
Room在编译时会根据实体类生成一个JSON格式的Schema文件,默认导出到app/schemas/目录下。这个文件记录了当前版本数据库的完整结构:有哪些表、每张表有哪些列、列的类型和约束、索引信息等。
很多人一开始不重视这个文件,觉得它只是编译产物。但实际上,Schema文件是Room做迁移验证的核心依据。当你写了Migration之后,Room会在编译时对比迁移前后的Schema,确认迁移结果和目标版本一致。如果Schema文件缺失,这个验证就没法做,Room会在运行时才暴露问题。
所以第一件事:确保build.gradle里开启了Schema导出。Room 2.6之后,可以用Room Gradle Plugin来配置,更规范。
// 使用Room Gradle Plugin(推荐) plugins { id 'androidx.room' } room { schemaDirectory "$projectDir/schemas" }如果不用插件,也可以在build.gradle里通过room.schemaLocation参数指定:
android { defaultConfig { javaCompileOptions { annotationProcessorOptions { arguments += ["room.schemaLocation": "$projectDir/schemas".toString()] } } } }注意:Schema文件必须提交到版本控制里。它是团队协作和迁移测试的基础,丢了就很难恢复。
2.3 自动迁移和手动迁移的区别
Room从2.4版本开始支持自动迁移(AutoMigration)。你只需要在@Database注解里声明从哪个版本迁移到哪个版本,Room会自动生成迁移逻辑。比如:
@Database( entities = [User::class], version = 2, autoMigrations = [ AutoMigration(from = 1, to = 2) ] ) abstract class AppDatabase : RoomDatabase() { // ... }自动迁移能处理的情况包括:新增列(有默认值)、删除列、修改列的类型(部分情况)、新增表、删除表等。但它不是万能的,有些场景必须手动写Migration:
- 需要拆分或合并表
- 需要根据旧数据计算新列的值
- 需要重命名列(自动迁移无法推断是重命名还是删旧增新)
- 需要修改主键或外键约束
手动迁移就是继承Migration类,在migrate()方法里写SQL:
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") } }然后在构建数据库时注册:
Room.databaseBuilder(context, AppDatabase::class.java, "app.db") .addMigrations(MIGRATION_1_2) .build()我的建议是:能用自动迁移就用自动迁移,减少手写SQL出错的风险。但涉及数据转换的场景,老老实实写手动迁移,并且在迁移前后做好数据校验。
3. 手写Migration的核心要点
3.1 Migration的基本结构
一个Migration对象由三个要素组成:起始版本、目标版本、迁移逻辑。Room在打开数据库时,会检查当前数据库版本和代码版本,如果发现需要升级,就会按顺序执行注册的Migration。
val MIGRATION_2_3 = object : Migration(2, 3) { override fun migrate(db: SupportSQLiteDatabase) { // 迁移逻辑 } }这里有个关键点:Migration的版本号必须是连续的。如果你从版本1升到版本3,但只注册了1到2的Migration,Room会报错说找不到2到3的迁移路径。所以每次加版本,都要确保迁移链完整。
3.2 新增列的注意事项
新增列是最常见的迁移操作。SQLite的ALTER TABLE ADD COLUMN有几个限制:
- 新增的列如果有NOT NULL约束,必须指定DEFAULT值
- 新增的列不能是PRIMARY KEY或UNIQUE
- 新增的列如果有外键约束,默认值必须是NULL
val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL( "ALTER TABLE user ADD COLUMN nickname TEXT NOT NULL DEFAULT ''" ) } }对应的实体类:
@Entity(tableName = "user") data class User( @PrimaryKey val id: Long, val name: String, @ColumnInfo(defaultValue = "") val nickname: String )注意:实体类里的
defaultValue要和Migration里的DEFAULT值保持一致,否则Room的Schema验证会失败。
3.3 修改列类型的正确姿势
SQLite不支持直接修改列的类型。如果你要把一个列从INTEGER改成TEXT,需要走"新建表-复制数据-删旧表-重命名"的流程:
val MIGRATION_3_4 = object : Migration(3, 4) { override fun migrate(db: SupportSQLiteDatabase) { // 1. 创建新表 db.execSQL(""" CREATE TABLE user_new ( id INTEGER PRIMARY KEY NOT NULL, name TEXT NOT NULL, age TEXT NOT NULL DEFAULT '0' ) """) // 2. 复制数据并转换类型 db.execSQL(""" INSERT INTO user_new (id, name, age) SELECT id, name, CAST(age AS TEXT) FROM user """) // 3. 删除旧表 db.execSQL("DROP TABLE user") // 4. 重命名新表 db.execSQL("ALTER TABLE user_new RENAME TO user") } }这个流程看起来简单,但有几个坑:
- 新表的Schema必须和实体类完全一致,包括索引、外键、默认值
- 复制数据时要注意类型转换的边界情况,比如NULL值
- 如果旧表有索引,新表也要重建索引
- 如果旧表有触发器,也要重新创建
我一般会先用sqlite3命令行工具在本地数据库上把整个流程跑一遍,确认没问题再写到代码里。
3.4 重命名列的处理
Room的自动迁移无法区分"重命名列"和"删除旧列+新增新列"。如果你只是重命名,用自动迁移会导致旧列数据丢失。正确做法是手动写Migration:
val MIGRATION_4_5 = object : Migration(4, 5) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE user RENAME COLUMN username TO display_name") } }然后在实体类里用@ColumnInfo(name = "display_name")指定新列名。这样旧数据就保留下来了。
3.5 迁移中的事务处理
Room在执行Migration时,默认会把整个迁移过程包在一个事务里。如果迁移过程中抛异常,事务会回滚,数据库保持原样。这个机制很重要,它保证了迁移的原子性。
但要注意:如果你的迁移逻辑里包含大量数据操作,事务可能会很大,导致内存占用高或者执行时间长。这种情况下可以考虑分批处理,但分批处理会破坏原子性,需要自己做好错误恢复。
实操心得:迁移逻辑尽量保持简单,不要在Migration里做复杂的业务计算。如果确实需要,先把数据迁移到临时表,然后在App启动后异步处理。
4. 自动迁移的边界与限制
4.1 自动迁移能做什么
Room的自动迁移基于Schema文件的对比。它会对比两个版本的Schema JSON,推断出需要执行的SQL。目前支持的操作包括:
| 操作类型 | 是否支持 | 说明 |
|---|---|---|
| 新增表 | 支持 | 直接CREATE TABLE |
| 删除表 | 支持 | 直接DROP TABLE |
| 新增列 | 支持 | 需要指定默认值 |
| 删除列 | 支持 | 直接DROP COLUMN(SQLite 3.35+) |
| 修改列默认值 | 支持 | 需要重建表 |
| 修改列类型 | 部分支持 | 需要兼容的类型转换 |
| 重命名列 | 不支持 | 需要手动指定 |
| 重命名表 | 不支持 | 需要手动指定 |
| 修改主键 | 不支持 | 需要手动迁移 |
| 修改外键 | 不支持 | 需要手动迁移 |
4.2 用@RenameColumn和@RenameTable辅助
虽然自动迁移不能自动识别重命名,但你可以通过@RenameColumn和@RenameTable注解告诉Room:
@Database( entities = [User::class], version = 6, autoMigrations = [ AutoMigration( from = 5, to = 6, spec = MyAutoMigrationSpec::class ) ] ) abstract class AppDatabase : RoomDatabase() { // ... } @RenameColumn(tableName = "user", fromColumnName = "username", toColumnName = "display_name") class MyAutoMigrationSpec : AutoMigrationSpec这样Room就知道是重命名而不是删旧增新,数据不会丢。
4.3 自动迁移的Schema验证
自动迁移生成后,Room会在编译时验证迁移结果是否和目标版本的Schema一致。如果验证失败,编译就会报错。这个机制能帮你提前发现问题,但也意味着你必须保证Schema文件是最新的。
有时候你会遇到这样的情况:改了实体类,但忘了导出Schema,或者Schema文件没提交。这时候编译可能会通过,但运行时迁移会失败。所以养成习惯:每次改实体类,先编译一次,确认Schema文件更新了,再提交。
5. 迁移测试怎么做才靠谱
5.1 用MigrationTestHelper做自动化测试
Room提供了MigrationTestHelper类,可以在instrumented test里验证迁移逻辑。基本用法:
@RunWith(AndroidJUnit4::class) class MigrationTest { @get:Rule val helper = MigrationTestHelper( InstrumentationRegistry.getInstrumentation(), AppDatabase::class.java ) @Test fun migrate1To2() { // 创建版本1的数据库 helper.createDatabase("test.db", 1).apply { execSQL("INSERT INTO user (id, name) VALUES (1, 'test')") close() } // 执行迁移到版本2 val db = helper.runMigrationsAndValidate("test.db", 2, true, MIGRATION_1_2) // 验证数据 val cursor = db.query("SELECT * FROM user WHERE id = 1") assertTrue(cursor.moveToFirst()) assertEquals("test", cursor.getString(cursor.getColumnIndex("name"))) assertEquals("", cursor.getString(cursor.getColumnIndex("nickname"))) } }这个测试会做两件事:执行迁移,然后验证迁移后的Schema是否和目标版本一致。如果Schema不匹配,测试会失败。
5.2 测试数据的准备
测试迁移时,测试数据要覆盖各种边界情况:
- 空表
- 只有一条记录
- 有NULL值的记录
- 有特殊字符的记录
- 有外键关联的记录
我一般会写一个辅助方法,往每个版本的数据库里插入一套标准测试数据,然后迁移后逐条验证。
5.3 多版本连续迁移测试
实际项目中,用户可能从版本1直接升级到版本5。所以测试不能只测相邻版本,还要测跨版本迁移:
@Test fun migrate1To5() { helper.createDatabase("test.db", 1).apply { // 插入版本1的数据 close() } val db = helper.runMigrationsAndValidate( "test.db", 5, true, MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5 ) // 验证数据 }注意:跨版本迁移测试要确保所有中间版本的Migration都注册了,否则会报错。
5.4 迁移性能测试
如果数据量大,迁移可能会耗时较长。我建议在测试环境模拟大量数据(比如10万条),测一下迁移耗时。如果超过几秒,就要考虑优化,比如分批处理或者把耗时操作放到后台线程。
6. 线上升级的避坑指南
6.1 不要在主线程做迁移
Room默认不允许在主线程访问数据库,迁移也一样。如果你在Room.databaseBuilder之后直接调用build(),第一次访问数据库时会在调用线程执行迁移。如果迁移耗时较长,会阻塞UI。
正确的做法是:在后台线程触发一次数据库访问,让迁移在后台完成。或者用RoomDatabase.Callback的onOpen回调,但注意onOpen也是在访问线程执行的。
// 在Application的onCreate里异步触发 CoroutineScope(Dispatchers.IO).launch { AppDatabase.getInstance(context).userDao().count() }6.2 迁移失败的回退策略
如果迁移失败,Room会抛异常。默认情况下,数据库会保持原样,但App可能无法正常使用。你需要决定回退策略:
- 方案A:捕获异常,提示用户重启App
- 方案B:删除旧数据库,重建新库(数据丢失)
- 方案C:降级到旧版本数据库(需要保留旧Schema)
我一般推荐方案A,因为数据丢失是不可接受的。但如果迁移逻辑有bug,用户重启也没用,这时候就需要紧急发版修复。
6.3 数据库文件备份
在迁移之前,可以先备份数据库文件。如果迁移失败,可以用备份恢复。但备份文件会占用存储空间,需要定期清理。
val dbFile = context.getDatabasePath("app.db") val backupFile = File(dbFile.parent, "app.db.backup") dbFile.copyTo(backupFile, overwrite = true)注意:备份要在数据库关闭状态下进行,否则可能备份到不完整的数据。
6.4 版本回退的处理
如果新版本App的数据库版本比旧版本低(比如用户卸载了新版本,装了旧版本),Room会抛异常。这种情况在测试阶段常见,线上也有可能出现(比如应用商店回滚)。
处理方式有两种:
- 在
Room.databaseBuilder里调用fallbackToDestructiveMigration(),但会丢失数据 - 自己处理版本回退,比如把数据库版本号改回去,但需要确保Schema兼容
我一般会在测试包里加fallbackToDestructiveMigration(),正式包不加,避免误删用户数据。
6.5 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 启动时闪退,日志显示IllegalStateException | 缺少Migration | 检查是否注册了所有需要的Migration |
| 迁移后数据丢失 | Migration逻辑错误 | 用MigrationTestHelper验证 |
| 编译时报Schema验证失败 | 实体类和Migration不一致 | 检查defaultValue、列类型、索引 |
| 迁移耗时过长 | 数据量大或SQL效率低 | 分批处理,加索引,后台执行 |
| 自动迁移不生效 | Schema文件缺失或未更新 | 确认schemaLocation配置正确 |
| 重命名列后数据丢失 | 自动迁移识别为删旧增新 | 用@RenameColumn注解 |
7. 一些实战中的经验体会
Room升级这件事,说复杂也复杂,说简单也简单。核心就一句话:每次改数据库结构,都要想清楚旧数据怎么办。我见过太多团队因为迁移没做好,导致用户升级后数据丢失,最后只能发公告道歉。
几个我踩过的坑,分享出来供参考:
第一,Schema文件一定要提交到Git。有一次团队新人改了实体类,编译通过,但Schema文件没提交,CI上跑迁移测试直接挂了。后来我们在CI里加了一步,检查Schema文件是否有未提交的变更。
第二,迁移测试要覆盖所有历史版本。不要只测相邻版本,用户可能从任意版本升级上来。我们现在的做法是,每加一个版本,就补一个从版本1到当前版本的测试。
第三,迁移逻辑尽量简单。不要在Migration里做业务逻辑,比如根据用户等级计算积分什么的。迁移只做结构变更和数据搬运,业务逻辑放到App启动后处理。
第四,线上监控要做好。我们在数据库打开的地方加了埋点,记录迁移耗时和是否成功。如果发现某个版本迁移失败率偏高,可以及时回滚。
第五,自动迁移不是万能的。虽然它能省不少事,但涉及数据转换、表拆分、主键修改这些场景,还是得手写Migration。手写的时候,一定要在本地用真实数据量测试。
最后说一个细节:Room的fallbackToDestructiveMigration()在开发阶段很好用,但千万别带到正式包。我见过有人忘了删,结果用户升级后数据全没了。可以在build.gradle里根据buildType配置,debug包才加这个选项。
数据库升级是App迭代中不可避免的一环,提前规划好迁移策略,比出了问题再补救要省心得多。希望这些经验能帮到正在处理Room升级的朋友。