在此 Codelab 中,您将学习如何在 Android 应用中使用 Kotlin 协程。Kotlin 协程是一种管理后台线程的新方法,可通过减少回调需求来简化代码。协程是一项 Kotlin 功能,可将长时间运行的任务(例如数据库或网络访问)的异步回调转换为顺序代码。
通过以下代码段,您可以了解自己将做什么。
// Async callbacks
networkRequest { result ->
// Successful network request
databaseSave(result) { rows ->
// Result saved
}
}
系统使用协程将基于回调的代码转换为顺序代码。
// The same code with coroutines
val result = networkRequest()
// Successful network request
databaseSave(result)
// Result saved
您将从一款使用架构组件构建的现有应用入手,该应用使用回调样式来处理长时间运行的任务。
到此 Codelab 结束时,您将有足够的经验在应用中使用协程来从网络加载数据,而且您将能够将协程集成到应用中。您还将熟悉有关协程的最佳做法,以及如何针对使用协程的代码编写测试。
前提条件
- 熟悉
ViewModel
、LiveData
、Repository
和Room
架构组件。 - 具有使用 Kotlin 语法(包括扩展函数和 lambda)的经验。
- 对于在 Android 上使用线程(包括主线程、后台线程和回调)有基本的了解。
您应执行的操作
- 调用使用协程编写的代码并获取结果。
- 使用挂起函数将异步代码依序调用。
- 使用
launch
和runBlocking
可控制代码的执行方式。 - 了解使用
suspendCoroutine
将现有 API 转换为协程的技巧。 - 将协程与架构组件一起使用。
- 了解测试协程的最佳做法。
您需要满足的条件
- Android Studio 3.5(此 Codelab 可能适用于其他版本,但某些内容可能会缺失或有所不同)。
如果在此 Codelab 的操作过程中遇到任何问题(代码错误、语法错误、措辞含义不明等),欢迎通过 Codelab 左下角的“报告错误”链接向我们报告相应问题。
下载代码
点击下面的链接可下载此 Codelab 的所有代码:
…或从命令行使用下列命令克隆 GitHub 代码库:
$ git clone https://github.com/googlecodelabs/kotlin-coroutines.git
常见问题解答
首先,我们来看看起始示例应用的外观。按照下列说明在 Android Studio 中打开示例应用。
- 如果已下载
kotlin-coroutines
zip 文件,请将其解压缩。 - 在 Android Studio 中打开
coroutines-codelab
项目。 - 选择
start
应用模块。 - 点击
Run 按钮,然后选择模拟器或者能够运行 Android Lollipop(支持的最低 SDK 为 21)的 Android 设备。Kotlin 协程屏幕应显示:
在您点按屏幕后,此初始应用会使用线程在经过短暂延迟后增加计数。它还会从网络中提取新标题并将其显示在屏幕上。现在就试试看吧,您应该会看到计数和消息在短暂延迟后出现变化。在此 Codelab 中,您会将此应用转换为使用协程。
此应用使用架构组件将 MainActivity
中的界面代码与 MainViewModel
的应用逻辑分隔开。请花点时间熟悉一下项目的结构。
MainActivity
显示界面、注册点击监听器,并且可以显示Snackbar
。它将事件传递给MainViewModel
,并根据MainViewModel
中的LiveData
更新屏幕。MainViewModel
处理onMainViewClicked
中的事件,并将使用LiveData.
与MainActivity
通信Executors
定义BACKGROUND,
,后者可以在后台线程上运行内容。TitleRepository
从网络提取结果,并将结果保存到数据库。
向项目中添加协程
要在 Kotlin 中使用协程,您必须在项目的 build.gradle (Module: app)
文件中添加 coroutines-core
库。此 Codelab 项目已经为您完成了此步骤,因此您在完成此 Codelab 时无需执行此步骤。
Android 上的协程作为核心库和 Android 专用扩展函数提供:
- kotlinx-corountines-core - 在 Kotlin 中使用协程的主接口
- kotlinx-coroutines-android - 对协程中的 Android 主线程的支持
此初始应用已在 build.gradle.
中包含依赖项。创建新的应用项目时,您需要打开 build.gradle (Module: app)
并将协程依赖项添加到项目中。
dependencies { ... implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:x.x.x" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:x.x.x" }
在 Android 中,避免阻塞主线程是至关重要的。主线程是一个处理所有界面更新的线程。它也是调用所有点击处理程序和其他界面回调的线程。因此,它必须顺畅地运行才能确保出色的用户体验。
为了让您的应用在没有任何可见暂停的情况下向用户显示,主线程必须每 16 毫秒或更长时间更新一次屏幕,约为每秒 60 帧。许多常见任务都比这个时间长,例如解析大型 JSON 数据集、将数据写入数据库或从网络提取数据。因此,从主线程调用此类代码可能会导致应用暂停、卡顿甚至冻结。如果您阻塞主线程太久,应用甚至可能会崩溃并显示一个 Application Not Responding 对话框。
观看下面的视频,了解协程如何通过引入主线程安全帮助我们在 Android 上解决此问题。
回调模式
在不阻塞主线程的情况下执行长时间运行的任务的一种模式是回调。通过使用回调,您可以在后台线程上启动长时间运行的任务。任务完成后,系统会调用回调函数,以在主线程上告知您结果。
我们来看一个回调模式的示例。
// Slow request with callbacks
@UiThread
fun makeNetworkRequest() {
// The slow network request runs on another thread
slowFetch { result ->
// When the result is ready, this callback will get the result
show(result)
}
// makeNetworkRequest() exits after calling slowFetch without waiting for the result
}
由于此代码带有 @UiThread
注解,因此它必须以足够快的速度在主线程上执行。这意味着它需要非常快地返回,这样下一个屏幕更新才会延迟。不过,由于 slowFetch
需要几秒钟甚至几分钟的时间才能完成,因此主线程无法等待结果。show(result)
回调允许 slowFetch
在后台线程上运行,并在准备就绪后返回结果。
使用协程移除回调
回调是一种很好的模式,但也存在缺点。过多使用回调的代码可能会变得难以读取和推演。此外,回调也不允许使用某些语言功能,例如异常。
Kotlin 协程使您能够将基于回调的代码转换为顺序的代码。依序编写的代码通常更易于阅读,甚至可以使用异常语言等语言功能。
最后,两者所做的事情完全相同:等待长时间运行的任务获得结果,然后继续执行。不过,两者的代码看起来却截然不同。
关键字 suspend
是 Kotlin 将函数(即函数类型)标记为可供协程使用的方式。当协程调用标记为 suspend
的函数时,它不会像常规函数调用一样在函数返回之前进行阻塞,而是挂起执行,直到结果就绪为止,然后从上次停止的位置恢复并使用返回的结果。当函数挂起以等待结果时,会解除线程运行的线程,以便其他函数或协程可以运行。
例如,在下面的代码中,makeNetworkRequest()
和 slowFetch()
都是 suspend
函数。
// Slow request with coroutines
@UiThread
suspend fun makeNetworkRequest() {
// slowFetch is another suspend function so instead of
// blocking the main thread makeNetworkRequest will `suspend` until the result is
// ready
val result = slowFetch()
// continue to execute after the result is ready
show(result)
}
// slowFetch is main-safe using coroutines
suspend fun slowFetch(): SlowResult { ... }
与回调版本一样,makeNetworkRequest
必须立即从主线程返回,因为它被标记为 @UiThread
。这意味着,它通常无法调用 slowFetch
等阻塞方法。在这里,suspend
关键字发挥了神奇的作用。
与基于回调的代码相比,协程代码使用更少的代码可实现与解除阻塞当前线程的相同结果。由于其顺序样式,可以轻松链接多个长时间运行的任务,而无需创建多个回调。例如,用于从两个网络端点中提取结果并将其保存到数据库的代码可以作为函数在协程中进行编写,而无需回调。如下所示:
// Request data from network and save it to database with coroutines
// Because of the @WorkerThread, this function cannot be called on the
// main thread without causing an error.
@WorkerThread
suspend fun makeNetworkRequest() {
// slowFetch and anotherFetch are suspend functions
val slow = slowFetch()
val another = anotherFetch()
// save is a regular function and will block this thread
database.save(slow, another)
}
// slowFetch is main-safe using coroutines
suspend fun slowFetch(): SlowResult { ... }
// anotherFetch is main-safe using coroutines
suspend fun anotherFetch(): AnotherResult { ... }
在下一部分中,您将向示例应用引入协程。
在本练习中,您将编写一个协程,用于在一定延迟之后显示消息。首先,请确保您在 Android Studio 中打开了 start
模块。
了解 CoroutineScope
在 Kotlin 中,所有协程都在 CoroutineScope
中运行。范围用于控制协程在整个作业过程中的生命周期。如果取消某个作用域的作业,则该作用域内启动的所有协程也将取消。在 Android 中,您可以使用作用域取消所有正在运行的协程,例如,当用户离开 Activity
或 Fragment
时。范围还允许您指定默认调度程序。调度程序控制哪个线程运行协程。
对于由界面启动的协程,通常可以在 Dispatchers.Main
(Android 上的主线程)中启动它们。在 Dispatchers.Main
上启动的协程在挂起时不会阻塞主线程。由于 ViewModel
协程几乎始终会在主线程上更新界面,因此在主线程上启动协程可以节省额外的线程切换次数。在主线程上启动的协程在启动后可以随时切换调度程序。例如,它可以使用另一个调度程序从主线程解析大型 JSON 结果。
使用 viewModelScope
AndroidX lifecycle-viewmodel-ktx
库将 CoroutineScope 添加到已配置为启动界面相关协程的 ViewModel 中。要使用此库,您必须将其添加到项目的 build.gradle (Module: start)
文件中。此步骤已在此 Codelab 项目中完成。
dependencies { ... implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:x.x.x" }
该库添加 viewModelScope
作为 ViewModel
类的扩展函数。此范围绑定到 Dispatchers.Main
,会在 ViewModel
被清除时自动取消。
从线程切换到协程
在 MainViewModel.kt
中,找到下一个 TODO 以及以下代码:
MainViewModel.kt
/**
* Wait one second then update the tap count.
*/
private fun updateTaps() {
// TODO: Convert updateTaps to use coroutines
tapCount++
BACKGROUND.submit {
Thread.sleep(1_000)
_taps.postValue("$tapCount taps")
}
}
此代码使用 BACKGROUND ExecutorService
(在 util/Executor.kt
中定义)在后台线程中运行。由于 sleep
会阻塞当前线程,因此如果在主线程上调用该线程,界面会冻结。用户点击主视图后 1 秒,请求请求信息提示控件。
从代码中移除 BACKGROUND 并重新运行代码,就能看到这种情况。加载旋转图标将不会显示,并且所有内容都将在一秒钟后“跳到”最终状态。
MainViewModel.kt
/**
* Wait one second then update the tap count.
*/
private fun updateTaps() {
// TODO: Convert updateTaps to use coroutines
tapCount++
Thread.sleep(1_000)
_taps.postValue("$tapCount taps")
}
将 updateTaps
替换为执行相同操作且基于协程的代码。您必须导入 launch
和 delay
。
MainViewModel.kt
/**
* Wait one second then display a snackbar.
*/
fun updateTaps() {
// launch a coroutine in viewModelScope
viewModelScope.launch {
tapCount++
// suspend this coroutine for one second
delay(1_000)
// resume in the main dispatcher
// _snackbar.value can be called directly from main thread
_taps.postValue("$tapCount taps")
}
}
此代码执行的操作相同,即等待 1 秒钟后显示信息提示控件。不过,它们存在一些重要区别:
viewModelScope.
launch
将在viewModelScope
中启动协程。这意味着,当我们传递给viewModelScope
的作业取消时,此作业/作用域中的所有协程都将被取消。如果用户在delay
返回之前退出 Activity,那么在销毁 ViewModel 时,调用onCleared
会自动取消此协程。- 由于
viewModelScope
的默认调度程序为Dispatchers.Main
,因此该协程将在主线程中启动。我们稍后将介绍如何使用不同的线程。 delay
函数属于suspend
函数。在 Android Studio 中,系统会在左侧边线中显示图标。虽然此协程在主线程上运行,但
delay
不会阻塞此线程 1 秒钟。相反,调度程序将安排协程在一秒钟内在下一个语句中恢复。
开始运行测试。点击主视图后,您应该会在一秒钟后看到信息提示控件。
在下一部分中,我们将探讨如何测试此函数。
在本练习中,您将为刚刚编写的代码编写一个测试。本练习将介绍如何使用 kotlinx-coroutines-test 库测试在 Dispatchers.Main
上运行的协程。在此 Codelab 的后面部分,您将实现一个直接与协程交互的测试。
查看现有代码
在 androidTest
文件夹中打开 MainViewModelTest.kt
。
MainViewModelTest.kt
class MainViewModelTest {
@get:Rule
val coroutineScope = MainCoroutineScopeRule()
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
lateinit var subject: MainViewModel
@Before
fun setup() {
subject = MainViewModel(
TitleRepository(
MainNetworkFake("OK"),
TitleDaoFake("initial")
))
}
}
规则是一种在 JUnit 中运行测试之前和之后运行代码的方式。我们利用以下两个规则在设备外测试中测试 MainViewModel:
InstantTaskExecutorRule
是一种 JUnit 规则,用于配置LiveData
以同步执行每项任务MainCoroutineScopeRule
是此代码库中的自定义规则,用于将Dispatchers.Main
配置为使用kotlinx-coroutines-test
中的TestCoroutineDispatcher
。这样一来,测试可以将用于测试的虚拟时钟拨快,并让代码可以使用单元测试中的Dispatchers.Main
。
在 setup
方法中,使用测试伪造项创建一个新的 MainViewModel
实例 - 这些是起始代码中提供的网络和数据库的虚假实现,以帮助编写测试,而无需使用实际的网络或数据库。
对于此测试,只需要依赖虚拟对象来满足 MainViewModel
的依赖项要求。在此 Codelab 的后半部分,您将更新虚拟对象以支持协程。
编写用于控制协程的测试
添加一项新的测试,以确保系统在用户点按主视图的一秒钟后更新点按计数:
MainViewModelTest.kt
@Test
fun whenMainClicked_updatesTaps() {
subject.onMainViewClicked()
Truth.assertThat(subject.taps.getValueForTest()).isEqualTo("0 taps")
coroutineScope.advanceTimeBy(1000)
Truth.assertThat(subject.taps.getValueForTest()).isEqualTo("1 taps")
}
通过调用 onMainViewClicked
,系统将启动我们刚刚创建的协程。此测试用于检查是否出现以下情况:点按计数文字在系统调用 onMainViewClicked
后保持“0 taps”不变,然后在 1 秒钟后更新为“1 taps”。
此测试使用虚拟时间控制 onMainViewClicked
所启动的协程的执行。使用 MainCoroutineScopeRule
,您可以暂停、恢复或控制在 Dispatchers.Main
上启动的协程的执行。在这里,我们将调用 advanceTimeBy(1_000)
,这会导致主调度程序立即执行预定在 1 秒钟后恢复的协程。
此测试具有完全确定性,这意味着它将始终以相同的方式执行。此外,由于此测试完全控制在 Dispatchers.Main
上启动的协程的执行,因此无需等待一秒钟,即可设置值。
运行现有测试
- 右键点击编辑器中的类名称
MainViewModelTest
,以打开上下文菜单。 - 在上下文菜单中,选择
Run 'MainViewModelTest'
- 对于后续运行,您可以在工具栏中的
按钮旁边的配置中选择此测试配置。默认情况下,此配置将称为 MainViewModelTest。
您应该会看到测试通过!而且运行时间应该不到一秒。
在下一个练习中,您将学习如何从现有的 callback API 转换为使用协程。
在此步骤中,您将开始转换代码库以使用协程。为此,我们将向 ViewModel
、Repository
、Room
和 Retrofit
添加协程。
在将架构的各个部分转换为使用协程之前,最好先了解每个部分的作用。
MainDatabase
使用 Room 实现一个数据库,以保存和加载Title
。MainNetwork
会实现一个可获取新标题的网络 API。它使用 Retrofit 获取标题。Retrofit
配置为随机返回错误或模拟数据,但除此之外,它的行为方式就像是在发出实际的网络请求。TitleRepository
实现了一个 API,用于通过结合来自网络和数据库的数据来提取或刷新标题。MainViewModel
表示屏幕的状态,并负责处理事件。它会指示代码库在用户点按屏幕时刷新标题。
由于网络请求由界面事件驱动,并且我们希望根据这些事件启动协程,那么自然而然应在 ViewModel
中开始使用协程。
回调版本
打开 MainViewModel.kt
可查看 refreshTitle
的声明。
MainViewModel.kt
/**
* Update title text via this LiveData
*/
val title = repository.title
// ... other code ...
/**
* Refresh the title, showing a loading spinner while it refreshes and errors via snackbar.
*/
fun refreshTitle() {
// TODO: Convert refreshTitle to use coroutines
_spinner.value = true
repository.refreshTitleWithCallbacks(object: TitleRefreshCallback {
override fun onCompleted() {
_spinner.postValue(false)
}
override fun onError(cause: Throwable) {
_snackBar.postValue(cause.message)
_spinner.postValue(false)
}
})
}
每次用户点击屏幕时,系统都会调用此函数,这会导致代码库刷新标题,然后将新标题写入数据库。
此实现使用回调来执行几项操作:
- 在开始查询之前,它使用
_spinner.value = true
显示一个加载旋转图标 - 获取结果后,它会使用
_spinner.value = false
清除加载旋转图标 - 如果出现错误,它会指示系统显示信息提示控件并清除旋转图标
请注意,onCompleted
回调不会传递到 title
。由于我们将所有标题写入 Room
数据库,因此界面会观察由 Room
更新的 LiveData
,以更新当前标题。
在协程更新中,我们将保持完全相同的行为。使用 Room
数据库等可观察数据源让界面始终保持最新状态是一种很好的模式。
协程版本
我们来使用协程重写 refreshTitle
!
我们马上需要使用它,因此我们将在我们的存储区 (TitleRespository.kt
) 中创建一个空的挂起函数。定义一个使用 suspend
运算符的新函数,告知 Kotlin 它可以与协程配合使用。
TitleRepository.kt
suspend fun refreshTitle() {
// TODO: Refresh from network and write to database
delay(500)
}
完成此 Codelab 后,您将更新此代码,以使用 Retrofit 和 Room 提取新标题,并使用协程将标题写入数据库。现在,它会等待 500 毫秒来假装在执行操作,然后再继续。
在 MainViewModel
中,将 refreshTitle
的回调版本替换为启动新协程的版本:
MainViewModel.kt
/**
* Refresh the title, showing a loading spinner while it refreshes and errors via snackbar.
*/
fun refreshTitle() {
viewModelScope.launch {
try {
_spinner.value = true
repository.refreshTitle()
} catch (error: TitleRefreshError) {
_snackBar.value = error.message
} finally {
_spinner.value = false
}
}
}
我们来了解一下这个函数:
viewModelScope.launch {
与更新点按计数的协程一样,首先在 viewModelScope
中启动新的协程。这将使用 Dispatchers.Main
,这没有问题。即使 refreshTitle
会发出网络请求和数据库查询,但它也可以使用协程提供主线程安全接口。这意味着,您可以放心地从主线程调用它。
由于我们使用了 viewModelScope
,因此,当用户离开此屏幕时,此协程启动的操作将自动取消。这意味着它不会发出其他网络请求或数据库查询。
接下来的几行代码实际上会调用 repository
中的 refreshTitle
。
try {
_spinner.value = true
repository.refreshTitle()
}
此协程在执行任何操作之前都会启动加载旋转图标,然后像调用常规函数一样调用 refreshTitle
。不过,由于 refreshTitle
是一个挂起函数,因此其执行方式与常规函数不同。
我们不必传递回调。该协程将挂起,直到 refreshTitle
将其恢复。虽然看起来就像常规阻塞函数调用一样,但它会自动等到网络和数据库查询完成后再恢复执行,而不会阻塞主线程。
} catch (error: TitleRefreshError) {
_snackBar.value = error.message
} finally {
_spinner.value = false
}
挂起函数中的异常的作用类似于常规函数中的错误。如果您在挂起函数中抛出错误,则错误会抛给调用方。因此,尽管它们的执行方式截然不同,您可以使用常规 try/catch 块来处理它们。这非常有用,因为这让您可以依靠针对错误处理的内置语言支持,而不是为每个回调构建自定义错误处理。
此外,如果您从协程丢出异常,则此协程将默认取消其父级。也就是说,同时取消多项相关任务非常容易。
然后,在一个 finally 块中,我们可以确保旋转图标始终在查询运行后关闭。
如需再次运行应用,请选择 start 配置,然后按 。点按任意位置时,系统应会显示正在加载的旋转图标。由于我们尚未连接网络或数据库,标题不会发生变化。
在下一个练习中,您将更新代码库以实际执行操作。
在本练习中,您将学习如何切换运行协程的线程,以实现 TitleRepository
的工作版本。
查看 refreshTitle 中的现有回调代码
打开 TitleRepository.kt
并查看现有的基于回调的实现。
TitleRepository.kt
// TitleRepository.kt
fun refreshTitleWithCallbacks(titleRefreshCallback: TitleRefreshCallback) {
// This request will be run on a background thread by retrofit
BACKGROUND.submit {
try {
// Make network request using a blocking call
val result = network.fetchNextTitle().execute()
if (result.isSuccessful) {
// Save it to database
titleDao.insertTitle(Title(result.body()!!))
// Inform the caller the refresh is completed
titleRefreshCallback.onCompleted()
} else {
// If it's not successful, inform the callback of the error
titleRefreshCallback.onError(
TitleRefreshError("Unable to refresh title", null))
}
} catch (cause: Throwable) {
// If anything throws an exception, inform the caller
titleRefreshCallback.onError(
TitleRefreshError("Unable to refresh title", cause))
}
}
}
在 TitleRepository.kt
中,refreshTitleWithCallbacks
方法实现了一个回调,用于向调用方传达加载状态和错误状态。
为了实现刷新,此函数会执行多项操作。
- 切换到包含
BACKGROUND
ExecutorService
的另一个线程 - 使用阻塞
execute()
方法运行fetchNextTitle
网络请求。这将在当前线程(本例中为BACKGROUND
中的一个线程)中运行网络请求。 - 如果结果成功,则使用
insertTitle
将其保存到数据库,并调用onCompleted()
方法。 - 如果结果不成功或者出现异常,则调用 onError 方法,以告知调用方刷新失败。
基于回调的实现是主线程安全的,因为它不会阻止主线程。但是,它必须使用回调在工作完成后通知调用方。它还会在切换的 BACKGROUND
线程上调用回调。
从协程调用阻塞调用
在不向网络或数据库引入协程的情况下,我们可以使用协程让此代码具有主线程安全性。这样,我们就可以移除回调,并将结果传回最初调用回调的线程。
当您需要从协程内执行阻塞或 CPU 密集型工作时,可随时使用此模式,例如对大型列表进行排序和过滤或从磁盘读取。
在任何调度程序之间切换时,协程会使用 withContext
。调用 withContext
会切换到仅适用于 lambda 的另一个调度程序,然后返回到使用该 lambda 的结果调用它的调度程序。
默认情况下,Kotlin 协程提供三个调度程序:Main
、IO
和 Default
。IO 调度程序针对 IO 工作进行了优化,例如从网络或磁盘读取内容,而 Default 调度程序则针对 CPU 密集型任务进行了优化。
TitleRepository.kt
suspend fun refreshTitle() {
// interact with *blocking* network and IO calls from a coroutine
withContext(Dispatchers.IO) {
val result = try {
// Make network request using a blocking call
network.fetchNextTitle().execute()
} catch (cause: Throwable) {
// If the network throws an exception, inform the caller
throw TitleRefreshError("Unable to refresh title", cause)
}
if (result.isSuccessful) {
// Save it to database
titleDao.insertTitle(Title(result.body()!!))
} else {
// If it's not successful, inform the callback of the error
throw TitleRefreshError("Unable to refresh title", null)
}
}
}
此实现为网络和数据库使用阻塞调用,但它仍然比回调版本简单一点。
此代码仍使用阻塞调用。调用 execute()
和 insertTitle(...)
都会阻塞运行此协程的线程。不过,通过使用 withContext
切换到 Dispatchers.IO
,我们会阻止 IO 调度程序中的一个线程。调用此函数的协程(可能会在 Dispatchers.Main
上运行)将挂起,直到 withContext
lambda 完成为止。
与回调版本相比,有以下两个主要区别:
withContext
的结果会返回给调用它的调度程序,在本示例中为Dispatchers.Main
。回调版本在BACKGROUND
执行器服务中调用线程上的回调。- 调用方无需向该函数传递回调。他们可以依靠挂起和恢复功能来获取结果或错误。
再次运行应用
如果您再次运行应用,就会看到基于协程的新实现会从网络加载结果!
在下一步中,您会将协程集成到 Room 和 Retrofit。
为了继续实现协程集成,我们将利用对稳定版 Room 和 Retrofit 中挂起函数的支持,然后使用挂起函数大幅简化我们刚刚编写的代码。
Room 中的协程
首先,打开 MainDatabase.kt
并将 insertTitle
设置为挂起函数:
MainDatabase.kt
// add the suspend modifier to the existing insertTitle
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTitle(title: Title)
执行此操作后,Room 会将您的查询设为主线程安全,并在后台线程上自动执行该查询。不过,这也意味着您只能从协程内调用此查询。
这就是在 Room 中使用协程所做的所有操作。相当不错。
Retrofit 中的协程
接下来,让我们了解如何将协程与 Retrofit 集成。打开 MainNetwork.kt
并将 fetchNextTitle
更改为挂起函数。
MainNetwork.kt
// add suspend modifier to the existing fetchNextTitle
// change return type from Call<String> to String
interface MainNetwork {
@GET("next_title.json")
suspend fun fetchNextTitle(): String
}
要将挂起函数与 Retrofit 一起使用,您必须执行以下两项操作:
- 为函数添加挂起修饰符
- 从返回值类型中移除
Call
封装容器。这里我们会返回String
,但您也可以返回 json 支持的复杂类型。如果您仍想提供对 Retrofit 的完整Result
的访问权限,可以从挂起函数返回Result<String>
,而不是String
。
Retrofit 将自动使挂起函数具有主线程安全性,以便您可以直接从 Dispatchers.Main
调用它们。
使用 Room 和 Retrofit
现在,Room 和 Retrofit 支持挂起函数,因此我们可以从代码库中使用它们。打开 TitleRepository.kt
,并观察使用挂起函数如何大大简化逻辑,甚至与阻塞版本相比也不例外:
TitleRepository.kt
suspend fun refreshTitle() {
try {
// Make network request using a blocking call
val result = network.fetchNextTitle()
titleDao.insertTitle(Title(result))
} catch (cause: Throwable) {
// If anything throws an exception, inform the caller
throw TitleRefreshError("Unable to refresh title", cause)
}
}
哇,简直比我短得多。What happened? 因此,它依赖于挂起和恢复,可以大大缩短代码。借助 Retrofit,我们可以在此处使用返回类型(如 String
或 User
对象),而不是 Call
。这很安全,因为在挂起函数内,Retrofit
能够在后台线程上运行网络请求,并在调用完成时恢复协程。
更棒的是,我们弃用了 withContext
。由于 Room 和 Retrofit 均提供主线程安全挂起函数,因此从 Dispatchers.Main
编排此异步工作是安全的。
修正编译器错误
转用协程确实涉及更改函数的签名,因为您无法通过常规函数调用挂起函数。如果您在此步骤中添加了 suspend
修饰符,系统会生成一些编译器错误,从中您会明白在实际项目中将函数更改为挂起函数时会发生的情况。
检查项目,并修正将函数更改为挂起函数时所产生的编译器错误。以下是快速解决各类问题的方法:
TestingFakes.kt
更新测试虚假对象,以支持新的挂起修饰符。
TitleDaoFake
- 按 Alt-Enter 给挂起函数中的所有函数添加挂起修饰符
MainNetworkFake
- 按 Alt-Enter 给挂起函数中的所有函数添加挂起修饰符
- 将
fetchNextTitle
替换为此函数
override suspend fun fetchNextTitle() = result
MainNetworkCompletableFake
- 按 Alt-Enter 给挂起函数中的所有函数添加挂起修饰符
- 将
fetchNextTitle
替换为此函数
override suspend fun fetchNextTitle() = completable.await()
TitleRepository.kt
- 删除
refreshTitleWithCallbacks
函数,因为系统已不再使用它。
运行应用
再次运行应用,编译完成后,您会发现它使用协程将数据从 ViewModel 一直加载到 Room 和 Retrofit!
恭喜,您已将此应用完全转换为使用协程!最后,我们简单介绍下如何测试我们所完成的工作。
在本练习中,您将编写一个直接调用 suspend
函数的测试。
由于 refreshTitle
作为公共 API 公开提供,系统会直接测试它,从而展示如何从测试中调用协程。
以下是您在上次练习中实现的 refreshTitle
函数:
TitleRepository.kt
suspend fun refreshTitle() {
try {
// Make network request using a blocking call
val result = network.fetchNextTitle()
titleDao.insertTitle(Title(result))
} catch (cause: Throwable) {
// If anything throws an exception, inform the caller
throw TitleRefreshError("Unable to refresh title", cause)
}
}
编写用于调用挂起函数的测试
在具有两个 TODOS 的 test
文件夹中打开 TitleRepositoryTest.kt
。
尝试从第一个测试 whenRefreshTitleSuccess_insertsRows
调用 refreshTitle
。
@Test
fun whenRefreshTitleSuccess_insertsRows() {
val subject = TitleRepository(
MainNetworkFake("OK"),
TitleDaoFake("title")
)
subject.refreshTitle()
}
由于 refreshTitle
是 suspend
函数,Kotlin 不知道如何调用此函数(除非从协程或另一个挂起函数调用),并且您会收到一个编译器错误,例如“Suspend function refreshTitle should be called only from a coroutine or another suspend function.”
测试运行程序对协程一无所知,因此我们无法将此测试设为挂起函数。我们可以像使用 ViewModel
中的 CoroutineScope
一样对协程执行 launch
操作,不过,测试需要运行协程才能完成,然后再返回结果。测试函数返回后,测试已结束。以 launch
开头的协程是异步代码,可能会在将来的某个时间点完成。因此,要测试该异步代码,您需要通过某种方式让测试等待协程完成。由于 launch
是非阻塞调用,因此它会立即返回,并且可以在函数返回后继续运行协程 - 它不能在测试中使用。例如:
@Test
fun whenRefreshTitleSuccess_insertsRows() {
val subject = TitleRepository(
MainNetworkFake("OK"),
TitleDaoFake("title")
)
// launch starts a coroutine then immediately returns
GlobalScope.launch {
// since this is asynchronous code, this may be called *after* the test completes
subject.refreshTitle()
}
// test function returns immediately, and
// doesn't see the results of refreshTitle
}
此测试有时会失败。对 launch
的调用将立即返回,并在与测试用例的其余部分同时执行。此测试无法确定 refreshTitle
是否已运行,并且任何断言(例如检查数据库是否已更新)都会不稳定。如果 refreshTitle
抛出异常,此异常将不会在测试调用堆栈中抛出。而是会抛出 GlobalScope
的未捕获异常处理程序。
kotlinx-coroutines-test
库具有 runBlockingTest
函数,该函数在调用挂起函数时会执行阻塞操作。默认情况下,当 runBlockingTest
调用挂起函数或对新协程执行 launches
时,它会立即执行。您可以将它看作一种将挂起函数和协程转换为正常函数调用的方式。
此外,runBlockingTest
还会为您重新抛出未捕获的异常。这样可以更轻松地测试协程抛出异常的情况。
使用一个协程实现测试
使用 runBlockingTest
封装对 refreshTitle
的调用,并从 subject.refreshTitle() 中移除 GlobalScope.launch
封装容器。
TitleRepositoryTest.kt
@Test
fun whenRefreshTitleSuccess_insertsRows() = runBlockingTest {
val titleDao = TitleDaoFake("title")
val subject = TitleRepository(
MainNetworkFake("OK"),
titleDao
)
subject.refreshTitle()
Truth.assertThat(titleDao.nextInsertedOrNull()).isEqualTo("OK")
}
此测试使用提供的模拟对象来验证 refreshTitle
是否已将“OK”插入数据库。
在测试调用 runBlockingTest
时,它将会阻塞,直到由 runBlockingTest
启动的协程完成为止。然后,在内部,当我们调用 refreshTitle
时,它会使用常规的挂起和恢复机制,以等待数据库行添加到我们的虚拟对象中。
测试协程完成后,会返回 runBlockingTest
。
编写超时测试
我们想为网络请求增加一小段超时时间。让我们先编写测试,然后再实现超时。创建新测试:
TitleRepositoryTest.kt
@Test(expected = TitleRefreshError::class)
fun whenRefreshTitleTimeout_throws() = runBlockingTest {
val network = MainNetworkCompletableFake()
val subject = TitleRepository(
network,
TitleDaoFake("title")
)
launch {
subject.refreshTitle()
}
advanceTimeBy(5_000)
}
此测试使用提供的虚假 MainNetworkCompletableFake
,这是一种网络假想,用于在测试用户继续进行之前挂起其调用方。当 refreshTitle
尝试发出网络请求时,它会永久挂起,因为我们想测试超时情况。
然后,它会启动一个单独的协程来调用 refreshTitle
。这是测试超时的一个关键部分,超时应在与 runBlockingTest
创建的协程不同的协程中发生。这样一来,我们可以调用下一行,即 advanceTimeBy(5_000)
,它会将时间提前 5 秒并导致另一个协程超时。
这是一项完整的超时测试,并会在我们实现超时后顺利通过。
立即运行,看看会发生什么:
Caused by: kotlinx.coroutines.test.UncompletedCoroutinesError: Test finished with active jobs: ["...]
runBlockingTest
的一项功能是它不允许您在测试完成后泄露协程。如果存在任何未完成的协程,例如我们的启动协程,在测试结束时都会导致测试失败。
添加超时
打开 TitleRepository
并为网络提取添加五秒钟的超时时间。您可以使用 withTimeout
函数执行此操作:
TitleRepository.kt
suspend fun refreshTitle() {
try {
// Make network request using a blocking call
val result = withTimeout(5_000) {
network.fetchNextTitle()
}
titleDao.insertTitle(Title(result))
} catch (cause: Throwable) {
// If anything throws an exception, inform the caller
throw TitleRefreshError("Unable to refresh title", cause)
}
}
运行测试。运行测试时,您应该会看到所有测试都通过!
在下个练习中,您将学习如何使用协程编写高阶函数。
在本练习中,您将重构 MainViewModel
中的 refreshTitle
,以使用常规的数据加载函数。此 Codelab 将向您介绍如何构建使用协程的高阶函数。
refreshTitle
的当前实现运行正常,但我们可以创建一个始终显示旋转图标的常规数据加载协程。对于加载数据以响应多个事件且希望确保加载旋转图标始终显示的代码库,这可能非常有用。
查看当前实现,可以发现,除 repository.refreshTitle()
之外的每行代码都是显示旋转图标和错误的样板代码。
// MainViewModel.kt
fun refreshTitle() {
viewModelScope.launch {
try {
_spinner.value = true
// this is the only part that changes between sources
repository.refreshTitle()
} catch (error: TitleRefreshError) {
_snackBar.value = error.message
} finally {
_spinner.value = false
}
}
}
在高阶函数中使用协程
将此代码添加到 MainViewModel.kt
MainViewModel.kt
private fun launchDataLoad(block: suspend () -> Unit): Job {
return viewModelScope.launch {
try {
_spinner.value = true
block()
} catch (error: TitleRefreshError) {
_snackBar.value = error.message
} finally {
_spinner.value = false
}
}
}
现在,重构 refreshTitle()
以使用以下高阶函数。
MainViewModel.kt
fun refreshTitle() {
launchDataLoad {
repository.refreshTitle()
}
}
通过抽象化用于显示加载旋转图标和显示错误的逻辑,我们简化了加载数据所需的实际代码。显示旋转图标或显示错误是易于泛化到任何数据加载的内容,而实际数据源和目标则需要每次都指定。
为了构建此抽象,launchDataLoad
接受一个属于挂起 lambda 的参数 block
。挂起 lambda 可让您调用挂起函数。Kotlin 就是通过这种方式实现我们在此 Codelab 中使用的 launch
和 runBlocking
协程构建器。
// suspend lambda
block: suspend () -> Unit
要创建挂起 lambda,应从 suspend
关键字着手。函数箭头和返回值类型 Unit
用于完成声明。
您通常不必声明您自己的挂起 lambda,但它们可能有助于创建这类用于封装重复逻辑的抽象!
在本练习中,您将学习如何使用 WorkManager 中基于协程的代码。
什么是 WorkManager
Android 有多个选项用于处理可延迟的后台工作。本练习将介绍如何将 WorkManager 与协程集成。WorkManager 是一个兼容、灵活且简单的库,用于处理可延迟的后台工作。WorkManager 是 Android 平台上这些用例的推荐解决方案。
WorkManager 是 Android Jetpack 的一部分,是一种架构组件,用于处理既需要机会性执行,又需要有保证的执行的后台工作。机会性执行意味着 WorkManager 会尽快执行您的后台工作。有保证的执行意味着 WorkManager 会负责通过逻辑保障在各种情况下启动您的工作,即使用户离开您的应用也无妨。
因此,WorkManager 是最终必须完成的任务的理想选择。
以下是一些适合使用 WorkManager 的任务的典型示例:
- 上传日志
- 对图片应用滤镜并保存图片
- 定期将本地数据与网络同步
将协程与 WorkManager 一起使用
WorkManager 为不同的用例提供了其基础 ListanableWorker
类的不同实现。
最简单的 Worker 类可让我们通过 WorkManager 执行一些同步操作。不过,根据目前为止我们将代码库转换为使用协程和挂起函数的经验,我们发现使用 WorkManager 的最好方法是通过 CoroutineWorker
类,此类支持将 doWork()
函数定义为挂起函数。
首先,请打开 RefreshMainDataWork
。它已经扩展了 CoroutineWorker
,您需要实现 doWork
。
在 suspend
doWork
函数内,从代码库中调用 refreshTitle()
并返回相应的结果!
完成 TODO 后,代码将如下所示:
override suspend fun doWork(): Result {
val database = getDatabase(applicationContext)
val repository = TitleRepository(network, database.titleDao)
return try {
repository.refreshTitle()
Result.success()
} catch (error: TitleRefreshError) {
Result.failure()
}
}
请注意,CoroutineWorker.doWork()
是一个挂起函数。与更简单的 Worker
类不同,此代码不会在您的 WorkManager 配置所指定的执行器上运行,而是使用 coroutineContext
成员的调度程序(默认为 Dispatchers.Default
)。
测试 CoroutineWorker
如果不进行测试,任何代码库都不应是完整的。
WorkManager 提供了几种不同的方法来测试 Worker
类。如需详细了解原始测试基础架构,您可以参阅相关文档。
WorkManager v2.1 引入了一组新的 API 来支持更简单的 ListenableWorker
类测试方法,并最终推出了 CoroutineWorker。在我们的代码中,我们将使用以下某个新 API:TestListenableWorkerBuilder
。
为了添加我们的新测试,更新 androidTest
文件夹下的 RefreshMainDataWorkTest
文件。
此文件的内容为:
package com.example.android.kotlincoroutines.main
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.work.ListenableWorker.Result
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.work.ListenableWorker.Result
import androidx.work.testing.TestListenableWorkerBuilder
import com.example.android.kotlincoroutines.fakes.MainNetworkFake
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
@RunWith(JUnit4::class)
class RefreshMainDataWorkTest {
@Test
fun testRefreshMainDataWork() {
val fakeNetwork = MainNetworkFake("OK")
val context = ApplicationProvider.getApplicationContext<Context>()
val worker = TestListenableWorkerBuilder<RefreshMainDataWork>(context)
.setWorkerFactory(RefreshMainDataWork.Factory(fakeNetwork))
.build()
// Start the work synchronously
val result = worker.startWork().get()
assertThat(result).isEqualTo(Result.success())
}
}
在开始测试之前,我们会向 WorkManager
介绍工厂问题,以便我们注入虚构网络。
测试本身会使用 TestListenableWorkerBuilder
创建我们的工作器,然后我们可以运行该工作器来调用 startWork()
方法。
WorkManager 只是用来说明如何使用协程简化 API 设计的一个示例。
在此 Codelab 中,我们介绍了您需要开始在应用中使用协程的基础知识!
我们介绍了:
- 如何从界面和 WorkManager 作业将协程集成到 Android 应用中,以简化异步编程。
- 如何使用
ViewModel
内的协程从网络中提取数据,并将数据保存到数据库而不阻塞主线程。 - 以及如何在
ViewModel
完成后取消所有协程。
对于测试基于协程的代码,我们介绍了测试行为以及直接从测试调用 suspend
函数。
了解详情
查看使用 Kotlin Flow 和 LiveData 的高级协程” Codelab,详细了解 Android 上的高级协程用法。
Kotlin 协程的许多功能未在此 Codelab 中作介绍。如果您有兴趣详细了解 Kotlin 协程,请阅读 JetBrains 发布的协程指南。另请参阅“利用 Kotlin 协程提升应用性能”,了解更多有关 Android 上的协程的使用模式。