Jetpack ComposeのLazyColumn
(LazyVerticalGrid
やLazyRow
も同じです)のスクロールがカクカクとコマ落ちしたような感じになってしまう場合に確認すべき項目の一つとして、Stable
アノテーションを説明します。Stable
アノテーションを正しく使うことによって、不要な再コンポジションをスキップし、パフォーマンスを改善することができます。
目次
LazyColumnのitemsに渡すデータはStableにしよう
LazyColumn
でリストを作成する際に、それぞれのアイテムに表示するデータを一つのクラスで保持し、それをList
にしてitems()
に渡すというのはよくあるパターンです。このとき、データを保持するクラスがStableであれば、アイテムが最初に画面に表示されるときにコンポジションが発生し、その後は画面から消えるまで、データが変更されない限り、再コンポジションは発生しません。ところが、データを保持するクラスがStable
ではない場合、スクロールでアイテムの位置が少し移動するたびに再コンポジションが発生し、非常に負荷が高くなります。
具体例を示します。
@Composable
fun AppScreen(items: List<ItemData>) {
LazyColumn {
items(items) { item ->
Row(
...
) {
AsyncImage(
model = item.imageUri,
...
)
Text(text = item.title)
}
}
}
}
このように、ItemData
クラスのList
をLazyColumn
のitems()
に渡してリストを作成するコンポーザブルがあったとします。各アイテムでは、ItemData
クラスのimageUri
プロパティとtitle
プロパティを参照して、画像と文字列を表示します。なお、AsyncImage
はCoil
ライブラリの非同期画像表示コンポーザブルです。
このとき、ItemData
クラスには次のようにStable
アノテーションをつけておく必要があります。
@Stable
data class ItemData(val title: String, val imageUri: Uri)
ItemData
クラスは自作クラスで、Stable
アノテーションがついていなければ、Composeコンパイラは安定した型とはみなしません。そのため、実際のデータに変化がなくても、スクロールで位置が変化するたびにLazyColumn
のアイテム一つ一つが再描画されてしまいます。これはかなりの負荷になり、画像を表示している場合などはスクロールの動きにも影響が出ます。
(ご注意)
上で示した例をそのまま実行させた場合、Stable
が無い場合に必ず再コンポジションが発生するとは限らないようです。実際のプロジェクトでは、ViewModelの更新の監視をしていたり、DIが間に挟まっていたりするので、そのあたりも再コンポジションの有無の判定に関わってくるのかもしれません。このあたりはまた何か分かったら記事にしたいと思います。
再コンポジションのスキップ
ここからは、再コンポジションのスキップと型の安定について説明し、Stable
アノテーションの意味も説明していきます。
Jetpack Composeでは、動的なUIを実現するために、コンポーザブルの状態が変化したことをComposeランタイムが検出すると、UIツリー全体を再描画します。これを再コンポジションと言います。
ただし、すべてのコンポーザブルを再描画しているとパフォーマンスに問題が出るため、更新が不要な部分は再描画しません。これを再コンポジションのスキップと言います。
コンポーザブルがスキップ可能と判断される条件は、コンポーザブルの状態が安定していて変化がないことです。例えば、次の例のinputText
変数は、Text
コンポーザブルに表示するための文字列を保持しているので、状態を表す変数です。後で説明しますが、String
型は安定した型です。したがって、String
型の変数の値に変化がなければ、このコンポーザブルの再コンポジションはスキップされます。
@Composable
fun textComposable(inputText: String) {
Text(text = inputText)
}
型の安定
型が「安定している」とは、クラスやインターフェースが次の条件を満たすことを言います。
- 2 つのインスタンスの
equals
の結果が、同じ 2 つのインスタンスについて常に同じになる。 - 型の公開プロパティが変化すると、Composition に通知される。
- すべての公開プロパティの型も安定している。
一つ目は、再コンポジションをスキップするかどうかの判定に必要な条件です。同じインスタンスに対してequals
が返す結果が常に同じなら、Composeは新旧の状態変数をequalsで比較し、true
なら再コンポジションをスキップするという判定ができます。逆に、保持している情報が異なっていてもequals
がtrue
を返す(かもしれない)型の場合は、equals
に結果にかかわらず再コンポジションが必要になります。このような型は「不安定」な型と言います。
二つ目の条件に出てくる「通知」とは、MutableState
型のようにComposeに値の変更を通知する仕組みのことです。あるいは、イミュータブルな型もこの条件を満たします。変化がないため通知が必要ないからです。
以下に挙げる型は、Composeコンパイラが安定しているとみなします。
- プリミティブ型(Int, Float, Booleanなど)
- 文字列
- 関数型(ラムダ)
Stable
アノテーションがついたクラスやインターフェース
Stableアノテーション
Stable
アノテーションは、型が「安定している」ことをComposeコンパイラに伝えるためのアノテーションです。次の例のように、クラスやインターフェースの定義と合わせて使うと、Composeによってそのクラスやインターフェースは安定しているとみなされ、変化がない(新旧の状態変数をequals
で比較した時の結果がtrue
になる)場合は再コンポジションがスキップされます。
@Stable
data class ItemData(val title: String, val imageUri: Uri)
ちなみにdata classの場合、equals
は自動生成されます。自動生成されたequals
は、プライマリコンストラクタ内で定義されているプロパティ(クラス名の直後のカッコ内で定義されているプロパティ)をそれぞれequals
で比較した結果がすべてtrue
だった場合に、true
を返します。
よく使うものの中では、例えばMutableState
の定義にはStable
がマークされています。
@Stable
interface MutableState<T> : State<T> {
override var value: T
operator fun component1(): T
operator fun component2(): (T) -> Unit
}
再コンポジションがスキップされていることを確認する
再コンポジションの回数をログ出力する便利なスニペットがあります。オリジナルのコードを見つけることができなかったのですが、Sean McQuillanという人が最初に書いたものが出回っているようです。
class Ref(var value: Int)
@Composable
inline fun LogCompositions(tag: String) {
if (EnableDebugCompositionLogs) {
val ref = remember { Ref(0) }
SideEffect { ref.value++ }
Log.d(tag, "Compositions: ${ref.value}")
}
}
このLogCompositions()
をitems()
の中に仕込みます。ItemData
にStable
をつけたときには、それぞれのアイテムが画面内にスクロールインしてくるタイミングで一度だけログが出力されるのに対し、Stable
をつけないときには、スクロールで位置が移動するたびに何度もログが出力されるのを確認できます。