8、统一网络状态封装:创建Resource/Result密封类、封装网络请求的Loading/Success/Error状态、在UI层消费状态

网络请求这件事,说白了就三种结果:正在加载、成功了、出错了。但很多项目里,这三种状态的处理方式五花八门——有人用回调,有人用LiveData传个String,还有人直接在ViewModel里写一堆if-else。我见过最离谱的,是把错误信息塞进一个全局静态变量里……嗯,那项目后来重构了。

这一章,我们来聊聊怎么用Kotlin的密封类,把网络状态统一起来。我个人习惯叫它Resource,也有人叫Result,名字不重要,核心是让状态变得可预测、可组合。

8.1 为什么需要统一状态封装?

你想想看,一个网络请求从发出到结束,UI层需要知道什么?

  • 加载中:显示进度条或骨架屏
  • 成功:展示数据
  • 失败:显示错误提示,可能还要区分是网络错误、服务器错误还是业务错误

如果没有统一封装,每个页面都得自己写一套状态管理逻辑。我在项目中遇到过这样的情况:一个列表页,加载失败时有的页面弹Toast,有的页面显示空视图,还有的直接崩溃了——因为没做空安全处理。

核心思想:用密封类把状态和对应的数据绑定在一起,让编译器帮我们检查所有分支是否都已处理。

8.2 创建Resource密封类

我们先定义一个最基础的版本。Kotlin的密封类(sealed class)非常适合做这件事,因为它限制了子类的数量,而且when表达式可以穷举所有分支。

sealed class Resource<out T> {
    data class Success<T>(val data: T) : Resource<T>()
    data class Error(val message: String, val throwable: Throwable? = null) : Resource<Nothing>()
    object Loading : Resource<Nothing>()
}

这里有几个设计细节:

  • out T 表示协变,这样Resource<User>可以赋值给Resource<Any>,灵活性更高
  • ErrorLoading的泛型是Nothing,因为它们不携带数据
  • throwable是可选的,有时候我们只需要错误信息就够了

不过,这个版本有个小问题——Loading是单例,如果多个请求同时进行,UI层无法区分是哪个请求在加载。我建议在复杂场景下,给Loading也加上一个标识字段:

data class Loading(val tag: String = "default") : Resource<Nothing>()

8.3 封装网络请求:从Retrofit到Resource

有了密封类,接下来就是把网络请求的结果转换成Resource。我通常会写一个扩展函数,放在网络层和仓库层之间:

suspend fun <T> safeApiCall(call: suspend () -> T): Resource<T> {
    return try {
        Resource.Success(call.invoke())
    } catch (e: Exception) {
        when (e) {
            is HttpException -> {
                val errorBody = e.response()?.errorBody()?.string()
                Resource.Error(errorBody ?: "服务器错误", e)
            }
            is IOException -> Resource.Error("网络连接失败,请检查网络", e)
            else -> Resource.Error("未知错误:${e.localizedMessage}", e)
        }
    }
}

这个函数做了三件事:

  1. 正常执行时返回Success
  2. 捕获HttpException(非2xx响应),尝试解析错误体
  3. 捕获IOException(网络断开、超时等),给出用户友好的提示
我的习惯:在safeApiCall里不要直接处理Loading状态。Loading应该在调用方控制,比如在ViewModel里用flowOn或者stateIn来管理。这样职责更清晰。

在仓库层使用起来是这样的:

class UserRepository(private val api: UserApi) {
    suspend fun getUser(id: String): Resource<User> {
        return safeApiCall { api.getUser(id) }
    }
}

8.4 在ViewModel中管理状态

ViewModel是连接仓库和UI的桥梁。我推荐用StateFlow来持有Resource状态,因为它是响应式的,而且支持协程取消。

class UserViewModel(private val repository: UserRepository) : ViewModel() {

    private val _userState = MutableStateFlow<Resource<User>>(Resource.Loading)
    val userState: StateFlow<Resource<User>> = _userState.asStateFlow()

    fun loadUser(id: String) {
        viewModelScope.launch {
            _userState.value = Resource.Loading
            val result = repository.getUser(id)
            _userState.value = result
        }
    }
}

这里有个细节:在发起请求前,先把状态设为Loading。这样UI层可以立即响应,显示加载动画。如果请求很快完成,用户甚至感觉不到加载过程——但状态切换是完整的。

注意:不要在viewModelScope里直接使用liveData构建器来包裹safeApiCall。我曾经这么干过,结果发现LiveData的粘性事件导致页面重建后重复触发网络请求。改用StateFlow后,配合repeatOnLifecycle,问题就解决了。

8.5 在UI层消费状态

UI层拿到StateFlow后,用when表达式来穷举所有分支。这是密封类最大的优势——编译器会提醒你,如果漏掉了某个分支。

@Composable
fun UserScreen(viewModel: UserViewModel) {
    val state by viewModel.userState.collectAsState()

    when (val resource = state) {
        is Resource.Loading -> {
            // 显示加载动画
            CircularProgressIndicator()
        }
        is Resource.Success -> {
            // 显示用户信息
            UserCard(resource.data)
        }
        is Resource.Error -> {
            // 显示错误信息,并提供重试按钮
            ErrorView(
                message = resource.message,
                onRetry = { viewModel.loadUser("123") }
            )
        }
    }
}

如果你用的是XML布局,可以用LiveData配合observe

viewModel.userState.observe(viewLifecycleOwner) { resource ->
    when (resource) {
        is Resource.Loading -> showLoading()
        is Resource.Success -> showUser(resource.data)
        is Resource.Error -> showError(resource.message)
    }
}

嗯,这里要注意:observe的生命周期绑定是自动的,但如果你在Fragment里,记得用viewLifecycleOwner而不是this,否则可能发生内存泄漏。

8.6 进阶:带缓存的状态管理

有时候,我们希望先展示缓存数据,再发起网络请求。这时候Resource可以扩展一下:

sealed class Resource<out T> {
    data class Success<T>(val data: T, val isFromCache: Boolean = false) : Resource<T>()
    data class Error(val message: String, val cachedData: Any? = null) : Resource<Nothing>()
    object Loading : Resource<Nothing>()
}

这样,UI层可以区分数据是来自缓存还是网络,从而决定是否显示刷新动画。我在做新闻类App时用过这个方案——先展示本地缓存的文章列表,等网络请求完成后,无缝替换成最新数据,用户体验非常好。

8.7 知识体系总览

下面这张图概括了本章的核心逻辑:从网络请求到UI消费的完整链路。

统一网络状态封装:从请求到UI的完整链路 Retrofit 网络请求 suspend fun getUser() safeApiCall 封装 try-catch 异常处理 Resource 密封类 Success(data) Error(message) Loading ViewModel → StateFlow<Resource<T>> UI层:when(resource) { ... } 穷举所有分支

8.8 避坑指南

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

  • 不要用Resource包装整个列表:如果列表数据是分页加载的,每个分页都应该有自己的Resource状态,而不是用一个Resource<List<T>>来管理所有页。否则翻页失败时,整个列表都会变成错误状态。
  • 注意Loading状态的重复触发:如果用户在加载过程中快速点击重试按钮,可能会并发多个请求。我习惯在ViewModel里加一个isLoading标志位来防抖。
  • 错误信息要用户友好:不要把throwable.message直接展示给用户。像"Connection refused"这种信息,普通用户看不懂。应该映射成"网络连接失败,请稍后重试"。

好了,这一章的内容就到这里。统一状态封装看起来简单,但用好了能让整个项目的网络层变得非常清晰。下一章我们会继续深入,看看如何用Flow来进一步优化数据流的管理。


公众号:蓝海资料掘金营,微信deep3321