FlutterでFeature-Firstアーキテクチャの実例紹介

 ・ 8 min

black and green hamming bird

Feature-Firstアーキテクチャ#

以前にも開発手法に関する記事(FlutterにRiverpod用アーキテクチャを適用する)を書きましたが、サンプルについて詳しく作ってみたことはありませんでした。Feature-Firstアーキテクチャをどのような順序で作り、Riverpodを使ってどのように実装するかを今回整理し、思い出せなくなった時に見返すために書いてみようと思います!

Feature-Firstアーキテクチャは、ソフトウェアシステムを設計・開発する際にFeatureを中心に考えるアプローチです。つまり、ユーザーに提供する機能を先に定義し、それを実装するための技術的な部分を後から考える方式です。どの本にも出てくる内容ですが、プロジェクト活動は計画 -> 分析 -> 設計 -> コーディング -> テストの順序で進みます。開発アーキテクチャは一つの方式が正解ではありません。チームごとに異なり、人によって好みの方式も違うでしょう。このアーキテクチャを使おうとしていますが、使いながら変わったり別の方式を適用することもあるかもしれません。

実際に適用してみて感じたのは、バックエンドのMVCパターンと非常に似ていますが、presentation層のcontroller周りが少し異なるということでした。

用語整理#

アーキテクチャに基づいて作る前に知っておくべきことをまず整理し、それからサンプルを作ります。

まずFeatureとDomainについて理解しておきましょう。

  • Feature: ユーザーに提供される特定の機能やサービスを意味します。例えば、オンラインショッピングモールの「カートに入れる」機能、SNSの「コメント作成」機能などがあります。Featureはユーザー視点でのシステムの機能を表します。
  • Domain: システムが扱う特定の領域や問題空間を意味します。例えば、オンラインショッピングモールのDomainは「商品」「注文」「決済」などになります。Domainはシステムの内部的なロジックとルールを担当します。
    簡単に言えば、Featureはユーザーが直接目にする機能で、Domainはその機能を実装するための内部的なモデルとルールです。

プロジェクトのフォルダ構造では、一つのfeatureを基にフォルダが作られ、関連するフォルダとファイルを作ります。プロジェクトにはlib/src/features/feature1/domainフォルダがあります。この時のdomainフォルダは上記のdomainの定義と同一で、ModelsやEntitiesの役割を果たします。

開発開始前に決めるべきこと#

何かの機能を作る時は常に、企画段階でこのサービスにはどのような機能があるかが決まってから開発が始まるべきです。この機能がどのような役割を果たし、要件が定義されている必要があります。

開発者が要件を決めたか、誰かが整理した要件をもとに、ユースケースとユーザーフローを把握してどのようなdataが必要で、どのようにDBに保存されるか決めればよいです。ただしFlutterでは一般的にDBに保存する役割を担っていないので、バリデーション済みのdataをAPIを呼び出してDBに保存できる形で渡すことと、APIを通じてデータを取得する時(fromJson関数呼び出し後)に受け取るオブジェクトをdomainとして考えています。

ユースケース(use case)とユーザーフロー(user flow)についてもう少し見てみましょう。実はこの2つは開発者が定義すべきものではありません。開発者はこの要件をコードで具体化すればよいです。

Use case(使用事例)#

  • ユーザーがサービス(=システム)を利用して達成したい目標を作業できるように実行するための一連の定義された動作です。

Use case diagram - Wikipedia

Use caseの定義#
  • 主体: 誰がこの機能を使うのか?(ユーザー、管理者など)
  • 目標: ユーザーがこの機能を通じて何を達成したいのか?
  • 前提条件: 機能を使うための事前条件は何か?
  • 実行ステップ: 機能を実行するためのステップは何か?
  • 結果: 機能実行後にどのような結果が出るか?
  • 例外: 予想される例外状況は何か?

User flow(ユーザーフロー)#

  • ユーザーが特定の目標を達成するためにシステムと相互作用する過程を段階的に可視化したもの
  • つまり、ユーザーがどのような順序でどの画面を経て目標を達成するかを示します

image

user flowの定義#
  • 開始点: ユーザーはどこから始めるか?
  • ステップ: ユーザーがどのようなステップを経て目標を達成するか?各ステップでのユーザーの行動とシステムの応答を明記します。
  • 結果: ユーザーが最終的にどのような結果を得るか?
  • 別の経路: ユーザーが別の選択をした場合、どのような経路を辿るか?

使用例#

use case: オンラインショッピングモールで商品を購入する

  • 主体: 一般ユーザー
  • 目標: 欲しい商品を購入し決済する
  • 前提条件: 会員登録、商品検索
  • 実行ステップ: 商品詳細ページを見る、カートに入れる、決済情報を入力、注文完了
  • 結果: 注文完了メッセージ、注文履歴確認可能
  • 例外: 在庫不足、決済失敗
    user flow:
  1. ユーザーが商品検索欄にキーワードを入力し検索ボタンをクリック
  2. 検索結果ページで目的の商品を選択し詳細ページへ移動
  3. 商品詳細ページで商品情報を確認しカートに入れる
  4. カートページで購入する商品を確認し決済ボタンをクリック
  5. 決済情報を入力し決済を完了
  6. 注文完了メッセージが表示され、注文履歴ページへ移動

このように、ある機能について完璧でなくても定義をしてからデザイン後に開発すればよいです。Feature-Firstアーキテクチャではdomain領域にentitiesとusecasesフォルダをそれぞれ作る方式もあります。

その場合のusecaseはビジネスロジックを担当します。商品注文、会員登録のようなユーザー操作に対するルールとアルゴリズムを実装します。ルールとアルゴリズムを実装するというのは、ユーザーログインを処理したり特定の投稿を取得するなどの機能を実装すると考えればよいです。これはservice領域で行うことと似た作業です。

いくつかのサンプルを見ると、usecasesというフォルダを作って画面内で担当する各関数をファイルごとに作って処理していますが、私はcontrollerやservice側で処理することにしました。

機能一つを開発する順序#

いよいよ書きたかった部分です。友達との約束の時間を決めるために、各自が都合の良い時間を投票できるよう、一人のユーザーが約束のために決めた複数のメタデータ(タイトル、日付、時間帯)を持つデータを生成する機能を作ろうとしています。一つの機能のように見えますが、アプリでは一つの機能ではありません。複数の機能のまとまりになっています。ユーザーフローでは、約束の時間作成を押すと複数の画面を遷移してタイトルを入力し、日付を選択し、時間帯を選択して作成を押すこと、投票用URLを共有すること、約束の時間を投票すること、投票結果を見ることまで複数の機能があります。

Feature-Firstアーキテクチャでの機能は、ユーザーがサービスを利用して達成したい完結した目標を意味します。
開発者が一般的に考える機能(イベント、関数)とは異なります。

あるfeatureを開発するにはどのように始めればよいでしょうか?フロントエンド側として何を先に作るのが良いかいつも悩んでいました。実はフロントエンドとして画面ができていれば画面を先に作ってみるのも良いですが、果たしてそれが良い方法なのかという疑問がありました。

実はこの部分については正解はありません。しかし、우아한형제들(Woowa Brothers)の元CTOだったキム・ヨンハンさんの回答も悩みの答えの参考になると思います。

この部分は実は正確な正解はありません。
場合によっては画面から作って進めるのが良い場合もあり、バックエンドのロジックを先に書くのが良い場合もあります。特にWebフロントエンド開発者とサーバー開発者が完全に分かれている場合を考えると、それぞれ同時に作業を進めて結合する必要があります。こうした場合を考えると、画面とバックエンドのロジックが同時に開発を進められる必要があります。
そのため重要なのは、実は要件分析と設計です。きちんと要件を分析し設計しておき、機能ごとにどのように動作すべきか、どのようなデータが流れるかスペックを明確にしておけば、どちらからでも進められます。
私は核心的なビジネス機能はバックエンドから作業するのを好みます。
ありがとうございます^^

ビジネス機能から作ってみようと思います。UIから作ると以下のようなシナリオが予想されます。

  1. UI画面を全部作った後、ボタンイベントやデータ一覧の取得・画面表示が必要になる <- 後でAPIや状態管理を追加して再確認が必要
  2. UI画面を全部作った後にcontrollerやserviceを作ると、結局repositoryやmodelを注入・参照する必要が生じ、controllerをテストするには残りが先行する必要がある

これは無理やり考えた事例なので必ずこのような問題が起きるわけではありませんが、UIがあるオブジェクトに依存する時、それが解決されるまで開発中にエラーが出ている状態が長く続く可能性があります。また、他の層を作る時にUIを考慮してしまうと、UI側のコードを変更したくなくて依存性を強く作ってしまうかもしれません。

機能要件があるので、domainから作ります。今回作るサンプルは友達との約束の時間を決めるための時間投票機能です。

ほとんどの場合Riverpodを使いますが、controller領域を除いてはほとんど画面で直接生成を避け、自動的に注入されるシングルトンとして使います。

コードからimportは削除しているので、そのまま真似するとエラーになります。VS Codeでエラーメッセージをよく確認してください!

Domain層の実装#

Entitiesに該当する主要エンティティ(=モデル)を定義します。時間投票のためなのでvotingという機能名を考えましたが、スケジュール関連サービスだと思いlib/src/features/calendarフォルダを作りました。機能を間違えて作ったり間違った場所に作っても後でファイル名を変えればよいので、あまり気にしないようにしました。エンティティ名はWhen2meetというサービスがあるので、features/calendar/domain/when_to_meet.dartファイルを作ります。

コードを見るとコードジェネレータを使っているのでpart部分が追加されています。
flutter pub run build_runner watchを入力して開発してください。
便利に使うためにVS CodeでBuild Runner拡張機能を使っています。

part 'when_to_meet.freezed.dart';
part 'when_to_meet.g.dart';
 
typedef WhenToMeetID = String;
 
/// WhenToMeetは約束の時間を決めるために使用するクラスです。
@freezed
class WhenToMeet with _$WhenToMeet {
  const factory WhenToMeet({
    required WhenToMeetID id,
    required String title,
    required UserID creator,
    required List<User> invitees,
    required List<AnonymousUser> attendees,
    required Map<DateTime, Set<TimeSlot>> availableTimes,
    required Map<UserID, Map<DateTime, Set<TimeSlot>>> selectedTimes,
  }) = _WhenToMeet;
 
  factory WhenToMeet.fromJson(Map<String, dynamic> json) => _WhenToMeet.fromJson(json);
 
  factory WhenToMeet.empty() => WhenToMeet(
        id: const UuidV4().generate(),
        title: '',
        creator: '',
        invitees: [],
        attendees: [],
        availableTimes: {},
        selectedTimes: {},
      );
 
  factory WhenToMeet.fromCreationState(String title, Map<DateTime, Set<TimeSlot>> availableTimes) => WhenToMeet(
        id: const UuidV4().generate(),
        title: title,
        creator: '',
        invitees: [],
        attendees: [],
        availableTimes: availableTimes,
        selectedTimes: {},
      );
}

スタティック関数のemptyはテスト用に作り、fromCreationState関数はこの後のPresentation層でWhenToMeetCreationControllerのWhenToMeetCreationStateのための初期化関数です。

Data層の実装#

データ層ではローカルまたはリモートからデータのCRUDを処理します。特定のEntityごとにCRUDの中で実装すべきものが異なる場合がありますが、今回はCRUDすべてを抽象化します。この部分もVS Codeスニペットを利用すると便利です。

// features/calendar/data/when_to_meet_repository.dartファイルとして作成
abstract class WhenToMeetRepository {
  Future<List<WhenToMeet>> getWhenToMeets();
  Future<void> addWhenToMeet(WhenToMeet whenToMeet);
  Future<void> deleteWhenToMeet(WhenToMeet whenToMeet);
  Future<void> updateWhenToMeet(WhenToMeet whenToMeet);
}

次に実装クラスを作ります。Firestoreを利用しているので、以下のような名前で作ればよいです。

  • remote_when_to_meet_repository.dart
  • firebase_when_to_meet_repository.dart

Repository内部ではFirebaseFirestoreを通じて外部からデータを操作します。なのでこれを外部から注入します。RemoteWhenToMeetRepositoryを使う側で直接生成せず、providerを通じて受け取ります。このようにRiverpodを通じて必要な層を担当するオブジェクトを受け渡します。

part 'remote_when_to_meet_repository.g.dart';
 
class RemoteWhenToMeetRepository implements WhenToMeetRepository {
  const RemoteWhenToMeetRepository(this._fs);
  final FirebaseFirestore _fs;
 
  CollectionReference get _whenToMeetCollection => _fs.collection('when_to_meet');
 
  @override
  Future<void> addWhenToMeet(WhenToMeet whenToMeet) {
    return _whenToMeetCollection.doc(whenToMeet.id).set(whenToMeet.toJson());
  }
 
  @override
  Future<void> deleteWhenToMeet(WhenToMeet whenToMeet) {
    return _whenToMeetCollection.doc(whenToMeet.id).delete();
  }
 
  @override
  Future<List<WhenToMeet>> getWhenToMeets() {
    return _whenToMeetCollection.get().then((snapshot) {
      return snapshot.docs.map((doc) {
        return WhenToMeet.fromJson(doc.data() as Map<String, dynamic>);
      }).toList();
    });
  }
 
  @override
  Future<void> updateWhenToMeet(WhenToMeet whenToMeet) {
    return _whenToMeetCollection.doc(whenToMeet.id).update(whenToMeet.toJson());
  }
}
 
@riverpod
WhenToMeetRepository whenToMeetRepository(WhenToMeetRepositoryRef ref) {
  return RemoteWhenToMeetRepository(fs: ref.watch(firebaseFirestoreProvider));
}

本当に特別な状況でない限り、whenToMeetRepositoryProviderのようなproviderを作る部分ではref.readの代わりにref.watchを使ってください。

テストのためにはFakeWhenToMeetRepositoryを作り、各関数で任意の値を設定して返せばテストしやすくなります。

firebaseFirestoreProviderはlib/src/common/providers/general_providers.dartファイルに作りました。ただし、これはすでにグローバル変数として使用されているため、必須ではありません。

part 'general_provider.g.dart';
 
@riverpod
FirebaseFirestore firebaseFirestore(FirebaseFirestoreRef ref) {
  return FirebaseFirestore.instance;
}

Applicationの実装#

次にWhenToMeetServiceを作る番です。このサービスはビジネスロジックを処理します。

part 'when_to_meet_service.g.dart';
 
class WhenToMeetService {
  WhenToMeetService({
    required this.remoteWhenToMeetRepository,
  });
  final RemoteWhenToMeetRepository remoteWhenToMeetRepository;
 
  Future<void> addWhenToMeet(WhenToMeet whenToMeet) {
    return remoteWhenToMeetRepository.addWhenToMeet(whenToMeet);
  }
 
  Future<List<WhenToMeet>> fetchWhenToMeets(User user) {
    return remoteWhenToMeetRepository.getWhenToMeets(user.id);
  }
 
  Future<WhenToMeet?> fetchWhenToMeet(WhenToMeetID id) {
    return remoteWhenToMeetRepository.getWhenToMeet(id);
  }
 
  Future<void> updateWhenToMeet(WhenToMeet whenToMeet) {
    return remoteWhenToMeetRepository.updateWhenToMeet(whenToMeet);
  }
}
 
@riverpod
WhenToMeetService whenToMeetService(WhenToMeetServiceRef ref) {
  return WhenToMeetService(
    remoteWhenToMeetRepository: ref.watch(remoteWhenToMeetRepositoryProvider),
  );
}

サービスではデータの追加・更新や単件データの取得を行います。関数名をrepositoryと似せたい衝動もありますが、できるだけ意味を込めるのも良いと思います。本来ならもっと多くの関数が必要でしょう。ログインしたユーザーが作成・参加した一覧を取得する必要もあるかもしれませんが、今はシンプルに作ります。今は特別な処理はしていませんが、必要であればservice関数内で何らかの処理を行うこともできます。

Presentationの実装#

次にScreenとControllerを作ります。ScreenとControllerはプラットフォームごとに異なる作り方が必要になることもあると思います。モバイル、タブレット、デスクトップ画面によって表現すべきUIが変わりますが、UIが変わればデータ処理は同じでも、一度に処理されるか画面遷移しながらデータを一つずつ集める場合にはScreenの数やその中で状態管理が必要な場合にControllerも変わってくると考えています。

今回はモバイル用の画面のみ作ってみます。タイトル入力、日付選択、時間帯選択のために最低3つの画面が必要ですが、記事がかなり長くなりそうなので最初の画面だけお見せします。_pages変数でタイトル入力日付選択時間帯選択を順番に行います。ここで使用される変数からデータを受け取るのはControllerで行います。

class WhenToMeetCreationPage extends ConsumerWidget {
  const WhenToMeetCreationPage({super.key});
 
  final List<Widget> _pages = const [
    WhenToMeetCreationTitlePage(),
    WhenToMeetCreationDatePage(),
    WhenToMeetCreationTimePage(),
  ];
 
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final controller = ref.read(whenToMeetCreationControllerProvider.notifier);
    final state = ref.watch(whenToMeetCreationControllerProvider);
 
    return GestureDetector(
      onTap: () => FocusScope.of(context).unfocus(),
      child: Scaffold(
        appBar: AppBar(
          title: const Text('約束を作成'),
          leading: controller.isFirstStep()
              ? null
              : IconButton(
                  icon: const Icon(Icons.arrow_back),
                  onPressed: () => controller.previousStep(),
                ),
        ),
        body: SafeArea(
          child: Column(
            children: [
              Padding(
                padding: const EdgeInsets.symmetric(horizontal: pg),
                child: StepProgressIndicator(stepProgress: state.stepProgress),
              ),
              const SizedBox(height: pg * 2),
              Expanded(child: _pages[state.stepProgress.step]),
            ],
          ),
        ),
      ),
    );
  }
}

Controllerの説明#

Controllerに記述される関数は主に、バリデーション済みの入力データを受け取ったり、ウィジェットがレンダリングされるタイミングでデータを渡したり、ボタンなどのイベントを通じてデータを渡すケースがほとんどです。

Controllerが実はこのアーキテクチャで最も難解で、どうすればよいか一番悩んだ部分でした。最初はRiverpodでNotifierProviderを作る時、必ずdomainオブジェクトを使わなければならないという強迫観念がありました。しかし修正を重ねるうちに、画面で得られたデータを必ず空のdomainに一つずつ詰め込む必要はないことに気づきました。domainがserviceに渡される直前にオブジェクトにすれば十分でした。serviceに渡す前まではできるだけ扱いやすい形のデータにしておく方が、より楽しく開発できました。

そこでController用のクラスを作りました。必ずfreezedを使わなくても大丈夫だと思います。
WhenToMeetCreationStateファイルはWhenToMeetCreationControllerのbuild関数の戻り値です。
このクラスが持つ変数を見ると、約束作成画面で使用する変数を保持しています。title、dates、selectedTimesです。この3つの変数がユーザーの選択のたびに値が埋まっていきます。WhenToMeetCreationStateはWhenToMeetCreationControllerと同じファイルに作りました。2つのファイルに分割しても良いですが、便宜上一つのファイルに置きました。

part 'when_to_meet_creation_controller.freezed.dart';
part 'when_to_meet_creation_controller.g.dart';
 
@freezed
class WhenToMeetCreationState with _$WhenToMeetCreationState {
  const factory WhenToMeetCreationState({
    required String title,
    required List<DateTime> dates,
    required List<int> selectedTimes,
    required List<TimeSlot> availableTimes,
    required StepProgress stepProgress,
  }) = _WhenToMeetCreationState;
 
  factory WhenToMeetCreationState.empty() {
    return WhenToMeetCreationState(
      title: '',
      dates: [],
      selectedTimes: [],
      availableTimes: [],
      stepProgress: StepProgress(step: 0, initialStep: 0, totalSteps: 3),
    );
  }
}
 
extension WhenToMeetCreationStateX on WhenToMeetCreationState {
  WhenToMeet toDomain() {
    final availableTimesMap =
        Map<DateTime, Set<TimeSlot>>.fromEntries(dates.map((date) => MapEntry(date, Set.from(availableTimes))));
    return WhenToMeet.fromCreationState(title, availableTimesMap);
  }
}

StepProgressは画面遷移時に何番目の順序かを示す値ですが、アーキテクチャの説明にはあまり重要ではないので省略します。

WhenToMeetCreationControllerには複数の関数があります。タイトルを入力する時、日付を選択する時、時間帯を選択する時に呼び出されるものや、入力フィールドの値が変わるたびに呼び出される関数もあります。最後に保存する時はcreateWhenToMeet関数を呼び出してFirestoreに保存します。

@riverpod
class WhenToMeetCreationController extends _$WhenToMeetCreationController {
  @override
  WhenToMeetCreationState build() {
    ref.onDispose(() => print('WhenToMeetCreationController disposed'));
    print('WhenToMeetCreationController created');
    // 戻り値にDomainをそのまま使わず、Stateを作って使う方が良い。ただし今は作ってしまったのでそのまま使おう。
    return WhenToMeetCreationState.empty();
  }
 
  Future<WhenToMeet> createWhenToMeet(List<TimeSlot> slots) async {
    state = state.copyWith(availableTimes: slots);
    final service = ref.read(whenToMeetServiceProvider);
    final whenToMeet = state.toDomain();
    await service.addWhenToMeet(whenToMeet);
    return whenToMeet;
  }
 
  void previousStep() {
    if (state.stepProgress.isFirstStep()) {
      return;
    }
    state = state.copyWith(stepProgress: state.stepProgress.previousStep());
  }
 
  void nextStep() {
    if (state.stepProgress.isLastStep()) {
      return;
    }
    state = state.copyWith(stepProgress: state.stepProgress.nextStep());
  }
 
  // タイトル入力
  void updateTitle(String title) {
    state = state.copyWith(title: title);
    nextStep();
  }
 
  // 日付入力
  void updateDates(List<DateTime> dates) {
    if (state.title.isEmpty) {
      assert(false, 'title is empty');
    }
 
    state = state.copyWith(dates: dates);
    nextStep();
  }
 
  // 時間帯入力
  void updateAvailableTimes(List<int> selectedTimes) {
    if (state.dates.isEmpty) {
      assert(false, 'Dates are empty');
    }
 
    state = state.copyWith(selectedTimes: selectedTimes);
  }
 
  void updateSelectedTimes(List<int> selectedTimes) {
    state = state.copyWith(selectedTimes: selectedTimes);
  }
 
  bool isFirstStep() => state.stepProgress.isFirstStep();
}

このアーキテクチャを進める中で最も悩んだ部分は、PresentationでデータをDomainに固執して管理していた時でした。バックエンドにはDAOとDTOという概念があります。これと同様に、画面では必要なデータがあっても、保存時には加工されたり不要なデータであることがありました。これをdomainオブジェクトを使いながら補助関数を作って複雑にしていましたが、入力データをそのまま保存するWhenToMeetCreationStateオブジェクトを作ったらコードがより簡潔になりました。

まだ完全に理解してFeature-Firstアーキテクチャ + Riverpodを一緒に使いこなせているとは思っていません。
使い続けながら似たように作ってみた資料だと思ってください!

Flutter開発を始める方々に少しでもガイドになれば幸いです!


You can stand tall without standing on someone. You can be a victor without having victims.

— Harriet Woods


他の投稿
ダウンロードしたFlutterプロジェクトが実行できない時の解決方法 커버 이미지
 ・ 1 min

ダウンロードしたFlutterプロジェクトが実行できない時の解決方法

VS CodeでFlutter DevToolsをブラウザで開く方法 커버 이미지
 ・ 1 min

VS CodeでFlutter DevToolsをブラウザで開く方法

Linuxディレクトリ別の説明 커버 이미지
 ・ 2 min

Linuxディレクトリ別の説明