[iOS]Firebase Crashlyticsを使って致命的でないとして回避したイベントを記録する

Firebase Crashlyticsのカスタムクラッシュレポートについての記事です。
2020.08.25

アプリケーションのエラー設計で、アプリ側のクラッシュは極力避ける方針でコードを実装する場合があります。それでも開発中はクラッシュさせたい場合にassertなどを使うことがあると思います。本番環境でユーザーがアプリを触る時にエラーを踏んで操作は継続するもののエラーを踏んで回避したいことを知りたい場合にCrashlyticsのnon-fatalが便利です。

Swiftのエラー分類

SwiftにはErrorHandlingRationale.rstというエラーハンドリングに関するドキュメントがあります。そこでは、Simple domain errorsRecoverbable errorsUniversal errorsLogic failuresという四つのエラー分類があります。

このタイプのエラーはこれらのどれに分類される、といったような分類の仕方ではなく、どのようにエラーを扱いたいかによってエラーを分類します。使いわけの例自体は示されていますが、実際の運用によってどれに分類するかはエラー設計次第だと思います。

本筋ではないためこの記事では割愛しますがこのエラー分類について、日本語で説明された記事は以下があります。

これらの内Simple domain errorsとLogic failuresが起きた時に、開発中はクラッシュさせて迅速に解決させたいが、本番環境では落とさずに済ませたい。しかしそれらのエラーが生じてしまったことは検知したい場合がありました。

precondition と assert

fatalErrorで落とすと、これまでに書いた要件を満たせないです。Swiftはprecodtionとassertというデバッグ用のツールを提供しています。

満たされると予想される条件に対して自分のコードをチェックする必要があるときに使用できます。満たされない場合は例外がスローされます。

C言語由来(ソースコードにC-style assert with optional messageと記載がある`)の assert() はリリース環境では評価対象にならないので開発のためだけに積極的に使用することができます。

Swiftコンパイラの最適化レベルでいうと-Ononeの時のみ評価が行われます。

preconditionは-Ononeに加えて-Oの時も評価されます。

-Ononeはデバッグビルド時のコンパイラの最適化レベルでリリースビルドの時の最適化レベルは-Oです。

assert(条件式)preconditionFailure(条件式)のように使えますが、評価されると条件式の真偽が偽になったことを表わすのにそれぞれassertionFailureとpreconditionFailureが使えます

最適化レベルやそれぞれの概要についてはソースコードに加えて以下のドキュメントが参考になります。

Crashlyticsの導入

iOSアプリにFirebaseを追加するまで

ガイドに従いFirebaseの初期設定を行います。Info.plistの設定やBunlde Identifierの指定にミスがなければ問題なく動きます。ステップバイステップで動作確認まで行えるのでここで躓くことは少いと思います。

PodfileにFirebase/Analyticsを追加します。

  # add the Firebase pod for Google Analytics
  pod 'Firebase/Analytics'
  # add pods for any other desired Firebase products
  # https://firebase.google.com/docs/ios/setup#available-pods

保存後pod installを叩いてライブラリを導入します。問題なく成功したらAppDelegate.swiftでFirebaseをimportしてapplication(_:didFinishLaunchingWithOptions:)内でFirebaseAppのstaticメソッド configure()を呼び出して動作確認を行います。

導入が成功していることを確認できました。

Crashlyticsの有効化

Firebase Consoleの赤枠からCrashlyticsを有効化した後、PodfileにFirebase/Crashlyticsを追加してpod installします。

pod 'Firebase/Crashlytics'

問題なく終わったらアプリを再コンパイルしておきます。

Crashlyticsの設定をBuild Phasesに追加します。新しくRun scriptを追加して"${PODS_ROOT}/FirebaseCrashlytics/run"をペーストします。

Input filesに以下の二つを指定します。例なのでこのInput filesは環境変数などをチェックして任意のpathを指定してください。今回はドキュメントに記載のままで問題なかったです。

${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${TARGET_NAME}
$(SRCROOT)/$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)

non-fatal exceptionのレポートを送信

クラッシュレポートのカスタマイズについて扱ったページにnon-fatal exceptionを含んだドキュメントがあります。Crashlyticsの日本語のドキュメントはFabricの頃の記述が残っていて言語設定でEnglishに変更して読みました。

リンク先ではFirebase Crashlyticsのクラッシュレポートをカスタマイズする方法が紹介されています。デフォルトの設定でも自動でクラッシュレポートが集められていくのですが、自動のクラッシュレポート収集をユーザー判断でオフにすることも可能ですし、オプトインのレポートを有効化することもできます。

Firebase Crashlyticsが提供しているロギングの仕組みは以下です。

  • Custom keys
  • Custom logs
  • User identifiers
  • Caught exceptions

non-fatalはCaught exceptionsとして説明されています。

non-fatal exceptionはrecord(error:)を使用して NSErrorのインスタンスを渡すことで、致命的ではない例外を記録することができます。 recordError は、[NSThread callStackReturnAddresses] を呼び出すことでスレッドのコールスタックをキャプチャします。

let userInfo = [
    NSLocalizedDescriptionKey: NSLocalizedString("The request failed.", comment: ""),
    NSLocalizedFailureReasonErrorKey: NSLocalizedString("The response returned a 404.", comment: ""),
    NSLocalizedRecoverySuggestionErrorKey: NSLocalizedString("Does this page exist?", comment: ""),
    "ProductID": "123456",
    "View": "MainView"
]

let error = NSError(domain: "NSCocoaErrorDomain", code: 1001, userInfo: userInfo)
Crashlytics.crashlytics().record(error: error)

これとActive Compilation Coditionsなどでデバッグやリリースによって処理を振り分けられます。

#if DEBUG
assertionFailure("The response returned a 404.")
#else
let userInfo = [
   NSLocalizedDescriptionKey: NSLocalizedString("The request failed.", comment: ""),
    NSLocalizedFailureReasonErrorKey: NSLocalizedString("The response returned a 404.", comment: ""),
    NSLocalizedRecoverySuggestionErrorKey: NSLocalizedString("Does this page exist?", comment: ""),
    "ProductID": "123456",
    "View": "MainView"
]
let error = NSError(domain: "NSCocoaErrorDomain", code: 1001, userInfo: userInfo)

Crashlytics.crashlytics().record(error: error)
#endif

Report non-fatal exceptionsの注意事項

non-fatal exceptionのレポートはrecord(error:)に渡したNSErrorのインスタンスの内domainとcodeを基準にグループ化されます。

そしてこのdomainとcodeにはタイムスタンプのようなユニークな値を使用しないよう警告されています。ユニークな値の格納場所はkey-valueで値が設定できるuserInfoに渡す値で使用しましょう。

加えてパフォーマンスの問題も注意が促されています。コールスタックのキャプチャを行うプロセスでCPUとI/Oのコストがかかります。

まとめ

実装の効率を落とさずに、仕様を満たした上で検知しやすいエラー設計をしたくてドキュメントや公開されているプロジェクトのコード、コミュニティ内のやりとりを見ていますが初めて知ることも多いので勉強になります。