Compose の状態のライフスパン

Jetpack Compose では、コンポーズ可能な関数は多くの場合、remember 関数を使用して状態を保持します。状態と Jetpack Compose で説明されているように、記憶された値は再コンポーズ間で再利用できます。

remember は再コンポーズ全体で値を保持するツールとして機能しますが、多くの場合、状態はコンポジションの存続期間を超えて存続する必要があります。このページでは、remember、retain、rememberSaveable、rememberSerializable の各 API の違い、API の選択方法、Compose で記憶された値と保持された値を管理するためのベスト プラクティスについて説明します。

適切な寿命を選択する

Compose には、コンポジション全体で状態を保持するために使用できる関数がいくつかあります。remember、retain、rememberSaveable、rememberSerializable などです。これらの関数は、有効期間とセマンティクスが異なり、それぞれ特定の種類の状態を保存するのに適しています。違いを次の表に示します。

remember

retain

rememberSaveable、rememberSerializable

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

✅

✅

✅

アクティビティの再作成後に値が維持されるか

❌

✅

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

✅

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

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

❌

❌

✅

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

すべて

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

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

ユースケース

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

remember

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

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

記憶された値が使用されなくなると、その値は忘れられ、そのレコードは破棄されます。記憶済みの値は、コンポジション階層から削除されたとき(値が削除され、key コンポーズ可能または MovableContent を使用せずに別の場所に移動するために再追加されたときを含む)、または別の key パラメータで呼び出されたときに、失われます。

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

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

ただし、次のことは避けてください。

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

rememberSaveable、rememberSerializable

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

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

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

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

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

kotlinx.serialization を使用せずに、より複雑なデータ型を保存するには、カスタムの Saver を実装して、オブジェクトをサポートされているデータ型にシリアル化および逆シリアル化します。Compose は、State、List、Map、Set などの一般的なデータ型をすぐに認識し、サポートされている型に自動的に変換します。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 は、値をメモ化する期間の観点から、remember と rememberSaveable / rememberSerializable の間に存在します。名前が異なるのは、保持された値のライフサイクルが、記憶された値のライフサイクルとは異なるためです。

値が保持されると、位置的にメモ化され、アプリのライフサイクルに関連付けられた別のライフサイクルを持つセカンダリ データ構造に保存されます。保持された値は、シリアル化されることなく構成変更後も残りますが、プロセスの終了後は残りません。コンポジション階層の再作成後に値が使用されない場合、保持された値は破棄されます(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() }
    // ...
}

retain 対 ViewModel

retain と ViewModel はどちらも、構成変更をまたいでオブジェクト インスタンスを保持する機能が最もよく使用され、その点では同様の機能を提供します。retain と ViewModel のどちらを使用するかは、保持する値のタイプ、スコープの設定方法、追加機能が必要かどうかによって異なります。

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

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

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

最新の Android アプリ アーキテクチャの推奨事項に沿わないカスタムアプリ アーキテクチャ パターンを設計する上級ユーザーの場合、retain を使用して社内向けの「ViewModel のような」API を構築することもできます。コルーチンと保存済み状態のサポートは標準では提供されていませんが、retain は、これらの機能を備えた ViewModel に似たライフサイクルの構成要素として機能します。このようなコンポーネントを設計する方法の詳細は、このガイドでは扱いません。

retain

ViewModel

スコープ指定

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

ViewModel は ViewModelStore 内のシングルトンです。

破壊

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

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

追加機能

オブジェクトがコンポジション階層にあるかどうかに関係なく、コールバックを受信できる

組み込みの coroutineScope、SavedStateHandle のサポート、Hilt を使用して挿入可能

所有者

RetainedValuesStore

ViewModelStore

ユースケース

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

retain と rememberSaveable または rememberSerializable を組み合わせる

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

retain と rememberSaveable または 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 列のリストと詳細ビューを表示できますが、画面の小さいスマートフォンでは、リストページと詳細ページの間を移動することがあります。

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

ListDetailPaneScaffold や NavDisplay(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) }
    )
}