引言
上一讲你成功引气入体,写出了第一个挂起函数。那感觉就像第一次让飞剑悬浮在掌心——它听你指挥了,但你还不知道怎么让它停下。
想象一个场景:用户在你的应用里点击了“下载文件”,你启动了一个协程去后台拉取数据。但用户突然改变主意,按下了返回键。这时如果你不能立刻取消那个还在运行的协程,它就会像一支脱手的飞剑,带着内存和网络资源一头扎进虚空,最终撞出 IllegalStateException 或者更糟——内存泄漏。
传统 Android 开发中,“取消”是一个痛苦的话题。你用 Thread 时得小心翼翼地检查 isInterrupted 标志;你用 Handler 时得手动 removeCallbacksAndMessages;你甚至可能自己维护一个 volatile boolean cancelled,然后在循环里不断检查。稍有不慎,任务就成了“野线程”,直到应用进程被杀才能消停。
协程对这个问题给出了一个优雅到令人发指的答案:Job。
本讲是炼气境的中阶修炼。你将掌握三式核心剑法:
cancel():主动中止协程。join():等待协程自然结束。isActive:感知协程的生死状态。
学完这一讲,你不仅能启动协程,还能精准地收回来。收发自如,方为入门。
操千曲而后晓声,观千剑而后识器。虐它千百遍方能通晓其真意。
什么是 Job?
当你调用 launch 启动一个协程时,它不会默默地消失在后台。它会返回一个句柄(Handle)——就像你放飞一只信鸽时,手里还握着一根能感知它状态、甚至能唤它回来的丝线。
Job 是协程的生命周期句柄。它代表一个可取消的异步任务单元,提供了对协程执行状态的查询(
isActive、isCompleted、isCancelled),以及控制协程生命周期的方法(cancel、join)。
如果你熟悉 Java 的 Future 或 Thread,可以这样对比:
Thread给你一个start()和interrupt(),但interrupt()只是设置标志位,线程是否响应完全取决于你的代码是否检查。Job给你的是一套协作式取消协议——你发出取消信号,协程内部会在挂起点自动响应,无需你手动检查标志位(除非你在做特殊计算)。
这个机制之所以如此顺滑,根源在于协程的挂起函数都是可取消的。delay、withContext、channel.receive() 等等,它们在内部都会响应取消信号,一旦检测到取消,立即抛出 CancellationException 干净利落地结束协程。
直观感受:一个会自我了断的协程
在讲概念之前,我们先跑一段能亲眼看到“取消”效果的代码。在你的 Kotlin 测试环境(或者 Android ViewModel 中)运行以下片段:
import kotlinx.coroutines.*
fun main() = runBlocking {
println("🧘 主协程:启动一个子协程,它将运行 5 秒...")
val job = launch {
repeat(1000) { i ->
println("子协程:心跳 $i")
delay(500) // 每 500ms 打印一次
}
}
delay(2000) // 主协程等待 2 秒
println("🧘 主协程:等够了,取消它!")
job.cancel() // 发出取消信号
job.join() // 等待子协程彻底结束
println("🧘 主协程:子协程已终结,收工。")
}
运行结果大致如下:
🧘 主协程:启动一个子协程,它将运行 5 秒...
子协程:心跳 0
子协程:心跳 1
子协程:心跳 2
子协程:心跳 3
🧘 主协程:等够了,取消它!
子协程:心跳 4
🧘 主协程:子协程已终结,收工。
注意:心跳 4 可能还会出现一次,这是因为 delay(500) 正在挂起中时收到取消信号,它会立即抛出异常结束协程,但可能在此之前已经执行了当次循环的打印。无论如何,协程在 cancel() 后不会无休止地运行下去。
sequenceDiagram
participant Main as 🧘 主协程 (runBlocking)
participant Child as 🚀 子协程 (launch)
participant Timer as ⏳ 挂起函数 delay
rect rgb(232, 245, 233)
Main->>Child: launch 启动
loop 每 500ms
Child->>Child: 打印心跳
Child->>Timer: delay(500)
Timer-->>Child: 恢复
end
end
rect rgb(255, 243, 224)
Main->>Main: delay(2000)
Main->>Child: job.cancel()
Child->>Timer: 当前挂起中的 delay 收到取消信号
Timer-->>Child: 抛出 CancellationException
Child->>Child: 协程体结束,资源清理
Child-->>Main: 协程完成(状态变为 Cancelled)
Main->>Main: job.join() 返回
end
核心三剑式:cancel、join、isActive
cancel() —— 发出取消指令
job.cancel() 并不会像 Thread.stop() 那样暴力地立刻终止一切(Thread.stop() 已废弃,因为它会释放所有锁导致对象状态不一致)。协程的取消是协作式的。
当你调用 job.cancel() 时,发生了两件事:
- Job 的状态变为 Cancelling(取消中)。
- 协程内部下一次调用挂起函数(如
delay、yield)时,该挂起函数会检测到取消状态,并抛出CancellationException。 - 协程体收到异常后,执行
finally块(如果有),然后将状态改为 Cancelled(已取消)。
stateDiagram-v2
[*] --> New : launch / async
New --> Active : 开始执行
Active --> Completing : 正常执行完毕
Completing --> Completed : 子协程全部完成
Active --> Cancelling : cancel() 被调用
Cancelling --> Cancelled : 挂起点抛出 CancellationException<br>且 finally 执行完毕
Completed --> [*]
Cancelled --> [*]
join() —— 等待协程终结
job.join() 是一个挂起函数。它会暂停当前协程,直到目标 Job 进入终态(Completed 或 Cancelled)。这在你需要确保某个后台任务彻底完成后再进行下一步时非常有用。
一个经典组合:job.cancelAndJoin()。它是 cancel() + join() 的语法糖,确保取消操作完成并等待协程体真正退出。
suspend fun cancelAndAwaitTermination(job: Job) {
job.cancelAndJoin()
println("Job 状态:isCancelled = ${job.isCancelled}")
}
isActive —— 协程的“生命体征监测仪”
在协程内部,你可以通过 coroutineContext[Job]?.isActive 或直接使用顶层扩展属性 isActive 来检查当前协程是否还“活着”。
fun main() = runBlocking {
val job = launch {
var count = 0
while (isActive) { // 只要协程未被取消,就继续
println("计算中:${count++}")
// 注意:这里没有挂起函数,是纯 CPU 计算
}
println("检测到取消,退出循环")
}
delay(1) // 让子协程跑一会儿
job.cancelAndJoin()
}
关键点:如果你在协程内部执行的是纯计算代码(没有挂起点),协程是无法感知到取消信号的。这时你必须手动检查 isActive 或调用 yield() 来主动让出执行权并检测取消。
下面这个例子永远不会被取消:
// ❌ 危险:纯计算循环,没有挂起点,永远不会响应取消
val job = launch {
var count = 0
while (true) { // 没有检查 isActive,也没有挂起函数
count++
}
}
delay(100)
job.cancel() // 无效!协程会一直运行直到进程被杀
Android 实战:可取消的文件下载模拟
理论讲完,回到我们最熟悉的 Android 场景。假设你正在开发一个文件下载功能,用户点击“开始下载”后,按钮变为“取消下载”。当用户点击取消时,下载协程必须立即停止并释放资源。
ViewModel 实现:
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kotlinx.coroutines.*
class DownloadViewModel : ViewModel() {
sealed interface UiState {
object Idle : UiState
object Downloading : UiState
data class Success(val message: String) : UiState
data class Cancelled(val message: String) : UiState
}
var uiState by mutableStateOf<UiState>(UiState.Idle)
private set
private var downloadJob: Job? = null
fun startDownload() {
// 防止重复点击
if (uiState is UiState.Downloading) return
downloadJob = viewModelScope.launch {
uiState = UiState.Downloading
try {
for (progress in 0..100 step 10) {
// 每次循环都检查协程是否还活着(可选,delay 已经能响应取消)
ensureActive()
// 模拟网络下载的耗时操作
delay(500)
println("下载进度:$progress%")
}
uiState = UiState.Success("下载完成!")
} catch (e: CancellationException) {
// 捕获取消异常,执行清理工作
uiState = UiState.Cancelled("下载已取消")
// 这里可以添加清理临时文件的逻辑
println("下载被取消,清理临时资源")
// 注意:不要吞掉 CancellationException,重新抛出是推荐做法
throw e
} finally {
// 无论成功还是取消,都会执行的清理逻辑
downloadJob = null
}
}
}
fun cancelDownload() {
downloadJob?.cancel()
// 注意:不需要调用 join,因为 cancel 后协程会在 finally 中更新 UI 状态
}
}
Compose UI 实现:
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
fun DownloadScreen(viewModel: DownloadViewModel = viewModel()) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
when (val state = viewModel.uiState) {
is DownloadViewModel.UiState.Idle -> {
Text("等待下载", style = MaterialTheme.typography.headlineSmall)
Button(onClick = { viewModel.startDownload() }) {
Text("开始下载")
}
}
is DownloadViewModel.UiState.Downloading -> {
Text("下载中...", style = MaterialTheme.typography.headlineSmall)
LinearProgressIndicator(modifier = Modifier.padding(16.dp))
Button(onClick = { viewModel.cancelDownload() }) {
Text("取消下载")
}
}
is DownloadViewModel.UiState.Success -> {
Text(state.message, style = MaterialTheme.typography.headlineSmall)
Button(onClick = { viewModel.startDownload() }) {
Text("重新下载")
}
}
is DownloadViewModel.UiState.Cancelled -> {
Text(state.message, style = MaterialTheme.typography.headlineSmall)
Button(onClick = { viewModel.startDownload() }) {
Text("重新下载")
}
}
}
}
}
当用户在下载过程中点击“取消下载”时,downloadJob?.cancel() 被调用。此时协程内部正在执行的 delay(500) 会立即抛出 CancellationException,控制流跳转到 catch 块更新 UI 为“已取消”,然后进入 finally 清理资源。整个过程行云流水,没有任何资源泄漏。
常见错误与避坑指南
错误 1:忘记处理 CancellationException 导致资源泄漏
// ❌ 错误:如果网络请求在挂起时被取消,临时文件不会被删除
suspend fun downloadFile() {
val tempFile = createTempFile()
try {
val data = networkRequest() // 挂起函数,可能被取消
saveToFile(data, tempFile)
} finally {
tempFile.delete() // ✅ 但 finally 总会执行吗?
}
}
上面这段代码其实是正确的!因为 finally 块一定会执行,即使在协程被取消时也是如此。但有一个陷阱:如果你在 finally 中调用了挂起函数,则必须使用 withContext(NonCancellable) 包裹,因为此时协程已经在取消中,无法再挂起。
// ❌ 错误:在 finally 中调用挂起函数会抛出 CancellationException
suspend fun downloadFile() {
val tempFile = createTempFile()
try {
// ...
} finally {
delay(100) // 崩!协程已取消,不能挂起
tempFile.delete()
}
}
// ✅ 正确:使用 NonCancellable 强制执行清理挂起操作
suspend fun downloadFile() {
val tempFile = createTempFile()
try {
// ...
} finally {
withContext(NonCancellable) {
delay(100) // 安全执行
tempFile.delete()
}
}
}
错误 2:吞掉 CancellationException
// ❌ 危险:吞掉了取消异常,协程无法正确结束
try {
delay(1000)
} catch (e: Exception) {
// 把 CancellationException 也捕获了
println("出错了:${e.message}")
}
CancellationException 是 Exception 的子类,但它是一个特殊的异常——它用于协程的正常控制流,不应被视为“错误”。如果你在 catch (e: Exception) 中捕获了它却不重新抛出,协程的取消传播会被打断。正确的做法是:
// ✅ 正确:单独处理 CancellationException 或只捕获特定异常
try {
delay(1000)
} catch (e: CancellationException) {
// 执行必要的清理,然后重新抛出
throw e
} catch (e: IOException) {
// 处理真正的 IO 错误
}
错误 3:在已取消的 CoroutineScope 中启动新协程
val scope = CoroutineScope(Job())
scope.cancel() // 整个 scope 被取消
// ❌ 这个 launch 不会执行任何代码,直接跳过
scope.launch {
println("你永远不会看到这行字")
}
一旦 CoroutineScope 的 Job 被取消,它内部再也无法启动新的协程。如果你需要能够“重启”的能力,应该使用 SupervisorJob 或者创建新的 Scope(这将在筑基境深入讲解)。
最佳实践
-
总是使用
viewModelScope或lifecycleScope: 它们会在组件销毁时自动取消所有子协程,这是结构化并发的第一道防线。 -
在纯计算循环中显式检查
isActive: 如果你写的协程体包含while(true)且没有挂起点,必须加上if (!isActive) break或使用yield()。 -
使用
cancelAndJoin()确保清理完成: 当你需要取消一个协程并等待其资源完全释放时,cancelAndJoin()是最佳选择。 -
永远重新抛出
CancellationException: 除非你明确知道自己在做什么(比如要在catch中执行非挂起清理后结束协程),否则务必throw e。 -
用
finally释放资源,必要时配合NonCancellable: 文件句柄、数据库游标、网络连接等资源,都应该在finally块中释放。
这里藏着一个巨大的悬念
在本讲的示例中,你可能注意到了一个现象:
当我们在
viewModelScope中启动downloadJob,然后调用job.cancel()时,协程内部通过launch启动的任何子协程也会自动被取消。
这并不是因为我们在代码里显式取消了它们,而是因为 viewModelScope 内部维护了一棵 Job 树,父 Job 的取消会像多米诺骨牌一样向下传播。
点击取消时,你只需要取消 downloadJob(或者直接取消整个 viewModelScope),所有的孙协程都会自动、安全、无遗漏地被取消。这种设计彻底消灭了传统多线程编程中“忘了关某个线程”的噩梦。
这个机制的正式名称叫作 结构化并发(Structured Concurrency)。
它正是协程区别于传统线程模型的最核心设计原则。在下一讲 【炼气境·后阶】 中,我们将深入 CoroutineScope 的创建与绑定,届时你会看到这个原则如何在 Android 生命周期中发挥威力。而在 【筑基境·初阶】,我们将彻底解剖这棵 Job 树的内部运作,让你看透父子取消传播的每一行原理。
【当前境界修为面板】
- 当前境界:
[炼气境 · 中阶] - 下一突破:
[炼气境 · 后阶](需领悟:CoroutineScope的自定义创建、与Android生命周期的绑定艺术) - 修炼进度:
████░░░░░░░░░░░░░░░░ 22% - 本讲获得法器:
Job 控制三式(cancel、join、isActive)、可取消的下载协程模板
【本讲思考题】
1、表象题:以下代码中,println("清理完成") 会被执行吗?为什么?
val job = launch {
try {
delay(1000)
} finally {
println("清理完成")
}
}
delay(100)
job.cancel()
2、场景题:假设你在协程中执行一个非常长的 JSON 解析任务(纯 CPU 计算,没有挂起点),你希望用户点击取消后能够中止解析。你会如何修改代码?写出关键片段。
3、原理题:CancellationException 为什么被设计为 Exception 的子类而不是 RuntimeException 或其他?这种设计对协程的异常传播机制有什么深层影响?(提示:思考 try-catch 和协程作用域的异常聚合)
道友,你已经能够自如地掌控协程的生灭了。下一讲,我们将走出单体协程,学习如何为协程划定疆域——CoroutineScope。那是写出健壮、可维护 Android 协程代码的基石。我们炼气境·后阶见。
欢迎一键四连(
关注+点赞+收藏+评论)