概要
約半年間、独学で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があります。
/// 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から呼び出されるビジネスロジックが記述されています。
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を通じて実行します。
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に記述したメソッドを使いまわすことができます。
/// ユースケース実行のためのメソッドを備えた 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に戻す処理をしています。
これにより処理が実行されている間のみ以下のウィジェットが表示されるようになっています
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です
//押すとカウントを増やすボタン
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を使用できるようにしています。
/// プレゼンテーション層用のエラーハンドリングをラップした共通処理 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層編は以上になります。
以下の記事も併せてよろしくお願いします!