27
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

FlutterAdvent Calendar 2024

Day 5

作りながら考える MVVM な Flutter アプリ

Last updated at Posted at 2024-12-04

Flutter で MVVM パターンがどのように実装できるのか、そしてそれは Flutter の仕組みと合っているのか、という話を以前以下の記事に書きました。

そしてその数日後、今度は公式から "Architecting Flutter apps" という Flutter の推奨アーキテクチャガイドが公開され、そこでは MVVM パターンが使われていました。

さらにそれを受けて、そのドキュメントに対し「MVVM というよりも MVC の方が良いのではないか」という issue が立ち、そこでは作りの見直しだったりパターンの呼び方の見直しだったりとさまざまな議論が盛り上がりました。

Flutter アプリのアーキテクチャについては昔から MVVM を適用させようとする試みと「いや、 Flutter に MVVM は適していない」という意見とがあった認識です。公式のドキュメントが出たことで改めて世界的にホットな話題になっていると言えるのではないかと思います。

私個人としてはそんな盛り上がりとは関係なく少し前からアーキテクチャパターンに興味を持って 腰を据えて調べていた ところでしたので、この記事では改めて「Flutter アプリに MVVM は有りや無しや」という話を、私見ではありますが書いてみたいと思います。

アーキテクチャパターンとの向き合い方

まず大前提としてですが、MVC や MVP, MVVM などに代表されるアーキテクチャパターンは「適用すること」が目的ではありません。というより、そもそもそれらのパターンはそのままコーディングに適用できる形で情報がまとめられているわけではありません。そのパターンが登場した時に Flutter フレームワークは存在していませんし、そうでなくとも言語やフレームワークによって書き方や考え方も変わって然るべきです。

さらに大事なこととして、開発における前提条件はプロジェクトごとに異なる という点は考えなければなりません。

アーキテクチャの目的は「適用すること」ではなく 「課題を解決すること」 にあります。事業の方向性、チームメンバー、技術スタックなど、 さまざまな要因によって課題は変化し、それを解決するための適切なアーキテクチャもプロジェクトごとに異なる というのが私の意見です。

それは「パターン」についても同様で、パターンを適用したらそれだけですべての課題が解決するということはないでしょう。下手をすると、そのプロジェクトには存在しない課題を解決するための不要なルールが開発を非効率にする、というリスクすら考えられます。

そう考えると、アーキテクチャパターンと付き合う上で大事なことは 適用して終わりではなく、個別のプロジェクトの事情に合わせて最適化する ことと言えるのではないでしょうか。

Martin Fowler も、"Patterns of Enterprise Application Architecture" という書籍の中で以下のように書いています。

I like to say that patterns are “half baked,” meaning that you always have to finish them off in the oven of your own project.

パターンを適用すること自体はゴールではありません。そのパターンがどのような課題をどう解決するかを把握し、その課題が自分のプロジェクトに当てはまっているのかを検証し、過不足があればアーキテクチャの形やルール自体を修正します。そう考えると、パターンに対する「正しい」実装方法やパターンの定義に固執することに意味はない と言えるでしょう。先述の公式ドキュメントもあくまで「ガイドライン」であり、それ自体がベストプラクティスだったり絶対的に正解なアーキテクチャというわけではありません。

以上を踏まえ、この記事では MVVM(実際は後述する "Presentation Model" パターン)が提唱された時に

  • どのような課題が想定され
  • どのようなアプローチで解決しようとしていたのか

を整理し、それを Flutter で作るちょっとした TODO アプリの実装に落とし込んでみながら「Flutter における MVVM とは」を検証します。

MVVM とは

MVVM は、GUI アプリケーションを M(Model), V(View), VM(ViewModel) の 3 つのレイヤーに分けることで

  • UI とそうでない部分を分離する
  • データベースからソフトウェア全体のデータの整合性を保つ

ことを目的としたアーキテクチャパターンです。

ただし、ここで注意しなければいけないのは、"MVVM" というパターン自体は Microsoft の WPF や Silverlight で実装することを前提としたアーキテクチャであって、その元の発想になっているのは Martin Fowler による Presentation Model というパターンです。

そのため、今回はこの Presentation Model の方を主に参照しながら、Model, View, ViewModel(Presentation Model) のそれぞれのレイヤーの考え方について整理してみましょう。

Model

Model はデータベースとのデータのやりとりとビジネスロジックを担当します。

基本的にソフトウェアには前提となる「ビジネス」があります。

たとえば TODO 管理を例にとると、

  • やるべきことをメモに残す
  • 一覧しやすいように整理する
  • 進捗をつける
  • 担当者を決め、共有する
  • 期日までに間に合わない TODO が発生した場合は調整する

といったことを そのアプリがなかったとしても手作業で 行います。「納期厳守でクライアントから信頼を得て次の大きな案件を勝ち取る」など、その作業の先に解決したい課題がさらにある場合もあるでしょう。

ソフトウェアはその作業を自動化しますが、そこで発生する「やりたいこと」と「発生するデータ」をセットで管理するのが Model です。

なお、「どのような UI 操作で」それを実現するのかは担当しません。あくまでひとつひとつの作業に対する処理の流れとデータの読み込み・書き込みのみを行います。つまり、Model は UI やプラットフォームの事情は考慮せず、プログラムとしても依存関係はない形で実装します。

このような分離は、特に「ビジネスに詳しい人」と「ユーザビリティを追求する人」でプロジェクトメンバーの役割が分かれている場合に特に重要です。1

テストコードを書くことを考えた場合も、一般的に UI やフレームワークに依存する部分の自動テストは複雑になりがちですので、ビジネス上重要なロジックをテストしやすく保つ という点においても Model の分離は重要になるでしょう。

ソフトウェアがなくてもビジネスを進めるために実行する必要があり、UI やプラットフォームに関係なく、GUI アプリか CLI アプリかに関わらず利用可能なモジュール 、それが Model です。

View

View はアプリケーションにおける UI を担当します。

Model でビジネスロジックとデータの管理を実装したとしても、エンジニアではないユーザーは当然ながらそれを操作できません。

そのようなユーザーのために Model を扱う入り口となり、また Model から取得したデータを可視化するためのレイヤーが View です。

View はそのような可視化や入り口の提供を UI フレームワークを使って実現します。今回は Flutter ですね。

View はデザインの試行錯誤や機能の追加・修正に応じて頻繁に修正が入り、かつリッチな UI を実現したい場合はそれだけで複雑になりがちなレイヤーです。先述の通り、unit test でテスト可能な Dart コードと比べてテストにも工数がかかります。メンテナンスも大変でしょう。Flutter のようなフレームワークや Android / iOS / ブラウザといったプラットフォームの事情や制約にも直接影響されます。

そのため、View では往々にしてビジネスロジックとは異なる課題が発生し、その解決のためにはビジネス的な知識とは別のノウハウや知見が必要とされます。その意味でも Model と View を明確に分離することは重要です。

ViewModel(Presentation Model)

ViewModel は View と Model を結びつけるためのレイヤーです。

先述の通り、この層は Martin Fowler の "Presentation Model" パターンの説明において一番重要な Presentation Model と説明されているものになりますが、単語の認識しやすさの都合でこの記事では "ViewModel" という言葉を使って説明します。2

現代のリッチな GUI アプリケーションでは、View は必ずしも Model のデータをそのまま画面に出力するわけではありません。ひとつの画面に複数のデータを同時に表示することもあるでしょう。その画面上でユーザーが行える操作もさまざまで、それにより「一時的な」データが発生することも珍しくありません。ひとつの操作が複数の Model に影響する場合もあります。

そのような状況では、View が Model を直接扱ってしまっては結局 View の中にも「表示のためのロジック」を書く結果となり、View が複雑になってしまいます。

特に Flutter においては View も Dart のプログラムとして記述しますので、そのようなロジックのコードと UI 構築のコードが混ざってしまっては、結局どのような場合にどのような UI が表示されるのかを掴むのが困難になってしまいます。

そこで ViewModel です。

ViewModel は View に変わって

  • Model からのデータ取得
  • ユーザー操作に応じた適切な処理方法の定義
  • データベースのデータとユーザー操作を考慮した上での、View で「そのまま」表示できるデータの生成

などを行います。

これにより、View 上では ViewModel から受けとった値をそのまま表示するだけ、ユーザーから受け取ったイベントを ViewModel に流すだけ という単純なコードが実現します。

データの一貫性の問題

さて、「UI とビジネスロジックの分離」という観点での MVVM の各レイヤーの説明は以上となるのですが、GUI アプリケーションにおいてはもうひとつ解決すべき課題が残っています。それが「データの一貫性・整合性」という問題です。

Model が管理するデータはユーザーの操作により絶えず変化します。誰かの操作が別の誰かのデータに変化を与える場合もあるでしょう。

そしてそのデータは、ソフトウェアの中でさまざまな理由で「コピー」されて 扱われます。

Martin Fowler はその「コピー」について、一般的には最低でも以下の 3 箇所で発生すると考えています。

  • record state
  • session state
  • screen state

record state はデータベースに保存されたデータそのものです。この値こそがユーザーが信頼すべきデータと言えるでしょう。

session state はソフトウェアがデータベースからデータを取得し、メモリ上に格納した record state のコピーです。われわれはこの値をプログラム上で利用します。

screen state は UI に表示するために UI コンポーネントに渡したコピーです。Flutter では、たとえば Text(title) のように widget を生成した場合に Text がそのコピーを保持することになります。(実際は RenderObject やエンジン層など、さらにその先が存在します)

それらを過不足なく同期させ View に反映させられなければ、ユーザーは古く誤ったデータを目にする結果になってしまいます。MVVM のようにアプリケーションのレイヤーを分ければ必然的にその「過不足ない同期」も複雑になり、「不具合が出ないよう気をつけて実装する」では管理しきれなくなります。そのため 同期を取るための仕組みについてもアーキテクチャのルールとして考える 必要性が出てきます。3

この「データの同期」という点にも着目しながら、ここから先は Flutter における Model / View / ViewModel の実装を考えてみましょう。

Flutter における MVVM の実装

では、実際に MVVM パターンを意識しながら TODO アプリを実装してみたいと思います。

ここから先は、この記事のために作成した TODO アプリのコードを使いながら話を進めます。リポジトリは以下です。

この TODO アプリには、よくある機能として以下のようなものを実装しています。

  • TODO の一覧表示
  • TODO の追加、編集、削除
  • 共有メンバーの一覧
  • TODO に対するメンバーのアサイン

あくまで「Flutter における MVVM」を検証する目的ですので、プロジェクトごとに異なることが想定される状態管理パッケージや DI パッケージは利用せず InheritedWidgetStatefulWidgetValueNotifierStreamController といった Dart/Flutter の標準の仕組みで実現を目指します。

Model

まずは Model の例として TodoModel の実装を見てみましょう。

TodoModel では、このアプリの大部分である TODO データの取得、追加、編集、削除といった一連の処理を担当します。

Model クラスは UI フレームワークには依存しません。Widget や BuildContext などは持ち込まず、dart:asyncintl といった Dart のみで利用可能なクラスやパッケージを使って実装します。

import 'dart:async';
import 'package:collection/collection.dart';
import 'package:intl/intl.dart';

class TodoModel {
}

続けて TodoModel に fetch/add/edit/delete といったデータ操作のメソッドも用意してあげます。

ここではデータを追加する add() を例にとって見てみましょう。

class TodoModel {

  final TodoStorage _storage;

  void addTodo({
    required String title,
    required double estimatedHours,
    required DateTime deadline,
    required Member assignee,
  }) {
    final todo = // create a new todo with given arguments;
    _storage.save(todo);
 }
}

MVVM において、"Model" の中をどのように実装するかは言及されていません。つまり、データ保存の具体的なコードをどのように分離するか、というようなことは議論のスコープ外です。ここでは話をシンプルにするために、メモリ上にデータを保持するだけの TodoStorage _storage を使って「データを保存した」ことにしようと思います。

Model は UI から独立してコーディングされ、どの UI から、いくつの UI から同じ Model が参照されるかは分からないため、複数の ViewModel から参照されることを前提として作っておく 必要があります。

つまり、Model が管理するデータに変更があったらそれを参照するすべての ViewModel に「通知」することでデータの整合性を保ちます。そのため add()edit() のようなデータに変更を加える操作の結果は戻り値ではなく StreamController.broadcast を使って通知できるようにしておきます。

class TodoModel {

  // provide broadcast stream to notify data updates to ViewModel
  final _todoController = StreamController<List<Todo>>.broadcast();

  void addTodo() {
    final todo = // create a new todo with given arguments;
    _storage.save(todo);

    // refetch all todos from the storage
    final allTodo = _storage.fetchAll();
    // notify latest data to all the dependent ViewModels
    _todoController.add(allTodo);
  }
}

繰り返しですが、解決したい課題には「UI とビジネスロジックの分離」の他にも「データの整合性」があります。この課題とアプローチは MVVM だけでなく、MVC, MVP といった MVVM 以前のアーキテクチャパターンでも変わりません。

さて、データの出し入れだけでなく、その上で発生するビジネスロジックについても考えてみましょう。

ここでは TODO を追加する際に、以下のようなルールを付け加えてみます。

  • 期限は現在時刻よりも後でなければならない
  • 他の TODO も考慮して、期限までの空き時間が見積り時間より多くなければならない

たとえば、期限が 10 時間後であり、他に見積り 6 時間の TODO が入っている場合、追加で入れられる TODO は見積りが 4 時間未満のものに限られます。

こうしたルールは UI とは関係なく(そしてソフトウェアとも関係なく)存在すべきものですので、Model に実装していきます。4

class TodoModel {

  void addTodo({
    required String title,
    required double estimatedHours,
    required DateTime deadline,
    required Member assignee,
  }) {
    final hoursUntilDeadline =
        newTodoDeadline.difference(DateTime.now()).inHours;
    if (hoursUntilDeadline < newTodoHours) {
      // throw an exception if the deadline is before the current date
      throw DeadlineRestrictionException(
        'Not enough time to complete this task. Task needs $newTodoHours hours but only $hoursUntilDeadline hours available until deadline.',
      );
    }

    final todosBeforeDeadline = _fetchTodosBefore(newTodoDeadline);

    // Calculate total hours needed for existing todos
    final totalExistingHours = todosBeforeDeadline.fold<double>(
      0,
      (sum, todo) => sum + todo.estimatedHours,
    );

    // Check if there's enough time for both existing todos and new todo
    final totalRequiredHours = totalExistingHours + newTodoHours;
    if (totalRequiredHours > hoursUntilDeadline) {
      throw DeadlineRestrictionException(
        'Not enough time to complete this task. You already have $totalExistingHours hours of tasks before this deadline.',
      );
    }

    // if everything is fine, create a new todo
    _storage.save(todo);
    // and notify the latest data to all the dependent ViewModels
    final allTodo = _storage.fetchAll();
    _todoController.add(allTodo);
  }
}

なお、コードの詳細はこの記事ではあまり気にする必要はありません。あくまで「こんな処理をしているっぽい」だけ読み取っていただければ十分です。

さて、このようにビジネスロジックを Flutter に依存せず Dart のみで実装することで、テストのしやすさが向上します。

先述の通り、フレームワークやプラットフォームの影響を受ける UI はテストコードにおいても扱いが複雑になります。言い換えると、widget test や integration test よりも unit test の方が扱いやすいと考えられます。

ビジネスロジックは 100% Dart で書かれていますので、 unit test でテストでき、少ない工数で重要な部分を効果的にテストできる ようになります。

テストコードが書けると、副産物として ソフトウェアの使用や挙動が可視化されます

テストコードのタイトルや説明には「どのような場合に」「どんな操作を呼び出したら」「どうなる」がまとめられていますので、たとえ仕様を別ドキュメントにまとめてメンテナンスし続ける工数がとれないプロジェクトであっても、動作確認のために作成したテストコードを代わりに参照することである程度は仕様を把握できるようになるでしょう。

void main() {
  group('test for adding todo feature', () {
    group('test deadline restrictions', () {
      test('should avoid adding todo when deadline is too soon for estimated hours', () {
        // ...some test code
      });

      test('should throw when not enough time considering existing todos', () {
        // ...some test code
      });
    });
  });
}

ひとまず Model については以上となります。次は View をみていきましょう。

View

View の説明のサンプルとして、 TodoListView を見ていきたいと思います。

TodoListView にはそれに対応する ViewModel として TodoListViewModel が用意されています。つまり、 TodoListViewTodoListViewModel から受け取った値を表示し、また TodoListView で発生したイベントは TodoListViewModel の対応するメソッドを呼び出す形で処理します

そのため、まずは TodoListViewModel オブジェクトを保持して View - ViewModel 間でやりとりできる仕組みを考える必要があるわけですが、ここではシンプルに StatefulWidget を使おうと思います。

class TodoListView extends StatefulWidget {
  const TodoListView({super.key});

  @override
  State<TodoListView> createState() => _TodoListViewState();
}

class _TodoListViewState extends State<TodoListView> {
  late final TodoListViewModel _viewModel;

  @override
  void initState() {
    super.initState();

    // instantiate and hold an instance of ViewModel
    _viewModel = TodoListViewModel(
      // retrieve a model instance from the widget tree 
      ModelProvider.todoModelOf(context),
    );
  }

  @override
  Widget build(BuildContext context) {
    // ...build UI with ViewModel
  }
}

ViewModel がひととおりの状態管理やイベント処理をしてくれるのであれば、View 自体は StatelessWidget でも良さそうな気がしますが、Flutter において Widget はリビルドに応じて何度も破棄と生成が繰り返されるため、Widget のフィールドに ViewModel の参照を保持しておくことはできません。また初期化処理をするタイミングも StatelessWidget では提供されません。

そのため、StatefulWidget と対応する State クラスに TodoListViewModel を保持するフィールドを用意し、さらに initState() で初期化を行うというやり方をとっています。5

また、MVVM ではひとつの Model オブジェクトを複数の ViewModel が使い回しすることが想定されています。

get_it などのパッケージを使うこともできますが、今回は極力標準の API のみで実装しながら考えたいため、 ModelProvider という InheritedWidget を用意して、各画面ではそこから ModelProvider.todoModelOf(context) のような形で取得できるようにしてあります。

View には UI を構築する以外の一切のコードを記述しません。ViewModel から取得した値をそのまま表示に利用し、イベントは可能な限り ViewModel のメソッドで処理します。それによって どのような場合にどのような UI が表示されるのか、特に Flutter においては Widget ツリーの形や上下関係がどうなるのかを把握しやすくなる メリットが生まれます。

@override
Widget build(BuildContext context) {
  return ValueListenableBuilder<TodoViewState>(
    valueListenable: _viewModel,
    builder: (context, state, _) {
      // ViewModel exposes .filteredTodos state and View just use it
      final todos = state.filteredTodos;

      return // ...build UI with todos
        // ...some more code here...

        IconButton(
          icon: Icon(
            // ViewModel exposes .showCompleted state and View just use it
            state.showCompleted
                ? Icons.check_circle
                : Icons.check_circle_outline,
          ),
          // ViewModel exposes .showCompleted state and View just use it
          tooltip: state.showCompleted
              ? 'Hide completed tasks'
              : 'Show completed tasks',
      );
    },
  );
}

ViewModel からのデータの取得と変更の検知には ValueListenableBuilder を利用しています。これにより、(詳細は後述しますが)ViewModel が保持する「View がそのまま使えば良い値」を state という引数で受け取れるだけでなく、その state に変化があればそれを検知して自動でリビルドが発生する仕組みが作れます。

ユーザー操作に対する処理のサンプルコードは以下のようになります。

IconButton(
  // View just calls ViewModel's method when the interaction happens
  onPressed: _viewModel.toggleShowCompleted,
);

以上のような工夫により、View のコーディングは状態として受け取る値がどのような流れで生成されるのかを気にすることなく、デザイナーと連携しながらデザインの実現に集中できるというわけです。

ViewModel

TodoListView とペアになる ViewModel が TodoListViewModel です。

このクラスでは

  • Model から取得したデータとユーザー操作で一時的に発生したデータをマージして View が表示すべき値を生成する
  • ユーザー操作の処理方法を実装する
  • 初期化時に必要に応じて必要な Model のデータを Stream で監視する

View が表示すべき値は ViewModel のフィールドにひとつずつ定義する方法もありますが、今回は TodoListViewState という immutable なオブジェクトにひとまとめにする形で定義します。ここに定義されたフィールドと getter が「UI に影響を与える値」ということになります。

class TodoListViewState {
  TodoListViewState({
    required this.todos,
    required this.showCompleted,
  });

  final List<Todo> todos;
  final bool showCompleted;

  List<Todo> get filteredTodos =>
      showCompleted ? todos : todos.where((todo) => !todo.isCompleted).toList();
}

Dart ではフィールドの値が変わったことを検知する仕組みが言語レベルでは用意されていませんので、フィールドが増えれば増えるほど「更新の際は忘れずにリビルドが発生するように 注意して コーディングする」ことを余儀なくされます。ValueNotifier を使って immutable なオブジェクトをひとつだけ .value に保持する仕組みにすることで、「リビルド無しで値だけが変わる」ことを仕組みとして防ぐ狙いです。

class TodoListViewModel extends ValueNotifier<TodoListViewState> {
}

先述の通り、ValueNotifierChangeNotifier で ViewModel を定義しておくことで、View では ValueListenableBuilder を使ってデータの更新を自動的に検知してリビルドを発生させられます。これにより、データの更新と UI の更新を過不足なく行えるというわけです。

ViewModel は ViewModel で、Model が保持する値の変更を初期化時に監視し、変更があればそれを .value に代入して変更を View に伝えます。

class TodoListViewModel extends ValueNotifier<TodoListViewState> {
  TodoListViewModel(this._model)
      : super(TodoListViewState(todos: [], showCompleted: true)) {
    // start listening to the data changes from Model
    _model.todoStream.listen((todos) {
      // update TodoListViewState when the data is changed
      value = value.copyWith(todos: todos);
    });
  }
}

TodoListViewModel はユーザー操作によるイベントも処理します。

class TodoListViewModel extends ValueNotifier<TodoListViewState> {
  void deleteTodo(String id) {
    _model.deleteTodo(id);
  }

  void toggleShowCompleted() {
    value = value.copyWith(showCompleted: !value.showCompleted);
  }
}

処理するとは言っても、基本的には 「どの Model をどの順番でどのように呼び出すか、もしくは呼び出さないか。」を判断するだけ です。

具体的には、削除や変更などのデータの更新を必要とする処理は Model を呼び出す形で実現しますし、ユーザーの入力に伴うボタン活性化の判断などは ViewModel の中で完結させて結果を TodoListViewState に保持させます。

Martin Fowler の "Presentation Model" の説明では「ViewModel の変更を View に同期する」部分が悩ましいという記述があり、また Microsoft はこれを「双方向バインディング」というフレームワークの機能で解決しましたが、Flutter は Flutter で「宣言的な UI 構築」という考え方で状態と UI を同期する仕組みを実現していますので、それに乗るのが良いでしょう。

このような作りにすることにより、ViewModel を俯瞰すればその画面が

  • その画面が何のデータに依存して UI が変化するのか
  • その画面で発生しうる処理は何か

を一覧できるのも ViewModel にまとめるメリットです。

以上、これで M/V/VM の一通りの流れが完成しました。

結局 Flutter で MVVM ってどうなの?

ここまで ViewModel とはどのようなものか、Flutter でそれを実現するとどのようなコードになるのかを確認しました。最後に 「結局 Flutter に MVVM は合うのか」 について考えてみたいと思います。

MVVM のメリットについてはここまで記載した通りで、たしかに Flutter でも GUI アプリケーションでよく発生する課題を解決することはできます。ただし、「Flutter らしいやり方」を追求すると話はさらにシンプルに、無駄なくできる場合が少なくなさそうです。

たとえば MVVM パターンではデータベースから取得した値をまず Model に保持し、さらにそれと同期する形で ViewModel が保持し、ViewModel の変更を View が同期することで、「データの不整合」の問題を解決しています。

しかし、Flutter では同じ課題を解決する標準的な方法として、InheritedWidget で管理する "app state" と、StatefulWidget で管理する "ephemeral state" という考え方を元にした状態管理の仕組みが用意されています。

つまり、自分で仕組みや実装を考えずとも、同じ課題の解決方法をフレームワークが提供してくれていて、さらにそれを最大限活かして効率的な UI の描画が実現できるように BuildContext やリビルドなどの周辺の仕組みも整えられています。

Flutter のエコシステムとしても、InheritedWidget を改善する providerriverpod, bloc などのパッケージが、また StatefulWidget を代替する flutter_hooks というパッケージが作られています。

「Flutter に依存しない役割分担」をしたければ riverpodbloc などは「Flutter に依存しない部分」を別パッケージで切り出していますので、それを活用する方法も検討できます。

そして、このようなフレームワークの仕組みやサードパーティのパッケージは必ずしも MVVM の上で使われることを想定したものではない ことは必ず考えなければなりません。

たしかにこれらの仕組みを無理やり MVVM の仕組みに当てはめることはできるでしょうが、それぞれのパッケージにはそれぞれに想定する使われ方があり、無理に MVVM の考え方に当てはめずとも同じ課題が解決できる場合が少なくありません。

たとえば Riverpod はひとつひとつの「状態」に着目して Provider を細かく作ることを想定していて、View では都合に応じて必要な Provider を柔軟に選びながら UI を構築できるようになっています。さらに ref.watch() の仕組みのおかげでデータが変化した場合の UI 更新をわれわれが気にしてコーディングする必要もありません。

ViewModel が解決しようとした「View からのロジックの分離」についても、これもやはり StatefulWidgetState に適宜「処理を担当する」別オブジェクトを持たせて適宜 setState() を呼び出すだけで事足りる場合もあります。状態と UI の同期はフレームワークが効率的にやってくれます。

これは MVVM パターン自体が力不足という話ではなく、Flutter とそのエコシステムに則ればもっと適したやり方がいろいろあるはず というのがこの記事における結論です。

まとめ

以上、この記事では Presentation Model パターンを参照しながら Flutter アプリを作ってみることで、いわゆる MVVM が Flutter アプリ開発においてどの程度適しているのかを検証してみました。

結論として、MVVM パターンをそのまま適用することが最適と言える状況は多くないのでは と、この記事を書き終えた時点では考えています。

MVVM パターンを含めたアーキテクチャパターンが どのような課題を想定してどのように解決しようとしているのかを把握し、プロジェクトごとの状況を踏まえながらその仕組み取捨選択したり独自で調整することこそが大事 で、「MVVM パターンをどのように実装するのが正しいか」に固執する必要はないと考えています。6

ViewModel をどのような単位で作るのか、Model と ViewModel はどのようにやりとりするのか、Model 内部の設計はどうするのか、など、実際の設計ではさらに具体的な検討が求められます。そして、そこに対する具体的な解答は誰も提供できません。UI やビジネスロジックの複雑さやメンバーの技術的な背景など、プロジェクトの要件が変われば最適なやり方も変わって然るべきです

MVVM のアイデアから参考にできる部分は参考にし、足りない部分は他をあたりながら考え、個々のプロジェクトに最適なアーキテクチャを考えるのがわれわれソフトウェア開発者の仕事なのかなと思います。

アプリ全体のコードは以下に公開していますので、もしご自身で機能追加したりパッケージを導入してリファクタリングしたりしてみたい場合は Folk して使ってみてください。

  1. とはいえ、システムによってはこの「ビジネスロジック」自体はバックエンドがすべて行うということも珍しくないと思います。そのような場合、そもそもこの「Model を独立させる」必要があるかどうかから議論が必要です。その意味でも MVVM のようなパターンを適用するのが適切かどうかはプロジェクト次第です。

  2. 繰り返しですが、アーキテクチャにおいて大事なのは「用語の正確な定義」ではなく「発想の正確な理解」です。発想としては "Presentation Model" も、それを WPF に落とし込んだ "MVVM" もあまり違いはありませんので、この記事では認識のしやすさを優先して "ViewModel" と呼んでいます。

  3. 逆に、なんの工夫もしていないカウンターアプリのような小さなアプリケーションで「データの整合性」を気にする必要はほとんどないはずです。

  4. この仕様と実装については、細かく考慮するともっといろんな場合が発生しますが、いったん話をシンプルに保つために見逃してください。逆にいうと、ちゃんと作る場合は Model に着目してさえいればルール自体が適切かどうか、それが適切に実装されているかどうかが判断できるといえます。

  5. これはあくまでパッケージを使わない、Flutter 標準の Widget を使った際に考えられる方法のひとつです。パッケージの利用を前提とするならば、たとえば flutter_hooksuseMemoized() を使うなどの方法も考えられそうです。

  6. いずれにしても、MVVM も Presentation Model も「Flutter でどう実装するか」という具体的なコーディングルールについては当然のことながら何も言及していませんので、自然とプロジェクトごとの最適な実装方針は考えざるを得ないと思いますが。

27
12
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
27
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?