实战:用密封类封装网络请求状态

网络请求是 Android 开发中最常见的场景之一。你想想看,一个请求从发起到结束,会经历哪些状态?加载中、成功、失败——嗯,还有可能是空数据。我见过太多项目用一堆布尔值来管理这些状态,结果代码里到处都是 isLoadingisSuccessisError 的排列组合。说实话,这种方式维护起来真的很痛苦。

今天我们就用密封类来彻底解决这个问题。我会带着你一步步封装一个网络请求状态管理器,把那些散落的布尔值全部收拢到一个类型里。

为什么不用枚举?

有人可能会问:「枚举也能表示状态啊,为什么非要用密封类?」

好问题。枚举确实可以定义一组固定的状态,但它有一个致命缺陷——每个枚举值都是单例,没法携带额外的数据。比如成功状态需要携带返回的数据,错误状态需要携带异常信息,枚举就无能为力了。

密封类就不一样。它的每个子类都可以有自己的属性,想带什么数据就带什么数据。说白了,密封类就是「带数据的枚举」。

定义网络请求状态

我们先定义一个基础的密封类:

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 表达式是穷举的,编译器会强制你处理所有分支。如果你新增了一个状态但忘记处理,代码根本编译不过。这种「编译期安全检查」正是密封类的核心价值。

注意:在 Compose 中记得用 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)
    }
}

知识体系总览

下面这张图总结了本章的核心逻辑:

密封类封装网络请求状态 NetworkResult<T> Idle Loading Success Error ViewModel 中使用 StateFlow + sealed class → 单向数据流 UI 层消费 when 表达式穷举所有状态 → 编译期安全检查 核心优势:类型安全 · 穷举检查 · 携带数据 · 消除布尔值混乱

避坑指南

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

  • 不要用 sealed class 替代 sealed interface:如果你的状态需要被多个类实现(比如跨模块),用 sealed interface 更合适。Kotlin 1.5 之后支持了。
  • 注意泛型擦除:在 when 分支里做类型判断时,is NetworkResult.Success<*>is NetworkResult.Success<User> 在运行时是一样的。所以尽量用 * 通配符。
  • 避免在密封类里放可变数据:所有子类都用 data classdata object,保持不可变性。这样在多线程环境下更安全。

好了,关于用密封类封装网络请求状态,核心要点就这些。你可以在自己的项目里试试看,把那些散落的 isLoadingisError 全部替换成 NetworkResult。相信我,用完之后你会觉得——嗯,早该这么做了。


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