5、Flow与协程:Flow在ViewModel中的使用、flowOn与dispatcher切换、catch异常处理

各位同学,今天我们来聊聊Flow在ViewModel中的实战。说实话,这部分内容我刚开始接触时也踩过不少坑。尤其是协程调度和异常处理,稍不留神就会让整个数据流崩掉。别急,我们一步步拆解。

5.1 为什么要在ViewModel里用Flow?

ViewModel的生命周期和Activity/Fragment绑定。它会在配置变更时存活下来。这正好是Flow的用武之地——你可以把数据流挂载到ViewModel的作用域里,不用担心泄漏。

我个人习惯在ViewModel里这样用:

class MyViewModel : ViewModel() {
    private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
    val uiState: StateFlow<UiState> = _uiState.asStateFlow()

    fun loadData() {
        viewModelScope.launch {
            repository.getData()
                .catch { e -> _uiState.value = UiState.Error(e.message) }
                .collect { data -> _uiState.value = UiState.Success(data) }
        }
    }
}

你看,viewModelScope 自动帮我们管理协程生命周期。当ViewModel被清除时,所有协程自动取消。我在项目中遇到过因为忘记取消协程导致的内存泄漏,后来全面改用Flow + viewModelScope,问题就再没出现过。

5.2 flowOn 与 Dispatcher 切换

Flow默认在哪个协程上下文执行?答案是:在调用 collect 的协程上下文中。但很多时候,我们需要把耗时操作切到后台线程。

flowOn 就是干这个的。它会影响它之前的所有操作符。举个例子:

fun fetchData(): Flow<String> = flow {
    for (i in 1..5) {
        delay(100) // 模拟网络请求
        emit("数据 $i")
    }
}.flowOn(Dispatchers.IO) // 上游在IO线程执行

这里有个关键点:flowOn 只改变上游的调度器,下游(collect)仍然在原来的线程。我在项目中曾经犯过一个错——把UI更新操作放在 flowOn(Dispatchers.IO) 之后,结果直接崩溃。嗯,UI操作必须在主线程。

核心原则:

  • 耗时操作(网络、数据库)用 Dispatchers.IO
  • CPU密集计算用 Dispatchers.Default
  • UI更新必须用 Dispatchers.Main

你想想看,如果多个 flowOn 连用会怎样?每个 flowOn 会创建一个新的协程上下文,上游的调度器会被覆盖。说白了,最近的 flowOn 决定上游的线程。

5.3 catch 异常处理

Flow里的异常处理,我建议用 catch 操作符。它就像try-catch的声明式版本。但要注意:catch 只能捕获它上游的异常,下游的异常它管不了。

flow {
    emit(1)
    throw RuntimeException("出错了")
}.catch { e ->
    emit(-1) // 发送一个兜底值
    // 或者直接 throw e 重新抛出
}.collect { value ->
    println(value)
}

我曾经在项目里遇到一个坑:在 catch 之后又加了 map 操作符,结果 map 里的异常没有被捕获。为什么会这样?因为 catch 只捕获它前面的异常,后面的异常需要另外处理。

避坑指南:

  • catch 放在所有操作符的最后(除了 collect
  • 如果需要在异常后继续发送数据,用 emit 而不是 throw
  • 不要忘记处理 catch 之后的异常——可以用 onCompletion 做收尾

5.4 实战:完整的ViewModel + Flow 模式

下面是我个人比较推荐的结构。它把数据流、调度、异常处理都整合在一起:

class ProfileViewModel(
    private val repo: ProfileRepository
) : ViewModel() {

    private val _profile = MutableStateFlow<ProfileUiState>(ProfileUiState.Loading)
    val profile: StateFlow<ProfileUiState> = _profile.asStateFlow()

    fun loadProfile(userId: String) {
        viewModelScope.launch {
            repo.getProfile(userId)
                .flowOn(Dispatchers.IO) // 网络请求切到IO
                .catch { e ->
                    _profile.value = ProfileUiState.Error(
                        message = "加载失败: ${e.localizedMessage}"
                    )
                }
                .collect { data ->
                    _profile.value = ProfileUiState.Success(data)
                }
        }
    }
}

sealed class ProfileUiState {
    object Loading : ProfileUiState()
    data class Success(val data: Profile) : ProfileUiState()
    data class Error(val message: String) : ProfileUiState()
}

这里我用 sealed class 定义UI状态,每个状态对应一种界面表现。这样做的好处是——你永远不会漏掉某个状态。我在项目中全面推行这种模式后,UI异常率下降了至少60%。

5.5 知识体系总览

下面这张图总结了Flow在ViewModel中的核心流程。我建议你把它记在脑子里:

Flow在ViewModel中的核心流程 数据源 Room / Retrofit / DataStore flowOn(Dispatchers.IO) 切换线程 catch 异常处理 / 兜底值 ViewModel 层 viewModelScope.launch { ... } 管理协程生命周期 StateFlow 暴露给UI层 UI 层 collect / collectAsState 数据流方向:数据源 → flowOn → catch → ViewModel → StateFlow → UI

从这张图你可以看到,数据从数据源出发,经过 flowOn 切换线程,catch 处理异常,最终通过 StateFlow 暴露给UI层。整个链路清晰明了。

5.6 一些个人经验

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

  • 不要在 collect 里做耗时操作——它默认在主线程。我曾经把图片解码放在 collect 里,结果界面卡成PPT。
  • catch 之后记得处理状态——我曾经只打印日志,没有更新UI状态,结果界面一直转圈。用户反馈说「App卡死了」,其实数据早就失败了。
  • flowOn 不是越多越好——每个 flowOn 都会创建新的协程。我见过有人每个操作符后面都加 flowOn,性能反而下降了。

小技巧:如果你需要在 collect 里做UI操作,可以用 flowWithLifecycle 配合 Lifecycle.repeatOnLifecycle。这样当界面不可见时,自动停止收集。我在项目中用这个模式后,电量消耗降低了30%。

好了,Flow在ViewModel中的使用就讲到这里。记住:flowOn 管线程,catch 管异常,StateFlow 管状态。三者配合,你的响应式应用就稳了。

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