App 发到线上之后,你对它的了解会突然变得很有限:用户在什么机型上闪退、哪个页面的接口超时最多、卡顿是普遍现象还是个别设备。没有可观测性(Observability)能力,你只能靠应用商店的差评和用户模糊的描述去猜。对 Flutter 而言这件事更复杂——一次崩溃可能发生在 Dart 层,也可能发生在原生层(iOS/Android),两套堆栈要分开采集、分别还原。
可观测性的三根支柱是日志(Logs)、指标(Metrics)、链路追踪(Traces),移动端还要加上「崩溃」和「性能」两个维度。本文聚焦 Flutter 的线上监控落地:如何全局捕获 Dart 异常与原生异常、如何上传符号表把混淆后的堆栈还原成可读的代码位置、如何采集性能指标与卡顿、以及数据脱敏与采样这些容易被忽略但绕不开的工程问题。方法论层面,错误追踪工具 的通用实践同样适用于 Flutter。
一、崩溃的两条栈:Dart 与原生
理解 Flutter 崩溃监控的第一件事,是它有两套独立的异常体系:
| 层 | 异常来源 | 捕获入口 | 典型错误 |
|---|---|---|---|
| Dart 层 | Widget build、异步 Future、Isolate | FlutterError.onError / PlatformDispatcher.onError / runZonedGuarded | Null check operator、setState after dispose |
| 原生层 | Java/Kotlin(Android)、Objective-C/Swift(iOS) | 平台崩溃 SDK(Crashlytics/Sentry 原生端) | NullPointerException、EXC_BAD_ACCESS |
只接 Dart 侧 SDK 会漏掉原生崩溃,只接原生 SDK 会漏掉 Dart 异常。完整方案必须两端都接。而且原生崩溃会直接杀死进程,Dart 侧的 onError 根本来不及触发——所以原生崩溃必须靠原生 SDK 捕获。
两类崩溃的特征对比:
| 维度 | Dart 异常 | 原生崩溃 |
|---|---|---|
| 是否杀死进程 | 通常否(可用 ErrorWidget 兜住) | 是 |
| 堆栈可读性 | 需符号还原(混淆后) | 需 dSYM/mapping 还原 |
| 采集 SDK | sentry_flutter / firebase_crashlytics | 原生 SDK 自动接入 |
| 常见根因 | 空安全、状态生命周期 | 内存、空指针、第三方 SDK |
二、全局异常捕获
Dart 侧有三层捕获网,缺一不可:
import 'dart:async';
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
Future<void> main() async {
// 必须在 runApp 之前初始化
WidgetsFlutterBinding.ensureInitialized();
// 第一层:框架异常(build/layout/paint 期间的错误)
FlutterError.onError = (FlutterErrorDetails details) {
// 转发给框架默认处理(debug 下会打印红屏)
FlutterError.presentError(details);
// 上报到监控
Sentry.captureException(
details.exception,
stackTrace: details.stack,
hint: Hint.withMap({'context': details.context?.toString() ?? ''}),
);
};
// 第二层:异步未捕获异常(Dart 2.19+ 推荐入口)
PlatformDispatcher.instance.onError = (Object error, StackTrace stack) {
Sentry.captureException(error, stackTrace: stack);
return true; // 返回 true 表示已处理,不再向上抛
};
await SentryFlutter.init(
(options) {
options.dsn = 'https://xxx@sentry.example.com/1';
options.tracesSampleRate = 0.2;
},
appRunner: () => runApp(const MyApp()),
);
}
三种捕获方式的分工:
FlutterError.onError:捕获 Widget 生命周期内的同步错误(build、layout、paint、手势回调)。这些错误框架会捕获后交给这个回调,不会直接崩溃。PlatformDispatcher.instance.onError:捕获所有 Zone 之外的未处理异步异常,是 Dart 2.19 之后替代runZonedGuarded的推荐方式。它能看到所有异步错误的根因。runZonedGuarded:更早期的方式,把整个 App 包进一个自定义 Zone,捕获该 Zone 内所有未处理异常。当第三方库自己创建了 Zone 时它可能漏掉部分异常,所以新版优先用PlatformDispatcher.onError。
isFatal 的判定:并非所有异常都导致崩溃。FlutterError.onError 里的错误在 release 下通常不会终止进程(框架会用 ErrorWidget 代替),而 PlatformDispatcher.onError 里返回 true 表示「已处理」,返回 false 会让错误继续上抛。上报时应正确标记 fatal/non-fatal,否则崩溃率统计会失真。
三层捕获网的对照:
| 入口 | 覆盖范围 | 是否终止进程 | 建议 |
|---|---|---|---|
FlutterError.onError | Widget 同步错误 | 否 | 必设 |
PlatformDispatcher.onError | 异步未捕获异常 | 视返回值 | 必设(Dart 2.19+) |
runZonedGuarded | 自定义 Zone 内异常 | 否 | 兼容旧代码 |
| 每个 Isolate 内单独处理 | 后台 Isolate 异常 | 否 | 用 compute/Isolate 时必设 |
三、符号表上传与堆栈还原
--obfuscate 和 --split-debug-info 混淆后的堆栈长这样:
#0 a (package:myapp/main.dart)
#1 b (package:myapp/main.dart)
全是无意义的 a、b。要还原成可读堆栈,必须把构建时生成的符号文件上传到监控平台。Sentry 的 Flutter 插件提供了命令行工具:
# 1. 构建时剥离符号
flutter build appbundle --release \
--obfuscate \
--split-debug-info=build/symbols
# 2. 上传符号到 Sentry(需要 SENTRY_AUTH_TOKEN 与 org/project)
sentry-cli upload-dif --org my-org --project my-flutter-app build/symbols
# Android 原生符号(native debug symbols)也要上传
sentry-cli upload-dif --org my-org --project my-flutter-app \
build/app/intermediates/merged_native_libs/release/out/lib
# iOS 需要上传 dSYM
sentry-cli upload-dif --org my-org --project my-flutter-app \
build/ios/archive/Runner.xcarchive/dSYMs
三条必须遵守的规则:
- 每次发版都必须上传对应版本的符号。符号与构建一一对应,版本对不上就无法还原。
- 符号文件归档保存。丢失符号 = 丢失线上崩溃的排查能力。CI 里应把
build/symbols作为 artifact 长期保存。 --obfuscate必须与--split-debug-info同用。单独用--obfuscate会得到无法还原的堆栈,等于自毁排查能力。
在 CI 里,符号上传应作为发布流水线的固定步骤,紧跟构建之后:
# .github/workflows/release.yml(节选)
- name: Build with symbols
run: flutter build appbundle --release --obfuscate --split-debug-info=build/symbols
- name: Upload symbols
run: sentry-cli upload-dif --org $ORG --project $PROJECT build/symbols
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- name: Archive symbols
uses: actions/upload-artifact@v4
with:
name: symbols-${{ github.sha }}
path: build/symbols
retention-days: 90
Firebase Crashlytics 的符号上传则是通过 Gradle 插件(Android)与 Xcode 构建脚本(iOS)自动完成的,Dart 符号需要额外用 flutterfire 工具或 upload-symbols 脚本处理。选型上,Sentry 对 Flutter 的 Dart 符号支持更成熟,Crashlytics 胜在与 Firebase 生态的集成。
四、性能与卡顿监控
崩溃只是可观测性的一半,「App 没崩但卡得要死」同样影响留存。Flutter 的性能监控有两个层面:Dart 侧帧率与卡顿、原生侧启动与 ANR/OOM。
Dart 侧可以用 SentryFlutter 的性能监控,或自己用 SchedulerBinding 采集帧时间:
import 'package:flutter/scheduler.dart';
class FrameMonitor {
static const _slowFrameThreshold = Duration(milliseconds: 16); // 60fps 预算
static void start() {
SchedulerBinding.instance.addTimingsCallback((List<FrameTiming> timings) {
for (final t in timings) {
final buildMs = t.buildDuration.inMilliseconds;
final rasterMs = t.rasterDuration.inMilliseconds;
final totalMs = t.totalSpan.inMilliseconds;
// 掉帧上报:单帧超过预算 2 倍视为卡顿
if (totalMs > 32) {
Sentry.addBreadcrumb(Breadcrumb(
message: 'Slow frame: build=${buildMs}ms raster=${rasterMs}ms',
category: 'performance',
level: Severity.warning,
));
}
}
});
}
}
FrameTiming 区分了 buildDuration(Dart 侧构建)和 rasterDuration(GPU 光栅化),这个区分很关键:build 慢通常是 Dart 代码问题(大 Widget 树、频繁 setState),raster 慢通常是绘制问题(复杂图层、过度重绘)。定位方向完全不同,优化手段也见 https://plumephp.com/flutter-performance-optimization/。
指标与阈值的参考:
| 指标 | 良好 | 需关注 | 说明 |
|---|---|---|---|
| 帧构建耗时 | < 8ms | > 16ms | buildDuration |
| 光栅化耗时 | < 8ms | > 16ms | rasterDuration |
| 卡顿率(>32ms 帧占比) | < 1% | > 5% | 用户体验分水岭 |
| 冷启动首帧 | < 800ms | > 1500ms | 见启动优化 |
| ANR 率 | < 0.1% | > 0.5% | 仅 Android |
原生侧指标:
- Android:启动时间(
ActivityManager冷启动)、ANR(应用无响应)、内存 OOM、闪退。 - iOS:启动时间、Watchdog 超时(主线程卡死)、内存压力崩溃、后台被杀。
这些指标由监控 SDK 的原生端自动采集,但需要在原生工程里正确初始化。
五、面包屑与用户上下文
崩溃堆栈告诉你「哪里崩了」,面包屑(Breadcrumb)告诉你「崩之前用户干了什么」。这是从「知道崩溃」到「能复现崩溃」的关键。
// 记录关键操作(导航、接口调用、状态变更)
Sentry.addBreadcrumb(Breadcrumb(
message: 'Navigate to /checkout',
category: 'navigation',
level: Severity.info,
));
// 网络请求失败时记录
Sentry.addBreadcrumb(Breadcrumb(
message: 'GET /api/cart failed: 500',
category: 'http',
level: Severity.error,
data: {'status': 500, 'duration_ms': 1230},
));
// 设置用户上下文(注意脱敏,不要放手机号、身份证)
Sentry.configureScope((scope) {
scope.setUser(SentryUser(
id: userId, // 内部 ID,非手机号
// 不要设置 email、username 等 PII
));
scope.setTag('app_version', '1.2.3');
scope.setTag('build_flavor', 'prod');
scope.setContexts('device', {
'model': deviceModel,
'os_version': osVersion,
'screen': '$width x $height',
});
});
面包屑的价值在「因果链」:一条崩溃记录带上「用户点了结算 → 购物车接口超时 → 点了重试 → 崩溃」,比孤零零的堆栈有用得多。要控制面包屑数量(默认保留最近 100 条),避免内存与带宽开销。
面包屑的分类建议:
| category | 记录内容 | 用途 |
|---|---|---|
| navigation | 页面跳转 | 还原用户路径 |
| http | 请求 URL、状态码、耗时 | 定位接口问题 |
| ui.click | 关键按钮点击 | 还原操作序列 |
| state | 状态变更(登录、切环境) | 定位状态相关崩溃 |
用户上下文必须脱敏。日志与崩溃上报里绝不能出现手机号、身份证、银行卡、密码、Token。原则是「上报内部 ID 而非业务标识」,敏感字段在上报前过滤。
六、采样、限流与上报策略
线上环境崩溃量可能很大(尤其是发版初期),无节制上报会打爆监控平台配额、耗光用户流量。需要采样与限流:
SentryFlutter.init((options) {
options.dsn = 'https://xxx@sentry.example.com/1';
// 错误采样:崩溃全量上报,非致命错误按比例采样
options.sampleRate = 1.0;
// 性能追踪采样:只对 20% 的会话追踪,控制成本
options.tracesSampleRate = 0.2;
// 同一异常在上报前聚合,避免刷屏
options.beforeSend = (event, hint) {
// 过滤掉已知的、无价值的噪音
final ex = event.throwable;
if (ex is FlutterError && ex.message.contains('setState() called after dispose')) {
return null; // 返回 null 丢弃
}
return event;
};
// 上报前脱敏
options.beforeBreadcrumb = (crumb, hint) {
// 移除 URL 里的 token 参数
return crumb;
};
});
上报策略的权衡:
| 策略 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| 崩溃全量 | 不漏关键问题 | 发版初期量可能很大 | 崩溃必选 |
| 错误采样 | 省配额 | 可能漏偶发问题 | 非致命错误 |
| 性能采样 | 成本可控 | 样本偏差 | 性能追踪 |
| 立即上报 | 数据实时 | 耗流量、启动期干扰 | 崩溃 |
| 批量延迟上报 | 省流量 | 时效差 | 日志/面包屑 |
移动端的通用原则:崩溃立即上报,其他数据批量延迟上报。上报要避开启动阶段(首帧前不发起网络请求),避免监控本身拖慢启动。离线时缓存事件、联网后补传,但要注意缓存上限(如最多 100 条)与过期时间(如 7 天)。
七、多平台与 Flutter 特有陷阱
| 陷阱 | 表现 | 规避 |
|---|---|---|
| 符号版本错配 | 堆栈无法还原 | CI 强制「构建即上传符号」 |
| 只接 Dart SDK | 原生崩溃漏报 | 同时接入原生端 SDK |
| release 下错误静默 | onError 未设置 | 三层捕获网全部设置 |
| Isolate 内异常 | 主 Isolate 的 onError 收不到 | 每个 Isolate 单独设置错误处理 |
| 上报阻塞启动 | 启动变慢 | 首帧后再初始化非崩溃类监控 |
| PII 泄漏 | 隐私合规风险 | 上报前脱敏 + 内部 ID |
其中「Isolate 内异常」容易被忽略:PlatformDispatcher.onError 只捕获主 Isolate 的错误。如果你用了 compute 或自定义 Isolate,每个 Isolate 内部都要单独包 runZonedGuarded 并手动上报——否则后台任务里的崩溃会石沉大海。
// 后台 Isolate 内单独捕获
void isolateEntry(SendPort sendPort) {
runZonedGuarded(() {
// 后台任务
}, (error, stack) {
sendPort.send({'error': error.toString(), 'stack': stack.toString()});
});
}
监控数据的落地页通常是看板(Dashboard):崩溃率、影响用户数、Top 崩溃、ANR 率、卡顿率、启动 P50/P90。这些指标的展示与告警设计,可以参考 前端真实用户监控(RUM) 的指标体系——移动端与 Web 端在「用户真实体验度量」上思路一致,只是采集手段不同。
7.1 监控 SDK 选型对比
| 维度 | Sentry | Firebase Crashlytics |
|---|---|---|
| Dart 符号还原 | 原生支持,sentry-cli 上传 | 需额外工具处理 |
| 性能追踪 | 内置 traces,功能完整 | 有限 |
| 与 Firebase 集成 | 需手动配置 | 开箱即用 |
| 免费额度 | 自建/云端按量 | 免费额度较大 |
| 自托管 | 支持 | 不支持 |
| 面包屑 | 支持 | 支持(自动采集) |
选型建议:已经用 Firebase 全家桶(Auth/Firestore/FCM)的团队优先 Crashlytics,集成成本最低;需要完整链路追踪、自托管或对 Dart 符号还原要求高的团队选 Sentry。两者也可以并存——用 Crashlytics 兜崩溃、用 Sentry 做性能与追踪。
7.2 数据保留与合规
监控数据涉及用户隐私,必须考虑合规:
- 保留期限:崩溃与性能数据通常保留 30~90 天,超期自动清理,避免无限增长。
- 数据出境:若服务部署在境外,需评估数据合规要求,必要时选境内节点或自托管。
- 用户同意:部分地区(如欧盟 GDPR)要求用户同意后才能采集设备标识,App 首次启动应提供开关。
- 删除权:支持按用户 ID 删除其所有监控数据。
告警阈值建议(避免告警疲劳):
| 指标 | 告警线 | 升级线 |
|---|---|---|
| 崩溃率 | > 0.5% | > 2% |
| 新增崩溃类型 | 影响用户 > 100 | 影响用户 > 1000 |
| ANR 率 | > 0.3% | > 1% |
| 首帧 P90 | > 2000ms | > 3000ms |
小结
Flutter 线上可观测性的关键是「双栈采集 + 符号还原 + 上下文」。三条落地主线:捕获要全(FlutterError.onError + PlatformDispatcher.onError + 原生 SDK + 每个 Isolate 单独处理);符号要归档(--obfuscate 必配 --split-debug-info,每次发版 CI 自动上传,符号文件长期保存);数据要脱敏(面包屑记录因果链,用户上下文只放内部 ID,上报前过滤 PII)。先把崩溃监控做扎实——它是线上问题的第一手线索;再逐步补齐性能、卡顿、ANR 等指标,最后才是日志与追踪的完整三支柱体系。配合 https://plumephp.com/flutter-error-handling-reliability/ 里的重试与降级机制,才能构成从「发现问题」到「兜住问题」的闭环。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。