JetpackComposeのViewModelにおける状態管理を試してみた

現在JetpackComposeについて勉強していてViewModelで状態管理する場合はどうするのがいいのか気になったので調べてみました。
なお本記事の内容以外にも方法はたくさんあるので一例として参考程度に見ていただければと思います。

# 方針
実装の方針として以下２点に重点をおいて調査しました。

1. 再コンポーズの際に変更のある関数だけが再実行される [^1]
1. 状態を１つのクラスで管理する

[^1]: [Compose の思想 <再コンポーズ>](https://developer.android.com/jetpack/compose/mental-model#recomposition)に関連する記載があります。

# さきに結論
`mutableStateOf` を使って状態を管理するクラスをラップすると比較的簡単かと思います。
他に `LiveData` `Flow` `RxJava2` も利用できるとのことで[^2]、その中でLiveDataだけよく使っていたので試したのですが2つの方針を同時に満たすことが難しそうでした。（最後におまけで記載します）

[^2]: [状態と Jetpack Compose <その他のサポートされている状態の種類>](https://developer.android.com/jetpack/compose/state?hl=ja#use-other-types-of-state-in-jetpack-compose)に記載されています。

# 作成物
今回検証のために作成したアプリはこのようなものです。赤、緑、青をそれぞれ入力して四角の色をそれらの値から生成したRGBで色付けします。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149859/23649c9d-a762-45fc-95f0-720cb68a011a.gif" width="300px" />


## レイアウト
こちらの画面は主に３つのComposable関数から構成されています。（なお再コンポーズが行われるか確認するために全ての関数の最初にログを仕込んでいます）


```kotlin
@Composable
fun ImageShape(modifier: Modifier = Modifier, color: Color) {
    Log.d("compose", "ImageShape")
    Box(
        modifier = modifier
            .size(150.dp)
            .clip(shape = RectangleShape)
            .background(color)
    )
}
```
四角を作るComposable関数。適応する色は引数でもらいます。  

<br>

```kotlin
@Composable
fun InputColorField(
    modifier: Modifier = Modifier,
    value: Int,
    label: String,
    changeValue: (Int) -> Unit
) {
    Log.d("compose", "InputColorField $label")
    TextField(
        modifier = modifier
            .width(100.dp),
        value = value.toString(),
        onValueChange = {
            if (it.isNotBlank()) {
                changeValue(it.toInt())
            }
        },
        label = { Text(label) },
        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
    )
}
```
色ごとの値の入力を受け付けるComposable関数。表示する値は引数でもらい、値の変更があった場合は引数の関数（changeValue）に渡します。
<br>


```kotlin
@Composable
fun ImageSampleScreen(uiState: ImageSampleUiState, inputTextField: (Int, Color) -> Unit) {
    Log.d("compose", "ImageSampleScreen")
    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Top,
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        val color = Color(red = uiState.red, blue = uiState.blue, green = uiState.green)
        ImageShape(Modifier.padding(20.dp), color = color)

        Row() {
            InputColorField(value = uiState.red, label = "Red") {
                inputTextField(it, Color.Red)
            }
            InputColorField(value = uiState.green, label = "Green") {
                inputTextField(it, Color.Green)
            }
            InputColorField(value = uiState.blue, label = "Blue") {
                inputTextField(it, Color.Blue)
            }
        }
    }
}

data class ImageSampleUiState(
    var red: Int,
    var blue: Int,
    var green: Int,
)
```
画面のレイアウトです。先ほど紹介したComposable関数を配置したものです。状態に関わる情報は全て引数の `uiState:ImageSampleUiState` からもらいます。また各色の入力欄に値の変更があれば、どの色を、どんな値にしたかを呼び出し元に伝えます。

レイアウトをスクリーンショットと照らし合わせるとこんな感じです
![スクリーンショット 2021-12-21 17.14.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149859/1c392f1f-f714-f621-67cc-8fed6e820984.png)

## ViewModelの実装とComposable関数との連携

```kotlin
class ImageSampleViewModel : ViewModel() {

    var uiState by mutableStateOf(ImageSampleUiState(red = 0, blue = 0, green = 0))
        private set

    fun changeRed(value: Int) {
        uiState = uiState.copy(red = value)
    }

    fun changeGreen(value: Int) {
        uiState = uiState.copy(green = value)
    }

    fun changeBlue(value: Int) {
        uiState = uiState.copy(blue = value)
    }
}
```
ViewModelには赤と緑と青のそれぞれを変更するメソッドを用意し、copy関数を使って特定の値のみ変更してuiStateを更新します。
ViewModelを扱うActivityは以下のようなコードになります。


```kotlin
class MainActivity : ComponentActivity() {

    val viewModel by viewModels<ImageSampleViewModel>()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            Surface() {
                MainActivityScreen(viewModel)
            }
        }
    }
}

@Composable
private fun MainActivityScreen(viewModel: ImageSampleViewModel) {
    ImageSampleScreen(uiState = viewModel.uiState, inputTextField = { value, color ->
        when (color) {
            Color.Red -> viewModel.changeRed(value)
            Color.Green -> viewModel.changeGreen(value)
            Color.Blue -> viewModel.changeBlue(value)
            else -> {
            }
        }
    })
}
```
`ImageSampleScreen`に`viewModel`の`uiState`を渡し、入力欄に変更があれば`viewModel`の関数を実行します。

## 動作結果
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149859/23649c9d-a762-45fc-95f0-720cb68a011a.gif" width="300px" />
赤、緑、青の入力変更にあわせて四角の色が変わります。

ログはこんな感じ
![スクリーンショット 2021-12-21 18.33.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149859/34fcd809-9784-27e0-3b8f-78663e7402de.png)

ImageSampleScreenとImageShapeは毎回再実行されていますが赤・緑・青の入力欄は変更があった時のみ再実行されるようになっています。

<font color="Red">※今回の調査の中でたまに全てのComposableが値の変更にかかわらず必ず再コンポーズされる事象が発生しました。たまたまかもしれませんが `Clean Project` をしたら解消されたので、もし同様のことが発生した場合は `Clean Project` をお試しください</font>

# まとめと感想
`mutableStateOf`を活用すれば１つのデータクラスで複数パラメータの状態管理をしても、再コンポーズ時には変更のある値に関連するComposable関数のみ実行されることがわかりました。
思ったより簡単にやりたいことが実現できたのでこれからは非同期処理との連携方法など調べてうまく活用できるようになりたいと思います。


# おまけ（LiveDataで実装）
状態を1つのクラスで管理しようとすると、LiveDataの場合全てのComposableが再実行されてしまいました。
どういう実装にしたかというとViewModelは以下のような感じ

```kotlin
class ImageSampleViewModel : ViewModel() {

    private val _uiState = MutableLiveData(ImageSampleUiState(red = 0, blue = 0, green = 0))
    var uiState: LiveData<ImageSampleUiState> = _uiState

    fun changeRed(value: Int) {
        _uiState.value = uiState.value?.copy(red = value)
    }

    fun changeGreen(value: Int) {
        _uiState.value = uiState.value?.copy(green = value)
    }

    fun changeBlue(value: Int) {
        _uiState.value = uiState.value?.copy(blue = value)
    }
}
```

Activityでは`observeAsState`を利用してStateに変換
この関数は`runtime-livedata`に入っている拡張関数なのでgradleのdependenciesにも追加します

```groovy
dependencies {
    implementation "androidx.compose.runtime:runtime-livedata:$compose_version"
}
```

```kotlin:MainActivity.kt
@Composable
private fun MainActivityScreen(viewModel: ImageSampleViewModel) {
    viewModel.uiState.observeAsState().value?.let {
        ImageSampleScreen(uiState = it) { value, color ->
            when (color) {
                Color.Red -> viewModel.changeRed(value)
                Color.Green -> viewModel.changeGreen(value)
                Color.Blue -> viewModel.changeBlue(value)
                else -> {
                }
            }
        }
    }
}

```

実行結果は残念なことに特定の色のみを変更しても全てのInputColorFieldが再コンポーズされています
![スクリーンショット 2021-12-22 1.34.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149859/bcf98294-238d-9b29-9317-92357de21c53.png)

おそらくred,green,blueを個別のLiveDataで扱えば余計な再Composeは防げるとは思いますが、Composable関数の方での値の渡し方がそれだと少し面倒になりそうなのと`mutableStateOf`で目標を達成できたので調査はここまでにしました。