1. 开始之前
简介
在上一个 Codelab 中,您学习了如何通过 ViewModel
使用 API 服务从网络检索火星照片的 URL 来从 Web 服务获取数据。虽然这种方法可行且易于实现,但随着应用的增长并需要处理多个数据源时,其扩展性并不好。为了解决此问题,Android 架构最佳实践建议将 UI 层和数据层分开。
在本 Codelab 中,您将把 Mars Photos 应用重构为独立的 UI 层和数据层。您将学习如何实现仓库模式并使用依赖注入。依赖注入创建了一种更灵活的代码结构,有助于开发和测试。
前提条件
- 能够使用 Retrofit 和 Serialization (kotlinx.serialization) 库从 REST Web 服务检索 JSON 并将数据解析为 Kotlin 对象。
- 了解如何使用 REST Web 服务。
- 能够在您的应用中实现协程。
您将学到什么
- 仓库模式
- 依赖注入
您将构建什么
- 修改 Mars Photos 应用,将应用分为 UI 层和数据层。
- 在分离数据层时,您将实现仓库模式。
- 使用依赖注入创建松散耦合的代码库。
您需要什么
- 一台装有现代 Web 浏览器的计算机,例如最新版本的 Chrome
获取入门代码
首先,下载入门代码
或者,您可以克隆代码的 GitHub 仓库
$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-mars-photos.git $ cd basic-android-kotlin-compose-training-mars-photos $ git checkout repo-starter
您可以在 Mars Photos
GitHub 仓库中浏览代码。
2. 分离 UI 层和数据层
为什么要有不同的层?
将代码分成不同的层可以使您的应用更具可扩展性、更健壮,并且更容易测试。拥有多个边界清晰的层也使得多个开发者更容易在同一个应用上协作而不互相影响。
Android 推荐的应用架构指出,应用应至少包含 UI 层和数据层。
在本 Codelab 中,您将专注于数据层并进行更改,以便您的应用遵循推荐的最佳实践。
什么是数据层?
数据层负责应用的业务逻辑以及为应用获取和保存数据。数据层使用单向数据流模式向 UI 层公开数据。数据可以来自多个来源,例如网络请求、本地数据库或设备上的文件。
一个应用甚至可能有多个数据源。应用打开时,它会从设备上的本地数据库检索数据,这是第一个来源。应用运行时,它会向第二个来源发起网络请求以检索更新的数据。
将数据与 UI 代码分离,您可以在代码的一部分中进行更改而不影响另一部分。这种方法是关注点分离设计原则的一部分。一段代码专注于自己的关注点,并将内部工作原理与其他代码封装起来。封装是一种向其他代码段隐藏代码内部工作方式的形式。当一段代码需要与另一段代码交互时,它通过接口进行交互。
UI 层的关注点是显示提供给它的数据。UI 不再检索数据,因为这是数据层的关注点。
数据层由一个或多个仓库组成。仓库本身包含零个或多个数据源。
最佳实践要求您的应用为使用的每种类型的数据源拥有一个仓库。
在本 Codelab 中,应用有一个单一数据源,因此重构代码后会有一个仓库。对于此应用,从互联网检索数据的仓库完成了数据源的职责。它通过向 API 发起网络请求来完成此操作。如果数据源编码更复杂或添加了额外的数据源,数据源的职责将封装在单独的数据源类中,而仓库负责管理所有数据源。
什么是仓库?
通常,仓库类
- 向应用的其余部分公开数据。
- 集中管理数据的更改。
- 解决多个数据源之间的冲突。
- 将数据源从应用的其余部分抽象出来。
- 包含业务逻辑。
Mars Photos 应用有一个单一数据源,即网络 API 调用。它没有任何业务逻辑,因为它只是检索数据。数据通过仓库类暴露给应用,该类抽象了数据的来源。
3. 创建数据层
首先,您需要创建仓库类。Android 开发者指南指出,仓库类以其负责的数据命名。仓库命名约定是 数据类型 + Repository。在您的应用中,这是 MarsPhotosRepository
。
创建仓库
- 右键点击 com.example.marsphotos,然后选择 New > Package。
- 在对话框中,输入
data
。 - 右键点击
data
包,然后选择 New > Kotlin Class/File。 - 在对话框中,选择 Interface,然后输入
MarsPhotosRepository
作为接口名称。 - 在
MarsPhotosRepository
接口中,添加一个抽象函数getMarsPhotos()
,它返回一个MarsPhoto
对象列表。该函数从协程调用,因此使用suspend
声明它。
import com.example.marsphotos.model.MarsPhoto
interface MarsPhotosRepository {
suspend fun getMarsPhotos(): List<MarsPhoto>
}
- 在接口声明下方,创建一个名为
NetworkMarsPhotosRepository
的类,用于实现MarsPhotosRepository
接口。 - 将接口
MarsPhotosRepository
添加到类声明中。
由于您没有覆盖接口的抽象方法,因此会出现错误消息。下一步将解决此错误。
- 在
NetworkMarsPhotosRepository
类中,覆盖抽象函数getMarsPhotos()
。此函数返回调用MarsApi.retrofitService.getPhotos()
的数据。
import com.example.marsphotos.network.MarsApi
class NetworkMarsPhotosRepository() : MarsPhotosRepository {
override suspend fun getMarsPhotos(): List<MarsPhoto> {
return MarsApi.retrofitService.getPhotos()
}
}
接下来,您需要更新 ViewModel
代码以使用仓库获取数据,如 Android 最佳实践所建议。
- 打开
ui/screens/MarsViewModel.kt
文件。 - 向下滚动到
getMarsPhotos()
方法。 - 将行 "
val listResult = MarsApi.retrofitService.getPhotos()
" 替换为以下代码
import com.example.marsphotos.data.NetworkMarsPhotosRepository
val marsPhotosRepository = NetworkMarsPhotosRepository()
val listResult = marsPhotosRepository.getMarsPhotos()
- 运行应用。注意,显示的结果与之前的结果相同。
仓库提供了数据,而不是由 ViewModel
直接发起网络请求获取数据。ViewModel
不再直接引用 MarsApi
代码。
这种方法有助于使检索数据的代码与 ViewModel
松散耦合。松散耦合允许更改 ViewModel
或仓库而不互相产生不利影响,只要仓库有一个名为 getMarsPhotos()
的函数。
现在我们可以更改仓库内部的实现而不影响调用方。对于更大的应用,这种更改可以支持多个调用方。
4. 依赖注入
很多时候,类需要其他类的对象才能正常工作。当一个类需要另一个类时,所需的类被称为依赖。
在以下示例中,Car
对象依赖于一个 Engine
对象。
类获取这些所需对象有两种方式。一种方式是类自己实例化所需的对象。
interface Engine {
fun start()
}
class GasEngine : Engine {
override fun start() {
println("GasEngine started!")
}
}
class Car {
private val engine = GasEngine()
fun start() {
engine.start()
}
}
fun main() {
val car = Car()
car.start()
}
另一种方式是将所需的对象作为参数传递进来。
interface Engine {
fun start()
}
class GasEngine : Engine {
override fun start() {
println("GasEngine started!")
}
}
class Car(private val engine: Engine) {
fun start() {
engine.start()
}
}
fun main() {
val engine = GasEngine()
val car = Car(engine)
car.start()
}
让类实例化所需对象很容易,但这种方法使得代码不灵活且更难测试,因为类和所需对象是紧密耦合的。
调用类需要调用对象的构造函数,这是一个实现细节。如果构造函数发生变化,调用代码也需要随之变化。
为了使代码更灵活和适应性更强,类不能实例化其依赖的对象。它依赖的对象必须在类外部实例化,然后传递进来。这种方法创建了更灵活的代码,因为类不再硬编码到某个特定对象。所需对象的实现可以更改而无需修改调用代码。
继续前面的例子,如果需要一个 ElectricEngine
,可以创建它并将其传递给 Car
类。Car
类不需要以任何方式修改。
interface Engine {
fun start()
}
class ElectricEngine : Engine {
override fun start() {
println("ElectricEngine started!")
}
}
class Car(private val engine: Engine) {
fun start() {
engine.start()
}
}
fun main() {
val engine = ElectricEngine()
val car = Car(engine)
car.start()
}
传入所需对象称为依赖注入 (DI)。它也被称为控制反转。
DI 是指在运行时提供依赖,而不是硬编码到调用类中。
实现依赖注入
- 有助于代码重用。代码不依赖于特定对象,因此具有更大的灵活性。
- 使重构更容易。代码是松散耦合的,因此重构代码的一部分不会影响代码的另一部分。
- 有助于测试。测试期间可以传入测试对象。
依赖注入如何帮助测试的一个例子是在测试网络调用代码时。对于此测试,您实际上是想测试网络调用是否已发起以及数据是否已返回。如果在测试期间每次发起网络请求都需要付费,您可能会决定跳过对此代码的测试,因为它可能很昂贵。现在,想象一下我们是否可以伪造网络请求进行测试。这会让您快乐(和富有)多少?对于测试,您可以将测试对象传递给仓库,该对象在被调用时返回伪造的数据,而无需实际执行真正的网络调用。
我们希望 ViewModel
是可测试的,但它目前依赖于进行实际网络调用的仓库。使用真实的生产仓库进行测试时,它会发起多次网络调用。为了解决此问题,我们需要一种方式来动态决定并传递用于生产和测试的仓库实例,而不是由 ViewModel
创建仓库。
此过程通过实现一个应用容器来完成,该容器将仓库提供给 MarsViewModel
。
一个容器是一个对象,它包含应用所需的依赖项。这些依赖项在整个应用中都会使用,因此它们需要放在所有 Activity 都可以访问的公共位置。您可以创建 Application 类的子类并存储对容器的引用。
创建应用容器
- 右键点击
data
包,然后选择 New > Kotlin Class/File。 - 在对话框中,选择 Interface,然后输入
AppContainer
作为接口名称。 - 在
AppContainer
接口内部,添加一个抽象属性marsPhotosRepository
,类型为MarsPhotosRepository
。 - 在接口定义下方,创建一个名为
DefaultAppContainer
的类,该类实现AppContainer
接口。 - 从
network/MarsApiService.kt
文件中,将变量BASE_URL
、retrofit
和retrofitService
的代码移动到DefaultAppContainer
类中,以便它们都位于维护依赖项的容器内。
import retrofit2.Retrofit
import com.example.marsphotos.network.MarsApiService
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
class DefaultAppContainer : AppContainer {
private const val BASE_URL =
"https://android-kotlin-fun-mars-server.appspot.com"
private val retrofit: Retrofit = Retrofit.Builder()
.addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
.baseUrl(BASE_URL)
.build()
private val retrofitService: MarsApiService by lazy {
retrofit.create(MarsApiService::class.java)
}
}
- 对于变量
BASE_URL
,移除const
关键字。移除const
是必要的,因为BASE_URL
不再是顶层变量,而是DefaultAppContainer
类的一个属性。将其重构为驼峰命名法baseUrl
。 - 对于变量
retrofitService
,添加private
可见性修饰符。添加private
修饰符是因为变量retrofitService
仅在类内部由属性marsPhotosRepository
使用,因此不需要在类外部访问。 DefaultAppContainer
类实现了AppContainer
接口,因此我们需要覆盖marsPhotosRepository
属性。在变量retrofitService
之后,添加以下代码
override val marsPhotosRepository: MarsPhotosRepository by lazy {
NetworkMarsPhotosRepository(retrofitService)
}
完成的 DefaultAppContainer
类应如下所示
class DefaultAppContainer : AppContainer {
private val baseUrl =
"https://android-kotlin-fun-mars-server.appspot.com"
/**
* Use the Retrofit builder to build a retrofit object using a kotlinx.serialization converter
*/
private val retrofit = Retrofit.Builder()
.addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
.baseUrl(baseUrl)
.build()
private val retrofitService: MarsApiService by lazy {
retrofit.create(MarsApiService::class.java)
}
override val marsPhotosRepository: MarsPhotosRepository by lazy {
NetworkMarsPhotosRepository(retrofitService)
}
}
- 打开
data/MarsPhotosRepository.kt
文件。我们现在将retrofitService
传递给NetworkMarsPhotosRepository
,并且您需要修改NetworkMarsPhotosRepository
类。 - 在
NetworkMarsPhotosRepository
类声明中,添加构造函数参数marsApiService
,如以下代码所示。
import com.example.marsphotos.network.MarsApiService
class NetworkMarsPhotosRepository(
private val marsApiService: MarsApiService
) : MarsPhotosRepository {
- 在
NetworkMarsPhotosRepository
类中,在getMarsPhotos()
函数中,更改返回语句以从marsApiService
检索数据。
override suspend fun getMarsPhotos(): List<MarsPhoto> = marsApiService.getPhotos()
}
- 从
MarsPhotosRepository.kt
文件中移除以下导入。
// Remove
import com.example.marsphotos.network.MarsApi
从 network/MarsApiService.kt
文件中,我们已将所有代码移出对象。由于不再需要,我们现在可以删除剩余的对象声明。
- 删除以下代码
object MarsApi {
}
5. 将应用容器附加到应用
本节中的步骤将应用对象连接到应用容器,如以下图所示。
- 右键点击
com.example.marsphotos
,然后选择 New > Kotlin Class/File。 - 在对话框中,输入
MarsPhotosApplication
。此类别继承自应用对象,因此您需要将其添加到类声明中。
import android.app.Application
class MarsPhotosApplication : Application() {
}
- 在
MarsPhotosApplication
类内部,声明一个名为container
的变量,类型为AppContainer
,用于存储DefaultAppContainer
对象。该变量在调用onCreate()
期间初始化,因此需要使用lateinit
修饰符标记。
import com.example.marsphotos.data.AppContainer
import com.example.marsphotos.data.DefaultAppContainer
lateinit var container: AppContainer
override fun onCreate() {
super.onCreate()
container = DefaultAppContainer()
}
- 完整的
MarsPhotosApplication.kt
文件应如下所示
package com.example.marsphotos
import android.app.Application
import com.example.marsphotos.data.AppContainer
import com.example.marsphotos.data.DefaultAppContainer
class MarsPhotosApplication : Application() {
lateinit var container: AppContainer
override fun onCreate() {
super.onCreate()
container = DefaultAppContainer()
}
}
- 您需要更新 Android 清单,以便应用使用您刚刚定义的应用类。打开
manifests/AndroidManifest.xml
文件。
- 在
application
部分,添加android:name
属性,其值为应用类名称".MarsPhotosApplication"
。
<application
android:name=".MarsPhotosApplication"
android:allowBackup="true"
...
</application>
6. 将仓库添加到 ViewModel
完成这些步骤后,ViewModel
就可以调用仓库对象来检索火星数据了。
- 打开
ui/screens/MarsViewModel.kt
文件。 - 在
MarsViewModel
的类声明中,添加一个私有构造函数参数marsPhotosRepository
,类型为MarsPhotosRepository
。构造函数参数的值来自应用容器,因为应用现在使用了依赖注入。
import com.example.marsphotos.data.MarsPhotosRepository
class MarsViewModel(private val marsPhotosRepository: MarsPhotosRepository) : ViewModel(){
- 在
getMarsPhotos()
函数中,删除以下代码行,因为marsPhotosRepository
现在已在构造函数调用中填充。
val marsPhotosRepository = NetworkMarsPhotosRepository()
- 由于 Android 框架不允许在创建
ViewModel
时在构造函数中传递值,我们实现了ViewModelProvider.Factory
对象,这使我们能够规避此限制。
工厂模式是一种用于创建对象的创建型模式。MarsViewModel.Factory
对象使用应用容器检索 marsPhotosRepository
,然后在创建 ViewModel
对象时将此仓库传递给 ViewModel
。
- 在函数
getMarsPhotos()
下方,输入伴生对象的代码。
伴生对象通过提供一个供所有人使用的单一对象实例而无需创建新的昂贵对象实例来帮助我们。这是一个实现细节,将其分离可以让我们进行更改而不会影响应用代码的其他部分。
APPLICATION_KEY
是 ViewModelProvider.AndroidViewModelFactory.Companion
对象的一部分,用于查找应用的 MarsPhotosApplication
对象,该对象具有用于检索依赖注入所用仓库的 container
属性。
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.example.marsphotos.MarsPhotosApplication
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = (this[APPLICATION_KEY] as MarsPhotosApplication)
val marsPhotosRepository = application.container.marsPhotosRepository
MarsViewModel(marsPhotosRepository = marsPhotosRepository)
}
}
}
- 打开
theme/MarsPhotosApp.kt
文件,在MarsPhotosApp()
函数内部,更新viewModel()
以使用工厂。
Surface(
// ...
) {
val marsViewModel: MarsViewModel =
viewModel(factory = MarsViewModel.Factory)
// ...
}
此 marsViewModel
变量通过调用 viewModel()
函数填充,该函数将伴生对象中的 MarsViewModel.Factory
作为参数传递以创建 ViewModel
。
- 运行应用以确认其行为与之前一致。
恭喜您将 Mars Photos 应用重构以使用仓库和依赖注入!通过使用仓库实现数据层,UI 和数据源代码已分离,遵循了 Android 最佳实践。
通过使用依赖注入,更容易测试 ViewModel
。您的应用现在更加灵活、健壮,并随时可以扩展。
进行这些改进后,现在是学习如何测试它们的时候了。测试可以确保您的代码按预期运行,并减少在继续处理代码时引入错误的可能。
7. 设置本地测试
在前面的章节中,您实现了一个仓库,以将与 REST API 服务的直接交互从 ViewModel
中抽象出来。这种实践使您能够测试用途有限的小段代码。与为具有多种功能的大段代码编写的测试相比,为功能有限的小段代码编写的测试更容易构建、实现和理解。
您还通过利用接口、继承和依赖注入实现了仓库。在接下来的章节中,您将了解为什么这些架构最佳实践使测试更容易。此外,您还使用了 Kotlin 协程来发起网络请求。测试使用协程的代码需要额外的步骤来考虑代码的异步执行。这些步骤将在本 Codelab 的后面介绍。
添加本地测试依赖项
将以下依赖项添加到 app/build.gradle.kts
。
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1")
创建本地测试目录
- 通过右键点击项目视图中的 src 目录并选择 New > Directory > test/java 来创建本地测试目录。
- 在 test 目录中创建一个名为
com.example.marsphotos
的新包。
8. 创建用于测试的伪造数据和依赖项
在本节中,您将学习依赖注入如何帮助您编写本地测试。在 Codelab 的前面部分,您创建了一个依赖于 API 服务的仓库。然后,您修改了 ViewModel
以使其依赖于该仓库。
每个本地测试只需要测试一件事。例如,当您测试视图模型的功时,您不希望测试仓库或 API 服务的功。同样,当您测试仓库时,您不希望测试 API 服务。
通过使用接口以及随后的依赖注入来包含继承自这些接口的类,您可以使用专门为测试目的创建的伪造类来模拟这些依赖项的功。注入伪造类和数据源进行测试允许代码独立测试,具有可重复性和一致性。
您首先需要的是用于稍后创建的伪造类中的伪造数据。
- 在 test 目录中,在
com.example.marsphotos
下创建一个名为fake
的包。 - 在
fake
目录中创建一个新的 Kotlin 对象,名为FakeDataSource
。 - 在此对象中,创建一个属性,设置为
MarsPhoto
对象列表。列表不必很长,但应至少包含两个对象。
object FakeDataSource {
const val idOne = "img1"
const val idTwo = "img2"
const val imgOne = "url.1"
const val imgTwo = "url.2"
val photosList = listOf(
MarsPhoto(
id = idOne,
imgSrc = imgOne
),
MarsPhoto(
id = idTwo,
imgSrc = imgTwo
)
)
}
本 codelab 前面提到,仓库依赖于 API 服务。要创建仓库测试,必须有一个返回您刚刚创建的伪造数据的伪造 API 服务。当此伪造 API 服务传递到仓库中时,仓库在调用伪造 API 服务中的方法时会收到伪造数据。
- 在
fake
包中,创建一个名为FakeMarsApiService
的新类。 - 设置
FakeMarsApiService
类以继承MarsApiService
接口。
class FakeMarsApiService : MarsApiService {
}
- 覆盖
getPhotos()
函数。
override suspend fun getPhotos(): List<MarsPhoto> {
}
- 从
getPhotos()
方法返回伪造照片列表。
override suspend fun getPhotos(): List<MarsPhoto> {
return FakeDataSource.photosList
}
请记住,如果您仍然不清楚此类别的用途,没关系!此伪造类别的用途将在下一节中详细解释。
9. 编写仓库测试
在本节中,您将测试 NetworkMarsPhotosRepository
类的 getMarsPhotos()
方法。本节阐明了伪造类的用法,并演示了如何测试协程。
- 在 fake 目录中,创建一个名为
NetworkMarsRepositoryTest
的新类。 - 在您刚刚创建的类中创建一个新方法,名为
networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList()
,并使用@Test
进行注解。
@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList(){
}
要测试仓库,您需要 NetworkMarsPhotosRepository
的实例。回想一下,此类依赖于 MarsApiService
接口。这就是您利用上一节中伪造 API 服务的地方。
- 创建一个
NetworkMarsPhotosRepository
实例,并将FakeMarsApiService
作为marsApiService
参数传递。
@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList(){
val repository = NetworkMarsPhotosRepository(
marsApiService = FakeMarsApiService()
)
}
通过传递伪造的 API 服务,对仓库中 marsApiService
属性的任何调用都会导致调用 FakeMarsApiService
。通过传递依赖项的伪造类,您可以精确控制依赖项返回什么。这种方法确保您正在测试的代码不依赖于未经测试的代码或可能更改或存在意外问题的 API。这种情况可能导致您的测试失败,即使您编写的代码没有问题。伪造类有助于创建更一致的测试环境,减少测试的偶然性,并促进测试单一功能的简洁测试。
- 断言
getMarsPhotos()
方法返回的数据等于FakeDataSource.photosList
。
@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList(){
val repository = NetworkMarsPhotosRepository(
marsApiService = FakeMarsApiService()
)assertEquals(FakeDataSource.photosList, repository.getMarsPhotos())
}
请注意,在您的 IDE 中,getMarsPhotos()
方法调用下方有红色下划线。
如果您将鼠标悬停在该方法上,您会看到一个工具提示,指示“挂起函数 ‘getMarsPhotos’ 只能从协程或其他挂起函数中调用:”
在 data/MarsPhotosRepository.kt
文件中,查看 NetworkMarsPhotosRepository
中 getMarsPhotos()
的实现,您会看到 getMarsPhotos()
函数是一个挂起函数。
class NetworkMarsPhotosRepository(
private val marsApiService: MarsApiService
) : MarsPhotosRepository {
/** Fetches list of MarsPhoto from marsApi*/
override suspend fun getMarsPhotos(): List<MarsPhoto> = marsApiService.getPhotos()
}
记住,当您从 MarsViewModel
调用此函数时,您是通过调用传递给 viewModelScope.launch()
的 lambda 来从协程中调用此方法的。在测试中,您也必须从协程中调用挂起函数,例如 getMarsPhotos()
。但是,方法是不同的。下一节讨论如何解决此问题。
测试协程
在本节中,您将修改 networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList()
测试,以便测试方法的主体从协程运行。
- 修改
NetworkMarsRepositoryTest.kt
文件中的networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList()
函数,使其成为一个表达式。
@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList() =
- 将表达式设置为等于
runTest()
函数。此方法需要一个 lambda。
...
import kotlinx.coroutines.test.runTest
...
@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList() =
runTest {}
协程测试库提供了 runTest()
函数。该函数接受您在 lambda 中传递的方法,并从 TestScope
(继承自 CoroutineScope
)运行它。
- 将测试函数的内容移动到 lambda 函数中。
@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList() =
runTest {
val repository = NetworkMarsPhotosRepository(
marsApiService = FakeMarsApiService()
)
assertEquals(FakeDataSource.photosList, repository.getMarsPhotos())
}
请注意,getMarsPhotos()
下方的红线现在消失了。如果您运行此测试,它会通过!
10. 编写 ViewModel 测试
在本节中,您将为 MarsViewModel
中的 getMarsPhotos()
函数编写测试。MarsViewModel
依赖于 MarsPhotosRepository
。因此,要编写此测试,您需要创建一个伪造的 MarsPhotosRepository
。此外,除了使用 runTest()
方法外,还需要考虑协程的一些额外步骤。
创建伪造仓库
此步骤的目标是创建一个伪造类,该类继承自 MarsPhotosRepository
接口并覆盖 getMarsPhotos()
函数以返回伪造数据。这种方法与您处理伪造 API 服务的方法类似,区别在于此类扩展的是 MarsPhotosRepository
接口而不是 MarsApiService
。
- 在
fake
目录中创建一个名为FakeNetworkMarsPhotosRepository
的新类。 - 使用
MarsPhotosRepository
接口扩展此类。
class FakeNetworkMarsPhotosRepository : MarsPhotosRepository{
}
- 覆盖
getMarsPhotos()
函数。
class FakeNetworkMarsPhotosRepository : MarsPhotosRepository{
override suspend fun getMarsPhotos(): List<MarsPhoto> {
}
}
- 从
getMarsPhotos()
函数返回FakeDataSource.photosList
。
class FakeNetworkMarsPhotosRepository : MarsPhotosRepository{
override suspend fun getMarsPhotos(): List<MarsPhoto> {
return FakeDataSource.photosList
}
}
编写 ViewModel 测试
- 创建一个名为
MarsViewModelTest
的新类。 - 创建一个名为
marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess()
的函数,并用@Test
进行注解。
@Test
fun marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess()
- 将此函数设置为等于
runTest()
方法的结果,以确保测试从协程运行,就像上一节中的仓库测试一样。
@Test
fun marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess() =
runTest{
}
- 在
runTest()
的 lambda 主体中,创建MarsViewModel
的实例,并将其传递您创建的伪造仓库实例。
@Test
fun marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess() =
runTest{
val marsViewModel = MarsViewModel(
marsPhotosRepository = FakeNetworkMarsPhotosRepository()
)
}
- 断言
ViewModel
实例的marsUiState
与成功调用MarsPhotosRepository.getMarsPhotos()
的结果匹配。
@Test
fun marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess() =
runTest {
val marsViewModel = MarsViewModel(
marsPhotosRepository = FakeNetworkMarsPhotosRepository()
)
assertEquals(
MarsUiState.Success("Success: ${FakeDataSource.photosList.size} Mars " +
"photos retrieved"),
marsViewModel.marsUiState
)
}
如果您尝试按原样运行此测试,它将失败。错误看起来类似于以下示例
Exception in thread "Test worker @coroutine#1" java.lang.IllegalStateException: Module with the Main dispatcher had failed to initialize. For tests Dispatchers.setMain from kotlinx-coroutines-test module can be used
回想一下,MarsViewModel
使用 viewModelScope.launch()
调用仓库。此指令在默认的协程调度器(称为 Main
调度器)下启动一个新的协程。Main
调度器封装了 Android UI 线程。前面错误的原因是 Android UI 线程在单元测试中不可用。单元测试在您的工作站上执行,而不是 Android 设备或模拟器上。如果本地单元测试下的代码引用了 Main
调度器,则在运行单元测试时会抛出异常(如上所示)。为了克服此问题,您必须在运行单元测试时显式定义默认调度器。请转到下一节了解如何操作。
创建测试调度器
由于 Main
调度器仅在 UI 上下文中可用,您必须将其替换为对单元测试友好的调度器。Kotlin Coroutines 库为此目的提供了一个协程调度器,称为 TestDispatcher
。对于任何创建新协程的单元测试,都必须使用 TestDispatcher
而不是 Main
调度器,视图模型中的 getMarsPhotos()
函数就是这种情况。
要在所有情况下将 Main
调度器替换为 TestDispatcher
,请使用 Dispatchers.setMain()
函数。您可以使用 Dispatchers.resetMain()
函数将线程调度器重置回 Main
调度器。为了避免在每个测试中重复替换 Main
调度器的代码,您可以将其提取到 JUnit 测试规则中。TestRule 提供了一种控制测试运行环境的方式。TestRule 可以添加额外的检查,可以执行必要的设置或清理,或者可以观察测试执行并将结果报告到其他地方。它们可以在测试类之间轻松共享。
创建一个专用类来编写 TestRule 以替换 Main
调度器。要实现自定义 TestRule,请完成以下步骤
- 在 test 目录中创建一个名为
rules
的新包。 - 在 rules 目录中,创建一个名为
TestDispatcherRule
的新类。 - 使用
TestWatcher
扩展TestDispatcherRule
。TestWatcher
类使您能够在测试的不同执行阶段采取操作。
class TestDispatcherRule(): TestWatcher(){
}
- 为
TestDispatcherRule
创建一个TestDispatcher
构造函数参数。
此参数允许使用不同的调度器,例如 StandardTestDispatcher
。此构造函数参数需要具有默认值,设置为 UnconfinedTestDispatcher
对象的一个实例。UnconfinedTestDispatcher
类继承自 TestDispatcher
类,并指定任务不必按任何特定顺序执行。这种执行模式适用于简单测试,因为协程会自动处理。与 UnconfinedTestDispatcher
不同,StandardTestDispatcher
类允许完全控制协程的执行。这种方式更适合需要手动方法的复杂测试,但对于本 Codelab 中的测试来说不是必需的。
class TestDispatcherRule(
val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
}
- 此测试规则的主要目标是在测试开始执行之前将
Main
调度器替换为测试调度器。TestWatcher
类的starting()
函数在给定测试执行之前执行。覆盖starting()
函数。
class TestDispatcherRule(
val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
override fun starting(description: Description) {
}
}
- 添加对
Dispatchers.setMain()
的调用,将testDispatcher
作为参数传入。
class TestDispatcherRule(
val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
override fun starting(description: Description) {
Dispatchers.setMain(testDispatcher)
}
}
- 测试执行完成后,通过覆盖
finished()
方法重置Main
调度器。调用Dispatchers.resetMain()
函数。
class TestDispatcherRule(
val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
override fun starting(description: Description) {
Dispatchers.setMain(testDispatcher)
}
override fun finished(description: Description) {
Dispatchers.resetMain()
}
}
TestDispatcherRule
规则已准备好重用。
- 打开
MarsViewModelTest.kt
文件。 - 在
MarsViewModelTest
类中,实例化TestDispatcherRule
类并将其分配给testDispatcher
只读属性。
class MarsViewModelTest {
val testDispatcher = TestDispatcherRule()
...
}
- 要将此规则应用于您的测试,请将
@get:Rule
注解添加到testDispatcher
属性。
class MarsViewModelTest {
@get:Rule
val testDispatcher = TestDispatcherRule()
...
}
- 重新运行测试。确认这次通过了。
11. 获取解决方案代码
要下载完成的 codelab 代码,您可以使用这些命令
$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-mars-photos.git $ cd basic-android-kotlin-compose-training-mars-photos $ git checkout coil-starter
或者,您可以将仓库下载为 zip 文件,解压缩并在 Android Studio 中打开。
如果您想查看此 codelab 的解决方案代码,请在 GitHub 上查看。
12. 总结
恭喜您完成本 codelab 并将 Mars Photos 应用重构,实现了仓库模式和依赖注入!
应用的现有代码遵循了 Android 数据层的最佳实践,这意味着它更灵活、更健壮且易于扩展。
这些更改还有助于使应用更易于测试。此益处非常重要,因为代码可以在不断演进的同时确保其行为仍符合预期。
不要忘记在社交媒体上分享您的作品,并带上 #AndroidBasics 标签!
13. 了解更多
Android 开发者文档
其他