关于kotlin:在-Android-开发中使用协程-代码实战

4次阅读

共计 10169 个字符,预计需要花费 26 分钟才能阅读完成。

本文是介绍 Android 协程系列中的第三局部,这篇文章通过发送一次性申请来介绍如何应用协程解决在理论编码过程中遇到的问题。在浏览本文之前,建议您先浏览本系列的前两篇文章,对于在 Android 开发中应用协程的 背景介绍 和 上手指南。

应用协程解决理论编码问题

前两篇文章次要是介绍了如何应用协程来简化代码,在 Android 上保障主线程平安,防止工作透露。以此为背景,咱们认为应用协程是在解决后台任务和简化 Android 回调代码的绝佳计划。

目前为止,咱们次要集中在介绍协程是什么,以及如何治理它们,本文咱们将介绍如何应用协程来实现一些理论工作。协程同函数一样,是在编程语言个性中的一个罕用个性,您能够应用它来实现任何能够通过函数和对象能实现的性能。然而,在理论编程中,始终存在两种类型的工作非常适合应用协程来解决:

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

协程对于解决这些工作是一个绝佳的解决方案。在这篇文章中,咱们将会深刻介绍一次性申请,并摸索如何在 Android 中应用协程实现它们。

一次性申请

一次性申请会调用一次就申请一次,获取到后果后就完结执行。这个模式同调用惯例函数很像 —— 调用一次,执行,而后返回。正因为同函数调用类似,所以绝对于流式申请它更容易了解。

一次性申请会调用一次就申请一次,获取到后果后就完结执行。

举例来说,您能够把它类比为浏览器加载页面。当您点击了这篇文章的链接后,浏览器向服务器发送了网络申请,而后进行页面加载。一旦页面数据传输到浏览器后,浏览器就有了所有须要的数据,而后进行同后端服务的对话。如果服务器起初又批改了这篇文章的内容,新的更改是不会显示在浏览器中的,除非您被动刷新了浏览器页面。

只管这样的形式短少了流式申请那样的实时推送个性,然而它还是十分有用的。在 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 结构器,感兴趣的读者请持续关注咱们的更新。

正文完
 0