Kotlin 协程之一:基础使用
系列文章:
Kotlin 协程之一:基础使用
Kotlin 协程之二:原理剖析
Kotlin 协程之三:Android中的应用
一.什么是协程
kotlin在1.3版本后,提供了协程coroutine库,协程提供了一种,简化异步任务处理的方案。
二.为什么用协程
1.简化代码,增加可读性
使用协程可以用简洁直观可读性高的写法,实现多重依赖关系的异步任务的书写。
先来回想一下我们平时的异步任务怎么实现呢?
(1).通过Callback回调的方式
//get token
TokenCall().addCallback(object:Callback{
override fun onCall(token: String) {
//get data with token
DataCall(token).addCallback(object :Callback{
override fun onCall(data: String) {
//do something with data...
}
})
}
})
这种经典实现的问题就不用多说了,代码量大,可读性差,还有最严重的回调地狱。
(2)通过链式调用
fun getToken():String{}
fun getData(token:String):String{}
Single.fromCallable {
//get token
getToken()
}.map {token->
//get data with token
getData(token)
}.subscribe {data->
//do something with data...
}
RXJava这种链式实现多重依赖的异步任务的方法,是现在我们用的最多的方案,可以有效地解决回调地狱的问题,相比于callback回调,可读性也增加了许多。
除了RXJava,java8提供的CompletableFuture、JS中提供的Promise,都是类似的解决方案。
这种写法虽然将异步任务过程打平的展现出来,但是毕竟还是在各个异步任务逻辑外面,套了一层RXJava的api,那有没有更简单的方法,只通过异步任务的逻辑代码的组合,就实现多重依赖的异步任务的处理呢?kotlin的协程为我们提供了一种方案。
(3)通过kotlin的协程
//挂起函数
suspend fun getToken() = suspendCancellableCoroutine<String>{}
suspend fun getData(token:String) = suspendCancellableCoroutine<String> {}
GlobalScope.launch {
//get token
val token = getToken()
//get data with token
val data = getData(token)
//do something with data
}
我们从launch{}中可以看到,使用了kotlin提供的协程,可以像写普通的方法顺序调用一样,来写有依赖关系的异步任务的处理,如此一来,可读性瞬间提升,且真正的逻辑处理代码量很少很少。
2.合理使用线程,减少性能损耗
协程依赖于线程的使用,但是挂起协程不会像挂起线程一样(如Thread.sleep())阻塞线程,因此挂起协程非常轻量级,不会阻塞线程的后续执行和复用。
除此之外还优化了线程的使用方式,可以有效地做到尽量复用线程,减少线程间切换所造成的性能损耗。
比如我们在一个子线程里,需要暂停两秒,我们会怎么做呢?
(1)使用Thread.sleep()方法
Thread {
Thread.sleep(2000)
}.start()
线程运行时,会阻塞线程2秒,在这2秒内,线程无法执行其他操作;并且sleep会使线程进入阻塞状态,导致线程状态切换,这一过程也需要很大的开销。
(2)使用协程的delay()方法
GlobalScope.launch {
delay(2000)
//do something after 2s
}
-
launch协程会运行在一个线程中,而delay方法只是将当前协程挂起,2秒后执行后面的代码;但是在这2秒内,该线程并没有因为协程的挂起而阻塞,而是相当于将该协程放入了个事件队列,在2秒后会继续执行后面的代码;所以在这2秒内,该线程可以继续用来执行其他任务(如线程池队列中有其他任务),避免了线程的阻塞状态的开销。
-
而且在协程默认的线程池中,处理逻辑也是更偏向于像当前线程的任务队列中添加任务,而不是开启新的工作线程;在多cpu情况下,会开启不超过cpu数的线程数,并可以从其他线程的任务队列中抢夺任务,这样就可以最大程度的复用已有的工作线程。
三.怎么使用协程
现在我们就来看看,如何使用协程处理异步任务。
1.工程配置
首先,我们要在工程里引入协程。
这里注意,kotlin相关库的版本最好都用1.3.+的版本,并且要符合gradle插件版本在3.0.0版本以上才可以使用。
//project.gradle
classpath 'com.android.tools.build:gradle:3.1.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.3.40"
//app.gradle
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.0-M2'
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.3.40"
2.协程作用域(协程运行环境)
引入了协程的开发环境,我们就可以使用了,首先,我们要知道,协程是一套管理和运行异步任务的框架,所以需要有运行的环境,也叫协程的作用域,在这个作用域里,才可以使用协程来执行异步任务;就相当于java中,我们把逻辑写在一个Runnable对象的run方法中才能在线程中运行一样。
(1)全局环境
GlobalScope.launch {}
GlobalScope代表协程的全局作用域,在该作用域启动的协程为顶层协程,没有父任务(下面会讲),且该scope没有Job对象(管理任务),所以无法对整个scope执行cancel()操作,所以如果没有手动取消每个任务,会造成这些任务一直运行(覆水难收),可能会导致内存泄露等问题。
(2)局部环境
CoroutineScope(Dispatchers.Main).launch {}
通常我们会通过创建CoroutineScope,来实现一个自己的协程作用域,并且可以指定派发器(下面会讲),在我们需要取消该scope下所有任务时(比如Activity退出时),调用scope.cancel()方法,就可以取消该scope下所有正在进行的任务,这才是我们所期望的。
3.协程派发器(任务执行环境)
有了运行环境执行异步任务,还需要有派发器将不同的任务派发到不同的线程执行,这也是我们经常遇到的,比如网络请求在工作线程,结果回来后的UI展示,需要在主线程进行。kotlin给我们提供了一些默认的Dispatcher:
-
Dispatchers.IO:工作线程池,依赖于Dispatchers.Default,支持最大并行任务数。
-
Dispatchers.Main:主线程,这个在不同平台定义不一样,所以需要引入相关的依赖,比如Android平台,需要使用包含MainLooper的handler来向主线程派发。
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.0-M2'
-
Dispatchers.Default:默认线程池,核心线程和最大线程数依赖cpu数量。
-
Dispatchers.Unconfined:无指定派发线程,会根据运行时的上线文环境决定。
通常我们用的就是1和2,在创建scope时,可以指定派发器,如CoroutineScope(Dispatchers.Main),就是指定该scope启动的协程,都在主线程执行。
4.启动协程任务
现在有了协程运行环境和任务执行环境,接下来要做的就是启动一个协程了!
(1)对于一个scope对象,通常使用launch和async方法创建协程。
CoroutineScope(Dispatchers.IO).launch { }
CoroutineScope(Dispatchers.IO).async { }
而两者的最大不同是,async会创建一个Deferred的协程,可以用来等待该协程执行完毕再进行后续操作。
CoroutineScope(Dispatchers.IO).launch {
val job = async { }
val data = job.await()
//do something with data
}
上述代码,在launch的协程执行到await()方法时,会将协程挂起(而不是线程挂起,不会阻塞线程),等待async异步任务执行完成后,会返回结果到data,从而进行后续逻辑。
当然,如果在调用await()方法时,async协程已经执行完毕拥有了结果,那么不会挂起协程,而是直接返回结果到data变量里。
(2)内部协程
CoroutineScope(Dispatchers.IO).launch {
launch {
async {
//do something
}
}
}
在一个协程内部,也可以创建协程,创建的协程与外部的协程为父子关系,这有助于联动的cancel、异常处理等(下面会说)。
(3)改变协程任务执行环境
加入我们在scope的一个协程内部,想创建一个运行在主线程的协程任务怎么办呢?这个也是必要的,比如我们在IO线程执行异步请求,数据回来后在主线程进行展示。
CoroutineScope(Dispatchers.IO).launch {
val dataJob = async {
//request data
}
async(Dispatchers.Main) {
//prepare
val data = dataJob.await()
//display with data
}
}
这是一个很经典的demo,我们创建了一个async协程执行网络请求,然后在主线程中创建一个协程任务,做一些准备工作,然后等待数据回来,在进行展示,在等待期间,主线程不会阻塞。
而假如我们需要在主线程展示数据后,继续发起一个请求也是可以的,demo中的最后一个async,会在withContext协程执行完后执行,也就是说,withContext协程会挂起外部launch协程,知道自己内部协程全部返回后,才会恢复外部的launch协程,执行后面的async协程,这一点也很有用。
5.协程挂起
协程可以顺序完成异步任务,那么在等待上一个协程任务完成时,当前的协程需要被挂起(不阻塞线程),这个协程挂起,就是kotlin协程实现的精髓。
CoroutineScope(Dispatchers.IO).launch {
val dataJob = async {
//request data
}
withContext(Dispatchers.Main) {
//prepare
val data = dataJob.await()
//display with data
}
//do some post async job after display data
async{ }
}
如上代码,我们需要先请求网络数据,再在主线程进行数据展示,展示后,我们需要再发送其他的请求。通常我们可以在得到data后,再使用一个async协程,并指定IO线程执行任务即可,那样的话又是很多嵌套,失去了协程的意义,而协程的挂起为我们提供了解决办法。
withContext()方法,除了可以指定他启动的协程任务的执行环境外,最重要的是,他会挂起当前协程,即外部的launch的协程,直到withContext启动的协程任务完成之后(包括其所有内部协程),才会重新恢复外部的launch协程,执行下面的async语句。协程的挂起也为我们提供了组合多个异步任务的可能性。
那withContext这个API的协程挂起和恢复是固定的,我们想自己写一个可以么?当然可以,这就用到kotlin协程为我们提供的API和关键字来声明方法即可。
CoroutineScope(Dispatchers.IO).launch {
//prepare
val data = getData()
//display with data
}
//挂起当前协程
private suspend fun getData() = suspendCoroutine<String> {
Call().addCallback(object : Callback {
override fun onCall(res: String) {
//恢复协程
it.resume(res)
}
})
}
使用suspend关键字,表明方法可以被挂起,称为挂起函数suspendCoroutine方法,会挂起当前的协程,并把挂起的协程返回到代码块的参数中,代码块执行我们自己的逻辑,如上,当数据回来后,调用协程的resume方法,传入结果,就可以恢复协程,并在getData()处拿到结果。
这就是协程的挂起,上面这个demo有没有发现,可以用来将我们现有的请求callback模式,转换为协程模式?这样就可以按协程的方式写请求了!
6.异常捕获
现在我们成功通过协程执行了一段代码,对于执行代码,必不可少就是对可能的异常进行捕获和处理。
kotlin的协程,也有一套自己的捕获异常机制。
//1.根协程为launch
CoroutineScope(Dispatchers.IO).launch {
async{ launch { throw IllegalStateException("this is an error") } }
}
//2.根协程为async
CoroutineScope(Dispatchers.IO).async {
async{ launch { throw IllegalStateException("this is an error") } }
}
//3.捕获async{}.await()异常
CoroutineScope(Dispatchers.IO).async {
try {
val job = async { throw IllegalStateException("this is an error") }
val data = job.await()
//do something with data
} catch (e: Exception) {
log(e.message)
}
}
先来简单描述下协程的异常机制:
-
在协程内部通过try-catch捕获的异常,由我们自己处理(和java异常一样)。
-
未捕获的异常,协程本身默认不处理,而是一层一层的交由父协程,直到根协程进行处理。
-
根协程处理异常时,会使用注册的CoroutineExceptionHandler对象进行处理;Android的协程依赖包,会引入并自动注册一个该对象,处理行为与java处理一致(直接交由UncaughtExceptionHandler)。
-
有些协程类型重写了处理异常方法,默认不处理异常,比如async式协程,这类协程作为根协程的话,最终会导致异常丢失,继续执行后续逻辑。
-
内部协程出现异常,会逐层cancel掉父协程。
据此机制,我们看下上面三个demo的异常处理情况:
-
根协程为launch式协程时,会使用Android提供的handler进行异常抛出,最终表现就是应用崩溃。
-
根协程为async式协程时,不会处理异常,最终表现就是没异常的抛出(但继续执行下去其实很危险)。
-
async式协程的await()方法,在返回异常时,会进行抛出,所以我们可以通过try-catch这个await()方法,来捕获async式协程产生的异常。
-
这里需要注意的是,如果根协程为launch式协程,即使我们使用(3)描述的办法,依然没有办法阻止崩溃,因为launch协程会处理异常并抛出。
综上所述,对于我们想捕获的异常,最靠谱的办法还是在协程内部自己捕获异常进行处理,避免因为未捕获而直接崩溃。
7.取消协程
现在我们已经可以完整的运行一个协程任务了,还有一个问题,就是如何取消协程呢?这个也很重要,比如Android中的网络请求等资源数据的加载,需要在页面关闭时中断,从而减少性能流量的损耗,以及避免一些内存泄露的问题。
//1.通过协程cancel()
val scope = CoroutineScope(Dispatchers.IO)
scope.launch {
launch {
while (true) {
log("inner-launch")
}
}
while (true) {
log("outer-launch")
}
}
scope.launch {
delay(1000)
scope.cancel()
}
//2.通过CorouinteScope.cancel)(
val scope = CoroutineScope(Dispatchers.IO)
val outerJob = scope.launch {
launch {
while (true) {
log("inner-launch")
}
}
while (true) {
log("outer-launch")
}
}
scope.launch {
delay(1000)
outerJob.cancel()
}
kotlin协程的取消规则是这样的:
-
父协程调用cancel(),会取消自己以及所有子(内部)协程。
-
子协程调用cancel(),默认不会取消父协程。
-
可以通过调用CoroutineScope的cancel()方法,取消掉该scope产生的所有协程。
据此,以上两个demo的行为是这样的:
-
outer和inner的协程全部被取消。
-
outer和inner以及scope开启的所有协程被取消。
但是,运行上面的demo我们会发现,log会一直输出东西,这是为什么呢?因为协程的cancel()原理是改变了协程对象的内部状态,但并没有终止逻辑代码的调用,也就是说协程状态和代码运行是两个部分,具体的原理我们在下面会说。那我们应该怎么办呢?
答案很简单,既然改变了协程的状态,那么我们用协程状态字段来判断协程是否被取消了即可,将判断条件代码改成如下:
while (isActive) {
log("outer-launch")
}
isActive是协程的一个状态字段。