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,可以用 catch 和 retry 操作符:
userDao.getUserById(userId)
.retry(3) { cause ->
// 只有网络相关的错误才重试
cause is IOException
}
.catch { e ->
// 重试3次还失败,就发射默认值
emit(null)
Log.e("UserViewModel", "最终获取失败", e)
}
.collect { user ->
_userState.value = user
}
注意:不要对所有异常都无脑重试。比如 SQLite 的约束冲突,重试多少次都没用。我一般只对 SQLiteDatabaseCorruptException 和 IOException 这类可恢复的异常做重试。
还有一种情况是并发写入冲突。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 与协程结合的核心脉络:
这张图把今天讲的内容串起来了。你从 DAO 出发,根据业务需求选择不同的协程方案。单次操作用 suspend,持续监听用 Flow,组合多个操作用 withContext,最后别忘了用异常处理兜底。而协程作用域,则是贯穿始终的生命周期管理者。
好了,这一章的内容就到这里。Room 和协程的组合,说白了就是让数据库操作变得像普通函数调用一样简单,同时又保留了异步和响应式的特性。你在项目中遇到什么问题,欢迎随时交流。
公众号:蓝海资料掘金营,微信deep3321