状態と Jetpack Compose

アプリにおいて状態とは、時間とともに変化する可能性がある値すべてを意味します。これは非常に広範な定義であり、Room データベースにも、クラス内の変数一つにも当てはまります。

すべての Android アプリはユーザーに状態を表示します。Android アプリの状態の例を次にいくつか示します。

  • ネットワーク接続を確立できないときに表示されるスナックバー。
  • ブログ投稿と関連コメント。
  • ユーザーがボタンをクリックしたときに再生されるボタンの波紋アニメーション。
  • ユーザーが画像の上に描画できるステッカー。

Jetpack Compose では、Android アプリが状態をどこで、どのように保存し、使用するかの設定を明示的に行うことができます。このガイドでは、状態とコンポーザブルの関係に加え、Jetpack Compose が提供する、状態を簡単に処理するための API を中心に説明します。

状態とコンポジション

Compose は宣言型であるため、更新する唯一の方法は、新しい引数で同じコンポーザブルを呼び出すことです。この場合の引数は、UI の状態を表します。状態が更新されると、常に再コンポーズが行われます。そのため、命令型の XML ベースのビューとは異なり、TextField などは自動更新されません。状態に応じた更新が行われるためには、コンポーザブルに新しい状態を明示する必要があります。

@Composable
fun HelloContent() {
   Column(modifier = Modifier.padding(16.dp)) {
       Text(
           text = "Hello!",
           modifier = Modifier.padding(bottom = 8.dp),
           style = MaterialTheme.typography.h5
       )
       OutlinedTextField(
           value = "",
           onValueChange = { },
           label = { Text("Name") }
       )
   }
}

これを実行しても、何も起こりません。これは、TextField が自身を更新しないためです。value パラメータが変更されると、更新が発生します。これは、Compose のコンポジションと再コンポーズの仕組みによるものです。

初回コンポーズと再コンポーズの詳細については、Compose の思想をご覧ください。

コンポーザブル内の状態

コンポーズ可能な関数は、remember コンポーザブルを使用して、単一のオブジェクトをメモリに保存できます。初回コンポーズの際に、remember によって計算された値がコンポジションに保存され、保存された値は再コンポーズの際に返されます。remember を使用すると、可変オブジェクトと不変オブジェクトの両方を保存できます。

mutableStateOf はオブザーバブルな MutableState<T> を作成します。これは、Compose ランタイムに統合されているオブザーバブルな型です。

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

value を変更すると、value を読み取るすべてのコンポーズ可能な関数の再コンポーズがスケジュール設定されます。ExpandingCard の場合は、expanded が変更されるたびに ExpandingCard が再コンポーズされます。

コンポーザブルの MutableState オブジェクトを宣言するには、次の 3 つの方法があります。

  • val mutableState = remember { mutableStateOf(default) }
  • var value by remember { mutableStateOf(default) }
  • val (value, setValue) = remember { mutableStateOf(default) }

これらの宣言は同等であり、状態のさまざまな用途に応じて糖衣構文として提供されます。作成するコンポーザブル向けに読みやすいコードを生成する構文を選択する必要があります。

by デリゲート構文には、次のインポートが必要です。

import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue

remember で保存される値を、他のコンポーザブルのパラメータとして使用できます。または、ステートメントのロジックとして使用して、表示されるコンポーザブルを変更することもできます。たとえば、名前が空の場合に挨拶を表示したくない場合は、以下のように if ステートメントで状態を使用します。

@Composable
fun HelloContent() {
   Column(modifier = Modifier.padding(16.dp)) {
       var name by remember { mutableStateOf("") }
       if (name.isNotEmpty()) {
           Text(
               text = "Hello, $name!",
               modifier = Modifier.padding(bottom = 8.dp),
               style = MaterialTheme.typography.h5
           )
       }
       OutlinedTextField(
           value = name,
           onValueChange = { name = it },
           label = { Text("Name") }
       )
   }
}

remember を使用すると、状態は再コンポーズをまたいで保持されますが、設定の変更後は保持されません。保持するには、rememberSaveable を使用する必要があります。rememberSaveable は、Bundle に保存可能なすべての値を自動的に保存します。その他の値については、カスタムのセーバー オブジェクトに渡すことができます。

その他のサポートされている状態の種類

Jetpack Compose では、状態を保持するために MutableState<T> を使用する必要はありません。Jetpack Compose は他のオブザーバブルな型をサポートします。Jetpack Compose で別のオブザーバブルな型を読み取る前に、それを State<T> に変換して、状態が変更されたときに Jetpack Compose が自動的に再コンポーズを実行できるようにする必要があります。

Compose には、Android アプリで一般的に使用されるオブザーバブルな型から State<T> を作成する関数が用意されています。

アプリがオブザーバブルなカスタムクラスを使用する場合は、拡張関数を作成して、Jetpack Compose が他のオブザーバブルな型を読み取れるようにすることができます。これを行う方法の例については、組み込み機能の実装を参照してください。Jetpack Compose がすべての変更をサブスクライブできるようにするオブジェクトは、すべて State<T> に変換してコンポーザブルで読み取れるようにすることができます。

ステートフルとステートレス

remember を使用してオブジェクトを保存するコンポーザブルは、内部状態を作成し、コンポーザブルをステートフルにします。たとえば、HelloContent は、name 状態を内部に保持して変更するため、ステートフルなコンポーザブルです。これは呼び出し元が状態を制御する必要がない場合に便利です。状態を自分で管理しなくても使用できます。ただし、内部状態を持つコンポーザブルは、再利用性が低く、テストも難しくなりがちです。

ステートレスなコンポーザブルとは、一切の状態を保持しないコンポーザブルです。ステートレスは、状態ホイスティングを使用すると簡単に実現できます。

再利用可能なコンポーザブルを開発する際は、同じコンポーザブルのステートフル バージョンとステートレス バージョンの両方を公開することがよくあります。状態を考慮しない呼び出し元にとっては、ステートフル バージョンが便利です。状態の制御またはホイスティングを行う必要がある呼び出し元には、ステートレス バージョンが必要です。

状態ホイスティング

Compose の状態ホイスティングは、状態をコンポーザブルの呼び出し元に移動してコンポーザブルをステートレスにするプログラミング パターンです。Jetpack Compose の状態ホイスティングの一般的なパターンでは、状態変数を次の 2 つのパラメータに置き換えます。

  • value: T: 表示する現在の値。
  • onValueChange: (T) -> Unit: 値の変更をリクエストするイベント。T は提案される新しい値です。

ただし、上記のパラメータは onValueChange に限定されません。コンポーザブルに適した特定のイベントがある場合は、ExpandingCardonExpandonCollapse を扱う場合と同様に、ラムダを使用してそのようなイベントを定義する必要があります。

この方法でホイスティングされる状態には、次のような重要な特性があります。

  • 信頼できる唯一の情報源: 状態を複製するのではなく移動することで、信頼できる情報源を 1 つだけにすることができます。これは、バグを防ぐのに役立ちます。
  • カプセル化: 状態を変更できるのはステートフル コンポーザブルに限られます。完全に内部です。
  • 共有可能: ホイスティングされた状態は複数のコンポーザブルで共有できます。たとえば、別のコンポーザブルで name を行いたい場合は、ホイスティングでそれが可能になります。
  • インターセプト可能: ステートレスなコンポーザブルの呼び出し元は、状態を変更する前にイベントを無視するか変更するかを決定できます。
  • 分離: ステートレスな ExpandingCard の状態はどこにでも保存できます。たとえば、nameViewModel に移動できます。

この例では、nameonValueChangeHelloContent から抽出して、HelloContent を呼び出すツリー上位の HelloScreen コンポーザブルに移動します。

@Composable
fun HelloScreen() {
    var name by rememberSaveable { mutableStateOf("") }

    HelloContent(name = name, onNameChange = { name = it })
}

@Composable
fun HelloContent(name: String, onNameChange: (String) -> Unit) {
    Column(modifier = Modifier.padding(16.dp)) {
        Text(
            text = "Hello, $name",
            modifier = Modifier.padding(bottom = 8.dp),
            style = MaterialTheme.typography.h5
        )
        OutlinedTextField(
            value = name,
            onValueChange = onNameChange,
            label = { Text("Name") }
        )
    }
}

HelloContent の状態をホイスティングすることで、コンポーザブルの使用を考慮し、さまざまな状況で再利用して、テストすることが容易になります。HelloContent は、状態を保存する方法から切り離されています。これは、HelloScreen を変更または置換する場合、HelloContent の実装方法を変更する必要がないことを意味します。

状態が下降し、イベントが上昇するパターンは、単方向データフローと呼ばれます。この場合、状態は HelloScreen から HelloContent に下降し、イベントは HelloContent から HelloScreen に上昇します。単方向データフローに従うことで、UI に状態を表示するコンポーザブルと、状態を保存および変更するアプリの要素を切り離すことができます。

ViewModel と状態

ViewModel は、Compose UI ツリーの上位レベルのコンポーザブル、または Navigation ライブラリ内のデスティネーションであるコンポーザブルの状態ホルダーとしておすすめです。ViewModel は構成変更の前後で保持されるため、Compose コードをホストするアクティビティまたはフラグメントのライフサイクルを処理しなくても、UI に関連する状態とイベントをカプセル化できます。

ViewModel は、LiveDataStateFlow などのオブザーバブルなホルダーで状態を公開する必要があります。コンポーズの際に状態オブジェクトが読み取られると、コンポジションの現在の再コンポーズ スコープは、その状態オブジェクトの更新を自動的にサブスクライブします。

オブザーバブルな状態ホルダーを 1 つ以上持つことができます。各ホルダーは概念的に関連性があり、一緒に変化する画面の領域の状態を保持する必要があります。これにより、状態が複数のコンポーザブルで使用されている場合でも、唯一の信頼できる情報源を保持できます。

Jetpack Compose で LiveDataViewModel を使用して、単方向データフローを実装できます。HelloScreen の例は、次のように ViewModel を使用して実装します。

class HelloViewModel : ViewModel() {

    // LiveData holds state which is observed by the UI
    // (state flows down from ViewModel)
    private val _name = MutableLiveData("")
    val name: LiveData<String> = _name

    // onNameChange is an event we're defining that the UI can invoke
    // (events flow up from UI)
    fun onNameChange(newName: String) {
        _name.value = newName
    }
}

@Composable
fun HelloScreen(helloViewModel: HelloViewModel = viewModel()) {
    // by default, viewModel() follows the Lifecycle as the Activity or Fragment
    // that calls HelloScreen(). This lifecycle can be modified by callers of HelloScreen.

    // name is the current value of [helloViewModel.name]
    // with an initial value of ""
    val name: String by helloViewModel.name.observeAsState("")
    HelloContent(name = name, onNameChange = { helloViewModel.onNameChange(it) })
}

@Composable
fun HelloContent(name: String, onNameChange: (String) -> Unit) {
    Column(modifier = Modifier.padding(16.dp)) {
        Text(
            text = "Hello, $name",
            modifier = Modifier.padding(bottom = 8.dp),
            style = MaterialTheme.typography.h5
        )
        OutlinedTextField(
            value = name,
            onValueChange = onNameChange,
            label = { Text("Name") }
        )
    }
}

observeAsStateLiveData<T> を監視し、LiveData が変更されるたびに更新される State<T> オブジェクトを返します。State<T> は、Jetpack Compose が直接使用できるオブザーバブルな型です。observeAsState は、コンポジション内にあるときに限り、LiveData を監視します。

次の行をご覧ください。

val name: String by helloViewModel.name.observeAsState("")

これは、observeAsState によって返される状態オブジェクトを自動的にラップ解除する糖衣構文です。また、代入演算子(=)を使用して状態オブジェクトを代入することもできます。この場合、String ではなく State<String> になります。

val nameState: State<String> = helloViewModel.name.observeAsState("")

Compose 内の状態を復元する

アクティビティまたはプロセスを再作成した後、rememberSaveable を使用して UI の状態を復元します。rememberSaveable は再コンポーズ全体で状態を保持します。さらに、rememberSaveable はアクティビティとプロセスの再作成をまたいで状態を保持します。

状態を保存する方法

Bundle に追加されたデータタイプはすべて、自動的に保存されます。Bundle に追加できないものを保存する場合は、複数のオプションがあります。

Parcelize

最も簡単なのは、@Parcelize アノテーションをオブジェクトに追加する方法です。オブジェクトが Parcelable になり、バンドルできます。たとえば、このコードは Parcelable な City データ型を作成し、状態に保存します。

@Parcelize
data class City(val name: String, val country: String) : Parcelable

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable {
        mutableStateOf(City("Madrid", "Spain"))
    }
}

mapSaver

なんらかの理由で @Parcelize が適さない場合は、mapSaver を使用して、オブジェクトを Bundle に保存できる値のセットに変換するための独自ルールを定義できます。

data class City(val name: String, val country: String)

val CitySaver = run {
    val nameKey = "Name"
    val countryKey = "Country"
    mapSaver(
        save = { mapOf(nameKey to it.name, countryKey to it.country) },
        restore = { City(it[nameKey] as String, it[countryKey] as String) }
    )
}

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable(stateSaver = CitySaver) {
        mutableStateOf(City("Madrid", "Spain"))
    }
}

listSaver

マップのキーを定義する必要がないようにするには、listSaver を使用して、そのインデックスをキーとして使用することもできます。

data class City(val name: String, val country: String)

val CitySaver = listSaver<City, Any>(
    save = { listOf(it.name, it.country) },
    restore = { City(it[0] as String, it[1] as String) }
)

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable(stateSaver = CitySaver) {
        mutableStateOf(City("Madrid", "Spain"))
    }
}

詳細

状態と Jetpack Compose の詳細については、Jetpack Compose での状態の使用に関する Codelab をご覧ください。