前言
网上很多讲 Kotlin
协程的文章都有点深奥,还有一些一上来就是源码分析啥的,质量也是参差不齐,很不利于新手学习,因此我想写一篇新手就能看懂的协程文章。
希望大家看完这篇文章能简单正确地使用协程。
本文针对于初学协程的Android开发者们,尽量不涉及源码,但会让您学会协程基本操作
什么是协程
学习一个新东西之前,一定要搞清楚这个东西是什么。
Kotlin
中的协程是官方定义的一套可以让开发者非常方便地实现并发操作的线程API。比如在我们熟悉的连续网络请求等场景中,如果使用回调函数:
loadUser() { result ->
if (result.successful) {
loadUsername() { username ->
...
loadUserMotto() { motto ->
...
runOnUiThread {
nameTextView.text = username
mottoTextView.text = motto
}
}
}
}
}
我们可以看到这种串行的加载方式既浪费时间又浪费资源,关键是如果我还需要加载更多的信息,将会嵌套更多层,这种维护起来是相当的困难,所以大家给它取了个昵称 --- 回调地狱(Callback Hell)
。国外甚至还有同名网站,但是在 Kotlin
协程里边,简单的几行代码就可以搞定:
//launch 表示开启一个协程
launch(Dispatchers.Main) {
//同步代码块的方式实现异步操作
val username = async { loadUser() }
val motto = async { loadUserMotto() }
nameTextView.text = username
mottoTextView.text = motto
}
上面的代码暂时不需要看懂,这里只是举个例子,让大家直观感受一下协程中这个巨好用的功能。您可以最后再回来看这段代码。
关于 挂起
挂起
在Kotlin
协程中是一个出现频率很高的一个词,但是在官方文档上似乎没有解释什么叫 挂起
。 顾名思义,挂起
就像是将一个物体高高地举起,使其依附在另一个物体上。在Kotlin
协程中,就是将一个协程脱离当前的线程并进入到另一个线程,最后再返回到最开始那个线程的一个操作。相信这么一说,大家还是很糊涂,这到底是是个啥?😅
⚠️ 先看看一个协程挂起的大致过程
launch
表示启动了一个协程,当代码执行到第一句耗时操作 loadUsername()
时,launch
中的代码就会暂停执行,从当前线程(线程1️⃣)转到另一个线程(线程2️⃣)去执行。此时,我们可以看到这段代码涉及到了两条线程。
线程 1️⃣
:执行它的后续代码,如果在 Android
中,就会去刷新界面。
线程 2️⃣
:执行刚刚转到这个线程的代码,耗时操作执行完毕后又会将线程切回去,执行剩下的代码,在例子中剩下的代码便是 nameTextView.text = name
总结:挂起就是一个先切出去完成任务,完成后会被切回来的线程切换
那为什么是在执行 loadUsername()
的时候将协程挂起呢?难道就因为它是耗时操作吗?
回答这个问题之前,我们先说说什么是 挂起点
和 挂起函数
。
挂起点与挂起函数
首先,当代码执行到挂起点的时候,这个协程就会被挂起。显然,上述例子中的 loadUsername()
就是一个挂起点。我们来看看 loadUsername()
里面是些什么代码:
suspend fun loadUsernameBySuspending(): String {
//withContext 是一个官方定义好的挂起函数,它的作用是指定代码块切换线程,而不是创建一个协程
return withContext(Dispatchers.IO) {
//返回 getUsername 的返回值
getUsername()
}
}
用 suspend
关键字修饰的函数叫作 挂起函数
。当程序执行到 挂起函数
时,代码所在的协程将被挂起,也就是会经历上面讲的那个过程。那 suspend
是干嘛的呢,这个关键字怎么用呢?
suspend
知识点:挂起函数只能在协程中或者另一个挂起函数里调用
我们用 suspend
就表明当前这个方法是一个耗时任务,通常来说就是 网络请求
和 读写操作
。它是用来标记此函数是一个耗时的操作,表明您需要在 Kotlin
协程中调用(或者另一个挂起函数),规范了编程风格,让开发人员能够区分 挂起函数
和 普通函数
。
✔️另外还要注意,上面我们提到 laodUsername()
是一个挂起点,并不是因为它前面有一个 suspend
关键字才实现了 挂起
, 而是 loadUsername()
方法里面的 withContext
。这一类官方定义的挂起函数里面有已经定义好的一行代码专门去做切换线程的这个工作,也就是说我们前面提到的 loadUsername()
是挂起点,说得再准确一点是 loadUsername()
中的 withContext
里面的那行代码是挂起点,是由那行代码实现的 挂起
操作。
😀总结:所以说并不是因为耗时操作协程才被挂起,本质上是因为它是耗时操作,我们将它放到了协程中,而协程中的一个被定义好的 挂起函数
中的一行代码实现了挂起。
现在我们回头看看,为什么 挂起函数只能在协程中或者另一个挂起函数里调用 ?
首先我们要明白一点,就是 挂起函数
最终都会直接或间接地在 协程
中执行,我们讲 挂起
的时候说,协程 最后会将它包裹的代码从另一个线程切回到它最开始在的那个线程,然后在这个线程执行剩余的代码。
❗❗❗ 注意,是 协程
将它切回来的,这便解释为什么挂起函数非得在协程或者另一个挂起函数中调用。
⚡因为是协程帮你将线程切回来的⚡
Dispatchers 调度器
前面说到,当代码执行到一个挂起点的时候,协程将会被挂起。那么它从当前线程切到 另一个线程
,这个另一个线程
究竟是哪一个线程呢?
这就是我们接下来说的 调度器。
初学协程,我们不必去认识 拦截器
以及底层原理啥的太多关于调度器的知识。调度调度,你只要明白它是安排(调度)协程到哪一个线程上工作的一个媒介就好了。
讲两种调度器,Dispatchers.Main
和 Dispatchers.IO
。
-
Dispatchers.Main
这是
Android
主线程,主要用于UI刷新。如果在这里做耗时任务,可能会导致界面丢帧。 -
Dispathers.IO
这是
IO
线程,主要用于Room
数据库操作以及网络请求等耗时任务。
当不指定调度器时,将会从父协程中继承。
runBlocking 与 coroutineScope
官方里的文档拿 runBlocking
和 coroutineScope
来做比较,写的不明不白的,可能有些小伙伴看得云里雾里的,没有看懂它举的例子。
✈️其实 runBlocking
与 coroutineScope
区别很大。
runBlocking 会开启一个协程,并且会阻塞当前线程,因此它是一个 普通函数
。
coroutineScope 构建了一个协程作用域,便于管理协程体以及其子协程,使子协程的生命周期跟着这个协程作用域,这就能做到取消这个作用域,里面的子协程也能跟着取消。它不会阻塞线程,因此它是一个 挂起函数
。
✅我们来看看 runBlocking
:
fun main() {
runBlocking {
//这也是一个官方定义好的挂起函数,能起到延时的作用
delay(1000)
println("runBlocking执行结束了")
}
println("我被阻塞了")
}
结果:
runBlocking执行结束了
我被阻塞了
可以看到,程序是等待 runBlocking
执行完毕后,才输出的 我被阻塞了
。runBlocking
由于会阻塞,所以一般用于代码测试,在真实际项目中并不常见。
✅我们换成 coroutineScope
试试呢:
fun main() {
//开启一个协程
GlobalScope.launch {
coroutineScope {
//这也是一个官方定义好的挂起函数,能起到延时的作用
delay(1000)
println("delay结束")
}
}
println("我没有被阻塞了")
}
结果:
我没有被阻塞了
⛔GlobalScope
是一个顶级协程,意思就是说它的生命周期是跟着整个进程的,进程在它就在,进程亡它才亡。这就会造成很严重的一点,如果这个协程中的代码有异常,那么协程不会结束一直占用资源,这就造成了 内存泄漏
。所以官方也是不推荐使用这个的。
上面的代码运行结果只输出了 我没有被阻塞了
。这是因为当代码执行到 delay()
时,协程将被挂起,但是由于是 coroutineScope
它不会阻塞,因此主线程继续执行下面代码,当最后一行代码执行完毕时程序退出,最后也就没有输出协程里的 delay结束
了。
我们此时来看看官方的例子,那就能轻松看懂了:
fun main() = runBlocking { // this: CoroutineScope
launch {
delay(200L)
println("Task from runBlocking")
}
coroutineScope { // 创建一个协程作用域
launch {
delay(500L)
println("Task from nested launch")
}
delay(100L)
println("Task from coroutine scope") // 这一行会在内嵌 launch 之前输出
}
println("Coroutine scope is over") // 这一行在内嵌 launch 执行完毕后才输出
}
结果:
Task from coroutine scope
Task from runBlocking
Task from nested launch
Coroutine scope is over
为什么 Coroutine scope is over
最后输出呢?当 coroutineScope
中的协程被挂起时,程序理应继续执行主线程中剩下的代码,但由于主线程也被阻塞了(runBlocking),因此 Coroutine scope is over
最后输出。
supervisor
我想在这里再多提一点吧,coroutineScope
和 supervisorScope
拿来做比较倒很合适,它们都是开启一个 协程作用域 。对于前者,如果它里面的一个子协程抛出异常,那么这个子协程的兄弟协程都会停止,对于后者,它里面的一个子协程失败是不会影响其他兄弟协程的。
对于这一点,大家可以亲自动手试一试。
launch 与 async 以及 withContext
前面我们提到了 lauch
是用来开启一个协程的,同样,这个 async
也是用来开启协程的,但是它会带一个 Deferred 类型的返回值。withContext
不会创建协程,但会指定切换线程
♈launch 用来开启一个协程并返回一个 Job
,可以使用这个 Job
来管控您开启的协程,比如取消Job.cancelAndJoin
。
♉async 用来开启一个协程,和 launch
相似,但是会根据 async
最后一行代码的返回值来返回可以一个 Deferred<T>
类型的值,在这里我们先不管 Deferred
是什么,我们只用知道利用 await()
来得到这个值。因为 await()
是一个挂起函数,所以就会阻塞直到得到这个值。另外多个 async
是并行的,很适合用来做多个没有逻辑关系的网络请求,就像文章开头那样,现在可以回去看看那段代码😀。
下面我们来看看官方给的例子:
suspend fun doSomethingUsefulOne(): Int {
delay(1000L) // 假设我们在这里做了一些有用的事
return 13
}
suspend fun doSomethingUsefulTwo(): Int {
delay(1000L) // 假设我们在这里也做了一些有用的事
return 29
}
val time = measureTimeMillis {
val one = doSomethingUsefulOne()
val two = doSomethingUsefulTwo()
println("The answer is ${one + two}")
}
println("Completed in $time ms")
The answer is 42
Completed in 2017 ms
我们可以看到这是不使用 async
,按照顺序执行的时候,一共用了 2017ms 。
再来看看使用 async
的情况:
val time = measureTimeMillis {
val one = async { doSomethingUsefulOne() }
val two = async { doSomethingUsefulTwo() }
println("The answer is ${one.await() + two.await()}")
}
println("Completed in $time ms")
out:
The answer is 42
Completed in 1017 ms
可以看到由于是并行,时间少了一半,这就非常适合多个网络请求了,这就是Kotlin协程的最大特点 -- 以同步代码块的方式实现异步操作 。
♊withContext 不会开启一个协程,而是根据调度器将指定代码切到对应的线程上,它也是挂起函数,也可以用来等待一个函数返回值,但由于 withContext
是串行的,因此多个耗时函数还请使用 async
。
结尾
本篇文章讲述了有关 Kotlin协程 的部分基本知识点,现在看完请在脑海中想想这几个问题。
1️⃣ 什么是 Kotlin协程 ?
2️⃣ 什么叫 挂起 ?
3️⃣ suspend 关键字的作用是什么?
4️⃣ coroutineScope 和 supervisorScope 的 区别 ?
5️⃣ 利用 协程 怎么 优秀地发起 多个无逻辑关系的网络请求?
网友评论