ベスト プラクティスを実践する

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 layouts は、アイテムをインテリジェントに再利用するように努め、必要なときにだけアイテムを再生成または再コンポーズします 。ただし、再コンポーズのために Lazy レイアウトを最適化することもできます。

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

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

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

デベロッパーがサポートしないと、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: 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 コンポーザブルです。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 { mutableIntStateOf(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 の例のように、常にイベントに応答してラムダで状態に書き込みます。

参考情報