0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Flutterプロジェクト 自分用のテンプレートを公開した ~Applicatin層編~

Last updated at Posted at 2024-01-30

概要

約半年間、独学でFlutterを勉強しました。
自分なりに開発の型が見えてきたのでGitのリポジトリと記事を残したいと思います。
本記事はそのApplication層編です。



flutterプロジェクトの開始時に作成されるサンプルアプリを置き換えたもので、基本的な仕様やUIは一緒ですが、カウントを1つ進めるたびにsharedPreferencesに現在のカウントが保存されるようになっています。


デザインパターン

こちらの大変わかりやすい記事を参考にさせていただきました!

レイヤードアーキテクチャに関しては、こちらの記事内で詳しく(かつ非常にわかりやすく!)説明がされているので、こちらをご参照ください。



ディレクトリ構成

lib
├ features/
│ ├ common_utils/
│ └ #feature-1
│ ├ application/
│ │ ├ state/
│ │ └ usecase/
│ ├ domain/
│ └ infrastructure/
├ presentation/
└ main.dart


state

アプリ内で使用する値をriverpodで提供する処理が置いてあるです。
このテンプレートではDomain層でfreezedを用いて定義されたCount型の値を提供するcountProviderがあります。

\lib\features\counter\application\state\count_provider.dart
/// countを保持するプロバイダー
final countProvider = FutureProvider.autoDispose<Count>(
  (ref) async {
    //repositoryからcountを取得する
    final Count? count = await ref.read(counterRepositoryProvider).fetch();

    //countが取得できない場合は初期値を生成する
    return count ?? CountCreator.create();
  },
);
final Count? count = await ref.read(counterRepositoryProvider).fetch();

ここでcounterRepositoryから非同期でcountを取得しています。
.fetchにはsharedPreferencesからデータを取り出す処理が書かれており、初回起動時などデータが存在しない場合にはnullが返ってくるため、nullを許容して受けてあげています。

return count ?? CountCreator.create();

データが存在しない場合には初期値として新しくCountを作成して返却しています。

FutureProviderを利用すると、初期化メソッド内で非同期関数が利用できます。
returnしている値はCount型ですが、実際にpresentation層でcountProviderを呼び出したとこではAsyncValue型になるため、.whenや.valueOrNullなどでCountを取り出してあげる必要があります。
.autoDisposeとすることで、画面遷移などでproviderが使用されなくなると自動的に破棄してくれます。再度countProviderを呼び出すと再びこの初期化メソッドが呼び出されます。

usecase

usecaseにはUIから呼び出されるビジネスロジックが記述されています。

\lib\features\counter\application\usecase\counter_usecase.dart
final counterUsecaseProvider =
    Provider.autoDispose<CounterUsecase>(CounterUsecase.new);

class CounterUsecase with RunUsecaseMixin {
  final Ref _ref;
  CounterUsecase(
    this._ref,
  );
  CounterRepository get _counterRepository =>
      _ref.read(counterRepositoryProvider);

  StateController<bool> get _loadingController =>
      _ref.read(overlayLoadingProvider.notifier);

  //providerの初期化(再読み込み)
  void _invalidateCountProvider() => _ref.invalidate(countProvider);

  //実行されるとcountを1増やし、repositoryに保存する
  Future<void> countUp({required Count count}) async {
    await execute(
        //loadingControllerを渡しているため、実行中はローディング表示される
        loadingController: _loadingController,
        action: () async {
          //valueを更新したCountを作成
          final newCount =
              CountUpdator.update(count: count, newValue: count.value + 1);

          //1秒待つ
          await Future.delayed(const Duration(seconds: 1));

          //値を更新したCountを保存
          await _ref.read(counterRepositoryProvider).saveCount(newCount);
        });
    //countProviderを更新
    _invalidateCountProvider();
  }

  Future<void> save({required Count count}) async {
    await execute(
        //loadingControllerを渡しているため、実行中はローディング表示される
        loadingController: _loadingController,
        action: () async {
          //1秒待つ
          await Future.delayed(const Duration(seconds: 1));
          //countを保存
          _counterRepository.saveCount(count);
        });
  }
}

countUpとsaveというメソッドを持つCounterUsecaseというクラスをproviderを通じて提供しています。

Count型の値の更新

freezedで提供されるCount型の更新はCountUpdatorを通じて実行します。

\lib\features\counter\domain\features\count_updater.dart
class CountUpdator {
  /// updatedAtをかならず現在時刻で更新するために更新時は必ずこのクラスから生成する
  static Count update({
    required Count count,
    int? newValue,
  }) {
    final DateTime now = DateTime.now();
    final Count newCount = count.copyWith(
      updatedAt: now,
      value: newValue ?? count.value,
    );
    return newCount;
  }
}

usecase内で直接.copyWithしてしまっても問題ないのですが、updatedAtは必ず現在時刻にしてあげたいです。このようにupdate関数を利用してあげることでupdatedAtの値が必ず現在時刻で更新することが保証されるので安心です。
countProvider内のCountCreator.create()も同様ですね!

RunUsecaseMixin

usecaseにwithしてあげることで、RunUsecaseMixinに記述したメソッドを使いまわすことができます。

\lib\features\common_utils\run_usecase_mixin.dart
/// ユースケース実行のためのメソッドを備えた Mixin
mixin RunUsecaseMixin {
  Future<T> execute<T>({
    //通常はoverlayLoadingProviderを指定する
    StateController<bool>? loadingController,
    //usecaseで実行する処理を指定する
    required Future<T> Function() action,
  }) async {
    //loadingontrollerが渡されているかを判定
    final disableLoading = loadingController == null;

    //loadingControllerが渡されていない場合
    if (disableLoading) {
      try {
        //actionを実行して処理を終了
        return await action();
      } catch (e) {
        //actionでエラーが発生した場合は、そのままrethrow
        //presentation層でMixinを利用hしている場合、そちらでエラーハンドリング(snackbarの表示含む)を行う
        rethrow;
      }
    }

    //loadingControllerが渡されている場合
    //loadingControllerをtrueにする
    //app.dartでstackされているLoadingScreen()が表示される
    loadingController.update((_) => true);
    try {
      //actionを実行
      return await action();
    } catch (e) {
      //actionでエラーが発生した場合は、そのままrethrow
      //presentation層でMixinを利用hしている場合、そちらでエラーハンドリング(snackbarの表示含む)を行う
      rethrow;
      //actionの成否にかかわらずloadingControllerをfalseにする
    } finally {
      loadingController.update((_) => false);
    }
  }
}

executeというメソッドを持っています。
usecaseのメソッドをexecuteを通して実行してあげることで、UIにローディング画面を表示したりログ出力したりの処理をusecase内で共通化できます。

こちらの実装ではactionで実行する関数を、ladingControllerでStateControllerを引数として受けとるようにしてあり、loadingControllerが渡された場合にはactionの実行前にcontrollerの値をtrueに、処理が終了したらfalseに戻す処理をしています。
これにより処理が実行されている間のみ以下のウィジェットが表示されるようになっています

\lib\presentation\page\loading_page.dart
class LoadingScreen extends ConsumerWidget {
  const LoadingScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final isLoading = ref.watch(overlayLoadingProvider);

    //loadingの状態がtrueの場合のみインジケーターを表示する
    return isLoading
        ? Container(
            decoration: const BoxDecoration(color: BrandColor.loadingBgColor),
            child: const Center(
              child: CircularProgressIndicator(
                color: BrandColor.mainColor,
              ),
            ))
        : Container();
  }
}

isLoadingがtrueの場合はCircleProgressIndecatorを表示し、falseの場合は空のContainer()を表示しています

エラーハンドリング

エラーをcatchするとrethrowします。
usecaseのメソッドはpresentation層のPresentationMixinのexecuteを通じて実行されています。
以下はCounterUsecase.countUp()呼び出し元のwidgetです

\lib\presentation\page\counter\components\count_up_floating_action_button.dart
//押すとカウントを増やすボタン
class CountUpFloatingActionButton extends ConsumerWidget
    with PresentationMixin {
  final Count count;
  const CountUpFloatingActionButton({
    super.key,
    required this.count,
  });

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return FloatingActionButton(
      onPressed: () async {
        //mixinのexecuteを使ってカウントを増やす処理を実行する
        execute(context, action: () async {
          await ref.read(counterUsecaseProvider).countUp(count: count);
        },
            //成功時のメッセージ
            //失敗時のメッセージはrepositoryのAppExeptionから渡ってくる
            successMessage: countSaveSuccess);
      },
      tooltip: floatingActionButtonTooltip,
      child: const Icon(Icons.add),
    );
  }
}

presentation層のwidgetでもusecaseと同様にPresentationMixinをwithする事でPresentationMixinのexecuteを使用できるようにしています。

\lib\features\common_utils\presentation_mixin.dart
/// プレゼンテーション層用のエラーハンドリングをラップした共通処理 Mixin
mixin PresentationMixin {
  Future<void> execute(
    BuildContext context, {
    required Future<void> Function() action,
    required String successMessage,
  }) async {
    final scaffoldMessenger = ScaffoldMessenger.of(context);
    try {
      await action();
      //actionを実行した後に成功した場合は、SuccessSnackBarを表示する
      SuccessSnackBar.show(
        scaffoldMessenger,
        message: successMessage,
      );
    } on AppException catch (e) {
      //失敗した場合は、FailureSnackBarを表示する
      FailureSnackBar.show(
        scaffoldMessenger,
        message: e.toString(),
      );
    }
  }
}


RunUsecaseMixinのexecuteでrethrowされたエラーはpresentationMixinのexecuteで再度catchされます。
AppExceptionというエラーを扱うクラスでcatchされ、snackbarの表示を実行します。
repositoyで発生したエラーはusecaseのexecuteからrethrowされ、presentationのexecuteで再度catchされるという流れになります。
少しややこしいですが、うまく使うとかなりコードがスッキリする気がしますね!

providerの初期化

void _invalidateCountProvider() => _ref.invalidate(countProvider);

とすることで、countProviderの初期化メソッドを任意のタイミングで再度実行することができます。
countUpメソッド内では、カウントを+1したcountをsharedPreferencesに保存し、.invalidateすることで再度countProvider初期化メソッドを実行しsharedPreferencesからデータを取得しなおし値を更新するという処理になっています。
あまり重い処理には向かないような気がしますが、記述がスッキリするので私はよく使っています。


おわりに

Application層編は以上になります。
以下の記事も併せてよろしくお願いします!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?