最近我团队面试了 14 个 Android 工程师,只有 3 个人能讲清楚"协程的挂起到底在挂什么"。
这个数字让我有点意外。协程已经是 Android 官方推荐的并发方案,Compose、Room、Retrofit、WorkManager 的官方文档全部基于协程。即便如此,它依然被大量工程师当作"Thread 的语法糖"在用。
这篇文章不重复那些"协程是轻量级线程"的陈词滥调。我想给你一个正确的心智模型,让你从此以后用协程时不慌。
一、先讲清楚:协程和 Thread 是完全不同维度的东西
Thread 是操作系统调度的执行流。一个 Thread 占 1MB 左右的栈内存,调度由 OS 负责。
协程是用户态的可挂起计算。一个协程占几百字节内存,调度由 Kotlin 运行时负责。
关键差异:
协程最大的优势不是"轻量"——是"结构化"。
二、协程的真实心智模型:一个带返回值的"被中断函数"
错误的模型
"协程是轻量 Thread,可以替代 Thread 的使用场景。"
正确的模型
"协程是一个可能被挂起、但完成后能从原位置继续的函数。"
来看一个例子:
suspend fun loadUser(): User {
val response = api.getUser() // 第 1 步:可能挂起
val avatar = storage.download(response.avatarUrl) // 第 2 步:可能挂起
return User(response.name, avatar)
}
suspend fun showProfile() {
val user = loadUser() // 这里"挂起",但不阻塞当前线程
showOnUi(user) // 恢复后继续执行
}
当 api.getUser() 内部需要等待网络时:
当前协程被 Kotlin 运行时挂起——保存栈帧和局部变量。
当前线程立刻被释放,可以去做别的事。
等网络回来,Kotlin 把协程恢复到之前的位置继续执行。
注意第 2 点——线程没被阻塞。这才是协程真正的力量。在 Android 里,这意味着主线程可以处理 UI 事件,同时协程在 IO 线程池跑网络请求。
三、Dispatchers 不要乱选
Kotlin 协程有 4 个常用的调度器:
黄金法则
如果你不确定,UI 用 Main,IO 用 IO,CPU 密集用 Default。
看个真实错误:
// 错误:在 Default 上做 IO 任务,会阻塞 CPU 线程池
val data = withContext(Dispatchers.Default) {
file.readText() // 这是 IO 不是 CPU
}
// 正确:
val data = withContext(Dispatchers.IO) {
file.readText()
}
把 IO 任务放在 Default 上会导致CPU 线程池被阻塞,反之把 CPU 任务放在 IO 上会导致IO 线程池过载。这两件事我都亲眼见过线上事故。
四、结构化并发:协程最被低估的能力
Thread 在 Java 里是无根的孤儿——你 new 一个 Thread,它就独立运行。要等它结束,要么 join() 阻塞,要么用 Future——怎么都别扭。
协程是有父子的树:
fun loadProfile() {
lifecycleScope.launch { // 父协程
val user = async { api.getUser() } // 子协程 1
val avatar = async { download() } // 子协程 2
showProfile(user.await(), avatar.await())
}
}
父协程取消,所有子协程自动取消。
任何一个子协程抛异常,会按规则传播到父协程。
父协程负责等所有子协程结束才结束——不存在"漏掉的协程"。
这就是结构化并发的精髓——它让你摆脱了"线程泄露"和"回调地狱"两个地狱。
五、实战:用协程重写一个老掉牙的网络 + 数据库任务
老代码(Thread + Handler):
fun loadAndShow() {
Thread {
val user = api.getUser().execute().body() ?: return@Thread
val orders = db.ordersDao().getByUserSync(user.id)
runOnUiThread {
binding.tvName.text = user.name
adapter.submitList(orders)
}
}.start()
}
新代码(协程):
fun loadAndShow() {
lifecycleScope.launch {
val user = withContext(Dispatchers.IO) { api.getUser() }
val orders = withContext(Dispatchers.IO) { db.ordersDao().getByUser(user.id) }
binding.tvName.text = user.name
adapter.submitList(orders)
}
}
不仅代码简短,更重要的是:
如果用户中途退出页面,整个协程树会被自动取消——不会回调到已销毁的 UI。
如果 IO 抛异常,会被协程捕获,不会闪退。
如果你想并发拿 user 和 orders,用 async + await 即可。
六、协程最容易踩的 5 个坑
坑 1:GlobalScope 滥用
// 错误:脱离结构化并发,泄漏风险高
GlobalScope.launch { /* ... */ }
// 正确:用 lifecycleScope / viewModelScope
lifecycleScope.launch { /* ... */ }
坑 2:runBlocking 误用
runBlocking 会阻塞当前线程。主线程绝对不能用——会卡顿甚至 ANR。
坑 3:CoroutineExceptionHandler 抓不全
默认情况下,子协程的异常会传给父协程。协程里 try-catch 该写还得写,不要全指望 handler。
坑 4:把 suspend 关键字当成"协程转换器"
suspend 只是提示编译器这个函数可能会挂起。调用 suspend fun 不需要 launch,但需要在协程里或者另一个 suspend 函数里调用。
坑 5:忘记取消导致的内存泄漏
长期运行的协程一定要可取消:
val job = launch {
while (isActive) { // 关键!不要用 while (true)
delay(1000)
doWork()
}
}
七、协程 + Compose 的真实样板
在 Compose 里,协程通常这样用:
@Composable
fun UserList(viewModel: UserViewModel = viewModel()) {
val users by viewModel.users.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
viewModel.refresh()
}
LazyColumn {
items(users) { user ->
UserRow(user)
}
}
}
collectAsStateWithLifecycle() 是 androidx.lifecycle 提供的神器——它会在 Lifecycle 至少 STARTED 时才收集,自动暂停/恢复。这个比 collectAsState 更适合 Android——后者只在 Lifecycle destroyed 时停止,会浪费资源。
我们下一篇架构演进时,会更深入地讲 StateFlow、SharedFlow 的选型。
小结
协程的真正价值不是"轻量",而是:
结构化并发——自动取消、自动等待、自动传播异常。
挂起语义——不阻塞线程,并能精确控制恢复时机。
与生命周期绑定——
lifecycleScope/viewModelScope让你放心地启动协程,不用担心泄漏。
一旦你的协程心智模型切换过来,很多 Retrofit、Room、WorkManager 上的复杂并发逻辑会变得自然而然。
下篇预告
下一篇是《第 4 篇:App 架构演进——MVC → MVP → MVVM → MVI,一篇看懂》。
我们把今天的协程 + 上篇的 Compose 用起来,搭一套真实可用的 MVI 样板。这会是这个系列的"分水岭"——从会用 API 上到会设计架构。
参考阅读
Kotlin 官方文档:Coroutines
"Android 上的协程最佳实践"(Google I/O 演讲)
《Kotlin 编程实战》第 6 章
上一篇文章:第 2 篇《为什么 Android 工程师都在转 Compose?》