状態と Jetpack Compose

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

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

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

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

Compose の状態

状態のコンセプトは Compose の中核をなす要素です。簡単な例として、ユーザーが名前を入力すると、それに応じて挨拶が表示される画面について考えてみましょう。以下のコードには、挨拶用のテキストと名前入力用のテキスト フィールドが含まれています。

@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 のコンポジションと再コンポジションの仕組みによるものです。

コンポジションと再コンポジション

コンポジションは UI の記述であり、コンポーザブルの実行により作成されます。コンポジションは、UI を記述するコンポーザブルのツリー構造です。

初回コンポジションの際に、Compose は、コンポジションの UI を記述するために呼び出されたコンポーザブルをトラッキングします。その後、アプリの状態が変更されると、Jetpack Compose は再コンポジションをスケジュール設定します。再コンポジションでは、状態の変更に応じて変更される可能性があるコンポーザブルが実行され、Jetpack Compose がコンポジションを更新して変更を反映します。

コンポジションは、初回コンポジションによってのみ作成され、再コンポジションによってのみ更新されます。コンポジションを変更する唯一の方法は、再コンポジションを行うことです。

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

状態について

コンポーザブルを更新するには、TextField の状態を表す値を渡して、TextField の値が変更されたときに状態を更新するコードを追加します。

表示する名前を保持するローカル状態を導入するには、remember { mutableStateOf() } を使用し、テキストのデフォルト値を渡します。これにより、name の状態が変更されるたびに、TextField に表示される値も変わります。

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

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

mutableStateOfMutableState を作成します。これは Compose のオブザーバブルな型です。その値を変更すると、該当値を読み取るすべてのコンポーズ可能な関数の再コンポジションがスケジュール設定されます。

remember を使用すると、再コンポジション全体で状態を保持できます。remember も使用せずに mutableStateOf を使用すると、HelloContent コンポーザブルが再コンポーズされるたびに、状態が空の文字列に再初期化されます。

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 に保存可能なすべての値を自動的に保存します。その他の値については、カスタムのセーバー オブジェクトに渡すことができます。

@Composable
fun HelloContent() {
    Column(modifier = Modifier.padding(16.dp)) {
        var name by rememberSaveable { 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") }
        )
    }
}

ステートレスなコンポーザブル

コンポーザブルが上記の例のように自身の状態を保持すると、コンポーザブルの再利用やテストが難しくなります。また、コンポーザブルとその状態の保存方法が密接に結びつけられます。代わりに、これをステートレスなコンポーザブル(状態を保持しないコンポーザブル)にする必要があります。

これを行うには、状態ホイスティングを使用します。状態ホイスティングとは、コンポーザブルの状態をそのコンポーザブルの呼び出し元に移動するプログラミング パターンです。簡単に行うには、状態をパラメータに置き換え、ラムダを使用してイベントを表します。

この例では、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 は、不変の String パラメータとしての状態にアクセスできます。また、状態の変更をリクエストするときに呼び出せるラムダ onNameChange としての状態にもアクセスできます。

ラムダは、コンポーザブルでイベントを記述する最も一般的な方法です。この例では、Kotlin の関数型構文 (String) -> Unit を使用して、String を受け取るラムダで onNameChange というイベントを定義します。ラムダは onNameChange と呼ばれますが、これが現在形であることにご注意ください。これは、状態がすでに変更されたイベントではなく、コンポーザブルがイベント ハンドラに状態の変更をリクエストするイベントであることを意味します。

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

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

ViewModel と状態

Jetpack Compose では、ViewModel を使用して、オブザーバブル ホルダー(LiveDataFlow など)で状態を公開できます。また、その状態に影響するイベントを処理することもできます。上記の 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("")

HelloViewModelHelloScreen は、状態が HelloViewModel から下に流れ、イベントが HelloScreen から上方に流れる単方向データフロー パターンに従います。

HelloInput、HelloScreen、HelloViewModel の間の状態とイベントの流れ

この画面の UI イベントループについて考えてみましょう。

  1. イベント: ユーザーが文字を入力すると、それに応じて onValueChange が呼び出されます。
  2. 状態の更新: HelloViewModel.onNameChange が処理を行った後、可変である LiveData_name の状態を設定します。
  3. 状態の表示: HelloViewmodel.name の値が変更され、それが observeAsState の Compose によって観測されます。次に、HelloScreen が再実行(再コンポーズ)され、name の新しい値に基づいて UI を記述します。

ViewModel と Jetpack Compose を使用して単方向データフローを実装する方法については、Compose UI の設計をご覧ください。

remember の使用

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

remember を使用して不変値を保存する

テキストの書式の計算のような負荷の高い UI オペレーションをキャッシュに保存する際に、不変値を保存できます。remember で保存される値は、remember を呼び出したコンポーザブルにより Composition に格納されます。

@Composable
fun FancyText(text: String) {
    // by passing text as a parameter to remember, it will re-run the calculation on
    // recomposition if text has changed since the last recomposition
    val formattedText = remember(text) { computeTextFormatting(text) }
    /*...*/
}
formattedText を子として持つ FancyText の Composition

remember を使用してコンポーザブルの内部状態を作成する

remember を使用して可変オブジェクトを保存する際は、コンポーザブルに状態を追加します。この方法により、単一のステートフルなコンポーザブルに内部状態を作成できます。

コンポーザブルによって使用されるすべての可変状態をオブザーバブルにすることを強くおすすめします。そうすれば、状態が変更されるたびに、Compose は自動的に再コンポーズを実行できます。Compose には、Compose ランタイムに直接統合されている組み込みのオブザーバブルな型 State<T> が用意されています。

コンポーザブルの内部状態の良い例は、ユーザーがボタンをクリックしたときに折りたたみと展開を切り替える ExpandingCard です。

ExpandedCard コンポーザブルによる折りたたみと展開の切り替え

このコンポーザブルには、重要な状態 expanded があります。コンポーザブルは、expanded のときは本文を表示し、折りたたまれているときは本文を非表示にする必要があります。

expanded 状態を子として持つ ExpandingCard の Composition

コンポーザブルに状態 expanded を追加するには、remember を使用して mutableStateOf(initialValue) を保存します。

@Composable
fun ExpandingCard(title: String, body: String) {
    // expanded is "internal state" for ExpandingCard
    var expanded by remember { mutableStateOf(false) }

    // describe the card for the current state of expanded
    Card {
        Column(
            Modifier
                .width(280.dp)
                .animateContentSize() // automatically animate size when it changes
                .padding(top = 16.dp, start = 16.dp, end = 16.dp)
        ) {
            Text(text = title)

            // content of the card depends on the current value of expanded
            if (expanded) {
                // TODO: show body & collapse icon
            } else {
                // TODO: show expand icon
            }
        }
    }
}

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

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

コンポーザブルの内部状態の値は、別のコンポーザブルに渡すパラメータとして使用できます。どのコンポーザブルを呼び出すかを変更するために使用することさえできます。ExpandingCard では、expanded の現在の値に基づいて、if ステートメントによりカードの内容が変更されます。

if (expanded) {
   // TODO: show body & collapse icon
} else {
   // TODO: show expand icon
}

コンポーザブルの内部状態を変更する

状態はコンポーザブル内のイベントによって変更する必要があります。イベントではなくコンポーザブルの実行によって状態を変更すると、回避すべきコンポーザブルの副作用が生じます。Jetpack Compose の副作用の詳細については、Compose の思想をご覧ください。

ExpandingCard コンポーザブルを完了するため、expandedtrue のときは body と折りたたみボタンを表示し、expandedfalse のときは展開ボタンを表示します。

@Composable
fun ExpandingCard(title: String, body: String) {
    var expanded by remember { mutableStateOf(false) }

    // describe the card for the current state of expanded
    Card {
        Column(
            Modifier
                .width(280.dp)
                .animateContentSize() // automatically animate size when it changes
                .padding(top = 16.dp, start = 16.dp, end = 16.dp)
        ) {
            Text(text = title)

            // content of the card depends on the current value of expanded
            if (expanded) {
                Text(text = body, Modifier.padding(top = 8.dp))
                // change expanded in response to click events
                IconButton(onClick = { expanded = false }, modifier = Modifier.fillMaxWidth()) {
                    Icon(imageVector = Icons.Default.ExpandLess, contentDescription = "Expand less")
                }
            } else {
                // change expanded in response to click events
                IconButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) {
                    Icon(imageVector = Icons.Default.ExpandMore, contentDescription = "Expand more")
                }
            }
        }
    }
}

このコンポーザブルでは、onClick イベントに応じて状態が変更されます。expandedプロパティ デリゲート構文で var を使用するので、onClick コールバックは expanded を直接割り当てることができます。

IconButton(onClick = { expanded = true }, /* … */) {
   // ...
}

以上により、ExpandingCard の UI 更新ループを記述して、Compose によってどのように内部状態が変更され、使用されるかを確認できるようになりました。

  1. イベント: ユーザーがいずれかのボタンをタップすると、それに応じて onClick が呼び出されます。
  2. 状態の更新: 割り当てを使用して、onClick リスナーで expanded が変更されます。
  3. 状態の表示: expanded が変更された State<Boolean> となり、ExpandingCardif(expanded) 行でそれを読み取るため、ExpandingCard が再コンポーズされます。次に、ExpandingCardexpanded の新しい値に対応する画面を記述します。

Jetpack Compose で他の型の状態を使用する

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

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

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

内部状態を UI コンポーザブルから分離する

ExpandingCard の最後のセクションには、内部状態があります。そのため、呼び出し元は状態を制御できません。つまり、展開された状態で ExpandingCard を開始したい場合などに、それを行う手段がありません。別のイベント(ユーザーによる Fab のクリックなど)に応じてカードを展開することもできません。また、expanded 状態を ViewModel に移行したくても、そうすることはできません。

一方、状態の制御またはホイスティングを行う必要がない呼び出し元は、ExpandingCard の内部状態を使用することにより、状態を自分で管理しなくてもそれを使用できます。

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

ステートフル インターフェースとステートレス インターフェースの両方を提供するには、状態ホイスティングを使用して UI を表示するステートレスなコンポーザブルを抽出します。

2 つのコンポーザブルは、異なるパラメータを受け取るにもかかわらず、両方とも ExpandingCard という名前になっていることにご注意ください。UI を出力するコンポーザブルの命名規則は、コンポーザブルが画面に表示するものを記述する大文字表記形式の名詞です。この場合は、どちらも ExpandingCard を表示します。この命名規則は Compose ライブラリ全体に適用されます(例: TextFieldTextField など)。

ExpandingCard をステートフルなコンポーザブルとステートレスなコンポーザブルに分割する例を以下に示します。

// this stateful composable is only responsible for holding internal state
// and defers the UI to the stateless composable
@Composable
fun ExpandingCard(title: String, body: String) {
    var expanded by remember { mutableStateOf(false) }
    ExpandingCard(
        title = title,
        body = body,
        expanded = expanded,
        onExpand = { expanded = true },
        onCollapse = { expanded = false }
    )
}

// this stateless composable is responsible for describing the UI based on the state
// passed to it and firing events in response to the buttons being pressed
@Composable
fun ExpandingCard(
    title: String,
    body: String,
    expanded: Boolean,
    onExpand: () -> Unit,
    onCollapse: () -> Unit
) {
    Card {
        Column(
            Modifier
                .width(280.dp)
                .animateContentSize() // automatically animate size when it changes
                .padding(top = 16.dp, start = 16.dp, end = 16.dp)
        ) {
            Text(title)
            if (expanded) {
                Spacer(Modifier.height(8.dp))
                Text(body)
                IconButton(onClick = onCollapse, Modifier.fillMaxWidth()) {
                    Icon(imageVector = Icons.Default.ExpandLess, contentDescription = "Expand less")
                }
            } else {
                IconButton(onClick = onExpand, Modifier.fillMaxWidth()) {
                    Icon(imageVector = Icons.Default.ExpandMore, contentDescription = "Expand more")
                }
            }
        }
    }
}

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

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

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

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

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

この方法を使用する場合も、単方向データフローに従います。状態はステートフルなコンポーザブルから下位に渡され、イベントはステートレスなコンポーザブルから上位に渡されます。

ステートフルおよびステートレスな ExpandingCard の単方向データフロー図

アクティビティやプロセスの再作成後に UI の状態を復元する

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

@Composable
fun MyExample() {
    var selectedId by rememberSaveable<String?> { mutableStateOf(null) }
    /*...*/
}

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

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

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

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

なんらかの理由で @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, nameKey to it.country) },
        restore = { City(it[nameKey] as String, it[countryKey] as String) }
    )
}

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

マップのキーを定義する必要がないようにするには、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 MyExample() {
    var selectedCity = rememberSaveable(saver = CitySaver) { mutableStateOf(City("Madrid", "Spain")) }
    /*...*/
}

詳細

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