クラッシュの検出と診断

Android アプリは、未処理の例外またはシグナルが原因で予期しない終了が発生するとクラッシュします。Java で作成されたアプリは、Throwable クラスによって表される未処理の例外をスローするとクラッシュします。ネイティブ コード言語で作成されたアプリは、未処理のシグナル(SIGSEGV など)が実行時に存在するとクラッシュします。

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

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

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

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

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

問題を検出する

デベロッパーは、ユーザーがアプリのクラッシュを頻繁に経験していることを常に把握できるとは限りません。アプリをすでに公開されている場合は、Android Vitals を活用することで問題を認識できます。

Android Vitals

Android Vitals は、アプリがきわめて頻繁にクラッシュする場合に Play Console を介してデベロッパーにアラートを送信することで、アプリのパフォーマンスの改善をサポートします。Android Vitals は、アプリが次の状態のときに、クラッシュが多すぎると判断します。

  • 1 日のセッションの 1.09% 以上で、クラッシュが 1 回以上発生する。
  • 1 日のセッションの 0.18% 以上で、クラッシュが 2 回以上発生する。

1 日のセッションとは、アプリが使用された 1 日を意味します。 Google Play が Android Vitals のデータを収集する方法については、Play Console のドキュメントをご覧ください。

アプリが頻繁にクラッシュしていることがわかったら、次のステップとして、クラッシュを診断します。

クラッシュを診断する

クラッシュの解決は時として困難です。しかし、クラッシュの根本原因を特定できれば、ほとんどの場合は解決策を見つけられます。

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

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

スタック トレースを確認する

クラッシュを解決するには、まずクラッシュの発生場所を特定します。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% 減少しました。