一个 isLoading 布尔值不足以描述真实移动页面。页面可能正在刷新,但旧数据仍可读;也可能请求成功却为空;断网时有缓存与没有缓存,用户应该看到完全不同的内容。
用数据和同步状态两个维度建模
保留 snapshot、lastSyncedAt、requestState、error。渲染规则从这些字段推导,不把每一种组合都塞进一个巨大的枚举。这样“旧数据 + 刷新中”和“旧数据 + 刷新失败”都能保留页面主体,同时展示不同提示。
| 快照 | 请求 | 页面表现 |
|---|---|---|
| 无 | 首次加载 | 骨架屏,保留返回操作 |
| 无 | 失败 | 错误说明与重试入口 |
| 有 | 刷新中 | 继续展示快照,局部刷新提示 |
| 有 | 失败 | 展示快照、同步时间和非阻断错误 |
| 空列表 | 成功 | 真正的空态和创建入口 |
把状态规则放在视图外
在 RN 的 selector 或 Flutter 的 ViewModel 中实现纯函数 deriveScreenState(snapshot, request),让多个页面共享规则。进入页面、下拉刷新、前台恢复、推送消息触发刷新,都调用同一数据入口;每个入口只改变请求原因,不改变“什么是可信快照”。
验收时模拟慢网和离线:连续刷新不会把列表清空;失败时不会把旧列表误判为空;账户切换后绝不展示上一账户的快照。空态只能由一次可信的成功响应确认。
把“空”变成有证据的状态
以消息列表为例,首次进入页面时没有缓存,状态是 initialLoading;成功返回零条消息后才是 confirmedEmpty。如果先前有二十条消息,用户下拉刷新时应保持列表可读,状态是 refreshingWithData。刷新失败则进入 staleWithError,继续保留二十条和上次成功时间。把所有情况压成 loading | success | error,会迫使界面在刷新期间清空内容,或把失败误画成空列表。
数据仓库建议保存 {accountId, items, lastSuccessAt, revision, pending},展示状态由这些事实和当前请求推导,不要让每个组件各自维护一套布尔值。items=[] 只表示当前数组为空;只有“当前账号的一次可信成功响应”才能证明真正空态。账户切换时先隔离旧账号快照,再加载新账号缓存,避免上一账号的消息在新账号页面闪现。
| 事件 | 保留的数据 | 页面反馈 |
|---|---|---|
| 首次加载失败 | 无 | 错误页与重试按钮 |
| 有缓存时刷新失败 | 上次确认的列表 | 内联错误、同步时间与重试 |
| 请求成功返回空列表 | 当前账号的空快照 | 明确的空态文案 |
| 账号切换 | 仅新账号缓存 | 骨架屏或新账号快照 |
状态转换比截图更值得测试
以 reducer 或状态机测试事件序列:成功(20条) → 刷新 → 超时 必须保留二十条;成功(20条) → 成功(0条) 才能变成空态;账号A加载 → 切换B → A响应 必须丢弃 A 的结果。UI 测试再验证重试入口、读屏提示和旧数据标识。这样测试的是用户会经历的变化,而不仅是一张静态页面。
