Jetpack Composeのパフォーマンスのベストプラクティス @Google I/O報告LT会

こんにちは、モバイルチーム・Androidエンジニアの東條です。

今年の5月に Google I/O 2022 のイベントがありました。 モバイルチームでは、私を含めた6名のAndroidエンジニアがイベントの中で興味のあるセッションを一つ選び、その内容について紹介するLT会を開催しました。

LT会の詳細については 社内のAndroidエンジニア達を集めてGoogle I/O報告LT会をやってみた で紹介しています。

私はセッションの中からJetpack Composeのパフォーマンス面でのベストプラクティスについて紹介した Common performance gotchas in Jetpack Compose のセッションについて発表しました。 なお、当該セッションは公式ドキュメントの Composeのパフォーマンス とほぼ同じ内容でした。

このセッションを選んだ理由は、私が担当しているサイボウズ Office 新着通知アプリでJetpack Composeを採用しており、Jetpack Composeの技術についてもっと理解を深めたいと思ったからです。

このブログでは、LT会で発表するために当該セッションと公式ドキュメントを読んでまとめた内容を紹介したいと思います。

パフォーマンスを考える上での前提知識

パフォーマンスの話をするにあたり、前提知識としてJetpack Composeの仕組みを理解する必要があります。そのため、本題に入る前に理解しておくべきJetpack Composeの仕組みについておさらいします。

Composition

UIを画面に表示する最初のステップとして、ComposeはComposableのツリー構造で表現されるCompositionを作成します。

CompositionはUIを構築するための設計図のようなものです。 Composableを呼び出すことで、対応するUI要素のインスタンスがComposition内にツリー構造で追加されていきます。

Composableを呼び出すとComposition内にインスタンスのツリーが生成される。
Compositionとは

Composition内のインスタンスは、コールサイト(ソースコード上の呼び出し位置)によって識別されています。 同一のComposableを複数回呼び出した場合、呼び出した数のインスタンスがComposition内に作成されます。 Composition内のインスタンスは公開されないため、UIの状態を変更したい場合は新しい状態でComposableを呼び出しCompositionを更新します。

Recomposition

RecompositionはComposable内で読み取っている状態に変更があったときに、Composableを再実行するプロセスを指します。

Composeで状態を扱い変化を検知するためには、オブザーバブルの Stateオブジェクト を利用します。 Composable内でStateのvalueを読み取ることで、その状態を監視するようになります。

状態が変化すると読み取り位置からもっとも近い親ComposableからRecompositionをスケジュール実行します。 読み取る状態によってRecompositionは頻繁に発生します。 例えば、スクロールの状態を読み取っていれば、スクロール中は常にRecompositionが発生し、アニメーションする状態を読み取っていれば、フレーム毎にRecompositionが発生します。

Recompositionでは、入力値が変化していないComposableの再実行はスキップされます。

以下の図で説明すると、最初にMyContentを① enabled = falseで呼び出した後に、続けて② enabled = trueで呼び出した場合、 ②の呼び出しでRecompositionが発生し、Composition内ではenabledに依存しているButtonのインスタンスだけが更新されます。

Composition内では状態が変化したComposableのインスタンスだけ更新される。
Recompositionとは

Recompositionのその他の仕組みについて知りたい場合は、公式ドキュメントの Recompositionの解説 を参照してください。

UIを表示するまでの3つのフェーズ

Composeでは、フレームを更新する際にCompositionフェーズ、Layoutフェーズ、Drawフェーズの3つのフェーズを経由します。 例外を除けば、基本的にはこの順番で進行します。

  • Compositionフェーズ

    Composableを実行し、どのようなUIを表示させるかを決めるフェーズです。これまでに説明したCompositionの構築やRecompositionはこのフェーズで実行されます。

  • Layoutフェーズ

    Compositionフェーズで作成されたCompositionを基にして、どこにUIを配置するかを決めるフェーズです。ツリー構造になっているUI要素毎に自身とその子要素の測定と配置を行います。

  • Drawフェーズ

    Layoutフェーズで配置されたUI要素をどのようにレンダリングするかを決めるフェーズです。CanvasにUI要素を描画します。

フェーズ毎に状態の読み取りを追跡するため、状態の読み取り方によって特定のフェーズから再実行することができます。 例えば、Drawフェーズのスコープで状態を読み取らせることで、状態が変化したときにCompositionフェーズとLayoutフェーズをスキップしてDrawフェーズから処理が実行されます。

Composeのパフォーマンスのベストプラクティス

それでは本題です。 セッション公式ドキュメント ではパフォーマンスのベストプラクティスとして7つのキーワードを紹介しています。

  1. remember {}
  2. Lazy List key
  3. derivedStateOf {}
  4. 読み取りの延期
  5. 逆方向書き込みの回避
  6. Baseline Profiles
  7. リリースモードでビルドする

1〜5は実装面のキーワード、6〜7はアプリ構成面のキーワードになっています。

今回は、1〜5の実装面のキーワードについて紹介します。 キーワード毎にパフォーマンスに問題があるアンチパターンのコードを示し、それに対するベストプラクティスを紹介します。

1. remember {}

次のコードは連絡先一覧をソートしてリストで表示するComposableです。ソート処理の部分でパフォーマンスの問題があります。

@Composable
fun ContactList(
    contacts: List<Contact>,
    comparator: Comparator<Contact>,
    modifier: Modifier = Modifier
) {
    LazyColumn(modifier) {
        // ❌ 連絡リストが更新されていなくても、Recompositionの度にsortの計算処理が実行されてしまう
        items(contacts.sortedWith(comparator)) { contact ->
            // ...
        }
    }
}

このコードでは、Recompositionやスクロールが発生する度にソート処理が実行されてしまいます。 連絡先データが変わらない限りソート結果は同じなのに何度もソート処理が実行されるのはパフォーマンスが悪いです。

そこで、

✅ rememberで計算回数を最小限に抑える

rememberを使用して計算した値はCompositionに保存され、Recompositionの度に保存された値を返します。 rememberの引数のkeyに指定した状態が変化するまでは再度計算し直すことはありません。

今回の場合、ソート処理をremember{}でラップします。 これによって、ソート処理は初回呼び出し時に実行され、以降はkeyに設定しているcontactsとsortComparatorの値が変更されるまで計算結果が再利用されます。

@Composable
fun ContactList(
    contacts: List<Contact>,
    comparator: Comparator<Contact>,
    modifier: Modifier = Modifier
) {
    // ✅ rememberで初回Composition時にソート済みのリストを作成
    // contacts,sortComparatorの値が変更されるまで再計算されない
    val sortedContacts = remember(contacts, sortComparator) {
        contacts.sortedWith(sortComparator)
    }
    LazyColumn(modifier) {
        // sortedContactsはキャッシュに保存されたソート済みリストが使われる
        items(sortedContacts) {
          // ...
        }
    }
}

rememberはTextFieldの入力中の状態やButtonの有効状態のような、Composableに閉じた状態を保持する場面で利用するのが効果的です。 今回の例のようなUIと切り離せる計算はなるべくComposableの外で行い、ComposableではUIの表示に専念することが推奨されています。

サイボウズ Office 新着通知アプリでは、UIと切り離せる計算はViewModelとその下層で処理し、ViewModelはUIの状態を表すViewDataをComposableに公開しています。 状態をComposableから切り離すことで、Composableはステートレスになり、再利用性が上がりテストがしやすくなるメリットがあります。

2. Lazy List key

引き続き一つ前のコードを例に説明します。 このLazyColumnのitemsの指定方法は、リスト内でのアイテムの位置が変更された場合にパフォーマンスの問題が発生します。

@Composable
fun ContactList(
    contacts: List<Contact>,
    comparator: Comparator<Contact>,
    modifier: Modifier = Modifier
) {
    // ...
    LazyColumn(modifier) {
        // ❌ contactsの順番が変わるだけで、ContactItemのRecompositionが起きる
        items(sortedContacts) { contact ->
          ContactItem(contact)
        }
    }
}

Composeの仕組みでは、Compositionの中のインスタンスはコールサイトによって識別されます。 また、リストのアイテムのようにComposableを同じコールサイトから呼び出す場合は、Composableを呼び出した順番が識別子として使用されます。

今回の例でいうと、Composeは各ContactItemをリスト内での位置で識別することになります。

ここで、アイテムの追加や削除のようなリストの中身の位置が入れ替わる更新があったとします。 位置が入れ替わったことで、Composeは更新前後のアイテムを同じインスタンスだと識別できません。 そのため、位置が変わった全てのアイテムでRecompositionが発生してしまいます。

アイテムの内容は変わっていないのに、リスト内での位置が変わるだけでアイテムのComposableを再実行するのはパフォーマンスが悪いです。

Lazy List keyを使わない場合、位置がずれた全てのアイテムでRecompositionが発生
Lazy List keyを使わない場合

そこで、

✅ リストのアイテムにユニークなIDを指定してアイテムのRecompositionをスキップする

items()の第2引数ではアイテム毎に key を指定することができます。 keyを指定すると、リスト内でのアイテムはkeyで指定したIDで識別されるようになります。そのため、アイテムの位置が変わった場合でも更新前後のインスタンスは同一であると認識され、アイテムのRecompositionはスキップされます。

@Composable
fun ContactList(
    contacts: List<Contact>,
    comparator: Comparator<Contact>,
    modifier: Modifier = Modifier
) {
    // ...
    LazyColumn(modifier) {
        // ✅ 各アイテムにユニークなkeyを指定してやることで
        // アイテムの並び替えなどで発生するContactItemの不要なRecompositionを防止
        items(sortedContacts, key = { item -> item.id }) { contact ->
          ContactItem(contact)
        }
    }
}

Lazy List keyを使った場合、アイテムの位置が変わってもRecompositionは発生しない
Lazy List keyを使った場合

3. derivedStateOf {}

次のコードは、リストのスクロールで最初のアイテムが画面から見切れるのを条件にリストのトップに戻るボタンを表示します。 ボタンを表示する判定式の部分でパフォーマンスの問題があります。

val listState = rememberLazyListState()
LazyColumn(state = listState) {
    // ...
}
// ❌ リストがスクロールされる度にlistStateは変化するため、頻繁に計算が実行される
val showButton = listState.firstVisibleItemIndex > 0
AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}

このコードでは、listStateがスクロール中に何度も更新されるため、その度に判定式の計算が不必要に実行されてしまいます。

今回の場合、理想的な判定式の計算タイミングはfirstVisibleItemIndexが変化したときです。それ以上に計算が実行されるのはパフォーマンスが悪いです。

そこで、

✅ derivedStateOfで計算回数を最小限に抑える

derivedStateOfはある状態から新しい状態を計算するときに利用します。

特徴として、派生元の状態が変化したときだけ新しい状態を算出します。 またderivedStateOfによって生成された値を変更した場合、その状態が宣言されているComposableではRecompositionは発生しません。 値を読み取っているComposableのみがRecompositionの対象となります。

今回の場合、判定式の計算をderivedStateOfでラップしてrememberで状態を保持します。 これにより、ListState#firstVisibleItemIndexが変更されたときだけ計算が実行されるようになり、計算回数を抑えることができます。

val listState = rememberLazyListState()
LazyColumn(state = listState) {
  // ...
  }
val showButton by remember {
    // ✅ firstVisibleItemIdexの変化をトリガーにして値を算出する
    derivedStateOf {
        listState.firstVisibleItemIndex > 0
    }
}
AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}

4. 読み取りの延期

次のコードは、Boxの背景色が2色の間で変化し続けるアニメーションの実装です。

val color by animateColorBetween(Color.Cyan, Color.Magenta)
// ❌ colorが変化する度にBoxのRecompositionが発生する
// colorの変化でComposition、Layoutフェーズが実行されるのは無駄
Box(Modifier.fillMaxSize().background(color))

アニメーションによって頻繁にcolorの値が変化し、変化の度にBoxのRecompositionが発生します。 colorは背景色を変えるだけなので、Compositionフェーズ(どのUIを表示させるか)、Layoutフェーズ(どこにUIを配置するか)が再実行されるのはパフォーマンスが悪いです。

そこで、

✅ 状態の読み取りを適切なフェーズまで延期させ不必要なフェーズの実行をスキップする

Modifier関数のラムダの中には特定のフェーズで呼び出されるものがあります。そのラムダの中で状態の読み取りを行うことで、状態の更新が合った場合に特定のフェーズから再実行させることができます。

今回の場合、Modifier.drawBehind(onDraw: DrawScope.() -> Unit) のラムダの中で背景色を変えます。 このラムダの中はDrawフェーズで読み取られるため、colorが変化するとDrawフェーズから再実行され、それ以外のフェーズはスキップされます。

val color by animateColorBetween(Color.Cyan, Color.Magenta)
Box(
   Modifier
      .fillMaxSize()
      .drawBehind {
         // ✅ drawBehindのラムダでStateを読み取ることで、
         // 色が変化するとDrawフェーズのみ再実行される
         drawRect(color)
      }
)

読み取りの延期をした場合、Drawフェーズから再実行される
読み取りの延期をした場合

drawBehindの他にも、Layoutフェーズで読み取られる Modifier.offset(offset: Density.() -> IntOffset) もあります。

5. 逆方向書き込みの回避

逆方向書き込みはComposableが状態を読み取った後に、同じフレーム内でその状態に値を書き込むアンチパターンです。

以下のコードは逆方向書き込みの例で、ボタンとボタンを押した回数を表示するテキストがあり、最後にcountをインクリメントして更新しています。

@Composable
fun BadComposable() {
    var count by remember { mutableStateOf(0) }
    Button(onClick = { count++ }, Modifier.wrapContentSize()) {
        Text("Recompose")
    }
    Text("$count")
    // ❌ countの状態が読み取られた後に値を更新すると
    // Recompositionが発生し、それを繰り返すことで無限ループしてしまう
    count++
}

Textでcountの状態を読み取った後に、countの値が書き込まれるとBadComposableのRecompositionが発生します。 すると再びcountの読み取り後に書き込みが行われ、結果的にRecompositionの無限ループになります。

そこで、

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

今回の場合、最後のcountのインクリメントのコードを削除することで逆方向書き込みを回避します。

Composableではデータの流れは単一方向であるべきなので、状態の更新は常にイベントのコールバックを通して行われるべきです。 そうすることで逆方向書き込みを回避することができます。

まとめ

以上、Jetpack Composeのパフォーマンスに対するベストプラクティスの紹介でした。

Jetpack Composeを使えば簡単にUIを構築できる反面、パフォーマンスの落とし穴に注意を払う必要があります。 特に、状態が頻繁に変わるようなアニメーションやリストの状態を読み取る場合はパフォーマンスの観点が大事だと感じました。

サイボウズ Office 新着通知アプリでは、今回紹介したベストプラクティスを採り入れられる箇所はありませんでしたが、 今後機能が増えてパフォーマンスの問題に直面した場合に、ベストプラクティスを試して見ようと思います。

ユーザに高品質のアプリを届けるためにも、上辺だけで技術を扱うのではなく、仕組みを理解し正しく利用できるように努めていきたいと思います。

参考リンク