kotlin协程 看完应该会理解了叭

714 阅读7分钟

1.前言

kotlin 协程只有演示很简单,其他的都很难。当抱着一种研究api,如何使用的角度去理解它,大概率是无法理解或者很难理解的(如果是大佬当我没说)

可能介绍协程的帖子和使用api看一遍又一遍,看的时候:哦!明白了明白了,就这么回事。在琢磨琢磨就又懵了,这都是啥玩意??

kotlin 有时候真的让人又爱又恨,爱它因为它真的很简单,恨它因为它简单的过了头,封装的太好屏蔽了太多细节。让开发者一脸懵逼的就实现了一个效果。

就好比小时候解应用题,Java是半个老师写的时候有解题步骤,kotlin是本参考答案上面只写着选A。

对我这种小菜鸡很友好又很不友好,毕竟咱们没有大神的实力 能看懂kotlin源码,只能一点点磨。

协程更是出了名的困难,Java中复杂的多线程操作,在kotlin协程中被封装成函数调用,消除了回调形式,变成同步顺序执行代码。

但是Java中复杂步骤不是凭空消失了,只是大家看不见了,kotlin官方封装的太好。好处是使用简单,弊端就是很难理解。

我磨了一段实践协程,总算有点点微末的心得,希望能帮到大家。

2.线程框架

首先明确思路,kotlin协程是一套封装好的线程框架!! 它的目的是为了帮助开发更简单处理耗时任务。

理解kotlin协程很容易被它的各种高阶函数迷了双眼,如果刚从Java转过来的同学,更是直接懵逼了,这都是什么令人眼花缭乱的语法。

开始查kotlin协程的API,每个类,函数的作用特性。但是请牢记一点!一定要从线程框架的眼光去看待kotlin协程。

假设线程框架的发展阶段:

  1. Java Thread 原始阶段
  2. Handler 初级阶段
  3. Java8 Executor 中级阶段
  4. kotlin协程 高级阶段

说的玄乎点 要以一种发展的眼光看待kotlin协程,它不是孙悟空直接从石头里蹦出来。它的出现肯定有原因 和条件。

编程语言在发展进步,kotlin有很多Java不具备的语言特性,原本java多线程框架有如何如何的问题。kotlin作为一门新语言在设计多线程框架的时候,借鉴了原本旧的框架,解决了旧框架的问题,使用的更方便。

学习kotlin协程 在一定程度上了解kotlin协程api后。 也不该忘记Java,可以用一种对比的眼光,比如:协程作用域对比Java多线程解决了什么问题,协程挂起与恢复对比Java多线程解决了什么问题。用这种方式思考相信会比单纯研究api有点不一样的理解。

3.协程初体验

//定义协程作用域
val mainScope =MainScope()
//把启动协程需要参数都定义出来
//block 协程体
val block: suspend CoroutineScope.() -> Unit ={
	Log.d("aaa","协程运行于 ${Thread.currentThread().name}")
}
//launch函数启动协程
val job :Job = mainScope.launch(context = EmptyCoroutineContext,
    start = CoroutineStart.DEFAULT,block)

//启动协程 简化参数使用
val job :Job =  mainScope.launch(Dispatchers.IO){
	Log.d("aaa","协程运行于 ${Thread.currentThread().name}")
}

---日志输出--
D/aaa: 协程运行于 DefaultDispatcher-worker-1
D/aaa: 协程运行于 main

上面的小例子中有很多kotlin协程的重要概念 和 现象 让我们一点一点的解释,讨论

  1. MainScope 协程作用域 实现接口 CoroutineScope , 它是一个顶层接口,所有的协程作用域都实现此接口。
    1. 我倾向于把他看成 协程任务的管理者

    2. launch函数 用于创建协程,launch函数是 CoroutineScope 的扩展函数 public fun CoroutineScope.launch() 。 其他的协程启动函数 也同样是CoroutineScope 的扩展函数。

    3. 表明协程启动必须依附于协程作用域

    4. 当协程作用域被取消时 内部所有协程都会被取消

    5. 单独的协程作用域,比如: MainScope 单独创建它并没什么作用,它并不能执行任何任务

    6. 所以我倾向把它看出 协程任务的管理者

      public interface CoroutineScope {
      
          public val coroutineContext: CoroutineContext
      }
      
  2. CoroutineContext 协程上下文, 实际上是一个以Key为索引的数据集合,Job、Dispatcher调度器都可以是它的元素。
  3. block 协程体
    1. 编写业务代码的地方 ,suspend CoroutineScope.() -> Unit 是协程作用域的扩展函数。
    2. 也就是说 协程体本身也是个协程作用域,可以使用launch函数启动新协程。
    3. 还有一个好处是,可以拿到CoroutineContext对象 ,写业务代码时可以拿到Job、Dispatcher调度器等存储在 CoroutineContext对象中的数据
  4. Dispatchers 线程调度器
    1. 继承自CoroutineDispatcher,提供了四个默认实现 ,当函数不使用调度器时承接当前作用域的调度器
      1. Dispatchers.Unconfined 不指定线程, 如果子协程切换线程那么接下来的代码也运行在该线程上
      2. Dispatchers.IO 适用于IO读写
      3. Dispatchers.Main 根据平台不同而有所差, Android上为主线程
      4. Dispatchers.Default 默认调度器, 在线程池中执行协程体, 适用于计算操作
  5. CoroutineStart 协程启动模式
    1. 默认值为 CoroutineStart.DEFAULT 。即协程会在声明的同时就立即进入等待调度的状态,即可以立即执行的状态。可以通过将其设置为CoroutineStart.LAZY 来实现延迟启动,即懒加载
  6. launch函数
    1. 协程构造器,负责创建协程。是 CoroutineScope 的扩展函数
  7. Job
    1. launch函数返回值,代表一个协程任务,通过Job对象可以拿到当前协程的状态,取消协程任务,获取当前协程内的子协程等等

4.协程演示demo讨论

上一节初步认识了kotlin协程,简单介绍了kotlin协程中一些重要的元素,并演示了一个协程案例。接下来讨论这个简约而不简单的案例

				val mainScope = MainScope()
        val job1 = mainScope.launch {
            Log.d("aaa","111 协程运行于 ${Thread.currentThread().name}")
        }
        val job2 = mainScope.launch {
            Log.d("aaa","222 协程运行于 ${Thread.currentThread().name}")
        }
        val job3 = mainScope.launch(Dispatchers.IO){
            Log.d("aaa","333 协程运行于 ${Thread.currentThread().name}")
        }

//日志输出
333 协程运行于 DefaultDispatcher-worker-1
111 协程运行于 main
222 协程运行于 main

不就是启动三个协程任务,job3处于IO协程,job1,job2处于主线程,输出了三行日志,这有什么好讨论的?

看日志的输出内容,job3子线程名,job1,job2主线程名。那么可不可以这么说不考虑协程具有的挂起,恢复特性。开启运行在主线程的协程直接在主线程写代码 等价。开启运行在子线程的协程java new Thread() 等价。如下代码:


void runIOThread() {
    Thread thread = new Thread(runnable);
    thread.start();
}

void runMainThread() {
    Handler handler = new Handler(Looper.getMainLooper());
    handler.post(runnable);
}

private final Runnable runnable = new Runnable() {
    @Override
    public void run() {
        Log.d("aaa", "日志输出Runnable 运行于 :" + Thread.currentThread().getName());
    }
};

kotlin协程是一套线程api,切换线程同样是把代码块送到主线程 或 子线程。

Java代码写在Runnable ,协程代码写在 block: suspend CoroutineScope.() -> Unit 协程体中。 从效果上看 两者可以等价 Runnable == block: suspend CoroutineScope.() -> Unit

在看输出日志的顺序是:3,1,2。 需要强调的是 这个顺序不是随机的,是固定的,无论运行多少次执行的顺序都是:3,1,2。在代码中job3是最后编写,反而第一个运行,为什么会出现这种情况?

依然用Runnable 等价 block: suspend CoroutineScope.() -> Unit 去思考。协程运行的代码可以理解为Handler发送消息。

job1,job2 使用主线程Handler向主线程消息队列中发送两个任务

job3使用子线程Handler向子线程消息队列发送一个任务。

Android主线程消息队列是非常繁忙 有很多任务要处理。job1,job2排在消息队列的末端需要一点处理时间。

子线程队列处于空闲,job3立刻被处理,所以日志输出顺序是:3,1,2。

Dispatchers调度器也能佐证 Runnable 等价 block: suspend CoroutineScope.() -> Unit 的猜想,简单看下源码:

Dispatchers.IO 指定协程运行在IO线程。DefaultIoScheduler 实现了 Java线程池接口 Executor

Executor 需要传入 Runnable

也可以仔细思考一下 我们对 Dispatchers调度器的描述:指定协程运行于某个线程。线程的范畴大过协程,协程可以在任意线程中运行,可不就是任务Runnable 的概念。

public val IO: CoroutineDispatcher = DefaultIoScheduler

internal object DefaultIoScheduler : ExecutorCoroutineDispatcher(), Executor {}

public interface Executor {
    void execute(Runnable command);
}

5.协程挂起,恢复概念讨论

经过上一节的讨论,我们可以说我们编写的协程代码是可以运行在不同的线程的任务。时时刻刻需要谨记的是,kotlin协程是一套线程框架,一切思维点都要围绕线程去思考。处理多线程问题最麻烦和棘手的就是线程间通信。

模拟一个用户登录的场景:

Android原生方式处理,thread+handler 伪代码如下

  1. 收集登录参数
  2. 开启子线程发起网络请求
  3. 通过handler发送消息到主线程,修改UI
private Handler handler = new Handler(Looper.getMainLooper()){
    @Override
    public void handleMessage(@NonNull Message msg) {
        super.handleMessage(msg);
        if (msg.what ==1){
            textView.setText("登录成功");
        }
    }
};
void login(){
    String userName = "";
    String password = "";
    new Thread(new Runnable() {
        @Override
        public void run() {
            //TODO: 2022/6/18网络请求
						handler.sendEmptyMessage(1);
        }
    }).start();
}

kotlin协程方式处理,伪代码如下

  1. 通过 launch函数创建协程
  2. 收集参数
  3. 调用 挂起函数request(),内部使用withContent()函数,切换IO线程执行网络请求 ,使用return语句返回网络请求结果
  4. 在协程内接收 request() 返回值
  5. 修改UI
		private fun login(){
        lifecycleScope.launch{
            val userName = ""  //主线程
            val password = ""  //主线程
            val user = request() //子线程
            textView.text = user.name //主线程
        }
    }
    
    private suspend fun request():UserEntity = withContext(Dispatchers.IO){
        // TODO: 网络请求 
        return@withContext UserEntity("")
    }

因为Android系统设计的原因,主线程不能执行耗时任务,子线程不能修改UI。所以在子线程执行网络请求后必须切换到主线程才能执行UI操作。

withContext 是最常见的挂起函数,可以使函数体当中的代码运行在指定线程。withContext(Dispatchers.IO){} 的效果是指定代码运行在IO线程,那么不考虑通信的情况withContext(Dispatchers.IO){} 的效果等价于 new Thread

两者区别在于:

  1. new Thread 必须通过handler方式 把结果发送到主线程处理
  2. withContext() 由于kotlin协程底层的封装可以直接把数据通过return语句返回

上述两种代码实际上都完成了同一件事情,主线程与子线程通信。thread+handler方式不仅仅需要程序员编写业务代码,还需要创建子线程,做线程间通信。

kotlin协程也是存在线程间通信的,不过它经过层层封装把线程间通信的过程隐藏的特别好达到了代码按顺序同步调用的效果。不需要程序员处理线程间通信,kotlin协程自动完成了。

线程间通信在 kotlin协程中叫做 挂起和恢复。

suspend 关键字的作用是标记 提醒, 协程代码经过编译后会根据suspend关键字分隔为一个个代码片段,大概长这样:

private fun login(){
        lifecycleScope.launch{
          val userName = ""
          val password = ""
            val user = withContext(Dispatchers.IO){
                // TODO: 网络请求
                return@withContext UserEntity("")
            }
            val list = withContext(Dispatchers.IO){
                // TODO: 网络请求
                return@withContext UserEntity("")
            }
            textView.text = user.name
        }
    }

// 编译后

private fun login(code:Int){
        when(code){
            0 ->{
                val userName = ""
                val password = ""
            }
            1 ->{
                // TODO: 网络请求 子线程 获取用户信息
            }
            2 ->{
                // TODO: 网络请求 子线程 获取列表
            }
            3 ->{
                textView.text = user.name
            }
        }
    }

6.协程API分析

经过前面的讨论相信对于协程已经有了基本的认识,现在来仔细研究一下协程的api,kotlin的优点是它太方便太简洁了,封装的太好。但是学习的时候优点就变成了缺点,因为封装的太好,开发人员什么都不用做,一个函数名一个花括号 程序就运行起来了, 只能说一脸懵逼,表示这段程序能运行和我没什么关系。所以搞明白协程的api 还是很有必要的。

看一个最简单的例子,使用 MainScope 启动一个协程, 代码很简单但是对于刚接触协程的人 绝对是噩梦。 啥啥啥?这都是啥?我们一点一点的分析

private val mainScope = MainScope()

mainScope.launch {
            
}

6.1协程作用域的概念

MainScope 是一个官方提供的协程作用域,协程只能在协程作用域当中被启动,那么为什么要有协程作用域呢?

Android中有个很经典的问题:内存泄漏。 本质上是生命周期的问题。

在Activity内执行一段耗时操作,比如:开启子线程网络请求 或 io操作,完成之后在UI展示成功文本。 在耗时操作执行过程中,关闭activity,如果没有停止子线程,那么当子线程任务完成后依然会操作UI展示成功文本。但这个时候activity已经关闭,无法进行UI操作,就会抛异常。

触发的原因在于耗时任务的存活时间比Activity长,Activity结束之后耗时任务依旧要获取Activity相关的对象引用当然会报错。

网络请求要在Activity结束后及时取消也是这个原因。

上面已经提到过 协程的本质是线程API的封装,协程的挂起与恢复的本质是切线程,协程的出现也是为了让开发人员能够更方便的处理耗时任务。

一个协程可以看作一个耗时任务,那么就一定会出现生命周期不一致的问题。kotlin约束协程必须在协程作用域内启动,目的在于方便管理协程。

开发者需要取消协程时,只需要对协程作用域进行操作,无需关注N个耗时任务,协程作用域取消后内部的协程会自动取消。

总结:协程作用域的目的在于管理协程

CoroutineScope 大体上可以分为三种:

GlobalScope。即全局协程作用域,在这个范围内启动的协程可以一直运行直到应用停止运行。GlobalScope 本身不会阻塞当前线程,且启动的协程相当于守护线程,不会阻止 JVM 结束运行

runBlocking。一个顶层函数,和 GlobalScope 不一样,它会阻塞当前线程直到其内部所有相同作用域的协程执行结束

自定义 CoroutineScope。可用于实现主动控制协程的生命周期范围,对于 Android 开发来说最大意义之一就是可以在 Activity、Fragment、ViewModel 等具有生命周期的对象中按需取消所有协程任务,从而确保生命周期安全,避免内存泄露

转自:juejin.cn/post/690827…

6.2 协程作用域分析

所有协程作用域都实现接口 CoroutineScope ,没有抽象方法,只有一个抽象函数 CoroutineContext 。放到后面讲

public interface CoroutineScope {
	public val coroutineContext: CoroutineContext
}

常见的协程作用域如下:

  1. GlobalScope

    1. object修饰的单例类,全局作用域,
    2. 通过GlobalScope 启动的协程生命周期与整个应用的生命周期一致,应用程序不结束协程任务就可以一直运行
    3. GlobalScope 启动的协程相当于守护线程,不会阻止 JVM 结束运行
  2. runBlocking

    1. public actual fun <T> runBlocking(context: CoroutineContext, block: suspend CoroutineScope.() -> T): T
    2. 顶层高阶函数,第二个参数 block: suspend CoroutineScope.() -> T 被定义为CoroutineScope 的扩展函数,因此它内部就隐含了协程作用域,可以在方法体中启动新协程。单纯的suspend函数 是无法启动新协程的,协程只能运行在协程作用域中
    3. runBlocking会阻塞启动线程,假设在主线程中使用runBlocking开启协程,只有等待runBlocking内部代码完成后才会执行 runBlocking代码块后面声明的代码
    4. 为什么runBlocking 会阻塞线程呢? 因为它只是一个普通的顶层函数,没有被suspend修饰,可以运行在任意位置,即使运行在协程中 也没有挂起恢复的操作。而它具备开启协程执行耗时操作的功能,所以它就像一个普通的耗时函数一样 会阻塞线程。
  3. 自定义CoroutineScope

    1. *MainScopelifecycleScopeCoroutineScope*(Dispatchers.Main) **都是自定义的协程作用域

    2. MainScope 是系统提供的运行在主线程的协程作用域,在Activity中使用可以手动调用 mainScope.*cancel*() 管理协程作用域的生命周期,在适当的位置取消。

    3. 协程作用域被取消后,内部运行的所有协程都会取消,不会发生内存泄漏的问题

    4. lifecycleScope 是Lifecycle组件 提供的协程作用域 开箱即用,不需要开发人员自己处理生命周期问题

    5. public fun CoroutineScope(context: CoroutineContext): CoroutineScope 顶层函数,用来自定义协程作用域,大部分情况不用自定义,用jetpack提供的就好

      class Activity {
      
      		private val mainScope = MainScope()
      
          private fun initData(){
              mainScope.launch {}
          }
      
          override fun onDestroy() {
              mainScope.cancel()
              super.onDestroy()
          }
      }
      
  4. coroutineScope

    1. 顶层函数 public suspend fun <R> coroutineScope(block: suspend CoroutineScope.() -> R)
    2. 创建一个子协程作用域
    3. 因为它是一个suspend函数,只能运行在协程中 或 另一个suspend函数中。 参数是CoroutineScope 协程作用域的扩展函数,两者结合 所以叫它子协程作用域
    4. coroutineScope函数 无法设置Dispatchers 参数指定运行线程,它的运行线程与所在协程一致。
    5. 在作用域会阻塞所在协程,等待coroutineScope函数 内部所有任务执行完毕,才会执行其他任务,也就是说coroutineScope函数 是按顺序串行执行
    6. coroutineScope函数 可以指明返回值 与lambda表达式的语法一直,可以使用return语句,也可以不使用最后一行当作返回值
    7. 如下代码的输出顺序 串行依次输出
    lifecycleScope.launch(Dispatchers.Main) {
                Log.d("aaa", "==start== ${Thread.currentThread().name}")
                val value = coroutineScope {
                    Log.d("aaa", "==coroutineScope 1== ${Thread.currentThread().name}")
                    delay(1000)
                    Log.d("aaa", "==coroutineScope 2== ${Thread.currentThread().name}")
                    return@coroutineScope 2
                }
                Log.d("aaa", "value:$value")
                coroutineScope {
                    Log.d("aaa", "==coroutineScope 3==")
                    delay(500)
                    Log.d("aaa", "==coroutineScope 4==")
                }
                Log.d("aaa", "==end== ${Thread.currentThread().name}")
            }
    
    ==end== main
    ==coroutineScope 1== main
    ==coroutineScope 2== main
    ==coroutineScope 3==
    ==coroutineScope 4==
    value:2
    ==coroutineScope 5==
    ==coroutineScope 6==
    ==coroutineScope 7==
    ==coroutineScope 8==
    ==end== main
    
  5. supervisorScope

    1. 顶层函数 public suspend fun <R> supervisorScope(block: suspend CoroutineScope.() -> R): R

    2. 创建一个子协程作用域 与 coroutineScope的区别不大 主要差别在于异常的处理,coroutineScope内部协程发生异常后,整个作用域内所有的任务都会停止。supervisorScope内部子协程发生异常后,只有错误的协程才会停止,其他协程正常运行

      val handler =CoroutineExceptionHandler{_, exception->
      	log("CoroutineExceptionHandler got $exception")
      }
      
      lifecycleScope.launch{
      	supervisorScope{
      		launch(handler){
      		// Child 1
                  throw IllegalArgumentException()
      		}
      		launch{
      		// Child 2
                  delay(1000)
                  log("child 2 run over") //可顺利执行完成
      		}
      	 delay(1000)
         log("Job: ${coroutineContext[Job]?.javaClass}")
      	}
      }
      

6.3 创建协程

除了launch()函数外 还有 async() 函数 用于创建协程。两者的区别在于 async() 函数可以设置协程返回值,如下

val job =lifecycleScope.launch{}
val deferred =lifecycleScope.async{
	return@async "啦啦啦"
}

更常见的写法是

lifecycleScope.launch {
           val deferred1 =  async(Dispatchers.IO) {
                // TODO: 耗时操作
		             delay(1000)
							   return@async "啦啦啦1"
            }
            val deferred2 =  async(Dispatchers.IO) {
                // TODO: 耗时操作
                delay(1000)
								return@async "啦啦啦2"
            }
            val result1 = deferred1.await()
            val result2 = deferred2.await()
}

这有啥用? 好像和withContext(Dispatchers.IO)**{}** 效果一样阿?

首先需要明确的是 withContext() 是挂起函数,async() 是顶层函数 用于创建协程 返回一个协程任务对象Deferred ,两者有本质区别。

上述代码使用async()lifecycleScope.launch() 父协程中开启两个子协程,父协程默认运行在主线程,那么上述代码的效果相当于在主线程开启两个子线程并发执行任务。

如果换成withContext() 那么只有lifecycleScope.launch() 一个协程协程,执行过程中遇见withContext() 函数会执行挂起恢复操作,代码按顺序依次执行。

所以单纯在协程中使用withContext() 函数切换线程,此时任务并没有并发执行,是串行执行。

开启多个运行于子线程的协程才会并发执行。

7.学习路线推荐

先看扔物线老师的三篇文章或视频入门:

【码上开学】Kotlin 的协程用力瞥一眼 - 掘金 (juejin.cn)

两篇原理讲解:

硬核万字解读:Kotlin 协程原理解析 - 开发者头条 (toutiao.io)

Kotlin Coroutines(协程) 完全解析(一),协程简介 - 简书 (jianshu.com)

两篇细节讲解:

一文快速入门 Kotlin 协程 - 掘金 (juejin.cn)

最全面的Kotlin协程: Coroutine/Channel/Flow 以及实际应用 - 掘金 (juejin.cn)

kotlin协程内容多难理解, 希望大家多多练习,早日掌握。乌拉!