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

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

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

 

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

 CoroutineScope - 协程作用域

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

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

 当然,如果在协程内部再次使用 GlobalScope 建协程 ,那么新协程仍然是根协程,如代码清单 5-69 所示
【深入理解Kotlin协程】协程作用域、启动模式、调度器、异常和取消【使用篇】_第5张图片

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

当我们创建一个协程的时候,都会需要一个  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方法:
【深入理解Kotlin协程】协程作用域、启动模式、调度器、异常和取消【使用篇】_第6张图片
如果不知道当前代码是否处在一个协程作用域内,AS编译器也会有所提示。

coroutineScope & supervisorScope

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

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

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

还可以进行一些简单的封装,比如我们可以定义一个 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之前
如下图中,红色框之内的是并发的,它们的顺序是无法保证按照代码顺序的,但是红色框一定执行在蓝色框之前。
  【深入理解Kotlin协程】协程作用域、启动模式、调度器、异常和取消【使用篇】_第8张图片

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

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

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

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

除了单独调用每个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}")
    }
    println("cost time: ${times}ms")
}

suspend fun main() = runBlocking { 
    val times = measureTimeMillis {
        // 这样写是并行执行,总耗时1s
        val one = async { doOne() }
        val two = async { doTwo() }
        println("The result is ${one.await() + two.await()}")
    }
    println("cost time: ${times}ms")
}

suspend fun main() = runBlocking { 
    val times = measureTimeMillis {
        // 但是这样写不是并行执行,总耗时2s
        val one = async { doOne() }.await()
        val two = async { doTwo() }.await()
        println("The result is ${one + two}")
    }
    println("cost time: ${times}ms")
}

private suspend fun doOne(): Int {
    delay(1000L)
    return 13
}

private suspend fun doTwo(): Int {
    delay(1000L)
    return 14
}

MainScope & GlobalScope

都是全局的作用域,但是他们有区别。如果不做处理他们都是运行在全局无法取消的,但是 GlobalScope是无法取消的,MainScope是可以取消的
    
GlobalScope 的源码如下:
public object GlobalScope : CoroutineScope {
    override val coroutineContext: CoroutineContext
        get() = EmptyCoroutineContext
}
可以看到它的上下文对象是  EmptyCoroutineContext 对象,并 没有Job对象,所以我们 无法通过 Job 对象去 cancel 此协程。所以他是无法取消的进程级别的协程。除非有特殊的需求,我们都不推荐使用此协程。
   
MianScope 的源码如下:
public fun MainScope(): CoroutineScope = ContextScope(SupervisorJob() + Dispatchers.Main)
可以看到它的上下文对象是  SupervisorJob + 主线程调度器构成的。所以我们说它是一个可以取消的全局主线程协程。MianScope是一个全局函数,意味着你可以任何地方调用它(包括Activity、Fragment、Dialog、ViewModel等),但是需要注意在页面销毁的时候记得取消它。(因此相比在能使用lifecycleScope或viewModelScope的地方,使用它并不方便)
如何取消 MianScope
var mainScope= MainScope()

mainScope.launch {
    println("执行在一个协程中...")
    val result = saveLocal {
            async(Dispatchers.IO) {
                "123456"
            }.await()
        }
    println("一个协程执行完毕... result:$result")
}
...
override fun onDestroy() {
    super.onDestroy()
     mainScope.cancel()
}

ViewModelScope

viewModelScope是一个 CloseableCoroutineScope,它的上下文由 SupervisorJob() + Dispatchers.Main.immediate构成。源码如下:
private const val JOB_KEY = "androidx.lifecycle.ViewModelCoroutineScope.JOB_KEY"

val ViewModel.viewModelScope: CoroutineScope
        get() {
            val scope: CoroutineScope? = this.getTag(JOB_KEY)
            if (scope != null) {
                return scope
            }
            return setTagIfAbsent(JOB_KEY, CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate))
        }

internal class CloseableCoroutineScope(context: CoroutineContext) : Closeable, CoroutineScope {
    override val coroutineContext: CoroutineContext = context

    override fun close() {
        coroutineContext.cancel()
    }
}
可以看到 viewModelScopeViewModel类的 扩展属性,假如这个  ViewModel 是 Activity 的,那么在 Activity 退出的时候,ViewModel 的  clear() 方法就会被调用,而  clear() 方法中会扫描当前 ViewModel 的成员  mBagsOfTags(一个Map对象)中保存的所有的  Closeable 的  object 对象(也就是上面的 CloseableCoroutineScope),并调用其  close() 方法。(大概流程是在 ComponentActivity中会通过 Lifecycle注册 观察者接口,当页面销毁时,该回调接口中会调用 ViewModelStore.clear() -> ViewModel.clear() -> (Closeable)obj.close())
  【深入理解Kotlin协程】协程作用域、启动模式、调度器、异常和取消【使用篇】_第11张图片

lifecycleScope

lifecycleScope的实例是 LifecycleCoroutineScopeImpl,它的上下文也是由 SupervisorJob() + Dispatchers.Main.immediate构成。
public val LifecycleOwner.lifecycleScope: LifecycleCoroutineScope
    get() = lifecycle.coroutineScope

public val Lifecycle.coroutineScope: LifecycleCoroutineScope
    get() {
        while (true) {
            val existing = mInternalScopeRef.get() as LifecycleCoroutineScopeImpl?
            if (existing != null) {
                return existing
            }
            val newScope = LifecycleCoroutineScopeImpl(
                this,
                SupervisorJob() + Dispatchers.Main.immediate
            )
            if (mInternalScopeRef.compareAndSet(null, newScope)) {
                newScope.register()
                return newScope
            }
        }
    }
lifecycleScope是LifecycleOwner的扩展属性,因此它只能在Activity、Fragment中使用,会绑定Activity和Fragment的生命周期。 它的基本使用和 viewModelScope 是一样的。但是它多了生命周期的的一些感知。
比如提供在Activity的onCreate/onStart/onResumed时候才执行的方法:
public fun launchWhenCreated(block: suspend CoroutineScope.() -> Unit): Job = launch {
    lifecycle.whenCreated(block)
}
public fun launchWhenStarted(block: suspend CoroutineScope.() -> Unit): Job = launch {
    lifecycle.whenStarted(block)
}
public fun launchWhenResumed(block: suspend CoroutineScope.() -> Unit): Job = launch { 
    lifecycle.whenResumed(block)
}
它也是通过  LifecycleController 中为 Lifecycle注册 观察者接口, 来感知 onResume的状态,然后进行调用的。

自定义CoroutineScope

除了这些自带的Scope以外,如果您需要创建自己的 CoroutineScope 以控制协程在应用的特定层中的生命周期,则可以创建一个如下所示的 CoroutineScope:
class ExampleClass {

    // Job and Dispatcher are combined into a CoroutineContext which will be discussed shortly
    val scope = CoroutineScope(Job() + Dispatchers.Main)

    fun exampleMethod() {
        // Starts a new coroutine within the scope
        scope.launch {
            fetchDocs()  // New coroutine that can call suspend functions
        }
    }

    fun cleanUp() {
        // Cancel the scope to cancel ongoing coroutines work
        scope.cancel()
    }
}
注意: 已取消的作用域无法再创建协程。因此,仅当控制其生命周期的类被销毁时,才应调用  scope.cancel()。使用  viewModelScope 时, ViewModel 类会在 ViewModel 的  onCleared() 方法中自动为您取消作用域。
      
还可以通过继承  CoroutineScope 在一些自定义 Dialog, PopupWindow场景的时候使用:
class CancelJobDialog() : DialogFragment(), CoroutineScope by MainScope() {

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        dialog?.requestWindowFeature(Window.FEATURE_NO_TITLE)
        dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
        return inflater.inflate(R.layout.dialog_cancel_job, container, false)
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        val mNoTv = view.findViewById(R.id.btn_n)
        val mYesTv = view.findViewById(R.id.btn_p)
        mNoTv.click { dismiss() }
        mYesTv.click { doSth() }
    }

    private fun doSth() {
       launch{
           println("执行在另一个协程中...")
           delay(1000L)
           println("另一个协程执行完毕...")
        }
        dismiss()
   }

    override fun onDismiss(dialog: DialogInterface) {
        cancel()
        super.onDismiss(dialog)
    }
}
这里我们让 Dialog实现一个  CoroutineScope 作用域接口,然后使用委托属性  by 将  MainScope 的实现给它。这样这个 Dialog 就是一个 MainScope的作用域了。在内部就能 launch N多个子协程了。当我们在  onDismiss 的时候,会把 MainScope取消掉,按照协程作用域的原则, 父协程被取消,则所有子协程均被取消,那么它内部 launch 的N个子协程就能一起取消了。
     
再比如我想封装一个带协程的PopupWindow,可以这样封装一个基类:
/**
* 自定义带协程作用域的弹窗
*/
abstract class CoroutineScopeCenterPopup(activity: FragmentActivity) : CenterPopupView(activity), CoroutineScope {

    private lateinit var job: Job

    private val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
        println(throwable.message ?: "Unkown Error")
    }

    // 此协程作用域的自定义 CoroutineContext
    override val coroutineContext: CoroutineContext
        get() = Dispatchers.Main + job + CoroutineName("CenterPopupScope") + exceptionHandler

    override fun onCreate() {
        job = Job()
        super.onCreate()
    }

    override fun onDismiss() {
        job.cancel()  // 关闭弹窗后,结束所有协程任务
        println("关闭弹窗后,结束所有协程任务")
        super.onDismiss()
    }
}
那么就可以直接使用这个基类的实现了:
class InterviewAcceptPopup(private val mActivity: FragmentActivity) : CoroutineScopeCenterPopup(mActivity) {

    override fun getImplLayoutId(): Int {
        return R.layout.dialog_interview_accept
    }

    override fun onCreate() {
        super.onCreate()
        val btnYes = findViewById(R.id.btn_y)
        btnYes.click {
            doSth()
        }
    }

    private fun doSth() {
        launch {
            println("执行在协程中...")
            delay(1000L)
            println("执行完毕...")
            dismiss()
        }
    }
}
 使用Job控制协程的生命周期实际开发中如果是涉及到 Android 页面的一些生命周期的,我们可以使用viewModelScope、lifecycleScope 。如果是其他的页面比如 View 或者 Dialog 或者干脆不涉及到页面的一些地方,我们就可以使用以上的几种方法来实现自定义的协程作用域。

runBlocking

runBlocking是一个顶层函数,注意 它不是一个挂起函数,但是它会运行一个新的协程,并且 会阻塞当前调用runBlocking的线程,直到 runBlocking 中的协程体完成。不应该在协程中使用此函数。它的设计目的是将常规阻塞代码与以挂起风格编写的库连接起来,以便在主函数和测试中使用。
fun main() {
    runBlocking {
        println("coroutine1 start")
        delay(1000) //模拟耗时
        println("coroutine1 end")
    }
    runBlocking {
        println("coroutine2 start")
        delay(2000) //模拟耗时
        println("coroutine2 end")
    }
    println("process end")
}
上面代码执行结果会按顺序输出:
【深入理解Kotlin协程】协程作用域、启动模式、调度器、异常和取消【使用篇】_第12张图片
上面代码如果改成使用GlobalScope.launch就不会按顺序输出:
suspend fun main() {
    GlobalScope.launch {
        println("coroutine1 start")
        delay(1000) //模拟耗时
        println("coroutine1 end")
    }
    GlobalScope.launch {
        println("coroutine2 start")
        delay(2000) //模拟耗时
        println("coroutine2 end")
    }
    println("process end")
}

在suspend的main函数中执行结果如下:

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

如果在不带suspend的main函数中执行,则只会输出process end。因此使用runBlocking的效果就是按顺序阻塞式的调用,后面的协程体会等runBlocking执行完。runBlocking更适合用在测试中,比如希望main函数等待它当中的协程能够完成里面的功能后再退出。不过,类似的功能也可以使用Job.join()或者Deferred.await()来实现。
因此只需要记住: runBlocking是阻塞的,不要在主线程中调用它。

CoroutineStart - 协程启动模式

  【深入理解Kotlin协程】协程作用域、启动模式、调度器、异常和取消【使用篇】_第14张图片
  • CoroutineStart.DEFAULT: 协程创建后,立即开始调度,但 有可能在执行前被取消。在调度前如果协程被取消,其将直接进入取消响应的状态。 
  • CoroutineStart.ATOMIC 协程创建后,立即开始调度协程执行到第一个挂起点之前不响应取消。其将调度和执行两个步骤合二为一,就像它的名字一样,其保证调度和执行是原子操作,因此协程也 一定会执行
  • CoroutineStart.LAZY 只要协程被需要时(主动调用该协程的 startjoinawait等函数时 才会开始调度,如果调度前就被取消,协程将直接进入异常结束状态。
  • CoroutineStart.UNDISPATCHED 协程创建后,立即在当前线程中执行,直到遇到第一个真正挂起的点。是立即执行,因此协程 一定会执行
 注意: UNDISPATCHED是立即在 当前线程执行,而  DEFAULT和 ATOMIC则不一定(取决于配置的调度器)。 UNDISPATCHED 如果遇到挂起点,就切回主流程了,后面的协程体继续执行在单独的调度器。
    
要彻底搞清楚这几个模式的效果 ,我们需要先搞清楚  立即调度 和 立即执行 的区别 。立即调度表示协程的调度器会立即接收调度指令,但具体执行的时机以及在哪个线程上执行,还需要根据调度器的具体情况而定,也就是说立即调度到立即执行之间通常会有一段时间。
  【深入理解Kotlin协程】协程作用域、启动模式、调度器、异常和取消【使用篇】_第15张图片

这些启动模式的设计主要是为了应对某些特殊的场景,业务开发实践中通常使用  DEFAULT 和  LAZY 这两个启动模式就足够了。

协程调度器

对于调度器的实现机制我们已经非常清楚了(拦截器),官方框架中预置了 4 个调度器,我们可以通过  Dispatchers 对象访问它们。
  • Default: 默认调度器 ,适合处理后台计算,其是一个  CPU 密集型任务调度器
  • IO: IO 调度器,适合执行 IO 相关操作,其是  IO 密集型任务调度器
  • Main: UI 调度器,根据平台不同会被初始化为对应的 UI 线程的调度器, 例如在Android 平台上它会将协程调度到 UI 事件循环中执行,即通常在 主线程上执行。
  • Unconfined:“无所谓“调度器,不要求协程执行在特定线程上。协程的调度器如果是 Unconfined, 那么它在挂起点恢复执行时会在恢复所在的线程上直接执行,当然, 如果嵌套创建以它为调度器的协程,那么这些协程会在启动时被调度到协程框架内部的事件循环上,以避免出现 StackOverflow。
如果创建 Coroutine的时候未指定调度器,或者使用未指定的调度器的上下文的 Scope通过 launchasync启动一个协程,则默认是使用 Dispatchers.Default调度器
   
由于 子协程会默认继承 父协程context上下文,所以一般我们可以直接为 父协程context上设置一个 Dispatcher,这样所有的子协程就自动使用这个 Dispatcher,当某个子协程有特殊需要的时候再其指定特定的 Dispatcher。
    
如果当前协程会访问 UI 资源,那么使用 Main
【深入理解Kotlin协程】协程作用域、启动模式、调度器、异常和取消【使用篇】_第16张图片
为确保对 UI 读写的并发安全性,我们需要确保相关协程 UI 线程上执行 ,因此需要指定调度器为 Main。
      
如果是只包含单纯的计算任务的协程,则通常其存续时间较短,比较适合使用 Default 度器; 如果是包含 IO 操作的协程,则通常其存续时间较长,且无须连续占据 CPU 资源,因此适合使用   IO 作为其调度器。
        
如果大家仔细阅读  Default 和  IO 这两个调度器的实现 ,就会发现它们背后实际上是 同一个线程池。
那么,为什么二者在使用上会存在差异呢?由于 IO 任务通常会阻塞实际执行任务的线程,在阻塞过程中线程虽然不占用 CPU,  但却占用了大量内存,这段时间内被 IO 任务占据线程实际上是资源使用不合理的表现,因此 IO 调度器对于 IO 任务的并发做了限制, 避免过多的 IO 任务并发占用过多的系统资源,同时在调度时为任务打上 PROBABLY BLOCKING 标签,以方便线程池在执行任务调度时对阻塞任务和非阻塞任务区别对待。
          
Default和IO线程 的区别:IO内部多了一个队列的维护
【深入理解Kotlin协程】协程作用域、启动模式、调度器、异常和取消【使用篇】_第17张图片

自定义调度器

如果内置的调度器无法满足需求,我们也可以自行定义调度器,只需要实现 CoroutineDispatcher 接口即可 如代码清单 6-3 所示。
  【深入理解Kotlin协程】协程作用域、启动模式、调度器、异常和取消【使用篇】_第18张图片

 不过自己定义调度器的情况不多见,更常见的是将我们自己定义好的线程池转成调度器 ,如代码清单 6-4 所示。

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

使用扩展函数 asCoroutineDispatcher 就可以将 Executor 转为调度器,不过这个调度器需要在使用完毕后主动关闭,以免造成线程泄露。本例中,我们使用 use 在协程执行完成后主动关闭这个调度器。

再比如Android中我们想要运行在 HandleThread 线程,我们可以这样做:
    private var mHandlerThread: HandlerThread? = HandlerThread("handle_thread")
    private var mHandler: Handler? = mHandlerThread?.run {
        start()
        Handler(this.looper)
    }
    ...
    GlobalScope.launch(mHandler.asCoroutineDispatcher("handle_thread")) {
        println("执行在协程中...")
        delay(1000L)
        println("执行完毕...")
    }

withContext 

官方框架还为我们提供了一个很好用的 API  withContext 我们可以使用它来简化前面的例子,如代码清单 6-5 所示。
【深入理解Kotlin协程】协程作用域、启动模式、调度器、异常和取消【使用篇】_第20张图片
withContext 会将参数中的 Lambda 表达式调度到对应的调度器上,它自己本身就是一个挂起函数,返回值为 Lambda 表达式的值,由此可见它的作用几乎等价于  async {... }. await()
    
提示:与  async { ... }.await()相比,  withContext 的内存开销更低,因此对于使用 async 后立即调用 await 情况,应当优先使用  with Context
    
在Android中,一般通过 withContext(Dispatchers.IO)将网络请求、文件读写等阻塞IO操作移出主线程:
class LoginRepository(...) {
    ...
    suspend fun makeLoginRequest(
        jsonBody: String
    ): Result {
        // Move the execution of the coroutine to the I/O dispatcher
        return withContext(Dispatchers.IO) {
            // 网络请求阻塞代码
        }
    }
}
在以下示例中,协程是在 LoginViewModel 中创建的。由于 makeLoginRequest 将执行操作移出主线程,login 函数中的协程现在可以在主线程中执行:
class LoginViewModel(
    private val loginRepository: LoginRepository
): ViewModel() {
    fun login(username: String, token: String) {
        // Create a new coroutine on the UI thread
        viewModelScope.launch {
            val jsonBody = "{ username: \"$username\", token: \"$token\"}"
            // 调用挂起函数请求网络接口
            val result = loginRepository.makeLoginRequest(jsonBody)
            // 显示请求结果
            when (result) {
                is Result.Success -> // Happy path
                else -> // Show error in UI
            }
        }
    }
}

withContext() 的效用

与基于回调的等效实现相比, withContext() 不会增加额外的开销。此外,在某些情况下,还可以优化 withContext() 调用,使其超越基于回调的等效实现。例如,如果某个函数对一个网络进行十次调用,您可以使用外部 withContext() 让 Kotlin 只切换一次线程。这样,即使网络库多次使用 withContext(),它也会留在同一调度程序上,并避免切换线程。此外,Kotlin 还优化了 Dispatchers.Default 与 Dispatchers.IO 之间的切换,以尽可能避免线程切换。

捕获协程中的异常

未处理协程中抛出的异常可能会导致应用崩溃。如果可能会发生异常,请在使用  viewModelScope 或  lifecycleScope 创建的任何协程主体中捕获相应异常。
class LoginViewModel(
    private val loginRepository: LoginRepository
) : ViewModel() {
    fun login(username: String, token: String) {
        viewModelScope.launch {
            try {
                loginRepository.login(username, token)
                // Notify view user logged in successfully
            } catch (exception: IOException) {
                // Notify view login attempt failed
            }
        }
    }
}

通过launch方式启动的协程,异常应当在协程体内进行捕获,而通过async方式启动的协程,需要对await调用处进行捕获异常

【深入理解Kotlin协程】协程作用域、启动模式、调度器、异常和取消【使用篇】_第21张图片
另外, 如果async创建的是根协程,没有人调用await消费的话,异常也不会被抛出, 如下代码不会导致应用崩溃:
【深入理解Kotlin协程】协程作用域、启动模式、调度器、异常和取消【使用篇】_第22张图片

 对于async创建的非根协程,仍然会将异常向上抛出,如下代码会导致应用Crash

【深入理解Kotlin协程】协程作用域、启动模式、调度器、异常和取消【使用篇】_第23张图片
            
注意:如需启用协程取消流程, 请不要捕获 CancellationException 类型的异常(或在被发现时总是重新抛出)。首选捕获特定类型的异常(如  IOException),而不是 Exception 或 Throwable 等一般类型。 
       
如果捕获了  CancellationException 类型 的异常会发生什么呢?例如下面的代码:
【深入理解Kotlin协程】协程作用域、启动模式、调度器、异常和取消【使用篇】_第24张图片
这里我们启动一个子协程,然后在300ms之后取消它,在子协程中延时了500ms然后继续执行,因此在300ms之后,我们应该不会看到下面的日志输出,因为被取消了。但是上面代码运行之后,仍然会输出try-catch之后的日志。Why? 这里try-catch中未指定具体的异常类型,这意味着它将捕获所有的异常类型,包括 CancellationException 异常,因此当我们在某个时刻取消协程时(比如关闭页面退出),try-catch之后的业务代码仍将会继续执行,协程没有被真正取消。
          
所以我们 要么在try-catch时总是指定具体的异常类型要么捕获到 CancellationException 异常时将其抛出,如下:
【深入理解Kotlin协程】协程作用域、启动模式、调度器、异常和取消【使用篇】_第25张图片

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

使用CoroutineExceptionHandler对协程的异常进行捕获

除了使用传统的 try-catch捕获异常的手段, 优先推荐使用CoroutineExceptionHandler来捕获协程的异常,launch中抛出的异常会被CoroutineExceptionHandler捕获并打印,而async中抛出的异常不会被捕获直接在控制台抛出异常:
suspend fun main() { 
    val exceptionHandler = CoroutineExceptionHandler { context, throwable ->
        println("exceptionHandler: $throwable")
    }
    val job = GlobalScope.launch(exceptionHandler) {
        throw ArithmeticException()
    }
    val deferred = GlobalScope.async(exceptionHandler) {
        throw IllegalStateException()
    }
    job.join()
    deferred.await()
}

需要注意的是异常处理Handler需要挂在最外部的根协程上,如果挂在子协程上,可能无法捕获异常:

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

捕获异常非常重要,如下代码如果没有传handler会导致崩溃:
        val exceptionHandler = CoroutineExceptionHandler { context, throwable ->
            println("exceptionHandler: $throwable")
        }
        findViewById

异常聚合

当协程的多个子协程因为异常而失败时,一般情况下取第一个异常进行处理。在第一个异常之后发生的所有其他异常,都将 被绑定到第一个异常之上
【深入理解Kotlin协程】协程作用域、启动模式、调度器、异常和取消【使用篇】_第28张图片

 exception.supressed是一个数组,里面保存了所有被抑制的异常,如果想获知所有的异常类型可以通过此方法获取。

协程的全局异常处理器

官方协程框架还支待全局的异常处理器, 在根协程未设置异常处理器时,未捕获异常会优 先传递给全局异常处理器处理,之后再交给所在线程的  UncaughtExceptionHandler。
   
由此可见,全局异常处理器可以 获取所有协程未处理的未捕获异常,不过它并 不能 对异常进行捕获。虽然不能阻止程序崩溃,全局异常处理器在程序调试和异常上报等场景中仍然有非常大的用处。
    
定义全局异常处理器本身与定义普通异常处理器没有什么区别 ,具体如代码清单 6-6 所示。
  【深入理解Kotlin协程】协程作用域、启动模式、调度器、异常和取消【使用篇】_第29张图片
关键之处在于我们需要在 classpath 下面创建 META-INF/services 目录,并在其中创建个名为  kotlinx.coroutines.CoroutineExceptionHandler 的文件,文件的内容就是我们的全局异常处理器的全类名。本例中该文件的内容为:
com.my.kotlin.coroutine.exceptionhandler.GlobalCoroutineExceptionHandler
【深入理解Kotlin协程】协程作用域、启动模式、调度器、异常和取消【使用篇】_第30张图片
接下来测试一下它的效果,如代码清单 6-7 所示
  【深入理解Kotlin协程】协程作用域、启动模式、调度器、异常和取消【使用篇】_第31张图片

如果大家在 Android 设备上尝试运行该程序,部分机型可能只能看到全局异常处理器输出的异常信息。换言之,如果我们不配置全局异常处理器,在 Default 或者 IO 调度器上遇到未捕获的异常时极有可能发生程序闪退却没有任何异常信息的情况,此时全局异常处理器的配置就显得格外有用了。(提示:全局异常处理器不适用于 JavaScript 和 Native 平台)

注意:发生异常时,全局Handler会打印异常信息,但是并不会阻止App崩溃的发生。

协程的取消检查

协程的取消是通过抛出一个 CancellationException异常实现的,该异常不会取消它的父协程,这个异常通常会被忽略静默处理(或者你可以try-catch捕获它,但是不推荐)。
                      
我们已知道挂起函数可以通过  suspendCancellableCoroutine 来响应所在协程的取消状态,我们在设计异步任务时,异步任务的取消响应点可能就在这些挂起点处。
              
如果没有挂起点呢?例如在协程中实现一个文件复制的函数,如使用 Java BIO,完成则不需要调用挂起函数,如代码清单 6-8 所示
  【深入理解Kotlin协程】协程作用域、启动模式、调度器、异常和取消【使用篇】_第32张图片

 这是标准库提供的扩展函数,可以实现流复制。

将这段程序直接放入协程中之后,你就会发现协程的取消状态对它没有丝毫影响。要解决这个问题,我们首先可以想到的是在  while 循环处添加一个对所在协程的  isActive 的判断。这个思路没有问题, 我们可以通过 全局属性  coroutineContext 获取所在协程的  Job 实例来做到这一点,如代码清单 6-9 所示。
【深入理解Kotlin协程】协程作用域、启动模式、调度器、异常和取消【使用篇】_第33张图片

 如果 job 为空 ,那么说明所在的协程是个简单协程,这种情况不存在取消逻辑;job 不为空时,如果 isActive 也不为 true, 则说明当前协程被取消了,抛出它对应的取消异常即可。

目的达成,不过这样做看上去似乎有些烦琐,如果对协程的内部逻辑了解不多的话很容易出错。有没有更好的办法呢? 那我们就要看看官方协程框架还提供了哪些对逻辑没有影响的挂起函数,这其中最合适的就是  yield 函数 ,如代码清单 -10 所示。
【深入理解Kotlin协程】协程作用域、启动模式、调度器、异常和取消【使用篇】_第34张图片

 yield 函数的作用主要是检查所在协程状态, 如果已经取消,则抛出取消异常予以响应。此外,它还会尝试出让线程的执行权,给其他协程提供执行机会。

出让调度方面,线程和协程的  yield 的设计思路基本一致,不过线程的  yield 不会抛出中断异常,因而我们知道它不会检查线程的中断状态,这是线程的  yield 与协程的  yield之间一个较大的差异。

协程的超时取消

我们发送网络请求,通常会设置一个超时来应对网络不佳的情况,所有的网络框架(如 OkHttp)都会提供这样的参数。如果有一个特定的需求,用户等不了太久,比如要求 5s 以内没有响应就要取消,这种情况下就要单独修改网络库的超时配置,但这样做不太方便。为了解决这个问题,我们可以这样做,如代码清单 6-1 所示。
【深入理解Kotlin协程】协程作用域、启动模式、调度器、异常和取消【使用篇】_第35张图片

 这看上去没什么问题,只是不够简洁,甚至还有些令人迷惑。幸运的是,官方框架提供了一个可以设定超时的 API withTimeout 我们可以用这个 API 来优化上面的代码,如代码清单 6-12 所示。

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

 withTimeout  这个 AP 可以设定一个超时,如果它的第二个参数 block 运行超时,那么就会被取消,取消后 withTimeout 直接抛出取消异常。如果不希望在超时的情况下抛出取消异常,也可以使用 withTimeoutOrNull, 它的效果是在超时的情况下返回 null。

隔离协程之间的异常传播

首先要明确一点:协程出现异常后都会根据所在作用域来尝试将异常向上传递。

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

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

根据前面协程作用域异常传播的结论,子协程产生的未捕获异常会传播给它的父协程,然后父协程会按照如下顺序进行处理:取消所有的子协程、取消自己、将异常继续向上传递。如下图:

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

但这种情况有时并不是我们想要的,我们更希望一个协程在产生异常时,不影响其他协程的执行

为了解决上述问题,有以下几种方案:

使用SupervisorJob

第一种方案是可以使用 SupervisorJob替代 JobSupervisorJobJob基本类似,区别在于不会被子协程的异常所影响( 主从作用域)。
先看一下使用普通Job的效果:
 private fun testSupervisorJob() {
        val context = Job() + Dispatchers.IO
        lifecycleScope.launch {
            launch(context) {
                delay(1000)
                println("子协程1")
            }
            launch(context) {
                delay(2000)
                println("子协程2")
                9 / 0 // 此处会抛出ArithmeticException异常
            }
            launch(context) {
                delay(3000)
                println("子协程3")
            }
            launch(context) {
                delay(4000)
                println("子协程4")
            }
            delay(5000)
            println("父协程")
        }
    }
}
上面在 lifecycleScope中启动了4个子协程,并且为这4个子协程的上下文指定为Job()对象,而在第2个子协程中会抛出运行时异常。
输出如下:
【深入理解Kotlin协程】协程作用域、启动模式、调度器、异常和取消【使用篇】_第40张图片 运行之后直接崩溃了, 后面2个子协程没有被执行,这是因为第2个子协程中发生了未捕获的异常,将它传递给了父协程,而父协程发现也不能处理这个异常,于是交给系统处理,默认处理就是终止应用程序。同时父协程又取消了所有的子协程。假如为子协程指定异常处理器,则不会导致崩溃,如下:
private fun testSupervisorJob() {
    val context = Job() + Dispatchers.IO + CoroutineExceptionHandler { context, throwable ->
        println("${context[CoroutineName]} 发生了异常: $throwable")
    }
    lifecycleScope.launch {
        launch(context + CoroutineName("子协程1")) {
            delay(1000)
            println("子协程1")
        }
        launch(context + CoroutineName("子协程2")) { 
            delay(2000)
            println("子协程2")
            9 / 0 // 此处会抛出ArithmeticException异常
        }
        launch(context + CoroutineName("子协程3")) {
            delay(3000)
            println("子协程3")
        }
        launch(context + CoroutineName("子协程4")) {
            delay(4000)
            println("子协程4")
        }
        delay(5000)
        println("父协程")
    }
}

 此时主线程不会崩溃了,但是由于第二个协程发生了异常,传递给了父协程,导致父协程取消了其他子协程,因此看不到子协程3和4的输出。此时我们将Job替换成SupervisorJob:

private fun testSupervisorJob() {
    val context = SupervisorJob() + Dispatchers.IO + CoroutineExceptionHandler { context, throwable ->
        println("${context[CoroutineName]} 发生了异常: $throwable")
    }
    lifecycleScope.launch {
        launch(context + CoroutineName("子协程1")) {
            delay(1000)
            println("子协程1")
        }
        launch(context + CoroutineName("子协程2")) {
            delay(2000)
            println("子协程2")
            9 / 0 // 此处会抛出ArithmeticException异常
        }
        launch(context + CoroutineName("子协程3")) {
            delay(3000)
            println("子协程3")
        }
        launch(context + CoroutineName("子协程4")) {
            delay(4000)
            println("子协程4")
        }
        delay(5000)
        println("父协程")
    }
}

运行之后输出:

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

 可以看到子协程3和4这两个子协程不会因为子协程2发生了异常而被取消了,也就是说4个子协程都可以独立运行,互不影响

所以, SupervisorJob的一个重要作用就是定义隔离协程作用域之间的异常传播。
  
【深入理解Kotlin协程】协程作用域、启动模式、调度器、异常和取消【使用篇】_第42张图片

使用上下文自带SupervisorJob的Scope对象

如果只是针对上面的例子,可以不用这么麻烦,因为我们已经知道Android中 lifecycleScope、viewModelScope、MainScope这几种作用域的上下文构成已经包含了  SupervisorJob(),官方之所以设计这几种Scope的上下文,其实是已经为我们考虑好了使用场景,所以第二种方案就是直接使用上面几种scope来launch子协程即可。例如:
private fun testSupervisorJob() {
    val exceptionHandler = CoroutineExceptionHandler { context, throwable ->
        println("${context[CoroutineName]} 发生了异常: $throwable")
    }
    lifecycleScope.launch {
        lifecycleScope.launch {
            delay(1000)
            println("子协程1")
        }
        lifecycleScope.launch(exceptionHandler) {
            delay(2000)
            println("子协程2")
            9 / 0 // 此处会抛出ArithmeticException异常
        }
        lifecycleScope.launch {
            delay(3000)
            println("子协程3")
        }
        lifecycleScope.launch {
            delay(4000)
            println("子协程4")
        }
        delay(5000)
        println("父协程")
    }
}

这样同样可以使得子协程之间的异常互不影响。

使用supervisorScope

另外一种解决方案是可以使用前面协程作用域中提到的  supervisorScope 这个挂起函数,它的作用就是定义一个 主从作用域,因此可以用来隔离:
private fun testSupervisorJob() {
    val exceptionHandler = CoroutineExceptionHandler { context, throwable ->
        println("${context[CoroutineName]} 发生了异常: $throwable")
    }
    lifecycleScope.launch {
        supervisorScope {
            launch {
                delay(1000)
                println("子协程1")
            }
            launch(exceptionHandler) {
                delay(2000)
                println("子协程2")
                9 / 0 // 此处会抛出ArithmeticException异常
            }
            launch {
                delay(3000)
                println("子协程3")
            }
            launch {
                delay(4000)
                println("子协程4")
            }
        }
        delay(5000)
        println("父协程")
    }
}

相比之下,SupervisorJob可能更适合用于自定义Scope的场景中(例如在ViewModel或Service中),除了官方库自带的几种Scope,我们有时可以通过继承或组合的方式来使用CoroutineScope来创建自己的Scope对象,例如:

class MyViewModel : ViewModel() {
    private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
        println("发生了异常: $throwable")
    }
    private val scopeWithNoEffect = CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler)

    fun doBusiness() {
        viewModelScope.launch {
            scopeWithNoEffect.launch {
                // ... 业务1
            }
            launch {
                // ... 业务2
            }
        }
    }

    override fun onCleared() {
        super.onCleared()
        scopeWithNoEffect.cancel()
    }
}

这样业务1挂掉了不会影响业务2的执行。

问题:既然 lifecycleScope是使用的 SupervisorJob,而协程上下文是可以继承的,那么 lifecycleScope启动的子协程不应该是自动继承 SupervisorJob吗?为什么还需要为子协程显示指定 SupervisorJob
也就是说下面的代码,子协程中即使抛出异常,也不会影响其他兄弟协程才对,因为继承了父协程的SupervisorJob。
private fun testSupervisorJob() {
    lifecycleScope.launch {
        launch {
            throw Exception()
        }
        launch { }
    }
}

而实际上这个代码中的子协程的异常依然传递给了父协程导致崩溃,所以说子协程并没有从父协程继承SupervisorJob。

这个问题的答案是: 对于在作用域内创建的新协程, 系统始终 会为每个新的子协程创建一个新的Job实例。
  【深入理解Kotlin协程】协程作用域、启动模式、调度器、异常和取消【使用篇】_第43张图片


以下部分来自官网:
            
在 Android 中使用协程的最佳实践https://developer.android.google.cn/kotlin/coroutines/coroutines-best-practices?hl=zh-cn

注入调度程序

在创建新协程或调用 withContext 时,请勿对 Dispatchers 进行硬编码。

// DO inject Dispatchers
class NewsRepository(
    private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
) {
    suspend fun loadNews() = withContext(defaultDispatcher) { /* ... */ }
}

// DO NOT hardcode Dispatchers
class NewsRepository {
    // DO NOT use Dispatchers.Default directly, inject it instead
    suspend fun loadNews() = withContext(Dispatchers.Default) { /* ... */ }
}

这种依赖项注入模式可以降低测试难度,因为您可以使用测试调度程序替换单元测试和插桩测试中的这些调度程序,以提高测试的确定性。

注意:虽然您可能在本网站和 Codelab 上的代码段中看到过已经过硬编码的调度程序,但这只是为了让示例代码显得简单明了。在您的应用中,您应该注入调度程序。

挂起函数应该能够安全地从主线程调用

挂起函数应该是主线程安全的,这意味着,您可以安全地从主线程调用挂起函数。如果某个类在协程中执行长期运行的阻塞操作,那么该类负责使用 withContext 将执行操作移出主线程。这适用于应用中的所有类,无论其属于架构的哪个部分都不例外。
class NewsRepository(private val ioDispatcher: CoroutineDispatcher) {
    // 因为该操作是从服务器手动获取新闻,使用阻塞Http请求,它需要将代码执行到IO调度器上,以使主线程安全
    suspend fun fetchLatestNews(): List
{         withContext(ioDispatcher) { /* ... implementation ... */ }     } } // 这个用例获取最新的新闻和相关的作者。 class GetLatestNewsWithAuthorsUseCase(     private val newsRepository: NewsRepository,     private val authorsRepository: AuthorsRepository ) {     // 该方法不需要移动协程到不同的线程,因为newsRepository是主线程安全的,     // 协程中完成的工作是轻量级的,因为它只创建一个列表,并向其中添加元素     suspend operator fun invoke(): List {         val news = newsRepository.fetchLatestNews()         val response: List = mutableEmptyList()         for (article in news) {             val author = authorsRepository.getAuthor(article.author)             response.add(ArticleWithAuthor(article, author))         }         return Result.Success(response)     } }

此模式可以提高应用的可伸缩性,因为调用挂起函数的类无需担心使用哪个 Dispatcher 来处理哪种类型的工作。该责任将由执行相关工作的类承担。

ViewModel 应创建协程

ViewModel 类应首选创建协程,而不是公开挂起函数来执行业务逻辑。如果只需要发出一个值,而不是使用数据流公开状态,ViewModel 中的挂起函数就会非常有用。
// DO create coroutines in the ViewModel
class LatestNewsViewModel(
    private val getLatestNewsWithAuthors: GetLatestNewsWithAuthorsUseCase
) : ViewModel() {

    private val _uiState = MutableStateFlow(LatestNewsUiState.Loading)
    val uiState: StateFlow = _uiState

    fun loadNews() {
        viewModelScope.launch {
            val latestNewsWithAuthors = getLatestNewsWithAuthors()
            _uiState.value = LatestNewsUiState.Success(latestNewsWithAuthors)
        }
    }
}

// Prefer observable state rather than suspend functions from the ViewModel
class LatestNewsViewModel(
    private val getLatestNewsWithAuthors: GetLatestNewsWithAuthorsUseCase
) : ViewModel() {
    // DO NOT do this. News would probably need to be refreshed as well.
    // Instead of exposing a single value with a suspend function, news should
    // be exposed using a stream of data as in the code snippet above.
    suspend fun loadNews() = getLatestNewsWithAuthors()
}

视图不应直接触发任何协程来执行业务逻辑,而应将这项工作委托给 ViewModel。这样一来,业务逻辑就会变得更易于测试,因为可以对 ViewModel 对象进行单元测试,而不必使用测试视图所必需的插桩测试。

此外,如果工作是在 viewModelScope 中启动,您的协程将在配置更改后自动保留。如果您改用 lifecycleScope 创建协程,则必须手动进行处理该操作。如果协程的存在时间需要比 ViewModel 的作用域更长,请查看 “在业务和数据层中创建协程”部分。

不要公开可变类型

最好向其他类公开不可变类型。这样一来,对可变类型的所有更改都会集中在一个类中,便于在出现问题时进行调试。
// DO expose immutable types
class LatestNewsViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(LatestNewsUiState.Loading)
    val uiState: StateFlow = _uiState
    /* ... */
}

class LatestNewsViewModel : ViewModel() {
    // DO NOT expose mutable types
    val uiState = MutableStateFlow(LatestNewsUiState.Loading)
    /* ... */
}

数据层和业务层应公开挂起函数和数据流

数据层和业务层中的类通常会公开函数以执行一次性调用,或接收数据随时间变化的通知。这些层中的类应该 针对一次性调用公开挂起函数,并 公开数据流以接收关于数据更改的通知
// Classes in the data and business layer expose either suspend functions or Flows
class ExampleRepository {
    suspend fun makeNetworkRequest() { /* ... */ }

    fun getExamples(): Flow { /* ... */ }
}

采用该最佳实践后,调用方(通常是演示层)能够控制这些层中发生的工作的执行和生命周期,并在需要时取消相应工作。

在业务层和数据层中创建协程

对于数据层或业务层中因不同原因而需要创建协程的类,它们可以选择不同的选项。如果仅当用户查看当前屏幕时,要在这些协程中完成的工作才具有相关性,则应遵循调用方的生命周期。在大多数情况下,调用方将是 ViewModel。在这种情况下,应使用  coroutineScope 或  supervisorScope。
class GetAllBooksAndAuthorsUseCase(
    private val booksRepository: BooksRepository,
    private val authorsRepository: AuthorsRepository,
    private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
) {
    suspend fun getBookAndAuthors(): BookAndAuthors {
        // 并行地获取书籍和作者,并在两者请求时返回完成,数据准备好
        return coroutineScope {
            val books = async(defaultDispatcher) {
                booksRepository.getAllBooks()
            }
            val authors = async(defaultDispatcher) {
                authorsRepository.getAllAuthors()
            }
            BookAndAuthors(books.await(), authors.await())
        }
    }
}

如果只要应用处于打开状态,要完成的工作就具有相关性,并且此工作不限于特定屏幕,那么此工作的存在时间应该比调用方的生命周期更长。对于这种情况,您应使用外部 CoroutineScope。

class ArticlesRepository(
    private val articlesDataSource: ArticlesDataSource,
    private val externalScope: CoroutineScope,
    private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
) {
    // As we want to complete bookmarking the article even if the user moves
    // away from the screen, the work is done creating a new coroutine
    // from an external scope
    suspend fun bookmarkArticle(article: Article) {
        externalScope.launch(defaultDispatcher) {
            articlesDataSource.bookmarkArticle(article)
        }
        .join() // Wait for the coroutine to complete
    }
}
externalScope 应由存在时间比当前屏幕更长的类进行创建和管理,并且可由 Application 类或作用域限定为导航图的 ViewModel 进行管理。

在测试中注入 TestDispatcher

应在测试内的类中注入  TestDispatcher 的实例。 kotlinx-coroutines-test  库中有两种可用的实现:
  • StandardTestDispatcher:使用调度器将已在其上启动的协程加入队列,并在测试线程不繁忙时执行这些协程。您可以使用  advanceUntilIdle 等方法挂起测试线程,以允许其他加入队列的协程运行。
  • UnconfinedTestDispatcher:以阻塞方式即刻运行新协程。这样做通常可以更轻松地编写测试,但会使您无法更好地控制测试期间协程的执行方式。
如需测试协程,请使用  runTest 协程构建器。runTest 使用  TestCoroutineScheduler 跳过测试中的延迟过程,并允许您控制虚拟时间。您还可以根据需要使用此调度器创建其他测试调度程序。
class ArticlesRepositoryTest {
    @Test
    fun testBookmarkArticle() = runTest {
        // Pass the testScheduler provided by runTest's coroutine scope to the test dispatcher
        val testDispatcher = UnconfinedTestDispatcher(testScheduler)

        val articlesDataSource = FakeArticlesDataSource()
        val repository = ArticlesRepository(
            articlesDataSource,
            testDispatcher
        )
        val article = Article()
        repository.bookmarkArticle(article)
        assertThat(articlesDataSource.isBookmarked(article)).isTrue()
    }
}

所有 TestDispatchers 都应共用同一调度器。这样可让您在单个测试线程上运行所有协程代码,从而使测试具有确定性。runTest 会等待同一调度器上的所有协程或测试协程的所有子协程完成运行后再返回。

注意:如果被测试代码中未使用其他 Dispatchers,则以上代码效果最好。因此,不建议在类中对 Dispatchers 进行硬编码。
注意:协程测试 API 在 kotlinx.coroutines 1.6.0 中发生了显著变化。如果您需要从以前的测试 API 迁移到新版本,请参阅 迁移指南。

避免使用 GlobalScope

这类似于“注入调度程序”最佳做法。通过使用  GlobalScope,您将对类使用的 CoroutineScope 进行硬编码,而这会带来一些问题:
  • 提高硬编码值。如果您对 GlobalScope 进行硬编码,则可能同时对 Dispatchers 进行硬编码。
  • 这会让测试变得非常困难,因为您的代码是在非受控的作用域内执行的,您将无法控制其执行。
  • 您无法设置一个通用的 CoroutineContext 来对内置于作用域本身的所有协程执行。
而您可以考虑针对存在时间需要比当前作用域更长的工作注入一个 CoroutineScope。

参考:

   《深入理解Kotlin协程》- 2020年-机械工业出版社-霍丙乾

     KOTLIN COROUTINES

你可能感兴趣的:(Android,kotlin,协程,android,kotlin协程)