【深入理解Kotlin协程】协程作用域、启动模式、调度器、异常和取消【使用篇】

startCoroutine和createCoroutine这两个API不太适合在业务开发中直接使用,因此对于协程的创建,框架中提供了不同目的的协程构造器。

这两组 API 的差异在千 Receiver 的有无。Receiver 通常用千约束和扩展协程体,剩下的部分就是作为协程体的 suspend 函数和作为协程完成后回调的 completion。
           
我们对协程的这两组 API 做进一步的封装,目的就是降低协程的创建和管理的成本。而降低协程的创建成本无非就是提供一个函数来简化操作,就像 async{ }函数那样;而要降低管理的成本,就必须引入一个新的类型来描述协程本身,并且提供相应的 API 来控制协程的执行。
            
无返回值的 launch
             
如果一个协程的返回值 Unit,  那么我们可以称它“无返回值 ”( 或者返回值为“空”类型)。对于这样的协程,我们只需要启动它即可。

 

其中 StandaloneCoroutine 是 AstractCoroutine 的子类,目前只有一个空实现,如代码清单 5-14 所示

 CoroutineScope - 协程作用域

官方框架在实现复合协程的过程中也提供了作用域,主要用以明确协程之间的父子关系,以及对于取消或者异常处理等方面的传播行为。
public interface CoroutineScope {
    public val coroutineContext: CoroutineContext
}
该作用域包括以下三种:
  • 顶级作用域  没有父协程的协程所在的作用域为顶级作用域。
  • 协同作用域 协程中启动新的协程,新协程为所在协程的 子协程,这种情况下, 子协程所在的作用域默认为协同作用域。此时 子协程抛出的未捕获异常,都将传递给父协程处理,父协程同时也会被取消。
  • 主从作用域 与协同作用域在协程的父子关系上一致,区别在于, 处于该作用域下的协程出现未捕获的异常时,不会将异常向上传递给父协程
除了三种作用域中提到的行为以外,父子协程之间还存在以下规则:
  • 父协程被取消,则所有子协程均被取消。由于协同作用域和主从作用域中都存在父子协程关系,因此此条规则都适用。
  • 父协程需要等待子协程执行完毕之后才会最终进入完成状态,不管父协程自身的协程体是否已经执行完。
  • 子协程会继承父协程的协程上下文中的元素如果自身有相同key的成员,则覆盖对应的key,覆盖的效果仅限自身范围内有效。
简单总结就是, 主从关系:无法坑爹,爹可以坑儿子。协同关系:可以坑爹,可以坑儿子,互相坑。
通过 GlobalScope 创建的协程将不会有父协程,我们也可以把它称作 根协程,协程的协程体的 Receiver 就是作用域实例,因此可以在它的协程体内部再创建新的协程,最终产生一个协程树(如图 5-11 所示 )。 如代码清单 5-68 所示

 

 当然,如果在协程内部再次使用 GlobalScope 建协程 ,那么新协程仍然是根协程,如代码清单 5-69 所示

使用协程作用域来创建协程

当我们创建一个协程的时候,都会需要一个  CoroutineScope 我们一般使用它的  launch 或  async 函数去进行协程的创建。 CoroutineScope 会跟踪它使用  launch 或  async 创建的所有协程。您可以随时调用  scope.cancel() 以取消正在运行的协程。不过,与调度程序不同,CoroutineScope 不运行协程,它只是确保您不会失去对协程的追踪。为了确保所有的协程都会被追踪,Kotlin 不允许在没有使用 CoroutineScope 的情况下启动新的协程。
           
CoroutineScope 可被看作是一个具有超能力的轻量级版本的ExecutorService。CoroutineScope 会跟踪所有协程,同样它还可以取消由它所启动的所有协程。这在 Android 开发中非常有用,比如它能够在用户离开界面时停止执行协程。 
             
在 Android 中,某些 KTX 库为某些生命周期类提供自己的 CoroutineScope。主要有以下4种: 
  • GlobeScope:全局范围,不会自动结束执行。
  • MainScope:主线程的作用域,全局范围
  • lifecycleScope:生命周期范围,用于activity等有生命周期的组件,在Desroyed的时候会自动结束。
  • viewModeScope:ViewModel范围,用于ViewModel中,在ViewModel被回收时会自动结束
 所有的Scope都是 CoroutineScope 的子类。以上4种可以认为是最顶级的协程作用域,能在Activity、Fragment、ViewModel等类的 普通函数直接调用,其中只有 lifecycleScopeviewModelScope具备页面销毁状态感知自动取消协程的功能,而另外两种则没有具备这种感知功能。 

如何使用 coroutineScope 启动协程

  • 调用  xxxScope.launch{...}  启动一个协程块, launch方法启动的协程不会将结果返回给调用方。任何被视为“一劳永逸”的工作都可以使用 launch来启动。
  • 在  xxxScope {...} 中调用  async{...} 创建一个子协程, async会返回一个 Deferred对象,随后可以调用 Deferred对象的 await()方法来启动该协程。
  • withContext(){...} 一个 suspend方法,在给定的上下文中执行并返回结果,它的目的不在于启动子协程,主要用于 线程切换,将长耗时操作从UI线程切走,完事再切回来。用它执行的挂起块中的上下文是当前协程的上下文和由它执行的上下文的合并结果。 
  • coroutineScope{...} 一个 suspend方法,创建一个新的作用域,并在该作用域内执行指定代码块,它并不启动协程。其存在的目的是进行符合结构化并发的并行分解。
  • runBlocking{...} 创建一个协程,并阻塞当前线程,直到协程执行完毕。 
通常,应该在 普通函数中使用  Scope. launch,而在 协程块内挂起函数内使用 async,因为常规函数中无法调用 await()。
launch其实是 CoroutineScope的一个扩展方法:
public fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
): Job {
    ...省略
}
所以,原则上 只要是在协程作用域范围内的任意地方都可以调用launch方法:
如果不知道当前代码是否处在一个协程作用域内,AS编译器也会有所提示。

coroutineScope & supervisorScope

这两个就是2个挂起函数,分别表示协同作用域和主从作用域,因为是挂起函数所以也必须在协程块或挂起函数内调用:
private fun request() {
    lifecycleScope.launch {
        coroutineScope { // 协同作用域,抛出未捕获异常时会取消父协程
            launch { }
        }
        supervisorScope { // 主从作用域,抛出未捕获异常时不会取消父协程
            launch { }
        }
    }
}

注意这两个函数的作用只是定义了2个作用域而已,如果想要启动新的子协程请在里面调用launch。如果需要异步请使用async。

二者的区别:
  • supervisorScope 表示 主从作用域,会继承父协程的上下文,它的特点就是子协程的异常不会影响父协程,内部的 子协程挂掉 不会影响外部的父协程和兄弟协程的继续运行,它就像一道防火墙,隔离了异常,保证程序健壮,但是如果外部协程挂掉还是可以取消子协程的,即 单向传播。它的设计应用场景多用于 子协程为独立对等的任务实体的时候,比如一个下载器,每一个子协程都是一个下载任务,当一个下载任务异常时,它不应该影响其他的下载任务。
  • coroutineScope  表示 协同作用域,  内部的协程 出现异常 会向外部传播,子协程未捕获的异常会向上传递给父协程,  子协程 可以挂掉外部协程 外部协程挂掉也会挂掉子协程,即 双向传播 。 任何一个子协程异常退出,会导致整体的退出。

还可以进行一些简单的封装,比如我们可以定义一个 suspend 方法,内部返回一个 coroutineScope 作用域对象来执行一个传入的协程代码块: 

private suspend fun saveLocal(coroutineBlock: (suspend CoroutineScope.() -> String)? = null): String? {
        return coroutineScope {
           // 以下几种写法等价,都是执行block代码块
           // coroutineBlock!!.invoke(this)
           // coroutineBlock?.invoke(this)
           // if (coroutineBlock != null) {
           //     coroutineBlock.invoke(this)
           // }
            coroutineBlock?.let { block ->
                block()
            }
        }
    }
 那么在使用我们这一个函数的时候就可以这么使用:
MainScope().launch {    
    println("执行在一个协程中...")
    val result = saveLocal {
        async(Dispatchers.IO) {
            "123456"
        }.await()
     }
    println("一个协程执行完毕... result:$result")
}

并行分解

并行分解就是将长耗时任务拆分为并发的多个短耗时任务,并等待所有并发任务完成后再返回。
    
借助 Kotlin 中的 结构化并发机制,我们可以定义用于启动一个或多个协程的  coroutineScope。然后,您可以使用  await()(针对单个协程)或  awaitAll()(针对多个协程)保证这些协程在从函数返回结果之前完成。
          
await()调用会等待 async{...}中的代码块(包括挂起函数)执行完毕后,得到返回结果,再继续往下运行,它的执行流程如下:

 例如,假设我们定义一个用于异步获取两个文档的 coroutineScope。通过对每个延迟引用调用 await(),我们可以保证这两项 async 操作在返回值之前完成:

suspend fun fetchTwoDocs() = coroutineScope {
        val deferredOne = async { fetchDoc(1) }
        val deferredTwo = async { fetchDoc(2) }
        deferredOne.await()
        deferredTwo.await()
}
这里需要注意的一点是, 两个async块中的代码是并发执行的(默认是调度在线程池上执行),并且跟是否调用 await没有直接关系,上面代码中即使将await都注释掉,两个 async块仍然是并发执行的,而 coroutineScope会等待两个async完毕返回才结束。只不过调用await能保证 async一定执行在await之前
如下图中,红色框之内的是并发的,它们的顺序是无法保证按照代码顺序的,但是红色框一定执行在蓝色框之前。
 

 假如像上面这样直接使用coroutineScope,那么async执行完成,coroutineScope中排在async之后的代码有可能被调度到某个子线程中执行,即上面的红色部分执行完后,蓝色部分可能运行在某个子线程中。如下图:

所以在Android中,最好是在lifecycleScopeviewModelScope中去使用async, 这样能保证async之后的代码仍然执行在主线程上。但是此时在lifecycleScopeviewModelScope中调用的async中的代码也会执行在主线程(虽然是异步的,但既然是主线程就会有IO太长阻塞主线程的风险),也就是说async默认跟父协程的调度器是一样的,因此,如果有需要,此时可以为async指定线程调度器。如下:

除了单独调用每个await方法,还可以对集合使用 awaitAll(),如以下示例所示:

suspend fun fetchTwoDocs() =        // called on any Dispatcher (any thread, possibly Main)
    coroutineScope {
        val deferreds = listOf(     // fetch two docs at the same time
            async { fetchDoc(1) },  // async returns a result for the first doc
            async { fetchDoc(2) }   // async returns a result for the second doc
        )
        deferreds.awaitAll()        // use awaitAll to wait for both network requests
    }

虽然 fetchTwoDocs() 使用 async 启动新协程,但该函数使用 awaitAll() 等待启动的协程完成后才会返回结果。 此外,coroutineScope 会捕获协程抛出的所有异常,并将其传送回调用方。

写法上需要注意的点:

suspend fun main() = runBlocking {
    val times = measureTimeMillis {
        // 这样写是串行执行,总耗时2s
        val one = doOne()
        val two = doTwo()
        println("The result is ${one + two}")
  • 4
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

川峰

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值