アプリにおいて状態とは、時間とともに変化する可能性がある値すべてを指します。これは非常に広範な定義であり、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
API を使用してオブジェクトをメモリに格納できます。初回コンポーズの際に、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
に限定されません。コンポーザブルに適した特定のイベントがある場合は、ExpandingCard
が onExpand
と onCollapse
を扱う場合と同様に、ラムダを使用してそのようなイベントを定義する必要があります。
この方法でホイスティングされる状態には、次のような重要な特性があります。
- 信頼できる唯一の情報源: 状態を複製するのではなく移動することで、信頼できる情報源を 1 つだけにすることができます。これは、バグを防ぐのに役立ちます。
- カプセル化: 状態を変更できるのはステートフル コンポーザブルに限られます。完全に内部です。
- 共有可能: ホイスティングされた状態は複数のコンポーザブルで共有できます。たとえば、別のコンポーザブルで
name
を行いたい場合は、ホイスティングでそれが可能になります。 - インターセプト可能: ステートレスなコンポーザブルの呼び出し元は、状態を変更する前にイベントを無視するか変更するかを決定できます。
- 分離: ステートレスな
ExpandingCard
の状態はどこにでも保存できます。たとえば、name
をViewModel
に移動できます。
この例では、name
と onValueChange
を HelloContent
から抽出して、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 に状態を表示するコンポーザブルと、状態を保存および変更するアプリの要素を切り離すことができます。
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"))
}
}
Compose 内の状態を管理する
シンプルな状態ホイスティングは、コンポーズ可能な関数自体で管理できます。ただし、トラッキングする状態の量が増加する場合や、コンポーズ可能な関数で実行するロジックが発生する場合は、ロジックと状態の役割を他のクラスの状態ホルダーにデリゲートすることをおすすめします。
このセクションでは、Compose でさまざまな方法で状態を管理する方法について説明します。コンポーザブルの複雑さに応じて、使用する代替方法を決定してください。
- コンポーザブル: シンプルな UI 要素の状態を管理します。
- 状態ホルダー: 複雑な UI 要素の状態を管理します。UI 要素の状態と UI ロジックを保持します。
- アーキテクチャ コンポーネントの ViewModel: ビジネス ロジックと画面または UI の状態へのアクセスを提供する特別なタイプの状態ホルダー。
状態ホルダーには、画面下のアプリバーなどの単一のウィジェットから画面全体まで、管理対象の対応する UI 要素のスコープに応じてさまざまなサイズが用意されています。状態ホルダーは組み合わせることができます。つまり、特に状態を集約する場合に、状態ホルダーを別の状態ホルダーに統合できます。
次の図は、Compose の状態管理に関連するエンティティ間の関係の概要を示しています。このセクションの残りの部分では、各エンティティについて詳しく説明します。
- コンポーザブルは、複雑さに応じて 0 個以上の状態ホルダー(プレーン オブジェクト、ViewModel、またはその両方)に依存します。
- プレーンの状態ホルダーは、ビジネス ロジックまたは画面状態にアクセスする必要がある場合、ViewModel に依存する場合があります。
- ViewModel はビジネスレイヤまたはデータレイヤに依存します。
Compose の状態管理に関連する各エンティティの依存関係(オプション)の概要
状態とロジックのタイプ
Android アプリでは、さまざまなタイプの状態を考慮する必要があります。
UI 要素の状態: UI 要素のホイスティング状態。たとえば、
ScaffoldState
はScaffold
コンポーザブルの状態を処理します。画面または UI の状態: 画面に表示する必要がある要素です。たとえば、
CartUiState
クラスは、カートのアイテム、ユーザーに表示するメッセージ、または読み込みフラグを含めることができます。この状態にはアプリのデータが含まれるため、通常は階層の他のレイヤに接続されます。
また、ロジックにもさまざまなタイプがあります。
UI の動作ロジックまたは UI ロジック: 画面に状態の変化を表示する方法に関連します。たとえば、ナビゲーション ロジックは次に表示する画面を決定し、UI ロジックはスナックバーやトーストを使用している画面にユーザー メッセージを表示する方法を決定します。UI の動作ロジックは常にコンポジション内に存在する必要があります。
ビジネス ロジック: 状態の変化により何を行うかに関連します。支払いやユーザー設定の保存などが例として挙げられます。このロジックは通常、UI レイヤではなくビジネスレイヤまたはデータレイヤに配置されます。
信頼できる情報源としてのコンポーザブル
状態とロジックが単純な場合、コンポーザブルに UI ロジックと UI 要素を格納するのが最適なアプローチです。たとえば、ScaffoldState
と CoroutineScope
を処理する MyApp
コンポーザブルは次のようになります。
@Composable
fun MyApp() {
MyTheme {
val scaffoldState = rememberScaffoldState()
val coroutineScope = rememberCoroutineScope()
Scaffold(scaffoldState = scaffoldState) {
MyContent(
showSnackbar = { message ->
coroutineScope.launch {
scaffoldState.snackbarHostState.showSnackbar(message)
}
}
)
}
}
}
ScaffoldState
には可変プロパティが含まれているため、それに対する操作はすべて MyApp
コンポーザブル内で行う必要があります。他のコンポーザブルに渡すと、渡した先のコンポーザブルでその状態が変更される可能性があります。これは、唯一の信頼できる情報源と矛盾することになるため、バグの追跡が困難になります。
信頼できる情報源としての状態ホルダー
複数の UI 要素の状態を含む複雑な UI ロジックをコンポーザブルに含める場合は、その役割を状態ホルダーにデリゲートする必要があります。これにより、このロジックを単独でテストできるようになり、コンポーザブルの複雑さを軽減できます。このアプローチは、関心の分離の原則に則ったアプローチです。つまり、コンポーザブルは UI 要素の出力を管理し、状態ホルダーには UI ロジックと UI 要素の状態が含まれます。
状態ホルダーはコンポジション内に作成され、記憶されるプレーンなクラスです。コンポーザブルのライフサイクルに沿って実行されるため、Compose の依存関係を使用できます。
信頼できる情報源としてのコンポーザブル セクションの MyApp
コンポーザブルの役割が増加した場合は、MyAppState
状態ホルダーを作成して複雑さを管理できます。
// Plain class that manages App's UI logic and UI elements' state
class MyAppState(
val scaffoldState: ScaffoldState,
val navController: NavHostController,
private val resources: Resources,
/* ... */
) {
val bottomBarTabs = /* State */
// Logic to decide when to show the bottom bar
val shouldShowBottomBar: Boolean
get() = /* ... */
// Navigation logic, which is a type of UI logic
fun navigateToBottomBarRoute(route: String) { /* ... */ }
// Show snackbar using Resources
fun showSnackbar(message: String) { /* ... */ }
}
@Composable
fun rememberMyAppState(
scaffoldState: ScaffoldState = rememberScaffoldState(),
navController: NavHostController = rememberNavController(),
resources: Resources = LocalContext.current.resources,
/* ... */
) = remember(scaffoldState, navController, resources, /* ... */) {
MyAppState(scaffoldState, navController, resources, /* ... */)
}
MyAppState
は依存関係を使用するため、コンポジション内の MyAppState
のインスタンスを記憶するメソッドを使用することをおすすめします。上記では、rememberMyAppState
関数がそれに該当します。
これで、MyApp
は UI 要素の出力に集中し、すべての UI ロジックと UI 要素の状態は MyAppState
にデリゲートします。
@Composable
fun MyApp() {
MyTheme {
val myAppState = rememberMyAppState()
Scaffold(
scaffoldState = myAppState.scaffoldState,
bottomBar = {
if (myAppState.shouldShowBottomBar) {
BottomBar(
tabs = myAppState.bottomBarTabs,
navigateToRoute = {
myAppState.navigateToBottomBarRoute(it)
}
)
}
}
) {
NavHost(navController = myAppState.navController, "initial") { /* ... */ }
}
}
}
このように、コンポーザブルの役割を増やすことで、状態ホルダーの必要性が高まります。状態ホルダーの役割は、UI ロジックに対応するまたは、トラッキングする状態の量の増加に対応することにあります。
信頼できる情報源としての ViewModel
プレーンな状態ホルダークラスが UI ロジックと UI 要素の状態を管理する場合、ViewModel は特別なタイプの状態ホルダーであり、以下の役割を担います。
- ビジネスレイヤやデータレイヤなど、通常は階層の他のレイヤに配置されるアプリのビジネス ロジックへのアクセスを提供する。
- 特定の画面に表示するアプリデータ(画面または UI の状態)を準備する。
ViewModel のライフサイクルは、構成の変更後も存続するため、コンポジションよりも長くなります。また、Compose コンテンツのホスト(アクティビティやフラグメント)や、デスティネーションまたは Navigation グラフのライフサイクル(Navigation ライブラリを使用している場合)に従うようにすることができます。ViewModel はライフサイクルが長いため、コンポジションのライフサイクルにバインドされている状態への長期的な参照を保持すべきではありません。そうすることで、メモリリークが発生する場合があります。
画面レベルのコンポーザブルには、ビジネス ロジックへのアクセスを提供し、UI の状態の信頼できる情報源となるために、ViewModel インスタンスを使用することをおすすめします。ViewModel インスタンスを他のコンポーザブルに渡すことはしないでください。この目的で ViewModel を使用できる理由については、ViewModel と状態ホルダーのセクションをご覧ください。
画面レベルのコンポーザブルで使用される ViewModel の例を次に示します。
data class ExampleUiState(
val dataToDisplayOnScreen: List<Example> = emptyList(),
val userMessages: List<Message> = emptyList(),
val loading: Boolean = false
)
class ExampleViewModel(
private val repository: MyRepository,
private val savedState: SavedStateHandle
) : ViewModel() {
var uiState by mutableStateOf(ExampleUiState())
private set
// Business logic
fun somethingRelatedToBusinessLogic() { /* ... */ }
}
@Composable
fun ExampleScreen(viewModel: ExampleViewModel = viewModel()) {
val uiState = viewModel.uiState
/* ... */
ExampleReusableComponent(
someData = uiState.dataToDisplayOnScreen,
onDoSomething = { viewModel.somethingRelatedToBusinessLogic() }
)
}
@Composable
fun ExampleReusableComponent(someData: Any, onDoSomething: () -> Unit) {
/* ... */
Button(onClick = onDoSomething) {
Text("Do something")
}
}
ViewModel と状態ホルダー
ビジネス ロジックへのアクセスを提供し、画面に表示するアプリデータを準備するのには ViewModel が適しています。これは、ViewModel の使用が Android 開発においてメリットがあるためです。具体的には、次のようなメリットがあります。
- ViewModel によってトリガーされるオペレーションは、構成変更後も引き継ぐことができます。
- Navigation との統合:
- 画面がバックスタックにある間、Navigation が ViewModel をキャッシュに保存します。これは、デスティネーションに戻るときに以前に読み込んだデータをすぐに利用可能にするために重要です。これは、コンポーザブル画面のライフサイクルに従う状態ホルダーでは困難です。
- また、デスティネーションがバックスタックからポップオフされたときにも ViewModel がクリアされ、状態が自動的にクリーンアップされるようになります。これは、さまざまな理由(新しい画面に移動する、構成が変更されたなど)のために発生するコンポーザブルの廃棄をリッスンするのとは異なります。
- Hilt など、他の Jetpack ライブラリとの統合。
状態ホルダーは複合可能であり、ViewModel とプレーンな状態ホルダーはそれぞれ役割が異なります。そのため、画面レベルのコンポーザブルに、ビジネス ロジックへのアクセスを提供する ViewModel と、UI ロジックと UI 要素の状態を管理する状態ホルダーの両方が含まれる場合もあります。ViewModel は状態ホルダーより寿命が長いため、必要に応じて、状態ホルダーは ViewModel を依存関係として使用できます。
次のコードは、ExampleScreen
で連携する ViewModel とプレーンな状態ホルダーを示しています。
class ExampleState(
val lazyListState: LazyListState,
private val resources: Resources,
private val expandedItems: List<Item> = emptyList()
) {
fun isExpandedItem(item: Item): Boolean = TODO()
/* ... */
}
@Composable
fun rememberExampleState(/* ... */): ExampleState { TODO() }
@Composable
fun ExampleScreen(viewModel: ExampleViewModel = viewModel()) {
val uiState = viewModel.uiState
val exampleState = rememberExampleState()
LazyColumn(state = exampleState.lazyListState) {
items(uiState.dataToDisplayOnScreen) { item ->
if (exampleState.isExpandedItem(item)) {
/* ... */
}
/* ... */
}
}
}
詳細
状態と Jetpack Compose の詳細については、以下の参考情報をご覧ください。