协程进阶:异常 02. CoroutineExceptionHandler

本篇解析 example-exceptions-02.kt。学习如何全局捕获未处理的协程异常。

1. 核心概念:最后防线

CoroutineExceptionHandler 是协程中的 Thread.UncaughtExceptionHandler

特点:

  • 全局捕获:用于处理那些没有被 try-catch 捕获且向上传播到根协程的异常。
  • 不可恢复:当它被触发时,协程已经由于异常而终止。你无法在其中“恢复”协程运行。
  • 生效位置:它必须设置在根协程(由 GlobalScopeCoroutineScope() 创建的顶级协程)中。

2. 规则解析

  • 子协程无效:如果你在子协程(在另一个 Job 上下文中创建的协程)设置处理者,它不会生效。因为子协程总会将异常委托给父协程处理。
  • async 无效async 产生的异常由 deferred.await() 负责,即使在 GlobalScope.async 中设置了 handler,它也不会在后台静默触发打印。

3. 开发者感悟

CoroutineExceptionHandler 通常用于记录异常日志或弹出全局错误对话框。在 Android 开发中,不要期望用它来“挽救”崩溃,它的作用是让你在应用退出前知道发生了什么。

Flow 基础:02. Sequence 同步惰性序列

本篇解析 example-flow-02.kt。探讨 Kotlin 标准库中的 Sequence(序列)。

1. 什么是 Sequence?

Sequence<T> 提供了一种惰性求值的机制。

特点:

  • 按需计算:只有在真正需要下一个元素(如调用 forEach)时,才会执行 sequence { ... } 块内的代码。
  • 节省内存:不需要像 List 那样一次性在内存中存储所有元素。
  • **yield()**:在 sequence 构建器中,使用 yield(n) 来产出值。

2. 局限性:同步阻塞

虽然 Sequence 是惰性的,但它是同步的。

  • sequence { ... } 块内,你不能调用挂起函数(如 delay)。
  • 如果你在其中调用了 Thread.sleep(如本例所示),它会阻塞当前执行线程

3. 开发者感悟

Sequence 非常适合处理 CPU 密集型的大数据集合,但不适合处理包含等待逻辑的异步任务。如果你需要在流式产出数据的同时不阻塞线程,那么你需要的是 Flow

协程进阶:02. 计算任务无法取消

本篇解析 example-cancel-02.kt,探讨为什么有些协程在调用 cancel 后依然继续运行。

1. 核心现象

在这个例子中,协程执行的是一个纯 CPU 计算的 while 循环。

  • 结果:即使主线程调用了 job.cancelAndJoin(),协程依然会完整跑完 5 次循环打印,直到任务自行结束。

2. 为什么取消失效?

协程的取消是协作式的。

  • 如果协程内部没有调用任何“挂起函数”(如 delay),或者没有手动检查“取消状态”,它就无法感知外部的取消请求。
  • 传统的 while (i < 5) 循环是阻塞式的 CPU 操作,它会一直霸占执行权。

3. 开发者感悟

协程不是线程的强制终止(Thread.stop),它更像是一种“请假”机制。如果你不看手机(不检查取消状态),你就不知道假被取消了。在处理密集型计算任务时,必须考虑到这一点。

Kotlin 协程基础 02:挂起函数 (Suspending Functions)

本篇文档解析 example-basic-02.kt,探讨协程中最核心的代码组织单位:挂起函数。

1. 什么是挂起函数?

挂起函数是使用 suspend 关键字修饰的函数。它是协程的逻辑单元。

特性:

  • 非阻塞:挂起函数在执行到某些异步操作(如 delay)时,会释放当前线程,让线程去处理其他任务。
  • 可恢复:当异步操作完成后,挂起函数会从刚才暂停的地方恢复执行。
  • 调用限制:挂起函数只能在协程或其他挂起函数中调用。

2. 代码解析

1
2
3
4
5
6
7
8
9
10
fun main() = runBlocking { 
launch { doWorld() }
println("Hello")
}

// 这是一个挂起函数
suspend fun doWorld() {
delay(1000L)
println("World!")
}
  • 提炼逻辑doWorld() 将原来直接写在 launch 里的逻辑提取了出来。
  • 代码整洁:通过挂起函数,我们可以像写同步代码一样组织复杂的异步逻辑,避免了传统嵌套回调(Callback Hell)。

3. 开发者感悟

suspend 关键字是一个信号,它告诉编译器:“这个函数可能会挂起当前协程”。它并不代表这个函数会自动在后台运行,它只是允许该函数内部调用其他的挂起函数。这是协程实现“顺序编写异步代码”的基础。

2. 两数相加

题目描述

给你两个 非空 的链表,表示两个非负的整数。它们每位数字都是按照 逆序 的方式存储的,并且每个节点只能存储 一位 数字。

请你将两个数相加,并以相同形式返回一个表示和的链表。

你可以假设除了数字 0 之外,这两个数都不会以 0 开头。


核心思路:数学模拟

这道题的本质是模拟手动加法运算。由于链表已经是逆序存储(个位在头),我们直接按顺序遍历并相加即可,不需要额外反转链表。

1. 关键变量

  • **进位 (carry)**:相加结果大于等于 10 时,需要将 1 传递到下一位。
  • **虚拟头节点 (dummyHead)**:用于简化链表的构建逻辑,最后返回 dummyHead.next
  • 双指针同步遍历:同时推移两个链表的指针。

2. 边界处理

  • 长度不等:当其中一个链表遍历结束时,其值视为 0
  • 最终进位:如果最高位相加后产生进位(如 5 + 5 = 10),需在链表末尾新增一个值为 1 的节点。

代码实现

注意:我们将常见的 `val` 命名修改为 nodeValue,以避免与 Kotlin 关键字冲突并提高代码可读性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
/**
* 链表节点定义
*/
class ListNode(var nodeValue: Int) {
var next: ListNode? = null
}

class Solution {
fun addTwoNumbers(l1: ListNode?, l2: ListNode?): ListNode? {
// 创建虚拟头节点,其 next 存储真正的结果
val dummyHead = ListNode(0)
var tail = dummyHead

// 进位标识
var carry = 0

// 两个链表的遍历指针
var p1 = l1
var p2 = l2

// 只要还有节点未处理,或还有进位需要结算,就继续循环
while (p1 != null || p2 != null || carry != 0) {
// 取出当前位的值,若已空则取 0 (等长化处理)
val digit1 = p1?.nodeValue ?: 0
val digit2 = p2?.nodeValue ?: 0

// 计算当前位总和 (包含前一位的进位)
val totalSum = digit1 + digit2 + carry

// 计算新的进位
carry = totalSum / 10

// 创建新节点存储当前位的结果 (取模) 并连接到结果链表
tail.next = ListNode(totalSum % 10)
tail = tail.next!!

// 向后推移指针
p1 = p1?.next
p2 = p2?.next
}

return dummyHead.next
}
}

复杂度分析

  • 时间复杂度:$O(\max(m, n))$。其中 $m$ 和 $n$ 分别为两个链表的节点数。我们需要完整遍历较长的那个链表。
  • 空间复杂度:$O(1)$。除了用于存储答案的链表外,我们只使用了常数级别的额外空间(进位变量、临时指针等)。

技巧总结

  1. **虚拟头节点 (Dummy Head)**:链表题“必备神器”。它消除了对“头节点是否为空”的特殊处理逻辑。
  2. 逻辑合并:将 while 循环条件设为 p1 != null || p2 != null || carry != 0,可以一次性处理完所有计算,包括最后一位产生的额外进位。
  3. 命名优化:在 Kotlin 中,LeetCode 默认的 `val` 属性由于是关键字,必须加反引号,可读性极差。将其改名为 nodeValuevalue 是更符合现代工程实践的做法。

协程进阶:监督 03. 监督环境下的异常捕获

本篇解析 example-supervision-03.kt。探讨在 supervisorScope 中如何正确地处理子任务的报错。

1. 核心概念:独立责任

supervisorScope 中,由于子任务的异常不会自动向上传播给父作用域,因此每个子协程必须通过 CoroutineExceptionHandler 来处理自己的错误。

执行流程:

  • 子协程报错:它会触发自己上下文中的 handler,然后安静地终止。
  • 作用域正常:由于使用了监督机制,supervisorScope 本身会保持活跃,并正常完成所有的逻辑输出。

2. 代码解析

1
2
3
4
5
6
7
8
9
10
11
val handler = CoroutineExceptionHandler { _, exception -> 
println("CoroutineExceptionHandler got $exception")
}
supervisorScope {
val child = launch(handler) { // 必须在这里传入处理者
println("The child throws an exception")
throw AssertionError()
}
println("The scope is completing") // 这里会正常执行
}
println("The scope is completed") // 这里也会正常执行

3. 开发者感悟

supervisorScope 结合 CoroutineExceptionHandler 是处理复杂并行任务的最佳搭档。它不仅让各个子任务之间互不干扰(“隔离”),还保证了每一个失败的任务都能被优雅地记录。在 Android 中,如果你同时发起三个并行的 API 请求,并希望其中一个报错能显示特定的提示而不影响其他两个正常显示,这套组合就是你的核心武器。

协程进阶:同步 03. 原子操作 (Atomic)

本篇解析 example-sync-03.kt。探讨如何利用 Java 的原子类解决并发累加问题。

1. 核心工具:AtomicInteger

在 Java/Kotlin 中,AtomicInteger 利用了底层的 CAS(Compare-And-Swap)机制,保证了操作的原子性。

代码解析

1
2
3
4
5
6
7
val counter = AtomicInteger()
withContext(Dispatchers.Default) {
massiveRun {
counter.incrementAndGet() // 这是一个原子操作
}
}
// 结果:Counter = 100000 (准确)

2. 开发者感悟

对于简单的计数器或状态位,原子类是最轻量且高效的选择。它比使用锁(Lock)开销更小。但在处理复杂的业务逻辑(不仅是简单的加法)时,原子类的局限性就会体现出来,我们需要更通用的方案。

协程进阶:选择 03. 在发送上选择 (onSend)

本篇解析 example-select-03.kt。探讨 select 如何决定数据该发送给谁。

1. 核心操作:onSend

select 不仅可以监听接收,也可以监听发送。onSend 子句用于将一个元素发送到一个可用的通道中。

特点:

  • 负载均衡:如果主通道正忙(缓冲区满),select 会尝试发送到备用通道。
  • 多路分流:它能自动将产出的数据分流到第一个准备好接收的通道中。

2. 代码解析

1
2
3
4
select<Unit> {
onSend(num) { /* 发送到主通道成功后的逻辑 */ }
side.onSend(num) { /* 发送到备用通道成功后的逻辑 */ }
}
  • 执行逻辑:生产者产出一个数,如果消费者 1 慢,消费者 2 快,这个数就会被发送给消费者 2。

3. 开发者感悟

onSend 是处理“拥塞控制”的有力武器。在 Android 的网络上传场景下,如果你有两个上传接口(如 Wi-Fi 和 5G),你可以用 select 尝试先发给响应更快的那个,从而实现动态的链路负载均衡。

协程进阶:通道 03. 生产者构建器 (produce)

本篇解析 example-channel-03.kt。学习一种更简便的通道创建方式。

1. 核心构建器:produce

produce 是一个协程构建器,它会启动一个新的协程并返回一个 ReceiveChannel

优点:

  • 封装性:它将“启动协程”与“发送逻辑”封装在一起。
  • 自动关闭:当 produce 块内的代码执行完毕后,它会**自动调用 close()**。

2. 核心操作:consumeEach

consumeEach 是一个扩展函数,用于替代传统的 for 循环。

  • 安全消费:它保证了在消费完数据后,不论成功还是异常,都会自动处理资源释放。

3. 开发者感悟

produce 是处理异步流的工业级选择。它就像是协程里的“工厂”,源源不断地生产零件并直接交付给消费者,且自带“完工关机”功能,极大减少了由于忘记关闭 Channel 导致的资源泄漏。

协程进阶:组合 03. 惰性并发 (CoroutineStart.LAZY)

本篇解析 example-compose-03.kt。探讨如何延迟启动一个并发任务。

1. 核心概念:惰性启动

async 中,通过设置 start = CoroutineStart.LAZY,协程在调用时不会立即启动

启动方式:

  1. **await()**:如果直接调用 await(),协程会开始运行并挂起等待结果(这会导致任务变成顺序执行)。
  2. **start()**:在 await() 之前手动调用 start(),可以实现并发启动。

2. 代码解析

1
2
3
4
5
6
val one = async(start = CoroutineStart.LAZY) { doSomethingUsefulOne() }
val two = async(start = CoroutineStart.LAZY) { doSomethingUsefulTwo() }

one.start() // 提前启动
two.start() // 提前启动
println("The answer is ${one.await() + two.await()}")

3. 开发者感悟

LAZY 模式就像是“按需加载”。它可以让你先定义好复杂的并发逻辑,在真正需要的地方才一键启动。但要小心,如果不先调用 start() 就直接 await(),你的多个任务就会变成串行,从而失去并发的优势。