协程进阶:上下文 03. 协程日志调试

本篇解析 example-context-03.kt。学习如何看清协程的运行轨迹。

1. 核心技巧:打印线程名

在异步开发中,我们通过 Thread.currentThread().name 来确认当前代码跑在哪个线程上。

2. 协程调试模式

如果在 JVM 参数中开启了 -Dkotlinx.coroutines.debug,线程名中会自动附带协程的编号(如 [main @coroutine#1])。

3. 开发者感悟

调试协程时,最怕的就是“迷路”。由于协程会频繁在不同线程间挂起和恢复,养成在日志中打印线程名 and 协程 ID 的习惯,能帮你快速定位并发竞争或死锁问题。

协程进阶:异常 03. 取消信号与异常传播

本篇解析 example-exceptions-03.kt。探讨取消信号与普通异常在传播上的差异。

1. 核心概念:取消不是失败

在协程中,CancellationException 被视为正常的控制流程,而不是导致程序失败的异常。

传播规则:

  • 向下传播:父协程取消时,所有子协程都会被取消。
  • 不向上传播:子协程被 cancel() 取消时,不会导致父协程被取消。

2. 代码解析

1
2
3
4
5
6
7
8
9
10
11
12
13
val job = launch {
val child = launch {
try {
delay(Long.MAX_VALUE)
} finally {
println("Child is cancelled")
}
}
yield() // 让出执行权,让子协程运行
child.cancel() // 取消子协程
child.join()
println("Parent is not cancelled") // 父协程继续运行
}

3. 开发者感悟

这是一个非常关键的设计。它允许我们在不中断整体任务的情况下,灵活地停止其中的某一部分逻辑。在 Android 中,如果你在一个 Activity 里启动了两个下载任务,取消其中一个任务不应该导致另一个任务也被取消,这就是结构化并发带来的好处。

Flow 基础:03. 异步挂起函数与集合

本篇解析 example-flow-03.kt。探讨如何在异步场景下返回数据集合。

1. 代码解析

1
2
3
4
5
6
7
8
suspend fun simple(): List<Int> {
delay(1000) // 模拟异步耗时
return listOf(1, 2, 3)
}

fun main() = runBlocking {
simple().forEach { println(it) }
}

2. 特点与局限

  • 非阻塞:使用了 delay 而不是 Thread.sleep,挂起时不会占用线程。
  • 非流式:它必须等待 1 秒钟后,一次性返回 [1, 2, 3]
  • 无法实时展示:如果你希望在 1 秒后返回 1,再过 1 秒后返回 2,这种“细水长流”式的产出,普通的 List 是无法做到的。

3. 开发者感悟

当数据量很大,或者每个数据的生成都比较耗时,这种“等全家到齐再出发”的集合模式会造成很长的白屏等待。我们需要一种既能“异步执行”,又能“一个一个产出”的工具,这就是 Flow 登场的原因。

协程进阶:03. 取消与异常的关系

本篇解析 example-cancel-03.kt,探讨协程被取消后发生了什么。

1. 核心现象

  • CancellationException:当协程被取消时,内部的可挂起函数(如 delay)会抛出 CancellationException
  • 捕获行为:如果你在协程内部使用了 try-catch 并捕获了所有 Exception,你会发现你可以“拦截”到这个取消信号。

2. 开发者感悟

协程的取消本质上是通过抛异常实现的。

  • 如果你捕获了异常但没有重新抛出,协程逻辑上虽然结束了,但它可能依然在执行后续代码。
  • 切记:通常不建议捕获 CancellationException,除非你需要做特定的资源清理逻辑,且清理完后应当将其重新抛出。

Kotlin 协程基础 03:结构化并发 (Structured Concurrency)

在 Kotlin 协程中,coroutineScope 是实现结构化并发的核心构建器。它允许我们在挂起函数内部安全地启动新的协程,并确保任务的生命周期受到严格管理。


1. 核心概念:什么是结构化并发?

结构化并发意味着:在一个作用域内启动的协程,其生命周期被限制在该作用域内。父协程会等待所有子协程完成后才结束。

coroutineScope 的核心作用

  • 建立任务边界:它定义了一个局部作用域。在此块内启动的所有子协程(通过 launchasync)必须在 coroutineScope 块结束前全部完成。
  • **非阻塞式挂起 (Thread-Friendly)**:它是一个 suspend 函数。当它等待子协程完成时,它会释放当前线程供其他任务使用,而不会造成线程阻塞。
  • 异常联动与传播
    • 如果作用域内的任何一个子协程失败,整个作用域会立即取消其他子协程。
    • 它会将异常向上抛出给调用者,保证了任务的原子性。

2. 实际编码中的使用场景

场景 A:并行分解任务(多接口并发调用)

这是最常见的性能优化场景。当你需要同时从多个数据源获取数据,并等待所有结果返回后刷新 UI 时:

1
2
3
4
5
6
7
8
9
10
11
12
suspend fun loadDashboardData() = coroutineScope {
// 1. 同时发起两个异步请求
val userDeferred = async { api.getUserInfo() }
val postsDeferred = async { api.getRecentPosts() }

// 2. 等待两个结果都准备好
// 如果其中一个接口报错,另一个会自动取消,避免浪费资源
val user = userDeferred.await()
val posts = postsDeferred.await()

updateUI(user, posts)
}

场景 B:保证挂起函数的内部完整性

当你封装一个复杂的异步业务逻辑时,使用 coroutineScope 可以确保:当该函数返回时,它内部启动的所有后台任务已经彻底结束。这能有效防止“协程泄漏”(即函数执行完了,但后台仍有任务在跑,占用内存)。


3. coroutineScope vs runBlocking

特性 coroutineScope runBlocking
性质 挂起函数 (suspend) 普通函数
线程行为 非阻塞:释放线程供其他任务使用 阻塞:死等直到内部逻辑完成
使用位置 业务逻辑、其他挂起函数内部 main 函数、单元测试、桥接代码
目的 并行化任务、局部作用域管理 启动顶层协程、阻塞线程等待

4. 代码解析 (example-basic-03.kt)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fun main() = runBlocking {
println("Program starting...")
doWorld()
println("Done!")
}

// 使用 coroutineScope 封装逻辑
suspend fun doWorld() = coroutineScope {
launch {
delay(1000L)
println("World!")
}
println("Hello")
}

执行流程分析:

  1. main 调用 doWorld
  2. doWorld 进入 coroutineScope
  3. launch 启动子协程(准备延迟 1 秒)。
  4. 立即打印 Hello(主流程不等待 launch)。
  5. coroutineScope 挂起,等待其内部 launch 完成。
  6. 1 秒后打印 **World!**。
  7. 所有子协程完成,coroutineScope 返回,doWorld 结束。
  8. main 打印 Done! 并退出。

5. 开发者感悟

coroutineScope 是 Android 开发中处理并发任务的推荐做法。它不仅让代码逻辑看起来像同步代码一样清晰,更重要的是它通过“结构化”的约束,从根源上解决了异步任务管理混乱和内存泄漏的问题。

3. 无重复字符的最长子串

题目

给定一个字符串 s ,请你找出其中不含有重复字符的 最长子串 的长度。

示例 1:

1
2
3
输入: s = "abcabcbb"
输出: 3
解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。

示例 2:

1
2
3
输入: s = "bbbbb"
输出: 1
解释: 因为无重复字符的最长子串是 "b",所以其长度为 1。

示例 3:

1
2
3
4
输入: s = "pwwkew"
输出: 3
解释: 因为无重复字符的最长子串是 "wke",所以其长度为 3。
请注意,你的答案必须是 子串 的长度,"pwke" 是一个子序列,不是子串。

提示:

  • 0 <= s.length <= 5 * 10^4
  • s 由英文字母、数字、符号和空格组成

简介

1
2
3
4
5
巧妙的使用 HashMap<Char, Int> 记录每个字符的最新的位置
巧妙的确定了每个元素对应的滑动窗口的左边界
关键词:
HashMap <Char, Int>用以查找位置
窗口长度不固定,遍历指针作为左边界

正文

本题的巧妙在于使用 HashMap 和 遍历指针构建了一个滑动窗口

在寻找滑动窗口的时候,我们总是固定住一端位置去寻找另一端的位置,通常需要我们找到两个端点之间的关系

来分析滑动窗口的性质:左边届位置,右边界的位置,滑动窗口的长度
三者有以下这些关系:滑动窗口的长度 = 滑动窗口右边届 - 滑动窗口左边界
无论算法如何变化,我们知二求一

我们分析,遍历指针和左右边界的关系有三
情况一:遍历指针是滑动窗口的左边界
情况二:遍历指针是滑动窗口的右边界
情况三:遍历指针在滑动窗口的中间

结合滑动窗口的性质
情况一:知道窗口的长度,以遍历指针为左边界
情况二:窗口长度不固定,新增的元素决定窗口的长度,也就是左边界的位置
情况三:对于滑动窗口算法,通常情况下遍历指针要么位于窗口的左边界,要么位于右边界,用于控制窗口的扩展和收缩。在常规的滑动窗口算法中,遍历指针并不位于窗口的中间位置。

显然这种是情况二

当滑动窗口的位置和长度变化受制于新增的元素时,我们将遍历指针设置为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
fun lengthOfLongestSubstring(s: String): Int {
var maxLen = 0 // 最长不含重复字符子串的长度
var left = 0 // 窗口左边界
val map = HashMap<Char, Int>() // 哈希表记录字符最后出现的位置

for(right in s.indices){
val char = s[right]

if(map.containsKey(char) && map[char]!! >= left){
left = (map[char]?: 0) + 1
}

map[char] = right
maxLen = maxOf(maxLen, right - left + 1)
}
return maxLen
}
}

协程进阶:同步 04. 细粒度线程限制 (Fine-grained)

本篇解析 example-sync-04.kt。探讨一种利用单线程环境解决并发冲突的方案。

1. 核心概念:单一线程限制

如果所有的修改操作都发生在一个固定的单线程内,那么并发冲突就自然消失了。

代码解析

1
2
3
4
5
6
val counterContext = newSingleThreadContext("CounterContext")
massiveRun {
withContext(counterContext) { // 每次加法都切回单线程
counter++
}
}
  • 代价:由于每次加法都要从多线程池切回单线程,再切回去,性能开销极大

2. 开发者感悟

这种细粒度的切换(在循环内部 withContext)是非常低效的。在实际开发中,我们应该尽量避免在高频循环中切换线程。这种方案虽然结果准确,但速度非常慢。

协程进阶:选择 04. 在 Await 上选择 (onAwait)

本篇解析 example-select-04.kt。探讨如何获取一组并发任务中最快返回的那个结果。

1. 核心操作:onAwait

select 可以用于监听 Deferred 对象的完成。

特点:

  • 非阻塞等待:当有多个并发的 async 任务时,谁先返回,就执行谁的 onAwait 代码块。
  • 自动忽略其他:一旦其中一个任务返回并被选中,其余的任务依然在后台运行,但它们的结果在本次 select 中会被忽略。

2. 代码解析

1
2
3
4
5
6
7
8
9
val list = asyncStringsList() // 启动 12 个随机延迟的 async 任务
val result = select<String> {
list.withIndex().forEach { (index, deferred) ->
deferred.onAwait { answer ->
"Deferred $index produced answer '$answer'"
}
}
}
println(result) // 打印第一个产出的结果

3. 开发者感悟

onAwait 配合 select 是处理“多源竞争”的最佳实践。在 Android 中,如果你从缓存(磁盘)和网络同时拉取数据,你可以通过这种方式展示“最快到达”的数据,显著缩短用户的等待感。

协程进阶:通道 04. 管道模式 (Pipelines)

本篇解析 example-channel-04.kt。学习如何将多个协程串联起来处理数据。

1. 核心概念:管道 (Pipeline)

管道是一种设计模式:一个协程生产无穷序列,另一个协程接收并处理这些序列,最后返回一个新的序列。

特点:

  • 流式处理:数据像水流一样,在不同的“阀门”(协程)间流转。
  • 非阻塞:每个阶段都是异步执行的,不会阻塞整体任务。

2. 开发者感悟

管道是函数式编程在协程中的体现。在 Android 中,如果你需要对图片进行滤镜处理,你可以创建一个管道:第一个协程读文件,第二个协程缩放,第三个协程加滤镜,第四个协程存文件。每个阶段各司其职,代码耦合度极低。

协程进阶:组合 04. 异步函数风格

本篇解析 example-compose-04.kt。探讨一种看似“方便”但在协程中不被推荐的编程风格。

1. 核心现象:全局作用域异步

有些开发者喜欢通过 GlobalScope.async 封装一个普通函数,使其变为异步返回 Deferred 的函数。

缺点:

  • 不具有结构化:如果在调用这些函数和 await() 之间发生了异常,后台协程可能依然在运行,造成资源浪费或逻辑冲突。
  • 风险高:这种模式绕过了父子作用域的级联关系,让异常处理变得非常棘手。

2. 开发者感悟

这种风格虽然模仿了 JavaScript 或 C# 的异步模式,但在 Kotlin 协程中被视为“反模式”。在 Android 开发中,应优先使用 suspend 函数或 coroutineScope,而不是直接在普通函数里启动全局异步任务。