くらげになりたい。

くらげのようにふわふわ生きたい日曜プログラマなブログ。趣味の備忘録です。

FlutterのRiverpod App ArchitectureとFeature-firstをちゃんと学んでみた

この記事で出てきたfreature-firstなディレクトリ構成がよさそうだなと思い、
原文をちゃんと読んでみたときの備忘録(*´ω`*)

原文はこちら

原文で書かれていること、ざっくり

フォルダ構成に、Layer-firstとFeature-firstの2パターンがあるけど、
Feature-firstでつくるとよかったよ

というはなし。

ただ、前提として、この記事の筆者(Andreaさん)が考案したアーキテクチャである、
Riverpod App Architectureを採用している

さらに、Presentation Layerの章では、
Andoridのアーキテクチャガイドが参照されている

ちなみに、Flutter公式のApp Architectureはこんな感じ

Flutter App Architecture

共通するコンセプト

いずれもコアとしてあるのは、このあたり

  • 関心の分離(Separation of concerns)
  • 信頼できる唯一の情報源(Single source of truth / SSOT)
  • 単方向データフロー(Unidirectional data flow / UDF)
  • UIはデータモデルで操作する(UI = f(state))

主に、AndroidとFlutterの公式から抜粋

関心の分離

関心の分離(Wikipedia)は、最も重要な原則としてあげられている。

プログラムを関心・責務・役割ごとに分離し、クラスをできる限りシンプルに保つことで、
再利用やテスト、可読性を高くする。

いずれも、階層化アーキテクチャ(Layered architecture)を利用する形になっている

  • UI / Presentation ... アプリの画面など
  • Data ... データをやり取りするDBやAPIなど
  • Domain / Logic ... (任意) UIとDataの間。ビジネスロジックなど

各レイヤの構成要素やDomain層の分割方法とかは、若干際があるが、概ねこの形

信頼できる唯一の情報源

データ型ごとに、そのデータ型の取得や変更を一つの場所(SSOT)にまとめる原則
SSOTのみがデータを変更でき、変更不可(イミュータブル)の型を使って公開する

これにより、以下のメリットがある

  • 特定のデータ型に対するすべての変更を1か所に集約できる
  • 他の型によって改ざんされないようにデータを保護できる
  • データに対する変更が追跡しやすくなり、それによりバグを見つけやすくなる

一般的に、DataレイヤーのRepositoryと呼ばれるクラスに保持され、
データ型ごとに1つのリポジトリクラスが用意される

通常、DBやAPIなどのDataレイヤーがSSOTになるが、
場合によっては、ViewModelやUIがSSOTになることもある

単方向データフロー

「状態(State)」とその状態を表示する「UI」を切り離すのに役立つ設計パターン
状態は一方向にのみ流れ、データを変更するイベントはその反対方向に流れる

UDF

UIからのイベントから開始し、SSOTであるDataLayerからState(DBのデータ)を返す流れ
単一方向なので、データの流れがシンプルになり、間違えにくく、デバッグしやすくなる

また、リポジトリAPIをポーリングして新しいデータを取得したときなど、
Dataレイヤーから開始することもある。

UIはデータモデルで操作する

Flutterは宣言的UIであり、現在の状態をそのままUIとして表示するように構築する
状態が変化に応じて、UIの再構築するため、「UI は状態の関数である」と表現される

あくまで、データがUIを操作するのであり、その逆ではない

Riverpod App Architecture

それを踏まえて、この記事を見ていく

Riverpod自体が、設計や構造化に関する話がないため、
試行錯誤の結果、考えたアーキテクチャらしい

あるあるな悩みがリストアップされている。どれも悩ましい。。。

  • UIと、ビジネスロジックたデータアクセスロジックの分離
  • 用意するクラスとその責務や、クラス間の連携・連動
  • 複数人でそれぞれ機能開発する場合、うまくやるためには
  • フォルダ構成やファイルの整理方法
  • エラーハンドリングはどうするか? どこで処理され、UIにどのように伝播されるか?
  • どのProviderを使うべきか? どこで宣言するすべきか?

Presentation Layer

  • レイヤーの責務: 画面の表示、UIイベントの受け取り
  • Widgets: ページなどのUIコンポーネント
  • States: 画面の状態
  • Controllers: 非同期のデータ変更を実行し、Stateを管理
    • MVVMのViewModelやBLocのcubitと同じ役割
    • AsyncNotifierProviderなどで実装されることが多い

Presentation Layerの解説はこのあたり。コードなども載ってる

ログイン画面の例が載っている

Controllerはこんな感じ

@riverpod
class SignInScreenController extends _$SignInScreenController {
  @override
  FutureOr<void> build() {
    // no-op
  }

  Future<void> signInAnonymously() async {
    final authRepository = ref.read(authRepositoryProvider);
    state = const AsyncLoading();
    state = await AsyncValue.guard(() => authRepository.signInAnonymously());
  }
}

画面はこんな感じで、AsyncValueで状態を受け取り、
onPressedに、Controllerのmethod(signInAnonymously)を割り当てる形

class SignInScreen extends ConsumerWidget {
  const SignInScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // watch and rebuild when the state changes
    final AsyncValue<void> state = ref.watch(signInScreenControllerProvider);
    return Scaffold(
      appBar: AppBar(
        title: const Text('Sign In'),
      ),
      body: Center(
        child: ElevatedButton(
          // conditionally show a CircularProgressIndicator if the state is "loading"
          child: state.isLoading
              ? const CircularProgressIndicator()
              : const Text('Sign in anonymously'),
          // disable the button if the state is loading
          onPressed: state.isLoading
              ? null
              // otherwise, get the notifier and sign in
              : () => ref
                  .read(signInScreenControllerProvider.notifier)
                  .signInAnonymously(),
        ),
      ),
    );
  }
}

Domain Layer

  • レイヤーの責務: アプリ固有のモデルクラスの定義
  • Models: 単純なデータクラス

Domain Layerの解説はこのあたり。コードなども載ってる

Modelだけと書いているが、モデルの変更を楽にする、
ヘルパー関数をextensionで提供する話も載っている

class Cart {
  const Cart([this.items = const {}]);
  /// All the items in the shopping cart, where:
  /// - key: product ID
  /// - value: quantity
  final Map<ProductID, int> items;

  factory Cart.fromMap(Map<String, dynamic> map) { ... }
  Map<String, dynamic> toMap() { ... }
}

/// Helper extension used to update the items in the shopping cart.
extension MutableCart on Cart {
  Cart addItem({required ProductID productId, required int quantity}) {
    final copy = Map<ProductID, int>.from(items);
    // * update item quantity. Read this for more details:
    // * https://codewithandrea.com/tips/dart-map-update-method/
    copy[productId] = quantity + (copy[productId] ?? 0);
    return Cart(copy);
  }

  Cart removeItemById(ProductID productId) {
    final copy = Map<ProductID, int>.from(items);
    copy.remove(productId);
    return Cart(copy);
  }
}

Data Layer

  • レイヤーの責務: DBやAPIなどさまざまな場所へのデータアクセス
  • DataSources: アプリ外との連動に使われるサードパーティ部分
  • DTOs: DataSourceから返されるデータクラス
  • Repositories: SSOTの入口。Repository Pattern

Repository Patternの解説はこのあたり。コードなども載ってる

Repositoryの抽象クラスが必要かどうかの見解が書かれていて、この記事も面白い
(具象クラスが1つだけなら、抽象クラスは不要とのこと)

Application Layer

  • レイヤーの責務: Presentation LayerとData Layerの橋渡し
    • 必要なときのみ。複数の構成要素と依存する場合に使う
  • Services: アプリ固有のロジックの実装
    • 複数のControllerと複数のRepositoryの仲介

Application Layerの解説はこのあたり。コードなども載ってる

Servicesでは、以下は"行わない"

  • Widgetの状態の管理と更新(Controllerの責務)
  • データのパースやシリアライズ(Repositoryの責務)

あくまで、アプリ固有のロジックの実装のみ

Feature-firstでのディレクトリ構成

ここまできて、やっと本題

最終的なプロジェクト構造はこんな感じらしい。
features配下がFeature-firstなディレクトリ構成の部分

‣ lib
  ‣ src
    ‣ common_widgets   ... 共通Widget
    ‣ constants        ... 定数
    ‣ exceptions       ... 例外
    ‣ features
      ‣ authentication ... 認証機能
        ‣ presentation
        ‣ application
        ‣ domain
        ‣ data
      ‣ products       ... 商品機能
        ‣ presentation
        ‣ application
        ‣ domain
        ‣ data
    ‣ localization     ... 多言語対応
    ‣ routing          ... ルーティング
    ‣ utils            ... utils

Feature-firstのメリット

書かれているメリットは、以下の2つ

  • 機能ごとにまとまっているので、1つのフォルダに集中できる
  • 機能を削除するときは、1つのフォルダだけでOK

ただ、

しかし、現実の世界では物事はそれほど簡単ではありません。
However, things are not so easy in the real world.

らしい。。。

機能(Feature)とはなにか

機能は、UIなどユーザが見るものではなく、
ユーザが何をするかとのこと。

たとえば、ECサイトの場合だと、

  • 認証する
  • ショッピングカートを管理する
  • チェックアウト
  • 過去の注文をすべて表示
  • レビューを残す

など。

Feature-firstをうまくやるには

ドメイン駆動設計(DDD)のように、
ドメイン層を中心に編成するとうまくいくらしい

  • ドメインからはじめ、モデルとそれらを操作するビジネスロジックを特定
  • 一緒に属する各モデル(またはモデルグループ)のフォルダを作成
  • そのフォルダにレイヤーごとのサブフォルダを作成(presentationapplicationdomaindata)

良くないフォルダ構成の例

ドメインではなく、画面単位でまとめた場合、
うまくいかなかったらしい

‣ lib
  ‣ src
    ‣ features
      ‣ account
      ‣ admin
      ‣ checkout
      ‣ leave_review_page
      ‣ orders_list
      ‣ product_page  <- 同じproductドメイン
      ‣ products_list <- 同じproductドメイン
      ‣ shopping_cart
      ‣ sign_in

そうではなく、機能の中心となるモデルを起点にすると、
うまくいく、フォルダ構成ができたとのこと

‣ lib
  ‣ src
    ‣ features
      ‣ products
        ‣ application
        ‣ data
        ‣ domain
        ‣ presentation
          ‣ admin
          ‣ product_screen
          ‣ products_list

まとめ

Reverpod App Architectureベースの
Feature-firstなディレクトリ構成について、だいぶ理解できた気がする

実例やコードでの説明もあるので、試しつつ、また参照していくのがよさそう
とはいえ、実際にやるとハマるところが多そうなので、いろいろ試しつつ。。。(*´ω`*)

参考にしたサイト様