4. Room 进阶:关系、迁移、测试
上一章我们聊了Room的基础CRUD和DAO,算是打了地基。这一章要往上盖楼了——关系映射、数据库迁移、还有单元测试。这三块,说实话,很多人用Room一年了都不一定搞得清楚。我今天就把我踩过的坑和总结的经验一次性说透。
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。
4.7 实战建议:我的Room进阶清单
最后,我把自己平时写Room进阶代码时一定会检查的点列出来,你可以参照着过一遍自己的代码。
| 检查项 | 说明 | 优先级 |
|---|---|---|
| 外键是否加了索引 | Room编译会提示,但建议手动加上 @Index | 高 |
| @Relation 方法是否加了 @Transaction | 否则可能读到不完整数据 | 高 |
| 迁移SQL是否兼容最低版本 | 参考用户设备的SQLite版本 | 中 |
| 迁移测试是否覆盖升级路径 | 至少测试一次全链路升级 | 高 |
| 测试是否清理了数据库实例 | 用 @After 关闭 db,防止泄露 | 中 |
| 是否处理了多版本跳迁 | 用户可能从 v1 直接升到 v3 | 高 |