⚾️

Flutterの例外ハンドリングの仕組みを読む

に公開

アプリを運用する上で、例外(Exception)の発生状況を確認することは非常に重要です。多くのプロジェクトではfirebase_crashlyticssentry_flutterなどの例外収集ツールを導入していることと存じます。

ツールのドキュメントにはFlutterError.onErrorrunZonedGuardedPlatformDispatcher.onErrorが登場します。あまり馴染みがないメソッドですが、添えられたコメントを読むと、キャッチしなかった例外を拾うことができるようです。

https://firebase.google.com/docs/crashlytics/get-started?platform=flutter&hl=en

You can automatically catch all errors that are thrown within the Flutter framework by overriding FlutterError.onError with FirebaseCrashlytics.instance.recordFlutterFatalError:

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await Firebase.initializeApp();

  // Pass all uncaught "fatal" errors from the framework to Crashlytics
  FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError;

  runApp(MyApp());
}

To catch asynchronous errors that aren't handled by the Flutter framework, use PlatformDispatcher.instance.onError:

Future<void> main() async {
    WidgetsFlutterBinding.ensureInitialized();
    await Firebase.initializeApp();
    FlutterError.onError = (errorDetails) {
      FirebaseCrashlytics.instance.recordFlutterFatalError(errorDetails);
    };
    // Pass all uncaught asynchronous errors that aren't handled by the Flutter framework to Crashlytics
    PlatformDispatcher.instance.onError = (error, stack) {
      FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
      return true;
    };
    runApp(MyApp());

}

https://pub.dev/packages/sentry_flutter

The SDK already runs your init callback on an error handler, such as runZonedGuarded on Flutter versions prior to 3.3, or PlatformDispatcher.onError on Flutter versions 3.3 and higher, so that errors are automatically captured.

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

Future<void> main() async {
  await SentryFlutter.init(
    (options) {
      options.dsn = 'https://example@sentry.io/add-your-dsn-here';
    },
    // Init your App.
    appRunner: () => runApp(MyApp()),
  );
}

本記事では、このキャッチされなかった例外がキャッチされる仕組みを確認します。なおFlutterにおけるエラーハンドリングについては、ぜひ一度公式ドキュメントを確認するべきです。本記事で得られる知見も、よりコンパクトにまとまっています。この記事を読むよりも、公式ドキュメントの方がより有益です。

https://docs.flutter.dev/testing/errors

本記事は、公式ドキュメントをよりよく理解するためのサポートになることを目指します。特にソースコードを確認することで、「自分たちのアプリに適したハンドリングはどういったものか」を議論できることを目指します。

(前準備) Firebase Crashlyticsのドキュメントを読む

https://firebase.google.com/docs/crashlytics/customize-crash-reports?platform=flutter&hl=en

Firebase Crashlyticsの公式ドキュメントをよく読んでみると、次の2点が書かれています。

  1. 非同期処理内での例外はFlutter frameworkではキャッチされない
    • このため、PlatformDispatcher.instance.onErrorでキャッチする必要がある
  2. 別のIsolateを作成している場合、そのIsolate内で発生した例外はFlutter frameworkではキャッチされない
    • このため、Isolateに対してエラーリスナーを登録する必要がある

Firebase Crashlyticsのドキュメントに書かれているサンプルは、この2つの振る舞いを踏まえた上の実装です。また「アプリ内でハンドリングされなかった例外はfatalとして扱う」というポリシーも読み取れます。

この点は、Firebase Crashlyticsを使う際の注意点として重要です。というのも、non-fatalな例外は(non-fatalであるが故に)優先度が明確に低い例外として扱われます。

In addition to automatically reporting your app’s crashes, Crashlytics lets you record non-fatal exceptions and sends them to you the next time a fatal event is reported or when the app restarts.

であり、

Note: Crashlytics only stores the most recent eight recorded non-fatal exceptions. If your app throws more than eight, older exceptions are lost. This count is reset each time a fatal exception is thrown, since this causes a report to be sent to Crashlytics.

と記述されています。non-fatalな例外を8件までしか保存せず、fatalな例外が発生した際にnon-fatalな例外を送信する仕組みのため、non-fatalな例外を多用すると検知できない恐れがあります。ただfatalな例外はベロシティアラートの対象となるため、上に送信するとオオカミ少年になりかねません。

このような事情から、Firebase Crashlyticsを利用する場合には、Flutterにおける例外の扱いを正しく理解しておくことが重要です。

FlutterError.onErrorの仕組み

FlutterError.onErrorがどのような実装になっているのか確認しましょう。Flutterのソースコードを読みにいくと、/packages/flutter/lib/src/foundation/assertions.dartに定義されています。

https://github.com/flutter/flutter/blob/3.35.6/packages/flutter/lib/src/foundation/assertions.dart#L755-L1205

該当の実装を抜き出します。Flutter SDKのバージョンは3.35.6です。

/// Signature for [FlutterError.onError] handler.
typedef FlutterExceptionHandler = void Function(FlutterErrorDetails details);

/// Error class used to report Flutter-specific assertion failures and
/// contract violations.
///
/// See also:
///
///  * <https://docs.flutter.dev/testing/errors>, more information about error
///    handling in Flutter.
class FlutterError extends Error with DiagnosticableTreeMixin implements AssertionError {

  /// Called whenever the Flutter framework catches an error.
  ///
  /// The default behavior is to call [presentError].
  ///
  /// You can set this to your own function to override this default behavior.
  /// For example, you could report all errors to your server. Consider calling
  /// [presentError] from your custom error handler in order to see the logs in
  /// the console as well.
  ///
  /// If the error handler throws an exception, it will not be caught by the
  /// Flutter framework.
  ///
  /// Set this to null to silently catch and ignore errors. This is not
  /// recommended.
  ///
  /// Do not call [onError] directly, instead, call [reportError], which
  /// forwards to [onError] if it is not null.
  ///
  /// See also:
  ///
  ///  * <https://docs.flutter.dev/testing/errors>, more information about error
  ///    handling in Flutter.
  static FlutterExceptionHandler? onError = presentError;

  /// Called whenever the Flutter framework wants to present an error to the
  /// users.
  ///
  /// The default behavior is to call [dumpErrorToConsole].
  ///
  /// Plugins can override how an error is to be presented to the user. For
  /// example, the structured errors service extension sets its own method when
  /// the extension is enabled. If you want to change how Flutter responds to an
  /// error, use [onError] instead.
  static FlutterExceptionHandler presentError = dumpErrorToConsole;

  /// Calls [onError] with the given details, unless it is null.
  ///
  /// {@tool snippet}
  /// When calling this from a `catch` block consider annotating the method
  /// containing the `catch` block with
  /// `@pragma('vm:notify-debugger-on-exception')` to allow an attached debugger
  /// to treat the exception as unhandled. This means instead of executing the
  /// `catch` block, the debugger can break at the original source location from
  /// which the exception was thrown.
  ///
  /// ```dart
  /// @pragma('vm:notify-debugger-on-exception')
  /// void doSomething() {
  ///   try {
  ///     methodThatMayThrow();
  ///   } catch (exception, stack) {
  ///     FlutterError.reportError(FlutterErrorDetails(
  ///       exception: exception,
  ///       stack: stack,
  ///       library: 'example library',
  ///       context: ErrorDescription('while doing something'),
  ///     ));
  ///   }
  /// }
  /// ```
  /// {@end-tool}
  static void reportError(FlutterErrorDetails details) {
    onError?.call(details);
  }
}

FlutterError.onErrorは、見た目の通りstaticなプロパティです。コメントにある通り、開発者によって上書きされることを想定しています。FlutterError.reportErrorを経由して、FlutterError.onErrorに登録されている関数が呼び出される仕組みになっています。

Flutter SDKに対してFlutterError.reportErrorの呼び出し箇所を検索してみると、多くの箇所で呼ばれていることがわかります。例を挙げると、ChangeNotifiernotifyListenersの呼び出し時に例外が発生した箇所、ImageのprecacheImageにおいて例外が発生した箇所といった、Flutter frameworkの例外が発生しうる箇所で呼び出されています。どこかで一括してFlutterError.reportErrorが呼び出されているわけではなく、個別にFlutterError.reportErrorの呼び出しが記述されています。

FlutterError.onErrorの仕組みは非常にシンプルです。

FlutterError.reportErrorが呼び出されると、FlutterError.onErrorに登録されている関数が呼び出されます。デフォルトではFlutterError.presentErrorが登録されており、さらにそのデフォルト実装はFlutterError.dumpErrorToConsoleです。つまり、開発者が書いたコード内でキャッチされなかった例外は、最終的にコンソールに出力されることになります。

コンソールに出力されるような例外と考えると、Firebase Crashlyticsのドキュメントの記述も腑に落ちやすいのではないでしょうか。FlutterError.onErrorにコードを記述することで、開発者がキャッチしなかった例外を拾えます。つまりUncaught Exceptionを拾えるわけです。

PlatformDispatcher.instance.onErrorの仕組み

PlatformDispatcher.instance.onErrorがどのような実装になっているのか確認しましょう。Flutterのソースコードを読みにいくと、/engine/src/flutter/lib/ui/platform_dispatcher.dartに定義されています。/packages/flutterではなく/engine/src/flutter/lib/uiにあることに注意してください。

https://github.com/flutter/flutter/blob/3.35.6/engine/src/flutter/lib/ui/platform_dispatcher.dart#L104-L1524

該当の実装を抜き出します。こちらも、Flutter SDKのバージョンは3.35.6です。

/// Signature for [PlatformDispatcher.onError].
///
/// If this method returns false, the engine may use some fallback method to
/// provide information about the error.
///
/// After calling this method, the process or the VM may terminate. Some severe
/// unhandled errors may not be able to call this method either, such as Dart
/// compilation errors or process terminating errors.
typedef ErrorCallback = bool Function(Object exception, StackTrace stackTrace);

/// Platform event dispatcher singleton.
///
/// The most basic interface to the host operating system's interface.
///
/// This is the central entry point for platform messages and configuration
/// events from the platform.
///
/// It exposes the core scheduler API, the input event callback, the graphics
/// drawing API, and other such core services.
///
/// It manages the list of the application's [views] as well as the
/// [configuration] of various platform attributes.
///
/// Consider avoiding static references to this singleton through
/// [PlatformDispatcher.instance] and instead prefer using a binding for
/// dependency resolution such as `WidgetsBinding.instance.platformDispatcher`.
/// See [PlatformDispatcher.instance] for more information about why this is
/// preferred.
class PlatformDispatcher {

  /// The [PlatformDispatcher] singleton.
  ///
  /// Consider avoiding static references to this singleton through
  /// [PlatformDispatcher.instance] and instead prefer using a binding for
  /// dependency resolution such as `WidgetsBinding.instance.platformDispatcher`.
  ///
  /// Static access of this object means that Flutter has few, if any options to
  /// fake or mock the given object in tests. Even in cases where Dart offers
  /// special language constructs to forcefully shadow such properties, those
  /// mechanisms would only be reasonable for tests and they would not be
  /// reasonable for a future of Flutter where we legitimately want to select an
  /// appropriate implementation at runtime.
  ///
  /// The only place that `WidgetsBinding.instance.platformDispatcher` is
  /// inappropriate is if access to these APIs is required before the binding is
  /// initialized by invoking `runApp()` or
  /// `WidgetsFlutterBinding.instance.ensureInitialized()`. In that case, it is
  /// necessary (though unfortunate) to use the [PlatformDispatcher.instance]
  /// object statically.
  static PlatformDispatcher get instance => _instance;
  static final PlatformDispatcher _instance = PlatformDispatcher._();

  ErrorCallback? _onError;
  Zone? _onErrorZone;

  /// A callback that is invoked when an unhandled error occurs in the root
  /// isolate.
  ///
  /// This callback must return `true` if it has handled the error. Otherwise,
  /// it must return `false` and a fallback mechanism such as printing to stderr
  /// will be used, as configured by the specific platform embedding via
  /// `Settings::unhandled_exception_callback`.
  ///
  /// The VM or the process may exit or become unresponsive after calling this
  /// callback. The callback will not be called for exceptions that cause the VM
  /// or process to terminate or become unresponsive before the callback can be
  /// invoked.
  ///
  /// This callback is not directly invoked by errors in child isolates of the
  /// root isolate. Programs that create new isolates must listen for errors on
  /// those isolates and forward the errors to the root isolate.
  ErrorCallback? get onError => _onError;
  set onError(ErrorCallback? callback) {
    _onError = callback;
    _onErrorZone = Zone.current;
  }

  bool _dispatchError(Object error, StackTrace stackTrace) {
    if (_onError == null) {
      return false;
    }
    assert(_onErrorZone != null);

    if (identical(_onErrorZone, Zone.current)) {
      return _onError!(error, stackTrace);
    } else {
      try {
        return _onErrorZone!.runBinary<bool, Object, StackTrace>(_onError!, error, stackTrace);
      } catch (e, s) {
        _onErrorZone!.handleUncaughtError(e, s);
        return false;
      }
    }
  }
}

PlatformDispatcher.instance._dispatchErrorの呼び出し箇所は、もう少しコードをジャンプしていく必要があります。検索するとflutter/engine/src/flutter/lib/ui/hooks.dartで呼び出されています。

https://github.com/flutter/flutter/blob/3.35.6/engine/src/flutter/lib/ui/hooks.dart#L305-L308

そして、Dartのコードを検索しても、これ以上辿ることはできません。_onErrorはEngineのC++のコードから、Dartのコードを呼び出すために用意されたfunctionです。

https://github.com/flutter/flutter/blob/3.35.6/engine/src/flutter/lib/ui/window/platform_configuration.cc#L40-L44

https://github.com/flutter/flutter/blob/3.35.6/engine/src/flutter/lib/ui/window/platform_configuration.h#L553

ここから先は筆者がC++に明るくない&FlutterのC++コードに精通していないため、詳細は割愛します。このfunctionが導入されたPRを読む限りでは、フレームワークでキャッチされなかった例外を、Dartのコードでキャッチするよう実装されているようです。

なお、Flutter WebではPlatformDispatcher.instance.onErrorの仕組みは実装途中のようです。2025年現在でも未解決なため、詳細が気になる方はIssueを参照してください。

https://github.com/flutter/flutter/issues/100277

https://github.com/flutter/flutter/blob/3.35.6/engine/src/flutter/lib/web_ui/lib/src/engine/platform_dispatcher.dart#L1231-L1242

runZonedGuardedとの違い

design docを(ざっくり)読むと、「Dartでエラーハンドリングを簡単にする」ことに焦点が置かれていたようです。

runZonedGuardedでは複数のZoneで起きた例外をキャッチすることはできますが、Dart VMの外で起きた例外をキャッチすることはできません。一方、PlatformDispatcher.instance.onErrorはC++のコード内でキャッチした例外をDartのコードに伝搬できるため、Dart VMの外で起きた例外もキャッチできるようです。結果として、Future内で発生した非同期処理中の例外以外のケースにも、Dartのコードとしてはシンプルなままで対応できるようになった、と筆者は現時点で理解しています。

Flutterを「Dartでアプリを書くフレームワーク」と捉えると、runZonedGuardedでも十分そうに思えます。しかし、実際にはC++のコードであったり、Method Channelなどを介してネイティブのAPI呼び出したり、場合によってはネイティブのViewと連動するのがFlutterアプリです。Flutterアプリにおける例外は、Dart VMの中だけで発生するわけではありません。PlatformDispatcher.instance.onErrorが導入されたことで、よりFlutterに適した例外ハンドリングが可能になったと言えます。

まとめ

ざっとではありますが、Flutter SDKのソースコードを読み、Flutterの"未処理の"例外をキャッチする仕組みを確認しました。また、なぜFlutterのコード内でExceptionが発生したときに、なぜネイティブのアプリのように"クラッシュ"しないのか、その理由も理解できたかと思います。

と、ここまで書いたものの。多くの場合、Firebase Crashlyticsのドキュメントのコメントに書いてある内容を把握すれば、例外の扱いは十分です。ちょっと不安に思う場合には、Flutterの公式ドキュメントを読めば十二分です。FlutterError.onErrorPass all uncaught "fatal" errors from the frameworkをキャッチ、PlatformDispatcher.instance.onErrorPass all uncaught asynchronous errors that aren't handled by the Flutter frameworkをキャッチ、この理解で問題ありません。

問題があるとすれば、この捉え方はFirebase Crashlyticsを中心に考えればと前提がつくことでしょう。ここまで記事を読んでいただいた方には、Flutterの例外ハンドリングの仕組みを正しく理解すると、Firebase Crashlyticsのドキュメントが省いている部分が見えてくることを理解していただけたかと思います。

AndroidやiOSのネイティブアプリの場合、アプリクラッシュを伴うものをfatal、アプリクラッシュを伴わないものをnon-fatalとして扱うの一般的でした。しかしFlutterにおいては、try-catchで捕捉されなかった例外がfatal、try-catchで捕捉され送信された例外がnon-fatalとして扱われている事実があります。特に、Firebase Crashlyticsのドキュメントを読んだ通りに実装している場合には、意図せずこの扱いになっています。

Flutterの例外ハンドリングの仕組みを正しく理解することで、これらの捉え方が自分たちのチームにとって適しているのかどうか。またどういった点を修正するべきか、議論できるようになるはずです。

GitHubで編集を提案

Discussion