🐥

Flutterの初期コードをRiverpod + Freezed + ドメイン駆動設計(DDD) でリファクタする

2023/07/01に公開
6

対象読者

  • Flutterの基礎知識がある
  • Riverpod, Freezedを使ったことがない
  • ドメイン駆動設計(DDD)でのアプリ開発がない

本記事の目的

本記事では各技術の説明を最低限にし、実際に手を動かすことでRiverpod+Freezed+DDDのミニマムな開発が体験できるように執筆しています。

前提条件

  • Flutterプロジェクトを作成していること

パッケージのインストール

Riverpod

以下を参考にパッケージをインストールします

はじめに | Riverpod

pubspec.yamlに以下を追記します

flutter_hooks: ^0.18.0
hooks_riverpod: ^2.1.1

Freezed

以下を参考にパッケージをインストールします

freezed | Dart Package

ターミナルで以下を1行ずつ実行します

flutter pub add freezed_annotation
flutter pub add --dev build_runner
flutter pub add --dev freezed

アーキテクチャ

注意点

ドメイン駆動設計(DDD)の勉強中なので、原則に沿っていない設計が含まれている可能性があります。もし、間違い等ありましたらコメントでご指摘いただけると助かります。

構成

以下のような構成で実装しました。

今回の実装では外部DBや共通ロジックがないので、repositoryディレクトリとutilsディレクトリは作成しませんでした。

└── lib
    ├── domain                    // Domain層
    │   └── **
    │       ├── **_notifier.dart    // UI層の状態管理
    │       ├── **_service.dart   // UI層に必要なビジネスロジック
    │       └── **_state.dart     // UI層で使うstate
    ├── infrastructure           // Infrastructure層
    │   ├── model                 // Model層 (型を定義する)
    │   │   └── ~~
    │   │       └── ~~.dart
    │   └── repository            // 外部DBとの接続部
    │       └── ¥¥
    │           └── ¥¥.dart
    ├── presentation            // UI層 (画面を定義する)
    │   └── **
    │       └── **_page.dart
    ├── utils                   // システム全体で使うロジック
    └── main.dart

UI層作成

初期コードのmain.dartをコメントアウトしたものは以下のようになっています。

main.dart
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

このうち、表示部分に該当するMyHomePageクラスをUI層に移動します。

presentation/my_home/my_home_page.dart
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_freezed_test/domain/my_home/my_home_notifier.dart';
import 'package:riverpod_freezed_test/domain/my_home/my_home_state.dart';

class MyHomePage extends HookConsumerWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  
  Widget build(BuildContext context, WidgetRef ref) {
    final MyHomeState myHomeState = ref.watch(myHomeStateNotifierProvider);
    final MyHomeNoifier myHomeNotifier = ref.watch(myHomeStateNotifierProvider.notifier);
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            Text(
              myHomeState.counter.value.toString(),
              style: Theme.of(context).textTheme.headlineMedium,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => myHomeNotifier.increment(),
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

カウント回数のクラス作成

Freezedでクラスを定義する場合、以下の記述が必須です。

import 'package:freezed_annotation/freezed_annotation.dart';

part 'クラスのファイル名.freezed.dart';

クラスのファイルを作成したら、以下のコマンドを実行します。これにより、クラスのファイル名.freezed.dartが作成されます。

flutter pub run build_runner build

では、カウント回数を表すCounterクラスを定義します。

以下のようにCounterクラスのファイルを作成します。

infrastructure/model/counter/counter.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'counter.freezed.dart';


class Counter with _$Counter {
  const factory Counter({
    required int value,
  }) = _Counter;
}

表示用画面で使うstateのクラス作成

先ほど作成したCounterクラスを使ってMyHomePage用のstateを定義します。

以下のようにCounterのvalueの初期値を0として定義します。

domain/my_home/my_home_state.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:riverpod_freezed_test/infrastructure/model/counter/counter.dart';

part 'my_home_state.freezed.dart';


class MyHomeState with _$MyHomeState {
  const factory MyHomeState({
    (Counter(value: 0)) Counter counter,
  }) = _MyHomeState;
}

Domain層の作成

ボタンタップで+1する処理の作成

まずは、引数で現在のカウンターの数を受け取り、それに+1する関数をserviceファイルに定義します。

domain/my_home/my_home_service.dart
int increment(int value) {
  return value + 1;
}

次に、上記の関数をMyHomeServiceクラスでカプセル化します。

domain/my_home/my_home_service.dart
class MyHomeService {
  int increment(int value) {
    return value + 1;
  }
}

次にRiverpodのProviderを使ってMyHomeServiceのインスタンスを全ファイルから参照できるようにします。

domain/my_home/my_home_service.dart
import 'package:hooks_riverpod/hooks_riverpod.dart';

final myHomeService = Provider.autoDispose((ref) => MyHomeService());

class MyHomeService {
  int increment(int value) {
    return value + 1;
  }
}

myHomeServiceはMyHomePageのみで参照できればいいので、ProviderにautoDispose修飾子をつけ、参照がなくなった後(ex. 別ページに遷移する)キャッシュを削除するようにします。

notifierの作成

notifierファイルでは画面の状態管理を行います。

具体的には先ほど作成したMyHomeStateのインスタンスの状態管理を行うのですが、ここでRiverpodのStateNotifierProviderを使います。

StateNotifierProviderはその名前の通り、StateNotifierのキャッシュ及び共有を行います。

StateNotifierは、変数の状態管理及び更新を行うためのクラスです。immutable(不変)であることが大きな特徴であり、変数の更新はStateNotifier内で定義された関数でしか行えないようになっています。

以下のように記述することで、MyHomeStateのインスタンスをimmutableに管理することができます。

domain/my_home/my_home_notifier.dart
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_freezed_test/domain/my_home/my_home_state.dart';

class MyHomeNoifier extends StateNotifier<MyHomeState> {
  MyHomeNoifier() : super(const MyHomeState());
}

StateNotifierは引数を持つことができます。

今回はserviceで定義した関数を利用するため、MyHomeService型の引数を定義します。

domain/my_home/my_home_notifier.dart
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_freezed_test/domain/my_home/my_home_service.dart';
import 'package:riverpod_freezed_test/domain/my_home/my_home_state.dart';

class MyHomeNoifier extends StateNotifier<MyHomeState> {
  MyHomeNoifier({required MyHomeService myHomeService})
      : _myHomeService = myHomeService,
        super(const MyHomeState());

  final MyHomeService _myHomeService
}

次にStateNotifierProviderを使い、MyHomeNoifierをキャッシュしつつ、全ての場所から参照できるようにします。

domain/my_home/my_home_notifier.dart
final myHomeStateNotifierProvider = StateNotifierProvider.autoDispose<MyHomeNoifier, MyHomeState>((ref) {
  return MyHomeNoifier(
    myHomeService: ref.watch(myHomeService),
  );
});

class MyHomeNoifier extends StateNotifier<MyHomeState> {
  MyHomeNoifier({required MyHomeService myHomeService})
      : _myHomeService = myHomeService,
        super(const MyHomeState());

  final MyHomeService _myHomeService
}

引数に代入しているref.watch(myHomeService)により、先ほど定義したmyHomeServiceを参照できます。

Providerの参照は以下の2通りがありますが、基本的にはref.watchを使います。

ref.watch(Provider)
ref.read(Provider)

次に、+ボタンがタップされた時の処理を定義します。

StateNotifierではstateで現在保持している値を参照できます。

また、stateに値を代入することで保持している値を更新できます。

今回は、incrementという関数で更新されたCounterをstateに代入します。

domain/my_home/my_home_notifier.dart
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_freezed_test/domain/my_home/my_home_service.dart';
import 'package:riverpod_freezed_test/domain/my_home/my_home_state.dart';

final myHomeStateNotifierProvider = StateNotifierProvider.autoDispose<MyHomeNoifier, MyHomeState>((ref) {
  return MyHomeNoifier(
    myHomeService: ref.watch(myHomeService),
  );
});

class MyHomeNoifier extends StateNotifier<MyHomeState> {
  MyHomeNoifier({required MyHomeService myHomeService})
      : _myHomeService = myHomeService,
        super(const MyHomeState());

  final MyHomeService _myHomeService;

  void increment() {
    final counter = state.counter;
    final newCounter = _myHomeService.increment(counter);
    state = state.copyWith(counter: newCounter);
  }
}

UI層とnotifierの繋ぎこみ

先ほど定義したnotifierをMyHomePageに反映します。

RiverpodをWidget内で参照するには、ConsumerWidget等の専用のWidgetクラスを使用する必要があります。

まずは、my_home_page.dartを以下のように書き換えます。

presentation/my_home/my_home_page.dart
class MyHomePage extends ConsumerWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      ...
    );
  }
}

次に、先ほど定義したmyHomeStateNotifierProviderを呼び出します。

以下をScaffoldの前で定義します。

myHomeStateではStateNotifierで管理しているstate(Counter)を参照しています。

また、myHomeNotifierではStateNotifier自体を参照しています。StateNotifierの関数を呼び出す際はmyHomeNotifierを利用します。

final MyHomeState myHomeState = ref.watch(myHomeStateNotifierProvider);
final MyHomeNoifier myHomeNotifier = ref.watch(myHomeStateNotifierProvider.notifier);

次に、カウント数の表示部分とボタンタップ時の処理を以下のように書き換えます。

Text(
-  '$_counter',
+  myHomeState.counter.value.toString(),
  style: Theme.of(context).textTheme.headlineMedium,
),
floatingActionButton: FloatingActionButton(
-  onPressed: _incrementCounter,
+  onPressed: () => myHomeNotifier.increment(),
  tooltip: 'Increment',
  child: const Icon(Icons.add),
),

以上で実装は完了です。
動作確認を行い、初期コードと同様にタップ回数がカウントされていればOKです!

おわりに

ソースコードを以下で公開しています。
ご不明点等あればお気軽にコメントください!
https://github.com/fumiyadoi/riverpod_freezed_test

Midman - 技術ブログ

Discussion

JboyHashimotoJboyHashimoto

DDDとは何かについての説明がわかりやすかったです。とても参考になりました。

fumiya_doifumiya_doi

フィードバックをいただきありがとうございます!
DDDについての説明が分かりやすかったとのこと大変嬉しく思います。
DDDは複雑な概念を扱うため、それを分かりやすく伝えることを目指してました!

他に疑問点や質問があれば、遠慮なくお知らせください!
これからも有用な情報を提供できるよう努めます。
ご感想をいただきありがとうございました!

ぬこ丸ぬこ丸

Riverpodを使用している時点で独自の設計を入れるのはアンチパターンに思えます。

https://docs-v2.riverpod.dev/docs/introduction
にあるように

Riverpod (anagram of Provider) is a reactive caching framework for Flutter/Dart. It can automatically fetch,
cache, combine and recompute network requests, while also taking care of errors for you.

つまりネットワーク リクエストを自動的にフェッチ、キャッシュ、結合、再計算すると同時に、エラーの処理も行います。これがRiverpodの最大の利点です。

Riverpodは独自のオレオレ設計が入る隙がないくらいよくできたフレームワークです。
公式のドキュメントもよくできておりそれに沿って組むだけ。

基本形は
FutureProvider/StreamProvider/StateProvider
でほとんど組めて
フォームとpostがセットになっているような場面など多くの機能を備えた画面に NotifierProvider を使用するかと思います。

サンプルにある
Widget build直下にある

final MyHomeState myHomeState = ref.watch(myHomeStateNotifierProvider);
final MyHomeNoifier myHomeNotifier = ref.watch(myHomeStateNotifierProvider.notifier);

このあたりはScaffoldをまるごと更新してしまい、パフォーマンスが著しく低くなるように思えます。
その為リアクティブな処理は最小限の範囲で Widgetを分けるなどしないと後に負債となります。
サンプルであってもScaffoldの上で ref.watchを入れるのは抵抗があります。

さらに開発がすすみ、myHomeStateが肥大化していったら、あるあるの神Stateの誕生です。
どこでどう stateとview間で更新しているかわからなくなってしまいます。
よってWidgetごとに役割を切り分け疎結合にすることをおすすめします。

リアクティブな箇所は

Text(
     myHomeState.counter.value.toString(),
     style: Theme.of(context).textTheme.headlineMedium,
)

ここと

FloatingActionButton(
    onPressed: () => myHomeNotifier.increment(),
    tooltip: 'Increment',
    child: const Icon(Icons.add),
)

ここのみを
ConsumerWidgetを使用して
Widgetに切り離してあとはすべてStateWidgetにするのが正しそうに思います。

この例だとリアクティブな値の型はint型なので StateProviderで事足りるかと思います。
これも公式のRiverpodドキュメントにも書いてあるように

数値型、例えばページネーションのページ数やフォームの年齢など

と明記されているので用途はこれな気がします。

Providerの使用場面は主に値をキャッシュするために使用する。
NotifierProviderがもともとある状態で特定の値を保持するときに威力を発揮します。
ドキュメントにも

reducing rebuilds of providers/widgets without having to use select.

と書いてあるので今回のようにScaffoldをまるごと更新は意図しない用途に思えます。
よってこの場合NotifierProviderが適しているように思えます。しかしながら単純な場合は
簡潔に書ける StateProviderで事足りると感じました。

もう少し複雑な型 List<Custom> のような型はアプリ開発において fetch, promiseが多い型なので
FutureProvider/StreamProvider/AsyncNotifier
が必ず出てくるかと思われます。
その際は
Model -> Repository -> Usecase > FutureProvider > Widget
の流れに書けばしっかりテストもかけるかと思われます。

よって実際の現場ではサンプルのようなMyHomeServiceは出現は少なそうに思えます。

hooks_riverpodを入れていますが useXXXX系の処理がないのでriverpodで良いように思えます。
hooksを使用すると複雑になり、かつては中継するようなendpointで使用していましたが1.x系からFutureProviderがすべて解決しているのであまり出番がない。むしろ現時点でhooksを導入しているプロジェクトはつらい設計が多い。

個人的にはFlutter開発においてDDDは不向きと考えます。
アプリはドメイン全体の中のプレゼンテーション層でしかないため矛盾が生じます。

fumiya_doifumiya_doi

コメントありがとうございます!

to ぬこ丸様
ご指摘の通りレンダリング範囲を限定する観点が抜けています。また、それにより拡張時の負債となるリスクについても同意です。
こうしたリスクを周知するために記事のはじめに注釈を追加させていただきました。

また、代替としてFutureProviderを用いた設計をご提案いただきありがとうございます。
このアーキでは開発経験がないのでキャッチアップしていこうと思っています。

to Noriaki Handa様
なるほど、discordでRiverpodの最新情報を追えるのですね...!
公式ドキュメントにはまだご指摘の内容は反映されていないようなので、本記事ではご指摘内容への対応については保留とさせていただきます。