Compose の状態のライフスパン

Jetpack Compose では、コンポーザブル関数は多くの場合、remember 関数を使用して状態を保持します。State や Jetpack Compose で説明されているように、保存された値は再コンポーズ間で再利用できます。

remember は再コンポーズをまたいで値を永続化するツールとして機能しますが、状態はコンポーズのライフタイムを超えて存続する必要があることがよくあります。このページでは、rememberretainrememberSaveablerememberSerializable の各 API の違い、どの API を選択すべきか、Compose で記憶された値と保持された値を管理するためのベスト プラクティスについて説明します。

適切なライフサイクルを選択する

Compose には、コンポジション間やコンポジションを超えて状態を保持するために使用できる関数がいくつかあります。rememberretainrememberSaveablerememberSerializable などです。これらの関数は、ライフサイクルとセマンティクスが異なり、それぞれ特定の種類の状態を保存するのに適しています。違いは次の表のとおりです。

remember

retain

rememberSaveablerememberSerializable

値は再コンポーズ後も保持されますか?

値はアクティビティの再作成後も維持されますか?

常に同じ(===)インスタンスが返されます。

同等の(==)オブジェクトが返されます(逆シリアル化されたコピーの場合もあります)。

値はプロセス終了後も保持されるか?

サポートされるデータタイプ

すべて

アクティビティが破棄された場合にリークされるオブジェクトを参照してはならない

シリアル化可能である必要があります(カスタム Saver または kotlinx.serialization のいずれかを使用)。

ユースケース

  • コンポジションにスコープ設定されたオブジェクト
  • コンポーザブルの構成オブジェクト
  • UI の忠実性を損なうことなく再作成できる状態
  • キャッシュ
  • 存続期間の長いオブジェクトまたは「マネージャー」オブジェクト
  • ユーザー入力
  • テキスト フィールドの入力、スクロール状態、切り替えなど、アプリで再作成できない状態。

remember

remember は、Compose で状態を保存する最も一般的な方法です。remember が初めて呼び出されると、指定された計算が実行され、記憶されます。つまり、コンポーザブルで後で再利用できるように、Compose によって保存されます。コンポーザブルが再コンポーズされると、コードが再度実行されますが、remember の呼び出しは、計算を再度実行するのではなく、以前のコンポジションから値を返します。

コンポーザブル関数の各インスタンスには、位置メモ化と呼ばれる独自の記憶値のセットがあります。再コンポーズ全体で使用するためにメモ化された値は、コンポジション階層内の位置に関連付けられます。コンポーザブルが異なる場所で使用されている場合、コンポジション階層内の各インスタンスには、独自の記憶された値のセットがあります。

記憶された値が使用されなくなると、その値は忘れられ、そのレコードは破棄されます。保存された値は、コンポジション階層から削除されたとき(key コンポーザブルまたは MovableContent を使用せずに、値を削除して別の場所に移動するために再追加した場合を含む)、または異なる key パラメータで呼び出されたときに消去されます。

使用可能な選択肢のうち、remember は最も寿命が短く、このページで説明する 4 つのメモ化関数のうち最も早く値を忘れます。このため、次のような場合に最適です。

  • スクロール位置やアニメーションの状態などの内部状態オブジェクトを作成する
  • 再コンポーズごとに高価なオブジェクトの再作成を回避する

ただし、次のことは避ける必要があります。

  • remember を使用してユーザー入力を保存する。記憶されたオブジェクトは、アクティビティの構成変更やシステムによって開始されたプロセスの終了をまたいで保持されないためです。

rememberSaveablerememberSerializable

rememberSaveablerememberSerializableremember をベースに構築されています。このガイドで説明するメモ化関数の中で最も存続期間が長くなります。再コンポーズをまたいでオブジェクトを位置的にメモ化するだけでなく、値を保存して、アクティビティの再作成をまたいで復元できるようにすることもできます。これには、設定の変更やプロセスの終了(システムがアプリのプロセスをバックグラウンドで強制終了する場合。通常は、フォアグラウンド アプリのメモリを解放するため、またはアプリの実行中にユーザーがアプリの権限を取り消した場合)が含まれます。

rememberSerializablerememberSaveable と同じように機能しますが、kotlinx.serialization ライブラリでシリアル化可能な複雑な型の永続化を自動的にサポートします。タイプが @Serializable でマークされている(またはマークされる可能性がある)場合は rememberSerializable を選択し、それ以外の場合は rememberSaveable を選択します。

これにより、rememberSaveablerememberSerializable は、テキスト フィールドの入力、スクロール位置、切り替え状態など、ユーザー入力に関連付けられた状態を保存するのに最適な候補となります。ユーザーが現在位置を見失わないように、この状態を保存する必要があります。一般に、アプリがデータベースなどの別の永続データソースから取得できない状態をメモ化するには、rememberSaveable または rememberSerializable を使用する必要があります。

rememberSaveablerememberSerializable は、メモ化された値を Bundle にシリアル化して保存します。これには次の 2 つの結果があります。

  • メモ化する値は、プリミティブ(IntLongFloatDouble を含む)、String、またはこれらの型の配列の 1 つ以上のデータ型で表現できる必要があります。
  • 保存された値が復元されると、それは(==)と等しい新しいインスタンスになりますが、コンポジションが以前に使用したのと同じ参照(===)ではありません。

kotlinx.serialization を使用せずに複雑なデータ型を保存するには、カスタム Saver を実装して、オブジェクトをサポートされているデータ型にシリアル化および逆シリアル化します。Compose は、StateListMapSet などの一般的なデータ型をすぐに理解し、これらの型をサポートされている型に自動的に変換します。Size クラスの Saver の例を次に示します。これは、listSaver を使用して Size のすべてのプロパティをリストにパックすることで実装されます。

data class Size(val x: Int, val y: Int) {
    object Saver : androidx.compose.runtime.saveable.Saver<Size, Any> by listSaver(
        save = { listOf(it.x, it.y) },
        restore = { Size(it[0], it[1]) }
    )
}

@Composable
fun rememberSize(x: Int, y: Int) {
    rememberSaveable(x, y, saver = Size.Saver) {
        Size(x, y)
    }
}

retain

retain API は、値をメモ化する期間の点で rememberrememberSaveable/rememberSerializable の間に存在します。名前が異なるのは、保持された値のライフサイクルが remember で保存された値のライフサイクルと異なるためです。

値が保持されると、位置的にメモ化され、アプリのライフサイクルに関連付けられた別のライフサイクルを持つセカンダリ データ構造に保存されます。保持された値は、シリアル化されずに構成の変更を乗り切ることができますが、プロセスの終了を乗り切ることはできません。コンポジション階層の再作成後に値が使用されなかった場合、保持された値は廃止されます(これは retain の忘れられることに相当します)。

この rememberSaveable より短いライフサイクルと引き換えに、retain は、ラムダ式、フロー、ビットマップなどの大きなオブジェクトなど、シリアル化できない値を保持できます。たとえば、retain を使用してメディア プレーヤー(ExoPlayer など)を管理し、構成の変更中にメディア再生が中断されないようにすることができます。

@Composable
fun MediaPlayer() {
    // Use the application context to avoid a memory leak
    val applicationContext = LocalContext.current.applicationContext
    val exoPlayer = retain { ExoPlayer.Builder(applicationContext).apply { /* ... */ }.build() }
    // ...
}

retainViewModel

retainViewModel は、構成の変更にわたってオブジェクト インスタンスを永続化するという最も一般的な機能において、同様の機能を提供します。retainViewModel のどちらを使用するかは、永続化する値の型、スコープ設定の方法、追加機能が必要かどうかによって決まります。

ViewModel は、通常、アプリの UI レイヤとデータレイヤ間の通信をカプセル化するオブジェクトです。これにより、ロジックをコンポーザブル関数から移動できるため、テスト可能性が向上します。ViewModelViewModelStore 内のシングルトンとして管理され、保持された値とは異なるライフサイクルを持ちます。ViewModelViewModelStore が破棄されるまでアクティブな状態を維持しますが、保持された値は、コンテンツがコンポジションから完全に削除されると廃止されます(たとえば、構成が変更された場合、UI 階層が再作成され、コンポジションの再作成後に保持された値が使用されなかった場合、保持された値は廃止されます)。

ViewModel には、Dagger と Hilt を使用した依存関係インジェクションのすぐに使える統合、SavedState との統合、バックグラウンド タスクを起動するためのコルーチンの組み込みサポートも含まれています。そのため、ViewModel は、バックグラウンド タスクやネットワーク リクエストの起動、プロジェクト内の他のデータソースとのやり取り、必要に応じて ViewModel の構成変更後も保持され、プロセスの終了後も存続するミッション クリティカルな UI 状態のキャプチャと永続化を行うのに最適な場所です。

retain は、特定のコンポーザブル インスタンスにスコープ設定され、兄弟コンポーザブル間での再利用や共有を必要としないオブジェクトに最適です。ViewModel は UI 状態の保存とバックグラウンド タスクの実行に適した場所ですが、retain は、キャッシュ、インプレッション トラッキングと分析、AndroidView への依存関係、Android OS とやり取りするオブジェクトや、決済処理や広告などのサードパーティ ライブラリを管理するオブジェクトなど、UI の配管用のオブジェクトを保存するのに適しています。

モダン Android アプリ アーキテクチャの推奨事項以外のカスタム アプリ アーキテクチャ パターンを設計する上級ユーザーの場合: retain を使用して、社内用の「ViewModel のような」API を構築することもできます。コルーチンと保存状態のサポートはすぐに利用できるわけではありませんが、retain は、これらの機能が上に構築された ViewModel のようなもののライフサイクルのビルディング ブロックとして機能します。このようなコンポーネントを設計する方法の詳細は、このガイドの範囲外です。

retain

ViewModel

範囲決定

共有値はありません。各値は、コンポジション階層内の特定のポイントで保持され、関連付けられます。別のロケーションで同じタイプを保持すると、常に新しいインスタンスに対して動作します。

ViewModelViewModelStore 内のシングルトンです。

破壊

コンポジション階層から完全に離れる場合

ViewModelStore がクリアまたは破棄された場合

追加機能

オブジェクトがコンポジション階層内にあるかどうかでコールバックを受け取ることができる

組み込みの coroutineScopeSavedStateHandle のサポートは、Hilt を使用して挿入できます

所有者

RetainedValuesStore

ViewModelStore

ユースケース

  • 個々のコンポーザブル インスタンスにローカルな UI 固有の値を永続化する
  • インプレッション トラッキング(RetainedEffect を使用)
  • カスタムの「ViewModel のような」アーキテクチャ コンポーネントを定義するためのビルディング ブロック
  • コードの整理とテストの両方のために、UI レイヤとデータレイヤ間のインタラクションを別のクラスに抽出する
  • FlowState オブジェクトに変換し、構成の変更によって中断されない suspend 関数を呼び出す
  • 画面全体など、UI の広い範囲で状態を共有する
  • View との相互運用性

retainrememberSaveable または rememberSerializable を組み合わせる

オブジェクトに retainedrememberSaveable または rememberSerializable の両方のハイブリッド ライフサイクルが必要になることがあります。これは、オブジェクトが ViewModel であるべきことを示す可能性があります。ViewModel は、ViewModel の保存済み状態モジュールのガイドで説明されているように、保存済み状態をサポートできます。

retainrememberSaveable または rememberSerializable を同時に使用できます。両方のライフサイクルを正しく組み合わせると、複雑さが大幅に増します。このパターンは、より高度なカスタム アーキテクチャ パターンの一部として、次の条件をすべて満たす場合にのみ使用することをおすすめします。

  • 保持または保存する必要がある値の組み合わせで構成されるオブジェクトを定義している(ユーザー入力を追跡するオブジェクトや、ディスクに書き込めないインメモリ キャッシュなど)。
  • 状態がコンポーザブルにスコープ設定されており、ViewModel のシングルトン スコープ設定やライフサイクルには適していません

このような場合は、クラスを 3 つの部分に分割することをおすすめします。保存されたデータ、保持されたデータ、独自の状態を持たず、保持されたオブジェクトと保存されたオブジェクトに委任して状態を適宜更新する「メディエータ」オブジェクトです。このパターンは次のようになります。

@Composable
fun rememberAndRetain(): CombinedRememberRetained {
    val saveData = rememberSerializable(serializer = serializer<ExtractedSaveData>()) {
        ExtractedSaveData()
    }
    val retainData = retain { ExtractedRetainData() }
    return remember(saveData, retainData) {
        CombinedRememberRetained(saveData, retainData)
    }
}

@Serializable
data class ExtractedSaveData(
    // All values that should persist process death should be managed by this class.
    var savedData: AnotherSerializableType = defaultValue()
)

class ExtractedRetainData {
    // All values that should be retained should appear in this class.
    // It's possible to manage a CoroutineScope using RetainObserver.
    // See the full sample for details.
    var retainedData = Any()
}

class CombinedRememberRetained(
    private val saveData: ExtractedSaveData,
    private val retainData: ExtractedRetainData,
) {
    fun doAction() {
        // Manipulate the retained and saved state as needed.
    }
}

状態をライフスパンで分離することで、責任とストレージの分離が非常に明確になります。保存データは保持データによって操作できないように意図的に設計されています。これにより、savedInstanceState バンドルがすでにキャプチャされていて更新できない場合に、保存データの更新が試行されるシナリオを防ぐことができます。また、Compose を呼び出したり、Activity の再作成をシミュレートしたりせずに、コンストラクタをテストすることで、再作成シナリオをテストすることもできます。

このパターンの実装方法の完全な例については、完全なサンプル(RetainAndSaveSample.kt)をご覧ください。

位置メモ化とアダプティブ レイアウト

Android アプリケーションは、スマートフォン、折りたたみ式デバイス、タブレット、デスクトップなど、さまざまなフォーム ファクタをサポートできます。アプリでは、アダプティブ レイアウトを使用してこれらのフォーム ファクタ間を頻繁に移行する必要があります。たとえば、タブレットで実行されているアプリは 2 列のリスト詳細ビューを表示できますが、スマートフォンなどの小さな画面に表示される場合は、リストと詳細ページの間を移動する場合があります。

remember で保存された値と保持された値は位置的にメモ化されるため、コンポジション階層の同じポイントに現れた場合にのみ再利用されます。レイアウトがさまざまなフォーム ファクタに対応するにつれて、コンポジション階層の構造が変更され、値が忘れられる可能性があります。

ListDetailPaneScaffoldNavDisplay(Jetpack Navigation 3 から)などの組み込みコンポーネントの場合、これは問題ではなく、レイアウトの変更後も状態は保持されます。フォーム ファクタに適応するカスタム コンポーネントの場合、次のいずれかの方法で、レイアウトの変更によって状態が影響を受けないようにします。

  • ステートフル コンポーザブルがコンポジション階層の同じ場所で常に呼び出されるようにします。コンポジション階層内のオブジェクトを再配置するのではなく、レイアウト ロジックを変更して、アダプティブ レイアウトを実装します。
  • MovableContent を使用して、ステートフル コンポーザブルを正常に再配置します。MovableContent のインスタンスは、保存された値と保持された値を古い場所から新しい場所に移動できます。

ファクトリー関数を記憶する

Compose UI はコンポーズ可能な関数で構成されていますが、コンポジションの作成と編成には多くのオブジェクトが関与します。この最も一般的な例は、LazyListState を受け取る LazyList のように、独自の状態を定義する複雑なコンポーザブル オブジェクトです。

Compose に焦点を当てたオブジェクトを定義する場合は、remember 関数を作成して、寿命とキー入力の両方を含む、意図した記憶動作を定義することをおすすめします。これにより、状態のコンシューマーは、想定どおりに存続し、無効になるコンポジション階層でインスタンスを自信を持って作成できます。コンポーズ可能なファクトリ関数を定義する場合は、次のガイドラインに従ってください。

  • 関数名の先頭に remember を付けます。必要に応じて、関数実装がオブジェクトの retained に依存しており、API が remember の別のバリエーションに依存するように進化することはない場合は、代わりに retain 接頭辞を使用します。
  • 状態の永続性が選択され、正しい Saver 実装を記述できる場合は、rememberSaveable または rememberSerializable を使用します。
  • 使用に関連しない可能性がある CompositionLocal に基づく副作用や値の初期化を回避します。状態が作成される場所と使用される場所は異なる場合があります。

@Composable
fun rememberImageState(
    imageUri: String,
    initialZoom: Float = 1f,
    initialPanX: Int = 0,
    initialPanY: Int = 0
): ImageState {
    return rememberSaveable(imageUri, saver = ImageState.Saver) {
        ImageState(
            imageUri, initialZoom, initialPanX, initialPanY
        )
    }
}

data class ImageState(
    val imageUri: String,
    val zoom: Float,
    val panX: Int,
    val panY: Int
) {
    object Saver : androidx.compose.runtime.saveable.Saver<ImageState, Any> by listSaver(
        save = { listOf(it.imageUri, it.zoom, it.panX, it.panY) },
        restore = { ImageState(it[0] as String, it[1] as Float, it[2] as Int, it[3] as Int) }
    )
}