🎯

実用的なFlutter/MVVMの考え方・作り方 (Riverpod)

に公開

私はFlutterアプリ開発を行っていますが、バックエンド主戦場のエンジニアにもFlutter機能開発を手伝って欲しいということになり、社内向けに羅針盤的なオンボーディングドキュメントを作成しました。
内容を改めて確認してみると、意外とWeb上や生成AIの知識として存在している断片的なWhatの知識と、実務で必要な必要なHow, Whyの差分を埋める一般向けの良い資料になるのでは?と感じたため公開します。

前提と使い方

本稿は、新規画面追加や機能修正の際の方向性を示すモノです。

そのため、前提としてアプリの大体のセットアップは既に完了しているとします。
また、あくまで開発の方向性を示すものなので、個別のWhatの技術的詳細には触れません。本記事だけで完全に実装までサポートできるわけではないので、適宜他の記事をご参照ください。

たとえばエントリポイントの整備や MaterialApp の設定などは終わっているものとし、パッケージマネージャ pub の使い方や 各ウィジェットの詳細な使い方も説明しません。

使い方としては、実装に必要な技術的詳細の知識(What)と、それを実際に利用するために必要な考え方との差分を埋めるためのあらゆる用途が考えられます。たとえば、記事自体を読者自身が頑張って理解するのも良いですが、生成AIに記事全体を読ませて、出力の精度を上げたりコンテキストに富む質問をするなどの使い方も考えられます。

対象読者としては次のようなケースを想定しています。

  • 画面全体の状態管理にRiverpodを用いたMVVMアーキテクチャでFlutterアプリの画面を開発している方

    画面全体の状態管理に組み込みのStatefulWidgetオンリーで開発されているケースやflutter_hooksやfluxベースのデザインパターンを使われているケースでは若干馴染みのないやり方になるかと思います。ただし、個別の子ウィジェットのローカルステート管理(↔画面全体のグローバルステート管理)に StatefulWidgetflutter_hooksを使っているだけなら問題ありません。

  • 既存のFlutter開発PJに新たに加わることになったが、どこから手をつければいいのかわからない
  • 新規アプリ開発をしたくて、モバイルに向いたMVVMアーキテクチャを勉強しているが、Webの感覚との差分が埋められない
  • Flutterの豊富な知識をもつが、求めるコードとAIの出力するコードの雰囲気にギャップを感じる

以下、本編です。


RiverpodやFreezedを用いたMVVMアーキテクチャを用いて1画面を実装する場合、大雑把には以下のような流れが標準的である。

1. lib/screen 以下に、ページ単位でディレクトリ作成。

ページ用ファイル、ViewModel用ファイル、ViewModelが管理するState用ファイル、個別実装のコンポーネントディレクトリを作成。

# flutter/lib/screen/client_home/post_request における例
.
├── components
│   └── *component_name*.dart
├── *page_name*.dart
├── state.dart
│   └── state.dart  # build runnerを実行したら、さらにstate.g.dartが生成される
└── viewmodel.dart

2. ページのメインファイルに ConsumerWidget を継承したクラスを作成。

build メソッドでは Scaffold を一番親にして、appBarbody を設定すると作りやすい。

何らかの値を受け取るには、コンストラクタ引数を使うのが一番カンタン。Reactでいうpropsみたいな感じであらゆるデータを渡せる。また、後述のConsumerWidgetを用いると Provider 経由で非同期にフェッチした値を取得する、といったことも可能である。

これらの使い分けについて: 画面全体ではある程度、グローバルステートに依存するのは仕方ない。しかし、個別のコンポーネント化された子ウィジェットたちは、できるだけステートレスにpropsを受け取るだけで、親コンポーネントが必要なものを注入するような設計が望まれる。

Q. Scaffoldは何をしてくれるの?

A. 「はしご」のように、画面全体の骨格を作ってくれる。

これを用いると、上部に固定のバーがあって、その下にメインコンテンツを表示する…というような典型的なスマホアプリの1画面を簡単に作ることができる。
なお、Scaffold を親にせずとも、たとえば Button とかで1ページを為すことも可能ではある。FlutterではScaffoldButtonも、どちらも同じWidgetとして扱われるためそこに区別はない。
とはいえども、やはり画面全体を構成する際には Scaffold を親にしておけば間違いないので、おまじないだと思って使えば良い。

Q. 他の◯◯Widgetとの違いは
A. ウィジェット本体が状態管理するか?とRiverpod Providerにアクセスするか?で使い分ける。

Riverpod Providerにアクセスしない Riverpod Providerにアクセスする
ステートレス StatelessWidget ConsumerWidget
ステートフル StatefulWidget ConsumerStatefulWidget

StatelessWidgetは、その名の通り一切の状態をもたない。
コンストラクタで受け取った値を描画するだけである。ただし、コンストラクタでコールバック関数を受け取って子ウィジェットのonTapに登録することもできるし、child: Widgetとしてウィジェットそのもの(←コイツはステートフルでも良い)を受け取って表示することもできる。状態管理ができないオブジェクトというより、状態管理を外部(呼び出し側)に委ねるオブジェクトという理解が正確である。

StatefulWidgetは、その名の通り状態を内部に保持できる。
典型的なReactのuseStateを使ったローカルステート管理に感覚は似ている。ただし、Flutterではウィジェットが再描画されても状態を維持できるよう、WidgetそのものとStateクラスを別々に定義する必要があることに注意が必要。
ReactのuseStateで画面全体を管理しようとするのが大変なのと同じで、StatefulWidgetは画面全体の大規模な状態管理をするのには向かない。しかし、ドロップダウンの開閉状態のような、ローカルに完全に閉じており、その状態変化が画面全体には影響しないようなケースでは非常に便利である。

ConsumerWidgetは、後述のViewModelを使って画面全体を構築して、状態を購読する際に用いる標準的な方法である
ConsumerWidgetStatelessWidgetの進化版で、refを通じて種々のProviderを参照できるウィジェットである。
これを用いると、状態やビジネスロジック管理を全てViewModelにまかせて、View側は見た目だけの実装に集中できる。というのも、Providerが提供してくれる状態オブジェクトには依存しつつも、ウィジェット自体は全く状態を保持しないからである。ConsumerWidgetは、ViewModelによるステートの変更をただただ監視して、変更があれば再描画するだけである。

実際final state = ref.watch(stateNotifierProvider)のようにstateを取得し、UIはstateの値を読み出すように作る。それだけで勝手にViewとステートのデータバインディングができる

ConsumeStatefulWidgetは、めったに使わないと思って良い。
これは、StatefulWidgetの、Providerを参照できるバージョンなわけだが、せっかくViewModelが状態管理をしているのにウィジェット側でも状態管理するのは、責任の分散となって良くない。よっぽど、グローバルステートとは別に管理すべき理由があるわけでもないなら、基本的には使わなくて良い。

3. ViewModel作成

ViewModelは、状態管理および外部API呼び出しロジックなど、見た目以外の全てのロジックを担当する。

3.1. まず、状態管理のためのDTOを作成:

stateオブジェクトをfreezedで定義し、flutter pub run build_runner build --delete-conflicting-outputsでDTO生成。

※状態管理するだけなら、freezedのfromJsonは消してしまって良い。なぜならfromJsonを使うのは普通、DB永続化するときぐらいだから。永続化しないデータの塊としてなら不要である。

3.2. ViewModelクラスを定義:

状況に応じた基底◯◯Notifierを継承したクラスを作成して<画面名>ViewModelとして命名。

一番ベーシックなのは StateNotifier である。これを継承したクラスでは…

  • イミュータブルな状態オブジェクトを保持。
  • 状態管理やAPI呼び出しなどのメソッドを自由に定義できる
  • 後述のProviderを通じて、画面(View)側に状態変化(=イミュータブルオブジェクトの再代入)を通知(Notify)することができる

しかし、単なるStateNotifierには次のような制約もある。そのため、Notifierには様々なバリアントが定義されている。

  • ステートの初期化タイミングがインスタンス化時のみ → AsyncStream なら非同期に対応
  • 個別の引数を渡すことができない → Family なら引数を渡せる
  • ページ移動等で必要なくなったときに自動で破棄(ガベージコレクション)されない → AutoDispose なら自動破棄
3.2.1. ViewModelのベースクラスの使い分け

AsyncNotifierStreamNotifierは、非同期処理をうまく取り扱うためのバリアントである。
AsyncFuture値のラッパーで、一回限りのデータフェッチを扱う。Streamは名の通りStream値のラッパーであり、継続的なリアルタイムデータ更新を扱う。
画面側では、これらのNotifierが提供するステートの AsyncValue.when を通じて、loading時、data取得完了時、error時の処理をそれぞれbuilder(=Widgetを返す関数)として指定することで、画面の出し分けロジック実装をサポートする。

Family系統は、Notifierに引数を渡すことができる。
典型的には requestId: "req_001"を渡したり、等価性を実装したオブジェクト(※等価性を実装していないと、毎回別オブジェクトとして扱われ期待通りにならないので注意)を渡したりする。
基本的には、引数を渡して動的にViewModelを作成するために使うもの、と解釈して問題ない。

しかし厳密には、引数を与えるだけではなく、その引数をもとに各Notifierがハッシュマップで管理されているので、注意が必要である。たとえばrequestId: "req_001"でViewModelを作成した後で、別の場所から再度requestId: "req_001"でViewModelを作成したとする。
すると、(最初に作成されたものが破棄されていなければ) 新しいViewModelが作成されるのではなく、最初に作成されたViewModelが再利用される。つまり、引数が同じならば全く同じインスタンスがシングルトンとして返ってくることに注意する必要がある。
こうした振る舞いによる予期せぬ挙動を防ぐには、適切なライフサイクル管理が重要である。

なお、StateNotifierベースのViewModelに引数を与えたいときには、そのまま後述のProviderでだけ調整すれば良い。しかし、Async/Streamあたりを使いたい場合には FamilyAsyncNotifier 等を継承したViewModel定義が必要になることに注意せよ。

  • class FooViewModel extends StateNotifierに引数を与えたい→クラス側は何もいじらず、Provider側でfamilyを指定すれば良い
  • class FooViewModel extends AsyncNotifierに引数を与えたい→クラス側でclass FooViewModel extends FamilyAsyncNotifierとしたうえで、Providerでもfamilyを指定する

AutoDisposeは、ライフサイクル管理を自動でやってくれるバリアントである。
通常のNotifierのProviderでは、シングルトンインスタンスがアプリ起動中はデフォルトで保持される。
しかし、ページを移動するなどして、そのProviderに依存しているページが不要になったとき、それを検知して自動でインスタンスを破棄してくれる。GC(ガベージコレクション)みたいな感じである。これにより、メモリの圧迫や、予期せぬシングルトン参照問題を防ぐことができる。
AutoDisposeでなくても、手動でライフサイクルを管理することはできる。それ用のメソッドがあったはず。でもあんまり使わない。

3.3. 上記で選んだ◯◯Notifier専用のProviderでインスタンスをラップする:

StateNotifierAutoDisposeFamilyAsyncNotifier など、様々な種類のNotifierには、それに対応した Provider が用意されている。

たとえば以下のようにProviderを定義し、画面から呼び出す。
ステートは ref.watchで、ViewModel自体は ref.watchまたはref.readを用いて読み込む。
ステートは、watchで取得した値をもとにUIを構築するだけで、更新があれば自動で再描画される。ViewModel自体は、一度読み込めれば十分なので ref.read でも良いのだが、ウィジェットツリー内では特段の事情がなければref.watchを用いるのが良いのだそう

DON'T use ref.read inside the build method (See Reading a Provider | Riverpod)

final userTaskListViewModelProvider = AutoDisposeAsyncNotifierProviderFamily<
  UserTaskListViewModel,
  UserTaskListState,
  String
>(() => UserTaskListViewModel());

// 参照がなくなったら自動で破棄され(AutoDispose)引数を渡せる(Family)非同期データ(Async)を提供するViewModel
class UserTaskListViewModel
    extends AutoDisposeFamilyAsyncNotifier<UserTaskListState, String> {
  
  Future<UserTaskListState> build(String arg) async {
    final userId = arg;
    return await refresh(arg);
  }

class UserTaskListPage extends ConsumerWidget {
  final String userId;

  const UserTaskListPage({super.key, required this.userId});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final asyncTaskList = ref.watch(userTaskListViewModelProvider(userId));
    final viewmodel = ref.watch(
      userTaskListViewModelProvider(userId).notifier,
    );

    return asyncTaskList.when(
      loading: () => const Center(child: CircularProgressIndicator()),
      error: (error, stackTrace) => Center(child: Text('エラーが発生しました')),
      data:
          (state) => DefaultTabController(
            length: 3,
            child: Scaffold(
              appBar: AppBar(

4. 画面の本格実装

ChatGPT等に「Flutterで必要になる基本的なウィジェットを、役割ごとに分けて紹介してください」とでも聞けば、だいたいわかると思う。

SingleChildScrollView: 要素のスクロールを可能にするウィジェット。デフォルトでは全てのウィジェットがスクロール不可だが、これを親にするとスクロール可能になる。とりあえずColumnとあわせて、スクロール可能な画面の骨格を作っておくとやりやすい。

ColumnRowは、子要素を複数配置するのを可能にする。
通常のほとんどのウィジェットがchild: Widgetを受け取るのに対して、ColumnRowchildren: List<Widget>を受け取る。
Columnは縦方向に、Rowは横方向に子要素を配置する。mainAxisAlignmentcrossAxisAlignmentで、子要素を何に沿って配置するか (例: 上寄せ、下寄せ、中央寄せ) を指定できる。

Container は Webでいうdivのようなもので、それ自体は意味を持たない。しかし、コンストラクタで引数を与えてあげれば、ある程度スタイル調整することもできる。

その他、Padding, Margin, SizedBox, Align, Center などは、それ自体でスタイル調整の意味をもつウィジェットである。WebでいうならCSSつきのdivとでも言えるだろうか。

UI要素としては Text , TextButton, や、Imageなど様々なものが用意されている。
webでは、あらゆる要素にonClickなどイベントハンドラを定義できるが、Flutterではできない。ゆえに、TextButtononPressedのように、イベントハンドリングできるウィジェットを親とし、その子要素としてTextなど表示ウィジェットを配置することで、イベントハンドリングを実現する。
Webのようなイベントキャプチャ・バブリング等の柔軟性はない一方、イベントの取り扱いが抽象化され、扱えるウィジェットだけがハンドルできる、という点ではむしろシンプルになっているともいえる。

また、Flutterデフォルトの TextFiledCheckboxだけでなく、複数の入力フォームの一元管理をしたい場合には、reactive_formsを使うケースもある。
ReactiveFormを使う際にはいくつかルールがある(親にReactiveFormを配置してから使うとか。)ので別途調査されたい。
また、ローカルなコーディング規約としても、できる限り型安全に扱えるように、直接FormControl名を受け渡すようなやり方は避け、ViewModelに定義されたgetterを介してアクセスすることが望ましい。比較的新しいページはすべてこの方針で実装されているが、前の担当者の方が主だって開発されていた部分は、それが行われていないケースもあるので注意せよ。


さらに発展的な高難度な部分について

SingleChildScrollViewListViewなど複数要素の配置用ウィジェットでは、「無限の縦のサイズを要求する」など、各ウィジェットの描画占有領域の管理が少し大変である。ExpandedFlexible, SizedBox等を用いて、適切に領域の制約を与えることが重要である。
またListViewshrinkWrapphysicsなどのオプションを用いて、振る舞いを大きく変えることができる。ここで書くには難しすぎる内容なので、適宜生成AI等を参照されたい。

BuildContextのライフサイクル管理は、ポップアップ(showDialog/showCupertinoDialog)やモーダル(showModalBottomSheet)、ページ遷移(Navigator)を実装するうえで非常に重要なコンセプトである。
BuildContextとは、ウィジェットツリー内での特定ウィジェット(ノード)への参照のようなものであり、そのウィジェットが「今どこにいるか」を表す。
参照先ウィジェットと同じライフサイクルを共有するので、参照先ウィジェットが破棄されたのにBuildContextを呼び出そうとするとエラーになる。そのため、非同期呼び出しの際にはリンターから「できればacross async gapでの呼び出しはやめるか、呼び出し直前にcontext.mountedのチェックを欠かさないでね」と指摘されるのである。
 で、実際のページ遷移やポップアップ表示の際には、Routeという概念が登場するので、contextに関して不整合を起こさないように取り扱う必要がある。Navigator.pushshowDialogNavigator.pop(context)などのメソッドたちは、Routeと呼ばれるスタックデータ構造に追加したり消したり、置き換えたりすることで、動作を実現する。そのさいにcontextを間違ってpopしすぎたりすると問題が起こるので、contextには個別に名前をつけて管理するのがおすすめ。このへんはFlutter Navigationとかで別途調べるのを推奨

命名の例: Builder(ウィジェットを返す関数)が登場するたびに、contextdialogContextsecondDialogContextpageContextなどと役割に応じて命名。

5. 画面のつなぎこみ

作った画面も、ただそれだけではアクセスしようがない。どこかしらで子要素として描画するか、Navigator等で遷移させるのが良い。

Navigatorの取り扱いは前章でも少し触れたが、厳密な動作はやはり自身で調べていただきたい。
簡単に説明するなら、

  • ページを移動したい。戻ってこれる: Navigator.push
  • ページを移動したい。完全に戻ってこれない(history全部消す): Navigator.pushAndRemoveUntil
  • ページを移動したい。今のページだけ消すけど、それ以外には戻ってこれる: Navigator.pushReplacement
  • ダイアログを表示したい: showDialog, showCupertinoDialog (※CupertinoのほうはiOS風のUIになっている)
  • モーダルシートを表示したい: showModalBottomSheet
  • ページを戻りたい/ダイアログ等を閉じたい: Navigator.pop

    ※これを明示的に呼び出さなくても勝手にやってくれるケースもある。たとえばScaffold等を使っていると、左上に自動で< ボタンが表示され、それを押したら元ページに戻れる。またモーダル等は、表示領域外をタップしたら閉じる。これらはどちらも、内部的にNavigator.popを呼び出している。

※混乱を防ぐため補足すると、FlutterのページナビゲーションにはNavigator 1.0とNavigator 2.0(←go_routerなど)がある。本プロジェクトで使用しているのはNavigator 1.0の方なので、そこを混同しないように。

まとめ

以上のように、MVVMに基づく画面づくりの典型的な流れは次のようになる:

ファイルを作って、ConsumerWidgetでページの骨格作って、ViewModelで状態管理して、各ビルトインウィジェット駆使してデザインちゃんと作って、最後、どこからか呼び出す

これを知っておくことで、ちょっとした画面修正を行う際にも見通しが立ちやすくなるだろう。

Discussion