🎃

[Flutter] Firebase Crashlyticsを導入した

2022/01/14に公開

Crashlyticsとは

Crash Reporting Serviceの一種。
アプリ内で発生したエラーやクラッシュをFirebaseに送信し、Firebaseコンソール内のダッシュボードでそれらを確認することができる。
類似サービスにはSentryがある。公式ではSentryを利用したError Reportingを紹介している。

導入背景

現状、リリースされたアプリで確認できる不具合は、API通信時のエラーのみである。つまり、Flutterアプリ内のエラーやクラッシュに関しては、ユーザからのバグ報告のみでしか知ることができない。したがって、それを解決するために、Flutterアプリ内のエラーやクラッシュを監視し、補足したエラー等をCrashlyticsでFirebaseに送信することによって、中央集権的に常にFlutterアプリの状態を確認できるようにしたい。

導入手順

基本的に公式ドキュメントを参考にした。

Flutterアプリ内のエラーやクラッシュを監視し

→ Handling errors in Flutter: https://docs.flutter.dev/testing/errors

補足したエラー等をCrashlyticsでFirebaseに送信する

→ FlutterFire Crashlytics: https://firebase.flutter.dev/docs/crashlytics/overview

まず、Flutterでのエラーハンドリングについて知る必要がある。
その種類と解決法は以下である。

  • Flutterによってキャッチされるエラー
    • FlutterError.onError に上書きする
  • ビルドフェーズ中に発生したエラー
    • ErrorWidget.builder に上書きする
  • Flutterによってキャッチされないエラー
    • runZonedGuarded でラップする

よって、それぞれの場合において発生したエラーをCrashlyticsで送信すればよい。
具体的な方法に関しては公式に任せることにする。

注意する点

クラッシュレポートがFirebaseに送信されるタイミングは、次回のアプリケーションの起動時である。したがって、アプリケーションの再起動が必要である。

  • debugビルドの場合は、一度アプリケーションをstopさせてから再びrunさせる必要がある。
  • releaseビルドの場合は、アプリケーション終了させて、再起動させればよい。

実装方針

Firebase Crashlyticsを直接使用せず、インターフェースを切ることで、異なるCrash Reporting Serviceを用いたくなった場合に切り替えが容易になる。また、ユニットテスト時においても簡単にmockに切り替えることができる。

ユースケースは以下2つ。

  • Widget構築前の初期化やオーバーライド
    • シングルトン化したクラスのインスタンスを静的ゲッターで取得し、各々メソッドコール
  • ログイン後のidentifyの設定
    • Riverpodでシングルトン化したクラスのインスタンスをDIし、WidgetRefから参照

コード

crash_reporter.dart
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

final crashReporter = Provider<ICrashReporter>((ref) => CrashReporter.instance);

abstract class ICrashReporter {
  Future<void> initialize();
  Future<void> setIdentify(String id);
  Future<void> report(dynamic exception, StackTrace? stack);
}

class CrashReporter implements ICrashReporter {
  CrashReporter._(this._reporter);
  final FirebaseCrashlytics _reporter;

  static ICrashReporter? _instance;
  static ICrashReporter get instance {
    _instance ??= CrashReporter._(FirebaseCrashlytics.instance);

    return _instance!;
  }

  
  Future<void> initialize() async {
    await _reporter.setCrashlyticsCollectionEnabled(kReleaseMode);
  }

  
  Future<void> setIdentify(String id) async {
    await _reporter.setUserIdentifier(id);
  }

  
  Future<void> report(dynamic exception, StackTrace? stack) async {
    await _reporter.recordError(exception, stack);
  }
}
how_to_use.dart
void main() {
  runZonedGuarded(() async {
    WidgetsFlutterBinding.ensureInitialized();
    await CrashReporter.instance.initialize();

    FlutterError.onError = (FlutterErrorDetails details) {
      CrashReporter.instance.report(details.exceptionAsString(), details.stack);
    };

    runApp(
      ProviderScope(
        child: const App('user_identify'),
      ),
    );
  }, (error, stack) {
    CrashReporter.instance.report(error, stack);
  });
}

class App extends HookConsumerWidget {
  const App(this.id, {Key? key}) : super(key: key);
  final String id;
  
  
  Widget build(BuildContext context, WidgetRef ref) {
    useEffect(() {
      ref.read(crashReporter).setIdentify(id);
      return null;
    }, []);
    
    return MaterialApp(
      builder: (context, widget) {
        Widget error = const Text('...rendering error...');
        if (widget is Scaffold || widget is Navigator) {
          error = Scaffold(body: Center(child: error));
        }
        ErrorWidget.builder = (FlutterErrorDetails details) {
          ref
              .read(crashReporter)
              .report(details.exceptionAsString(), details.stack);
          return error;
        };

        return widget;
      },
    );
  }
}

よくわかっていない点

  1. 以下の使い所: https://firebase.flutter.dev/docs/crashlytics/usage#errors-outside-of-flutter
    Isolateを用いたバックグラウンド処理中のエラーを補足するのか?
Isolate.current.addErrorListener(RawReceivePort((pair) async {
  final List<dynamic> errorAndStacktrace = pair;
  await FirebaseCrashlytics.instance.recordError(
    errorAndStacktrace.first,
    errorAndStacktrace.last,
  );
}).sendPort);
  1. オプトインレポーティングの使い所
    「クラッシュしたエラー内容を送信してよいですか?」などの確認ダイアログが出現し、それを許可したアプリケーションのみfirebaseに送信するといったユースケースか?

  2. プラットフォーム統合の設定用途: https://firebase.flutter.dev/docs/crashlytics/overview#2-optional-platform-integration
    Add to app用か?

  3. dSYMについて

debugビルド時にはデフォルトでアプリのbinary fileにdebug symbolが含まれているが、release build時では配布するアプリのサイズを減らすためにアプリのbinaryとは別にDebug Symbol fileを生成する。
このDebug Symbol fileがdSYM と呼ばれている。

参照: https://plum-plus.jp/2021/02/08/dsym-bitcodeについて/

デフォルトでは、Firebase Crashlytics はデバッグ シンボル(dSYM)ファイルを自動的に処理して、難読化解除された(人が読める形式の)クラッシュ レポートを生成します。この動作は、Crashlytics を初期化する実行スクリプトをアプリのビルドフェーズに追加するときに設定されます。

参照: https://firebase.google.com/docs/crashlytics/get-deobfuscated-reports?hl=ja&platform=ios

今のところ、Crashlyticsのダッシュボードを見る限り、『不足している dSYM はありません』と表示されているので、問題ないという認識。
もし不足しているdSYMがあれば、ダッシュボードの手順通りにdSYMをアップロードすればよさそう。

追記

Firebase コンソールに「不足している dSYM」というアラートが表示されたので、それを見つけ、アップロードする手順を追記する。

  1. Firebase コンソールの存在しない dSYM ファイルから警告が出ているUUIDを見つける

  2. 上記UUIDより不足しているdSYMファイルを見つける

mdfind -name .dSYM | while read -r line; do dwarfdump -u "$line"; done
  1. dSYMファイルをアップロードする
/path/to/pods/directory/FirebaseCrashlytics/upload-symbols -gsp /path/to/GoogleService-Info.plist -p ios /path/to/dSYMs

upload-symbolsはFinderで検索をかけて発見した。

Successfully uploaded Crashlytics symbols

上記メッセージが表示されれば成功。Firebaseコンソールに無事反映された。

Discussion