协程进阶:上下文 04. 调度器之间的跳转

本篇解析 example-context-04.kt。学习如何手动控制协程运行的线程。

1. 核心操作符:withContext

withContext 允许你在同一个协程内部切换到不同的调度器执行一段逻辑。

执行流程:

  1. Ctx1 线程启动。
  2. 调用 withContext(Ctx2),协程挂起,在 Ctx2 执行业务逻辑。
  3. 执行完毕后,协程自动恢复到 Ctx1 继续运行。

2. 开发者感悟

withContext 是协程中最强大的特性之一。它让异步代码写起来像同步代码一样直观。在 Android 中,它常用于:在主线程协程中,临时切到 IO 线程读个数据库,然后直接拿到返回值切回主线程。

协程进阶:异常 04. 非取消异常的双向传播

本篇解析 example-exceptions-04.kt。探讨非 CancellationException 异常如何在协程层级中“株连九族”。

1. 核心概念:双向取消

在协程中,如果一个子协程由于异常(非取消信号)而失败:

  1. 向上抛出:子协程会向上传递异常给父协程。
  2. 父协程失败:父协程接收到异常后,会立即标记自己为失败。
  3. 向下取消:父协程会同时取消其所有的其他子协程(即“兄弟协程”)。
  4. 汇总处理:只有当所有的子协程都由于取消而终止后,父协程才会最终处理原始异常。

2. 异常捕获点

在这种模式下,即使你在子协程内部写了 try-catch 或设置了 handler,通常也无法阻止父协程和兄弟协程的被动取消。异常最终会传播到根协程。

3. 开发者感悟

这是一个非常严格的设计,强调了任务的整体性。如果你有多个并行的子任务,且希望其中一个失败不影响其他任务,你需要使用 supervisorScopeSupervisorJob(这将在后续章节讨论)。这种默认的传播机制更适合处理那些“一荣俱荣,一损俱损”的强关联任务。

Flow 基础:04. 什么是异步流 (Flow)

本篇解析 example-flow-04.kt。正式引入 Flow 概念。

1. 核心概念

Flow 是 Kotlin 协程中处理异步数据流的最佳工具。

特点:

  • 非阻塞异步:在 flow { ... } 块中可以自由调用挂起函数(如 delay)。
  • **发射 (Emit)**:使用 emit(value) 产出数据。
  • **收集 (Collect)**:使用 collect { ... } 消费数据。
  • 与 Sequence 的区别Sequence 会阻塞调用线程,而 Flow 会挂起协程,让出线程执行权(如本例中的 launch 打印不会被阻塞)。

2. 开发者总结

  • List:一次性返回全家桶。
  • Sequence:同步、惰性、一个一个给,但会死等。
  • **Flow**:异步、惰性、一个一个给,等待时不阻塞。

3. 应用场景

任何涉及连续产出数据的异步任务:

  • 网络轮询、数据库查询监控。
  • 传感器数据实时更新(如 GPS、计步器)。
  • 文件下载进度通知。

协程进阶:04. 检查状态实现取消

本篇解析 example-cancel-04.kt,介绍如何让密集型计算任务也能响应取消。

1. 核心属性:isActive

isActiveCoroutineScope 的一个扩展属性。

  • 原理:当协程被外部调用 cancel() 时,isActive 会从 true 变为 false
  • 代码方案:将传统的循环条件从 while (i < 5) 改为 while (isActive)

2. 为什么这样有效?

通过手动检查 isActive,协程在每一次循环迭代时都会确认自己是否还需要继续运行。如果检测到已取消,循环就会自然终止。

3. 开发者感悟

这是处理耗时计算逻辑(如图片处理、大数据过滤)时的标准做法。除了 isActive,也可以使用 yield() 函数,它不仅会检查取消状态,还会让出执行权给其他协程。

Kotlin 协程基础 04:并发执行与作用域边界

本篇文档解析 example-basic-04.kt,探讨如何在同一个作用域内处理多个并发任务。

1. 核心概念:多个 launch 的并发行为

在同一个 coroutineScoperunBlocking 中,如果你连续调用多个 launch,它们会立即并发执行,而不会互相等待。

代码解析

1
2
3
4
5
6
7
8
9
10
11
suspend fun doWorld() = coroutineScope { 
launch {
delay(2000L)
println("World 2")
}
launch {
delay(1000L)
println("World 1")
}
println("Hello")
}
  • 并行启动:两个 launch 块几乎同时启动。
  • 非阻塞主流程println("Hello") 会在 launch 启动后立即执行,不会等待 1 秒或 2 秒。
  • 自动等待完成coroutineScope 会确保其内部的所有子协程(World 1 和 World 2)都完成后,doWorld() 函数才会返回。

2. 输出顺序分析

  1. Hello:主流程立即打印。
  2. World 1:经过 1000ms 后打印(因为它延迟最短)。
  3. World 2:经过 2000ms 后打印(从开始计时起)。

3. 开发者感悟

可以将 launch 理解为向 CoroutineScope 注册了一个待执行的任务。这些任务被“塞”进作用域后,它们在各自的轨道上并行运行。这种模式极大地简化了复杂的异步逻辑,避免了传统回调地狱。

4. 寻找两个正序数组的中位数

题目描述

给定两个大小分别为 mn 的正序(从小到大)数组 nums1nums2。请你找出并返回这两个正序数组的 中位数

算法的时间复杂度应为 $O(\log (m+n))$。

示例 1:

1
2
3
输入:nums1 = [1,3], nums2 = [2]
输出:2.00000
解释:合并数组 = [1,2,3] ,中位数 2

示例 2:

1
2
3
输入:nums1 = [1,2], nums2 = [3,4]
输出:2.50000
解释:合并数组 = [1,2,3,4] ,中位数 (2 + 3) / 2 = 2.5

核心思路:切分与二分查找

要在 $O(\log(m+n))$ 时间内解决问题,我们不能通过合并后再排序($O(m+n)$),而必须利用数组的有序性使用二分查找。

1. 中位数的本质:划分

中位数的本质是将一个集合划分为两个长度相等(或差1)的子集,使得左半部分的最大值 $\le$ 右半部分的最小值

对于两个数组 nums1nums2,我们要找到两个切分点 ij

  • nums1 被切分为:nums1[0...i-1] (左) | nums1[i...m-1] (右)
  • nums2 被切分为:nums2[0...j-1] (左) | nums2[j...n-1] (右)

2. 满足的条件

为了让这两部分合并后依然满足中位数的定义,我们需要满足:

  1. 数量平衡i + j = (m + n + 1) / 2。这样左半部分的总长度就固定了。如果我们确定了 i,那么 j 也就随之确定。
  2. 数值交叉小于等于
    • nums1[i-1] <= nums2[j] (nums1 的左侧最大 $\le$ nums2 的右侧最小)
    • nums2[j-1] <= nums1[i] (nums2 的左侧最大 $\le$ nums1 的右侧最小)

3. 为什么用二分?

因为 i 的增加会导致 j 的减少(为了维持数量平衡),这种单调性让我们可以在较短的数组(假设为 nums1)中通过二分查找来寻找完美的切分点 i


算法详解

  1. 选择短数组:对较短的数组进行二分查找,可以将复杂度控制在 $O(\log(\min(m, n)))$。
  2. 范围确定left = 0, right = m
  3. 二分收缩
    • 如果 nums1[i-1] > nums2[j]:说明 i 太大了,需要向左收缩 right = i - 1
    • 如果 nums2[j-1] > nums1[i]:说明 i 太小了,需要向右扩张 left = i + 1
  4. 边界处理:当 ij 处于数组边缘(0length)时,使用 Int.MIN_VALUEInt.MAX_VALUE 代替。

代码实现 (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
45
class Solution {
fun findMedianSortedArrays(nums1: IntArray, nums2: IntArray): Double {
val n1 = nums1.size
val n2 = nums2.size

// 确保对短数组进行二分查找,优化性能
if (n1 > n2) return findMedianSortedArrays(nums2, nums1)

var left = 0
var right = n1

while (left <= right) {
// i 是 nums1 的切分点,j 是 nums2 的切分点
val i = (left + right) / 2
val j = (n1 + n2 + 1) / 2 - i

// 边界检查:处理切分点在数组两端的情况
val maxLeft1 = if (i == 0) Int.MIN_VALUE else nums1[i - 1]
val minRight1 = if (i == n1) Int.MAX_VALUE else nums1[i]

val maxLeft2 = if (j == 0) Int.MIN_VALUE else nums2[j - 1]
val minRight2 = if (j == n2) Int.MAX_VALUE else nums2[j]

// 判断是否找到了完美的切分点
if (maxLeft1 <= minRight2 && maxLeft2 <= minRight1) {
// 结果处理:根据总长度奇偶性返回
return if ((n1 + n2) % 2 == 0) {
val leftMax = maxOf(maxLeft1, maxLeft2)
val rightMin = minOf(minRight1, minRight2)
(leftMax + rightMin).toDouble() / 2.0
} else {
maxOf(maxLeft1, maxLeft2).toDouble()
}
} else if (maxLeft1 > minRight2) {
// nums1 左边太大,i 需要减小
right = i - 1
} else {
// nums1 左边太小,i 需要增大
left = i + 1
}
}

return 0.0
}
}

复杂度分析

  • 时间复杂度:$O(\log(\min(m, n)))$。我们只对较短的数组进行二分查找。
  • 空间复杂度:$O(1)$。只使用了常数个变量。

思考与扩展

Q:如果有 N 个有序数组,如何求中位数?

  1. 堆排序法:使用最小堆同时遍历 N 个数组,弹出 $(m+n+…)/2$ 次元素。时间复杂度 $O(K \cdot \log N)$,其中 $K$ 是总元素个数的一半。
  2. 多路归并:类似合并 K 个有序链表。
  3. 二分答案法:在所有数字的数值范围 [min, max] 内进行二分查找,通过 count 函数统计小于等于 mid 的元素总数,直到找到第 K 小的数。这种方法对处理超大规模数据非常有效。

协程进阶:通道 05. 管道案例:素数序列 (Primes)

本篇解析 example-channel-05.kt。探讨如何利用管道进行复杂的数学计算。

1. 核心算法:埃拉托斯特尼筛法

通过不断地过滤掉已知素数的倍数,从而在无限整数序列中筛选出素数。

核心步骤:

  1. **numbersFrom(2)**:生成从 2 开始的无限整数流。
  2. 取出第一个数:它是当前流中的第一个素数(例如 2)。
  3. **filter(cur, 2)**:创建一个过滤器,把能被 2 整除的数全部拦住,产出一个新流。
  4. 循环往复:不断取出新流的第一个数,再加一个新的过滤器。

2. 开发者总结

这个例子展示了协程的极度轻量:每一个过滤器实际上都是一个新的协程在后台运行。即使叠加了几十层过滤器,协程也能保持高效的并发处理。这种将复杂计算通过管道解耦到微小任务(协程)中的思路,是高阶并发开发的精华。

协程进阶:同步 05. 粗粒度线程限制 (Coarse-grained)

本篇解析 example-sync-05.kt。探讨一种更高效的单线程环境同步方案。

1. 核心概念:整体限制

与“细粒度”在循环内部切换线程不同,“粗粒度”方案将整个协程任务流都放在同一个单线程内执行。

代码解析

1
2
3
4
5
6
val counterContext = newSingleThreadContext("CounterContext")
withContext(counterContext) { // 整个 massiveRun 都在单线程中
massiveRun {
counter++ // 无需切换,线程安全
}
}
  • 性能提升:由于去掉了频繁的线程切换开销,执行速度远快于方案 04。
  • 结果:Counter = 100000 (准确且高效)。

2. 开发者感悟

这种方案在 UI 开发(如 Android 主线程更新)中非常常见。如果你的业务逻辑本质上就是串行的,且涉及大量状态修改,直接在单线程作用域内运行它是最简单的同步手段。它比加锁更轻快,也更易于理解。

协程进阶:选择 05. 实现 SwitchMap (Deferred 切换)

本篇解析 example-select-05.kt。探讨如何利用 select 处理动态切换的任务流。

1. 核心概念:任务切换 (Switching)

在响应式编程中,switchMap 是一个经典操作:当有新的任务到来时,立即抛弃旧任务的执行结果,转而监听新任务。

实现逻辑:

  • 多源监听:同时监听“新任务的进入”和“当前任务的完成”。
  • 动态更新:一旦新任务进入,立即通过 select 的循环机制将其设为 current,从而实现逻辑上的“覆盖”。

2. 代码解析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fun CoroutineScope.switchMapDeferreds(input: ReceiveChannel<Deferred<String>>) = produce {
var current = input.receive() // 初始任务
while (isActive) {
val next = select<Deferred<String>?> {
input.onReceiveCatching { update -> // 监听新任务进入
update.getOrNull()
}
current.onAwait { value -> // 监听当前任务完成
send(value)
input.receiveCatching().getOrNull() // 任务完成后取下一个
}
}
if (next == null) break else current = next // 切换到新任务
}
}
  • 示例场景
    1. 发送任务 “BEGIN” -> 成功打印。
    2. 发送任务 “Slow” (耗时长) -> 紧接着发送 “Replace”。
    3. 结果:”Slow” 由于没跑完就被 “Replace” 顶替了,最终只打印了 “Replace”。

3. 开发者感悟

这是一个非常高级的 select 用法。它展示了协程如何通过“非阻塞选择”来实现复杂的流控制逻辑。在 Android 搜索功能中,如果用户连续输入关键词,我们可以通过这种机制确保只有最后一次输入的搜索结果被最终展示,中间由于输入过快产生的无效请求都会被忽略。

协程进阶:通道 05. 管道案例:素数序列 (Primes)

本篇解析 example-channel-05.kt。探讨如何利用管道进行复杂的数学计算。

1. 核心算法:埃拉托斯特尼筛法

通过不断地过滤掉已知素数的倍数,从而在无限整数序列中筛选出素数。

核心步骤:

  1. **numbersFrom(2)**:生成从 2 开始的无限整数流。
  2. 取出第一个数:它是当前流中的第一个素数(例如 2)。
  3. **filter(cur, 2)**:创建一个过滤器,把能被 2 整除的数全部拦住,产出一个新流。
  4. 循环往复:不断取出新流的第一个数,再加一个新的过滤器。

2. 开发者总结

这个例子展示了协程的极度轻量:每一个过滤器实际上都是一个新的协程在后台运行。即使叠加了几十层过滤器,协程也能保持高效的并发处理。这种将复杂计算通过管道解耦到微小任务(协程)中的思路,是高阶并发开发的精华。