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(应用无响应)
- 用户体验会变得非常糟糕
你想想看,用户点了个保存按钮,结果界面卡住两三秒,谁受得了?
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 被清除时,所有未完成的协程也会被取消,防止内存泄漏。
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类型返回。 - 不要滥用 GlobalScope:
GlobalScope.launch不会自动取消,容易导致内存泄漏。始终使用viewModelScope或lifecycleScope。 - 不要在主线程调用 collect:如果你在 Activity 里直接
lifecycleScope.launch { flow.collect { ... } },没问题。但如果你在onCreate里用runBlocking去 collect,那就等着 ANR 吧。
嗯,这一章的内容就到这里。协程 + Room 的组合,说白了就是「用最优雅的方式做最脏的活」。你只要记住:suspend 是承诺,协程是执行者,Dispatchers.IO 是工作台,三者配合好,数据库操作就不再是噩梦。
公众号:蓝海资料掘金营,微信 deep3321