Kotlin Coroutines 与 Flow 的高级应用与原理
Kotlin Coroutines 与 Flow 的高级应用与原理
引言:告别回调地狱,拥抱结构化并发
在 Android 开发中,异步编程是不可避免的。我们需要在不阻塞主线程(UI 线程)的情况下执行网络请求、数据库操作、复杂计算等耗时任务。传统的并发模型,如直接使用 Thread、AsyncTask 或基于回调(Callback)的设计,往往会导致代码结构复杂、难以维护(回调地狱 Callback Hell)、资源管理困难(内存泄漏、线程泄漏),以及复杂的取消和异常处理逻辑。
Kotlin Coroutines(协程) 应运而生,它提供了一种全新的、更轻量级、更易于理解和管理的并发编程范式。协程允许我们用近乎同步的方式编写异步代码,极大地简化了异步逻辑。而 Kotlin Flow 则是基于协程构建的响应式流(Reactive Streams)实现,用于处理异步的数据序列。
对于 Android 专家来说,仅仅会使用 launch 启动一个协程或用 collect 收集一个简单的 Flow 是远远不够的。必须深入理解协程的底层工作原理(挂起与恢复、状态机)、结构化并发的核心理念(作用域、Job 层级、监督机制)、调度器(Dispatcher)的选择与影响、Flow 的冷热流特性与高级操作符、背压(Backpressure)处理策略、StateFlow 与 SharedFlow 的内部机制与应用场景、精妙的异常处理和取消机制,以及协程与 Flow 的测试方法和常见陷阱。 掌握这些,才能在复杂场景下运用自如,编写出高效、健壮、可维护的并发代码。
本文将深入探索协程与 Flow 的高级应用与原理:
- 协程基础: 挂起函数(suspend)与续体传递风格(CPS)的内部机制
- 结构化并发: CoroutineScope、Job、SupervisorJob 的层级与生命周期管理
- 调度器(Dispatchers): 线程切换的奥秘(IO、Default、Main、Unconfined)
- Flow 深入: 冷流特性、强大的中间操作符、flowOn 与上下文
- 背压处理: buffer、conflate、collectLatest 等策略
- StateFlow & SharedFlow: 热流的应用,配置参数详解
- Channel: 协程间通信
- 高级异常处理: try-catch、CoroutineExceptionHandler、supervisorScope、Flow 的 catch 操作符
- 取消机制: 协作式取消与 NonCancellable
- 测试: runTest 与 TestDispatcher
- 对比与陷阱: 与 RxJava 对比,常见误区与最佳实践
一、协程基础:挂起的魔力 —— suspend 与 Continuations
1. 协程 vs. 线程
- 协程并非线程。它们是可以在特定点挂起(suspend) 执行,稍后在同一或不同线程恢复(resume) 执行的计算单元
- 协程非常轻量级,可以在少量线程上运行成千上万个协程。切换协程上下文通常比切换线程上下文开销小得多
2. suspend 关键字
- 标记一个函数可以在不阻塞线程的情况下被挂起。例如,等待网络响应、
delay()、等待另一个协程的结果(await()) suspend函数只能在其他suspend函数或协程构建器(如launch、async、runBlocking)内部被调用
3. 挂起的内部机制 —— 续体传递风格(Continuation Passing Style, CPS)
Kotlin 编译器在遇到 suspend 函数时,会对其进行转换(这发生在编译期,开发者无需关心细节,但理解原理有助于深入):
- 状态机(State Machine): 函数体被转换成一个有限状态机。函数的局部变量成为状态机的字段,每个挂起点(调用其他
suspend函数的地方)成为状态机的一个状态 - Continuation 对象: 编译器会给
suspend函数隐式地添加一个Continuation<T>类型的参数。这个 Continuation 对象封装了协程挂起后需要恢复执行的下一段逻辑(可以看作是回调),同时也持有状态机的当前状态 - 挂起点: 当调用另一个
suspend函数时,当前协程会:(1) 将当前状态(局部变量等)保存到 Continuation 对象中;(2) 调用目标suspend函数,并将这个 Continuation 对象传递给它;(3) 挂起当前协程的执行(可能让出线程) - 恢复执行: 当被调用的
suspend函数完成时(例如网络响应回来),它会调用保存的 Continuation 对象的resumeWith(Result)方法。这会:(1) 恢复状态机到之前的状态;(2) 从挂起点之后的下一个状态继续执行。恢复可能发生在原来的线程,也可能根据调度器切换到其他线程 - 效果: 这种编译期转换使得开发者可以用看似同步的代码风格编写异步逻辑,编译器和协程库处理了状态保存和回调的复杂性
(图示:挂起函数 CPS 转换)
// Original suspend function
suspend fun fetchData(url: String): String {
val request = prepareRequest(url) // Normal code
val response = networkCall(request) // Suspend point 1
val processed = processData(response) // Normal code after resume 1
saveToDb(processed) // Suspend point 2
return "Success" // Final result after resume 2
}
// Compiled State Machine (Conceptual)
class FetchDataStateMachine(private val continuation: Continuation<String>) : ContinuationImpl {
var label = 0 // State indicator
var result: Any? = null
// Fields to store local variables like 'request', 'response', 'processed'
override fun invokeSuspend(result: Result<Any?>): Any? {
this.result = result.getOrThrow() // Store result from previous suspension
while(true) {
when (label) {
0 -> { // Initial state
val request = prepareRequest(url)
// Save state ('request')
label = 1 // Set next state
val responseResult = networkCall(request, this) // Call suspend func, pass 'this' as continuation
if (responseResult == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED // Suspend successful
// If networkCall completed immediately (rare), fall through
this.result = responseResult // Store immediate result
// Fall through to state 1 (simulates immediate resume)
}
1 -> { // Resumed after networkCall
val response = this.result as ResponseType
val processed = processData(response)
// Save state ('processed')
label = 2 // Set next state
val dbResult = saveToDb(processed, this) // Call suspend func
if (dbResult == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
this.result = dbResult
// Fall through to state 2
}
2 -> { // Resumed after saveToDb
// Final processing / return calculation
return "Success"
}
// ... other states for error handling etc. ...
}
}
}
}
二、结构化并发:告别协程泄漏与混乱
这是协程区别于其他并发模型(如裸线程、GlobalScope)的核心优势。
1. 核心理念
协程的生命周期应该与执行它的某个作用域(Scope)绑定。当作用域结束时,其内部启动的所有协程都应该被自动取消。这极大地简化了资源管理,避免了「协程泄漏」。
2. 核心概念
- CoroutineScope: 定义协程的作用域。每个作用域都有一个关联的 CoroutineContext,其中通常包含一个 Job
- Job: 代表一个可取消的工作单元,具有生命周期状态(Active、Completing、Completed、Cancelling、Cancelled)。Job 可以组织成父子层级结构
3. 结构化并发的关键原则
- 作用域约束: 协程必须在某个 CoroutineScope 内启动(使用
launch、async等构建器) - 生命周期绑定: 协程的生命周期受其所在 CoroutineScope 的 Job 控制。取消 Scope 的 Job 会递归地取消其所有子 Job(及其协程)
- 父子关系:
- 父等子: 父 Job 只有在其所有子 Job 都完成后才能进入完成状态
- 子败父崩(默认): 如果一个子协程(非 SupervisorJob 下的直接子级)因为未捕获的异常而失败,它会取消它的父 Job,进而导致父 Job 取消所有其他子 Job
4. 常用作用域与构建器
- GlobalScope: 谨慎使用! 这是一个全局单例 Scope,生命周期与整个应用进程绑定。在 GlobalScope 中启动的协程很容易泄漏,因为它们不会随特定的 UI 组件或业务逻辑结束而自动取消。主要用于某些顶层后台常驻任务(且需要极其小心的手动管理)
- runBlocking { … }: 启动一个协程并阻塞当前线程直到其内部所有任务完成。主要用于连接阻塞代码与挂起世界(如在 main 函数或测试代码中调用
suspend函数)。切勿在 Android 主线程或协程内部使用(除非明确知道后果) - coroutineScope { … }(suspend 函数): 创建一个结构化的嵌套作用域。它会继承外部作用域的上下文,但拥有自己的 Job。它会挂起调用者,直到其内部启动的所有子协程都完成。如果其内部任何一个子协程失败,coroutineScope 自身会失败并重新抛出异常,同时取消其他子协程。常用于将一项工作分解为多个并行子任务,并等待它们全部完成
- supervisorScope { … }(suspend 函数): 与 coroutineScope 类似,也创建嵌套作用域并等待子任务完成。关键区别: 它使用 SupervisorJob。其直接子协程的失败不会导致 supervisorScope 本身失败,也不会取消其他兄弟子协程。异常需要由子协程自己处理(或通过 CoroutineExceptionHandler)。常用于需要隔离子任务失败影响的场景(如一个 UI 界面上有多个独立加载数据的区域)
5. Android Jetpack Scopes
- viewModelScope(ViewModel 扩展属性): 预置在 ViewModel 中的 Scope,生命周期与 ViewModel 绑定(ViewModel
onCleared()时自动取消)。内部使用 SupervisorJob + Dispatchers.Main.immediate。是在 ViewModel 中启动协程处理业务逻辑和数据加载的首选方式 - lifecycleScope(LifecycleOwner 扩展属性): 预置在 Activity/Fragment 中的 Scope,生命周期与组件的 Lifecycle 绑定(Lifecycle DESTROYED 时自动取消)。内部也使用 SupervisorJob + Dispatchers.Main.immediate。提供了
launchWhenCreated、launchWhenStarted、launchWhenResumed等方法,可以在特定生命周期状态下启动协程,并在状态退出时自动暂停或取消
6. Job() vs. SupervisorJob()
- Job(): 子任务失败会导致父任务和所有兄弟任务被取消(默认的失败传递)
- SupervisorJob(): 子任务失败不会影响父任务或兄弟任务,实现失败隔离。viewModelScope 和 lifecycleScope 默认使用它。直接创建
CoroutineScope(SupervisorJob() + ...)可以自定义使用
(图示:结构化并发 —— Job 层级与取消)
+---------------------------------------------+
| CoroutineScope (Parent Job) |
|---------------------------------------------|
| launch { // Child Job 1 |
| ... |
| launch { // Grandchild Job 1.1 | --------+
| ... | | Cancellation
| } | | propagates down
| } // Child Job 1 completes when 1.1 done | V
| |
| async { // Child Job 2 (using default Job) |
| ... |
| if (error) throw Exception() --------->|--- X (Failure)
| ... |
| } // Failure here cancels Parent & Child 1|
| |
| launch(SupervisorJob()) { // Child Job 3 |
| launch { // Grandchild Job 3.1 |
| if (error) throw Exception() ------->|--- X (Failure) - Only 3.1 fails, 3 survives
| } |
| } // Child Job 3 unaffected by 3.1 failure|
+---------------------------------------------+
|
| Parent Job completes only when ALL (non-failing or supervised) children complete.
| If Parent Job is cancelled, ALL children are cancelled.
三、调度器(Dispatchers):协程在何处运行
CoroutineDispatcher 决定了协程代码实际在哪个线程或线程池上执行。它是 CoroutineContext 的一部分。
1. 标准调度器
- Dispatchers.Default:
- 线程池: 由 JVM 共享的后台线程池,大小通常等于 CPU 核心数(至少为 2)
- 适用: CPU 密集型计算(排序、解析复杂数据、图像处理等不涉及阻塞 IO 的操作)。不应在此执行阻塞 IO
- Dispatchers.IO:
- 线程池: 由 JVM 共享的、可按需创建更多线程的后台线程池(上限较高,如 64 个或更多)
- 适用: 阻塞式 IO 操作(网络请求、文件读写、数据库访问等)。因为 IO 操作大部分时间线程处于阻塞等待状态,需要更多线程来提高并发吞吐量
- Dispatchers.Main:
- 线程: Android 应用的主线程(UI 线程)
- 适用: 任何需要与 UI 交互的操作(更新 View、显示 Toast)、调用需要主线程执行的 Android API
- .immediate: Dispatchers.Main.immediate 是一个优化。如果当前已经在主线程,它会尝试立即执行协程代码,而不是先 post 到事件队列再执行,可能减少一点延迟。但在复杂情况下行为需注意
- Dispatchers.Unconfined:
- 行为: 协程启动时在当前调用者线程执行,但在第一个挂起点恢复时,会由恢复该协程的线程(即执行
continuation.resumeWith的线程)继续执行。执行线程可能在挂起/恢复之间发生变化 - 适用: 非常有限。通常不需要在普通应用代码中使用。可能用于某些需要极低延迟且不关心执行线程的场景,或某些框架/库的内部实现。容易导致线程混乱,不推荐常规使用
- 行为: 协程启动时在当前调用者线程执行,但在第一个挂起点恢复时,会由恢复该协程的线程(即执行
2. 切换调度器 —— withContext(Dispatcher) { … }
- 作用: 在协程内部临时切换到指定的 Dispatcher 执行代码块,执行完毕后自动切回原来的 Dispatcher。这是一个
suspend函数 - 核心用途: 将在特定线程上执行的任务(如 IO 操作、CPU 计算)封装起来,同时保持调用者代码的简洁性。 例如,在 viewModelScope(主线程)启动的协程中,使用
withContext(Dispatchers.IO)来执行网络或数据库操作 - 返回值: withContext 会返回其代码块的执行结果
viewModelScope.launch { // Starts on Dispatchers.Main.immediate
val userData = fetchUserData() // Calls suspend function below
updateUi(userData) // Back on Main thread automatically
}
suspend fun fetchUserData(): UserData {
// Switch to IO dispatcher for network call
return withContext(Dispatchers.IO) {
// This block runs on an IO thread
val response = RetrofitClient.api.getUser()
processResponse(response) // Still on IO thread
} // Switches back to the original caller's dispatcher (Main) after block completes
}
四、Flow 深入:协程时代的响应式流
Flow 是 Kotlin 协程生态中处理异步数据流的核心工具。
1. 冷流(Cold Stream)特性
- Flow 默认是冷的。意味着只有当存在终端操作符(Terminal Operator)(如
collect)订阅它时,Flow 构建器(flow { ... })内部的代码才会开始执行 - 每个终端操作符都会触发一次独立的 Flow 执行。 如果有多个
collect调用,flow { ... }内的代码会被执行多次
2. 核心组成
- Builders: 创建 Flow 实例(
flow { emit(T) }、flowOf(T...)、List<T>.asFlow()、channelFlow、callbackFlow) - Intermediate Operators(中间操作符): 对 Flow 进行转换、过滤、组合,返回一个新的 Flow,本身不触发执行(
map、filter、transform、zip、combine、flatMapConcat、flatMapMerge、flatMapLatest、flowOn、buffer、conflate、catch、onCompletion等)。这些操作符通常是suspend函数或内联函数 - Terminal Operators(终端操作符): 消费 Flow,触发其执行,通常是
suspend函数(collect、first、single、toList、toSet、count、reduce、fold、launchIn(scope))
3. flowOn(Dispatcher) —— 指定上游运行线程
- 关键作用: 改变执行 flow 构建器以及它之前所有中间操作符的 CoroutineContext(特别是 Dispatcher)
- 用法:
myRepository.getData().map { ... }.flowOn(Dispatchers.IO).collect { ... }。这里,getData()(假设是flow { ... })和map操作会在 Dispatchers.IO 上执行,而collect会在调用者的上下文中执行(例如主线程) - 对比 withContext: withContext 用于改变一小段代码块的上下文;flowOn 影响整个上游 Flow 的执行上下文
4. 背压处理(Backpressure):生产者快于消费者怎么办?
- 默认行为: 挂起。当
collect处理不过来时,上游的emit调用会被挂起,等待collect处理完当前元素 - 缓冲(buffer(capacity)):
- 在生产者和消费者之间引入一个缓冲区(内部使用 Channel)。生产者可以向缓冲区快速 emit(直到缓冲区满),消费者则从缓冲区取出数据处理
- 允许生产者和消费者并发执行,提高吞吐量
- capacity:缓冲区大小。Channel.BUFFERED(默认 64)、Channel.CONFLATED(只保留最新)、Channel.RENDEZVOUS(容量 0,类似无缓冲)
- 需要注意缓冲区可能消耗额外内存
- 合并(conflate()):
- 当生产者发出新值时,如果消费者还在处理旧值,则丢弃缓冲区中所有未处理的值,只保留最新的值给消费者
- 适用于只需要关心最新状态的场景(如 UI 更新)
- 处理最新(collectLatest { action }):
- 终端操作符。当 Flow 发出一个新值时,如果之前的值对应的 action 挂起函数仍在执行,则取消之前的 action,并为新值启动一个新的 action
- 适用于只需要响应最新事件的场景(例如,用户快速输入触发搜索,只需要处理最后一次输入的搜索请求)
(图示:Flow 背压策略)
Producer: --E1---E2---E3---E4---E5--> emit()
Collector: | Process(E1) | Process(E2) | Process(E3) | ... -> collect() (Slow)
Default (Suspend):
Producer: --E1(sus)E2(sus)E3(sus)E4---> emit() waits for collector
buffer(1): (Producer runs ahead slightly)
Buffer: [E2] [E3]
Producer: --E1---E2---E3---E4-------> emit() fills buffer then suspends
Collector: | Process(E1) | Process(E2) | Process(E3) | ... -> collect() takes from buffer
conflate(): (Only latest matters)
Producer: --E1---E2---E3---E4---E5--> emit() continuously
Collector: | Process(E1) | Process(E3) | Process(E5) | ... -> collect() gets latest when ready
collectLatest(): (Cancel previous processing)
Producer: --E1---E2---E3---E4---E5--> emit()
Collector Action: | Prc(E1) | Prc(E2) | Prc(E3)-cancel| Prc(E4)-cancel| Process(E5) | ...
五、StateFlow & SharedFlow:热流状态与事件总线
冷流在某些场景下不适用,例如需要多个订阅者接收相同的数据流(而不是重新执行),或者需要表示一个可观察的当前状态。
1. StateFlow<T> —— 状态表示的热流
特性:
- 热流(Hot): 存在时就有值,独立于是否有收集者
- 状态容器: 持有单一的、最新的状态值
- 初始值: 创建时必须提供一个初始值
- 立即获取: 新的收集者会立即收到当前的最新状态值
- 值比较: 只会发射与前一个值
equals()结果为 false 的新值(去重) - 合并(Conflation): 内部机制保证收集者只会收到最新的状态,中间过快产生的状态会被「合并」掉(类似于
conflate())
MutableStateFlow<T>: 可变的 StateFlow,提供 value 属性进行读写,update { ... } 原子更新,tryEmit() 非挂起尝试发射。
应用场景: ViewModel 中向 UI 层暴露状态的最佳实践之一。 UI 层通过 stateIn(scope, SharingStarted.WhileSubscribed(5000), initialValue) 将冷流转换为 StateFlow,或者直接使用 MutableStateFlow,然后 UI 层用 collectAsStateWithLifecycle 收集。
2. SharedFlow<T> —— 通用型热流/事件总线
特性:
- 热流(Hot): 独立于收集者存在
- 广播: 可以将值广播给所有当前的收集者
- 高度可配置: 通过构造函数参数控制其行为:
- replay: (Int) 向新加入的收集者回放多少个最近发射过的值。0 表示不回放(新收集者只收到后续的新值)。1 以上用于缓存历史值
- extraBufferCapacity: (Int) 在 replay 缓存之外,为活跃收集者提供的额外缓冲空间,用于应对背压。0 表示没有额外缓冲
- onBufferOverflow: (BufferOverflow) 当缓冲区(replay + extraBufferCapacity)已满时,新的 emit 操作的行为:SUSPEND(挂起,默认)、DROP_OLDEST(丢弃最旧的值)、DROP_LATEST(丢弃最新的值)
MutableSharedFlow<T>: 可变的 SharedFlow,提供 emit()(挂起)和 tryEmit()(非挂起)方法发射值。subscriptionCount: StateFlow<Int> 可观察当前活跃收集者数量。
应用场景:
- 事件总线: 需要将事件(如用户操作、后台通知)广播给多个监听者。需要特别注意「一次性事件」的处理(见下文)
- 需要回放历史数据的热流
- 实现自定义 StateFlow 行为(通过特定配置)
关键考量:
- 参数选择: replay、extraBufferCapacity、onBufferOverflow 的选择对 SharedFlow 的行为至关重要,必须根据具体需求仔细设定,否则可能导致事件丢失、内存泄漏(如果缓存过大)或死锁(如果 SUSPEND 策略下没有收集者消费)
- 一次性事件处理(Single-Shot Events): 如果使用 SharedFlow 传递需要确保只被处理一次的事件(如显示 Toast、导航),需要特别小心。因为配置更改等原因可能导致 UI 层重新订阅,如果 replay > 0,可能会收到并再次处理旧事件。常用解决方案包括:(1) 使用 Channel 替代;(2) 在下游使用某种机制标记事件已被消费;(3) 配合 Event 包装类
六、Channel:协程间的信使
Channel 是协程提供的另一种热流(或称为通信原语),类似于线程安全的阻塞队列(BlockingQueue),但使用 suspend 函数进行发送和接收。
1. 特性
- 热的,数据在发送者和接收者之间传递
send(element):挂起发送者直到元素被接收者receive()(或放入缓冲区)receive():挂起接收者直到 Channel 中有元素可供接收- 支持不同容量和策略:RENDEZVOUS(0 容量,发送/接收必须配对)、BUFFERED(有限容量)、CONFLATED(只保留最新)、UNLIMITED(无限容量,小心内存)
2. 构建器
Channel<E>(capacity)、produce<E>(context, capacity) { ... }(返回 ReceiveChannel<E>)
3. callbackFlow<T> { … } / channelFlow<T> { … }
- 目的: 将基于回调的 API 或需要主动推送数据的逻辑桥接为冷 Flow
- 机制: 内部创建一个 Channel。提供一个 ProducerScope(继承自 CoroutineScope,并实现了 SendChannel),可以在其中安全地调用
send()或trySend()来发射数据。必须调用awaitClose { cleanup_logic },它会在 Flow 被取消或关闭时执行,用于注销回调或清理资源,防止泄漏 - 区别: channelFlow 是 callbackFlow 的更通用版本,callbackFlow 对回调 API 做了些优化(如保证 send 安全)
4. 应用场景
协程间的生产者-消费者模式、需要精确控制发送/接收同步的场景、桥接回调 API。
七、高级异常处理:优雅地应对失败
协程的结构化并发对异常处理有重要影响。
1. 基本 try-catch
在 launch 或 async 代码块内部使用 try-catch 可以捕获其内部代码(包括调用的 suspend 函数)抛出的异常。这是最直接的处理方式。
2. 结构化并发的传播
- 默认 Job: 如果一个协程(非 SupervisorJob 的直接子级)发生未捕获异常,该异常会向上传播给父 Job,导致父 Job 和所有其他兄弟 Job 被取消。异常最终可能导致应用崩溃
- async 的异常: async 启动的协程如果发生异常,异常会被持有,直到调用
await()时才会被抛出。如果不调用await(),异常可能「丢失」(除非父作用域被取消)
3. CoroutineExceptionHandler —— 最后的防线
- 角色: 作为一个 CoroutineContext 元素,用于处理未被捕获的异常(即没有被 try-catch 住,并且传播到了 Scope 的边界或 GlobalScope)
- 目的: 记录日志、上报错误。它不能阻止协程或其作用域的取消过程
- 安装位置: 通常安装在顶层作用域(如 GlobalScope、viewModelScope、lifecycleScope)或顶层
launch构建器中。安装在子协程或非 SupervisorJob 的中间作用域通常无效(因为异常会先取消父级)
4. supervisorScope 与异常隔离
在 supervisorScope { ... } 内部,其直接子协程的失败不会传播给 supervisorScope 本身或其他兄弟子协程。失败的子协程需要自己处理异常(通过 try-catch 或安装在其 launch 构建器中的 CoroutineExceptionHandler)。
5. Flow 的异常处理
- 下游 try-catch:
collect调用者可以用 try-catch 捕获来自 collect 本身、上游所有操作符以及 flow 构建器内部抛出的异常 - catch { e -> emit(…) } 操作符:
- 只捕获上游异常: 仅捕获其上游(包括 builder 和之前的操作符)发生的异常
- 提供恢复/转换: 可以在 catch 块中 emit 一个备用值,或者记录日志,或者 throw 一个不同的异常
- 不捕获下游异常: catch 操作符无法捕获其下游(如 collect 内部)发生的异常
- onCompletion { cause: Throwable? -> … } 操作符:
- 无论 Flow 是正常完成还是因异常终止,onCompletion 块都会执行
- cause 参数在异常终止时为非 null,否则为 null
- 用途: 执行清理逻辑(类似 finally),无论成功或失败
八、取消机制:优雅地停止
协程的取消也是基于结构化并发和协作机制。
1. 传播
取消请求会从父 Job 向下传播到所有子 Job。调用 scope.cancel() 或 job.cancel()。
2. 协作式(Cooperative)
协程代码需要主动检查取消状态并做出响应,才能被有效取消。
- 内置检查点: 所有 kotlinx.coroutines 库中的挂起函数(如
delay、yield、withContext、channel.receive、Flow 操作符等)内部都会检查当前协程是否已被取消。如果已取消,它们会抛出CancellationException - 手动检查: 对于长时间运行的、不包含挂起函数调用的 CPU 密集型计算循环,必须手动检查取消状态:
if (!isActive) return或if (!isActive) throw CancellationException()ensureActive():如果已取消则抛出CancellationExceptionyield():挂起当前协程并允许其他协程运行,同时也会检查取消状态
- CancellationException: 这是协程取消的标准信号,通常被认为是正常流程,默认的异常处理器(如 CoroutineExceptionHandler)通常会忽略它
3. NonCancellable Context
- 场景: 在 finally 块或 onCompletion 中执行必须完成的清理操作(如释放文件句柄、关闭网络连接),即使协程已经被取消
- 用法:
withContext(NonCancellable) { // cleanup code }。在此代码块内,协程暂时不会响应取消请求。谨慎使用,避免执行耗时操作
:::danger 千万注意:
- 不要乱 catch: 如果你在代码中
catch (e: Exception),请务必将CancellationException重新抛出(throw e),否则协程会「假装没听见」取消信号,继续运行,导致资源浪费或内存泄漏 - 非 cancellable 的 suspend 函数: 如果协程卡在一个不检查取消状态的 suspend 函数(如某些 IO 操作)上,它可能无法及时响应取消 :::
九、测试协程与 Flow
kotlinx-coroutines-test 库提供了强大的测试支持。
1. runTest { … }
- 核心测试构建器: 取代 runBlockingTest。提供一个 TestScope,运行在 TestDispatcher 上
- 虚拟时间: 默认情况下,
delay等时间相关的挂起函数会立即完成(虚拟时间推进),使得测试无需实际等待 - 调度器控制: 可以注入和控制 TestDispatcher(如 StandardTestDispatcher、UnconfinedTestDispatcher)来管理协程的执行顺序和时间
2. TestCoroutineScheduler
提供对虚拟时间的更精细控制(advanceTimeBy、runCurrent)。
3. 测试 Flow
- 在 runTest 中直接 collect Flow
- 使用第三方库 Turbine(app.cash.turbine:turbine)提供了更简洁、更强大的 Flow 测试 API,如
flow.test { awaitItem(), expectNoEvents(), awaitComplete(), ... }
4. 依赖注入
最佳实践是在 ViewModel、Repository 等类中注入 CoroutineDispatcher(而不是直接使用 Dispatchers.IO 等),这样在测试中可以方便地替换为 TestDispatcher。
十、Coroutines vs. RxJava vs. Threads
- Coroutines vs. Threads: 协程更轻量,易于管理,结构化并发避免泄漏,代码更简洁
- Coroutines vs. RxJava:
- 相似性: 都处理异步数据流
- 差异性:
- 范式: 协程基于挂起函数,代码更接近同步风格;RxJava 基于观察者模式和链式操作符
- 简洁性: 协程通常样板代码更少
- 结构化并发: 协程内置支持更好
- 操作符: RxJava 拥有极其丰富的操作符生态;Flow 的操作符也在不断完善
- 学习曲线: 协程通常被认为更容易上手
- 互操作: kotlinx-coroutines-rx3(或 rx2)库提供了在两者之间转换的方法(如
Flow.asObservable()、Observable.asFlow())
对于新项目,尤其基于 Kotlin,协程 + Flow 通常是首选。对于已有大量 RxJava 代码的项目,可以逐步迁移或混合使用。
十一、常见陷阱与最佳实践
- 阻塞调度器: 在 Dispatchers.Default 执行阻塞 IO,或在 Dispatchers.IO 执行 CPU 密集长计算
- 滥用 GlobalScope: 导致潜在泄漏和测试困难。优先使用 viewModelScope、lifecycleScope 或自定义 Scope
- 忘记检查取消: 在无挂起函数的长循环中不检查
isActive - 异常处理不当: 错误地认为 CoroutineExceptionHandler 能阻止取消;async 异常未被 await 捕获;supervisorScope 下的子协程异常未处理
- SharedFlow 配置错误: 对 replay/buffer/overflow 理解不清,导致事件丢失或行为不符预期,尤其对于「一次性事件」
- callbackFlow/channelFlow 忘记 awaitClose: 导致回调/监听器泄漏
- 过度使用 Dispatchers.Unconfined: 导致线程行为难以预测
- 硬编码 Dispatcher: 不利于测试。应通过 DI 注入 Dispatcher
- UI 层收集 Flow 未使用 collectAsStateWithLifecycle: 可能导致后台不必要的资源消耗
- StateFlow 与 SharedFlow 选择不当: 用 StateFlow 处理需要保证送达的事件流(可能丢失),或用 SharedFlow 处理只需最新值的状态(效率不高且可能收到旧值)
十二、结论:并发编程的现代利器
Kotlin Coroutines 与 Flow 为 Android 开发者提供了强大、现代且优雅的异步编程解决方案。它们通过轻量级的协程、革命性的结构化并发、灵活的调度器以及功能丰富的响应式流(Flow),极大地简化了并发代码的编写、管理和测试,有效解决了传统方式下的诸多痛点。
精通协程与 Flow 意味着不仅能熟练运用 API,更能深刻理解其挂起与恢复的本质、状态机转换、结构化并发的生命周期与异常传播机制、不同调度器的适用场景与性能影响、Flow 冷热流模型与背压策略、StateFlow/SharedFlow 的精妙配置与应用场景,以及协程取消与异常处理的各种细节。
掌握这些高级原理与实践,才能在面对日益复杂的业务逻辑和用户体验要求时,自信地构建出高性能、高并发、高稳定性的 Android 应用,将异步编程的复杂性化繁为简。这是现代 Android 高级工程师必备的核心技能。