【Flutter】Architecture case study【日本語訳】

  • 2024年12月28日
  • 2024年12月28日
  • 翻訳

はじめに

Flutter公式サイトのArchitecture case studyの勝手に日本語訳です。
Flutterアーキテクチャ徹底入門というのを書きましたが、こちらを参考に、さらに深めたい。

Overview

このガイドで使用されるコード例は、Compass サンプルアプリケーションからのものです。このアプリは、旅行のための行程を作成・予約するのに役立つアプリです。多くの機能、ルート、画面を備えた堅牢なサンプルアプリケーションであり、HTTP サーバーとの通信、開発・本番環境の切り替え、ブランド固有のスタイリング、高いテストカバレッジを備えています。このようにして、現実世界の機能豊富な Flutter アプリケーションをシミュレートしています。

Compassアプリのスプラッシュ画面 Compassアプリのホーム画面 Compassアプリの検索フォーム画面 Compassアプリの予約画面

Compass アプリのアーキテクチャは、Flutter のアプリアーキテクチャガイドラインで説明されている MVVM デザインパターンに最も似ています。このケーススタディでは、Compass アプリの「ホーム」機能を通じて、これらのガイドラインをどのように実装するかを説明します。MVVM を知らない場合は、まずこれらのガイドラインを読むことをお勧めします。

Compass アプリのホーム画面では、ユーザーアカウント情報と保存された旅行のリストが表示されます。この画面からログアウトしたり、詳細な旅行ページを開いたり、保存された旅行を削除したり、コアアプリフローの最初のページ(新しい行程を作成する機能)に移動したりできます。

このケーススタディでは、以下のことを学べます:

このケーススタディは順を追って読むことを意図しています。各ページは前のページを参照する場合があります。

コード例はアーキテクチャを理解するために必要な詳細を含んでいますが、完全に実行可能なスニペットではありません。完全なアプリを確認しながら進めたい場合は、GitHub 上の Compass アプリを参照してください。

パッケージ構造

整理されたコードは、複数のエンジニアが最小限のコード衝突で作業するのを容易にし、新しいエンジニアがナビゲートし理解するのを簡単にします。コードの整理は、明確に定義されたアーキテクチャの恩恵を受け、それをさらに強化します。

コードを整理するための一般的な方法は 2 つあります:

  1. 機能ごとにグループ化
    各機能に必要なクラスをまとめます。例:auth ディレクトリには、auth_viewmodel.dartlogin_usecase.dartlogout_usecase.dartlogin_screen.dartlogout_button.dart などが含まれます。

  2. 型ごとにグループ化
    各アーキテクチャ「型」をまとめます。例:repositoriesmodelsservicesviewmodels などのディレクトリ。

このガイドで推奨されるアーキテクチャは、これら 2 つの組み合わせに適しています。データ層オブジェクト(リポジトリやサービス)は単一の機能に縛られませんが、UI 層オブジェクト(ビューやビューモデル)は縛られます。Compass アプリケーション内のコードは以下のように整理されています:

<div class="joplin-editable"><pre class="joplin-source" data-joplin-language="plaintext" data-joplin-source-open="```plaintext
" data-joplin-source-close="
```">lib
|____ui
| |____core
| | |____ui
| | | |____<shared widgets>
| | |____themes
| |____<FEATURE NAME>
| | |____view_model
| | | |_____<view_model class>.dart
| | |____widgets
| | | |____<feature name>_screen.dart
| | | |____<other widgets>
|____domain
| |____models
| | |____<model name>.dart
|____data
| |____repositories
| | |____<repository class>.dart
| |____services
| | |____<service class>.dart
| |____model
| | |____<api model class>.dart
|____config
|____utils
|____routing
|____main_staging.dart
|____main_development.dart
|____main.dart

test
|____data
|____domain
|____ui
|____utils

testing
|____fakes
|____models

ほとんどのアプリケーションコードは datadomainui フォルダに配置されています。dataフォルダーはコードをタイプ別に整理します。リポジトリやサービスは、異なる機能や複数のビューモデルで使用される可能性があるからです。 uiフォルダーはコードを機能別に整理します。各機能には、ビューとビューモデルがそれぞれ1つだけ存在するからです。

このフォルダ構造の主な特徴

  • UI フォルダには「core」というサブディレクトリも含まれており、複数のビューで共有されるウィジェットやテーマロジック(ブランドスタイリングを持つボタンなど)が含まれています。
  • domain フォルダには、アプリケーションのデータ型が含まれています。これらはデータ層と UI 層の両方で使用されます。
  • 開発、ステージング、本番の異なるエントリーポイントとして機能する「main」ファイルが 3 つ含まれています。
  • テスト関連のディレクトリが 2 つ存在します:test/ はテストコード用で、lib/ と同じ構造を持っています。testing/ はモックや他のテストユーティリティを含むサブパッケージで、他のパッケージのテストコードで使用できます。

Compass アプリにはアーキテクチャに関係しない追加コードも含まれています。完全なパッケージ構造は GitHubで確認できます。

その他のアーキテクチャオプション

このケーススタディは、推奨されるアーキテクチャ規則に従う 1 つのアプリケーション例を示していますが、他にも多くの例を記述できます。このアプリの UI は、主にビューモデルと ChangeNotifier に依存していますが、ストリームや riverpodflutter_blocsignals などのライブラリを使用して記述することもできます。

このガイドを正確に守り、追加のライブラリを導入しない場合でも、決定する必要がある点があります:ドメイン層を持つべきか?データアクセスをどのように管理するか?これらの答えは個々のチームのニーズによって大きく異なりますが、このガイドの原則はスケーラブルな Flutter アプリを書くのに役立ちます。

結局のところ、全てのアーキテクチャは MVVM に収束するのではないでしょうか?

UI layer

Flutter アプリケーションの各機能の UI レイヤー は、以下の 2 つのコンポーネントで構成されます:

Compass アプリの予約画面のスクリーンショット

一般的に、ViewModel は UI の状態を管理し、View はその状態を表示します。View と ViewModel は 1 対 1 の関係にあり、それぞれの View に対応する ViewModel が状態を管理します。この View と ViewModel のペアが 1 つの機能の UI を構成します。

例えば、アプリに LogOutViewLogOutViewModel というクラスが存在する場合を考えてみましょう。

ViewModel を定義する

ViewModel は Dart クラスであり、UI ロジックを担当します。ViewModel はドメインデータモデルを入力として受け取り、そのデータを UI Stateとして対応する View に公開します。また、ボタンの押下などのイベントハンドラに紐づけられるロジックをカプセル化し、これらのイベントをアプリケーションのデータ層に送信してデータ変更を管理します。

以下は HomeViewModel クラスの定義例です。この ViewModel の入力は、データを提供するリポジトリ(BookingRepositoryUserRepository)です。

class HomeViewModel {
  HomeViewModel({
    required BookingRepository bookingRepository,
    required UserRepository userRepository,
  })  : _bookingRepository = bookingRepository,
        _userRepository = userRepository;

  final BookingRepository _bookingRepository;
  final UserRepository _userRepository;

  // その他のロジック
}

ViewModel は常にデータリポジトリに依存しており、これらは ViewModel のコンストラクタ引数として提供されます。ViewModel とリポジトリは多対多の関係を持ち、ほとんどの ViewModel は複数のリポジトリに依存します。

上記の例では、リポジトリは ViewModel 内でプライベートメンバーとして管理されています。これにより、View が直接アプリケーションのデータ層にアクセスするのを防ぎます。

UI State

ViewModel の出力は、View がレンダリングするために必要なデータ、つまり UI Stateと呼ばれるものです。UI Stateは、不変なスナップショットであり、View を完全にレンダリングするために必要なデータを表します。

Compass アプリの予約画面のスクリーンショット

ViewModel は公開メンバーとして状態を提供します。以下のコード例では、公開されているデータは User オブジェクトと、ユーザーが保存した行程リスト(List<TripSummary> 型)です。

class HomeViewModel {
  HomeViewModel({
    required BookingRepository bookingRepository,
    required UserRepository userRepository,
  })  : _bookingRepository = bookingRepository,
        _userRepository = userRepository;

  final BookingRepository _bookingRepository;
  final UserRepository _userRepository;

  User? _user;
  User? get user => _user;

  List<BookingSummary> _bookings = [];
  UnmodifiableListView<BookingSummary> get bookings => UnmodifiableListView(_bookings);

  // その他のロジック
}

UI Stateは不変であるべきです。これはバグのないソフトウェアを作る上で重要です。

Compass アプリでは、データクラスの不変性を保証するために package:freezed を使用しています。以下は User クラスの定義例です。freezed パッケージは深い不変性を提供し、copyWithtoJson などの便利なメソッドの実装を自動生成します。

@freezed
class User with _$User {
  const factory User({
    required String name,
    required String picture,
  }) = _User;

  factory User.fromJson(Map<String, Object?> json) => _$UserFromJson(json);
}

Note:
ViewModelの例では、Viewをレンダリングするために2つのオブジェクトが必要です。任意のモデルのUI Stateが複雑になるにつれ、ViewModelには、Viewに表示される多くのリポジトリからの多くのデータが含まれるようになります。場合によっては、UI Stateを特に表すオブジェクトを作成したい場合もあるでしょう。例えば、HomeUiStateという名前のクラスを作成することができます。

UI Stateの更新

ViewModel は状態を保持するだけでなく、データ層が新しい状態を提供したときに Flutter に View を再レンダリングするよう通知する必要があります。Compass アプリでは、この目的のために ViewModel が ChangeNotifier を継承しています。

以下のコード例は、HomeViewModel クラスで新しいデータが提供されると View が再レンダリングされる様子を示しています。

class HomeViewModel extends ChangeNotifier {
  HomeViewModel({
    required BookingRepository bookingRepository,
    required UserRepository userRepository,
  })  : _bookingRepository = bookingRepository,
        _userRepository = userRepository;

  final BookingRepository _bookingRepository;
  final UserRepository _userRepository;

  User? _user;
  User? get user => _user;

  List<BookingSummary> _bookings = [];
  List<BookingSummary> get bookings => _bookings;

  Future<Result> _load() async {
    try {
      final userResult = await _userRepository.getUser();
      switch (userResult) {
           case Ok<User>():
               _user = userResult.value;
               _log.fine('Loaded user');
           case Error<User>():
           // エラー処理
               _log.warning('Failed to load user', userResult.error);
      }
      return userResult;
    } finally {
      notifyListeners();
    }
  }

}

HomeViewModel.userは、Viewが依存するパブリックメンバーです。新しいデータが提供されると、notifyListeners() が呼び出され、View に新しい UI Stateが通知されます。この通知を受け取った View は再レンダリングされます。

以下の図は、リポジトリから提供された新しいデータが UI レイヤーに伝播し、Flutter のウィジェットが再構築される高レベルのプロセスを示しています。

新しいデータがリポジトリから UI 層に伝わる流れ
  1. リポジトリから新しい状態が ViewModel に提供される。
  2. ViewModel が UI Stateを更新して新しいデータを反映する。
  3. ViewModel.notifyListeners が呼び出され、View に新しい UI Stateが通知される。
  4. View(ウィジェット)が再レンダリングされる。

例えば、ユーザーがホーム画面に移動し、ViewModelが作成されると、_loadメソッドが呼び出されます。このメソッドが完了するまでは、UIの状態は空であり、ビューにはローディングインジケータが表示されます。_loadメソッドが完了し、正常に完了した場合、ビューモデルに新しいデータが追加され、ビューに新しいデータが利用可能になったことを通知する必要があります。

Note
ChangeNotifierListenableBuilder(本ページで後述)は、Flutter SDK の一部であり、状態が変化した際に UI を更新するためのシンプルなソリューションを提供します。また、より強力なサードパーティの状態管理ソリューション(riverpodflutter_blocsignals など)を使用することも可能です。これらのライブラリは UI の更新を扱うさまざまなツールを提供します。ChangeNotifier の詳細は、公式ドキュメントの State Management を参照してください。

View を定義する

View はアプリ内のウィジェットです。多くの場合、View はアプリ内の 1 つの画面を表し、独自のルートを持ち、ウィジェットサブツリーの先頭に Scaffold が含まれるようなもの (例: HomeScreen) ですが、必ずしもそうとは限りません。

ときには、View がアプリ全体で使い回される単一の UI 要素である場合もあります。Compass アプリには、LogoutButton という View があり、ユーザーがログアウトボタンを期待するあらゆる場所にウィジェットツリー内で簡単に配置できるようになっています。LogoutButton View には対応する LogoutViewModel が存在します。また、大きな画面の場合、モバイルでは全画面を使うような複数の View を同時に配置することもあります。

Note
「View」とは抽象的な用語であり、1 View = 1 ウィジェットというわけではありません。ウィジェットは合成可能であり、複数のウィジェットを組み合わせて 1 つの View を作ることができます。そのため、ViewModel とウィジェットの関係は 1 対 1 ではなく、「View を構成するウィジェットの集まり」に対して 1 つの ViewModel という関係になります。

View 内のウィジェットが果たす役割は以下の 3 つです。

  1. ViewModel のデータプロパティを表示する。
  2. ViewModel からの更新通知を受け取り、新しいデータが利用可能になると再描画する。
  3. ユーザーのイベントハンドラに ViewModel のコールバックを紐づける(必要があれば)。
A diagram showing a view's relationship to a view model.

Home 機能の例を続けます。以下のコードは HomeScreen View の定義を示しています。

class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key, required this.viewModel});

  final HomeViewModel viewModel;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      // ...
    );
  }
}

多くの場合、View の入力は key (すべての Flutter ウィジェットがオプション引数として取るもの) と、対応する ViewModel のみとすれば十分です。

UI データを View に表示する

View は ViewModel による状態を利用します。Compass アプリでは、ViewModel はコンストラクタの引数として渡されます。以下の例は HomeScreen ウィジェットのコードスニペットです。

class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key, required this.viewModel});

  final HomeViewModel viewModel;

  @override
  Widget build(BuildContext context) {
    // ...
  }
}

このウィジェットの中で、渡された viewModel から bookings を取得し、さらにサブウィジェットに渡すことができます。
下記コードでは、booking プロパティをサブウィジェットに渡しています。

@override
Widget build(BuildContext context) {
  return Scaffold(
    // Some code was removed for brevity.
    body: SafeArea(
      child: ListenableBuilder(
        listenable: viewModel,
        builder: (context, _) {
          return CustomScrollView(
            slivers: [
              SliverToBoxAdapter(...),
              SliverList.builder(
                itemCount: viewModel.bookings.length,
                itemBuilder: (_, index) => _Booking(
                  key: ValueKey(viewModel.bookings[index].id),
                  booking:viewModel.bookings[index],
                  onTap: () => context.push(
                    Routes.bookingWithId(
                      viewModel.bookings[index].id)),
                  onDismissed: (_) => viewModel.deleteBooking.execute(
                       viewModel.bookings[index].id,
                     ),
                ),
              ),
            ],
          );
        },
      ),
    ),
  );
}

UI の更新

HomeScreen ウィジェットは、ListenableBuilder を使って ViewModel の更新を監視します。ListenableBuilder の下にあるウィジェットサブツリー全体が、提供された Listenable が変更されるたびに再レンダリングされます。

以下はその例です:

@override
Widget build(BuildContext context) {
  return Scaffold(
    body: SafeArea(
      child: ListenableBuilder(
        listenable: viewModel,
        builder: (context, _) {
          return CustomScrollView(
            slivers: [
              SliverToBoxAdapter(...),
              SliverList.builder(
                itemCount: viewModel.bookings.length,
                itemBuilder: (_, index) => _Booking(
                  key: ValueKey(viewModel.bookings[index].id),
                  booking: viewModel.bookings[index],
                  onTap: () => context.push(Routes.bookingWithId(viewModel.bookings[index].id)),
                  onDismissed: (_) => viewModel.deleteBooking.execute(viewModel.bookings[index].id),
                ),
              ),
            ],
          );
        },
      ),
    ),
  );
}

このように、ListenableBuilder を使うことで、状態の変更に応じた UI の更新を簡単に実現できます。

ユーザーイベントの処理

最後に、View はユーザーからの イベント を受け取り、ViewModel に処理させる必要があります。これは、ViewModel クラスでコールバックメソッドを公開し、そのロジックをカプセル化することによって実現されます。

A diagram showing a view's relationship to a view model.

HomeScreen では、ユーザーが以前に予約したイベントを Dismissible ウィジェットをスワイプすることで削除できます。

次のコードは先ほどの抜粋コードを再掲したものです。

SliverList.builder(
  itemCount: widget.viewModel.bookings.length,
  itemBuilder: (_, index) => _Booking(
    key: ValueKey(viewModel.bookings[index].id),
    booking: viewModel.bookings[index],
    onTap: () => context.push(
      Routes.bookingWithId(viewModel.bookings[index].id)
    ),
    onDismissed: (_) =>
      viewModel.deleteBooking.execute(widget.viewModel.bookings[index].id),
  ),
),
A clip that demonstrates the 'dismissible' functionality of the Compass app.

HomeScreen では、ユーザーが保存した旅行を表すウィジェットとして _Booking が使われています。_Booking がスワイプされると(つまり Dismissible が行われると)、viewModel.deleteBooking メソッドが実行されます。

ユーザーが保存した予約はアプリケーション状態であり、画面のライフサイクルを超えて永続的に保存されます。そのため、このようなアプリケーション状態を変更できるのはリポジトリだけです。つまり、HomeViewModel.deleteBooking メソッドはデータレイヤーのリポジトリメソッドを呼び出します。以下のコードスニペットで確認できます。

Future<Result<void>> _deleteBooking(int id) async {
  try {
    final resultDelete = await _bookingRepository.delete(id);
    switch (resultDelete) {
      case Ok<void>():
        _log.fine('Deleted booking $id');
      case Error<void>():
        _log.warning('Failed to delete booking $id', resultDelete.error);
        return resultDelete;
    }

    // Some code was omitted for brevity.
    // final  resultLoadBookings = ...;

    return resultLoadBookings;
  } finally {
    notifyListeners();
  }
}

Compass アプリでは、これらのユーザーイベントを処理するメソッドを commands と呼んでいます。

Command objects

Command は UI レイヤーからデータレイヤーへと流れていく処理を担う責任を持ちます。具体的には、このアプリでは、Command というクラスがメソッドをラップし、そのメソッドの実行状態(runningcompleteerror など)に対応する UI 更新を安全に取り扱う仕組みになっています。

Command クラスは runningcompleteerror といったメソッド状態を管理することで、UI 側でロード中を示すインジケータを表示するなどの操作を簡単に行えるようにします。

以下に Command クラスからの抜粋コードを示します(デモ用に一部コードを省略しています)。

abstract class Command<T> extends ChangeNotifier {
  Command();

  bool running = false;
  Result<T>? _result;

  /// true if action completed with error
  bool get error => _result is Error;

  /// true if action completed successfully
  bool get completed => _result is Ok;

  /// Internal execute implementation
  Future<void> _execute(action) async {
    if (_running) return;

    // Emit running state - e.g. button shows loading state
    _running = true;
    _result = null;
    notifyListeners();

    try {
      _result = await action();
    } finally {
      _running = false;
      notifyListeners();
    }
  }
}

Command 自体が ChangeNotifier を継承しており、Command.execute メソッド内では複数回 notifyListeners が呼ばれます。
これにより、View は最小限のロジックで異なる状態を簡単に扱うことができます。

また、Command は抽象クラスになっています。これは Command0Command1 などの具象クラスによって実装されます。クラス名の数字はそのメソッドが想定する引数の数を指します。Compass アプリの utils ディレクトリに実装例があります。

Package recommendation
独自に Command クラスを作る代わりに、より強力な flutter_command パッケージを利用するのもよい選択です。このパッケージは上記のような仕組みを実装しており、便利な機能を提供します。

Ensuring views can render before data exists

ViewModel クラスでは、コマンドをコンストラクタ内で生成します。

class HomeViewModel extends ChangeNotifier {
  HomeViewModel({
   required BookingRepository bookingRepository,
   required UserRepository userRepository,
  }) : _bookingRepository = bookingRepository,
      _userRepository = userRepository {
    // Load required data when this screen is built.
    load = Command0(_load)..execute();
    deleteBooking = Command1(_deleteBooking);
  }

  final BookingRepository _bookingRepository;
  final UserRepository _userRepository;

  late Command0 load;
  late Command1<void, int> deleteBooking;

  User? _user;
  User? get user => _user;

  List<BookingSummary> _bookings = [];
  List<BookingSummary> get bookings => _bookings;

  Future<Result> _load() async {
    // ...
  }

  Future<Result<void>> _deleteBooking(int id) async {
    // ...
  }

  // ...
}

Command.execute メソッドは非同期であり、View が描画を行うタイミングとデータが利用できるタイミングを保証できるわけではありません。これこそが Compass アプリで Commands を使っている大きな理由の 1 つです。
View の Widget.build メソッドでは、コマンドを使って状態によって異なるウィジェットを表示します。

// ...
child: ListenableBuilder(
  listenable: viewModel.load,
  builder: (context, child) {
    if (viewModel.load.running) {
      return const Center(child: CircularProgressIndicator());
    }

    if (viewModel.load.error) {
      return ErrorIndicator(
        title: AppLocalization.of(context).errorWhileLoadingHome,
        label: AppLocalization.of(context).tryAgain,
        onPressed: viewModel.load.execute,
      );
    }

    // The command has completed without error.
    // Return the main view widget.
    return child!;
  },
),
...

load コマンドは ViewModel のプロパティであり、一時的に作られるものではありません。そのため、たとえ load コマンドが HomeScreen ウィジェットの生成前に完了していたとしても問題はありません。Command オブジェクトは同じインスタンスとして残っており、常に正しい状態を公開してくれるからです。

このパターンにより、アプリでありがちな UI 上の問題(非同期処理中のロード表示など)を一元的・標準的に扱うことができます。結果としてバグを減らし、よりスケーラブルなコードベースを実現できます。しかし、すべてのアプリに同じパターンが当てはまるわけではありません。どのようなライブラリやアーキテクチャを選択しているかによっては、別の方法を使うほうが適切な場合もあります。
たとえば、アプリ内で StreamStreamBuilder を使用するのであれば、Flutter が提供している AsyncSnapshot が同様の問題を解決してくれます。

Real world example
Compass アプリの開発中、この Command パターンを導入することで解決できたバグがありました。詳細は GitHub のプルリクエスト をご覧ください。

Data layer

アプリケーションのデータレイヤーは、MVVM 用語では model と呼ばれ、アプリケーションデータの唯一の信頼できる情報源 (source of truth) です。
唯一の情報源として、このレイヤーだけがアプリケーションデータを更新する責任を持ちます。

このレイヤーは、さまざまな外部 API からデータを取得し、それを UI に公開し、UI から発生するデータ更新のイベントを処理し、必要に応じて外部 API へ更新リクエストを送信する役割を担っています。

本ガイドで紹介するデータレイヤーは大きく 2 つのコンポーネント、repositoriesservices から構成されます。

A diagram that highlights the data layer components of an application.
  • Repositories (リポジトリ)
    アプリケーションデータの唯一の情報源であり、そのデータにまつわるロジックを含んでいます。たとえば、新しいユーザーイベントを受け取ってデータを更新したり、サービスからデータを取得したりします。オフライン機能がサポートされている場合はデータの同期を行い、リトライロジックの管理や、データのキャッシュなどもリポジトリの責任です。

  • Services (サービス)
    状態を持たない (stateless) Dart クラスで、HTTP サーバーやプラットフォームプラグインなどの外部 API とやり取りします。アプリケーションコードの内部以外で生成されるデータは、すべてサービスクラス内で取得する必要があります。

Define a service

サービスクラスは、アーキテクチャコンポーネントの中でも最も明確な役割を持つ存在です。状態を持たず (stateless)、副作用のない関数だけで構成されます。唯一の役割は外部 API をラップすることであり、通常はデータソースごとに 1 つのサービスクラスが存在します(例: クライアント側 HTTP サーバーやプラットフォームプラグインなど)。

A diagram that shows the inputs and outputs of service objects.

Compass アプリの例では、APIClient というサービスが存在し、クライアント側サーバーへの CRUD 呼び出しを担当しています。

class ApiClient {
  // Some code omitted for demo purposes.

  Future<Result<List<ContinentApiModel>>> getContinents() async { /* ... */ }

  Future<Result<List<DestinationApiModel>>> getDestinations() async { /* ... */ }

  Future<Result<List<ActivityApiModel>>> getActivityByDestination(String ref) async { /* ... */ }

  Future<Result<List<BookingApiModel>>> getBookings() async { /* ... */ }

  Future<Result<BookingApiModel>> getBooking(int id) async { /* ... */ }

  Future<Result<BookingApiModel>> postBooking(BookingApiModel booking) async { /* ... */ }

  Future<Result<void>> deleteBooking(int id) async { /* ... */ }

  Future<Result<UserApiModel>> getUser() async { /* ... */ }
}

サービス自体はクラスであり、各メソッドは異なる API エンドポイントをラップし、非同期のレスポンスオブジェクトを公開しています。
先ほどの保存済みブッキング (booking) を削除する例を続けると、deleteBooking メソッドは Future<Result<void>> を返します。

Note
いくつかのメソッドは、BookingApiModel のように API から取得する生データ専用のクラスを返す場合があります。後述しますが、リポジトリはこれらのデータを取り出し、異なる形式でアプリケーション全体で使われるデータに変換して公開します。

Define a repository

リポジトリの唯一の責任は、アプリケーションデータを管理することです。リポジトリはある特定の種類のアプリケーションデータに対する唯一の情報源であり、そのデータ型を変化させるのはリポジトリだけに限定するべきです。
リポジトリは外部ソースから新しいデータをポーリングしたり、リトライロジックを扱ったり、キャッシュされたデータを管理したり、生のデータをドメインモデルに変換したりする責任を負います。

A diagram that highlights the repository component of an application.

アプリケーション内のデータごとに異なるリポジトリを用意するとよいでしょう。たとえば、Compass アプリには UserRepositoryBookingRepositoryAuthRepositoryDestinationRepository などが存在します。

以下は、Compass アプリの BookingRepository の例で、リポジトリの基本的な構造を示しています。

class BookingRepositoryRemote implements BookingRepository {
  BookingRepositoryRemote({
    required ApiClient apiClient,
  }) : _apiClient = apiClient;

  final ApiClient _apiClient;
  List<Destination>? _cachedDestinations;

  Future<Result<void>> createBooking(Booking booking) async {...}
  Future<Result<Booking>> getBooking(int id) async {...}
  Future<Result<List<BookingSummary>>> getBookingsList() async {...}
  Future<Result<void>> delete(int id) async {...}
}

Development versus staging environments
上記のクラス名は BookingRepositoryRemote で、BookingRepository という抽象クラスを継承しています。このベースクラスを使って、異なる環境 (本番用やローカル開発用など) に対応するリポジトリを作成できます。たとえば、Compass アプリにはローカル開発用の BookingRepositoryLocal というクラスもあります。
GitHub で各 BookingRepository クラスの違いを確認できます。

BookingRepositoryApiClient サービスを入力として受け取ります。これはサーバーから生データを取得・更新するために利用します。
サービスをプライベートメンバーにしておくことが重要です。こうすることで、UI レイヤーがリポジトリをバイパスして直接サービスを呼び出すことを防ぎます。

ApiClient サービスを通じて、リポジトリはユーザーが保存したブッキングに対するサーバー上の変更をポーリングしたり、保存済みブッキングを削除するために POST リクエストを送信したりできます。

リポジトリがアプリケーションモデルへ変換する生データは、複数のソースや複数のサービスから供給される可能性があるため、リポジトリとサービスの関係は多対多 (many-to-many) です。あるサービスは任意の数のリポジトリによって使用され、あるリポジトリは複数のサービスを利用できます。

A diagram that highlights the data layer components of an application.

Domain models

BookingRepository は、BookingBookingSummary といった ドメインモデル を出力します。すべてのリポジトリは対応するドメインモデルを出力するようになっています。
これらのデータモデルは API モデルとは異なり、アプリ内で必要なデータだけを含んでいます。API モデルには生のデータが含まれていますが、アプリのユーザーにとって役立つ形にするにはフィルタリングや結合、あるいは削除が必要となる場合があります。リポジトリは生データを整理・変換して、ドメインモデルとして出力します。

サンプルアプリでは、BookingRepository.getBooking のようなメソッドの戻り値としてドメインモデルを公開しています。
getBooking メソッドは、ApiClient サービスから生データを取得し、それを Booking オブジェクトに変換する責任を負います。これは複数のサービスエンドポイントからデータを組み合わせることで実現しています。

 // This method was edited for brevity.
Future<Result<Booking>> getBooking(int id) async {
  try {
    // Get the booking by ID from server.
    final resultBooking = await _apiClient.getBooking(id);
    if (resultBooking is Error<BookingApiModel>) {
      return Result.error(resultBooking.error);
    }
    final booking = resultBooking.asOk.value;

    final destination = _apiClient.getDestination(booking.destinationRef);
    final activities = _apiClient.getActivitiesForBooking(
            booking.activitiesRef);

    return Result.ok(
      Booking(
        startDate: booking.startDate,
        endDate: booking.endDate,
        destination: destination,
        activity: activities,
      ),
    );
  } on Exception catch (e) {
    return Result.error(e);
  }
}

Note
Compass アプリでは、サービスクラスが Result オブジェクトを返します。Result は非同期呼び出しをラップしてエラー処理を簡単にし、非同期処理に依存する UI 状態を管理しやすくするためのユーティリティクラスです。
このパターンは推奨されていますが、必須ではありません。本ガイドで推奨されるアーキテクチャは Result を使わなくても実装可能です。
このクラスについて詳しくは、Result クックブックレシピ をご覧ください。

Complete the event cycle

ここまで、ユーザーが保存済みブッキングを削除する際の流れを見てきました。
ユーザーが Dismissible ウィジェットをスワイプするというイベントから始まり、ViewModel は実際のデータ変更を BookingRepository に委譲します。次のスニペットは BookingRepository.deleteBooking メソッドを示しています。

Future<Result<void>> delete(int id) async {
  try {
    return _apiClient.deleteBooking(id);
  } on Exception catch (e) {
    return Result.error(e);
  }
}

リポジトリは _apiClient.deleteBooking メソッドを使って API クライアントに POST リクエストを送り、Result を返します。
HomeViewModel はこの Result を受け取り、その中のデータを処理したのち、最終的に notifyListeners を呼び出してサイクルを完了させる、という流れになります。

Dependency injection

各アーキテクチャコンポーネントの責務を明確に定義するだけでなく、コンポーネント間のやり取り方法を考慮することも重要です。これは、コンポーネント間の通信ルールと、それをどのように実装するかという 2 つの側面を指します。アプリのアーキテクチャは以下の質問に答えられる必要があります。

  • どのコンポーネントが、どの他のコンポーネント(同じ種類のコンポーネントを含む)と通信してもよいのか?
  • コンポーネント同士はどのような出力を公開してやり取りを行うのか?
  • あるレイヤーが別のレイヤーと “接続” されるとき、具体的にどのように “配線” するのか?
A diagram showing the components of app architecture.

この図を参考にすると、コンポーネント間のやり取りに関するルールは以下のとおりです。

Component Rules of engagement
View
  1. View はちょうど 1 つの ViewModel のみを知っており、他のレイヤーやコンポーネントを知ることはありません。作成時に、Flutter が引数として ViewModel を View に渡し、ViewModel のデータとコマンドコールバックを View に公開します。
ViewModel
  1. ViewModel は 1 つの View に属し、View はそのデータを参照できますが、ViewModel 側は View の存在を知る必要はありません。
  2. ViewModel は 1 つ以上の Repository を知っています。これらはコンストラクタの引数として渡されます。
Repository
  1. Repository は複数の Service を知る場合があります。サービスはコンストラクタの引数として渡されます。
  2. Repository は多くの ViewModel から利用されますが、Repository 自体が ViewModel の存在を知る必要はありません。
Service
  1. Service は多くの Repository から利用される可能性がありますが、Service 自体がリポジトリ (あるいはその他のオブジェクト) を知る必要はありません。

Dependency injection

ここまでに示したとおり、コンポーネント間のやり取りは入出力を使って行われます。あらゆる場合において、2 つのレイヤー間の通信は、サービスをリポジトリのコンストラクタに渡すなどのように、コンストラクタの引数を使って行われます。

class MyRepository {
  MyRepository({required MyService myService})
          : _myService = myService;

  late final MyService _myService;
}

しかし、まだ扱っていない問題として、オブジェクト生成があります。MyService インスタンスはどこで生成され、それをどのように MyRepository に渡すのでしょうか? これには、dependency injection と呼ばれるパターンが関係します。

Compass アプリでは、依存性の注入 (dependency injection) は package:provider を使って行われています。Google で Flutter アプリを構築してきた経験のあるチームでは、依存性注入を実装するために package:provider を使うことを推奨しています。

サービスやリポジトリは、Flutter アプリのウィジェットツリーの最上位で Provider オブジェクトとして公開されます。

runApp(
  MultiProvider(
    providers: [
      Provider(create: (context) => AuthApiClient()),
      Provider(create: (context) => ApiClient()),
      Provider(create: (context) => SharedPreferencesService()),
      ChangeNotifierProvider(
        create: (context) => AuthRepositoryRemote(
          authApiClient: context.read(),
          apiClient: context.read(),
          sharedPreferencesService: context.read(),
        ) as AuthRepository,
      ),
      Provider(create: (context) =>
        DestinationRepositoryRemote(
          apiClient: context.read(),
        ) as DestinationRepository,
      ),
      Provider(create: (context) =>
        ContinentRepositoryRemote(
          apiClient: context.read(),
        ) as ContinentRepository,
      ),
      // In the Compass app, additional service and repository providers live here.
    ],
    child: const MainApp(),
  ),
);

ここでは、サービスは Provider で公開されますが、すぐにリポジトリへ BuildContext.read 経由で注入するためだけに存在します。リポジトリも同様に公開され、必要に応じて ViewModel に注入されます。

ウィジェットツリーを少し下ると、画面全体に対応する ViewModel が、package:go_router の設定内で作られます。ここでも provider を使って、必要なリポジトリが注入されます。

 // This code was modified for demo purposes.
GoRouter router(
  AuthRepository authRepository,
) =>
    GoRouter(
      initialLocation: Routes.home,
      debugLogDiagnostics: true,
      redirect: _redirect,
      refreshListenable: authRepository,
      routes: [
        GoRoute(
          path: Routes.login,
          builder: (context, state) {
            return LoginScreen(
              viewModel: LoginViewModel(
                authRepository: context.read(),
              ),
            );
          },
        ),
        GoRoute(
          path: Routes.home,
          builder: (context, state) {
            final viewModel = HomeViewModel(
              bookingRepository: context.read(),
            );
            return HomeScreen(viewModel: viewModel);
          },
          routes: [
            // ...
          ],
        ),
      ],
    );

ViewModel や Repository の内部では、注入されたコンポーネントはプライベートにしておくべきです。たとえば、HomeViewModel クラスは以下のようになります。

class HomeViewModel extends ChangeNotifier {
  HomeViewModel({
    required BookingRepository bookingRepository,
    required UserRepository userRepository,
  })  : _bookingRepository = bookingRepository,
        _userRepository = userRepository;

  final BookingRepository _bookingRepository;
  final UserRepository _userRepository;

  // ...
}

プライベートメンバーにすることで、View(ViewModel にアクセス可能)が直接リポジトリのメソッドを呼び出すことを防ぎます。

これで Compass アプリのコード解説は終了です。ここで紹介したのはあくまでアーキテクチャに関わるコードのみであり、すべてを網羅してはいません。実際のユーティリティコードやウィジェットコード、UI のスタイリングなどは省略しました。これらすべてを含めた完成形の例は、Compass アプリのリポジトリを参照してください。これらの原則に従って構築された堅牢な Flutter アプリの例を閲覧できます。

Testing each layer

1つの方法として、アプリケーションのアーキテクチャが適切に設計されているかどうかを判断するには、そのアプリケーションがテストしやすいかどうかを考慮することが挙げられます。ViewModel と View は入力が明確に定義されているため、依存関係を簡単にモックやフェイクに置き換えることができ、単体テスト (ユニットテスト) も容易に作成できます。

ViewModel unit tests

ViewModel の UI ロジックをテストする場合、Flutter のライブラリやテストフレームワークに依存しないユニットテストを書く必要があります。

ViewModel の唯一の依存先はリポジトリ (もし ドメインレイヤー の use-cases を実装していなければ) なので、リポジトリの mocksfakes を用意するだけで済みます。以下のテスト例では FakeBookingRepository と呼ばれるフェイクが使われています。

void main() {
  group('HomeViewModel tests', () {
    test('Load bookings', () {
      // HomeViewModel._load is called in the constructor of HomeViewModel.
      final viewModel = HomeViewModel(
        bookingRepository: FakeBookingRepository()
          ..createBooking(kBooking),
        userRepository: FakeUserRepository(),
      );

      expect(viewModel.bookings.isNotEmpty, true);
    });
  });
}

こちらFakeBookingRepository クラスは、こちら で解説されている BookingRepository インターフェースを実装しています。

class FakeBookingRepository implements BookingRepository {
  List<Booking> bookings = List.empty(growable: true);

  @override
  Future<Result<void>> createBooking(Booking booking) async {
    bookings.add(booking);
    return Result.ok(null);
  }
  // ...
}

Note
ドメインレイヤー の use-cases を使うアーキテクチャの場合、それらのクラスも同様にフェイクを用意する必要があります。

View widget tests

ViewModel 向けのテストを作成したら、同時にフェイクリポジトリも用意できているので、そのままウィジェットテストにも利用できます。以下の例は、HomeScreen ウィジェットのテストがどのように HomeViewModel と必要なリポジトリを使ってセットアップされるかを示しています。

void main() {
  group('HomeScreen tests', () {
    late HomeViewModel viewModel;
    late MockGoRouter goRouter;
    late FakeBookingRepository bookingRepository;

    setUp(() {
      bookingRepository = FakeBookingRepository()
        ..createBooking(kBooking);
      viewModel = HomeViewModel(
        bookingRepository: bookingRepository,
        userRepository: FakeUserRepository(),
      );
      goRouter = MockGoRouter();
      when(() => goRouter.push(any()))
          .thenAnswer((_) => Future.value(null));
    });

    // ...
  });
}

このセットアップでは、2つのフェイクリポジトリを用意し、それらを HomeViewModel に渡しています。HomeViewModel 自体はフェイク化する必要はありません。

Note
コードでは MockGoRouter というものも定義されています。これは package:mocktail を使ってモック化されており、このケーススタディの範囲外の概念です。一般的なテストのガイドラインは Flutterのテストドキュメント を参照してください。

ViewModel とその依存関係を定義したあと、テスト対象のウィジェットツリーを作成します。以下は HomeScreen のテストで用いられる loadWidget メソッドの例です。

void main() {
  group('HomeScreen tests', () {
    late HomeViewModel viewModel;
    late MockGoRouter goRouter;
    late FakeBookingRepository bookingRepository;

    setUp(
      // ...
    );

    void loadWidget(WidgetTester tester) async {
      await testApp(
        tester,
        ChangeNotifierProvider.value(
          value: FakeAuthRepository() as AuthRepository,
          child: Provider.value(
            value: FakeItineraryConfigRepository() as ItineraryConfigRepository,
            child: HomeScreen(viewModel: viewModel),
          ),
        ),
        goRouter: goRouter,
      );
    }

    // ...
  });
}

このメソッドは、testApp と呼ばれる汎用のテストメソッドを呼び出します。Compass アプリではすべてのウィジェットテストでこのメソッドを用います。実装は以下のようになっています。

void testApp(
  WidgetTester tester,
  Widget body, {
  GoRouter? goRouter,
}) async {
  tester.view.devicePixelRatio = 1.0;
  await tester.binding.setSurfaceSize(const Size(1200, 800));
  await mockNetworkImages(() async {
    await tester.pumpWidget(
      MaterialApp(
        localizationsDelegates: [
          GlobalWidgetsLocalizations.delegate,
          GlobalMaterialLocalizations.delegate,
          AppLocalizationDelegate(),
        ],
        theme: AppTheme.lightTheme,
        home: InheritedGoRouter(
          goRouter: goRouter ?? MockGoRouter(),
          child: Scaffold(
            body: body,
          ),
        ),
      ),
    );
  });
}

この関数は、テスト可能なウィジェットツリーを作成するだけの役割を持ちます。

loadWidget メソッドはウィジェットツリーの中で必要となる部分 (今回は HomeScreen とその ViewModel、さらにウィジェットツリー上位に位置するいくつかのフェイクリポジトリなど) を渡しています。

最も重要なポイントは、アーキテクチャが適切に設計されていれば、View や ViewModel のテストを書くときはリポジトリをモック化するだけで済むという点です。

Testing the data layer

UI レイヤーと同様に、データレイヤーのコンポーネントも入出力が明確に定義されており、両方をフェイクに置き換えやすくなっています。特定のリポジトリの単体テストを行うには、リポジトリが依存するサービスをモック化します。以下は BookingRepository のユニットテスト例です。

void main() {
  group('BookingRepositoryRemote tests', () {
    late BookingRepository bookingRepository;
    late FakeApiClient fakeApiClient;

    setUp(() {
      fakeApiClient = FakeApiClient();
      bookingRepository = BookingRepositoryRemote(
        apiClient: fakeApiClient,
      );
    });

    test('should get booking', () async {
      final result = await bookingRepository.getBooking(0);
      final booking = result.asOk.value;
      expect(booking, kBooking);
    });
  });
}

モックやフェイクの書き方について詳しくは、Compass アプリの testing ディレクトリFlutterのテストドキュメントを参照してください。

Feedback

このウェブサイトのセクションは発展途上のため、フィードバックをぜひお寄せください!