离线场景与弱网体验优化:别让用户在「转圈」和「沉默」之间二选一
系列:网络与数据篇(稳定性)6/6
很多团队在「功能完成度」上卷得很凶,却在 离线 / 弱网 上只留下两种体验:要么一直 Loading,要么白屏报错。用户实际场景里:地铁、电梯、Wi‑Fi 切换、国外漫游、酒店 Captive Portal——网络不是“异常”,是常态。
这篇把离线弱网拆成:感知 → 策略 → 展示 → 收尾,让你有章可循地上线,而不是靠「加个断网 toast」应付验收。
1. 问题背景:业务场景 + 现象
- 典型场景:列表进详情、表单提交、即时状态同步(点赞、进房、游戏房间内 WS)、大图/音视频、启动冷启动拉配置。
- 用户能感知到的坏体验:
- 无限转圈,没有「到底有没有在重试」;
- 点按钮没反应(其实在转圈但被盖住);
- 断网回到有网后,页面仍是旧数据或空数据,只能杀进程;
- 弱网下重复点提交,产生重复订单/重复请求;
- WebSocket 断链后 UI 仍显示「已连接」,导致误操作。
产品视角:这不是“网络问题”,是 你们没有把失败当作一等公民设计。
2. 原因分析:核心原理 + 排查过程
2.1 原理层面:Flutter 并不替你区分「慢」和「断」
应用层通常只看到:请求 pending、超时、socket error。
慢(弱网) 与 断(离线) 在用户心理上不同:前者要「可等待 + 可预期」,后者要「可继续用本地 + 可排队」。
2.2 常见根因(对症自查)
| 现象 | 常见根因 |
|---|---|
| 一直 Loading | 无超时、无降级、无缓存读取路径 |
| 空白 / 闪一下 | 仅有网络态数据,无骨架屏或本地占位 |
| 回网不刷新 | 没有监听网络恢复事件或没有统一 invalidate |
| 重复提交 | mutation 无幂等、无 debounce、无「进行中」门禁 |
| WS 状态错乱 | 重连与 UI 状态不是同一事实来源 |
2.3 排查顺序(工程上很实用)
- 区分:读(query) 还是 写(mutation)——离线策略完全不同。
- 画一张 「数据从哪来」:仅远端 / 远端 + 内存 / 远端 + 持久化缓存。
- 用 Charles / Proxyman 或系统限速,压 RTT 丢包,看 UI 是否符合预期。
- 对写操作查:是否幂等、服务端是否接受客户端
idempotency-key。
3. 解决方案:方案对比 + 最终选择
3.1 读请求:缓存与失效
- 内存缓存:快,进程杀了就没。
- 持久化缓存(Hive/Isar/sqflite 等):冷启动可用,要做版本与 TTL。
- Stale‑While‑Revalidate:先展示旧数据,后台悄悄刷新——弱网体验提升最大。
取舍建议:核心列表/详情可走「持久化 + SWR」;强实时(余额、对局状态)走短 TTL + 明确「可能过期」提示。
3.2 写请求:队列 vs 直接失败
| 策略 | 适用 |
|---|---|
| 立刻失败 + 明确可重试 | 非关键、可幂等、操作简单 |
| 本地队列 + 回网 replay | 表单、草稿、日志上传(需幂等与去重) |
| 乐观更新 + 回滚 | 点赞、关注(需清晰失败恢复 UI) |
原则:没有幂等保障的写,不要盲目乐观更新。
3.3 网络感知:不要迷信单一 API
connectivity_plus一类:能连 Wi‑Fi 不代表能上网(Captive Portal)。- 最终应以「业务探测」为准:关键接口健康检查 / 第一个真实请求的成败,再配合 connectivity 做「提前提示」。
3.4 UI 分层:Loading / Empty / Error / Offline
弱网时避免「全页 Loading」。更稳妥:
- 首次:骨架屏或占位;
- 有缓存:先出内容,顶栏或小条提示「网络较慢 / 离线查看缓存」;
- 失败:错误态 + 重试成本可解释(上次失败原因、可手动刷新)。
3.5 长连接(如 WS)
- 连接态、重连退避、心跳失败计数,应收敛到 单一模块,UI 只 watch「已连接 / 重连中 / 不可用」。
- 消息队列:断线期间可先入本地队列或标记 pending,避免用户以为「已发送」。
4. 关键代码:最小必要代码片段
4.1 读:AsyncValue 表达三层态 + 重试(Riverpod 常见写法)
ref.listen<MyFeatureAsync>(myFeatureProvider, (prev, next) {
next.whenOrNull(
error: (e, st) => messenger.showSnackBar(
SnackBar(content: Text(mapErrorToUserText(e)), action: SnackBarAction(
label: '重试',
onPressed: () => ref.invalidate(myFeatureProvider),
)),
),
);
});
要点:错误不要用弹窗轰炸;SnackBar + invalidate / refresh 成本低。
4.2 写:防止重复点击(弱网用户最爱狂点)
Future<void> submit() async {
if (_busy) return;
_busy = true;
try {
await repo.createOrder(request);
} finally {
_busy = false;
}
}
更稳:配合 服务端幂等键(header 或 body 里 Idempotency-Key),否则只靠前端仍会重复。
4.3 缓存读取的「分支」心智(伪接口)
Future<OrderDetail> loadDetail(String id) async {
final local = await cache.get(id);
if (local != null && !local.isExpired) {
unawaited(_refreshInBackground(id)); // SWR
return local.data;
}
return await remote.fetch(id);
}
4.4 网络恢复后的统一刷新(示意)
connectivityStream.listen((status) {
if (status.hasInternet) {
ref.invalidate(portfolioProvider);
ref.invalidate(sessionBootstrapProvider);
}
});
注意:真正生产环境建议 防抖(几秒内的连续变化合并一次),避免抖动 invalidate。
5. 效果验证:数据 / 截图 / 日志
建议在迭代里固定这几项:
| 维度 | 怎么验 | 目标 |
|---|---|---|
| 首屏可感 | 飞行模式冷启动 | 若有缓存,应在 X 秒内看到内容或明确空态 |
| 弱网 | Network Link Conditioner 限速 | 列表可滚动、不全屏遮罩;后台刷新成功后有 subtle 提示 |
| 重复写 | 弱网下连点提交 | 服务端/日志无重复副作用或可被幂等合并 |
| WS | 断网 30s 再恢复 | UI 连接态正确;消息不静默丢(或有「发送失败」) |
| 崩溃 / ANR | 大量缓存读写 | 大数据 decode 放 isolate,避免卡 UI |
截图验收:同一屏 展示「在线 / 离线仅供查看 / 弱网刷新中」三种条样式即可,产品一眼懂。
6. 可复用结论:通用经验 + 避坑清单
6.1 通用经验
- 读用缓存与 SWR,写靠幂等与门禁——别把两者混成一套「retry 逻辑」。
- 慢和断要分开表达:慢 = 可等待 + 进度感;断 = 本地延续 + 说明限制。
- Connectivity 是预告,业务请求是判决:能 ping 通路由不等于业务可用。
- 长连接状态必须单源真理,避免页面各自
setState猜在线。
6.2 避坑清单
- 全页 Loading 盖死交互,弱网下像假死。
- 只 toast「网络错误」,不提供重试与原因归类。
- 持久化缓存无版本号,升级 App 后读到脏结构。
- 乐观更新无回滚,失败卡死在错误 UI。
- 回网后疯狂
invalidate全站 Provider,演成风暴。
结语:离线弱网优化的核心是 把失败路径产品化:用户永远知道「现在处在什么世界(缓存/实时/排队)」以及「我还能做什么」。做到这一点,比多做一个功能更能留住人。