UPSIDER Tech Blog

UPSIDERのこれからを担うFlutterアプリのアーキテクチャ

こんにちは、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

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向けのアーキテクチャフレームワークです。

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

ルーティング

go_router / auto_route から、書き心地の好みで auto_route を採用しました。

アーキテクチャ解説

アーキテクチャにおいて特徴的な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 がこちら

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;
}

■ 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);
}

■ 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 のユニットテストは以下の流れで実装します。

  1. notifier.sendメソッドで Action を送信
  2. 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」のアプリは多くのユーザーにご活用いただいており、しつ高い顧客体験を提供すべき、日々開発に奮闘しております。より利便性の高いアプリ開発を通じて、ユーザー体験を向上させ、一緒に挑戦者を支えるチャレンジをしませんか?

ご興味を持っていただけたら、ぜひカジュアル面談でお話ししましょう!

herp.careers

余談

サンプルアプリを公開するにあたって、リポジトリ名をどうするかで少し議論になりました。

flutter_architecture_blueprintflutter-architecture-blurprint

結論、チームによっていろんな判断がありそうでした。