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

この記事は[andfactoryアドベントカレンダー2021](https://qiita.com/advent-calendar/2021/andfactory)の3日目の記事です🎄

## はじめに

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

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

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

### 再Composeについて

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

https://developer.android.com/jetpack/compose/lifecycle?hl=ja#add-info-smart-recomposition

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

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

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

## 色々試してみる

### 今回使うコード

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

<details><summary>MainScreen.kt</summary><div>

```kotlin
@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))
                )
            }
        }
    }
}
```
</div></details>

<details><summary>MainViewModel.kt</summary><div>

```kotlin
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)
        }
    }
}
```

</div></details>

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166629/816ba242-b971-fadf-3a68-916cdaff7ce0.gif" width=320 />

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166629/1f6f6408-e7ff-4682-a391-67c887759dd7.png)

### 検証1

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

```kotlin
@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が成功するたびに呼び出されるやつです。

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

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

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

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

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

```log
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が走るようでした。

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

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

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166629/30e1963d-b5d4-2741-d4aa-0eec21368ec3.png)


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

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

### 検証2

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

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

```kotlin
@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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166629/287f5320-7da2-b279-bf2a-a43bafc44622.png)


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

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

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166629/2bc4ac34-edbe-13ff-4c8c-18739e1052a5.png)


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

### 検証3

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

```kotlin
@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をタップしてみます。
結果はこちら。

```log
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166629/1f2b5fae-0a65-e15a-637f-fb61ed847300.png)

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

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

```kotlin
@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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166629/1f17b1a6-f2e4-5a47-0c70-63a11b266ca7.png)

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

## まとめ

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

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

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