第9回:ViewModel に何を書いて、何を書かないか

― Compose時代に“ViewModelが太る”問題の処方箋 ―

※ChatGPTを使用して記事を作成しています。

はじめに

Jetpack Compose を導入すると、UIの責務が大きく変わります。

  • UI は「状態を描画するだけ」
  • 副作用は Effect に寄せる
  • イベントは Event として分離する

ここまでは理屈として分かっていても、現場ではこうなりがちです。

「UIを薄くしたら、ViewModel が全部背負ってしまった」

結果、ViewModel が

  • 画面の状態管理
  • 入力バリデーション
  • API呼び出し
  • ログ送信
  • 画面遷移判断
  • Snackbarの文言生成
  • 画面固有の整形処理
  • 日付フォーマット

まで抱えて、肥大化します。

そして数か月後、

  • テスト不能
  • 改修が怖い
  • 影響範囲が読めない

という、いつもの地獄に戻ります。

この記事では、

  • ViewModel に書くべきもの
  • 書いてはいけないもの
  • “太らないViewModel”の設計パターン

失敗例→修正版→設計原則 の順で整理します。

Compose時代の ViewModel の役割(結論)

まず結論です。

ViewModel は「UI State を生成する層」である

もう少し具体的に言うと、

  • 画面に必要なデータを集める
  • UI State を正規化して持つ
  • UI からの Intent を受ける
  • Domain層(UseCase)を呼び出す
  • 結果を State / Event に変換する

これが ViewModel の仕事です。

 

ViewModel に書いていいもの(OK)

✅ OK1: UI State の保持

sealed class UiState {
    object Loading : UiState()
    data class Content(val items: List<ItemUiModel>) : UiState()
    data class Error(val message: String) : UiState()
}

✅ OK2: UI Event の発行

sealed class UiEvent {
    data class ShowSnackbar(val message: String) : UiEvent()
    object NavigateDetail : UiEvent()
}

✅ OK3: UI からのアクションを受ける関数

fun onRetry()
fun onItemClick(id: String)
fun onPullToRefresh()

✅ OK4: UseCase 呼び出しと結果変換

  • UseCase の結果を UiState に変換
  • エラーを文言化して UiEvent に変換

 

ViewModel に書いてはいけないもの(NG)

ここが一番重要です。

❌ NG1: Composable依存(Context, NavController)

ViewModel に Context を渡したくなる瞬間がありますが、基本アウトです。

class MyViewModel(
    private val context: Context // ❌
)

理由:

  • テスト困難
  • ライフサイクルとズレる
  • DIが崩れる

❌ NG2: 画面遷移の実行そのもの

fun goDetail(navController: NavController) { // ❌
    navController.navigate("detail")
}

ViewModel は「遷移してほしい」という Eventを出すだけ

❌ NG3: UI描画のためだけの状態二重管理

val title = MutableStateFlow("")
val isButtonEnabled = MutableStateFlow(false)

この2つが同時に存在して、整合性が崩れます。

👉 Stateは正規化して1つに寄せるべきです。

❌ NG4: Viewの都合のif分岐を詰め込む

例:

  • TextFieldのフォーカス
  • スクロール位置
  • キーボード表示

これらはUI側の責務です。

失敗例:太りすぎた ViewModel(よくある)

class SampleViewModel(
    private val repository: ItemRepository
) : ViewModel() {

    val title = MutableStateFlow("")
    val isLoading = MutableStateFlow(false)
    val errorMessage = MutableStateFlow<String?>(null)
    val items = MutableStateFlow<List<Item>>(emptyList())
    val showSnackbar = MutableStateFlow(false)

    fun init() {
        isLoading.value = true
        viewModelScope.launch {
            try {
                val result = repository.fetch()
                items.value = result
                if (result.isEmpty()) {
                    errorMessage.value = "データがありません"
                }
            } catch (e: Exception) {
                errorMessage.value = "通信に失敗しました"
                showSnackbar.value = true
            } finally {
                isLoading.value = false
            }
        }
    }

    fun formatDate(date: Long): String {
        return SimpleDateFormat("yyyy/MM/dd").format(Date(date))
    }
}

何がダメか

  • 状態が散らばっている(正規化されてない)
  • SnackbarをStateにしている(イベント混入)
  • エラーと空状態の意味が混線
  • 日付フォーマットが混ざっている(責務逸脱)

修正版:ViewModel を「State生成器」に戻す

① UiState を正規化

sealed class UiState {
    object Loading : UiState()
    object Empty : UiState()
    data class Content(val items: List<ItemUiModel>) : UiState()
    data class Error(val message: String) : UiState()
}

② UiEvent は SharedFlow

sealed class UiEvent {
    data class ShowSnackbar(val message: String) : UiEvent()
}

class SampleViewModel(
    private val fetchItems: FetchItemsUseCase,
    private val mapper: ItemUiMapper
) : ViewModel() {

    private val _state = MutableStateFlow<UiState>(UiState.Loading)
    val state = _state.asStateFlow()

    private val _event = MutableSharedFlow<UiEvent>()
    val event = _event.asSharedFlow()

    fun load() {
        viewModelScope.launch {
            _state.value = UiState.Loading

            runCatching { fetchItems() }
                .onSuccess { items ->
                    if (items.isEmpty()) {
                        _state.value = UiState.Empty
                    } else {
                        _state.value = UiState.Content(
                            items = items.map(mapper::toUi)
                        )
                    }
                }
                .onFailure {
                    _state.value = UiState.Error("読み込みに失敗しました")
                    _event.emit(UiEvent.ShowSnackbar("通信エラーが発生しました"))
                }
        }
    }
}

③ UI 側は State を描画し、Event を消費する

@Composable
fun SampleScreen(
    viewModel: SampleViewModel = hiltViewModel()
) {
    val state by viewModel.state.collectAsState()

    val snackbarHostState = remember { SnackbarHostState() }

    LaunchedEffect(Unit) {
        viewModel.event.collect { event ->
            when (event) {
                is UiEvent.ShowSnackbar ->
                    snackbarHostState.showSnackbar(event.message)
            }
        }
    }

    when (state) {
        UiState.Loading -> Loading()
        UiState.Empty -> Empty()
        is UiState.Content -> Content((state as UiState.Content).items)
        is UiState.Error -> Error((state as UiState.Error).message)
    }
}

ViewModel が太る原因は「境界が曖昧」だから

太る ViewModel には共通点があります。

  • どこまでが UI か分からない
  • どこまでが Domain か分からない
  • 便利だから入れる
  • 後で整理する(整理されない)

つまり、

ViewModel が“最後のゴミ箱”になっている

ViewModel を太らせない設計パターン

パターン①:Intent(UI操作)を固定する

sealed class UiAction {
    object Retry : UiAction()
    data class ClickItem(val id: String) : UiAction()
}

ViewModelはこれを受けるだけにすると、責務が明確になります。

パターン②:Mapper を外に出す

UI表示用の整形(例:日付)は、Mapperに寄せます。

class ItemUiMapper(
    private val dateFormatter: DateFormatter
) {
    fun toUi(item: Item): ItemUiModel {
        return ItemUiModel(
            title = item.title,
            dateText = dateFormatter.format(item.createdAt)
        )
    }
}

ViewModel から「表示都合の整形」を消すのがコツです。

パターン③:UseCase で“業務ルール”を閉じる

  • 並び替え
  • フィルタ
  • 制約チェック

は ViewModel ではなく UseCase に置きます。

実務チェックリスト(この線を越えたら危険)

ViewModel に次が出てきたら黄色信号です。

  • Context
  • NavController
  • SimpleDateFormat
  • Toast
  • SnackbarHostState
  • mutableStateOf の乱立
  • UI表示のための if/else の嵐

👉 その処理は「ViewModelの仕事ではない」可能性が高い。

まとめ

Compose時代の ViewModel は、

UI State を生成して、UI に渡す層

です。

  • UIの都合は UI に寄せる
  • 業務ルールは UseCase に寄せる
  • ViewModel は “接着剤” に徹する

これができると、

  • ViewModel が太らない
  • 改修が怖くない
  • テストが書ける
  • Compose が本当に楽になる
タイトルとURLをコピーしました