Flutterを技術スタックに加えてアプリチームは加速する #2 アーキテクチャ選定編
🎍🍱 あけましておめでとうございます 🗻🦅🍆
年末年始はいかがお過ごしでしたか?
新しい抱負を持って2022年アクセル踏みっぱなしでいきましょう🚗
今回はFlutterをプロダクト採用するにあたって検討したアーキテクチャにフォーカスしてお送りしたいと思います。
Flutterを技術スタックに加えてアプリチームは加速する #1 導入編の続編となります。
プロジェクト構成
アプリケーションコードはlibディレクトリ配下に属し、以下のようなツリーになっています。
├── lib
│ ├── app
│ │ ├── app.dart
│ │ ├── entity # <------------------------- エンティティクラスを格納
│ │ ├── env # <------------------------- 環境変数の管理モジュール
│ │ ├── model
│ │ │ ├── action
│ │ │ │ ├── creator # <---------------- 一連のaction発行ロジックをaction_creatorとして定義
│ │ │ │ ├── xxxx_action # <------------ fluxのaction定義
│ │ │ ├── navigation
│ │ │ │ └── navigation_stack.dart # <-- navigationのロジック定義
│ │ │ ├── repository
│ │ │ │ ├── xxxx_repository.dart # <--- repositoryモジュール
│ │ │ └── status
│ │ │ ├── xxxx_status # <------------ アプリケーション共通の状態を定義
│ │ ├── router # <------------------------- 画面遷移モジュール
│ │ └── view
│ │ ├── screen # <--------------------- 1画面1screenで定義(タブ内画面も)
│ │ │ ├── xxxx
│ │ │ ├── xxxx_screen.dart # <--- widget
│ │ │ ├── xxxx_store.dart # <---- store
│ │ │ ├── xxxx_viewmodel.dart# <- viewmodel
│ │ │ └── state # <-------------- screenの状態クラス
│ │ └── widget
│ ├── core
│ │ ├── flux # <--------------------------- flux抽象化クラス/モジュール群
│ │ │ ├── action_creator.dart
│ │ │ ├── dispatcher.dart
│ │ │ ├── event_action.dart
│ │ │ └── event_store.dart
│ │ ├── res # <---------------------------- リソース管理モジュール
│ │ │ ├── r.dart
│ │ │ └── text
│ │ │ ├── text_string.dart
│ │ │ └── text_string_ja.dart
│ │ ├── theme # <-------------------------- テーマ管理モジュール
│ │ └── viewmodel # <---------------------- ViewModel抽象化クラス
│ ├── external # <-------------------------- アプリ外部のデータアクセスモジュール群
│ │ ├── api
│ │ │ ├── spm_v1_api_service
│ │ │ └── spm_v2_api_service
│ │ ├── cookie
│ │ ├── firebase
│ │ ├── secure_storage
│ │ └── shared_preferences
│ └── main.dart
ポイントとしては、
・UI構成とUIロジックに関わるものは、app/view配下
・ドメイン知識に関するものは、app/entity配下
・アプリケーションの状態やロジックに関わるものは、app/model配下
・アプリの外界に対するクライアントモジュールは、external配下としrepositoryを経由して参照する
ということを意識したディレクトリ構成としています。
Fluxの構成
上記ディレクトリのlib/core/flux以下にはFluxを実現するためのモジュールを用意しています。
ActionCreatorとEventActionはextendsして使うためにabstractで定義しています。
EventStoreはChangeNotifierをextendsして、新しいstateに更新されたときに notifyListeners() することで、watchしているモジュールに変更が伝播されます。
上記をもとに、BottomNavigationBarで表示を切り替えるホーム画面のStoreを作ると以下のようになります。
///
/// home_store.dart
///
// storeをwatchするためのproviderを定義
final homeStoreProvider =
ChangeNotifierProvider.autoDispose((_) => HomeStore());
// EventStoreをextendsしたStoreクラスを定義
// 型ジェネリクスにはstoreで取り扱うstateクラスを指定
class HomeStore extends EventStore<HomeState> {
HomeStore() : super(HomeState());
// Actionに応じて新しいstateをセットする
@override
void onAction(EventAction action) {
if (action is HomeActionTapTab) {
state = state.copyWith(selectedTabIndex: action.index);
}
}
}
///
/// home_state.dart
///
@freezed
class HomeState with _$HomeState {
const factory HomeState({
@Default(0) int selectedTabIndex,
}) = _HomeState;
}
ViewModelの構成
上記のホーム画面に対するUIロジックを切り出したViewModelは以下のようになります。
final homeTabViewModelProvider = ChangeNotifierProvider.autoDispose(
(ref) => HomeTabViewModel(
ref.watch(homeStoreProvider),
ref.read(homeActionCreatorProvider),
),
);
abstract class _Input {
void onTapBottomNavigationBar(int index);
}
abstract class _Output {
int get selectedTabIndex;
}
class HomeTabViewModel extends ViewModel with _Input, _Output {
final HomeStore _homeStore;
final HomeActionCreator _homeActionCreator;
final TrackingManager _trackingManager;
HomeTabViewModel(
this._homeStore,
this._homeActionCreator,
this._trackingManager,
);
@override
void onTapBottomNavigationBar(int index) {
_homeActionCreator.setTab(index);
}
@override
int get selectedTabIndex => _homeStore.state.selectedTabIndex;
}
riverpodのChangeNotifierProviderを使用して、ViewModelを監視できるproviderを作成しています。(使い方は次のセクションで紹介します)
生成時に必要なstoreやActionCreatorへの依存性はそれぞれのproviderをwatchまたはreadすることで解決しています。
ViewModelに対するinputとoutputをそれぞれabstractで定義して、見通しを良くしています。
ViewModelはinputに応じてActionCreatorのメソッドを実行したり、outputに基づいてstoreから必要な値を読み取るという仕事をしています。
riverpodを使った状態監視とDI
今回DI(Dependency Injection)を実現するためにriverpodのProviderを使用しました。
repositoryモジュールなど外部モジュールから参照されるようなモジュールは以下のようにProviderをまとめています。
repositoryでも依存しているクライアントモジュールを取得するために必要なProviderをreadしています。
final calendarRepositoryProvider = Provider(
(ref) => CalendarRepository(
ref.read(sharedPreferencesManagerProvider),
ref.read(spmV1ApiServiceProvider),
),
);
final reservationRepositoryProvider = Provider(
(ref) => ReservationRepository(
ref.read(sharedPreferencesManagerProvider),
ref.read(spmV1ApiServiceProvider),
ref.read(spmV2ApiServiceProvider),
),
);
final roomRepositoryProvider = Provider(
(ref) => RoomRepository(
ref.read(sharedPreferencesManagerProvider),
ref.read(spmV1ApiServiceProvider),
),
);
そしてrepositoryを使いたいモジュールでは、そのrepositoryのProviderをreadすることで参照することができます。
final reservationActionCreatorProvider = Provider(
(ref) => ReservationActionCreator(
ref.read(reservationRepositoryProvider),
ref.read(userRepositoryProvider),
),
);
依存性の方向としては
・ユーザーのアクションに対して何か行う場合
View → ViewModel → ActionCreator → Repository → 外界(API等)
・アプリケーションの状態を表示する場合
View → ViewModel → Store
という方向になることを意識しています。
widgetの設計思想
Flutterのwidgetは宣言的UIであり、Reactでのベストプラクティスを活かすことができます。
スペースマーケットのフロントエンド開発では、こちらの記事での紹介をベースに、componentは以下の思想でディレクトリ設計しています。
・関連するコンポーネント(サブコンポーネント)をcomponentsディレクトリに格納する
・componentsディレクトリ内の構造は再帰的な構造とする
これをFlutterのwidgetにも適用することにしました。
例えば以下のようなツリーになります。
そのwidgetで必要な子孫widgetはwidgetディレクトリを再起的に定義することで見通し良く、かつ影響範囲を最小限にしています。
├── todo
├── todo_screen.dart
├── todo_screen_viewmodel.dart
└── widget
└── message_list
├── message_list.dart
├── message_list_viewmodel.dart
└── widget
└── message_bubble
└── message_bubble.dart
└── widget
├── bubble_image.dart
├── bubble_text.dart
└── bubble_opponent.dart
簡単ではありましたが、以上が今回のアプリ開発で設計面でポイントとなった部分でした。
まだまだ細かい点でご紹介できるので、気になる方はお気軽に聞いていただければと思います。
今回のまとめ
プロジェクト構成の紹介
Flux・ViewModelの設計、riverpodの活用方法について紹介
widgetのディレクトリ設計の紹介
次回「運用編」でチームビルディングやナレッジ共有など紹介します。
お知らせ
モバイルアプリエンジニア採用中です!
いっしょに指先の体験をアップデートしていきませんか?
この記事が気に入ったらサポートをしてみませんか?