协程进阶:通道 10. Ticker 计时器 (Ticker Channels)

本篇解析 example-channel-10.kt。学习如何创建一个定时产出信号的通道。

1. 核心概念:ticker()

ticker() 是一个特殊的通道,它会按照固定的时间间隔发送 Unit

核心参数:

  • delayMillis:产出信号的周期(如每 100ms 一发)。
  • initialDelayMillis:启动前的初始延迟。

2. 核心特点:补偿机制

Ticker 通道具有自适应特性。

  • 消费慢时:如果消费者处理慢了,下一次产出的时间会相应缩短,以尽量维持总体的产出频率。
  • 非阻塞:当没有人接收时,它是合并(Conflated)的,不会在后台无限堆积信号。

3. 开发者感悟

ticker 是实现定时任务(如心跳包、UI 刷新、超时检测)的利器。相比于 while(true) { delay(100) }ticker 在高并发环境下具有更好的时间精确度和资源调度能力。在 Android 界面需要每秒更新一次倒计时时,使用 ticker 是最专业且低功耗的方案。

协程进阶:上下文 10. 管理 CoroutineScope

本篇解析 example-context-10.kt。探讨如何让协程的生命周期与组件(如 Activity)对齐。

1. 核心概念:手动控制作用域

为了防止内存泄漏,我们必须在 Activity 销毁时停止所有后台任务。

核心步骤:

  1. 创建作用域val mainScope = CoroutineScope(Dispatchers.Main)
  2. 启动协程:使用这个作用域发起的 mainScope.launch { ... }
  3. 统一取消:在 Activity 销毁时调用 mainScope.cancel()

2. 开发者感悟

CoroutineScope 是所有协程的“家”。当 Activity 销毁时,我们“拆掉”这个家,里面所有的子协程都会被自动清理。在现代 Android 开发中,推荐使用 lifecycleScope,它已经帮你自动完成了这一过程。

Flow 基础:10. Take 限速操作符 (Size-limiting)

本篇解析 example-flow-10.kt。学习如何限制 Flow 的产出数量。

1. 核心概念

take(n) 操作符类似于 List 的同名函数,它只截取流中的前 n 个元素。

关键点:取消机制

  • 截断并取消:当收集到第 n 个元素后,take 会自动取消对应的协程,并触发 Flow 构建器的 finally 块。

2. 代码解析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fun numbers(): Flow<Int> = flow {
try {
emit(1)
emit(2)
println("This line will not execute") // 这行不会执行
emit(3)
} finally {
println("Finally in numbers") // 取消时会触发
}
}

fun main() = runBlocking {
numbers().take(2).collect { println(it) }
}
  • 虽然 flow 块中写了三个 emit,但 take(2) 在拿到前两个后就通过抛出异常(内部取消)的方式结束了流。

3. 开发者感悟

take 在处理无限流或大数据流时非常有用。例如:监听传感器数据,但只要前 10 条结果就结束。它能确保协程被及时取消,不会浪费 CPU 资源。

协程进阶:10. 安全的资源清理实践

本篇解析 example-cancel-10.kt,给出解决超时下资源泄漏的终极方案。

1. 核心改进

对比示例 09,本例通过标准的 try-finally 结构重构了逻辑。

代码解析

1
2
3
4
5
6
7
8
9
10
11
12
launch {
var resource: Resource? = null // 1. 先在外部声明变量
try {
withTimeout(60) {
delay(50)
resource = Resource() // 2. 在超时块内分配资源并赋值
}
} finally {
// 3. 无论超时是否发生,只要变量不为空,就执行清理
resource?.close()
}
}

2. 为什么这样安全?

  • 原子性保障:即使 withTimeout 抛出了异常,执行流也会跳转到 finally
  • 清理可靠:在 finally 中检查 resource。如果资源已经成功创建,它一定会被关闭。
  • 结果:无论运行多少次,acquired 变量的值最终一定为 0。

3. 开发者感悟

在处理诸如数据库连接、Socket、文件句柄等重要资源时,永远不要假设代码会按部就班地执行完。异步环境(如协程取消、超时、异常)无处不在。使用 try-finally 是每一位合格协程开发者的基本功。

协程进阶:上下文 11. 线程局部变量与协程

本篇解析 example-context-11.kt。探讨如何解决协程切换带来的线程局部变量失效问题。

1. 核心概念:asContextElement

由于协程会跨线程执行,直接使用 ThreadLocal 无法保证在协程恢复后依然能拿到之前设置的数据。

解决方案:

  • **asContextElement()**:将 ThreadLocal 变量包装为协程上下文。
  • 特性:每当协程恢复执行时,它会自动从上下文中取出对应的值,重新设置到当前线程的 ThreadLocal 中。

2. 代码解析

1
2
3
4
5
6
7
8
9
10
11
val threadLocal = ThreadLocal<String?>()

fun main() = runBlocking {
threadLocal.set("main")
// 通过 asContextElement 传递数据给子协程
val job = launch(Dispatchers.Default + threadLocal.asContextElement(value = "launch")) {
println(threadLocal.get()) // 拿到 "launch"
yield()
println(threadLocal.get()) // 挂起后恢复,依然拿到 "launch"
}
}

3. 开发者感悟

asContextElement 是连接“旧世界”(基于 ThreadLocal 的各种日志、事务管理等)与“新世界”(协程)的桥梁。在 Android 开发中,如果需要传递 TraceID 或全局状态,这种机制是不可或缺的。

Flow 基础:11. 末端操作符 (Terminal Operators)

本篇解析 example-flow-11.kt。探讨如何触发 Flow 的执行并获取最终结果。

1. 核心概念

Flow 是冷的,必须通过末端操作符来启动收集。

常用末端操作符:

  • collect:最基础的,逐个处理发射出的值。
  • toList / toSet:将流中的所有值收集到集合中。
  • first / single:只取第一个值,或确保流中只有一个值。
  • reduce / fold:对流中的值进行累加计算。

2. 代码解析 (reduce)

1
2
3
4
val sum = (1..5).asFlow()
.map { it * it } // 1, 4, 9, 16, 25
.reduce { a, b -> a + b } // 计算总和:55
println(sum)
  • reduce 会将流中的第一个元素作为初始值,然后依次与后续元素进行计算。

3. 开发者感悟

末端操作符是“收网”的过程。在 Android 中,最常用的是 collect(用于更新 UI)和 first(用于从 DataStore 或数据库中获取单次配置信息)。

Flow 基础:12. 流的连续性 (Flows are Sequential)

本篇解析 example-flow-12.kt。理解 Flow 的执行顺序。

1. 核心概念

Flow 的收集是连续执行执行的。

连续性的含义:

  • 默认情况下,对于流中的每一个元素,它都会按顺序通过所有的操作符(filter, map 等),最后到达 collect
  • 只有当这一个元素被处理完后,流才会开始发射下一个元素。

2. 代码解析

1
2
3
4
5
6
7
8
9
10
11
(1..5).asFlow()
.filter {
println("Filter $it")
it % 2 == 0
}
.map {
println("Map $it")
"string $it"
}.collect {
println("Collect $it")
}
  • 执行逻辑
    1. 发射 1 -> filter 不通过。
    2. 发射 2 -> filter 通过 -> map 转换 -> collect 打印。
    3. 重复以上过程直到 5。
  • 注意:它不是先过滤出所有偶数,再批量转换。而是一个个处理

3. 开发者感悟

这种连续性保证了逻辑的简单和预测性。你可以把它想象成工厂的流水线:一个零件走完所有工序后,下一个零件才上场。这种模式非常节省内存,因为同一时间只有一个数据在管道中流动。

Flow 进阶:13. 流的上下文 (Flow Context)

本篇解析 example-flow-13.kt。探讨 Flow 在默认情况下运行在哪个线程。

1. 核心准则:上下文保存 (Context Preservation)

Flow 的收集始终发生在调用末端操作符的协程上下文中。

规则解析:

  • 如果你在 runBlocking 中调用 collect,那么 Flow 构建器 (flow { ... }) 里的代码就会运行在 runBlocking 的线程中。
  • 这种特性被称为“上下文保存”。

2. 代码解析

1
2
3
4
5
6
7
8
fun simple(): Flow<Int> = flow {
log("Started simple flow") // [main]
for (i in 1..3) { emit(i) }
}

fun main() = runBlocking {
simple().collect { value -> log("Collected $value") } // [main]
}

3. 开发者感悟

这种设计非常安全且易于理解。作为 API 的提供者,你不需要关心调用者在哪个线程运行;作为调用者,你完全控制了数据的消费线程。

Flow 进阶:14. 改变上下文的错误方式 (withContext)

本篇解析 example-flow-14.kt。探讨为什么不能在 Flow 内部使用 withContext

1. 核心禁忌:Flow 不允许在发射逻辑中使用 withContext

如果你在 flow { ... } 内部尝试使用 withContext 来改变发射(emit)时的线程环境,程序会直接抛出 IllegalStateException

错误示范:

1
2
3
4
5
6
7
8
fun simple(): Flow<Int> = flow {
// ❌ 错误做法:在 flow 内部改变上下文
kotlinx.coroutines.withContext(Dispatchers.Default) {
for (i in 1..3) {
emit(i) // 这里会报错!
}
}
}

2. 为什么这样设计?

Flow 必须保证上下文保存属性。这意味着 emit 必须和 collect 在同一个上下文中。如果 flow 构建器能随意切换线程发数据,那么 collect 的代码就无法预测自己会在哪个线程运行,这会导致严重的线程安全问题。

3. 开发者感悟

这是一个非常重要的设计约束。它强迫开发者使用官方推荐的方式来切换线程,从而保持流的结构清晰且线程安全。如果你需要切换发射端的线程,请使用下一节介绍的 flowOn

Flow 进阶:15. 使用 flowOn 正确切换上下文

本篇解析 example-flow-15.kt。学习如何改变发射端的线程。

1. 核心操作符:flowOn

flowOn 是改变 Flow 发射上下文的唯一正确方式

工作原理:

  • 向上游生效flowOn 只会改变它之前(上游)的操作符或构建器的上下文。
  • 不影响下游:它不会改变 collect 或它之后的操作符的上下文。

2. 代码解析

1
2
3
4
5
6
7
8
9
10
11
12
fun simple(): Flow<Int> = flow {
for (i in 1..3) {
log("Emitting $i") // 这里运行在 Dispatchers.Default (由 flowOn 指定)
emit(i)
}
}.flowOn(Dispatchers.Default)

fun main() = runBlocking {
simple().collect { value ->
log("Collected $value") // 这里运行在 main (runBlocking 的上下文)
}
}

3. 开发者感悟

flowOn 实际上在后台创建了一个 Channel。它让发射端在线程 A 运行,收集端在线程 B 运行,实现了异步的并发生产和消费。在 Android 开发中,我们经常用它来在 IO 线程请求数据,然后在 Main 线程刷新 UI。