本期做者:java
视频:扔物线(朱凯)android
文章:LewisLuo(罗宇)git
你们好,我是扔物线朱凯。github
终于到了协程的一期了。编程
Kotlin 的协程是它很是特别的一块地方:宣扬它的人都在说协程多么好多么棒,但多数人无论是看了协程的官方文档仍是一些网络文章以后又都以为彻底看不懂。并且这个「不懂」和 RxJava 是属于一类的:因为协程在概念上对于 Java 开发者来讲就是个新东西,因此对于大多数人来讲,别说怎么用了,我连它是个什么东西都没看明白。api
因此今天,我就先从「协程是什么」提及。首先仍是看视频。不过由于我一直不知道怎么在掘金发视频,因此你能够点击 这里 去哔哩哔哩看视频,能够点击 这里 去 YouTube 看。网络
这期内容主要是讲一个概念:什么是协程。由于这个概念有点难(其实看完视频你会发现它超级简单),因此专门花一期来说解。后面的内容会更劲爆,若是你喜欢个人视频,别忘了去 原视频 点个赞投个币,以及关注订阅一下,不错过个人任何新视频!多线程
如下内容来自文章做者 LewisLuo。闭包
码上开学 Kotlin 系列的文章,协程已是第五期了,这里简单讲一下咱们(扔物线和即刻 Android 团队)出品的 Kotlin 上手指南系列文章的一些考量:并发
咱们期许基于上述的考量和原则,把技术文章写得更加轻松易读,激发读者学习的兴趣,真正实现「上手」。
协程在 Kotlin 中是很是特别的一部分,和 Java 相比,它是一个新颖的概念。宣扬它的人都在说协程是多么好用,但就目前而言无论是官方文档仍是网络上的一些文章都让人难以读懂。
形成这种「不懂」的缘由和大多数人在初学 RxJava 时所遇到的问题实际上是一致的:对于 Java 开发者来讲这是一个新东西。下面咱们从「协程是什么」开始提及。
协程并非 Kotlin 提出来的新概念,其余的一些编程语言,例如:Go、Python 等均可以在语言层面上实现协程,甚至是 Java,也能够经过使用扩展库来间接地支持协程。
当在网上搜索协程时,咱们会看到:
做为 Kotlin 协程的初学者,这些概念并非那么容易让人理解。这些每每是做者根据本身的经验总结出来的,只看结果,而无论过程就不容易理解协程。
「协程 Coroutines」源自 Simula 和 Modula-2 语言,这个术语早在 1958 年就被 Melvin Edward Conway 发明并用于构建汇编程序,说明协程是一种编程思想,并不局限于特定的语言。
Go 语言也有协程,叫 Goroutines,从英文拼写就知道它和 Coroutines 仍是有些差异的(设计思想上是有关系的),不然 Kotlin 的协程彻底能够叫 Koroutines 了。
所以,对一个新术语,咱们须要知道什么是「标准」术语,什么是变种。
当咱们讨论协程和线程的关系时,很容易陷入中文的误区,二者都有一个「程」字,就以为有关系,其实就英文而言,Coroutines 和 Threads 就是两个概念。
从 Android 开发者的角度去理解它们的关系:
NetworkOnMainThreadException
,对于在主线程上的协程也不例外,这种场景使用协程仍是要切线程的。协程设计的初衷是为了解决并发问题,让 「协做式多任务」 实现起来更加方便。这里就先不展开「协做式多任务」的概念,等咱们学会了怎么用再讲。
视频里讲到,协程就是 Kotlin 提供的一套线程封装的 API,但并非说协程就是为线程而生的。
不过,咱们学习 Kotlin 中的协程,一开始确实能够从线程控制的角度来切入。由于在 Kotlin 中,协程的一个典型的使用场景就是线程控制。就像 Java 中的 Executor
和 Android 中的 AsyncTask
,Kotlin 中的协程也有对 Thread API 的封装,让咱们能够在写代码时,不用关注多线程就可以很方便地写出并发操做。
在 Java 中要实现并发操做一般须要开启一个 Thread
:
☕️
new Thread(new Runnable() {
@Override
public void run() {
...
}
}).start();
复制代码
这里仅仅只是开启了一个新线程,至于它什么时候结束、执行结果怎么样,咱们在主线程中是没法直接知道的。
Kotlin 中一样能够经过线程的方式去写:
🏝️
Thread({
...
}).start()
复制代码
能够看到,和 Java 同样也摆脱不了直接使用 Thead
的那些困难和不方便:
咱们能够用 Java 的 Executor
线程池来进行线程管理:
🏝️
val executor = Executors.newCachedThreadPool()
executor.execute({
...
})
复制代码
用 Android 的 AsyncTask
来解决线程间通讯:
🏝️
object : AsyncTask<T0, T1, T2> {
override fun doInBackground(vararg args: T0): String { ... }
override fun onProgressUpdate(vararg args: T1) { ... }
override fun onPostExecute(t3: T3) { ... }
}
复制代码
AsyncTask
是 Android 对线程池 Executor
的封装,但它的缺点也很明显:
看到这里你很天然想到使用 RxJava 解决回调地狱,它确实能够很方便地解决上面的问题。
RxJava,准确来说是 ReactiveX 在 Java 上的实现,是一种响应式程序框架,咱们经过它提供的「Observable」的编程范式进行链式调用,能够很好地消除回调。
使用协程,一样能够像 Rx 那样有效地消除回调地狱,不过不管是设计理念,仍是代码风格,二者是有很大区别的,协程在写法上和普通的顺序代码相似。
这里并不会比较 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
为例,来看看什么是闭包:
🏝️
// 建立一个 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<User>() {
@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:$kotlin_coroutines"
// 👇 依赖当前平台所对应的平台库
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
就可以编译经过:
🏝️
//👇
suspend fun getImage(imageId: Int) = withContext(Dispatchers.IO) {
...
}
复制代码
本篇文章到此结束,而 suspend
具体是什么,「非阻塞式」又是怎么回事,函数怎么被挂起,这些疑问的答案,将在下一篇文章所有揭晓。
LewisLuo(罗宇) ,即刻 Android 工程师。2019 年加入即刻,参与即刻日记功能的开发和迭代及中台基础建设。曾就任于 mobike,负责国际化业务开发。