Kotlin 系列文章详细计划-06-视频脚本

这是码上开学 Kotlin 系列第 6 集的视频脚本。

视频脚本不是文章结构要求,所以不属于「必读」;但建议参与文章编写的作者看一下视频脚本再去看「文章结构要求」和写文章,这样写出来的文章和视频会更容易打配合,更容易比较「搭」。

如果你想用视频脚本直接作为基准来扩展成文章,也没问题,写得好、容易读是唯一标准。

标题:Kotlin 协程「挂起」的本质

脚本:

开场白

大家好,我是扔物线朱凯,我回来啦。今天我们接着讲协程。

上期回顾

在上一期里,我介绍了 Kotlin 的协程到底是什么——它就是个线程框架。没什么说不清的,就这么简单,它就是个线程框架,只不过这个线程框架比较方便——另外呢,上期也讲了一下协程的基本用法,但到最后也留下了一个大问号:协程最核心的那个「非阻塞式」的「挂起」到底是怎么回事?今天,我们的核心内容就是来说一说这个「挂起」。

「挂起」的本质

这个「挂起」到底是挂起谁?挂起线程?还是挂起函数?都不是,挂起的是协程。还记得协程是什么吗?上期说过的,协程就是 launch() 里的那些代码——其实除了 launch() 还有一个创建协程的函数叫 async() ,不过我在视频里就不介绍了,你可以去看我们的文章——launch() 创建的这个协程,在执行到某一个 suspend 函数的时候,这个协程会被 suspend,被挂起。从哪挂起?从当前线程挂起,说白了就是这个协程从正在执行它的线程上脱离了。注意,不是这个协程停下来了,虽然 suspend 有暂停的意思,而是它所在的线程从这行代码开始不再执行它了。到这里我们需要兵分两路,去分别看看这两个互相脱离的线程和协程将会发生什么:

首先这个线程,它和协程分离了,具体到代码是什么意思呢?协程的代码块,在线程里到了 suspend 函数这里的时候突然执行完毕了,返回了。完毕之后线程干嘛呢?该干嘛干嘛去。如果它是一个后台线程,那这个线程接下来可能就没事干了,或者去执行别的后台任务,总之,跟 Java 线程池里的线程在工作结束之后是完全一样的:要么回收掉,要么再利用;而如果这个线程是 Android 的主线程,那它在这里突然执行完毕之后就继续回去工作。

什么叫继续回去工作?

首先,如果你启动一个执行在主线程的协程,它实质上会往你的主线程 post() 一个新任务,这个任务就是你的协程代码:

  1. handler.post {
  2. suspendingGetImageimageId)
  3. avatarIv.setImageBitmap(image)
  4. }

那么当这个协程被挂起的时候,实质上就是你 post() 的这个任务提前结束了。这时候主线程该干嘛?继续刷新界面,对吧?一秒钟 60 次的界面刷新啊。就是这样。

那剩下的代码怎么办?协程不是还没执行完吗?

刚才我说兵分两路,对吧?看完线程,我们来看线程和协程分离之后,协程发生了什么。协程的代码在到达 suspend() 函数的时候被掐断了,所以接下来,它会从这个 suspend() 函数开始继续往下执行,不过——是在指定的线程。谁指定的? suspend 函数指定的,比如,我们这个例子中,函数内部的 withContext() 所指定的 IO 线程。

另外,在 suspend 函数执行完成之后,协程为我们做的最爽的事就来了:它会自动帮我们把线程再切回来。比如我的协程原本是运行在主线程的,当代码遇到 suspend 函数的时候,会发生线程切换是吧?当这个函数执行完毕,我的线程就又被切换回来了,比如如果我的协程原本是在主线程运行的,那么这个所谓的「切回来」,就是协程会帮我再 post() 一个任务,让我剩下的代码继续回到主线程去执行。这就是为什么你指定线程的那个参数不叫 Threads 而是叫 Dispatchers,调度器,它不只能指定协程执行的线程,还能在 suspend 函数之后自动切回来。——其实也不是一定会切回来,你也可以通过设置特殊的 Dispatcher 来让挂起方法执行完之后也不切回来,不过这是你的选择,而不是它的定位,挂起的定位就是「暂时切走,稍后再切回来」。

好,到这里,终于可以对协程的「挂起」 suspend 做一个解释了:协程在执行到有 suspend 标记的函数的时候,会被 suspend 也就是被挂起,而所谓的被挂起,其实跟开启协程一样,说起来比较玄乎,但实质上就是切个线程;不过区别在于,挂起函数在执行完成之后,协程会重新切回它原先的线程。所以所谓的挂起,就是一个稍后会被自动切回来的线程切换。顺便说一下,这个「切回来」的动作,在 Kotlin 里叫做 resume,恢复。

那么回到上期最后的那个问题:为什么 suspend 函数只能在协程里或者另一个 suspend 函数里被调用?首先,挂起之后是要恢复的对吧?而恢复这个功能是协程的,如果你不在协程的里面调用,恢复这个功能没法实现,所以挂起函数必须在协程里被调用;另外你想一下这个逻辑:如果一个挂起函数要么在协程里被调用,要么在另一个挂起函数里被调用,那么它其实直接或者间接地,总是会在一个协程里被调用的,是吧?所以,要求 suspend 函数只能在协程里或者另一个 suspend 函数里被调用,还是为了要让协程能够在 suspend 函数切换线程之后再切换回来。

怎么就「挂起」了?

说完了挂起是什么,现在我们就再顺着去看一看:这个「挂起」是怎么做到的。

我先写一个自定义的 suspend 函数:

  1. suspend fun suspendingPrint() {
  2. println("Thread: ${Thread.currentThread().name}")
  3. }
  4. I/System.out: Thread: main

还是运行在主线程。

为什么没切?因为它不知道往哪切啊,你又没告诉它,它怎么切?

你看我们之前的例子里,自定义的 suspend 函数里面是有一个 withContext() 的对吧?而且这个 withContext() 它也是一个 suspend 函数,它接收一个 Dispatcher 参数。依赖这个 Dispatcher 参数的指示,你的协程才被挂起了,也就是切到别的线程去了,是吧?

也就是说,所谓的协程被挂起,或者说切线程这件事,并不是发生在你外部这个 suspend 函数被调用的时候,而是里面那个 suspend 函数——那个 withContext() 被调用的时候。当然了,如果你再往代码里面钻一下,你肯定也会发现这个 withContext() 也不是真正的切线程的点,而是它内部的某一行代码,不过这个不重要了,我要说的是:

所以这个 suspend,其实并不起到任何把协程挂起,或者说切换线程的作用。真正要挂起协程,还需要你在 suspend 函数里调用另外一个 suspend 函数,而且这个里面的 suspend 函数,它需要是协程自带的、内部实现了协程挂起代码的,或者它不是自带的,但它的内部直接或者间接地调用了某一个自带的 suspend 函数,那也行,总之你最终需要调用到一个自带的 susend 函数,让它来真正去做挂起,也就是线程切换的工作。自带的 suspend 函数不只 withContext() 一个,还有别的,它们都能实现协程的挂起。而我们要想自己写一个挂起函数,就需要在这个函数的内部直接或者间接地调用到自带的 suspend 函数才行,只加上个 suspend 关键字是不行的。这个关键字没有这么神奇。这个……应该好理解吧?

suspend 的意义?

那么到这里,其实另一个问题就来了:这个 suspend 关键字,既然它并不是真正实现挂起,那它的作用是什么?

这是一个看起来学术,但其实非常实际、对写代码非常有用的问题。我知道,很多人其实已经研读了 Kotlin 协程的代码,了解了编译器是怎么实现了协程的、了解了 Kotlin 是怎么实现了挂起的,但今天我们抛开这些底层实现的问题,我们就看语法:在语法上,这个 suspend 关键字,它的作用是什么?

具体点说,为什么 Kotlin 要给我提供这个东西让我用呢?你看咱刚讲过,它对于协程的挂起并不起实质作用,那它到底是干嘛用的呢?

注意听了,不管你是阿里的、腾讯的、头条的,还是 Google 的、Facebook 的,现在讲的东西都很有用。

suspend 关键字的作用是什么?

它其实是一个提醒。谁对谁的提醒?函数的创建者对函数的使用者的提醒:我是一个耗时函数,因此我被我的创建者用挂起的方式放在了后台运行,所以请在协程里调用我。表面上,它是一个要求:你需要在协程里调用我;但本质上,它其实是一个提醒:我是一个被自动放在后台运行的耗时函数,所以你需要在协程里调用我。明白了吧,这才是 suspend 关键字的作用:它是一个提醒。

所以为什么 suspend 关键字并没有实际去操作挂起,但 Kotlin 却把它提供出来?因为它本来就不是用来操作挂起的。挂起的操作——也就是切线程——依赖的是挂起方法里面的实际代码,而不是这个关键字。

实际上你试一下,如果你创建一个 suspend 函数但却不在它内部调用别的 suspend 函数,Android Studio 会给你一个提醒:redundant suspend modifier,告诉你这个 suspend 是多余的。为啥多余?因为你这个函数实质上并没有发生挂起,那你这个 suspend 关键字只有一个效果:就是限制这个函数只能在协程里被调用。那这肯定没必要啊,对吧,你又不去挂起协程,为啥还要限制在协程里才能调用呢?所以说,你创建一个 suspend 函数,一定要在它内部调用别的 suspend 函数,你的这个 suspend 才能是有意义的。

怎么自定义 suspend 函数?

那么在了解了挂起到底是什么、suspend 关键字到底有什么意义之后,我们就可以进入下一个话题了:怎么自定义 suspend 函数。这个话题你如果不了解前面我讲的那些,直接进入这一步,你会头晕呕吐的。

这个「怎么自定义」其实分为两个问题:

  • 什么时候需要自定义 suspend 函数?
  • 写的时候怎么写?

什么时候自定义 suspend 函数?

这个问题可能很多人会觉得太虚无了,不好回答,但其实答案很简单:如果你的某个函数比较耗时,那就把它写成 suspend 函数。这就是原则。

那什么会比较耗时呢?一般就两类:I/O 操作和 CPU 计算工作。比如文件的读写、网络交互、图片的模糊或者美化处理,都是耗时的,通通可以把它们写进 suspend 函数里。

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

写的时候怎么写?

太简单了,给函数加上 suspend 关键字,然后用 withContext() 把函数的内容包住就可以了。

不论是「什么时候要写」,还是「具体怎么写」,都是很简单的问题。但为什么大家觉得难?因为你的前置知识不够啊!你不知道什么是挂起、不知道 suspend 关键字是什么意思,怎么可能能写好呢?对吧?

「其他 suspend 函数」?

有的人在这里可能会有问题了:「哎你不是说除了 withContext() 还有别的 suspend 函数吗?为什么你说自定义 suspend 函数要用 withContext()?那别的不能用吗?」

不是别的不能用,而是 withContext() 是最常用的,也是最适合用来上手的,因为它功能最简单最直接:把线程自动切走和切回来。别的 suspend 函数的功能总会比它多一些或者少一些,比如有一个 suspend 函数叫 delay() ,它的作用是等待一段时间后再去往下执行代码,那你能用它来写自定义 suspend 函数吗?当然可以的呀!比如刚才我说的那种等待类型的耗时操作,就可以用 delay() 来做呀。

  1. suspend fun suspendUntilDone() {
  2. while (!done) {
  3. delay(10)
  4. }
  5. }

只不过,你不用第一时间就去接触它们,可以先把协程用得熟悉一点了再说,不着急。

好,这期内容就到这里:什么是挂起?说一千道一万,其实跟协程一样,还是切线程,只不过是个可以自动切回来的切线程。下期我们会讲一下 Kotlin 的挂起的「非阻塞式」到底是怎么回事,以及协程与线程的关系等等常见疑难问题。另外上一期的最后我其实跟大家说过,「非阻塞式」本来是要在这期讲的。为什么挪到下一期了呢,主要是你看看进度条,已经这么久了,我做视频很累的好吗。还有我也知道,这个理由对于有些人是不够有说服力的,所以我还准备了另外一个理由。这个理由就是……

略略略!