Flutter アーキテクチャ考察
筆者の結論
小規模なら MVVM が最適
ある程度規模があるなら クリーンアーキテクチャが最適
MVVMを使用した例
Widget 層(View 層)
Widget がある層
TextEditingController のような単一の画面で完結するような状態は flutter_hooks を使用する
そのため、厳密には MVVM の View 層ではなく、ViewModel 層の一部を内包している層となる
Provider 層を ref watch して 画面更新
Provider 層を ref read してロジックの呼び出し
Provider 層(ViewModel 層)
Provider がある(Provider, StateProvider, StateNotifierProvider etc)層
画面に関する状態の保持
View 層に呼び出される命令の解釈
ref read で Model 層にロジックを委譲
Model 層
ロジックやデータ永続化を担うクラスがある場所
Provider としてインスタンスを公開
Widget 層から直接呼び出されないように注意する
Repository 層
Provider 層と Model 層のクッションとして適宜定義する
Data 層
Entity がある層
freezed パッケージを使った immutable クラスなどがここ
各層から よしなに使われる
l10n などの多言語化もここに置くけど、それは Resource とかの別フォルダを用意したほうがいいかも
https://scrapbox.io/files/638059b77860930020c00e61.png
Navigator 2.0 を触接使い、riverpod で状態を管理する場合、ページ遷移に関する状態を一度に複数変更する可能性がある。
その状態をそれぞれ listen して RouterDelegate に更新を通知すると、変更された状態の個数回 一度に更新されることになる。
setNewRoutePath における状態更新も同様。
それを防ぐために Navigation に関する状態の更新の責務を NavigateTriger に負わせる。
NavigateTriger を RouterDelegate が listen することで、ページ遷移一回に対して一回の更新で済む。
https://scrapbox.io/files/63805d33406c7e001dd29e46.png
すっきり😊
2024-03-23 更新
クリーンアーキテクチャを使用した例
ドメイン駆動開発のためのクリーンアーキテクチャを使用する。
ざっくりと以下のようなフォルダ構造にする。
code: tree
.
├── common
│ ├── extension
│ ├── exception
│ └── utils
├── infrastructure
│ ├── 他 datasource etc
│ ├── repository_impl
│ └── service_impl
├── domain
│ ├── entity(model)
│ ├── repository
│ ├── service
│ └── use_case
├── application
│ ├── use_case_impl
│ └── ACL(Anti Corruption Layer:provider/state <-> domain/entity)
├── provider
│ ├── notifier
│ └── state
└── view
├── component
├── navigation
├── page
└── theme
common
ここには全ての層で共通のファイルを置く。
extensionやexceptionなどのフォルダを置くが、要するに utils フォルダになる。
infrastructure
外部との連携部分を実装する。
例えば、DBやAPIのClientクラスなど。
domain/repository の実装は、大抵この層に配置することになる。
Repositoryはデータの永続層であり、基本的に DB や API との接続が必須になるため。
加えて domain/service の実装もここに配置されることがある。
serviceクラスは外部サービスとの連携が必要になる場合があるため。
Domain
作成するアプリの核となる部分を書く所謂ドメイン層。
ドメイン(直訳で領域)とは解決したい課題のこと(分かりづらくない?)。
例えば、「料金の出納を記録したい」といった課題のことをドメインと言う。
ドメイン駆動開発というのは、その課題を中心に考えて設計することと言える(と思う)。
domain/entity(or model)
ドメインエンティティやドメインモデルなどと言われる部分。
ドメインにおける登場人物を記述する。ユーザーやお金といったもの。
その登場人物が知りうる範囲のロジックはここに記述することになる。(オブジェクト指向の単一責任の話)
ユーザーについてであれば、以下のようなコードになる。
code: user.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:flutter/foundation.dart';
part '../../_generated/domain/entity/user.freezed.dart';
part '../../_generated/domain/entity/user.g.dart';
@freezed
class User with _$User {
@Assert('id.isNotEmpty')
@Assert('username.isNotEmpty')
const factory User({
required String id,
required String username,
}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}
ユーザー名とIDを持ち、それぞれの値は空を渡すことができない。また、Jsonに変換するロジックもここに記述されている。
より具体的に言えば値オブジェクトがどうこうありますが、freezedで書いておけばだいたいOKです。
domain/repository
データの永続化の定義をここに書く。
実装は infrastructure/repository_impl に記述する。
code: user_repository.dart
abstract class UserRepository {
Future<void> save({required User user});
Future<void> delete({required String userId});
}
domain/service
ドメインロジックのうち、ドメインモデル単体が知りえないことを service クラスとして記述する。
ドメインモデル単体が知りえる範囲のロジックはここでは書かないように注意する。
例えば、ユーザーを登録するときに、ユーザー単体は id と username しか知らないため、登録しようとしているそのユーザーと同じIDが既に登録されているかどうかを調べることができない。そんな時に service クラスとして、とある ID で既に登録されているユーザーがいるかどうかを調べるクラスを定義する。
service クラスは外部との連携が必要になる場合があり、その場合は abstract class として定義して実装を infrastructure 層に記述する場合や、infrastructure のクラスを注入(DI)して記述する場合がある。
domain/use_case
domain/use_case はそのままユースケースを表す。
domain/repository、domain/service、domain/entity を利用して、一連の処理を記述する部分。
Application
アプリケーション層。ユースケース層とも。
application/use_case_impl
アプリケーション層(ユースケース層)domain/use_case の実装をここで記述する。
application/alc
腐敗防止層と呼ばれるもの
provider/state にある UI 構築用のオブジェクトを use_case で使用できるように変換する部分。
この層によって UI と domain の完全な分離を行う。
わざわざ用意する必要がないほど変換ロジックが簡単で少ないのであれば、provider/notifier で use_case を蹴る時に変換するといい。
Provider
ViewとApplication の橋渡しを行う層
provider/notifier
Notifier を継承したクラスを定義
UseCase を注入して widget から呼び出されるメソッドを定義する。
基本的には widget が UseCase を直接呼び出すことはない。
Notifier クラスを NotifierProvider で公開し、この Provider を ref.read、ref.watch して参照する。
provider/state
画面で使用するためのオブジェクト。
多くは freezed を使用して riverpod の provider で公開し、copyWith による画面更新を行うのが楽。
ひとつのオブジェクトが持つデータの範囲はそれぞれであり、ViewModel のように実装するのであれば、画面ひとつが持つ状態をまとめて持つことになる。ただし、状態を別の widget からも呼び出す可能性があるものは、他の状態は autoDispose されても一部の値だけは残しておきたいなどがあり得るので分離しておくといいかも。
View
言わずもがな見た目部分
view/components
使いまわしのできる widget 定義
view/page
画面の構成定義
page と書いているが別に widget でもいい
筆者は MaterialPage を extends した Page クラスを定義して、そこに private widget を渡している。
使い回しのきかない画面構成に関する widget を公開するデメリットが若干あるので、そうしている。
view/navigation
RouterDelegate や RouteInformationParser といった Navigator 2.0 用のクラスや Drawer メニューなどの画面間で共通の widget 定義をしている。
provider/navigation_state_provider などを用意して、その状態によって画面遷移の状態を更新する。
画面構成に view/page を使用する。
(画面遷移は画面に関することでもありながらロジックでもある気がするので provider/notifier に書くのが適切な気もするが Widget があるなら view でもいいのかな...... みたいなふわっとした気分でここに配置されている。)
view/theme
テーマ
クリーンアーキテクチャで不満なのは、 VSCode のフォルダ並び順
本当は common -> infra -> domain -> application -> provider -> view にしたい