Flutterでよく使う構成

こんにちは、アベです
せっかくの休日、台風でアレなので記事でも書こうと思いまして。

一応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しておきます。

``` dart:service_b/topic.dart
typedef BTopicModel = GetTopics$Query$Topic;
```

### pages/

viewを定義するいわゆるUI層です。
viewごとにファイルを定義します。画面構成が膨大になってきた時は、Widgetを共通化したりして外出しします。
基本的にはConsumerWidgetを使用し、ref.watchでstateを取って描画します。

rootは起動後最初に呼ばれるviewです。
ボトムナビゲーションを持ってる場合が多いです。
僕の場合、rootだけはConsumerStatefulWidgetを使用し、initStateから各view_providerの初期処理をまとめて呼び出します。

``` dart:root.dart
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を別ファイルに定義するかは好みの問題かなと思います。

``` dart:topics/provider.dart

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](https://www.apollographql.com/docs/react/data/fragments/#colocating-fragments)の思想を踏襲し、queryやfragmentを定義します。
その際、queryやfragmentの名前には_`provider名`を付けるとうまいこと分離しやすいです。

``` dart:service_b/provider.dart

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あたりは特にコマンドが長いので重宝しますね。

``` Makefile:Makefile

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みたいな役割を明確にしているところでした。

開発をやっていると後任に引き継ぐってシーンが出てくるので、可読性、保守性は意識してるつもりです。

この記事を読んで「こう言う時あかんのでは？」とか「こう言う時はどうする？」みたいなご意見や質問などいただけると嬉しいです。

さいなら