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 は SnackDetail
と SnackDetail
内のコンポーザブルを再コンポーズします。実際に使用した状態のみを読み取るようにコードを変更すると、再コンポーズが必要な要素の数を減らすことができます。
@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
の例のように、常にイベントに応答してラムダで状態に書き込みます。
その他のリソース
- アプリのパフォーマンス ガイド: Android でのパフォーマンスを向上させるためのベスト プラクティス、ライブラリ、ツールを確認できます。
- パフォーマンスの検査: アプリのパフォーマンスを検査します。
- ベンチマーク: アプリのパフォーマンスをベンチマークします。
- アプリの起動: アプリの起動を最適化します。
- ベースライン プロファイル: ベースライン プロファイルについて理解します。
あなたへのおすすめ
- 注: JavaScript がオフになっている場合はリンクテキストが表示されます
- 状態と Jetpack Compose
- グラフィック修飾子
- Compose の思想