Kotlin 协程


什么是协程

用同步的方式写异步的代码

【协程 Coroutines】 源于Simula 和 Modula-2语言,术语来自于 1958 年的 Melvin Edward Conway 发明并且拥有构建汇编程序,说明 协程是一种编程思想, 并不局限于特定的语言。

线程和协程的关系

从 Android 开发者的角度去理解它们的关系:

  • Android中我们所有的代码都跑在线程中的,而线程是跑在进程中的
  • 协程没有直接和操作系统关联,它也是跑在线程中的,可以是单线程,也可以是多线程。
  • 单线程中的协程总的执行时间并不会比不用协程少。
  • Android中,如果在主线程中进行网络访问,会抛出 NetworkOnMainThreadException,对于在主线程上的协程也不例外,所以在这种使用场景中也是要切换线程的。

协程的应用场景之一 线程控制

协程可以让我们在写代码时不用更多的关注多线程同时更方便的写出并发操作

场景

在Java中实现并发操作通常需要开启 Thread:

new Thread(new Runnable() {
    @Override
    public void run() {
        ...
    }
}).start();

Kotlin中

Thread({
    ...
}).start()

Kotlin和Java使用线程同样存在的问题:

  • 线程什么时候执行结束
  • 线程间如何相互通信
  • 多线程的管理

可以用Java控制线程 Executor线程池来进行线程管理

val executor = Executors.newCachedThreadPool()
executor.execute({
    ...
})

用Android的AsyncTask解决线程之间的通讯

object : AsyncTask<T0, T1, T2> { 
    override fun doInBackground(vararg args: T0): String { ... }
    override fun onProgressUpdate(vararg args: T1) { ... }
    override fun onPostExecute(t3: T3) { ... }
}

使用AsyncTask进行线程之间通讯带来的缺点AsyncTaskAndroid线程池Executor的封装):

  • 需要处理多个回调,容易陷入【回调地狱】。
  • 将业务强行拆分到前台、中间更新、后台三个函数。

使用 RxJava和协程都可以很好的解决上述的问题。

例子 使用协程进行网络访问将请求到的数据显示到对应的控件上:
launch({
    val user = api.getUser() // 👈 网络请求(IO 线程)
    nameTv.text = user.name  // 👈 更新 UI(主线程)
})

无需关心 launch不是一个顶层函数,只需要关系它的业务逻辑:

launch 函数加上实现在 {} 中的具体逻辑构成协程

通常我们进行网络请求,会传一个callback,或者在IO线程里进行阻塞式的同步调用,而在这段代码中,上下两个语句分别工作在两个线程里,并且写法上和普通单线程代码是一样的。

这里的 api.getUser是一个挂起函数,所以能够保证nameTv.text的正确赋值,这就涉及到了协程中最著名的【非阻塞式挂起】了。

协程好在哪里

什么是闭包

起源

闭包kotlin中提出的新概念,在Java8中就已经支持。

以Thread为例,看看什么是闭包:

// 创建一个 Thread 的完整写法
Thread(object : Runnable {
    override fun run() {
        ...
    }
})

// 满足 SAM,先简化为
Thread({
    ...
})

// 使用闭包,再简化为
Thread {
    ...
}

语法糖:当函数的最后一个参数是lambda表达式时,可以将lambda写在括号外。这就是它的闭包原则

注释

需要一个类型为 Runnable 的参数,而 Runnable 是一个接口,且只定义了一个函数 run,这种情况满足了 Kotlin 的SAM,可以转换成一个传递的 lambda表达式(第二段),以为闭包原则直接写成 Thread{…}(第三段)。

通过闭包简化 launch 函数

launch {
    ...
}

基本使用协程

说过launch函数不是顶层函数 并不能直接使用,我们通过下列方法来创建协程:

// 方法一,使用 runBlocking 顶层函数
runBlocking {
    getImage(imageId)
}

// 方法二,使用 GlobalScope 单例对象
//            👇 可以直接调用 launch 开启协程
GlobalScope.launch {
    getImage(imageId)
}

// 方法三,自行通过 CoroutineContext 创建一个 CoroutineScope 对象
//                                    👇 需要一个类型为 CoroutineContext 的参数
val coroutineScope = CoroutineScope(context)
coroutineScope.launch {
    getImage(imageId)
}

方法①,多用于单元测试,业务开发不会用到它,因为它是线程阻塞的。

方法②,在Android中不推荐这种用法,因为它的生命周期会和app一直,且不能够取消。和使用runBlocking不同它不会阻塞线程。

方法三,推荐用法,通过context参数去管理和控制协程的生命周期(这里的context和Android里的不是同一种东西,更像是一种概念,会有Android平台的封装配合使用)

其中 GlobaScopeCoroutineScope 的更多内容后面的文章再说。

协程最常用功能并发,而并发最常用的场景是多线程。可以使用Dispatchers.IO参数把任务切换到IO线程执行

coroutineScope.launch(Dispatchers.IO) {
    ...
}

使用 Dispatchers.Main 参数切换到主线程:

coroutineScope.launch(Dispatchers.Main) {
    ...
}

所以【什么是协程】中说到的异步请求的完整例子是这样的:

coroutineScope.launch(Dispatchers.Main) {   // 在主线程开启协程
    val user = api.getUser() // IO 线程执行网络请求
    nameTv.text = user.name  // 主线程更新 UI
}

在Java中回调式的写法是这样的:

api.getUser(new Callback<User>() {
    @Override
    public void success(User user) {
        runOnUiThread(new Runnable() {
            @Override
            public void run() {
                nameTv.setText(user.name);
            }
        })
    }

    @Override
    public void failure(Exception e) {
        ...
    }
});

更为麻烦的并发场景

对于回调式的写法,如果并发场景再复杂一点,嵌套的可能够多。如果使用协程,多层网络请求。

coroutineScope.launch(Dispatchers.Main) {       // 开始协程:主线程
    val token = api.getToken()                  // 网络请求:IO 线程
    val user = api.getUser(token)               // 网络请求:IO 线程
    nameTv.text = user.name                     // 更新 UI:主线程
}

如果进行多个网络请求等待完成后再刷新UI

api.getAvatar(user, callback)
api.getCompanyLogo(user, callback)

如果使用回调式写法,我们可能会选择妥协,则使用先后请求代替同时请求。

api.getAvatar(user) { avatar ->
    api.getCompanyLogo(user) { logo ->
        show(merge(avatar, logo))
    }
}

使用窗帘方式去实现可能会导致等待时长了一倍,也相差了一倍的性能。

协程并行请求网络

使用协程可以直接并行请求上下两行,最后把结果合并即可:

coroutineScope.launch(Dispatchers.Main) {
    //            👇  async 函数之后再讲
    val avatar = async { api.getAvatar(user) }    // 获取用户头像
    val logo = async { api.getCompanyLogo(user) } // 获取用户所在公司的 logo
    val merged = suspendingMerge(avatar, logo)    // 合并结果
    //                  👆
    show(merged) // 更新 UI
}

即便是比较复杂的并行网络请求,也可以使用并行写出结构清晰的代码。suspendingMerge 并不是协程API提供的方法,而是自定义挂起的结果合并方法。

协程的优势

让复杂的并发代码,变得简单且清晰是协程的优势。

协程如何在项目中使用

在项目中配置对Kotlin协程的支持

使用协程前,需要在 build.gradle 文件中增加 Kotlin 协程的依赖:

  • 项目目录下
buildscript {
    ...
    // 👇
    ext.kotlin_coroutines = '1.3.1'
    ...
}
  • Module 目录下:
dependencies {
    ...
    //                                       👇 依赖协程核心库
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines"
    //                                       👇 依赖当前平台所对应的平台库
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines"
    ...
}

Kotlin 协程以官方扩展库的形式进行支持。其中【核心库】和【平台库】的版本应该保持一致。

  • 核心库为协程的公共API部分。为拉在各个平台统一接口。
  • 平台库主要为协程在具体平台的具体实现方式。原因:多线程在各个平台的实现方式不同。

开始使用协程

协程最简单的使用。通过launch函数实现线程切换的功能:

//               👇
coroutineScope.launch(Dispatchers.IO) {
    ...
}
代码中协程的表现。

launch 函数:我要创建协程,并在指定的线程中运行它。被创建的【协程】是谁? 就是你传给 launch的代码,这段代码叫【协程】。

当你要切换线程或指定线程时。要在后台执行任务?

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

切换到前台刷新界面?

coroutineScope.launch(Dispatchers.IO) {
    val image = getImage(imageId)
    launch(Dispatchers.Main) {
        avatarIv.setImageBitmap(image)
    }
}

发生了嵌套的代码。

避免嵌套的样子使用协程

单单使用协程并不会比线程做更多的事情。使用更实用的函数:withContext。指定切换线程,并且在执行完内部逻辑后,自动线程切回执行。上述代码使用 withContext:

coroutineScope.launch(Dispatchers.Main) {      // 👈 在 UI 线程开始
    val image = withContext(Dispatchers.IO) {  // 👈 切换到 IO 线程,并在执行完成后切回 UI 线程
        getImage(imageId)                      // 👈 将会运行在 IO 线程
    }
    avatarIv.setImageBitmap(image)             // 👈 回到 UI 线程更新 UI
} 
比较两种写法来体现 withContext的优势

当要频繁切换线程时。通过下属代码对比:

// 第一种写法
coroutineScope.launch(Dispatchers.IO) {
    ...
    launch(Dispatchers.Main){
        ...
        launch(Dispatchers.IO) {
            ...
            launch(Dispatchers.Main) {
                ...
            }
        }
    }
}

// 通过第二种写法来实现相同的逻辑
coroutineScope.launch(Dispatchers.Main) {
    ...
    withContext(Dispatchers.IO) {
        ...
    }
    ...
    withContext(Dispatchers.IO) {
        ...
    }
    ...
}

优势:

由于它可以“来回切回来”消除了并发代码在协作时的嵌套。消除了嵌套关系,还可以将 withContext 放在单独的函数里:

launch(Dispatchers.Main) {              // 👈 在 UI 线程开始
    val image = getImage(imageId)
    avatarIv.setImageBitmap(image)     // 👈 执行结束后,自动切换回 UI 线程
}
//                               👇
fun getImage(imageId: Int) = withContext(Dispatchers.IO) {
    ...
}

实现了【同步的方式写异步的代码】。

launch(Dispatchers.Main) {              // 👈 在 UI 线程开始
    val image = getImage(imageId)
    avatarIv.setImageBitmap(image)     // 👈 执行结束后,自动切换回 UI 线程
}
//                               👇
fun getImage(imageId: Int) = withContext(Dispatchers.IO) {
    ...
}

withContext 单独放在函数中要注意那些

fun getImage(imageId: Int) = withContext(Dispatchers.IO) {
    // IDE 报错 Suspend function'withContext' should be called only from a coroutine or another suspend funcion
}

需要在 suspend函数中调用。(withContext一个 suspend 函数,所以需要在协程或者suspend函数中调用。)

什么是 suspend 函数

suspend 函数是kotlin协程最核心的关键字。中文意思为【暂停】、【可挂起】。

解释:

代码执行到 suspend 函数的时候会【挂起】,并且这个【挂起】非阻塞式的,不会阻塞当前线程。

修改上述代码让它可以执行:

//👇
suspend fun getImage(imageId: Int) = withContext(Dispatchers.IO) {
    ...
}

到底什么是 suspend ,什么是 【非阻塞】,如何【挂起】。下篇。

【挂起的本质】

协程中挂起的对象是什么? 挂起的对象是协程。

协程是什么?

启动一个协程可以使用 launch 或者 async 函数,协程就是这两个函数中闭包的代码块launch,async 或者其他函数创建的协程,在执行到某个 suspend函数时,这个协程会被 【suspend】挂起。

从哪里挂起?

在当前线程挂起。就是说当前协程在执行的线程中脱离。注意 它只是脱离了,当前线程不再去管理这个协程要求做什么。

当线程执行到协程的suspend函数的时候,暂时不再执行协程中的代码了。

分开来看,互相脱离的线程和协程接下来将发生什么事情。

线程:

协程的代码块中,线程执行到了suspend函数这里的时候,就暂时不再执行剩余的协程代码,跳出协程的代码块。

那线程接下来会做什么?

例如它是后台线程:

  • 无事可做,被系统回收
  • 继续执行别的后台任务

同Java线程池用到线程在工作结束后的完全一样:回收或者再利用。

如果是个Android的主线程,那么它会以一秒六十次的界面刷新任务。

常见的场景是,获取图片,显示出来:

// 主线程中
GlobalScope.launch(Dispatchers.Main) {
  val image = suspendingGetImage(imageId)  // 获取图片
  avatarIv.setImageBitmap(image)           // 显示出来
}

suspend fun suspendingGetImage(id: String) = withContext(Dispatchers.IO) {
  ...
}

在主线程的协程,它实质上会往你的主线程 post 一个 Runnable,这个 Runnable 就是你的协程代码:

handler.post {
  val image = suspendingGetImage(imageId)
  avatarIv.setImageBitmap(image)
}

协程被挂起时,主线程的 post 的Runnable 提前结束,然后继续执行它界面的刷新任务。

协程:

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

谁指定的?

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

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

常用的 Dispatchers,共有那些:

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

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

在 suspend 函数执行完成之后,协程为我们:自动将线程切回来

切回?什么意思?

协程原本是在 主线程中运动的,在代码执到 suspend 函数的时候,发生线程切换,根据 Dispatchers 切换到了IO线程;

切回】是协程会棒我再 post 一个 Runnable,让剩下的代码继续回到主线程去执行。

本质:

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

【切回】动作,在kotlin里叫做 resume,

函数挂起后是需要恢复的。

而恢复这个功能是协程的,如果不在协程里调用,恢复这个功能是没法实现的,所以也就回答了这个问题:为什么挂起函数必须在协程或者另一个挂起函数中调用。

如何挂起的?

挂起】是如何做到的。

定义的任意函数:

suspend fun suspendingPrint() {
  println("Thread: ${Thread.currentThread().name}")
}

I/System.out: Thread: main

输出结果还是为主线程。

为何没有切换线程了?因为它并不知道往哪里切,需要我们告诉它。

对比之前例子中的 suspendingGetImage函数代码:

//                                               👇
suspend fun suspendingGetImage(id: String) = withContext(Dispatchers.IO) {
  ...
}

不同之处在于 withContext 函数

withContext源码可知,它本身就是一个挂起函数,它接受一个Dispatcher 函数,依赖这个Dispatcher 函数的指示,你的协程被挂起,然后切换到别的线程。

所以suspend,并不能起到挂起函数的作用。

而真正挂起协程的,kotlin协程框架帮助我们做的。

加上 suspend 关键词是不行的,还需要直接或者间接的调用到 kotlin 协程框架自带的 suspend 函数才行。

suspend 的意义?

这个关键词最重要的作用就是提醒

提醒你这个函数是一个挂起操作,提醒它是个耗时函数,请在协程你调用它。

// 👇 redundant suspend modifier
suspend fun suspendingPrint() {
  println("Thread: ${Thread.currentThread().name}")
}

如果你创建使用 suspend 函数但是它内部并没有包含真正的挂起逻辑时,编译器会给你提示redundant suspend modigier,这个 suspend 关键词是多余的。

因为这个函数并没有发生真正的挂起,此时 suspend 关键词只有一个效果:限制这个函数只能在协程里被调用,在非协程的代码中是无法调用的。

创建suspend函数,要在它内部直接或者间接的调用 kotlin 自带的suspend 函数,这是你的 suspend才有意义。

如何自定义 suspend 函数?

先分为两个问题:

  • 什么时候需要自定义 suspend 函数?
  • 具体应该怎么写?

什么时候需要自定义 suspend 函数?

某个函数需要耗时操作时,那就可以把它写成 suspend 函数。这是原则。

耗时操作共分为两类:IO操作和CPU计算工作,比如文件的读写、网络交付、图片的模糊处理,都是耗时的。

具体操作:

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

使用 withContext 是因为它在挂起函数中功能最简单直接:把线程自动切走或者切回。

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

使用delay执行等待耗时操作

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

总结

什么是挂起?

挂起,就是一个稍后会被指定切回来的线程调度操作。

疑惑:

协程中的非阻塞式是什么

协程和RxJava在切换线程方面功能是一样的,都能写出避免嵌套回调的复杂并发代码,协程相比有什么优势,或者让开发者使用的理由?

协程 Job

Job 是标准库中启动协程后返回的对象,代表着协程本次作业。我们可以判断协程是否接受,是否取消,是否完成并且额可以取消当前协程以及嵌套子协程。

基本上每启动一个协程就会产生对应的Job。

coroutineScope.launch(Dispatchers.IO) {
    ...
}

launch 返回的就是一个Job,它可以用来管理协程,一个Job中可以关联多个子Job,同时它也提供了通过外部传入parent的实现。

Job 管理协程,那么它提供了六种状态表示协程的运行状态。

  1. New:创建
  2. Active:运行
  3. Completing:已经完成等待自身的子协程
  4. Completed:完成
  5. Cancelling:正在进行取消或者失败
  6. Cancelled:取消或失败

这六种状态 Job对外暴露了三种状态,它们可以随时通过Job来获取

public val isActive: Boolean
public val isCompleted: Boolean
public val isCancelled: Boolean

在你需要手动管理协程时,通过下面的方法来判断当前协程是否在运行。

while (job.isActive) {
// 协程运行中            
}

一般协程创建的时候就处在 Active状态,但是也有特殊情况。

kotlin.coroutines.CoroutineContext类

plus方法

  • Returns a context containing elements from this context and elements from other [context].

    返回一个上下文,其中包含来自此上下文的元素和来自其他[context]的元素。

  • The elements from this context with the same key as in the other one are dropped.

    删除这个上下文中与另一个上下文中具有相同键的元素。

 public operator fun plus(context: CoroutineContext):CoroutineContext

简写为 “+”

引用:

Kotlin 的协程用力瞥一眼 - 学不会协程?很可能因为你看过的教程都是错的

Kotlin 协程的挂起好神奇好难懂?今天我把它的皮给扒了

Kotlin协程核心库分析-2 Job简述

Kotlin协程实现原理:CoroutineScope&Job


文章作者: TheCara
版权声明: 本博客所有文章除特別声明外,均采用 CC BY-NC 4.0 许可协议。转载请注明来源 TheCara !
 上一篇
MVC、MVP、MVVM MVC、MVP、MVVM
MVC MVP MVVM 图示MVC(Model、View、Controller) 视图(View):用户界面 控制器(Controller):业务逻辑 模型(Model):数据保存 通信方式 View 传送指令到 Contro
2020-10-28
下一篇 
Lifecycle 讲解 Lifecycle 讲解
Lifecycle使用详解Lifecycle可以做什么Lifecycle 是具有生命周期感知能力的组件,也就是说,在Activity或者Fragment的生命周期发生变动的是否得到通知。我们往往会在Activity的各种生命周期方法里执行特
  目录