深入 Android 应用异常状态处理与用户体验降级全链路:从网络错误优雅提示到空状态骨架屏的工程化容错 UI 架构

上个月做大促活动页,产品和 UI 评审完交互稿,我估了一周工时。

实际花了近两周。多出来的时间全花在各种状态处理上:Loading 时怎么避免闪屏、网络出错后重试按钮放哪、数据为空时文案怎么改、Token 过期如何做到用户无感刷新。这些”异常路径”的代码量占页面 30-40%,但团队里写得最随意——每人一套野路子,Code Review 的时候要蒙半天。

在经历几个中大型项目之后,我越来越确定一件事:容错 UI 不是功能补丁,需要系统化设计。

四种状态,一个状态机

一个数据驱动的页面,本质上是四个状态之间的流转:

Loading ──→ Content ──→ Empty
   │           │
   ▼           ▼
 Error       Error

这四种状态覆盖了 90% 的页面场景。用枚举或密封类把它们显式定义出来,是系统化处理的第一步:

sealed class PageState<out T> {
    object Loading : PageState<Nothing>()
    data class Content<T>(val data: T) : PageState<T>()
    data class Empty(val message: String = "暂无数据") : PageState<Nothing>()
    data class Error(val throwable: Throwable, val canRetry: Boolean = true) : PageState<Nothing>()
}

把状态和数据绑定在一起,ViewModel 只需暴露一个 StateFlow<PageState<T>>,UI 层根据状态渲染对应界面。用密封类还有一个实际好处:编译器帮你检查穷尽性。忘记处理 Empty 状态时,when 直接标红,不用靠 Code Review 人肉检查。

状态容器的组合能力

单页面处理四种状态是基础操作。真正的难题在于:一个页面往往由多个独立数据源组成,各自有自己的状态。

我在一个首页改版项目中踩过坑:顶部 Banner 加载失败,整个页面被 Error 态接管,下面的推荐列表明明加载成功却不可见。根因是团队用了全局状态覆盖——一个 isError 字段控制整页。

解法是用复合状态容器做局部降级:

data class HomePageState(
    val banner: PageState<List<Banner>> = PageState.Loading,
    val feedList: PageState<List<FeedItem>> = PageState.Loading,
    val quickEntry: PageState<List<Entry>> = PageState.Loading
)

渲染时每个模块独立消费自己的状态。Banner 出错只影响 Banner 区域,Feed 列表照常展示。ViewModel 中各自请求、各自更新,互不干扰:

viewModelScope.launch {
    bannerRepo.fetch().collect { result ->
        _state.update { it.copy(banner = result.toPageState()) }
    }
}
viewModelScope.launch {
    feedRepo.fetch().collect { result ->
        _state.update { it.copy(feedList = result.toPageState()) }
    }
}

骨架屏:Loading 不只是转圈

CircularProgressIndicator 放中间是最偷懒的做法,也是让页面显粗糙的根源。用户看到一片空白加一个转圈,既不知道页面结构,也不知道要等多久。

骨架屏的本质是用占位图形预演页面布局,降低用户对等待时长的感知。实现上,我倾向用 shimmer 动画而非静态灰色块——微妙的流光效果比纯色块看起来更”有东西在发生”。

骨架屏真正的坑在于适配。真实 UI 的宽度、高度、圆角各不相同,为每种卡片写一套骨架屏 XML 是体力活。我的做法是抽通用组件:

@Composable
fun ShimmerCard(lines: Int = 3, showAvatar: Boolean = true) {
    Column(modifier = Modifier.shimmerEffect()) {
        if (showAvatar) {
            Box(modifier = Modifier.size(40.dp).clip(CircleShape).shimmerPlaceholder())
            Spacer(modifier = Modifier.height(8.dp))
        }
        repeat(lines) { index ->
            Box(
                modifier = Modifier
                    .fillMaxWidth(if (index == lines - 1) 0.6f else 1f)
                    .height(14.dp)
                    .shimmerPlaceholder()
            )
            Spacer(modifier = Modifier.height(6.dp))
        }
    }
}

一个 ShimmerCard 覆盖了列表页 80% 的骨架屏需求,剩下的特殊布局再手写,投入产出比合理。

错误态的分级处理

并不是所有错误都该弹 Toast 再放一个重试按钮。不同错误对用户的影响不同,处理策略也应该分层。

可恢复错误:网络超时、服务端 5xx。给重试按钮,附带一句说明。“网络不太稳定,点击重试”比冷冰冰的”请求失败”多了点温度。

业务错误:库存不足、优惠券已过期。这是”正常的异常”,直接展示业务文案,不需要重试按钮。

不可恢复错误:Token 过期、接口 403。静默处理比弹框更好。Token 过期后,拦截器自动刷新,上层 UI 无感知。用 OkHttp 的 Authenticator 统一处理:

class TokenAuthenticator(
    private val tokenProvider: TokenProvider
) : Authenticator {
    override fun authenticate(route: Route?, response: Response): Request? {
        synchronized(this) {
            val newToken = runBlocking { tokenProvider.refresh() }
            return if (newToken != null) {
                response.request.newBuilder()
                    .header("Authorization", "Bearer $newToken")
                    .build()
            } else {
                // 刷新失败,跳到登录页
                null
            }
        }
    }
}

开发阶段错误:参数错误、数据格式不一致。这类应该在开发和测试阶段暴露为 crash,不要用 try-catch 吞掉。线上环境记录到 Crash 平台,走常规修复流程。

空状态的差异化

空状态是四种状态里最容易被一刀切的——所有空页面都显示同一个”暂无数据”图标。但用户首次打开 App 看到的空列表,和老用户清空数据后看到的空列表,心理预期完全不同。

我习惯拆成三类:

  • 初始空状态:用户还没产生数据,适合放引导文案和操作入口。“你还没有收藏任何内容,去逛逛吧” + 跳转按钮。
  • 筛选空状态:搜索结果为空。提示语改为”没有找到相关结果,换个关键词试试”,保留搜索框让用户直接修改。
  • 清空空状态:用户清空了数据。给一个轻量级的挽回入口,比如”已清空所有历史记录”。

三种空状态共用同一个组件,通过参数区分:

@Composable
fun EmptyView(
    type: EmptyType,
    onAction: (() -> Unit)? = null
) {
    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        Image(painter = painterResource(type.icon), ...)
        Text(text = type.message)
        if (onAction != null && type.actionText != null) {
            Button(onClick = onAction) {
                Text(type.actionText)
            }
        }
    }
}

团队规范落地

架构设计得再好,团队不遵循也是白搭。我把状态处理标准化为模板代码,放在项目 Wiki 里。

强制规则三条:

  1. 每个数据驱动页面,ViewModel 暴露的 StateFlow 类型必须是 PageState 或其复合类型
  2. 不允许在 UI 层(Activity/Fragment/Composable)直接访问 repository 或发起网络请求
  3. 每个模块必须覆盖 Loading、Empty、Error 三种状态的 UI,缺失任一状态不予通过 Code Review

Code Review 检查清单,不用凭记忆检查:

  • Loading 态是否有骨架屏而非空白转圈
  • Error 态是否有重试按钮(可恢复错误)或业务文案(业务错误)
  • Empty 态文案是否符合业务场景而非通用占位
  • 各模块错误是否独立降级,不会互相牵连

推行这套东西的阻力意外地小。因为每个人都在各自的页面踩过状态处理的坑,看到规范化方案反而觉得早该这样了。


这套架构带来的最大变化不是代码更”优雅”,而是线上问题定位快了。以前用户反馈”页面打不开”,要挨个排查接口;现在每个模块的状态独立上报,在日志平台搜 module=banner state=error 就能定位到具体问题。

建议分三步走:先在新页面强制使用 PageState,跑两个迭代验证模板是否够用;然后在老页面重构时逐个迁移;最后定出团队规范写进 Wiki。一步到位推行全局规范阻力太大——人对自己写过的东西总有偏爱,先用效果说话。