开篇:上生产的第一课是「处理错误」
开发时错误是崩溃,生产时错误是流失。一个 App 的可靠度,不取决于「代码写得多优雅」,而取决于错误发生时系统怎么反应——是白屏、静默失败,还是「友好提示 + 自动重试 + 完整上报」?
可靠性工程的三个层次:
- 捕获层:所有异常都被捕获,一个都不能「逃出」变成崩溃。
- 处理层:用户看到可理解的降级提示,关键操作能重试,坏数据不污染状态。
- 观测层:错误、性能、用户路径都被记录上报,能定位、能告警、能量化。
本文按这三层给出 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 网络与异步 — 异常与重试的网络层基础
- Flutter 测试 — 错误路径的测试方法
- Flutter 本地存储 — 离线缓存实现
- Flutter 状态管理 — 错误状态在状态管理中的表达
- Flutter 安全加固 — 日志脱敏与安全上报
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。