一个厨师同时做五道菜的秘密 — 不需要更多的人,只需要更聪明的等待
物理直觉,并发就像同时做菜
你见过一个厨师同时做五道菜吗?他不会傻等着水烧开——他会在等水烧开的时候去切菜,在等菜炖烂的时候去调酱汁。他只有一双手(一个线程),但他同时在"处理"五道菜。这就是协程的本质:不是增加更多的厨师,而是让一个厨师更聪明地利用等待时间。
如果你理解了"等待不等于空闲"这个道理,你就理解了协程的全部哲学。
想象你在做一个物理实验。你点燃了酒精灯,需要等水加热到 100 度。在这段时间里,你会怎么做?
一个笨办法:死盯着温度计——你站在那里一动不动,什么都不做,直到水烧开。这就是阻塞式编程(blocking)。在 Android 中,如果你在主线程(UI 线程)上做网络请求或数据库查询,就相当于死盯着温度计——界面卡住了,用户什么都点不了,系统甚至会弹出"应用无响应"(ANR)。
一个聪明的办法:你对助手说"水开了告诉我",然后你去做别的实验。但如果你有十个实验都在等待,每个都需要一个助手来喊你——这就是回调(Callback)模式。助手多了,沟通就乱了,这就是经典的回调地狱(Callback Hell):
// 回调地狱示例(伪代码)
fetchUser(userId) { user ->
fetchOrders(user.id) { orders ->
fetchOrderDetail(orders[0].id) { detail ->
fetchProduct(detail.productId) { product ->
// 终于拿到数据了...但代码已经嵌套了4层
updateUI(product)
}
}
}
}
最聪明的办法呢?你自己就是那个聪明的厨师。你不需要助手帮你盯着,你只需要设好计时器,到点了自己回来看一眼。在等待期间,你可以自由地做别的事——这就是协程的工作方式。
你可能会想:"多开几个线程不就行了?"这就像是说"多请几个厨师来帮忙"。听起来不错,但问题是:
Kotlin 协程(Coroutines)是一种全新的并发方案。协程是轻量级的线程——更准确地说,它根本不是线程,它只是一段可以暂停和恢复的代码。一个线程可以运行成千上万个协程,就像一个厨师可以同时管理几十道菜。
协程最妙的地方在于:你用同步的写法做异步的事。代码看起来是一行一行顺序执行的,但底层其实在你"等待"的时候偷偷去做了别的事。
C++ 对比:Kotlin 协程 vs C++20 Coroutines
C++20 也引入了协程,但它是非常底层的机制——你需要手动定义 promise_type、处理 co_await/co_yield/co_return、自己管理 std::future 和 std::promise。这就像给你一堆零件让你自己组装发动机。Kotlin 协程则是更高层的抽象,编译器帮你处理了所有底层细节——你只需要写一个 suspend 关键字,剩下的自动搞定。就好比 C++ 给你一堆齿轮和螺丝,而 Kotlin 直接给你一辆车。
先看回调风格——就像每个实验都喊一个助手来帮你盯着:
// 回调风格 — 嵌套深,错误处理散落各处
fetchUser(userId, object : Callback<User> {
override fun onSuccess(user: User) {
fetchOrders(user.id, object : Callback<List<Order>> {
override fun onSuccess(orders: List<Order>) { /* ... */ }
override fun onError(e: Exception) { /* 又一处错误处理 */ }
})
}
override fun onError(e: Exception) { /* 错误处理 */ }
})
再看 RxJava 风格——像一条流水线,但你得学会所有工位的操作手册:
// RxJava 风格 — 链式调用,但学习曲线陡峭
fetchUser(userId)
.flatMap { user -> fetchOrders(user.id) }
.flatMap { orders -> fetchOrderDetail(orders[0].id) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ detail -> updateUI(detail) },
{ error -> handleError(error) }
)
最后看协程风格——就像一个厨师的工作笔记,一步步写得清清楚楚:
// 协程风格 — 像写同步代码一样简洁
suspend fun loadData(userId: String) {
try {
val user = fetchUser(userId) // 挂起,等待结果
val orders = fetchOrders(user.id) // 挂起,等待结果
val detail = fetchOrderDetail(orders[0].id)
updateUI(detail)
} catch (e: Exception) {
handleError(e) // 统一错误处理
}
}
费曼的直觉
协程代码看起来和同步代码几乎一样——就像厨师的菜谱上写着"第一步煮面、第二步炒菜",但实际上厨师在煮面的空档已经在切菜了。编译器会帮你把 suspend 函数转换为状态机,自动处理"放下这道菜、去做那道菜"的切换逻辑。
| 特性 | 回调 | RxJava | 协程 |
|---|---|---|---|
| 代码可读性 | 差(嵌套深) | 中等(链式) | 优秀(顺序) |
| 错误处理 | 分散 | 统一(onError) | 统一(try-catch) |
| 学习曲线 | 低 | 高 | 中 |
| 取消支持 | 需手动实现 | dispose() | 内建支持 |
| Kotlin 集成 | 一般 | 一般 | 原生支持 |
在做实验之前,我们先把实验器材准备好。在 build.gradle.kts(Module 级别)中添加协程依赖:
// build.gradle.kts (Module :app)
dependencies {
// 协程核心库
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
// Android 协程支持(提供 Dispatchers.Main)
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
}
我们的第一个实验用 runBlocking——这是一个特殊的协程构建器,它会阻塞当前线程直到协程执行完毕。把它想象成"我要在这里做完这个实验才走"。它主要用于 main 函数和测试代码中,千万不要在 Android 的主线程使用,否则就回到"死盯温度计"了。
import kotlinx.coroutines.*
fun main() = runBlocking {
println("协程开始 — 线程: ${Thread.currentThread().name}")
delay(1000) // 挂起 1 秒(不阻塞线程)
println("协程结束")
}
// 输出:
// 协程开始 — 线程: main
// (等待1秒)
// 协程结束
launch 是最常用的协程构建器。它启动一个新的协程但不返回结果——就像你点燃了一个酒精灯然后转身去做别的事。它返回一个 Job 对象,你可以用它来管理协程(比如取消它,或者等它完成)。
看看下面的例子,注意输出顺序——这是理解协程的关键时刻:
fun main() = runBlocking {
println("主协程开始")
launch {
delay(1000)
println("子协程 1 完成")
}
launch {
delay(500)
println("子协程 2 完成")
}
println("主协程继续执行(不会等待子协程)")
}
// 输出:
// 主协程开始
// 主协程继续执行(不会等待子协程)
// 子协程 2 完成 ← 500ms 后
// 子协程 1 完成 ← 1000ms 后
看到了吗?"主协程继续执行"先打印出来了!这就像厨师点燃两个炉灶后,立刻去做其他事了,不会傻等其中任何一个。子协程 2 先完成,因为它只需要等 500 毫秒——哪道菜先好就先出哪道。
C++ 对比:Thread vs Coroutine
C++ 的 std::thread 是真正的操作系统线程——每创建一个就像新雇一个厨师,占用真实的系统资源(约 1MB 栈内存)。Kotlin 协程则是用户态的轻量级任务,一个线程可以运行上万个协程。打个比方:C++ 的做法是一道菜请一个厨师(开一个线程),而 Kotlin 的做法是一个厨师管所有菜(一个线程跑多个协程)。后者的内存开销几乎可以忽略不计。
这个区别至关重要。delay() 是"暂停当前实验,去做别的";Thread.sleep() 是"锁上实验室的门,谁都别想进来"。
| 对比 | delay() | Thread.sleep() |
|---|---|---|
| 是否阻塞线程 | 不阻塞(挂起协程) | 阻塞整个线程 |
| 其他协程能否运行 | 可以 | 不可以(线程被占用) |
| 使用场景 | 协程中 | 普通线程代码 |
| 可取消 | 是 | 否 |
注意
永远不要在协程中使用 Thread.sleep()!这就像厨师在等水烧开的时候把厨房门锁了——其他菜全部停摆。在协程中应该使用 delay(),这样线程可以在你"等待"的时候去服务其他协程。
挂起函数(suspend function)是协程的核心概念。用物理学家的话说:suspend 意味着"这个实验需要等待一段时间,我先去看看隔壁那个实验进展如何"。
被 suspend 关键字修饰的函数可以暂停执行并在稍后恢复执行,且不会阻塞线程。你可以把 suspend 理解为一个标记,告诉编译器:"这个函数里有需要等待的操作——网络请求、数据库查询、文件读取——别让厨师干等着。"
// 用 suspend 关键字声明挂起函数
suspend fun fetchUserFromNetwork(userId: String): User {
delay(2000) // 模拟网络延迟
return User(userId, "张三")
}
// 挂起函数只能在协程或另一个挂起函数中调用
suspend fun loadUserData() {
val user = fetchUserFromNetwork("001") // OK: 在 suspend 函数中调用
println("用户: ${user.name}")
}
fun normalFunction() {
// val user = fetchUserFromNetwork("001") // 编译错误!普通函数不能调用 suspend 函数
}
这是 Kotlin 编译器的强制规则——就像实验室的安全规范一样不可违背。为什么?因为挂起操作需要一个"协程上下文"来管理暂停和恢复。在普通函数中调用 suspend 函数,就像在没有实验台的地方做化学实验——编译器会果断阻止你。
费曼的类比
挂起函数就像一个需要等待的物理实验:你把样品放进离心机(开始网络请求),设好时间(等待响应),然后转身去写论文(线程执行其他协程)。离心机转完后,实验室系统会通知你"样品好了"(协程恢复执行)。你不需要站在离心机旁边干等——这就是非阻塞的含义。
import kotlinx.coroutines.*
// 数据类
data class User(val id: String, val name: String)
// 模拟网络请求的挂起函数
suspend fun fetchUser(id: String): User {
println(" 开始请求用户 $id ...")
delay(1500) // 模拟 1.5 秒网络延迟
println(" 用户 $id 请求完成")
return User(id, "用户$id")
}
fun main() = runBlocking {
println("开始加载数据...")
val user = fetchUser("42")
println("加载完成: ${user.name}")
}
// 输出:
// 开始加载数据...
// 开始请求用户 42 ...
// 用户 42 请求完成
// 加载完成: 用户42
协程构建器就是你"启动实验"的方式。不同的构建器适用于不同的场景——有的实验你只要它做完就行(launch),有的实验你需要它的结果(async)。
launch 启动一个新协程,不返回结果。它就像你点燃了一个酒精灯加热某个东西——你不需要从这个操作得到什么"返回值",你只需要它默默完成。它返回一个 Job 对象,让你可以管理这个"实验进程"。
fun main() = runBlocking {
val job: Job = launch {
println("协程正在执行...")
delay(1000)
println("协程执行完毕")
}
println("等待协程完成...")
job.join() // 等待协程执行完毕
println("全部完成")
}
async 和 launch 类似,但它会返回一个 Deferred<T> 对象——这就像一张"实验结果取件单"。你可以在需要结果的时候调用 await() 来取走它。如果结果还没准备好,await() 会挂起等待(但不阻塞线程)。
fun main() = runBlocking {
val deferred: Deferred<Int> = async {
println("正在计算...")
delay(1000)
42 // 返回值
}
println("等待结果...")
val result = deferred.await() // 挂起直到结果准备好
println("结果是: $result")
}
这是 async 最强大的用法——同时发起多个异步操作,等所有结果都准备好后再一起端上桌。这就是真正的"并发":不是一道菜做完再做下一道,而是三个锅同时开火。
suspend fun fetchUserProfile(): String {
delay(1000) // 模拟 1 秒
return "用户资料"
}
suspend fun fetchUserOrders(): String {
delay(1200) // 模拟 1.2 秒
return "订单列表"
}
fun main() = runBlocking {
val startTime = System.currentTimeMillis()
// 串行执行:总共需要 2.2 秒(一道菜做完再做下一道)
// val profile = fetchUserProfile()
// val orders = fetchUserOrders()
// 并发执行:只需要约 1.2 秒(两道菜同时做,取最慢的)
val profileDeferred = async { fetchUserProfile() }
val ordersDeferred = async { fetchUserOrders() }
val profile = profileDeferred.await()
val orders = ordersDeferred.await()
val elapsed = System.currentTimeMillis() - startTime
println("$profile + $orders, 耗时: ${elapsed}ms")
}
// 输出: 用户资料 + 订单列表, 耗时: ~1200ms
费曼的直觉
两个 async 同时启动,总耗时取决于最慢的那道菜,而非两者之和。这就像物理实验中的并行测量——同时测温度和压力,总时间是 max(测温时间, 测压时间),而不是两者相加。这就是并发的威力。
withContext 用来在协程中切换执行的线程。想象你在化学实验室做分析,但需要用到物理实验室的仪器——你端着样品走过去(切换线程),用完仪器后再走回来(切回原线程)。
suspend fun loadDataFromDisk(): String {
return withContext(Dispatchers.IO) {
// 这里运行在 IO 线程池
println("读取磁盘 — 线程: ${Thread.currentThread().name}")
delay(500)
"磁盘数据"
}
// 执行完自动切回原来的线程
}
fun main() = runBlocking {
println("主线程: ${Thread.currentThread().name}")
val data = loadDataFromDisk()
println("回到主线程: ${Thread.currentThread().name}, 数据: $data")
}
CoroutineScope 定义了协程的生命周期范围。在物理实验中,你不会让一个实验无人看管地一直运行——实验室关门了,所有实验都必须安全地停下来。CoroutineScope 就是这个"实验室管理制度":作用域结束时,其中所有未完成的协程都会被取消。
fun main() = runBlocking {
// runBlocking 自身就是一个 CoroutineScope
launch {
delay(200)
println("子协程 A")
}
coroutineScope { // 创建一个新的作用域
launch {
delay(500)
println("子协程 B(在 coroutineScope 内)")
}
println("coroutineScope 开始")
} // coroutineScope 会等待内部所有协程完成
println("所有都完成了")
}
Kotlin 协程的一大设计原则是结构化并发(Structured Concurrency)。这就像一个严格的实验室管理规范:
fun main() = runBlocking {
val parentJob = launch {
val childA = launch {
repeat(5) { i ->
println("子协程 A: $i")
delay(300)
}
}
val childB = launch {
repeat(5) { i ->
println("子协程 B: $i")
delay(300)
}
}
}
delay(800)
println("取消父协程")
parentJob.cancel() // 子协程 A 和 B 都会被取消
println("父协程已取消")
}
C++ 对比:结构化并发
C++ 没有结构化并发的概念。当你用 std::thread 创建线程后,你必须手动调用 join() 或 detach()——如果忘了,程序直接崩溃(std::terminate)。这就像一个没有管理制度的实验室,每个实验做完了你得自己记着去收拾,忘了就出事故。Kotlin 的 CoroutineScope 自动管理所有子协程的生命周期:作用域结束时自动取消、自动等待,不需要你手动 join 每一个。安全、省心。
不推荐使用 GlobalScope
GlobalScope 启动的协程是顶级协程,不受任何父作用域管理。这就像在实验室外面的走廊上做实验——没人管你,也没人在实验室关门时提醒你停下。后果是:
// 不推荐
GlobalScope.launch {
// 这个协程的生命周期与应用进程一样长
// Activity 销毁后它还在跑 — 内存泄漏!
fetchData()
}
// 推荐:使用与生命周期绑定的 scope
class MyViewModel : ViewModel() {
fun loadData() {
viewModelScope.launch {
// ViewModel 清除时,协程自动取消
fetchData()
}
}
}
Android Jetpack 提供了与生命周期绑定的协程作用域——就像不同等级的实验室门禁,到点了自动关门断电:
| 作用域 | 所属组件 | 自动取消时机 |
|---|---|---|
viewModelScope |
ViewModel | ViewModel 被清除时 |
lifecycleScope |
Activity / Fragment | Lifecycle 进入 DESTROYED 状态时 |
// 在 ViewModel 中使用
class UserViewModel : ViewModel() {
fun loadUser() {
viewModelScope.launch {
val user = repository.fetchUser()
_userState.value = user
}
}
}
// 在 Activity 中使用
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch {
val data = loadSomething()
textView.text = data
}
}
}
依赖
要使用 viewModelScope 和 lifecycleScope,需要添加对应的 Jetpack 依赖:
androidx.lifecycle:lifecycle-viewmodel-ktxandroidx.lifecycle:lifecycle-runtime-ktx调度器决定了协程在哪个线程或线程池上执行。用厨房的比喻来说,调度器就是"在哪个灶台上做菜"——炒菜要用猛火灶(CPU 密集),炖汤可以用小火慢炖灶(IO 操作),摆盘装饰要在出菜口完成(UI 线程)。选错灶台,不仅效率低下,还可能出安全问题。
| 调度器 | 用途 | 线程 |
|---|---|---|
Dispatchers.Main |
更新 UI、处理用户交互 | Android 主线程 |
Dispatchers.IO |
网络请求、文件读写、数据库操作 | 共享的 IO 线程池(最多 64 个线程) |
Dispatchers.Default |
CPU 密集型计算(排序、解析 JSON 等) | 共享线程池(线程数 = CPU 核心数) |
Dispatchers.Unconfined |
测试或特殊场景(不推荐日常使用) | 不限定线程 |
C++ 对比:Dispatchers vs 线程池
Kotlin 的调度器概念类似于 C++ 中的线程池,比如 boost::asio::thread_pool 或 C++17 的 std::execution 提案。但 Kotlin 把这些封装得更简洁:你不需要手动创建线程池、管理任务队列、处理线程的生死——只需要指定 Dispatchers.IO 或 Dispatchers.Default,框架帮你搞定一切。C++ 里面你得写一堆样板代码来设置线程池,而 Kotlin 里只是一个参数的事。
在 Android 中,所有 UI 更新必须在主线程执行——这就像菜做好了必须在出菜口装盘。Dispatchers.Main 将协程调度到主线程。
viewModelScope.launch(Dispatchers.Main) {
// 在主线程更新 UI
progressBar.visibility = View.VISIBLE
val data = withContext(Dispatchers.IO) {
// 切换到 IO 线程做网络请求
api.fetchData()
}
// 自动切回主线程
textView.text = data
progressBar.visibility = View.GONE
}
IO 操作(网络、文件、数据库)就像炖汤——大部分时间都在等待,不怎么占用厨师的精力。把这些任务交给 IO 调度器,主线程(主厨师)就不会被堵住。
suspend fun readFile(path: String): String {
return withContext(Dispatchers.IO) {
File(path).readText()
}
}
排序大数组、解析大型 JSON、图像处理——这些是需要厨师全力投入的"猛火爆炒"任务。Default 调度器的线程数等于 CPU 核心数,确保不会因为开太多线程反而拖慢系统。
suspend fun sortLargeList(list: List<Int>): List<Int> {
return withContext(Dispatchers.Default) {
list.sorted() // CPU 密集计算
}
}
一个典型的 Android 数据加载流程——在出菜口(Main)开始准备,去后厨(IO)取原料,回到出菜口(Main)装盘:
class UserRepository {
suspend fun getUser(id: String): User {
return withContext(Dispatchers.IO) {
// 在 IO 线程池执行网络请求
api.fetchUser(id)
}
}
}
class UserViewModel(private val repo: UserRepository) : ViewModel() {
fun loadUser(id: String) {
viewModelScope.launch { // 默认在 Main 线程
_loading.value = true
val user = repo.getUser(id) // 内部已切换到 IO
_user.value = user // 回到 Main 线程更新 UI
_loading.value = false
}
}
}
最佳实践
让 Repository 层的函数内部使用 withContext 切换调度器,这样调用方不需要关心线程切换——就像你点菜时不需要知道厨师用的是猛火灶还是慢炖灶,菜端上来就行。这被称为"main-safety"(主线程安全)。
下面是一个完整的可运行示例,综合运用了前面所有知识。把它想象成一个厨师先去市场买菜(获取用户列表),然后同时处理每道菜的配料(并发获取详情):
import kotlinx.coroutines.*
// ===== 数据模型 =====
data class User(
val id: Int,
val name: String,
val email: String
)
// ===== 模拟网络 API =====
object FakeApi {
suspend fun fetchUsers(): List<User> {
println("[API] 正在请求用户列表...")
delay(2000) // 模拟 2 秒网络延迟
return listOf(
User(1, "张三", "zhangsan@example.com"),
User(2, "李四", "lisi@example.com"),
User(3, "王五", "wangwu@example.com")
)
}
suspend fun fetchUserDetail(userId: Int): String {
println("[API] 正在请求用户 $userId 的详情...")
delay(1000) // 模拟 1 秒延迟
return "用户 $userId 的详细信息:VIP 会员,注册于 2024 年"
}
}
// ===== 主程序 =====
fun main() = runBlocking {
println("===== 开始加载数据 =====")
val startTime = System.currentTimeMillis()
// 第一步:获取用户列表
val users = FakeApi.fetchUsers()
println("[结果] 获取到 ${users.size} 个用户")
// 第二步:并发获取每个用户的详情
val details = users.map { user ->
async {
FakeApi.fetchUserDetail(user.id)
}
}.map { it.await() } // 等待所有结果
// 第三步:展示结果
users.forEachIndexed { index, user ->
println(" ${user.name} (${user.email})")
println(" ${details[index]}")
}
val elapsed = System.currentTimeMillis() - startTime
println("===== 加载完成,总耗时: ${elapsed}ms =====")
// 总耗时约 3 秒(2秒列表 + 1秒详情并发),而非 5 秒(串行)
}
// 输出:
// ===== 开始加载数据 =====
// [API] 正在请求用户列表...
// [结果] 获取到 3 个用户
// [API] 正在请求用户 1 的详情...
// [API] 正在请求用户 2 的详情...
// [API] 正在请求用户 3 的详情...
// 张三 (zhangsan@example.com)
// 用户 1 的详细信息:VIP 会员,注册于 2024 年
// 李四 (lisi@example.com)
// 用户 2 的详细信息:VIP 会员,注册于 2024 年
// 王五 (wangwu@example.com)
// 用户 3 的详细信息:VIP 会员,注册于 2024 年
// ===== 加载完成,总耗时: ~3000ms =====
注意到了吗?三个用户详情的请求是同时发出的(三道菜同时下锅),所以总共只花了约 1 秒而不是 3 秒。加上之前获取列表的 2 秒,总共约 3 秒——比串行的 5 秒快了将近一半。这就是一个聪明厨师的效率。
做实验难免有失败的时候。协程中的错误处理非常直观——直接使用 try-catch,和同步代码完全一样。不需要学新的错误处理机制,用你已经熟悉的方式就好。这就是"用同步的写法做异步的事"的另一个好处。
suspend fun riskyFetchUser(id: Int): User {
delay(500)
if (id < 0) {
throw IllegalArgumentException("用户 ID 不能为负数: $id")
}
return User(id, "用户$id", "user$id@example.com")
}
fun main() = runBlocking {
// 方式一:直接 try-catch
try {
val user = riskyFetchUser(-1)
println("用户: ${user.name}")
} catch (e: Exception) {
println("出错了: ${e.message}")
}
// 方式二:对 async 使用 try-catch
val deferred = async {
riskyFetchUser(-1)
}
try {
val user = deferred.await() // 异常在 await() 时抛出
println("用户: ${user.name}")
} catch (e: Exception) {
println("async 出错了: ${e.message}")
}
}
// 输出:
// 出错了: 用户 ID 不能为负数: -1
// async 出错了: 用户 ID 不能为负数: -1
费曼的直觉
对于 launch,异常会直接在协程内"爆炸"(实验当场出事);对于 async,异常会延迟到 await() 调用时才"爆炸"(你去取实验结果时才发现出了问题)。两种情况都可以用 try-catch 这个"安全护具"来应对。
协程支持协作式取消——这就像实验室的紧急停止按钮,但它不是暴力断电(那会损坏仪器),而是在每个安全检查点(挂起点)通知协程"该停了"。调用 cancel() 后,协程会在下一个挂起点(如 delay、yield、withContext)检查取消状态并优雅地停止。
fun main() = runBlocking {
val job = launch {
repeat(10) { i ->
println("正在处理: $i")
delay(300) // 这是一个挂起点,会检查取消状态
}
}
delay(1000)
println("等够了,取消协程!")
job.cancel() // 请求取消
job.join() // 等待取消完成
// 也可以用 job.cancelAndJoin() 合并上面两步
println("协程已取消")
}
// 输出:
// 正在处理: 0
// 正在处理: 1
// 正在处理: 2
// 正在处理: 3
// 等够了,取消协程!
// 协程已取消
注意:纯计算循环不会响应取消
如果协程中没有挂起点(比如纯 CPU 计算的循环),取消请求不会生效——就像你按了紧急停止按钮,但仪器上根本没装传感器。这时你需要手动检查取消状态:
val job = launch(Dispatchers.Default) {
var i = 0
while (isActive) { // 手动检查协程是否仍在活跃
i++
// 纯 CPU 计算...
}
println("计算了 $i 次后被取消")
}
让我们用厨房的比喻来回顾本章学到的一切:
viewModelScope 和 lifecycleScope 让实验室到点自动关门try-catch(安全护具),取消用 job.cancel()(紧急停止按钮)费曼的总结
协程的核心思想其实很简单:等待不等于空闲。一个聪明的物理学家不会在等待实验结果时发呆,一个聪明的厨师不会在等水烧开时干站着。协程让你的代码也学会了这种智慧。在后续章节中,我们将结合 Retrofit、Room 等库,在实际的 Android 项目中用协程处理网络请求和数据库操作——到时候你会看到,这个聪明的厨师在真正的厨房里有多高效。
编写一个挂起函数 brewCoffee(type: String): String,模拟冲泡咖啡的过程:打印"正在冲泡 {type} ...",延迟 1.5 秒后返回"{type} 已做好"。然后在 runBlocking 中依次调用它两次(分别传入"拿铁"和"美式"),打印返回的结果。
import kotlinx.coroutines.*
suspend fun brewCoffee(type: String): String {
println("正在冲泡 $type ...")
delay(1500)
return "$type 已做好"
}
fun main() = runBlocking {
val coffee1 = brewCoffee("拿铁")
println(coffee1)
val coffee2 = brewCoffee("美式")
println(coffee2)
}
// 输出:
// 正在冲泡 拿铁 ...
// 拿铁 已做好
// 正在冲泡 美式 ...
// 美式 已做好
// 总耗时约 3 秒(串行)
改进练习 1:用 async 同时冲泡"拿铁"和"美式"两杯咖啡,并在两杯都做好后打印结果。另外再用 launch 启动一个"清洗咖啡机"的后台任务(延迟 500ms 后打印"咖啡机已清洗")。观察输出顺序,思考为什么"清洗咖啡机"在两杯咖啡之前完成。
import kotlinx.coroutines.*
suspend fun brewCoffee(type: String): String {
println("正在冲泡 $type ...")
delay(1500)
return "$type 已做好"
}
fun main() = runBlocking {
// 后台任务:清洗咖啡机(不需要结果)
launch {
delay(500)
println("咖啡机已清洗")
}
// 并发冲泡两杯咖啡(需要结果)
val latte = async { brewCoffee("拿铁") }
val americano = async { brewCoffee("美式") }
println("${latte.await()} & ${americano.await()}")
}
// 输出:
// 正在冲泡 拿铁 ...
// 正在冲泡 美式 ...
// 咖啡机已清洗 ← 500ms 后
// 拿铁 已做好 & 美式 已做好 ← 1500ms 后
// 总耗时约 1.5 秒(并发),比串行的 3 秒快了一倍!
"清洗咖啡机"只需要 500ms 就完成了,而两杯咖啡各需要 1500ms。三个任务同时启动,谁先做完谁先输出——这就是并发的魅力。
为以下场景选择合适的调度器(Dispatchers.Main、Dispatchers.IO、Dispatchers.Default),并说明理由:
ImageView 上完整的代码流程大致如下:
viewModelScope.launch {
// 1. IO 线程下载图片
val rawImage = withContext(Dispatchers.IO) { downloadImage(url) }
// 2. Default 线程做图像处理
val blurred = withContext(Dispatchers.Default) { applyBlur(rawImage) }
// 3. Main 线程更新 UI
imageView.setImageBitmap(blurred)
// 4. IO 线程保存文件
withContext(Dispatchers.IO) { saveToFile(blurred, path) }
}
编写一个程序,模拟"厨师同时做三道菜,但客人突然退单了"的场景:
runBlocking 中用 launch 启动一个父协程launch 启动三道菜的制作(分别延迟 2秒、3秒、4秒),每道菜开始时打印"开始做 {菜名}",完成时打印"{菜名} 做好了"import kotlinx.coroutines.*
fun main() = runBlocking {
val chef = launch {
launch {
println("开始做 宫保鸡丁")
delay(2000)
println("宫保鸡丁 做好了")
}
launch {
println("开始做 红烧排骨")
delay(3000)
println("红烧排骨 做好了")
}
launch {
println("开始做 清蒸鲈鱼")
delay(4000)
println("清蒸鲈鱼 做好了")
}
}
delay(1500)
println("客人退单了!取消所有菜品")
chef.cancelAndJoin()
println("所有菜品已取消,清理厨房")
}
// 输出:
// 开始做 宫保鸡丁
// 开始做 红烧排骨
// 开始做 清蒸鲈鱼
// 客人退单了!取消所有菜品
// 所有菜品已取消,清理厨房
// (注意:没有任何"做好了"被打印——结构化并发确保了所有子协程都被成功取消)
这就是结构化并发的威力:取消父协程,所有子协程自动被取消。不需要一个一个去停,不需要手动追踪哪些任务还在运行。实验室喊"紧急撤离",所有实验自动安全关停。