实战:用密封类封装网络请求状态
网络请求是 Android 开发中最常见的场景之一。你想想看,一个请求从发起到结束,会经历哪些状态?加载中、成功、失败——嗯,还有可能是空数据。我见过太多项目用一堆布尔值来管理这些状态,结果代码里到处都是 isLoading、isSuccess、isError 的排列组合。说实话,这种方式维护起来真的很痛苦。
今天我们就用密封类来彻底解决这个问题。我会带着你一步步封装一个网络请求状态管理器,把那些散落的布尔值全部收拢到一个类型里。
为什么不用枚举?
有人可能会问:「枚举也能表示状态啊,为什么非要用密封类?」
好问题。枚举确实可以定义一组固定的状态,但它有一个致命缺陷——每个枚举值都是单例,没法携带额外的数据。比如成功状态需要携带返回的数据,错误状态需要携带异常信息,枚举就无能为力了。
密封类就不一样。它的每个子类都可以有自己的属性,想带什么数据就带什么数据。说白了,密封类就是「带数据的枚举」。
定义网络请求状态
我们先定义一个基础的密封类:
sealed class NetworkResult<out T> {
data class Success<T>(val data: T) : NetworkResult<T>()
data class Error(val exception: Throwable, val message: String? = null) : NetworkResult<Nothing>()
data object Loading : NetworkResult<Nothing>()
data object Idle : NetworkResult<Nothing>()
}
这里我定义了四个状态:
- Idle:初始状态,还没发起请求
- Loading:请求进行中
- Success:请求成功,携带数据
- Error:请求失败,携带异常信息
注意我用了 data object 而不是 object。这是 Kotlin 1.9 引入的新特性,让单例对象也能有 toString()、equals() 等默认实现。我个人习惯在密封类里统一用 data object,保持风格一致。
out 关键字,表示协变。这样 NetworkResult<String> 可以赋值给 NetworkResult<Any>,灵活性更高。
在 ViewModel 中使用
定义好状态类型后,我们来看看怎么在 ViewModel 里用:
class UserViewModel : ViewModel() {
private val _userResult = MutableStateFlow<NetworkResult<User>>(NetworkResult.Idle)
val userResult: StateFlow<NetworkResult<User>> = _userResult.asStateFlow()
fun loadUser(userId: String) {
viewModelScope.launch {
_userResult.value = NetworkResult.Loading
try {
val user = userRepository.fetchUser(userId)
_userResult.value = NetworkResult.Success(user)
} catch (e: Exception) {
_userResult.value = NetworkResult.Error(e, "加载用户信息失败")
}
}
}
}
这段代码的逻辑很清晰:先发 Loading,然后根据结果发 Success 或 Error。没有多余的布尔值,没有状态冲突的可能。我在项目中遇到过好几次因为忘记重置 isLoading 导致的 UI 闪烁问题,用了密封类之后再也没有这种烦恼了。
在 UI 层优雅地处理
UI 层怎么消费这些状态呢?用 when 表达式就对了:
@Composable
fun UserScreen(viewModel: UserViewModel = hiltViewModel()) {
val result by viewModel.userResult.collectAsStateWithLifecycle()
when (result) {
is NetworkResult.Idle -> {
// 初始状态,显示占位内容
PlaceholderContent()
}
is NetworkResult.Loading -> {
// 加载中,显示进度条
LoadingIndicator()
}
is NetworkResult.Success -> {
// 成功,显示用户信息
val user = (result as NetworkResult.Success).data
UserProfile(user)
}
is NetworkResult.Error -> {
// 失败,显示错误信息
val error = result as NetworkResult.Error
ErrorView(
message = error.message ?: "未知错误",
onRetry = { viewModel.loadUser("123") }
)
}
}
}
这里有个细节:when 表达式是穷举的,编译器会强制你处理所有分支。如果你新增了一个状态但忘记处理,代码根本编译不过。这种「编译期安全检查」正是密封类的核心价值。
collectAsStateWithLifecycle() 而不是 collectAsState()。前者能感知生命周期,避免在后台更新 UI 导致崩溃。我曾经因为这个踩过坑,后来就养成了习惯。
封装一个通用的网络请求工具
每次写 try-catch 也挺烦的,对吧?我们可以封装一个工具函数:
suspend fun <T> safeApiCall(
call: suspend () -> T,
errorMessage: String? = null
): NetworkResult<T> {
return try {
NetworkResult.Success(call())
} catch (e: Exception) {
NetworkResult.Error(e, errorMessage ?: e.localizedMessage)
}
}
然后在 ViewModel 里这样用:
fun loadUser(userId: String) {
viewModelScope.launch {
_userResult.value = NetworkResult.Loading
_userResult.value = safeApiCall(
call = { userRepository.fetchUser(userId) },
errorMessage = "加载用户信息失败"
)
}
}
代码是不是清爽多了?这个工具函数我在好几个项目里复用,效果都很好。
进阶:带缓存的状态管理
有时候我们想先展示缓存数据,再刷新网络数据。可以扩展一下密封类:
sealed class NetworkResult<out T> {
data class Success<T>(val data: T, val isFromCache: Boolean = false) : NetworkResult<T>()
// ... 其他状态不变
}
这样 UI 层就可以根据 isFromCache 来决定是否显示刷新动画:
is NetworkResult.Success -> {
val result = result as NetworkResult.Success
if (result.isFromCache) {
// 显示缓存数据,后台静默刷新
UserProfile(result.data, showRefreshIndicator = false)
} else {
// 显示最新数据
UserProfile(result.data, showRefreshIndicator = true)
}
}
知识体系总览
下面这张图总结了本章的核心逻辑:
避坑指南
最后分享几个我踩过的坑:
- 不要用
sealed class替代sealed interface:如果你的状态需要被多个类实现(比如跨模块),用sealed interface更合适。Kotlin 1.5 之后支持了。 - 注意泛型擦除:在
when分支里做类型判断时,is NetworkResult.Success<*>和is NetworkResult.Success<User>在运行时是一样的。所以尽量用*通配符。 - 避免在密封类里放可变数据:所有子类都用
data class或data object,保持不可变性。这样在多线程环境下更安全。
好了,关于用密封类封装网络请求状态,核心要点就这些。你可以在自己的项目里试试看,把那些散落的 isLoading、isError 全部替换成 NetworkResult。相信我,用完之后你会觉得——嗯,早该这么做了。