こんにちは、UPSIDERで日々モバイルアプリ開発をしているふっくです。
UPSIDERでは今後、よりアプリ開発に注力し決済プラットフォームの中核的な役割を果たすことを目指しています。
今回は、今後の開発・運用を目指して考えたFlutterアプリ向けのアーキテクチャを紹介します。
ネイティブアプリの世界で触れてきた色々なアーキテクチャ・フレームワークを参考に、開発の後半でも順調にスケールさせることができるように、工夫を凝らしました。
本アーキテクチャで作ったサンプルアプリもあるので、ぜひ以下のリンクから見てみてください。
https://github.com/upsidr/flutter_architecture_blueprint
デモはこちら
https://upsidr.github.io/flutter_architecture_blueprint/
対象読者
この記事は、Flutterアプリの設計に興味がある方を対象としています。また、以下の知識を持っている前提となっています。
- riverpod
- レイヤードアーキテクチャ
- Dependency Injection (DI)
目指すところ
サービスの成長に応じてアプリを破綻なくスケールさせるためには、適切なレイヤー分割とその疎結合化が必要になります。
本アーキテクチャは、
- Presentation / Domain / Data の3層レイヤーを採用する
- View と ViewModel (このブログでは以降Notifierと呼びます) の責務を明確に分離する
- Notifierの内部では、StateとActionなどを用いて整理し、アプリの振る舞いを予測可能なものにする
の3つを前提に設計を行いました。
持続可能な形で安全にアプリ開発を進めることができるよう、テストコードフレンドリーになるよう設計しました。とくに、Presentation層のテストがしやすい形を目指しました。
参考にしたもの
全体的な作りは、モバイルアプリで流行りの実装手法を参考にしました。
Now in Android
Now in Androidは、Google Androidチームによる開発者向けの技術記事のシリーズ名です。同タイトルで、Android開発におけるベストプラクティスを示すリファレンスアプリが公開されています。
https://developer.android.com/series/now-in-android?hl=ja
関心事で分離するモジュール
関心事で分離するモジュール構成は広く知られています。Flutterにおいてもディレクトリの切り方として扱いやすいため、参考としました。
https://github.com/android/nowinandroid/blob/main/docs/ModularizationLearningJourney.md
モックライブラリに深く依存しないテストダブル
DIを活用してテストダブルにFakeを使用することで、モックライブラリに深い依存をせずにテストコードを記述することができます。これにより、テストコードがシンプルになり、忠実度の制御が容易になります。
https://github.com/android/nowinandroid?tab=readme-ov-file#testing
class TestUserDataRepository : UserDataRepository { /** * The backing hot flow for the list of followed topic ids for testing. */ private val _userData = MutableSharedFlow<UserData>(replay = 1, onBufferOverflow = DROP_OLDEST) ... /** * A test-only API to allow setting of user data directly. */ fun setUserData(userData: UserData) { _userData.tryEmit(userData) } }
The Composable Architecture
The Composable Architecture (TCA)は、Point-Freeチームが開発しているiOS向けのアーキテクチャフレームワークです。
UIの抽象化
TCA はUIを State / Action で抽象化しています。他の宣言型UIの世界でも見られる手法であり、単方向データフローを構築しやすい・テスト容易性が向上するなどのメリットがあります。
@Reducer struct Feature { struct State { /* ... */ } enum Action { /* ... */ } var body: some ReducerOf<Self> { // ... } }
swift-dependencies
TCA では swift-dependencies というDIライブラリを使っています。このライブラリでは、メソッドをクロージャープロパティで定義し、本番用・テスト用などと実装を差し替え可能にする手法が紹介されています。
struct AudioPlayerClient { var loop: (_ url: URL) async throws -> Void var play: (_ url: URL) async throws -> Void var setVolume: (_ volume: Float) async -> Void var stop: () async -> Void }
ライブラリ選定
アーキテクチャに関わるパッケージを以下のように選定しました。
目的 | パッケージ |
---|---|
状態管理 | hooks_riverpod, rxdart |
モデル | freezed |
ルーティング | auto_route |
状態管理
3層レイヤーを採用するため、レイヤー間の繋ぎ込み(DI)に riverpod を使いました。
rxdartは、主にBehaviorSubjectを使ってData層から Presentation層 までのデータの伝搬に使用します。
モデル
Dart3では、代数的データ型(直和型・直積型)を言語レベルで扱えるようになりました。
https://dart.dev/language/patterns#algebraic-data-types
これに freezed を組み合わせると、より簡単に記述することができるようになるので採用しました。
https://pub.dev/packages/freezed#legacy-union-types-and-sealed-classes
💡 代数的データ型: 直和型と直積型から成るデータ構造です。データの持ち方や意味が異なる複数のクラスを単一クラスの派生として扱うことができます。Swift は enum Associated Values、Kotlin は sealed class で表現できます。
ルーティング
go_router / auto_route から、書き心地の好みで auto_route を採用しました。
💡 実際試してみたわけではありませんが、今回のアーキテクチャが特に go_router と親和性が悪いとは考えていません。
アーキテクチャ解説
本アーキテクチャにおいて特徴的なPresentation層に焦点を絞って解説をします。
Domain・Data層の詳細はサンプルコードをご参照ください。
https://github.com/upsidr/flutter_architecture_blueprint
Presentation層
本アーキテクチャのデータフローは以下のようになります。
図のように、UIState / Action / Effect を使って View・Notifier間の単方向データフローを実現します。
■ Contract
ここでは、TCAのUI抽象化 を参考に画面が取りうる全ての状態を抽象化して記述し、クラスとして定義します。
- UIState - 画面の表示要素
- Action - UIの操作イベント
- Effect - 画面遷移などの画面イベント
この3要素をまとめて Contract という名前で扱います。
TCAは State と Action の2つを定義しますが、本アーキテクチャでは State を UIState と Effect に分割しています。
これは、SwiftUIがダイアログ表示や画面遷移を1つの State として扱えるのに対し、FlutterではUIの宣言とは別に手続き的に表示・遷移処理が必要であり、Effect が独立している方が扱いやすいためです。
💡 Contract の命名は、iOSのVIPERアーキテクチャから引用しています。VIPERにおいては、「画面モジュールの明確なインターフェイスの提供」という同様の役割で定義されます。
サンプルのタスク一覧画面の Contract がこちら
abstract class BaseContract<UiState, Action, Effect> { void consume(); Future<void> send(Action action); } typedef TaskListContract = BaseContract<TaskListUiState, TaskListAction, TaskListEffect>; // 画面の表示要素 @freezed class TaskListUiState with _$TaskListUiState { const factory TaskListUiState({ @Default([]) List<EditableUserTask> taskList, }) = _TaskListUiState; } // UIの操作を定義 @freezed sealed class TaskListAction with _$TaskListAction { const factory TaskListAction.onAppear() = OnAppear; const factory TaskListAction.newTaskButtonTapped() = NewTaskButtonTapped; const factory TaskListAction.taskTapped(EditableUserTask task) = TaskTapped; const factory TaskListAction.toggleIsCompleted(EditableUserTask task) = ToggleIsCompleted; const factory TaskListAction.onTaskSwiped(EditableUserTask task) = OnTaskSwiped; } // 画面遷移などの画面イベントを定義 @freezed sealed class TaskListEffect with _$TaskListEffect { const factory TaskListEffect.none() = None; const factory TaskListEffect.goDetail({ required EditableUserTask? task, }) = GoDetail; const factory TaskListEffect.showAlert({ required AlertState state, }) = ShowAlert; }
💡 Effect の None は処理対象の Effect が存在しないことを示します
■ Notifier
ここでは
- UIState・Effect の管理
- Action のハンドリング
を行います。
Notifier は AutoDisposeNotifierProvider で実装し、UIState を state として扱います。
Effect は別途 StateProvider を作って管理します。
サンプルのタスク一覧画面の Notifier はこちら
(riverpod_generator による自動生成)
final taskListEffectProvider = StateProvider((ref) => const TaskListEffect.none()); @riverpod class TaskListNotifier extends _$TaskListNotifier implements TaskListContract { late final StreamSubscription _taskListSubscription; ... @override TaskListUiState build() { _taskListSubscription = _taskListUseCase() .listen((list) => state = state.copyWith(taskList: list)); ref.onDispose(_taskListSubscription.cancel); return const TaskListUiState(); } @override void consume() { _updateEffect(const TaskListEffect.none()); } @override Future<void> send(TaskListAction action) async { switch (action) { case NewTaskButtonTapped(): _updateEffect(const TaskListEffect.goDetail(task: null)); case ...: state = state.copyWith(...) } } _updateEffect(TaskListEffect effect) => ref.read(taskListEffectProvider.notifier).update((state) => effect); }
💡 sendメソッド内のパターンマッチングによって、すべての Action をハンドリングします。もしFatなロジックが実装された場合は、複雑なビジネスロジックが混入していたり、適切なView構造の分割ができていないと判断できます。/>
■ View
ここでは
- UIState の表示
- Effect のハンドリング
- Action の送信
を行います。
Extensionプロパティを定義して、 ref.notifier.send()
の形で Action を送信できるようにしています。
Effect は ref.listen で購読し、画面イベントを副作用として処理します。
サンプルのタスク一覧画面の View はこちら
extension _TaskListEx on WidgetRef { TaskListNotifier get notifier => read(taskListNotifierProvider.notifier); } @RoutePage() class TaskListPage extends HookConsumerWidget with AlertStateCompatible { const TaskListPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { ref.listen(taskListEffectProvider, (_, effect) => handleEffect(context, ref, effect: effect)); ... return Scaffold( appBar: AppBar( title: const Text('ToDo'), centerTitle: true, ), body: const _TaskListBody(), floatingActionButton: FloatingActionButton( onPressed: () => ref.notifier.send(const TaskListAction.newTaskButtonTapped()), child: const Icon(Icons.add), ), ); } Future<void> handleEffect( BuildContext context, WidgetRef ref, { required TaskListEffect effect, }) async { switch (effect) { case None(): break; case GoDetail(): ref.notifier.consume(); context.router.push(EditTaskRoute(task: effect.task)); case ShowAlert(): ref.notifier.consume(); handleAlertState(context, effect.state); } } } class _TaskListBody extends HookConsumerWidget { const _TaskListBody(); @override Widget build(BuildContext context, WidgetRef ref) { final taskListCompleted = ref.watch( taskListNotifierProvider.select( (value) => value.taskList.where((t) => t.isCompleted)), ); ... return CustomScrollView( slivers: [ ... SliverPadding( padding: const EdgeInsets.symmetric(vertical: 16), sliver: SliverList( delegate: SliverChildListDelegate( buildList(ref, taskList: taskListCompleted)), ), ), ], ); } ... }
ディレクトリ構成
Now in Andoirdをベースとしています。
ディレクトリ構成
├── app_router.dart ├── app_router.gr.dart ├── core │ ├── data │ │ ├── network │ │ │ └── fake_todo_api_client.dart 👈 サンプルでは実際の通信はナシ │ │ └── repository │ │ └── todo │ │ ├── fake_todo_repository.dart │ │ ├── fake_todo_repository.freezed.dart │ │ ├── todo_repository.dart │ │ └── todo_repository.freezed.dart │ ├── domain │ │ ├── model │ │ │ ├── editable_user_task.dart │ │ │ └── editable_user_task.freezed.dart │ │ └── todo │ │ ├── edit_task_usecase.dart │ │ ├── edit_task_usecase.freezed.dart │ │ ├── task_list_usecase.dart │ │ └── task_list_usecase.freezed.dart │ ├── model │ │ ├── user_task.dart │ │ ├── user_task.freezed.dart │ │ └── user_task.g.dart │ └── util │ ├── alert_state.dart │ ├── alert_state.freezed.dart │ ├── base_contract.dart │ ├── datetime_formatted.dart │ └── stream_extensions.dart ├── feature │ └── todo │ ├── edit_task │ │ ├── edit_task_contract.dart │ │ ├── edit_task_contract.freezed.dart │ │ ├── edit_task_notifier.dart │ │ ├── edit_task_notifier.g.dart │ │ ├── edit_task_page.dart │ │ └── ui_components │ │ ├── task_text_field.dart │ │ └── toggle_complete_button.dart │ └── task_list │ ├── task_list_contract.dart │ ├── task_list_contract.freezed.dart │ ├── task_list_notifier.dart │ ├── task_list_notifier.g.dart │ ├── task_list_page.dart │ └── ui_components │ └── task_list_item.dart │ └── task_list_placeholder.dart ├── main.dart └── main_device_preview.dart
feature 直下のディレクトリは関心事の単位で切ります。タスク一覧画面のファイルの場合は以下の箇所になります。
👉 lib/feature/todo/task_list
── task_list ├── task_list_contract.dart ├── task_list_contract.freezed.dart ├── task_list_notifier.dart ├── task_list_notifier.g.dart ├── task_list_page.dart └── ui_components ├── task_list_item.dart └── task_list_placeholder.dart
テストについて
本アーキテクチャは、Presentation層のテストに重きを置いています。
画面の実装では、Contract のモデリングを真っ先に行うことで Notifier をテスト駆動開発(TDD)で実装することが可能になります。
基本的に Notifier のユニットテストは以下の流れで実装します。
notifier.send
メソッドで Action を送信- Notifierが処理した結果の UIState / Effect を評価する
サンプルのタスク一覧画面の Notifier 「タスク作成ボタンをタップしてタスク編集画面に遷移する」のテストがこちら
group('Navigation', () { test('Tap NewTaskButton, navigate detail', () async { final (notifier, uiState, effect) = buildAccessors(); // 👇 Data層のFakeRepositoryの挙動を変更 todoRepository.handler.fetchTaskList = () async => fakeTodoState.update((value) => value.copyWith(taskList: [])); await notifier.send(const TaskListAction.onAppear()); expect(uiState().taskList.isEmpty, true); await notifier.send(const TaskListAction.newTaskButtonTapped()); expect( effect(), const TaskListEffect.goDetail(task: null), ); }); ...
setUp
にてFakeをDIしています。
void main() { ... buildAccessors() { final subscription = container.listen(taskListNotifierProvider, (_, __) {}); addTearDown(subscription.close); return ( container.read(taskListNotifierProvider.notifier), () => container.read(taskListNotifierProvider), () => container.read(taskListEffectProvider), ); } ... setUp(() { fakeTodoState = BehaviorSubject.seeded(const FakeTodoRepositoryState()); todoRepository = FakeTodoRepository.from(fakeTodoState); container = ProviderContainer(overrides: [ todoRepositoryProvider.overrideWithValue(todoRepository), // 👈 ココでDI ]); ... }); tearDown(() => container.dispose()); ...
FakeTodoRepositoryの実装
swift-dependencies DIのアイデアを使っています 👍
class FakeTodoRepository implements TodoRepository { const FakeTodoRepository._(this.fakeState, this.handler); factory FakeTodoRepository.from( BehaviorSubject<FakeTodoRepositoryState> fakeState) { return FakeTodoRepository._( fakeState, FakeTodoRepositoryHandler(fakeState)); } final BehaviorSubject<FakeTodoRepositoryState> fakeState; final FakeTodoRepositoryHandler handler; @override Stream<List<UserTask>> get taskList => fakeState.map((state) => state.taskList); @override Future<void> fetchTaskList() async { await handler.fetchTaskList(); } @override Future<void> addTask({required UserTask task}) async { await handler.addTask(task); } @override Future<void> updateTask({required UserTask task}) async { await handler.updateTask(task); } @override Future<void> removeTask({required String id}) async { await handler.removeTask(id); } } class FakeTodoRepositoryHandler { FakeTodoRepositoryHandler(this.fakeState); final BehaviorSubject<FakeTodoRepositoryState> fakeState; late AsyncCallback fetchTaskList = () async {}; late AsyncValueSetter<UserTask> addTask = (task) async { fakeState.update( (value) => value.copyWith(taskList: value.taskList.appending(task))); }; late AsyncValueSetter<UserTask> updateTask = (task) async { fakeState.update( (value) => value.copyWith(taskList: value.taskList.replaced(task))); }; late AsyncValueSetter<String> removeTask = (taskId) async { fakeState.update( (value) => value.copyWith(taskList: value.taskList.deletedBy(taskId))); }; } @freezed class FakeTodoRepositoryState with _$FakeTodoRepositoryState { const factory FakeTodoRepositoryState({ @Default([]) List<UserTask> taskList, }) = _FakeTodoRepositoryState; }
今後に向けて
Flutter Web + device_preview の体験
今回のサンプルは device_preview を使って Flutter Web で公開してみました。
👉 https://upsidr.github.io/flutter_architecture_blueprint/
https://pub.dev/packages/device_preview
device_preview を Flutter Web と組み合わせることで、
- アプリ外観の確認が容易になる
- 誰でもすぐに成果物を閲覧できる
などの利点を強く感じました。
公開範囲の制限など考えないといけないこともありますが、開発フローの要所要所で活かしていきたいと思いました 👍
実装方針を決めてみた感想
プロダクトで実際に運用することになれば、色々な課題が出てくるでしょう。
ボイラープレートの自動生成をしてみる、状態の変更に対する網羅的なテストを実装できるようにするなど、チームのみんなと一緒に改良を続けていければと思います。
最後に
今回は運用検討中のFlutterアーキテクチャの紹介でした。
UPSIDERでは、「挑戦者を支える世界的な金融プラットフォームを創る」をミッションに世界中の挑戦者を支えるチャレンジをしています。UPSIDERが提供する法人カード「UPSIDER」のアプリは多くのユーザーにご活用いただいており、しつ高い顧客体験を提供すべき、日々開発に奮闘しております。より利便性の高いアプリ開発を通じて、ユーザー体験を向上させ、一緒に挑戦者を支えるチャレンジをしませんか?
ご興味を持っていただけたら、ぜひカジュアル面談でお話ししましょう!
余談
サンプルアプリを公開するにあたって、リポジトリ名をどうするかで少し議論になりました。
flutter_architecture_blueprint
とflutter-architecture-blurprint
結論、チームによっていろんな判断がありそうでした。