40
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

and factory.incAdvent Calendar 2021

Day 3

【Jetpack Compose】Stateと再Composeの仕組みを検証してみた

Last updated at Posted at 2021-12-03

この記事はandfactoryアドベントカレンダー2021の3日目の記事です🎄

はじめに

Jetpack Composeを書いていて、Stateを更新すると画面が更新されると思うのですが、その際に再Compose処理が発生します。再Compose処理はドキュメントによると「入力が変化している」部分で発生し、そうでない部分は回避してくれるようです。

ですが、じゃあ具体的にどのように書いたら回避されて、どう書いていたら回避されないのかがいまいち理解できていませんでした。

特に、ViewModelで保持するFlowやLiveDataのObserve場所です。自分は基本的に、ViewModelの値をComposeの上位でobserveして、他の下位Composeはシンプルな値を引数をとるように実装しています。
その場合、ViewModelの値が変わるたびに全体が再Composeされるのか、よしなになってくれるのかがわからなかったので、今回実際にコードを書いて試してみることにしました💡

再Composeについて

再Composeについての説明は公式のドキュメントにまとまっています。

再Composeとは、値が変化したときなどに状態を画面に反映させるためComposeが再構築されることです。
画面の更新には必要不可欠の処理ですが、この再Composeの範囲が大きいとパフォーマンスが悪くなります。

通常、再コンポジションは State オブジェクトの変更によってトリガーされます。Compose はそうした変更をトラッキングし、特定の State を読み取る Composition 内のすべてのコンポーザブルと、スキップできない呼び出し対象コンポーザブルを実行します。

再コンポジションの際にコンポーザブルが前回のコンポジションのときと異なるコンポーザブルを呼び出した場合、Compose はどのコンポーザブルが呼び出され、どのコンポーザブルが呼び出されなかったかを識別し、両方のコンポジションで呼び出されたコンポーザブルについては、入力が変化していなければ再コンポジションを回避します。

色々試してみる

今回使うコード

今回試すにあたって、こんな感じのコードで試してみました。

MainScreen.kt
@ExperimentalMaterialApi
@Composable
fun MainScreen(viewModel: MainViewModel = viewModel()) {
    val cards by viewModel.cards.collectAsState()

    Body(cards, onClickCard = viewModel::onClickCard)
}

@ExperimentalMaterialApi
@Composable
private fun Body(cards: List<CardData>, onClickCard: (id: Int) -> Unit) {
    Column(modifier = Modifier.fillMaxSize()) {
        LazyColumn(modifier = Modifier.fillMaxSize()) {
            item { Header("State with Recompose") }
            item { Spacer(modifier = Modifier.height(16.dp)) }
            cards.forEach {
                item {
                    CardCompose(
                        modifier = Modifier
                            .fillMaxWidth()
                            .padding(horizontal = 32.dp),
                        card = it,
                        onClick = onClickCard,

                        )
                    Spacer(modifier = Modifier.height(24.dp))
                }
            }
        }
    }
}

@Composable
private fun Header(text: String) {
    Box(
        contentAlignment = Alignment.Center,
        modifier = Modifier
            .height(150.dp)
            .fillMaxWidth(),
    ) {
        Text(text, fontSize = 22.sp)
    }
}

@ExperimentalMaterialApi
@Composable
private fun CardCompose(modifier: Modifier = Modifier, card: CardData, onClick: (id: Int) -> Unit) {
    Card(
        modifier = modifier.wrapContentHeight(),
        shape = RoundedCornerShape(8.dp),
        elevation = 4.dp,
        onClick = { onClick(card.id) },
    ) {
        Box(Modifier.fillMaxWidth()) {
            Row(modifier = Modifier.fillMaxWidth()) {
                Image(
                    painter = rememberImagePainter(card.url),
                    contentDescription = null,
                    modifier = Modifier.size(80.dp)
                )

                Column(
                    modifier = Modifier
                        .wrapContentHeight()
                        .padding(8.dp)
                ) {
                    Text(card.title, fontSize = 22.sp)
                    Text(card.description, fontSize = 16.sp, softWrap = true)
                }
            }

            if (!card.enable) {
                Box(
                    modifier = Modifier
                        .matchParentSize()
                        .background(Color(0x88FFFFFF))
                )
            }
        }
    }
}
MainViewModel.kt
data class CardData(
    val id: Int,
    val url: String,
    val title: String,
    val description: String,
    val enable: Boolean,
)

class MainViewModel : ViewModel() {

    private val _cards = MutableStateFlow(
        (1..20).toList().map {
            CardData(
                id = it,
                url = "https://placehold.jp/3d4070/ffffff/80x80.png?text=Image",
                title = "Card $it",
                description = "description description",
                enable = true,
            )
        }
    )
    val cards: StateFlow<List<CardData>> get() = _cards

    fun onClickCard(id: Int) {
        viewModelScope.launch {
            val update = cards.value.map {
                it.copy(
                    enable = if (it.id == id) {
                        !it.enable
                    } else {
                        it.enable
                    }
                )
            }
            _cards.emit(update)
        }
    }
}

Cardをリスト表示するサンプルです。ViewModelにカードのリストデータをFlowで保持しています。
そしてタップするとFlowを更新してカードの活性非活性をトグル表示しています。

Composeの関係性は下記のような構成になっています。

image.png

検証1

まず最初に、ViewModelの値を上位のComposeでobserveしてみます。

@ExperimentalMaterialApi
@Composable
fun MainScreen(viewModel: MainViewModel = viewModel()) {
    val cards by viewModel.cards.collectAsState()

    Body(cards, onClickCard = viewModel::onClickCard)
}

@ExperimentalMaterialApi
@Composable
private fun Body(cards: List<CardData>, onClickCard: (id: Int) -> Unit) {
    // ...
}

@ExperimentalMaterialApi
@Composable
private fun CardCompose(modifier: Modifier = Modifier, card: CardData, onClick: (id: Int) -> Unit) {
    // ...
}

こんな感じです。cardリストデータはMainScreenでobserveし、値を下位のComposeに渡しています。下位のComposeはStateではなく Card 型を受け取っています。

この場合どこが再Composeされるのでしょうか。

再Composeの判定にあたっては SideEffect を使ってログ表示してみました。
SideEffect はCompositionが成功するたびに呼び出されるやつです。

@ExperimentalMaterialApi
@Composable
fun MainScreen(viewModel: MainViewModel = viewModel()) {
    val cards by viewModel.cards.collectAsState()

    SideEffect {
        Log.d("MainScreen", "composition!")
    }

    Body(cards, onClickCard = viewModel::onClickCard)
}

これでComposeが呼び出されるたびにログが表示されます。

まずは初回起動のログ表示です。
普通に全部の箇所のログが表示されますね。

D/MainScreen: composition!
D/Body: composition!
D/Header: composition!
D/CardCompose: composition! card id = 1
D/CardCompose: composition! card id = 2
D/CardCompose: composition! card id = 3
D/CardCompose: composition! card id = 4
D/CardCompose: composition! card id = 5
D/CardCompose: composition! card id = 6

スクロールしていくと、CardComposeのログが増えていきます。カードデータは全部で20件なのですが、ある程度スクロールすると破棄されて、また画面に入るたびに再Composeが走るようでした。

では早速カードをタップして状態を更新してみます!

D/MainScreen: composition!
D/Body: composition!
D/CardCompose: composition! card id = 2

カード2をタップして表示を切り替えてみたときのログです。
結果はFlowをObserveしているところからCardまで再Composeが発生していました。
ですが、CardComposeのログが1件して表示されていません。タップして状態が変化したCardComposeのみが再Composeされており、他のComposeは走ってませんでした💡

image.png

赤色にしている部分が再Composeが走ったところです。Bodyは再Composeされてますが、Headerや関係のないCardは再Composeされていません。

正直、僕はMainScreenから値の変更を通知した場合はまるごと再Composeされるとばかり思っていました。
いや、これめっちゃ賢いぞ。。🤔

検証2

もう一つ検証してみます。
今はCardComposeは単一のComposeですが、この中のImageの部分やテキストの部分を適当に階層分けしてみた場合、上のComposeの状態が変更されて、下位は更新されないケースはどうでしょうか。

こんな感じで適当に階層を増やしてみました。

@Composable
private fun CardThumbnail(modifier: Modifier = Modifier, url: String) {
    Image(
        painter = rememberImagePainter(url),
        contentDescription = null,
        modifier = modifier
    )
}

@Composable
private fun CardTitle(title: String) {
    Text(title, fontSize = 22.sp)
}

@Composable
private fun CardDescription(description: String) {
    Text(description, fontSize = 16.sp, softWrap = true)
}

image.png

urltitle description は全部Cardの値です。タップされたときに更新されるのはenableなので値の変更はないのですが、対象のCardは更新されているわけで、、なんとなくこの子要素たちも再Composeされそうな予感がしていますがどうでしょうか。

同様に新しい子要素にもSideEffectとログを仕込んで、2つ目のカードをタップしてみました。
結果はこちら!

D/MainScreen: composition!
D/Body: composition!
D/CardCompose: composition! card id = 2

image.png

おおすごい!値が変わっていない部分は再Composeが走りません。

検証3

今度はStateの値の渡し方をちょっと変えてみます。カードの値をStateのまま渡した場合はどうでしょうか。
階層構造は変更しませんが、MainScreenからBodyへの値の渡し方だけ変えてみます。

@ExperimentalMaterialApi
@Composable
fun MainScreen(viewModel: MainViewModel = viewModel()) {
    val cards = viewModel.cards.collectAsState()
    Body(cards, onClickCard = viewModel::onClickCard)
}

@ExperimentalMaterialApi
@Composable
private fun Body(cards: State<List<CardData>>, onClickCard: (id: Int) -> Unit) {
    Column(modifier = Modifier.fillMaxSize()) {
        LazyColumn(modifier = Modifier.fillMaxSize()) {
            item { Header("State with Recompose") }
            item { Spacer(modifier = Modifier.height(16.dp)) }

            // ここでState.valueを呼び出す
            cards.value.forEach {
                item {
                    CardCompose(
                        modifier = Modifier
                            .fillMaxWidth()
                            .padding(horizontal = 32.dp),
                        card = it,
                        onClick = onClickCard,

                        )
                    Spacer(modifier = Modifier.height(24.dp))
                }
            }
        }
    }
}
}

この状態でカード2をタップしてみます。
結果はこちら。

D/CardCompose: composition! card id = 1
D/CardCompose: composition! card id = 2
D/CardCompose: composition! card id = 3
D/CardCompose: composition! card id = 4
D/CardCompose: composition! card id = 5
D/CardCompose: composition! card id = 6

image.png

な、なるほど。。たしかにLazyColumnのなかで cards.value しているのでこうなるんですね。必要ないCardの再Composeも走ってしまっています。

ではBodyでの受け取り方を下記のようにしてみたらどうでしょうか。

@ExperimentalMaterialApi
@Composable
private fun Body(cardsState: State<List<CardData>>, onClickCard: (id: Int) -> Unit) {
    val cards = cardsState.value

    Column(modifier = Modifier.fillMaxSize()) {
        LazyColumn(modifier = Modifier.fillMaxSize()) {
            item { Header("State with Recompose") }
            item { Spacer(modifier = Modifier.height(16.dp)) }
            cards.forEach {
                item {
                    CardCompose(
                        modifier = Modifier
                            .fillMaxWidth()
                            .padding(horizontal = 32.dp),
                        card = it,
                        onClick = onClickCard,

                        )
                    Spacer(modifier = Modifier.height(24.dp))
                }
            }
        }
    }
}

ログは割愛しますが、期待した通りの結果となりました。ObserveしているMainScreenでは再Composeが走らずに、Bodyからになっています。

image.png

State.valueをしたComposeから変更検知となるのでMainScreenの再Composeはスキップすることができていますが、、正直わざわざStateの形式で引数に渡していく必要性はないかもですね。

まとめ

  • State.valueを呼び出したComposeは再Composeされる
  • 引数の値が変わっていれば再Compose。自身の引数が変わっていなければ、親が再Composeされていても自分は再Composeされない!
  • ViewModelの値は親のComposeでObserveして問題なさそう

再Composeの仕組みが自分で検証してみて理解することができました🤗
今まで、親のComposeが再Composeされると子も再Composeされるのかとばかり勘違いしていたのですが、もっと賢い動きをしていました(笑)

この基本的な挙動に加えて、LazyColumnなどではkeyを使うことで再Composeの最適化などもできるようですが、普通に使ってても賢く更新処理してくれてました。まずは普通に実装して、パフォーマンス改善したいときなどに検討してみれば良さそうです。

40
17
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
40
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?