Room 数据库迁移与多表关联查询实战

Room 数据库迁移与多表关联查询实战

在 Android 本地数据持久化方案中,Room 已经成为事实标准。但很多团队在实际项目中遇到的痛点,往往不是"怎么用 Room",而是"数据库升级了怎么办"和"多表查询怎么写才优雅"。

本文将从真实项目场景出发,梳理 Room 数据库迁移策略、多表关联查询的最佳实践,以及线上遇到的几个典型踩坑。


场景:从单表到关联查询的演进

假设我们的 App 初版只有一个用户表:

kotlin 复制代码
@Entity(tableName = "users")
data class User(
    @PrimaryKey val uid: Long,
    val name: String,
    val avatar: String
)

随着业务发展,需要增加订单表,并关联用户:

kotlin 复制代码
@Entity(tableName = "orders")
data class Order(
    @PrimaryKey val orderId: Long,
    val userId: Long,
    val amount: Double,
    val createTime: Long
)

这时就面临两个问题:

  1. 如何平滑升级数据库结构?
  2. 如何高效查询"用户及其所有订单"?

数据库迁移:从 Migration 到 fallbackToDestructiveMigration

方案一:编写 Migration 脚本

Room 提供 Migration 类,可以定义版本间的升级 SQL:

kotlin 复制代码
val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(database: SupportSQLiteDatabase) {
        database.execSQL(
            """
            CREATE TABLE IF NOT EXISTS orders (
                orderId INTEGER PRIMARY KEY NOT NULL,
                userId INTEGER NOT NULL,
                amount REAL NOT NULL,
                createTime INTEGER NOT NULL
            )
            """.trimIndent()
        )
    }
}

val db = Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
    .addMigrations(MIGRATION_1_2)
    .build()

适用场景:

  • 生产环境数据必须保留
  • 表结构变更频繁但有清晰版本规划

注意事项:

  • 必须覆盖所有版本路径(如 1→2、2→3、1→3)
  • SQL 错误会导致升级失败,需要提前在测试库验证

方案二:fallbackToDestructiveMigration

如果是开发阶段或数据可丢弃场景,可以直接清库重建:

kotlin 复制代码
val db = Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
    .fallbackToDestructiveMigration()
    .build()

风险:

  • 用户数据丢失
  • 线上版本禁用

方案三:按版本粒度降级

只允许特定版本强制降级:

kotlin 复制代码
.fallbackToDestructiveMigrationFrom(1, 2) // 从版本 1 或 2 升级时清库

实战建议:

  • 开发环境用 fallbackToDestructiveMigration
  • 生产环境编写 Migration,并在 CI 中跑迁移测试
  • 关键字段变更前,先加新字段 + 数据迁移,再删旧字段(分两个版本)

多表关联查询:从 JOIN 到 @Relation

场景 1:一对多关系(用户 → 订单)

定义关系实体:

kotlin 复制代码
data class UserWithOrders(
    @Embedded val user: User,
    @Relation(
        parentColumn = "uid",
        entityColumn = "userId"
    )
    val orders: List<Order>
)

DAO 查询:

kotlin 复制代码
@Transaction
@Query("SELECT * FROM users WHERE uid = :userId")
suspend fun getUserWithOrders(userId: Long): UserWithOrders?

工作原理:

  1. @Embedded 将 User 字段平铺到结果中
  2. @Relation 触发第二次查询:SELECT * FROM orders WHERE userId = :uid
  3. @Transaction 保证两次查询的原子性

性能考量:

  • Room 会执行 N+1 查询(一次主查询 + N 次关联查询)
  • 如果用户列表很长,建议用 JOIN 手写 SQL

场景 2:多对多关系(课程 ↔ 学生)

需要中间表:

kotlin 复制代码
@Entity(tableName = "course_student_cross_ref")
data class CourseStudentCrossRef(
    val courseId: Long,
    val studentId: Long,
    @PrimaryKey(autoGenerate = true) val id: Long = 0
)

关系定义:

kotlin 复制代码
data class CourseWithStudents(
    @Embedded val course: Course,
    @Relation(
        parentColumn = "courseId",
        entityColumn = "studentId",
        associateBy = Junction(CourseStudentCrossRef::class)
    )
    val students: List<Student>
)

查询:

kotlin 复制代码
@Transaction
@Query("SELECT * FROM courses WHERE courseId = :courseId")
suspend fun getCourseWithStudents(courseId: Long): CourseWithStudents?

场景 3:手写 JOIN 优化

如果只需要部分字段,用 @RawQuery 或直接写 JOIN:

kotlin 复制代码
data class OrderSummary(
    val orderId: Long,
    val userName: String,
    val amount: Double
)

@Query("""
    SELECT orders.orderId, users.name AS userName, orders.amount
    FROM orders
    INNER JOIN users ON orders.userId = users.uid
    WHERE orders.createTime > :startTime
""")
suspend fun getOrderSummaries(startTime: Long): List<OrderSummary>

优势:

  • 一次查询完成
  • 减少字段传输和内存占用

踩坑记录

坑 1:忘记 @Transaction 导致数据不一致

场景:查询用户及订单时,中途有新订单插入,导致两次查询结果不匹配。

解决 :所有 @Relation 查询都加 @Transaction。

坑 2:Migration 脚本字段类型不匹配

手写 SQL 时,INTEGER 和 REAL 必须与 Kotlin 类型对应:

Kotlin 类型 SQLite 类型
Int, Long INTEGER
Float, Double REAL
String TEXT
Boolean INTEGER (0/1)

错误示例:

kotlin 复制代码
// Entity 定义
val amount: Double

// Migration 脚本
database.execSQL("CREATE TABLE ... amount INTEGER ...") // ❌ 类型不匹配

运行时不会报错,但写入时会丢失小数部分。

坑 3:@Relation 查询在主线程阻塞

即使用了 suspend,如果 Database 没配置 allowMainThreadQueries(),在主线程调用仍会崩溃。

正确做法:

  • 不要用 allowMainThreadQueries()(会降低性能)
  • 所有 DAO 方法改为 suspend 或返回 Flow
  • 在 ViewModel 中用协程调用

迁移测试最佳实践

Room 提供 MigrationTestHelper 来验证升级逻辑:

kotlin 复制代码
@RunWith(AndroidJUnit4::class)
class MigrationTest {
    @get:Rule
    val helper = MigrationTestHelper(
        InstrumentationRegistry.getInstrumentation(),
        AppDatabase::class.java
    )

    @Test
    fun migrate1To2() {
        // 创建版本 1 数据库
        val db = helper.createDatabase("test.db", 1)
        db.execSQL("INSERT INTO users VALUES (1, 'Alice', 'avatar.png')")
        db.close()

        // 执行迁移
        helper.runMigrationsAndValidate("test.db", 2, true, MIGRATION_1_2)

        // 验证数据完整性
        val migratedDb = helper.runMigrationsAndValidate("test.db", 2, true)
        val cursor = migratedDb.query("SELECT * FROM users WHERE uid = 1")
        assert(cursor.count == 1)
    }
}

CI 集成:

在合并前自动跑所有迁移路径测试,避免线上升级失败。


总结

场景 推荐方案
开发阶段频繁改表 fallbackToDestructiveMigration()
生产环境升级 编写 Migration + 测试验证
一对多查询(数据量小) @Relation
一对多查询(数据量大) 手写 JOIN
多对多查询 Junction + @Relation
部分字段投影 自定义 DTO + JOIN

Room 的强大在于编译时校验和类型安全,但复杂查询和迁移仍需要对 SQL 有清晰理解。掌握这些实战技巧后,才能在项目规模扩大时,保持数据层的稳定和性能。

相关推荐
海雅达手持终端PDA4 小时前
巡线、深井、爆管抢修与极寒水网的硬件矩阵怎么配?海雅达水务巡检终端PDA选型指南
android·物联网·5g·集成测试·能源·政务
恋猫de小郭6 小时前
Flutter 开始支持 Jetpack Compose Platform View
android·前端·flutter
三少爷的鞋18 小时前
Android 架构七条铁律:架构师真正设计的不是代码,而是秩序
android
xiangxiongfly91520 小时前
Android GsonFactory总结
android·gson·gsonfactory
WythePlus1 天前
Android 14 SELinux策略深度解析
android·安全策略·selinux策略
俩个逗号。。1 天前
Kotlin flow 详解
android·开发语言·kotlin
橙光成长1 天前
android-ad-cleaner:android免 Root 批量关闭系统开屏与营销推送
android·1024程序员节
深圳雨林凯AI1 天前
图案裂变的底色衰减:alpha通道、亮度对比与白墨分色的量化拆解
android
mmsx1 天前
Android 模块化收口:能力组合框架、手写 LifecycleOwner 与热修复共存
android
恋猫de小郭1 天前
Flutter 可以更直接地调用原生 API 了,Dart Direct Native Interop
android·前端·flutter