ツールとパフォーマンス

Firebase Crashlytics を使用したメモリのトラブルシューティング

スピーカー

  • Polina Govorkova

    シニア ソフトウェア デベロッパー

概要

講演について

Firebase Crashlytics を使用して、アプリのメモリ使用量を最小限に抑え、セグメンテーション違反を回避します。このセッションでは、Firebase コンソールと Android Studio で利用できる、メモリのトラブルシューティング ツールを包括的に紹介します。まず、ANR の分析情報と GWP-ASan レポートを活用して、見つけにくいメモリバグを診断する方法について説明します。次に、メモリ不足(OOM)イベントとメモリリミッター キル(MLK)イベントに対応する最新のツールについて詳しく説明します。このツールは、Android 17 で導入された新しいメモリ制限を把握し、適応できるように設計されています。

概要

  • アプリケーション応答なしエラーは、アクティブな CPU 負荷ではなく、バックグラウンドのボトルネックやメモリ不足が原因であることがよくあります。Firebase Crashlytics は ApplicationExitInfo にクエリを実行して、マルチスレッド トレースを表示し、根本的なブロッキング スレッドを特定します。
  • メモリ不足によるクラッシュは、アプリがランタイム Java ヒープの上限を超えた場合に発生します。Crashlytics は Android 17 の ProfilingManager と統合されており、難読化が解除されたヒープ スナップショットを収集します。このスナップショットは、Perfetto または Android CLI で検査できます。
  • Android 17 のメモリリミッターは、ネイティブ割り当てを含め、合計 RAM を過剰に消費する個々のプロセスを終了します。Crashlytics は、これらの終了をヒープダンプとともにダッシュボードにグループ化し、C++ メモリリークをマネージド ヒープ割り当てから分離できるようにします。
  • ネイティブ メモリ破損は、アプリコードではなくシステム スレッドで頻繁に発生します。Crashlytics は GWP-ASan と Arm MTE をサポートしており、GWP-ASan レポートは、use-after-free などのバグの正確な割り当て / 割り当て解除サイトを特定します。

リソース

Gemini Notebook

トピック

  • firebase-crashlytics
  • android-memory
  • anr
  • メモリ不足
  • profiling-manager
  • perfetto
  • gwp-asan
  • arm-mte

文字起こし

はじめに

  • Polina Govorkova

    皆さん、こんにちは。

  • Firebase Crashlytics を使用したメモリのトラブルシューティングへようこそ。

  • ちょっと待って。すごい役職ですね。

  • 少し混ぜてみましょう。

  • Firebase Crashlytics でアプリを効率的に管理。

  • それはいいですね。

  • 私は Polina と申します。Firebase Crashlytics チームのシニア ソフトウェア デベロッパーで、本日のプレゼンターを務めます。

  • 開発サイクルのある時点で、すべてのバグを解決したと感じることがあります。

  • JSON 解析の入力ミスや、null ポインタ チェックの忘れはすべてなくなりました。

  • 「これで終わりなのか?」と疑問に思うかもしれません。

  • 残念ながら、そうではありません。

  • なぜなら、残るのは実際には難しい問題だからです。

  • そして、皆さんが直面する最も困難な問題の 2 つのカテゴリは、応答性とメモリであると私は主張します。

  • 幸いなことに、Google がサポートします。

  • 本日は、Android と Crashlytics の既存のツールと新しいツールについて説明します。これらのツールは、アプリケーション応答なしエラー、メモリ不足例外、メモリリーク、ネイティブ メモリ破損など、応答性の問題の根本原因を特定するのに役立ちます。

  • それでは詳しく見ていきましょう。

  • 応答性とメモリの問題がこれほどまでに悪名高いのはなぜでしょうか。

  • 典型的なバグを例に挙げましょう。文字列を宣言して null に設定し、ある時点でその長さを読み取ろうとします。

  • お客様のチームが得られるものは?

  • おなじみの NullPointerException です。

  • しかし、実際には、例外スタック トレースがアプリをクラッシュさせたコード行を正確に示しているため、簡単に解決できます。

  • 左側のスタック トレースを見てください。

    java.lang.NullPointerException
      at MainActivity.onCreate(MainActivity.kt:42)
      at android.app.Activity.performCreate
      at android.os.Handler.dispatchMessage
      at android.os.Looper.loopOnce
      at android.app.ActivityThread.main
  • MainActivity.kt の 42 行目に問題があることがすぐにわかります。

  • このアプローチは、ほとんどの軽微なバグに当てはまります。

  • 現在、応答性やメモリの問題が発生した場合(右側の「アプリケーション応答なし」など)、スタック トレースが真の原因を指し示すことはほとんどありません。

    "main" prio=5 tid=1 Blocked
      | group="main" sCount=1 dsCount=0 flags=1
      | state=S schedstat=( 1450239 48192 145 )
      at DataManager.getData(DataManager.kt:25)
      - waiting to lock <0x0a1b> held by thread 12
      at MainActivity.onResume(MainActivity.kt:50)
      at android.app.Activity.performResume
      at android.os.Handler.dispatchMessage
      at android.os.Looper.loopOnce
      at android.app.ActivityThread.main
  • 最良のケースでも、表示されるのは影響のみです。つまり、アプリがフリーズしたときやメモリ不足になったときに、たまたま実行されていた無害なスレッドや割り当てが表示されるだけです。

  • しかし、実際の根本原因は、はるか前に、あるいはまったく別の場所で発生していた可能性があります。

  • 幸いなことに、この問題に関しては、Firebase Crashlytics が答えを教えてくれるので、暗中模索する必要はありません。

  • トラブルシューティングを始める前に、Crashlytics をよく知っている方は挙手してください。

  • それはよかった。

  • たくさんの手が挙がっていますね。

  • Firebase Crashlytics は、リアルタイムの Error Reporting ソリューションです。フィールド データ、コンテキストと AI を活用した分析情報、トラブルシューティングのヒント、新リリースに関する明確なシグナルを提供し、問題を迅速に修正できるようにします。

  • Crashlytics はデベロッパーに広く愛用されており、モバイルで最も採用されているクラッシュ レポート ソリューションとしての地位を確立しています。37 万以上のネイティブ アプリを支えており、その中には Android の上位 Android アプリの 57% が含まれています。

  • Crashlytics がバグの追跡にどのように役立つか、具体的な例を見てみましょう。

  • 本日は、ChromaClip のストーリーをご紹介します。

  • これは私が開発している動画カラーパレット抽出ツールです。開発中にレスポンシブ性とメモリのバグにどのように対処したかについてお話しします。

  • ChromaClip のコンセプトはシンプルです。ユーザーが動画(美しい夕日のクリップや、カラー グレーディングが優れたシーンなど)を選択すると、アプリがフレームを分析してカスタム カラーパレットを生成します。ユーザーは、このパレットをデザイン、デジタルアート、ムードボードなど、あらゆる用途に使用できます。

  • 私たちの物語は、1.0 リリースの直前から始まります。

  • リリース前に万全を期すため、複数のデバイスでローカルに最終テストを行っていました。動画を選択し、ボタンをタップしてカラーパレットを抽出したところ、運悪く、Android デベロッパーなら誰もが嫌うダイアログが表示されました。

  • ChromaClip が応答していません。

  • これは、アプリケーション応答なし(ANR)エラーです。

ANR のデバッグと分析

  • Polina Govorkova

    では、このような状況では、パニックになる以外に何ができるでしょうか。

  • テスト用のスマートフォンがノートパソコンに直接接続されているため、まず Android Studio を開いて Logcat を確認します。

  • メインスレッドのスタック トレースが表示されています。

    14:32:15.105  1205-1250/system_process E/ActivityManager: ANR in com.chromaclip.app (com.chromaclip.app/.MainActivity)
        PID: 14205
        Reason: Input dispatching timed out
        Dumped ANR trace to /data/anr/traces.txt
    "main" prio=5 tid=1 Native
      | group="main" sCount=1 dsCount=0 flags=1 obj=0x72a8c3d0 self=0x759fb92000
      | sysTid=14205 nice=-10 cgrp=default sched=0/0 handle=0x7623910548
      | state=S schedstat=( 1450239120 4819203 1453 ) utm=132 stm=13 core=4 HZ=100
      | stack=0x7fed2a0000-0x7fed2a2000 stackSize=8MB
      at sun.misc.Unsafe.park(Native method)
      - waiting on an unknown object
      at java.util.concurrent.locks.LockSupport.park(LockSupport.java:190)
      at kotlinx.coroutines.EventLoopImplBase.processNextEvent(EventLoop.common.kt:279)
      at kotlinx.coroutines.BlockingCoroutine.joinBlocking(Builders.kt:85)
      at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking(Builders.kt:59)
      at kotlinx.coroutines.BuildersKt.runBlocking(Unknown Source:1)
      at com.chromaclip.app.extractor.PaletteExtractor.extractColors(PaletteExtractor.kt:42)
  • このことから、実際には何がわかるでしょうか。

  • まず、一番上に、MainActivity で実行されている ANR であることが確認できます。

  • 他には?

  • 入力ディスパッチがタイムアウトしたという理由が表示されます。

  • これは、ユーザーが画面をタップしたことを意味します。

  • はい、リリースしました。

  • しかし、メインスレッドが非常にビジーであったため、Android の 5 秒のウォッチドッグ タイムアウト内にそのイベントを処理できませんでした。

  • また、システムがファイルを traces.txt にダンプしたことがわかります。そのすぐ下で、logcat がメインスレッドのスタック トレースを出力しています。

  • 状態が S になっていることに注目してください。

  • メインスレッドは CPU を積極的に消費していません。

  • 「S」は睡眠を表します。

  • 待機中にメインスレッドがスリープしているのはなぜでしょうか。

  • スタック フレームを詳しく見てみましょう。

  • トレースの中央に、メインスレッドが Unsafe.park 内で停止し、不明なオブジェクトを待機していることが示されています。

  • なぜ待機しているのかがわからないのはなぜですか?

  • 実際、一部の ANR(スレッドが従来の Java 同期ロックによってブロックされている場合など)では、メインスレッドのスタック トレースが、実際に待機しているものやロックを保持しているものを教えてくれます。

  • しかし、内部で高レベルの同時実行プリミティブを使用している場合、logcat が教えてくれるのは、不明なオブジェクトを待機しているということだけです。

  • そのため、どのオブジェクトを待機しているのか、どのバックグラウンド ワーカー スレッドを具体的に待機しているのか、なぜ 5 秒以上かかっているのかがわかりません。

  • ローカルでテストしているため、デバイスの traces.txt を取得して、数十個のスリープ状態のスレッドを手動でスクロールし、原因を突き止めるか、AI に質問してトークンを消費することもできます。

  • 場合によっては、実際に機能することもあります。実際、このケースでは機能します。

  • しかし、生の thread dump を手動で読みたくないだけでなく(正直なところ、それは 2019 年のことです)、このアプローチは本番環境ではまったく実行できないことがわかっています。

  • お客様のスマートフォンにある traces.txt ファイルに直接アクセスできないだけですよね?

  • Firebase Crashlytics と統合しておいてよかったですね。

  • 検索が不要になり、本番環境でも ANR の根本原因を突き止められるため。

  • 方法

  • そこで役立つのが、Android の ApplicationExitInfo API です。

  • Android 11 以降では、システムが ANR をフラグ付けしたり、アプリプロセスを終了したりするたびに、OS が豊富な終了メタデータを記録します。これには、クラッシュの正確な理由、メモリの状態、その時点でプロセスで実行されているすべてのスレッドの完全なシステム トレースが含まれます。

  • さらに、追加のプラグインをインストールしたり、コードを 1 行も記述したりする必要はありません。

  • Crashlytics SDK 18.3.5 以降を使用している場合は、すぐに利用できます。

  • そのため、アプリが再起動するとすぐに、Crashlytics SDK は自動的に ApplicationExitInfo をクエリし、デバイスのテレメトリーとともにマルチスレッドの完全なダンプをキャプチャして、Crashlytics バックエンドに送信します。

  • Crashlytics コンソールを開いて、何が表示されるかを確認しましょう。

  • これは、ANR の Firebase Crashlytics コンソールの問題の詳細ビューです。[スタック トレース] タブの下に DefaultDispatcher ワーカー 1 が表示されます。

    DefaultDispatcher-worker-1 (Root blocking)
    
    dalvik.system.VMRuntime.allocateNode (Native method)
    android.graphics.Bitmap.createBitmap (Bitmap.java:1124)
    android.graphics.Bitmap.createBitmap (Bitmap.java:1091)
    com.chromaclip.app.extractor.FrameDecoder.getUncompressedFrame (FrameDecoder.kt:112)
    com.chromaclip.app.extractor.PaletteExtractor$processVideo$2.invokeSuspend (PaletteExtractor.kt:65)
    kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith (ContinuationImpl.kt:33)
    kotlinx.coroutines.DispatchedTask.run (DispatchedTask.kt:106)
    java.util.concurrent.ThreadPoolExecutor.runWorker (ThreadPoolExecutor.java:1167)
    java.util.concurrent.ThreadPoolExecutor$Worker.run (ThreadPoolExecutor.java:641)
    java.lang.Thread.run (Thread.java:923)
  • 実際、これは Crashlytics によって一番上に表示されたスレッドであり、メインスレッドのボトルネックとなっています。Crashlytics は、このスレッドに「Root blocking」というバッジを付けています。

  • バッジのツールチップで説明されているように、このワーカー スレッドは、ANR をトリガーしたスレッドをブロックしているスレッドです。

  • なお、Crashlytics には、他の原因を示すスマートバッジもあります。

  • たとえば、スレッドが巨大なファイルの読み取りや非常に遅いデータベース クエリの実行でスタックし、ANR がトリガーされた場合、そのスレッドには「IO ルート ブロッキング」というタグが付けられます。

  • 2 つのスレッドが互いに待機している状態で停止した場合、Crashlytics はそれらをデッドロックとしてタグ付けします。

  • 次に、ルート ブロッキング スレッド内のスタック フレーム自体を見てみましょう。

  • PaletteExtractor processVideo が FrameDecoder getUncompressedFrame を呼び出していることが青色でハイライト表示されています。これは、1 つのフレームにメモリを割り当てようとして createBitmap 内で完全にスタックしています。

  • 別のロックを待機しているわけでも、ネットワーク呼び出しを行っているわけでもないことがわかっているので、主な原因はメモリであると考えられます。実際に、Crashlytics コンソールでデバイスの空きメモリを確認することで、すぐに確認できます。

  • Crashlytics でこの情報が表示される 2 つの場所を見てみましょう。

  • 先ほど確認したスタック トレース タブのすぐ横にある、特定のイベントのデータタブの下部と、すべてのイベントの平均を示す右上に表示されます。

  • どちらの場合も、100 KB にも満たない、ほとんど何もない状態です。

  • では、考えてみましょう。

  • ここで起こったのは、プロセスがメモリ不足になったため、Android ランタイムがガベージ コレクションを必死に実行し、次のビットマップに十分なスペースを見つけようとしたものの、見つからなかったということです。

  • ワーカー スレッドがフリーズし、UI が 5 秒以上停止します。

  • そこで、簡単なヒントをご紹介します。

  • ANR が発生したときに、このような空き RAM の不足が同時に発生している場合は、ANR がメモリリークまたは肥大化の問題であることを示す決定的な証拠となります。

  • ここで問題となるのは、このアプリが被害者なのか、それともメモリを独占している犯人なのかということです。

  • プロジェクトの問題リストに戻って、他に関連する問題がないか確認してみましょう。

メモリ不足例外の調査

  • Polina Govorkova

    ここを見てください。

  • 下部には問題のリストが表示され、8 件のイベントがある ANR のすぐ上に OutOfMemory 例外が表示されています。

  • 結局、私たちが犯人なのかもしれません。

  • これは大きな手がかりになります。

  • Android では、Java の OutOfMemoryError(OOM)は、スマートフォンの RAM が不足していることを意味するだけではないからです。

  • これは、アプリのプロセスが Android ランタイムの Java ヒープの上限を超えたことを意味します。

  • この時点で、ANR と OOM は実際には表裏一体であるという仮説を立てました。

  • テスト実行中のタイミングによっては、ChromaClip がガベージ コレクション内でスラッシングに陥り、ANR が発生するほど長時間にわたってスタックするか、ヒープを完全に使い果たして OOM が発生する可能性があります。

  • OutOfMemory 例外を調査して、原因を特定しましょう。

  • スタック トレースから始めます。

    java.lang.OutOfMemoryError: Failed to allocate a 256 byte allocation
     at android.graphics.Bitmap.createBitmap
     at android.view.LayoutInflater.inflate(LayoutInflater.java:532)
     at com.chromaclip.app.SettingsActivity.onCreate(SettingsActivity.kt:21)
  • ちょっと待って。

  • Bitmap.createBitmap さん、よくご存じですね。

  • 前に見たことがあるような気がする。

  • ただし、今回はどこから呼び出されているかを見てください。

  • 設定アクティビティを開くときに 256 バイトの割り当てに失敗しました。

  • 同じ罠にまた引っかかっている。

  • 小さな設定アイコンをインフレートできないためにアプリがクラッシュした場合、設定アクティビティが原因ではありません。

  • メモリが不足しているときにメモリを要求した不運な呼び出し元です。

  • そのため、真の原因を特定するには、ズームアウトして、クラッシュ発生時にメモリに存在していたすべてのものを確認する必要があります。

  • つまり、Java ヒープダンプが必要です。

  • デスクで OOM をトリガーする方法がわかっている場合は、Android Studio Memory Profiler を使用して Java ヒープダンプを手動で取得できます。

  • しかし、テストの実行中や実際の使用中に OOM が予期せず発生した場合、この方法は機能しません。

ヒープダンプのキャプチャと分析

  • Polina Govorkova

    これは、Firebase Crashlytics のもう一つの優れた機能です。

  • アプリの終了情報に加えて、Crashlytics が Android の ProfilingManager API と統合されるようになりました。

  • Android 17 の ProfilingManager API は、システム トリガーのメモリ プロファイリングで強化されています。まさにここで必要な機能です。

  • つまり、アプリが OutOfMemory エラーに達するか、システムメモリの上限を超えると、OS がバックグラウンドでヒープ スナップショットをキャプチャし、Crashlytics でトリガーされたコレクションが、Crashlytics SDK 20.1.0 以降を使用している限り、すぐに無料で利用できるようになります。

  • そして、Crashlytics は OOM とメモリリミッターによる強制終了を自動的にグループ化します。これらについては、後ほどダッシュボードで詳しく説明します。

  • Firebase コンソールまたは CLI でヒープダンプの収集を有効にすると、Crashlytics はキャプチャされたヒープ スナップショットを自動的に収集し、難読化を解除します。これにより、クラッシュ発生時にアプリの Java ヒープに存在するすべての要素を完全に把握できます。

  • ヒープがキャプチャされると、Crashlytics コンソールに ChromaClip OOM が次のように表示されます。

  • 上部には、Java がヒープを使い果たした理由とその原因が表示されます。

  • また、トラブルシューティングに役立つヒントや、外部リソースへのリンクも紹介します。

  • [Stack trace] タブの、誤解を招く設定アクティビティのすぐ上に、Crashlytics が OOM スタック トレースは決定的ではないというフラグを立てています。これにより、先ほどのようにスタック トレースを見て時間を無駄にすることがなくなります。

  • 次に、Crashlytics は、難読化解除された Perfetto ファイル(ヒープダンプ)をセッションに添付します。

  • ここからファイルをダウンロードできます。

  • Google Cloud Storage ですべてのダンプを閲覧することも、Perfetto UI で開くこともできます。

  • Perfetto UI で [Analyze] をクリックすると、Perfetto ヒープダンプ エクスプローラが開きます。

  • この図をすでに見たことがある方は挙手をお願いします。

  • 何人か手が挙がりました。

  • このビューは非常に複雑に見えますが、この場合、問題は実際には非常に単純です。

  • Java ヒープのほぼ全体にわたる巨大なバーのセットを確認します。

  • そのため、ほとんどの場合、これはペア オブジェクトの配列であり、各ペア オブジェクトは色と文字列インスタンスをラップしています。

  • オー!

  • 何が起きているのか、私にはわかっていると思います。

  • このコードは、各フレームのすべてのピクセルに色とサポートデータを割り当て、数百万のピクセルをリストに保持しています。

  • そのため、設定アクティビティが 256 バイトを割り当てられなかったのも当然です。

  • まあ、少なくとも 1 つの謎は解けた。

  • この問題を改善するには次のような方法があります。

  • この時点で、いくつかのオプションがあります。

  • Perfetto がすでに PaletteExtractor を示しているので、IDE を開いてループを手動で書き換えるか、AI コーディング エージェントを使用できます。

  • または、手動のヒープ検査を完全にバイパスして、Android CLI を活用することもできます。

    # 1. Install Android CLI & Perfetto skills
    $ android skills add android-cli perfetto
    
    # 2. List recent heap dumps from Crashlytics
    $ android signals heaps list --kind oom --timeframe=90d
    
    # 3. Download a heap dump as a Perfetto trace
    $ android signals heaps download <heap-dump-id> \
        --output ./heaps
    
    # 4. Query top retained objects in the heap dump
    $ trace_processor query ./heaps/oom.perfetto-trace ...
    
    # 5. Fetch matching Crashlytics events
    $ android signals heaps describe <heap-dump-id>
  • Android CLI と Perfetto のスキルをインストールすると、難読化が解除された Crashlytics ヒープダンプを一覧表示してダウンロードし、trace_processor を介してこれらのヒープダンプから上位の保持オブジェクトをクエリし、一致する Crashlytics の問題データ(カスタムキー、ログ、パンくずリスト、関連するロールアウトなど)をすべてターミナルから取得できます。

  • さらに、スライドの下部を見てください。

  • このコマンドは自分で実行しないでください。

  • ここでも、お気に入りの AI コーディング エージェントに自然言語で、Android コマンドを使用して最新の OOM ヒープダンプを検査し、修正をリリースするよう依頼します。

    # Or ask your favorite LLM in natural language
    > Using `android` command, inspect the latest heap dump
      for my OOM issue and propose a fix
    ● Ran android signals heaps list · download · describe
    ● Analyzed ./heaps/oom.perfetto-trace (perfetto)
    ● Updated PaletteExtractor.kt (+6 -4 lines)
  • そして、フロー全体を実行するのに十分なコンテキストが提供されます。

  • つまり、OOM の一覧表示、トレースのダウンロード、分析の実行、コードパッチの生成をプロジェクト内で直接行います。

  • PaletteExtractor ファイルのこのパッチを見てみましょう。

    // PaletteExtractor.kt
    fun extractPalette(framePixels: IntArray): SparseIntArray {
    -   val pixelColors = mutableListOf<Pair<Color, String>>() // 8.3M objects per 4K frame!
    -   for (px in framePixels) {
    -       pixelColors.add(Color.valueOf(px) to "#%06X".format(px))
    -   }
    
    +   val colorHistogram = SparseIntArray() // Downsampled & packed ARGB ints!
    +   for (i in framePixels.indices step 16) {
    +       val argb = framePixels[i]
    +       colorHistogram.put(argb, colorHistogram.get(argb) + 1)
    +   }
    +   return colorHistogram
    }
  • 数百万のオブジェクトを割り当てる代わりに、フレームをダウンサンプリングして、未加工の整数を直接カウントするようになりました。

  • そこで、修正を適用し、問題をクローズして、バージョンを上げ、テストを続行します。

  • うまくいけば、メモリ不足エラーが解消されるだけでなく、ガベージ コレクションのスラッシングがなくなるため、厄介な ANR も解消されるはずです。

  • そこで、テストデバイスにパッチビルドをデプロイし、新しいクリップを選択して、パレットの抽出を開始しました。

  • そして、UI が再びフリーズします。

  • 別の ANR。

  • 興味深いことに、さらにテストを重ねると、一部のデバイスでは依然として ANR が発生するものの、他のデバイスではアプリがランダムにクラッシュするようになり、その頻度は OOM よりも高いことがわかりました。

  • しかし、OOM は修正されています。何が起きているのでしょうか。

ネイティブ メモリの問題を調査する

  • Polina Govorkova

    Crashlytics に戻りましょう。

  • まずは良いニュースから。

  • 問題リストを確認します。

  • 期待どおり、メモリ不足例外は完全に解消されました。

  • 悪いニュースは、

  • ChromaClip の生存時間が少し長くなったことで、その下に隠れていたものを発見する時間ができました。

  • そのため、ANR は解消されませんでした。

  • 下部にはネイティブ コードでのクラッシュが 2 つあり、幸いなことにイベントは数件しかありません。

  • そして、一番上の 11 件のイベントは、Memory Limiter による強制終了です。ありがたいことに、ヒープダンプがあることを示すバッジが付いています。

  • 待って

  • メモリリミッターの強制終了とは何ですか?

  • この中で、この言葉を聞いたことがある方は挙手をお願いします。

  • それでは

  • 珍しい手札。

  • 一緒に学んでいきましょう。

  • Memory Limiter による強制終了をこれまで見たことがないのは、これが Android 17 の新機能だからです。

  • Java OOM は、管理対象の Java ヒープの上限を超えた場合にのみトリガーされます。しかし、アプリの合計メモリ使用量には、グラフィック バッファや C++ 割り当てなどのネイティブ RAM も含まれます。以前は、アプリごとの上限はありませんでした。

  • そのため、グローバルなローメモリ キラーがありました。

  • アプリがシステム RAM を使い果たしてスマートフォン全体が動作しなくなるような場合に、この機能が介入します。

  • Android 17 以降では、メモリリミッターが各アプリの個別のメモリ使用量を制限します。

  • そのため、プロセスが不釣り合いな量の RAM を消費すると、OS はその特定のアプリを終了します。

  • Crashlytics は、これらの詳細をすべてキャプチャし、ダッシュボードで終了をグループ化します。

  • Crashlytics で Memory Limiter による強制終了をクリックすると、OOM の場合と同様に、OS が介入した理由の説明が表示されます。そのため、私が今言ったことを覚えておく必要はありません。

  • また、次のステップに進むためのヒントや、Android のメモリ制限に関するドキュメントへのリンクも表示されます。

  • OOM の場合と同様に、OS がプラグを抜いたときに、スタック トレースは無実のフレームを非難し、Crashlytics ダッシュボードはそれをハイライト表示します。

  • 現在、メモリリミッターによる強制終了は主にネイティブ コードで発生しています。ネイティブ環境には、メモリの安全性と割り当てを処理するレイヤがないためです。

  • それ以外の場合は、OOM になります。

  • 最初の質問は、Kotlin オブジェクトがネイティブにバックアップされた巨大な割り当てを保持しているかどうかです。

  • それとも、未加工の C++ コードが単独でリークしているのでしょうか。

  • Perfetto UI で [Analyze] をクリックして確認してみましょう。

  • Perfetto ヒープダンプ エクスプローラの [Overview] タブを確認します。

  • [General Information] の [anon RSS + swap] を見ると、プロセスのメモリ使用量の合計が 4 GB に達していることがわかります。

  • 違いますね。

  • しかし、その下の [Bytes Retained by Heap] を見ると、ビットマップなどの Kotlin オブジェクトに関連付けられたネイティブ メモリを含む、マネージド ヒープ全体は 42 MB しかありません。

  • 違いますね。

  • このように、10 秒ですべての Kotlin コードと Java コードを除外できました。

  • したがって、ネイティブ C++ レイヤからリークしているはずです。

  • ChromaClip にはフレーム処理用の薄い C++ レイヤがあるため、それほど探す必要はありません。

  • コーディング エージェントまたは Android Studio のネイティブ プロファイラで NativePaletteEngine.cpp processFrame を確認すると、バグがすぐに見つかります。

    // NativePaletteEngine.cpp
    void NativePaletteEngine::processFrame(const uint8_t* rgba, size_t size) {
        uint8_t* scratch = new uint8_t[size]; // Temporary frame buffer
        transformColorSpace(rgba, scratch, size);
    
        // BUG: Allocated every frame, but never freed!
    +   delete[] scratch; // Free native buffer when done
    }
  • C++ コードは、動画フレームごとに一時的なスクラッチ バッファを割り当てます。解放しないと一時的ではなくなります。これは初心者のミスです。

  • では、点と点をつなぎましょう。

  • 詳細

  • Java のリークを修正すると、C++ のリークが蓄積して暴走する時間が生じます。

  • Android 17 では、メモリ制限機能がこれを検知してアプリを強制終了しました。

  • しかし、メモリ制限機能がまだ搭載されていない古いデバイスでは、実際に ANR が発生していました。

  • これで、すべてが腑に落ちました。

  • ここに delete[] scratch を追加し、バージョンを上げて、アップデートをリリースしましょう。

  • メモリリミッターによる強制終了と ANR が最終的に解消されたため、1.0 をリリースできるようになりました。

  • 楽しみですね。

  • ChromaClip が高速かつ安定したことで、アプリは人気を博します。

  • 毎日、何千人もの人々が動画をアップロードし、リミックスしています。

  • しかし、トラフィックが増加するにつれて、先ほどダッシュボードの下部で確認した謎のネイティブ コードでのクラッシュが発生しました。

  • 彼らも暴走している。

  • 突然、その数が大幅に増えました。

  • 私たちの仕事はまだ終わっていません。

ネイティブ メモリ破損によるクラッシュの調査

  • Polina Govorkova

    Crashlytics でネイティブ コードでのクラッシュを開き、スタック トレースを確認します。

  • 独自のアプリのコードは 1 行もありません。

  • RenderThread がメモリの解放中に libc.so の内部でクラッシュしています。

  • もう 1 つのヒントとして、アプリコードが内部にないメモリ割り当て内でシステム スレッドがクラッシュした場合、これはネイティブ メモリ破損の典型的な兆候です。

  • つまり、C++ コードがバッファを破損または解放したのが先で、レンダリング スレッドがそれにつまずいたのが後です。

  • では、破損が発生したまさにその瞬間に、真犯人を捕まえるにはどうすればよいでしょうか。

  • Android には 2 つの強力なツールが用意されており、Crashlytics はその両方をすぐにサポートします。

  • 1 つ目は、本番環境専用に構築された軽量のセーフティ ネットである GWP-ASan です。

  • ネイティブ ヒープ割り当てのほんの一部をサンプリングし、ガードページで囲むことで、CPU とメモリのオーバーヘッドをほぼゼロにします。

  • 数千ものユーザー セッションで、サンプリングされた割り当てで解放されたメモリや範囲外のメモリにバグが触れると、GWP-ASan はすぐにそれをキャッチします。

  • ただし、GWP-ASan は本質的に確率論的であるため、ローカルでテストする場合、ランダム サンプルにヒットするまでテスト用スマートフォンを 16 時間以上実行することは望ましくありません。

  • では、何が得られるのでしょうか。

  • 決定論的なローカル デバッグには、ARM MTE(Memory Tagging Extension)があります。

  • MTE は、ハードウェア ポインタとメモリタグを使用してすべての割り当てをチェックします。Google Pixel 8 以降などの ARM v9 デバイスで利用できます。

  • そのため、本番環境で GWP-ASan と組み合わせることで、両方のメリットを得ることができます。

  • 予想どおり、どちらかのツールがバグを検出すると、Crashlytics は自動的に問題にバッジを付け、GWP-ASan または MTE でフィルタできるようにします。また、破損の正確な原因を示す情報カードをダッシュボードに固定します。

  • GWP-ASan がクラッシュをキャッチしたときに Crashlytics コンソールで何が起こるかを見てみましょう。

  • 通常のスタック トレース タブの横に、メモリ スタック トレースがあります。

  • そして、一番上には、破損の正確な原因である「Use After Free」が表示されています。

  • また、libc.so でクラッシュしたレンダリング スレッドを表示する代わりに、GWP-ASan は独自の effect_pipeline.cc 内にあるバッファの完全な履歴を表示するようになりました。

  • [Allocated By] には、バッファが作成された場所が表示されます。

  • [Deallocated By] のすぐ下に、レンダリング スレッドから解放した原因が表示されます。

  • これは、エフェクト パイプラインの 208 行目に隠れています。

  • そこで、EffectPipeline でネイティブ コードを開くと、バグが私たちを嘲笑っています。

    // effect_pipeline.cc
    void EffectPipeline::onFilterSwitched(FilterPresetId preset) {
        // BUG: Freed on worker thread while RenderThread is still reading it!
    +   std::lock_guard<std::mutex> lock(lut_mutex_);
    
        releaseLutBuffer();       // Frees old color-grading LUT
        loadFilterPreset(preset); // Allocates new color-grading LUT
    }
  • ユーザーがバックグラウンド スレッドでカラーフィルタを切り替えるたびに、RenderThread がまだ読み取っている間に古いバッファを解放します。

  • そこで、バッファ スワップの周囲にミューテックス ロックを追加してアップデートをリリースしたところ、GWP-ASan レポートと謎のクラッシュの両方がほぼ一晩で消え去りました。

  • クラッシュの影響を受けていないユーザーの指標は 100% に急増しました。

  • おめでとうございます!

  • このセッションの冒頭で、「これで終わりか?」と自問したことを思い出してください。

  • その直後に、応答性とメモリに関するあらゆる問題に直面しました。

  • ANR、メモリ不足エラー、メモリリミッターによる強制終了、ネイティブ メモリの破損によるクラッシュはすべて解決されました。

  • スタック トレースが嘘をついていて、犯人が C++ のスレッドのどこかに隠れている場合でも、Android と Firebase Crashlytics には、追跡して自信を持ってリリースするためのツールが用意されています。

まとめと早期アクセスへの登録

  • Polina Govorkova

    では、ノートパソコンに戻ったらすぐに使える機能をまとめましょう。ApplicationExitInfo を介した ANR スレッド分析情報とその他の関連メタデータ、ProfilingManager を介した OOM とメモリリミッターの強制終了トリガーの自動収集、ネイティブ メモリ破損に対する GWP-ASan と Arm MTE の完全なサポートです。

  • また、Android 17 以降のフィールド ヒープダンプが限定公開プレビューでリリースされます。

  • Firebase Crashlytics で自動ヒープダンプ収集をいち早く試し、Perfetto と Android CLI で分析したい場合は、スライドの写真を撮って、リンクから登録してください。

  • 本日はご参加いただき、ありがとうございました。

  • メインスレッドがブロックされず、ヒープがクリーンに保たれ、次のリリースが安定したものになることを願っています。

  • デバッグをお楽しみください。

  • 重ねてお礼申し上げます。

コード

コード スニペット

「概要」の 1 分 45 秒のテキスト スニペット
java.lang.NullPointerException
  at MainActivity.onCreate(MainActivity.kt:42)
  at android.app.Activity.performCreate
  at android.os.Handler.dispatchMessage
  at android.os.Looper.loopOnce
  at android.app.ActivityThread.main
「概要」の 1 分 58 秒のテキスト スニペット
"main" prio=5 tid=1 Blocked
  | group="main" sCount=1 dsCount=0 flags=1
  | state=S schedstat=( 1450239 48192 145 )
  at DataManager.getData(DataManager.kt:25)
  - waiting to lock <0x0a1b> held by thread 12
  at MainActivity.onResume(MainActivity.kt:50)
  at android.app.Activity.performResume
  at android.os.Handler.dispatchMessage
  at android.os.Looper.loopOnce
  at android.app.ActivityThread.main
「ANR のデバッグと分析」の 4 分 45 秒のテキスト スニペット
14:32:15.105  1205-1250/system_process E/ActivityManager: ANR in com.chromaclip.app (com.chromaclip.app/.MainActivity)
    PID: 14205
    Reason: Input dispatching timed out
    Dumped ANR trace to /data/anr/traces.txt
"main" prio=5 tid=1 Native
  | group="main" sCount=1 dsCount=0 flags=1 obj=0x72a8c3d0 self=0x759fb92000
  | sysTid=14205 nice=-10 cgrp=default sched=0/0 handle=0x7623910548
  | state=S schedstat=( 1450239120 4819203 1453 ) utm=132 stm=13 core=4 HZ=100
  | stack=0x7fed2a0000-0x7fed2a2000 stackSize=8MB
  at sun.misc.Unsafe.park(Native method)
  - waiting on an unknown object
  at java.util.concurrent.locks.LockSupport.park(LockSupport.java:190)
  at kotlinx.coroutines.EventLoopImplBase.processNextEvent(EventLoop.common.kt:279)
  at kotlinx.coroutines.BlockingCoroutine.joinBlocking(Builders.kt:85)
  at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking(Builders.kt:59)
  at kotlinx.coroutines.BuildersKt.runBlocking(Unknown Source:1)
  at com.chromaclip.app.extractor.PaletteExtractor.extractColors(PaletteExtractor.kt:42)
「ANR のデバッグと分析」の 08:08 のテキスト スニペット
DefaultDispatcher-worker-1 (Root blocking)

dalvik.system.VMRuntime.allocateNode (Native method)
android.graphics.Bitmap.createBitmap (Bitmap.java:1124)
android.graphics.Bitmap.createBitmap (Bitmap.java:1091)
com.chromaclip.app.extractor.FrameDecoder.getUncompressedFrame (FrameDecoder.kt:112)
com.chromaclip.app.extractor.PaletteExtractor$processVideo$2.invokeSuspend (PaletteExtractor.kt:65)
kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith (ContinuationImpl.kt:33)
kotlinx.coroutines.DispatchedTask.run (DispatchedTask.kt:106)
java.util.concurrent.ThreadPoolExecutor.runWorker (ThreadPoolExecutor.java:1167)
java.util.concurrent.ThreadPoolExecutor$Worker.run (ThreadPoolExecutor.java:641)
java.lang.Thread.run (Thread.java:923)
「メモリ不足例外の調査」の 11:54 のテキスト スニペット
java.lang.OutOfMemoryError: Failed to allocate a 256 byte allocation
 at android.graphics.Bitmap.createBitmap
 at android.view.LayoutInflater.inflate(LayoutInflater.java:532)
 at com.chromaclip.app.SettingsActivity.onCreate(SettingsActivity.kt:21)
「ヒープダンプのキャプチャと分析」の 15:54 のシェル スニペット
# 1. Install Android CLI & Perfetto skills
$ android skills add android-cli perfetto

# 2. List recent heap dumps from Crashlytics
$ android signals heaps list --kind oom --timeframe=90d

# 3. Download a heap dump as a Perfetto trace
$ android signals heaps download <heap-dump-id> \
    --output ./heaps

# 4. Query top retained objects in the heap dump
$ trace_processor query ./heaps/oom.perfetto-trace ...

# 5. Fetch matching Crashlytics events
$ android signals heaps describe <heap-dump-id>
「ヒープダンプのキャプチャと分析」の 16:27 のテキスト スニペット
# Or ask your favorite LLM in natural language
> Using `android` command, inspect the latest heap dump
  for my OOM issue and propose a fix
● Ran android signals heaps list · download · describe
● Analyzed ./heaps/oom.perfetto-trace (perfetto)
● Updated PaletteExtractor.kt (+6 -4 lines)
「ヒープダンプのキャプチャと分析」の 16:47 の Kotlin スニペット
// PaletteExtractor.kt
fun extractPalette(framePixels: IntArray): SparseIntArray {
-   val pixelColors = mutableListOf<Pair<Color, String>>() // 8.3M objects per 4K frame!
-   for (px in framePixels) {
-       pixelColors.add(Color.valueOf(px) to "#%06X".format(px))
-   }

+   val colorHistogram = SparseIntArray() // Downsampled & packed ARGB ints!
+   for (i in framePixels.indices step 16) {
+       val argb = framePixels[i]
+       colorHistogram.put(argb, colorHistogram.get(argb) + 1)
+   }
+   return colorHistogram
}
「Investigating native memory issues」の 21:06 の C++ スニペット
// NativePaletteEngine.cpp
void NativePaletteEngine::processFrame(const uint8_t* rgba, size_t size) {
    uint8_t* scratch = new uint8_t[size]; // Temporary frame buffer
    transformColorSpace(rgba, scratch, size);

    // BUG: Allocated every frame, but never freed!
+   delete[] scratch; // Free native buffer when done
}
「Investigating native memory corruption crashes」の 25:14 の C++ スニペット
// effect_pipeline.cc
void EffectPipeline::onFilterSwitched(FilterPresetId preset) {
    // BUG: Freed on worker thread while RenderThread is still reading it!
+   std::lock_guard<std::mutex> lock(lut_mutex_);

    releaseLutBuffer();       // Frees old color-grading LUT
    loadFilterPreset(preset); // Allocates new color-grading LUT
}