在你的Android App中使用Kotlin协程
1.前言
通过本文,你将学习到如何在Android App中使用协程。这是一种管理后台线程的方法,可以使代码简洁,减少回调代码的使用。
协程是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
下面会通过一个简单的sample App来讲解,该App使用架构组件构建,使用的是回调的方式完成异步任务。
在本文结束,你将学习到如何在App使用协程从网络加载数据,你也将有能力将协程集成进你的App中。你将得到协程的最佳实践,以及如何使用协程编写测试代码。
准备
- 熟悉架构组件,如ViewModel、LiveData、Repository以及Room
- 熟练使用Kotlin语法,包括扩展函数及lambda语法
- Android中线程的进本使用,包括主线程,后台线程以及回调。
如何做
- 调用协程代码,并获取结果
- 使用挂起函数使异步代码顺序调用
- 使用launch和runBlocking来控制代码如何执行。
- 学习使用suspendCoroutine转换APIs成协程
- 将架构组件与协程配合使用
- 学习协程测试的最佳实践
扩展阅读
Room使用介绍,请查看使用Room DAOs获取数据
架构组件使用介绍,请查看App架构指南
Kotlin语法介绍,请查看Kotlin语法基础
Android线程基础介绍,请查看后台处理指南
为了保证示例代码正常运行,请使用Android Studio3.6以上的版本。
2. 准备
下载示例代码
$ git clone https://github.com/googlecodelabs/kotlin-coroutines.git
kotlin-coroutines
仓库包含2 个工程。本文中使用的是coroutines-codelab
工程,该工程包含2个module:
android_studio_folder.pngstart
— 使用Android架构组件的构建的简单app,将在此app基础上添加协程使用
android_studio_folder.pngfinished_code
— 该module已经使用了协程。
3.运行示例app
首先,我们将该示例app运行起来,看下它长什么样,将coroutines-codelab
工程导入到AS中运行,运行效果如下:
当你单击屏幕时,这个启动的App使用线程来做延迟计数,它也将从网络获取新的标题,并显示在屏幕上。重新尝试下,在短暂的延迟之后,你将看到计数和消息更新。在该示例中,我们将使用协程来改造它。
该示例app使用了架构组件进行了UI和业务逻辑的分层,UI代码放在MainActivity
中,业务逻辑放在MainViewModel
中,花点时间熟悉下这个工程的结构。
-
MainActivity
显示UI,注册点击监听,显示Snackbar。传递事件到MainViewModel
中,并依赖于MainViewModel
中的LiveData
数据更新屏幕。 -
MainViewModel
在onMainViewClicked
中处理事件,通过LiveData
与MainActivity
进行通信。 -
Executors
定义BACKGROUND,用于处理耗时的任务。 -
TitleRepository
从网络获取数据并保存到数据库中。
在你的App中引入协程
在你的app module中添加协程相关的依赖,代码如下:
dependencies {
...
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:x.x.x" //kotlin协程的主要接口
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:x.x.x" //Android主线程协程支持
}
协程与RxJava
如果你在你的工程中使用了RxJava,你可以通过使用 kotlin-coroutines-rx集成协程。
4. Kotlin中的协程
在Android中,避免主线程阻塞是基本的要求。主线程中只能用于处理UI更新。主线程也可以用于点击事件处理以及UI回调。只有这样,App才能运行流程,从而有良好的用户体验。
Android系统比较流畅的帧率是60fps, 换算成时间就是16ms,也就是UI渲染必须在该时间段内完成,否则就会出现卡帧的现象。
许多任务的花费的时间都会比这个时间长,例如解析大的JSON数据集,往数据库写入数据,或者从网络获取数据。因此,在主线程调用这些耗时的代码段会引起app的暂停,卡顿甚至冻结。如果你阻塞主线程太长时间,app可能会crash,系统会弹出应用无响应(ANR)的dialog。
回调方式
避免主线程阻塞的通常做法是使用异步回调。通过使用回调,你可以将耗时任务运行在后台线程,当任务完成时,通过回调向主线程通知结果。
看下面回调方式的代码示例
// 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
将花费几秒甚至几分钟才能完成,主线程不可能等这么久。回调方式允许slowFetch
调用运行在后台线程,并在任务完成后返回结果。
使用协程剔除callbacks
回调是一种处理异步调用的很好方式,然而它也有一些缺点。过多使用callbacks将导致代码很难阅读及理解。另外,回调在一些语法功能中是不允许的,例如异常处理。
Kotlin协程让你可以将基于回调的调用方式转成顺序调用。顺序调用的书写方式很容易阅读,这种方式也可以用在一些语法功能上,例如异常。
最后,它们将做相同的事情,等待结果返回,并继续执行,然而,代码形式上有很大的不同。
关键字suspend
用于标识函数可以在协程中使用。当协程调用一个suspend
标识的函数时,会像调用普通函数一样,它将挂起执行直到结果返回。当它挂起时,不会阻塞线程,从而其他函数和协程可以继续执行。
下面的示例代码将展示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
关键字magic的地方。
同基于回调风格的代码比较,协程代码在不阻塞当前线程的前提下,可以达到同样的效果。协程可以将多个耗时任务串联起来,而不需要多个callback,例如下面的示例代码:
// 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 { ... }
协程的另一个名称
在其他语言中,async
和await
的方式异步是基于协程的。如果你熟悉这种方式,suspend
关键字同async
比较像。然而在Kotlin中,当调用suspend
函数时,await()
是隐含的。
Kotlin中有一个方法Deferred.await()
,用于等待async
启动的协程返回的结果。
5. 使用协程控制UI
理解CoroutineScope
在Kotlin中,所有的协程都运行在CoroutineScope
中。它通过job控制coroutines
的生命周期。当你取消了该scope上的job, 该scope上所有启动的coroutine
都会被取消。当Activity
与Fragment
销毁时,你可以用一个scope取消所有运行的协程。Scopes也允许你运行在一个特定的dispatcher
上。一个dispatcher
控制着coroutine
运行在哪个线程上。
对于UI启动的协程,会运行在Dispatchers.Main
主线程上。运行在Dispatchers.Main
线程上的协程挂起时,不会阻塞主线程。由于ViewModel
协程几乎总是在UI主线程中更新,在主线程中启动协程会增加额外的线程切换。在主线程中启动的协程可以随时切换线程。例如,可以使用另外一个dispatcher
解析大的JSON数据集。
Coroutines提供主线程安全
因为coroutines
可以随时切换线程,并传递结果到原始线程中,所以在主线程中启动UI相关的coroutines
是个不错的主意。
Room和Retrofit都提供了使用coroutines
时的主线程安全特性,因此你在处理网络和数据库时不需要管理线程。这通常能使代码简洁。
然而,像列表排序或从文件读取等blocking code
仍然需要明确声明main-safety
,尽管使用了coroutines
。如果你使用了网络请求或者数据操作的lib,并且没有支持coroutines
,你仍然需要明确指定main-safety
。
使用viewModelScope
AndroidX 的lifecycle-viewmodel-ktx
库提供了CoroutineScope
到ViewModels
中,该ViewModels
启动UI相关连的协程。为了使用该库,你必须将该lib引入到你app的build.gradle
文件中
dependencies {
...
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:x.x.x"
}
该lib添加了viewModelScope
作为ViewModel
类的扩展函数。该scope与Dispatchers.Main
绑定,当viewModel
被清理时,该scope会被自动取消。
从线程切换到协程
在处理耗时任务时,我们传统做法是使用后台线程进行处理。
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
阻塞了当前线程,它将在主线程阻塞UI。
后面我们将移除BACKGROUND
,并再次运行它。加载的spinner
将不再显示,并跳转到最终状态。
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")
}
}
这段代码,等待1s后会显示一个snackbar.然而,两者有很大的不同:
viewModelScope.launch
将在viewModelScope
中启动一个协程,这意味一旦我们取消传递给viewModelScope
的job,所有在该job/scope
中的协程将被取消。如果用户在delay
调用返回前销毁了Activity,协程将在ViewModel调用onCleared
函数时自动取消。- 由于
viewModelScope
有一个默认的dispatcherDispatchers.Main
,该协程将在主线程中启动。我们后续将看到如何在不同线程中使用协程。
3.delay
是一个suspend
函数。在Android Studio中将展示一个 icon 在左侧空白。尽管该协程运行在主线程,delay
不会阻塞该线程。相反,dispatcher将在1s后恢复协程。
6. 通过行为测试协程
协程测试通过kotlinx-coroutines-test
Lib完成,该协程运行在Dispatchers.Main
下面是示例代码
打开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")
))
}
}
1.InstantTaskExecutorRule
是一个JUnit规则,用于配置LiveData
同步的运行每一个任务
2.MainCoroutineScopeRule
是一个自定义规则用于配置Dispatchers.Main
来调用kotlinx-coroutines-test
中的TestCoroutineDispatcher
。这个允许单元测试运行在Dispatchers.Main
。
在setup
方法,Fake一个虚拟的MainViewModel
实例,这个Fake ViewModel实现了网络和数据库数据,而不需要真实的网络或者数据库。
在该测试中,这些Fakes(假的、赝品)仅需要安全依赖MainViewModel
.后续你将升级这些fakes以支持协程。
写一个控制协程的测试
添加一个测试确保当用户点击主view时,1s后taps可以被更新。
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
被调用时,1s后,taps 上的文字将由“0 taps”更新为“1 taps”
该测试使用virtual-time
用来控制由onMainViewClicked
启动的协程。MainCoroutineScopeRule
让你停止,恢复或者控制由Dispatchers.Main
启动的协程。这里我们调用advanceTimeBy(1_000)
,从而让主dispatcher立即执行协程,并在1秒后恢复。
该测试是完全确定,它将总是按照相同的方式执行。并且,因为在Dispatchers.Main
上启动的协程是完全可控的,不需要等待1s再设置值。
为了测试下效果,你可以在AS中运行该测试Case。
7. 从callbacks(回调)切换到coroutines(协程)
在该步骤中,你将开始在repository中使用协程。我们将添加协程到ViewModel
,Repository
,Room
及Retrofit
。
在我们使用协程之前,我们需要先了解各架构组件的职责。
1.MainDatabase
使用Room
实现的数据库,用于保存及加载Title
2.MainNetwork
实现了一个网络API用于获取一个新的title
。它使用Retrofit
来获取titles
。Retrofit
被配置成随机返回错误或者mock
数据,而不是发出了一个真的网络请求。
3.TitleRepository
实现了一个单例API用于获取或者刷新title
,通过将网络和数据库的数据做合并。
4.MainViewModel
用于展示状态并处理事件。它将通知repository
刷新title
,当用户点击屏幕时。
由于网络请求是通过UI-event
驱动的。并且我们想要通过它来启动一个协程,我们将在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)
}
})
}
该函数在用户每次点击屏幕时都会调用,这将引起repository
刷新title
,并写入新的title
到数据库中。
回调实现将做如下几件事:
- 查询开始之前,
_spinner.value = true
操作将会显示一个加载spinner - 当获取到结果后,
_spinner.value = true
操作将会清掉加载spinner - 如果发生错误,将通过snackbar显示,并清理掉spinner
注意 onCompleted
回调没有传递title
。由于我们将所有的title写入到Room 数据库中,UI将通过观察LiveData
来更新当前的title。
在下面的协程版本中,我们将做同样的事。使用可观察数据源来自动保持UI的更新是一个很好的方式,例如Room
数据库。
小提示:
object: TitleRefreshCallback
是什么意思呢?
这是kotlin的匿名类写法,通过实现TitleRefreshCallback
接口来创建一个匿名对象。
协程版本
我们将使用协程重写refreshTitle
方法!
接下来,我们将在TitleRespository.kt
写一个空的挂起函数。使用suspend
定义一个新函数,用于告诉Kotlin将使用协程工作。
TitleRepository.kt
suspend fun refreshTitle() {
// TODO: Refresh from network and write to database
delay(500)
}
在该代码段中,我们将使用协程的方式 ,通过Retrofit
和Room
获取一个新的title
并写入到数据库。现在,我们在这里做个500ms的延迟来模拟这些工作,然后继续:
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 {
和协程更新tap计数一样,我们在viewModelScope
中启动一个协程。该协程将运行在Dispatchers.Main
中。尽管refreshTitle
将发起一个网络请求和一个数据库查询,在使用协程时将是main-safe
的接口。这意味着从主线程调用将是安全的。
因为我们使用了viewModelScope
,当用户销毁了界面,协程将被自动取消。从而不会再发出网络请求和数据库查询。
小贴士:
启动一个协程使用launch
该方式中,如果launch中抛出一个异常,将被自动传递给未捕获异常处理者(可以导致App crash)。使用async
启动协程将不会抛出一个异常,只有当你调用await
时才会抛出异常。然而,你只能在协程内部调用await
,由于它是suspend函数。
一旦在一个协程内部,你可以使用launch或者async来启动一个子协程。当你不需要结果返回时使用launch
,需要结果返回时使用async
接下来的代码,我们将在repository
中调用refreshTitle
函数。
try {
_spinner.value = true
repository.refreshTitle()
}
在这个协程启动之前,将首先显示一个正在加载的spinner-然后正常调用refreshTitle
。然而,由于refreshTitle
是一个挂起函数,和普通函数执行有很大不同。
我们不需要传递一个callback。协程将被挂起直到refreshTitle
调用后恢复。当它看起来像一个普通的阻塞函数调用,它将自动等待知道网络和数据库查询完成,而不会阻塞主线程。
} catch (error: TitleRefreshError) {
_snackBar.value = error.message
} finally {
_spinner.value = false
}
异常的处理在suspend函数同普通函数一样。如果你在一个suspend函数中抛出一个错误,它将会抛给调用者。因此尽管他们的执行有很大的不同,你可以使用普通的try/catch
代码块处理它们。
这是非常有用的,它允许你依赖内建语言支持来进行错误处理,而不用为每个回调建立单独的错误处理器。
在finally代码块中,我们可以确保spinner在查询后总是关闭的。
小贴士:
未捕获的异常将如何处理?
在协程中,未捕获的异常的处理同非协程代码块中相同。默认条件下,它们将取消协程中的Job,并通知父协程自动取消其Jobs。如果没有协程处理这些异常,它最后将传递给CoroutineScope
中的一个未捕获异常处理器。
默认情况下,未捕获异常将发送到JVM中的未捕获异常处理器中处理。你可以自定义一个CoroutineExceptionHandler
用来做异常处理。
8. 从阻塞代码块提炼线程安全函数
本节内容中,你将学习到如何切换一个协程运行的线程。
打开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
通过回调方式来通知调用者加载进度和错误状态。
为了实现刷新功能,该函数做了几件事情。
1.使用BACKGROUND ExecutorService
切换到后台线程。
2.运行fetchNextTitle
进行网络请求,并使用execute()
方法进行阻塞调用。网络请求将在当前线程中运行,该场景下BACKGROUND
线程。
3.如何结果成功,使用insertTitle
保存到数据库中,并调用onCompleted()
方法.
4.如何结果未成功,或者存在异常,则调用onError
方法通知调用者刷新失败。
回调的实现是main-safe
安全的,因为它不会阻塞主线程。但是,当任务完成时,它不得不使用回调来通知调用者。
从协程中进行阻塞调用
不需要向网络和数据库引入协程,我们可以使用协程让代码main-safe
。这可以让我们摆脱回调并让我们回传结果到调用线程。你可以在任何时候使用这种方式,当你需要在一个协程内部做阻塞任务或者CPU密集型任务,例如排序、对一个大的列表过滤或者从磁盘读取数据。
小贴士:
这种方式应该通过阻塞API的方式集成或者执行CPU密集型工作。如果有可能,最好使用suspend
函数,例如Room或者Retrofi都有提供。
在任何dispatcher
间切换,协程使用withContext
。调用withContext
切换到其他dispatcher
,然后携带结果切换回来,这个过程都是通过lambda
方式完成的。
默认情况下,Kotlin协程提供了3种Dispatchers:Main
,IO
和Default.IO dispatcher
是对IO操作的优化,例如从网络或者磁盘读取数据,Default dispatcher
是对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)
}
}
}
该代码实现使用阻塞调用从网络或者数据库获取数据,想较于回调版本简单了些许。
该代码仍然使用了阻塞调用。调用excute()
和insertTitle(...)
都将阻塞线程,该线程是协程运行所在。然后,使用witchContext
来切换到Dispatchers.IO
,我们将在某个IO线程中阻塞。协程可能运行在Dispatchers.Main
中,调用将被挂起直到withContext
lambda表达式完成调用。
同回调版本比较起来,有两点重要的不同:
withContext
将返回结果到Dispatcher,该场景下是Dispatchers.Main线程。回调版本中回调函数将在BACKGROUND
后台线程调用。- 调用者不需要给这个函数传递callback。他们可以通过
suspend
和resume
来获取结果或错误。
特别提示:
上面的代码没有支持协程取消
,但是协程取消在Kotlin中是被支持的,协程取消支持。这就意味着你的代码在做协程取消操作时需要明确的检查,无论在何时你调用kotlin协程函数。
因为withContext
代码块一旦被调用将不可被取消,直到有结果返回。
为了弥补这点,你可以调用yield
来允许其他协程运行并进行取消检查。你可能会在网络请求和数据库查询中间加入一个yield
调用。然后,在网络请求期间,协程被取消,数据将不会被保存到数据库中。你也可以检查明确取消,当你编写底层的协程接口时,你应该做明确的取消检查。
再次运行App
如果你再次运行该App,你将看到基于协程实现的效果,从网络加载数据。接下来,我们将在Room和Retrofit中集成协程。
9. Room&Retrofit中的协程
接下来我们将使用支持了suspend
函数的稳定版本的Room和Retrofit,使用suspend
函数的调用代码将是简单的顺序调用。
Room中的协程
打开MainDatabase.kt
文件并改写insertTitle
成一个挂起函数:
MainDatabase.kt
// add the suspend modifier to the existing insertTitle
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTitle(title: Title)
当你这样做时,Room将使你的查询main-safe
并自动在后台线程中执行。然而,这也意味着你只能在一个协程中调用该查询。
在Retrofit中使用协程
接下来,让我看看如何在Retrofit中集成协程。打开MainNetwork.kt
并将fetchNextTitle
改写成suspend
函数。将返回结果类型由Call<String>
改成String
类型。
小提示
Retrofit的挂起支持要求版本2.6.0及以上。
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的挂起函数,必须做以下两件事:
1.添加suspend
修饰符
2.将Call类型返回修改为String类,但是你也可以返回复杂的json格式。如果你想返回完整的结果Result
,你可以Result<String>
作为返回结果类型。
小提示
Room和Retrofit的挂起函数都是main-safe
的,从Dispatchers.Main
调用这些挂起函数是安全的,尽管是从网络获取数据并写入到数据库。
提示
Room和Retrofit使用自定义的dispatcher,并没有使用Dispatchers.IO
.
Room将使用默认的query
和transaction
Excutor来运行协程。Retrofit将在该线程创建一个新的Call
对象,并调用enqueue
发送异步请求。
使用Room和Retrofit
现在Room和Retrofi支持suspend函数,我们可以在repository
中使用它们。打开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)
}
}
代码非常简单。那么这是怎么回事呢?返回结果依赖于suspend
和resume
让代码如此简洁。Retrofit让我们可以返回像String
或者User
对象这样的结果类型,而不是Call对象。这种调用是安全的,因为内部的suspend
函数,Retrofit
可以将网络请求运行在后台线程并在调用完成时恢复。
更好的一点是,我们摆脱了witchContext
代码块。由于Room和Retrofit都提供了main-safe
挂起函数,从Dispatchers.Main
发起这些异步任务调用是非常安全的。
小提示
你不需要使用withContext
调用main-safe
挂起函数
通常,你需要确保suspend
函数是main-safe
。通过这种方式,从任何dispatcher调用是安全,包括在Dispatchers.Main
中。
修复编译错误
改写成协程确实需要更改函数的签名,因为你不能从常规函数调用挂起函数。当你在这一步中添加suspend
修饰符时,会有一些编译错误生成,用于告诉你如果将一个函数改写成suspend
函数会发生什么。
遍历整个工程,修改这些编译错误。下面有一些快速解决办法:
TestingFakes.kt
更新这个测试类用于支持挂起。
TitleDaoFake
1.键入alt-enter(Mac电脑上键入option-enter)用于将这个类中的所有函数添加suspend
修饰符。
MainNetworkFake
2. 键入alt-enter
将该类中所有函数添加suspend
修饰符
3.替换fetchNextTitle
函数为如下代码
override suspend fun fetchNextTitle() = result
MainNetworkCompletableFake
- 键入alt-enter 为
hierarchy
中的所有函数添加suspend
修饰符 - 替换
fetchNextTitle
为如下函数
override suspend fun fetchNextTitle() = completable.await()
TitleRepository.kt
- 删除
refreshTitleWithCallbacks
函数
再次运行app
再次运行app,一旦编译完成,你将看到它将通过协程加载数据,从ViewModel到Room和Retrofit。
你已经完成了从app的协程改造! 接下来,我们将讲一点关于如何测试的问题。
10. 直接测试协程
接下来,我们将写一个测试case直接调用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)
}
}
写一个调用suspend
函数的测试
打开test文件夹下的TitleRepositoryTest.kt
文件,有两个待实现的TODOS
.
尝试在第一个测试casewhenRefreshTitleSuccess_insertsRows
中调用refreshTitle
@Test
fun whenRefreshTitleSuccess_insertsRows() {
val subject = TitleRepository(
MainNetworkFake("OK"),
TitleDaoFake("title")
)
subject.refreshTitle()
}
由于refreshTitle
是一个suspend
函数,Kotlin只能从一个协程或者另一个suspend
函数调用它,你将得到如下编译错误,“Suspend function refreshTitle should be called only from a coroutine or another suspend function.”
test runner不知道协程,所以这个test不能被标记为suspend函数。我们可以用CoroutineScope
来launch
一个协程,就像在ViewModel
中一样,然而tests需要在返回结果前跑完协程。一旦一个test函数返回,test将结束。launch
启动的协程是异步代码,可能在未来的某个时间点完成。因此为了测试异步代码,你需要用一些方式告诉test等待,直到协程完成。由于launch
是非阻塞调用,那就意味在函数调用返回之后,它将立即返回并且可以继续运行一个协程-所以launch
将不可以用在tests中。例如:
@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
调用将立即返回,并在剩余测试case执行时执行。这个test无法得知refreshTitle
是否执行了-任何断言数据库更新等行为看起来是很奇怪。并且,如果refreshTitle
抛出了一个异常,它将无法在test调用栈中打印。它将在GlobalScope
中的未捕获异常处理器中抛出。
kotlinx-coroutines-test
Lib有runBlockingTest
函数,调用挂起函数时会被阻塞。当runBlockingTest
调用一个suspend函数时或launches
一个新的协程时,它将立即按照默认方式执行。你可以考虑将它作为一种转换方式,从挂起函数和协程转换成普通函数调用。
另外,runBlockingTest
将再次向你抛出未捕获的异常。这将使测试变得容易,当一个协程正在抛出一个异常。
重要提示:
runBlockingTest
函数将一直阻塞调用者,有点像普通函数调用。协程将在同一个线程中同步运行。你应该避免在你的app代码中调用runBlocking
和runBlockingTest
函数,并优先使用launch
,可以立即返回。
runBlockingTest
应该在某些特定场景下运行,如在一个测试控制管理器中执行协程,runBlocking
可以用于为协程提供阻塞接口。
用一个协程实现一个测试
用runBlockingTest
代码块包裹refreshTitle
调用,并从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
是否被写入到了数据库中。
当这个test调用了runBlockingTest
,它将阻塞直到协程运行完成。在runBlockingTest
内部,当我们调用refreshTitle
时。它将使用suspend
和resume机制等待数据库行添加完成。
当测试协程完成之后,runBlockingTest
将返回。
编写一个超时测试
我们想为网路请求添加一个超时机制。下面我们先写一个超时测试的实现。创建一个新的test:
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
类进行测试,这是一个网络fake,用于挂起调用者直到测试继续运行。当refreshTitle
尝试进行网络请求,它将一直挂起,因为我们想要测试超时。
然后,它将启动一个独立的协程来调用refreshTitle
。这是测试超时的关键,超时将发生在一个不同的协程中,这个协程不是runBlockingTest
创建的。通过这样做,我们可以调用下一行,advanceTimeBy(5_000)
, 这将导致时间提前5s,并引起其他协程超时。
运行这段测试将看到如下异常信息:
Caused by: kotlinx.coroutines.test.UncompletedCoroutinesError: Test finished with active jobs: ["...]
runBlockingTest
的一个特点就是在你完成测试后,将不会让你泄漏协程。如果有任何未完成的协程任务,例如,我们在测试结束时启动的协程,将导致测试失败。
添加一个超时
打开文件TitleRepository
,为网络获取添加5s超时。你可以使用witchTimeout
函数完成这个操作:
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)
}
}
运行相关测试项,你将看到所有测试都通过了!
下面的学习中,你将学习如何使用协程编写高阶函数。
提示:
runBlockingTest
依赖TestCoroutineDispatcher
来控制协程。
因此,在使用runBlockingTest
时注入一个TestCoroutineDispatcher
实例或者TestCoroutineScope
实例是一个很不错的做法。这可以使协程成为单线程,并提供明确控制测试中所有协程的能力。
如果你不想改变协程的这个能力——例如在一个集成测试中-你可以用runBlocking
来替代所有dispatchers的默认实现。
runBlockingTest
是实验性的,当前还有bug,在协程切换线程时候会导致测试失败。最终的稳定版本将解决这个bug
11. 在高阶函数中使用协程
在接下来的练习中,你将重构MainViewMode
中的refreshTitle
函数用于通用数据加载功能。这将教会你u如何使用协程编写高阶函数。
在refreshTitle
的当前实现中,我们可以创建一个通用的数据加载协程,总是显示spinner。这在代码库中是很有用的,比如从响应中加载数据成几个事件,你想要确保加载进度的spinner持续显示。
请看下面代码的实现,refreshTitle()
是一个样板代码实现,用于显示spinner
及错误。
// 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
}
}
}
重要提示:
尽管我们在代码中仅使用了viewModelScope
,一般来说,可以在任何有意义的地方添加一个scope。但是当它不再需要时,不要忘记取消它。
例如,你可以在RecyclerView
的Adapter
中声明一个执行DiffUtil
的操作。
在高阶函数中使用协程
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
携带了一个block
参数,该参数是一个suspend
的lambda表达式。一个suspend lambda表达式让你可以调用suspend
函数。这就是Kotlin使用launch
和runBlocking
来实现协程的方式。
// suspend lambda
block: suspend () -> Unit
suspend lambda表达式以suspend
关键词开头。这个函数将指向并返回Unit
类型的结果。
12.WorkManager配合协程使用
什么是WorkManager
WorkManager是Google官方出品的标准Lib,是Jetpack组件之一,用于处理后台任务,可以确保任务有机会执行,即使用户退出了app,任务仍然可以在后台在设定的条件下执行。
因为这个特性,对于那些必须要完成的任务,WorkManager是个很好的选择。
WorkManager的应用场景:
- 上传Log
- 过滤图片并保存图片
- 周期性地从网络同步数据到本地
小提示
学习更多WorkManager的内容,请参考相关文档
WorkManager配合协程使用
WorkManager提供了ListenableWorker
类的不同实现以应对不同应用场景。
最简单的Worker类允许我们使用WorkManager执行一些同步操作。然而,到目前为止,我们一直致力于将代码库转换为使用协程和挂起函数,使用WorkManager最好的方式是使用CoroutineWork
类,这个类允许我们定义doWork()
函数为挂起函数。
打开RefreshMainDataWork
文件,它已经继承了CoroutineWorker
类,你需要实现doWork
方法。
在挂起函数doWork
内部,调用refreshTitle()
并返回合适的结果!
在你完成这些工作后,代码将看起来是如下形式:
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
类不同的是,这个函数不会运行在Executor上,而Executor是在配置WorkManager时配置的,doWork()
使用的是coroutineContext
中的dispatcher(默认的是Dispatchers.Default
)
测试CoroutineWorker
类
WorkManager提供了几种不同的方法来测试Worker类,学习这些测试框架,请参考文档
WorkManager v2.1引入了一系列新的API用于测试ListenableWorker
类,接下来我们将使用这些新的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传入factory参数,从而我们可以注入fake
network对象。
测试本身使用TestListenableWorkerBuilder
来创建worker,从而可以调用startWork()
方法。
13. 总结
通过本文,你已经学会了在你的app中使用协程的基本技能。
我们学习了:
- 如何集成协程到你的Android app中,从UI和WorkManager Jobs到简单的异步编程,
- 如何在
ViewModel
中使用协程从网络获取数据并保存的数据库中,而这个过程不会阻塞主线程。 - 如何在ViewModel销毁时,取消所有的协程。
对于测试基于协程的代码,我们可以直接调用挂起函数来进行测试。