深入 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 里。
强制规则三条:
- 每个数据驱动页面,ViewModel 暴露的
StateFlow类型必须是PageState或其复合类型 - 不允许在 UI 层(Activity/Fragment/Composable)直接访问 repository 或发起网络请求
- 每个模块必须覆盖 Loading、Empty、Error 三种状态的 UI,缺失任一状态不予通过 Code Review
Code Review 检查清单,不用凭记忆检查:
- Loading 态是否有骨架屏而非空白转圈
- Error 态是否有重试按钮(可恢复错误)或业务文案(业务错误)
- Empty 态文案是否符合业务场景而非通用占位
- 各模块错误是否独立降级,不会互相牵连
推行这套东西的阻力意外地小。因为每个人都在各自的页面踩过状态处理的坑,看到规范化方案反而觉得早该这样了。
这套架构带来的最大变化不是代码更”优雅”,而是线上问题定位快了。以前用户反馈”页面打不开”,要挨个排查接口;现在每个模块的状态独立上报,在日志平台搜 module=banner state=error 就能定位到具体问题。
建议分三步走:先在新页面强制使用 PageState,跑两个迭代验证模板是否够用;然后在老页面重构时逐个迁移;最后定出团队规范写进 Wiki。一步到位推行全局规范阻力太大——人对自己写过的东西总有偏爱,先用效果说话。