1. 从一次数据库迁移事故说起:为什么Room升级值得单独拎出来讲
做Android开发的人,几乎都经历过这样的场景:App上线了一个版本,本地数据库表结构设计得不太合理,产品经理又提了新需求,需要加字段、改类型、拆表。你改完Entity,编译运行,直接崩了——IllegalStateException: Room cannot verify the data integrity。更让人头疼的是,用户手机上已经存了几百兆的本地数据,你不可能让用户卸载重装。
这就是Room升级要解决的核心问题:在保证用户数据不丢失的前提下,安全地完成数据库Schema的版本迁移。
很多人对Room的印象停留在"一个ORM框架,写写注解就能用",觉得升级无非就是改个version数字、加个Migration对象。但真正在线上环境跑过几个版本迭代之后你会发现,Room的迁移机制远比想象中复杂。一个不小心,轻则数据丢失,重则线上大面积崩溃。而且Room的报错信息往往非常"隐晦",它只会告诉你"迁移失败",但具体哪张表、哪个字段、哪条SQL出了问题,需要你自己一步步排查。
这篇文章适合所有正在使用或准备使用Room的Android开发者。不管你是刚接触Room的新手,还是已经写过几个Migration的老手,我都会从底层原理到实战操作,把Room升级这件事彻底讲透。包括Schema验证机制、Migration的正确写法、自动化迁移工具的使用、迁移测试方案、以及我在实际项目中踩过的那些坑。读完你至少能搞清楚三件事:Room升级到底在升什么、怎么升才安全、出了问题怎么排查。
2. Room的Schema验证机制:升级失败的根源在哪里
2.1 编译期与运行期的双重校验
要理解Room升级为什么会失败,首先得搞清楚Room的Schema验证是怎么工作的。Room的校验分为两个阶段:编译期校验和运行期校验。
编译期,Room的注解处理器会扫描所有@Entity、@Dao、@Database注解,生成对应的Java实现类。同时,它会把当前数据库的Schema信息导出成一个JSON文件,存放在app/schemas/目录下。这个JSON文件非常关键,它记录了每个版本数据库的完整结构:表名、字段名、字段类型、主键、索引、外键、默认值等等。
运行期,当App启动、Room第一次访问数据库时,它会做一次校验:读取设备上现有数据库的user_version(SQLite内置的版本号),和你代码中@Database(version = N)声明的版本号做对比。如果设备上的版本低于代码版本,Room就会触发迁移流程;如果版本一致,Room会进一步校验设备上数据库的实际Schema是否和schema JSON文件中记录的一致。如果不一致,直接抛出IllegalStateException。
这里有个很多人忽略的细节:Room在运行期校验Schema时,比对的是设备上数据库的真实结构,而不是你代码里的Entity定义。也就是说,即使你的Entity改对了,但如果Migration执行后数据库的实际结构和Schema JSON对不上,照样崩溃。
2.2 Schema JSON文件的作用与生成逻辑
Schema JSON文件是Room升级体系的核心枢纽。它的默认导出路径是app/schemas/你的数据库类全限定名/版本号.json。比如你的数据库类叫AppDatabase,包名是com.example.myapp,那么版本1的Schema文件就在app/schemas/com.example.myapp.AppDatabase/1.json。
这个文件的内容大致长这样:
{ "formatVersion": 1, "database": { "version": 1, "identityHash": "abc123...", "entities": [ { "tableName": "users", "createSql": "CREATE TABLE IF NOT EXISTS `users` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `age` INTEGER NOT NULL)", "fields": [ {"fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true}, {"fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true}, {"fieldPath": "age", "columnName": "age", "affinity": "INTEGER", "notNull": true} ], "primaryKey": {"columnNames": ["id"], "autoGenerate": true} } ], "views": [], "setupQueries": [...] } }其中identityHash是一个关键字段。它是Room根据整个Schema内容计算出来的哈希值。每次你修改Entity,重新编译后生成的Schema JSON的identityHash都会变化。Room在运行期就是通过比对这个哈希值来判断Schema是否发生了变更。
如果你没有正确配置Schema的导出路径,或者把exportSchema设成了false,那么Room在运行期就无法进行完整的Schema校验,迁移出问题的概率会大大增加。所以我的建议是:永远把exportSchema设为true,并且把app/schemas/目录纳入版本控制。
2.3 版本号不匹配时的三种走向
当设备上的数据库版本和代码声明的版本不一致时,Room会根据情况走三条不同的路径:
第一条路径:设备版本 < 代码版本,且找到了对应的Migration。Room会按照版本号从小到大依次执行Migration,比如从版本1升到版本3,它会先执行MIGRATION_1_2,再执行MIGRATION_2_3。每一步执行完毕后,Room会更新数据库的user_version。
第二条路径:设备版本 < 代码版本,但没有找到对应的Migration。这时候Room会检查你是否启用了fallbackToDestructiveMigration()。如果启用了,Room会直接删除所有表然后重建,数据全部丢失。如果没有启用,直接抛出IllegalStateException,App崩溃。
第三条路径:设备版本 > 代码版本。这种情况通常发生在用户安装了旧版本App(比如从应用商店回滚了版本)。Room同样会抛出异常,因为它不知道如何处理"降级"。你可以通过fallbackToDestructiveMigrationOnDowngrade()来处理这种情况,但同样会丢失数据。
理解了这三条路径,你就明白了:Migration的本质就是告诉Room,从版本A到版本B,数据库结构发生了哪些变化,需要执行哪些SQL来完成这个变化。
3. Migration的正确写法:从手动迁移到自动化方案
3.1 手动编写Migration的完整流程
手动Migration是最基础也最可控的方式。假设我们有一个users表,版本1的结构是id + name,版本2需要增加一个email字段,同时给name字段加一个索引。
第一步,修改Entity:
@Entity(tableName = "users", indices = [Index(value = ["name"])]) data class User( @PrimaryKey(autoGenerate = true) val id: Long = 0, val name: String, val email: String = "" )第二步,升级数据库版本号:
@Database(entities = [User::class], version = 2, exportSchema = true) abstract class AppDatabase : RoomDatabase() { abstract fun userDao(): UserDao }第三步,编写Migration:
val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(database: SupportSQLiteDatabase) { // 添加新字段,注意设置默认值 database.execSQL("ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT ''") // 创建索引 database.execSQL("CREATE INDEX IF NOT EXISTS index_users_name ON users (name)") } }第四步,在构建数据库时注册Migration:
Room.databaseBuilder(context, AppDatabase::class.java, "app_database") .addMigrations(MIGRATION_1_2) .build()这四步看起来简单,但每一步都有坑。我逐个说。
关于ALTER TABLE ADD COLUMN的坑:SQLite的ALTER TABLE能力非常有限,它只支持ADD COLUMN、RENAME TABLE、RENAME COLUMN这几种操作。如果你想删除字段、修改字段类型、修改约束,SQLite是不支持的。这时候你需要用"创建新表 -> 拷贝数据 -> 删除旧表 -> 重命名新表"的方式来完成。
关于NOT NULL字段的默认值:如果你新增的字段是NOT NULL的,必须提供DEFAULT值,否则已有数据的行会违反约束。而且这个默认值必须和Entity中声明的defaultValue一致,否则Schema校验会失败。
关于索引命名:Room生成的索引名有固定格式:index_表名_字段名。如果你手动创建的索引名和Room期望的不一致,Schema校验同样会失败。所以最稳妥的方式是:先写好Entity,编译一次,然后去生成的Schema JSON文件里看Room期望的createSql是什么,照着写Migration。
3.2 复杂迁移场景:拆表、合表与数据转换
实际项目中,简单的加字段只是冰山一角。更复杂的情况包括:把一张宽表拆成两张表、把两张表合并成一张、修改主键、修改字段类型并转换数据。
以拆表为例。假设版本1有一个users表,包含id, name, email, phone。版本2需要把email和phone拆到单独的contacts表中。
这种迁移没法用一条ALTER TABLE搞定,需要分步骤:
val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(database: SupportSQLiteDatabase) { // 1. 创建新表 database.execSQL(""" CREATE TABLE IF NOT EXISTS contacts ( id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, user_id INTEGER NOT NULL, email TEXT NOT NULL DEFAULT '', phone TEXT NOT NULL DEFAULT '', FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE ) """) // 2. 从旧表拷贝数据 database.execSQL(""" INSERT INTO contacts (user_id, email, phone) SELECT id, email, phone FROM users """) // 3. 重建users表(去掉email和phone字段) database.execSQL(""" CREATE TABLE users_new ( id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name TEXT NOT NULL ) """) database.execSQL("INSERT INTO users_new (id, name) SELECT id, name FROM users") database.execSQL("DROP TABLE users") database.execSQL("ALTER TABLE users_new RENAME TO users") // 4. 创建索引 database.execSQL("CREATE INDEX IF NOT EXISTS index_contacts_user_id ON contacts (user_id)") } }这段代码有几个关键点:第一,创建新表时的SQL必须和Room生成的完全一致,包括字段顺序、类型、约束、外键。第二,拷贝数据时要注意字段映射关系,别搞错了列。第三,删除旧表之前一定要确保数据已经完整拷贝,否则就是不可逆的数据丢失。
3.3 AutoMigration:让Room帮你写迁移代码
从Room 2.4.0开始,官方提供了AutoMigration功能。你只需要在@Database注解中声明autoMigrations,Room就会在编译期自动生成Migration代码。
@Database( entities = [User::class], version = 2, exportSchema = true, autoMigrations = [ AutoMigration(from = 1, to = 2) ] ) abstract class AppDatabase : RoomDatabase()AutoMigration能处理大部分常见场景:添加字段、删除字段、创建索引、删除索引、重命名表、重命名字段。但它也有局限性:它无法处理需要数据转换的场景,比如把String类型的日期转成Long时间戳,或者拆表合表。
对于AutoMigration无法处理的场景,你可以提供一个AutoMigrationSpec来补充:
@RenameColumn(tableName = "users", fromColumnName = "username", toColumnName = "name") static class MyAutoMigrationSpec : AutoMigrationSpec然后在@Database中引用:
autoMigrations = [ AutoMigration(from = 1, to = 2, spec = MyAutoMigrationSpec::class) ]我的建议是:能用AutoMigration就用AutoMigration,但一定要检查生成的Schema JSON和迁移测试结果。AutoMigration虽然方便,但它生成的SQL不一定是最优的,而且在复杂场景下可能生成错误的迁移逻辑。
4. 迁移测试:上线前的最后一道防线
4.1 为什么必须写迁移测试
我见过太多团队,Migration写完就直接上线,结果线上崩溃率飙升。原因很简单:Migration的代码路径在开发阶段几乎不会被触发。你本地开发时,数据库版本永远是最新的,除非你手动降级或者用旧版本App覆盖安装,否则根本走不到Migration逻辑。
而线上用户的情况千奇百怪:有人从版本1直接升到版本5,有人从版本3升到版本4,有人卸载重装,有人清除了数据。每一种路径都可能触发不同的Migration组合。如果不写测试,你根本不知道这些组合能不能正常工作。
Room官方提供了一个room-testing库,专门用来做迁移测试。它的原理是:用旧版本的Schema创建一个数据库,插入一些测试数据,然后执行Migration,最后校验新数据库的结构和数据是否符合预期。
4.2 用MigrationTestHelper搭建测试用例
首先添加依赖:
androidTestImplementation "androidx.room:room-testing:2.6.1"然后编写测试:
@RunWith(AndroidJUnit4::class) class MigrationTest { private val TEST_DB = "migration-test" @get:Rule val helper: MigrationTestHelper = MigrationTestHelper( InstrumentationRegistry.getInstrumentation(), AppDatabase::class.java.canonicalName, FrameworkSQLiteOpenHelperFactory() ) @Test fun migrate1To2() { // 1. 用版本1的Schema创建数据库 helper.createDatabase(TEST_DB, 1).apply { execSQL("INSERT INTO users (name) VALUES ('张三')") execSQL("INSERT INTO users (name) VALUES ('李四')") close() } // 2. 执行迁移到版本2 val db = helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2) // 3. 校验数据 val cursor = db.query("SELECT * FROM users") assertEquals(2, cursor.count) cursor.moveToFirst() assertEquals("张三", cursor.getString(cursor.getColumnIndex("name"))) assertEquals("", cursor.getString(cursor.getColumnIndex("email"))) cursor.close() } }runMigrationsAndValidate这个方法非常关键。它做了三件事:执行Migration、校验新数据库的Schema是否和Schema JSON一致、返回可查询的数据库对象。如果Schema校验失败,测试会直接报错,告诉你哪里不一致。
4.3 多版本跳跃迁移的测试策略
实际项目中,用户可能从任意一个历史版本升级到最新版本。你需要测试所有可能的迁移路径。比如当前最新版本是4,历史版本有1、2、3,那么需要测试的路径包括:1->4、2->4、3->4。
Room的MigrationTestHelper支持传入多个Migration:
@Test fun migrate1To4() { helper.createDatabase(TEST_DB, 1).apply { execSQL("INSERT INTO users (name) VALUES ('王五')") close() } val db = helper.runMigrationsAndValidate( TEST_DB, 4, true, MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4 ) // 校验数据... }这里有个经验:不要只测试相邻版本的迁移,一定要测试跨版本迁移。因为跨版本迁移会依次执行多个Migration,中间任何一步出问题都会导致最终失败。而且跨版本迁移时,数据会经过多次转换,更容易出现数据丢失或类型不匹配的问题。
5. 线上环境迁移失败的排查链路
5.1 从崩溃日志定位问题
线上Migration失败时,崩溃日志通常长这样:
java.lang.IllegalStateException: Room cannot verify the data integrity. Looks like you've changed schema but forgot to update the version number. You can simply fix this by increasing the version number. Expected: TableInfo{name='users', columns={id=Column{name='id', type='INTEGER', affinity='3', notNull=true, primaryKeyPosition=1}, name=Column{name='name', type='TEXT', affinity='2', notNull=true, primaryKeyPosition=0}}, foreignKeys=[], indices=[]} Found: TableInfo{name='users', columns={id=Column{name='id', type='INTEGER', affinity='3', notNull=true, primaryKeyPosition=1}, name=Column{name='name', type='TEXT', affinity='2', notNull=true, primaryKeyPosition=0}, email=Column{name='email', type='TEXT', affinity='2', notNull=true, primaryKeyPosition=0}}, foreignKeys=[], indices=[]}这段日志的关键信息在Expected和Found两个部分。Expected是Room期望的Schema(来自Schema JSON),Found是设备上数据库的实际Schema。对比两者,你就能发现差异:Found中多了一个email字段,说明Migration执行了添加字段的操作,但Schema JSON中没有这个字段,说明你忘记升级版本号或者忘记重新编译导出Schema。
常见的崩溃原因和排查方向:
| 崩溃信息关键词 | 可能原因 | 排查方向 |
|---|---|---|
cannot verify the data integrity | Schema不一致 | 对比Expected和Found的差异 |
A migration from X to Y was required but not found | 缺少Migration | 检查addMigrations是否注册了对应版本 |
no such table | 表未创建 | 检查Migration中是否创建了新表 |
no such column | 字段未添加 | 检查ALTER TABLE语句 |
UNIQUE constraint failed | 唯一约束冲突 | 检查数据是否有重复值 |
FOREIGN KEY constraint failed | 外键约束冲突 | 检查关联数据是否存在 |
5.2 用Schema JSON做差异比对
当崩溃日志不够清晰时,最有效的排查方式是直接比对Schema JSON文件。假设你从版本2升到版本3失败了,你可以打开app/schemas/com.example.AppDatabase/2.json和3.json,逐字段对比。
我通常会关注这几个地方:表名是否一致、字段名是否一致、字段类型是否一致、notNull约束是否一致、默认值是否一致、索引是否一致、外键是否一致。任何一处不一致都会导致校验失败。
有个小技巧:你可以用diff命令或者IDE的对比工具来快速找出两个JSON文件的差异。如果差异很多,说明你的Entity改动很大,需要仔细检查每一处。
5.3 降级与异常兜底方案
即使做了充分的测试,线上仍然可能出现意料之外的迁移失败。这时候你需要一个兜底方案。最常见的做法是:捕获迁移异常,降级到 destructive migration。
Room.databaseBuilder(context, AppDatabase::class.java, "app_database") .addMigrations(MIGRATION_1_2, MIGRATION_2_3) .fallbackToDestructiveMigrationOnDowngrade() .build()但fallbackToDestructiveMigration会丢失所有数据,只适合那些本地数据不重要、可以从服务器重新拉取的场景。如果本地数据很重要,你需要更精细的兜底策略:比如在迁移失败时,把旧数据库文件备份到另一个路径,然后创建一个新数据库,再尝试从备份中恢复数据。
还有一种做法是:在Application启动时,先检查数据库版本,如果发现异常,提前处理。比如:
try { val db = Room.databaseBuilder(...).build() db.openHelper.writableDatabase } catch (e: IllegalStateException) { // 迁移失败,执行兜底逻辑 handleMigrationFailure() }6. 那些年我踩过的Room升级坑
6.1 忘记导出Schema导致的连锁反应
早期做项目时,我觉得exportSchema没什么用,就设成了false。结果有一次线上迁移失败,我想排查问题,发现根本没有Schema JSON文件可以对比。只能靠猜,效率极低。后来我把所有项目的exportSchema都改成了true,并且把app/schemas/目录提交到了Git。
这里有个细节:如果你用的是KSP而不是KAPT,Schema的导出路径可能需要在build.gradle中额外配置:
ksp { arg("room.schemaLocation", "$projectDir/schemas") }另外,Schema JSON文件必须纳入版本控制。因为迁移测试需要用到历史版本的Schema文件,如果你不提交,CI上跑测试时会找不到旧版本的Schema,测试直接失败。
6.2 字段默认值与Entity声明不一致
这个问题非常隐蔽。假设你在Entity中声明了一个字段:
@ColumnInfo(defaultValue = "0") val age: Int = 0然后在Migration中写:
database.execSQL("ALTER TABLE users ADD COLUMN age INTEGER NOT NULL DEFAULT 1")注意,Entity中声明的默认值是0,但Migration中写的默认值是1。这两个不一致,Schema校验会失败。而且报错信息不会直接告诉你"默认值不一致",只会说"Schema不匹配",你需要自己去对比JSON文件才能发现。
所以我的经验是:Migration中的SQL语句,最好直接从Schema JSON文件中复制createSql,然后稍作修改。这样能最大程度保证一致性。
6.3 主键自增与数据拷贝的冲突
在做拆表迁移时,我遇到过一个问题:新表的主键是AUTOINCREMENT的,我从旧表拷贝数据时,手动指定了id值。结果新表的sqlite_sequence没有更新,后续插入数据时,自增ID从1开始,导致主键冲突。
解决方案是:在拷贝数据后,手动更新sqlite_sequence:
UPDATE sqlite_sequence SET seq = (SELECT MAX(id) FROM contacts) WHERE name = 'contacts'或者更简单的做法:拷贝数据时不指定id,让SQLite自动生成。但这样会丢失旧表中的ID关联关系,需要根据实际情况权衡。
6.4 多模块项目中的Schema路径冲突
在组件化项目中,数据库可能被多个模块引用。如果每个模块都配置了room.schemaLocation,可能会出现Schema文件路径冲突或者覆盖的问题。
我的做法是:只在app模块中配置Schema导出路径,其他模块通过api依赖传递Room的注解处理器。同时,确保所有模块使用的Room版本一致,避免因为版本差异导致Schema格式不兼容。
6.5 迁移耗时过长导致的ANR
当数据库数据量很大时,Migration可能会执行很长时间。如果Migration在主线程执行,直接ANR。Room默认的databaseBuilder是在主线程构建的,但实际的数据库操作是在子线程执行的。不过,Migration本身是在打开数据库时执行的,如果你在Application的onCreate中直接访问数据库,Migration就会在主线程执行。
解决方案是:不要在Application启动时同步访问数据库。把数据库的初始化放到子线程,或者使用RoomDatabase.Builder的setQueryExecutor和setTransactionExecutor来指定执行器。
Room.databaseBuilder(context, AppDatabase::class.java, "app_database") .setQueryExecutor(Executors.newFixedThreadPool(4)) .setTransactionExecutor(Executors.newSingleThreadExecutor()) .build()另外,对于大数据量的迁移,可以考虑分批处理。比如每次只迁移1000条数据,迁移完一批就更新一次版本号,避免一次性迁移太多导致超时。
7. 面向未来的Room升级策略
7.1 建立Schema变更的规范流程
经过多个项目的迭代,我总结了一套Schema变更的规范流程,团队里每个人都必须遵守:
- 修改Entity之前,先确认当前数据库版本号。
- 修改Entity之后,立即升级版本号,并编写对应的Migration。
- 编译项目,检查生成的Schema JSON文件,确认
identityHash已更新。 - 编写迁移测试,覆盖相邻版本和跨版本迁移。
- 在CI上运行迁移测试,确保所有历史版本的迁移路径都能通过。
- Code Review时,重点检查Migration的SQL语句和Schema JSON的差异。
这套流程看起来繁琐,但能避免90%以上的迁移问题。尤其是第3步和第4步,很多团队会忽略,结果上线后才发现问题。
7.2 用CI自动化迁移测试
手动跑迁移测试容易遗漏,最好把它集成到CI流程中。在build.gradle中配置:
android { testOptions { unitTests { includeAndroidResources = true } } }然后在CI脚本中添加:
./gradlew connectedAndroidTest这样每次提交代码时,CI会自动运行所有迁移测试。如果某个迁移路径失败了,CI会直接报错,阻止合并。
对于没有条件跑仪器化测试的团队,可以考虑用Robolectric来跑迁移测试。Room的MigrationTestHelper也支持Robolectric,虽然不如真机测试准确,但能覆盖大部分场景。
7.3 数据库版本管理的长期维护建议
随着App迭代,数据库版本会越来越多,Migration文件也会越积越多。如果不加以管理,最终会变成一团乱麻。我的建议是:
第一,为每个Migration写注释,说明这个版本做了什么变更、为什么做这个变更。比如:
// 版本2 -> 3:新增用户头像字段,用于支持个性化推荐 val MIGRATION_2_3 = object : Migration(2, 3) { ... }第二,定期整理Migration文件,把相关的Migration放在同一个文件中,按版本号排序。不要把所有Migration都堆在一个文件里。
第三,考虑使用Room的AutoMigration来减少手动代码。对于简单的加字段、加索引,AutoMigration完全够用。只有复杂的数据转换才需要手动写Migration。
第四,保留所有历史版本的Schema JSON文件。这些文件是迁移测试的基础,也是排查线上问题的关键依据。不要因为文件多就删除它们。
第五,在数据库版本达到一定数量后,考虑做一次"基线合并"。比如当前最新版本是10,你可以把版本1到10的Migration合并成一个"从1直接到10"的Migration,减少迁移路径的复杂度。但这样做的前提是,你确定没有用户还在使用版本1到9的App。