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 有清晰理解。掌握这些实战技巧后,才能在项目规模扩大时,保持数据层的稳定和性能。

相关推荐
吐了啊取名字太难1 小时前
Android 相机 USB 连接别写在 Activity 里:锁屏、切页和后台传输怎么设计?
android·数码相机
hunterandroid2 小时前
Android ViewModel + LiveData 工程实践:配置变更、一次性事件与状态恢复
android
2501_915921432 小时前
iOS应用从开发到上架App Store的完整步骤详解
android·ios·小程序·https·uni-app·iphone·webview
HackTwoHub3 小时前
免 Root 安卓渗透工具 Stryker|把 Kali 装进手机!随身无线渗透工作站,内置 Nmap/Nuclei/Metasploit 全套武器库
android·linux·运维·人工智能·web安全·macos·智能手机
朱涛的自习室13 小时前
70 美金的 Beyond Compare,我让 Gemini 4 和 DeepSeek 造个开源免费版(Day 2)
android·前端·人工智能
.Bryce.17 小时前
在智能体中使用A2A工具:让大模型自己决定“该找谁帮忙“
android·java·服务器·redis·后端·agent·a2a
方舟小孩18 小时前
开源办公软件海豹办公 1.9.15 实战 从模板异常到 Android WebView 资源回归
android
用户21816970493018 小时前
Kotlin 语法四 协程
android