状態と Jetpack Compose

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

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

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

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

状態とコンポジション

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

@Composable
private fun HelloContent() {
    Column(modifier = Modifier.padding(16.dp)) {
        Text(
            text = "Hello!",
            modifier = Modifier.padding(bottom = 8.dp),
            style = MaterialTheme.typography.bodyMedium
        )
        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 を読み取るすべてのコンポーズ可能な関数の再コンポーズがスケジュール設定されます。

コンポーザブルの 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.bodyMedium
            )
        }
        OutlinedTextField(
            value = name,
            onValueChange = { name = it },
            label = { Text("Name") }
        )
    }
}

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

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

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

Compose には、Android アプリで一般的に使用されるオブザーバブルな型から State<T> を作成する関数が用意されています。これらの統合を使用する前に、下記の説明に沿って適切なアーティファクトを追加してください。

  • Flow: collectAsStateWithLifecycle()

    collectAsStateWithLifecycle() は、ライフサイクルを意識した方法で Flow から値を収集します。そのため、アプリはアプリリソースを節約できます。これは、Compose State から出力された最新の値を表します。この API は、Android アプリでフローを収集するためのおすすめの方法として使用してください。

    build.gradle ファイルには次の依存関係が必要です(2.6.0-beta01 以降であることが必要です)。

Kotlin

dependencies {
      ...
      implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
}

Groovy

dependencies {
      ...
      implementation "androidx.lifecycle:lifecycle-runtime-compose:2.8.7"
}
  • Flow: collectAsState()

    collectAsStatecollectAsStateWithLifecycle に似ています。こちらも、Flow から値を収集して Compose State に変換します。

    プラットフォームに依存しないコードの場合は、Android のみの collectAsStateWithLifecycle ではなく、collectAsState を使用します。

    compose-runtime で使用できるため、collectAsState には追加の依存関係は必要ありません。

  • LiveData: observeAsState()

    observeAsState() はこの LiveData のモニタリングを開始し、State を介してその値を表します。

    build.gradle ファイルに、次のような依存関係が必要です。

Kotlin

dependencies {
      ...
      implementation("androidx.compose.runtime:runtime-livedata:1.7.5")
}

Groovy

dependencies {
      ...
      implementation "androidx.compose.runtime:runtime-livedata:1.7.5"
}

Kotlin

dependencies {
      ...
      implementation("androidx.compose.runtime:runtime-rxjava2:1.7.5")
}

Groovy

dependencies {
      ...
      implementation "androidx.compose.runtime:runtime-rxjava2:1.7.5"
}

Kotlin

dependencies {
      ...
      implementation("androidx.compose.runtime:runtime-rxjava3:1.7.5")
}

Groovy

dependencies {
      ...
      implementation "androidx.compose.runtime:runtime-rxjava3:1.7.5"
}

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

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

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

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

状態ホイスティング

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

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

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

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

  • 信頼できる唯一の情報源: 状態を複製するのではなく移動することで、信頼できる情報源を 1 つだけにすることができます。これは、バグを防ぐのに役立ちます。
  • カプセル化: 状態を変更できるのはステートフル コンポーザブルに限られます。完全に内部です。
  • 共有可能: ホイスティングされた状態は複数のコンポーザブルで共有できます。別のコンポーザブルで name を読み取りたい場合は、ホイスティングでそれが可能になります。
  • インターセプト可能: ステートレスなコンポーザブルの呼び出し元は、状態を変更する前にイベントを無視するか変更するかを決定できます。
  • 分離: ステートレスなコンポーザブルの状態はどこにでも保存できます。たとえば、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.bodyMedium
        )
        OutlinedTextField(value = name, onValueChange = onNameChange, label = { Text("Name") })
    }
}

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

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

詳しくは、状態をホイスティングする場所のページをご覧ください。

Compose 内の状態を復元する

rememberSaveable API は、再コンポーズ後も状態を保持し、保存されたインスタンス状態メカニズムを使用するアクティビティやプロセスの再作成後も状態を保持するため、remember と同様に動作します。たとえば、画面が回転されたときに発生します。

状態を保存する方法

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 状態のページをご覧ください。

キーが変更されたときに remember による計算を再トリガーする

remember API は、多くの場合 MutableState と併用されます。

var name by remember { mutableStateOf("") }

ここで、remember 関数を使用すると、MutableState 値が再コンポーズ後も保持されます。

一般的に、remembercalculation ラムダ パラメータを受け取ります。remember が初めて実行されると、calculation ラムダが呼び出され、結果が保存されます。再コンポーズの際に、remember は最後に保存された値を返します。

キャッシュ状態とは別に、remember を使用して、初期化や計算のコストが高いオブジェクトまたはオペレーションの結果を Composition に保存することもできます。この計算を再コンポーズのたびに繰り返すことはおすすめしません。例としては、次の ShaderBrush オブジェクトの作成が挙げられます。これは高コストのオペレーションです。

val brush = remember {
    ShaderBrush(
        BitmapShader(
            ImageBitmap.imageResource(res, avatarRes).asAndroidBitmap(),
            Shader.TileMode.REPEAT,
            Shader.TileMode.REPEAT
        )
    )
}

remember は、Composition から出るまで値を保存します。ただし、キャッシュに保存された値を無効にする方法があります。remember API は、key パラメータまたは keys パラメータも受け取ります。いずれかのキーが変更されると、次に関数が再コンポーズされたときにremember はキャッシュを無効にして計算ラムダブロックを再度実行します。このメカニズムにより、Composition 内のオブジェクトの存続期間を制御できます。この計算は、remember で保存されている値がコンポジションから出るまでではなく、入力が変更されるまで有効です。

次の例は、このメカニズムの仕組みを示しています。

このスニペットでは、ShaderBrush が作成され、Box コンポーザブルの背景塗りとして使用されます。前述のように、再作成にコストがかかるため、rememberShaderBrush インスタンスを保存します。remember は、選択された背景画像である key1 パラメータとして avatarRes を受け取ります。avatarRes が変化すると、ブラシは新しい画像で再コンポーズし、Box に再適用されます。この処理は、ユーザーが選択ツールから背景に別の画像を選択したときに行われる可能性があります。

@Composable
private fun BackgroundBanner(
    @DrawableRes avatarRes: Int,
    modifier: Modifier = Modifier,
    res: Resources = LocalContext.current.resources
) {
    val brush = remember(key1 = avatarRes) {
        ShaderBrush(
            BitmapShader(
                ImageBitmap.imageResource(res, avatarRes).asAndroidBitmap(),
                Shader.TileMode.REPEAT,
                Shader.TileMode.REPEAT
            )
        )
    }

    Box(
        modifier = modifier.background(brush)
    ) {
        /* ... */
    }
}

次のスニペットでは、状態はプレーンな状態ホルダークラス MyAppState にホイスティングされます。この関数は rememberMyAppState 関数を公開し、remember を使用してクラスのインスタンスを初期化します。このような関数を公開して再コンポーズ後も存続するインスタンスを作成することは、Compose では一般的なパターンです。rememberMyAppState 関数は windowSizeClass を受け取ります。これは rememberkey パラメータとして機能します。このパラメータが変更された場合、アプリはプレーンな状態ホルダークラスを最新の値で再作成する必要があります。これは、ユーザーがデバイスを回転させた場合などに発生することがあります。

@Composable
private fun rememberMyAppState(
    windowSizeClass: WindowSizeClass
): MyAppState {
    return remember(windowSizeClass) {
        MyAppState(windowSizeClass)
    }
}

@Stable
class MyAppState(
    private val windowSizeClass: WindowSizeClass
) { /* ... */ }

Compose は、クラスの equals 実装を使用して、キーが変更されたかどうかを判断し、保存されている値を無効にします。

再コンポーズ以外のキーを使用して状態を保存する

rememberSaveable API は、データを Bundle に保存できる remember のラッパーです。この API を使用すると、状態は再コンポーズだけでなく、アクティビティの再作成やシステムによって開始されたプロセスの終了後も保持されます。rememberSaveable は、rememberkeys を受け取るのと同じ目的で、input パラメータを受け取ります。入力のいずれかが変更されると、キャッシュは無効になります。次回関数が再コンポーズされたときに、rememberSaveable が計算ラムダブロックを再実行します。

次の例では、typedQuery が変更されるまで rememberSaveableuserTypedQuery を保存します。

var userTypedQuery by rememberSaveable(typedQuery, stateSaver = TextFieldValue.Saver) {
    mutableStateOf(
        TextFieldValue(text = typedQuery, selection = TextRange(typedQuery.length))
    )
}

詳細

状態と Jetpack Compose の詳細については、以下の参考情報をご覧ください。

サンプル

Codelabs

動画

ブログ