📘

FlutterのViewModelでモヤっとしていたことを解消したのでメモ

2022/01/27に公開
5

僕はずっとFlutterで「ViewModel」とやらを組むときにモヤっとしていたことがあります。
それは、ちまたのコードでは 「画面ごとのViewModel」「機能ごとのViewModel」 があるのでは?ということです。
それが良いのか悪いのかは置いておき、自分の中でどっちをどの時に使うのかしっくり来ないことがありました。

「こんなのどうかな?」と思いついた方法をメモしてたら記事に出来そうだったので、一旦記事にするかと思ってばばーっとまとめました。
1つの考え方として見てもらえればと思います🙏

要約

  • 基本的には 機能ごとに分けたControllerクラス で状態管理を行う。
    • 複数画面で同じ機能を使用する際にコードをまとめられる。
  • 登録画面などで 画面ごとに状態を管理する必要がある場合はNotifierクラスを用意 し、そこで状態管理をする。
    • NotifierクラスにonTapメソッドなど丸ごと記載することにより、WidgetクラスのコードをUIに関連するものに絞れる。

2021/2/2追記
記事に記載のコードとは違いますが、この考え方を採用したサンプルリポジトリを作成してみました。
https://github.com/oke331/moyatto_view_model_sample

素直なAさんの例

※コード解説というより考え方の解説記事なので厳密なコードは省き簡略化します。
※Riverpodを例として使います。

例えば、 Home画面でユーザーの名前を表示したい とします。
そのため、Aさんは 「HomeViewModel」 を作成し、Repositoryからの取得処理とnameを定義しました。
(コード量減らすためにChangeNotifier使います)

final homeViewModel = ChangeNotifierProvider((ref)=>HomeViewModel());
class HomeViewModel extends ChangeNotifier {
  /// 名前
  String? name;

  /// 名前取得処理
  Future<void> fetchName() async {
    // 名前を取得
    name = await repository.fetchName(); 
		
    // 変更をUIに通知
    notifyListeners();
  }
}

これで一件落着。エラー処理とかは一旦見逃して。
次の機能に取り掛かろうとすると、今度は 次のページでもユーザー名を表示して欲しいとの仕様が。
Aさんはまたも 同じような「NextPageViewModel」 を作ります。

final nextPageViewModel = ChangeNotifierProvider((ref)=>NextPageViewModel());
class NextPageViewModel extends ChangeNotifier {
  String? name;
  Future<void> fetchName() async {
    name = await repository.fetchName();
    notifyListeners();
  }
}

ここでAさんは気づきます。

「同じじゃね?」

同じ情報取得するのに2回もデータ取得していてAさんはぷんぷん。
そこで、 二つのViewModelをひとまとめにして、一件落着

final userNameViewModel = ChangeNotifierProvider((ref)=>UserNameViewModel());
class UserNameViewModel extends ChangeNotifier {
  String? name;
  Future<void> fetchName() async {
    name = await repository.fetchName();
    notifyListeners();
  }
}

しかしここで、二つの種類のViewModelがあるのではとAさんは思いました。

  • 「HomeViewModel」「NextPageViewModel」は 画面ごとのViewModel
  • 「UserNameViewModel」は 機能ごとのViewModel

そこでAさんは迷います。

「新しく作るViewModelはどっちで定義したらいいんだぁー!!」

僕の考え

僕は全部 「機能ごとでViewModelを作っちまえ!」 に行き着きました。
でも、果たしてこれはViewModelと言えるのかは際どいところ。
「ViewModelはViewごとでないと」という意見もちらほら。
だからちょっと名前をいじって Controller にしちゃいます。
何となく違和感は消えたような気がします。
(この辺り詳しくないので良い名称があれば教えてほしいです🙏)

class UserNameController extends ChangeNotifier {
  String? name;
  Future<void> fetchName() async {
    name = await repository.fetchName();
    notifyListeners();
  }
}

データを登録しようとしたAさんの悩み

次にAさんは、ユーザーが Aさんのことが好きかを登録する画面を実装 します。
Aさんは大興奮です。

しかしここで、Aさんは今までと違うことに気づきます。

「あれ?今までViewModelにbool値持たせてたんだけど、ViewModelなくなっちゃった!」

ここでAさんはあることに気づきます。

「そっか!ふつうにStatefulWidgetのsetState()で状態を変えたらいっか!」

そして、こんな感じで組んでみました。

class LoveAPage extends StatefulWidget {
  const LoveAPage({ Key? key }) : super(key: key);

  
  _LoveAPageState createState() => _LoveAPageState();
}

class _LoveAPageState extends State<LoveAPage> {
  bool lovesA;

  
  Widget build(BuildContext context) {
    ...  setState({lovesA = true});
  }
}

でもここでAさんはまた悩みます。

「結構複雑な画面でコード量多いしWidgetクラスを分けたいんだけど、setStateはStateクラスの中で呼ばないとだし、 引数でどんどん中のWidgetに渡していかないといけなかったり して、かっこいい書き方思いつかないなぁ」
(hooksを使用した場合も同様の理由で悩みそう)

さらに他のことでも悩みます。

「今まで ViewModelからRepositoryを呼んで登録してたんだけど、 ViewModelないしどこに定義して良いかわかんない!

僕の考え

Aさんには以下の二つのわからないことがありました。

「ViewModelで状態管理していたのはどうするの?」
「ViewModelから呼んでいたRepositoryの処理はどうするの?」

まず、Repositoryについて、一つ目の僕の考えでお伝えしたControllerに実装します。

final loveAController = ChangeNotifierProvider((ref) => LoveAController());
class LoveAController extends ChangeNotifier {
  /// Aさんが好きか
  bool lovesA;

  /// Aさんが好きか取得
  Future<void> fetchLove() async {
    lovesA = await repository.fetchLove();
    notifyListeners();
  }

  /// Aさんが好きか登録
  Future<void> registerLove(bool lovesA) async {
    await repository.registerLovesA(lovesA);
		
    // 最新のデータに更新する
    await fetchLove();
  }
}

そして、ViewModelで状態管理していたものは、 ページごとに専用のNotifierを作ってあげます
今回は「LoveAPage」なので、「LoveAPageNotifier」というものを作ってみました。
このLoveAPageNotifierからControllerに対してへの registerLoveを行い、そこからRepositoryを介して登録される、といった形です。

final loveAPageNotifier =
    ChangeNotifierProvider((ref) => LoveAPageNotifier(ref.read));
class LoveAPageNotifier extends ChangeNotifier {
  LoveAPageNotifier(this._read);

  final Reader _read;

  /// Aさんが好きか
  bool lovesA;

  Future<void> onTapRegisterLoveButton() async {
    // Controllerを呼んで名前を取得
    await _read(loveAController).registerLove(lovesA);
		
    // 任意で画面遷移の処理やスナックバーを表示する
  }
}

このように一つNotifierをPageごとに被せる利点は下記のようなものがあるかな?と思います。

  • ページに依存する状態管理(Notifier)とアプリ全体に関連する状態管理(Controller)に分けることができる。
  • ControllerにRepositoryの処理を集めることによって、他の画面から同一の処理が呼ばれても一つのコードベースで対応でき 、登録後のControllerで扱っている変数のアップデートまで行える。
  • Notifierにタップ時のonTapメソッドのコードを丸ごと記載することで、 Widget側にタップ後の条件分岐等のコードが含まれるのを防ぎ、WidgetはUIに関するコードを主にできる。

また、個人的に悩ましかった 「ViewModelにBuildContextは渡して良いのか?」 という部分について、NotifierはPageと1体1の関係 であり、 Repositoryとも疎となっておりViewModelの立ち位置とも違う ので、気兼ねなく使っても良いのでは?という気がしています。

フォルダとしては下記のように NotifierはPageと一緒の箇所でまとめる 感じが良いかな?と思います。

presentation/
  controller/
    loveA_controller.dart
  page/
    loveA_page/
      loveA_page.dart
      loveA_page_notofier.dart

終わりに

僕の知識が浅すぎてそんなのダメだよとか、
そんなのみんなわかってるよとか、
色々ある気がするのですが、お手柔らかにご指導ご鞭撻いただければ幸いです…🙇‍♂️

何となく思いついて記事書き終えて2時間半。また詳しく編集や考え直しなどしていきます。

Twitter: https://twitter.com/oke331

Discussion

TanuchiTanuchi

初めまして。
同じような悩みを抱えてまして、参考になりました。
私もどちらかといえば、「MVVM」アーキテクチャは画面ごとのViewModelの思い込みだったのですが、
例えば、ニュースのトップ画面とニュース検索画面が検索条件に対しての件数(表示)を両画面で表示しなければならないと想定した場合、「同じじゃね?」に行きつき、かと言ってMixin?共通の具象class?ってなっていたのでViewModelをページに依存する状態管理(Notifier)とアプリ全体に関連する状態管理(Controller)に分けるのを検討させて頂きます!

記事にして頂きありがとうございます!

oke331oke331

コメントありがとうございます🙇‍♂️
僕も同じように悩み記事にしてみた次第ですが、ガッツリとこの考え方で組んだアプリはないので、もし「こういう時微妙じゃない?」とか「こっちの方が良い!」とかありましたら、またTwitterでもこちらでもコメントいただければ嬉しいです🙏

osanaiksosanaiks

こんにちは。私はもっと全体的に viewModelはどうあるべきか、というところで悩んでて、こちらに行きつきました👀

すでに1年経過していますが、今もこの考え方ですか??それとも最近の違う考え方があれば、ぜひご教授いただきたいです🙇‍♂️

oke331oke331

返信が遅れて申し訳ありません!
ほとんど考え方は変わっておらず、全体で管理する機能ごとの状態と、画面ごとで管理する状態は分けた方が良いのではと思っています😺
この方がやりやすかったなどあれば、教えていただけると嬉しいです!

osanaiksosanaiks

ありがとうございます!とても参考になります。

私のところでは、受託で短期間でとりあえず進める、やりながら修正、みたいなのが多く、変数やビジネスロジックが曖昧だったりするので、stateで処理しつつ、全体で管理するものはserviceとして分けました。

本当は落ち着いたタイミングでViewModelとして分けたいと思っています😂