数据访问对象 (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. 常见错误与调试

  • 挂起函数在非协程上下文调用: 直接从主线程调用 suspend DAO 函数,IDE 会编译报错(挂起函数只能在协程内调用)。如果误用了 runBlocking 会导致 ANR。
  • @Transaction 修饰非 suspend 函数: Room 允许在普通函数上使用 @Transaction,但此时调用者需要自行管理线程(建议统一使用 suspend 避免混用)。
  • Flow 在横竖屏切换时重新发射: Flow 本身不保存状态,配合 stateIn 或 shareIn(作用域 WhileSubscribed)可避免每次屏幕旋转重复触发数据库查询。
  • 批量操作返回的 List<Long> 并非所有插入的行 ID: Room 批量插入返回的 List<Long> 可能包含重复或无效值,应避免依赖返回值顺序。

6. 性能优化要点

  1. 批量时始终开启事务: 举例:1000 条插入,无事务时约需 3000ms,加事务后压缩到 50ms。
  2. 使用 allowMainThreadQueries() 不可取: 调试阶段可能临时允许,但生产环境一定走挂起函数或 Flow,避免阻塞 UI 线程。
  3. 避免对 Flow 使用 distinctUntilChanged: Room 的 Flow 已经内置了表级变化去重,再添加 distinctUntilChanged 只在需要对象属性级过滤时才有意义。
  4. 分页查询搭配 Paging 3: 大量数据列表应使用 @Query 返回 PagingSource,而非一次性加载全部行。
调试技巧: 启用 Room 的 SQL 日志(Room.databaseBuilder().setQueryCallback())可以观察挂起函数是否在后台线程执行、事务是否成功提交。日志中若出现 Auto-commit 频繁出现,说明缺少事务封装。
Room DAO 进阶特性关系图 Room DAO 进阶数据流 Compose ViewModel DAO 层(Room) 挂起函数 Flow 返回值 @Transaction 批量操作 自动切换后台线程 协程内调用 自动感知表变化 collectAsState() 驱动 UI 原子提交/回滚 异常不写脏数据 集合参数 IO 次数 ↓ SQLite 数据库