LazyColumnのスクロールがカクカクするときの確認ポイント(Stableアノテーション)

この記事をシェア

Jetpack ComposeのLazyColumnLazyVerticalGridLazyRowも同じです)のスクロールがカクカクとコマ落ちしたような感じになってしまう場合に確認すべき項目の一つとして、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クラスのListLazyColumnitems()に渡してリストを作成するコンポーザブルがあったとします。各アイテムでは、ItemDataクラスのimageUriプロパティとtitleプロパティを参照して、画像と文字列を表示します。なお、AsyncImageCoilライブラリの非同期画像表示コンポーザブルです。

このとき、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なら再コンポジションをスキップするという判定ができます。逆に、保持している情報が異なっていてもequalstrueを返す(かもしれない)型の場合は、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()の中に仕込みます。ItemDataStableをつけたときには、それぞれのアイテムが画面内にスクロールインしてくるタイミングで一度だけログが出力されるのに対し、Stableをつけないときには、スクロールで位置が移動するたびに何度もログが出力されるのを確認できます。

この記事をシェア