由于文章涉及到的只是点比较多、内容可能过长,可以根据自己的能力水平和熟悉程度分阶段跳着看。如有讲述的不正确的地方劳烦各位私信给笔者,万分感谢
由于时间原因,笔者白天工作只有晚上空闲时间才能写作,所以更新频率应该在一周一篇,当然我也会尽量的利用时间,争取能够提前发布。为了方便阅读将本文章拆分个多个章节,根据自己需要选择对应的章节,现在也只是目前笔者心里的一个大概目录,最终以更新为准:
Kotlin
协程基础及原理系列
- 史上最详Android版kotlin协程入门进阶实战(一) -> kotlin协程的基础用法
- 史上最详Android版kotlin协程入门进阶实战(二) -> kotlin协程的关键知识点初步讲解
- 史上最详Android版kotlin协程入门进阶实战(三) -> kotlin协程的异常处理
- 史上最详Android版kotlin协程入门进阶实战(四) -> 使用kotlin协程开发Android的应用
- 史上最详Android版kotlin协程入门进阶实战(五) -> kotlin协程的网络请求封装
Flow
系列
扩展系列
kotlin协程的关键知识点
上一本章节末尾我们提到,将在本章节中对以下知识点做初步讲解,包含上文提到的launch
和async
函数中的3个参数作用。清单如下:
- 协程调度器
CoroutineDispatcher
- 协程下上文
CoroutineContext
作用 - 协程启动模式
CoroutineStart
- 协程作用域
CoroutineScope
- 挂起函数以及
suspend
关键字的作用
当然还有一些其他的知识点也是很重要的,比如:CoroutineExceptionHandler
、Continuation
、Scheduler
、ContinuationInterceptor
等。但是确实涉及到的东西比较多,如果都展开的话,可能再写几个篇幅都没有办法讲完。上面这些是笔者认为掌握了这些知识点以后,基本可以开始着手项目实战了。我们后面在实战的过程中,边写边讲解。
协程调度器
上文我们提到一个协程调度器CoroutineDispatcher
的概念,调度器又是一个什么神奇的东西。在这里我们对调度器不做过多深入的解释,这可是协程的三大件之一,后面我们会有专门的篇幅做深入讲解。为了方便我们把协程调度器简称为调度器
,那接下来我们就看看什么是调度器。偷个懒,引用一下官方的原话:
- 调度器它确定了相关的协程在哪个线程或哪些线程上执行。协程调度器可以将协程限制在一个特定的线程执行,或将它分派到一个线程池,亦或是让它不受限地运行。
对于调度器的实现机制我们已经非常清楚了,官方框架中预置了4个调度器,我们可以通过Dispatchers
对象直接访问它们:
1 | kotlin复制代码public actual object Dispatchers { |
Default
:默认调度器,CPU密集型任务调度器,适合处理后台计算。通常处理一些单纯的计算任务,或者执行时间较短任务。比如:Json的解析,数据计算等IO
:IO调度器,,IO密集型任务调度器,适合执行IO相关操作。比如:网络处理,数据库操作,文件操作等Main
:UI调度器, 即在主线程上执行,通常用于UI交互,刷新等Unconfined
:非受限调度器,又或者称为“无所谓”调度器,不要求协程执行在特定线程上。
比如上面我们通过launch
启动的时候,因为我们没有传入参数,所有实际上它使用的是默认调度器Dispatchers.Default
1 | kotlin复制代码GlobalScope.launch{ |
Dispatchers.IO
和Dispatchers.Main
就都很好理解了。这是我们以后在Android开发过程中,打交道最多的2个调度器。比如后台数据上传,我们就可以使用Dispatchers.IO
调度器。刷新界面我们就使用Dispatchers.Main
调度器。为方便使用官方在Android协程框架库中,已经为我们定义好了几个供我们开发使用,如:MainScope
、lifecycleScope
、viewModelScope
。它们都是使用的Dispatchers.Main
,这些后续我们都将会使用到。
根据我们上面使用的方法,我们好像只有在启动协程的时候,才能指定具体使用那个Dispatchers
调度器。如果我要是想中途切换线程怎么办,比如:
- 现在我们需要通过网络请求获取到数据的时候填充到我们的布局当中,但是网络处理在
IO
线程上,而刷新UI是在主线程
上,那我们应该怎么办。
莫慌,莫慌,万事万物总有解决的办法。官方为我们提供了一个withContext
顶级函数,使用withContext
函数来改变协程的上下文,而仍然驻留在相同的协程中,同时withContext
还携带有一个泛型T
返回值。
1 | kotlin复制代码public suspend fun <T> withContext( |
呀,这一看withContext
这个东西好像很符合我们的需求嘛,我们可以先使用launch(Dispatchers.Main)
启动协程,然后再通过withContext(Dispatchers.IO)
调度到IO
线程上去做网络请求,把得到的结果返回,这样我们就解决了我们上面的问题了。
1 | kotlin复制代码GlobalScope.launch(Dispatchers.Main) { |
是不是很简单!!! 麻麻再也不会说我的handler满飞了,也不用走那万恶的回调地狱了。我想怎么切就怎么切,想去走个线程就去哪个线程。逻辑都按着顺序一步一步走,而且代码都是这么的丝滑。还要什么自行车,额.错了,还要什么handler,管他回调不回调,哥现在就是这么嚣张。
协程上下文
CoroutineContext
即协程上下文。它是一个包含了用户定义的一些各种不同元素的Element
对象集合。其中主要元素是Job
、协程调度器CoroutineDispatcher
、还有包含协程异常CoroutineExceptionHandler
、拦截器ContinuationInterceptor
、协程名CoroutineName
等。这些数据都是和协程密切相关的,每一个Element
都一个唯一key。
1 | kotlin复制代码public interface CoroutineContext { |
我们可以看到Element
是CoroutineContext
的内部接口,同时它又实现了CoroutineContext
接口,这么设计的原因是为了保证Element
中一定只能存放的Element
它自己,而不能存放其他类型的数据CoroutineContext
内还有一个内部接口Key
,同时它又是Element
的一个属性,这个属性很重要,我们先在这里插个眼,待会再讲解这个属性的作用。
那我们上面提到Job
、CoroutineDispatcher
、CoroutineExceptionHandler
、ContinuationInterceptor
、CoroutineName
等为什么又可以存放到CoroutineContext
中呢。我们接着往下看看它们各自的实现:
Job
1 | kotlin复制代码public interface Job : CoroutineContext.Element { |
CoroutineDispatcher
1 | kotlin复制代码public abstract class CoroutineDispatcher : |
CoroutineExceptionHandler
1 | kotlin复制代码public interface CoroutineExceptionHandler : CoroutineContext.Element { |
ContinuationInterceptor
1 | kotlin复制代码public interface ContinuationInterceptor : CoroutineContext.Element { |
CoroutineName
1 | kotlin复制代码public data class CoroutineName( |
现在要开始要集中注意力了。我们可以看到他们都是实现了Element
接口,同时都有个CoroutineContext.Key
类型的伴生对象key
,这个属性的作用是什么呢。那我们就得回过头来看看CoroutineContext
接口的几个方法了。
1 | kotlin复制代码public operator fun <E : CoroutineContext.Element> get(key: Key<E>): E? |
我们先从plus
方法说起,plus
有个关键字operator
表示这是一个运算符重载的方法,类似List.plus的运算符,可以通过+
号来返回一个包含原始集合和第二个操作数中的元素的结果。同理CoroutineContext
中是通过plus
来返回一个由原始的Element
集合和通过+
号引入的Element
产生新的Element
集合。
get
方法,顾名思义。可以通过 key
来获取一个Element
fold
方法它和集合中的fold是一样的,用来遍历当前协程上下文中的Element
集合。
minusKey
方法plus
作用相反,它相当于是做减法,是用来取出除key
以外的当前协程上下文其他Element
,返回的就是不包含key
的协程上下文。
现在我们就知道为什么我们之前说Element
中的key
这个属性很重要了吧。因为我们就是通过它从协程上下文中获取我们想要的Element
,同时也解释为什么Job
、CoroutineDispatcher
、CoroutineExceptionHandler
、ContinuationInterceptor
、CoroutineName
等等,这些Element
都有需要有一个CoroutineContext.Key
类型的伴生对象key
。我们写个测试方法:
如:
1 | js复制代码 private fun testCoroutineContext(){ |
1 | kotlin复制代码D/coroutineContext1: [JobImpl{Active}@21a6a21, CoroutineName(这是第一个上下文)] |
我们通过对比日志输出信息可以看到,通过+
号我们可以把多个Element
整合到一个集合中,同时我们也发现:
- 三个上下文中的
Job
是同一个对象。 - 第二个上下文在第一个的基础上增加了一个新的
CoroutineName
,新增的CoroutineName
替换了第一个上下文中的CoroutineName
。 - 第三个上下文在第二个的基础上又增加了一个新的
CoroutineName
和Dispatchers
,同时他们也替换了第二个上下文中的CoroutineName
和Dispatchers
。
但是因为这个+
运算符是不对称的,所以在我们实际的运用过程中,通过+
增加Element
的时候一定要注意它们结合的顺序。那么现在关于协程上下文的内容就讲到这里,我们点到为止,后面在深入理解阶段在细讲这些东西运行的原理细节。
协程启动模式
CoroutineStart
协程启动模式,是启动协程时需要传入的第二个参数。协程启动有4种:
DEFAULT
默认启动模式,我们可以称之为饿汉启动模式,因为协程创建后立即开始调度,虽然是立即调度,单不是立即执行,有可能在执行前被取消。LAZY
懒汉启动模式,启动后并不会有任何调度行为,直到我们需要它执行的时候才会产生调度。也就是说只有我们主动的调用Job
的start
、join
或者await
等函数时才会开始调度。ATOMIC
一样也是在协程创建后立即开始调度,但是它和DEFAULT
模式有一点不一样,通过ATOMIC
模式启动的协程执行到第一个挂起点之前是不响应cancel
取消操作的,ATOMIC
一定要涉及到协程挂起后cancel
取消操作的时候才有意义。UNDISPATCHED
协程在这种模式下会直接开始在当前线程下执行,直到运行到第一个挂起点。这听起来有点像ATOMIC
,不同之处在于UNDISPATCHED
是不经过任何调度器就开始执行的。当然遇到挂起点之后的执行,将取决于挂起点本身的逻辑和协程上下文中的调度器。
我们可以通过一个小例子的来看看这几个启动模式的实际情况:
1 | kotlin复制代码private fun testCoroutineStart(){ |
每个模式我们分别启动一个一次,DEFAULT
模式启动时,我们接着调用了cancel
取消协程,ATOMIC
模式启动时,我们在里面增加了一个挂起点delay
挂起函数,来区分ATOMIC
启动时的挂起前后执行情况,同样的UNDISPATCHED
模式启动时,我们也调用了cancel
取消协程,我们看实际的日志输出情况:
1 | kotlin复制代码D/defaultJob: CoroutineStart.DEFAULT |
或者
1 | kotlin复制代码D/undispatchedJob: CoroutineStart.UNDISPATCHED挂起前 |
为什么会出现2种情况。我们上面提到过DEFAULT
模式协程创建后立即开始调度,但不是立即执行,所以它有可能会被cancel
取消,导致没有输出defaultJob
这条日志。
同样的ATOMIC
模式启动的时候也接着调用了cancel
取消协程,但是因为没有遇到挂起点,所以挂起前的日志输出了,但是挂起后的日志没有输出。
而UNDISPATCHED
模式启动的时候也接着调用了cancel
取消协程,同样的因为没有遇到挂起点所以输出了UNDISPATCHED挂起前
,但是因为UNDISPATCHED
是立即执行的,所以他的日志UNDISPATCHED挂起前
输出在ATOMIC挂起前
的前面(注意这里是概率事件,主要突出UNDISPATCHED
是立即执行)。
接着我们在补充一下关于UNDISPATCHED
模式。我们上面有提到当以UNDISPATCHED
模式启动时,遇到挂起点之后的执行,将取决于挂起点本身的逻辑和协程上下文中的调度器。这句话我们又要怎么理解呢。我们还是以一个例子来认识解释UNDISPATCHED
模式,比如:
1 | kotlin复制代码private fun testUnDispatched(){ |
那我们将会看到如下输出,挂起前后都在一个worker-1
线程里面执行:
1 | kotlin复制代码D/main线程: -> join前 |
现在我们在稍作修改,我们在子协程launch
的时候使用UNDISPATCHED
模式启动:
1 | kotlin复制代码 private fun testUnDispatched(){ |
那我们将会看到如下输出:
1 | kotlin复制代码D/main线程: -> 挂起前 |
我们看到当以UNDISPATCHED
模式即使我们指定了协程调度器Dispatchers.IO
,挂起前
还是在main
线程里执行,但是挂起后
是在worker-1
线程里面执行,这是因为当以UNDISPATCHED
启动时,协程在这种模式下会直接开始在当前线程下执行,直到第一个挂起点。遇到挂起点之后的执行,将取决于挂起点本身的逻辑和协程上下文中的调度器,即join
处恢复执行时,因为所在的协程有调度器,所以后面的执行将会在调度器对应的线程上执行。
我们再改一下,把子协程在launch
的时候使用UNDISPATCHED
模式启动,去掉Dispatchers.IO
调度器,那又会出现什么情况呢
1 | kotlin复制代码 private fun testUnDispatched(){ |
1 | kotlin复制代码D/main线程: -> 挂起前 |
我们发现它们都在一个线程里面执行了。这是因为当通过UNDISPATCHED
启动后遇到挂起,join
处恢复执行时,如果所在的协程没有指定调度器,那么就会在join
处恢复执行的线程里执行,即挂起后
是在父协程(Dispatchers.Main
线程里面执行,而最后join后
这条日志的输出调度取决于这个最外层的协程的调度规则。
现在我们可以总结一下,当以UNDISPATCHED
启动时:
- 无论我们是否指定协程调度器,
挂起前
的执行都是在当前线程下执行。 - 如果所在的协程没有指定调度器,那么就会在
join
处恢复执行的线程里执行,即我们上述案例中的挂起后
的执行是在main
线程中执行。 - 当我们指定了协程调度器时,遇到挂起点之后的执行将取决于挂起点本身的逻辑和协程上下文中的调度器。即
join
处恢复执行时,因为所在的协程有调度器,所以后面的执行将会在调度器对应的线程上执行。
同样的我们点到为止,关于启动模式的的相关内容我们就现讲到这里。
协程作用域
协程作用域CoroutineScope
为协程定义作用范围,每个协程生成器launch
、async
等都是CoroutineScope
的扩展,并继承了它的coroutineContext
自动传播其所有Element
和取消。协程作用域本质是一个接口,不建议手工实现该接口,而应该首选委托实现。下面我们列出了部分CoroutineScope
相关定义:
1 | kotlin复制代码public interface CoroutineScope { |
我们可以看到CoroutineScope
也重载了plus
方法,通过+
号来新增或者修改我们CoroutineContext
协程上下文中的Element
。同时官方也为我们定义好了 MainScope
和GlobalScope
2个顶级作用域。GlobalScope
我们已经很熟了,前面的案例都是通过它来实现的。
MainScope
我们可以看到它的上下文是通过SupervisorJob
和 Dispatchers.Main
组合的,说明它是一个在主线程执行的协程作用域,我们在后续的Android实战开发中,会结合Activity、Fragment,dialog等使用它。这里不再继续往下扩展。
至于SupervisorJob
分析它之前,我们得先说一下协程作用域的分类。我们之前提到过父协程和子协程的概念,既然有父协程和子协程,那么必然也有父协程作用域和子父协程作用域。不过我们不是这么称呼,因为他们不仅仅是父与子的概念。协程作用域分为三种:
顶级作用域
–> 没有父协程的协程所在的作用域称之为顶级作用域。协同作用域
–> 在协程中启动一个协程,新协程为所在协程的子协程。子协程所在的作用域默认为协同作用域。此时子协程抛出未捕获的异常时,会将异常传递给父协程处理,如果父协程被取消,则所有子协程同时也会被取消。主从作用域
官方称之为监督作用域
。与协同作用域一致,区别在于该作用域下的协程取消操作的单向传播性,子协程的异常不会导致其它子协程取消。但是如果父协程被取消,则所有子协程同时也会被取消。
同时补充一点:父协程需要等待所有的子协程执行完毕之后才会进入Completed
状态,不管父协程自身的协程体是否已经执行完成。我们在最开始提到协程生命周期的时候就提到过下,现在回过头看是不是感觉很流程变得清晰。
1 | sql复制代码 wait children |
子协程会继承父协程的协程上下文中的Element
,如果自身有相同key的成员,则覆盖对应的key
,覆盖的效果仅限自身范围内有效。这个就可以用上我们前面学到的协程上下文CoroutineContext
的知识,小案例奉上:
1 | kotlin复制代码private fun testCoroutineScope(){ |
日志顺序的问题我们前面已经分析过原因,如果还不懂的话,麻烦您回到基础用法里面仔细的再看一遍。
1 | kotlin复制代码D/父协程上下文: [StandaloneCoroutine{Active}@81b6e46, Dispatchers.Main] |
可以看到第一个子协程的覆盖了父协程的coroutineContext
,它继承了父协程的调度器 Dispatchers.Main
,同时也新增了一个CoroutineName
属性。第二个子协程覆盖了父协程的coroutineContext
中的Dispatchers
,也就是将父协程的调度器Dispatchers.Main
覆盖为Dispatchers.Unconfined
,但是他没有继承第一个子协程的CoroutineName
,这就是我们说的覆盖的效果仅限自身范围内有效。接下来我们看看上面提到的协同作用域
和主从(监督)作用域
异常传递和协程取消的问题。
我们上面提到协同作用域
如果子协程抛出未捕获的异常时,会将异常传递给父协程处理,如果父协程被取消,则所有子协程同时也会被取消。先上代码看看效果:
1 | kotlin复制代码private fun testCoroutineScope2() { |
1 | kotlin复制代码D/scope: --------- 1 |
可以看到子协程scope2
抛出了一个异常,将异常传递给父协程scope1
处理,但是因为任何一个子协程异常退出会导致整体都将退出。所以导致父协程scope1
未执行完成成就被取消,同时还未执行完子协程scope3
也被取消了。
主从(监督)作用域
与协同作用域
一致,区别在于该作用域下的协程取消操作的单向传播性,子协程的异常不会导致其它子协程取消。分析主从(监督)作用域
的时候,我们需要用到supervisorScope
或者SupervisorJob
,如下代码块:
1 | kotlin复制代码private fun testCoroutineScope3() { |
1 | kotlin复制代码D/scope: --------- 1 |
可以看到子协程scope2
抛出了一个异常,并将异常传递给父协程scope1
处理,同时也结束了自己本身。因为在于主从(监督)作用域
下的协程取消操作是单向传播性,因此协程scope2
的异常并没有导致父协程退出,所以6
7
8
都照常输出,而3
4
5
因为在协程scope2
里面所以没有输出。
我们刚刚使用了supervisorScope
实现了主从(监督)作用域
,那我们通过SupervisorJob
又该如何实现呢。我们把supervisorScope
称之为主从(监督)作用域
,那么SupervisorJob
就可以称之为主从(监督)作业
,如下:
1 | kotlin复制代码private fun testCoroutineScope4() { |
1 | kotlin复制代码D/scope: 1--------- CoroutineName(scope2) |
是不是感觉和supervisorScope
的用法很像,我们通过创建了一个SupervisorJob
的主从(监督)协程作用域,调用了子协程的join
是为了保证它一定是会执行。同样的子协程scope2
抛出了一个异常,通过协程scope2
自己内部消化了,同时也结束了自己本身。
因为协程scope2
的异常并没有导致coroutineScope
作用域下的协程取消退出,所以协程scope3
照常运行输出2
,后又因为调用了我们定义的协程作用域coroutineScope
的cancel
方法取消了协程,所以即使我们后面调用了协程scope3
的join
,也没有输出3
,因为SupervisorJob
的取消是向下传播的,所以后面的4
5
都是在coroutineScope
的作用域中输出的。
现在我们关于协程作用域CoroutineScope
的作用我们已经有了一个大概的了解,同样的因为这个篇幅中我们是基础讲解,所以我们点到为止,如果还想深入了解,那就只能看后面的深入协程篇幅。
挂起函数
通过前面的篇幅我们已经知道,使用suspend
关键字修饰的函数叫作挂起函数
,挂起函数
只能在协程体内,或着在其他挂起函数
内调用。那挂起又是啥玩意呢
我估计各位看到这里的时候,可能有些人已经被上面的知识点弄的有点晕乎,别急,先放松下大脑,喝杯水,然后做个眼保健操缓解一下。下面开始敲黑板了,打起精神,要开始划重点了。
首先一个挂起函数
既然要挂起,那么他必定得有一个挂起点
,不然我们怎么知道函数是否挂起,从哪挂起呢。
我们定义一个空实现的suspend
方法,然后通过AS的工具栏中Tools
->kotlin
->show kotlin ByteCode
解析成字节码
1 | kotlin复制代码private suspend fun test(){ |
1 | kotlin复制代码final synthetic test(Lkotlin/coroutines/Continuation;)Ljava/lang/Object; |
1 | kotlin复制代码public interface Continuation<in T> { |
我们看到test
方法需要的是一个Continuation
接口,官方给的介绍是用于挂起点之后,返回类型为T
的值用的。那我们又是怎么拿到的这个Continuation
呢。要解开这个问题我们得先回到协程的创建和运行是的过程。
我们启动一个协程无非是通过launch
,async
等方法。我们之前有说到过他们的启动模式CoroutineStart
,但是并没有深入的去分析它的创建和启动过程,我们这里先回过头大概的看一下:
1 | kotlin复制代码public fun CoroutineScope.launch( |
我们看到在通过launch
启动一个协程的时候,他通过coroutine
的start
方法启动协程,然后我们接着往下看
1 | kotlin复制代码public fun start(start: CoroutineStart, block: suspend () -> T) { |
然后start
方法里面调用了CoroutineStart
的invoke
,这个时候我们发现了Continuation
。
1 | kotlin复制代码public operator fun <T> invoke(block: suspend () -> T, completion: Continuation<T>): Unit = |
而Continuation
又是通过start
方法传进来的coroutine
。所以现在可以确定,我们的协程体本身就是一个Continuation
,这也就解释了为什么可以在协程体内调用suspend
挂起函数了。
现在我们也可以确定,在协程内部挂起函数
的调用处就是挂起点
,如果挂起点
出现异步调用,那么当前协程就被挂起,直到对应的Continuation
通过调用resumeWith
函数才会恢复协程的执行,同时返回Result<T>
类型的成功或者失败的结果。
由于章节主题的限制,这里我们就不再下深入了。需要注意的是挂起函数
不一定真的会挂起,如果只是提供了挂起的条件,但是协程没有产生异步调用,那么协程还是不会被挂起。
预告:下一篇我们将会讲解kotlin协程中的异常处理,其实我们在这篇章节中已经,提到了一些异常处理,没有注意的同学可以回到协程作用域
看看。
需要源码的看这里:demo源码
原创不易。如果您喜欢这篇文章,您可以动动小手点赞收藏。
Android技术交流群,有兴趣的可以私聊加入
关联文章
Kotlin
协程基础及原理系列
- 史上最详Android版kotlin协程入门进阶实战(一) -> kotlin协程的基础用法
- 史上最详Android版kotlin协程入门进阶实战(二) -> kotlin协程的关键知识点初步讲解
- 史上最详Android版kotlin协程入门进阶实战(三) -> kotlin协程的异常处理
- 史上最详Android版kotlin协程入门进阶实战(四) -> 使用kotlin协程开发Android的应用
- 史上最详Android版kotlin协程入门进阶实战(五) -> kotlin协程的网络请求封装
Flow
系列
扩展系列
本文转载自: 掘金