DEBUG.WIKI
丽萨主机;为跨境业务选好 IP;原生 / 住宅 IP · 按套餐选择;查看 IP →
阿里云;从建站到云上业务;云服务器 · 数据库 · 对象存储;查看产品 →
Evoxt;最高 6.0GHz CPU;高频云服务器 · 免费每周备份;查看配置 →

Room 报迁移路径缺失:已写 Migration 仍失败,先检查实际 Builder

Room 提示迁移路径缺失,检查真正使用的数据库 Builder、版本路径和 schema 一致性,通过迁移测试保留旧记录,避免以删库或破坏性回退掩盖升级问题。

修改实体并把数据库版本从 1 升到 2 后,已有用户打开应用就遇到 A migration from 1 to 2 was required but not found,全新安装却能正常使用。新数据库直接按最新结构创建,旧数据库需要迁移,因此“重装后好了”不能证明升级过程已经修复。

本文使用 Android 上的 androidx.room 2.x,示例以 2.6.x API 为基准,通过 SupportSQLiteOpenHelper 打开数据库,没有配置 setDriver。测试 helper 的数据库类构造器要求 Room 2.4.0 及以后。Room 3 或使用 SQLiteDriver 的项目应使用对应连接 API,不能直接混用本文的 SupportSQLiteDatabase 回调。

先保留出现问题的数据库和应用版本信息。不要清应用数据、卸载或添加破坏性回退来观察是否还崩溃;这些操作会抹掉正在调查的旧数据,也可能让迁移问题暂时消失。

写了迁移类,还要注册到真正使用的 Builder

下面两个入口会生成不同配置的数据库实例:

// 入口 A:注册了迁移
Room.databaseBuilder(context, NoteDatabase::class.java, "notes.db")
    .addMigrations(MIGRATION_1_2)
    .build()

// 入口 B:没有注册迁移
Room.databaseBuilder(context, NoteDatabase::class.java, "notes.db")
    .build()

即使入口 A 写得正确,Activity、依赖注入模块、测试环境或某个单例仍调用入口 B,打开旧数据库时也找不到路径。搜索整个工程的 databaseBuilder、inMemoryDatabaseBuilder 和该数据库的创建工厂,沿实际注入关系确认应用取得哪个实例。

同时核对数据库类、文件名和错误中的起止版本。示例的 notes.db 是已有文件名的占位值,实际修复必须保持原文件名。改成一个新名字后应用可能成功启动,但打开的是新数据库,用户旧记录没有因此迁移。

错误如果是从 4 到 7,就需要从实际版本 4 可达 7 的路径,而不是只写 6 到 7。Room 可以组合已注册的迁移,也可以选择覆盖多版本的直接迁移;不必为所有版本两两各写一份,但每种仍需支持的起始版本都必须能到达当前结构。

一个保留原记录的 1 到 2 示例

假设版本 1 已有 notes 表,字段只有 id INTEGER NOT NULL PRIMARY KEY 与 text TEXT NOT NULL;版本 2 增加可空的 image 文本列。下面是版本 2 的相关定义,接入现有项目时保留原数据库类名、表名和包名:

import android.content.Context
import androidx.room.Dao
import androidx.room.Database
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase

@Entity(tableName = "notes")
data class Note(
    @PrimaryKey val id: Long,
    val text: String,
    val image: String? = null
)

@Dao
interface NoteDao {
    @Query("SELECT * FROM notes WHERE id = :id")
    suspend fun findById(id: Long): Note?
}

val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL("ALTER TABLE `notes` ADD COLUMN `image` TEXT")
    }
}

@Database(entities = [Note::class], version = 2, exportSchema = true)
abstract class NoteDatabase : RoomDatabase() {
    abstract fun noteDao(): NoteDao

    companion object {
        fun create(context: Context): NoteDatabase =
            Room.databaseBuilder(
                context.applicationContext,
                NoteDatabase::class.java,
                "notes.db"
            )
                .addMigrations(MIGRATION_1_2)
                .build()
    }
}

应用原有的数据库单例或依赖注入入口统一调用 NoteDatabase.create(...),不要在各个页面重复创建数据库。示例工厂展示配置集中位置,本身不实现单例缓存。DAO 使用挂起查询,实际调用放到项目已有协程范围,不添加 allowMainThreadQueries()。

新增可空列时,原记录的 image 为 SQL NULL,原有 id 与 text 保留。这只适用于上述表结构;实际数据库若有其他实体、索引或约束,必须按导出的 schema 写迁移,不把整段示例覆盖进项目。

找到了迁移,但结构校验失败时

注册成功后,错误可能变成 Migration didn't properly handle 并列出 Expected 与 Found。此时路径已经被找到,要比较迁移后的列类型、可空性、默认值、主键、索引及外键。

Kotlin String? 对应本例的可空 TEXT 列;不要在 SQL 中写 STRING 就假定 Room 会把它当作相同结构。若新属性是非空 String,也不能只加一个允许 NULL 的列。

确实需要非空列时,先确定旧记录应取什么值,再在迁移 SQL 中提供合适的 NOT NULL DEFAULT,并让实体的 @ColumnInfo(defaultValue = ...) 与数据库默认值对应。Kotlin 构造参数的默认值只在创建 Kotlin 对象时起作用,不会自动变成 SQLite 列默认值。没有合理的旧数据取值时,应重新设计迁移,而不是随手填一个业务上无效的字符串。

自动迁移也需要旧、新版本的 schema,并正确声明版本路径。它不会因为只增加了 version 就自动启用;复杂数据转换仍应显式设计。不要在缺少历史 schema 时编造一份看似相同的 JSON 作为迁移测试依据。

用旧 schema 和真实形态的数据验收

保留版本 1、2 的导出 schema 到版本控制。exportSchema = true 还要配套设置 schema 输出目录;项目使用 Room 2.6 的 Gradle 插件时,可在既有插件配置中指定:

room {
    schemaDirectory("$projectDir/schemas")
}

如果项目仍通过 KSP/KAPT 参数导出,应沿用那套正确配置,不重复启用两种方案。测试依赖使用与运行库相同版本的 androidx.room:room-testing,并把 schema 目录加入 androidTest 的 assets。以下片段加入现有模块的 android 配置:

android {
    sourceSets.getByName("androidTest").assets.srcDir("$projectDir/schemas")
}

对于前面的表结构,可以在隔离测试设备上运行以下 instrumentation 测试。它使用专属测试文件 notes-migration-test,不要改成用户实际数据库名;helper 创建测试数据库时可能删除同名旧文件。

import androidx.room.testing.MigrationTestHelper
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class NoteMigrationTest {
    @get:Rule
    val helper = MigrationTestHelper(
        InstrumentationRegistry.getInstrumentation(),
        NoteDatabase::class.java
    )

    @Test
    fun migrate1To2KeepsNote() {
        val name = "notes-migration-test"
        helper.createDatabase(name, 1).apply {
            execSQL("INSERT INTO notes (id, text) VALUES (1, 'keep me')")
            close()
        }

        val db = helper.runMigrationsAndValidate(
            name, 2, true, MIGRATION_1_2
        )
        try {
            db.query("SELECT text, image FROM notes WHERE id = 1").use { cursor ->
                assertTrue(cursor.moveToFirst())
                assertEquals("keep me", cursor.getString(0))
                assertTrue(cursor.isNull(1))
            }
        } finally {
            db.close()
        }
    }
}

helper 校验结构,测试里的查询另外确认原数据没有丢失。还应覆盖每个仍受支持的旧版本、跨版本升级、空表、真实业务字段,以及最新版本全新创建的数据库。最后通过应用真正的数据库工厂打开升级后的数据,确认生产创建路径确实注册了迁移,而非只有测试专门提供了它。

不把删库当修复,也不要直接降版本回滚

fallbackToDestructiveMigration 用于允许丢弃旧数据的特定产品策略。缺少迁移路径时,它可能通过重建表让启动恢复,同时永久删除用户数据;本例的笔记数据应补迁移,不能默认启用这一回退。

修改前应在隔离环境保留可恢复的数据副本。涉及 WAL 的数据库,不能在持续写入时只复制一个主文件就认定备份完整,应采用项目已有的一致性备份或停止写入后的备份流程,并确认能够恢复。

上线验收要看到旧记录仍可查询和编辑、新字段行为正确、原崩溃不再发生。数据库已经迁到新版本后,退回旧 APK 不一定能打开它;应用代码回退与数据库降级是两件事。若需要回滚,使用事先验证的降级方案或完整备份恢复,并评估升级后新增数据的去向,不能只把 @Database(version) 改小。