Flutter Android 崩溃监控配置:补齐 Isolate 漏报

很多 Flutter Android 团队已经有了“上线后怎么排查崩溃”的流程,比如保留 Dart 符号表、看 adb logcat、对照 Play Console、回放用户路径。但真正让排查失真的,往往不是“不会看日志”,而是日志根本没有被稳定收上来。页面构建时报错会进 Flutter 框架回调,插件异步错误会落到 root isolate,自己起的后台 isolate 又是另一条链路。结果就是同样叫“崩溃”,团队实际面对的是三套入口、两套日志格式和一堆漏报。

如果你前一阶段已经把上线后的对照排查跑顺了,可以把这篇当成那篇的前置补全:Flutter Android 上线后崩溃怎么排查:符号表、logcat 与 Play Console 对照清单。这一篇不再讲符号表,而是只解决一个更靠前、也更常青的问题:Flutter Android 项目怎样把 FlutterError、root isolate 和 background isolate 的异常统一接住,并且用日志、测试和验收步骤证明没有继续漏报。

Flutter 官方在 2026-08-24 更新的错误处理文档已经把边界说得很清楚:框架自己捕获到的错误走 FlutterError.onError,没有经过 Flutter 回调栈的未处理错误要走 PlatformDispatcher.instance.onError,而 child isolate 的错误不会自动进入 root isolate 处理器。Dart 的 Isolate API也明确给了 addErrorListener / onError 这条回传通道。所以这次的目标不是“再包一层神秘全局 try/catch”,而是按官方边界把三段链路接完整。

先把三条异常链路分开,不要再用一个“全局兜底”概念糊过去

在真实 Flutter Android 项目里,最常见的错位有三种。

第一种是框架回调内的异常。例如 widget build、layout、paint、手势回调里直接抛错,这类错误进入 FlutterError.onError。如果你改写了自己的 handler,却没有继续调用 FlutterError.presentError,控制台会安静很多,但开发阶段反而更难排查。

第二种是root isolate 的未处理异步错误。Flutter 官方文档专门拿 MethodChannel.invokeMethod 举例:插件或平台调用里的异步异常,不会自动回到 FlutterError.onError,而是要靠 PlatformDispatcher.instance.onError 去接。官方在“what's new”归档里还特别提醒,应用级 catch-all 应优先用 PlatformDispatcher.onError,而不是把自定义 Zone 当成长期主方案。

第三种是你自己创建的 background isolatePlatformDispatcher.onError 文档写得非常直接:它不会处理 child isolate 的错误,程序如果创建了新 isolate,就必须自己监听这些 isolate 的错误并转发回 root isolate。也就是说,很多团队看到“监控 SDK 已经初始化成功”就以为收口完成,其实只要 JSON 解析、压缩、加解密、离线同步这些任务被挪进 isolate,就仍然可能继续漏报。

把这三条边界先分清,后面的实现就简单很多:框架错误保留原始输出,root isolate 错误统一归口,background isolate 错误显式回传。

建议验证环境和目录结构先固定下来

这套写法不依赖某个监控厂商,适合先在项目里做“统一入口 + 验收基线”。示例代码按 2026-08 官方 Flutter 错误处理 API 组织,建议至少在下面环境里验证一次:

  • Flutter stable 通道,先跑 flutter doctor -v
  • Dart 3 运行时
  • Android 14 模拟器或真机
  • 一台能执行 adb logcat 的本地开发机

目录可以先保持很小,不需要一上来把监控做成平台级中间件:

lib/
  main.dart
  app/bootstrap/crash_bootstrap.dart
  core/monitoring/crash_reporter.dart
  core/monitoring/isolates.dart
  features/debug/crash_lab_page.dart
test/
  crash_bootstrap_test.dart

这样做的好处是后面无论你接自建接口、Sentry 还是别的服务,改的都只是 CrashReporter 实现,不用把入口代码再拆第二次。

第一步:先做一个只负责归一化的 CrashReporter

不要让 FlutterError.onErrorPlatformDispatcher.onError 和 isolate 监听各自拼不同字段。先统一成一份事件结构,后面验证日志和接第三方服务都会轻很多。

import 'dart:convert';
import 'dart:developer' as developer;

enum CrashSource { flutterFramework, rootIsolate, backgroundIsolate }

class CrashEvent {
  CrashEvent({
    required this.source,
    required this.error,
    required this.stackTrace,
    required this.fatal,
    required this.context,
  });

  final CrashSource source;
  final Object error;
  final StackTrace stackTrace;
  final bool fatal;
  final Map<String, Object?> context;

  Map<String, Object?> toJson() => {
        'source': source.name,
        'error': error.toString(),
        'stack': stackTrace.toString(),
        'fatal': fatal,
        'context': context,
      };
}

abstract class CrashReporter {
  Future<void> record(CrashEvent event);
}

class DebugCrashReporter implements CrashReporter {
  @override
  Future<void> record(CrashEvent event) async {
    developer.log(
      jsonEncode(event.toJson()),
      name: 'CrashMonitor',
      error: event.error,
      stackTrace: event.stackTrace,
      level: event.fatal ? 1000 : 900,
    );
  }
}

这里先故意保持朴素。原因很现实:如果你的 reporter 初始化本身还依赖网络、登录态或复杂缓存,那么崩溃入口会先被你自己的监控代码拖垮。第一版只要做到三件事就够了:字段统一、输出稳定、替换成本低。

第二步:在 main.dart 同时接住 FlutterError 和 root isolate

入口不要只挂一个 handler。Flutter 官方的推荐组合已经够清楚,直接照边界接。

import 'dart:async';
import 'dart:ui';

import 'package:flutter/material.dart';

import 'app/bootstrap/crash_bootstrap.dart';
import 'core/monitoring/crash_reporter.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  final reporter = DebugCrashReporter();
  installCrashBootstrap(reporter);

  runApp(const StepnexApp());
}

void installCrashBootstrap(CrashReporter reporter) {
  FlutterError.onError = (FlutterErrorDetails details) {
    FlutterError.presentError(details);
    unawaited(
      reporter.record(
        CrashEvent(
          source: CrashSource.flutterFramework,
          error: details.exception,
          stackTrace: details.stack ?? StackTrace.empty,
          fatal: true,
          context: <String, Object?>{
            'library': details.library,
            'context': details.context?.toDescription(),
          },
        ),
      ),
    );
  };

  PlatformDispatcher.instance.onError = (Object error, StackTrace stackTrace) {
    unawaited(
      reporter.record(
        CrashEvent(
          source: CrashSource.rootIsolate,
          error: error,
          stackTrace: stackTrace,
          fatal: true,
          context: const <String, Object?>{
            'zone': 'root_isolate',
          },
        ),
      ),
    );
    return true;
  };
}

这里有三个细节不要省。

  • 第一,FlutterError.presentError(details) 继续保留。官方 API 说明里明确建议自定义 handler 里继续调用它,否则开发阶段你会丢掉最直观的控制台输出。
  • 第二,PlatformDispatcher.instance.onError 必须返回 true,表示这次错误已经被你的应用处理;否则还会走宿主侧 fallback。
  • 第三,不要把 reporter 的 Future 直接 await 在错误回调里。错误链路里再阻塞主线程,只会让你把“异常上报”变成新的卡顿来源。

如果你已经在做Flutter Android 测试别只跑 flutter run:单元、Widget、集成测试与设备回归清单,这一步最适合补进你现有的 smoke test,而不是留到发版前靠人工点按钮。

第三步:自己起的 isolate 必须显式回传错误

真正最容易漏掉的是后台 isolate。很多团队把大 JSON 解析、压缩、批量计算挪到 isolate 以后,主线程不卡了,但错误也一起“挪丢了”。最稳的办法是:创建 isolate 时就把错误端口和退出端口一起配好。

import 'dart:async';
import 'dart:isolate';

import 'crash_reporter.dart';

Future<Isolate> spawnMonitoredIsolate({
  required void Function(SendPort) entryPoint,
  required CrashReporter reporter,
  String debugName = 'bg-worker',
}) async {
  final errorPort = ReceivePort();
  final exitPort = ReceivePort();

  final isolate = await Isolate.spawn<SendPort>(
    entryPoint,
    errorPort.sendPort,
    errorsAreFatal: true,
    onError: errorPort.sendPort,
    onExit: exitPort.sendPort,
    debugName: debugName,
  );

  final errorSubscription = errorPort.listen((dynamic message) {
    final values = message is List ? message : const <Object?>[];
    final error = values.isNotEmpty ? values[0] ?? StateError('unknown') : StateError('unknown');
    final stackTrace = values.length > 1
        ? StackTrace.fromString('${values[1]}')
        : StackTrace.empty;

    unawaited(
      reporter.record(
        CrashEvent(
          source: CrashSource.backgroundIsolate,
          error: error,
          stackTrace: stackTrace,
          fatal: true,
          context: <String, Object?>{'debugName': debugName},
        ),
      ),
    );
  });

  late final StreamSubscription<dynamic> exitSubscription;
  exitSubscription = exitPort.listen((_) async {
    await errorSubscription.cancel();
    await exitSubscription.cancel();
    errorPort.close();
    exitPort.close();
  });

  return isolate;
}

void crashyParser(SendPort _) {
  throw StateError('background isolate parse failed');
}

如果你用的是 compute,边界会稍微不同:它把异常通过 Future 回给调用方,前提是你真的 await 了返回值,并且没有在更上层吞掉错误。只要是显式 Isolate.spawn、离线同步 worker、压缩任务或长生命周期解析器,我更建议像上面这样把错误端口固定下来,不要把“也许会在调用方被 catch 到”当成监控方案。

第四步:不要等线上真实事故,直接做一页故障注入和一条测试

监控入口有没有补齐,最怕嘴上说“理论上能收”。最省时间的验收方式,是在 debug 页面里准备三种可重复触发的错误。

class CrashLabPage extends StatelessWidget {
  const CrashLabPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Crash Lab')),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          ElevatedButton(
            onPressed: () => throw FlutterError('framework boom'),
            child: const Text('触发 FlutterError'),
          ),
          ElevatedButton(
            onPressed: () async {
              Future<void>.microtask(() => throw StateError('root isolate boom'));
            },
            child: const Text('触发 root isolate 异常'),
          ),
          ElevatedButton(
            onPressed: () async {
              await spawnMonitoredIsolate(
                entryPoint: crashyParser,
                reporter: DebugCrashReporter(),
                debugName: 'parser-worker',
              );
            },
            child: const Text('触发 background isolate 异常'),
          ),
        ],
      ),
    );
  }
}

再补一条最小测试,把“入口是否真的被安装”固定住:

import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  testWidgets('crash bootstrap routes framework and root isolate errors', (tester) async {
    final records = <CrashEvent>[];
    final reporter = _MemoryReporter(records);

    installCrashBootstrap(reporter);

    FlutterError.reportError(
      FlutterErrorDetails(
        exception: StateError('framework boom'),
        stack: StackTrace.empty,
        library: 'widget test',
      ),
    );

    final handled = PlatformDispatcher.instance.onError!.call(
      StateError('root boom'),
      StackTrace.empty,
    );

    expect(handled, isTrue);
    expect(records.map((item) => item.source.name), containsAll(<String>[
      'flutterFramework',
      'rootIsolate',
    ]));
  });
}

这条测试不是为了模拟所有崩溃,而是为了防止后续有人在重构 main.dart、替换监控 SDK 或拆分启动流程时,把 handler 悄悄删掉。

验收命令、日志信号和指标要写成固定清单

Android 官方的 logcat 文档说明得很明确:它是系统日志缓冲区的命令行查看工具,可以按 tag 和 priority 过滤。所以验收时不要只看“有没有红字”,而要固定看三类信号是否都出现。

flutter analyze
flutter test test/crash_bootstrap_test.dart
adb logcat -c
flutter run --profile -d emulator-5554
adb logcat -v time CrashMonitor:D flutter:D *:S

建议把验收结果至少记成下面三项指标:

  • framework_error_count:点击“触发 FlutterError”后,CrashMonitor 至少出现 1 条 flutterFramework
  • root_isolate_error_count:触发异步异常后,至少出现 1 条 rootIsolate
  • background_isolate_error_count:触发 worker 异常后,至少出现 1 条 backgroundIsolate

如果三类日志都能稳定出现,再回到你原来的崩溃排查流程里看符号表、用户路径和发布版本,才有意义;否则后面的 triage 都是在拿不完整样本做判断。

失败边界要提前写进实现,不要等误报后再补

这套配置能补齐很多“本来没被记录到”的 Dart/Flutter 异常,但它不是万能 catch-all,至少有四个边界要提前说清。

第一,PlatformDispatcher.onError 文档本身就提醒了:如果异常已经让 VM 或进程直接终止、卡死或失去响应,回调可能来不及执行。也就是说,原生层 SIGABRT、SIGSEGV、某些 ANR 和宿主进程级崩溃,仍然要靠 Play Console、系统 tombstone 或你接入的原生崩溃 SDK。

第二,Flutter 官方已经明确建议应用层用 PlatformDispatcher.onError 代替把自定义 Zone 当全局兜底。Dart 的 runZonedGuarded依然有用,但更适合你明确创建的一小段异步上下文,而不是继续承担整 app 主入口。

第三,Android 官方的日志泄露风险说明明确不建议把敏感数据直接打进 logcat。因此 CrashEvent.context 里只放页面名、任务名、版本号、debugName 这类可预测字段,不要塞 token、手机号、请求体和完整用户输入。生产版如果还保留日志,优先只留 warning/error 级别,并确认 R8 策略没有把调试日志整包带上。

第四,background isolate 的错误监听是有生命周期成本的。你如果只创建端口不清理 subscription,监控入口会先变成新的内存泄漏点。最近站内那篇Flutter Android 内存泄漏排查:用快照差异定位未释放对象正好可以拿来交叉检查这类“为了监控新增的常驻对象”。

总结

Flutter Android 崩溃监控真正难的,不是再接一个 SDK,而是先把异常边界拆对:框架错误走 FlutterError.onError,root isolate 未处理异步错误走 PlatformDispatcher.instance.onError,自己创建的 background isolate 必须显式回传。把这三段链路补齐之后,你才有资格说“这次线上没有继续漏日志”。

更务实的落地顺序是:先统一 CrashReporter 字段,再安装两类主入口 handler,然后给 isolate 加错误端口,最后用故障注入页、flutter testadb logcat 三重验收把结果固定下来。做到这一步,后面无论接 Play Console、Sentry 还是自建告警,讨论的都不再是“能不能收到”,而是“收到以后怎样更快归因”。