这是一篇关于在 Android 上使用协程的系列文章之一。经过实现一次请求来解释使用协程中的实际问题是这篇文章的重点。android
[译] 在 Android 使用协程(part I) - 协程的背景知识git
[译] 在 Android 使用协程(part II) - 入门指南github
本系列的第 1 部分和第 2 部分重点介绍了如何使用协程来简化代码、在 Android 上提供主线程安全调用以及避免协程泄露。有了这个背景,协程看起来是一个既能够用于后台处理,又能够简化 Callback 的很好解决方案。web
到目前为止,咱们主要关注的是「什么是协程」以及「如何管理它们」。在这篇文章中,咱们将看看如何使用它们来完成一些真正的任务。协程是一种通用的编程语言特性,与函数处于同一级别,所以,你可使用它们来实现任何使用函数和对象实现的功能。然而,有两种类型的任务老是出如今实际代码中,协程是一种很好的解决方案:数据库
协程是这两个任务的一个很好的解决方案。在这篇文章中,咱们将深刻研究一次性请求,并探索如何在 Android 上用协程实现它们。编程
每次调用一个一次性请求都会执行一次,并在响应时完成。此模式与常规函数调用相同——被调用,执行一些操做,而后返回。因为与函数调用的类似性,它们每每比流请求更容易理解。后端
每次调用一个一次性请求时都会执行一次。一旦获得响应,就中止执行。浏览器
对于一次性请求的示例,请考虑浏览器如何加载此页面。当你点击到这篇文章的连接时,浏览器向服务器发送了一个网络请求来加载页面。一旦页面被传输到你的浏览器,它就中止与后端通讯——它已经获取到须要的全部数据。若是服务器修改了这篇文章,除非你刷新页面不然新的修改将不会显示在浏览器中。缓存
所以,虽然它们缺少流请求的实时推送功能,但一次性请求仍旧很是强大。在 Android 应用中,有不少事情能够经过一次性请求来解决,好比获取、存储或更新数据。对于排序列表之类的事情,它也是一种很好的模式。安全
让咱们经过查看如何显示排序列表来研究一次性请求。为了让示例更加具体,咱们构建了一个「存货清单」的应用,供商店员工使用。它将用于根据产品最后一次进货的时间查找产品——他们但愿可以对列表进行升序和降序排序。由于有不少产品,排序产品可能须要一秒钟,因此咱们将使用协程来避免阻塞主线程!
在这个应用中,全部的产品都存储在一个 Room 数据库中。这是一个很好的用例,由于它不须要涉及网络请求,因此咱们能够关注模式。尽管这个示例比较简单,由于它不使用网络,可是它展现了实现一次性请求所需的模式。
要使用协程实现这个请求,你将把协程引入到 ViewModel、Repository 和 Dao。让咱们逐个浏览一下,看看如何将它们与协程集成在一块儿。
class ProductsViewModel(val productsRepository: ProductsRepository): ViewModel() {
val _sortedProducts = MutableLiveData<List<ProductListing>>()
val sortedProducts: LiveData<List<ProductListing>> = _sortedProducts
/** * 当用户点击 sort 按钮时调用,由 UI 层调用 */
fun onSortAscending() = sortPricesBy(ascending = true)
fun onSortDescending() = sortPricesBy(ascending = false)
private fun sortPricesBy(ascending: Boolean) {
viewModelScope.launch {
// 挂起和恢复使这个数据库请求确保主线程安全
// 因此咱们的 ViewModel 不须要担忧线程
_sortedProducts.value =
productsRepository.loadSortedProducts(ascending)
}
}
}
复制代码
ProductsViewModel
负责从 UI 层接收事件,而后向存储库请求更新后的数据。它使用 LiveData 保存当前已排序的列表,以便让 UI 显示。当 sortProductsBy
接收一个新事件时,启动一个新的协程来对列表进行排序,并在响应时更新 LiveData。ViewModel 一般是这个体系结构中启动大多数协程的正确位置,由于它能够在 onCleared
中取消协程。若是用户离开界面,它们一般再也不须要工做。
若是你还不常用 LiveData,请查看 @CeruleanOtter发布的这篇很棒的文章,它介绍了如何为 UI 存储数据.
这是 Android 上协程的通常模式。因为 Android 框架不能调用挂起函数,所以你须要配合一个协程来响应 UI 事件。最简单的办法是事件发生时启动一个新的协程,而在 ViewModel 作这件事比较合适。
在 ViewModel 中启动协程做为通常模式。
ViewModel 使用 ProductsRepository
来实际获取数据。来看它是这样作的:
class ProductsRepository(val productsDao: ProductsDao) {
/** * 这是一个"常规"挂起函数,这意味着调用者必须处于一个协程中。存储层不负责启动或 * 中止协程,由于它没有一个合适的生命周期来取消没必要要的工做。 * 这能够是从 Dispatchers.Main 调用的,并且是主线程安全的,由于 Room 将为咱们负责 * 主线程安全。 */
suspend fun loadSortedProducts(ascending: Boolean): List<ProductListing> {
return if (ascending) {
productsDao.loadProductsByDateStockedAscending()
} else {
productsDao.loadProductsByDateStockedDescending()
}
}
}
复制代码
ProductsRepository
为产品交互提供了一个合理的接口。在这个应用程序中,因为全部内容都在本地的 Room 数据库中,因此他只是为 @Dao
提供了一个很好的接口,对于不一样的排序,@Dao
有两个不一样的函数。
存储层是 Android 架构体系中一个可选部分——但若是你的应用有它或相似的层,它应该更愿意暴露常规挂起函数。由于存储层没有一个自然的生命周期——它只是一个对象——它没有办法清理工做。所以,在默认状况下,在存储库中启动的任何协程都会泄露。
除了避免泄露以外,经过暴露常规挂起函数,还能够很容易地在不一样的上下文中复用存储库。任何知道如何创造协程的东西均可以调用 loadSortedProducts
。例如,Workmanager
调度的后台 Job 能够直接调用它。
存储库应该暴露出主线程安全的常规挂起函数。
注意:一些后台保存操做可能会但愿用户离开界面后继续执行——在没有生命周期的状况下运行这些保存是有意义的。在大多数其余状况下,
viewModelScope
是一个合理的选择。
继续看 ProductsDao
,它看起来是这样的:
@Dao
interface ProductsDao {
// 由于这是挂起的,Room 将使用它本身的调度器以主线程安全的方式运行这个查询
@Query("select * from ProductListing ORDER BY dateStocked ASC")
suspend fun loadProductsByDateStockedAscending(): List<ProductListing>
// 由于这是挂起的,Room 将使用它本身的调度器以主线程安全的方式运行这个查询
@Query("select * from ProductListing ORDER BY dateStocked DESC")
suspend fun loadProductsByDateStockedDescending(): List<ProductListing>
}
复制代码
ProductsDao
是一个 Room @Dao
,它公开了两个挂起函数。因为函数被suspend
标记,Room 确保它们是主线程安全的。这意味着你能够直接从 Dispatchers.Main
来调用它们。
若是你尚未在 Room 中看到协程,请查看 @FMuntenescu的这篇很棒的文章
不过有一点注意,调用它的协程将位于主线程上。所以,若是你对结果作了一些花销大的操做(好比将它们转换为一个新列表),你应该确保它没有阻塞主线程。
注意:Room 使用本身的调度器在后台线程上运行查询。你的代码不该该用
withContext(Dispatchers.IO)
来调用 Room 的挂起查询函数。这会使代码变得复杂,使查询运行的更慢。
Room 的挂起函数是主线程安全的,并在自定义调度器上运行。
这是在 Android 架构组件中使用协程发出一次性请求的完整模式。咱们将协程添加到 ViewModel、Repository 和 Room 中,每一层都有不一样的职责。
ViewModel 在主线程上启动一个协程——当它获得响应时,它就完成了。
Repository 暴露常规挂起函数,并确保它们是主线程安全的。
数据库和网络暴露常规挂起函数,并确保它们是主线程安全的。
ViewModel 负责启动协程,并确保在用户离开界面时它们被取消。它不作大开销的工做——而是依靠其余层来完成繁重的工做。一旦有了响应,它就使用 LiveData 将其发送到 UI。因为 ViewModel 不作大开销的工做,因此它在主线程上启动协程。由于运行在主线程上,若是响应当即可用(例如从内存缓存中获取),它能够更快的响应用户事件。
存储库经过暴露挂起函数来外界访问数据。它一般不会本身启动一个长生命周期的协程,由于没有任何办法取消它们。每当存储库必须作一些开销大的事情,好比转换列表时,它应该使用 withContext
来暴露一个主线程安全的接口。
数据层(网络或数据库) 老是暴露常规挂起函数。使用 Kotlin 协程时,这些挂起函数是主线程安全的,这一点很重要,Room 和 Retrofit 都遵循这种模式。
在一次性请求中,数据层只暴露挂起函数。若是调用者想要一个新的数据,则必须再调用它们。这就像 web 浏览器上的刷新按钮同样。
值的花点时间来确保你理解这些一次性请求的模式。这是 Android 上协程的正常模式,你会一直使用它。
在测试了该解决方案以后,你将其投入生产,而且在接下来的几周内一切都很顺利,直到你获得一个很是奇怪的 Bug 报告:
主题:🐞——错误的排序顺序!
报告:当我很是很是很是快地点击排序按钮时,有时排序是错误的。这个问题偶尔才会发生🙃。
你看了看,挠挠头。有什么地方可能出错呢?流程看起来至关简单:
你很想用 "不会修复-不要按按钮那么快"来关闭这个 Bug,可是你担忧可能有哪里不对。在添加打印 Log 并编写了一个测试来同时调用多个排序以后——你终于找到了答案!
最终显示的结果实际上不是"排序的结果",而是"最后一个完成的排序"的结果。"当用户重复点击按钮时,它们会同时启动多个排序任务,而且可能获得任意排序的结果"。
在响应 UI 事件启动一个新的协程时,请考虑若是用户在这个事件完成以前启动了另外一个协程会发生什么。
这是一个并发性 Bug,它实际上与协程没有关系。若是咱们以一样的方式使用回调、Rx,甚至是 ExecutorService
也会有一样的 Bug。
在 ViewModel 和存储库中,有不少办法能够修复这个问题。让咱们研究一些模式,以确保按用户指望的顺序完成一个一次性请求。
问题的根本缘由是咱们同时执行了两次排序。咱们能够经过让它同时只作一次排序来解决这个问题!最简单的办法是在合适的时候禁用排序按钮。
这彷佛是一个简单的解决方案,但它确实是一个好主意。实现这一点的代码很简单,而且易于测试,只要它的 UI 不是无厘头的,就彻底能够解决问题!
要禁用按钮,那么就告诉 UI 排序请求正在 sortPricesBy
中进行,以下所示:
// 0 号解决方案:在运行任何排序时禁用排序按钮
class ProductsViewModel(val productsRepository: ProductsRepository): ViewModel() {
val _sortedProducts = MutableLiveData<List<ProductListing>>()
val sortedProducts: LiveData<List<ProductListing>> = _sortedProducts
val _sortButtonsEnabled = MutableLiveData<Boolean>()
val sortButtonsEnabled: LiveData<Boolean> = _sortButtonsEnabled
init {
_sortButtonsEnabled.value = true
}
/** * 在用户点击合适的排序按钮时,由 UI 层调用 */
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
,排序开始时就会禁用排序按钮。
在大多数状况下,这是解决这个问题的正确方法。可是若是咱们想让按钮保持启用状态并修复 Bug 呢?这有点难,咱们将在接下来的部分探索一些不一样的选项。
重要提示:这段代码显示了在主线程上启动协程的一个主要优点——按钮在点击时马上禁用。若是你切换调度器,在低端手机上进行快速操做的用户能够发送不止一个点击事件!
接下来几节将探索高级主题——若是你刚刚开始使用协程,那么你不须要马上理解它们。简单地禁用按钮时你将遇到的大多数问题的最佳解决方案。
在这篇文章的其他部分,咱们将探讨如何在按钮可用时,但又确保一次性请求的执行顺序不会让用户感到意外的状况下使用协程。咱们能够经过控制协程什么时候运行(或不运行)来避免意外的并发状况。
对于一次性请求,可使用三种基本模式来确保每次只运行一个请求。
当你查看这些解决方案时,你会注意到它们的实现有些复杂。为了关注如何使用这些模式而不是实现细节,我 建立了一个gist,将全部的三个模式的实现都做为可复用的抽象。
在排序时,从用户得到一个新事件一般意味着能够取消最后一个排序。毕竟,若是用户已经告诉你他们不想要前面那个结果,那么继续下去又有什么意义呢?
要取消以前的请求,咱们须要以某种方式跟踪它。函数cancelPreviousThenRun
在 gist中就是这样作的。
让咱们来看看如何用它来修复 Bug:
// 1 号解决方案:取消以前的工做
// 对于排序和过滤这样的任务,这是一个很好的解决方案,若是有新的请求进来,能够取消这些任务
class ProductsRepository(val productsDao: ProductsDao, val productsApi: ProductsService) {
var controlledRunner = ControlledRunner<List<ProductListing>>()
suspend fun loadSortedProducts(ascending: Boolean): List<ProductListing> {
// cancel the previous sorts before starting a new one
return controlledRunner.cancelPreviousThenRun {
if (ascending) {
productsDao.loadProductsByDateStockedAscending()
} else {
productsDao.loadProductsByDateStockedDescending()
}
}
}
}
复制代码
查看 gist 中的 cancelPreviousThenRun
的示例实现是了解如何跟踪正在进行工做的好办法。
// 查看完整的实如今 https://gist.github.com/objcode/7ab4e7b1df8acd88696cb0ccecad16f7
suspend fun cancelPreviousThenRun(block: suspend () -> T): T {
// 若是有 activeTask,取消它,由于不在须要它的结果
activeTask?.cancelAndJoin()
// ...
复制代码
简而言之,它老是跟踪成员变量 activeTask
中当前活动的排序。每当排序开始时,它将当即调用activeTask
的 cancelAndJoin
。这样作的效果是,在开始一个新的排序以前,取消任何正在进行的排序。
使用相似 ControlledRunner<T>
的抽象来封装这样的逻辑是一个好主意,而不是将特别的并发性与应用程序逻辑混合在一块儿。
考虑构建抽象,以免将特别的并发模式与应用程序代码搞混。
重要说明:此模式不适合在全局单例中使用,由于不相关的调用方不该该相互取消。
有一种解决并发 bug 的办法老是有效的。只要把请求排队,一次只能发生一件事!就像商店中的队列同样,请求将按启动的顺序依次执行。
对于这个特殊的排序问题,取消可能比排队更好,可是排队进行仍是值得讨论,由于它也是有用的。
// 2 号解决方案:添加互斥锁
// 注意:这对于排序或过滤的特定用例不是最优的,可是对于网络保存是一种很好的模式
class ProductsRepository(val productsDao: ProductsDao, val productsApi: ProductsService) {
val singleRunner = SingleRunner()
suspend fun loadSortedProducts(ascending: Boolean): List<ProductListing> {
// wait for the previous sort to complete before starting a new one
return singleRunner.afterPrevious {
if (ascending) {
productsDao.loadProductsByDateStockedAscending()
} else {
productsDao.loadProductsByDateStockedDescending()
}
}
}
}
复制代码
每当出现新的排序时,它都会使用SingleRunner
实例来确保一次只运行一个排序。它使用 Mutex
,这是一个单一的票据(或锁),协程必须得到它才能进入代码块。若是在一个协程正在运行时尝试启动另外一个协程,它将挂起本身,直到全部等待的协程都完成了。
Mutex 容许你确保一次只运行一个协程——而且它们会按启动的顺序结束。
第三个解决方案是使用前面的已经进行工做。若是新请求将从新启动已经完成一半的相同工做,这是一个好主意。
这种模式对于 sort 函数没有太大意义,可是对于加载网络数据来讲,它是一种合适的选择。
对于咱们的产品目录应用,用户须要一种办法从服务器来获取一个新的产品目录。做为一个简单的 UI 咱们将为它提供一个刷新按钮,它们能够按这个按钮启动一个新的网络请求。
与排序按钮同样,只要在请求运行时禁用按钮,就能够彻底解决这个问题。可是若是咱们不这样作,或者不能这样作,咱们能够加入现有的请求。
让咱们来看看一些使用 joinPreviousOrRun 的 gist 代码,看看它是如何工做的。
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
的行为。它将丢弃新请求并避免运行它,而不是经过取消它来丢弃之前的请求。若是已经有一个请求在运行,它将等待当前"正在执行"的请求的结果,并返回该结果,而不是运行一个新请求。传入的代码块只有在没有任何正在运行的请求时才会被执行。
你能够在 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 获取产品这样的请求。你能够维护一个id
对应 Deferred
的 Map,而后使用相同的结合逻辑跟踪相同产品的先前的请求。
结合以前的工做是避免重复网络请求的一个很好的解决方案。
在本文中,咱们探讨了如何使用 Kotlin 协程实现一次性请求。首先,咱们实现了一个完整的模式,展现了如何在 ViewModel 中启动协程,而后从存储库和 Room Dao 中暴露常规挂起函数。
对于大多数任务,为了在 Android 上使用 Kotlin 协程,这就是你须要作的所有工做。这种模式能够应用于许多常见的任务,好比咱们这里展现的排序列表。你还可使用它来获取、保存或更新网络上的数据。
而后咱们研究了一个可能出现的小错误和可能会用到的解决方案。修复这个问题最简单(一般也是最好)的办法是在 UI 中——只要在排序过程当中禁用排序按钮。
最后,咱们研究了一些高级并发模式以及如何在 Kotlin 协程中实现它们。这方面的代码有点复杂,但它确实为一些高级协程主题提供了很好的介绍。
在下一篇文章中,咱们将研究流请求,并探索如何使用 LiveData 构建器!