Kotlin 协程使用手册

最近抽出闲暇,把 kotlinx.coroutines 官方的三份入手指南翻译了一下,挂在了 GitBook ,可以直接去这里查看。不过,文档的内容其实还是比较多的,为了厘清协程的特殊之处,下面我就总结一番。

协程是什么

协程的定义其实不太好描述,那我干脆由用途及定义,简述一下协程。

轻量级的线程

标题的说法可能不太准确,但也能一窥其功用。协程是工作在线程之上的。我们知道线程是由系统(语言系统或者操作系统)进行调度的,切换时有着一定的开销。而协程,它的切换由程序自己来控制,无论是 CPU 的消耗还是内存的消耗都大大降低。

从这一点出发,它的应用场景可能就在于提高硬件性能的瓶颈。譬如说,你启动十万个协程不会有什么问题,但你启动十万个线程试试?

可暂停的程序

相较于第一点,这才是协程的本质;同时也是由这一点,协程发挥了很大的作用。在协程中,某段代码可以暂停,转而去执行另外的协程代码;被暂停的代码也可以在你的控制下随时恢复运行。

这在前端编程中有一个很大的用处 —— 避免回调地狱。就 Android 编程而言,在 Rx 之前,要获取某个异步操作的返回结果,标准做法就是定义接口,用回调来接收结果。而 Rx 出现之后,以其巧妙的转换,通过响应式的代码,以一层的回调(辅以 lambda 表达式,看起来就像没有回调一样)链解决了回调地狱的问题。但在这里,习惯以命令式写法写代码的同学就需要稍稍理解一些函数式的编程思维了。协程不一样,它的代码是可以暂停的!也就是说,在我通过 getUser() 方法异步获取数据的时候,调用它的代码块就可以选择挂起,等到获取到数据,再恢复运行。代码看起来就这样:

1
val user = getUser() // 这儿的 getUser 就是 suspend function

是不是和同步代码看起来一样?

写过 JS 的同学可能就觉着很眼熟了:

1
2
3
4
5
6
7
8
async function getUser() {
try {
const response = await fetchUser();
// ...
} catch (e) {
// error handle
}
}

没错,通过协程,Kotlin 是可以写出类似代码来的!

协程的使用

协程的骨架

首先,需要通过构造器来启动协程。官方目前提供的基础构造器有两个:

  • launch
  • runBlocking

它们都会启动一个协程,区别在于前者不会阻塞当前线程,并且会返回一个协程的引用,而后者会等待协程的代码执行结束,再执行剩下的代码。

其次,关于协程,Kotlin 新增了一个关键字:suspend ,被该关键字修饰的函数 / 方法 / 代码块只能由协程代码(也就是上述构造器的代码块参数内部)或者被 suspend 修饰的函数 / 方法 / 代码块调用。说简单一点,suspend fun 只能被 suspend fun 调用(协程构造器的最后一个参数的类型声明就是 suspend CoroutineScope.() -> Unit)。

知道了这两点,就可以写出最简单的协程代码:

1
2
3
4
5
6
7
8
9
10
11
fun main(args: Array<String>) {
repeat(100_000) { // 启动十万个协程试试
launch { suspendPrint() }
}
Thread.sleep(1200) // 等待协程代码的结束
}

suspend fun suspendPrint() {
delay(1000)
println(".")
}

其中的 delay 就是一个 suspend fun

除了以上两点,另一个很重要的概念就是上下文context)。协程虽然是依赖于线程的,但一个协程并非就绑死在一个线程上。启动协程的时候可以指定上下文,在协程内部也可以通过 withContext 切换上下文。而这个上下文,也就是一个 CoroutineDispatcher 类的对象,从名字可以看出,就是由它去进行协程调度。比如,如果你需要新建一个线程去跑协程的代码,可以这样:

1
launch(context = newSingleThreadContext("new-thread")) { delay(1000) }

以上三点是我个人认为重要的内容,当然还有协程的取消、协程的生命周期、协程与子协程的关系等等,这些要点可以去官方文档或者我的翻译查看,内容写得很棒。

常规操作

async 与 await

就我个人所知,asyncawait 作为 JS 与 C# 的两个关键字,精简了异步操作(当然,这两门语言的细节并不一样)。但是在 Kotlin 中,async 其实是一个普通的函数:

1
2
3
4
5
6
7
8
9
fun main(args: Array<String>) = runBlocking<Unit> {
val result: Deferred<String> = async { doSomethingTimeout() }
println("I will got the result ${result.await()}")
}

suspend fun doSomethingTimeout(): String {
delay(1000)
return "Result"
}

在这里, async 代码块会新启动一个协程后立即执行,并且返回一个 Deferred 类型的值,调用它的 await 方法后会暂停当前协程,直到获取到 async 代码块执行结果,当前协程才会继续执行。

其实谈到这个,就不得不提一下 Retrofit 了,作为 RESTful 架构的优秀解决方案,有人已经为其适配了协程版的 adapter 了。我知道的有两个:

其实前者并不是 Retrofit 的 Adapter,Andrey Mischenko 只是为 Call 类添加了扩展函数而已。但是它们都是使用 Deferred 对象来处理结果。

channel 相关

这儿有个 channel 的概念,顾名思义,它的作用就在于收发事件。调用它的 sendreceive 方法,就是最简单的使用了。不过要注意,这两个方法会互相等待,所以它们肯定得运行在不同的协程。

1
2
3
4
5
6
7
8
9
fun main(args: Array<String>) = runBlocking<Unit> {
val channel = Channel()
launch {
for (x in 1..5) channel.send(x)
channel.close()
}
for (x in 1..5) println(channel.receive())
// or `for (x in channel) println(x)`
}

如上所示,channel 其实本身就可以迭代,迭代的结束条件就是 channel.close()

一个自定义的 onClick 方法

官方文档提供了一个 channel 版的 onClick 方法的实现,我觉得比较好用:

1
2
3
4
5
6
7
8
fun View.onClick(action: suspend (View) -> Unit) {
val eventActor = actor<View>(UI) {
for (event in channel) action(event)
}
setOnClickListener {
eventActor.offer(it)
}
}

这里的 actor 内部有一个 channel 用于接收外部的数据,点击事件产生的时候,通过 actor 向其发送数据,channel 迭代就会向前移动,调用传入的 action 。这里还可以通过参数处理背压的问题。

从这个应用可以延展开来,凡是由事件触发的操作,都可以用类似的思路来实现。当然,无论实现方式的好与坏。

自定义 Rx 操作符

截至目前来看,协程与 Rx 似乎不能共存,它们的功用大多重复,导致许多场景非此即彼。不过通过官方的第三份手册,我才发现协程还专门为 Rx 写了一个模块,让我们能够以协程的方式写 Rx 代码。需要介绍的是 publish 函数,他就是两者的桥梁:

1
2
3
4
fun range(context: CoroutineContext, start: Int, count: Int): Publisher<Int> = 
publish<Int>(context) {
for (x in start until start + count) send(x)
}

publish 内部可以用 channel 的方式去组织代码,通过 send 方法将数据流向下一级,它返回的 Publisher 就是 Rx 标准中的那个,可以通过扩展方法 consumeEach 来接收每一项数据。

1
range(CommonPool, 1, 5).consumeEach { println(it) }

最后

前后几天时间,翻译了三篇指南,切身体会到看一遍与写一遍的差距。这篇文章旨在罗列要点,许多细节并未说明,更详尽的内容还是需要文档。当然,也可以加入 kotlinlangcoroutine channel 参与讨论。