【習慣トラッカー開発記 #8】登録・編集画面の ViewModel 設計と状態管理について

はじめに

前回の記事では、習慣の「登録画面」と「編集画面」を分けつつ、共通UIを HabitForm に集約する構成について紹介しました。

UIの共通化自体は比較的スムーズに進んだ一方で、実装中に最も悩んだのが 「状態をどこで持つか」 でした。

  • Composable にどこまで状態を持たせるか
  • ViewModel に集約すべき責務は何か
  • 登録と編集で ViewModel を分ける意味はあるのか

今回は、そうした試行錯誤の末に落ち着いた編集画面の ViewModel 設計と状態管理の考え方 を整理します。

今回の前提構成

  • Jetpack Compose
  • MVVM + Repository
  • 状態管理は StateFlow
  • 登録画面 / 編集画面は別 ViewModel
  • UI は collectAsState() で購読

編集画面 ViewModel の責務

編集画面では、登録画面にはない以下の責務が発生します。

  • 既存の習慣データをロードする
  • 初期値をフォームに反映する
  • 編集内容を即座に UI に反映する
  • 更新・削除などの操作を扱う

これらをすべて ViewModel に集約したのがHabitEditViewModel です。

UI状態は HabitUiModel をそのまま持つ編集画面では、UI状態として HabitUiModel をそのまま使っています。

Composeでは以下指定するだけで状態変更に応じた再描画が自動的に行うことができます。

private val _habitUiModel = MutableStateFlow<HabitUiModel?>(null)
val uiState = _habitUiModel.asStateFlow()

初期データのロードは ViewModel に閉じる

編集画面特有の処理である「既存データのロード」も、ComposableではなくViewModel に集約しています。

fun loadHabit(habitId: Long) {
    viewModelScope.launch {
        if (_habitUiModel.value == null) {
            habitRepository.getHabitById(habitId).collect { habitWithCategory ->
                _habitUiModel.value = habitWithCategory?.toUiModel()
            }
        }
    }
}

ここで意識したポイントは以下です。

  • Composableは「いつロードするか」を知らない
  • 初回ロードのみ実行されるように行う
  • Entity → UI Model の変換もViewModelで完結

UI は「状態を表示するだけ」に徹します。

フォーム操作はすべて ViewModel 経由

フォーム内の操作は、それぞれ専用の更新関数としてViewModelに用意しています。

// 開始時間の変更を反映する
fun updateStartTime(time: String) {
    _habitUiModel.value = _habitUiModel.value?.copy(startTime = time)
}

// 終了時間の変更を反映する
fun updateEndTime(time: String) {
    _habitUiModel.value = _habitUiModel.value?.copy(endTime = time)
}

// 有効フラグの変更を反映する
fun updateChecked(checked: Boolean) {
    _habitUiModel.value = _habitUiModel.value?.copy(isChecked = checked)
}

// 曜日の変更を反映する
fun updateDayOfWeek(days: List<Int>) {
    // 安全のため、0〜6の範囲にフィルタリング
    _habitUiModel.value = _habitUiModel.value?.copy(dayOfWeek = days.filter { it in 0..6 })
}

これにより、

  • ViewModel(UIイベント):UIからの報告(イベント)を受け取り、状態を更新する
  • StateFlow(状態更新):最新の状態を常に一定方向に流す
  • Compose(UI再描画):流れてきた状態をそのまま画面に映し出す

という一方向データフローを保てています。

画面間連携も ViewModel に集約

カテゴリ選択画面から戻った際の更新も、ViewModelに閉じています。

fun updateCategory(category: CategorySelectUiModel) {
    _habitUiModel.value = _habitUiModel.value?.copy(
        iconResId = category.iconResId,
        title = category.categoryName,
        categoryId = category.categoryId,
        subCategoryId = category.subCategoryId,
        subCategorySource = category.subCategorySource
    )
}

画面が増えてもロジックを保ちやすく、Composable 同士が直接依存しない構成になります。

保存・削除などの副作用は ViewModel に任せる

DB更新や削除といった副作用を伴う処理もすべて ViewModel に集約しています。

// 習慣の更新
fun updateHabit(updated: HabitUiModel) {
    viewModelScope.launch {
// 習慣がいつ更新されたかを現在時刻で上書き 
val habit = updated.toEntity().copy(
            updatedAt = Instant.now().epochSecond
        )
        habitRepository.updateHabit(habit)
    }
}

// 習慣の削除
fun deleteHabitById(id: Long) {
    viewModelScope.launch {
        habitRepository.deleteById(id)
    }
}

Composable 側では、

  • ボタンを押す
  • コールバックを呼ぶ

だけで完結するため、DBや時間管理の存在を意識する必要がありません。

なぜこの構成に落ち着いたのか

実装中に最も悩んだのは、状態をComposable 持つか、ViewModelで持つか

という点でした。

最終的には、

  • 保存される値はすべて ViewModel
  • Composable は入力と表示に集中

という役割分担に落ち着きました。

その結果、

  • 登録・編集画面で設計を揃えやすい
  • UI共通化がシンプルになる
  • 状態の流れが追いやすい

というメリットが得られました。

まとめ

観点採用した方針メリット
状態管理全てViewModelに集約画面回転や再生成に強い
UIの責務入力イベントの通知と表示Previewが作りやすくテストが容易
データ更新ViewModelの明示的な関数ビジネスロジックの漏洩を防ぐ
データフローStateFlowによる一方向流状態の不整合が起きない

Compose では、「どこまで共通化し、どこから分けるか」 に加えて、「状態を誰が持つか」 が重要だと感じました。


次回予告

次回は、登録画面と編集画面で 使用するカテゴリ選択画面について深堀りしていく予定です。

タイトルとURLをコピーしました