协程进阶:组合 05. 结构化并发并发 (coroutineScope)

本篇解析 example-compose-05.kt。学习一种更安全的并发组织方式。

1. 核心实践:使用 coroutineScope

将一组相关的异步任务封装在 coroutineScope 块中。

优势:

  • 异常级联:如果作用域内的任何一个 async 抛出异常,整个作用域都会被取消。
  • 自动等待coroutineScope 会自动等待其内部的所有子任务完成。

2. 开发者感悟

这是我最推荐的一种并发编程模式。它不仅让代码逻辑更加清晰,最重要的是通过“父子联动”的机制,极大地降低了由于个别任务失败而导致的资源泄漏风险。在 Android 的 Repository 层中,通过 coroutineScope 组合多个数据请求是处理复杂逻辑的最佳方案。

协程进阶:上下文 05. 任务句柄 (Job)

本篇解析 example-context-05.kt。探讨协程的标识与管理。

1. 核心概念:Job

每个协程都有一个 Job 对象,它是协程的唯一句柄。

如何获取?

你可以通过 coroutineContext[Job] 在协程内部直接获取当前的 Job 实例。

2. 开发者感悟

Job 不仅仅是一个 ID。它是你控制协程生命周期的遥控器。你可以通过它查询协程的状态(是否活跃、是否已取消、是否已完成),也可以通过它主动结束任务。

协程进阶:异常 05. 异常聚合 (Aggregation)

本篇解析 example-exceptions-05.kt。探讨当多个子协程同时抛出异常时,协程库是如何处理这些冲突的。

1. 核心概念:第一个异常获胜

当多个子协程因为不同的异常而失败时,基本的聚合规则是:“取第一个抛出的异常”

特点:

  • 第一个异常:作为主异常,传递给 CoroutineExceptionHandler
  • 后续异常:会被绑定到主异常的 suppressed 列表中(仅限 JDK 7+)。

2. 代码解析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
val handler = CoroutineExceptionHandler { _, exception ->
// 捕获到的是第一个异常:IOException
// 被抑制的是第二个异常:ArithmeticException
println("Caught $exception with suppressed ${exception.suppressed.contentToString()}")
}

val job = GlobalScope.launch(handler) {
launch {
try { delay(Long.MAX_VALUE) }
finally { throw ArithmeticException() } // 后发生的异常
}
launch {
delay(100)
throw IOException() // 先发生的异常
}
}

3. 开发者感悟

这是一个非常严密的并发错误处理机制。它保证了即便在复杂的并行任务中,也不会漏掉任何一个报错信息。所有的异常都能通过主异常的 suppressed 列表进行溯源。在调试高并发 Bug 时,检查 suppressed 列表是寻找真凶的关键。

Flow 基础:05. 冷流 (Cold Stream) 特性

本篇解析 example-flow-05.kt。探讨 Flow 的一个关键特性:冷流

1. 什么是冷流?

Flow 是一种冷流。这意味着:flow { ... } 构建器中的代码在调用 collect 之前不会运行

代码解析

1
2
3
4
5
6
7
8
9
10
11
12
13
fun simple(): Flow<Int> = flow { 
println("Flow started")
for (i in 1..3) {
delay(100)
emit(i)
}
}

fun main() = runBlocking {
val flow = simple() // 这里只是创建了 flow 对象,不会打印 "Flow started"
flow.collect { println(it) } // 第一次收集,打印 "Flow started"
flow.collect { println(it) } // 第二次收集,再次打印 "Flow started"
}

2. 特点

  • 重复执行:每次调用 collect,Flow 都会重新开始从头运行。
  • 资源节省:如果不收集,就不会产生任何计算开销。

3. 开发者感悟

冷流就像是“按需播放”的视频。你不点播放(collect),它就不会产生流量。这与 Channel(热流)不同,Channel 无论有没有人在听,都会在后台运行。在 Android 开发中,这种冷流特性非常适合处理数据库订阅或 API 轮询。

协程进阶:05. 资源清理与 finally 块

本篇解析 example-cancel-05.kt,探讨协程取消后的收尾工作。

1. 核心机制:finally

由于协程取消会抛出 CancellationException,我们可以利用标准的 try-finally 语法来确保清理逻辑。

  • 清理时机:无论协程是正常完成,还是中途被外部取消,finally 块里的代码都保证会被执行。

2. 实际应用

  • 关闭数据库连接。
  • 释放文件流。
  • 停止动画或网络轮询。

3. 开发者感悟

在编写复杂的协程逻辑时,始终要养成“随手关门”的习惯。将重要的资源回收逻辑放在 finally 中,是避免内存泄漏和资源浪费的关键。

Kotlin 协程基础 05:Job 与生命周期控制

本篇文档解析 example-basic-05.kt,探讨如何通过 Job 对象显式管理协程的执行顺序。

1. 核心概念:什么是 Job?

当调用 launch 启动一个协程时,它会返回一个 Job 对象。它是协程的句柄,用于控制协程的生命周期。

代码解析

1
2
3
4
5
6
7
val job = launch { 
delay(1000L)
println("World!")
}
println("Hello")
job.join() // 挂起并等待子协程完成
println("Done")
  • 异步运行launch 启动后立即返回,执行 println("Hello")
  • join() 的作用join() 会将当前协程(即 main)挂起,直到该 job 内部的逻辑(即 World!)完成。它确保了后续的 println("Done") 一定在 World! 之后打印。

2. 深入理解:Join 的本质

  • 语义理解join 的意思是“加入”。你可以理解为将 join() 之后的代码块“加入”到该 job 的任务流末端。
  • 非阻塞挂起job.join() 是一个挂起函数。它不会阻塞线程(CPU 仍可去处理其他事情),它只是暂停了当前协程的执行流程,等待 job 的信号。

3. 开发者总结

  1. launch 负责“启动”任务。
  2. Job 负责“管理”任务。
  3. join() 负责“同步”异步任务,使异步流程表现得像同步流程一样。

Kotlin 泛型进阶:in, out 与 reified

1. 泛型基础

泛型本质上是参数化类型。它让我们能够编写可以处理不同类型数据的代码,同时保持类型安全。

1
2
3
class Box<T>(t: T) {
var value = t
}

2. 型变 (Variance):out 与 in

这是 Kotlin 泛型中最难理解的部分。它的核心是为了解决:**List<String> 是否是 List<Any> 的子类?**

2.1 协变 (Covariance):out

  • 定义:如果 StringAny 的子类,那么 Producer<String> 也是 Producer<Any> 的子类。
  • 限制:只能从对象中读取(Produce),不能写入(Consume)。
  • 关键字out
1
2
3
4
5
// 声明处型变
interface Producer<out T> {
fun produce(): T // OK
// fun consume(item: T) // 编译错误:Type parameter T is declared as 'out'
}

2.2 逆变 (Contravariance):in

  • 定义:如果 StringAny 的子类,那么 Consumer<Any> 反而是 Consumer<String> 的子类。
  • 限制:只能向对象中写入(Consume),不能读取(Produce)。
  • 关键字in
1
2
3
4
interface Consumer<in T> {
fun consume(item: T) // OK
// fun produce(): T // 编译错误:Type parameter T is declared as 'in'
}

2.3 为什么需要型变?

Java 的泛型是不型变的(Invariant)。在 Java 中,List<Object> list = new ArrayList<String>(); 是不允许的。Kotlin 通过 outin 优雅地解决了生产与消费场景下的类型兼容问题。


3. 类型擦除与实化类型参数 (reified)

3.1 类型擦除

在运行时,泛型信息会被擦除。这意味着你不能在运行时直接判断一个对象的泛型类型:

1
2
// 错误示例
fun <T> isType(value: Any) = value is T // 编译错误:Cannot check for instance of erased type: T

3.2 reified 关键字

Kotlin 引入了 inline 函数配合 reified 关键字,使得我们可以在运行时访问泛型类型。

1
2
3
4
5
6
7
8
9
inline fun <reified T> isType(value: Any): Boolean {
return value is T // 此时是合法的!
}

// 实际应用场景:启动 Activity
inline fun <reified T : Activity> Context.startActivity() {
val intent = Intent(this, T::class.java)
startActivity(intent)
}

4. 泛型约束 (Upper Bounds)

我们可以通过 : 来限制泛型必须是某个类的子类。

1
2
3
4
5
6
7
8
9
// T 必须是 Number 或其子类
fun <T : Number> sum(a: T, b: T) {
// ...
}

// 多个约束使用 where 关键字
fun <T> copyData(source: T) where T : CharSequence, T : Appendable {
// ...
}

5. 星投影 (Star-projections) *

当你不知道或者不关心泛型的具体类型时,可以使用 *

  • List<*> 代表“我不知道这里面是什么类型,但我知道它一定是 Any? 的子类”。
  • 它是只读的(类似 out Any?)。

总结

  • **out (协变)**:生产者,子类到父类,只读。
  • **in (逆变)**:消费者,父类到子类,只写。
  • **reified**:打破类型擦除,运行时获取类型信息。
  • **:**:类型约束。

5. 最长回文子串

题目

给你一个字符串 s,找到 s 中最长的回文子串。

如果字符串的反序与原始字符串相同,则该字符串称为回文字符串。

示例 1:

1
2
3
输入:s = "babad"
输出:"bab"
解释:"aba" 同样是符合题意的答案。

示例 2:

1
2
输入:s = "cbbd"
输出:"bb"

提示:

  • 1 <= s.length <= 1000
  • s 仅由数字和英文字母组成

简介

1
2
3
4
5
介绍 Manacher 算法
理解回文串的对称性,减少与回文串相交的字符串的计算量
关键词
情况一:完全包含,直接赋值
情况二:部分相交。直接从后一位接续计算

正文

数据预处理: 首先回文子串有两种形式 奇数 与 偶数 也就有两种对应的指针操作方式

假定有字符数组 ababaabc 改成 # a # b # a # b # a # a # b # c # 将偶数数组变成奇数统一处理 索性改成 ^ # a # b # a # b # a # a # b # c # $,头尾清晰
这样就可以通过把每个字符作为回文子串的中心向两边扩展,找出最长回文子串 时间复杂度是 O(n^2)

现在需要我们观察回文子串的规律,简化计算

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
      0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8
^ # a # b # a # b # a # a # b # c # $
0 ^ P[0] = 0
1 ^ P[1] = 0
2 --^-- p[2] = 1
3 ^ P[3] = 0
4 ------^------ P[4] = 3
5 ^ P[5] = 0
6 ----------^---------- P[6] = 5
7 ^ P[7] = 0
8 ------^------ P[8] = 3
9 ^ P[9] = 0
10 --^-- P[10] = 1
11 --------^-------- P[11] = 2
12 --^-- P[12] = 0
13 ^ P[13] = 0
14 --^-- P[14] = 1
15 ^ P[15] = 0
16 --^-- P[16] = 1
17 ^ P[17] = 0
18 ^ P[18] = 0

情况一:

1
2
3
10                      --^--                P[10] = 1
11 --------^-------- P[11] = 2
12 --^-- P[12] = 0

第 10 行,第 12 行 都是 第 11 行 的子串,完全包含在 第 11 行 之中,由于回文串的对称性 此时直接有 P[10] = P[12]

情况二:

1
2
3
4       ------^------                        P[4]  = 3
6 ----------^---------- P[6] = 5
8 ------^------ P[8] = 3

第 4 行,第 8 行 都是 第 6 行 的子串,分别位于字符串的两端,当我们知道 第 4 行 的信息之后,我们知道 第 8 行 至少有 第 4 行 那么长,至于会不会更长,继续试着向两边扩展即可 此时需要干两件事 1. 将 P[8] = P[4]; 2. 继续向外扩展

情况三: 8 ——^—— P[8] = 3 11 ——–^——– P[11] = 2 14 –^– P[14] = 1 第 8 行 部分与 第 11 行 重叠,第 14 行 是 第 11 行 的子串,非常简单,舍弃超出部分 8 –^– P[8] = 3 11 ——–^——– P[11] = 2 14 –^– P[14] = 1 当成这样处理即可 定义遍历指针 i ,指向回文的中心的指针 center 和 指向回文串右边界的指针 r 此时需要干两件事 1. 将 P[8] = r - i ; 2. 继续向外扩展

好,我们现在已经理解了 Manacher 算法的精髓了 我们思考一下算法该怎么写

P[i] 计算过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
      0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8
^ # a # b # a # b # a # a # b # c # $
0 ^ P[0] = 0 -- 起始,不必计算,更新 r
1 ^ P[1] = 0 -- 暴力计算,一次计算,更新 r
2 --^-- p[2] = 1 -- 暴力计算,二次计算,更新 r
3 ^ P[3] = 0 -- 情况二,一次计算
4 ------^------ P[4] = 3 -- 暴力计算,四次计算,更新 r
5 ^ P[5] = 0 -- 情况一,零次计算
6 ----------^---------- P[6] = 5 -- 情况二,六次计算,更新 r
7 ^ P[7] = 0 -- 情况一,零次计算
8 ------^------ P[8] = 3 -- 情况二,一次计算
9 ^ P[9] = 0 -- 情况一,零次计算
10 --^-- P[10] = 1 -- 情况二,一次计算
11 --------^-------- P[11] = 2 -- 情况二,五次计算,更新 r
12 --^-- P[12] = 0 -- 情况一,零次计算
13 ^ P[13] = 0 -- 情况一,零次计算
14 --^-- P[14] = 1 -- 情况三,一次计算
15 ^ P[15] = 0 -- 情况二,一次计算
16 --^-- P[16] = 1 -- 暴力计算,两次计算,更新 r
17 ^ P[17] = 0 -- 情况二,一次计算
18 ^ P[18] = 0 -- 终止

我列出了每次计算面对的情况,计算的次数以及是否需要 r 我希望大家思考 当新计算出的 r 与旧的 r 相等时,是否应该更新 center ? 当然不应该,我们肯定更倾向于选择更长的回文串

是这样吗? 我们思考一种情况

1
2
3
4
5
6
7
8
9
2       --^--                                p[2]  = 1
3 ^ P[3] = 0
4 ------^------ P[4] = 3
5 ^ P[5] = 0
6 ----------^---------- P[6] = 5
7 ^ P[7] = 0
8 ------^------ P[8] = 3
9 ^ P[9] = 0
10 --^-- P[10] = 1

第 6 行 较长有什么用呢,有用的只是 i 到 r 这一小截而已,不更新是因为都一样,没必要更新,所以只有当我们发现了更右边的 r 更新即可

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
class Solution {

private fun formatString(s: String): String{
val tString : StringBuffer = StringBuffer("^#")
for ( i in s.indices ){
tString.append(s[i])
tString.append('#')
}
return tString.append('$').toString()
}

private fun extend(s: String, leftIndex: Int, rightIndex: Int): Int {
var r = 0
var left = leftIndex
var right = rightIndex
while (left > 0 && right < s.length && s[left] == s[right]){
r ++
left --
right ++
}
return r
}

fun longestPalindrome(s: String): String {
val tString = formatString(s)
var maxR = 0
var maxCenter = 0
var center = -1
var R = 0
val p = Array<Int>(tString.length) { 0 }

for ( i in tString.indices ){
val iMirror = center - (i - center)
var rightIndex = i
var leftIndex = i
val maxLength = R - i

val hasMirrorIndex = i < R && center - maxLength > 0
val case1CompletelyIncluded = hasMirrorIndex && i > center && p[iMirror] < R - i
val case2NotCompletelyInclude = hasMirrorIndex && !case1CompletelyIncluded

if(!hasMirrorIndex){
p[i] = 0
rightIndex = i + 1
leftIndex = i - 1
} else {
if(case1CompletelyIncluded) {
p[i] = p[iMirror]
continue
} else if (case2NotCompletelyInclude) {
p[i] = maxLength
rightIndex = R + 1
leftIndex = i - (rightIndex - i)
}
}

p[i] += extend(tString, leftIndex, rightIndex)

// 更新最右边的 R 和 center
if(i + p[i] > R){
R = i + p[i]
center = i
}

// 判断是不是最长的回文串
if(p[i] > maxR){
maxR = p[i]
maxCenter = i
}
}

if(maxR == 0) return ""

val start = maxCenter - maxR
val end = maxCenter + maxR
return tString.substring(start..end).replace("#", "")
}
}

协程进阶:同步 06. 互斥锁 (Mutex)

本篇解析 example-sync-06.kt。学习如何在协程中安全地保护共享状态。

1. 核心工具:Mutex

Mutex(Mutual Exclusion)是协程中的互斥锁,类似于 Java 的 synchronizedReentrantLock

关键区别:

  • 非阻塞挂起:不同于 ReentrantLock 会阻塞线程,Mutexlock() 是一个挂起函数。当锁被占用时,协程会挂起并释放线程,而不会死等。
  • withLock:便捷的扩展函数,保证了即使在业务逻辑报错时,锁也能被正确释放。

2. 代码解析

1
2
3
4
5
6
val mutex = Mutex()
massiveRun {
mutex.withLock { // 保护临界区
counter++
}
}
  • 结果:Counter = 100000 (准确)。

3. 开发者感悟

Mutex 是处理复杂并发逻辑(例如读写文件、数据库事务)时的终极武器。它保证了同一时间只有一个协程能进入“临界区”。由于它是非阻塞挂起的,它在性能上通常优于传统的线程锁。

协程进阶:通道 06. 扇出模式 (Fan-out)

本篇解析 example-channel-06.kt。学习如何将一个生产者的任务分发给多个消费者。

1. 核心概念:一发多收

扇出模式指:一个生产者向通道发送数据,多个消费者同时从同一个通道接收数据。

特点:

  • 负载均衡:多个消费者并发运行,每个消费者取走一部分数据。
  • 数据不重复:一个元素只能被其中一个消费者接收,绝不会被重复处理。

2. 代码解析

1
2
val producer = produceNumbers() // 生产者每 100ms 发一个数
repeat(5) { launchProcessor(it, producer) } // 启动 5 个并发消费者

3. 开发者感悟

在 Android 实际开发中,扇出模式常用于处理海量图片下载或并发网络请求。你只需要一个请求通道,后台启动 5-10 个协程作为消费者,就能极大地提高系统的整体吞吐量。