崩溃监控与线上可观测性

Flutter 线上可观测性建设:FlutterError.onError 与 PlatformDispatcher 全局捕获、Dart/原生双栈崩溃上报、符号表上传与堆栈还原、Sentry/Firebase Crashlytics 接入、性能与卡顿监控、面包屑与用户上下文、数据脱敏与采样策略。

App 发到线上之后,你对它的了解会突然变得很有限:用户在什么机型上闪退、哪个页面的接口超时最多、卡顿是普遍现象还是个别设备。没有可观测性(Observability)能力,你只能靠应用商店的差评和用户模糊的描述去猜。对 Flutter 而言这件事更复杂——一次崩溃可能发生在 Dart 层,也可能发生在原生层(iOS/Android),两套堆栈要分开采集、分别还原。

可观测性的三根支柱是日志(Logs)、指标(Metrics)、链路追踪(Traces),移动端还要加上「崩溃」和「性能」两个维度。本文聚焦 Flutter 的线上监控落地:如何全局捕获 Dart 异常与原生异常、如何上传符号表把混淆后的堆栈还原成可读的代码位置、如何采集性能指标与卡顿、以及数据脱敏与采样这些容易被忽略但绕不开的工程问题。方法论层面,错误追踪工具 的通用实践同样适用于 Flutter。

一、崩溃的两条栈:Dart 与原生

理解 Flutter 崩溃监控的第一件事,是它有两套独立的异常体系:

层异常来源捕获入口典型错误
Dart 层Widget build、异步 Future、IsolateFlutterError.onError / PlatformDispatcher.onError / runZonedGuardedNull 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 还原
采集 SDKsentry_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.onErrorWidget 同步错误否必设
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

三条必须遵守的规则:

  1. 每次发版都必须上传对应版本的符号。符号与构建一一对应,版本对不上就无法还原。
  2. 符号文件归档保存。丢失符号 = 丢失线上崩溃的排查能力。CI 里应把 build/symbols 作为 artifact 长期保存。
  3. --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> 16msbuildDuration
光栅化耗时< 8ms> 16msrasterDuration
卡顿率(>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 选型对比

维度SentryFirebase 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/ 里的重试与降级机制,才能构成从「发现问题」到「兜住问题」的闭环。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「Flutter」更多文章

  1. 蓝牙 BLE 与外设集成
  2. 包体积与启动优化
  3. Golden 测试与视觉回归