网络与数据篇(6/6):离线场景与弱网体验优化

7 阅读6分钟

离线场景与弱网体验优化:别让用户在「转圈」和「沉默」之间二选一

系列:网络与数据篇(稳定性)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 排查顺序(工程上很实用)

  1. 区分:读(query) 还是 写(mutation)——离线策略完全不同。
  2. 画一张 「数据从哪来」:仅远端 / 远端 + 内存 / 远端 + 持久化缓存。
  3. 用 Charles / Proxyman 或系统限速,压 RTT 丢包,看 UI 是否符合预期。
  4. 对写操作查:是否幂等、服务端是否接受客户端 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 通用经验

  1. 读用缓存与 SWR,写靠幂等与门禁——别把两者混成一套「retry 逻辑」。
  2. 慢和断要分开表达:慢 = 可等待 + 进度感;断 = 本地延续 + 说明限制。
  3. Connectivity 是预告,业务请求是判决:能 ping 通路由不等于业务可用。
  4. 长连接状态必须单源真理,避免页面各自 setState 猜在线。

6.2 避坑清单

  • 全页 Loading 盖死交互,弱网下像假死。
  • 只 toast「网络错误」,不提供重试与原因归类。
  • 持久化缓存无版本号,升级 App 后读到脏结构。
  • 乐观更新无回滚,失败卡死在错误 UI。
  • 回网后疯狂 invalidate 全站 Provider,演成风暴。

结语:离线弱网优化的核心是 把失败路径产品化:用户永远知道「现在处在什么世界(缓存/实时/排队)」以及「我还能做什么」。做到这一点,比多做一个功能更能留住人。