为什么加了 suspend 就不用写回调了?挂起到底挂起了什么?launch 和 withContext 的并行/串行差异从何而来?——从 Retrofit 网络请求出发,一步步拆解挂起函数的本质
使用 Retrofit 进行网络请求并将结果展示在界面上,传统方式至少需要一次回调。先定义一个 ApiService 接口:
interface ApiService {
@GET("/path/{params}")
fun getResultCall(
@Path("params") params: String
): Call<List<Result>> // ← 回调风格:返回 Call 对象
@GET("/path/{params}")
suspend fun getResult(
@Path("params") params: String
): List<Result> // ← 协程风格:直接返回数据,加 suspend
}
回调风格的使用代码:
private fun callbackStyle() {
service.getResultCall("params")
.enqueue(object : Callback<List<Result>> {
override fun onResponse(
call: Call<List<Result>>,
response: Response<List<Result>>,
) {
showResult(response.body()!!) // 成功回调
}
override fun onFailure(call: Call<List<Result>>, t: Throwable) {
t.printStackTrace() // 失败回调
}
})
}
private fun showResult(results: List<Result>) {
textView.text = results.toString()
}
这段代码的问题很明显:成功和失败的逻辑被拆在两个方法里,如果连续发多个请求就会出现"回调地狱"——每个回调里再嵌套下一个请求,代码缩进越来越深,异常处理也必须重复写。
同样的需求,用协程只需要这样写:
private fun coroutineStyle() = CoroutineScope(Dispatchers.Main).launch {
val results = service.getResult("params") // 直接拿到结果,像同步代码一样
showResult(results)
}
Callbacktry/catch 统一处理核心魔法:挂起函数让出当前线程(主线程),Retrofit 把耗时任务移到后台执行,完成后协程带着结果回到主线程赋值和更新 UI。整个过程由协程框架自动完成,开发者只需要写一行 service.getResult("params")。
suspend 关键字用来标记一个函数为挂起函数。当该函数被执行时,所在的协程就被挂起——或者说被暂停。
所谓"被挂起",真正含义是:
service.getResult("params") 本身并未被暂停——它切换到了指定线程(后台线程)去执行网络请求关键区分:"协程挂起"不等于"线程阻塞"。线程阻塞是操作系统把线程挂起(BLOCKED 状态),线程在此期间完全不可用。而协程挂起只是协程逻辑暂停、释放线程去做别的事,底层线程依然是自由的、可被复用的。
在实际 Android 开发中,建议的写法是在创建协程作用域时指定 Dispatchers.Main,然后通过挂起函数自动切后台线程:
// 推荐写法:作用域绑定 Main,通过网络请求的 suspend 自动切后台
CoroutineScope(Dispatchers.Main).launch {
// 当前在主线程
val result1 = service.getResult("params1") // suspend → 自动切后台 → 拿到结果回主线程
val result2 = service.getResult("params2") // 同上
showResult(result1, result2) // 在主线程更新 UI
}
核心思想:suspend 让你可以在主线程调度器上"写同步代码",但实际执行时协程框架自动完成线程切换。调用方不需要知道底层用的是哪个线程池——Retrofit 内部会根据 suspend 的协程上下文自动选择后台线程执行。
在 Android 开发中,一般不需要自己去创建 CoroutineScope,直接使用 Jetpack 的 KTX 扩展即可。比如 lifecycleScope,它是 LifecycleOwner 的扩展属性:
// 源码定义
public val LifecycleOwner.lifecycleScope: LifecycleCoroutineScope
get() = lifecycle.coroutineScope
它的两个最典型的实现类:
这两个实现类几乎是所有 Activity 和 Fragment 的基类,所以在 Activity 中可以直接使用 lifecycleScope,它是一个现成的 CoroutineScope,具备以下特点:
Dispatchers.Main.immediate(不是 Dispatchers.Default,也不是 Dispatchers.Main)lifecycleScope 和 viewModelScope 都默认使用 Main.immediate,如果已在主线程中启动协程,第一段代码是同步执行的,零延迟。关于 Main.immediate 和 Main 的区别——前者已在线程时跳过 Handler 入队直接执行,后者无条件走 Handler.post(),详见 四种协程调度器深度解析。
viewModelScope 和 lifecycleScope 几乎没什么不同,只是可供使用的位置不一样:
| 作用域 | 使用位置 | 生命周期绑定 | 默认调度器 |
|---|---|---|---|
| lifecycleScope | Activity / Fragment | 组件 Lifecycle(onDestroy 时取消) | Main.immediate |
| viewModelScope | ViewModel | ViewModel(onCleared 时取消) | Main.immediate |
比如在 ViewModel 中发起网络请求:
class MyViewModel : ViewModel() {
fun loadData() {
viewModelScope.launch { // 默认 Main.immediate
val results = apiService.getResult("params") // suspend → 自动切后台
_liveData.value = results // 回到主线程更新 LiveData
} // ViewModel 清理时自动取消,不用担心泄漏
}
}
在协程中可以启动另一个协程,但它们不是串行的,而是并行的:
CoroutineScope(Dispatchers.Main).launch { // 外层协程在主线程
launch(Dispatchers.IO) { // 内层协程切到 IO 线程
funOfHTTP() // 耗时操作,在 IO 线程执行
}
funOfUI() // 外层继续执行,不等待内层!
}
陷阱:当 funOfHTTP() 还没执行完的时候,funOfUI() 就已经开始执行了。因为 launch 是"发射后不管"——它立即返回,不等待内部逻辑完成。如果需要顺序执行(串行),不能用 launch。
如果需要串行——先等 HTTP 请求完成,再更新 UI——应该用 withContext:
CoroutineScope(Dispatchers.Main).launch {
withContext(Dispatchers.IO) {
funOfHTTP() // 协程挂起 → 切到 IO 线程执行
} // ← 执行完毕才继续
funOfUI() // 回到主线程,此时 HTTP 一定已完成
}
这样就实现了:funOfHTTP() 执行时协程挂起并切到 IO 线程,等到 funOfHTTP() 执行完毕之后,funOfUI() 才会开始执行。
下面这段代码包含了两种写法的对比测试,日志输出能直接看出差异:
class CoroutineTest {
val scope = CoroutineScope(Job() + Dispatchers.Main)
fun launchTest() {
scope.launch {
println("CoroutineTestOfLaunch: 1 - launch + ${Thread.currentThread().name}")
launch(Dispatchers.IO) {
Thread.sleep(2000)
println("CoroutineTestOfLaunch: 2 - launches + ${Thread.currentThread().name}")
}
println("CoroutineTestOfLaunch: 3 - launch + ${Thread.currentThread().name}")
}
}
fun withContextTest() {
scope.launch {
println("CoroutineTestOfWithContext: 1 - launch + ${Thread.currentThread().name}")
withContext(Dispatchers.IO) {
Thread.sleep(2000)
println("CoroutineTestOfWithContext: 2 - withContext + ${Thread.currentThread().name}")
}
println("CoroutineTestOfWithContext: 3 - launch + ${Thread.currentThread().name}")
}
}
}
依次调用两个方法,观察日志:
14:32:18.505 17210-17210 I CoroutineTestOfLaunch: 1 - launch + main
14:32:18.506 17210-17210 I CoroutineTestOfLaunch: 3 - launch + main
↑ 1和3几乎同一毫秒!不等待内层 launch
14:32:20.507 17210-17283 I CoroutineTestOfLaunch: 2 - launches + DefaultDispatcher-worker-2
↑ 2秒后才打印,在 IO 线程
执行顺序:1(主) → 3(主) → 2(IO延时2秒)
结论:launch 不等待,3 在 2 之前就执行了
14:32:18.507 17210-17210 I CoroutineTestOfWithContext: 1 - launch + main
14:32:20.509 17210-17284 I CoroutineTestOfWithContext: 2 - withContext + DefaultDispatcher-worker-3
↑ 2秒后才打印,协程在此挂起等待
14:32:20.510 17210-17210 I CoroutineTestOfWithContext: 3 - launch + main
↑ 2 执行完毕后才回到主线程打印 3
执行顺序:1(主) → [挂起2秒] → 2(IO) → 3(主)
结论:withContext 挂起等待,3 一定在 2 之后执行
launch(IO) — 并行
withContext(IO) — 串行
withContext 这个名字可以从两部分拆解,对齐 Kotlin 标准库的命名惯例:
| 词 | 含义 | 类比 |
|---|---|---|
| Context | 指 协程上下文(CoroutineContext),一个协程在运行时会携带一组上下文信息,其中最常用的就是调度器(Dispatcher) | withContext(Dispatchers.IO) 的参数就是一个上下文,实际传的是调度器 |
| with | 表示"使用这个上下文,去执行接下来的代码",遵循 Kotlin 惯用模式:withX 表示"用 X 来做某事" |
标准库 with(s) { ... }:以 s 为上下文执行代码 |
Kotlin 标准库中的 with 函数:
val s = StringBuilder()
with(s) {
append("Hello")
append(" World")
}
// 以 s 为上下文,{} 内的 append 都是对 s 的操作
命名规律:withContext(ctx) { } 就是"以 ctx 为协程上下文,执行花括号中的代码"。这个命名和标准库 with 一脉相承。
从原理上去看,withContext 的本质只是切换当前协程所在的调度器。协程的执行始终跟随着某个调度器,当你调用 withContext(Dispatchers.IO) 时,协程框架执行以下三步操作:
一句话总结原理:withContext 的本质 = 挂起当前协程 → 在新调度器执行 block → 完成后在原调度器恢复。它只是临时"借用"另一个调度器,不会改变协程本身的上下文。
suspend 函数让你像写同步代码一样写异步请求。底层协程框架自动完成线程切换——调用方不需要知道底层用的是哪个线程池,不需要手动切回主线程。launch(IO) { } 不等待内部完成就继续执行外层;withContext(IO) { } 挂起当前协程,等 block 执行完毕才继续。选择哪个取决于任务之间是否有依赖关系。with(obj) { } 的命名风格,表示"在这个上下文中执行"。底层实现就是挂起 → 切调度器 → 执行 → 恢复四步。suspend 关键字让异步代码读起来像同步——协程遇到挂起点时让出线程但不阻塞线程,Retrofit 自动在后台完成请求后再把结果带回原来的线程。launch 是"发射后不管"的并行,withContext 是"等你干完我再走"的串行。实际开发中用 lifecycleScope / viewModelScope 开箱即用,调度器选 Main.immediate,剩下的交给挂起函数自动切线程。