State は宣言的に、ユーザーの操作は命令的に

 

🤔 ViewModelで「Stateの生成」と「ユーザー操作」を分けて考える

Jetpack Composeでは、UIを宣言的に書くことが当たり前になりました。

「UIをどう更新するか」を細かく指示するのではなく、

「このStateなら、UIはこう見える」

という関係をコードで表現します。

この考え方は、ViewModelにもそのまま応用できます。

私はViewModelを書くとき、次のシンプルなルールで考えると整理しやすいと思っています。

・ Stateの生成は宣言的に。
・ ユーザーの意図への反応は命令的に。

この2つを分けるだけで、ViewModelはかなり読みやすくなります。

 

🤔 State は宣言的に生成する

たとえば、Repositoryがユーザー一覧をFlowとして公開しているとします。


Repository
    │
    │ users: Flow<List<User>>
    ▼
   map
    │
    ▼
 UiState
    │
    ▼
 Compose

この場合、ViewModelではFlowからUiStateをそのまま導出できます。


class UserViewModel(
    repository: UserRepository
) : ViewModel() {

    val uiState: StateFlow<UiState> =
        repository.users
            .map { users ->
                UiState.Success(users)
            }
            .stateIn(
                viewModelScope,
                SharingStarted.WhileSubscribed(5_000),
                UiState.Loading
            )
}

ここには init がありません。

load() もありません。

ViewModel がやっているのは、

uiStaterepository.users から導出される

という関係を定義することだけです。

Repository から新しい値が流れてくれば、それに応じて uiState も変わります。

ここには「最初にこれを実行して、そのあとこれを実行する」という手順がありません。

あるのは、


users → UiState

という関係だけです。

これが「宣言的な State 生成」です。

 

🤔 命令的なコードにも、もちろん役割がある

だからといって、命令的なコードをなくす必要はありません。

たとえば「更新」ボタンを考えてみます。


fun refresh() {
    viewModelScope.launch {
        repository.refresh()
    }
}

これは自然なコードです。

ユーザーが「更新したい」と明示的に操作したからです。

処理の流れも明確です。


ユーザーがRefreshをタップ
        ↓
     refresh()
        ↓
Repositoryが更新処理を実行
        ↓
     Stateが変化

これは命令的な処理です。

そして、こういう場所こそ命令的なコードが適しています。

同じことは、たとえば次のような操作にも当てはまります。


fun retry()
fun deleteUser(id: String)
fun submit()

これらはすべて、

「ユーザーがこうしてほしい」

という意図を表しています。

そのため、命令として表現するのが自然です。

 

🤔 問題は、この2つを混ぜてしまうこと

少し気になるのは、State を生成するためだけに命令的なコードを使うケースです。

たとえば、こんな ViewModel です。


class UserViewModel(
    private val repository: UserRepository
) : ViewModel() {

    val uiState: StateFlow<UiState> = ...

    init {
        load()
    }

    private fun load() {
        viewModelScope.launch {
            repository.loadUsers()
        }
    }
}

このコード自体が間違っているわけではありません。

ただ、ここで一度考えてみたいことがあります。

なぜ load() が必要なのでしょうか?

すでに uiState を宣言的に定義しているのに、その State を作るために別の命令を実行しなければなりません。

構造としては、こうなっています。



ViewModelの生成
      ↓
    init
      ↓
    load()
      ↓
 Stateが生成・更新

つまり、State を得るために「いつ load() を呼ぶか」という別の問題が発生します。

もし Repository がデータを Flow として公開できるのであれば、この命令自体が不要になるかもしれません。

 

🤔 LaunchedEffect に移しても、本質は変わらない

「それなら load() を Compose 側から呼べばいい」と考えることもできます。


@Composable
fun UserScreen(
    viewModel: UserViewModel
) {
    LaunchedEffect(Unit) {
        viewModel.load()
    }

    // UI
}

UI そのものは宣言的です。

しかし、ここでは別の命令が追加されています。

「この画面がCompositionに入ったらload()を呼ぶ」

つまり、


Screen enters Composition
          ↓
   LaunchedEffect
          ↓
       load()
          ↓
      State changes

という流れです。

もちろん、これは常に悪いわけではありません。

明示的な副作用が必要なケースでは、LaunchedEffect は適切な選択です。

ただし、

「Flowとして表現できるStateを最初に作るためだけ」

に LaunchedEffect を使っているのであれば、一度設計を見直してみる価値があります。

 

🤔 ViewModel の init も同じ視点で考える

ここで、ViewModel の init についても考えてみます。


init {
    load()
}

これは、

「ViewModelが生成されたら、この処理を開始する」

という命令です。

処理の順番は明確です。


ViewModelが生成される
        ↓
       init
        ↓
      load()
        ↓
    Stateが変化

一方、State を宣言的なパイプラインとして表現できるなら、


Repository State
       ↓
      map
       ↓
     UiState

と直接書けます。

ここでは「いつ State を作るか」をコードで指示していません。

State がどのように導出されるかだけを記述しています。

この違いは小さく見えますが、ViewModel の責務を考えるうえで重要です。

 

🤔 すべてを宣言的にする必要はない

ここが一番重要なポイントかもしれません。

この考え方は、

「ViewModelの処理を全部Flowにしよう」

という話ではありません。

たとえばユーザーが削除ボタンを押した場合、


fun deleteUser(id: String) {
    viewModelScope.launch {
        repository.deleteUser(id)
    }
}

で何も問題ありません。

ユーザーが「このユーザーを削除したい」と意図を示したので、その意図を命令的な処理として表現するのは自然です。

重要なのは、

・ すべてを宣言的にすること

ではありません。

むしろ、

・ Stateは宣言的に。
・ ユーザーの意図は命令的に。

という境界を持つことです。

境界があると、ViewModel のコードを読むときにも、

・ Stateを作っているコードなのか
・ ユーザーの操作に反応しているコードなのか

を簡単に区別できます。

 

🤔 init { load() } を疑ってみる

この考え方をすると、これまで何となく書いていたコードも少し違って見えてきます。


init {
    load()
}

あるいは、


LaunchedEffect(Unit) {
    viewModel.load()
}

を見たとき、

「初期ロードだから、とりあえずこれを書く」

ではなく、

「そもそも、この load() は必要なのだろうか?」

と考えられるようになります。

もちろん、必要な場合もあります。

外部 API を明示的に叩く必要があったり、ユーザーの操作によって処理を開始する必要があったりするなら、命令的な処理は適切です。

ただ、State そのものが Flow から自然に導出できるのであれば、State を作るためだけの load() は必要ないかもしれません。

 

🧑🏻‍💻 まとめ

宣言的プログラミングと命令的プログラミングのどちらかを選ぶ必要はありません。

ViewModelの中でも、それぞれに適した場所があります。


State Generation
      ↓
  Declarative

Flow → map → UiState


User Intent
      ↓
  Imperative

大切なのは、この2つを混ぜないことです。

State の生成は宣言的に。
ユーザーの意図への反応は命令的に。

この境界を意識すると、init { load() } LaunchedEffect { viewModel.load() } を「お決まりの初期化パターン」として使うのではなく、

本当にこの load() は必要なのか?

と考えられるようになります。

そして、ときには初期化問題を解決する最善の方法は、load() を別の場所に移動することではありません。

そもそも load() が必要な設計なのかを見直すことです。

 

🧑🏻‍💻 参考記事


Jetpack Compose で 反転フラップ式案内表示機 (split-flap display) を実装する

これ。

👉 反転フラップ式案内表示機 - Wikipedia
👉 Split-flap display - Wikipedia

まあ、AI で動くものはすぐにできる。

が、なんか微妙なよくわからないコード。

どんなロジックになっているのかな、と興味あったので、ロジックをみながら整理してみました。

 

🧑🏻‍💻 考え方

切り替える文字列を以下に設定。


"各駅電車", "急行", "快速", "特急", "御座候"

そして、2つのレイヤーを重ねる。

・上下半分に分けた文字板
・アニメーションでめくれていく文字板

この考え方がいいようです。

重ね合わせには、BoxModifier.zIndex() を使います。

 

🧑🏻‍💻 上下半分に分けた文字板

下のレイヤーに配置する。

同じタイミングで、上下半分ずつの文字を切り替える。

 

🧑🏻‍💻 アニメーションでめくれていく文字板

上のレイヤーに配置する。

これを下のレイヤーに被せて、同期的にアニメーションさせる。

対象の Composable で Mofifier.graphicsLayer を使うと便利。

意図する回転方向と原点を指定するだけで良い。


modifier = Modifier
    .graphicsLayer {
        this.transformOrigin = TransformOrigin(...)
        this.rotationX = ...
    }

 

🧑🏻‍💻 出来上がり

とりあえず、ある程度まで整理。

アニメーション知識なく AI で生成したコードから入ると、ある程度ロジックを理解するまでは不具合修正はもちろん、調整さえ自在にできない。

厳しい時代かもしれません。


MVI で sealed interface を使う理由

MVI では、ユーザー操作を Intent として表現することがよくあります。

Kotlin では、この Intent を表現するために sealed interface がとても相性のよい選択肢になります。


sealed interface UiIntent {
    data object Refresh : UiIntent
    data object Retry : UiIntent
    data class SelectItem(val id: Long) : UiIntent
}

こうすると、UI から ViewModel への入力を1つの入口にまとめられます。


fun onIntent(intent: UiIntent)

では、なぜ MVI で sealed interface が便利なのでしょうか。

 

🤔 Intent を1つの型にまとめられる

通常の ViewModel では、ユーザー操作ごとにメソッドを用意することがあります。


fun refresh()
fun retry()
fun selectItem(id: Long)

Intent を使えば、これらを1つの入口にまとめられます。


fun onIntent(intent: UiIntent)

UI から ViewModel への入力は、次のようになります。


UI

 ↓ UiIntent

ViewModel

これによって、UDF における入力側の流れが分かりやすくなります。

 

🤔 どんな Intent が存在するのか明示できる

sealed interface を見るだけで、その画面で扱う Intent が分かります。


sealed interface UiIntent {
    data object Refresh : UiIntent
    data object Retry : UiIntent
    data class SelectItem(val id: Long) : UiIntent
}

つまり、Intent そのものが画面の入力モデルになります。

コードを読めば、

・ Refresh できる
・ Retry できる
・ Item を選択できる

ということが分かります。

 

🤔 when の網羅性をコンパイラにチェックさせられる

sealed interface の大きなメリットの1つがこれです。


when (intent) {
    UiIntent.Refresh -> refresh()
    UiIntent.Retry -> retry()
    is UiIntent.SelectItem -> selectItem(intent.id)
}

Intent を追加すると、


data object LoadMore : UiIntent

その処理が必要な when をコンパイラが知らせてくれます。

つまり、Intent の追加が処理漏れにつながりにくいわけです。

 

🤔 Intent ごとに必要なデータを型で表現できる

Intent によって必要なデータは異なります。


data object Refresh : UiIntent
data object Retry : UiIntent
data class SelectItem(val id: Long) : UiIntent

Refresh にはデータは必要ありません。

一方、SelectItem には id が必要です。

これを Intent 自身の型として表現できます。


SelectItem(id)

「この操作にはこのデータが必要」という契約が、型として明確になります。

 

🤔 「何が起きたか」と「どう処理するか」を分離できる

例えば、


viewModel.selectItem(id)

と書くと、UI が ViewModel の具体的なメソッドを直接呼んでいます。

Intent を使うと、


onIntent(UiIntent.SelectItem(id))

となります。

UI は、

Item が選択された

という何が起きたかを伝えます。

それをどう処理するかは ViewModel 側が決めます。


UI

 ↓ SelectItem(id)

ViewModel

 ↓ 処理

State

この分離は、MVI の考え方と非常に相性がよいものです。

 

🤔 UDF の入力側をコードで表現しやすい

MVI の流れは、シンプルに考えるとこうなります。


UI

 ↓ Intent

State Processor

 ↓ State

UI

もちろん、sealed interface が UDF を作っているわけではありません。

しかし、


UI → Intent → State → UI

という流れをコード上で明確に表現できます。

アーキテクチャは、重要な関係がコードから読み取れることが大切です。

その意味で sealed interface は非常に便利です。

 

🤔 Intent の追加を「設計上の変更」として扱える

例えば、


data object LoadMore : UiIntent

を追加したとします。

これは単に ViewModel へ新しいメソッドを追加するのとは少し違います。


sealed interface UiIntent {
    ...
    data object LoadMore : UiIntent
}

とすることで、

この画面が新しい入力を受け付けるようになったことが明確になります。

さらに when の網羅性チェックによって、対応すべき場所も見つけやすくなります。

UiIntent が、ある意味で画面の入力に対するチェックリストとして機能するわけです。

 

🤔 MVVM と MVIの違いも見えやすくなる

この考え方を使うと、MVVM と MVI の違いもコード上で比較しやすくなります。


MVVM

UI
 │
 ├── refresh()
 ├── retry()
 └── selectItem(id)
       
       ↓
        
    ViewModel

一方、Intent を使う MVI では、


MVI

UI
 │
 └── onIntent(intent)
          
          ↓

    sealed interface
     ├── Refresh
     ├── Retry
     └── SelectItem(id)

          ↓

      ViewModel

となります。

ここで重要なのは、

MVI だから sealed interface を使う

ということではありません。

むしろ、

Intent を明示的な型として扱いたいので、sealed interface が適している

と考える方が正確です。

 

🤔 まとめ

MVI で sealed interface を使うと、次のメリットがあります。

・ Intent を1つの型にまとめられる
・ 可能な Intent を明示できる
・ when による網羅性チェックを利用できる
・ Intent ごとのデータを型安全に表現できる
・ UI と ViewModel の入力契約が明確になる
・ 「何が起きたか」と「どう処理するか」を分離しやすい
・ UDF の入力側をコードとして表現しやすい

そして最も重要なのは、

Intent-driven な設計を、型安全かつ明示的に表現するのに sealed interface が適している。

ということです。

MVI において sealed interface は、アーキテクチャそのものではなく、アーキテクチャをコードに落とし込むための非常に便利な道具なのです。

 

🤔 参考


Jetpack Compose 移行完了チェック:旧 AndroidView システムを安全に完全削除できたか確認する

プロダクション環境のAndroidアプリを Jetpack Compose へ移行する作業は、一朝一夕で完了するものではありません。

中〜大規模のコードベースでは、移行は段階的に進むのが一般的です。そのため、XMLレイアウト、ComposeView、AndroidView が、モダンなComposableと数ヶ月〜数年にわたって共存することになります。

段階的な移行は最も安全なアプローチです。しかし、移行が完了したあとも古いインフラを残したままにしておくと、無駄な複雑さが増すだけになってしまいます。

ここで生まれるのが、

「本当に移行が完了したと、どうやって判断すればいいのか?」

という疑問です。

この記事では、アプリから旧Viewシステムのコードを安全に削除し、完全移行を果たすための実践的なチェック方法を紹介します。

 

🧑🏻‍💻 ビルド設定(buildFeatures)をオフにする

まずは一番わかりやすいビルド設定から見直しましょう。

View Binding や Data Binding なしでビルドできれば、旧Viewシステムへの大きな依存を断ち切れた証拠になります。

モジュールレベルの build.gradle.kts を確認・修正します。


android {
    // ...

    buildFeatures {
        compose = true

        viewBinding = false
        dataBinding = false
    }
}

続いて、依存関係を見直し、不要になったライブラリを削除します。


dependencies {
    // 不要になった旧Viewシステムの依存関係を削除

    // implementation("com.google.android.material:compose-theme-adapter:...")
    // implementation("androidx.appcompat:appcompat:...")
    // implementation("androidx.constraintlayout:constraintlayout:...")
    // implementation("androidx.navigation:navigation-fragment-ktx:...")
    // implementation("androidx.navigation:navigation-ui-ktx:...")
}

 

🧑🏻‍💻 エントリーポイントを Compose 単一にする

完全移行したアプリでは、UIをホストするためだけに FragmentActivityNavHostFragment を使う必要はありません。

メインのエントリーポイントは、シンプルな ComponentActivity で十分です。


class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            AppTheme {
                AppNavigation()
            }
        }
    }
}

もちろん、以下のようなコードは完全に不要になります。


// 不要!
setContentView(R.layout.activity_main)

Activityは、XMLレイアウトのコンテナではなく、Compose UIへのエントリーポイントとして機能するようになります。

 

🧑🏻‍💻 相互運用(Interop)のためのブリッジコードを削除する

移行期間中、ComposeViewAndroidView といったAPIは非常に強力な味方でした。これらのおかげで、View システムとCompose の境界線を段階的にまたぐことができたからです。

しかし、移行が終わったのであれば、これらの「架け橋」は役目を終えています。

プロジェクト全体で以下を検索してみてください。

- ComposeView

- AndroidView

- androidx.fragment.app.Fragment

目指すべき残存数は「0」です。

例えば、以下のようなコードは存在しなくなるはずです。


// これらはもう不要
val composeView = ComposeView(context).apply {
    setContent {
        MainScreen()
    }
}

また、こちらも同様です。


// これらも不要
AndroidView(
    factory = { context ->
        CustomLegacyView(context)
    }
)

重要な考え方はシンプルです。

「相互運用(Interop)は足場であって、目的地ではない」 ということです。

 

🧑🏻‍💻 デザインシステムを Compose 純正にする

完全移行したアプリでは、デザイントークンをKotlinコード内で直接定義できます。


@Composable
fun AppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val colorScheme =
        if (darkTheme) DarkColorScheme
        else LightColorScheme

    MaterialTheme(
        colorScheme = colorScheme,
        typography = AppTypography,
        shapes = AppShapes,
        content = content
    )
}

カラー、タイポグラフィ、シェイプなどを Compose に適用するためだけに、res/values/styles.xml を参照する必要はもうありません。

UIテクノロジーと信頼できる唯一の情報源(Source of Truth)が同じ Kotlin コード上に並ぶため、デザインシステムの保守性・見通しが大幅に向上します。

 

🧑🏻‍💻 ナビゲーションを宣言型にする

画面遷移(ナビゲーション)についても、XMLの Navigation Graph や Fragment トランザクションから脱却しましょう。

ルート(Route)、引数(Arguments)、画面(Destinations)は、Compose Navigation のセットアップ内で直接管理できます。


@Serializable
data object Home : NavKey

@Serializable
data class Detail(val id: String) : NavKey

@Composable
fun AppNavigation() {
    val backStack = rememberNavBackStack(Home)

    NavDisplay(
        backStack = backStack,
        entryProvider = entryProvider {
            entry<Home> {
                HomeScreen(
                    onOpenDetail = { id ->
                        backStack.add(Detail(id))
                    }
                )
            }

            entry<Detail> { key ->
                DetailScreen(
                    id = key.id
                )
            }
        }
    )
}

ここでの目的は、単に「XMLをKotlinに置き換える」ことではありません。

本質は、Fragmentベースのナビゲーション層を取り払い、UIと同じ「宣言型アーキテクチャ」にナビゲーション構造を一致させることにあります。

 

🧑🏻‍💻 最後にリポジトリ全体を走査する

「移行完了!」と宣言する前に、古いUIシステムを引き戻してしまうようなコードが残っていないか、リポジトリ全体を検索して確認しましょう。

ターミナルで以下のコマンドを実行してみるのがおすすめです。


# 残っているUI用XMLレイアウトのチェック
find app/src/main/res/layout -name "*.xml"

# Compose / View 相互運用コードの検索
grep -rn "ComposeView" app/src/main/java/
grep -rn "AndroidView" app/src/main/java/

# Fragment の使用箇所の検索
grep -rn "androidx.fragment.app.Fragment" app/src/main/java/

理想的な状態は以下の通りです。

・ res/layout にUIレイアウト用のXMLが存在しない

・ ComposeView の使用箇所が 0 である

・ AndroidView の使用箇所が 0 である

・ Fragment ベースの画面が存在しない

・ View Binding および Data Binding が無効化されている

・ テーマがXMLスタイルに依存していない

・ ナビゲーションが Fragment インフラに依存していない

 

🧑🏻‍💻 まとめ

100% Jetpack Compose への移行とは、単に XML ファイルを Composable 関数に書き換えることではありません。

本当のゴールは、「旧 View システムがアプリの UI アーキテクチャから完全に消え去ること」 です。

【移行前】


Activity
 └─ Fragment
     └─ XML
         └─ View
             └─ ComposeView
                 └─ Compose

【移行後】


Activity
 └─ Compose
     ├─ Theme
     ├─ Navigation
     └─ Screens

私たちの目標は、単に「Compose を使っている」と言うことではありません。

「もうViewシステムを必要としない」 状態を作ることこそが、本当のゴールなのです。


Jetpack Compose で巨大化した UiState をどう整理するか

見通しが悪くなった UiState を、責務ごとに整理する7つの方法

 

🧑🏻‍💻 1. Loading / Error / Success が混ざっているなら sealed interface にする

まず、ありがちなパターンです。

変更前:


data class UiState(
    val isLoading: Boolean,
    val error: String?,
    val items: List<Item>
)

- isLoading、error、items の組み合わせで画面の状態を表しています。
- しかし、これでは「Loading なのに items が入っている」「Error なのに isLoading が true」といった、あり得ない状態も作れてしまいます。

変更後:


sealed interface UiState {
    data object Loading : UiState
    data class Error(val message: String) : UiState
    data class Success(val items: List<Item>) : UiState
}

- Loading、Error、Success を、そのまま画面の状態として表現します。
- 状態を Boolean や nullable なプロパティの組み合わせで表現する必要がなくなります。

 

🧑🏻‍💻 2. フィールドが増えすぎたら、Sub-Stateにまとめる

UiState にプロパティを追加していった結果、何がどこに関係しているのか分からなくなることがあります。

変更前:


data class UiState(
    val title: String,
    val query: String,
    val items: List<Item>,
    val selectedCategory: Category?
)

変更後:


data class UiState(
    val header: HeaderState,
    val filter: FilterState,
    val list: ListState
)

data class HeaderState(
    val title: String
)

data class FilterState(
    val query: String,
    val category: Category?
)

data class ListState(
    val items: List<Item>
)


-「ヘッダー」「検索・フィルター」「リスト」という画面上のまとまりで整理します。
- UiState の中身を見ただけで、どの状態がどの役割なのか分かるようになります。

 

🧑🏻‍💻 3. UIだけで使う状態はComposableに閉じ込める

すべての状態をViewModelの UiState に入れる必要はありません。

変更前:


data class UiState(
    val items: List<Item>,
    val isExpanded: Boolean
)

変更後:


data class UiState(
    val items: List<Item>
)

@Composable
fun Screen(state: UiState) {
    var isExpanded by rememberSaveable {
        mutableStateOf(false)
    }
}

- isExpanded が画面の一時的な表示状態にすぎないなら、ViewModelに持たせる必要はありません。
- remember / rememberSaveable に移すことで、UiState をシンプルにできます。

例えば次のようなものです。

・一時的な展開状態
・アニメーションの状態
・Tooltipの表示状態
・フォーカス状態
・スクロール位置

ただし、スクロール位置などを画面復元やビジネスロジック上の理由で ViewModel 側に保持する必要がある場合は例外です。

 

🧑🏻‍💻 4. 計算できる値はStateとして持たない

「元のStateから計算できる値」まで UiState に入れてしまうと、Stateがどんどん増えていきます。

変更前:


data class UiState(
    val items: List<Item>,
    val filteredItems: List<Item>,
    val isEmpty: Boolean
)

変更後:


data class UiState(
    val items: List<Item>,
    val query: String
)

val filteredItems =
    state.items.filter { it.name.contains(state.query) }

val isEmpty = filteredItems.isEmpty()

- filteredItems は items と query から計算できます。
- isEmpty も filteredItems から計算できます。
- つまり、わざわざStateとして保存する必要がありません。
- ViewModelで StateFlow を組み合わせるなら combine や map、Compose 内で Compose State から値を導出するなら derivedStateOf などを使います。

 

🧑🏻‍💻 5. ViewModelまで巨大になったら、ViewModelを分ける

UiState が巨大になった原因が、そもそもViewModelがいろいろな責務を持ちすぎているケースもあります。

変更前:


class MainViewModel : ViewModel() {
    val headerState = ...
    val searchState = ...
    val contentState = ...
}

Stateを分けていても、ViewModel自体がすべてを管理しています。

変更後:


class HeaderViewModel : ViewModel() {
    val state = ...
}

class ContentViewModel : ViewModel() {
    val state = ...
}

@Composable
fun MainScreen(
    header: HeaderViewModel,
    content: ContentViewModel
) {
    HeaderSection(header.state)
    ContentSection(content.state)
}

- 「State を分割する」だけではなく、責務そのものを分けるという考え方です。
- ただし、Composable ごとに ViewModel を作る、という意味ではありません。
- 独立した責務やライフサイクルがある場合に検討します。

 

🧑🏻‍💻 6. 大量のデータを UiState に詰め込まない

数千件、数万件のデータをそのまま UiState に持たせるケースでは、そもそもStateの持ち方を見直します。

変更前:


data class UiState(
    val items: List<Item>
)

val uiState = repository.loadAllItems()

変更後:


data class UiState(
    val query: String
)
val items =
    repository.items()
        .cachedIn(viewModelScope)

- 検索条件などの画面状態と、大量のデータを分離します。
- Paging が適しているケースでは、Paging を利用して必要なデータを段階的に扱います。

 

🧑🏻‍💻 7. 1つの巨大な StateFlow に全部詰め込まない

すべての状態を1つの UiState にまとめることが、かえって分かりにくくなるケースもあります。

変更前:


data class UiState(
    val header: HeaderState,
    val search: SearchState,
    val content: ContentState
)

val uiState: StateFlow<UiState> = ...

変更後:


val headerState: StateFlow<HeaderState> = ...
val searchState: StateFlow<SearchState> = ...
val contentState: StateFlow<ContentState> = ...

@Composable
fun Screen(viewModel: MainViewModel) {
    Header(viewModel.headerState)
    Search(viewModel.searchState)
    Content(viewModel.contentState)
}

- 各 State が本当に独立しているなら、無理に1つへまとめる必要はありません。
- それぞれの UI が必要な State だけを受け取る形にできます。
- ただし、何でも StateFlow に分ければいいわけではありません。

 

🧑🏻‍💻 まとめ - どう整理するか


巨大な UiState
      │
      ├─ Loading / Error / Success が混在
      │       → sealed interface
      │
      ├─ フィールドが増えすぎた
      │       → Sub-State
      │
      ├─ UIだけで使う状態
      │       → remember / rememberSaveable
      │
      ├─ 計算すれば求められる値
      │       → Derived State
      │
      ├─ ViewModelの責務が多すぎる
      │       → ViewModelを分割
      │
      ├─ 大量のデータを保持している
      │       → Pagingなどを検討
      │
      └─ 独立した状態が混在している
              → StateFlowを分割

重要なのは、「UiStateの行数を減らす」ことではありません。

まず、今 UiState に入っているものを見て、


これは本当にStateなのか?
        ↓
UIだけのStateでは?
        ↓
計算できる値では?
        ↓
別の責務では?
        ↓
大量データでは?

と整理していくことです。

つまり、巨大な UiState は、Stateの責務を見直すきっかけと考えると分かりやすいです。

注意点:

- UiState が大きいこと自体が悪いわけではありません。
- StateFlow を細かく分けすぎると、今度は State 同士の関係が分かりにくくなります。
- sealed interface は、Loading / Error / Successのように本当に相互排他的な状態に使います。
- rememberSaveable に移すか ViewModel に残すかは、画面再生成やプロセス終了後にも復元する必要があるかも判断材料になります。
- Pagingも「List が大きいから必ず使う」というものではありません。

 

🤔 参考