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

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

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

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

重要な手法として、remember を使用した計算結果の保存があります。この手法を使用すると、計算を一度だけ実行して、必要になるたびに計算結果を取得できます。

たとえば、以下のコードは、並べ替えられた名前のリストを表示しますが、非常にコストの高い方法で並べ替えを行っています。

@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, comparator) {
        contacts.sortedWith(comparator)
    }

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

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

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

Lazy レイアウトは、アイテムをインテリジェントに再利用するように努め、必要なときにだけアイテムを再生成または再コンポーズします。ただし、デベロッパーは、最適な判断を行うようにこの処理をサポートできます。

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

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

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

デベロッパーのサポートがなければ、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 は、詳細画面に折りたたみツールバーのような効果を実装します。この手法が機能する理由については、ブログ投稿「Debugging Recomposition」をご覧ください。

この効果を実現するには、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 は最も近い親再コンポーズ スコープを見つけて無効にします。この例では、最も近いスコープは SnackDetail コンポーザブルです。注: ボックスはインライン関数なので、再コンポーズ スコープとしては機能しません。したがって、Compose は SnackDetail を再コンポーズし、SnackDetail 内のコンポーザブルもすべて再コンポーズします。実際に使用する場合にのみ State を読み取るようにコードを変更すると、再コンポーズが必要になる要素の数を削減できます。

@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(x = 0, 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</b>
}

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

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