Compose で UI 状態を保存する

UI 状態の保存と復元は、状態をホイスティングする場所と必要なロジックに応じて、さまざまな API を使用して行うことできます。どのアプリも、これを最適な形で実現するために API を組み合わせて使用しています。

アクティビティやプロセスの再作成が原因で、Android アプリの UI 状態が失われる可能性があります。このように状態が失われるのは、以下の事象が原因です。

こういった事象の後に状態を存続させることは、好ましいユーザー エクスペリエンスの実現に不可欠です。どの状態を存続させるのが適切かは、アプリの個々のユーザーフローによって違ってきます。ベスト プラクティスとしては、少なくともユーザー入力とナビゲーション関連の状態は存続させることをおすすめします。たとえば、リストのスクロール位置、ユーザーが詳細を確認したいと思うアイテムの ID、選択している途中のユーザー設定、テキスト フィールドへの入力などです。

このページでは、UI 状態の保存に使用できる API の概要を、状態がホイスティングされる場所と、それを必要とするロジックごとにまとめています。

UI ロジック

UI で状態がホイスティングされる場合、コンポーズ可能な関数と、Composition をスコープとする通常の状態ホルダークラスのどちらでも、rememberSaveable を使用するとアクティビティとプロセスの再作成前後で状態を保持できます。

次のスニペットでは、rememberSaveable を使用して 1 つのブール値の UI 要素の状態を保存しています。

@Composable
fun ChatBubble(
    message: Message
) {
    var showDetails by rememberSaveable { mutableStateOf(false) }

    ClickableText(
        text = AnnotatedString(message.content),
        onClick = { showDetails = !showDetails }
    )

    if (showDetails) {
        Text(message.timestamp)
    }
}

図 1: タップするとチャット メッセージのふきだしが開いたり閉じたりする

showDetails は、チャットのふきだしが開いているか閉じているかを示すブール値の変数です。

rememberSaveable が、保存済みインスタンス状態のメカニズムにより、UI 要素の状態Bundle に保存します。

プリミティブ型はバンドルに自動保存できます。データクラスなど、プリミティブではない型に状態を保持する場合は、さまざまな保存メカニズムを使用できます。たとえば、Parcelize アノテーションの使用、listSavermapSaver のような Compose API の使用、Compose ランタイムを拡張するカスタム セーバー クラス Saver クラスの実装などです。これらのメソッドの詳細については、状態を保存する方法をご覧ください。

次のスニペットでは、rememberLazyListState Compose API で rememberSaveable を使用して、LazyColumn または LazyRow のスクロール状態から構成される LazyListState を保存しています。ここでは、スクロール状態の保存と復元が可能なカスタム セーバーである LazyListState.Saver を使用します。アクティビティまたはプロセスの再作成後(デバイスの向きの変更などの構成変更の後)、スクロール状態が維持されます。

@Composable
fun rememberLazyListState(
    initialFirstVisibleItemIndex: Int = 0,
    initialFirstVisibleItemScrollOffset: Int = 0
): LazyListState {
    return rememberSaveable(saver = LazyListState.Saver) {
        LazyListState(
            initialFirstVisibleItemIndex, initialFirstVisibleItemScrollOffset
        )
    }
}

ベスト プラクティス

rememberSaveableBundle を使用して UI 状態を保存し、それがアクティビティでの onSaveInstanceState() の呼び出しなど、そこに書き込みを行う他の API によって共有されます。ただし、この Bundle のサイズには制限があり、大きなオブジェクトを保存すると実行時に TransactionTooLarge 例外が発生する可能性があります。これは特に、同じ Bundle がアプリ全体で使用されている単一 Activity アプリで問題になります。

この種のクラッシュを回避するために、サイズの大きい複雑なオブジェクトやオブジェクト リストをバンドルに保存しないでください

代わりに、必要最小限の状態(ID やキーなど)を保存し、それらを使用して、より複雑な UI 状態の復元を他のメカニズム(永続ストレージなど)に委譲してください。

どの設計を選択するかは、アプリの具体的なユースケースと、ユーザーが期待する動作によって異なります。

状態の復元を検証する

rememberSaveable で Compose 要素に保存された状態が、アクティビティまたはプロセスを再作成したときに正しく復元されることを検証できます。StateRestorationTester など、これを行う専用の API があります。詳しくは、こちらのテストをご覧ください。

ビジネス ロジック

ビジネス ロジックに必要であることから UI 要素の状態ViewModel にホイスティングする場合は、ViewModel の API を使用できます。

Android アプリで ViewModel を使用する主なメリットの一つは、構成変更をコストなしで処理できることです。構成変更があり、アクティビティが破棄されて再作成されるとき、ViewModel にホイスティングされた UI 状態はメモリに保持されます。再作成後は、古い ViewModel インスタンスが新しいアクティビティ インスタンスにアタッチされます。

なお、ViewModel インスタンスは、システムの主導によるプロセスの終了後まで存続しません。この状況で UI 状態を存続させるには、SavedStateHandle API を含む ViewModel の保存済み状態のモジュールを使用してください。

ベスト プラクティス

SavedStateHandle も UI 状態の保存に Bundle メカニズムを使用するため、これはシンプルな UI 要素の状態を保存する場合にのみ使用する必要があります。

ビジネスルールを適用し、UI 以外のアプリのレイヤにアクセスすることで生成される画面 UI 状態は、複雑さとサイズに関する問題があるため、SavedStateHandle に保存しないでください。複雑なデータや大規模なデータの保存には、ローカル永続ストレージなど、さまざまなメカニズムを使用できます。プロセスの再作成後、SavedStateHandle に保存されて復元された一時状態(存在する場合)で画面が再作成され、画面の UI 状態がデータレイヤーから再度生成されます。

SavedStateHandle API

SavedStateHandle には、UI 要素の状態を保存するためのさまざまな API があります。最も注目すべきなのは次の API です。

Compose State saveable()
StateFlow getStateFlow()

Compose State

SavedStateHandlesaveable API を使用して UI 要素の状態を MutableState として読み書きします。そのため、最小限のコード セットアップでアクティビティとプロセスの再作成後まで維持できます。

saveable API は、組み込みのプリミティブ型をサポートし、rememberSaveable() と同様に、stateSaver パラメータを受け取ってカスタム セーバーを使用します。

次のスニペットでは、message がユーザー入力タイプを TextField に保存しています。

class ConversationViewModel(
    savedStateHandle: SavedStateHandle
) : ViewModel() {

    var message by savedStateHandle.saveable(stateSaver = TextFieldValue.Saver) {
        mutableStateOf(TextFieldValue(""))
    }
        private set

    fun update(newMessage: TextFieldValue) {
        message = newMessage
    }

    /*...*/
}

val viewModel = ConversationViewModel(SavedStateHandle())

@Composable
fun UserInput(/*...*/) {
    TextField(
        value = viewModel.message,
        onValueChange = { viewModel.update(it) }
    )
}

saveable API の使用方法については、SavedStateHandle のドキュメントをご覧ください。

StateFlow

getStateFlow() を使用して UI 要素の状態を保存し、それを SavedStateHandle からフローとして使用します。StateFlow は読み取り専用です。この API ではフローを置き換えて新しい値を出力させるためにキーを指定する必要があります。設定したキーを使用すると、StateFlow を取得して最新の値を収集できます。

次のスニペットで、savedFilterType はチャットアプリのチャット チャネルのリストに適用されるフィルタタイプを格納する StateFlow 変数です。

private const val CHANNEL_FILTER_SAVED_STATE_KEY = "ChannelFilterKey"

class ChannelViewModel(
    channelsRepository: ChannelsRepository,
    private val savedStateHandle: SavedStateHandle
) : ViewModel() {

    private val savedFilterType: StateFlow<ChannelsFilterType> = savedStateHandle.getStateFlow(
        key = CHANNEL_FILTER_SAVED_STATE_KEY, initialValue = ChannelsFilterType.ALL_CHANNELS
    )

    private val filteredChannels: Flow<List<Channel>> =
        combine(channelsRepository.getAll(), savedFilterType) { channels, type ->
            filter(channels, type)
        }.onStart { emit(emptyList()) }

    fun setFiltering(requestType: ChannelsFilterType) {
        savedStateHandle[CHANNEL_FILTER_SAVED_STATE_KEY] = requestType
    }

    /*...*/
}

enum class ChannelsFilterType {
    ALL_CHANNELS, RECENT_CHANNELS, ARCHIVED_CHANNELS
}

ユーザーが新しいフィルタタイプを選択するたびに、setFiltering が呼び出されます。これにより、新しい値がキー _CHANNEL_FILTER_SAVED_STATE_KEY_SavedStateHandle に保存されます。savedFilterType は、キーに対して保存されている最新の値を出力するフローです。filteredChannels は、チャネル フィルタリングを行うためにフローに対してサブスクライブされています。

getStateFlow() API について詳しくは、SavedStateHandle のドキュメントをご覧ください。

まとめ

次の表は、このセクションで説明されている API と、それぞれをどんな場合に使用して UI 状態を保存するのがよいかをまとめたものです。

イベント UI ロジック ViewModel でのビジネス ロジック
構成の変更 rememberSaveable 自動
システムの主導によるプロセスの終了 rememberSaveable SavedStateHandle

使用すべき API は、状態が保持される場所と、それに必要なロジックによって異なります。UI ロジックで使用される状態には、rememberSaveable を使用します。ビジネス ロジックで使用される状態については、ViewModel に保持する場合に SavedStateHandle を使用して保存します。

バンドル API(rememberSaveableSavedStateHandle)は、少量の UI 状態を保存する目的で使用する必要があります。このデータは、他の保存メカニズムを併用して UI を前の状態に戻すために必要な最小限のものです。たとえば、ユーザーが閲覧しているプロファイルの ID をバンドルに保存した場合、プロファイルの詳細などの大量のデータはデータレイヤーから取得できます。

UI 状態のさまざまな保存方法について詳しくは、一般的な UI 状態の保存に関するドキュメントとアーキテクチャ ガイドのデータレイヤーのページをご覧ください。