5
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Flutterでよく使う構成

Last updated at Posted at 2022-08-13

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

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

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の初期処理をまとめて呼び出します。

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

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の思想を踏襲し、queryやfragmentを定義します。
その際、queryやfragmentの名前には_provider名を付けるとうまいこと分離しやすいです。

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

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

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

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

さいなら

5
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?