Flutter 错误处理与可靠性工程

Flutter 生产级错误处理全解:Dart 异常体系与 try/catch/async 错误捕获、Flutter 全局错误捕获(FlutterError.onError/runZonedGuarded)与未捕获异常兜底、错误上报与崩溃分析(Crashlytics/Sentry)、超时重试与指数退避、网络断线降级与离线容错、用户可感知的错误提示与重试、以及可观测性(日志/链路/性能监控)与可靠性测试。

开篇:上生产的第一课是「处理错误」

开发时错误是崩溃,生产时错误是流失。一个 App 的可靠度,不取决于「代码写得多优雅」,而取决于错误发生时系统怎么反应——是白屏、静默失败,还是「友好提示 + 自动重试 + 完整上报」?

可靠性工程的三个层次:

  1. 捕获层:所有异常都被捕获,一个都不能「逃出」变成崩溃。
  2. 处理层:用户看到可理解的降级提示,关键操作能重试,坏数据不污染状态。
  3. 观测层:错误、性能、用户路径都被记录上报,能定位、能告警、能量化。

本文按这三层给出 Flutter 生产级错误处理的完整方案。


一、Dart 异常体系与本地捕获

1.1 Dart 异常不是 Java 的 Checked Exception

Dart 的异常是运行期的,函数不声明会抛什么。关键区别:

  • Error(TypeError、RangeError):程序性错误,多半是 bug,不该 catch。
  • Exception(业务/IO/网络异常):可恢复的失败,应被捕获处理。
// 好的捕获: 针对可恢复的失败
try {
  final data = await api.fetch();
} on TimeoutException {
  _showRetry();        // 超时 → 重试
} on ApiException catch (e) {
  _showError(e.message); // 业务错误 → 提示
} catch (e) {
  _reportError(e);      // 未知 → 上报 + 通用兜底
} finally {
  _hideLoading();       // 无论如何收尾
}

1.2 Future 与 Stream 的错误

async/await 的错误要留意两点:

  • 每个 await 都可能抛:调第三方 API 必须「知道它抛什么 + 决定在哪兜」。
  • 没 await 的 Future:错误成「未处理」,可能悄悄消失——.catchError() 显式接住,或用 unawaited 包装。
  • Stream:listen 的 onError 不写,错误直接喷到全局——每一条流订阅都要有 onError。
stream.listen(_handleData, onError: (e) {
  _reportError(e);
  // 决定: 重订阅 / 跳过 / 终止
});

二、全局错误捕获:兜住漏网之鱼

2.1 三层全局防线

Flutter 有专门的全局捕获点,一处都不能省:

void main() {
  WidgetsFlutterBinding.ensureInitialized();

  FlutterError.onError = (details) {
    // 1) Flutter 框架错误(widget 构建/布局/渲染异常)
    FlutterError.presentError(details);      // 保留默认红屏逻辑
    Crashlytics.instance.recordFlutterError(details);  // 上报
  };

  runZonedGuarded(() {
    runApp(const MyApp());
  }, (error, stack) {
    // 2) 未捕获的异步错误(Timer/事件回调里抛的)
    Crashlytics.instance.recordError(error, stack);
  });

  // 3) PlatformDispatcher.onError: 原生侧漏过来的
  PlatformDispatcher.instance.onError = (error, stack) {
    Crashlytics.instance.recordError(error, stack);
    return true;   // 返回 true 表示已处理
  };
}

runZonedGuarded 是异步错误的最后防线:任何 zone 内未被 catch 的异常都落到这里。没有它,一个 Timer 回调里的错误就可能导致应用退出。

2.2 错误分诊

全局捕获后要分诊,避免「刷屏式上报」:

  • 可恢复(网络波动、业务失败):只记日志,不当作崩溃。
  • 框架渲染错误:定位到 widget 树,隔离该子树(ErrorWidget 兜底)。
  • 真崩溃(OOM、原生异常):Crashlytics 按严重度分级,告警只给真崩溃。

三、错误上报与崩溃分析

3.1 接入崩溃分析 SDK

以 Crashlytics 为例(Sentry/自建同理):

flutter pub add firebase_crashlytics

// main() 里先初始化 Firebase,再启动 Crashlytics
await Firebase.initializeApp();
final crashlytics = FirebaseCrashlytics.instance;
await crashlytics.setUserIdentifier(userId);      // 绑定用户,定向排查

// 业务上「非崩溃但重要」的事件
crashlytics.log('checkout_failed amount=$total');
crashlytics.setCustomKey('build_flavor', flavor);

3.2 上报的艺术

上报不是「堆日志」,要能追根溯源:

# 1) 上下文为王: 崩溃栈 + 用户操作路径 + 设备/系统 + 版本
# 2) 面包屑: 关键操作打日志(crashlytics.log),崩溃时能看到「崩溃前发生了什么」
# 3) 去重: 同类错误按「栈顶 + 消息」聚合成 issue,别逐条告警
# 4) 版本对比: 新版本崩溃率上升 = 本次发版回归,立即告警
# 5) 用户维度: 特定机型/系统版本才崩 → 定向修复或灰度拦截

隐私:上报前脱敏——不做 PII(手机号、身份证)上报,长列表截断,文件名路径剔除敏感信息。

四、超时重试与指数退避

4.1 重试的工程范式

网络请求的「失败重试」要设计,不是「try 两遍」:

Future<T> withRetry<T>(
  Future<T> Function() action, {
  int maxAttempts = 3,
  Duration Function(int attempt)? backoff,
}) async {
  var attempt = 0;
  while (attempt < maxAttempts) {
    try {
      return await action();
    } catch (e) {
      attempt++;
      if (attempt == maxAttempts) rethrow;
      final delay = backoff?.call(attempt) ??
          Duration(milliseconds: 200 * attempt * attempt); // 指数退避
      await Future.delayed(delay);
    }
  }
  throw StateError('unreachable');
}

4.2 该重试与不该重试

  • 可重试:超时、5xx、瞬时网络错误、连接被重置。
  • 不可重试:4xx 业务错误(改了也错)、校验失败、鉴权过期(应先刷新 token)。
  • 幂等性:重试只对「幂等操作」安全——POST 创建要带幂等键,否则重试会重复下单。
  • 退避 + 抖动:纯指数退避会让 N 个用户同时重试(雷暴效应),加随机抖动分散。

五、离线容错与降级体验

5.1 网络断线的降级策略

用户断网不是错误,是常态。降级设计:

# 1) 读取优先本地: 列表页先从缓存渲染,网络好了再刷新(stale-while-revalidate)
# 2) 写操作乐观: 先更新 UI + 本地队列,联网后补发(写队列)
# 3) 明显标识: 顶部「离线模式,数据可能不是最新」横幅
# 4) 关键路径必须有离线兜底: 个人资料/历史订单等核心数据本地缓存
# 5) 图片占位: 网络图先显本地占位,渐进加载

写队列是离线写的核心:断网时的操作进队列,恢复后按序补发,失败的可回滚提示。

5.2 用户可感知的错误提示

错误提示的三个原则:

  • 说人话:「网络不太好,请检查连接」比「SocketException: Connection refused」强一百倍。
  • 给动作:提示必须带「重试/重试全部」按钮,别让用户干等。
  • 分级:可恢复 → Toast/Snackbar;影响功能 → 内嵌降级 UI + 重试;致命 → 引导重启。
// 通用错误 UI
ErrorRetryView(
  message: '加载失败,请检查网络',
  onRetry: _load,
  child: Icon(Icons.wifi_off),
)

六、可观测性:日志、性能与链路

6.1 结构化日志

print 不是日志(release 下会被删)。用 logging 包 + 分级:

final _log = Logger('CheckoutRepository');
_log.fine('request start');          // 细节
_log.info('checkout success id=$id'); // 常规
_log.warning('retry attempt $n');    // 可疑
_log.severe('checkout failed', e, s); // 严重

6.2 性能监控

  • 帧率:WidgetsBinding.instance.addPostFrameCallback 测首帧,长任务用 compute 隔离。
  • 网络耗时:dio 拦截器记录每接口耗时,超阈值告警。
  • 关键路径:启动时间、首屏渲染、登录耗时,埋点上报看趋势。
  • 链路:RequestId 贯穿 UI → 网络 → 服务端日志,一次用户操作一条链路,排障不用猜。

七、可靠性测试

7.1 该测什么

# 1) 单元: 重试逻辑/退避/错误映射(确定性输入→期望输出)
# 2) widget: 错误状态渲染、重试按钮交互、离线降级 UI
# 3) 集成: 模拟网络失败(dio mock / 断网)、服务端 5xx、超时
# 4) 混沌式: 随机断网/延迟/丢包,验证降级不白屏不崩溃

错误路径是代码里最容易漏测、也最致命的部分——正常路径测试覆盖了 80%,出错路径决定口碑。

7.2 可靠性清单

# [ ] 所有第三方调用都有 catch 兜底 + 上报
# [ ] runZonedGuarded 已配,异步异常不裸奔
# [ ] 重试带指数退避 + 抖动,且只重试幂等操作
# [ ] 断网有本地兜底 + 写队列
# [ ] 错误提示可读 + 可重试
# [ ] 崩溃上报含面包屑与上下文,无 PII
# [ ] 关键链路有性能埋点

FAQ

Q:FlutterError.onError 和 runZonedGuarded 区别?
A:前者接框架渲染错误,后者接未捕获异步异常,两者都要配,别只配一个。

Q:错误要不要全 catch?
A:可恢复的才 catch。程序性 bug(空指针、越界)不该吞,要让它上报并修复——吞 bug 等于埋雷。

Q:重试会不会把系统打崩?
A:会,如果没有退避+抖动。同时只重试幂等操作,POST 加幂等键。

Q:用 Crashlytics 还是 Sentry?
A:都用过的话选「接入成本 + 告警能力 + 价格」平衡的。Sentry 的 Flutter 支持现在很成熟,且自带 error bound。关键是有,不是纠结用哪家。

相关阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「Flutter」更多文章

  1. Flutter Design Tokens 与自适应主题工程
  2. Flutter 高级绘制与绘制动画
  3. Flutter Firebase 后端集成实战