Compose のパフォーマンス

Jetpack Compose は、初期設定の状態で優れたパフォーマンスを実現することを目的としています。このページでは、最適なパフォーマンスを実現するアプリを記述して構成する方法について説明し、回避すべきいくつかのパターンを示します。

この記事を読む前に、Compose の思想で Compose の主要なコンセプトを理解しておくことをおすすめします。

アプリを適切に構成する

アプリのパフォーマンスが低い場合は、構成に問題があることが考えられます。最初のステップとして、次の構成オプションを確認することをおすすめします。

リリースモードでビルドし、R8 を使用する

パフォーマンスの問題が見つかった場合は、アプリをリリースモードで実行してみてください。デバッグモードは、多くの問題を見つけるのに有効ですが、パフォーマンスに多大なコストが発生するため、パフォーマンスの低下につながる可能性のある他のコードの問題を特定するのが困難になることが考えられます。また、R8 コンパイラを使用して、アプリから不要なコードを削除する必要があります。デフォルトでは、リリースモードでのビルドでは、R8 コンパイラが自動的に使用されます。

ベースライン プロファイルを使用する

Compose は、Android プラットフォームの一部ではなく、ライブラリとして配布されています。このアプローチにより、Compose を頻繁に更新し、古い Android バージョンをサポートできます。ただし、Compose をライブラリとして配布するとコストが発生します。Android プラットフォーム コードはすでにコンパイルされ、デバイスにインストールされています。一方、ライブラリは、アプリ起動時に読み込み、機能が必要な場合にジャストインタイムで解釈する必要があります。これにより、アプリの起動時や、ライブラリ機能を初めて使用するときに、アプリの速度が低下する可能性があります。

ベースライン プロファイルを定義することで、パフォーマンスを改善できます。これらのプロファイルは、クリティカルなユーザー ジャーニーに必要なクラスとメソッドを定義し、アプリの APK とともに配布されます。ART は、アプリのインストール中にその重要なコードを事前にコンパイルするため、アプリの起動時に直ちに使用できます。

適切なベースライン プロファイルを定義するのは必ずしも簡単なことではありません。そのため、Compose にはデフォルトのプロファイルが付属しています。このメリットは、何もしなくても得られる可能性があります。ただし、独自のプロファイルを定義すると、アプリのパフォーマンスを実際に改善しないプロファイルが生成される場合があります。プロファイルをテストして、問題が解決したことを確認してください。これを行うには、アプリの Macrobenchmark テストを作成し、ベースライン プロファイルを作成および変更しながらテスト結果を確認するのが適切な方法です。Compose UI の Macrobenchmark テストの作成方法の例については、Macrobenchmark Compose のサンプルをご覧ください。

リリースモード、R8、ベースライン プロファイルの効果の詳細については、Why should you always test Compose performance in release? というブログ投稿をご覧ください。

3 つの Compose フェーズがパフォーマンスに与える影響

Jetpack Compose のフェーズで説明したように、Compose がフレームを更新する際、次の 3 つのフェーズを経由します。

  • 構成: Compose は表示する内容を決定します。つまり、コンポーズ可能な関数を実行し、UI ツリーをビルドします。
  • レイアウト: Compose は、UI ツリー内の各要素のサイズと配置を決定します。
  • 描画: Compose は、実際に個別の UI 要素をレンダリングします。

Compose は、必要ない場合にこれらのフェーズのいずれかをインテリジェントにスキップできます。たとえば、1 つのグラフィック要素が、同じサイズの 2 つのアイコン間で入れ替わるとします。その要素はサイズが変更されず、UI ツリーの要素が追加または削除されないため、Compose はコンポジション フェーズとレイアウト フェーズをスキップして、その 1 つの要素を再描画できます。

ただし、コーディング上の誤りがあると、Compose が安全にスキップできるフェーズを判別するのが難しくなる場合があります。疑問がある場合、Compose は最終的に 3 つのフェーズすべてを実行するため、UI が必要以上に低速になる可能性があります。そのため、パフォーマンスに関するおすすめの方法の多くは、Compose が実施する必要がないフェーズをスキップできるようにすることを目的としています。

一般的にパフォーマンスを改善する原則はいくつかあります。

まず、可能な限り、コンポーズ可能な関数の外部に計算を移動してください。UI が変更されるたびに、コンポーズ可能な関数の再実行が必要になる場合があります。コンポーザブルに追加したコードは、アニメーションのすべてのフレームごとに再実行される可能性があります。そのため、コンポーザブルのコードは、UI のビルドに実際に必要なコードのみに制限する必要があります。

次に、状態の読み取りを可能な限り延期します。状態の読み取りを子コンポーザブルまたは後のフェーズに移動することで、再コンポーズを最小限に抑えるか、コンポジション フェーズを完全にスキップできます。これを行うには、頻繁に変化する状態の場合は、状態値の代わりにラムダ関数を渡し、頻繁に変化する状態を渡すときにラムダベースの修飾子を優先します。この手法の例については、可能な限り読み取りを延期するをご覧ください。

次のセクションでは、このような問題の原因となる可能性がある特定のコードエラーについて説明します。この記事で紹介している具体例が、コード内の他の同様のエラーを発見する際にもお役に立てば幸いです。

ツールを使用して問題を見つける

パフォーマンスの問題がどこにあるのか、どのコードから最適化を開始すればよいのかを把握するのが難しいことがあります。まずはツールを使って、問題のある箇所を絞り込んでみましょう。

再コンポーズ数を取得する

Layout Inspector では、コンポーザブルが再コンポーズまたはスキップされる頻度を確認できます。

Layout Inspector に表示される再コンポーズ数

詳しくは、ツールのセクションをご覧ください。

おすすめの方法を実践する

Compose には、遭遇する可能性がある陥りやすい落とし穴がいくつかあります。これらの誤りにより、コードが十分に適切な状態で動作しているように見えても UI のパフォーマンスが低下する可能性があります。このセクションでは、こうした問題を回避するためのおすすめの方法をいくつか紹介します。

remember を使用して高コストの計算を最小限に抑える

コンポーズ可能な関数は、アニメーションのすべてのフレームごとに非常に高い頻度で実行できます。このため、コンポーザブルの本文では計算量を可能な限り低減する必要があります。

重要な手法は、remember を使用して計算結果を保存することです。これにより、計算を 1 回のみ行い、必要に応じて随時結果を取得できます。

たとえば、並べ替えた名前のリストを表示するものの、単純に非常に高コストな方法で並べ替えを行うコードを以下に示します。

@Composable
fun ContactList(
    contacts: List<Contact>,
    comparator: Comparator<Contact>,
    modifier: Modifier = Modifier
) {
    LazyColumn(modifier) {
        // DON’T DO THIS
        items(contacts.sortedWith(comparator)) { contact ->
            // ...
        }
    }
}

ここでの問題は、ContactsList が再コンポーズされるたびに、連絡先リストが変更されていなくても、リスト全体が再び並べ替えられることです。ユーザーがリストをスクロールすると、新しい行が表示されるたびにコンポーザブルが再コンポーズされます。

この問題を解決するには、LazyColumn の外部でリストを並べ替えて、並べ替えられたリストを remember に保存します。

@Composable
fun ContactList(
    contacts: List<Contact>,
    comparator: Comparator<Contact>,
    modifier: Modifier = Modifier
) {
    val sortedContacts = remember(contacts, sortComparator) {
        contacts.sortedWith(sortComparator)
    }

    LazyColumn(modifier) {
        items(sortedContacts) {
          // ...
        }
    }
}

これで、ContactList が最初に作成されたときに、リストが 1 回並べ替えられるようになります。連絡先またはコンパレータが変更されると、並べ替えられたリストが再生成されます。それ以外の場合は、コンポーザブルはキャッシュに保存されている並べ替えられたリストを引き続き使用できます。

遅延レイアウトキーを使用する

遅延レイアウトは、アイテムをインテリジェントに再利用するために最適な処理を行いますが、必要な場合にのみ再生成または再コンポーズします。ただし、最適な判断を行うように設定できます。

ユーザー オペレーションによってアイテムがリスト内に移動するとします。たとえば、変更時刻順に並べ替えたメモのリストを、最も直近で変更されたメモが最上部に配置されるように表示するとします。

@Composable
fun NotesList(notes: List<Note>) {
    LazyColumn {
        items(
            items = notes
        ) { note ->
            NoteRow(note)
        }
    }
}

ただし、このコードには問題があります。下部のメモが変更されたとします。直近で変更されたメモであるため、リストの一番上に移動し、他のすべてのメモは 1 つ下に移動します。

ここでの問題は、Compose はユーザーのサポートがなければ、変更されていないアイテムがリスト内で移動しただけである点を認識しないことです。そのように認識するのではなく、Compose は、古い「アイテム 2」が削除され、新しいアイテムが作成されたと判断し、アイテム 3、アイテム 4 をはじめ以降すべての項目について同様に認識します。その結果、Compose は、実際に変更されたのが 1 つのアイテムのみであっても、リスト内のすべてのアイテムを再コンポーズします。

ここでの解決方法は、アイテムキーを指定することです。各アイテムに安定したキーを指定することで、Compose が不要な再コンポーズを回避できます。この場合、Compose は、現在スポット 3 にあるアイテムがスポット 2 にあったものと同じアイテムであることを確認できます。そのアイテムに対してはどのデータも変更されていないため、Compose が再コンポーズを行う必要はありません。

@Composable
fun NotesList(notes: List<Note>) {
    LazyColumn {
        items(
            items = notes,
             key = { note ->
                // Return a stable, unique key for the note
                note.id
            }
        ) { note ->
            NoteRow(note)
        }
    }
}

derivedStateOf を使用して再コンポーズを制限する

コンポーズで状態を使用することのリスクの一つは、状態が急激に変化した場合、UI が不必要に再コンポーズされることです。たとえば、スクロール可能なリストを表示しているとします。リストの状態を調べて、リストで最初に表示されるアイテムを確認します。

val listState = rememberLazyListState()

LazyColumn(state = listState) {
    // ...
}

val showButton = listState.firstVisibleItemIndex > 0

AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}

ここでの問題は、ユーザーがリストをスクロールすると、ユーザーが指をドラッグしたときに listState が変化し続けることです。つまり、リストが継続的に再コンポーズされます。ただし、実際にはそれほど頻繁に再コンポーズする必要はありません。新しいアイテムが下部に表示されるまで再コンポーズする必要はありません。そのため、計算量が過剰に増加し、UI のパフォーマンスが低下します。

この問題を解決するには、派生状態を使用します。派生状態を使用すると、どの状態の変化により再コンポーズを実際にトリガーする必要があるかについて Compose に指示できます。この場合は、重要な点として最初に表示されるアイテムが変更されるタイミングを指定します。その状態値が変更された場合に、UI は再コンポーズを実施する必要があります。ただし、新しいアイテムが一番上に移動されるほどユーザーが十分なスクロール操作を行っていない場合、再コンポーズする必要はありません。

val listState = rememberLazyListState()

LazyColumn(state = listState) {
  // ...
  }

val showButton by remember {
    derivedStateOf {
        listState.firstVisibleItemIndex > 0
    }
}

AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}

可能な限り読み取りを延期する

可能な限り長い期間にわたって状態変数の読み取りを延期する必要があります。状態の読み取りを延期することで、Compose は再コンポーズ時に可能な限り最小限のコードを再実行できます。たとえば、UI にコンポーザブル ツリーの上位にホイストされる状態があり、子コンポーザブルで状態を読み取る場合は、読み取った状態をラムダ関数でラップできます。この操作を行うと、読み取りは実際に必要な場合にのみ行われます。このアプローチを Jetsnack サンプルアプリに適用した方法をご覧ください。Jetsnack は、詳細画面に折りたたみツールバーのような効果を実装します。

この効果を実現するには、Title コンポーザブルが Modifier を使用して自身をオフセットするためにスクロール オフセットを認識する必要があります。最適化を行う前のシンプルな Jetsnack コードの例を以下に示します。

@Composable
fun SnackDetail() {
    // ...

    Box(Modifier.fillMaxSize()) { // Recomposition Scope Start
        val scroll = rememberScrollState(0)
        // ...
        Title(snack, scroll.value)
        // ...
    } // Recomposition Scope End
}

@Composable
private fun Title(snack: Snack, scroll: Int) {
    // ...
    val offset = with(LocalDensity.current) { scroll.toDp() }

    Column(
        modifier = Modifier
            .offset(y = offset)
    ) {
        // ...
    }
}

スクロール状態が変更されると、Compose は最も近い親再コンポーズ スコープを検索し、無効にします。この場合、最も近い親は Box コンポーザブルです。そのため、Compose は Box を再コンポーズし、Box 内のコンポーザブルも再コンポーズします。実際に使用する状態のみを読み取るようにコードを変更すると、再コンポーズが必要な要素の数を削減できます。

@Composable
fun SnackDetail() {
    // ...

    Box(Modifier.fillMaxSize()) { // Recomposition Scope Start
        val scroll = rememberScrollState(0)
        // ...
        Title(snack) { scroll.value }
        // ...
    } // Recomposition Scope End
}

@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
    // ...
    val offset = with(LocalDensity.current) { scrollProvider().toDp() }
    Column(
        modifier = Modifier
            .offset(y = offset)
    ) {
    // ...
    }
}

スクロール パラメータはラムダになりました。つまり、Title はホイストされた状態を引き続き参照できますが、値は実際に必要とされる Title 内でのみ読み取られます。その結果、スクロール値が変化すると、最も近い再コンポーズ スコープが Title コンポーザブルとなり、Compose が Box 全体を再コンポーズする必要はなくなりました。

これは良好な改善ですが、さらに改善していただくことが可能です。コンポーザブルを再レイアウトまたは再描画するためだけに再コンポーズを実施している場合は、注意が必要です。この場合は、Title コンポーザブルのオフセットを変更することのみが必要であり、この操作はレイアウト フェーズで行うことができます。

@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
    // ...
    Column(
        modifier = Modifier
            .offset { IntOffset(y = scrollProvider()) }
    ) {
      // ...
    }
}

以前、コードはオフセットをパラメータとして取る Modifier.offset(x: Dp, y: Dp) を使用していました。ラムダ バージョンの修飾子に切り替えると、関数がレイアウト フェーズのスクロール状態を読み取るように設定できます。その結果、スクロール状態が変更されると、Compose はコンポジション フェーズを完全にスキップして、レイアウト フェーズに直接進むことができます。頻繁に変更される State 変数を修飾子に渡す場合は、可能な限りラムダ バージョンの修飾子を使用する必要があります。

以下に示すのは、このアプローチのもう一つの例です。このコードはまだ最適化されていません。

// Here, assume animateColorBetween() is a function that swaps between
// two colors
val color by animateColorBetween(Color.Cyan, Color.Magenta)

Box(Modifier.fillMaxSize().background(color))

このボックスの背景色は 2 色間で急速に切り替わっています。そのため、この状態は高頻度で変化します。コンポーザブルは、この状態をバックグラウンド修飾子で読み取ります。その結果、すべてのフレームごとに色が変化するため、すべてのフレームに対してボックスを再コンポーズする必要があります。

これを改善するには、ラムダベースの修飾子(この場合は drawBehind)を使用します。つまり、色の状態は描画フェーズでのみ読み取られます。その結果、Compose はコンポジション フェーズとレイアウト フェーズを完全にスキップできます。色が変更されると、Compose は描画フェーズに直接移行します。

val color by animateColorBetween(Color.Cyan, Color.Magenta)
Box(
   Modifier
      .fillMaxSize()
      .drawBehind {
         drawRect(color)
      }
)

逆方向書き込みを回避する

Compose には、すでに読み取られた状態に書き込むことはないという重要な前提があります。このような書き込みは逆方向書き込みと呼ばれますが、行うとフレームごとに無限に再コンポーズが行われる可能性があります。

次のコンポーザブルは、このようなエラーの例を示しています。

@Composable
fun BadComposable() {
    var count by remember { mutableStateOf(0) }

    // Causes recomposition on click
    Button(onClick = { count++ }, Modifier.wrapContentSize()) {
        Text("Recompose")
    }

    Text("$count")
    count++ // Backwards write, writing to state after it has been read
}

このコードは、上記の行で読み取った後、コンポーザブルの最後でカウントを更新します。このコードを実行すると、再コンポーズを実施するためのボタンをクリックした後に、Compose がこのコンポーザブルを再コンポーズし、古い読み取り状態になっているため、カウンタが無限ループで急激に増加します。このため、別の再コンポーズがスケジュール設定されます。

Composition で状態に書き込まないようにすることで、逆方向書き込みを完全に回避できます。可能な限り、前述の onClick の例のように、常にイベントに応答してラムダで状態に書き込みます。