第八章 协程进阶:协程上下文与调度器、withContext、Flow基础、Channel基础、协程异常处理

协程基础咱们聊完了,现在该进入真正有意思的部分了。

说实话,刚学完协程基础那会儿,我也有点懵——启动协程是学会了,但怎么控制它跑在哪个线程?怎么在协程之间传递数据?出错了怎么办?

这些问题,就是本章要解决的。

8.1 协程上下文与调度器

协程上下文,说白了就是协程运行时的“环境配置包”。它里面装着各种信息,比如协程的名字、异常处理器、最重要的是——调度器。

调度器(Dispatcher)决定了协程跑在哪个线程上。Kotlin 提供了几个内置的调度器:

调度器 说明 典型场景
Dispatchers.Main 主线程,Android 上就是 UI 线程 更新 UI、与 View 交互
Dispatchers.IO 专为 IO 操作优化的线程池 网络请求、文件读写、数据库操作
Dispatchers.Default CPU 密集型任务的线程池 大数据排序、图片处理、JSON 解析
Dispatchers.Unconfined 不限制,在调用者的线程执行 极少用,特殊场景
我的习惯:网络请求用 Dispatchers.IO,解析返回数据用 Dispatchers.Default,更新 UI 切回 Dispatchers.Main。这个三板斧,项目中用了好几年没出过问题。

来看个实际例子:

viewModelScope.launch(Dispatchers.IO) {
    val data = apiService.fetchData()  // 网络请求在 IO 线程
    withContext(Dispatchers.Default) {
        val processed = processData(data)  // 数据处理在 Default 线程
    }
    withContext(Dispatchers.Main) {
        uiState.value = processed  // 更新 UI 切回主线程
    }
}

你可能会问:为什么不直接用 launch 切换线程?嗯,这里有个关键点——launch 是启动一个新的协程,而 withContext 是在当前协程内切换上下文。后者更轻量,而且能保证执行顺序。

8.2 withContext 的妙用

withContext 是我个人非常喜欢的一个函数。它不会创建新协程,而是挂起当前协程,切换到指定上下文执行代码块,执行完再切回来。

它的返回值就是代码块最后一行的结果:

suspend fun loadUserProfile(): UserProfile = withContext(Dispatchers.IO) {
    val user = userRepo.getUser()       // IO 线程
    val posts = postRepo.getPosts()     // IO 线程
    UserProfile(user, posts)            // 返回结果
}

调用方不用关心线程切换,直接在主线程调用就行:

lifecycleScope.launch {
    val profile = loadUserProfile()  // 自动切到 IO,返回后切回 Main
    showProfile(profile)             // 主线程更新 UI
}
我曾经踩过的坑:在 withContext 内部直接修改 UI 组件。withContext(Dispatchers.IO) 里面调用 textView.text = "xxx",直接崩溃。记住,withContext 切换后,代码块内部就在指定线程执行了,别在里面碰 UI。

8.3 Flow 基础

Flow 是 Kotlin 协程体系里的“响应式数据流”。你可以把它想象成一个可以异步发射多条数据的序列。

为什么需要 Flow?

  • LiveData 只能处理简单场景,不支持线程切换、背压
  • RxJava 功能强大但学习成本高,API 复杂
  • Flow 天然支持协程,轻量且强大

一个最简单的 Flow:

fun countDown(): Flow<Int> = flow {
    for (i in 10 downTo 1) {
        emit(i)                     // 发射数据
        delay(1000)                 // 模拟每秒倒计时
    }
    emit(0)                         // 发射完成信号
}

// 收集数据
lifecycleScope.launch {
    countDown().collect { value ->
        textView.text = "倒计时: $value"
    }
}

Flow 有三个关键操作:

  • 构建flow { }flowOf()、集合的 .asFlow()
  • 中间操作mapfiltercatch 等,和集合操作类似
  • 终端操作collectfirsttoList 等,触发数据流动

来看一个带错误处理的例子:

fun searchProducts(query: String): Flow<List<Product>> = flow {
    emit(loadingState())                    // 先发一个加载中状态
    val result = apiService.search(query)   // 网络请求
    emit(successState(result))              // 发成功结果
}.catch { e ->
    emit(errorState(e.message ?: "未知错误"))  // 捕获异常,发射错误状态
}.flowOn(Dispatchers.IO)                    // 指定上游在 IO 线程执行
注意:flowOn 改变了上游的调度器,但下游的 collect 仍然在调用它的协程上下文中执行。这个设计很巧妙——数据准备在后台,消费在主线程。

8.4 Channel 基础

Channel 是协程之间的通信管道。一个协程往里面放数据,另一个协程从里面取数据。有点像 BlockingQueue,但它是挂起的,不会阻塞线程。

Channel 有三种主要类型:

类型 行为 适用场景
Rendezvous(默认) 无缓冲,发送和接收必须同时发生 一对一同步通信
Buffered 有缓冲区,发送方可以提前放数据 生产速度 > 消费速度的场景
Conflated 只保留最新值,旧值被覆盖 只关心最新状态,如 UI 状态更新

来个简单的生产者-消费者例子:

val channel = Channel<String>(Channel.BUFFERED)

// 生产者
fun produce() = scope.launch {
    for (i in 1..5) {
        channel.send("消息 $i")
        delay(200)
    }
    channel.close()  // 发送完毕,关闭通道
}

// 消费者
fun consume() = scope.launch {
    for (msg in channel) {  // 自动遍历直到通道关闭
        println("收到: $msg")
    }
}
我曾经犯过的错:忘记调用 channel.close(),导致消费者协程一直挂起等待,内存泄漏。后来我养成了习惯——用 produce 协程构建器,它会自动管理关闭。

其实 produceconsumeEach 是更推荐的方式:

val channel = produce {
    for (i in 1..5) {
        send(i)
    }
}

channel.consumeEach { value ->
    println("消费: $value")
}

8.5 协程异常处理

协程里的异常处理,和普通代码不太一样。你想想看,协程可能跑在任意线程上,异常怎么传播?怎么捕获?

核心原则:未捕获的异常会沿着协程作用域向上传播,直到被处理或导致作用域取消

处理方式有三种:

8.5.1 try-catch 包裹

最直接的方式,适合单个协程:

scope.launch {
    try {
        val data = apiService.fetchData()
        showData(data)
    } catch (e: Exception) {
        showError(e.message)
    }
}

8.5.2 CoroutineExceptionHandler

全局异常处理器,适合统一处理:

val handler = CoroutineExceptionHandler { _, exception ->
    Log.e("Coroutine", "捕获到异常: ${exception.message}")
}

scope.launch(handler) {
    throw RuntimeException("出错了")
}
重要:CoroutineExceptionHandler 只在 launch 中有效。async 的异常需要通过 await() 时的 try-catch 捕获,或者使用 supervisorScope

8.5.3 supervisorScope

这个我特别喜欢。它让子协程的失败不会影响兄弟协程:

supervisorScope {
    val job1 = launch {
        delay(100)
        throw RuntimeException("任务1失败")
    }
    val job2 = launch {
        delay(200)
        println("任务2完成")  // 这个会正常执行
    }
}

如果是普通的 coroutineScope,job1 失败会导致 job2 也被取消。但 supervisorScope 不会——每个子协程独立处理自己的异常。

我在项目中用 supervisorScope 处理过并行下载的场景:一个文件下载失败,不影响其他文件继续下载。用户体验好很多。

8.6 本章知识体系

下面这张图,是我梳理的协程进阶知识结构:

协程进阶 协程上下文 & 调度器 • Dispatchers.Main / IO / Default • 上下文组合:+ 运算符 • 作用域:viewModelScope / lifecycleScope withContext • 切换上下文不创建新协程 • 有返回值,挂起函数 • 自动恢复原上下文 Flow 基础 • flow { emit() } 构建 • 中间操作:map / filter / catch • 终端操作:collect / first • flowOn 切换上游线程 Channel 基础 • Rendezvous / Buffered / Conflated • send / receive 挂起函数 • produce / consumeEach 推荐 • 记得 close() 避免泄漏 异常处理 • try-catch 包裹协程体 • CoroutineExceptionHandler • supervisorScope 隔离失败

这五个知识点,是协程进阶的核心。调度器控制线程,withContext 切换上下文,Flow 处理数据流,Channel 做协程间通信,异常处理保证稳定性。把它们串起来,你就能写出健壮的异步代码了。

我个人觉得,Flow 和 Channel 是这里面最值得花时间研究的。尤其是 Flow,它在 Android 开发中的地位越来越高,Room、Retrofit、DataStore 都在用。学会了,后面很多框架用起来就顺手了。

避坑指南:我曾经在项目里混用 Flow 和 LiveData,导致数据流混乱。后来统一用 Flow + StateFlow 替代 LiveData,代码清晰多了。如果你在写新项目,建议直接上 Flow。

好了,协程进阶就聊到这儿。记住,多写多练,这些概念才能真正变成你自己的东西。


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