Google は、黒人コミュニティに対する人種平等の促進に取り組んでいます。取り組みを見る

状態と Jetpack Compose

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

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

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

Jetpack Compose では、Android アプリが状態をどこで、どのように保存し、使用するかの設定を明示的に行うことができます。

UI 更新ループとイベント

Android アプリでは、イベントに応じて状態が更新されます。イベントとはアプリ外で生成された入力であり、OnClickListener を呼び出すボタンをユーザーがタップする操作、EditText による afterTextChanged の呼び出し、加速度計による新しい値の送信などがあります。

すべての Android アプリは、次のようなコア UI 更新ループを備えています。

Android アプリのコア UI 更新ループ。

  • イベント: ユーザーまたはプログラムの要素によってイベントが生成されます。
  • 状態の更新: イベント ハンドラが状態を変更します。
  • 状態の表示: UI が更新され、新しい状態を表示します。

Jetpack Compose では、状態とイベントは分離されています。状態は変更可能な値を表し、イベントは何かが起こったことの通知を表します。

状態をイベントから分離することで、状態の保存と変更の仕組みから状態の表示を切り離すことが可能になります。

Jetpack Compose の単方向データフロー

Compose は、単方向データフローを使用するように設計されています。この設計では、状態は下方に流れ、イベントは上方に流れます

図 1. 単方向データフロー

単方向データフローに従うことで、UI に状態を表示するコンポーザブルと、状態を保存および変更するアプリの要素を切り離すことができます。

単方向データフローを使用するアプリの UI 更新ループは、次のようになります。

  • イベント: UI の要素によってイベントが生成され、上位に渡されます。
  • 状態の更新: イベント ハンドラが状態を変更します。
  • 状態の表示: 状態が下位に渡され、UI が新しい状態を観測して表示します。

Jetpack Compose を使用する際にこのパターンに従うと、次のような利点があります。

  • テストの容易性: 状態を表示する UI から状態を切り離すことで、両者を分離して簡単にテストできます。
  • 状態のカプセル化: 状態が 1 か所でのみ更新されるため、状態の不整合(バグ)が生じる可能性が低くなります。
  • UI の整合性: オブザーバブルな状態ホルダーを使用することにより、すべての状態の更新が UI に即座に反映されます。

ViewModel と単方向データフロー

Android アーキテクチャ コンポーネントの ViewModelLiveData を使用する場合は、アプリに単方向データフローを導入します。

Compose で ViewModel を参照する前に、Android ビューを使用する Activity と、"Hello, ${name}" を表示してユーザーが名前を入力できるようにする単方向データフローを使用することを検討してください。

ViewModel によるユーザー入力の例。

この画面のコードでは ViewModelActivity を使用しています。

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

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

class HelloActivity : AppCompatActivity() {
   val helloViewModel by viewModels<HelloViewModel>()

   override fun onCreate(savedInstanceState: Bundle?) {
       /* … */
       // binding represents the activity layout, inflated with ViewBinding
       binding.textInput.doAfterTextChanged {
           helloViewModel.onNameChanged(it.toString())
       }

       helloViewModel.name.observe(this) { name ->
           binding.helloText.text = "Hello, $name"
       }
   }
}

Android アーキテクチャ コンポーネントを使用することで、この Activity に単方向データフロー設計を導入しています。

図 2. ViewModel を使用する Activity での単方向データフロー

UI 更新ループで単方向データフローがどのように動作するかを確認するには、この Activity のループをチェックしてください。

  1. イベント: テキスト入力が変更されると、UI によって onNameChanged が呼び出されます。
  2. 状態の更新: onNameChanged が処理を行った後、_name の状態を設定します。
  3. 状態の表示: name のオブザーバーが呼び出され、UI が新しい状態を表示します。

ViewModel と Jetpack Compose

前のセクションの Activity の場合と同様に、Jetpack Compose の LiveDataViewModel を使用して単方向データフローを実装できます。

同じ HelloViewModel を使用して Jetpack Compose で記述された HelloActivity と同じ画面のコードを次に示します。

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

   // onNameChanged is an event we're defining that the UI can invoke
   // (events flow up from UI)
   fun onNameChanged(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("")

   Column {
       Text(text = name)
       TextField(
           value = name,
           onValueChange = { helloViewModel.onNameChanged(it) },
           label = { Text("Name") }
       )
   }
}

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

ビューモデルと Hello 画面の間の単方向フロー。

このコンポーザブルの UI イベントループについて考えてみましょう。

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

ViewModelLiveData を使用して Android で単方向データフローを構築する方法の詳細については、アプリ アーキテクチャのガイドをご覧ください。

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

ステートレスなコンポーザブルとは、状態そのものを変更できないコンポーザブルです。ステートレスなコンポーザブルはテストが簡単で、通常はバグが少なく、再利用の機会を数多く提供します。

コンポーザブルに状態がある場合は、状態ホイスティングを使用してステートレスにすることができます。状態ホイスティングとは、コンポーザブルの内部状態をパラメータとイベントで置き換えることにより、状態をコンポーザブルの呼び出し元に移動するプログラミング パターンです。

状態ホイスティングの例を確認するには、HelloScreen からステートレスなコンポーザブルを抽出します。

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

   // name is the _current_ value of [helloViewModel.name]
   val name: String by helloViewModel.name.observeAsState("")

   HelloInput(name = name, onNameChange = { helloViewModel.onNameChanged(it) })
}

@Composable
fun HelloInput(
   /* state */ name: String,
   /* event */ onNameChange: (String) -> Unit
) {
   Column {
       Text(name)
       TextField(
           value = name,
           onValueChange = onNameChange,
           label = { Text("Name") }
       )
   }
}

HelloInput は、不変の String パラメータとしての状態にアクセスできます。また、状態の変更をリクエストするときに呼び出せるイベント onNameChange としての状態にもアクセスできます。

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

HelloScreen は、name 状態を直接変更できる最終クラス HelloViewModel への依存関係を含んでいるため、ステートフルなコンポーザブルです。HelloScreen の呼び出し元が name 状態の更新を制御する方法はありません。HelloInput は、状態を直接変更できないため、ステートレスなコンポーザブルです。

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

状態ホイスティングのプロセスを使用すると、単方向データフローをステートレスなコンポーザブルに拡張できます。そのようなコンポーザブルの単方向データフロー図では、状態を操作するコンポーザブルが増えても、常に状態が下方に流れ、イベントが上方に流れます。

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

ステートレスなコンポーザブルであっても、単方向データフローと状態ホイスティングを使用することより、時間とともに変化する状態を操作できることを理解することが重要です。

この仕組みを理解するため、HelloInput の UI 更新ループについて考えてみましょう。

  1. イベント: ユーザーが文字を入力すると、それに応じて onNameChange が呼び出されます。
  2. 状態の更新: HelloInput は状態を直接変更できません。呼び出し元は、onNameChange イベントに応じて状態の変更を選択できます。ここで、呼び出し元 HelloScreenHelloViewModelonNameChanged を呼び出し、それによって name の状態が更新されます。
  3. 状態の表示: name の値が変更されると、observeAsState が原因となって、更新された nameHelloScreen が再度呼び出されます。次に、新しい name パラメータで HelloInput が再度呼び出されます。状態の変更に応じてコンポーザブルを再度呼び出すことを、再コンポジションと呼びます。

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

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

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

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

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

コンポーザブル内の状態

コンポーズ可能な関数は、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) }
    …
}
図 3. formattedText を子として持つ FancyText の Composition

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

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

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

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

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

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

図 5. 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(Icons.Default.ExpandLess)
               }
           } else {
               // change expanded in response to click events
               IconButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) {
                   Icon(Icons.Default.ExpandMore)
               }
           }
       }
   }
}

このコンポーザブルでは、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> に変換してコンポーザブルで読み取れるようにすることができます。

また、invalidate を使用して手動で再コンポジションをトリガーすることにより、非オブザーバブルな状態オブジェクト用の統合レイヤを構築できます。この方法は、非オブザーバブルな型の相互運用が必要な状況でのみ使用する必要があります。invalidate を使用したコードは誤りが生じやすく、オブザーバブルな状態オブジェクトを使用する同様のコードよりも読みづらい複雑なコードになりがちです。

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

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

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

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

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

2 つのコンポーザブルは、異なるパラメータを受け取るにもかかわらず、両方とも ExpandingCard という名前になっていることにご注意ください。UI を出力するコンポーザブルの命名規則は、コンポーザブルが画面に表示するものを記述する CapitalCase 形式の名詞です。この場合は、どちらも 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(Icons.Default.ExpandLess)
               }
           } else {
               IconButton(onClick = onExpand, Modifier.fillMaxWidth()) {
                   Icon(Icons.Default.ExpandMore)
               }
           }
       }
   }
}

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

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

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

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

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

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

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

内部状態と構成変更

Composition の remember によって保存された値は、回転などの構成が変更された際に消去され、再作成されます。

remember { mutableStateOf(false) } を使用している場合、ユーザーがスマートフォンを回転させるたびに、ステートフルな ExpandingCard は折りたたまれた状態にリセットされます。これを解決するには、代わりに保存済みインスタンスの状態を使用して、構成変更時に状態を自動的に保存して復元します。

@Composable
fun ExpandingCard(title: String, body: String) {
   var expanded by savedInstanceState { false }
   ExpandingCard(
       title = title,
       body = body,
       expanded = expanded,
       onExpand = { expanded = true },
       onCollapse = { expanded = false }
   )
}

コンポーズ可能な関数 savedInstanceState<T> は、構成変更時に自分自身を自動的に保存して復元する MutableState<T> を返します。ユーザーが構成変更後も保持されることを期待する内部状態には、この関数を使用する必要があります。

詳細

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