Jetpack Compose入門 アプリを作る知識-4(状態変更と保持~remember/Saveable/ViewModel)

2022/04/01に公開

概要

以前の記事でコンポーズ関数は、冪等であり、副作用がないと説明しました。

アプリでは状態が変わっていくのが当たり前ですが、どのように変更するのでしょうか。
それを説明する記事になります。

作成アプリ

ボタンを押下するとカウントアップしていくアプリです。

ソース

基本的に、remember()rememberSaveable()、ViewModelを使う方法の3種類があります。
状態を持たせる(ステートフル)箇所をどこにするかも複数の方法があります。
変更箇所のコンポーザブル関数に持たせる、呼び出し元のコンポーザブル関数に持たせる、State Holder(状態ホルダー)と呼ばれる専用のクラスに持たせる、ViewModelに持たせるなどの方法があります。

それぞれを見ていきましょう。

remember()を使う場合

@Composable
fun CountUpScreen() {
  // ① メモリにIntの値を保持します。
  var count: Int by remember { mutableStateOf(0) }
  Column(
    modifier = Modifier.fillMaxSize(),
    verticalArrangement = Arrangement.SpaceEvenly,
    horizontalAlignment = Alignment.CenterHorizontally,
  ) {
    Text(
      // ② 関数内にある変数を利用しています。
      text = "$count"
    )
    Button(onClick = {
      // ③ 関数内にある変数をカウントアップしています。
      count++
    }) {
      Icon(Icons.Outlined.Add, contentDescription = "+")
    }
  }
}

ポイントは、コメントの①になります。remember()コンポーザブル関数は引数の関数の値(このコードの場合はmutableStateOf(0))をメモリ上にキャッシュします。
byはKotlinの委譲のoperator関数です。 このコードの場合は、count変数を取得するとMutableStateのgetValueが呼ばれます。count変数に値を渡すとMutableStateのsetValueが呼ばれます。
mutableStateOf()関数は、オブザーバブルなMutableState<T>を作成します。
以下がインターフェイスです。

interface MutableState<T> : State<T> {
    override var value: T
}

オブザーバブルとは、つまり valueの値を変えるとこのクラスを監視している処理が動くということです。Stateの場合は、値が変わるとコンポーザブル関数が再コンポーズ(コンポーズ関数を再度呼び出す)されることを表すインターフェイスになります。

つまり、③でcount変数がプラスされるとMutableStatevalueに+1された値が設定されて、再コンポーズが走り、CountUpScreen()が再度呼ばれて、②で変更されたcount変数をTextコンポーズ関数に渡すことで表示が変わる。という流れになります。

アクティビティが破棄された場合

remember()コンポーザブル関数で保存していた値は、アクティビティが破棄されると消えてしまいます。
たとえば、カウントアップをした後にダークモードにして戻ってくると0に戻ります。

rememberSaveable()を使う場合

アクティビティが破棄されても状態を保持したい場合はどうすれば良いでしょう。単純なActivityであれば、以下の方法がありました。

  1. ViewModelで保持
  2. onSaveInstanceState()の利用
  3. アプリ内ストレージに入れる

ViewModelで保持とアプリ内ストレージに入れる方法は、Composeでも同じです。
ここでは、onSaveInstanceState()の利用に変わる方法を紹介します。

それは、単純にremember()の代わりにrememberSaveable()を使うだけです。

+    var count: Int by rememberSaveable { mutableStateOf(0) }
-    var count: Int by remember { mutableStateOf(0) }

Bundleに保存可能なすべての値を自動的に保存するそうです。
それ以外の型を保存したい場合は、カスタムのSaverを引数で渡すことでできるようになります。

Parcelable

Bundleに保存可能なひとつにParcelableの実装があります。試してみましょう。

まずは、Kotlinで簡単にParcelablを扱えるようにbuild.gradleに追加します。

plugins {
    id 'com.android.application'
    id 'kotlin-android'
+   id 'kotlin-parcelize'
}

で実装クラスを作り、

@Parcelize
class MyDto(val data: Int): Parcelable

以下のように使う箇所を修正します。

var count: MyDto by rememberSaveable { mutableStateOf(MyDto(0)) }
・・・
    Text(
      text = "${count.data}"
    )
    Button(onClick = {
      val data = count.data + 1
      count = MyDto(data)
    })
・・・    

この状態でダークモードのOn/Offをするとしっかりと状態が保持されていることがわかります。

mapSaver

mapSaver()関数を使用して、オブジェクトをBundleに保存できるように独自のkey,value形式のルールで定義できます。

class MyDto(val data: Int)

val MyDtoSaver = run {
  val key = "Data"
  mapSaver(
    save = { mapOf(key to it.data) },
    restore = { MyDto(it[key] as Int) }
  )
}

使い方は以下の通りです。

  var count: MyDto by rememberSaveable(stateSaver = MyDtoSaver) { mutableStateOf(MyDto(0)) }
・・・
    Text(
      text = "${count.data}"
    )
    Button(onClick = {
      val data = count.data + 1
      count = MyDto(data)
    }) {
・・・

listSaver

キーではなく、インデックスで扱いたい場合はlistSaver()を使います。

class MyDto(val data: Int)

val MyDtoSaver = listSaver<MyDto, Any>(
  save = { listOf(it.data) },
  restore = { MyDto(it[0] as Int) }
)
  var count: MyDto by rememberSaveable(stateSaver = MyDtoSaver) { mutableStateOf(MyDto(0)) }
・・・
    Text(
      text = "${count.data}"
    )
    Button(onClick = {
      val data = count.data + 1
      count = MyDto(data)
・・・

呼び出し元をステートフルにする

remember()rememberSaveable()コンポーザブル関数は、利用するコンポーザブル関数をステートフル(状態を持った)関数にすることがわかりました。
ただし、この場合、再利用やテスト容易性が失われてしまいます。

そこでState Hoisting(=状態ホイスティング、巻き上げ)をすることで自身はステートレスにして、呼び出し元をステートフルにすることができます。状態ホイスティングはただのプログラムのパターンに名前を付けただけです。

@Composable
fun CountUpScreenParent() {
  var count by rememberSaveable { mutableStateOf(0) }
  CountUpScreen(count = count, onClick = { count++ })
}

@Composable
fun CountUpScreen(count: Int, onClick: () -> Unit) {
  Column(
    modifier = Modifier.fillMaxSize(),
    verticalArrangement = Arrangement.SpaceEvenly,
    horizontalAlignment = Alignment.CenterHorizontally,
  ) {
    Text(
      text = "$count"
    )
    Button(onClick = {
      onClick()
    }) {
      Icon(Icons.Outlined.Add, contentDescription = "+")
    }
  }
}

CountUpScreen()コンポーザブル関数にあったrememberSaveable()を呼び出し元のCountUpScreenParent()コンポーザブル関数に移動してあります。そして、引数で変更対象のcount変数とそれを変更するラムダ関数を子供のCountUpScreen()関数に渡しています。

これにより、CountUpScreenコンポーザブル関数は部品としての再利用性が高まりました。
状態を持っていなく冪等で副作用もないのでテストも容易になりました。

ViewModelに状態を持たせる

このままでは、たとえば、onClickなどでコンポーザブル関数にロジックが入り込んでしまい、保守性が下がりそうです。さらに一歩進んで、関心事の分離をすることでほとんどのコンポーザブル関数のテストできるようにしたいです。
そのために、状態をViewModelに持たせると良いでしょう。

ViewModelでのオブザーブ可能な状態を保持する方法として、RxJava2, LiveData, Flowがあります。
または、Jetpack ComposeのStateで持つ方法もありますが、ViewModelをComposeに依存させないほうがViewModelの役割として良いので、Stateで持つのは依存性の観点で考えると私はお勧めしません。(が、公式では、Stateを持つのがいいとされているようです。ちょっと納得ができていないです。)

RxJava2, LiveData, Flowもそれぞれ、Stateに変換する関数がJetpack Composeに拡張関数として用意されています。いまの時代では、一番新しいFlowで持たせるのが良いでしょう。

以下のViewModelでは、StateFlowで保持しています。

また、このクラスでは、androidx.lifecycle.ViewModelを継承しています。これを継承することでViewのライフサイクルを超えてデータを保持することができます。
Activityのライフサイクルは複雑でたとえば、画面の向きを変えたりなどで簡単に破棄され状態がリセットしてしまいます。それはJetpack Composeでも同様で、それを解決するためにandroidx.lifecycle.ViewModelが使えます。rememberSaveable()でも保持できますが、ViewModelを使った方が責務を分割できるので良いでしょう。

class MainViewModel : ViewModel() {
  private val _count: MutableStateFlow<Int> = MutableStateFlow(0)
  val count: StateFlow<Int> = _count.asStateFlow()
  fun increaseCount() {
    _count.value++
  }
}

以下、コンポーザブル関数で、collectAsState()でStateに変換していてます。ViewModelのStateFlowが変化するとcount変数が変化するので、この関数が再コンポーズされて表示が変わるという仕組みです。

@Composable
fun CountUpScreen(viewModel: MainViewModel) {
  val count: Int by viewModel.count.collectAsState()
  Column(
    modifier = Modifier.fillMaxSize(),
    verticalArrangement = Arrangement.SpaceEvenly,
    horizontalAlignment = Alignment.CenterHorizontally,
  ) {
    Text(
      text = "$count"
    )
    Button(onClick = {
      viewModel.increaseCount()
    }) {
      Icon(Icons.Outlined.Add, contentDescription = "+")
    }
  }
}

State Holderに持たせる

State Holderを作る理由は、関心事の分離のためです。
特に、UI要素の状態とUIロジックを記述する箇所としての目的になります。

サンプルとして、以下の画面を作ります。チェックボックスを変更するとOn/OFFのテキストが代わり、Snackbarが表示されます。

早速、State Holderを作ります。以下MyAppStateクラスになります。
その中で、isCheckedがUI要素の状態になります。
onCheckedChange()というUIロジックをこのクラスに閉じ込めています。

class MyAppState(
  val scaffoldState: ScaffoldState,
  private val coroutinesScope: CoroutineScope,
) {
  private val _isChecked: MutableStateFlow<Boolean> = MutableStateFlow(false)
  val isChecked: Boolean
    @Composable get() = _isChecked.collectAsState().value

  fun onCheckedChange(isChecked: Boolean) {
    coroutinesScope.launch {
      _isChecked.value = isChecked
      scaffoldState.snackbarHostState.showSnackbar("success!")
    }
  }
}

State Holderを remember()で保持しておき、

@Composable
fun rememberMyAppState(
  scaffoldState: ScaffoldState = rememberScaffoldState(),
  coroutinesScope: CoroutineScope = rememberCoroutineScope(),
) = remember {
  MyAppState(
    coroutinesScope = coroutinesScope,
    scaffoldState = scaffoldState,
  )
}

以下、UIの状態=チェックボックスの状態をMyAppStateクラスのUIのロジックを使い変化させ、MyAppStateクラスの状態を変化させて、CountUpScreen()関数を再コンポーズして表示を変更しています。

@Composable
fun CountUpScreen() {
  val appState = rememberMyAppState()
  Scaffold(
    scaffoldState = appState.scaffoldState
  ) {
    Column(
      modifier = Modifier.fillMaxSize(),
      verticalArrangement = Arrangement.SpaceEvenly,
      horizontalAlignment = Alignment.CenterHorizontally,
    ) {
      Text("isChecked=${if (appState.isChecked) "ON" else "OFF"}")
      Checkbox(
        checked = appState.isChecked,
        onCheckedChange = { appState.onCheckedChange(it) }
      )
    }
  }
}

State HolderとはこういうUIの状態とロジックをカプセル化したい場合に役に立ちます。

ちなみにState Holderには、ViewModelも保持することができます。
ViewModelのほうがライフサイクルが長いので、State HolderにもViewModelを持たせるとコンポーズ関数がシンプルに実装できそうです。

まとめ

状態の保持と変更は、Jetpack Composeをはじめとする宣言的UIではデータを変更すると勝手に表示が変わるという便利なものですが、ルールがわからないとよくわからないと思います。
基本的なルールを書いてみましたが、単なアプリを作る知識以上の説明を記述したつもりなので、それなりに役に立つ記事になったかなと思います。

状態の変更は再コンポーズが走るため、パフォーマンスに影響が出てきます。
宣言的UIでは、パフォーマンスを意識しないと本当に遅くなります。この部分の知識は今度記述したいと思います。

NewsPicks の Zenn

Discussion