Android アプリのパフォーマンス改善のためのチェックリスト

はじめに

パフォーマンスの最適化を行うには、フレームワーク特有の知識と、プロファイラによる計測 の両面から攻めていく必要があります。 アプリケーションを 60FPS の描画性能で動作させるには、1フレームあたりわずか 16.6 ミリ秒で処理を終えなければいけません。 複雑な計算処理や描画を行うアプリケーションにおいて、常に 60FPS を達成するのは非常に大変で、考えるべきことがたくさんあります。 ここでは、Android アプリのパフォーマンス改善のヒントをまとめておきます。

パフォーマンス可視化自動化のススメ

定期的にパフォーマンスに関するプロファイリングを行うのはよいことですが、もっといい方法は、パフォーマンスに関する計測を自動化&見える化 することです。 例えば次のような仕組みを作り、普段の開発では常に有効化しておきます。

  1. 各フェーズの実行にかかった時間を、画面上に自動で表示する
  2. 画面上に FPS を表示する
  3. 各端末のパフォーマンス(メトリクス情報)を自動でサーバーに送り、統計をグラフ化する

このような仕組みを作り込んでおけば、チームメンバー全員が普段からパフォーマンスを意識 して開発できるようになります。 アーキテクトだけにプロファイリング作業を任せたり、プロジェクト終盤になってからパフォーマンス計測をはじめたりするのはやめましょう。 コードを作り込んでからデータ構造やスレッド戦略を変更するのはとても大変で、手遅れになることが多いです。

上記の仕組みによって表示された結果は、製品リリースのためのパフォーマンスクライテリアを満たしているかの指標にもなります。

まずは計測

  1. FPS の確認(1 フレームあたり何ミリ秒かかっているか)
  2. オーバードローの確認(Debug GPU overdraw で何度も重ねて描画している部分がないか確認)
  3. レイアウトの確認(Layout Inspector で無駄なネストを確認)
  4. 全般的なボトルネックの確認
    • CPU Profiler でアプリ内のボトルネックを調査(Traceview はサポート終了)
      • 各スレッドのビジー状態や、どのメソッドに時間がかかっているか を調べる → メソッド単位の最適化
      • GC (Garbage Collection) が頻繁に発生していないか を調べる (Perfeto/SystraceAllocation Tracker)。
    • Perfetto でシステム全体のボトルネックを調査
      • 他のプロセスとの Binder 通信などがボトルネックになっていなかを調べる
      • adb shell perfetto で計測開始するか、Perfetto の Web アプリから直接データ取得可能(要 Bluetooth/USB 接続)
      • 昔は Systrace だったけど、Android 10 以降は Perfetto で。

改善ポイント

  1. 背景色描画の削減
    • 背景色は、テーマ、Activity、Fragment、View のいずれかのレイヤで一回のみ指定する
    • テーマの背景色が余計なときは、テーマの定義で android:windowbackground="null" するか、Activity で window.setBackgroundDrawable(null) する
  2. カスタムビュー内のオーバードローをチェック
    • onDraw 内の描画で重なって見えない部分は clipRect でマスクする。カスタムビューの描画内容は Android フレームワークが最適化することができない
  3. レイアウトをフラット化
    • ListView → ConstraintLayout / RelativeLayout
  4. レイアウトリソースの Inflate 時間短縮
    • Layout XML の Inflate 処理時間が短縮したいなら、レイアウト情報をハードコード する手もあり
    • カスタムビューにして onDraw を実装
  5. スレッド戦略
    • 各種処理をどのようなスレッド上で実行するか設計する。各メソッドに スレッドアノテーション を付けてみる(Main/UI、Worker スレッド間の呼び出しで警告してくれる)
    • Kotlin のコルーチンで実行スレッドを分ける。
      • Dispatcher.Main … メインスレッド(ここの処理は最小限に)
      • Dispatcher.Default … ワーカースレッド(ほとんどの処理はここで実行する
      • Dispatcher.IO … I/O アクセス、ネットワーク処理など
    • メインスレッドの処理を極小化
      • 例えば、Android のユーザー入力イベントはメインスレッドでハンドルされるので、ViewModel のメソッドをトリガにコルーチン起動 (viewModelScope.launch) し、その中から戻り値なしの別コンテキスト(スレッド)処理を呼び出し (withContext(Dispatcher.Default)) て、メインスレッドはそのまま抜けるようにする。画面反映は LiveData からの更新通知のタイミングで行えばよい。ViewModel クラスには戻り値を持つ public メソッド (getter) を作らないということ(それは同期処理を意味する)。
    • 並列化できる処理を見極める
      • 順序依存のない処理は同時に開始する ように書き換える(とくに画面遷移後の初期化処理など)
      • 最後に Join の必要な並列処理は coroutineScope { ... async { ... }} など
    • スレッドのキャンセル、間引き処理
      • キー連打や同種のイベントが連続発生する可能性がある場合は、要求をコマンド化してキューイングして間引く(Command パターン)
      • あえてシングルスレッドでキュー処理して (SingleThreadExecutor)、新しい要求が来たらキューを空にするとか(最新要求だけ処理)
    • 排他制御のデザインパターン
      • 排他制御によるロック時間を最小化するパターンを学ぶ。例えば、Read-Write Lock パターンでは、Read スレッド同士は排他制御する必要がないことを示している(Java の標準クラスにも ReadWriteLock がある)。
  6. 頻繁な GC の抑制
    • onDraw 内でオブジェクト生成しないようにする(アニメーション中の GC 発生を抑制)
    • ループ内で一時オブジェクトを生成しないようにする
    • Flyweight パターンでオブジェクトを共有する
    • オブジェクトプールでオブジェクトを使いまわす(Android の Message クラス が参考になる (Message.obtain() でプールから取得 → recycle() でプールに戻す))
  7. その他
    • キャッシュ関連処理は全般的に難しいが重要
      • 時間のかかる関数呼び出し結果はメモ化
      • キャッシュコントロール(いつ消すかなど)は、基本的には HTTP の Cache-Control レスポンスヘッダの stale-while-revalidate 拡張 (RFC 5861) を参考にするとよい。簡単にいうと、キャッシュで高速に描画しつつ、背後でキャッシュの更新処理を走らせるという考え方。
    • シーケンシャルサーチ (indexOf) 処理はマップ処理に置き換える(O(n) → O(1)
    • ログ出力用のオブジェクト生成(主にテキスト構築)を削除(if (DEBUG) で引数生成部分ごと囲む)
    • 画像ファイルに PNG ではなく WebP フォーマットを使用する
    • 起動時 (onCreate) での初期化コードは最低限にする(各種処理の遅延化)
      • onResume へ遅らせる
      • スレッド起動するだけにする
      • レイアウトの Inflate 処理を遅らせる (ViewStub で次のフレームへ遅らせる)
      • DI による依存注入処理を Dagger/Hilt で遅延させる
    • コールバックオブジェクトの共通化

補足メモ

Android Profiler で関数レベルのボトルネックを探る

  • Android Studio の Android Profiler から CPU ペーンを開き、Record ボタンを押してレコード開始 → 何らかの操作をしてレコード停止
  • レコード中は動作が重くなるので、トレース結果は実時間で速度を見るのでなく、全体時間に対してどの程度の割合で時間がかかっているかで見る
  • 正確に呼び出し情報をトレースするために、プロファイルモードで Trace Java Methods を選択しておく。Sample Java Methods は一定間隔でサンプリングするだけなので、動作は軽いけど正確な呼び出し情報が取れない
  • Android Profiler でデバイスを認識しない時は、デバイス側の開発者オプションで USB デバッグが有効になっているかを確かめる
  • 本質的に遅いメソッドを見つけるには、Bottom Up タブを選択 → Self 時間でソートする
  • 先に main スレッドのチャート上でメソッドを選択しておくと、右側の分析ペーンの結果をフィルタできる
Android Studio の Profiler 機能
図: Android Studio の Profiler 機能

Layout Inspector で無駄なレイアウト構成を見つける

アプリ動作中に Layout Inspector を起動すると、リアルタイムに現在のレイアウト構造を確認することができます(Hierarchy Viewer はサポート終了)。 パフォーマンスへの影響を見るときは、主にレイアウトが 深いネスト構造になっていないか を調べます。 Layout Inspector は 3D 表示にして角度をずらして、Layer spacing スライダーを調整すると、どれだけ重なっているかがよく分かかります。

オーバードローされている部分を見つける

開発者オプションから Debug GPU overdraw(GPU オーバードローをデバッグ) を ON にすると、何度も重ねて描画してしまっている部分を確認できます。 不透明な部分を重ねて描画していると完全に無駄です。

  • 参考: GPU オーバードローの視覚化
  • この設定は開発者オプションの深いところにあるので、ADB で OFF/ON するのが早い
  • 何らかの操作をしているときに、赤や緑の矩形が表示されることがないか を調べる(できれば青の領域もない方がいい)
  • オーバードローされている部分は Layout Inspector でレイアウトを確認

コールバックオブジェクトの共通化

例えば、RecyclerView.Adapter#onBindViewHolder の中で次のようにリスナー登録していると、そのビューが表示されるごとにリスナーオブジェクトが生成されることになります。

setOnClickListener {
    // ...
}

リスナー内の処理が共通であれば、共通のリスナーオブジェクトを使うようにします。

setOnClickListener(handleClick)