最近在写 Android 端的管理后台应用时,我理所当然地在 ViewModel 里用 viewModelScope.launch 包裹网络请求,心想:协程都启动了,UI 总该流畅了吧?结果界面照样卡死。
带着这个挫败感,我回去翻了 Android 官方协程文档。文档写得信誓旦旦:
轻量:您可以在单个线程上运行多个协程,因为协程支持挂起,不会使正在运行协程的线程阻塞。
本文尽量保留当时的疑问和推导过程,希望能帮同样被官方文档“骗”过的你少走点弯路。
协程如何运作
挂起与恢复
协程的核心魔法在于 挂起 和 恢复:
- 执行到
suspend函数(如delay()、网络请求)时,协程把当前位置、局部变量等信息打包保存,主动脱离当前线程。 - 线程被释放,可以去干别的事——比如 Android 主线程继续刷新 UI。
- 后台任务完成后,调度器把协程恢复到某个线程(可以是原线程,也可以是新线程),从挂起点继续执行。
编译器通过 CPS 变换 把 suspend 函数变成状态机:每个挂起点对应一个状态,挂起时返回 COROUTINE_SUSPENDED,完成后通过 Continuation.resume() 跳转到下一状态。
三大核心组件
| 组件 | 职责 |
|---|---|
| CoroutineScope(作用域) | 控制协程生命周期。作用域销毁时,内部所有协程自动取消。 |
| CoroutineContext(上下文) | 协程的配置集,包含 Job、调度器、名称等。 |
| CoroutineDispatcher(调度器) | 决定协程在哪个线程上执行。 |
常用调度器:
Dispatchers.Main:主线程,处理 UI。Dispatchers.IO:I/O 密集型线程池,网络、文件读写。Dispatchers.Default:CPU 密集型线程池,计算、排序、JSON 解析。
viewModelScope 与三大组件的关系
viewModelScope 是 Jetpack 为 ViewModel 提供的协程作用域,内部大致等价于:
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
对应关系如下:
① 自动取消(Scope + Job)
Activity 关闭 → ViewModel.onCleared() → viewModelScope 取消 → 所有子协程收到 CancellationException 并停止。这就是结构化并发的价值:不用担心协程泄漏。
② 默认主线程(Dispatcher)
不向 launch 传调度器时,协程默认跑在主线程。这样拿到结果后可以直接更新 UI,不必再切回来。
③ SupervisorJob 防连带崩溃
viewModelScope 使用 SupervisorJob:一个子协程失败,不会拖垮同作用域下的其他子协程。
我的第一个误解:调度器管生命周期,作用域管线程池?
我的理解:调度器相当于管理协程及其上下文的协程池,作用域通知调度器生命周期?
需要修正:
- 调度器不存储协程。它只是把任务分发到 Java 线程池。真正的池是
ThreadPoolExecutor,调度器是进入池的通道。 - 作用域是容器,核心资产是上下文里的
Job。launch时子Job挂到父Job下形成任务树;scope.cancel()时整棵树被取消。
通俗比喻:
- 协程 = 业务项目
- 上下文 = 项目配置白皮书
- 调度器 = HR,把项目派给具体员工(线程)
- 作用域 = 董事长,手握
Job一票否决权;部门解散(ViewModel 销毁),所有项目立刻终止
launch(Dispatchers.IO) 还是 withContext?
我的疑问:在
launch里加调度器,和显式调用withContext声明调度器,有什么区别?
做法 A:launch(Dispatchers.IO) { ... }
整个代码块从头到尾在 IO 线程池执行。适合纯后台任务(写日志、静默同步),做完后若要更新 UI,还得切回主线程。
做法 B:launch { withContext(Dispatchers.IO) { ... } }(推荐)
协程先在主线程启动,遇到 withContext 时挂起,把内部代码外包给 IO 线程;完成后自动回到原线程继续执行。可以用同步写法表达跨线程逻辑——这正是 Android 开发的标准姿势。
开销上,launch(Dispatchers.IO) 只有一次线程分发;withContext 多一次切出和切回,但在现代设备上几乎可以忽略。
withContext 切换的是上下文,不是启动新协程
我的理解:
withContext就是用来切换launch所启动协程的上下文的?
基本正确,但有一个极易混淆的点:
launch/async:协程启动器,创建新的子协程,与父协程并发执行。withContext:只是挂起函数,不创建新协程。它让同一个协程临时换线程跑一段代码,并挂起后续语句直到块内完成。
比喻:launch 是新开一条生产线;withContext 是项目经理说「这段外包给 IO 团队,做完再继续」。
误区:launch 了就不会卡 UI?
我的疑问:请求网络 I/O 时不用
withContext切线程,viewModelScope已经启动了新协程,UI 就不会卡了吧?
不会。关键不在「有没有 launch」,而在「代码跑在哪个线程、会不会阻塞」。
viewModelScope.launch 默认在主线程启动协程。如果你在协程里写同步阻塞代码:
viewModelScope.launch {
Thread.sleep(3000) // 主线程死等 3 秒
updateUI()
}
主线程被堵死 3 秒,UI 冻结,超过 5 秒还可能 ANR。
如果你用的是 Retrofit suspend、Room suspend、Ktor Client 等现代化库,它们在底层已经做了线程切换,即使不写 withContext,主线程也不会被堵死。
协程与 UI 主线程:同一条路,不是平行公路
我的疑问:
viewModelScope既然启动了协程,为什么还会影响 UI?协程和 UI 进程是什么关系?
Android 只有一个 UI 主线程(严格说是一个 Looper 驱动的消息循环)。viewModelScope.launch 启动的协程,默认就跑在这条主线程公路上,不是旁边另开一条平行路。
- 场景 A:同步阻塞代码 → 协程霸占主线程,UI 渲染任务排队干等 → 卡死。
- 场景 B:
withContext(Dispatchers.IO)等真挂起 → 协程主动让出主线程 → UI 继续刷新 → 流畅。
viewModelScope.launch 的真正作用:在主线程上圈出一块地,允许你使用 suspend 挂起魔法。它不提供免费的后台线程。
主线程能跑多个协程,为什么还会卡?
我的追问:UI 线程不是可以切换协程、依次执行吗?切换了就不会卡死吧?
主线程的消息循环确实在依次处理任务:触摸事件、布局、绘制、viewModelScope 里的协程……
但关键是 协作式(Cooperative) 调度:
- 协程只有执行到
suspend挂起点(withContext、delay等)时,才自愿让出线程。 - 普通耗时代码没有挂起点,协程会死死霸占主线程,后面的 UI 任务无法执行。
银行排队比喻:
- 阻塞:你站在窗口前打电话等家人送文件,后面的人全得干等,银行瘫痪。
- 挂起:你跟柜员说「我去旁边等电话,先让后面的人办」,办完再回来排队。
其他调度器上,长任务会卡死后面的协程吗?
我的追问:
Dispatchers.IO等调度器如果总在单线程里,一个协程跑很久,后面的协程不也被卡死?
Dispatchers.IO 和 Dispatchers.Default 底层是多线程池,不是单线程。
Dispatchers.Default:线程数约等于 CPU 核心数。9 个纯 CPU 密集、无挂起点的协程同时跑,第 9 个可能排队。Dispatchers.IO:线程池很大(默认最高 64+)。一个线程被阻塞 I/O 卡住,调度器会派其他线程执行后续协程。
在单线程环境(如 Dispatchers.Main)里,没有挂起点的长耗时协程一定会饿死后面的任务。极端情况下可在循环里调用 yield(),主动挂起并重新排队。
协程之所以叫「协程」,就是因为需要协作。
viewModelScope 本质是单线程
我的结论:
viewModelScope就是一个单线程环境,即使它可以运行很多协程。
准确。一个线程能跑成千上万个协程,是因为它们通过挂起点交替使用线程:
协程 A 跑 2ms → delay 挂起 → 协程 B 跑 1ms → 挂起 → UI 刷新 → 协程 A 恢复 …
但这一切的前提是:代码里要有真正的挂起点。没有挂起点,就和普通函数一样霸占线程。
质疑官方文档:「不阻塞线程」到底指什么?
我的质疑:文档说协程支持挂起、不会使线程阻塞——是不是在骗人?
文档没有骗人,但容易让人误读。它说的是:
当协程执行
suspend函数 时,线程不会被阻塞。
它没有说:你把任何 Java 代码丢进 launch,框架都会自动帮你挂起。
任务分两类:
① 结构化挂起任务(真挂起)
代表:Retrofit / Room / Ktor Client 的 suspend API、delay()。
表现:在单线程里自动挂起,不卡 UI。
② 传统阻塞与 CPU 密集任务(真阻塞)
代表:FileInputStream 同步读写、旧版 OkHttp.execute()、大循环图片处理、巨型 JSON 解析。
表现:无法自动挂起。在 viewModelScope 默认环境里跑,直接卡死主线程。
withContext 的历史使命
表面看:文档说协程在单线程上支持挂起,不阻塞线程。
实际上:只有遵守 suspend 契约的函数才能被自动挂起。
真相:面对传统阻塞 I/O 和 CPU 密集计算,协程无法自动挂起它们,必须靠 withContext 强行外包给其他线程池,保护主线程流畅。
Ktor + OkHttp:不写 withContext 行不行?
我的追问:用 Ktor 的 OkHttp 引擎,直接
viewModelScope.launch里调,不会阻塞 UI 吧?OkHttp 应该自带suspend吧?
要分清两层:
- 传统 OkHttp:
call.execute()是同步阻塞。主线程直接调用会卡死。 - Ktor Client(
ktor-client-okhttp):官方用suspendCancellableCoroutine等机制,把 OkHttp 的阻塞/回调包装成符合协程规范的suspend函数。
因此,Ktor 的 HttpClient 请求可以直接在 viewModelScope.launch 里调用,底层会挂起而不堵主线程。
但以下情况仍会卡 UI:
// ❌ 假 suspend:函数标了 suspend,内部却是阻塞调用
suspend fun fakeFetch() {
URL("https://example.com").readText() // 阻塞!
}
// ❌ CPU 密集计算没有切线程
viewModelScope.launch {
val data = hugeList.map { heavyCompute(it) }
}
理想世界:库全是协程原生、主线程安全(Ktor、Retrofit suspend、Room suspend),launch 里直接调,不必手写 withContext。
现实世界:总有老旧 Java SDK、同步文件 I/O、CPU 密集逻辑混进来。
金科玉律:对无法自动挂起的代码,手动套 withContext(Dispatchers.IO) 或 withContext(Dispatchers.Default),发配到后台线程池。
工业级标准模板:Main-safety
结合项目里的实际写法,推荐把耗时逻辑封装进 主线程安全(Main-safe) 的 suspend 函数,在内部用 withContext 切换调度器。调用方只管 viewModelScope.launch,不必关心线程。
Repository 层(PostRepository.kt):
suspend fun getPosts(): List<SitePost> = withContext(Dispatchers.IO) {
val response: ApiListResponse<SitePost> = api.get("/api/admin/posts")
response.data
}
ViewModel 层(AuthViewModel.kt):
viewModelScope.launch {
auth.sessionStatus.collect { status ->
when (status) {
is SessionStatus.Authenticated -> {
_userState.value = status.session.user?.email
verifyAdminAccess() // suspend,内部 Repository 已切 IO
}
// ...
}
}
}
模板可以概括为:
// ViewModel:在主线程启动,专注 UI 状态
viewModelScope.launch {
_isLoading.value = true
try {
val result = repository.fetchSomething() // suspend + withContext(IO) 在 Repository 内
_uiState.value = result // 回到主线程,安全更新 UI
} catch (e: Exception) {
_error.value = e.message
} finally {
_isLoading.value = false
}
}
// Repository:保证 Main-safe
suspend fun fetchSomething(): Data = withContext(Dispatchers.IO) {
// 阻塞 I/O 或 CPU 任务放这里
}
这样写的好处:
- 单一职责:Repository 保证任何调用方都是主线程安全。
- 可读性:ViewModel 像同步代码一样线性阅读。
- 可测试:
suspend函数比嵌套launch更容易单测。
结论
| 认知 | 真相 |
|---|---|
launch = 后台线程 |
❌ 默认仍在主线程,除非传 Dispatchers 或内部 withContext |
| 协程 = 异步 = 不卡 UI | ❌ 只有 真挂起 才不卡;真阻塞 照样卡 |
| 官方文档在骗人 | ❌ 文档说的是 suspend 函数的行为,不是万能护身符 |
withContext 启动子协程 |
❌ 同一协程换线程,是挂起函数不是启动器 |
viewModelScope 多线程 |
❌ 默认单主线程;多协程靠挂起点协作 |
从把 selected 错打成 sealed,到质疑「单线程不阻塞」的官方表述,再到用 Ktor 和 withContext 把逻辑闭环——这套推导让我终于敢理直气壮地说:不是协程没用,是我以前没分清挂起和阻塞。
如果你也在 viewModelScope.launch 里写过 Thread.sleep 或者同步读文件,欢迎对号入座。记住一行原则就够了:
launch只负责在主线程上开场;能不能不卡 UI,取决于你调用的函数是真suspend,还是你手动withContext把真阻塞发配到了后台。