1. 开始之前
简介
在之前的Codelab中,您学习了如何通过让ViewModel
使用API服务从网络检索火星照片的URL来获取网络数据。虽然这种方法有效且易于实现,但随着应用程序的增长以及需要处理多个数据源时,它并不能很好地扩展。为了解决这个问题,Android架构最佳实践建议将UI层和数据层分离。
在这个Codelab中,您将把**火星照片**应用程序重构为单独的UI和数据层。您将学习如何实现资源库模式和使用依赖注入。依赖注入创建了更灵活的编码结构,有助于开发和测试。
先决条件
- 能够从REST Web服务检索JSON数据,并使用Retrofit和序列化 (kotlinx.serialization)库将其解析为Kotlin对象。
- 了解如何使用REST Web服务。
- 能够在您的应用程序中实现协程。
您将学习什么
- 资源库模式
- 依赖注入
您将构建什么
- 修改**火星照片**应用程序,将其分离为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
您可以在火星照片
GitHub资源库中浏览代码。
2. 分离UI层和数据层
为什么需要不同的层?
将代码分离到不同的层可以使您的应用程序更具可扩展性、更健壮且更易于测试。拥有多个具有明确定义边界的层还可以使多个开发人员更轻松地协同开发同一个应用程序,而不会对彼此产生负面影响。
Android推荐的应用程序架构指出,应用程序应该至少包含UI层和数据层。
在这个Codelab中,您将专注于数据层并进行更改,以使您的应用程序遵循推荐的最佳实践。
什么是数据层?
数据层负责应用程序的业务逻辑以及为应用程序获取和保存数据。数据层使用单向数据流模式向UI层公开数据。数据可以来自多个来源,例如网络请求、本地数据库或设备上的文件。
一个应用程序甚至可能有多个数据源。当应用程序打开时,它从设备上的本地数据库(第一个数据源)检索数据。在应用程序运行期间,它向第二个数据源发出网络请求以检索较新的数据。
通过将数据放在与UI代码分开的层中,您可以更改代码的一部分而不会影响另一部分。这种方法是称为关注点分离的设计原则的一部分。一段代码专注于它自己的关注点,并将它的内部工作封装起来,使其与代码的其他部分隔离开。封装是一种隐藏代码内部工作方式的方法。当一段代码需要与另一段代码交互时,它通过接口进行。
UI层的关注点是显示提供给它的数据。UI不再检索数据,因为这是数据层的关注点。
数据层由一个或多个资源库组成。资源库本身包含零个或多个数据源。
最佳实践要求应用程序为应用程序使用的每种类型的数据源都拥有一个资源库。
在这个Codelab中,应用程序只有一个数据源,因此在您重构代码后,它只有一个资源库。对于此应用程序,从互联网检索数据的资源库完成了数据源的职责。它是通过向API发出网络请求来实现的。如果数据源编码更复杂或添加了其他数据源,则数据源职责将封装在单独的数据源类中,而资源库负责管理所有数据源。
什么是资源库?
一般来说,资源库类
- 向应用程序的其余部分公开数据。
- 集中数据更改。
- 解决多个数据源之间的冲突。
- 将数据源从应用程序的其余部分抽象出来。
- 包含业务逻辑。
**火星照片**应用程序只有一个数据源,即网络API调用。它没有任何业务逻辑,因为它只是检索数据。数据通过资源库类公开给应用程序,该类抽象了数据源。
3. 创建数据层
首先,您需要创建资源库类。Android开发者指南指出,资源库类的命名方式与其负责的数据相对应。资源库命名约定为**数据类型 + 资源库**。在您的应用程序中,这是MarsPhotosRepository
。
创建资源库
- 右键单击**com.example.marsphotos**,然后选择**新建 > 包**。
- 在对话框中,输入
data
。 - 右键单击
data
包,然后选择**新建 > Kotlin 类/文件**。 - 在对话框中,选择**接口**,然后输入
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
提供存储库。
一个容器是一个包含应用程序所需依赖项的对象。这些依赖项在整个应用程序中使用,因此它们需要位于所有活动都可以使用的公共位置。你可以创建一个 Application 类的子类并存储对容器的引用。
创建应用程序容器
- 右键单击
data
包,然后选择新建 > Kotlin 类/文件。 - 在对话框中,选择接口,然后输入
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()
函数中,更改return语句以从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
,然后选择新建 > Kotlin 类/文件。 - 在对话框中,输入
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
。
- 运行应用程序以确认它仍然像以前一样运行。
恭喜你已将火星照片应用程序重构为使用存储库和依赖注入!通过实现带有存储库的数据层,UI和数据源代码已分离,以遵循Android最佳实践。
通过使用依赖注入,更容易测试ViewModel
。你的应用程序现在更加灵活、健壮,并已准备好扩展。
进行这些改进后,现在是学习如何测试它们的时候了。测试可确保你的代码按预期运行,并减少在你继续处理代码时引入错误的可能性。
7. 为本地测试做好准备
在前面的部分中,你已经实现了存储库,以将与REST API服务的直接交互从ViewModel
中抽象出来。此做法使你可以测试具有有限目的的小块代码。针对具有有限功能的小块代码的测试比针对具有多种功能的大块代码编写的测试更容易构建、实现和理解。
您还利用接口、继承和依赖注入实现了资源库。在接下来的章节中,您将了解这些架构最佳实践如何简化测试。此外,您还使用了 Kotlin 协程来进行网络请求。测试使用协程的代码需要额外的步骤来处理代码的异步执行。这些步骤将在本代码实验室的后面部分介绍。
添加本地测试依赖项
将以下依赖项添加到app/build.gradle.kts
。
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1")
创建本地测试目录
- 通过右键单击项目视图中的src目录并选择新建 > 目录 > test/java来创建本地测试目录。
- 在测试目录中创建一个名为
com.example.marsphotos
的新包。
8. 为测试创建伪造数据和依赖项
在本节中,您将了解依赖注入如何帮助您编写本地测试。在代码实验室的前面部分,您创建了一个依赖于 API 服务的资源库。然后,您修改了ViewModel
以依赖于该资源库。
每个本地测试只需要测试一件事情。例如,当您测试视图模型的功能时,您不需要测试资源库或 API 服务的功能。同样,当您测试资源库时,您不需要测试 API 服务。
通过使用接口,随后使用依赖注入来包含继承自这些接口的类,您可以使用专门用于测试的伪造类来模拟这些依赖项的功能。注入伪造类和数据源进行测试允许代码被隔离测试,具有可重复性和一致性。
您首先需要伪造数据,以便在稍后创建的伪造类中使用。
- 在测试目录中,在
com.example.marsphotos
下创建一个名为fake
的包。 - 在
fake
目录中创建一个名为FakeDataSource
的新 Kotlin 对象。 - 在这个对象中,创建一个设置为
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
)
)
}
在代码实验室的前面部分提到,资源库依赖于 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
运行它,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 协程库为此目的提供了一个协程调度程序,称为TestDispatcher
。TestDispatcher
需要用于任何创建新协程的单元测试中,例如ViewModel
中的getMarsPhotos()
函数。
要始终将Main
调度器替换为TestDispatcher
,请使用Dispatchers.setMain()
函数。您可以使用Dispatchers.resetMain()
函数将线程调度器重置回Main
调度器。为了避免在每个测试中重复替换Main
调度器的代码,您可以将其提取到JUnit测试规则中。TestRule提供了一种控制测试运行环境的方法。TestRule可以添加额外的检查,执行必要的测试设置或清理,或者观察测试执行以将其报告到其他地方。它们可以轻松地在测试类之间共享。
创建一个专用类来编写替换Main
调度器的TestRule。要实现自定义TestRule,请完成以下步骤
- 在测试目录中创建一个名为
rules
的新包。 - 在rules目录中,创建一个名为
TestDispatcherRule
的新类。 - 使用
TestWatcher
扩展TestDispatcherRule
。TestWatcher
类使您可以对测试的不同执行阶段采取操作。
class TestDispatcherRule(): TestWatcher(){
}
- 为
TestDispatcherRule
创建一个TestDispatcher
构造函数参数。
此参数允许使用不同的调度器,例如StandardTestDispatcher
。此构造函数参数需要将其默认值设置为UnconfinedTestDispatcher
对象的实例。UnconfinedTestDispatcher
类继承自TestDispatcher
类,它指定任务不必以任何特定顺序执行。对于简单的测试,这种执行模式很好,因为协程会自动处理。与UnconfinedTestDispatcher
不同,StandardTestDispatcher
类允许完全控制协程执行。这种方法更适合需要手动方法的复杂测试,但对于此代码实验室中的测试来说并非必需。
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. 获取解决方案代码
要下载完成的代码实验室的代码,您可以使用以下命令
$ 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中打开它。
如果您想查看此代码实验室的解决方案代码,请在GitHub上查看。
12. 结论
恭喜您完成了此代码实验室,并重构了**火星照片**应用程序以实现存储库模式和依赖项注入!
该应用程序的代码现在遵循数据层的Android最佳实践,这意味着它更灵活、更健壮且更易于扩展。
这些更改还有助于使应用程序更易于测试。此优势非常重要,因为代码可以继续发展,同时确保其仍然按预期运行。
不要忘记在社交媒体上分享您的作品,并使用#AndroidBasics!
13. 了解更多
Android 开发者文档
其他