尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Room数据库版本不一致?从报错原理到迁移实践

Room数据库版本不一致?从报错原理到迁移实践 1. 走近 Room 报错错误信息拆分解读先把这个报错完整写出来很多人拿到崩溃日志只截了一半Room cannot verify the data integrity. Looks like youve changed schema but forgot to update the version number. You can simply fix this by increasing the version number in your Database annotation.这个报错几乎每个用 Room 的 Android 开发都遇到过。我第一次踩到的时候是在一个已经上线半年的项目里当时刚把某张表加了两列字段本地跑测试一切正常结果用户端陆续反馈“一打开就崩溃”。拉回日志一看就是这个错整个人都是懵的——明明 build 都过了为什么一装到旧版本升级上来的手机上就崩先把报错本身拆开看。Room 在做数据库校验时有三条主线数据库文件的版本号、Database注解里声明的版本号、Room 持久化在数据库内部的“身份信息”。这三者任何一个对不上Room 就会保守地直接抛异常而不是猜测你的意图。这个设计其实很符合数据库的安全原则——数据无小事宁可崩溃也不允许在错误版本上执行 SQL。这个错误的核心含义是Room 通过自己的校验发现“代码里定义的表结构”和“手机里已经存在的数据库文件结构”不一致。而最讽刺的是这句报错提示里给出的修复建议——“把版本号加一”——在很多真实场景下根本不够用甚至会掩盖真正的问题。后面我会详细讲为什么光加版本号远远不够。先记住一句话Room 是一个在 SQLite 之上做了一层“关系映射和版本管理”的框架。SQLite 本身属于“无模式约束”的数据库你甚至可以往一个没有对应列的查询里硬塞数据但 Room 帮你把 Java/Kotlin 对象和 SQLite 表结构做了强绑定这种绑定关系的凭据就体现在数据库版本号和 schema 的哈希值上。错误信息里后面还有半句“Looks like youve changed schema but forgot to update the version number”注意这里的“schema”不是指某个表、某个字段而是整个数据库当前完整的结构快照。打个比方你手里的地图是 1.0 版但实际地形已经变成了 2.0导航软件出于安全考虑不敢直接带你走它抛出的就是“地图版本不匹配无法规划路线”的提示。所以这个问题的本质不是“某一行代码写错了”而是代码、版本声明、数据库文件三者之间的契约被破坏了。理解这一点后面所有的修复方案才立得住。2. 为什么 Room 会认为“数据库和代码不一致”——schema 验证机制拆解要真正根治这个错误得先明白 Room 是怎么做到“验证数据完整性”的。它靠的是三个东西协同工作。2.1 room_master_table数据库里的“身份证”第一次用 Room 的人可能没注意过只要 Room 帮你创建了数据库里面就会自动生成一张名为room_master_table的表。这张表只有一列叫identity_hash存的是当前数据库结构的哈希值。这张表就是 Room 的“身份证”。App 每次打开数据库时Room 会做如下动作读取数据库文件当前版本号SQLite 的PRAGMA user_version。读取room_master_table里存的identity_hash。根据代码里所有Entity注解的实体类、所有Dao相关的表重新计算出一个期望的哈希值。把数据库现有 hash 和期望 hash 做对比。再用fallbackToDestructiveMigration、addMigrations等策略决定是直接跑迁移脚本还是直接抛异常。这里的关键点在于第 3 步。Room 编译时通过注解处理器生成了Database_Impl类这个类里有一个createAllTables方法以及一个validateMigration方法。每次启动时Room 都会调用validateMigration来确认当前数据库文件里的表结构跟编译期生成的 schema 完全一致。如果identity_hash对不上Room 就会抛出这条IllegalStateException。所以这张表你可以理解为数据库的“指纹”一旦表结构变了“指纹”必然变但 Room 不会自动更新“指纹”它需要你显式地告诉它“结构变了这是新的版本”。2.2 版本号在其中的角色版本号PRAGMA user_version是 Room 判断如何迁移的直接依据。它的逻辑是如果数据库文件当前版本 代码里Database(version N)中声明的 N则认为“不需要迁移”直接把期望 hash 和现有 hash 做比对。如果 hash 不一致那说明代码改了表结构但版本号没变——这就直接触发我们遇到的报错。如果 hash 一致说明确实不需要迁移正常打开。如果数据库文件当前版本 N则查找是否有可用的Migration对象逐个执行迁移再更新版本号和 hash。所以光是版本号 1Room 就会认为“需要迁移”然后去找迁移脚本。如果没有对应的Migration且没设置fallbackToDestructiveMigration会抛另一个错误Migration didnt properly handle: xxx——这个话题后面会展开。现在再看报错提示“You can simply fix this by increasing the version number”它其实只是帮你绕过了“版本号没变”这层检查但没有解决“迁移脚本缺失”的问题。很多新手在开发阶段照着提示把版本号 1结果启动又报下一个错就是这个原因。2.3 schema 哈希是怎么生成的这个哈希不是随机的。Room 在编译期会把所有实体的建表语句、索引、外键约束、Entity里的indices、PrimaryKey等信息拼成一组有序的字符串序列然后通过hash运算生成一个长字符串作为这个版本数据库 schema 的指纹。所以如果你改了表名、字段名、字段类型、默认值、索引哪怕只是调整了一个ColumnInfo的排序都会导致哈希变化。这一点是容易忽略的有时你觉得自己“没改表结构”只是换了个字段顺序Room 依然会认为 schema 变了——因为建表语句里的列顺序变了生成 SQL 的字符串也变了。实时上这个设计是有意为之Room 希望通过精确匹配保证底层行为可预期。你不小心调整了列顺序Room 宁可报错也不“猜”因为旧版本数据库文件里的列顺序跟新代码期望的顺序不一致一旦执行某些不带列名的插入语句结果就可能错位。我在项目里遇到过一类很有意思的崩溃开发阶段大家共用同一个 debug 包版本号一直不动。某天有人加了一行ColumnInfo(defaultValue 0)另一个同事更新代码后一启动就崩。原因是 Room 检测到期望 hash 变了但版本号还是同一个直接抛“cannot verify the data integrity”。因为没发版大家可以选择清数据但如果这个情况发生在正式版升级就必须写 Migration 规范处理。2.4 版本升级的常规流程回顾为了后面讲修复方案时有个谱先梳理一遍 Room 正常升级时的完整流程修改实体类新增/删除/修改字段、索引、外键。修改Database(version N)把 N 增加通常 1。编写一个Migration对象定义从 N-1 到 N 的迁移 SQL 语句。在Room.databaseBuilder()上调用.addMigrations(migration)。编译运行验证数据还在结构正确。这五步缺一不可。很多人只做了第 2 步结果迁移时 Room 找不到对应 Migration直接崩溃还有人是第 3、4 步都做了但 SQL 写错了字段名对不上运行时就报 “no such column”。正式环境中最稳妥的做法是每一次发版前把 schema 导出成 JSON 文件放进项目里用 CI 做对比检查。这个思路稍后单独写一节因为它是真正能避免“上线后才发现 schema 不一致”的最有效手段。3. 解法一还没发版清数据重新创建最省事如果你的 App 还在开发调试阶段没有真实用户那这个报错的成本很低——清理数据、卸载重装、或者干脆用一个全新的模拟器就能解决。但这里也有讲究不能一上来就乱操作。3.1 Android Studio 里快速清理数据的方法在 Android Studio 里最简单的方式是打开“Device File Explorer”找到数据库目录删掉但我更推荐直接用命令adb shell pm clear com.example.yourapp这个命令会把该应用的所有数据、SharedPreferences、数据库文件全部清空应用回到“首次安装”的状态。注意它不是卸载所以代码和签名都还在适合开发调试时频繁切换结构。如果你不想清 SharedPreferences只想删数据库可以这样adb shell run-as com.example.yourapp rm /data/data/com.example.yourapp/databases/your_db_name rm /data/data/com.example.yourapp/databases/your_db_name-wal rm /data/data/com.example.yourapp/databases/your_db_name-shm exit这里特别提醒Room 默认开了 WAL 模式数据库旁边会有-wal和-shm两个文件只删主文件不够必须三个一起删否则 SQLite 可能在打开时用 WAL 里的旧数据重建一个看起来“不完整”的库。我见过有人只删了主文件结果崩溃依旧折腾半天发现是 WAL 文件在“捣乱”。3.2 fallbackToDestructiveMigration 的适用场景如果你不想每次改结构都手动清数据可以在构建数据库时加这一行Room.databaseBuilder(context, AppDatabase::class.java, app.db) .fallbackToDestructiveMigration() .build()它的语义是如果 Room 发现当前版本需要迁移但又没有找到对应的Migration对象那就直接“销毁重建”——把旧表全部DROP掉然后按新结构重新创建。用起来确实省事但要清楚代价所有本地数据都会丢失。所以它只适合开发调试期数据不重要测试环境需要快速验证新结构应用本身没有本地核心数据只有缓存性质的离线数据。但正式产品如果用了它用户升级后本地数据没了这通常是不可接受的。所以我在正式代码里从来不写fallbackToDestructiveMigration()只会在 debug flavor 里配置。用BuildConfig.DEBUG做个分支也行但更干净的做法是分开 debug/release 的数据库构建逻辑。3.3 如果只想重置 Room 的“身份证”而不动业务表有一种场景比较特殊你已经发过一版但发现 schema 其实没变只是room_master_table里的 hash 被人为改过或者因异常导入导致不一致。这时你不想丢数据也不想走完整迁移流程可以临时手动改这张表。具体操作用 Android Studio 的 Database Inspector 打开数据库文件执行DELETE FROM room_master_table;然后关掉再打开Room 检测到表里没有身份信息会重新写入当前期望 hash。相当于“认证信息过期重新登记”。这在紧急恢复时有用但需要注意如果实际表结构和代码不一致这个操作只会让 Room 跳过校验后面一旦执行插入、查询SQL 可能直接报“no such column”。所以这个手段只适用于“结构确实一致、只是 hash 被弄脏”的场景。这个操作我在生产环境踩过一次坑绝对不是常规手段不推荐作为长期方案。真正的长期方案是下面要讲的规范写 Migration。4. 解法二已经发版且有用户数据写 Migration 正道一旦你的 App 上过线、用户手里已经有数据库文件就必须把这件事当成正式需求来做写 Migration宁可多写一个空迁移也不要让 Room 走破坏性重建。4.1 从 V1 升级到 V2一个完整例子假设最初版本里有一张用户表Entity(tableName user) data class User( PrimaryKey val id: Int, val name: String, val age: Int )V2 需求是要给用户表加一个avatarUrl字段同时新增一张address表。规范的改动如下第一步改实体类Entity(tableName user) data class User( PrimaryKey val id: Int, val name: String, val age: Int, val avatarUrl: String? // 注意新加字段建议用可空类型避免非空约束导致旧数据无法迁移 ) Entity(tableName address) data class Address( PrimaryKey val id: Long, val userId: Int, val detail: String )第二步更新数据库版本号Database( entities [User::class, Address::class], version 2, exportSchema true ) abstract class AppDatabase : RoomDatabase() { abstract fun userDao(): UserDao abstract fun addressDao(): AddressDao }第三步写迁移val MIGRATION_1_2 object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL(ALTER TABLE user ADD COLUMN avatarUrl TEXT) db.execSQL( CREATE TABLE IF NOT EXISTS address ( id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, userId INTEGER NOT NULL, detail TEXT NOT NULL) ) } }第四步注册到 builderRoom.databaseBuilder(context, AppDatabase::class.java, app.db) .addMigrations(MIGRATION_1_2) .build()这里有几个细节直接决定迁移成败新增的字段类型必须和实体类里的类型严格匹配。String对应 SQLite 的TEXTInt/Long对应INTEGERBoolean在 Room 里通常存INTEGER0/1Float/Double对应REAL。写错类型有时 Room 不一定立刻报错但查询返回时类型转换可能崩溃。新增字段如果实体里声明为非空但旧表里没有默认值那么ALTER TABLE后旧的每一行该字段都是NULL这和“非空约束”冲突。Room 在迁移后校验 schema 时可能报NOT NULL constraint failed。所以要么声明String?可空类型要么给 SQL 里的新字段加DEFAULT值ALTER TABLE user ADD COLUMN avatarUrl TEXT DEFAULT 另一种做法是在表里加一个非空且有默认值的字段实体类里用ColumnInfo(defaultValue )声明。不过最简单的经验法则是新增可空字段代码最干净兼容性最强新增必填字段给默认值否则别用必填。4.2 多步升级的技巧链式迁移与跨版本迁移如果 App 已经发过 V1、V2、V3当前代码里是 V4而用户可能从 V1 直接升到 V4。你要么写多条 MigrationMIGRATION_1_2MIGRATION_2_3MIGRATION_3_4Room 会按顺序自动逐个执行。要么写一个大跨度的val MIGRATION_1_4 object : Migration(1, 4) { override fun migrate(db: SupportSQLiteDatabase) { // 一次性执行从 1 到 4 的所有 SQL } }但要注意如果同时注册了MIGRATION_1_2和MIGRATION_1_4Room 在从 1 升到 4 时会优先选择能“直达”的路径即MIGRATION_1_4不会先走 1→2 再 2→4。因为MIGRATION_2_4或者MIGRATION_1_2 MIGRATION_2_3 MIGRATION_3_4也是候选路径Room 会找“步数最少”的。这可能导致你精心设计的中间迁移没被执行如果大跨版本迁移没处理好数据就出问题。我的建议是保留所有小步迁移额外的大跨迁移只在必要时写。Room 在找迁移路径时会选择“从旧版本到新版本所需 Migration 数量最少”的路线如果小步迁移齐全它会组合执行如果只有大跨迁移它就直接用大跨。你可以通过getMigrationContainer()实际上是通过RoomOpenHelper内部的MigrationContainer在编译期看到候选路径——这在复杂项目里排查迁移路径时非常有用。4.3 如何安全地修改数据而不只是建表Migration 里不仅能做 DDL建表、加列还能做 DML改数据。比如 V2 想给所有用户默认头像写入一个固定 URLval MIGRATION_1_2 object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL(ALTER TABLE user ADD COLUMN avatarUrl TEXT DEFAULT ) db.execSQL(UPDATE user SET avatarUrl https://default.url/avatar.png WHERE avatarUrl ) } }这里要注意顺序先改结构再改数据。另外 SQLite 的ALTER TABLE ADD COLUMN有个限制——不能直接加带NOT NULL且无默认值的列。所以上面讲到的“可空字段”策略在这里更显重要。还有一点db.execSQL()不像 Room DAO 那样有编译期检查写错了只有运行时才能发现。所以 Migration 的 SQL 一定要在模拟器上装一个 V1 的旧包再装新包做升级测试不要只在全新安装的情况下测。4.4 升级崩溃回滚与“脏数据”问题真实项目里我最担心的是迁移脚本执行了一部分、另一部分报错数据库处于“半迁半不迁”的状态。SQLite 的ALTER TABLE和CREATE TABLE本身在一个事务里Migration 对象的migrate()方法执行时机是 Room 开启了事务之后。如果中途抛异常整个事务回滚数据库文件应该回到迁移前的状态。但实际经验是Room 会在迁移完成后校验整个 schema 的 hash而不是每执行一条 SQL 就校验一次。这意味着即使你的 SQL 写错了只要没抛异常最终 schema 和期望不一致Room 会抛IllegalStateException: Migration didnt properly handle并回滚。反过来如果 SQL 写得过于“宽松”比如把表结构改成了别的样子Room 也能通过最后校验发现问题。所以真的不必恐慌“脏数据”——SQLite 的事务机制保证了原子性。真正的隐患在于你自己写的 SQL 里包含了非 Room 能感知的自定义操作比如先DROP了一张 Room 不管的表或创建了 Room 不追踪的索引。这些操作如果中途失败可能影响的一致性只有你自己知道。所以 Migration 里不要夹带“私货”保持纯粹。5. 解法三导出 schema 文件做 CI 校验——防患于未然说了半天都是“出了问题怎么修”但作为写过几年 Android 的开发者我最想强调的是这个问题最好的处理方式是不要让它发生。要做到这一点核心工具是 Room 的exportSchema和编译期 schema 文件比较。5.1 开启 exportSchema 并正确配置在Database注解里加exportSchema true然后在build.gradle的kapt/ksp配置里指定 schema 导出目录// build.gradle.kts (Module) ksp { arg(room.schemaLocation, $projectDir/schemas) } // 或者 kapt kapt { arguments { arg(room.schemaLocation, $projectDir/schemas) } }开启后每次编译都会在app/schemas目录下生成形如com.example.app.AppDatabase/1.json、2.json的 JSON 文件。这些文件就是 Room 对你数据库结构的“精确快照”。关键做法把这些 JSON 文件提交到 Git 里。这样每次代码评审时改动不只是“加了一个字段”还能看到1.json与2.json的差异——相当于数据库结构的 diff。团队协作时这能避免有同事偷偷改了表结构却没写 Migration。我在实际项目中用过一轮之后感受是它不仅是“文档”更是 Reviewer 的“照妖镜”。两个开发者同时改实体类Git 冲突可能只在 Java/Kotlin 文件里暴露但如果 schema JSON 也变了另一个人的迁移脚本必然要跟上。CI 里只要看到 schema 目录有改动但Migration类没有对应版本就可以让构建失败。再往深一层Room 官方提供了一个room-test-util的MigrationTestHelper配合 AndroidX Test 可以在 JVM 测试里实际跑一遍迁移过程。如果你已经把 schema JSON 纳管那测试代码可以这样写RunWith(AndroidJUnit4::class) class MigrationTest { get:Rule val helper MigrationTestHelper( InstrumentationRegistry.getInstrumentation(), AppDatabase::class.java, emptyList(), true // 允许测试框架读取 schema 文件 ) Test fun migrate1To2() { helper.createDatabase(TEST_DB, 1).apply { // 这里可以插入一些旧版本的数据模拟真实用户数据 execSQL(INSERT INTO user (id, name, age) VALUES (1, test, 18)) close() } Room.databaseBuilder( InstrumentationRegistry.getInstrumentation().targetContext, AppDatabase::class.java, TEST_DB ).addMigrations(MIGRATION_1_2).build().run { // 迁移完成后验证数据还在 val dao userDao() val user dao.getUserById(1) assertEquals(test, user.name) assertEquals(https://default.url/avatar.png, user.avatarUrl) close() } } }这个测试的价值在于每次你改了实体类并提升了版本号都必须跑一遍从旧版本迁移到新版本的测试。如果avatarUrl不是可空字段、又没有默认值这个测试立刻就会红——这正是把问题挡在上线之前的最佳手段。我强烈建议每个有 Room 的模块都配上这个测试成本很低收益极高。5.2 schema 变化 vs Migration 的匹配规则Room 在编译期不会强制要求“每个版本都有 Migration”因为开发阶段经常允许“清数据重建”。但如果你配置了exportSchema true并且纳管了 JSON 文件那 CI 可以做一个很简单的检查每次 schema 目录新增了一个N.json时代码里必须有Migration(N-1, N)或者设置了fallbackToDestructiveMigration。其实 Android Gradle 插件和 Room 官方 lint 工具已经有一部分相关检查但还不够完整。比较常见的做法是自己在 CI 脚本里写一个 grep 检查schema 目录数量是否等于Database(version N)的 N。注意exportSchema为 true 时每次都会生成完整 JSON 文件所以 schema 目录数量应该和最新版本号一致。这里的坑在于如果你从 V1 直接改到 V3但没有Migration(2,3)中途的 V2 也没有实际发布——那 CI 检查可能误报。所以我的团队里约定每个版本号都对应一个线上发布版本schema JSON 的数量就代表线上版本数。如果某个版本只是在开发分支里临时改了一下、没有发布那 squash 代码的时候就要同步清理掉多余的 schema 文件否则后面的人看着版本号和文件数对不上会一脸懵。5.3 自动迁移AutoMigration与它的边界Room 2.4.0 之后引入了AutoMigration让不涉及复杂逻辑的表结构变更新增表、新增字段、新增索引可以不用手写 SQLDatabase( entities [User::class, Address::class], version 2, autoMigrations [ AutoMigration(from 1, to 2) ], exportSchema true ) abstract class AppDatabase : RoomDatabase()前提是两件事一是这个变更必须能被 Room 原生识别并自动生成迁移 SQL二是你要提供AutoMigrationSpec来告诉 Room 如何处理默认值等问题。比如给已有表加非空字段但有默认值可以写一个 specProvidedAutoMigrationSpec class Spec : AutoMigrationSpec val MIGRATION_1_2 AutoMigrationSpec(object : AutoMigrationSpec {}) // 实际上应该用 RenameColumn / DeleteColumn 等注解AutoMigration确实省心但它在字段重命名、数据迁移需要写 SQL 变换数据等场景下无能为力。我的经验是能用 AutoMigration 的场景比如加表、加可空字段就用它涉及“把旧字段的值拆分/合并进新字段”、“涉及删除整列但不想丢数据”的老老实实写Migration。不要为了“新特性”而把所有迁移都换成 AutoMigration出了问题反而更难排查。5.4 Room 的 schema 校验到底在“校”什么前面提到 Room 会比较期望 hash 和现有 hash现在可以补充一个细节这个 hash 是room_master_table.identity_hash和RoomOpenHelper里onValidateSchema返回值做比对的结果。Database_Impl中会调用一个类似validateMigration的方法它逐表检查表是否存在列是否齐全名字、类型、是否主键、是否非空索引是否存在、索引列是否一致外键约束是否匹配其中任何一项不匹配都会在日志里写详细原因。抓 logcat 时搜Room cannot verify之前往往能看到一行更具体的“缺了哪张表/哪一列”的提示——很多人在排查时只盯着异常方法栈顶部没往下翻日志错失了这个关键信息。严格来说Room 校验 hash 有两种模式onOpenPrepackagedDatabase针对预置数据库和onValidateSchema针对运行时创建/迁移后的数据库。前者是检查你打包带进来的.db文件是否和实体定义一致后者是校验迁移后结果。我们遇到的cannot verify the data integrity属于后者。6. 常见问题与排查技巧实录这一节列出我在处理 Room schema 问题时最常被问到、以及自己踩过的坑做成速查表供大家直接对照。症状产生原因直接方案cannot verify the data integrity版本号没变改了实体类忘了改 version改版本号写 Migration或开发期清数据版本号变了启动报Migration didnt properly handle缺少 Migration或 Migration SQL 与期望结构不符补对应版本的 Migration或调试期fallbackToDestructiveMigration()迁移后查询报no such columnSQL 里字段名拼写错误逐个检查 SQL 字段名尽量从实体类复制常量迁移后插入报NOT NULL constraint failed新加字段非空但无默认值旧数据为 NULL新字段可空或 SQL 里加DEFAULT清数据库后仍崩溃WAL/SHM 文件残留三件套一起删room_master_table不存在数据库文件被外部工具重建过使用迁移或重建紧急时手动补表AutoMigration 提示缺 spec字段变更需要特殊处理提供AutoMigrationSpec或改回手写 MigrationCI 中 schema JSON 与版本号不一致版本号手动改过未同步 schema 目录全局搜索 project 内 schema 目录统一清理到当前版本6.1 实战排查思路从崩溃日志到定位根因拿到崩溃日志后我一般按下面的顺序排查而不是直接去改代码先看崩溃日志里Room cannot verify的完整堆栈往下翻 20~30 行找RoomOpenHelper.onValidateSchema之前的日志——那里往往写了具体不匹配的表或列。用 adb 连上测试机打开同一个 App用run-as或adb pull把数据库文件拉到本地用 SQLite 命令行或 DB Browser 查看room_master_table、PRAGMA user_version、实际表结构。对比代码里Database(version)和表结构确认差异在哪。如果版本号没变判断这是“还没发版”还是“已发版”。前者直接清数据后者必须写 Migration。如果版本号变了但缺少 Migration补 Migration或如果只是本地调试直接用fallbackToDestructiveMigration()临时解决但记得上线前移除。第 2 步里最实用的命令是adb exec-out run-as com.example.yourapp cat /data/data/com.example.yourapp/databases/app.db app.db然后本地用 sqlite3 查看sqlite3 app.db .headers on .mode column SELECT * FROM room_master_table; PRAGMA user_version; .schema这个组合基本能覆盖 90% 的排查场景。6.2 多人协作时的典型冲突与兜底策略在多分支并行开发时常见的情况是分支 A 加了fieldX并把版本号升到 3分支 B 同时删了fieldY也把版本号升到 3。合并代码后Database(version 3)只有一个但两次结构变更实际都需要迁移。Git 不会帮你合并 schema JSON——它会直接冲突这时得手工处理选择一个版本号作为基准比如合并后仍为 3但此时数据库文件从 V2 到 V3 的迁移必须同时包含 A 和 B 的 SQL最稳妥的做法是把合并后版本号提到 4写 V2→V3A 的变更和 V3→V4B 的变更或者直接写 V2→V4 的大跨迁移。实际上我在团队里遇到过不止一次这种冲突。如果是快速迭代期我们通常会选择“版本号 1 然后清数据”的方式度过因为 release 前数据本来就要清。但一旦进入灰度或者已发版就必须走“合并 SQL 提升版本号”的正规路线。这里还有一个团队层面的兜底技巧所有数据库结构改动都必须在有对应 Migration 的前提下合并进主分支。可以在 GitLab CI 里加一个“schema diff 检查”的脚本如果schemas/目录比上一个 tag 多了 JSON 文件就去检查Migration相关代码是否有对应新增。这就把“人记性”变成了“机器校验”。6.3 关于预置数据库prepackaged database的单独提醒如果你用了Room.databaseBuilder(context, ...).createFromAsset(prepackaged.db)这种方式预置数据库那identity_hash的校验逻辑会走另一条路径onOpenPrepackagedDatabase阶段。此时如果预置库里没有room_master_table或者 hash 跟代码里期望的不一致也会报类似的错误。解决方案有两种一是用 Room 生成一个空数据库后再往里面手动灌数据保留里面的room_master_table和正确 hash二是在createFromAsset的同时把room_master_table里正确的 hash 手动写入预置库。第二种方式需要你先把代码跑起来生成一个库然后把它当预置库资源放进去——注意生成时用的 schema 必须和最终代码一致。这类问题最坑的地方是预置数据库文件很大不容易用文本对比 hash经常是“报错→拉库→对比→重生成”的循环。我的建议是在 CI 里加一个任务每次 schema 变更后自动重新生成预置库而不是靠人手工拿模拟器里的库出来复制。6.4 我看到过的几个糟糕案例案例一某团队在 release 前的冲刺中为了快速修复一个崩溃给数据库加了fallbackToDestructiveMigration()。上线后一周卸载率明显上升用户反馈“登录状态丢失”“收藏列表清了”。原因就是迁移脚本没写好直接走了破坏性重建。有些用户气得直接给了差评最后只能紧急发版把数据找回但被清掉的数据已经没了只能靠服务端兜底。这个教训让我从此把“迁移测试必须覆盖老版本升级”写进了 Release Checklist。案例二某个开发者觉得“SQLite 会自动加列”于是他只是改了User实体类没有写 Migration也没有升版本号。结果报的就是Room cannot verify the data integrity。他心里觉得“我没改 schema 啊”直到我让他打开room_master_table对比他才发现identity_hash早就不匹配了。这类情况在加班赶工时尤其常见——很多人把“数据库框架”和“数据库文件”混在一起以为框架会自动处理一切实际上 Room 只负责迁移不负责“猜”。案例三一个迁移 SQL 里把ALTER TABLE user ADD COLUMN avatarUrl TEXT写成了ALTER TABLE user ADD COLUMN avaterUrl TEXT拼写错误avater 而不是 avatar。因为 SQLite 对ALTER TABLE的执行是“你在给结构加列它不验证这个列在代码里有意义”所以avaterUrl真的被加进了表里。Room 迁移完成后校验期望 hash 时发现多了一个神秘列报了Migration didnt properly handle程序崩溃。如果你看到的是这个错请认真审视迁移脚本里所有列名拼写。这三个案例共同点都是迁移过程本身没报错问题出在结构不匹配。这再次验证了“schema JSON MigrationTest CI”这套组合拳的重要性——它们是唯一能在问题上线前拦住它的方式。7. 经验总结处理 Room schema 变更的几条铁律在这个领域摸爬滚打几年后我总结了几条自己一直遵守的原则送给正在看这篇文章的你。第一条永远把“数据不丢失”放在优先级第一位。开发调试期可以用销毁重建减少心智负担但生产环境里的数据库文件是用户资产的延伸任何破坏性操作都必须经过验证和评审。如果fallbackToDestructiveMigration出现在生产代码里请一定在注释里写明原因并且确保团队评审时看到了它。第二条schema 变更必须绑定版本号和迁移脚本。三件事缺一不可实体类改动、版本号 1、Migration 对象。少一件都会在某些用户设备上变成崩溃现场。我见过太多“本地好了就推到远端”的案例发版后第二天就炸了。第三条用 automation 代替记忆。靠人记“哪次改了哪个字段”完全不靠谱所以exportSchema true、schema JSON 入 Git、CI 检查、MigrationTestHelper全量测试——这套自动化流程是保护项目的最后一道大坝。每花费一小时搭这个架子省下的是未来好几天线上排查的时间。第四条遇到报错先读全日志再动手改代码。Room 这个错误信息乍一看很吓人但日志里其实埋了很多线索。找到具体是哪张表、哪个字段不匹配修复就是几分钟的事反过来如果一上来就删库、加fallbackToDestructiveMigration()很可能掩盖真相还会在后续产生数据丢失的连锁反应。第五条给团队定一个“schema 变更必须过 code review”的规矩。因为数据库影响面远大于普通业务代码一次不严谨的 Migration 可能让所有旧版本用户无法升级。在 PR 描述里加上“本 PR 变更表结构/新增表/修改字段”的模板勾选项能有效减少漏写 Migration 的情况。这不是流程绑架而是用合理的流程保护每一个潜在用户。再补充一个我最近才注意到的细节Room 的Database(exportSchema true)一定要放在正式的androidTest源码环境里校验因为debug和release使用的数据库名、表名可能因 flavor 不同而不同。如果 flavor 之间 schema 不一致MigrationTestHelper的测试也必须覆盖每个 flavor。否则你在 debug 分支测通过了release 分支依然可能在老用户手机上炸。这个问题的本质说到底就是“数据库是持久化契约而契约必须被显式管理”。Hope 这个比喻能帮你建立心智模型你手里的代码是“改需求的人”数据库文件是“已经签了旧合同的用户”而 Migration 就是“补充协议”——没有补充协议就想改条款用户直接拒绝执行就是理所当然的。 Room 只是把这个拒绝过程做得足够显眼而已。个人在实际项目中的体会是数据库错误不像普通崩溃那样“重装就好了”它直接关系到线上用户体验。所以尽早把 schema 流程自动化、尽早给团队立规矩比什么都重要。如果你现在正被这个报错折磨别慌——按上面文章里的排查顺序走一遍大概率十分钟内就能定位到问题如果已经定位到了就认认真真写迁移脚本和测试别再为了省一时的事给线上埋雷。
返回列表