28、数据库与Kotlin协程:suspend函数封装、Flow与Room结合、withContext切换线程、协程作用域管理、异常处理与重试

说实话,Room 和协程的组合,是我这几年做 Android 开发用得最爽的一套组合拳。以前用回调或者 RxJava 处理数据库异步,代码写起来总觉得有点别扭。协程一出来,整个思路就清晰多了。

今天我们就来聊聊,怎么把 Room 和协程玩明白。我会从最基础的 suspend 函数封装讲起,再到 Flow 的实时监听,最后聊聊异常处理和重试策略。这些都是我在实际项目中踩过坑、又爬出来的经验。

核心要点:Room 从 2.1 版本开始就原生支持协程了。你不需要额外加什么库,直接用就行。

28.1 suspend 函数封装:告别回调地狱

以前写 Room 的 DAO,查询方法要么返回 LiveData,要么就得用回调。现在简单了,直接加个 suspend 关键字就行。

@Dao
interface UserDao {
    @Query("SELECT * FROM users WHERE id = :userId")
    suspend fun getUserById(userId: Long): User?

    @Insert
    suspend fun insertUser(user: User): Long

    @Update
    suspend fun updateUser(user: User)

    @Delete
    suspend fun deleteUser(user: User)
}

你看,代码变得特别干净。调用的时候,在协程作用域里直接调用就行:

viewModelScope.launch {
    val user = userDao.getUserById(1L)
    // 拿到数据直接更新 UI
    _userState.value = user
}

我的习惯:所有 DAO 方法默认都写成 suspend 函数。除非你明确需要同步查询,否则别用普通函数。这样调用方必须用协程,反而能避免在主线程操作数据库。

28.2 Flow 与 Room 结合:实时监听数据库变化

如果你需要监听数据变化,比如列表刷新,那 Flow 就是最佳选择。Room 对 Flow 的支持非常自然。

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

    @Query("SELECT * FROM users WHERE id = :userId")
    fun getUserById(userId: Long): Flow<User?>
}

注意这里没有 suspend 关键字。因为 Flow 本身就是异步的,每次数据库有变化,它都会自动发射新数据。

在 ViewModel 里收集 Flow 也很简单:

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

为什么会用 SharingStarted.WhileSubscribed(5000)?我个人习惯加个 5 秒的延迟。这样屏幕旋转等配置变更时,不会重新触发数据库查询,用户体验更好。

28.3 withContext 切换线程:别乱用

很多人一看到数据库操作,就条件反射地写 withContext(Dispatchers.IO)。其实 Room 内部已经帮我们处理好了线程切换。

我曾经踩过的坑:在 DAO 方法里又包了一层 withContext(Dispatchers.IO),结果导致线程切换两次,性能反而下降了。

正确的做法是:

// ❌ 错误:Room 已经做了线程切换,不需要再包一层
suspend fun getUser() = withContext(Dispatchers.IO) {
    userDao.getUserById(1L)
}

// ✅ 正确:直接调用 DAO 方法
suspend fun getUser() = userDao.getUserById(1L)

那什么时候需要自己切换线程?当你需要同时做多个数据库操作,或者混合网络请求和数据库操作时:

suspend fun syncUserData(userId: Long) = withContext(Dispatchers.IO) {
    // 网络请求
    val remoteUser = apiService.fetchUser(userId)
    // 数据库写入
    userDao.insertUser(remoteUser.toEntity())
    // 数据库查询
    userDao.getUserById(userId)
}

说白了,withContext 是用来组合多个异步操作的,不是用来给单个 DAO 方法加壳的。

28.4 协程作用域管理:别让协程泄漏

协程作用域管理,说白了就是回答一个问题:这个协程该在什么时候取消?

我一般遵循这几个原则:

  • ViewModel 层:viewModelScope,ViewModel 销毁时自动取消
  • Repository 层:用外部传入的 CoroutineScope,或者自己创建带 SupervisorJob 的 scope
  • 普通工具类:CoroutineScope(SupervisorJob() + Dispatchers.IO),记得手动取消
class UserRepository(
    private val userDao: UserDao,
    private val externalScope: CoroutineScope
) {
    suspend fun getUser(userId: Long): User {
        return withContext(Dispatchers.IO) {
            userDao.getUserById(userId)
        }
    }

    // 需要长时间运行的任务,用外部 scope
    fun startPeriodicSync() {
        externalScope.launch {
            while (isActive) {
                delay(30_000) // 每30秒同步一次
                syncData()
            }
        }
    }
}

我的建议:Repository 层尽量不要自己创建 scope。让调用方传入,这样生命周期管理更清晰。你想想看,如果 Repository 自己搞个 scope,Activity 销毁了它还在跑,那不就泄漏了吗?

28.5 异常处理与重试:让应用更健壮

数据库操作也会出错。比如磁盘满了、表结构变了、并发写入冲突等等。我见过太多应用因为数据库异常直接崩溃的。

协程的异常处理,我推荐用 try-catch 配合 retry 操作符:

viewModelScope.launch {
    try {
        val user = userDao.getUserById(userId)
        _userState.value = user
    } catch (e: Exception) {
        // 记录日志
        Log.e("UserViewModel", "获取用户失败", e)
        // 显示错误状态
        _userState.value = null
        _errorState.value = "加载失败,请重试"
    }
}

对于 Flow,可以用 catchretry 操作符:

userDao.getUserById(userId)
    .retry(3) { cause ->
        // 只有网络相关的错误才重试
        cause is IOException
    }
    .catch { e ->
        // 重试3次还失败,就发射默认值
        emit(null)
        Log.e("UserViewModel", "最终获取失败", e)
    }
    .collect { user ->
        _userState.value = user
    }

注意:不要对所有异常都无脑重试。比如 SQLite 的约束冲突,重试多少次都没用。我一般只对 SQLiteDatabaseCorruptExceptionIOException 这类可恢复的异常做重试。

还有一种情况是并发写入冲突。Room 默认使用事务,但如果多个协程同时写入同一张表,可能会遇到 SQLiteDatabaseLockedException。我的处理方式是加个指数退避重试:

suspend fun <T> retryWithBackoff(
    maxRetries: Int = 3,
    initialDelay: Long = 100,
    block: suspend () -> T
): T {
    var currentDelay = initialDelay
    repeat(maxRetries) { attempt ->
        try {
            return block()
        } catch (e: SQLiteDatabaseLockedException) {
            if (attempt == maxRetries - 1) throw e
            delay(currentDelay)
            currentDelay *= 2 // 指数退避
        }
    }
    return block() // 最后一次尝试
}

嗯,这个工具方法我几乎每个项目都会写一份。数据库并发写入的场景其实挺多的,尤其是做离线同步的时候。

28.6 本章知识体系

下面这张图总结了 Room 与协程结合的核心脉络:

Room + 协程 核心知识体系 Room DAO suspend 函数封装 单次查询/写入 Flow 实时监听 数据变化自动推送 withContext 线程切换 组合多个异步操作 异常处理与重试 try-catch + retry 协程作用域管理 viewModelScope / 自定义 scope 单次 持续 组合 容错

这张图把今天讲的内容串起来了。你从 DAO 出发,根据业务需求选择不同的协程方案。单次操作用 suspend,持续监听用 Flow,组合多个操作用 withContext,最后别忘了用异常处理兜底。而协程作用域,则是贯穿始终的生命周期管理者。

好了,这一章的内容就到这里。Room 和协程的组合,说白了就是让数据库操作变得像普通函数调用一样简单,同时又保留了异步和响应式的特性。你在项目中遇到什么问题,欢迎随时交流。


公众号:蓝海资料掘金营,微信deep3321