在 Android 开发中使用协程 | 上手指南

本文是介绍 Android 协程系列中的第二部分,这篇文章主要会介绍如何使用协程来处理任务,而且能在任务开始执行后保持对它的追踪。

保持对协程的追踪

本系列文章的第一篇,咱们探讨了协程适合用来解决哪些问题。这里再简单回顾一下,协程适合解决如下两个常见的编程问题:html

  1. 处理耗时任务 (Long running tasks),这种任务经常会阻塞住主线程;
  2. 保证主线程安全 (Main-safety),即确保安全地从主线程调用任何 suspend 函数。

协程经过在常规函数之上增长 suspend 和 resume 两个操做来解决上述问题。当某个特定的线程上的全部协程被 suspend 后,该线程即可腾出资源去处理其余任务。android

协程自身并不可以追踪正在处理的任务,可是有成百上千个协程并对它们同时执行挂起操做并无太大问题。协程是轻量级的,但处理的任务却不必定是轻量的,好比读取文件或者发送网络请求。git

使用代码来手动追踪上千个协程是很是困难的,您能够尝试对全部协程进行跟踪,手动确保它们都完成了或者都被取消了,那么代码会臃肿且易出错。若是代码不是很完美,就会失去对协程的追踪,也就是所谓 "work leak" 的状况。github

任务泄漏 (work leak) 是指某个协程丢失没法追踪,它相似于内存泄漏,但比它更加糟糕,这样丢失的协程能够恢复本身,从而占用内存、CPU、磁盘资源,甚至会发起一个网络请求,而这也意味着它所占用的这些资源都没法获得重用。数据库

泄漏协程会浪费内存、CPU、磁盘资源,甚至发送一个无用的网络请求。编程

为了可以避免协程泄漏,Kotlin 引入了结构化并发 (structured concurrency) 机制,它是一系列编程语言特性和实践指南的结合,遵循它能帮助您追踪到全部运行于协程中的任务。安全

在 Android 平台上,咱们可使用结构化并发来作到如下三件事:bash

  1. 取消任务 —— 当某项任务再也不须要时取消它;
  2. 追踪任务 —— 当任务正在执行时,追踪它;
  3. 发出错误信号 —— 当协程失败时,发出错误信号代表有错误发生。

接下来咱们对以上几点一一进行探讨,看看结构化并发是如何帮助可以追踪全部协程,而不会致使泄漏出现的。网络

借助 scope 来取消任务

在 Kotlin 中,定义协程必须指定其 CoroutineScope 。CoroutineScope 能够对协程进行追踪,即便协程被挂起也是如此。同第一篇文章中讲到的调度程序 (Dispatcher) 不一样,CoroutineScope 并不运行协程,它只是确保您不会失去对协程的追踪。架构

为了确保全部的协程都会被追踪,Kotlin 不容许在没有使用 CoroutineScope 的状况下启动新的协程。CoroutineScope 可被看做是一个具备超能力的 ExecutorService 的轻量级版本。它能启动新的协程,同时这个协程还具有咱们在第一部分所说的 suspend 和 resume 的优点。

CoroutineScope 会跟踪全部协程,一样它还能够取消由它所启动的全部协程。这在 Android 开发中很是有用,好比它可以在用户离开界面时中止执行协程。

CoroutineScope 会跟踪全部协程,而且能够取消由它所启动的全部协程。

启动新的协程

须要特别注意的是,您不能随便就在某个地方调用 suspend 函数,suspend 和 resume 机制要求您从常规函数中切换到协程。

有两种方式可以启动协程,它们分别适用于不一样的场景:

  1. launch 构建器适合执行 "一劳永逸" 的工做,意思就是说它能够启动新协程而不将结果返回给调用方;
  2. async 构建器可启动新协程并容许您使用一个名为 await 的挂起函数返回 result。

一般,您应使用 launch 从常规函数中启动新协程。由于常规函数没法调用 await (记住,它没法直接调用 suspend 函数),因此将 async 做为协程的主要启动方法没有多大意义。稍后咱们会讨论应该如何使用 async。

您应该改成使用 coroutine scope 调用 launch 方法来启动协程。

scope.launch {
    // 这段代码在做用域里启动了一个新协程
   // 它能够调用挂起函数
   fetchDocs()
}
复制代码

您能够将 launch 看做是将代码从常规函数送往协程世界的桥梁。在 launch 函数体内,您能够调用 suspend 函数并可以像咱们上一篇介绍的那样保证主线程安全。

Launch 是将代码从常规函数送往协程世界的桥梁。

注意: launch 和 async 之间的很大差别是它们对异常的处理方式不一样。async 指望最终是经过调用 await 来获取结果 (或者异常),因此默认状况下它不会抛出异常。这意味着若是使用 async 启动新的协程,它会静默地将异常丢弃。

因为 launch 和 async 仅可以在 CouroutineScope 中使用,因此任何您所建立的协程都会被该 scope 追踪。Kotlin 禁止您建立不可以被追踪的协程,从而避免协程泄漏。

在 ViewModel 中启动协程

既然 CoroutineScope 会追踪由它启动的全部协程,而 launch 会建立一个新的协程,那么您应该在什么地方调用 launch 并将其放在 scope 中呢? 又该在何时取消在 scope 中启动的全部协程呢?

在 Android 平台上,您能够将 CoroutineScope 实现与用户界面相关联。这样可以让您避免泄漏内存或者对再也不与用户相关的 Activities 或 Fragments 执行额外的工做。当用户经过导航离开某界面时,与该界面相关的 CoroutineScope 能够取消掉全部不须要的任务。

结构化并发可以保证当某个做用域被取消后,它内部所建立的全部协程也都被取消。

当将协程同 Android 架构组件 (Android Architecture Components) 集成起来时,您每每会须要在 ViewModel 中启动协程。由于大部分的任务都是在这里开始进行处理的,因此在这个地方启动是一个很合理的作法,您也不用担忧旋转屏幕方向会终止您所建立的协程。

从生命周期感知型组件 (AndroidX Lifecycle) 的 2.1.0 版本开始 (发布于 2019 年 9 月),咱们经过添加扩展属性 ViewModel.viewModelScope 在 ViewModel 中加入了协程的支持。

推荐您阅读 Android 开发者文档 "将 Kotlin 协程与架构组件一块儿使用" 了解更多。

看看以下示例:

class MyViewModel(): ViewModel() {
    fun userNeedsDocs() {
       // 在 ViewModel 中启动新的协程
        viewModelScope.launch {
            fetchDocs()
        }
    }
}
复制代码

当 viewModelScope 被清除 (当 onCleared() 回调被调用时) 以后,它将自动取消它所启动的全部协程。这是一个标准作法,若是一个用户在还没有获取到数据时就关闭了应用,这时让请求继续完成就纯粹是在浪费电量。

为了提升安全性,CoroutineScope 会进行自行传播。也就是说,若是某个协程启动了另外一个新的协程,它们都会在同一个 scope 中终止运行。这意味着,即便当某个您所依赖的代码库从您建立的 viewModelScope 中启动某个协程,您也有方法将其取消。

注意: 协程被挂起时,系统会以抛出 CancellationException 的方式协做取消协程。捕获顶级异常 (如Throwable) 的异常处理程序将捕获此异常。若是您作异常处理时消费了这个异常,或从未进行 suspend 操做,那么协程将会徘徊于半取消 (semi-canceled) 状态下。

因此,当您须要将一个协程同 ViewModel 的生命周期保持一致时,使用 viewModelScope 来从常规函数切换到协程中。而后,viewModelScope 会自动为您取消协程,所以在这里哪怕是写了死循环也是彻底不会产生泄漏。以下示例:

fun runForever() {
    // 在 ViewModel 中启动新的协程
    viewModelScope.launch {
        // 当 ViewModel 被清除后,下列代码也会被取消
        while(true) {
            delay(1_000)
           // 每过 1 秒作点什么
        }
    }
}
复制代码

经过使用 viewModelScope,能够确保全部的任务,包含死循环在内,均可以在不须要的时候被取消掉。

任务追踪

使用协程来处理任务对于不少代码来讲真的很方便。启动协程,进行网络请求,将结果写入数据库,一切都很天然流畅。

但有时候,可能会遇到稍微复杂点的问题,例如您须要在一个协程中同时处理两个网络请求,这种状况下须要启动更多协程。

想要建立多个协程,能够在 suspend function 中使用名为 coroutineScopesupervisorScope 这样的构造器来启动多个协程。可是这个 API 说实话,有点使人困惑。coroutineScope 构造器和 CoroutineScope 这两个的区别只是一个字符之差,但它们倒是彻底不一样的东西。

另外,若是随意启动新协程,可能会致使潜在的任务泄漏 (work leak)。调用方可能感知不到启用了新的协程,也就意味着没法对其进行追踪。

为了解决这个问题,结构化并发发挥了做用,它保证了当 suspend 函数返回时,就意味着它所处理的任务也都已完成。

结构化并发保证了当 suspend 函数返回时,它所处理任务也都已完成。

示例使用 coroutineScope 来获取两个文档内容:

suspend fun fetchTwoDocs() {
    coroutineScope {
        launch { fetchDoc(1) }
        async { fetchDoc(2) }
    }
}
复制代码

在这个示例中,同时从网络中获取两个文档数据,第一个是经过 launch 这样 "一劳永逸" 的方式启动协程,这意味着它不会返回任何结果给调用方。

第二个是经过 async 的方式获取文档,因此是会有返回值返回的。不过上面示例有一点奇怪,由于一般来说两个文档的获取都应该使用 async,但这里我仅仅是想举例来讲明能够根据须要来选择使用 launch 仍是 async,或者是对二者进行混用。

coroutineScope 和 supervisorScope 可让您安全地从 suspend 函数中启动协程。

可是请注意,这段代码不会显式地等待所建立的两个协程完成任务后才返回,当 fetchTwoDocs 返回时,协程还正在运行中。

因此,为了作到结构化并发并避免泄漏的状况发生,咱们想作到在诸如 fetchTwoDocs 这样的 suspend 函数返回时,它们所作的全部任务也都能结束。换个说法就是,fetchTwoDocs 返回以前,它所启动的全部协程也都能完成任务。

Kotlin 确保使用 coroutineScope 构造器不会让 fetchTwoDocs 发生泄漏,coroutinScope 会先将自身挂起,等待它内部启动的全部协程完成,而后再返回。所以,只有在 coroutineScope 构建器中启动的全部协程完成任务以后,fetchTwoDocs 函数才会返回。

处理一堆任务

既然咱们已经作到了追踪一两个协程,那么来个刺激的,追踪一千个协程来试试!

先看看下面这个动画:

这个动画展现了 coroutineScope 是如何追踪一千个协程的。

这个动画向咱们展现了如何同时发出一千个网络请求。固然,在真实的 Android 开发中最好别这么作,太浪费资源了。

这段代码中,咱们在 coroutineScope 构造器中使用 launch 启动了一千个协程,您能够看到这一切是如何联系到一块儿的。因为咱们使用的是 suspend 函数,所以代码必定使用了 CoroutineScope 建立了协程。咱们目前对这个 CoroutineScope 一无所知,它多是viewModelScope 或者是其余地方定义的某个 CoroutineScope,但无论怎样,coroutineScope 构造器都会使用它做为其建立新的 scope 的父级。

而后,在 coroutineScope 代码块内,launch 将会在新的 scope 中启动协程,随着协程的启动完成,scope 会对其进行追踪。最后,一旦全部在 coroutineScope 内启动的协程都完成后,loadLots 方法就能够轻松地返回了。

注意: scope 和协程之间的父子关系是使用 Job 对象进行建立的。可是您不须要深刻去了解,只要知道这一点就能够了。

coroutineScope 和 supervisorScope 将会等待全部的子协程都完成。

以上的重点是,使用 coroutineScope 和 supervisorScope 能够从任何 suspend function 来安全地启动协程。即便是启动一个新的协程,也不会出现泄漏,由于在新的协程完成以前,调用方始终处于挂起状态。

更厉害的是,coroutineScope 将会建立一个子 scope,因此一旦父 scope 被取消,它会将取消的消息传递给全部新的协程。若是调用方是 viewModelScope,这一千个协程在用户离开界面后都会自动被取消掉,很是整洁高效。

在继续探讨报错 (error) 相关的问题以前,有必要花点时间来讨论一下 supervisorScope 和 coroutineScope,它们的主要区别是当出现任何一个子 scope 失败的状况,coroutineScope 将会被取消。若是一个网络请求失败了,全部其余的请求都将被当即取消,这种需求选择 coroutineScope。相反,若是您但愿即便一个请求失败了其余的请求也要继续,则可使用 supervisorScope,当一个协程失败了,supervisorScope 是不会取消剩余子协程的。

协程失败时发出报错信号

在协程中,报错信号是经过抛出异常来发出的,就像咱们日常写的函数同样。来自 suspend 函数的异常将经过 resume 从新抛给调用方来处理。跟常规函数同样,您不只可使用 try/catch 这样的方式来处理错误,还能够构建抽象来按照您喜欢的方式进行错误处理。

可是,在某些状况下,协程仍是有可能会弄丢获取到的错误的。

val unrelatedScope = MainScope()
// 丢失错误的例子
suspend fun lostError() {
   // 未使用结构化并发的 async
    unrelatedScope.async {
        throw InAsyncNoOneCanHearYou("except")
    }
}
复制代码

注意: 上述代码声明了一个无关联协程做用域,它将不会按照结构化并发的方式启动新的协程。还记得我在一开始说的结构化并发是一系列编程语言特性和实践指南的集合,在 suspend 函数中引入无关联协程做用域违背告终构化并发规则。

在这段代码中错误将会丢失,由于 async 假设您最终会调用 await 而且会从新抛出异常,然而您并无去调用 await,因此异常就永远在那等着被调用,那么这个错误就永远不会获得处理。

结构化并发保证当一个协程出错时,它的调用方或做用域会被通知到。

若是您按照结构化并发的规范去编写上述代码,错误就会被正确地抛给调用方处理。

suspend fun foundError() {
    coroutineScope {
        async { 
            throw StructuredConcurrencyWill("throw")
        }
    }
}
复制代码

coroutineScope 不只会等到全部子任务都完成才会结束,当它们出错时它也会获得通知。若是一个经过 coroutineScope 建立的协程抛出了异常,coroutineScope 会将其抛给调用方。由于咱们用的是coroutineScope 而不是 supervisorScope,因此当抛出异常时,它会马上取消全部的子任务。

使用结构化并发

在这篇文章中,我介绍告终构化并发,并展现了如何让咱们的代码配合 Android 中的 ViewModel 来避免出现任务泄漏。

一样,我还帮助您更深刻去理解和使用 suspend 函数,经过确保它们在函数返回以前完成任务,或者是经过暴露异常来确保它们正确发出错误信号。

若是咱们使用了不符合结构化并发的代码,将会很容易出现协程泄漏,即调用方不知如何追踪任务的状况。这种状况下,任务是没法取消的,一样也不能保证异常会被从新抛出来。这样会使得咱们的代码很难理解,并可能会致使一些难以追踪的 bug 出现。

您能够经过引入一个新的不相关的 CoroutineScope (注意是大写的 C),或者是使用 GlobalScope 建立的全局做用域,可是这种方式的代码不符合结构化并发要求的方式。

可是当出现须要协程比调用方的生命周期更长的状况时,就可能须要考虑非结构化并发的编码方式了,只是这种状况比较罕见。所以,使用结构化编程来追踪非结构化的协程,并进行错误处理和任务取消,将是很是不错的作法。

若是您以前一直未按照结构化并发的方法编码,一开始确实一段时间去适应。这种结构确实保证与 suspend 函数交互更安全,使用起来更简单。在编码过程当中,尽量多地使用结构化并发,这样让代码更易于维护和理解。

在本文的开始列举告终构化并发为咱们解决的三个问题:

  1. 取消任务 —— 当某项任务再也不须要时取消它;
  2. 追踪任务 —— 当任务正在执行时,追踪它;
  3. 发出错误信号 —— 当协程失败时,发出错误信号代表有错误发生。

实现这种结构化并发,会为咱们的代码提供一些保障:

  1. 做用域取消时,它内部全部的协程也会被取消
  2. suspend 函数返回时,意味着它的全部任务都已完成
  3. 协程报错时,它所在的做用域或调用方会收到报错通知

总结来讲,结构化并发让咱们的代码更安全,更容易理解,还避免了出现任务泄漏的状况。

下一步

本篇文章,咱们探讨了如何在 Android 的 ViewModel 中启动协程,以及如何在代码中运用结构化并发,来让咱们的代码更易于维护和理解。

在下一篇文章中,咱们将探讨如何在实际编码过程当中使用协程,感兴趣的读者请继续关注咱们的更新。

相关文章
相关标签/搜索