クラッシュを検出して診断する

Android アプリは、未処理の例外またはシグナルが原因で予期しない終了が発生するとクラッシュします。Java または Kotlin で記述されたアプリは、Throwable クラスによって表される未処理の例外をスローした場合にクラッシュします。マシンコードまたは C++ で記述されたアプリは、実行中に SIGSEGV などの未処理のシグナルが発生した場合にクラッシュします。

アプリがクラッシュすると、Android はアプリのプロセスを終了し、アプリが停止したことをユーザーに知らせる図 1 のようなダイアログを表示します。

Android デバイスでのアプリのクラッシュ

図 1. Android デバイスでのアプリのクラッシュ

アプリは、フォアグラウンドで実行されていない場合でも、クラッシュすることがあります。バックグラウンドで実行されるブロードキャスト レシーバやコンテンツ プロバイダを含め、あらゆるアプリ コンポーネントがアプリのクラッシュを引き起こす可能性があります。操作中ではないアプリがクラッシュすると、たいていのユーザーは困惑します。

アプリでクラッシュが発生する場合、このページのガイダンスが問題の診断と解決に有用です。

問題を検出する

ユーザーが使用中のアプリでクラッシュが発生していることをデベロッパーが常に把握できるとは限りません。アプリをすでに公開している場合は、Android Vitals を使用してアプリのクラッシュ発生率を確認できます。

Android Vitals

Android Vitals は、アプリのクラッシュ発生率をモニターして改善するために役立ちます。Android Vitals は、以下のクラッシュ発生率を測定します。

  • クラッシュ発生率: いずれかのタイプのクラッシュが発生した、1 日のアクティブ ユーザーの割合。
  • ユーザーが認識したクラッシュ発生率: アプリのアクティブな使用中のクラッシュ(ユーザーが認識したクラッシュ)が 1 回以上発生した、1 日のアクティブ ユーザーの割合。アプリは、アクティビティを表示しているかフォアグラウンド サービスを実行しているときに、アクティブに使用されているとみなされます。

  • 複数回クラッシュ発生率: クラッシュが 2 回以上発生した、1 日のアクティブ ユーザーの割合。

1 日のアクティブ ユーザーとは、1 日に 1 台のデバイスでアプリを使用するユニーク ユーザーを表し、複数のセッションにまたがる場合があります。1 人のユーザーが 1 日に複数のデバイスでアプリを使用する場合は、デバイスごとにその日のアクティブ ユーザーとしてカウントされます。複数のユーザーが 1 日に同じデバイスを使用する場合は、1 人のアクティブ ユーザーとしてカウントされます。

ユーザーが認識したクラッシュ発生率は「主な指標」のひとつであり、Google Play でのアプリの見つけやすさに影響します。この指標でカウントされるクラッシュは、ユーザーがアプリを使用中に発生するものであり、使用中断の主な原因となるため、これは重要な指標になります。

Google Play では、この指標について 2 つの不正な動作のしきい値を定義しています。

  • 全体的な不正な動作のしきい値: すべてのデバイスモデルで、1 日のアクティブ ユーザーの 1.09% 以上にユーザーが認識したクラッシュが発生している。
  • デバイスごとの不正な動作のしきい値: 1 つのデバイスモデルで、1 日のアクティブ ユーザーの 8% 以上にユーザーが認識したクラッシュが発生している。

アプリが全体的な不正な動作のしきい値を超えると、すべてのデバイスでアプリの見つけやすさが低下する可能性があります。一部のデバイスでアプリがデバイスごとの不正な動作のしきい値を超えると、それらのデバイスでアプリの見つけやすさが低下し、ストアの掲載情報に警告が表示される可能性があります。

Android Vitals を使用すると、アプリが過度にクラッシュするときに、Google Play Console を介してアラートを受け取ることができます。

Google Play が Android Vitals のデータを収集する方法については、Google Play Console のドキュメントをご覧ください。

クラッシュを診断する

アプリがクラッシュしていることを把握したら、クラッシュを診断します。クラッシュの解決は場合によっては困難です。しかし、クラッシュの根本原因を特定できれば、ほとんどの場合は解決策を見つけられます。

アプリのクラッシュが発生する状況はさまざまです。null 値または空の文字列の検出のように原因が明らかな場合もありますが、無効な引数が API に渡された、またはマルチスレッド化されたインタラクションが複雑すぎるなど、わかりにくい原因による場合もあります。

Android でクラッシュが発生すると、スタック トレースが生成されます。スタック トレースとは、クラッシュの時点までにプログラムで行われた関数呼び出しを順番にネストしたスナップショットです。クラッシュのスタック トレースは Android Vitals で確認できます。

スタック トレースの読み方

クラッシュを解決するには、まずクラッシュの発生場所を特定します。Google Play Console または logcat ツールの出力を使用している場合は、レポート詳細で参照可能なスタック トレースを使用できます。参照可能なスタック トレースがない場合は、ローカルでクラッシュを再現する必要があります。そのためには、アプリを手動でテストするか、クラッシュが発生しているユーザーに協力を依頼して、logcat を使用した状態でクラッシュを再現します。

次のトレースは、Java プログラミング言語を使用して記述されたアプリのクラッシュの例を示しています。

--------- beginning of crash
AndroidRuntime: FATAL EXCEPTION: main
Process: com.android.developer.crashsample, PID: 3686
java.lang.NullPointerException: crash sample
at com.android.developer.crashsample.MainActivity$1.onClick(MainActivity.java:27)
at android.view.View.performClick(View.java:6134)
at android.view.View$PerformClick.run(View.java:23965)
at android.os.Handler.handleCallback(Handler.java:751)
at android.os.Handler.dispatchMessage(Handler.java:95)
at android.os.Looper.loop(Looper.java:156)
at android.app.ActivityThread.main(ActivityThread.java:6440)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.Zygote$MethodAndArgsCaller.run(Zygote.java:240)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:746)
--------- beginning of system

スタック トレースには、クラッシュのデバッグに不可欠な、次の 2 つの情報が表示されます。

  • スローされた例外のタイプ。
  • 例外がスローされたコードのセクション。

一般的に、スローされた例外のタイプは、何が問題かを知るための強力なヒントになります。スローされたのが IOException か、OutOfMemoryError か、またはそれ以外かを確認して、該当する例外クラスに関するドキュメントを参照します。

スタック トレースの 2 行目には、例外がスローされたソースファイルのクラス、メソッド、ファイル、行番号が示されます。呼び出された関数ごとに、直前の呼び出しサイト(スタック フレームと呼びます)が別の行に表示されます。スタックをたどりながらコードを調べることで、誤った値を渡している場所を見つけられることがあります。コードがスタック トレースに表示されていなければ、どこかで無効なパラメータを非同期処理に渡している可能性があります。たいていの場合、スタック トレースの行をひとつひとつ調べて、使用した API クラスを探し出し、渡したパラメータが正しいか、適切な場所から API を呼び出したという点について確認すれば、何が起きたかを把握できます。

C / C++ コードを持つアプリのスタック トレースは、ほぼ同じように動作します。

*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
Build fingerprint: 'google/foo/bar:10/123.456/78910:user/release-keys'
ABI: 'arm64'
Timestamp: 2020-02-16 11:16:31+0100
pid: 8288, tid: 8288, name: com.example.testapp  >>> com.example.testapp <<<
uid: 1010332
signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0
Cause: null pointer dereference
    x0  0000007da81396c0  x1  0000007fc91522d4  x2  0000000000000001  x3  000000000000206e
    x4  0000007da8087000  x5  0000007fc9152310  x6  0000007d209c6c68  x7  0000007da8087000
    x8  0000000000000000  x9  0000007cba01b660  x10 0000000000430000  x11 0000007d80000000
    x12 0000000000000060  x13 0000000023fafc10  x14 0000000000000006  x15 ffffffffffffffff
    x16 0000007cba01b618  x17 0000007da44c88c0  x18 0000007da943c000  x19 0000007da8087000
    x20 0000000000000000  x21 0000007da8087000  x22 0000007fc9152540  x23 0000007d17982d6b
    x24 0000000000000004  x25 0000007da823c020  x26 0000007da80870b0  x27 0000000000000001
    x28 0000007fc91522d0  x29 0000007fc91522a0
    sp  0000007fc9152290  lr  0000007d22d4e354  pc  0000007cba01b640

backtrace:
  #00  pc 0000000000042f89  /data/app/com.example.testapp/lib/arm64/libexample.so (com::example::Crasher::crash() const)
  #01  pc 0000000000000640  /data/app/com.example.testapp/lib/arm64/libexample.so (com::example::runCrashThread())
  #02  pc 0000000000065a3b  /system/lib/libc.so (__pthread_start(void*))
  #03  pc 000000000001e4fd  /system/lib/libc.so (__start_thread)

ネイティブ スタック トレースにクラスと関数レベルの情報が表示されない場合は、ネイティブ デバッグ シンボル ファイルを生成して Google Play Console にアップロードする必要があります。詳しくは、クラッシュのスタック トレースの難読化解除をご覧ください。ネイティブ コードでのクラッシュに関する一般的な情報については、ネイティブ コードでのクラッシュの診断をご覧ください。

クラッシュを再現するためのヒント

エミュレータを起動したりデバイスをパソコンに接続したりするだけでは、問題を完全には再現できないかもしれません。多くの場合、開発環境は、帯域幅、メモリ、ストレージなどのリソースに余裕があります。例外のタイプを確認することで、不足しているリソースを特定したり、Android のバージョン、デバイスタイプ、アプリのバージョン間の相関を発見したりすることができます。

メモリエラー

OutOfMemoryError が発生する場合は、テスト用にメモリ容量が小さいエミュレータを作成します。図 2 は、デバイスのメモリ容量を制御できる AVD Manager 設定を示しています。

AVD Manager のメモリ設定

図 2. AVD Manager のメモリ設定

ネットワーク例外

ユーザーはモバイル ネットワークや Wi-Fi ネットワークの通信可能範囲を頻繁に出入りします。したがって、一般的にアプリのネットワーク例外は、エラーとしてではなく、予期せず発生する正常な動作状態として扱う必要があります。

UnknownHostException などのネットワーク例外を再現する必要がある場合は、アプリがネットワークを使用しようとしたときに機内モードをオンにしてみてください。

また、ネットワーク速度のエミュレーションまたはネットワーク遅延(あるいはその両方)をエミュレータで選択することにより、ネットワーク品質を低下させる方法もあります。AVD Manager の [Speed] と [Latency] の設定を使用するか、-netdelay フラグと -netspeed フラグを指定してエミュレータを起動します(次のコマンドラインの例を参照)。

emulator -avd [your-avd-image] -netdelay 20000 -netspeed gsm

この例では、すべてのネットワーク リクエストに対して、20 秒の遅延を設定し、アップロードとダウンロードの速度を 14.4 Kbps に設定しています。エミュレータのコマンドライン オプションの詳細については、コマンドラインからのエミュレータの起動をご覧ください。

logcat で確認する

クラッシュを再現する準備が整ったら、logcat などのツールを使用して、詳細な情報を取得します。

logcat 出力には、デベロッパーが出力したログメッセージが、システムによって出力された他のメッセージとともに表示されます。アプリの実行中にログを出力すると CPU の負荷が増えてバッテリーが消耗するため、追加した Log ステートメントをすべてオフにすることを忘れないでください。

null ポインタ例外によるクラッシュを防ぐ

null ポインタ例外(ランタイム エラー タイプ NullPointerException で識別される)は、通常、null であるオブジェクトにアクセスしようとしたときに、そのメソッドを呼び出すか、そのメンバーにアクセスすることで発生します。null ポインタ例外は、Google Play でアプリがクラッシュする最大の原因です。null の目的は、オブジェクトが存在しないこと、たとえば、まだオブジェクトが作成されていないか、割り当てられていないことを示すことです。null ポインタ例外を回避するには、操作対象のオブジェクト参照が null でないことを確認してから、オブジェクトでメソッドを呼び出したり、そのメンバーへのアクセスを試みる必要があります。オブジェクト参照が null である場合は、このケースを適切に処理します。たとえば、オブジェクト参照に対するオペレーションを実行する前にメソッドを終了し、デバッグログに情報を書き込みます。

呼び出される各メソッドのパラメータごとに null チェックは必要でないため、IDE またはオブジェクトのタイプに基づいて null 値許容を示すことができます。

Java プログラミング言語

以下のセクションは、Java プログラミング言語に適用されます。

コンパイル時の警告

IDE からコンパイル時の警告を受け取るには、メソッドのパラメータにアノテーションを付けて @Nullable@NonNull で値を返します。この警告は、null 可能オブジェクトが必要であることを示しています。

null ポインタ例外の警告

これらの null チェックは、null であることがわかっているオブジェクトを対象としています。@NonNull オブジェクトに対する例外は、コード内で対処が必要なエラーを示します。

コンパイル時のエラー

null 可能性は意味のある型であるため、使用する型に埋め込むことで、null のコンパイル時チェックを実行できます。オブジェクトが null になることがわかっており、null 可能性を処理する必要がある場合、Optional などのオブジェクトにラップできます。null 可能性は、常に型である必要があります。

Kotlin

Kotlin では、null 可能性が型システムに含まれています。たとえば、変数を null 可能や null 不可として宣言しておく必要があります。null 可能性型は、以下のように ? で表示されます。

// non-null
var s: String = "Hello"

// null
var s: String? = "Hello"

null 不可変数に null 値を指定することはできません。null 可能変数は、非 null として使用する前に null 可能性をチェックする必要があります。

null を明示的にチェックしない場合は、?. safe call 演算子を使用できます。

val length: Int? = string?.length  // length is a nullable int
                                   // if string is null, then length is null

null 可能オブジェクトについては、null ケースに対処することをおすすめします。そうしないと、アプリに予期しない状態が生じる可能性があります。こうしたエラーは、NullPointerException でアプリがクラッシュしてはじめて見つかります。

null をチェックする方法を次にいくつか示します。

  • if チェック

    val length = if(string != null) string.length else 0
    

    スマート キャストと null チェックにより、Kotlin コンパイラが文字列値が null でないことを認識するため、safe call 演算子を指定しなくとも、参照を直接使用できます。

  • ?: Elvis 演算子

    この演算子を使用すると、「オブジェクトが null でない場合はオブジェクトを返し、それ以外の場合は別の値を返す」ことができます。

    val length = string?.length ?: 0
    

Kotlin では、引き続き NullPointerException がスローされます。この場合の最も一般的な状況を以下に紹介します。

  • NullPointerException を明示的にスローする場合。
  • null アサーション !! 演算子を使用している場合。この演算子は任意の値を非 null 型に変換し、値が null の場合は NullPointerException をスローします。
  • プラットフォーム型の null 参照にアクセスする場合。

プラットフォーム型

プラットフォーム型は、Java のオブジェクト宣言です。これらの型は特別な方法で処理します。null チェックは強制適用されないため、null でないことが Java の場合と同様に保証されます。プラットフォーム型参照にアクセスすると、Kotlin はコンパイル時のエラーを生成しませんが、これらの参照によってランタイム エラーが引き起こされる可能性があります。Kotlin ドキュメントにある次の例をご覧ください。

val list = ArrayList<String>() // non-null (constructor result) list.add("Item")
val size = list.size // non-null (primitive int) val item = list[0] // platform
type inferred (ordinary Java object) item.substring(1) // allowed, may throw an
                                                       // exception if item == null

Kotlin は、プラットフォーム値が Kotlin 変数に指定された場合に型の推論を使用します。または型を想定して定義することもできます。Java の参照の正しい null 可能性ステータスを確認するには、Java コードで null 可能性アノテーション(@Nullable など)を使用することをおすすめします。Kotlin コンパイラはこれらの参照を、プラットフォーム型ではなく、実際の null 可能型または null 不可型として表します。

Java Jetpack API には必要に応じて @Nullable または @NonNull のアノテーションが付けられており、同様のアプローチが Android 11 SDK で行われています。この SDK の型は Kotlin で使用され、null 可能型または null 不可型として表されます。

Kotlin の型システムにより、アプリでは NullPointerException クラッシュが大幅に減少することが確認されています。たとえば、Google Home アプリでは、新機能開発を Kotlin へ移行した際に、null ポインタ例外に起因するクラッシュが 30% 減少しました。