在 Android 本地数据持久化方案中,Room 已经成为事实标准。但很多团队在实际项目中遇到的痛点,往往不是"怎么用 Room",而是"数据库升级了怎么办"和"多表查询怎么写才优雅"。
本文将从真实项目场景出发,梳理 Room 数据库迁移策略、多表关联查询的最佳实践,以及线上遇到的几个典型踩坑。
假设我们的 App 初版只有一个用户表:
@Entity(tableName="users")
dataclassUser(
@PrimaryKeyvaluid:Long,
valname:String,
valavatar:String
)随着业务发展,需要增加订单表,并关联用户:
@Entity(tableName="orders")
dataclassOrder(
@PrimaryKeyvalorderId:Long,
valuserId:Long,
valamount:Double,
valcreateTime:Long
)这时就面临两个问题:
Room 提供 Migration 类,可以定义版本间的升级 SQL:
valMIGRATION_1_2=object:Migration(1,2){
overridefunmigrate(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()
)
}
}
valdb=Room.databaseBuilder(context,AppDatabase::class.java,"app.db")
.addMigrations(MIGRATION_1_2)
.build()适用场景:
注意事项:
如果是开发阶段或数据可丢弃场景,可以直接清库重建:
valdb=Room.databaseBuilder(context,AppDatabase::class.java,"app.db")
.fallbackToDestructiveMigration()
.build()风险:
只允许特定版本强制降级:
.fallbackToDestructiveMigrationFrom(1,2)// 从版本 1 或 2 升级时清库实战建议:
fallbackToDestructiveMigration定义关系实体:
dataclassUserWithOrders(
@Embeddedvaluser:User,
@Relation(
parentColumn="uid",
entityColumn="userId"
)
valorders:List<Order>
)DAO 查询:
@Transaction
@Query("SELECT * FROM users WHERE uid = :userId")
suspendfungetUserWithOrders(userId:Long):UserWithOrders?工作原理:
@Embedded 将 User 字段平铺到结果中@Relation 触发第二次查询:SELECT * FROM orders WHERE userId = :uid@Transaction 保证两次查询的原子性性能考量:
需要中间表:
@Entity(tableName="course_student_cross_ref")
dataclassCourseStudentCrossRef(
valcourseId:Long,
valstudentId:Long,
@PrimaryKey(autoGenerate=true)valid:Long=0
)关系定义:
dataclassCourseWithStudents(
@Embeddedvalcourse:Course,
@Relation(
parentColumn="courseId",
entityColumn="studentId",
associateBy=Junction(CourseStudentCrossRef::class)
)
valstudents:List<Student>
)查询:
@Transaction
@Query("SELECT * FROM courses WHERE courseId = :courseId")
suspendfungetCourseWithStudents(courseId:Long):CourseWithStudents?如果只需要部分字段,用 @RawQuery 或直接写 JOIN:
dataclassOrderSummary(
valorderId:Long,
valuserName:String,
valamount: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
""")
suspendfungetOrderSummaries(startTime:Long):List<OrderSummary>优势:
场景:查询用户及订单时,中途有新订单插入,导致两次查询结果不匹配。
解决:所有 @Relation 查询都加 @Transaction。
手写 SQL 时,INTEGER 和 REAL 必须与 Kotlin 类型对应:
Kotlin 类型 | SQLite 类型 |
|---|---|
Int, Long | INTEGER |
Float, Double | REAL |
String | TEXT |
Boolean | INTEGER (0/1) |
错误示例:
// Entity 定义
valamount:Double
// Migration 脚本
database.execSQL("CREATE TABLE ... amount INTEGER ...")// ❌ 类型不匹配运行时不会报错,但写入时会丢失小数部分。
即使用了 suspend,如果 Database 没配置 allowMainThreadQueries(),在主线程调用仍会崩溃。
正确做法:
allowMainThreadQueries()(会降低性能)suspend 或返回 FlowRoom 提供 MigrationTestHelper 来验证升级逻辑:
@RunWith(AndroidJUnit4::class)
classMigrationTest{
@get:Rule
valhelper=MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java
)
@Test
funmigrate1To2(){
// 创建版本 1 数据库
valdb=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)
// 验证数据完整性
valmigratedDb=helper.runMigrationsAndValidate("test.db",2,true)
valcursor=migratedDb.query("SELECT * FROM users WHERE uid = 1")
assert(cursor.count==1)
}
}CI 集成:
在合并前自动跑所有迁移路径测试,避免线上升级失败。
场景 | 推荐方案 |
|---|---|
开发阶段频繁改表 | fallbackToDestructiveMigration() |
生产环境升级 | 编写 Migration + 测试验证 |
一对多查询(数据量小) | @Relation |
一对多查询(数据量大) | 手写 JOIN |
多对多查询 | Junction + @Relation |
部分字段投影 | 自定义 DTO + JOIN |
Room 的强大在于编译时校验和类型安全,但复杂查询和迁移仍需要对 SQL 有清晰理解。掌握这些实战技巧后,才能在项目规模扩大时,保持数据层的稳定和性能。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。