Administrator
发布于 2026-08-25 / 48 阅读
0
0

系列文章《Android 工程师的 100 篇进阶笔记》第 3 篇:Kotlin 协程:从 Thread 切到挂起函数的正确姿势

最近我团队面试了 14 个 Android 工程师,只有 3 个人能讲清楚"协程的挂起到底在挂什么"。

这个数字让我有点意外。协程已经是 Android 官方推荐的并发方案,Compose、Room、Retrofit、WorkManager 的官方文档全部基于协程。即便如此,它依然被大量工程师当作"Thread 的语法糖"在用。

这篇文章不重复那些"协程是轻量级线程"的陈词滥调。我想给你一个正确的心智模型,让你从此以后用协程时不慌。


一、先讲清楚:协程和 Thread 是完全不同维度的东西

Thread 是操作系统调度的执行流。一个 Thread 占 1MB 左右的栈内存,调度由 OS 负责。

协程是用户态的可挂起计算。一个协程占几百字节内存,调度由 Kotlin 运行时负责。

关键差异:

维度

Thread

Coroutine

调度者

操作系统

Kotlin Runtime

切换成本

高(内核态切换)

低(用户态切换)

取消

几乎不可能干净地取消

cancel() 一行解决

同步结果

Future.get() 阻塞

await() 挂起,不阻塞

异常传播

容易吞掉

结构化并发,自动传播

协程最大的优势不是"轻量"——是"结构化"。


二、协程的真实心智模型:一个带返回值的"被中断函数"

错误的模型

"协程是轻量 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() 内部需要等待网络时:

  1. 当前协程被 Kotlin 运行时挂起——保存栈帧和局部变量。

  2. 当前线程立刻被释放,可以去做别的事。

  3. 等网络回来,Kotlin 把协程恢复到之前的位置继续执行。

注意第 2 点——线程没被阻塞。这才是协程真正的力量。在 Android 里,这意味着主线程可以处理 UI 事件,同时协程在 IO 线程池跑网络请求。


三、Dispatchers 不要乱选

Kotlin 协程有 4 个常用的调度器:

Dispatcher

线程池

适用场景

Dispatchers.Main

主线程

UI 更新、轻量操作

Dispatchers.IO

IO 线程池

网络请求、文件 IO、数据库

Dispatchers.Default

CPU 线程池(CPU 核数)

CPU 密集型工作、JSON 解析、Bitmap 处理

Dispatchers.Unconfined

当前线程

测试场景居多,生产慎用

黄金法则

如果你不确定,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)
    }
}

不仅代码简短,更重要的是:

  1. 如果用户中途退出页面,整个协程树会被自动取消——不会回调到已销毁的 UI。

  2. 如果 IO 抛异常,会被协程捕获,不会闪退。

  3. 如果你想并发拿 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 的选型。


小结

协程的真正价值不是"轻量",而是:

  1. 结构化并发——自动取消、自动等待、自动传播异常。

  2. 挂起语义——不阻塞线程,并能精确控制恢复时机。

  3. 与生命周期绑定——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?》


评论