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
)
这时就面临两个问题:
- 如何平滑升级数据库结构?
- 如何高效查询"用户及其所有订单"?
数据库迁移:从 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?
工作原理:
@Embedded将User字段平铺到结果中@Relation触发第二次查询:SELECT * FROM orders WHERE userId = :uid@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 有清晰理解。掌握这些实战技巧后,才能在项目规模扩大时,保持数据层的稳定和性能。