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 + Coroutine + Flow 完整数据流 Retrofit 接口 suspend fun getUser() ViewModel viewModelScope.launch StateFlow UiState 状态 Loading 显示加载动画 Success 展示数据 Error 显示错误信息 UI 层通过 collect 观察 StateFlow,自动响应状态变化 重试/刷新

这张图把整个流程说清楚了:

  1. Retrofit 接口声明为 suspend 函数
  2. ViewModel 里用 viewModelScope.launch 启动协程
  3. 网络请求结果通过 StateFlow 暴露给 UI 层
  4. UI 层根据 Loading / Success / Error 三种状态展示不同界面

6.5 避坑指南

最后分享几个我踩过的坑:

  • 不要在主线程调网络请求——虽然 suspend 函数会自动切线程,但如果你在 Dispatchers.Main 里启动协程,Retrofit 内部还是会切到 IO 线程,但如果你自己写了 withContext(Dispatchers.Main) 包裹网络请求,那就等着 ANR 吧。
  • 异常处理要全面——网络请求可能抛出 IOExceptionHttpExceptionSocketTimeoutException 等等。我曾经只 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