FlutterにRiverpod用アーキテクチャを適用する

 ・ 8 min

photo by Danielle Stein on Unsplash

Riverpodを使ってみよう!#

Flutterには状態管理パッケージが複数あります。その中でもRiverpodというパッケージを好んで使っています。人気が高く使いやすく作られていて、個人的にRemi Rousseletのファンなので使いたかったのです。Blocは作成するファイルが多すぎて不便に見えました。

image

今回は状態管理を適用したアプリアーキテクチャをFlutterプロジェクトに適用する方法を調べて、個人プロジェクトに適用してみようと思います。

アプリアーキテクチャを適用する際に自然とフォルダ構成も決める必要がありますが、これはAndrea Bizzottoヘビプラン(尊敬するオンラインFlutter講師の方です!)の方式を参考にすることにしました。

image

アプリアーキテクチャフォルダ構成はAndreaの方式を使い、状態管理はRemiが作ったRiverpodを使い、ヘビプランさんのRiverpod講座の内容の一部を参考にして作ろうと思います。そして、Riverpodをcode generationを通じて作る予定です。

状態管理をうまく使いたくていろいろな記事を読みましたが、それでもどうすればうまく使えるのか分かりませんでした。そこで、文章にまとめながら頭と手に覚えさせようと思います。

状態管理には特定の状況で使われる構文やproviderがあります。そのため、どの状況で何を使えばいいのか、初心者の立場では戸惑うことが多いと思います。

一緒に使うパッケージは以下の通りです。

  • flutter_riverpod
  • riverpod_annotation
  • riverpod_generator
  • build_runner
  • custom_lint
  • riverpod_lint
  • freezed

Providerの種類と使用するprovider#

Riverpodサイトでは、providerごとに古い内容が含まれている可能性があるという警告があるため、誤った情報がある可能性もあります。

Provider Type Provider Create Function Example Use Case
Provider 特定の型を返す サービスクラス / 計算されたプロパティ(フィルタリングされたリスト)
StateProvider 特定の型を返す フィルタ条件 / 単純な状態オブジェクト
FutureProvider 特定の型のFutureを返す API呼び出し結果
StreamProvider 特定の型のStreamを返す APIの結果stream
NotifierProvider (Async)Notifierのサブクラスを返す Interfaceを通さないと変更できない複雑な状態オブジェクト
StateNotifierProvider StateNotifierのサブクラスを返す Interfaceを通さないと変更できない複雑な状態オブジェクト。NotifierProviderの使用を推奨
ChangeNotifierProvider ChangeNotifierのサブクラスを返す 可変性を要求する複雑な状態オブジェクト

Providerの具体的な使用例

  • 計算のキャッシュ
  • 他のプロバイダー(Repository、HttpClient)に値を公開する場合
  • テストやウィジェットが値をオーバーライドする方法を提供する場合
  • selectを使わずにproviders/widgetsの再構築を減らす必要がある場合

NotifierProvider/AsyncNotifierProviderの具体的な使用例

  • カスタムイベントに反応した後、時間の経過とともに変更される可能性のある状態を公開する場合(loading、error、successなど)
  • 一部の状態(ビジネスロジックとも呼ばれます)を変更するためのロジックを一か所に集約して、時間の経過とともにメンテナンス性を向上させる必要がある場合

StateNotifierProviderの具体的な使用例

  • カスタムイベントに反応した後、時間の経過とともに変更される可能性のある不変の状態を公開する場合
  • 一部の状態(ビジネスロジックとも呼ばれます)を変更するためのロジックを一か所に集約して、時間の経過とともにメンテナンス性を向上させる必要がある場合
  • このproviderの代わりに(Async)NotifierProviderを使用することが推奨されています

FutureProviderの具体的な使用例

  • 非同期操作の実行とキャッシュ(例:ネットワークリクエスト)
  • 非同期操作のエラー/ローディング状態を適切に処理する場合
  • 複数の非同期値を別の値に結合する場合
  • FutureProviderはユーザーインタラクション後に計算を直接変更する方法を提供しません。シンプルなユースケース向けに設計されています。AsyncNotifierProviderの使用を検討してください。

StreamProviderの具体的な使用例

  • FirebaseやWebSocketのリッスン用途で使用する場合
  • 数秒ごとに別のproviderを再構築する場合

StateProviderの具体的な使用例

  • filter typeのようなenum
  • 文字列(一般的にテキストフィールドの生のコンテンツ)
  • チェックボックス用のboolean
  • ページネーションや年齢フォームフィールド用のnumber

StateProviderはこのような場合には使うべきではありません

  • バリデーションロジックが必要なstateの場合
  • 複雑なオブジェクトのstateの場合(例:カスタムclass、list、mapなど)
  • count++より高度なstateの変更ロジックの場合

StateProviderは主にUIによる単純な変数変更を許可するために存在します。
APIを介さない場合はStateProviderまたはNotifierProviderを使用するのが良いでしょう。NotifierProviderよりStateProviderの方がシンプルに設計されているため、特定のロジックを処理する必要がある場合はNotifierProviderの方が良い選択肢かもしれません。
NotifierProviderを使用する方がメンテナンスしやすく、stateのビジネスロジックを一か所に集中させることができます。

ChangeNotifierProviderの具体的な使用例

  • providerパッケージからChangeNotifierProviderを使用していて、riverpodパッケージに移行する場合
  • 不変状態であるべきだが、可変状態をサポートする必要がある場合
    Riverpodはこのproviderの使用を推奨していません。NotifierProviderを使用してください。
    可変状態が必要だと確信がある場合にのみ、ChangeNotifierProviderの使用を検討してください。

各providerのユースケースを見てきましたが、一般的なAPI呼び出しのほとんどの場合にはNotifierProviderかAsyncNotifierProviderを使用することが分かりました。
まずNotifierProvider/AsyncNotifierProviderで作っておいて、小さい場合はProviderやStateProviderを使い、非同期処理が必要な場合はFutureProviderやStreamProviderの使用を検討すれば良さそうです。

今回このように整理したことで、どの状況で何を使えばいいのか分かるようになりました。
ところで、Flutterではproviderをcontroller、service、repositoryクラスをシングルトンとして使用する場合にも使っています。では、この場合はどのproviderを使うのが良いでしょうか?

  • シンプルな場合はProviderを使用するのが良いでしょう。
  • APIを通じて取得する場合はAsyncNotifierProviderを使用すれば良いです。
  • APIは使わないが少し複雑な場合はNotifierProviderを使用すれば良いです。

フロントエンドでもバックエンドでもビジネスロジックの処理は重要です。Riverpodで言うビジネスロジックとは、ネットワークリクエストを実行することを一般的に「ビジネスロジック」と呼びます。Riverpodではビジネスロジックが「provider」の内部に配置されます。


Riverpodのproviderを見てきたので、次はプロジェクトのフォルダ構成を見ていきましょう。

Andreaが書いたFlutter App Architecture with Riverpod: An Introductionを見ると、一つのfeatureはpresentation、application、domain、dataというフォルダを一つずつ持ちます。フォルダ構成を組むにはまずアプリアーキテクチャを理解する必要があり、そうすればなぜこのようなフォルダ構成になるのか分かるでしょう。そして、このようなアーキテクチャをFeature-First Architectureと呼びます。

アプリアーキテクチャ#

これはAndreaが複数のアーキテクチャを混ぜたり取り入れたりした感じのアーキテクチャですが、Flutterには別途確立されたアーキテクチャがないため、これを活用してみようと思います。

image

feature#

featureの定義は組織によって異なりますが、一つの機能または領域として捉えることができます。
featureフォルダはlib/src/featuresに作成します。
lib/src/features/feature1..100(特定の名前に変更する必要があります)
lib/src/features/feature1をauth(authentication)と仮定しましょう。
例えば、lib/src/features/authフォルダにはpresentation、application、domain、dataフォルダがあります。

featuresフォルダに入る例

‣ lib
	‣ src
		‣ features
			‣ account
			‣ admin
			‣ checkout
			‣ leave_review_page
			‣ orders_list
			‣ product_page
			‣ products_list
			‣ shopping_cart
			‣ sign_in

presentation#

presentationと類似する概念 = pages, views, screens, ui, widgets
ユーザーインターフェース(UI)に関するコードを担当します。状態に応じてUIコンポーネントをレンダリングします。
ログイン画面、会員登録画面、パスワード変更画面など、認証機能に必要なすべてのUI要素をここに実装します。
UIコードはstate変数を使用してRiverpod Providerを通じて他の領域と通信し、更新されます。

presentationフォルダに入る例

# lib/src/features/entries フォルダ例
‣ entries
	‣ presentation
		‣ entry_screen
			‣ entry_screen.dart
			‣ entry_screen_controller.dart
			‣ entry_screen_controller.g.dart
		‣ entries_screen.dart

Controllerにはserviceが注入されます。通常AsyncValueを活用します。
controllerと類似する概念 = state providers, blocs, cubits
もしくはControllerをriverpod classで作ることもあります。

ControllerにはUIが入ってはいけません。またUIにはロジックが入ってはいけません。

application#

applicationと類似する概念 = services, app logic
ビジネスロジックを担当します。状態を管理します。
ログイン、会員登録、パスワード変更など、認証機能に関するビジネスルールをここに実装します。
ビジネスロジックコードはuse cases関数を使用してRiverpod Providerを通じて他の領域と通信し、更新されます。

applicationフォルダに入る例

# lib/src/features/entries フォルダ例
‣ entries
	‣ application
		‣ entries_service.dart
		‣ entries_service.g.dart

serviceで必要なrepositoryはproviderで生成される時に注入されます。
この時ref.watchで渡します。もしくはrefだけ渡して関数内でref.readで都度呼び出して使用しても問題ありません。

Application Layerがすべてを仲介する役割です。

  • ウィジェットの状態管理と更新は気にしません。-> Controllerの役割
  • データの構文解析とシリアライズ -> Repositoryの役割

domain#

エンティティ、ドメインモデル、ビジネスルールなどを定義するコア領域です。
認証機能に関連する概念とモデルをここに定義し、コードの抽象化レベルを高めて再利用性を向上させます。
data層から出たデータを変換する役割を担当します。

domainフォルダに入る例

# lib/src/features/entries フォルダ例
‣ entries
	‣ domain
		‣ daily_jobs_details.dart
		‣ entries_list_tile_model.dart
		‣ entry.dart
		‣ entry_job.dart

data#

APIとやり取りしてデータの保存と読み込みを担当します。
ローカルストレージ、API、ネットワークなどを通じて認証関連データを保存・取得します。
データ読み込みコードはrepositories関数を使用してRiverpod Providerを通じて他の領域と通信し、更新されます。
保存時にlocalに記録するかremoteに記録するかは状況によって異なります。

dataフォルダに入る例

# lib/src/features/entries フォルダ例
‣ entries
	‣ data
		‣ entries_repository.dart

その他のフォルダ#

lib/srcフォルダにはfeatures以外にもさまざまなフォルダがあります。

common_widgets#

共通で使用されるウィジェットをまとめて定義するフォルダです。
アプリ全体で繰り返し使用されるボタン、テキスト入力フィールド、ローディングバーなどのウィジェットをここに実装し、コードの再利用性を高めて一貫性を維持します。
基本的にFlutterが提供するウィジェットをラップしたり組み合わせて新しいコンポーネントを作ります。

constants#

アプリで使用されるさまざまな定数を定義するフォルダです。
カラーコード、フォントスタイル、APIアドレス、バージョン情報などをここに保存し、コードの可読性を高めて変更管理を容易にします。
定数値を変更するとコード全体に影響するため、慎重に管理する必要があります。

exceptions#

アプリの実行中に発生する例外状況を処理するフォルダです。
例外発生時にエラーメッセージと復旧方法を定義し、ユーザーに明確な情報を提供して安定したアプリ動作を維持します。

localization#

アプリを多言語環境に対応させるための文字列を管理するフォルダです。
国や言語ごとに文字列リソースファイルを作成・管理し、ユーザーに適切な言語でアプリを提供します。

routing#

アプリ内の画面遷移を管理するフォルダです。
各画面遷移パスを定義しナビゲーションロジックを実装して、ユーザーがアプリを自然に探索できるようにします。
プロジェクトではgo_routerにriverpodを組み合わせた形で使用します。

utils#

アプリ開発に便利なさまざまなユーティリティ関数をまとめて定義するフォルダです。
ウィジェットではなく関数が主にまとまっています。

全体のフォルダ構成#

上記の内容を一つずつ説明しました。全体的なフォルダ構成は以下のように作ります。

‣ lib
	‣ src
		‣ common_widgets
		‣ constants
		‣ exceptions
		‣ features
			‣ address
				‣ application
				‣ data
				‣ domain
				‣ presentation
			‣ authentication
			‣ cart
			‣ checkout
			‣ orders
			‣ products
			‣ reviews
		‣ localization
		‣ routing
		‣ utils

必ずしもこの構成が正解というわけではありませんが、ただ作るよりもFlutterに長けた人のフォルダ構成を踏襲してみたかったので適用してみようと思います。もっと簡単で良いアーキテクチャが出てきたら教えてください! 適用しながら学んでいかなければと思います。

image

アプリアーキテクチャのフローはこのようになっています。すべてはユーザーの入力と行動によって変わります。
矢印を見ると単一方向です。

  • Presentation Layer -> Application Layer -> Data Layer に行く場合
  • Application Layer -> Domain Layerに行く場合

今回の記事はあちこちに散らばっていた情報をシンプルにまとめて整理したものです。Andreaのブログにはたくさんの記事があります。
すべて読んでも、サンプルや必要なものは有料コースに分けられているため、この記事を読んでGPTや他のGitHubコードを見ながら学ぶしかないと思います。それでも読みたい方のためにリンクをまとめておきました。

そして今回の記事にはコードは書いていません。このように文章で整理しましたが、実際にうまく扱えているわけではなく、実際のプロジェクトで適用しながら試す必要があるため書きませんでした。

あるロジックがPresentation層に属するのかApplication層に属するのかを判断する基準となる例をお伝えします。チームがGUIをサポートしないことになり、FlutterではなくCLIに移行することにしたと想像してみてください。この場合、理想的にはすべての変更はUI層で行われるべきで、Application層はまったく変更されないはずです。
PresentationではWidgetとControllerを使っているので、Application層とData層だけでCLIプログラムを作れるべきです。


The wisdom of the wise, and the experience of ages, may be preserved by quotation.

— Isaac D'Israeli


他の投稿
インターネットとは何でしょうか? 커버 이미지
 ・ 1 min

インターネットとは何でしょうか?

データ暗号化 커버 이미지
 ・ 1 min

データ暗号化

フロントエンドにおける入力検証とは? 커버 이미지
 ・ 1 min

フロントエンドにおける入力検証とは?