こんにちは、アベです
せっかくの休日、台風でアレなので記事でも書こうと思いまして。
一応Flutterメインでやってるエンジニアなのに、Flutterについて全然書いてないなと気づき、
最近僕が好んで使っているFlutterの構成について書いてみます。
前提
Flutter ver 3.0.5
アプリの要件
- トピックス一覧画面とアカウント情報表示画面に表示する
- 2つの外部サービスのAPIを利用
- サービスAのAPIがREST、サービスBがGraphQLを使用
- トピックス(記事一覧)とアカウント情報をそれぞれのAPIから取得
みたいなアプリがあったとして、それを例に下記していきます。
最低限使うパッケージの想定
dependencies
- flutter_riverpod
- flutter_dotenv
- freezed_annotation
- http
dev_dependencies
- build_runner
- freezed
- json_serializable
本題
アプリの構成について説明していきます
ディレクトリのツリー構造
こうだ!
├ config/
│ ├ .env.dev
│ └ .env.prod
├ lib/
│ ├ api/
│ │ ├ graphql/
│ │ │ └ client.dart
│ │ └ http/
│ │ └ client.dart
│ ├ models/
│ │ ├ service_a/
│ │ │ ├ topic.dart
│ │ │ └ account.dart
│ │ └ service_b/
│ │ ├ topic.dart
│ │ └ account.dart
│ ├ pages/
│ │ ├ topics.dart
│ │ ├ account.dart
│ │ └ root.dart
│ ├ view_provider/
│ │ ├ topics/
│ │ │ ├ provider.dart
│ │ │ └ provider_freezed.dart
│ │ ├ account/
│ │ │ ├ provider.dart
│ │ │ └ provider_freezed.dart
│ │ └ root/
│ │ ├ provider.dart
│ │ └ provider_freezed.dart
│ ├ provider/
│ │ ├ service_a/
│ │ │ └ provider.dart
│ │ └ service_b/
│ │ ├ provider.dart
│ │ ├ query.graphql
│ │ └ fragment.graphql
│ └ main.dart
└ Makefile
なんかこうやってディレクトリをツリーで表現するの好き
lib/配下
Flutterは好きだけど、src/じゃなくてlib/なのはいまだに謎
api/
GraphQLやRESTを使う場合の、クライアント部分などを定義します。
クライアントはstateを持たず、引数で渡されたパラメータをgetやmutationで投げるだけ。
GraphQLで、Scalarを定義する場合はここに置きます。
models/
データクラスを定義します。
GraphQLはデータクラスまで自動生成されますが、あまり使いまわしたくないのでtypedefでaliasしておきます。
typedef BTopicModel = GetTopics$Query$Topic;
pages/
viewを定義するいわゆるUI層です。
viewごとにファイルを定義します。画面構成が膨大になってきた時は、Widgetを共通化したりして外出しします。
基本的にはConsumerWidgetを使用し、ref.watchでstateを取って描画します。
rootは起動後最初に呼ばれるviewです。
ボトムナビゲーションを持ってる場合が多いです。
僕の場合、rootだけはConsumerStatefulWidgetを使用し、initStateから各view_providerの初期処理をまとめて呼び出します。
class Root extends ConsumerStatefulWidget {
const Root({Key? key}) : super(key: key);
@override
ConsumerState<Root> createState() => _RootState();
}
class _RootState extends ConsumerState<Root> {
@override
void initState() {
Future(() async {
await ref.read(rootProvider.notifier).init();
});
super.initState();
}
@override
Widget build(BuildContext context) {
final state = ref.watch(rootProvider); // 初期化終わったかをboolで持ってる
// 以下略
}
}
view_provider/
今回の肝です。providerを2層以上に分けています。
view_providerはMVVMで言うところのViewModelのような役割で、対応するviewで使われるstateを管理します。
基本的にはStateNotifierProviderを使用し、freezedでimmutableにしたstateを持っています。
こうすることで、stateの更新と画面の更新の同期を把握しやすくしています。
view_providerでは、stateをAPIでどうやって取ってくるかは意識しません。そこはproviderに任せます。
stateを別ファイルに定義するかは好みの問題かなと思います。
part 'provider.freezed.dart';
final topicsProvider = StateNotifierProvider<_TopicsNotifier, TopicsState>(
(ref) => _TopicsNotifier(ref),
);
class _TopicsNotifier extends StateNotifier<TopicsState> {
StateNotifierProviderRef<_TopicsNotifier, TopicsState> ref;
_TopicsNotifier(this.ref) : super(const TopicsState());
Future<void> getUserInfo() async {
try {
state = state.copyWith(isLoading: true); // view側でローディングとかに使用
// get
await ref.read(serviceAProvider.notifier).getAccount();
await ref.read(serviceBProvider.notifier).getAccount();
final serviceAState = ref.watch(serviceAProvider);
final serviceBState = ref.watch(serviceBProvider);
// データの加工(省略)
final serviceA = serviceAState.topics;
final serviceB = serviceBState.topics;
// set
state = state.copyWith(serviceA: serviceA, serviceB: serviceB);
} catch (e) {
rethrow; // エラーをキャッチしてダイアログに出すとかはviewで行う
} finally {
state = state.copyWith(isLoading: false);
}
}
}
@freezed
class TopicsState with _$TopicsState {
const factory TopicsState({
@Default(false) bool isLoading,
List<ATopicModel>? serviceA,
List<BTopicModel>? serviceB,
String? errorMessage,
}) = _TopicsState;
}
provider/
もう1つのproviderです。
こちらでは、APIから取ってきたstateを、取得元の単位で管理しています。
このstateがviewでどう使われるかは意識しません。なのでimmutableにする必要もありません。
ここからGraphQLを利用する場合、Fragment colocationの思想を踏襲し、queryやfragmentを定義します。
その際、queryやfragmentの名前には_provider名
を付けるとうまいこと分離しやすいです。
final serviceBProvider = StateNotifierProvider<_ServiceBNotifier, ServiceBState>(
(ref) => _ServiceBNotifier(),
);
class _ServiceBNotifier extends StateNotifier<ServiceBState> {
_ServiceBNotifier() : super(const ServiceBState());
Future<void> getAccount() async {
// 略
}
Future<void> getTopics() async {
// 略
}
}
main.dart
テーマの設定や.envの読込み、firebaseの初期化なんかはここで行います。
その他
業務レベルでは、ディレクトリルートにconfig/やMakefileを置くことが多いです。
config/
config/には.env.devや.env.prodを置き、環境変数の管理をします。
firebaseを使用する場合にはGoogleService-Info.plistやgoogle-services.jsonも環境別にディレクトリ分けてここに置くことになります。
Makefile
Flutterでよく使うコマンドを定義しています。
build_runnerあたりは特にコマンドが長いので重宝しますね。
MAKEFLAGS=--no-builtin-rules --no-builtin-variables --always-make
ROOT := $(realpath $(dir $(lastword $(MAKEFILE_LIST))))
refresh_pod:
cd ios && rm -rf Podfile.lock
cd ios && pod install --repo-update
flutter clean
clean:
flutter clean
vendor:
flutter pub get
gen:
flutter pub run build_runner build --delete-conflicting-outputs
setup_dev:
cp config/.env.dev .env
setup_prod:
cp config/.env.prod .env
まとめ
今回の構成の肝はproviderを多重にしているところで、view_providerみたいな役割を明確にしているところでした。
開発をやっていると後任に引き継ぐってシーンが出てくるので、可読性、保守性は意識してるつもりです。
この記事を読んで「こう言う時あかんのでは?」とか「こう言う時はどうする?」みたいなご意見や質問などいただけると嬉しいです。
さいなら