4. Room 进阶:关系、迁移、测试

上一章我们聊了Room的基础CRUD和DAO,算是打了地基。这一章要往上盖楼了——关系映射、数据库迁移、还有单元测试。这三块,说实话,很多人用Room一年了都不一定搞得清楚。我今天就把我踩过的坑和总结的经验一次性说透。

核心观点:Room的进阶使用,本质是在“类型安全”和“SQL灵活性”之间找到平衡。关系映射是解决对象嵌套,迁移是解决版本进化,测试是解决可信度。

4.1 关系映射:不止是@ForeignKey

很多人以为Room里关系就是加个外键。其实远不止。Room支持三种关系:一对一、一对多、多对多。但注意,Room不是ORM,它不会帮你自动拉取关联数据——你需要主动写查询。

// 一对多:一个用户有多篇文章
@Entity
data class User(
    @PrimaryKey val userId: String,
    val name: String
)

@Entity(
    foreignKeys = [ForeignKey(
        entity = User::class,
        parentColumns = ["userId"],
        childColumns = ["authorId"],
        onDelete = ForeignKey.CASCADE
    )]
)
data class Article(
    @PrimaryKey val articleId: String,
    val title: String,
    val authorId: String
)

这里有个坑。我刚开始写的时候,忘了加@Index。Room会提示你加索引以提高外键查询性能。嗯,我是被编译警告逼着改的。

注意:关系映射中的onDelete策略要谨慎选择。CASCADE会级联删除,CASCADE会级联删除,如果你在离线场景下想保留用户历史文章,最好用SET_NULL或RESTRICT。我之前一个项目因为用了CASCADE,用户删号后所有文章也跟着没了我才发现逻辑不对。

4.2 嵌套对象与@Relation

Room的核心局限是,它不会自动帮你填充嵌套对象。所以我们需要用@Relation注解配合@Transaction来手动组装。我个人习惯把所有关联查询放在一个专门的DAO方法里,用@Transaction保证一次事务完成多个查询的一致性。

data class UserWithArticles(
    @Embedded val user: User,
    @Relation(
        parentColumn = "userId",
        entityColumn = "authorId"
    )
    val articles: List<Article>
)

@Transaction
@Query("SELECT * FROM User WHERE userId = :id")
suspend fun getUserWithArticles(id: String): UserWithArticles

为什么一定要加@Transaction?因为Room需要确保两次查询之间没有数据变更,否则articles列表可能不完整。说白了,多表联查不是真的联查,而是两次查询,Room内部帮你做了merge。我在一个离线笔记App里就遇到过这个问题——不加Transaction,有时会漏掉几篇文章。

4.3 数据库迁移:版本升级的必修课

离线App的数据库升级是个大麻烦。用户的手机可能还跑着一年前的版本,你改了表结构,直接升级会导致崩溃。Room的Migration机制就是为了解决这个问题。

val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(database: SupportSQLiteDatabase) {
        database.execSQL("ALTER TABLE Article ADD COLUMN isFavorite INTEGER NOT NULL DEFAULT 0")
    }
}

迁移的原则:尽量用SQL兼容的语句。Room的Migration就是让你直接写SQL,所以你要保证写的SQL在用户当前SQLite版本上可执行。我遇到过最坑的一次是用了ALTER TABLE ... RENAME COLUMN,结果发现某款老旧Android手机的SQLite版本不支持这个语法……嗯,那时我只能回退到“创建新表→复制数据→删除旧表→重命名”这种笨办法。

避坑指南:如果你需要删除列或修改列类型,用三个步骤:
1. 创建临时表
2. 拷贝数据
3. 删除旧表,重命名临时表
直接ALTER TABLE ... DROP COLUMN在部分低版本设备上会闪退。

4.4 迁移测试:别让用户当小白鼠

迁移一旦写错,用户数据就丢了。所以必须写迁移测试。Room提供了一个测试框架,可以在内存数据库上模拟从旧版本升级到新版本的过程。

@Test
fun testMigration_1_2() {
    // 创建版本1的数据库
    val database = Room.databaseBuilder(
        context,
        AppDatabase::class.java,
        "test_db"
    ).addMigrations(MIGRATION_1_2)
     .build()

    // 主动降级到版本1(通过framework支持)
    supportSQLiteDatabase.execSQL("CREATE TABLE IF NOT EXISTS Article (...旧表结构...)")
    supportSQLiteDatabase.execSQL("INSERT INTO Article VALUES (...)")

    // 执行迁移
    database.close()
    val migratedDb = Room.databaseBuilder(...).addMigrations(MIGRATION_1_2).build()

    // 验证新表结构
    val cursor = database.query("SELECT isFavorite FROM Article")
    assertEquals(cursor.columnCount, 1) // 期望新增了isFavorite列
}

说实话,写迁移测试很琐碎,但是值得。我团队里有一条铁律:每个版本迭代的数据库迁移,必须附带迁移测试。没有测试的迁移代码不准合并。

4.5 Room单元测试:用内存数据库模拟真实环境

Room的测试也很直接。推荐用Room.inMemoryDatabaseBuilder创建内存数据库,这样每个测试用例跑完自动销毁,互不影响。

@RunWith(AndroidJUnit4::class)
class ArticleDaoTest {

    private lateinit var db: AppDatabase
    private lateinit var dao: ArticleDao

    @Before
    fun setUp() {
        db = Room.inMemoryDatabaseBuilder(
            ApplicationProvider.getApplicationContext(),
            AppDatabase::class.java
        ).build()
        dao = db.articleDao()
    }

    @After
    fun tearDown() {
        db.close()
    }

    @Test
    fun insertAndRead() = runBlocking {
        val article = Article(articleId = "1", title = "测试", authorId = "u1")
        dao.insert(article)
        val loaded = dao.getArticleById("1")
        assertEquals(loaded?.title, "测试")
    }
}

注意一个细节:用runBlocking来跑协程测试。如果你的DAO是suspend函数,测试方法必须能等它执行完。我一般会在测试基类里统一处理这个,或者直接用kotlinx.coroutines.test的runTest启动。

4.6 知识体系:一张图理清Room进阶脉络

下面这张图概括了本章的核心结构。你可以把它当作开发时的checklist。

Room 进阶知识体系 关系映射 数据库迁移 单元测试 外键 (@ForeignKey + @Index) 嵌套对象 (@Relation + @Transaction) 关系类型:1:1 / 1:N / N:M Migration 类 + SQL 语句 ALTER TABLE / 重建表策略 多版本升级链(1→2→3) in-memory 数据库模式 DAO 方法 + 协程测试 迁移测试(版本升级验证) 核心原则 关系映射:用 @Relation + @Transaction 保证一致性,不要依赖自动拉取。 迁移:每个版本写 Migration,并加上迁移测试,避免用户数据丢失。 测试:用 in-memory 数据库 + runBlocking/runTest 覆盖 DAO 和迁移逻辑。

4.7 实战建议:我的Room进阶清单

最后,我把自己平时写Room进阶代码时一定会检查的点列出来,你可以参照着过一遍自己的代码。

检查项说明优先级
外键是否加了索引Room编译会提示,但建议手动加上 @Index高
@Relation 方法是否加了 @Transaction否则可能读到不完整数据高
迁移SQL是否兼容最低版本参考用户设备的SQLite版本中
迁移测试是否覆盖升级路径至少测试一次全链路升级高
测试是否清理了数据库实例用 @After 关闭 db,防止泄露中
是否处理了多版本跳迁用户可能从 v1 直接升到 v3高