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

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

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

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

标题:Kotlin 的协程用力瞥一眼

脚本:

开场白

大家好,我是扔物线朱凯。

终于到了协程的一期了。

Kotlin 的协程是它非常特别的一块地方:宣扬它的人都在说协程多么好多么棒,但多数人不管是看了协程的官方文档还是一些网络文章之后又都觉得完全看不懂。而且这个「不懂」和 RxJava 是属于一类的:由于协程在概念上对于 Java 开发者来说就是个新东西,所以对于大多数人来说,别说怎么用了,我连它是个什么东西都没看明白。

所以今天,我就先从「协程是什么」说起。

协程是什么

你在网上搜「协程是什么」,搜到的答案是各不一样的。但你把它们综合起来,大致会包含这样一些描述:

  • 协程和线程类似,是一种在程序开发中处理多任务的组件。
  • 协程就像一种轻量级的线程。

  • 协程很像线程,但它不是线程。

  • 协程是「用户态」的,它的切换不需要和操作系统交互,因此协程切换的成本比线程切换低。
  • 协程由于是「协作式」的,所以不需要线程的同步操作(synchronized, volatile 等)。

这些模糊的描述让人非常难以理解。但我要告诉你:你根本不用理解。因为协程这个概念,其实并没有一个官方的或者统一的定义,而你在网上所能搜到的最主流的「协程」的定义——包括维基百科的——对于 Kotlin 的协程来讲是不准确的,有的甚至是完全错误的。「协程」原本是一个和「线程」非常类似的用于处理多任务的概念,但在 Kotlin 里,协程其实就是一套由 Kotlin 官方提供的线程 API。就像 Java 的 Executor 和 Android 的 AsyncTask ,Kotlin 的协程也对 Thread 相关的 API 做了一套封装,让我们不用过多关心线程也可以方便地写出并发操作。

  1. // 线程:
  2. Thread {
  3. ...
  4. }.start()
  1. // Executor
  2. val executor = Executors.newCachedThreadPool()
  3. executor.execute {
  4. ...
  5. }
  1. // 协程
  2. launch {
  3. ...
  4. }

这就是 Kotlin 的协程。

那……既然 Java 有了 Executor,Android 又添加了 HandlerAsyncTask,而且我们现在还有了 RxJava 这种神奇的瑞士军刀,我要协程有什么用啊?或者说,协程好在哪?

协程的好处,本质上和其他的线程 API 一样:方便。不过由于它借助了 Kotlin 的语言优势,所以它比起基于 Java 的方案会更方便一些;最重要的是,它能用看起来同步的方式写出异步的代码:

  1. val user = api.getUser() // 网络请求(后台线程)
  2. nameTv.text = user.name // 更新 UI(主线程)

这就是 Kotlin 的协程最著名的「非阻塞式挂起」。这是它最有用的特性,但很多人对它有困惑,而且不少已经在用协程的人对这个「非阻塞式」是有误解的,这些误解一旦得到传播,大家学习协程会更加困难,因为如果你在网上搜到的热文都是错的,你怎么保证你能学好?不过我们先把它放下,得慢慢来,先来扫一眼协程大概长什么样,从而更好地了解写成到底好在哪。

协程好在哪

基本使用

协程最基本的功能是并发,也就是多线程。用协程,你可以把任务切到后台执行:

  1. launch(Dispatchers.IO) {
  2. saveToDatabase(data)
  3. }

想切到前台也行:

  1. launch(Dispatchers.Main) {
  2. updateViews(data)
  3. }

这种写法很简单,但它并不能算是协程相对于直接使用 Thread 的优势,因为 Kotlin 已经专门添加了一个函数来简化对 Thread 的直接使用,还是非常方便的:

  1. thread {
  2. ...
  3. }
  4. // 相当于 Kotlin 的:
  5. Thread {
  6. ...
  7. }.start()
  8. // 也就是 Java 的:
  9. new Thread() {
  10. @Override
  11. public void run() {
  12. ...
  13. }
  14. }.start();

而 Kotlin 协程的最大好处,是在于你可以把运行在不同线程的代码,写在同一个代码块里:

  1. launch(Dispatchers.Main) { // 开始:主线程
  2. val user = api.getUser() // 网络请求:后台线程
  3. nameTv.text = user.name // 更新 UI:主线程
  4. }

上下两行代码,运行在不同线程,这是我们写 Java 的时候绝对做不到的。

Java 等价代码:

  1. api.getUser(new Callback<User>() {
  2. @Override
  3. public void success(User user) {
  4. runOnUiThread(new Runnable() {
  5. @Override
  6. public void run() {
  7. nameTv.setText(user.name);
  8. }
  9. }
  10. }
  11. @Override
  12. public void failure(Exception e) {
  13. }
  14. }

Kotlin 不使用协程时协程的等价代码:

  1. api.getUser(object : Callback<User>() {
  2. override fun success(user: User) {
  3. runOnUiThread {
  4. nameTv.text = user.name
  5. }
  6. }
  7. override fun failure(e: Exception) {
  8. }
  9. }

不过这个说实话,差别并不大,因为我们写回调早就写熟练了。就算再用连续的网络请求来让协程和被大家喷得体无完肤的回调地狱比较:

  1. launch(Dispatchers.Main) { // 开始:主线程
  2. val token = api.getToken() // 网络请求:后台线程
  3. val user = api.getUser(token) // 网络请求:后台线程
  4. nameTv.text = user.name // 更新 UI:主线程
  5. }
  1. // 不用协程的等价代码:
  2. api.getToken(object : Callback<Token>() {
  3. override fun success(token: Token) {
  4. api.getUser(object : Callback<User>() {
  5. override fun success(user: User) {
  6. runOnUiThread {
  7. nameTv.text = user.name
  8. }
  9. }
  10. }
  11. }
  12. override fun failure(e: Exception) {
  13. }
  14. }

对于习惯了的人来说,倒也还好,毕竟所谓的「回调地狱」,出现的几率并不高,而且一般也就那么两三层。一般忍一忍,也就过去了。而协程作为一个全新的东西,还有一套学习成本呢不是。

协程的「1 到 0」

但是,这个「不大」的差别,其实很大。为什么?

它改变了并发任务的操作难度对吧?不过只改变了一点对吧?但是这「一点」改变,不是从 10 变成了 9 这种,而是从 1 变成了 0。消除了回调,那么多线程协作任务的难度就直接被抹平了,没有了。这其实属于质变的。而且这种质变,会为我们的开发工作也带来质变。

我们平时用回调式的开发,倒也还挺顺手是吧?但是我们可能会忘了,回调式可不止是多了几个缩进,它也限制了我们的能力。比如如果我有一个需求,它需要分别执行两次网络请求,然后把结果数据合并后再显示到界面:

  1. api.getAvatar(user) // 获取用户头像
  2. api.getCompanyLogo(user) // 获取用户所在公司的 logo

按照正常思路,这两个接口没有相关性,我应该同时发起请求,在都获取到结果以后,再在本地把两个结果做融合对吧?但是回调式的开发要做这种工作很困难。于是我们可能会选择妥协:不并行请求了,先后请求吧:

  1. // 串行发起两个可以并行的请求
  2. api.getAvatar(user) { // 获取用户头像:第一步
  3. avatar ->
  4. api.getCompanyLogo(user) { // 获取用户所在公司的 logo:第二步
  5. logo -> show(merge(avatar, logo))
  6. }
  7. }

要知道,这就属于标准的「垃圾代码」了:明明可以并行的两个请求,你由于自身能力不足而做成了串行的,导致网络等待的时间长了一倍,也就是性能差了一倍。

而你如果用协程,可以直接把两个并行请求写成上下两行,然后在第三行把它俩的结果合并:

  1. launch(Dispatchers.Main) {
  2. val avatar = async { api.getAvatar(user) } // 获取用户头像
  3. val logo = async { api.getCompanyLogo(user) } // 获取用户所在公司的 logo
  4. val merged = suspendingMerge(avatar, logo) // 合并
  5. show(merged) // 显示
  6. }

如果网络请求的关系更复杂,用协程写出来依然是清晰的上下行代码结构。

所以,由于消除了并发任务之间协作的难度,协程让我们可以毫无障碍地写出复杂的并发代码;而且由于并发代码变得不难写,一些本来不可能实现的并发代码变得可能,甚至变得很轻松,这些才是协程的优势所在。

在真正了解了协程的作用和优势之后,我再给你讲怎么用协程,你才能更好吸收,也更有兴趣去吸收,对吧?好,说一下协程怎么用。

协程怎么用

最简单的使用,刚才已经展示过了。一个 launch() 函数,里面写上代码,就能切线程:

再展示一次代码。

这个 launch() 函数,它具体的含义是:我要创建一个新的协程,并在指定的线程上运行它。这个被创建、被运行的所谓「协程」是谁?就是你传给 launch() 的那些代码,这一段连续代码叫做一个「协程」,这就是所谓的协程。记住这个定义,等会儿会多次提到它。

所以,什么时候用协程?当你需要切线程或者指定线程的时候。你要在后台执行任务?切!

  1. launch(Dispatchers.IO) {
  2. val image = getImage(imageId)
  3. }

然后需要在前台更新界面?再切!

  1. launch(Dispatchers.IO) {
  2. val image = getImage(imageId)
  3. launch(Dispatchers.Main) {
  4. avatarIv.setImageBitmap(image)
  5. }
  6. }

转头愣住,皱眉,看向镜头 -> 眼睛转一下 -> 再看向镜头

好像有点不对劲?

如果只用 launch() 函数,协程并不能做出比直接用线程更多的事。不过协程里有一个很厉害的函数:withContext() 。这个函数可以指定线程来执行代码,并且在执行完成之后自动线程切回来继续执行:

  1. launch(Dispatchers.Main) { // 在前台开始
  2. withContext(Dispatchers.IO) { // 切到后台
  3. val image = getImage(imageId) // 这行代码在后台
  4. }
  5. avatarIv.setImageBitmap(image) // 自动切回前台
  6. }

这个写法跟刚才那种看起来区别不大,但你如果有更多的线程切换,区别就体现出来了:

  1. // 第一种:
  2. launch(Dispatchers.IO) {
  3. ...
  4. launch(Dispatchers.Main) {
  5. ...
  6. launch(Dispathers.IO) {
  7. ...
  8. launcher(Dispathers.Main) {
  9. ...
  10. }
  11. }
  12. }
  13. }
  14. // 第二种:
  15. launch(Dispatchers.Main) {
  16. withContext(Dispatchers.IO) {
  17. ...
  18. }
  19. ...
  20. withContext(Dispatchers.IO) {
  21. ...
  22. }
  23. ...
  24. }

由于有了「自动切回来」这个功能,协程消除了并发代码在协作时的嵌套。你直接写成上下关系的代码就能让多线程之间进行协作,这就是「协程」,协作式的例程。而且由于消除了嵌套,你可以把 withContext() 放进函数的里面,用它包着函数的实际业务代码:

  1. launch(Dispatchers.Main) { // 在前台开始
  2. suspendingGetImageimageId)
  3. avatarIv.setImageBitmap(image) // 自动切回前台
  4. }
  5. fun getImage(imageId: Int) = withContext(Dispatchers.IO) {
  6. ...
  7. }

不过,你如果试着这样写一下,会看到报错:

image-20190813190028628

报错告诉你,withContext() 是一个 suspend 函数,它需要在协程里被调用,或者在另一个 suspend 函数里被调用。什么是 suspend 函数?你点进 withContext() 这个函数去,会看见它有一个 suspend 修饰符:

  1. public suspend fun <T> withContext(
  2. context: CoroutineContext,
  3. block: suspend CoroutineScope.() -> T
  4. ): T = ...

有这种修饰符的就叫 suspend 函数,挂起函数。那你为了让它不报错,你得去给你的函数也加上这个修饰符:

  1. suspend fun getImage(imageId: Int) = withContext(Dispatchers.IO) {
  2. ...
  3. }

报错消失了,没问题了。不过……这个 suspend 到底什么意思?

suspend

suspend 是 Kotlin 协程最核心的关键词,国内外所有介绍 Kotlin 协程的文章和演讲都要提到它。但,它也是最难懂和最多被误解的点。我们去上网搜一下 Kotlin 的 suspend 或者去看一些国外的演讲,会了解到:「代码执行到 suspend 函数的时候会「挂起」,并且这个「挂起」是非阻塞式的,它不会阻挡你的线程。」

哦~~~~~~~~~~~~~~~~(瞪眼)

好了,这期内容就到这里。(噗嗤)没开玩笑,协程我们分成了两期,这期的内容就像我们的标题一样:用力瞥一眼 Kotlin 的协程。为什么要瞥一眼?因为要看清它。为什么要用力瞥?因为它不容易看清。本来就不容易看清,再加上网上各种各样对它错误的解释,你就更难看清了。所以在讲更本质的东西——比如 suspend ——之前,我需要先花一整期的时间来跟你解释清楚,Kotlin 的协程到底是个什么东西,其实它就是个比较方便的线程框架。

至于更多的东西,比如刚才这里的 suspend 挂起,它是怎么回事,网上都在说的它的「非阻塞式」又是怎么回事,它是不是真的像很多人说的那样比线程更高效更轻量级,等等等等,我都会在下一期说得清清楚楚。

如果你喜欢我们的东西,欢迎关注收藏留言分享在看。我们下期见。咻!