さよならViewModel

1. そもそもFlutterにViewModelって必要?

・WPFや古き良きAndroid開発と違って、UIと状態の同期システムとしてのDataBindingの仕組みがFlutterには存在しない。よって、その中継ハブとしての役割をViewModelは持つ必要はない。
・そして、Flutterにはriverpodやhooksといった非常に強力な状態管理ツールが存在しており、それらを用いて状態の監視を行うことで、状態の変更に応じてUIが自動で宣言的に更新される。

2. ViewModelってViewごとに用意するはずじゃなかった?

・設計にもよるが、ViewModelの実体はProviderで保持するパターンが多いはず。しかしProviderで保持しているViewModelはRefを通じてあらゆるViewから呼び出せてしまう。 ViewとViewModelの1:1の関係は遠い昔にすでに破綻してるのだ。

3. Viewの状態をすべてViewModelに持たせることは出来ない

・Flutterが提供するController系は、UIのライフサイクルに強く依存しており、これらはViewの中でのみ取り扱うことが可能。一方で、例えばTextEditingControllerのtextプロパティなどもViewの状態の一種といえる。つまり、ViewModelでViewのすべての状態を管理することは不可能。”View”の”Model”だなんて名前負けも良いところだ。

結論?:Flutterの設計思想とViewModelは相容れにくい存在

もう面倒くさいことは考えずに状態もロジックも全部Viewに書いてしまおう!ViewModelよさらば!
しかし困った、真に受けてViewになんでもかんでも書いていたら典型的なファットコードになってしまった
というかこれ、多重責務ってやつじゃないの?
そういえば一つ思い出した。ViewModelの役割って状態の保持の他にも、ユーザーのアクションをModelやApplication層に伝える役割もあるんだった。

結論・・・?:ならいっそViewModelにはPresenterのみの役割をもたせよう

関心の分離や責務の分離の原則に従うとどうにもViewにすべてを書くのは憚られる。でも状態管理の面ではクラスベースのViewModelはどうにも扱いにくい。
思い切って状態はView(のフック達)に管理させて、ViewModelとは名ばかりのPresenterにしてしまおうか?
でもそうなるとPresenterの関数を実行するときにアプリの状態を参照したい時には、一々引数に取らないといけないし、何より非同期処理の結果を受けて状態の変更を行いたい時は戻り値やコールバックをView側で受けて手動で変更しないといけないわけだ。なんてまどろっこしいんだろう。

真の結論:カスタムフックを作ろう

やはり私たちはViewModelと寄りを戻すしかないのだろうか?実際ViewModelの魔の手から逃れられないと主張するプログラマーたちは多く存在する。
ここで視点を最初に戻そう。ViewModelの問題点は状態管理のやり方がFlutterの設計思想に即していない点とViewとのリレーションが破綻している点だ。責務の分離という点ではViewModelの存在意義は大きかったはずだ。
つまり、それらの問題点を解消した実に現代的なやり方ViewModelを再構成できれば万事解決というわけだ。でもどうやって?
実は方策はある。クラスベースに慣れ親しんだ私たちには馴染が薄いが、Flutter(というよりflutter_hooks)にはカスタムフックという便利な道具が用意されている。
flutter_hooksにはuseStateやuseTextEditingControllerのような便利なフックがすでに多数用意されているが、自分でカスタムフックを書くこともできる。
そしてこのカスタムフックには、View内のフックの数々やロジックを束ねることができる。

こんな感じ↓

part of '../../component/friend_quiz/friend_quiz_dialog.dart';

typedef _FriendQuizController = ({
  TextEditingController textEditingController,
  Future<void> Function(FriendQuiz quiz) onSubmit,
});

_FriendQuizController _useFriendQuizController(BuildContext context, WidgetRef ref) {
  final friendQuizService = ref.watch(friendQuizServiceProvider);
  final textEditingController = useTextEditingController();

  void invalidateFriendQuizzes(String friendId) => ref.invalidate(friendQuizzesProvider(friendId));

  Future<void> onSubmit(FriendQuiz quiz) async {
    if (textEditingController.text.isEmpty) {
      Fluttertoast.showToast(
        msg: "回答を入力してください",
        toastLength: Toast.LENGTH_SHORT,
        gravity: ToastGravity.BOTTOM,
        backgroundColor: ColorName.primaryColorDark,
        textColor: Colors.white,
        fontSize: 16.0,
      );
      return;
    }

    final isCorrect = await friendQuizService.setUserFriendQuizData(
      quiz: quiz,
      userAnswer: textEditingController.text,
    );

    if (!isCorrect) {
      if (!context.mounted) return;
      showFriendQuizFailedBottomSheet(context);
      return;
    }

    invalidateFriendQuizzes(quiz.friendId);
  }

  return (
    textEditingController: textEditingController,
    onSubmit: onSubmit,
  );
}

呼び出す側 ↓

final controller = _useFriendQuizController(context, ref);

😀カスタムフックのメリット

・ViewModelと同じく状態とロジックを記述できるので、責務の分離が実現できる。
・クラスベースではなく関数ベースなので定義が簡潔。Providerも不要。
・完全プライベート化が可能。Flutterのpart機能を用いれば別ファイルに書きながら呼び出せるViewを限定できるので完全な1:1の関係を作れる。
・Viewに直接記述しなくても、本当の意味でのローカルな状態を作れる。
・use系のフックも直接記述できるので、実装する時にライフサイクルを考慮しなくていい。
・関数の差し替えだけでMockを作れるので単体テストも比較的容易。

😢カスタムフックのデメリット

・関数ベースなのでクラスベースに慣れ親しんでいる私たちにはやや馴染みがない。
・再利用性が低い。View内で利用する別ファイルに切り出したコンポーネントでも使用したい時は適していない。
・ViewModelと同じく、巨大フックが誕生する危険性は避けられない。

まとめ

実際のところViewModel的存在に別れを告げられているわけではないので、タイトル詐欺だったかもしれない。
しかし、ViewModelをなくした所でそこに格納されていた状態やロジックがブラックホールに吸われて消えるわけでもないので、必ずプロジェクトという惑星のどこかで管理する必要があるし、私たち開発者は保守性や可読性の重力から逃れる事もできない。
なら管理方法も時代に即した持続可能なものに適宜変えていこうというお話。プログラマーもSDGsを意識する時代。

💡FlutterにおけるViewModelの再評価ポイント

状態管理の責務はフックに任せる
useStateuseTextEditingController など、UIとライフサイクルに強く結びついた要素はView(Hook)で管理する方が自然。
ロジックの集約にはカスタムフックを使う
→ ViewModel的な「責務の調停者」は、関数ベースでコンパクトに定義可能。Mock化も容易でテスト性も高い。
「1:1の関係」を強制的に守るならpartでスコープを限定
→ グローバルに拡散してしまった従来のViewModelと異なり、スコープを限定できる点が大きな利点。
結局、状態もロジックもどこかで管理は必要
→ それがViewModelであろうと、Hookであろうと、プロジェクトにおける“責務の重力”からは逃れられない。

参考

Qiita
「SwiftUIでMVVMを採用するのは止めよう」と思い至った理由 - Qiita ※2022/04/23 追記本記事の続編として、以下の記事を書きましたので、合わせて御覧ください。仕事でSwiftUIでTCAを使ってみて、かなり知見がたまったので、その解説です。MV...
あわせて読みたい
Hooksについて | Riverpod このページでは Hooks とは何か、そして Riverpod とどのように関連しているかについて説明します。