UI Layer Case Study(要約)
結論:各機能のUI層は View と ViewModel のペアで構成。
ViewModel:UIロジックと状態管理を担当(入力=Repository群/出力=UI State)。
View:ViewModelが持つ状態を描画し、ユーザー操作をViewModelのコマンドに渡す。
→ 1機能 = 1 View + 1 ViewModel(1対1の関係)
ViewModel ― 役割と設計
依存関係:必ず1つ以上の Repository に依存(多対多)。コンストラクタで受け取り、private フィールドに保持(Viewからデータ層を隠す)。
UI Stateの公開:User? user、UnmodifiableListView のように 不変なスナップショット を公開。
データクラスは freezed で深いイミュータブル化(copyWith/toJson 自動生成)。
更新通知:ChangeNotifier を継承し、状態更新後に notifyListeners() を呼ぶ。
イベント処理:削除などのユーザー操作は コマンド(Command) として公開。中ではRepositoryを呼び出して永続状態を変更し、最後に notifyListeners()。
View ― 役割と設計
入力:基本は key と 対応する ViewModel のみ。
責務:
ViewModelのデータを表示
ViewModelの変更を購読して再描画(ListenableBuilder(listenable: viewModel) など)
イベントを委譲(ボタン押下で viewModel.deleteBooking.execute(id) など)
実装例:HomeScreen は ListenableBuilder でViewModel/Commandの状態(実行中・成功・エラー)に応じて
ローディング表示
エラー表示+再試行ボタン
本来のUI
を切り替える。
Command パターン(本事例の肝)
目的:UI→データ層への処理の往復で、実行中・成功・失敗 を安全に扱うためのヘルパー。
仕組み:Command extends ChangeNotifier が running/completed/error を持ち、execute() 内で状態を更新→ notifyListeners()。
使いどころ:
画面初期ロード:load = Command0(_load)..execute()
項目削除:deleteBooking = Command1(_deleteBooking)
利点:データ到着前でもビューが安全にレンダリング可能(コマンドの状態に合わせたUI分岐)。
代替:自作せず flutter_command の利用も推奨。Stream/StreamBuilder を使う場合は AsyncSnapshot が近い役割。
実装ポイント(抜粋)
不変データ+最小ロジックのView+状態はViewModel。
Repositoryのみがアプリの永続状態を変更(SSOT)。
ViewModelは 複数Repositoryを統合・整形 してUIに最適化。
ChangeNotifier/ListenableBuilder は標準機能で十分だが、Riverpod / flutter_bloc / signals などの採用も可。
まとめ
UI層は 「UIは状態の関数」 を徹底:
ViewModelが状態を管理 → Viewは状態を描画 → イベントはCommand経由でデータ層へ。
Commandパターン により、非同期・エラー・再試行・ローディングの扱いが統一され、堅牢でテストしやすいUIになる。