状態と 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 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 に限定されません。コンポーザブルに適した特定のイベントがある場合は、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 に状態を表示するコンポーザブルと、状態を保存および変更するアプリの要素を切り離すことができます。

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 の状態: 画面に表示する必要がある要素の状態です。たとえば、CartUiState クラスは、カートのアイテム、ユーザーに表示するメッセージ、または読み込みフラグを含めることができます。この状態にはアプリのデータが含まれるため、通常は階層の他のレイヤに接続されます。

  • UI 要素の状態: UI 要素のホイスティング状態。たとえば、ScaffoldStateScaffold コンポーザブルの状態を処理します。

また、ロジックにもさまざまなタイプがあります。

  • ビジネス ロジック: 状態の変化により何を行うかに関連します。支払いやユーザー設定の保存などが例として挙げられます。このロジックは通常、UI レイヤではなくビジネスレイヤまたはデータレイヤに配置されます。

  • UI ロジック: 状態の変化を画面に表示する方法に関連します。たとえば、ナビゲーション ロジックは次に表示する画面を決定し、UI ロジックはスナックバーやトーストを使用している画面にユーザー メッセージを表示する方法を決定します。UI ロジックは常にコンポジション内に存在する必要があります。

信頼できる情報源としてのコンポーザブル

状態とロジックが単純な場合、コンポーザブルに UI ロジックと UI 要素を格納するのが最適なアプローチです。たとえば、ScaffoldStateCoroutineScope を処理する 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 の詳細については、以下の参考情報をご覧ください。

Codelab

動画