15、协程与Room数据库:Room协程支持、Flow与Room的响应式查询、使用Flow监听数据库变化、复杂查询与协程

说实话,Room 和协程的组合,是我在 Android 开发中最喜欢的搭档之一。记得我刚接触 Room 时,它还在用 AsyncTask 做异步查询,那代码写起来真是又臭又长。后来 Room 2.1 开始支持协程,我第一时间就迁移了过去——嗯,那感觉就像从绿皮车换成了高铁。

这一章,我们就来聊聊 Room 怎么跟协程、Flow 深度结合。说白了,就是让你的数据库操作既快又优雅。

Room 的协程支持:从 suspend 到 Flow

Room 从 2.1 版本开始,直接支持 suspend 函数。这意味着你可以在 DAO 里这样写:

@Dao
interface UserDao {
    @Insert
    suspend fun insertUser(user: User)

    @Update
    suspend fun updateUser(user: User)

    @Delete
    suspend fun deleteUser(user: User)

    @Query("SELECT * FROM users WHERE id = :userId")
    suspend fun getUserById(userId: Int): User?
}

每个 suspend 函数都会在后台线程执行,不会阻塞主线程。我个人习惯在 Repository 层用 viewModelScope 调用这些方法:

class UserRepository(private val userDao: UserDao) {
    suspend fun insertUser(user: User) {
        withContext(Dispatchers.IO) {
            userDao.insertUser(user)
        }
    }
}

这里有个小细节:Room 的 suspend 函数默认就在后台线程执行,你其实不需要再包一层 withContext(Dispatchers.IO)。但我见过不少项目里还是这么写,可能是为了保险起见。我个人建议:如果 DAO 方法已经声明为 suspend,直接调用就好,Room 内部会处理好线程切换。

Flow 与 Room 的响应式查询

这才是重头戏。Room 对 Flow 的支持,让数据库查询变成了「订阅-推送」模式。你想想看,以前我们要手动刷新列表,现在只要数据库一变化,UI 自动就更新了。

@Dao
interface UserDao {
    @Query("SELECT * FROM users ORDER BY name ASC")
    fun getAllUsers(): Flow<List<User>>

    @Query("SELECT * FROM users WHERE age > :minAge")
    fun getUsersOlderThan(minAge: Int): Flow<List<User>>
}

在 ViewModel 里,我们可以这样收集:

class UserViewModel(private val userDao: UserDao) : ViewModel() {
    val allUsers: StateFlow<List<User>> = userDao.getAllUsers()
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = emptyList()
        )
}

这里我用了 SharingStarted.WhileSubscribed(5000),意思是当订阅者消失后,再等 5 秒才停止上游查询。为什么要等 5 秒?因为屏幕旋转时配置会重建,如果立刻停止再重启,会浪费一次数据库查询。5 秒的缓冲期足够应对配置变更了。

我的经验: 如果你用 SharingStarted.Eagerly,Flow 会在 ViewModel 创建时就启动查询,即使没有订阅者。这在某些场景下会造成资源浪费。我个人更推荐 WhileSubscribed,配合一个合理的超时时间。

使用 Flow 监听数据库变化

Flow 的响应式查询背后,其实是 Room 的 invalidation tracker 在起作用。当数据库中的表数据发生变化时,Room 会通知所有订阅了该表的 Flow 重新执行查询。

来看一个实际场景:假设我们有一个聊天应用,需要实时显示未读消息数。

@Dao
interface MessageDao {
    @Query("SELECT COUNT(*) FROM messages WHERE isRead = 0")
    fun observeUnreadCount(): Flow<Int>

    @Query("UPDATE messages SET isRead = 1 WHERE id = :messageId")
    suspend fun markAsRead(messageId: Int)
}

在 UI 层,我们可以这样监听:

@Composable
fun UnreadBadge(viewModel: MessageViewModel) {
    val unreadCount by viewModel.unreadCount.collectAsState()
    if (unreadCount > 0) {
        Badge(text = "$unreadCount")
    }
}

每次调用 markAsRead 后,Room 会自动触发 observeUnreadCount 重新查询,Flow 会发射新值,UI 自动更新。整个过程不需要你写任何通知代码。

注意: Flow 的查询是在 Room 的调度线程上执行的。如果你的查询很复杂(比如多表 JOIN),每次数据变化都会重新执行一次。我曾经在一个项目里看到有人把 10 个表的 JOIN 查询写成了 Flow,结果每次插入一条数据,整个查询要跑 200 多毫秒。UI 直接卡成 PPT。

复杂查询与协程

当查询变得复杂时,协程的优势就更明显了。比如我们需要分页加载、多表关联、或者条件组合查询。

分页查询

@Dao
interface PostDao {
    @Query("SELECT * FROM posts ORDER BY createdAt DESC LIMIT :limit OFFSET :offset")
    suspend fun getPostsPage(limit: Int, offset: Int): List<Post>

    @Query("SELECT COUNT(*) FROM posts")
    suspend fun getPostCount(): Int
}

在 Repository 里,我们可以用协程做分页加载:

class PostRepository(private val postDao: PostDao) {
    suspend fun loadPage(page: Int, pageSize: Int = 20): List<Post> {
        val offset = (page - 1) * pageSize
        return postDao.getPostsPage(pageSize, offset)
    }
}

多表关联查询

Room 支持返回嵌套对象,配合协程非常方便:

data class UserWithPosts(
    @Embedded val user: User,
    @Relation(
        parentColumn = "id",
        entityColumn = "userId"
    )
    val posts: List<Post>
)

@Dao
interface UserDao {
    @Transaction
    @Query("SELECT * FROM users WHERE id = :userId")
    suspend fun getUserWithPosts(userId: Int): UserWithPosts
}

这里用了 @Transaction 注解,确保查询的一致性。我遇到过一个问题:如果不加 @Transaction,在多表查询时可能会读到不一致的数据。比如用户信息更新了,但关联的帖子还没更新完。加上事务就能保证要么全部读到新数据,要么全部读到旧数据。

条件组合查询

有时候查询条件不是固定的,需要动态拼接。Room 不支持动态 SQL,但我们可以用协程做组合:

class ProductRepository(private val productDao: ProductDao) {
    suspend fun searchProducts(
        query: String?,
        categoryId: Int?,
        minPrice: Double?,
        maxPrice: Double?
    ): List<Product> {
        // 先构建基础查询
        val baseQuery = StringBuilder("SELECT * FROM products WHERE 1=1")
        val args = mutableListOf<Any>()

        query?.let {
            baseQuery.append(" AND name LIKE ?")
            args.add("%$it%")
        }
        categoryId?.let {
            baseQuery.append(" AND categoryId = ?")
            args.add(it)
        }
        minPrice?.let {
            baseQuery.append(" AND price >= ?")
            args.add(it)
        }
        maxPrice?.let {
            baseQuery.append(" AND price <= ?")
            args.add(it)
        }

        // 使用 rawQuery 执行
        return productDao.rawSearch(baseQuery.toString(), *args.toTypedArray())
    }
}
核心思路: 复杂查询不要硬写在一个 SQL 里。先用协程做逻辑判断和参数组装,再交给 Room 执行。这样代码可读性高,也容易测试。

知识体系总览

下面这张图总结了 Room + 协程 + Flow 的核心关系:

Room + 协程 + Flow 响应式数据流 DAO 层 suspend 函数 Flow 返回类型 @Transaction 注解 Repository 层 协程调度 Flow 转换 业务逻辑组合 ViewModel 层 stateIn 转换 StateFlow 暴露 viewModelScope suspend / Flow Flow / StateFlow 数据库变化自动通知机制 Room Invalidation Tracker → 自动重新执行 Flow 查询 → 发射新数据 → UI 更新 复杂查询场景 分页查询 | 多表关联 | 条件组合 | 事务保证

避坑指南

最后,分享几个我踩过的坑:

  • Flow 查询不要做耗时操作:Flow 的查询是在 Room 的线程池执行的,如果你在查询里做了复杂计算,会阻塞其他数据库操作。
  • 注意 Flow 的生命周期:在 Fragment 或 Activity 里收集 Flow 时,记得用 repeatOnLifecycle,否则可能造成内存泄漏。
  • 不要滥用 @Transaction:只有需要保证多表查询一致性时才加。单表查询加事务反而会降低性能。
  • Flow 的冷流特性:每次有新的订阅者,Flow 都会重新执行查询。如果你有多个订阅者,考虑用 shareInstateIn 共享数据流。
我的建议: 刚开始用 Room + Flow 时,先从小功能开始。比如先做一个「用户列表自动刷新」的功能,感受一下响应式编程的爽快。等熟悉了再上复杂查询。一口吃不成胖子,对吧?

好了,这一章的内容就到这里。Room 和协程的组合,说白了就是让数据库操作变得「声明式」——你只需要告诉 Room 你想要什么数据,剩下的它帮你搞定。这种编程体验,真的让人上瘾。