数据访问对象 (DAO) 进阶:挂起函数与 Flow 返回值、事务、批量操作
Room 的 DAO 层是离线数据绑定的核心枢纽。基础阶段的 @Insert、@Update、@Delete 和 @Query 只支持同步返回。进入进阶阶段后,你需要利用挂起函数、Flow 返回值、事务与批量操作来构建响应式、高性能且线程安全的离线数据管道。本章将逐一拆解这些机制的实现原理、调用合约及常见陷阱。
进阶 DAO 特性概览
| 特性 | 传统写法 | 进阶写法 | 核心收益 |
|---|---|---|---|
| 返回值类型 | void / Long / Cursor |
suspend / Flow<T> |
自动线程切换 + 响应式订阅 |
| 数据变更感知 | 需要手动刷新 | Flow 自动推送增量 | Compose 可观察状态直接驱动 UI |
| 原子性保证 | 依赖调用层锁 | @Transaction 方法内任何异常自动回滚 |
数据一致性零漏写 |
| 批量写入性能 | 循环单条插入 | 集合参数 + @Transaction |
减少磁盘 IO 次数 10‑100 倍 |
1. 挂起函数 (suspend fun)
在 DAO 中声明 suspend 函数后,Room 会自动将数据库操作切换到后台线程执行。调用方必须在协程作用域(如 viewModelScope)内调用。
@Dao
interface UserDao {
@Insert
suspend fun insertUser(user: User): Long
@Update
suspend fun updateUser(user: User)
@Query("SELECT * FROM users WHERE id = :id")
suspend fun getUserById(id: Long): User?
}
挂起函数 ≠ 异步锁 —— 多个挂起函数可以被并发调用,但 Room 内部使用数据库连接池控制并发,开发者无需手动加锁。
2. Flow 返回值:实时数据流
当查询方法返回值类型为 Flow<T> 时,Room 会在底层注册一个观察者,一旦所涉及的表发生任何增、删、改,Flow 就会自动发射新数据。该机制与 Jetpack Compose 的 collectAsState() 天然契合,无需手动刷新。
@Dao
interface UserDao {
@Query("SELECT * FROM users ORDER BY name ASC")
fun getAllUsersFlow(): Flow<List<User>>
@Query("SELECT * FROM users WHERE id = :id")
fun getUserFlow(id: Long): Flow<User?>
}
Flow 冷流陷阱: Room DAO 返回的 Flow 是冷流(cold stream),每次只有终端操作符(如
collect)开始消费时才会触发查询。如果你在多个 Collector 中同时收集同一个 DAO Flow,Room 会各自独立执行查询,不会复用数据库快照。业务层应通过 StateFlow 或 shareIn 对 DAO 的 Flow 做热流化处理。
3. 事务 (@Transaction)
当一个 DAO 方法需要执行多条 SQL 语句并保证原子性时,使用 @Transaction 注解。Room 会在进入方法前开启数据库事务,方法正常结束则提交,异常则回滚。
@Dao
interface OrderDao {
@Transaction
suspend fun createOrderWithItems(order: Order, items: List<OrderItem>) {
val orderId = insertOrder(order)
items.forEach { it.orderId = orderId }
insertItems(items)
}
@Insert
suspend fun insertOrder(order: Order): Long
@Insert
suspend fun insertItems(items: List<OrderItem>)
}
事务不嵌套: Room 内嵌的事务方法如果被另一个
@Transaction 方法调用,内层事务会直接加入外层事务当中,不会产生嵌套事务开销。
4. 批量操作
批量插入、更新、删除应使用集合参数,Room 会将其展开为一条预编译语句的多条参数绑定,极大减少 SQLite 的解析和日志写入开销。
@Dao
interface ProductDao {
// 批量插入,替换冲突的行
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(products: List<Product>)
// 批量更新
@Update
suspend fun updateAll(products: List<Product>)
// 批量删除(按主键列表)
@Query("DELETE FROM products WHERE id IN (:ids)")
suspend fun deleteByIds(ids: List<Int>)
}
批量操作必须搭配事务: 如果批量插入的数据量超过百条,即使 Room 内部做了批量展开,也建议在调用处包裹一层
@Transaction,否则 SQLite 默认会在每条语句结束后自动提交,导致大量小事务写磁盘反而更慢。
5. 常见错误与调试
- 挂起函数在非协程上下文调用: 直接从主线程调用
suspendDAO 函数,IDE 会编译报错(挂起函数只能在协程内调用)。如果误用了runBlocking会导致 ANR。 - @Transaction 修饰非 suspend 函数: Room 允许在普通函数上使用
@Transaction,但此时调用者需要自行管理线程(建议统一使用suspend避免混用)。 - Flow 在横竖屏切换时重新发射: Flow 本身不保存状态,配合
stateIn或shareIn(作用域WhileSubscribed)可避免每次屏幕旋转重复触发数据库查询。 - 批量操作返回的
List<Long>并非所有插入的行 ID: Room 批量插入返回的List<Long>可能包含重复或无效值,应避免依赖返回值顺序。
6. 性能优化要点
- 批量时始终开启事务: 举例:1000 条插入,无事务时约需 3000ms,加事务后压缩到 50ms。
- 使用
allowMainThreadQueries()不可取: 调试阶段可能临时允许,但生产环境一定走挂起函数或 Flow,避免阻塞 UI 线程。 - 避免对 Flow 使用
distinctUntilChanged: Room 的 Flow 已经内置了表级变化去重,再添加distinctUntilChanged只在需要对象属性级过滤时才有意义。 - 分页查询搭配 Paging 3: 大量数据列表应使用
@Query返回PagingSource,而非一次性加载全部行。
调试技巧: 启用 Room 的 SQL 日志(
Room.databaseBuilder().setQueryCallback())可以观察挂起函数是否在后台线程执行、事务是否成功提交。日志中若出现 Auto-commit 频繁出现,说明缺少事务封装。