Kotlin协程入门到实战全部三篇文章:
上一篇文章中我们对异步回调的本质做了比较深入的探讨,最终得出了异步回调就是代码的多线程顺序执行”的结论。接着引出了Kotlin协程可以实现顺序编写异步代码,自动进行线程切换的作用。既然Kotlin协程那么神奇,那具体该如何使用呢?
Kotlin协程不属于Kotlin语言本身,使用之前必须手动引入。在Android平台上使用可以添加Gradle依赖:
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.1.1'
首先看下如下代码:
GlobalScope.launch {
delay(1000L)
println("Hello,World!")
}
上述代码使用launch方法启动了一个协程,launch后面的花括号就是协程,花括号内的代码就是运行在协程内的代码。
接着来深入了解一下launch方法的声明:
public fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit): Job {...}
可以看到launch方法是CoroutineScope
的拓展方法,也就是说我们启动协程要在一个指定的CoroutineScope
上来启动。CoroutineScope
翻译过来就是“协程范围”,指的是协程内的代码运行的时间周期范围,如果超出了指定的协程范围,协程会被取消执行,上面第一段代码中的GlobalScope
指的是与应用进程相同的协程范围,也就是在进程没有结束之前协程内的代码都可以运行。除此之外为了方便我们的使用,在Google的Jetpack中也提供了一些生命周期感知型协程范围。实际开发中我们可以方便地选择适当的协程范围来为耗时操作(网络请求等)指定自动取消执行的时机,详情见:https://developer.android.google.cn/topic/libraries/architecture/coroutines
接着可以看下launch方法的其他参数:
context
:协程上下文,可以指定协程运行的线程。默认与指定的CoroutineScope
中的coroutineContext
保持一致,比如GlobalScope
默认运行在一个后台工作线程内。也可以通过显示指定参数来更改协程运行的线程,Dispatchers
提供了几个值可以指定:Dispatchers.Default
、Dispatchers.Main
、Dispatchers.IO
、Dispatchers.Unconfined
。start
:协程的启动模式。默认的(也是最常用的)CoroutineStart.DEFAULT
是指协程立即执行,除此之外还有CoroutineStart.LAZY
、CoroutineStart.ATOMIC
、CoroutineStart.UNDISPATCHED
。block
:协程主体。也就是要在协程内部运行的代码,可以通过lamda
表达式的方式方便的编写协程内运行的代码。CoroutineExceptionHandler
:除此之外还可以指定CoroutineExceptionHandler
来处理协程内部的异常。返回值Job
:对当前创建的协程的引用。可以通过Job
的start
、cancel
、join
等方法来控制协程的启动和取消。
启动协程不是只有launch
一个方法的,还有async
等其他方法可以启动协程,不过launch
是最常用的一种方法,其他的方法大家可以去自行了解。
回到上面的代码:
println("Start")
GlobalScope.launch(Dispatchers.Main) {
delay(1000L)
println("Hello World")
}
println("End")
首先通过GlobalScope.launch
启动了一个协程,这里指定协程运行的线程为主线程,接着协程内只有两行代码,协程启动之后就立即执行。首先直接输出了"Start"和"End",接着1秒钟后又输出了"Hello World"。这结果看起来看似顺理成章,因为我们使用非常相似的Thread
相关的代码也完全可以实现以上代码的效果:
println("Start")
Thread {
Thread.sleep(1000L)
println("Hello World")
}.start()
println("End")
两段代码看起来长得几乎一模一样,运行结果也完全一致。那究竟协程的神奇之处在哪里呢?顺序编写异步代码有体现在什么地方呢?
我们在上面两段代码的所有输出的位置上全部加上输出当前线程名的操作:
//协程代码
println("Start ${Thread.currentThread().name}")
GlobalScope.launch(Dispatchers.Main) {
delay(1000L)
println("Hello World ${Thread.currentThread().name}")
}
println("End ${Thread.currentThread().name}")
//线程代码
println("Start ${Thread.currentThread().name}")
Thread {
Thread.sleep(1000L)
println("Hello World ${Thread.currentThread().name}")
}.start()
println("End ${Thread.currentThread().name}")
线程代码输出为:“Start main”->“End main”->“Hello World Thread-2”。这个结果也很好理解,首先在主线程里输出"Start",接着创建了一个新的线程并启动后阻塞一秒,这时主线程继续向下执行输出"End",这时启动的线程阻塞时间结束,在当前创建的线程输出"Hello World"。
协程代码输出为:“Start main”->“End main”->“Hello World main”。前两个输出很好理解与上面一致,但是等待一秒之后协程里面的输出结果却显示当前输出的线程为主线程!这是个很神奇的事情,输出"Start"之后就立即输出了"End"说明了我们的主线程并没有被阻塞,等待的那一秒钟被阻塞的一定是其他线程。但是阻塞结束后的输出却发生在主线程中,这说明了一件事:协程中的代码自动地切换到其他线程之后又自动地切换回了主线程!这不正是我们一直想要的效果吗?
还记得上一篇文章中说到的吗?这个例子中delay
和println
两行代码紧密地写在协程之中,他们的执行也严格按照从上到下一行一行地顺序执行,但是这两行的代码却运行在完全不同的两个线程中,这就是我们想要的“既按照顺序的方式编写代码,又可以让代码在不同的线程顺序执行”的“顺序编写异步代码的效果”。顺序编写保证了逻辑上的直观性,协程的自动线程切换又保证了代码的非阻塞性。
那为什么协程中的delay
函数没有在主线程中执行呢?而且执行完毕为什么还会自动地切回主线程呢?这是怎么做到的呢?我们可以来看一下delay
函数的定义:
public suspend fun delay(timeMillis: Long) {...}
可以发现这个函数与正常的函数相比前面多了一个suspend
关键字,这个关键字翻译过来就是“挂起”的意思,suspend
关键字修饰的函数也就叫“挂起函数”。关于挂起函数有个规定:挂起函数必须在协程或者其他挂起函数中被调用,换句话说就是挂起函数必须直接或者间接地在协程中执行。
关于挂起的概念大家不要理解错了,挂起的不是线程而是协程。遇到了挂起函数,协程所在的线程不会挂起也不会阻塞,但是协程被挂起了,就是说协程被挂起时当前协程与它所运行在的线程脱钩了。线程继续执行其他代码去了,而协程被挂起等待着,等待着将来线程继续回来执行自己的代码。也就是协程中的代码对线程来说是非阻塞的,但是对协程自己本身来说是阻塞的。换句话说,协程的挂起阻塞的不是线程而是协程。
所以说,协程的挂起可以理解为协程中的代码离开协程所在线程的过程,协程的恢复可以理解为协程中的重新代码进入协程所在线程的过程。协程就是通过的这个挂起恢复机制进行线程的切换。
既然协程执行到了挂起函数会被挂起,那么是suspend
关键字进行的线程切换吗?怎么指定切换到哪个线程呢?对此我们可以做一个简单的试验:
GlobalScope.launch(Dispatchers.Main) {
println("Hello ${Thread.currentThread().name}")
test()
println("End ${Thread.currentThread().name}")
}
suspend fun test(){
println("World ${Thread.currentThread().name}")
}
执行结果为:Hello main -> World main -> End main
,也就是说这个suspend
函数仍然运行在主线程中,suspend
并没有切换线程的作用。
实际上我们可以withContext
方法来在suspend
函数中进行线程的切换:
GlobalScope.launch(Dispatchers.Main) {
println("Hello ${Thread.currentThread().name}")
test()
println("End ${Thread.currentThread().name}")
}
suspend fun test(){
withContext(Dispatchers.IO){
println("World ${Thread.currentThread().name}")
}
}
执行的结果为:Hello main -> World DefaultDispatcher-worker-1 -> End main
,这说明我们的suspend
函数的确运行在不同的线程之中了。就是说实际是上withContext
方法进行的线程切换的工作,那么suspend
关键字有什么用处呢?
其实,忽略原理只从使用上来讲,suspend
关键字只起到了标志这个函数是一个耗时操作,必须放在协程中执行的作用。关于线程切换其实还有其他方法,但是withContext
是最常用的一个,其他的如感兴趣可以自行了解。
这是上一篇文章中演示回调地狱的代码:
//客户端顺序进行三次网络异步请求,并用最终结果更新UI
request1(parameter) { value1 ->
request2(value1) { value2 ->
request3(value2) { value3 ->
updateUI(value3)
}
}
}
//用协程改造回调代码
GlobalScope.launch(Dispatchers.Main) {
//三次请求顺序执行
val value1 = request1(parameter)
val value2 = request2(value1)
val value3 = request2(value2)
//用最终结果更新UI
updateUI(value3)
}
//requestAPI适配了Kotlin协程
suspend fun request1(parameter : Parameter){...}
suspend fun request2(parameter : Parameter){...}
suspend fun request3(parameter : Parameter){...}
前提是request相关的API已经改造成了适应协程的方式,并在内部进行了线程切换。这样代码看起来是不是整洁多了?没有了烦人的嵌套,所有的逻辑都体现在了代码的先后顺序上了,是不是一目了然呢?
那么接下来实现一些有挑战性的东西:如果三次网络请求并不存在前后的依赖关系,也就是说三次请求要并发进行,但是最终更新UI要将三次请求的结果汇总才可以。这样的需求如果没有RxJava或Kotlin协程这种强大的工具支持,单靠自己编码实现的确是一个痛苦的过程。
不过Kotlin协程提供了一种简单的方案:async await
方法。
//并发请求
GlobalScope.launch(Dispatchers.Main) {
//三次请求并发进行
val value1 = async { request1(parameter1) }
val value2 = async { request2(parameter2) }
val value3 = async { request3(parameter3) }
//所有结果全部返回后更新UI
updateUI(value1.await(), value2.await(), value3.await())
}
//requestAPI适配了Kotlin协程
suspend fun request1(parameter : Parameter){...}
suspend fun request2(parameter : Parameter){...}
suspend fun request3(parameter : Parameter){...}
上面的代码中我们用async
方法包裹执行了suspend
方法,接着在用到结果的时候使用了await
方法来获取请求结果,这样三次请求就是并发进行的,而且三次请求的结果都返回之后就会切回主线程来更新UI。
实际开发遇到了串行与并行混合的复杂业务逻辑,那么我们当然也可以混合使用上面介绍的方法来编写对应的代码。比如这样的业务逻辑:request2和request3都依赖于request1的请求结果才能进行,request2和request3要并发进行,更新UI依赖request2和request3的请求结果。
这样的复杂业务逻辑,如果自己实现是不是感觉要被逼疯?来看看Kotlin协程给出的方案:
//复杂业务逻辑的Kotlin协程实现
GlobalScope.launch(Dispatchers.Main) {
//首先拿到request1的请求结果
val value1 = request1(parameter1)
//将request1的请求结果用于request2和request3两个请求的并发进行
val value2 = async { request2(value1) }
val value3 = async { request2(value1) }
//用request2和request3两个请求结果更新UI
updateUI(value2.await(), value3.await())
}
//requestAPI适配了Kotlin协程
suspend fun request1(parameter : Parameter){...}
suspend fun request2(parameter : Parameter){...}
suspend fun request3(parameter : Parameter){...}
怎么样?发现没有,无论怎样的复杂业务逻辑,用Kotlin协程表达出来始终是从上到下整齐排列的四行代码,无任何耦合嵌套,有没有从中感受到Kotlin协程的这股化腐朽为神奇的神秘力量。
了解了Kotlin协程的用法之后,是不是迫不及待地想要在实际Android项目中使用它了?接下来我们来在项目中使用Kotlin协程的最佳实践。