UI の外部で Compose State を使用する

Jetpack Compose の State<T> と MutableState<T> は、Compose スナップショット システムを基盤とする汎用の値ホルダーです。通常、スナップショットは UI の再コンポーズを促進するために使用されますが、スナップショット システム自体は UI レンダリングに関連付けられていません。

AppState ライブラリ(Transform と StateStore)を使用すると、ViewModel、マネージャー、リポジトリ、バックグラウンド サービス全体で Compose State を保持、派生、監視、永続化できます。

UI 以外で Compose State を使用する理由

状態を監視するブロック(UI コンポーザブル、transform、derivedStateOf など)内で Compose State を読み取ると、Compose ランタイムはアーキテクチャのどのレイヤでも次の 2 つの主なメリットを提供します。

  • 自動的かつ動的な依存関係のトラッキング: 状態の .value を読み取る(または by プロパティ委任を使用する)と、その状態がブロックの依存関係として自動的に登録されます。トラッキングは動的であるため、依存関係は実行時にどのコードパスが実行されるかによって決まります。条件分岐が実行されない場合(if チェックや早期 return の後など)、その分岐内の状態は読み取られず、状態の変更によって不要な再計算がトリガーされることはありません。
  • 整合性と粒度: 個別のきめ細かい MutableState プロパティを宣言し、複数のプロパティの書き込みを Snapshot.withMutableSnapshot でラップできます。中間更新は、変更可能なスナップショットに分離され、ブロックの完了時にアトミックに適用されます。そのため、スナップショット内で読み取りを行うオブザーバーは、一貫性のない中間状態を認識することはなく、読み取った特定のプロパティが変更された場合にのみ再計算を行います。StateFlow とは異なり、単一のモノリシックな StateFlow<UiState>(いずれかのフィールドが変更されると、すべてのコレクタが無効になる)と、個別の StateFlow プロパティ(単一のアトミック トランザクションで一緒に更新できない)のどちらかを選択する必要はありません。

アーキテクチャ全体で適切な状態 API を選択する

Compose の State を保持して永続化する方法は、状態を保持する期間と、その状態にアクセスする必要があるコンポーネントに基づいて選択します。

州のカテゴリ 存続期間とスコープ 例 推奨される Compose State API
ライブ状態 常にメモリに保存されます。起動時に新しくクエリされ、プロセスの終了をまたいで保持されることはありません。 ネットワーク接続ステータス、Bluetooth 接続ステータス、一時的な UI 状態(アイテムの選択など)。
一時的な UI 状態 構成変更やシステムによるプロセスの終了後も保持されますが、無期限に保存されるわけではありません。単一の画面または Activity にスコープされます。 展開または折りたたまれた UI 要素、スクロール位置、画面検索クエリ、フィルタの切り替え。
一時的なセッション状態 バックグラウンド プロセスの終了後も保持されるが、無期限に保存されるわけではない。アクティビティ、サービス、ウィジェット間で共有される。 アクティブなフォーカス タイマー、複数ステップの購入手続きカート、進行中のフォームの下書き。
  • マネージャーまたはリポジトリにカプセル化された StateStore
  • PersistToDataStore を実装し、syncToDataStore を使用して同期されるキー
永続的な状態 ディスクに無期限に保持されます。 ユーザー設定(DataStore)、構造化されたアプリデータとメディア ライブラリ(Room)。 collectAsState を使用して transform 内で収集された DataStore または Room のフロー

ViewModel で Compose の State を使用する

ViewModel 内では、独立した画面入力をきめ細かい MutableState プロパティとして宣言し、SavedStateHandle.saveable を使用してプロセスの終了後も保持し、Snapshot.withMutableSnapshot を使用して複数のプロパティをアトミックに更新し、transform 内でリポジトリの Flow ストリームと組み合わせることができます。

sealed interface TaskListState {
    data object Loading : TaskListState
    data class Success(
        val tasks: List<Task>,
        val selectedTaskIds: Set<String>,
        val isOnline: Boolean = true,
    ) : TaskListState
}

@HiltViewModel
class TaskListViewModel @Inject constructor(
    private val tasksRepository: TasksRepository,
    savedStateHandle: SavedStateHandle,
) : ViewModel() {

    var selectedTaskIds by mutableStateOf(emptySet<String>())
        private set

    var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
        private set

    fun updateSearchQuery(query: String) {
        Snapshot.withMutableSnapshot {
            searchQuery = query
            selectedTaskIds = emptySet()
        }
    }

    val tasksState: State<TaskListState> = transform<TaskListState>(
        initialValue = TaskListState.Loading,
        scope = viewModelScope,
    ) {
        val tasks = remember(searchQuery) {
            tasksRepository.getTasks(searchQuery)
        }.collectAsState(initial = null).value
            ?: return@transform TaskListState.Loading

        TaskListState.Success(
            tasks = tasks,
            selectedTaskIds = selectedTaskIds,
        )
    }
}

この ViewModel の主なパターン:

  • リアクティブな保存済み状態に SavedStateHandle.saveable を使用する: mutableStateOf(androidx.lifecycle:lifecycle-viewmodel-compose から)を使用して searchQuery を SavedStateHandle.saveable に委任すると、プロセスの終了をまたいで値が MutableState として保持されるため、transform は読み取りを自動的に追跡します。Compose の State ではなく、プレーンな Kotlin プロパティを返し、変更時に再コンポジションをトリガーしない、裸の SavedStateHandle.saved は避けてください。
  • Snapshot.withMutableSnapshot を使用したアトミックなマルチプロパティ書き込み: Snapshot.withMutableSnapshot 内で searchQuery と selectedTaskIds を更新すると、書き込みが分離され、両方の変更が単一のアトミックなスナップショット トランザクションで commit されます。そのため、オブザーバー(transform を含む)は、古い selectedTaskIds とペアになった新しい searchQuery を監視するのではなく、1 回の再コンポーズ パスで両方の更新をまとめて確認します。
  • remember(key) を使用して再コンポーズ間でフローを再利用する: searchQuery をキーとして remember で tasksRepository.getTasks(searchQuery) をラップすると、selectedTaskIds が変更されて transform ブロックが再コンポーズされるときに、Compose はクエリを再開するのではなく、既存のデータベース Flow を再利用します。

アプリ全体でライブ システムの状態を共有する

複数の ViewModel、アクティビティ、サービスで共有されるライブシステムの状態(ネットワークや Bluetooth の接続ステータスなど)については、プロセスの終了をまたいで古いステータスをディスクに保持する必要はありません。代わりに、起動時に現在のシステム状態をクエリし、アプリケーション スコープのシングルトン内のプライベート mutableStateOf プロパティに保持して、読み取り専用の State<T> を公開します。

interface NetworkMonitor {
    val isOnline: State<Boolean>
}

@Singleton
class LiveNetworkMonitor @Inject constructor(
    @ApplicationContext context: Context,
) : NetworkMonitor {
    private val connectivityManager =
        context.getSystemService(ConnectivityManager::class.java)

    private val _isOnline = mutableStateOf(checkCurrentConnectivity())
    override val isOnline: State<Boolean> = _isOnline

    private val callback = object : ConnectivityManager.NetworkCallback() {
        override fun onCapabilitiesChanged(
            network: Network,
            networkCapabilities: NetworkCapabilities,
        ) {
            _isOnline.value = networkCapabilities.hasCapability(
                NetworkCapabilities.NET_CAPABILITY_INTERNET
            )
        }

        override fun onLost(network: Network) {
            _isOnline.value = false
        }
    }

    init {
        connectivityManager.registerDefaultNetworkCallback(callback)
    }

    private fun checkCurrentConnectivity(): Boolean {
        val network = connectivityManager.activeNetwork ?: return false
        val capabilities =
            connectivityManager.getNetworkCapabilities(network) ?: return false
        return capabilities.hasCapability(
            NetworkCapabilities.NET_CAPABILITY_INTERNET
        )
    }
}

MutableState は、任意のスレッドからの整合性のある読み取りと更新をサポートしているため、NetworkCallback はバックグラウンド コールバック スレッドから _isOnline.value を直接更新できます。バックグラウンド コールバックが複数の MutableState プロパティをまとめて更新する場合は、書き込みを Snapshot.withMutableSnapshot でラップして、アトミックに適用されるようにします。

アプリ内の任意のコンポーネントは、1 回限りのチェックとして、UI コンポーザブル内で、または listener、derivedStateOf、transform などの状態監視ブロック内で、networkMonitor.isOnline.value を読み取ることができます。たとえば、TaskListViewModel は transform ブロック内で networkMonitor.isOnline.value を読み取ることができます。

val tasksState: State<TaskListState> = transform<TaskListState>(
    initialValue = TaskListState.Loading,
    scope = viewModelScope,
) {
    val tasks = remember(searchQuery) {
        tasksRepository.getTasks(searchQuery)
    }.collectAsState(initial = null).value
        ?: return@transform TaskListState.Loading

    TaskListState.Success(
        tasks = tasks,
        selectedTaskIds = selectedTaskIds,
        isOnline = networkMonitor.isOnline.value,
    )
}

transform は searchQuery、selectedTaskIds、リポジトリ Flow とともに networkMonitor.isOnline.value を追跡するため、接続性が変更されると、データベースに再クエリすることなく TaskListState.Success が自動的に再計算されます。