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>,灵活性更高Error和Loading的泛型是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)
}
}
}
这个函数做了三件事:
- 正常执行时返回
Success - 捕获
HttpException(非2xx响应),尝试解析错误体 - 捕获
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消费的完整链路。
8.8 避坑指南
最后,分享几个我踩过的坑:
- 不要用
Resource包装整个列表:如果列表数据是分页加载的,每个分页都应该有自己的Resource状态,而不是用一个Resource<List<T>>来管理所有页。否则翻页失败时,整个列表都会变成错误状态。 - 注意
Loading状态的重复触发:如果用户在加载过程中快速点击重试按钮,可能会并发多个请求。我习惯在ViewModel里加一个isLoading标志位来防抖。 - 错误信息要用户友好:不要把
throwable.message直接展示给用户。像"Connection refused"这种信息,普通用户看不懂。应该映射成"网络连接失败,请稍后重试"。
好了,这一章的内容就到这里。统一状态封装看起来简单,但用好了能让整个项目的网络层变得非常清晰。下一章我们会继续深入,看看如何用Flow来进一步优化数据流的管理。