ViewModel における stateIn の実力

Kotlin Flow を使って、UI State を宣言的に構築する

Jetpack Compose の ViewModel では、UI State を手動で管理し、init から load() を呼び出し、データが変わるたびに複数のプロパティを更新するコードをよく見かけます。

この方法でも問題なく動作します。しかし、アプリが複雑になるにつれて、複数の状態を同期させるためのコードが増え、管理が難しくなっていきます。

Kotlin Flow を使えば、もっと宣言的に UI State を構築できます。

Flow のオペレーターと stateIn を組み合わせることで、UI State がどの入力から、どのように導出されるのかをコードで表現できます。更新のたびに状態を手動で同期する必要はありません。

 

🤔 手動で UI State を管理するコスト

まずは、よくある ViewModel を見てみましょう。


class MyViewModel(
    private val repository: MyRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow(UiState())
    val uiState = _uiState.asStateFlow()

    init {
        load()
    }

    private fun load() {
        viewModelScope.launch {
            val items = repository.loadItems()

            _uiState.update {
                it.copy(items = items)
            }
        }
    }
}

これは正当な実装です。一度だけデータを取得するような処理なら、理解しやすく、十分に実用的です。

ただし、ViewModel は次のような責務を担っています。

・ 初回ロードを開始する。
・ データ取得後に UI State を更新する。
・ 変更していないフィールドを保持する。
・ 入力が増えたときも、複数の状態の整合性を維持する。

画面が複雑になるほど、こうした処理が繰り返し必要になります。

では、Repository がすでに Flow でデータを公開している場合はどうでしょうか。Repository の Flow を手動で収集し、その値を別の StateFlow にコピーし続ける必要があるのでしょうか。

多くの場合、その必要はありません。

 

🤔 そもそも load() を呼ぶ必要はあるのか?

Jetpack Compose では、次のようなコードをよく使います。


LaunchedEffect(Unit) {
    viewModel.load()
}

あるいは、ViewModel の init ブロックから呼び出します。


init {
    load()
}

どちらも適切な場面があります。ユーザーが明示的に実行する更新処理や、一度だけ行う操作、特定のイベントに応じた処理には、明示的なコマンドが必要です。

しかし、画面が Flow で公開されたデータをそのまま表示するだけなら、手動でロードを開始し、UI State に反映させるための処理は不要かもしれません。

ここで重要なのは、コマンドと状態を区別することです。

・ コマンドは「何を実行するか」を表す。
・ 状態は「現在どうなっているか」を表す。

既存の Flow から UI State を導出できるなら、stateIn を使うことで、命令的な更新処理を減らせます。

 

🤔 stateIn で Flow を UI State に変換する

Repository が次のような Flow を公開しているとします。


interface MyRepository {
    val items: Flow<List<Item>>
}

この場合、ViewModel は UI State を直接導出できます。


class MyViewModel(
    repository: MyRepository
) : ViewModel() {

    val uiState: StateFlow<UiState> =
        repository.items
            .map { items ->
                UiState(items = items)
            }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5_000),
                initialValue = UiState()
            )
}

何がなくなったでしょうか。

・ 同じ UI State を手動更新するための MutableStateFlow
・ 初回収集を開始するためだけの init ブロック
・ Repository の値をコピーするためだけの load() 呼び出し
・ Repository のデータと UI State を同期させる処理

コードは、データと UI State の関係を直接表現しています。


Repository Flow
       |
       v
      map
       |
       v
     UiState
       |
       v
    stateIn
       |
       v
StateFlow<UiState>
       |
       v
   Compose UI

Repository が新しいリストを発行すると、それに対応する UiState が生成されます。

stateIn は、上流の Flow をホットな StateFlow に変換し、最新の値を共有するとともに、初期値を提供します。

公式ドキュメントでも、この用途が説明されています。

👉 stateIn – Kotlin Programming Language
👉 StateFlow – Kotlin Programming Language

変更のたびに状態を同期する方法ではなく、状態がどのように導出されるのかをコードで宣言できるのです。

 

🤔 複数の入力から、一貫した UI State を導出する

このメリットは、画面が複数の入力に依存するときに、さらに明確になります。

例えば、検索機能のあるリスト画面を考えてみましょう。表示するアイテムは、Repository のデータと検索クエリの両方に依存します。

手動で状態を更新する場合、両方の変更を考慮して UI State を更新しなければなりません。

Flow なら、その依存関係を明示できます。


class MyViewModel(
    repository: MyRepository
) : ViewModel() {

    private val query = MutableStateFlow("")

    val uiState: StateFlow<UiState> =
        combine(
            repository.items,
            query
        ) { items, query ->
            UiState(
                items = items.filter {
                    it.matches(query)
                },
                query = query
            )
        }.stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = UiState()
        )

    fun onQueryChanged(query: String) {
        this.query.value = query
    }
}

ここで注目したいのは、UI アクションが更新するのは query だけだという点です。公開されている uiState を直接変更することはありません。

次のどちらが起きても、UI State は自動的に再計算されます。

・ Repository が新しいアイテムを発行する。
・ ユーザーが検索クエリを変更する。

表示リストを再計算する処理を、変更箇所ごとに書く必要はありません。

依存関係は combine の中で一度だけ定義されています。


Repository.items ──┐
                   ├── combine ──> UiState
query ─────────────┘
                                  |
                                  v
                               stateIn
                                  |
                                  v
                          StateFlow<UiState>

この設計では、入力を変更する処理と、その入力から UI State を導出する処理が明確に分離されています。

 

🤔 コマンドは明示的に、状態は宣言的に

もちろん、すべての操作を Flow で表現すべきという意味ではありません。

フォームの送信、アイテムの削除、ユーザー操作に応じた明示的な再取得などは、ViewModel がコマンドとして処理する必要があるでしょう。

重要なのは、導出できる状態を同期するためだけに、命令的な処理を増やさないことです。

複数の観測可能な入力から UI State が決まるなら、map や combine で依存関係を表現し、UI に StateFlow を公開する必要がある場合は stateIn を使います。

設計上の役割を整理すると、次のようになります。

依存関係がコードに表れるため、状態がどこで、なぜ変わるのかを理解しやすくなります。

 

🤔 適切な Sharing Strategy を選ぶ

stateIn では、上流の Flow をいつ開始・停止するかを指定します。

よく使われる設定の一つが、次のものです。


SharingStarted.WhileSubscribed(5_000)

これは、最初の購読者が現れたときに上流の収集を開始し、最後の購読者がいなくなってから 5 秒後に収集を停止する設定です。

👉 stateIn – Kotlin Programming Language

5 秒の猶予があることで、画面遷移などによる短時間の購読解除で、上流の収集がすぐに停止・再開するのを避けられます。

ただし、次の2つは同じではありません。

・ Flow の収集が再開されること。
・ ネットワークリクエストが再実行されること。

画面が再表示されたときに API が再度呼び出されるかどうかは、上流の Flow がどのように実装されているかによります。

また、stateIn 自体がデータを取得するわけではありません。上流の Flow が発行した値を共有し、最新の状態として保持する仕組みです。

よく見かける設定をそのまま採用するのではなく、アプリのライフサイクルやデータ取得の要件に合わせて選びましょう。

 

🤔 MutableStateFlow が必要な場面もある

MutableStateFlow をなくすこと自体が目的ではありません。命令的に変更する必要がある入力には、引き続き有用です。

例えば、ユーザーが操作する検索クエリです。


private val query = MutableStateFlow("")

ユーザーの入力に応じて query を変更するのは自然な設計です。

一方、Repository のデータと検索クエリから導出できる uiState は、別の可変状態として保持する必要がありません。

実践的なルールは次のとおりです。

・ 入力には MutableStateFlow を使う。
・ Flow オペレーターで入力から状態を導出する。
・ stateIn で結果を StateFlow として公開する。

これは可変状態を排除する設計ではありません。可変性が必要な場所にだけ、それを配置する設計です。

 

🤔 まとめ:stateIn に状態管理を任せよう

stateIn は、Flow を StateFlow に変換するだけの便利なオペレーターではありません。map や combine と組み合わせることで、ViewModel の状態管理を宣言的に設計するための重要な手段になります。

データを手動で収集し、値をコピーし、関連するフィールドを同期する代わりに、入力と UI State の依存関係を直接表現できます。

特に、複数のデータソースから画面の状態が決まる場合に、そのメリットが大きくなります。

コマンドは明示的に。状態は宣言的に。

Flow で依存関係を定義し、適切な場面で stateIn を使って UI State を公開しましょう。


Related Categories :  Android・Developmemt・JetpackCompose・KMP・Kotlin・Kotlin Multiplatform Mobile・Recommended・Trending