在 Android 开发中使用协程 | 代码实战

本文是介绍 Android 协程系列中的第三部分,这篇文章经过发送一次性请求来介绍如何使用协程处理在实际编码过程当中遇到的问题。在阅读本文以前,建议您先阅读本系列的前两篇文章,关于在 Android 开发中使用协程的 背景介绍上手指南html

使用协程解决实际编码问题

前两篇文章主要是介绍了如何使用协程来简化代码,在 Android 上保证主线程安全,避免任务泄漏。以此为背景,咱们认为使用协程是在处理后台任务和简化 Android 回调代码的绝佳方案。android

目前为止,咱们主要集中在介绍协程是什么,以及如何管理它们,本文咱们将介绍如何使用协程来完成一些实际任务。协程同函数同样,是在编程语言特性中的一个经常使用特性,您可使用它来实现任何能够经过函数和对象能实现的功能。可是,在实际编程中,始终存在两种类型的任务很是适合使用协程来解决:git

  1. 一次性请求 (one shot requests) 是那种调用一下就请求一下,请求获取到结果后就结束执行;
  2. 流式请求 (streaming request) 在发出请求后,还一直监听它的变化并返回给调用方,在拿到第一个结果以后它们也不会结束。

协程对于处理这些任务是一个绝佳的解决方案。在这篇文章中,咱们将会深刻介绍一次性请求,并探索如何在 Android 中使用协程实现它们。github

一次性请求

一次性请求会调用一次就请求一次,获取到结果后就结束执行。这个模式同调用常规函数很像 —— 调用一次,执行,而后返回。正由于同函数调用类似,因此相对于流式请求它更容易理解。数据库

一次性请求会调用一次就请求一次,获取到结果后就结束执行。编程

举例来讲,您能够把它类比为浏览器加载页面。当您点击了这篇文章的连接后,浏览器向服务器发送了网络请求,而后进行页面加载。一旦页面数据传输到浏览器后,浏览器就有了全部须要的数据,而后中止同后端服务的对话。若是服务器后来又修改了这篇文章的内容,新的更改是不会显示在浏览器中的,除非您主动刷新了浏览器页面。后端

尽管这样的方式缺乏了流式请求那样的实时推送特性,可是它仍是很是有用的。在 Android 的应用中您能够用这种方式解决不少问题,好比对数据的查询、存储或更新,它还很适用于处理列表排序问题。设计模式

问题: 展现一个有序列表

咱们经过一个展现有序列表的例子来探索一下如何构建一次性请求。为了让例子更具体一些,咱们来构建一个用于商店员工使用的库存应用,使用它可以根据上次进货的时间来查找相应商品,并可以以升序和降序的方式排列。由于这个仓库中存储的商品不少,因此对它们进行排序要花费将近 1 秒钟,所以咱们须要使用协程来避免阻塞主线程。浏览器

在应用中,全部的数据都会存储到 Room 数据库中。因为不涉及到网络请求,所以咱们不须要进行网络请求,从而专一于一次性请求这样的编程模式。因为无需进行网络请求,这个例子会很简单,尽管如此它仍然展现了该使用怎样的模式来实现一次性请求。安全

为了使用协程来实现此需求,您须要在协程中引入 ViewModel、Repository 和 Dao。让咱们逐个进行介绍,看看如何把它们同协程整合在一块儿。

class ProductsViewModel(val productsRepository: ProductsRepository): ViewModel() {
   private val _sortedProducts = MutableLiveData<List<ProductListing>>()
   val sortedProducts: LiveData<List<ProductListing>> = _sortedProducts
 
   /**
    * 当用户点击相应排序按钮后,UI 进行调用
    */
   fun onSortAscending() = sortPricesBy(ascending = true)
   fun onSortDescending() = sortPricesBy(ascending = false)
 
   private fun sortPricesBy(ascending: Boolean) {
       viewModelScope.launch {
      // suspend 和 resume 使得这个数据库请求是主线程安全的,因此 ViewModel 不须要关心线程安全问题
           _sortedProducts.value =
                   productsRepository.loadSortedProducts(ascending)
       }
   }
}

ProductsViewModel 负责从 UI 层接受事件,而后向 repository 请求更新的数据。它使用 LiveData 来存储当前排序的列表数据,以供 UI 进行展现。当出现某个新事件时,sortProductsBy 会启动一个新的协程对列表进行排序,当排序完成后更新 LiveData。在这种架构下,一般都是使用 ViewModel 启动协程,由于这样作的话能够在 onCleared 中取消所启动的协程。当用户离开此界面后,这些任务就不必继续进行了。

\*若是您以前没有用过 LiveData,您能够看看这篇由 @CeruleanOtter 写的文章,它介绍了 LiveData 是如何为 UI 保存数据的 —— ViewModels: A Simple Example。

这是在 Android 上使用协程的通用模式。因为 Android framework 不会主动调用挂起函数,因此您须要配合使用协程来响应 UI 事件。最简单的方法就是来一个事件就启动一个新的协程,最适合处理这种状况的地方就是 ViewModel 了。

在 ViewModel 中启动协程是很通用的模式。

ViewModel 实际上使用了 ProductsRepository 来获取数据,示例代码以下:

class ProductsRepository(val productsDao: ProductsDao) {

  /**
       这是一个普通的挂起函数,也就是说调用方必须在一个协程中。repository 并不负责启动或者中止协程,由于它并不负责对协程生命周期的掌控。
       这可能会在 Dispatchers.Main 中调用,一样它也是主线程安全的,由于 Room 会为咱们保证主线程安全。
    */
   suspend fun loadSortedProducts(ascending: Boolean): List<ProductListing> {
       return if (ascending) {
           productsDao.loadProductsByDateStockedAscending()
       } else {
           productsDao.loadProductsByDateStockedDescending()
       }
   }
}

ProductsRepository 提供了一个合理的同商品数据进行交互的接口,此应用中,全部内容都存储在本地 Room 数据库中,它为 @Dao 提供了针对不一样排序具备不一样功能的两个接口。

repository 是 Android 架构组件中的一个可选部分,若是您在应用中已经集成了它或者其余的类似功能的模块,那么它应该更偏向于使用挂起函数。由于 repository 并无生命周期,它仅仅是一个对象,因此它不能处理资源的清理工做,因此默认状况下,repository 中启动的全部协程都有可能出现泄漏。

使用挂起函数除了避免泄漏以外,在不一样的上下文中也能够重复使用 repository,任何知道如何建立协程的均可以调用 loadSortedProducts,例如 WorkManager 所调度管理的后台任务就能够直接调用它。

repository 应该使用挂起函数来保证主线程安全。

注意: 当用户离开界面后,有些在后台中处理数据保存的操做可能还要继续工做,这种状况下脱离了应用生命周期来运行是没有意义的,因此大部分状况下 viewModelScope 都是一个好的选择。

再来看看 ProductsDao,示例代码以下:

@Dao
interface ProductsDao {

   // 由于这个方法被标记为了 suspend,Room 将会在保证主线程安全的前提下使用本身的调度器来运行这个查询
   @Query("select * from ProductListing ORDER BY dateStocked ASC")
   suspend fun loadProductsByDateStockedAscending(): List<ProductListing>
   // 由于这个方法被标记为了 suspend,Room 将会在保证主线程安全的前提下使用本身的调度器来运行这个查询
   @Query("select * from ProductListing ORDER BY dateStocked DESC")
   suspend fun loadProductsByDateStockedDescending(): List<ProductListing>
}

ProductsDao 是一个 Room @Dao,它对外提供了两个挂起函数,由于这些函数都增长了 suspend 修饰,因此 Room 会保证它们是主线程安全的,这也意味着您能够直接在 Dispatchers.Main 中调用它们。

\*若是您没有在 Room 中使用过协程,您能够先看看这篇由 @FMuntenescu 写的文章: Room 🔗 Coroutines

不过要注意的是,调用它的协程将会在主线程上执行。因此,若是您要对执行结果作一些比较耗时的操做,好比对列表内容进行转换,您要确保这个操做不会阻塞主线程。

注意: Room 使用了本身的调度器在后台线程上进行查询操做。您不该该再使用 withContext(Dispatchers.IO) 来调用 Room 的 suspend 查询,这只会让您的代码变复杂,也会拖慢查询速度。

Room 的挂起函数是主线程安全的,并运行于自定义的调度器中。

一次性请求模式

这是在 Android 架构组件中使用协程进行一次性请求的完整模式,咱们将协程添加到了 ViewModel、Repository 和 Room 中,每一层都有着不一样的责任分工。

  1. ViewModel 在主线程上启动了协程,一旦有结果后就结束执行;
  2. Repository 提供了保证主线程安全的挂起函数;
  3. 数据库和网络层提供了保证主线程安全的挂起函数。

ViewModel 负责启动协程,并保证用户离开了相应界面时它们就会被取消。它自己并不会作一些耗时的操做,而是依赖别的层级来作。一旦有告终果,就使用 LiveData 将数据发送到 UI 层。由于 ViewModel 并不作一些耗时操做,因此它是在主线程启动协程的,以便可以更快地响应用户事件。

Repository 提供了挂起函数用来访问数据,它一般不会启动一些生命周期比较长的协程,由于它们一旦启动了便没法取消。不管什么时候 Repository 想要作一些耗时操做,好比对列表内容进行转换,都应该使用 withContext 来提供主线程安全的接口。

数据层 (网络或数据库) 老是会提供挂起函数,使用 Kotlin 协程的时候要保证这些挂起函数是主线程安全的,Room 和 Retrofit 都遵循了这一点。

在一次性请求中,数据层只提供挂起函数,调用方若是想要获取最新的值,只能再次进行调用,这就像浏览器中的刷新按钮同样。

花点时间让您了解一次性请求的模式是值得,它在 Android 协程中是比较通用的模式,您会一直用到它。

第一个 bug 出现了

在通过测试后,您部署到了生产环境,运行了几周都感受良好,直到您收到了一个很奇怪的 bug 报告:

标题: 🐞 — 排序错误!

错误报告: 当我很是快速地点击排序按钮时,排序的结果偶尔是错的,这还不是每次都能复现的🙃。

您研究了一下,不由问本身哪里出错了?这个逻辑很简单:

  1. 开始执行用户请求的排序操做;
  2. 在 Room 调度器中开始进行排序;
  3. 展现排序结果。

您以为这个 bug 不存在准备关闭它,由于解决方案很简单,"不要那么快地点击按钮",可是您仍是很担忧,以为仍是哪一个地方出了问题。因而在代码中加入一些日志,并跑了一堆测试用例后,您终于知道问题出在什么地方了!

看起来应用内展现的排序结果并非真正的 "排序结果",而是上一次完成排序的结果。当用户快速点击按钮时,就会同时触发多个排序操做,这些操做可能以任意顺序结束。

当启动一个新的协程来响应 UI 事件时,要去考虑一下用户若在上一个任务未完成以前又开始了新的任务,会有什么样的后果。

这实际上是一个并发致使的问题,它和是否使用了协程其实没有什么关系。若是您使用回调、Rx 或者是 ExecutorService,仍是可能会遇到这样的 bug。

有很是多方案可以解决这个问题,既能够在 ViewModel 中解决,又能够在 Repository 中解决。咱们来看看怎么才能让一次性请求按照咱们所指望的顺序返回结果。

最佳解决方案: 禁用按钮

核心问题出在咱们作了两次排序,要修复的话咱们能够只让它排序一次。最简单的解决方法就是禁用按钮,不让它发出新的事件就能够了。

这看起来很简单,并且确实是个好办法。实现起来的代码也很简单,还容易测试,只要它能在 UI 中体现出来这个按钮的状态,就彻底能够解决问题。

要禁用按钮,只须要告诉 UI 在 sortPricesBy 中是否有正在处理的排序请求,示例代码以下:

// 方案 0: 当有任何排序正在执行时,禁用排序按钮

class ProductsViewModel(val productsRepository: ProductsRepository): ViewModel() {
   private val _sortedProducts = MutableLiveData<List<ProductListing>>()
   val sortedProducts: LiveData<List<ProductListing>> = _sortedProducts

   private val _sortButtonsEnabled = MutableLiveData<Boolean>()
   val sortButtonsEnabled: LiveData<Boolean> = _sortButtonsEnabled

   init {
       _sortButtonsEnabled.value = true
   }

   /**
       当用户点击排序按钮时,调用
    */
   fun onSortAscending() = sortPricesBy(ascending = true)
   fun onSortDescending() = sortPricesBy(ascending = false)

   private fun sortPricesBy(ascending: Boolean) {
       viewModelScope.launch {
          // 只要有排序在进行,禁用按钮
           _sortButtonsEnabled.value = false
           try {
               _sortedProducts.value =
                       productsRepository.loadSortedProducts(ascending)
           } finally {
              // 排序结束后,启用按钮
               _sortButtonsEnabled.value = true
           }
       }
   }
}

使用 sortPricesBy 中的 _sortButtonsEnabled 在排序时禁用按钮

好了,这看起来还行,只须要在调用 repository 时在 sortPricesBy 内部禁用按钮就行了。

大部分状况下,这都是最佳解决方案,可是若是咱们想在保持按钮可用的前提下解决 bug 呢?这样的话有一点困难,在本文剩余的部分看看该怎么作。

注意: 这段代码展现了从主线程启动的巨大优点,点击以后按钮马上变得不可点了。但若是您换用了其余的调度程序,当出现某个手速很快的用户在运行速度较慢的手机上操做时,仍是可能出现发送屡次点击事件的状况。

并发模式

下面几个章节咱们探讨一些比较高级的话题,若是您才刚刚接触协程,能够不去理解这一部分,使用禁用按钮这一方案就是解决大部分相似问题的最佳方案。

在剩余部分咱们将探索在不由用按钮的前提下,确保一次性请求可以正常运行。咱们能够经过控制什么时候让协程运行 (或者不运行) 来避免刚刚出现的并发问题。

有三个基本的模式可让咱们确保在同一时间只会有一次请求进行:

  1. 在启动更多协程以前取消以前的任务
  2. 下一个任务排队等待前一个任务执行完成;
  3. 若是有一个任务正在执行,返回该任务,而不是启动一个新的任务。

当介绍完这三个方案后,您可能会发现它们的实现都挺复杂的。为了专一于设计模式而不是实现细节,我建立了一个 gist 来提供这三个模式的实现做为可重用抽象 。

方案 1: 取消以前的任务

在排序这种状况下,获取新的事件后就意味着能够取消上一个排序任务了。毕竟用户经过这样的行为已经代表了他们不想要上次的排序结果了,继续进行上一次排序操做没什么意义了。

要取消上一个请求,咱们首先要以某种方式追踪它。在 gist 中的 cancelPreviousThenRun 函数就作到了这个。

来看看如何使用它修复这个 bug:

// 方案 1: 取消以前的任务
 
// 对于排序和过滤的状况,新请求进来,取消上一个,这样的方案是很适合的。

class ProductsRepository(val productsDao: ProductsDao, val productsApi: ProductsService) {
   var controlledRunner = ControlledRunner<List<ProductListing>>()

   suspend fun loadSortedProducts(ascending: Boolean): List<ProductListing> {
      // 在开启新的排序以前,先取消上一个排序任务
       return controlledRunner.cancelPreviousThenRun {
           if (ascending) {
               productsDao.loadProductsByDateStockedAscending()
           } else {
               productsDao.loadProductsByDateStockedDescending()
           }
       }
   }
}

使用 cancelPreviousThenRun 来确保同一时间只有一个排序任务在进行

看一下 gist 中 cancelPreviousThenRun 中的 代码实现,您能够学习到如何追踪正在工做的任务。

// see the complete implementation at
// 在 https://gist.github.com/objcode/7ab4e7b1df8acd88696cb0ccecad16f7 中查看完整实现
suspend fun cancelPreviousThenRun(block: suspend () -> T): T {
   // 若是这是一个 activeTask,取消它,由于它的结果已经不须要了
   activeTask?.cancelAndJoin()

   // ...

简而言之,它会经过成员变量 activeTask 来保持对当前排序的追踪。不管什么时候开始一个新的排序,都当即对当前 activeTask 中的全部任务执行 cancelAndJoin 操做。这样会在开启一次新的排序以前就会把正在进行中的排序任务给取消掉。

使用相似于 ControlledRunner<T> 这样的抽象实现来对逻辑进行封装是比较好的方法,比直接混杂并发与应用逻辑要好不少。

选择使用抽象来封装代码逻辑,避免混杂并发和应用逻辑代码。

注意: 这个模式不适合在全局单例中使用,由于不相关的调用方是不该该相互取消。

方案 2: 让下一个任务排队等待

这里有一个对并发问题老是有效的解决方案。

让任务去排队等待依次执行,这样同一时间就只会有一个任务会被处理。就像在商场里进行排队,请求将会按照它们排队的顺序来依次处理。

对于这种特定的排序问题,其实选择方案 1 比使用本方案要更好一些,但仍是值得介绍一下这个方法,由于它老是可以有效的解决并发问题。

// 方案 2: 使用互斥锁
// 注意: 这个方法对于排序或者是过滤来讲并非一个很好的解决方案,可是它对于解决网络请求引发的并发问题很是适合。

class ProductsRepository(val productsDao: ProductsDao, val productsApi: ProductsService) {
   val singleRunner = SingleRunner()

   suspend fun loadSortedProducts(ascending: Boolean): List<ProductListing> {
      // 开始新的任务以前,等待以前的排序任务完成
       return singleRunner.afterPrevious {
           if (ascending) {
               productsDao.loadProductsByDateStockedAscending()
           } else {
               productsDao.loadProductsByDateStockedDescending()
           }
       }
   }
}

不管什么时候进行一次新的排序, 都使用一个 SingleRunner 实例来确保同时只会有一个排序任务在进行。

它使用了 Mutex,能够把它理解为一张单程票 (或是锁),协程在必需要获取锁才能进入代码块。若是一个协程在运行时,另外一个协程尝试进入该代码块就必须挂起本身,直到全部的持有 Mutex 的协程完成任务,并释放 Mutex 后才能进入。

Mutex 保证同时只会有一个协程运行,而且会按照启动的顺序依次结束。

方案 3: 复用前一个任务

第三种能够考虑的方案是复用前一个任务,也就是说新的请求能够重复使用以前存在的任务,好比前一个任务已经完成了一半进来了一个新的请求,那么这个请求直接重用这个已经完成了一半的任务,就省事不少。

但其实这种方法对于排序来讲并无多大意义,可是若是是一个网络数据请求的话,就很适用了。

对于咱们的库存应用来讲,用户须要一种方式来从服务器获取最新的商品库存数据。咱们提供了一个刷新按钮这样的简单操做来让用户点击一次就能够发起一次新的网络请求。

当请求正在进行时,禁用按钮就能够简单地解决问题。可是若是咱们不想这样,或者说不能这样,咱们就能够选择这种方法复用已经存在的请求。

查看下面的来自 gist 的使用了 joinPreviousOrRun 的示例代码:

class ProductsRepository(val productsDao: ProductsDao, val productsApi: ProductsService) {
   var controlledRunner = ControlledRunner<List<ProductListing>>()

   suspend fun fetchProductsFromBackend(): List<ProductListing> {
      // 若是已经有一个正在运行的请求,那么就返回它。若是没有的话,开启一个新的请求。
       return controlledRunner.joinPreviousOrRun {
           val result = productsApi.getProducts()
           productsDao.insertAll(result)
           result
       }
   }
}

上面的代码行为同 cancelPreviousAndRun 相反,它会直接使用以前的请求而放弃新的请求,而 cancelPreviousAndRun 则会放弃以前的请求而建立一个新的请求。若是已经存在了正在运行的请求,它会等待这个请求执行完成,并将结果直接返回。只有不存在正在运行的请求时才会建立新的请求来执行代码块。

您能够在 joinPreviousOrRun 开始时看到它是如何工做的,若是 activeTask 中存在任何正在工做的任务,就直接返回它。

// 在 https://gist.github.com/objcode/7ab4e7b1df8acd88696cb0ccecad16f7#file-concurrencyhelpers-kt-L124 中查看完整实现

suspend fun joinPreviousOrRun(block: suspend () -> T): T {
   // 若是存在 activeTask,直接返回它的结果,并不会执行代码块
    activeTask?.let {
        return it.await()
    }
    // ...

这个模式很适合那种经过 id 来查询商品数据的请求。您可使用 map 来创建 id 到 Deferred 的映射关系,而后使用相同的逻辑来追踪同一个产品以前的请求数据。

直接复用以前的任务能够有效避免重复的网络请求。

下一步

在这篇文章中,咱们探讨了如何使用 Kotlin 协程来实现一次性请求。咱们实现了如何在 ViewModel 中启动协程,而后在 Repository 和 Room Dao 中提供公开的 suspend function,这样造成了一个完整的编程范式。

对于大部分任务来讲,在 Android 上使用 Kotlin 协程按照上面这些方法就已经足够了。这些方法就像上面所说的排序同样能够应用在不少场景中,您也可使用这些方法来解决查询、保存、更新网络数据等问题。

而后咱们探讨了一下可能出现 bug 的地方,并给出了解决方案。最简单 (每每也是最好的) 的方案就是从 UI 上直接更改,排序运行时直接禁用按钮。

最后,咱们探讨了一些高级并发模式,并介绍了如何在 Kotlin 协程中实现它们。虽然 这些代码 有点复杂,可是为一些高级协程方面的话题作了很好的介绍。

在下一篇文章中,咱们将会研究一下流式请求,并探索如何使用 liveData 构造器,感兴趣的读者请继续关注咱们的更新。

相关文章
相关标签/搜索