学习笔记:在 Android 中使用 Retrofit 访问互联网

Ref:
从互联网获取数据
加载并显示来自互联网的图片

参考上述两个教程,从 0 实现一个使用 Retrofit 连接 REST Web 服务的 APP 。
使用:

  • MVVM 架构
  • 仓库模式
  • 依赖注入

以下网址将获取火星照片列表:
https://android-kotlin-fun-mars-server.appspot.com/photos

1
2
3
4
5
6
7
[
{
"id": "424905",
"img_src": "https://mars.jpl.nasa.gov/msl-raw-images/msss/01000/mcam/1000MR0044631300503690E01_DXXX.jpg"
},
//...
]

实现 APP 的界面

新建项目

使用 Android Studio 版本 : Ladybug 2024.2.1

File > New Project > 选择 Empty Activity

New Project

包名为 com.example.marsphoto

创建完成后,移除 Greeting 函数,MainActivity.kt 的内容如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package com.example.marsphoto

import ...

class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MarsPhotoTheme {
MarsPhotosApp()
}
}
}
}


@Composable
fun MarsPhotosApp(){

}

添加依赖(viewModel 与 Retrofit)

需要添加下面的依赖至 build.gradle.kts (Module:app)

1
2
3
4
5
6
7
8
// Retrofit
implementation("com.squareup.retrofit2:retrofit:2.9.0")
// Retrofit with Scalar Converter
// 将 Json 转为字符串,访问 Json 测试使用,然后更换 kotlinx.serialization
implementation("com.squareup.retrofit2:converter-scalars:2.9.0")

// lifecycle viewModel
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6")

使用 version catalogs
libs.versions.toml

1
2
3
4
5
6
7
8
9
10
[versions]
retrofit = "2.9.0"
lifecycleRuntimeKtx = "2.8.6"

[libraries]
# lifecycle-viewmodel-compose
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
# retrofit
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
converter-scalars = { module = "com.squareup.retrofit2:converter-scalars", version.ref = "retrofit" }

build.gradle.kts (Module:app)

1
2
3
4
5
6
7
// Retrofit
implementation(libs.retrofit)
// Retrofit with Scalar Converter
// 将 Json 转为字符串,访问 Json 测试使用,然后更换 kotlinx.serialization
implementation(libs.converter.scalars)
// lifecycle viewModel
implementation(libs.androidx.lifecycle.viewmodel.compose)

Github Commits

创建 Screen 与 viewModel 文件

分别创建 HomeScreen.ktMarsViewModel.kt 文件,以固定文本代替 Photos 的获取结果

MarsViewModel.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package com.example.marsphoto.ui

import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.getValue
import androidx.lifecycle.ViewModel

class MarsViewModel: ViewModel(){
var marsUiState: String by mutableStateOf("")
private set

init{
getMarsPhotos()
}

private fun getMarsPhotos() {
marsUiState = "Get Photos with Mars Api"
}
}

HomeScreen.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.example.marsphoto.ui

import ...

@Composable
fun HomeScreen(
modifier: Modifier = Modifier
) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState())
) {
val viewModel: MarsViewModel = viewModel()
val photos = viewModel.marsUiState
Text(text = photos)
}
}

MainActivity.kt 调用 HomeScreen() ,运行后可以查看 Get Photos with Mars Api 显示在屏幕中

1
2
3
4
@Composable
fun MarsPhotosApp(){
HomeScreen()
}

Github Commits

使用 Retrofit 访问网络

为 APP 添加联网权限

打开 manifests/AndroidManifest.xml 前面添加

1
<uses-permission android:name="android.permission.INTERNET" />

创建 Retrofit 对象

创建 network 包,创建 MarsApiService.kt 文件,创建 Retrofit 对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 设置 BASE_URL
private const val BASE_URL =
"https://android-kotlin-fun-mars-server.appspot.com"

//创建 Retrofit 对象
private val retrofit = Retrofit.Builder()
.addConverterFactory(ScalarsConverterFactory.create())
.baseUrl(BASE_URL)
.build()

//定义 MarsApiService 的接口,该接口定义 Retrofit 如何使用 HTTP 请求与网络服务器通信。
interface MarsApiService{
@GET("photos")
suspend fun getPhotos(): String
}

// 声明单例对象。单例模式可确保对于一个对象只创建一个实例。
object MarsApi{
val retrofitService: MarsApiService by lazy {
retrofit.create(MarsApiService::class.java)
}
}

MarsApi 对象通过 object 关键字声明,确保在应用程序的生命周期内只创建一个 MarsApiService 实例。
这种方式有助于管理网络请求的一致性。
单例模式应该只用于测试,正式使用推荐 Android 中的依赖项注入

在 viewModel 中调用 Retrofit 对象

修改 MarsViewModel.ktgetMarsPhotos() ,将硬编码的 marsUiState = "Get Photos with Mars Api" 修改为下面的内容

1
2
3
4
5
6
private fun getMarsPhotos() {
viewModelScope.launch{
val listResult = MarsApi.retrofitService.getPhotos()
marsUiState = listResult
}
}

重新编译即可查看 JSON 的内容显示在屏幕中。

使用 kotlinx.serialization 解析 JSON

添加依赖

编辑 build.gradle.kts (Module :app)

plugins 代码块中,添加 kotlinx serialization 插件。

1
id("org.jetbrains.kotlin.plugin.serialization") version "1.9.22"

dependencies 部分添加

1
2
// Kotlin serialization
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")

将 Scalar Converter 替换为 Kotlin serialization Converter

1
2
3
// Retrofit with Kotlin serialization Converter
implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
implementation("com.squareup.okhttp3:okhttp:4.11.0")

创建 Kotlin 对象

在 network 下创建 MarsPhoto.kt 数据类,包含一个 id 与一个 imgSrc 属性。

1
2
3
4
5
6
7
8
9
10
11
package com.example.marsphoto.network

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class MarsPhoto(
val id: String,
@SerialName(value = "img_src")
val imgSrc: String
)

Json 中的 id 是 “” 封装的,所以是 String 而不是 Int

@Serializable 注解表示类可序列化,kotlinx serialization 解析 JSON 时,它会按名称匹配键。
@SerialName 注解,如果 json 中的名称格式不符合 kotlin 的规范,可以使用这个注解映射, @SerialName(value = "img_src") 将该变量映射到 JSON 属性 img_src

更新 Retrofit 与 MarsViewModel

更新 Retrofit 的构建起,使用 kotlinx.serialization 。
编辑 MarsApiService.kt

1
2
private val retrofit = Retrofit.Builder()
.addConverterFactory(ScalarsConverterFactory.create())

修改为

1
2
private val retrofit = Retrofit.Builder()
.addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))

现在可以要求 Retrofit 返回 MarsPhoto 对象,而不是 String

1
2
3
4
interface MarsApiService{
@GET("photos")
suspend fun getPhotos(): List<MarsPhoto>
}

由于 Retrofit 不再返回 String 类型,MarsViewModel 中调用部分也需要修改。

1
2
3
marsUiState = "Get Photos with Mars Api"
// to
marsUiState = "There are ${listResult.size} MarPhotos"

运行查看结果。

Github Commits

添加 Loading 与 Error 屏幕

改造一下 MarsViewModel 与 HomeScreen ,使 APP 可以显示 Loading 与 Error 的情况。
HomeScreen

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49

@Composable
fun HomeScreen(
modifier: Modifier = Modifier
) {
val viewModel: MarsViewModel = viewModel()
val marsUiState = viewModel.marsUiState
when(marsUiState){
is MarsUiState.Success -> SuccessScreen(
marsUiState = marsUiState,
modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState())
)
MarsUiState.Loading -> LoadingScreen(modifier = modifier.fillMaxSize())
MarsUiState.Error -> ErrorScreen(modifier = modifier.fillMaxSize())
}
}

@Composable
fun LoadingScreen(modifier: Modifier = Modifier) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier
) {
Text("Loading")
}
}

@Composable
fun ErrorScreen(modifier: Modifier = Modifier) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier
) {
Text("Meet Error")
}
}

@Composable
fun SuccessScreen(
marsUiState: MarsUiState,
modifier: Modifier = Modifier
){
Box(
contentAlignment = Alignment.Center,
modifier = modifier
) {
Text(text = marsUiState.toString())
}
}

MarsViewModel

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
sealed interface MarsUiState{
data class Success(val photo: String): MarsUiState
object Error: MarsUiState
object Loading: MarsUiState
}

class MarsViewModel: ViewModel(){
var marsUiState: MarsUiState by mutableStateOf(MarsUiState.Loading)
private set

init{
getMarsPhotos()
}

private fun getMarsPhotos() {
viewModelScope.launch{
marsUiState = MarsUiState.Loading
marsUiState = try {
val listResult = MarsApi.retrofitService.getPhotos()
MarsUiState.Success(
"Success: ${listResult.size} Mars photos retrieved"
)
} catch (_: IOException){
MarsUiState.Error
} catch (_: HttpException){
MarsUiState.Error
}
}
}
}

Github Commits

添加仓库

数据层与界面层

根据 Android 的推荐应用架构 ,应用应至少具有一个界面层和一个数据层。

数据层负责应用的业务逻辑以及为应用寻源和保存数据。数据层使用单向数据流模式向界面层公开数据。数据可能来自多个来源,例如网络请求、本地数据库或设备上的文件。
一个应用甚至可能有多个数据源。
界面层的关注点是显示所提供的数据。界面(Screen 与 ViewModel)不再检索数据,因为这是数据层的关注点。
数据层由一个或多个仓库组成。仓库本身包含零个或多个数据源。

仓库类的作用包括:

  • 向应用的其余部分公开数据。
  • 集中管理数据更改。
  • 解决多个数据源之间的冲突。
  • 对应用其余部分的数据源进行抽象化处理。
  • 存放业务逻辑。

Mars Photos 应用只有一个数据源,即网络 API 调用。它只检索数据,因此没有任何业务逻辑。数据通过仓库类公开提供给应用,该类会对数据源进行抽象化处理。

Layer

创建仓库

Android 开发者指南指出,仓库类以其所负责的数据命名。仓库命名惯例是 数据类型 + 仓库。在这个应用中,其名称为 MarsPhotosRepository

  • 创建 package data
  • 新建一个名称为 MarsPhotosRepository.ktInterface
    1
    2
    3
    4
    5
    6
    7
    8
    package com.example.marsphoto.data

    import com.example.marsphoto.network.MarsApiService
    import com.example.marsphoto.network.MarsPhoto

    interface MarsPhotosRepository {
    suspend fun getMarsPhotos(): List<MarsPhoto>
    }
  • 然后创建一个名称为 NetworkMarsPhotosRepository 的类来实现 MarsPhotosRepository 接口
    1
    2
    3
    4
    5
    class NetWorkMarsPhotosRepository : MarsPhotosRepository{
    override suspend fun getMarsPhotos(): List<MarsPhoto> {
    return MarsApi.retrofitService.getPhotos()
    }
    }
    编辑 MarsViewModel.kt 文件,将 val listResult = MarsApi.retrofitService.getPhotos() 行替换为以下代码:
    1
    2
    val marsPhotosRepository = NetWorkMarsPhotosRepository()
    val listResult = marsPhotosRepository.getMarsPhotos()
    运行应用,查看结果,确认与之前相同。
    仓库将提供数据,而不是由 ViewModel 直接发出关于数据的网络请求。ViewModel 不再直接引用 MarsApi 代码。
    ViewModel without MarsApi

Github Commits

手动依赖项注入(DI)

Ref: 手动依赖项注入
依赖项注入(Dependency Injection,DI) 是一种软件设计模式,旨在将组件之间的依赖关系从内部创建转移到外部提供。这意味着一个类不再负责创建它所依赖的对象,而是通过构造函数、属性或方法参数由外部提供这些依赖。

核心概念:

  • 依赖项(Dependencies): 一个类所需要的其他类或对象。
  • 注入(Injection): 通过外部手段(如构造函数、属性、方法)将依赖项提供给需要的类。

依赖项注入的主要好处包括:

  • 解耦合: 类与其依赖项之间的耦合度降低,增强模块间的独立性。
  • 可测试性: 更容易为类编写单元测试,因为可以轻松地替换依赖项为模拟对象。
  • 可维护性: 更清晰的依赖关系使得代码更易于理解和维护。
  • 灵活性与可扩展性: 依赖项的实现可以在不改变依赖方代码的情况下进行替换或扩展。

手动依赖项注入的方法

构造函数注入 是最常见且推荐的 DI 方法。通过在类的构造函数中声明依赖项,使得这些依赖项在创建类的实例时被提供。

示例:

1
2
3
4
5
6
7
class Repository {
fun getData(): String = "Hello from Repository"
}

class ViewModel(private val repository: Repository) {
fun fetchData(): String = repository.getData()
}

使用:

1
2
val repository = Repository()
val viewModel = ViewModel(repository)

单例模式与服务定位器模式
单例模式服务定位器模式 是另一种手动 DI 的方法,但需要谨慎使用,因为它们可能引入全局状态和隐式依赖,降低代码的可测试性。

1
2
3
4
5
6
7
8
object ServiceLocator {
val repository: Repository by lazy { Repository() }
}

class ViewModel {
private val repository = ServiceLocator.repository
fun fetchData(): String = repository.getData()
}

这种方法使得依赖项在类中是隐式的,不利于测试和维护。

创建应用容器

  • data 下创建一个 AppContainerInterface
  • AppContainer 内添加一个名为 marsPhotosRepository 且类型为 MarsPhotosRepository 的抽象属性。
    1
    2
    3
    interface AppContainer {
    val marsPhotosRepository: MarsPhotosRepository
    }
  • 在接口定义下方,创建一个名为 DefaultAppContainer 的类来实现 AppContainer 接口。
  • network/MarsApiService.kt 中的内容移至 DefaultAppContainer 类,让它们都位于用于维护依赖项的容器中。
    MarsApiService.kt 只需要保留如下内容
    1
    2
    3
    4
    interface MarsApiService{
    @GET("photos")
    suspend fun getPhotos(): List<MarsPhoto>
    }
  • DefaultAppContainer 类会实现 AppContainer 接口,因此我们需要替换 marsPhotosRepository 属性。在变量 retrofitService 后面添加以下代码:
    1
    2
    3
    override val marsPhotosRepository: MarsPhotosRepository by lazy {
    NetworkMarsPhotosRepository(retrofitService)
    }
    完成的 DefaultAppContainer 类应如下所示:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    package com.example.marsphoto.data

    import ...

    interface AppContainer {
    val marsPhotosRepository: MarsPhotosRepository
    }

    class DefaultAppContainer: AppContainer{
    private val baseUrl = "https://android-kotlin-fun-mars-server.appspot.com"

    private val retrofit = Retrofit.Builder()
    .addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
    .baseUrl(baseUrl)
    .build()

    /**
    * Retrofit service object for creating api calls
    */
    private val retrofitService: MarsApiService by lazy {
    retrofit.create(MarsApiService::class.java)
    }

    /**
    * DI implementation for Mars photos repository
    */
    override val marsPhotosRepository: MarsPhotosRepository by lazy {
    NetWorkMarsPhotosRepository(retrofitService)
    }
    }
  • data/MarsPhotosRepository.kt 需要编辑 NetworkMarsPhotosRepository 传递 retrofitService
    1
    2
    3
    class NetWorkMarsPhotosRepository(private val marsApiService: MarsApiService) : MarsPhotosRepository{
    override suspend fun getMarsPhotos(): List<MarsPhoto> = marsApiService.getPhotos()
    }

    将应用容器附加到应用

目的:将应用容器与应用的生命周期关联起来,使得整个应用都能访问到这些依赖项。

com.example.marsphotos 下创建类 MarsPhotosApplication , 此类继承自 Application()

1
2
3
4
import android.app.Application

class MarsPhotosApplication : Application() {
}

MarsPhotosApplication 类中,声明一个名为 container 且类型为 AppContainer 的变量,用于存储 DefaultAppContainer 对象。该变量会在调用 onCreate() 期间初始化,因此需要使用 lateinit 修饰符标记该变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
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()
}
}

编辑清单文件 manifests/AndroidManifest.xml ,添加值为应用类名称 “.MarsPhotosApplication” 的 android:name 属性。```xml

1
2
3
4
5
6
<application
android:name=".MarsPhotosApplication"
android:allowBackup="true"
... >
...
</application>

将仓库添加到 ViewModel

完成上述步骤后,ViewModel 可以调用仓库对象来检索数据。

编辑 MarsViewModel.kt 文件,在 MarsViewModel 的类声明中,添加一个类型为 MarsPhotosRepository 的私有构造函数形参 marsPhotosRepository。构造函数形参的值来自应用容器,因为应用现在在使用依赖项注入。

1
2
3
class MarsViewModel(private val marsPhotosRepository: MarsPhotosRepository) : ViewModel(){
//...
}

getMarsPhotos() 函数中可以移除

1
val marsPhotosRepository = NetworkMarsPhotosRepository()

由于 Android 框架不允许在创建时向构造函数中的 ViewModel 传递值,因此我们实现了一个 ViewModelProvider.Factory 对象来绕过此限制。

在函数 MarsViewModel.ktgetMarsPhotos() 下,输入伴生对象的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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.marsphoto.MarsPhotosApplication

companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = (this[APPLICATION_KEY] as MarsPhotosApplication)
val marsPhotosRepository = application.container.marsPhotosRepository
MarsViewModel(marsPhotosRepository = marsPhotosRepository)
}
}
}

HomeScreen.kt 文件中,将

1
val viewModel: MarsViewModel = viewModel()

修改为

1
val viewModel: MarsViewModel = viewModel(factory = MarsViewModel.Factory)

运行应用,确认应用是否像之前一样正常运行。
它现已使用仓库和依赖项注入!通过仓库实现数据层之后,界面和数据源已实现分离,并且符合 Android 最佳实践。

Github Commits
学习设置测试

总结

应用容器 用于集中管理和提供应用所需的各种依赖项(如仓库、网络服务等)

  1. Repositroy 提供数据
    Repository 定义数据获取和处理逻辑,并将其提供给应用的其他部分(如 ViewModel)。
  2. 在 AppContainer 中定义
    声明应用所需的依赖项。
    1
    2
    3
    interface AppContainer {
    val marsPhotosRepository: MarsPhotosRepository
    }
  3. 在 DefaultAppContainer 中实现注入
    具体创建和提供依赖实例。
    DefaultAppContainer 类实现了 AppContainer 接口,并具体提供了各个依赖项的实例。
  4. 在 Application() 中初始化
    在应用启动时初始化依赖容器。
    1
    2
    3
    4
    5
    6
    7
    8
    class MarsPhotosApplication : Application() {
    lateinit var container: AppContainer

    override fun onCreate() {
    super.onCreate()
    container = DefaultAppContainer()
    }
    }
    同时需要修改清单文件
    1
    2
    3
    4
    5
    6
    <application
    android:name=".MarsPhotosApplication"
    android:allowBackup="true"
    ... >
    ...
    </application>
  5. 通过构造函数传递给 ViewModel
    将依赖注入到 ViewModel 中。
    1
    2
    3
    class MarsViewModel(private val marsPhotosRepository: MarsPhotosRepository) : ViewModel() {
    // ViewModel 的实现
    }
  6. 需要自定义 ViewModel.Factory 实现参数的传递
    创建自定义工厂以支持依赖注入。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    companion object {
    val Factory: ViewModelProvider.Factory = viewModelFactory {
    initializer {
    val application = (this[APPLICATION_KEY] as MarsPhotosApplication)
    val marsPhotosRepository = application.container.marsPhotosRepository
    MarsViewModel(marsPhotosRepository = marsPhotosRepository)
    }
    }
    }
  7. 在 Screen @Composable 函数中使用 Factory 初始化 ViewModel
    在 UI 层获取并使用 ViewModel。
    1
    2
    3
    4
    5
    @Composable
    fun HomeScreen() {
    val viewModel: MarsViewModel = viewModel(factory = MarsViewModel.Factory)
    // 使用 ViewModel 进行 UI 操作
    }

添加测试

测试依赖

libs.versions.toml

1
2
3
4
5
6
7
8
9
[versions]
# junit Android Studio 2024.2.1 默认项目自带
junit = "4.13.2"
# kotlinxCoroutinesTest
kotlinxCoroutinesTest = "1.8.1"

[libraries]
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" }

build.gradle.kts (Module: app) > dependencies

1
2
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)

添加测试数据

在 com.example.marsphoto(test) 目录下创建 package fake ,创建 FakeDataSource 文件,填入下面的内容
(androidTest) 是需要编译安装至模拟器进行测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
object FakeDataSource {
private const val idOne = "img1"
private const val idTwo = "img2"
private const val imgOne = "url.one"
private const val imgTwo = "url.two"
val photosList = listOf(
MarsPhoto(
id = idOne,
imgSrc = imgOne
),
MarsPhoto(
id = idTwo,
imgSrc = imgTwo
)
)
}

进行测试

创建 FakeMarsApi.kt

1
2
3
class FakeMarsApiService: MarsApiService{
override suspend fun getPhotos(): List<MarsPhoto> = FakeDataSource.photosList
}

创建 NetworkMarsRepositoryTest,填入测试内容

1
2
3
4
5
6
7
8
9
10
class NetworkMarsRepositoryTest {
@Test
fun networkMarsPhotoRepository_getMarsPhotos_verifyPhotoList() =
runTest {
val repository = NetWorkMarsPhotosRepository(
marsApiService = FakeMarsApiService()
)
assertEquals(FakeDataSource.photosList, repository.getMarsPhotos())
}
}

测试 ViewModel

创建 FakeNetworkMarsPhotosRepository 类供测试使用

1
2
3
class FakeNetworkMarsPhotosRepository : MarsPhotosRepository{
override suspend fun getMarsPhotos(): List<MarsPhoto> = FakeDataSource.photosList
}

创建,MarsViewModelTest 新类,编写 ViewModel 测试

1
2
3
4
5
6
7
8
9
10
11
12
13
class MarsViewModelTest (){
@Test
fun marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess() =
runTest{
val viewModel = MarsViewModel(
marsPhotosRepository = FakeNetworkMarsPhotosRepository()
)
assertEquals(
MarsUiState.Success("Success: ${FakeDataSource.photosList.size} Mars photos retrieved"),
viewModel.marsUiState
)
}
}

运行测试后报错,出现错误的原因是 Android 界面线程在单元测试中不可用,可以移到 androidTest 中进行测试。
不过依然报错

1
2
junit.framework.AssertionFailedError: expected:<Success(photo=Success: 2 Mars photos retrieved)> but
was:<com.example.marsphoto.ui.MarsUiState$Loading@93ca461>

这是因为 ViewModel 中的 getMarsPhotos() 函数是异步的,而测试用例没有等待异步任务完成。

可以在断言前,添加 advanceUntilIdle() 让测试等待协程执行完毕
MarsViewModelAndroidTest.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class MarsViewModelTest (){
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess() =
runTest {
val viewModel = MarsViewModel(
marsPhotosRepository = FakeNetworkMarsPhotosRepository()
)

//让测试等待协程执行完毕
advanceUntilIdle()

assertEquals(
MarsUiState.Success("Success: ${FakeDataSource.photosList.size} Mars photos retrieved"),
viewModel.marsUiState
)
}
}

再使用 androidTest 就显示通过。

当然在普通的单元测试中也是可以进行测试的,要在运行单元测试时 明确定义默认调度程序 #创建测试调度程序

  1. 在测试目录中创建一个名为 rules 的新软件包。
  2. rules 目录中,新建一个名为 TestDispatcherRule 的类。
  3. 使用 TestWatcher 扩展 TestDispatcherRule。借助 TestWatcher 类,可以在测试的不同执行阶段执行操作。
  4. TestDispatcherRule 创建一个 TestDispatcher 构造函数形参。
  5. 替换 starting() 函数,在测试开始执行之前将 Main 调度程序替换为测试调度程序,添加对 Dispatchers.setMain() 的调用,并传入 testDispatcher 作为实参。
  6. 替换 finished() 方法以重置 Main 调度程序。调用 Dispatchers.resetMain() 函数。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class TestDispatcherRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
    ) : TestWatcher() {
    override fun starting(description: Description) {
    Dispatchers.setMain(testDispatcher)
    }

    override fun finished(description: Description) {
    Dispatchers.resetMain()
    }
    }
  7. MarsViewModelTest 类中,对 TestDispatcherRule 类进行实例化并将其分配给 testDispatcher 只读属性。应用于测试时需要将 @get:Rule 注解添加到 testDispatcher 属性。
    1
    2
    3
    4
    5
    class MarsViewModelTest {
    @get:Rule
    val testDispatcher = TestDispatcherRule()
    ...
    }
    再运行就可以了,教程是这么写的
    但是我运行提示 java.lang.NoClassDefFoundError: com/example/marsphoto/ui/MarsViewModel ,下个 Github 的版本,直接运行也是这个报错。
    //TODO

使用 Hilt 注入

添加依赖

使用 version catalogs 添加 Hilt 依赖(Android Studio 2024.2.1)

使用 version catalogs

编辑 libs.versions.toml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[versions]
kotlin="2.0.20"
ksp="2.0.20-1.0.24"
hilt="2.51.1"
hiltCommon="1.2.0"

[libraries]
# hilt
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-android-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
# hilt-common
androidx-hilt-common = { group = "androidx.hilt", name = "hilt-common", version.ref = "hiltCommon" }
androidx-hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hiltCommon" }
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltCommon" }

[plugins]
# ksp
google-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp"}
android-hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt"}

[bundles]
hilt = ["hilt-android", "androidx-hilt-common", "androidx-hilt-navigation-compose"]
hilt-ksp = ["hilt-android-compiler", "androidx-hilt-compiler"]

build.gradle.kts (Project) > plugins 添加

1
2
alias(libs.plugins.google.ksp) apply false
alias(libs.plugins.android.hilt) apply false

build.gradle.kts (Module: app) > plugins 部分添加

1
2
alias(libs.plugins.google.ksp)
alias(libs.plugins.android.hilt)

build.gradle.kts (Module: app) > dependencies 部分添加

1
2
3
// Hilt
implementation(libs.bundles.hilt)
ksp(libs.bundles.hilt.ksp)

如果 Android Studio sync 之后全线标红报错,只要在 build.gradle.kts (Module: app) 输入一行再撤销就好了。

完成可以 build 一下 APP ,看看是否会报错。

Ref: Version Catalogを使ってみましょう

使用 hilt

除了依赖之外的起始代码为之前添加完了 MarsPhotosRepository.kt 的代码

  1. 首先创建 MarsApplication 类继承自 Application() 并添加 @HiltAndroidApp 注解
    所有使用 Hilt 的应用都必须包含一个带有 @HiltAndroidApp 注解的 Application()

    1
    2
    3
    4
    @HiltAndroidApp
    class MarsApplication : Application(){
    //在这里初始化全局依赖项
    }

    与手动注入一样,需要在清单文件 AndroidManifest.xml 中声明创建的 Application 类:

    1
    2
    3
    4
    5
    <application
    android:name=".MarsApplication"
    ... >
    <!-- 其他配置 -->
    </application>
  2. 创建 Hilt 模块以提供依赖项
    为了让 Hilt 知道如何提供 RetrofitMarsApiServiceMarsPhotosRepository,我们需要创建一个模块。模块是一个用 @Module 注解的类,里面包含了提供依赖的方法,用 @Provides@Binds 注解标注。
    创建一个 di 包(package),在其中创建 AppModule.kt

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    package com.example.marsphoto.di

    import com.example.marsphoto.data.MarsPhotosRepository
    import com.example.marsphoto.data.NetWorkMarsPhotosRepository
    import com.example.marsphoto.network.MarsApiService
    import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
    import dagger.Binds
    import dagger.Module
    import dagger.Provides
    import dagger.hilt.InstallIn
    import dagger.hilt.components.SingletonComponent
    import kotlinx.serialization.json.Json
    import retrofit2.Retrofit
    import javax.inject.Singleton

    private const val BASE_URL = "https://android-kotlin-fun-mars-server.appspot.com"

    @Module
    @InstallIn(SingletonComponent::class)
    abstract class AppModule{

    // 使用 @Binds 将接口绑定到实现
    @Binds
    @Singleton
    abstract fun bindMarsPhotosRepository(
    netWorkMarsPhotosRepository: NetWorkMarsPhotosRepository
    ) : MarsPhotosRepository

    companion object {
    // 使用 @Provides 提供 Retrofit 实例
    @Provides
    @Singleton
    fun provideRetrofit(): Retrofit {
    return Retrofit.Builder()
    .addConverterFactory(Json.asConverterFactory("application/json".toMeditaType()))
    .baseUrl(BASE_URL)
    .build()
    }

    // 使用 @Provides 提供 MarsApiService 实例
    @Provides
    @Singleton
    fun provideMarsApiService(retrofit: Retrofit) : MarsApiService{
    return retrofit.create(MarsApiService::class.java)
    }
    }
    }
  • @Module:标记该类为 Hilt 模块。
  • @InstallIn(SingletonComponent::class):指定模块的生命周期,这里使用 SingletonComponent 使其在整个应用程序生命周期内有效。
  • @Binds:用于绑定接口到实现。在这种情况下,我们将 MarsPhotosRepository 接口绑定到 NetWorkMarsPhotosRepository 实现。
  • @Provides:用于提供具体的依赖项实例,如 RetrofitMarsApiService
  1. 修改 MarsApiService.kt
    与手动注入一样,MarsApiService.kt 只需要保留接口的定义,不再需要 MarsApi 对象。

    1
    2
    3
    4
    5
    6
    7
    8
    package com.example.marsphoto.network

    import retrofit2.http.GET

    interface MarsApiService{
    @GET("photos")
    suspend fun getPhotos(): List<MarsPhoto>
    }
  2. 修改 NetWorkMarsPhotosRepository.kt
    确保 NetWorkMarsPhotosRepository 使用依赖注入来获取 MarsApiService

    1
    2
    3
    4
    5
    class NetWorkMarsPhotosRepository @Inject constructor(
    private val marsApiService: MarsApiService
    ) : MarsPhotosRepository{
    override suspend fun getMarsPhotos(): List<MarsPhoto> = marsApiService.getPhotos()
    }

    @Inject 构造函数注解:告诉 Hilt 如何创建 NetWorkMarsPhotosRepository,即通过注入 MarsApiService

  3. 修改 MarsViewModel.kt 以使用 Hilt 进行依赖注入

    1
    2
    3
    class MarsViewModel: ViewModel() { 
    //...
    }

    修改为

    1
    2
    3
    4
    5
    6
    @HiltViewModel 
    class MarsViewModel @Inject constructor(
    private val marsPhotosRepository: MarsPhotosRepository
    ) : ViewModel(){
    //...
    }

    移除 getMarsPhotos() 函数中的

    1
    val marsPhotosRepository = NetWorkMarsPhotosRepository()
  • @HiltViewModel:标记 ViewModel 以便 Hilt 可以为其提供依赖项。
  • @Inject constructor{}:注入 MarsPhotosRepository 到 ViewModel 中。
  • 移除了直接创建 NetWorkMarsPhotosRepository 的代码,改为使用注入的 marsPhotosRepository。
  1. 修改 UI Screen 中的 viewModel()hiltViewModel()

    1
    val viewModel: MarsViewModel = hiltViewModel()
  2. 修改 Activity 以使用 Hilt
    确保使用 viewModel 的 Activity 或 Fragment 使用 @AndroidEntryPoint 注解,以便 Hilt 能够为它们提供依赖项。

    1
    2
    3
    4
    @AndroidEntryPoint
    class MainActivity : ComponentActivity() {
    //...
    }

    运行 APP ,可以正常显示结果。

总结

AppModule.kt 中,使用了一个 @Binds 方法来将 MarsPhotosRepository 接口绑定到 NetWorkMarsPhotosRepository实现。
有关 @Binds 方法:
Hilt 在编译时会处理所有的模块和绑定方法,生成相应的代码来管理依赖关系。
@Binds 作用是告诉 Hilt 如何将接口类型绑定到具体实现。
Hilt 需要提供 MarsPhotosRepository 的实例时,它会参考 @Binds 方法的定义,知道应该实例化 NetWorkMarsPhotosRepository 并将其作为 MarsPhotosRepository 的实现。

Hilt 的依赖注入流程

1
2
3
4
5
6
7
8
9
10
11
MainActivity
|
|-- @Composable HomeScreen
|
|-- MarsViewModel
|
|-- MarsPhotosRepository (bound to NetWorkMarsPhotosRepository via @Binds)
|
|-- MarsApiService (provided by provideMarsApiService)
|
|-- Retrofit (provided by provideRetrofit)

Github Commits

使用 Coil 显示图片

添加依赖

1
implementation("io.coil-kt:coil-compose:2.7.0")

Coil 示例

1
2
3
4
5
6
7
8
9
10
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data("https://example.com/image.jpg")
.crossfade(true)
.build(),
placeholder = painterResource(R.drawable.placeholder),
contentDescription = stringResource(R.string.description),
contentScale = ContentScale.Crop,
modifier = Modifier.clip(CircleShape)
)

显示单张图片

MarsViewModel.kt 文件中,更新 MarsUiState 接口以接受 MarsPhoto 对象,而不是 String

1
2
3
sealed interface MarsUiState{
data class Success(val photo: MarsPhoto): MarsUiState
}

修改 getPhotos() 获取第一张图片

1
2
3
4
5
6
7
8
private fun getMarsPhotos() {
viewModelScope.launch{
marsUiState = MarsUiState.Loading
marsUiState = try {
val photo = marsPhotosRepository.getMarsPhotos()[0]
MarsUiState.Success(photo)
}
//...

修改 HomeScreen.kt 显示图片,首先创建一个 CoilScreen 显示单张图片,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Composable
fun CoilScreen(
photo: MarsPhoto,
modifier: Modifier = Modifier
){
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(photo.imgSrc)
.crossfade(true)
.build(),
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = modifier.fillMaxWidth().padding(8.dp)
)
}

然后修改 SuccessScreen 接受的参数

1
2
3
4
5
6
7
8
9
10
11
@Composable
fun SuccessScreen(
photos: MarsPhoto,
modifier: Modifier = Modifier) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier
) {
CoilScreen(photo)
}
}

显示全部图片

MarsViewModel.kt 文件中,更新 MarsUiState 接口以接受 List<MarsPhoto> 对象

1
2
3
sealed interface MarsUiState{
data class Success(val photos: List<MarsPhoto>): MarsUiState
}

修改 getPhotos() 的获取 List<MarsPhoto> 对象

1
2
3
4
5
6
7
8
private fun getMarsPhotos() {
viewModelScope.launch{
marsUiState = MarsUiState.Loading
marsUiState = try {
val photos = marsPhotosRepository.getMarsPhotos()
MarsUiState.Success(photos)
}
//...

然后修改 SuccessScreen 使用 LazyColum 显示列表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Composable
fun SuccessScreen(
photos: List<MarsPhoto>,
modifier: Modifier = Modifier) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier
) {
LazyColumn {
itemsIndexed(photos){ _, photo ->
CoilScreen(photo)
}
}
}
}

或是使用 LazyVerticalGrid 显示图片网格