Flutterの例外ハンドリングの仕組みを読む
アプリを運用する上で、例外(Exception)の発生状況を確認することは非常に重要です。多くのプロジェクトではfirebase_crashlyticsやsentry_flutterなどの例外収集ツールを導入していることと存じます。
ツールのドキュメントにはFlutterError.onErrorやrunZonedGuarded、PlatformDispatcher.onErrorが登場します。あまり馴染みがないメソッドですが、添えられたコメントを読むと、キャッチしなかった例外を拾うことができるようです。
You can automatically catch all errors that are thrown within the Flutter framework by overriding
FlutterError.onError
withFirebaseCrashlytics.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());
}
The SDK already runs your init
callback
on an error handler, such as runZonedGuarded on Flutter versions prior to3.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におけるエラーハンドリングについては、ぜひ一度公式ドキュメントを確認するべきです。本記事で得られる知見も、よりコンパクトにまとまっています。この記事を読むよりも、公式ドキュメントの方がより有益です。
本記事は、公式ドキュメントをよりよく理解するためのサポートになることを目指します。特にソースコードを確認することで、「自分たちのアプリに適したハンドリングはどういったものか」を議論できることを目指します。
(前準備) Firebase Crashlyticsのドキュメントを読む
Firebase Crashlyticsの公式ドキュメントをよく読んでみると、次の2点が書かれています。
- 非同期処理内での例外はFlutter frameworkではキャッチされない
- このため、
PlatformDispatcher.instance.onError
でキャッチする必要がある
- このため、
- 別の
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
に定義されています。
該当の実装を抜き出します。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
の呼び出し箇所を検索してみると、多くの箇所で呼ばれていることがわかります。例を挙げると、ChangeNotifier
のnotifyListeners
の呼び出し時に例外が発生した箇所、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
にあることに注意してください。
該当の実装を抜き出します。こちらも、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
で呼び出されています。
そして、Dartのコードを検索しても、これ以上辿ることはできません。_onError
はEngineのC++のコードから、Dartのコードを呼び出すために用意されたfunctionです。
ここから先は筆者がC++に明るくない&FlutterのC++コードに精通していないため、詳細は割愛します。このfunctionが導入されたPRを読む限りでは、フレームワークでキャッチされなかった例外を、Dartのコードでキャッチするよう実装されているようです。
なお、Flutter WebではPlatformDispatcher.instance.onError
の仕組みは実装途中のようです。2025年現在でも未解決なため、詳細が気になる方はIssueを参照してください。
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.onError
はPass all uncaught "fatal" errors from the frameworkをキャッチ、PlatformDispatcher.instance.onError
はPass 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の例外ハンドリングの仕組みを正しく理解することで、これらの捉え方が自分たちのチームにとって適しているのかどうか。またどういった点を修正するべきか、議論できるようになるはずです。
Discussion