16、异步操作:suspend函数、协程与Room、withContext、Dispatchers.IO

Room 操作数据库,说白了就是 IO 操作。IO 操作不能放在主线程,这是 Android 开发的基本常识。但很多新手朋友会问:「我明明用了 LiveData 或者 Flow,为什么还要管异步?」

嗯,这里要分清楚:LiveData 和 Flow 是观察者模式,它们帮你自动切换线程去监听数据变化,但「写操作」和「一次性查询」还是得你自己处理异步。我刚开始用 Room 时,就因为在主线程调用了 dao.insert(),直接崩了个 IllegalStateException,那叫一个尴尬。

所以这一章,我们就来彻底搞懂:suspend 函数、协程、withContext、Dispatchers.IO 到底怎么配合 Room 使用

16.1 为什么 Room 操作必须异步?

Room 的设计原则之一就是「不允许在主线程访问数据库」。原因很简单:

  • 数据库读写可能耗时(尤其是写入大量数据时)
  • 主线程被阻塞会导致 ANR(应用无响应)
  • 用户体验会变得非常糟糕

你想想看,用户点了个保存按钮,结果界面卡住两三秒,谁受得了?

⚠️ 注意: 如果你在 DAO 方法上没有加 suspend 或者没有使用 LiveData/Flow/RxJava,Room 默认会认为你是在主线程调用,直接抛异常给你看。

16.2 suspend 函数:协程的入口

Kotlin 的 suspend 关键字,说白了就是告诉编译器:「这个方法可能会挂起,请用协程来调度它」。

在 Room 的 DAO 中,我们可以这样写:

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

    @Update
    suspend fun update(user: User)

    @Delete
    suspend fun delete(user: User)

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

看到没?每个方法前面都加了 suspend。这样 Room 就知道:「哦,这个方法需要在协程里调用,我不会在主线程执行它」。

我个人习惯是:所有 DAO 的写操作(增删改)都加上 suspend,查询操作如果是一次性的也加 suspend。只有需要持续观察数据变化的查询,才用 Flow 或 LiveData。

16.3 协程与 Room 的配合

有了 suspend 函数,调用方就必须在协程作用域内调用。最常见的做法是在 ViewModel 里用 viewModelScope

class UserViewModel(private val userDao: UserDao) : ViewModel() {

    fun saveUser(user: User) {
        viewModelScope.launch {
            userDao.insert(user)
        }
    }

    fun loadUser(userId: Int) {
        viewModelScope.launch {
            val user = userDao.getUserById(userId)
            // 更新 UI
        }
    }
}

这里 viewModelScope 是 Android 官方提供的协程作用域,它会自动绑定 ViewModel 的生命周期。当 ViewModel 被清除时,所有未完成的协程也会被取消,防止内存泄漏。

💡 小技巧: 如果你在 Activity 或 Fragment 里直接调用,可以用 lifecycleScope。它绑定的是 LifecycleOwner 的生命周期,更灵活。

16.4 withContext 与 Dispatchers.IO

有时候,你可能会在协程里做「混合操作」——既有网络请求,又有数据库读写。这时候 withContext 就派上用场了。

withContext 的作用是:切换协程执行的线程,并返回结果。而 Dispatchers.IO 是专门为 IO 操作(文件读写、数据库操作、网络请求)优化的线程池。

viewModelScope.launch {
    // 默认在主线程(Dispatchers.Main)
    val user = withContext(Dispatchers.IO) {
        // 切换到 IO 线程执行数据库查询
        userDao.getUserById(userId)
    }
    // 回到主线程更新 UI
    textView.text = user.name
}

我在项目中遇到过一种情况:有个同事把所有 Room 操作都包在 withContext(Dispatchers.IO) 里,但 DAO 方法本身已经加了 suspend。其实没必要——Room 的 suspend 函数内部已经自动切换到 IO 线程了。你再加一层 withContext,反而多了一次线程切换开销。

那什么时候需要显式用 withContext(Dispatchers.IO)

  • 当你调用的是「非 suspend 的 Room 方法」(比如某些第三方库的同步 API)
  • 当你需要在一个协程里执行多个数据库操作,并且希望它们都在 IO 线程执行
  • 当你混合了网络请求和数据库操作,需要明确指定线程

16.5 实战:批量插入与事务

批量插入是常见的场景。比如从服务器同步 1000 条用户数据,一条一条 insert 肯定慢。Room 支持用 @Transaction 注解来保证原子性,同时配合协程可以做到高效写入。

@Dao
interface UserDao {
    @Insert
    suspend fun insertUsers(users: List<User>)

    @Transaction
    @Query("SELECT * FROM users WHERE age > :minAge")
    suspend fun getUsersWithAgeGreaterThan(minAge: Int): List<UserWithOrders>
}

注意:@Transaction 注解可以保证方法内的所有数据库操作在同一个事务中执行。如果中途失败,所有更改都会回滚。

我曾经在项目里踩过一个坑:批量插入 500 条数据,没有用 @Transaction,结果插入到第 300 条时崩溃了,前 299 条已经写进去了,导致数据不一致。从那以后,只要是批量操作,我一定加 @Transaction

16.6 协程与 Flow 的配合

如果你需要实时监听数据库变化,Flow 是最佳选择。Room 对 Flow 有原生支持:

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

    @Insert
    suspend fun insert(user: User)
}

在 ViewModel 里,你可以这样收集 Flow:

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

这里 stateIn 把 Flow 转换成了 StateFlow,方便 UI 层直接观察。而 SharingStarted.WhileSubscribed(5000) 的意思是:当没有订阅者时,等待 5 秒再停止上游的数据库监听。这样可以避免频繁开关数据库连接。

🔑 核心要点:
  • DAO 的写操作和一次性查询用 suspend
  • 需要实时监听的变化用 Flow
  • 在 ViewModel 中用 viewModelScope.launch 启动协程
  • Room 的 suspend 函数自动在 IO 线程执行,不需要额外 withContext(Dispatchers.IO)
  • 批量操作记得加 @Transaction

16.7 避坑指南

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

  • 不要在协程里直接操作 View:协程默认在 IO 线程执行时,不能更新 UI。记得切回 Dispatchers.Main 或者用 withContext 切换。
  • 不要忘记处理异常:数据库操作可能失败(比如唯一约束冲突)。用 try-catch 包一下,或者用 Result 类型返回。
  • 不要滥用 GlobalScopeGlobalScope.launch 不会自动取消,容易导致内存泄漏。始终使用 viewModelScopelifecycleScope
  • 不要在主线程调用 collect:如果你在 Activity 里直接 lifecycleScope.launch { flow.collect { ... } },没问题。但如果你在 onCreate 里用 runBlocking 去 collect,那就等着 ANR 吧。

嗯,这一章的内容就到这里。协程 + Room 的组合,说白了就是「用最优雅的方式做最脏的活」。你只要记住:suspend 是承诺,协程是执行者,Dispatchers.IO 是工作台,三者配合好,数据库操作就不再是噩梦。

Room + 协程异步操作核心流程 ViewModel / Activity viewModelScope.launch { } 启动协程,默认在 Main 线程 withContext(Dispatchers.IO) { } 切换到 IO 线程执行数据库操作 DAO.suspend 方法 自动在 IO 线程执行 关键说明 • suspend 函数可挂起 • Room 自动处理线程 • Flow 用于实时监听 • @Transaction 保证原子性 • 避免 GlobalScope • 异常处理用 try-catch • 批量操作加事务 • 不要在主线程 collect suspend 是承诺 → 协程是执行者 → Dispatchers.IO 是工作台

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