Flutter の Firebase Crashlytics がムズすぎる

Flutter アプリに Crashlytics を導入しようとドキュメントを読んでたんですが、

  • なんかエラーハンドラをいっぱい定義しないといけないっぽい、けど違いがわからない
  • そもそも Crashlytics のドキュメントと FlutterFire のドキュメントで書いてることが違う
  • 巷のブログ記事などもいまいちピンとこない

という状態だったので、いろいろ整理したまとめです。

Flutter アプリのエラーは 4 種類ある

Flutter のドキュメントでは 3 つのケースが紹介されていますが、それにネイティブ側でエラーが発生するケースを加えた 4 つのパターンを考慮する必要があります。

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

The Flutter framework catches errors that occur during callbacks triggered by the framework itself, including errors encountered during the build, layout, and paint phases. Errors that don’t occur within Flutter’s callbacks can’t be caught by the framework, but you can handle them by setting up an error handler on the PlatformDispatcher.

1. All errors caught by Flutter

Flutter が捕捉するもの。 FlutterError.onError でハンドリングします。これは後に出てくる 3. と対比するとわかりやすく、同期的に発生したエラーが該当します。

デフォルトだと、こういうエラーログが出力されます。

======== Exception caught by gesture ================================
The following _Exception was thrown while handling a gesture:
Exception: Firebase Crashlytics test exception.

2. When an error occurs during the build phase

Widget のビルド中に発生するもの。レイアウトのエラーなど。

デフォルトだと、えんじ色の全画面の Widget に黄色の文字でエラーが表示されます。 ErrorWidget.builder を使ってカスタマイズできます。

3. When errors occur without a Flutter callback on the call stack

call stack に Flutter callback が存在しないもの。 PlatformDispatcher.onError でハンドリングします。非同期処理中に発生したエラーが該当します。

デフォルトだと、こういうスタックトレースが出力されます。

E/flutter ( 3908): [ERROR:flutter/runtime/dart_vm_initializer.cc(41)] Unhandled Exception: Exception: Firebase Crashlytics test exception.
E/flutter ( 3908): #0      MyApp.build.<anonymous closure> (package:app/my_app.dart:39:21)
E/flutter ( 3908): #1      _InkResponseState.handleTap (package:flutter/src/material/ink_well.dart:1072:21)

4. Natively crash

Android / iOS ネイティブ側で発生するもの。 Flutter の外の世界の話なので、 Flutter のエラーハンドリングのドキュメントには出てきません。

fatal vs non-fatal

Crashlytics のドキュメントによると、 recordFlutterFatalErrorrecordFlutterError のどちらを使うかで、 fatal だけ拾うか non-fatal も含めて拾うかをよしなにハンドリングしてくれそうに読めます。

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

You can automatically catch all "fatal" errors that are thrown within the Flutter framework by overriding FlutterError.onError with FirebaseCrashlytics.instance.recordFlutterFatalError. Alternatively, to also catch "non-fatal" exceptions, override FlutterError.onError with FirebaseCrashlytics.instance.recordFlutterError

実際はそんなことはなくて、 recordFlutterFatalError は fatal: true を指定して recordFlutterError を呼んでいるだけです。つまり、渡したエラーをすべて fatal として扱うか、すべて non-fatal とするかの違いしかありません。

https://github.com/firebase/flutterfire/blob/2316eea214fb0ec00422c7951021632d5bbe7ef7/packages/firebase_crashlytics/firebase_crashlytics/lib/src/firebase_crashlytics.dart#L156-L159

Future<void> recordFlutterFatalError(
    FlutterErrorDetails flutterErrorDetails) {
  return recordFlutterError(flutterErrorDetails, fatal: true);
}

エラーによって fatal / non-fatal を使い分けたい場合は、判別するコードを自分で書く必要があります。

PlatformDispatcher.onError vs runZonedGuarded

非同期処理中のエラーに関して、 Crashlytics のドキュメントでは PlatformDispatcher が、 FlutterFire のドキュメントでは runZonedGuarded が使われています。

https://firebase.google.com/docs/crashlytics/get-started?platform=flutter#configure-crash-handlers

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

https://firebase.flutter.dev/docs/crashlytics/usage#zoned-errors

To catch such errors, you can use runZonedGuarded

Zone を利用するとアプリの起動に時間がかかってしまうとのことで、 Flutter 3.3 以降は PlatformDispatcher.onError が推奨されています。

https://medium.com/flutter/whats-new-in-flutter-3-3-893c7b9af1ff#d668

In this release, instead of using a custom Zone, you should catch all errors and exceptions by setting the PlatformDispatcher.onError callback.

エラーレポーティングを実装する

ここまでの内容を踏まえて、エラーハンドリングとレポーティングを実装していきます。

@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(
  RemoteMessage remoteMessage,
) async {
  WidgetsFlutterBinding.ensureInitialized().platformDispatcher.onError =
      (exception, stackTrace) {
    FirebaseCrashlytics.instance.recordError(exception, stackTrace);
    return true;
  };

  ...
}

void main() async {
  final widgetBinding = WidgetsFlutterBinding.ensureInitialized();
  FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);

  await Firebase.initializeApp();
  FlutterError.onError = (details) async {
    await FirebaseCrashlytics.instance.recordFlutterFatalError(details);
    exit(1);
  };
  widgetBinding.platformDispatcher.onError = (exception, stackTrace) {
    FirebaseCrashlytics.instance.recordError(exception, stackTrace);
    return true;
  };

  runApp(const MyApp());
}

4 種類のエラーのうち、 main 関数に 1. と 3. のハンドリングを、 FirebaseMessaging や WorkManager などのバックグラウンド処理の entry-point に 3. のハンドリングを実装しました。 4. は Crashlytics のライブラリを導入するだけで、他に何もしなくてもレポートされます。

エラー発生時に FlutterError.onErrorでアプリを強制終了させる場合は、 recordFlutterFatalError の処理を待ってから exit(1) を呼ぶ必要があります。

また、 FlutterError.onError をオーバーライドする際には presentError を呼ぶことが推奨されていますが、これは FirebaseCrashlytics.recordFlutterFatalError 内で呼ばれているので、改めて呼ぶ必要はありません。

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

Note: Consider calling FlutterError.presentError from your custom error handler in order to see the logs in the console as well.

https://github.com/firebase/flutterfire/blob/2316eea214fb0ec00422c7951021632d5bbe7ef7/packages/firebase_crashlytics/firebase_crashlytics/lib/src/firebase_crashlytics.dart#L139-L141

Future<void> recordFlutterError(FlutterErrorDetails flutterErrorDetails,
      {bool fatal = false}) {
  FlutterError.presentError(flutterErrorDetails);
  ...

Flutter のエラーハンドリングのドキュメントや Crashlytics のドキュメントでは PlatformDispatcher.instance に直接アクセスしていますが、 PlatformDispatcher のドキュメントに記載の通り WidgetsBinding からアクセスするほうがよいでしょう。

https://api.flutter.dev/flutter/dart-ui/PlatformDispatcher/instance.html

Consider avoiding static references to this singleton though PlatformDispatcher.instance and instead prefer using a binding for dependency resolution such as WidgetsBinding.instance.platformDispatcher.

クラッシュのテスト

Crashlytics のドキュメントにはボタンタップで例外を投げる方法が、 FlutterFire のドキュメントには crash メソッドを呼ぶ方法が記載されています。

https://firebase.google.com/docs/crashlytics/get-started?platform=flutter#force-test-crash

TextButton(
    onPressed: () => throw Exception(),
    child: const Text("Throw Test Exception"),
),

これは 4 種類のエラーの 1. に該当します。

https://firebase.flutter.dev/docs/crashlytics/usage#handling-uncaught-errors

To force a crash, call the crash method:

FirebaseCrashlytics.instance.crash();

これは 4. です。

ドキュメントに記載されている方法だけでは十分でなく、実装した内容に合わせて 4 種類のエラーを main 関数 / バックグラウンド処理の entry-point それぞれで確認する必要があります。

その他

FlutterFire のドキュメントには Isolate.current に addErrorListener するパターンの記載もあります。

https://firebase.flutter.dev/docs/crashlytics/usage#errors-outside-of-flutter

To catch errors that happen outside of the Flutter context, install an error listener on the current Isolate

main isolate で addErrorListener しても 4 種類のエラーのどれも呼ばれなかったので深追いはしていないんですが、 main 以外の isolate を起動する場合には必要になってくるのかもしれません。(そうなるとエラーは 5 種類以上ということに…。)