快速上手 Kotlin 开发系列之协程的挂起

687 阅读7分钟

站在巨人的肩膀上做个笔记,摘录自:kaixue.io/kotlin-coro…

前言

上一节中我们提到了下面的示例,它使用了 async 关键字来创建一个协程。

launch(Dispatchers.Main) {
    val avatar = async { getAvatar() }//获取用户头像
    val logo = async { getLogo() }//获取 Logo
    mergeShowUI(avatar.await(), logo.await())//合并展示
}

我们来看下 launch 和 async 的区别:

  • 相同点:它们都可以用来启动一个协程,返回的都是 Coroutine;
  • 不同点:async 返回的 Coroutine 多实现了 Deferred 接口。它的意思就是延迟也就是结果稍后才能拿到,调用 Deferred.await() 就可以得到结果了。

我们看下 await 函数签名如下:

//       👇挂起关键字
public suspend fun await(): T

我们看到了suspend关键字,它就是是本篇文章协程挂起的主角。

挂起的本质

协程中「挂起」的对象到底是什么?挂起线程,还是挂起函数?都不对,我们挂起的对象是协程

还记得协程是什么吗?启动一个协程可以使用 launch 或者 async 函数,协程其实就是这两个函数中闭包的代码块

launch ,async 或者其他函数创建的协程,在执行到某一个 suspend 函数的时候,这个协程会被「suspend」,也就是被挂起。

那此时又是从哪里挂起?从当前线程挂起。换句话说,就是这个协程从正在执行它的线程上脱离。需要注意,不是这个协程停下来了!是脱离,当前线程不再管这个协程要去做什么了。也可以理解为:当线程执行到协程的 suspend 函数的时候,暂时不继续执行协程代码了。

我们先让时间静止,然后兵分两路,分别看看这两个互相脱离的线程和协程接下来将会发生什么事情:

线程:

协程的代码块中,线程执行到了 suspend 函数这里的时候,就直接执行完毕然后返回了!完毕之后线程就该干嘛就干嘛了,如果是后台线程可能就去执行其他任务了,要是主线程则会继续刷新页面

如果你启动一个主线程的协程:

coroutineScope.launch {
    val bitmap = suspendingGetBitmap()// 网络请求,后台线程
    imageView1.setImageBitmap(bitmap)// 更新 UI 主线程
}

相当于会往你的主线程 post 一个新任务,这个任务就是你的协程代码,当这个协程被挂起的时候实质上你 post 的这个任务就提前结束了(虚线代码直接略过)。

handler.post {
        👇 协程代码,遇到 suspend 函数直接执行结束
    ------------------------------------
    |val bitmap = suspendingGetBitmap()|
    |imageView1.setImageBitmap(bitmap) |
    ------------------------------------
}

看完线程,再看下协程做了什么。

协程:

协程的代码在到达 suspend 函数的时候被掐断,接下来协程会从这个 suspend 函数开始继续往下执行,不过是在指定的线程

谁指定的?是 suspend 函数指定的,比如我们这个例子中,函数内部的 withContext 传入的 Dispatchers.IO 所指定的 IO 线程。

private suspend fun suspendingGetBitmap(): Bitmap {
    return withContext(Dispatchers.IO) {
        val url = URL("https://gitee.com/luluzhang/ImageCDN/raw/master/blog/20200420120447.png")
        val openConnection = url.openConnection() as HttpURLConnection
        BitmapFactory.decodeStream(openConnection.inputStream)
    }
}

Dispatchers 调度器,它可以将协程限制在一个特定的线程执行,或者将它分派到一个线程池,或者让它不受限制地运行。

常用的 Dispatchers 调用器有三种:

  • Dispatchers.Main:Android 中的主线程
  • Dispatchers.IO:针对磁盘和网络 IO 进行了优化,适合 IO 密集型的任务,比如:读写文件,操作数据库以及网络请求
  • Dispatchers.Default:适合 CPU 密集型的任务,比如计算

回到我们的协程,它从 suspend 函数开始脱离启动它的线程,继续执行在 Dispatchers 所指定的 IO 线程。

紧接着在 suspend 函数执行完成之后,协程为我们做的最爽的事就来了:会自动帮我们把线程再切回来

切回来是什么意思呢?

示例中我们的协程原本运行在主线程中,当代码遇到 suspend 函数时,发生线程切换,协程代码会在 Dispatchers 指定的 IO 线程中运行,运行结束后,协程会帮我们再 post 一个任务,让剩下的代码回到主线程执行。

从上面两个角度分析后,我们再对协程的挂起做一个解释:

协程在执行到有 suspend 标记的函数的时候,会被 suspend, 也就是被挂起,而所谓的被挂起,就是切个线程;不过区别在于,挂起函数在执行完成之后,协程会重新切回它原先的线程。再简单来讲,在 Kotlin 中所谓的挂起,就是一个稍后会被自动切回来的线程调度操作

另外,这个「切回来」的动作,在 Kotlin 里叫做 resume,恢复。

通过刚才分析我们了解到:挂起之后是需要恢复的。而恢复这个功能是协程的,如果你不在协程里面恢复这个功能就没法实现,这就是为什么挂起函数必须在协程中或者另一个挂起函数中被调用的原因。

挂起是如何做到的

首先,我们可以自定义一个挂起函数:

private suspend fun suspendingFun() {
    println("Current Thread: ${Thread.currentThread().name}")
}

Logcat 输出👇
I/System.out: Current Thread: main

输出结果还是主线程。嗯?为什么没切线程?因为它不知道往哪儿切,需要我们告诉它。

对比之前的示例,不同之处在于 withContext。

private suspend fun suspendingGetBitmap(): Bitmap {
    return withContext。(Dispatchers.IO) {
        ...
    }
}

其实通过 withContext 源码可以知道,它本身就是一个挂起函数,它接收一个 Dispatcher 参数,依赖这个 Dispatcher 参数的指示,你的协程被挂起,然后切到别的线程。

所以这个 suspend,其实并不是起到把任何协程挂起,或者说切换线程的作用。还需要你在挂起函数里面去调用另外一个挂起函数,而且里面的这个挂起函数需要是直接或间接调用协程自带的、内部实现了协程挂起代码的挂起函数,让它来真正做挂起,也就是线程切换的工作。

suspend 存在的意义

通过上面了解到,suspend 并不能真正的实现挂起,那它有什么作用呢?

它其实是一个提醒,是函数创建者对函数调用者的提醒。 提醒调用者我是一个耗时函数,请在协程中调用。

所以我们知道,suspend 关键字的定位就不是用来去操作挂起的,挂起操作靠的是挂起函数里面的实际代码,而不是这个关键字。

例如,我们写一个挂起函数,但是不在内部调用别的挂起函数:

Android Studio 会给你一个提醒,认为这个 suspend 关键字是多余的。因为其内部并没有调用其他挂起函数,也无需在协程中运行。

自定义挂起函数

什么时候自定义

其实原则上如果你的函数比较耗时就应该写成挂起函数。哪些操作会比较耗时呢?

耗时操作一般分为两类:I/O 操作和 CPU 计算工作 。比如文件的读写、网络交互、图片的模糊处理,都是耗时的,通通可以把它们写进 suspend 函数里。

另外这个「耗时」还有一种特殊情况,就是这件事本身做起来并不慢,但它需要等待,比如 5 秒钟之后再做这个操作。这种也是 suspend 函数的应用场景。

如何写

非常简单,给函数加上 suspend 关键字,然后用 withContext 把函数的内容包住就可以了:

private suspend fun getAvatar() = withContext(Dispatchers.IO) {
    val url = URL("https://gitee.com/luluzhang/ImageCDN/raw/master/blog/20200626111013.jpg")
    val openConnection = url.openConnection() as HttpURLConnection
    BitmapFactory.decodeStream(openConnection.inputStream)
}

当然并不是只有 withContext 这一个函数来辅助我们实现自定义的 suspend 函数,比如还有一个挂起函数叫 delay,它的作用是等待一段时间后再继续往下执行代码。

使用它就可以实现刚才提到的等待类型的耗时操作:

suspend fun suspendUntilDone() {
  while (!done) {
    delay(5)
  }
}

以上就是本节内容,欢迎大家关注👇👇👇

长按关注