本系列文章
Android 上的协程(第一部分):了解背景
Android 上的协程(第二部分):入门
Android上的协程 (第三部分): 实际应用
Android上的协程 (第三部分): 实际应用
这篇文章的重点是使用协程实现一次性请求来解决实际问题。
用协程解决现实世界的问题
本系列的第 1 部分和第 2 部分重点介绍了如何使用协程来简化代码、在 Android 上提供主线程安全以及避免任务泄漏。有了这样的背景,它们看起来像是一个很好的后台处理解决方案,也是一种简化 Android 上基于回调的代码的方法。
到目前为止,我们一直关注协程是什么以及如何管理它们。在这篇文章中,我们将看看如何使用它们来完成一些实际任务。协程是一种与函数处于同一级别的通用编程语言特性——因此您可以使用它们来实现您可以使用函数和对象实现的任何功能。然而,有两种类型的任务在实际代码中一直出现,协程是一个很好的解决方案:
- 一次性请求是每次调用时运行的请求——它们总是在结果准备好后完成。
- 流式请求是继续观察更改并将它们报告给调用者的请求——它们不会在第一个结果准备就绪时完成。
协程是这两项任务的绝佳解决方案。在这篇文章中,我们将深入研究一次性请求并探索如何在 Android 上使用协程来实现它们。
一次性请求
每次调用时都会执行一次一次性请求,并在结果准备好后立即返回。这种模式与常规函数调用相同——它被调用,做一些工作,然后返回。由于与函数调用的相似性,它们往往比流式请求更容易理解。
每次调用时都会执行一次请求。一旦结果准备就绪,它就会停止执行。
对于一次性请求的示例,请考虑您的浏览器如何加载此页面。当您单击这篇文章的链接时,您的浏览器会向服务器发送网络请求以加载该页面。一旦页面被传输到你的浏览器,它就停止与后端对话——它拥有它需要的所有数据。如果服务器修改了帖子,新的更改将不会显示在您的浏览器中——您必须刷新页面。
因此,虽然他们缺乏流媒体请求的实时推送,但一次性请求非常强大。您可以在 Android 应用程序中做很多事情,这些事情可以通过一次性请求解决,例如获取、存储或更新数据。对于排序列表之类的事情,这也是一个很好的模式。
问题:显示排序列表
让我们通过查看如何显示排序列表来探索一次性请求。为了使示例具体化,让我们构建一个供商店员工使用的库存应用程序。它将用于根据上次进货时间查找产品——他们希望能够对列表进行升序和降序排序。它有太多的产品,排序可能需要将近一秒钟——所以我们将使用协程来避免阻塞主线程!
在此应用程序中,所有产品都存储在 Room 数据库中。这是一个很好的研究用例,因为它不需要涉及网络请求,所以我们可以专注于模式。尽管该示例由于不使用网络而更简单,但它公开了实现一次性请求所需的模式。
要使用协程实现此请求,您将向ViewModel
、Repository
和引入数据库Dao
。让我们一次过一遍,看看如何将它们与协程集成。
class ProductsViewModel(val productsRepository: ProductsRepository): ViewModel() {
private val _sortedProducts = MutableLiveData<List<ProductListing>>()
val sortedProducts: LiveData<List<ProductListing>> = _sortedProducts
/**
* Called by the UI when the user clicks the appropriate sort button
*/
fun onSortAscending() = sortPricesBy(ascending = true)
fun onSortDescending() = sortPricesBy(ascending = false)
private fun sortPricesBy(ascending: Boolean) {
viewModelScope.launch {
// suspend and resume make this database request main-safe
// so our ViewModel doesn't need to worry about threading
_sortedProducts.value =
productsRepository.loadSortedProducts(ascending)
}
}
}
ProductsViewModel
负责从 UI 层接收事件,然后向存储库请求更新的数据。它用于LiveData
保存当前排序的列表以供 UI 显示。当一个新事件进来时,sortProductsBy
启动一个新的协程来对列表进行排序,LiveData
在结果准备好时更新。通常是ViewModel
在该架构中启动大多数协程的正确位置,因为它可以取消onCleared
, 如果用户离开屏幕,他们通常就无法完成出色的工作。
如果您没有经常使用 LiveData
,请查看@CeruleanOtter的这篇精彩文章,介绍它们如何为 UI 存储数据。
https://medium.com/androiddevelopers/viewmodels-a-simple-example-ed5ac416317e
这是 Android 上协程的通用模式。由于 Android 框架不调用挂起函数,因此您需要与协程协调以响应 UI 事件。最简单的方法是在事件到来时启动一个新协程——最自然的做法是在ViewModel
。
作为一般模式,在 ViewModel 中启动协程。
使用ViewModel
从ProductsRepository
中获取实际获取数据。示例代码如下:
class ProductsRepository(val productsDao: ProductsDao) {
/**
* This is a "regular" suspending function, which means the caller must
* be in a coroutine. The repository is not responsible for starting or
* stopping coroutines since it doesn't have a natural lifecycle to cancel
* unnecessary work.
*
* This *may* be called from Dispatchers.Main and is main-safe because
* Room will take care of main-safety for us.
*/
suspend fun loadSortedProducts(ascending: Boolean): List<ProductListing> {
return if (ascending) {
productsDao.loadProductsByDateStockedAscending()
} else {
productsDao.loadProductsByDateStockedDescending()
}
}
}
ProductsRepository
为与产品交互提供合理的接口。在此应用程序中,由于所有内容都在本地 Room 数据库中,因此它只是为具有针对@Dao
不同排序顺序的两个不同函数的界面提供了一个很好的界面。
Repository是 AAC(Android Architecture Components) 架构的可选部分——但如果您的应用程序中确实有它或类似的层,它应该更愿意公开常规的挂起功能。由于存储库没有自然的生命周期——它只是一个对象——它无法清理工作。因此,在存储库中启动的任何协程默认情况下都会泄漏。
除了避免泄漏之外,通过公开常规的挂起函数,可以轻松地在不同的上下文中重用存储库。协程启动的地方都可以调用loadSortedProducts
。 例如,由 WorkManager
库调度的后台作业可以直接调用它。
Repository应该更愿意公开主线程安全的常规挂起函数。
注意:一些后台保存操作可能希望在用户离开屏幕后继续——让这些保存在没有生命周期的情况下运行是有意义的。在大多数其他情况下,
viewModelScope
是一个合理的选择。
继续看ProductsDao
,示例代码如下:
@Dao
interface ProductsDao {
// Because this is marked suspend, Room will use it's own dispatcher
// to run this query in a main-safe way.
@Query("select * from ProductListing ORDER BY dateStocked ASC")
suspend fun loadProductsByDateStockedAscending(): List<ProductListing>
// Because this is marked suspend, Room will use it's own dispatcher
// to run this query in a main-safe way.
@Query("select * from ProductListing ORDER BY dateStocked DESC")
suspend fun loadProductsByDateStockedDescending(): List<ProductListing>
}
ProductsDao
是一个Room的@Dao暴露了两个挂起函数。因为函数被标记为suspend
,Room确保它们是主线程安全的。这意味着您可以直接从Dispatchers.Main调用他们。
下面是一篇Room中协程使用相关的博客
https://medium.com/androiddevelopers/room-coroutines-422b786dc4c5
不过有一点警告,调用它的协程将在主线程上。因此,如果您对结果做了一些耗时的工作——比如将它们转换为一个新列表——您应该确保您没有阻塞主线程。
Note: Room 使用自己的调度程序在后台线程上运行查询。你的代码不应该使用withContext(Dispatchers.IO)调用暂停Room查询。它会使代码复杂化并使您的查询运行速度变慢。
Room 中的挂起函数是主线程安全的,并在自定义调度程序上运行。
一次性请求模式
这是在 Android 架构组件中使用协程发出一次性请求的完整模式。我们在ViewModel
、Repository
中添加了带有协程的Room,每一层都有不同的职责。
ViewModel
在主线程上启动协程——它在有结果时完成。- 存储库公开常规挂起函数并确保它们是主线程安全的。
- 数据库和网络公开常规挂起函数并确保它们是主线程安全的。
负责ViewModel 启动协程并确保在用户离开屏幕时它们被取消。它不做耗时的工作——而是依靠其他层来完成繁重的工作。一旦它有了结果,它就会使用LiveData
将它发送到UI层。
由于ViewModel
不做繁重的工作,它在主线程上启动协程。通过在 main 上启动,如果结果立即可用(例如,来自内存中的缓存),它可以更快地响应用户事件。
公开Repository 常规挂起函数以访问数据。它通常不会启动它自己的长寿命协程,因为它没有任何方法可以取消它们。每当Repository必须做一些耗时的事情时,比如转换一个列表,它应该用withContext
来暴露一个主线程安全的接口。
数据层 (网络或数据库)总是公开常规的挂起函数。使用 Kotlin 协程时,这些挂起函数是主线程安全的很重要,Room 和 Retrofit 都遵循这种模式。
在一次性请求中,数据层仅公开挂起函数。如果调用者想要一个新值,则必须再次调用它们。这就像网络浏览器上的刷新按钮。
花点时间确保您了解一次性请求的这些模式是值得的。这是 Android 上协程的正常模式,您将一直使用它。
我们的第一个错误报告!
测试该解决方案后,将其投入生产,几周内一切正常,直到收到一个非常奇怪的错误报告:
主题: 🐞 — 排序顺序错误!
报告:当我非常非常非常非常快地单击排序顺序按钮时,有时排序是错误的。这不会一直发生🙃。
你看一看,挠了挠头。可能会出什么问题?
该算法看起来相当简单:
- 启动用户请求的排序。
- 在 Room 调度程序中运行排序。
- 显示排序结果。
你很想关闭这个错误,“不会修复——不要按按钮那么快”,但你担心可能有什么东西被破坏了。在添加了日志记录语句并编写了一次调用大量排序的测试之后——您终于弄明白了!
结果显示的结果实际上并不是“排序的结果” ,它实际上是“最后完成的排序”的结果。当用户点击按钮时——他们同时开始多个排序,并且可以按任何顺序完成!
当启动一个新协程以响应 UI 事件时,请考虑如果用户在这个协程完成之前启动另一个协程会发生什么。
这是一个并发错误,它实际上与协程没有任何关系。如果我们使用回调、Rx 或什至ExecutorService
相同的方式,我们会遇到同样的错误。
ViewModel
和Repository
中有很多方法可以解决这个问题。让我们探索一些模式,以确保一次性请求按照用户期望的顺序完成。
最佳解决方案:禁用按钮
根本问题是我们在做两种分类。我们可以通过让它只做一种来解决这个问题!最简单的方法是禁用排序按钮以停止新事件。
这似乎是一个简单的解决方案,但它确实是一个好主意。实现它的代码很简单,易于测试,只要它在 UI 中有意义,它就会完全解决问题!
要禁用按钮,请告诉 UI 内部正在发生排序请求,sortPricesBy
如下所示:
// Solution 0: Disable the sort buttons when any sort is running
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
}
/**
* Called by the UI when the user clicks the appropriate sort button
*/
fun onSortAscending() = sortPricesBy(ascending = true)
fun onSortDescending() = sortPricesBy(ascending = false)
private fun sortPricesBy(ascending: Boolean) {
viewModelScope.launch {
// disable the sort buttons whenever a sort is running
_sortButtonsEnabled.value = false
try {
_sortedProducts.value =
productsRepository.loadSortedProducts(ascending)
} finally {
// re-enable the sort buttons after the sort is complete
_sortButtonsEnabled.value = true
}
}
}
}
使用 sortPricesBy 中的 _sortButtonsEnabled 在排序运行时禁用按钮。
好吧,sortPricesBy
只需禁用排序按钮即可,这个看起来还不错!
在大多数情况下,这是解决此问题的正确方法。但是,如果我们想让按钮保持启用状态并修复错误怎么办?这有点难,我们将在本文的其余部分探索几个不同的选项。
重要提示:此代码显示了在 main 上启动的主要优势——按钮会在单击时立即禁用。如果您切换调度器,速度较慢的手机上的快速用户可能会发送多次点击!
并发模式
接下来的几节将探讨高级主题——如果您刚刚开始使用协程,则不需要立即理解它们。简单地禁用该按钮是解决您将遇到的大多数问题的最佳方法。
对于本文的其余部分,我们将探索使用协程使按钮保持启用状态但确保一次性请求的执行顺序不会让用户感到意外的方法。我们可以通过控制协程何时运行(或不运行)来避免意外并发来做到这一点。
您可以将三种基本模式用于一次性请求,以确保一次只运行一个请求。
- 在开始执行更多任务之前取消之前的任务。
- 插入下一个任务并等待前面的请求完成,然后再开始另一个。
- 如果已经有一个请求正在运行,请加入前一个任务,只需返回那个请求而不是启动另一个请求。
当您浏览这些解决方案时,您会注意到它们的实现有些复杂。为了专注于如何使用这些模式而不是实现细节,我创建了一个要点,将所有三种模式的实现作为可重用的抽象。
https://gist.github.com/objcode/7ab4e7b1df8acd88696cb0ccecad16f7#file-concurrencyhelpers-kt-L19
解决方案#1:取消之前的任务
在排序的情况下,从用户那里得到一个新的事件通常意味着你可以取消最后的排序。毕竟,如果用户已经告诉您他们不想要结果,那么继续有什么意义呢?
要取消之前的请求,我们需要以某种方式跟踪它。要点中的函数cancelPreviousThenRun
正是这样做的。
让我们看一下如何使用它来修复错误:
// Solution #1: Cancel previous work
// This is a great solution for tasks like sorting and filtering that
// can be cancelled if a new request comes in.
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()
}
}
}
}
使用
cancelPreviousThenRun
确保一次只运行一种排序。
查看要点中的示例实现cancelPreviousThenRun
是了解如何跟踪正在进行的工作的好方法。
// see the complete implementation at
// https://gist.github.com/objcode/7ab4e7b1df8acd88696cb0ccecad16f7
suspend fun cancelPreviousThenRun(block: suspend () -> T): T {
// If there is an activeTask, cancel it because it's result is no longer needed
activeTask?.cancelAndJoin()
// ...
简而言之,它始终跟踪成员变量中的当前活动排序activeTask
。每当排序开始时,它会立即cancelAndJoin
对activeTask. 这具有在开始新排序之前取消任何正在进行的排序的效果。
最好使用类似于的抽象ControlledRunner<T>
来封装这样的逻辑,而不是将临时并发与应用程序逻辑混在一起。
考虑构建抽象以避免将临时并发模式与应用程序代码混在一起。
重要提示:此模式不太适合在全局单例中使用,因为不相关的调用者不应相互取消。
解决方案#2:将任务加入队列
有一种始终有效的并发错误解决方案。
只需将请求加入队列,这样一次只能执行一项任务!就像商店里的人排队付款一样,请求将按照它们开始的顺序一个个执行。
对于这个特殊的排序问题,取消可能比排队要好,但值得一提,因为它总是有效。
// Solution #2: Add a Mutex
// Note: This is not optimal for the specific use case of sorting
// or filtering but is a good pattern for network saves.
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
。
Mutex 可让您确保一次只运行一个协程——并且它们将按照它们开始的顺序完成。
解决方案 3:加入前一个任务
第三个要考虑的解决方案是加入前一个任务。将前一个只完成了一半的相同任务重新开始执行是个不错的好主意。
这种模式对排序功能没有多大意义,但它很适合加载网络数据的获取。
对于我们的产品库存应用程序,用户将需要一种从服务器获取新产品库存的方法。作为一个简单的 UI,我们将为他们提供一个刷新按钮,他们可以按下该按钮来启动新的网络请求。
就像排序按钮一样,在请求运行时简单地禁用按钮是一个完整的问题解决方案。但如果我们没有——或不能——这样做,我们可以改为加入现有请求。
让我们看一些使用gist 中的joinPreviousOrRun
的代码,以了解其工作原理的示例:
class ProductsRepository(val productsDao: ProductsDao, val productsApi: ProductsService) {
var controlledRunner = ControlledRunner<List<ProductListing>>()
suspend fun fetchProductsFromBackend(): List<ProductListing> {
// if there's already a request running, return the result from the
// existing request. If not, start a new request by running the block.
return controlledRunner.joinPreviousOrRun {
val result = productsApi.getProducts()
productsDao.insertAll(result)
result
}
}
}
这会反转cancelPreviousAndRun
的行为。它不会通过取消来丢弃先前的请求——它会丢弃新请求并避免运行它。如果已经有一个请求在运行,它会等待当前“运行中”请求的结果并返回该结果,而不是运行一个新请求。只有在还没有运行请求时才会执行该块。
你可以在开始时看到它是如何工作的joinPreviousOrRun
——如果有任何内容,它只返回以前的结果activeTask
:
// see the complete implementation at
// https://gist.github.com/objcode/7ab4e7b1df8acd88696cb0ccecad16f7#file-concurrencyhelpers-kt-L124
suspend fun joinPreviousOrRun(block: suspend () -> T): T {
// if there is an activeTask, return it's result and don't run the block
activeTask?.let {
return it.await()
}
// ...
这种模式适用于像通过id
获取产品这样的请求。您可以添加一个从id
到Deferred
的映射,然后使用相同的join逻辑来跟踪前一个对同一产品的请求。
加入以前的工作是避免重复网络请求的一个很好的解决方案。
下一步是什么?
在这篇文章中,我们探讨了如何使用 Kotlin 协程实现一次性请求。首先,我们实现了一个完整的模式,展示了如何在ViewModel
中启动协程,然后从一个Repository
和 Room
公开常规挂起函数Dao
。
对于大多数任务,这就是您在 Android 上使用 Kotlin 协程所需要做的全部工作。这种模式可以应用于许多常见任务,例如,我们在此处展示的对列表进行排序。您还可以使用它来获取、保存或更新网络上的数据。
然后,我们研究了一个可能出现的细微错误和可能的解决方案。解决此问题的最简单(通常也是最佳的)方法是在 UI 中—只需在排序进行时禁用排序按钮即可。
最后,我们研究了一些高级并发模式以及如何在 Kotlin 协程中实现它们。这方面的代码有点复杂,但它确实很好地介绍了一些高级协程主题。
https://gist.github.com/objcode/7ab4e7b1df8acd88696cb0ccecad16f7#file-concurrencyhelpers-kt-L158
参考链接
https://medium.com/androiddevelopers/coroutines-on-android-part-iii-real-work-2ba8a2ec2f45