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 のドキュメントによると、 recordFlutterFatalError
と recordFlutterError
のどちらを使うかで、 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
withFirebaseCrashlytics.instance.recordFlutterFatalError
. Alternatively, to also catch "non-fatal" exceptions, overrideFlutterError.onError
withFirebaseCrashlytics.instance.recordFlutterError
実際はそんなことはなくて、 recordFlutterFatalError
は fatal: true を指定して recordFlutterError
を呼んでいるだけです。つまり、渡したエラーをすべて fatal として扱うか、すべて non-fatal とするかの違いしかありません。
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.
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 種類以上ということに…。)