这里并不会比较 RxJava 和协程哪个好,或者讨论谁取代谁的问题,我这里只给出一个建议,你最好都去了解下,因为协程和 Rx 的设计思想本来就不同。
下面的例子是使用协程进行网络请求获取用户信息并显示到 UI 控件上:
️
launch({
val user = api.getUser() // 网络请求(IO 线程)
nameTv.text = user.name // 更新 UI(主线程)
})
这里只是展示了一个代码片段,launch
并不是一个顶层函数,它必须在一个对象中使用,我们之后再讲,这里只关心它内部业务逻辑的写法。
launch
函数加上实现在 {}
中具体的逻辑,就构成了一个协程。
通常我们做网络请求,要不就传一个 callback,要不就是在 IO 线程里进行阻塞式的同步调用,而在这段代码中,上下两个语句分别工作在两个线程里,但写法上看起来和普通的单线程代码一样。
这里的 api.getUser
是一个挂起函数,所以能够保证 nameTv.text
的正确赋值,这就涉及到了协程中最著名的「非阻塞式挂起」。这个名词看起来不是那么容易理解,我们后续的文章会专门对这个概念进行讲解。现在先把这个概念放下,只需要记住协程就是这样写的就行了。
这种「用同步的方式写异步的代码」看起来很方便吧,那么我们来看看协程具体好在哪。
在讲之前,我们需要先了解一下「闭包」这个概念,调用 Kotlin 协程中的 API,经常会用到闭包写法。
其实闭包并不是 Kotlin 中的新概念,在 Java 8 中就已经支持。
我们先以 Thread
《Android学习笔记总结+最新移动架构视频+大厂安卓面试真题+项目实战源码讲义》
浏览器打开:qq.cn.hn/FTe 开源分享
为例,来看看什么是闭包:
️
// 创建一个 Thread 的完整写法
Thread(object : Runnable {
override fun run() {
…
}
})
// 满足 SAM,先简化为
Thread({
…
})
// 使用闭包,再简化为
Thread {
…
}
形如 Thread {...}
这样的结构中 {}
就是一个闭包。
在 Kotlin 中有这样一个语法糖:当函数的最后一个参数是 lambda 表达式时,可以将 lambda 写在括号外。这就是它的闭包原则。
在这里需要一个类型为 Runnable
的参数,而 Runnable
是一个接口,且只定义了一个函数 run
,这种情况满足了 Kotlin 的 SAM,可以转换成传递一个 lambda 表达式(第二段),因为是最后一个参数,根据闭包原则我们就可以直接写成 Thread {...}
(第三段) 的形式。
对于上文所使用的 launch
函数,可以通过闭包来进行简化 :
️
launch {
…
}
前面提到,launch
函数不是顶层函数,是不能直接用的,可以使用下面三种方法来创建协程:
️
// 方法一,使用 runBlocking 顶层函数
runBlocking {
getImage(imageId)
}
// 方法二,使用 GlobalScope 单例对象
// 可以直接调用 launch 开启协程
GlobalScope.launch {
getImage(imageId)
}
// 方法三,自行通过 CoroutineContext 创建一个 CoroutineScope 对象
// 需要一个类型为 CoroutineContext 的参数
val coroutineScope = CoroutineScope(context)
coroutineScope.launch {
getImage(imageId)
}
方法一通常适用于单元测试的场景,而业务开发中不会用到这种方法,因为它是线程阻塞的。
方法二和使用 runBlocking
的区别在于不会阻塞线程。但在 Android 开发中同样不推荐这种用法,因为它的生命周期会和 app 一致,且不能取消(什么是协程的取消后面的文章会讲)。
方法三是比较推荐的使用方法,我们可以通过 context
参数去管理和控制协程的生命周期(这里的 context
和 Android 里的不是一个东西,是一个更通用的概念,会有一个 Android 平台的封装来配合使用)。
关于 CoroutineScope
和 CoroutineContext
的更多内容后面的文章再讲。
协程最常用的功能是并发,而并发的典型场景就是多线程。可以使用 Dispatchers.IO
参数把任务切到 IO 线程执行:
️
coroutineScope.launch(Dispatchers.IO) {
…
}
也可以使用 Dispatchers.Main
参数切换到主线程:
️
coroutineScope.launch(Dispatchers.Main) {
…
}
所以在「协程是什么」一节中讲到的异步请求的例子完整写出来是这样的:
️
coroutineScope.launch(Dispatchers.Main) { // 在主线程开启协程
val user = api.getUser() // IO 线程执行网络请求
nameTv.text = user.name // 主线程更新 UI
}
而通过 Java 实现以上逻辑,我们通常需要这样写:
☕️
api.getUser(new Callback() {
@Override
public void success(User user) {
runOnUiThread(new Runnable() {
@Override
public void run() {
nameTv.setText(user.name);
}
})
}
@Override
public void failure(Exception e) {
…
}
});
这种回调式的写法,打破了代码的顺序结构和完整性,读起来相当难受。
对于回调式的写法,如果并发场景再复杂一些,代码的嵌套可能会更多,这样的话维护起来就非常麻烦。但如果你使用了 Kotlin 协程,多层网络请求只需要这么写:
️
coroutineScope.launch(Dispatchers.Main) { // 开始协程:主线程
val token = api.getToken() // 网络请求:IO 线程
val user = api.getUser(token) // 网络请求:IO 线程
nameTv.text = user.name // 更新 UI:主线程
}
如果遇到的场景是多个网络请求需要等待所有请求结束之后再对 UI 进行更新。比如以下两个请求:
️
api.getAvatar(user, callback)
api.getCompanyLogo(user, callback)
如果使用回调式的写法,那么代码可能写起来既困难又别扭。于是我们可能会选择妥协,通过先后请求代替同时请求:
️
api.getAvatar(user) { avatar ->
api.getCompanyLogo(user) { logo ->
show(merge(avatar, logo))
}
}
在实际开发中如果这样写,本来能够并行处理的请求被强制通过串行的方式去实现,可能会导致等待时间长了一倍,也就是性能差了一倍。
而如果使用协程,可以直接把两个并行请求写成上下两行,最后再把结果进行合并即可:
️
coroutineScope.launch(Dispatchers.Main) {
// async 函数之后再讲
val avatar = async { api.getAvatar(user) } // 获取用户头像
val logo = async { api.getCompanyLogo(user) } // 获取用户所在公司的 logo
val merged = suspendingMerge(avatar, logo) // 合并结果
//
show(merged) // 更新 UI
}
可以看到,即便是比较复杂的并行网络请求,也能够通过协程写出结构清晰的代码。需要注意的是 suspendingMerge
并不是协程 API 中提供的方法,而是我们自定义的一个可「挂起」的结果合并方法。至于挂起具体是什么,可以看下一篇文章。
让复杂的并发代码,写起来变得简单且清晰,是协程的优势。
这里,两个没有相关性的后台任务,因为用了协程,被安排得明明白白,互相之间配合得很好,也就是我们之前说的「协作式任务」。
本来需要回调,现在直接没有回调了,这种从 1 到 0 的设计思想真的妙哉。
在了解了协程的作用和优势之后,我们再来看看协程是怎么使用的。
在使用协程之前,我们需要在 build.gradle
文件中增加对 Kotlin 协程的依赖:
build.gradle
:buildscript {
…
//
ext.kotlin_coroutines = ‘1.3.1’
…
}
build.gradle
:dependencies {
…
// 依赖协程核心库
implementation “org.jetbrains.kotlinx:kotlinx-coroutines-core: k o t l i n c o r o u t i n e s " / / 依 赖 当 前 平 台 所 对 应 的 平 台 库 i m p l e m e n t a t i o n " o r g . j e t b r a i n s . k o t l i n x : k o t l i n x − c o r o u t i n e s − a n d r o i d : kotlin_coroutines" // 依赖当前平台所对应的平台库 implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android: kotlincoroutines"//依赖当前平台所对应的平台库implementation"org.jetbrains.kotlinx:kotlinx−coroutines−android:kotlin_coroutines”
…
}
Kotlin 协程是以官方扩展库的形式进行支持的。而且,我们所使用的「核心库」和 「平台库」的版本应该保持一致。
完成了以上的准备工作就可以开始使用协程了。
协程最简单的使用方法,其实在前面章节就已经看到了。我们可以通过一个 launch
函数实现线程切换的功能:
️
//
coroutineScope.launch(Dispatchers.IO) {
…
}
这个 launch
函数,它具体的含义是:我要创建一个新的协程,并在指定的线程上运行它。这个被创建、被运行的所谓「协程」是谁?就是你传给 launch
的那些代码,这一段连续代码叫做一个「协程」。
所以,什么时候用协程?当你需要切线程或者指定线程的时候。你要在后台执行任务?切!
️
launch(Dispatchers.IO) {
val image = getImage(imageId)
}
然后需要在前台更新界面?再切!
️
coroutineScope.launch(Dispatchers.IO) {
val image = getImage(imageId)
launch(Dispatch.Main) {
avatarIv.setImageBitmap(image)
}
}
好像有点不对劲?这不还是有嵌套嘛。
如果只是使用 launch
函数,协程并不能比线程做更多的事。不过协程中却有一个很实用的函数:withContext
。这个函数可以切换到指定的线程,并在闭包内的逻辑执行结束之后,自动把线程切回去继续执行。那么可以将上面的代码写成这样:
️
coroutineScope.launch(Dispatchers.Main) { // 在 UI 线程开始
val image = withContext(Dispatchers.IO) { // 切换到 IO 线程,并在执行完成后切回 UI 线程
getImage(imageId) // 将会运行在 IO 线程
}
avatarIv.setImageBitmap(image) // 回到 UI 线程更新 UI
}
这种写法看上去好像和刚才那种区别不大,但如果你需要频繁地进行线程切换,这种写法的优势就会体现出来。可以参考下面的对比:
️
// 第一种写法
coroutineScope.launch(Dispachers.IO) {
…
launch(Dispachers.Main){
…
launch(Dispachers.IO) {
…
launch(Dispacher.Main) {
…
}
}
}
}
// 通过第二种写法来实现相同的逻辑
coroutineScope.launch(Dispachers.Main) {
…
withContext(Dispachers.IO) {
…
}
…
withContext(Dispachers.IO) {
…
}
…
}
由于可以"自动切回来",消除了并发代码在协作时的嵌套。由于消除了嵌套关系,我们甚至可以把 withContext
放进一个单独的函数里面:
️
launch(Dispachers.Main) { // 在 UI 线程开始
val image = getImage(imageId)
avatarIv.setImageBitmap(image) // 执行结束后,自动切换回 UI 线程
}
//
fun getImage(imageId: Int) = withContext(Dispatchers.IO) {
…
}
这就是之前说的「用同步的方式写异步的代码」了。
不过如果只是这样写,编译器是会报错的:
️
fun getImage(imageId: Int) = withContext(Dispatchers.IO) {
// IDE 报错 Suspend function’withContext’ should be called only from a coroutine or another suspend funcion
}
意思是说,withContext
是一个 suspend
函数,它需要在协程或者是另一个 suspend
函数中调用。
suspend
是 Kotlin 协程最核心的关键字,几乎所有介绍 Kotlin 协程的文章和演讲都会提到它。它的中文意思是「暂停」或者「可挂起」。如果你去看一些技术博客或官方文档的时候,大概可以了解到:「代码执行到 suspend
函数的时候会『挂起』,并且这个『挂起』是非阻塞式的,它不会阻塞你当前的线程。」
上面报错的代码,其实只需要在前面加一个 suspend
就能够编译通过: