こんにちは、UPSIDERで日々モバイルアプリ開発 をしているふっくです。
UPSIDERでは今後、よりアプリ開発 に注力し決済プラットフォームの中核的な役割を果たすことを目指しています。
今回は、今後の開発・運用を目指して考えたFlutterアプリ向けのアーキテクチャ を紹介します。
ネイティブアプリの世界で触れてきた色々なアーキテクチャ ・フレームワーク を参考に、開発の後半でも順調にスケールさせることができるように、工夫を凝らしました。
本アーキテクチャ で作ったサンプルアプリもあるので、ぜひ以下のリンクから見てみてください。
https://github.com/upsidr/flutter_architecture_blueprint
デモはこちら
https://upsidr.github.io/flutter_architecture_blueprint/
対象読者
この記事は、Flutterアプリの設計に興味がある方を対象としています。また、以下の知識を持っている前提となっています。
目指すところ
サービスの成長に応じてアプリを破綻なくスケールさせるためには、適切なレイヤー分割とその疎結合 化が必要になります。
本アーキテクチャ は、
Presentation / Domain / Data の3層レイヤーを採用する
View と ViewModel (このブログでは以降Notifierと呼びます) の責務を明確に分離する
Notifierの内部では、StateとActionなどを用いて整理し、アプリの振る舞いを予測可能なものにする
の3つを前提に設計を行いました。
持続可能な形で安全にアプリ開発 を進めることができるよう、テストコードフレンドリーになるよう設計しました。とくに、Presentation層のテストがしやすい形を目指しました。
参考にしたもの
全体的な作りは、モバイルアプリで流行りの実装手法を参考にしました。
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 {
private val _userData = MutableSharedFlow<UserData>(replay = 1 , onBufferOverflow = DROP_OLDEST)
...
fun setUserData(userData: UserData) {
_userData.tryEmit(userData)
}
}
The Composable Architecture
The Composable Architecture (TCA)は、Point-Freeチームが開発しているiOS 向けのアーキテクチャ フレームワーク です。
https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/
UIの抽象化
TCA はUIを State / Action で抽象化しています。他の宣言型UIの世界でも見られる手法であり、単方向データフローを構築しやすい・テスト容易性が向上するなどのメリットがあります。
@Reducer
struct Feature {
struct State { }
enum Action { }
var body : some ReducerOf<Self> {
}
}
swift-dependencies
TCA では swift-dependencies というDIライブラリを使っています。このライブラリでは、メソッドをクロージャ ープロパティで定義し、本番用・テスト用などと実装を差し替え可能にする手法が紹介されています。
https://swiftpackageindex.com/pointfreeco/swift-dependencies/main/documentation/dependencies/designingdependencies#Struct-based-dependencies
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;
}
@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();
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),
]);
...
});
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」のアプリは多くのユーザーにご活用いただいており、しつ高い顧客体験を提供すべき、日々開発に奮闘しております。より利便性の高いアプリ開発 を通じて、ユーザー体験を向上させ、一緒に挑戦者を支えるチャレンジをしませんか?
ご興味を持っていただけたら、ぜひカジュアル面談でお話ししましょう!
herp.careers
余談
サンプルアプリを公開するにあたって、リポジトリ 名をどうするかで少し議論になりました。
flutter_architecture_blueprint
と flutter-architecture-blurprint
結論、チームによっていろんな判断がありそうでした。