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

Compose でよくある問題に遭遇することがあります。このようなミスにより、コードが十分に動作しているように見えても、UI のパフォーマンスが損なわれる可能性があります。ベスト プラクティスに沿って Compose でアプリを最適化します。

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, 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 は、変更されていないアイテムがリスト内で単に移動されることを認識しません。代わりに、Compose は古い「アイテム 2」が削除され、アイテム 3、アイテム 4、それ以降のすべてのアイテムに新しいアイテムが作成されたと見なします。その結果、実際に変更されたのは 1 つのアイテムだけであっても、Compose はリスト内のすべてのアイテムを再コンポーズします。

この場合の解決策は、アイテムキーを指定することです。各アイテムに安定したキーを提供することで、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 では、詳細画面に折りたたみツールバーのような効果が実装されています。この手法が機能する理由については、ブログ投稿 Jetpack Compose: 再コンポーズのデバッグをご覧ください。

この効果を実現するには、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 コンポーザブルです。Box はインライン関数であるため、再コンポーズ スコープではありません。そのため、Compose は SnackDetailSnackDetail 内のコンポーザブルを再コンポーズします。実際に使用した状態のみを読み取るようにコードを変更すると、再コンポーズが必要な要素の数を減らすことができます。

@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 の例のように、常にイベントに応答してラムダで状態に書き込みます。

その他のリソース