🔍

[Flutter x Firebase] Crashlyticsと連携してクラッシュレポートを取得する

2020/11/03に公開

やること

  • 前回作成したソーシャルログイン(Google)機能付きカウンターアプリをFirebaseのCrashlyticsと連携させて、クラッシュレポートを取得できるようにする
  • iOSのみ

前回

  1. [Flutter x Firebase] カウンターアプリに認証機能を追加する
  2. [Flutter x Firebase] カウンターアプリに認証機能を追加する 〜自動ログイン〜

※ 前回作った認証付きのカウンターアプリを使うが、FirebaseとFlutterアプリの連携が済んでいれば認証機能はなくてもCrashlyticsの機能を使うことはできる(と思う)

成果物

大きな動作は前回・前々回と変わらないので割愛。

ソースコード

https://github.com/popy1017/firebase_counter_app/tree/v0.3

使い方

参考ページ: Crashlytics | FlutterFire

準備

パッケージのインストール

https://pub.dev/packages/firebase_crashlytics

package.yaml
~~
dependencies:
  firebase_crashlytics: ^0.2.2
~~

エラー対処ログ

firebase_crashlyticsを追加してからビルドするとエラーになる

firebase_crashlyticspackage.yamlに追加してビルドしようとしたら、エラーが発生。
pod installを実行したところ、さらに次のようなエラーが。。。

pod updateを実行したら正常にビルドできた。

参考記事: ios - CocoaPods could not find compatible versions for pod "Firebase/CoreOnly" - Stack Overflow

Crashlyticsを有効にする

ステップ 1: Firebase コンソールで Crashlytics を設定する

  1. Firebase コンソールの左側のナビゲーション パネルにある [Crashlytics] をクリックします。
  2. Firebase プロジェクトに複数のアプリが登録されている場合は、コンソールの上部バーにある [Crashlytics] の横にあるプルダウンから追加したアプリを選択します。
  3. [Crashlytics を有効にする] をクリックします。

引用元: Firebase Crashlytics を使ってみる

iOS用の設定

4番目の項目は任意なのでやらなくてもOK。

  1. From Xcode select Runner from the project navigation.
  2. Select the Build Phases tab, then click + > New Run Script Phase.
  3. Add ${PODS_ROOT}/FirebaseCrashlytics/run to the Type a script... text box.
  4. Optionally you can also provide your app's built Info.plist location to the build phase's Input Files field: For example:$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)

引用元: Crashlytics | FlutterFire

ポイント

  • Crashlyticsの仕様を知る
  • 意図的にクラッシュさせてテストする
  • ログを仕込む
  • Uncaught errorをハンドリングする

Crashlyticsの仕様

クラッシュしたときではなく、クラッシュ後、再度アプリを起動したときにクラッシュレポートが送信される。
→クラッシュレポートが送信されるかのテストを行う際は注意が必要。

To send report data to Crashlytics, the application must be restarted. Crashlytics automatically sends any crash reports to Firebase the next time the application is launched.

意図的にクラッシュさせる

さーて、準備も終わったしクラッシュさせてみるか〜、と思ったが、クラッシュってどうやって発生させるの?という疑問が。。。
心配無用!!ちゃんとクラッシュを発生させるメソッドが用意されています・・・!
以下のコードをクラッシュさせたいところに差し込めばOK。
FirebaseCrashlytics.instance.crash()

今回は、完全にダミーボタンと化しているTwitterサインインボタンとGithubボタンを押下したときにクラッシュするようにしてみた。

login_form.dart
      SignInButton(
	buttonType: ButtonType.twitter,
	onPressed: () {
	  print('click');
	  FirebaseCrashlytics.instance.crash();
	},
      ),
      SignInButton(
	buttonType: ButtonType.github,
	onPressed: () {
	  print('click');
	  FirebaseCrashlytics.instance.crash();
	},
      ),

いずれかのボタンを押下すると、アプリが落ちる。
アプリを再起動することでクラッシュレポートが送信されるので再起動を忘れずに。

Firebaseコンソールで確認してみる

コンソールを確認してみると、以下のエラーが発生。

dSYMが不足していると表示されてクラッシュレポートが反映されない

クラッシュ発生後、Firebaseのコンソールを確認すると、「dSYMが不足しているよ」と言われ、クラッシュレポートが反映されないといったことがあった。
dSYM自体はApp Store Connect経由でダウンロードできるらしいが、以下のようにXCodeの設定を変えればとりあえずざっくりとしたクラッシュレポートは見られるようになるっぽい。

XcodeのBuild Settings > Build Options > Debug Information Formatを DWARF with dSYM File にします。
引用元: [Firebase][iOS] Firebase CrashlyticsにdSYMをアップロードしたい! | Developers.IO

上記対処を実行後、再度クラッシュを発生→再起動してコンソールを確認すると、以下のような赤枠で囲ったクラッシュレポートが登録されているはず。(図はいろいろ試した後に撮ったものなので、他のレポートが含まれている)

ログを仕込む

参考記事: Firebase Crashlytics のログを活用する - Qiita

上記でクラッシュレポートを取得できることが確認できたが、ちょっと見てもどこでクラッシュが起きているかがわかりにくい。
例えば、以下のようにクラッシュしそうなところにそれぞれ異なるログを仕込んでおけば、どこでクラッシュが起こったのかがわかり易くなる。

login_form.dart
    SignInButton(
	buttonType: ButtonType.twitter,
	onPressed: () {
	  print('click');
	  FirebaseCrashlytics.instance.log("Twitter sign in");
	  FirebaseCrashlytics.instance.crash();
	},
      ),
      SignInButton(
	buttonType: ButtonType.github,
	onPressed: () {
	  print('click');
	  FirebaseCrashlytics.instance.log("Github sign in");
	  FirebaseCrashlytics.instance.crash();
	},
      ),


このように、Githubのサインインボタンでクラッシュがおきたとわかる。

例外をキャッチする

これまでの設定で、アプリがクラッシュしてしまったときのレポートは送信されるようになったが、Crashlyticsでは プログラム内でキャッチできていないエラーが起きた際もレポートとして送信することができる。

FlutterError.onErrorをFirebaseCrashlytics.instance.recordFlutterErrorでオーバーライドすることにより、Flutterフレームワーク内でスローされたすべてのエラーを自動的にキャッチします。
By overriding FlutterError.onError with FirebaseCrashlytics.instance.recordFlutterError, it will automatically catch all errors that are thrown within the Flutter framework.

以下のように、runAppする前にFlutterError.onErrorFirebaseCrashlytics.instance.recordFlutterErrorでオーバーライドするだけ。

main.dart
void main() {
  // Pass all uncaught errors from the framework to Crashlytics.
  FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterError;

  runApp(MyApp());
}

例えば以下のような場合、hoge()内で発生したFormatExceptionのレポートはCrashlyticsに送信されるが、hogeFuture()で発生したFormatExceptionは送信されないことがわかった。

login_form.dart
    ~~
              SignInButton(
                buttonType: ButtonType.github,
                onPressed: hoge,
              ),
              SignInButton(
                buttonType: ButtonType.yahoo,
                onPressed: hogeFuture,
              )
            ],
          ),
        ),
      ),
    );
  }
  // jsonTextがjson文字列ではない場合、FormatExceptionが投げられる
  void parseToJson(String jsonText) {
    json.decode(jsonText);
  }

  // こっちで発生するFormatExceptionはCrashlyticsに送信される
  void hoge() {
    FirebaseCrashlytics.instance.log("Not future");
    parseToJson('aaaa');
  }

  // こっちは送信されない
  Future<void> hogeFuture() async {
    FirebaseCrashlytics.instance.log("Future");
    parseToJson('bbbb');
  }
}

非同期関数内で発生するエラーを捕捉したい場合は、以下のようにrunZonedGuardedで囲めばOK。
https://firebase.flutter.dev/docs/crashlytics/usage#zoned-errors

login_form.dart
  Future<void> hogeFuture() async {
    FirebaseCrashlytics.instance.log("Future");
    await runZonedGuarded<Future<void>>(() async {
      parseToJson('bbbb');
    }, FirebaseCrashlytics.instance.recordError);
  }

コードの修正

最後に、FirebaseCrashlytics.instance.crash()をそのまま残しておくのは気持ち悪いので、クラッシュが起こりそうなところにログを仕込むように修正する。
今回はログイン、ログアウト処理が呼ばれた際にログが出力されるように修正した。

また、 Google以外のサインインボタンが押されたときにUnimplementedErrorを発生させ、Crashlyticsにレポーティングするように修正した。

auth_model.dart
class AuthModel extends ChangeNotifier {
  ~~

  Future<bool> login(ButtonType type) async {
    // ログを追加
    FirebaseCrashlytics.instance.log("Login: ${type.toString()}");

    // エラーを追加
    if (type != ButtonType.google) {
      throw UnimplementedError(
        'Unimplemented login button was tapped.: ${type.toString()}',
      );
    }

    ~~
  }

  Future<void> logout() async {
    // ログを追加
    FirebaseCrashlytics.instance.log("Logout");
    ~~
  }

  Future<UserCredential> _signInWithGoogle() async {
    // ログを追加
    FirebaseCrashlytics.instance.log("Login with google");
    ~~
  }

  Future<void> _signOutWithGoogle() async {
    // ログを追加
    FirebaseCrashlytics.instance.log("Logout with google");
    await GoogleSignIn().signOut();
  }
}

login_form.dart
  Future<bool> _login(BuildContext context, ButtonType type) async {
    return await runZonedGuarded<Future<bool>>(() async {
      bool loggedIn = false;
      EasyLoading.show(status: 'loading...');
      if (await context.read<AuthModel>().login(type)) {
        loggedIn = true;
      }
      EasyLoading.dismiss();
      return loggedIn;
    }, FirebaseCrashlytics.instance.recordError);
  }

Discussion