6、Retrofit + Coroutine实战:将Retrofit接口声明为suspend函数、在ViewModel中调用、处理Loading与Error状态
好,咱们今天来点真格的。
前面几章我们把 Retrofit 和 Coroutine 的基础都铺了一遍,现在该把它们真正揉到一起了。说实话,我第一次把 Retrofit 接口改成 suspend 函数的时候,心里还有点打鼓——这玩意儿真的能跑通吗?结果一试,嗯,真香。
6.1 把 Retrofit 接口改成 suspend 函数
传统的 Retrofit 调用,你肯定见过:
// 老写法
@GET("users/{id}")
fun getUser(@Path("id") id: String): Call<User>
然后在代码里要写 enqueue,要处理回调,代码一多就乱成一锅粥。我个人习惯的做法是直接改成 suspend:
// 新写法
@GET("users/{id}")
suspend fun getUser(@Path("id") id: String): User
注意看区别:返回值从 Call<User> 变成了 User,前面加了个 suspend 关键字。就这么简单。
核心要点:suspend 函数会让 Retrofit 自动在后台线程执行网络请求,不需要你手动切换线程。返回的就是解析好的数据对象,不是 Call 包装。
我在项目中遇到过一个问题:如果接口返回的是 Response<User> 而不是 User,那 suspend 函数也能用吗?答案是肯定的:
@GET("users/{id}")
suspend fun getUserResponse(@Path("id") id: String): Response<User>
这样你还能拿到 HTTP 状态码、header 等信息。不过大多数情况下,直接返回数据对象就够了。
6.2 在 ViewModel 里调用 suspend 函数
接口写好了,怎么在 ViewModel 里调?很多新手会直接这么写:
// 错误示范
class UserViewModel : ViewModel() {
private val api = RetrofitClient.instance
fun loadUser(id: String) {
// 这样写会报错!suspend 函数只能在协程里调用
val user = api.getUser(id)
}
}
为什么会这样?因为 suspend 函数必须在协程作用域里才能跑。正确的做法是启动一个协程:
class UserViewModel : ViewModel() {
private val api = RetrofitClient.instance
fun loadUser(id: String) {
viewModelScope.launch {
val user = api.getUser(id)
// 更新 UI
_user.value = user
}
}
}
viewModelScope 是 Android 官方提供的扩展属性,它会自动绑定 ViewModel 的生命周期。ViewModel 销毁时,协程自动取消,不会泄漏。
小技巧:如果你需要在后台线程做耗时操作,可以用 withContext(Dispatchers.IO) 包裹网络请求。不过 Retrofit 的 suspend 函数默认就在 IO 线程执行,所以大多数情况下不需要手动指定。
6.3 处理 Loading 和 Error 状态
光有数据还不够,用户界面需要知道当前是加载中、成功还是失败。我一般会定义一个密封类来管理这些状态:
sealed class UiState<out T> {
object Loading : UiState<Nothing>()
data class Success<T>(val data: T) : UiState<T>()
data class Error(val message: String) : UiState<Nothing>()
}
然后在 ViewModel 里这样用:
class UserViewModel : ViewModel() {
private val api = RetrofitClient.instance
private val _uiState = MutableStateFlow<UiState<User>>(UiState.Loading)
val uiState: StateFlow<UiState<User>> = _uiState.asStateFlow()
fun loadUser(id: String) {
viewModelScope.launch {
_uiState.value = UiState.Loading
try {
val user = api.getUser(id)
_uiState.value = UiState.Success(user)
} catch (e: Exception) {
_uiState.value = UiState.Error(e.message ?: "未知错误")
}
}
}
}
UI 层只需要观察 uiState 这个 Flow,根据不同的状态展示不同的界面:
// 在 Activity 或 Fragment 里
lifecycleScope.launch {
viewModel.uiState.collect { state ->
when (state) {
is UiState.Loading -> showLoading()
is UiState.Success -> showUser(state.data)
is UiState.Error -> showError(state.message)
}
}
}
注意:千万不要在 catch 块里吞掉异常。我曾经见过有人直接写 catch {},结果网络错误永远不显示,用户以为 App 卡死了。至少要把错误信息抛出来。
6.4 完整的实战流程
咱们把上面所有东西串起来,画一张图看看整体流程:
这张图把整个流程说清楚了:
- Retrofit 接口声明为 suspend 函数
- ViewModel 里用
viewModelScope.launch启动协程 - 网络请求结果通过 StateFlow 暴露给 UI 层
- UI 层根据 Loading / Success / Error 三种状态展示不同界面
6.5 避坑指南
最后分享几个我踩过的坑:
- 不要在主线程调网络请求——虽然 suspend 函数会自动切线程,但如果你在
Dispatchers.Main里启动协程,Retrofit 内部还是会切到 IO 线程,但如果你自己写了withContext(Dispatchers.Main)包裹网络请求,那就等着 ANR 吧。 - 异常处理要全面——网络请求可能抛出
IOException、HttpException、SocketTimeoutException等等。我曾经只 catch 了Exception,结果CancellationException也被吞了,导致协程取消后 UI 还在转圈。建议区分处理:
try {
val user = api.getUser(id)
_uiState.value = UiState.Success(user)
} catch (e: CancellationException) {
throw e // 协程取消时重新抛出
} catch (e: HttpException) {
_uiState.value = UiState.Error("服务器错误: ${e.code()}")
} catch (e: IOException) {
_uiState.value = UiState.Error("网络连接失败,请检查网络")
} catch (e: Exception) {
_uiState.value = UiState.Error("未知错误: ${e.message}")
}
个人建议:把网络请求的异常处理封装成一个工具函数,比如 safeApiCall,这样每个 ViewModel 里就不用重复写 try-catch 了。后面章节我会专门讲这个封装技巧。
好了,这一章的内容就到这里。Retrofit + Coroutine 的组合拳打出去,网络层瞬间清爽很多。你想想看,以前写回调嵌套的日子,是不是一去不复返了?
公众号:蓝海资料掘金营,微信deep3321