実用的なFlutter/MVVMの考え方・作り方 (Riverpod)
私はFlutterアプリ開発を行っていますが、バックエンド主戦場のエンジニアにもFlutter機能開発を手伝って欲しいということになり、社内向けに羅針盤的なオンボーディングドキュメントを作成しました。
内容を改めて確認してみると、意外とWeb上や生成AIの知識として存在している断片的なWhatの知識と、実務で必要な必要なHow, Whyの差分を埋める一般向けの良い資料になるのでは?と感じたため公開します。
前提と使い方
本稿は、新規画面追加や機能修正の際の方向性を示すモノです。
そのため、前提としてアプリの大体のセットアップは既に完了しているとします。
また、あくまで開発の方向性を示すものなので、個別のWhatの技術的詳細には触れません。本記事だけで完全に実装までサポートできるわけではないので、適宜他の記事をご参照ください。
たとえばエントリポイントの整備や
MaterialApp
の設定などは終わっているものとし、パッケージマネージャpub
の使い方や 各ウィジェットの詳細な使い方も説明しません。
使い方としては、実装に必要な技術的詳細の知識(What)と、それを実際に利用するために必要な考え方との差分を埋めるためのあらゆる用途が考えられます。たとえば、記事自体を読者自身が頑張って理解するのも良いですが、生成AIに記事全体を読ませて、出力の精度を上げたりコンテキストに富む質問をするなどの使い方も考えられます。
対象読者としては次のようなケースを想定しています。
- 画面全体の状態管理に
Riverpod
を用いたMVVMアーキテクチャでFlutterアプリの画面を開発している方画面全体の状態管理に組み込みの
StatefulWidget
オンリーで開発されているケースやflutter_hooks
やfluxベースのデザインパターンを使われているケースでは若干馴染みのないやり方になるかと思います。ただし、個別の子ウィジェットのローカルステート管理(↔画面全体のグローバルステート管理)にStatefulWidget
やflutter_hooks
を使っているだけなら問題ありません。 - 既存のFlutter開発PJに新たに加わることになったが、どこから手をつければいいのかわからない
- 新規アプリ開発をしたくて、モバイルに向いたMVVMアーキテクチャを勉強しているが、Webの感覚との差分が埋められない
- Flutterの豊富な知識をもつが、求めるコードとAIの出力するコードの雰囲気にギャップを感じる
以下、本編です。
RiverpodやFreezedを用いたMVVMアーキテクチャを用いて1画面を実装する場合、大雑把には以下のような流れが標準的である。
lib/screen
以下に、ページ単位でディレクトリ作成。
1. ページ用ファイル、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
ConsumerWidget
を継承したクラスを作成。
2. ページのメインファイルに build
メソッドでは Scaffold
を一番親にして、appBar
や body
を設定すると作りやすい。
何らかの値を受け取るには、コンストラクタ引数を使うのが一番カンタン。Reactでいうprops
みたいな感じであらゆるデータを渡せる。また、後述のConsumerWidget
を用いると Provider
経由で非同期にフェッチした値を取得する、といったことも可能である。
これらの使い分けについて: 画面全体ではある程度、グローバルステートに依存するのは仕方ない。しかし、個別のコンポーネント化された子ウィジェットたちは、できるだけステートレスにprops
を受け取るだけで、親コンポーネントが必要なものを注入するような設計が望まれる。
Q. Scaffold
は何をしてくれるの?
A. 「はしご」のように、画面全体の骨格を作ってくれる。
これを用いると、上部に固定のバーがあって、その下にメインコンテンツを表示する…というような典型的なスマホアプリの1画面を簡単に作ることができる。
なお、Scaffold
を親にせずとも、たとえば Button
とかで1ページを為すことも可能ではある。FlutterではScaffold
もButton
も、どちらも同じ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を使って画面全体を構築して、状態を購読する際に用いる標準的な方法である。
ConsumerWidget
はStatelessWidget
の進化版で、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
には様々なバリアントが定義されている。
- ステートの初期化タイミングがインスタンス化時のみ →
Async
やStream
なら非同期に対応 - 個別の引数を渡すことができない →
Family
なら引数を渡せる - ページ移動等で必要なくなったときに自動で破棄(ガベージコレクション)されない →
AutoDispose
なら自動破棄
3.2.1. ViewModelのベースクラスの使い分け
AsyncNotifier
やStreamNotifier
は、非同期処理をうまく取り扱うためのバリアントである。
Async
はFuture
値のラッパーで、一回限りのデータフェッチを扱う。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
でなくても、手動でライフサイクルを管理することはできる。それ用のメソッドがあったはず。でもあんまり使わない。
◯◯Notifier
専用のProvider
でインスタンスをラップする:
3.3. 上記で選んだStateNotifier
やAutoDisposeFamilyAsyncNotifier
など、様々な種類の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
とあわせて、スクロール可能な画面の骨格を作っておくとやりやすい。
Column
やRow
は、子要素を複数配置するのを可能にする。
通常のほとんどのウィジェットがchild: Widget
を受け取るのに対して、Column
やRow
はchildren: List<Widget>
を受け取る。
Column
は縦方向に、Row
は横方向に子要素を配置する。mainAxisAlignment
やcrossAxisAlignment
で、子要素を何に沿って配置するか (例: 上寄せ、下寄せ、中央寄せ) を指定できる。
Container
は Webでいうdiv
のようなもので、それ自体は意味を持たない。しかし、コンストラクタで引数を与えてあげれば、ある程度スタイル調整することもできる。
その他、Padding
, Margin
, SizedBox
, Align
, Center
などは、それ自体でスタイル調整の意味をもつウィジェットである。WebでいうならCSSつきのdiv
とでも言えるだろうか。
UI要素としては Text
, TextButton
, や、Image
など様々なものが用意されている。
webでは、あらゆる要素にonClick
などイベントハンドラを定義できるが、Flutterではできない。ゆえに、TextButton
のonPressed
のように、イベントハンドリングできるウィジェットを親とし、その子要素としてText
など表示ウィジェットを配置することで、イベントハンドリングを実現する。
Webのようなイベントキャプチャ・バブリング等の柔軟性はない一方、イベントの取り扱いが抽象化され、扱えるウィジェットだけがハンドルできる、という点ではむしろシンプルになっているともいえる。
また、Flutterデフォルトの TextFiled
やCheckbox
だけでなく、複数の入力フォームの一元管理をしたい場合には、reactive_forms
を使うケースもある。
ReactiveForm
を使う際にはいくつかルールがある(親にReactiveForm
を配置してから使うとか。)ので別途調査されたい。
また、ローカルなコーディング規約としても、できる限り型安全に扱えるように、直接FormControl
名を受け渡すようなやり方は避け、ViewModelに定義されたgetterを介してアクセスすることが望ましい。比較的新しいページはすべてこの方針で実装されているが、前の担当者の方が主だって開発されていた部分は、それが行われていないケースもあるので注意せよ。
さらに発展的な高難度な部分について
SingleChildScrollView
やListView
など複数要素の配置用ウィジェットでは、「無限の縦のサイズを要求する」など、各ウィジェットの描画占有領域の管理が少し大変である。Expanded
やFlexible
, SizedBox
等を用いて、適切に領域の制約を与えることが重要である。
またListView
はshrinkWrap
やphysics
などのオプションを用いて、振る舞いを大きく変えることができる。ここで書くには難しすぎる内容なので、適宜生成AI等を参照されたい。
BuildContext
のライフサイクル管理は、ポップアップ(showDialog
/showCupertinoDialog
)やモーダル(showModalBottomSheet
)、ページ遷移(Navigator
)を実装するうえで非常に重要なコンセプトである。
BuildContext
とは、ウィジェットツリー内での特定ウィジェット(ノード)への参照のようなものであり、そのウィジェットが「今どこにいるか」を表す。
参照先ウィジェットと同じライフサイクルを共有するので、参照先ウィジェットが破棄されたのにBuildContext
を呼び出そうとするとエラーになる。そのため、非同期呼び出しの際にはリンターから「できればacross async gap
での呼び出しはやめるか、呼び出し直前にcontext.mounted
のチェックを欠かさないでね」と指摘されるのである。
で、実際のページ遷移やポップアップ表示の際には、Route
という概念が登場するので、context
に関して不整合を起こさないように取り扱う必要がある。Navigator.push
やshowDialog
やNavigator.pop(context)
などのメソッドたちは、Route
と呼ばれるスタックデータ構造に追加したり消したり、置き換えたりすることで、動作を実現する。そのさいにcontext
を間違ってpop
しすぎたりすると問題が起こるので、context
には個別に名前をつけて管理するのがおすすめ。このへんはFlutter Navigationとかで別途調べるのを推奨。
命名の例: Builder(ウィジェットを返す関数)が登場するたびに、
context
をdialogContext
やsecondDialogContext
やpageContext
などと役割に応じて命名。
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