学习笔记:在 Android 中使用 Retrofit 访问互联网
参考上述两个教程,从 0 实现一个使用 Retrofit 连接 REST Web 服务的 APP 。
使用:
- MVVM 架构
- 仓库模式
- 依赖注入
以下网址将获取火星照片列表:
https://android-kotlin-fun-mars-server.appspot.com/photos
1 | [ |
实现 APP 的界面
新建项目
使用 Android Studio 版本 : Ladybug 2024.2.1
File
> New Project
> 选择 Empty Activity
包名为 com.example.marsphoto
创建完成后,移除 Greeting 函数,MainActivity.kt
的内容如下
1 | package com.example.marsphoto |
添加依赖(viewModel 与 Retrofit)
需要添加下面的依赖至 build.gradle.kts (Module:app)
1 | // Retrofit |
使用 version catalogslibs.versions.toml
1 | [versions] |
build.gradle.kts (Module:app)
1 | // Retrofit |
创建 Screen 与 viewModel 文件
分别创建 HomeScreen.kt
与 MarsViewModel.kt
文件,以固定文本代替 Photos 的获取结果
MarsViewModel.kt
1 | package com.example.marsphoto.ui |
HomeScreen.kt
1 | package com.example.marsphoto.ui |
在 MainActivity.kt
调用 HomeScreen()
,运行后可以查看 Get Photos with Mars Api
显示在屏幕中
1 |
|
使用 Retrofit 访问网络
为 APP 添加联网权限
打开 manifests/AndroidManifest.xml
在
1 | <uses-permission android:name="android.permission.INTERNET" /> |
创建 Retrofit 对象
创建 network
包,创建 MarsApiService.kt
文件,创建 Retrofit 对象
1 | // 设置 BASE_URL |
MarsApi 对象通过 object 关键字声明,确保在应用程序的生命周期内只创建一个 MarsApiService 实例。
这种方式有助于管理网络请求的一致性。
单例模式应该只用于测试,正式使用推荐 Android 中的依赖项注入
在 viewModel 中调用 Retrofit 对象
修改 MarsViewModel.kt
的 getMarsPhotos()
,将硬编码的 marsUiState = "Get Photos with Mars Api"
修改为下面的内容
1 | private fun getMarsPhotos() { |
重新编译即可查看 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 | // Kotlin serialization |
将 Scalar Converter 替换为 Kotlin serialization Converter
1 | // Retrofit with Kotlin serialization Converter |
创建 Kotlin 对象
在 network 下创建 MarsPhoto.kt
数据类,包含一个 id 与一个 imgSrc 属性。
1 | package com.example.marsphoto.network |
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 | private val retrofit = Retrofit.Builder() |
修改为
1 | private val retrofit = Retrofit.Builder() |
现在可以要求 Retrofit 返回 MarsPhoto
对象,而不是 String
。
1 | interface MarsApiService{ |
由于 Retrofit 不再返回 String 类型,MarsViewModel 中调用部分也需要修改。
1 | marsUiState = "Get Photos with Mars Api" |
运行查看结果。
添加 Loading 与 Error 屏幕
改造一下 MarsViewModel 与 HomeScreen ,使 APP 可以显示 Loading 与 Error 的情况。
HomeScreen
1 |
|
MarsViewModel
1 | sealed interface MarsUiState{ |
添加仓库
数据层与界面层
根据 Android 的推荐应用架构 ,应用应至少具有一个界面层和一个数据层。
数据层负责应用的业务逻辑以及为应用寻源和保存数据。数据层使用单向数据流模式向界面层公开数据。数据可能来自多个来源,例如网络请求、本地数据库或设备上的文件。
一个应用甚至可能有多个数据源。
界面层的关注点是显示所提供的数据。界面(Screen 与 ViewModel)不再检索数据,因为这是数据层的关注点。
数据层由一个或多个仓库组成。仓库本身包含零个或多个数据源。
仓库类的作用包括:
- 向应用的其余部分公开数据。
- 集中管理数据更改。
- 解决多个数据源之间的冲突。
- 对应用其余部分的数据源进行抽象化处理。
- 存放业务逻辑。
Mars Photos 应用只有一个数据源,即网络 API 调用。它只检索数据,因此没有任何业务逻辑。数据通过仓库类公开提供给应用,该类会对数据源进行抽象化处理。
创建仓库
Android 开发者指南指出,仓库类以其所负责的数据命名。仓库命名惯例是 数据类型 + 仓库。在这个应用中,其名称为 MarsPhotosRepository。
- 创建 package
data
- 新建一个名称为
MarsPhotosRepository.kt
的 Interface1
2
3
4
5
6
7
8package 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
5class NetWorkMarsPhotosRepository : MarsPhotosRepository{
override suspend fun getMarsPhotos(): List<MarsPhoto> {
return MarsApi.retrofitService.getPhotos()
}
}MarsViewModel.kt
文件,将val listResult = MarsApi.retrofitService.getPhotos()
行替换为以下代码:运行应用,查看结果,确认与之前相同。1
2val marsPhotosRepository = NetWorkMarsPhotosRepository()
val listResult = marsPhotosRepository.getMarsPhotos()
仓库将提供数据,而不是由 ViewModel 直接发出关于数据的网络请求。ViewModel 不再直接引用 MarsApi 代码。
手动依赖项注入(DI)
Ref: 手动依赖项注入
依赖项注入(Dependency Injection,DI) 是一种软件设计模式,旨在将组件之间的依赖关系从内部创建转移到外部提供。这意味着一个类不再负责创建它所依赖的对象,而是通过构造函数、属性或方法参数由外部提供这些依赖。
核心概念:
- 依赖项(Dependencies): 一个类所需要的其他类或对象。
- 注入(Injection): 通过外部手段(如构造函数、属性、方法)将依赖项提供给需要的类。
依赖项注入的主要好处包括:
- 解耦合: 类与其依赖项之间的耦合度降低,增强模块间的独立性。
- 可测试性: 更容易为类编写单元测试,因为可以轻松地替换依赖项为模拟对象。
- 可维护性: 更清晰的依赖关系使得代码更易于理解和维护。
- 灵活性与可扩展性: 依赖项的实现可以在不改变依赖方代码的情况下进行替换或扩展。
手动依赖项注入的方法
构造函数注入 是最常见且推荐的 DI 方法。通过在类的构造函数中声明依赖项,使得这些依赖项在创建类的实例时被提供。
示例:
1 | class Repository { |
使用:
1 | val repository = Repository() |
单例模式与服务定位器模式
单例模式 和 服务定位器模式 是另一种手动 DI 的方法,但需要谨慎使用,因为它们可能引入全局状态和隐式依赖,降低代码的可测试性。
1 | object ServiceLocator { |
这种方法使得依赖项在类中是隐式的,不利于测试和维护。
创建应用容器
- 在
data
下创建一个AppContainer
的 Interface - 在
AppContainer
内添加一个名为marsPhotosRepository
且类型为MarsPhotosRepository
的抽象属性。1
2
3interface AppContainer {
val marsPhotosRepository: MarsPhotosRepository
} - 在接口定义下方,创建一个名为
DefaultAppContainer
的类来实现AppContainer
接口。 - 将
network/MarsApiService.kt
中的内容移至DefaultAppContainer
类,让它们都位于用于维护依赖项的容器中。
MarsApiService.kt 只需要保留如下内容1
2
3
4interface MarsApiService{
suspend fun getPhotos(): List<MarsPhoto>
} DefaultAppContainer
类会实现AppContainer
接口,因此我们需要替换marsPhotosRepository
属性。在变量retrofitService
后面添加以下代码:完成的1
2
3override 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
30package 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
3class NetWorkMarsPhotosRepository(private val marsApiService: MarsApiService) : MarsPhotosRepository{
override suspend fun getMarsPhotos(): List<MarsPhoto> = marsApiService.getPhotos()
}将应用容器附加到应用
目的:将应用容器与应用的生命周期关联起来,使得整个应用都能访问到这些依赖项。
在 com.example.marsphotos
下创建类 MarsPhotosApplication
, 此类继承自 Application()
1 | import android.app.Application |
在 MarsPhotosApplication
类中,声明一个名为 container
且类型为 AppContainer
的变量,用于存储 DefaultAppContainer
对象。该变量会在调用 onCreate()
期间初始化,因此需要使用 lateinit
修饰符标记该变量。
1 | package com.example.marsphotos |
编辑清单文件 manifests/AndroidManifest.xml
,添加值为应用类名称 “.MarsPhotosApplication” 的 android:name 属性。```xml
1 | <application |
将仓库添加到 ViewModel
完成上述步骤后,ViewModel 可以调用仓库对象来检索数据。
编辑 MarsViewModel.kt
文件,在 MarsViewModel
的类声明中,添加一个类型为 MarsPhotosRepository
的私有构造函数形参 marsPhotosRepository
。构造函数形参的值来自应用容器,因为应用现在在使用依赖项注入。
1 | class MarsViewModel(private val marsPhotosRepository: MarsPhotosRepository) : ViewModel(){ |
在 getMarsPhotos()
函数中可以移除
1 | val marsPhotosRepository = NetworkMarsPhotosRepository() |
由于 Android 框架不允许在创建时向构造函数中的 ViewModel 传递值,因此我们实现了一个 ViewModelProvider.Factory 对象来绕过此限制。
在函数 MarsViewModel.kt
的 getMarsPhotos()
下,输入伴生对象的代码。
1 | import androidx.lifecycle.ViewModelProvider |
在 HomeScreen.kt
文件中,将
1 | val viewModel: MarsViewModel = viewModel() |
修改为
1 | val viewModel: MarsViewModel = viewModel(factory = MarsViewModel.Factory) |
运行应用,确认应用是否像之前一样正常运行。
它现已使用仓库和依赖项注入!通过仓库实现数据层之后,界面和数据源已实现分离,并且符合 Android 最佳实践。
总结
应用容器 用于集中管理和提供应用所需的各种依赖项(如仓库、网络服务等)
- Repositroy 提供数据
Repository 定义数据获取和处理逻辑,并将其提供给应用的其他部分(如 ViewModel)。 - 在 AppContainer 中定义
声明应用所需的依赖项。1
2
3interface AppContainer {
val marsPhotosRepository: MarsPhotosRepository
} - 在 DefaultAppContainer 中实现注入
具体创建和提供依赖实例。DefaultAppContainer
类实现了AppContainer
接口,并具体提供了各个依赖项的实例。 - 在 Application() 中初始化
在应用启动时初始化依赖容器。同时需要修改清单文件1
2
3
4
5
6
7
8class 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> - 通过构造函数传递给 ViewModel
将依赖注入到 ViewModel 中。1
2
3class MarsViewModel(private val marsPhotosRepository: MarsPhotosRepository) : ViewModel() {
// ViewModel 的实现
} - 需要自定义 ViewModel.Factory 实现参数的传递
创建自定义工厂以支持依赖注入。1
2
3
4
5
6
7
8
9companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = (this[APPLICATION_KEY] as MarsPhotosApplication)
val marsPhotosRepository = application.container.marsPhotosRepository
MarsViewModel(marsPhotosRepository = marsPhotosRepository)
}
}
} - 在 Screen @Composable 函数中使用 Factory 初始化 ViewModel
在 UI 层获取并使用 ViewModel。1
2
3
4
5
fun HomeScreen() {
val viewModel: MarsViewModel = viewModel(factory = MarsViewModel.Factory)
// 使用 ViewModel 进行 UI 操作
}
添加测试
测试依赖
libs.versions.toml
1 | [versions] |
build.gradle.kts (Module: app)
> dependencies
1 | testImplementation(libs.junit) |
添加测试数据
在 com.example.marsphoto(test) 目录下创建 package fake
,创建 FakeDataSource
文件,填入下面的内容
(androidTest) 是需要编译安装至模拟器进行测试
1 | object FakeDataSource { |
进行测试
创建 FakeMarsApi.kt
1 | class FakeMarsApiService: MarsApiService{ |
创建 NetworkMarsRepositoryTest,填入测试内容
1 | class NetworkMarsRepositoryTest { |
测试 ViewModel
创建 FakeNetworkMarsPhotosRepository 类供测试使用
1 | class FakeNetworkMarsPhotosRepository : MarsPhotosRepository{ |
创建,MarsViewModelTest 新类,编写 ViewModel 测试
1 | class MarsViewModelTest (){ |
运行测试后报错,出现错误的原因是 Android 界面线程在单元测试中不可用,可以移到 androidTest 中进行测试。
不过依然报错
1 | junit.framework.AssertionFailedError: expected:<Success(photo=Success: 2 Mars photos retrieved)> but |
这是因为 ViewModel 中的 getMarsPhotos()
函数是异步的,而测试用例没有等待异步任务完成。
可以在断言前,添加 advanceUntilIdle()
让测试等待协程执行完毕MarsViewModelAndroidTest.kt
1 | class MarsViewModelTest (){ |
再使用 androidTest 就显示通过。
当然在普通的单元测试中也是可以进行测试的,要在运行单元测试时 明确定义默认调度程序 #创建测试调度程序
。
- 在测试目录中创建一个名为
rules
的新软件包。 - 在
rules
目录中,新建一个名为TestDispatcherRule
的类。 - 使用
TestWatcher
扩展TestDispatcherRul
e。借助 TestWatcher 类,可以在测试的不同执行阶段执行操作。 - 为
TestDispatcherRule
创建一个TestDispatcher
构造函数形参。 - 替换
starting()
函数,在测试开始执行之前将 Main 调度程序替换为测试调度程序,添加对Dispatchers.setMain()
的调用,并传入testDispatcher
作为实参。 - 替换
finished()
方法以重置 Main 调度程序。调用Dispatchers.resetMain()
函数。1
2
3
4
5
6
7
8
9
10
11class TestDispatcherRule(
val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
override fun starting(description: Description) {
Dispatchers.setMain(testDispatcher)
}
override fun finished(description: Description) {
Dispatchers.resetMain()
}
} - 在
MarsViewModelTest
类中,对TestDispatcherRule
类进行实例化并将其分配给testDispatcher
只读属性。应用于测试时需要将@get:Rule
注解添加到testDispatcher
属性。再运行就可以了,教程是这么写的1
2
3
4
5class MarsViewModelTest {
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 | [versions] |
build.gradle.kts (Project)
> plugins
添加
1 | alias(libs.plugins.google.ksp) apply false |
在 build.gradle.kts (Module: app)
> plugins
部分添加
1 | alias(libs.plugins.google.ksp) |
在 build.gradle.kts (Module: app)
> dependencies
部分添加
1 | // Hilt |
如果 Android Studio sync 之后全线标红报错,只要在 build.gradle.kts (Module: app)
输入一行再撤销就好了。
完成可以 build 一下 APP ,看看是否会报错。
使用 hilt
除了依赖之外的起始代码为之前添加完了 MarsPhotosRepository.kt
的代码
首先创建
MarsApplication
类继承自Application()
并添加@HiltAndroidApp
注解
所有使用 Hilt 的应用都必须包含一个带有@HiltAndroidApp
注解的Application()
类1
2
3
4
class MarsApplication : Application(){
//在这里初始化全局依赖项
}与手动注入一样,需要在清单文件
AndroidManifest.xml
中声明创建的 Application 类:1
2
3
4
5<application
android:name=".MarsApplication"
... >
<!-- 其他配置 -->
</application>创建 Hilt 模块以提供依赖项
为了让 Hilt 知道如何提供Retrofit
、MarsApiService
和MarsPhotosRepository
,我们需要创建一个模块。模块是一个用@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
47package 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"
abstract class AppModule{
// 使用 @Binds 将接口绑定到实现
abstract fun bindMarsPhotosRepository(
netWorkMarsPhotosRepository: NetWorkMarsPhotosRepository
) : MarsPhotosRepository
companion object {
// 使用 @Provides 提供 Retrofit 实例
fun provideRetrofit(): Retrofit {
return Retrofit.Builder()
.addConverterFactory(Json.asConverterFactory("application/json".toMeditaType()))
.baseUrl(BASE_URL)
.build()
}
// 使用 @Provides 提供 MarsApiService 实例
fun provideMarsApiService(retrofit: Retrofit) : MarsApiService{
return retrofit.create(MarsApiService::class.java)
}
}
}
@Module
:标记该类为Hilt
模块。@InstallIn(SingletonComponent::class)
:指定模块的生命周期,这里使用SingletonComponent
使其在整个应用程序生命周期内有效。@Binds
:用于绑定接口到实现。在这种情况下,我们将MarsPhotosRepository
接口绑定到NetWorkMarsPhotosRepository
实现。@Provides
:用于提供具体的依赖项实例,如Retrofit
和MarsApiService
修改 MarsApiService.kt
与手动注入一样,MarsApiService.kt
只需要保留接口的定义,不再需要 MarsApi 对象。1
2
3
4
5
6
7
8package com.example.marsphoto.network
import retrofit2.http.GET
interface MarsApiService{
suspend fun getPhotos(): List<MarsPhoto>
}修改
NetWorkMarsPhotosRepository.kt
确保NetWorkMarsPhotosRepository
使用依赖注入来获取MarsApiService
1
2
3
4
5class NetWorkMarsPhotosRepository constructor(
private val marsApiService: MarsApiService
) : MarsPhotosRepository{
override suspend fun getMarsPhotos(): List<MarsPhoto> = marsApiService.getPhotos()
}@Inject
构造函数注解:告诉 Hilt 如何创建NetWorkMarsPhotosRepository
,即通过注入MarsApiService
。修改
MarsViewModel.kt
以使用 Hilt 进行依赖注入1
2
3class MarsViewModel: ViewModel() {
//...
}修改为
1
2
3
4
5
6
class MarsViewModel constructor(
private val marsPhotosRepository: MarsPhotosRepository
) : ViewModel(){
//...
}移除
getMarsPhotos()
函数中的1
val marsPhotosRepository = NetWorkMarsPhotosRepository()
@HiltViewModel
:标记 ViewModel 以便 Hilt 可以为其提供依赖项。@Inject constructor{}
:注入 MarsPhotosRepository 到 ViewModel 中。- 移除了直接创建
NetWorkMarsPhotosRepository
的代码,改为使用注入的marsPhotosRepository。
修改 UI Screen 中的
viewModel()
为hiltViewModel()
1
val viewModel: MarsViewModel = hiltViewModel()
修改 Activity 以使用 Hilt
确保使用 viewModel 的 Activity 或 Fragment 使用@AndroidEntryPoint
注解,以便 Hilt 能够为它们提供依赖项。1
2
3
4
class MainActivity : ComponentActivity() {
//...
}运行 APP ,可以正常显示结果。
总结
在 AppModule.kt
中,使用了一个 @Binds
方法来将 MarsPhotosRepository
接口绑定到 NetWorkMarsPhotosRepository
实现。
有关 @Binds
方法:
Hilt 在编译时会处理所有的模块和绑定方法,生成相应的代码来管理依赖关系。@Binds
作用是告诉 Hilt 如何将接口类型绑定到具体实现。
当 Hilt
需要提供 MarsPhotosRepository
的实例时,它会参考 @Binds
方法的定义,知道应该实例化 NetWorkMarsPhotosRepository
并将其作为 MarsPhotosRepository
的实现。
Hilt 的依赖注入流程
1 | MainActivity |
使用 Coil 显示图片
添加依赖
1 | implementation("io.coil-kt:coil-compose:2.7.0") |
Coil 示例
1 | AsyncImage( |
显示单张图片
在 MarsViewModel.kt
文件中,更新 MarsUiState
接口以接受 MarsPhoto
对象,而不是 String
1 | sealed interface MarsUiState{ |
修改 getPhotos()
获取第一张图片
1 | private fun getMarsPhotos() { |
修改 HomeScreen.kt 显示图片,首先创建一个 CoilScreen
显示单张图片,
1 |
|
然后修改 SuccessScreen
接受的参数
1 |
|
显示全部图片
在 MarsViewModel.kt
文件中,更新 MarsUiState
接口以接受 List<MarsPhoto>
对象
1 | sealed interface MarsUiState{ |
修改 getPhotos()
的获取 List<MarsPhoto>
对象
1 | private fun getMarsPhotos() { |
然后修改 SuccessScreen
使用 LazyColum
显示列表
1 |
|
或是使用 LazyVerticalGrid
显示图片网格