【Kotlin 协程修仙录 · 炼气境 · 中阶】 | 掌控生灭:Job 的取消、等待与状态追踪

0 阅读8分钟

image_11.png

引言

上一讲你成功引气入体,写出了第一个挂起函数。那感觉就像第一次让飞剑悬浮在掌心——它听你指挥了,但你还不知道怎么让它停下。

想象一个场景:用户在你的应用里点击了“下载文件”,你启动了一个协程去后台拉取数据。但用户突然改变主意,按下了返回键。这时如果你不能立刻取消那个还在运行的协程,它就会像一支脱手的飞剑,带着内存和网络资源一头扎进虚空,最终撞出 IllegalStateException 或者更糟——内存泄漏

传统 Android 开发中,“取消”是一个痛苦的话题。你用 Thread 时得小心翼翼地检查 isInterrupted 标志;你用 Handler 时得手动 removeCallbacksAndMessages;你甚至可能自己维护一个 volatile boolean cancelled,然后在循环里不断检查。稍有不慎,任务就成了“野线程”,直到应用进程被杀才能消停。

协程对这个问题给出了一个优雅到令人发指的答案:Job

本讲是炼气境的中阶修炼。你将掌握三式核心剑法:

  • cancel():主动中止协程。
  • join():等待协程自然结束。
  • isActive:感知协程的生死状态。

学完这一讲,你不仅能启动协程,还能精准地回来。收发自如,方为入门。

千曲而后晓声,观千剑而后识器。虐它千百遍方能通晓其真意


什么是 Job

当你调用 launch 启动一个协程时,它不会默默地消失在后台。它会返回一个句柄(Handle——就像你放飞一只信鸽时,手里还握着一根能感知它状态、甚至能唤它回来的丝线。

Job 是协程的生命周期句柄。它代表一个可取消的异步任务单元,提供了对协程执行状态的查询(isActiveisCompletedisCancelled),以及控制协程生命周期的方法(canceljoin)。

如果你熟悉 Java 的 FutureThread,可以这样对比:

  • Thread 给你一个 start()interrupt(),但 interrupt() 只是设置标志位,线程是否响应完全取决于你的代码是否检查。
  • Job 给你的是一套协作式取消协议——你发出取消信号,协程内部会在挂起点自动响应,无需你手动检查标志位(除非你在做特殊计算)。

这个机制之所以如此顺滑,根源在于协程的挂起函数都是可取消的delaywithContextchannel.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

核心三剑式:canceljoinisActive

cancel() —— 发出取消指令

job.cancel() 并不会像 Thread.stop() 那样暴力地立刻终止一切(Thread.stop() 已废弃,因为它会释放所有锁导致对象状态不一致)。协程的取消是协作式的。

当你调用 job.cancel() 时,发生了两件事:

  1. Job 的状态变为 Cancelling(取消中)。
  2. 协程内部下一次调用挂起函数(如 delayyield)时,该挂起函数会检测到取消状态,并抛出 CancellationException
  3. 协程体收到异常后,执行 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 进入终态CompletedCancelled)。这在你需要确保某个后台任务彻底完成后再进行下一步时非常有用。

一个经典组合: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 场景。假设你正在开发一个文件下载功能,用户点击“开始下载”后,按钮变为“取消下载”。当用户点击取消时,下载协程必须立即停止并释放资源。

deepseek_mermaid_20260423_cb7897.png

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}")
}

CancellationExceptionException 的子类,但它是一个特殊的异常——它用于协程的正常控制流,不应被视为“错误”。如果你在 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("你永远不会看到这行字")
}

一旦 CoroutineScopeJob 被取消,它内部再也无法启动新的协程。如果你需要能够“重启”的能力,应该使用 SupervisorJob 或者创建新的 Scope(这将在筑基境深入讲解)。


最佳实践

  1. 总是使用 viewModelScopelifecycleScope: 它们会在组件销毁时自动取消所有子协程,这是结构化并发的第一道防线。

  2. 在纯计算循环中显式检查 isActive: 如果你写的协程体包含 while(true) 且没有挂起点,必须加上 if (!isActive) break 或使用 yield()

  3. 使用 cancelAndJoin() 确保清理完成: 当你需要取消一个协程并等待其资源完全释放时,cancelAndJoin() 是最佳选择。

  4. 永远重新抛出 CancellationException: 除非你明确知道自己在做什么(比如要在 catch 中执行非挂起清理后结束协程),否则务必 throw e

  5. finally 释放资源,必要时配合 NonCancellable: 文件句柄、数据库游标、网络连接等资源,都应该在 finally 块中释放。


这里藏着一个巨大的悬念

在本讲的示例中,你可能注意到了一个现象:

当我们在 viewModelScope 中启动 downloadJob,然后调用 job.cancel() 时,协程内部通过 launch 启动的任何子协程也会自动被取消。

这并不是因为我们在代码里显式取消了它们,而是因为 viewModelScope 内部维护了一棵 Job 树,父 Job 的取消会像多米诺骨牌一样向下传播

deepseek_mermaid_20260423_d27ffb.png

点击取消时,你只需要取消 downloadJob(或者直接取消整个 viewModelScope),所有的孙协程都会自动、安全、无遗漏地被取消。这种设计彻底消灭了传统多线程编程中“忘了关某个线程”的噩梦。

这个机制的正式名称叫作 结构化并发(Structured Concurrency)

它正是协程区别于传统线程模型的最核心设计原则。在下一讲 【炼气境·后阶】 中,我们将深入 CoroutineScope 的创建与绑定,届时你会看到这个原则如何在 Android 生命周期中发挥威力。而在 【筑基境·初阶】,我们将彻底解剖这棵 Job 树的内部运作,让你看透父子取消传播的每一行原理。


【当前境界修为面板】

  • 当前境界[炼气境 · 中阶]
  • 下一突破[炼气境 · 后阶] (需领悟:CoroutineScope 的自定义创建、与 Android 生命周期的绑定艺术)
  • 修炼进度████░░░░░░░░░░░░░░░░ 22%
  • 本讲获得法器Job 控制三式canceljoinisActive)、可取消的下载协程模板

【本讲思考题】

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 协程代码的基石。我们炼气境·后阶见。

欢迎一键四连关注 + 点赞 + 收藏 + 评论