阅读 3565

史上最详Android版kotlin协程入门进阶实战(二)

banners_twitter.png

由于文章涉及到的只是点比较多、内容可能过长,可以根据自己的能力水平和熟悉程度分阶段跳着看。如有讲述的不正确的地方劳烦各位私信给笔者,万分感谢image.png

由于时间原因,笔者白天工作只有晚上空闲时间才能写作,所以更新频率应该在一周一篇,当然我也会尽量的利用时间,争取能够提前发布。为了方便阅读将本文章拆分个多个章节,根据自己需要选择对应的章节,现在也只是目前笔者心里的一个大概目录,最终以更新为准:

Kotlin协程基础及原理系列

Flow系列

扩展系列

kotlin协程的关键知识点

上一本章节末尾我们提到,将在本章节中对以下知识点做初步讲解,包含上文提到的launchasync函数中的3个参数作用。清单如下:

  1. 协程调度器CoroutineDispatcher
  2. 协程下上文CoroutineContext作用
  3. 协程启动模式CoroutineStart
  4. 协程作用域CoroutineScope
  5. 挂起函数以及suspend关键字的作用

当然还有一些其他的知识点也是很重要的,比如:CoroutineExceptionHandlerContinuationSchedulerContinuationInterceptor等。但是确实涉及到的东西比较多,如果都展开的话,可能再写几个篇幅都没有办法讲完。上面这些是笔者认为掌握了这些知识点以后,基本可以开始着手项目实战了。我们后面在实战的过程中,边写边讲解。

协程调度器

上文我们提到一个协程调度器CoroutineDispatcher的概念,调度器又是一个什么神奇的东西。在这里我们对调度器不做过多深入的解释,这可是协程的三大件之一,后面我们会有专门的篇幅做深入讲解。为了方便我们把协程调度器简称为调度器,那接下来我们就看看什么是调度器。偷个懒,引用一下官方的原话:

  • 调度器它确定了相关的协程在哪个线程或哪些线程上执行。协程调度器可以将协程限制在一个特定的线程执行,或将它分派到一个线程池,亦或是让它不受限地运行。

对于调度器的实现机制我们已经非常清楚了,官方框架中预置了4个调度器,我们可以通过Dispatchers对象直接访问它们:

public actual object Dispatchers {
    @JvmStatic
    public actual val Default: CoroutineDispatcher = createDefaultDispatcher()
    @JvmStatic
    public actual val Main: MainCoroutineDispatcher
        get() = MainDispatcherLoader.dispatcher
    @JvmStatic
    public actual val Unconfined: CoroutineDispatcher = kotlinx.coroutines.Unconfined
    @JvmStatic
    public val IO: CoroutineDispatcher = DefaultScheduler.IO
}
复制代码
  • Default:默认调度器,CPU密集型任务调度器,适合处理后台计算。通常处理一些单纯的计算任务,或者执行时间较短任务。比如:Json的解析,数据计算等
  • IO:IO调度器,,IO密集型任务调度器,适合执行IO相关操作。比如:网络处理,数据库操作,文件操作等
  • Main:UI调度器, 即在主线程上执行,通常用于UI交互,刷新等
  • Unconfined:非受限调度器,又或者称为“无所谓”调度器,不要求协程执行在特定线程上。

比如上面我们通过launch启动的时候,因为我们没有传入参数,所有实际上它使用的是默认调度器Dispatchers.Default

GlobalScope.launch{
    Log.d("launch", "启动一个协程")
}
//等同于
GlobalScope.launch(Dispatchers.Default){
    Log.d("launch", "启动一个协程")
}
复制代码

Dispatchers.IODispatchers.Main就都很好理解了。这是我们以后在Android开发过程中,打交道最多的2个调度器。比如后台数据上传,我们就可以使用Dispatchers.IO调度器。刷新界面我们就使用Dispatchers.Main调度器。为方便使用官方在Android协程框架库中,已经为我们定义好了几个供我们开发使用,如:MainScopelifecycleScopeviewModelScope。它们都是使用的Dispatchers.Main,这些后续我们都将会使用到。

根据我们上面使用的方法,我们好像只有在启动协程的时候,才能指定具体使用那个Dispatchers调度器。如果我要是想中途切换线程怎么办,比如:

  • 现在我们需要通过网络请求获取到数据的时候填充到我们的布局当中,但是网络处理在IO线程上,而刷新UI是在主线程上,那我们应该怎么办。

莫慌,莫慌,万事万物总有解决的办法。官方为我们提供了一个withContext顶级函数,使用withContext函数来改变协程的上下文,而仍然驻留在相同的协程中,同时withContext还携带有一个泛型T返回值。

public suspend fun <T> withContext(
    context: CoroutineContext,
    block: suspend CoroutineScope.() -> T
): T {
 //......
}
复制代码

呀,这一看withContext这个东西好像很符合我们的需求嘛,我们可以先使用launch(Dispatchers.Main)启动协程,然后再通过withContext(Dispatchers.IO)调度到IO线程上去做网络请求,把得到的结果返回,这样我们就解决了我们上面的问题了。

GlobalScope.launch(Dispatchers.Main) {
    val result = withContext(Dispatchers.IO) {
        //网络请求...
        "请求结果"
    }
    btn.text = result
}
复制代码

是不是很简单!!! 麻麻再也不会说我的handler满飞了,也不用走那万恶的回调地狱了。我想怎么切就怎么切,想去走个线程就去哪个线程。逻辑都按着顺序一步一步走,而且代码都是这么的丝滑。还要什么自行车,额.错了,还要什么handler,管他回调不回调,哥现在就是这么嚣张image.png

协程上下文

CoroutineContext即协程上下文。它是一个包含了用户定义的一些各种不同元素的Element对象集合。其中主要元素是Job、协程调度器CoroutineDispatcher、还有包含协程异常CoroutineExceptionHandler、拦截器ContinuationInterceptor、协程名CoroutineName等。这些数据都是和协程密切相关的,每一个Element都一个唯一key。

public interface CoroutineContext {
    public operator fun <E : CoroutineContext.Element> get(key: Key<E>): E?
    
    public fun <R> fold(initial: R, operation: (R, CoroutineContext.Element) -> R): R
    
    public operator fun plus(context: CoroutineContext): CoroutineContext =
        if (context === EmptyCoroutineContext) this else context.fold(this) { ...}
        
    public fun minusKey(key: Key<*>): CoroutineContext

    //注意这里,这个key很关键
    public interface Key <E : CoroutineContext.Element>
    
     public interface Element : CoroutineContext {
        public val key: Key<*>
    
        public override operator fun <E : Element> get(key: Key<E>): E? =
            if (this.key == key) this as E else null
        
        public override fun <R> fold(initial: R, operation: (R, Element) -> R): R =
            operation(initial, this)
            
        public override fun minusKey(key: Key<*>): CoroutineContext =
            if (this.key == key) EmptyCoroutineContext else this
         }
}
复制代码

我们可以看到ElementCoroutineContext的内部接口,同时它又实现了CoroutineContext接口,这么设计的原因是为了保证Element中一定只能存放的Element它自己,而不能存放其他类型的数据CoroutineContext内还有一个内部接口Key,同时它又是Element的一个属性,这个属性很重要,我们先在这里插个眼,待会再讲解这个属性的作用。

那我们上面提到JobCoroutineDispatcherCoroutineExceptionHandlerContinuationInterceptorCoroutineName等为什么又可以存放到CoroutineContext中呢。我们接着往下看看它们各自的实现:

Job

public interface Job : CoroutineContext.Element {
    public companion object Key : CoroutineContext.Key<Job> {
        //省略...
    }
}
复制代码

CoroutineDispatcher

public abstract class CoroutineDispatcher :
    AbstractCoroutineContextElement(ContinuationInterceptor), ContinuationInterceptor {
      public companion object Key : AbstractCoroutineContextKey<ContinuationInterceptor, CoroutineDispatcher>(
        ContinuationInterceptor,
        { it as? CoroutineDispatcher })
}
复制代码

CoroutineExceptionHandler

public interface CoroutineExceptionHandler : CoroutineContext.Element {
    public companion object Key : CoroutineContext.Key<CoroutineExceptionHandler>
}
复制代码

ContinuationInterceptor

public interface ContinuationInterceptor : CoroutineContext.Element {
    companion object Key : CoroutineContext.Key<ContinuationInterceptor>
}
复制代码

CoroutineName

public data class CoroutineName(
    val name: String
) : AbstractCoroutineContextElement(CoroutineName) {
    public companion object Key : CoroutineContext.Key<CoroutineName>
}
复制代码

现在要开始要集中注意力了。我们可以看到他们都是实现了Element接口,同时都有个CoroutineContext.Key类型的伴生对象key,这个属性的作用是什么呢。那我们就得回过头来看看CoroutineContext接口的几个方法了。

public operator fun <E : CoroutineContext.Element> get(key: Key<E>): E?
    
public fun <R> fold(initial: R, operation: (R, CoroutineContext.Element) -> R): R
    
public operator fun plus(context: CoroutineContext): CoroutineContext =
    if (context === EmptyCoroutineContext) this else context.fold(this) { ...}
        
public fun minusKey(key: Key<*>): CoroutineContext
复制代码

我们先从plus方法说起,plus有个关键字operator表示这是一个运算符重载的方法,类似List.plus的运算符,可以通过+号来返回一个包含原始集合和第二个操作数中的元素的结果。同理CoroutineContext中是通过plus来返回一个由原始的Element集合和通过+号引入的Element产生新的Element集合。

get方法,顾名思义。可以通过 key 来获取一个Element

fold方法它和集合中的fold是一样的,用来遍历当前协程上下文中的Element集合。

minusKey方法plus作用相反,它相当于是做减法,是用来取出除key以外的当前协程上下文其他Element,返回的就是不包含key的协程上下文。

现在我们就知道为什么我们之前说Element中的key这个属性很重要了吧。因为我们就是通过它从协程上下文中获取我们想要的Element,同时也解释为什么JobCoroutineDispatcherCoroutineExceptionHandlerContinuationInterceptorCoroutineName等等,这些Element都有需要有一个CoroutineContext.Key类型的伴生对象key。我们写个测试方法: 如:

 private fun testCoroutineContext(){
     val coroutineContext1 = Job() + CoroutineName("这是第一个上下文")
     Log.d("coroutineContext1", "$coroutineContext1")
     val  coroutineContext2 = coroutineContext1 + Dispatchers.Default + CoroutineName("这是第二个上下文")
     Log.d("coroutineContext2", "$coroutineContext2")
     val coroutineContext3 = coroutineContext2 + Dispatchers.Main + CoroutineName("这是第三个上下文")
     Log.d("coroutineContext3", "$coroutineContext3")
 }
复制代码
D/coroutineContext1: [JobImpl{Active}@21a6a21, CoroutineName(这是第一个上下文)]
D/coroutineContext2: [JobImpl{Active}@21a6a21, CoroutineName(这是第二个上下文), Dispatchers.Default]
D/coroutineContext3: [JobImpl{Active}@21a6a21, CoroutineName(这是第三个上下文), Dispatchers.Main]
复制代码

我们通过对比日志输出信息可以看到,通过+号我们可以把多个Element整合到一个集合中,同时我们也发现:

  • 三个上下文中的Job是同一个对象。
  • 第二个上下文在第一个的基础上增加了一个新的CoroutineName,新增的CoroutineName替换了第一个上下文中的CoroutineName
  • 第三个上下文在第二个的基础上又增加了一个新的CoroutineNameDispatchers,同时他们也替换了第二个上下文中的CoroutineNameDispatchers

但是因为这个+运算符是不对称的,所以在我们实际的运用过程中,通过+增加Element的时候一定要注意它们结合的顺序。那么现在关于协程上下文的内容就讲到这里,我们点到为止,后面在深入理解阶段在细讲这些东西运行的原理细节。

协程启动模式

CoroutineStart协程启动模式,是启动协程时需要传入的第二个参数。协程启动有4种:

  • DEFAULT 默认启动模式,我们可以称之为饿汉启动模式,因为协程创建后立即开始调度,虽然是立即调度,单不是立即执行,有可能在执行前被取消。

  • LAZY 懒汉启动模式,启动后并不会有任何调度行为,直到我们需要它执行的时候才会产生调度。也就是说只有我们主动的调用Jobstartjoin或者await等函数时才会开始调度。

  • ATOMIC 一样也是在协程创建后立即开始调度,但是它和DEFAULT模式有一点不一样,通过ATOMIC模式启动的协程执行到第一个挂起点之前是不响应cancel 取消操作的,ATOMIC一定要涉及到协程挂起后cancel 取消操作的时候才有意义。

  • UNDISPATCHED 协程在这种模式下会直接开始在当前线程下执行,直到运行到第一个挂起点。这听起来有点像 ATOMIC,不同之处在于UNDISPATCHED是不经过任何调度器就开始执行的。当然遇到挂起点之后的执行,将取决于挂起点本身的逻辑和协程上下文中的调度器。

我们可以通过一个小例子的来看看这几个启动模式的实际情况:

private fun testCoroutineStart(){
    val defaultJob = GlobalScope.launch{
        Log.d("defaultJob", "CoroutineStart.DEFAULT")
    }
    defaultJob.cancel()
    val lazyJob = GlobalScope.launch(start = CoroutineStart.LAZY){
        Log.d("lazyJob", "CoroutineStart.LAZY")
    }
    val atomicJob = GlobalScope.launch(start = CoroutineStart.ATOMIC){
        Log.d("atomicJob", "CoroutineStart.ATOMIC挂起前")
        delay(100)
        Log.d("atomicJob", "CoroutineStart.ATOMIC挂起后")
    }
    atomicJob.cancel()
    val undispatchedJob = GlobalScope.launch(start = CoroutineStart.UNDISPATCHED){
        Log.d("undispatchedJob", "CoroutineStart.UNDISPATCHED挂起前")
        delay(100)
        Log.d("undispatchedJob", "CoroutineStart.UNDISPATCHED挂起后")
    }
    undispatchedJob.cancel()
}
复制代码

每个模式我们分别启动一个一次,DEFAULT模式启动时,我们接着调用了cancel取消协程,ATOMIC模式启动时,我们在里面增加了一个挂起点delay挂起函数,来区分ATOMIC启动时的挂起前后执行情况,同样的UNDISPATCHED模式启动时,我们也调用了cancel取消协程,我们看实际的日志输出情况:

D/defaultJob: CoroutineStart.DEFAULT
D/atomicJob: CoroutineStart.ATOMIC挂起前
D/undispatchedJob: CoroutineStart.UNDISPATCHED挂起前
复制代码

或者

D/undispatchedJob: CoroutineStart.UNDISPATCHED挂起前
D/atomicJob: CoroutineStart.ATOMIC挂起前
复制代码

为什么会出现2种情况。我们上面提到过DEFAULT模式协程创建后立即开始调度,但不是立即执行,所以它有可能会被cancel取消,导致没有输出defaultJob这条日志。

同样的ATOMIC模式启动的时候也接着调用了cancel取消协程,但是因为没有遇到挂起点,所以挂起前的日志输出了,但是挂起后的日志没有输出。

UNDISPATCHED模式启动的时候也接着调用了cancel取消协程,同样的因为没有遇到挂起点所以输出了UNDISPATCHED挂起前,但是因为UNDISPATCHED是立即执行的,所以他的日志UNDISPATCHED挂起前输出在ATOMIC挂起前的前面(注意这里是概率事件,主要突出UNDISPATCHED是立即执行)。

接着我们在补充一下关于UNDISPATCHED模式。我们上面有提到当以UNDISPATCHED模式启动时,遇到挂起点之后的执行,将取决于挂起点本身的逻辑和协程上下文中的调度器。这句话我们又要怎么理解呢。我们还是以一个例子来认识解释UNDISPATCHED模式,比如:

private fun testUnDispatched(){
    GlobalScope.launch(Dispatchers.Main){
       val job = launch(Dispatchers.IO) {
           Log.d("${Thread.currentThread().name}线程", "-> 挂起前")
           delay(100)
           Log.d("${Thread.currentThread().name}线程", "-> 挂起后")
       }
       Log.d("${Thread.currentThread().name}线程", "-> join前")
       job.join()
       Log.d("${Thread.currentThread().name}线程", "-> join后")
   }
}
复制代码

那我们将会看到如下输出,挂起前后都在一个worker-1线程里面执行:

D/main线程: -> join前
D/DefaultDispatcher-worker-1线程: -> 挂起前
D/DefaultDispatcher-worker-1线程: -> 挂起后
D/main线程: -> join后
复制代码

现在我们在稍作修改,我们在子协程launch的时候使用UNDISPATCHED模式启动:

 private fun testUnDispatched(){
     GlobalScope.launch(Dispatchers.Main){
        val job = launch(Dispatchers.IO,start = CoroutineStart.UNDISPATCHED) {
            Log.d("${Thread.currentThread().name}线程", "-> 挂起前")
            delay(100)
            Log.d("${Thread.currentThread().name}线程", "-> 挂起后")
        }
        Log.d("${Thread.currentThread().name}线程", "-> join前")
        job.join()
        Log.d("${Thread.currentThread().name}线程", "-> join后")
    }
 }
复制代码

那我们将会看到如下输出:

D/main线程: -> 挂起前
D/main线程: -> join前
D/DefaultDispatcher-worker-1线程: -> 挂起后
D/main线程: -> join后
复制代码

我们看到当以UNDISPATCHED模式即使我们指定了协程调度器Dispatchers.IO挂起前还是在main线程里执行,但是挂起后是在worker-1线程里面执行,这是因为当以UNDISPATCHED启动时,协程在这种模式下会直接开始在当前线程下执行,直到第一个挂起点。遇到挂起点之后的执行,将取决于挂起点本身的逻辑和协程上下文中的调度器,即join处恢复执行时,因为所在的协程有调度器,所以后面的执行将会在调度器对应的线程上执行。

我们再改一下,把子协程在launch的时候使用UNDISPATCHED模式启动,去掉Dispatchers.IO调度器,那又会出现什么情况呢

 private fun testUnDispatched(){
     GlobalScope.launch(Dispatchers.Main){
        val job = launch(start = CoroutineStart.UNDISPATCHED) {
            Log.d("${Thread.currentThread().name}线程", "-> 挂起前")
            delay(100)
            Log.d("${Thread.currentThread().name}线程", "-> 挂起后")
        }
        Log.d("${Thread.currentThread().name}线程", "-> join前")
        job.join()
        Log.d("${Thread.currentThread().name}线程", "-> join后")
    }
 }
复制代码
D/main线程: -> 挂起前
D/main线程: -> join前
D/main线程: -> 挂起后
D/main线程: -> join后
复制代码

我们发现它们都在一个线程里面执行了。这是因为当通过UNDISPATCHED启动后遇到挂起,join处恢复执行时,如果所在的协程没有指定调度器,那么就会在join处恢复执行的线程里执行,即挂起后是在父协程(Dispatchers.Main线程里面执行,而最后join后这条日志的输出调度取决于这个最外层的协程的调度规则。

现在我们可以总结一下,当以UNDISPATCHED启动时:

  • 无论我们是否指定协程调度器,挂起前的执行都是在当前线程下执行。

  • 如果所在的协程没有指定调度器,那么就会在join处恢复执行的线程里执行,即我们上述案例中的挂起后的执行是在main线程中执行。

  • 当我们指定了协程调度器时,遇到挂起点之后的执行将取决于挂起点本身的逻辑和协程上下文中的调度器。即join处恢复执行时,因为所在的协程有调度器,所以后面的执行将会在调度器对应的线程上执行。

同样的我们点到为止,关于启动模式的的相关内容我们就现讲到这里。

协程作用域

协程作用域CoroutineScope为协程定义作用范围,每个协程生成器launchasync等都是CoroutineScope的扩展,并继承了它的coroutineContext自动传播其所有Element和取消。协程作用域本质是一个接口,不建议手工实现该接口,而应该首选委托实现。下面我们列出了部分CoroutineScope相关定义:

public interface CoroutineScope {
    public val coroutineContext: CoroutineContext
}

public operator fun CoroutineScope.plus(context: CoroutineContext): CoroutineScope =
    ContextScope(coroutineContext + context)

public fun MainScope(): CoroutineScope = ContextScope(SupervisorJob() + Dispatchers.Main)

public object GlobalScope : CoroutineScope {
    override val coroutineContext: CoroutineContext
        get() = EmptyCoroutineContext
}

public fun CoroutineScope(context: CoroutineContext): CoroutineScope =
    ContextScope(if (context[Job] != null) context else context + Job())
复制代码

我们可以看到CoroutineScope也重载了plus方法,通过+号来新增或者修改我们CoroutineContext协程上下文中的Element。同时官方也为我们定义好了 MainScopeGlobalScope2个顶级作用域。GlobalScope我们已经很熟了,前面的案例都是通过它来实现的。

MainScope我们可以看到它的上下文是通过SupervisorJob Dispatchers.Main组合的,说明它是一个在主线程执行的协程作用域,我们在后续的Android实战开发中,会结合Activity、Fragment,dialog等使用它。这里不再继续往下扩展。

至于SupervisorJob分析它之前,我们得先说一下协程作用域的分类。我们之前提到过父协程和子协程的概念,既然有父协程和子协程,那么必然也有父协程作用域和子父协程作用域。不过我们不是这么称呼,因为他们不仅仅是父与子的概念。协程作用域分为三种:

  • 顶级作用域 --> 没有父协程的协程所在的作用域称之为顶级作用域。

  • 协同作用域 --> 在协程中启动一个协程,新协程为所在协程的子协程。子协程所在的作用域默认为协同作用域。此时子协程抛出未捕获的异常时,会将异常传递给父协程处理,如果父协程被取消,则所有子协程同时也会被取消。

  • 主从作用域 官方称之为监督作用域。与协同作用域一致,区别在于该作用域下的协程取消操作的单向传播性,子协程的异常不会导致其它子协程取消。但是如果父协程被取消,则所有子协程同时也会被取消。

同时补充一点:父协程需要等待所有的子协程执行完毕之后才会进入Completed状态,不管父协程自身的协程体是否已经执行完成。我们在最开始提到协程生命周期的时候就提到过下,现在回过头看是不是感觉很流程变得清晰。

                                      wait children
+-----+ start  +--------+ complete   +-------------+  finish  +-----------+
| New | -----> | Active | ---------> | Completing  | -------> | Completed |
+-----+        +--------+            +-------------+          +-----------+
                 |  cancel / fail       |
                 |     +----------------+
                 |     |
                 V     V
             +------------+                           finish  +-----------+
             | Cancelling | --------------------------------> | Cancelled |
             +------------+                                   +-----------+
复制代码

子协程会继承父协程的协程上下文中的Element,如果自身有相同key的成员,则覆盖对应的key,覆盖的效果仅限自身范围内有效。这个就可以用上我们前面学到的协程上下文CoroutineContext的知识,小案例奉上:

private fun  testCoroutineScope(){
    GlobalScope.launch(Dispatchers.Main){
        Log.d("父协程上下文", "$coroutineContext")
        launch(CoroutineName("第一个子协程")) {
            Log.d("第一个子协程上下文", "$coroutineContext")
        }
         launch(Dispatchers.Unconfined) {
            Log.d("第二个子协程协程上下文", "$coroutineContext")
        }
    }
}
复制代码

日志顺序的问题我们前面已经分析过原因,如果还不懂的话,麻烦您回到基础用法里面仔细的再看一遍。

D/父协程上下文: [StandaloneCoroutine{Active}@81b6e46, Dispatchers.Main]
D/第二个子协程协程上下文: [StandaloneCoroutine{Active}@f6b7807, Dispatchers.Unconfined]
D/第一个子协程上下文: [CoroutineName(第一个子协程), StandaloneCoroutine{Active}@bbe6d34, Dispatchers.Main]
复制代码

可以看到第一个子协程的覆盖了父协程的coroutineContext,它继承了父协程的调度器 Dispatchers.Main,同时也新增了一个CoroutineName属性。第二个子协程覆盖了父协程的coroutineContext中的Dispatchers,也就是将父协程的调度器Dispatchers.Main覆盖为Dispatchers.Unconfined,但是他没有继承第一个子协程的CoroutineName,这就是我们说的覆盖的效果仅限自身范围内有效。接下来我们看看上面提到的协同作用域主从(监督)作用域异常传递和协程取消的问题。

我们上面提到协同作用域如果子协程抛出未捕获的异常时,会将异常传递给父协程处理,如果父协程被取消,则所有子协程同时也会被取消。先上代码看看效果:

private fun  testCoroutineScope2() {
    val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
        Log.d("exceptionHandler", "${coroutineContext[CoroutineName]} $throwable")
    }
    GlobalScope.launch(Dispatchers.Main + CoroutineName("scope1") + exceptionHandler) {
        Log.d("scope", "--------- 1")
        launch(CoroutineName("scope2") + exceptionHandler) {
            Log.d("scope", "--------- 2")
            throw  NullPointerException("空指针")
            Log.d("scope", "--------- 3")
        }
        val scope3 = launch(CoroutineName("scope3") + exceptionHandler) {
            Log.d("scope", "--------- 4")
            delay(2000)
            Log.d("scope", "--------- 5")
        }
        scope3.join()
        Log.d("scope", "--------- 6")
    }
}
复制代码
D/scope: --------- 1
D/scope: --------- 2
D/exceptionHandler: CoroutineName(scope1) java.lang.NullPointerException: 空指针
复制代码

可以看到子协程scope2抛出了一个异常,将异常传递给父协程scope1处理,但是因为任何一个子协程异常退出会导致整体都将退出。所以导致父协程scope1未执行完成成就被取消,同时还未执行完子协程scope3也被取消了。

主从(监督)作用域协同作用域一致,区别在于该作用域下的协程取消操作的单向传播性,子协程的异常不会导致其它子协程取消。分析主从(监督)作用域的时候,我们需要用到supervisorScope或者SupervisorJob,如下代码块:

private fun testCoroutineScope3() {
    val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
        Log.d("exceptionHandler", "${coroutineContext[CoroutineName]} $throwable")
    }
    GlobalScope.launch(Dispatchers.Main + CoroutineName("scope1") + exceptionHandler) {
        supervisorScope {
            Log.d("scope", "--------- 1")
            launch(CoroutineName("scope2")) {
                Log.d("scope", "--------- 2")
                throw  NullPointerException("空指针")
                Log.d("scope", "--------- 3")
                val scope3 = launch(CoroutineName("scope3")) {
                    Log.d("scope", "--------- 4")
                    delay(2000)
                    Log.d("scope", "--------- 5")
                }
                scope3.join()
            }
            val scope4 = launch(CoroutineName("scope4")) {
                Log.d("scope", "--------- 6")
                delay(2000)
                Log.d("scope", "--------- 7")
            }
            scope4.join()
            Log.d("scope", "--------- 8")
        }
    }
}
复制代码
D/scope: --------- 1
D/scope: --------- 2
D/exceptionHandler: CoroutineName(scope2) java.lang.NullPointerException: 空指针
D/scope: --------- 6
D/scope: --------- 7
D/scope: --------- 8
复制代码

可以看到子协程scope2抛出了一个异常,并将异常传递给父协程scope1处理,同时也结束了自己本身。因为在于主从(监督)作用域下的协程取消操作是单向传播性,因此协程scope2的异常并没有导致父协程退出,所以6 7 8都照常输出,而3 4 5因为在协程scope2里面所以没有输出。

我们刚刚使用了supervisorScope实现了主从(监督)作用域,那我们通过SupervisorJob又该如何实现呢。我们把supervisorScope称之为主从(监督)作用域,那么SupervisorJob就可以称之为主从(监督)作业,如下:

private fun testCoroutineScope4() {
    val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
        Log.d("exceptionHandler", "${coroutineContext[CoroutineName]} $throwable")
    }
   val coroutineScope = CoroutineScope(SupervisorJob() +CoroutineName("coroutineScope"))
    GlobalScope.launch(Dispatchers.Main + CoroutineName("scope1") + exceptionHandler) {
        with(coroutineScope){
            val scope2 = launch(CoroutineName("scope2") + exceptionHandler) {
                Log.d("scope", "1--------- ${coroutineContext[CoroutineName]}")
                throw  NullPointerException("空指针")
            }
            val scope3 = launch(CoroutineName("scope3") + exceptionHandler) {
                scope2.join()
                Log.d("scope", "2--------- ${coroutineContext[CoroutineName]}")
                delay(2000)
                Log.d("scope", "3--------- ${coroutineContext[CoroutineName]}")
            }
            scope2.join()
            Log.d("scope", "4--------- ${coroutineContext[CoroutineName]}")
            coroutineScope.cancel()
            scope3.join()
            Log.d("scope", "5--------- ${coroutineContext[CoroutineName]}")
        }
        Log.d("scope", "6--------- ${coroutineContext[CoroutineName]}")
    }
}
复制代码
D/scope: 1--------- CoroutineName(scope2)
D/exceptionHandler: CoroutineName(scope2) java.lang.NullPointerException: 空指针
D/scope: 2--------- CoroutineName(scope3)
D/scope: 4--------- CoroutineName(coroutineScope)
D/scope: 5--------- CoroutineName(coroutineScope)
D/scope: 6--------- CoroutineName(scope1)
复制代码

是不是感觉和supervisorScope的用法很像,我们通过创建了一个SupervisorJob的主从(监督)协程作用域,调用了子协程的join是为了保证它一定是会执行。同样的子协程scope2抛出了一个异常,通过协程scope2自己内部消化了,同时也结束了自己本身。

因为协程scope2的异常并没有导致coroutineScope作用域下的协程取消退出,所以协程scope3照常运行输出2,后又因为调用了我们定义的协程作用域coroutineScopecancel方法取消了协程,所以即使我们后面调用了协程scope3join,也没有输出3,因为SupervisorJob的取消是向下传播的,所以后面的4 5都是在coroutineScope的作用域中输出的。

现在我们关于协程作用域CoroutineScope的作用我们已经有了一个大概的了解,同样的因为这个篇幅中我们是基础讲解,所以我们点到为止,如果还想深入了解,那就只能看后面的深入协程篇幅。

挂起函数

通过前面的篇幅我们已经知道,使用suspend关键字修饰的函数叫作挂起函数挂起函数只能在协程体内,或着在其他挂起函数内调用。那挂起又是啥玩意呢image.png

我估计各位看到这里的时候,可能有些人已经被上面的知识点弄的有点晕乎,别急,先放松下大脑,喝杯水,然后做个眼保健操缓解一下image.png。下面开始敲黑板了,打起精神,要开始划重点了。

首先一个挂起函数既然要挂起,那么他必定得有一个挂起点,不然我们怎么知道函数是否挂起,从哪挂起呢。 我们定义一个空实现的suspend方法,然后通过AS的工具栏中Tools->kotlin->show kotlin ByteCode解析成字节码

private suspend fun test(){
}
复制代码
final synthetic test(Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
复制代码
public interface Continuation<in T> {
    public val context: CoroutineContext
    public fun resumeWith(result: Result<T>)
}
复制代码

我们看到test方法需要的是一个Continuation接口,官方给的介绍是用于挂起点之后,返回类型为T的值用的。那我们又是怎么拿到的这个Continuation呢。要解开这个问题我们得先回到协程的创建和运行是的过程。

我们启动一个协程无非是通过launchasync等方法。我们之前有说到过他们的启动模式CoroutineStart,但是并没有深入的去分析它的创建和启动过程,我们这里先回过头大概的看一下:

public fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
): Job {
    val newContext = newCoroutineContext(context)
    val coroutine = if (start.isLazy)
        LazyStandaloneCoroutine(newContext, block) else
        StandaloneCoroutine(newContext, active = true)
    coroutine.start(start, coroutine, block)
    return coroutine
}
复制代码

我们看到在通过launch启动一个协程的时候,他通过coroutinestart方法启动协程,然后我们接着往下看

public fun start(start: CoroutineStart, block: suspend () -> T) {
    initParentJob()
    start(block, this)
}

public fun <R> start(start: CoroutineStart, receiver: R, block: suspend R.() -> T) {
    initParentJob()
    start(block, receiver, this)
}
复制代码

然后start方法里面调用了CoroutineStartinvoke,这个时候我们发现了Continuation

public operator fun <T> invoke(block: suspend () -> T, completion: Continuation<T>): Unit =
    when (this) {
        DEFAULT -> block.startCoroutineCancellable(completion)
        ATOMIC -> block.startCoroutine(completion)
        UNDISPATCHED -> block.startCoroutineUndispatched(completion)
        LAZY -> Unit // will start lazily
    }
    
public operator fun <R, T> invoke(block: suspend R.() -> T, receiver: R, completion: Continuation<T>): Unit =
    when (this) {
        DEFAULT -> block.startCoroutineCancellable(receiver, completion)
        ATOMIC -> block.startCoroutine(receiver, completion)
        UNDISPATCHED -> block.startCoroutineUndispatched(receiver, completion)
        LAZY -> Unit // will start lazily
    }
复制代码

Continuation又是通过start方法传进来的coroutine。所以现在可以确定,我们的协程体本身就是一个Continuation,这也就解释了为什么可以在协程体内调用suspend挂起函数了。

现在我们也可以确定,在协程内部挂起函数的调用处就是挂起点,如果挂起点出现异步调用,那么当前协程就被挂起,直到对应的Continuation通过调用resumeWith函数才会恢复协程的执行,同时返回Result<T>类型的成功或者失败的结果。

由于章节主题的限制,这里我们就不再下深入了。需要注意的是挂起函数不一定真的会挂起,如果只是提供了挂起的条件,但是协程没有产生异步调用,那么协程还是不会被挂起。

预告:下一篇我们将会讲解kotlin协程中的异常处理,其实我们在这篇章节中已经,提到了一些异常处理,没有注意的同学可以回到协程作用域看看。

需要源码的看这里:demo源码

原创不易。如果您喜欢这篇文章,您可以动动小手点赞收藏image.png

关联文章 Kotlin协程基础及原理系列

Flow系列

扩展系列

文章分类
Android
文章标签