(译) 协程在 Android 上的应用 (第三部分): 实战

原文链接

这是关于在 Android 上使用协程的多部分系列的一部分。
这篇文章的重点是通过实现一次性请求来解决使用协同程序的实际问题。

使用协程解决现实世界的问题

本系列的第一部分和第二部分重点介绍协程如何用于简化代码,在 Android 上提供主要安全性以及避免泄漏工作。有了这样的背景,它们看起来像是一个很好的解决方案,可以同时处理后台处理和简化基于回调的 Android 代码。

到目前为止,我们专注于协程是什么以及如何管理它们。在这篇文章中,我们将看看如何使用它们来完成一些真正的任务。协程是与方法处于同一级别的通用编程语言功能 - 因此你可以使用它们来实现方法和对象的任何功能。但是,在实际代码中始终对于这两种类型的任务,协程是一个很好的解决方案:

  1. 一次性请求是每次调用时运行的请求 - 它们总是在结果准备好后完成。
  2. 流式处理请求是继续观察更改并将其报告给调用者的请求 - 当第一个结果准备好时,它们不会完成。

协程是这两种任务的绝佳解决方案。在这篇文章中,我们将深入研究一次性请求,并探索如何在Android 上使用协程实现它们。

一次性请求

每次调用一次执行一次请求,并在结果准备好后立即完成。这种模式与常规函数调用相同 - 它被调用,做一些工作,然后返回。由于与方法调用的相似性,它们比流式请求更容易理解。

每次调用时都会执行一次性请求。一旦结果准备就会停止执行。

有关一次性请求的示例,请考虑浏览器如何加载此页面。当你点击此帖子的链接时,你的浏览器会向服务器发送网络请求以加载该页面。一旦页面被传输到你的浏览器,它就停止与后端通话 - 它拥有所需的所有数据。如果服务器修改了博客,则新的更改将不会显示在你的浏览器中 - 你必须刷新页面。

因此,虽然他们缺乏流式请求的实时推送,但一次性请求非常强大。你可以在 Android 应用程序中做很多事情,获取,存储或更新数据等都可以通过一次性请求解决。对于列表排序这样的事情来说,它也是一个很好的模式。

问题:展示排序列表

让我们通过查看如何显示排序列表来探索一次性请求。为了使示例具体,让我们构建一个库存应用程序供员工在商店使用。它将用于根据产品上次库存时查找产品 - 他们希望能够对列表进行升序和降序排序。它有这么多产品,排序可能需要几秒钟 - 所以我们将使用协程来避免阻塞主线程!

在这个应用程序中,所有产品都存储在 Room 数据库中。这是一个很好的用例,因为它不需要涉及网络请求,所以我们可以专注于模式。尽管该示例更简单,因为它不使用网络,但它暴露了实现一次性请求所需的模式。

要使用协程实现此请求,你将向 ViewModel ,Repository 和 Dao 引入协程。让我们一次遍历每一个,看看如何将它们与协程集成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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 显示。当 sortProducts 中出现新事件时,会启动一个新的协程来对列表进行排序,并在结果准备好时更新 LiveDataViewModel 通常是启动此体系结构中大多数协程的正确位置,因为它可以在onCleared 中取消协程。如果用户离开屏幕,它们通常不用再卖力工作了。

如果你还没有使用过 LiveData,请查看 @CeruleanOtter发布的这篇精彩文章,了解它们如何为 UI 存储数据。

ViewModels:A Simple Example

这是 Android 上协程的一般模式。由于 Android 框架不调用挂起函数,因此你需要与协程协调以响应 UI 事件。最简单的方法是在事件发生时启动一个新的协程 - 而自然的地方就是在ViewModel 中。

** 作为一般模式,在 ViewModel 中启动协同程序。**

ViewModel 使用 ProductsRepository 来实际获取数据。这是看起来像:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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 提供了一个很好的接口,它有两个不同的排序顺序的功能。

存储库是 Android 架构组件架构的可选部分 - 但如果你在应用程序中拥有它或类似的层,它应该更喜欢暴露常规的挂起功能。由于存储库没有自然生命周期 - 它只是一个对象 - 它无法清理工作。因此,默认情况下,在存储库中启动的任何协程都会泄漏。

除了避免泄漏之外,通过公开常规挂起函数,可以轻松地在不同的上下文中重用存储库。任何知道如何制作协程的东西都可以调用 loadSortedProducts。例如,WorkManager 库调度的后台作业可以直接调用它。

存储库应该更喜欢暴露主线程的常规挂起函数。

注意:一些后台保存操作可能希望在用户离开屏幕后继续 - 并且在没有生命周期的情况下运行这些保存是有意义的。在大多数其他情况下,viewModelScope 是一个合理的选择。

接着看 ProductsDao,它看起来是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
@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 ,展示了两个挂起方法。由于方法标记为挂起,因此会确保它们是主线程安全的。这意味着你可以直接从 Dispatchers.Main 调用它们。

如果你还没有在 Room 里用过协程,请查看 @FMuntenescu 的这篇精彩文章

Room & Coroutines

但是有点警告,调用它的协程将在主线程上。因此,如果你对结果做了一些昂贵的事情 - 比如将它们转换为新的列表 - 你应该确保没有阻塞主线程。

注意:Room 使用自己的调度程序在后台线程上运行查询。你的代码不应使用withContext(Dispatchers.IO)来调用挂起房间查询。它会使代码复杂化并使查询运行得更慢。

Room 中的挂起方法是主线程安全的,可以在自定义调度程序上运行。

一次性的请求模式

这是使用 Android 架构组件中的协程发出一次性请求的完整模式。我们将协程添加到ViewModel,Repository 和 Room,每个层都有不同的责任。

  • ViewModel在主线程上启动协程 - 它在有结果时完成。
  • 存储库公开常规挂起函数并确保它们是主线程的。
  • 数据库和网络公开常规挂起方法并确保它们是主线程安全的。

ViewModel 负责启动协程并确保在用户离开屏幕时取消协同程序。它不会做昂贵的事情 - 而是依靠其他层来完成繁重的工作。一旦有结果,它就会使用 LiveData 将其发送到UI。

由于 ViewModel 不会做繁重的工作,因此它会在主线程上启动协同程序。通过在 main 上启动,如果结果立即可用(例如,从内存缓存中),它可以更快地响应用户事件。

Repository 公开了常规的挂起函数来访问数据。它通常不会启动它自己的长期协同程序,因为它没有任何方法可以取消它们。每当存储库必须执行昂贵的操作(如转换列表)时,它应使用withContext 来公开主线程安全接口。

数据层(网络或数据库)始终公开常规挂起方法。使用 Kotlin 协程时,这些挂起方法是主要线程安全的,并且 Room 和 Retrofit 都遵循这种模式。

在一次性请求中,数据层仅公开挂起方法。如果调用者想要新值,则必须再次调用他们。这就像 Web浏览器上的刷新按钮一样。

值得花些时间确保你了解一次性请求的这些模式。这是 Android 上协程的常态模式,你将一直使用它。

我们的首份bug报告

在测试该解决方案之后,你将其启动到生产环境,一切都进行了好几周,直到你收到一个非常奇怪的错误报告:

对象:🐞 — 错误的排序
报告:当我以非常非常快的速度单击排序顺序按钮,有时排序是错误的。不是每次都发生。

你看了看然后开始挠头。什么可能出错?算法看起来很简单:

  1. 开始用户请求的排序。
  2. 在 Room 调度程序中运行排序。
  3. 显示排序结果。

你很想关闭“wontfix - 不要按这么快按钮”的错误,但是你担心某些事情可能会被打破。添加日志记录语句并编写测试以立即调用多种类型后,你终于弄明白了!

事实证明,显示的结果实际上并不是“排序的结果”,它实际上是“最后一次完成排序”的结果。当用户发送按钮时 - 它们同时启动多种排序并且它们可以完成任何顺序!

在响应UI事件启动新协程时,请考虑如果用户在此事件完成之前启动另一个协程会发生什么。

这是一个并发错误,它与协同程序没有任何关系。如果我们以相同的方式使用回调,Rx 或甚至ExecutorService,我们会遇到同样的错误。

在 ViewModel 和 Repository 中有很多种方法可以解决这个问题。让我们探索一些模式,以确保一个单次请求请求按照用户期望的顺序完成。

最佳解决方案:禁用按钮

问题的根源是我们做了两种。我们可以通过只做一种来解决这个问题!最简单的方法是禁用排序按钮以停止新事件。

这似乎是一个简单的解决方案,但这是一个非常好的主意。实现它的代码很简单,易于测试,只要它在UI中有意义就可以完全解决问题!

要禁用按钮,请告诉 UI在sortPricesBy 中发生排序请求,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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上启动的一个主要优点 - 按钮会在响应单击时立即禁用。如果你切换了调度,慢速手机上的快速手指用户可以发送多次点击!

并发模式

接下来的几节将探讨高级主题 - 如果你刚开始使用协程,则无需立即了解它们。简单地禁用按钮是你遇到的大多数问题的最佳解决方案。

对于本文的其余部分,我们将探索使用协程启用按钮的方法,但确保以不会让用户感到惊讶的顺序执行一次命令请求。我们可以通过控制协程何时运行(或不运行)来避免意外并发。

你可以使用三种基本模式进行一次性请求,以确保一次只运行一个请求。

  1. 在开始更多之前取消之前的工作
  2. 排队下一个工作并等待先前的请求完成,然后再开始另一个工作。
  3. 加入以前的工作,如果已经有一个请求正在运行,则返回该请求而不是启动另一个请求。

当您查看这些解决方案时,您会注意到它们的实现有一些复杂性。为了专注于如何使用这些模式而不是实现细节,我已经创建了一个要点,将所有三种模式的实现作为可重用的抽象。

解决方案#1 取消之前的工作

在排序的情况下,从用户获取新事件通常意味着你可以取消最后一次排序。毕竟,如果用户已经告诉你他们不想要结果,那么继续是什么意思?

要取消之前的请求,我们需要以某种方式跟踪它。要点中的方法 cancelPreviousThenRun 就是这样做的。

让我们看看如何使用它来修复错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 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 确保一次只运行一种排序。

在gist中查看cancelPreviousThenRun的示例实现是了解如何跟踪正在进行的工作的好方法。

1
2
3
4
5
6
7
// 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 中当前活动的排序。每当排序开始时,它将立即取消对当前处于 activeTask 中的任何内容的取消。这具有在开始新的排序之前取消任何正在进行的排序的效果。

使用类似于 ControlledRunner的抽象来封装这样的逻辑,而不是将ad-hoc并发与应用程序逻辑混合,这是一个好主意。

考虑构建抽象以避免将ad-hoc并发模式与应用程序代码混合。

重要提示:此模式不适合在全局单例中使用,因为不相关的调用者不应互相取消。

解决方案#2: 将下一个工作加入队列

有一种解决方案可以解决并发错误。

只是排队请求,所以一次只能发生一件事!就像商店中的队列或行一样,请求将按照它们开始的顺序一次执行一个。

对于这个特殊的排序问题,取消可能比排队更好,但值得讨论,因为它总是有效。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/ 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:join 之前的工作

要考虑的第三个解决方案是加入以前的工作。如果新请求将重新启动已经完成一半的完全相同的工作,这是一个好主意。

这种模式对sort函数没有多大意义,但它很适合加载数据的网络提取。

对于我们的产品库存应用,用户需要一种从服务器获取新产品库存的方法。作为一个简单的用户界面,我们将为他们提供一个刷新按钮,他们可以按下这个按钮来启动新的网络请求。

就像排序按钮一样,只需在请求运行时禁用按钮就可以完全解决问题。但如果我们没有 - 或者不能 - 这样做,我们可以加入现有的请求。

让我们看一下使用gist中的joinPreviousOrRun的一些代码,看看它是如何工作的一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
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中有任何内容,它只返回先前的结果:

1
2
3
4
5
6
7
8
9
/ 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 协程中实现它们。这个代码有点复杂,但它确实提供了一些高级协程主题的良好介绍。

在下一篇文章中,我们将介绍流式请求并探索如何使用 liveData 构建器!

非典型前端coder wechat
想要随时Follow我的最新博客,可扫码关注我的公众号