二、suspend 与挂起函数:从 Retrofit 到 withContext

为什么加了 suspend 就不用写回调了?挂起到底挂起了什么?launch 和 withContext 的并行/串行差异从何而来?——从 Retrofit 网络请求出发,一步步拆解挂起函数的本质

suspend Retrofit lifecycleScope withContext 协程

目录导航(点击跳转)

一、从回调到挂起:Retrofit + suspend 消灭回调地狱

1回调方式:熟悉但繁琐

使用 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()
}

这段代码的问题很明显:成功和失败的逻辑被拆在两个方法里,如果连续发多个请求就会出现"回调地狱"——每个回调里再嵌套下一个请求,代码缩进越来越深,异常处理也必须重复写。

2协程方式:一行代码,无回调

同样的需求,用协程只需要这样写:

private fun coroutineStyle() = CoroutineScope(Dispatchers.Main).launch {
    val results = service.getResult("params")  // 直接拿到结果,像同步代码一样
    showResult(results)
}

回调方式

  • 需要写匿名内部类 Callback
  • 成功/失败逻辑分散在两个方法
  • 多个请求嵌套 → 回调地狱
  • 需要手动切回主线程更新 UI

协程 + suspend 方式

  • 代码像同步一样从上到下线性书写
  • 成功/失败用 try/catch 统一处理
  • 多个请求也是顺序写,无嵌套
  • 协程自动回到主线程,安全更新 UI

核心魔法:挂起函数让出当前线程(主线程),Retrofit 把耗时任务移到后台执行,完成后协程带着结果回到主线程赋值和更新 UI。整个过程由协程框架自动完成,开发者只需要写一行 service.getResult("params")

3流程对比图:回调链 vs 挂起链
回调方式
1主线程:调用 enqueue()、注册回调
2后台线程:执行网络请求
3IO 线程回调 onResponse()
4手动 runOnUiThread 切回主线程
5主线程:更新 UI
vs
协程 + suspend 方式
1主线程:launch 启动协程
2遇挂起点 → 协程挂起、主线程释放
3后台线程:Retrofit 执行请求
4请求完成 → 协程自动恢复回主线程
5主线程:更新 UI(无需手动切线程)

二、suspend 关键字的本质:让出线程,而不是阻塞线程

1suspend 做了什么?—— 拆开来看

suspend 关键字用来标记一个函数为挂起函数。当该函数被执行时,所在的协程就被挂起——或者说被暂停。

所谓"被挂起",真正含义是:

  1. 协程不再占用它正在工作的那个线程——它让出了线程
  2. service.getResult("params") 本身并未被暂停——它切换到了指定线程(后台线程)去执行网络请求
  3. 网络请求执行完毕 → 协程恢复 → 回到原来的调度器(主线程)执行接下来的代码
主线程
执行协程代码
遇到 suspend
挂起点
协程挂起
主线程被释放
后台线程
执行耗时任务
任务完成
协程恢复回主线程

关键区分:"协程挂起"不等于"线程阻塞"。线程阻塞是操作系统把线程挂起(BLOCKED 状态),线程在此期间完全不可用。而协程挂起只是协程逻辑暂停、释放线程去做别的事,底层线程依然是自由的、可被复用的。

2suspend 带来的实际好处:以 Retrofit 为例

在实际 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 的协程上下文自动选择后台线程执行。

三、lifecycleScope 与 viewModelScope:Android 开箱即用的协程作用域

1为什么不需要自己创建 CoroutineScope?

在 Android 开发中,一般不需要自己去创建 CoroutineScope,直接使用 Jetpack 的 KTX 扩展即可。比如 lifecycleScope,它是 LifecycleOwner 的扩展属性:

// 源码定义
public val LifecycleOwner.lifecycleScope: LifecycleCoroutineScope
    get() = lifecycle.coroutineScope

它的两个最典型的实现类:

  • LifecycleOwner
    • ComponentActivity
    • Fragment

这两个实现类几乎是所有 Activity 和 Fragment 的基类,所以在 Activity 中可以直接使用 lifecycleScope,它是一个现成的 CoroutineScope,具备以下特点:

  • 跟当前所在组件的生命周期绑定——组件销毁时自动取消所有协程,不会内存泄漏
  • 内置的调度器默认使用 Dispatchers.Main.immediate(不是 Dispatchers.Default,也不是 Dispatchers.Main)

lifecycleScopeviewModelScope 都默认使用 Main.immediate,如果已在主线程中启动协程,第一段代码是同步执行的,零延迟。关于 Main.immediateMain 的区别——前者已在线程时跳过 Handler 入队直接执行,后者无条件走 Handler.post(),详见 四种协程调度器深度解析

2viewModelScope:在 ViewModel 中的等价物

viewModelScopelifecycleScope 几乎没什么不同,只是可供使用的位置不一样:

作用域使用位置生命周期绑定默认调度器
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 清理时自动取消,不用担心泄漏
    }
}

四、launch 与 withContext:并行与串行的分水岭

1launch 嵌套 launch:并行执行

在协程中可以启动另一个协程,但它们不是串行的,而是并行的

CoroutineScope(Dispatchers.Main).launch {   // 外层协程在主线程
    launch(Dispatchers.IO) {                // 内层协程切到 IO 线程
        funOfHTTP()                          // 耗时操作,在 IO 线程执行
    }
    funOfUI()                                // 外层继续执行,不等待内层!
}

陷阱:funOfHTTP() 还没执行完的时候,funOfUI() 就已经开始执行了。因为 launch 是"发射后不管"——它立即返回,不等待内部逻辑完成。如果需要顺序执行(串行),不能用 launch。

2withContext:挂起当前协程,实现串行

如果需要串行——先等 HTTP 请求完成,再更新 UI——应该用 withContext

CoroutineScope(Dispatchers.Main).launch {
    withContext(Dispatchers.IO) {
        funOfHTTP()    // 协程挂起 → 切到 IO 线程执行
    }                   // ← 执行完毕才继续
    funOfUI()           // 回到主线程,此时 HTTP 一定已完成
}

这样就实现了:funOfHTTP() 执行时协程挂起并切到 IO 线程,等到 funOfHTTP() 执行完毕之后,funOfUI() 才会开始执行。

3实测对比:日志不会说谎

下面这段代码包含了两种写法的对比测试,日志输出能直接看出差异:

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}")
        }
    }
}
4运行结果分析

依次调用两个方法,观察日志:

launchTest() — 并行 withContextTest() — 串行
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 嵌套 launch

  • 内层协程"发射后不管"
  • 外层立即继续执行 ← 并行
  • 打印 1 和 3 几乎同时
  • 适用于:多个不互相依赖的并发任务

withContext 切换上下文

  • 当前协程挂起等待
  • 等内层完成才继续 ← 串行
  • 打印 1 → 等 2 秒 → 打印 2 → 打印 3
  • 适用于:需要按顺序执行的依赖任务
5执行时序图:直观对比两种模式

launch(IO) — 并行

Main IO
1打印 1 (main)
2launch(IO) 启动 → 不等待
3打印 3 (main) ← 不等 IO 完成!
此时 IO 线程才刚开始 sleep
42 秒后 打印 2 (IO)

withContext(IO) — 串行

Main IO
1打印 1 (main)
withContext(IO) → 协程挂起
22 秒后 打印 2 (IO)
挂起结束 → 协程恢复回 Main
3打印 3 (main) ← 2 一定已完成

五、withContext 命名解析与底层原理

1从命名理解 withContext

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 一脉相承。

2withContext 底层原理:三步走

从原理上去看,withContext 的本质只是切换当前协程所在的调度器。协程的执行始终跟随着某个调度器,当你调用 withContext(Dispatchers.IO) 时,协程框架执行以下三步操作:

1
挂起当前协程,让出当前线程(如主线程)
2
将 block 作为任务交给 Dispatchers.IO,分配空闲线程执行
3
任务执行完毕 → IO 调度器通知协程框架"可以恢复了" → 在原来的调度器(Main)上恢复协程
主线程:协程正在执行
withContext(IO)
挂起 → 让出主线程
block 投递到 IO 线程池
IO 线程
执行 block
block 执行完
通知框架恢复协程
回到主线程
继续执行后续代码

一句话总结原理:withContext 的本质 = 挂起当前协程 → 在新调度器执行 block → 完成后在原调度器恢复。它只是临时"借用"另一个调度器,不会改变协程本身的上下文。

六、核心思想总结

核心要点

一句话总结

suspend 关键字让异步代码读起来像同步——协程遇到挂起点时让出线程但不阻塞线程,Retrofit 自动在后台完成请求后再把结果带回原来的线程。launch 是"发射后不管"的并行,withContext 是"等你干完我再走"的串行。实际开发中用 lifecycleScope / viewModelScope 开箱即用,调度器选 Main.immediate,剩下的交给挂起函数自动切线程。