14、协程与ViewModel:viewModelScope的最佳实践、ViewModel中管理协程任务、ViewModel与Flow的结合、在ViewModel中处理UI状态
说实话,ViewModel 和协程的搭配,是我在项目中用得最频繁的组合之一。很多开发者刚接触时,总觉得「不就是 launch 一下嘛」,但真正上线后,各种内存泄漏、任务丢失、状态不一致的问题就冒出来了。今天我就把这几年的实战经验掰开揉碎,跟你聊聊怎么用好它们。
viewModelScope:为什么它是「官方指定」的协程作用域?
先问个问题:你在 ViewModel 里启动协程时,用的是 GlobalScope 还是 lifecycleScope?
我个人习惯只用 viewModelScope。原因很简单——它会在 ViewModel 被清除时自动取消所有协程。你想想看,如果用了 GlobalScope,Activity 都销毁了,协程还在后台跑,轻则浪费资源,重则更新已销毁的 UI 引发崩溃。
核心要点:viewModelScope 是 ViewModel 内置的 CoroutineScope,它的生命周期与 ViewModel 绑定。ViewModel 被 clear() 时,scope 会自动 cancel。
来看一段我在项目中常用的写法:
class UserViewModel : ViewModel() {
private val userRepo = UserRepository()
fun loadUser(userId: String) {
viewModelScope.launch {
val user = userRepo.fetchUser(userId)
// 更新 UI
}
}
}
嗯,这里要注意:viewModelScope 默认使用 Dispatchers.Main。为什么?因为 ViewModel 的数据最终要交给 UI 层,主线程直接更新最安全。如果你需要做耗时操作,用 withContext(Dispatchers.IO) 切一下就好。
我的习惯:在 ViewModel 中,我几乎从不手动创建 CoroutineScope。viewModelScope 已经够用了,除非你有非常特殊的生命周期需求。
ViewModel 中管理协程任务:别让任务「失控」
我在项目中遇到过一个问题:用户快速点击「刷新」按钮,结果同时启动了 5 个网络请求,页面数据乱跳。这就是典型的协程任务管理不当。
怎么解决?我总结了三个原则:
- 取消旧任务:用 Job 来控制,启动新任务前取消旧的。
- 限制并发:用 Mutex 或 Semaphore 控制同一时间只能有一个任务执行。
- 任务分组:用 SupervisorJob 让子任务失败不影响其他任务。
举个例子,取消旧任务的写法:
class SearchViewModel : ViewModel() {
private var searchJob: Job? = null
fun search(query: String) {
searchJob?.cancel() // 取消上一次搜索
searchJob = viewModelScope.launch {
delay(300) // 防抖
val results = repository.search(query)
_searchResults.value = results
}
}
}
说白了,这就是「谁先来谁先走,后来的覆盖前面的」。用户只关心最后一次搜索的结果,之前的请求完全可以丢掉。
我曾经踩过的坑:用 cancel() 取消协程时,如果协程内部没有做 isActive 检查,取消可能不会立即生效。记得在循环或耗时操作中加 ensureActive() 或 yield()。
ViewModel 与 Flow 的结合:数据流的「黄金搭档」
Flow 和 ViewModel 的配合,说白了就是「数据生产者在后台,消费者在前台」。我常用的模式是:Repository 返回 Flow,ViewModel 用 stateIn 转为 StateFlow,UI 层直接收集。
来看一个完整的例子:
class DashboardViewModel : ViewModel() {
private val repo = DashboardRepository()
// 方式1:直接暴露 Flow
val realtimeData: StateFlow<DashboardState> = repo.observeRealtimeData()
.map { DashboardState.Success(it) }
.catch { emit(DashboardState.Error(it.message ?: "未知错误")) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = DashboardState.Loading
)
}
这里有个细节:SharingStarted.WhileSubscribed(5000)。为什么是 5000 毫秒?因为当 Activity 重建时(比如旋转屏幕),会有短暂的订阅中断。设置 5000ms 的缓冲,可以避免数据流频繁重启。我个人习惯用 5000,你也可以根据场景调整。
最佳实践:ViewModel 中尽量用 StateFlow 暴露状态,不要用 LiveData。StateFlow 天然支持协程,配合 stateIn 和 flowWithLifecycle 使用,生命周期管理更优雅。
在 ViewModel 中处理 UI 状态:告别「面条式代码」
UI 状态管理,说白了就是「页面该显示什么」。我见过太多人用多个 LiveData 分别控制加载、错误、数据,结果状态组合起来乱成一锅粥。
我的做法是:用一个密封类(sealed class)定义所有状态。
sealed class UiState<out T> {
object Loading : UiState<Nothing>()
data class Success<T>(val data: T) : UiState<T>()
data class Error(val message: String) : UiState<Nothing>()
}
class ProfileViewModel : ViewModel() {
private val _uiState = MutableStateFlow<UiState<Profile>>(UiState.Loading)
val uiState: StateFlow<UiState<Profile>> = _uiState.asStateFlow()
fun loadProfile(userId: String) {
viewModelScope.launch {
_uiState.value = UiState.Loading
try {
val profile = repo.getProfile(userId)
_uiState.value = UiState.Success(profile)
} catch (e: Exception) {
_uiState.value = UiState.Error(e.message ?: "加载失败")
}
}
}
}
UI 层只需要收集 uiState,然后 when 匹配三种状态即可。代码清晰,逻辑集中,维护起来特别舒服。
避坑指南:我曾经在状态类里直接放「错误码 + 错误消息」两个字段,结果 UI 层要判断各种组合。后来改成密封类,每个状态只包含必要数据,再也没出过状态不一致的问题。
知识体系总览
下面这张图是我自己总结的 ViewModel + 协程的核心结构,你可以对照着理解:
总结一下
ViewModel 和协程的配合,核心就三件事:
- 作用域:用 viewModelScope,别自己瞎搞。
- 任务管理:该取消就取消,该防抖就防抖。
- 状态管理:用密封类 + StateFlow,别用一堆 LiveData 拼凑。
做到这三点,你的 ViewModel 代码会变得非常干净,维护起来也轻松。我在好几个项目里都是这么做的,从来没出过协程相关的线上问题。