UUUO Tech Blog

水産流通のDXや業務効率化を実現するアプリケーション「UUUO」「atohama」を開発・展開している株式会社ウーオのテックブログです。プロダクトにまつわる様々な情報発信を行います。

Flutter アプリのアーキテクチャを見直してみました

こんにちは!
ソフトウェア開発者の石田 (@geckour) です。

今回は Flutter のアーキテクチャの見直しを始めてみた話をしたいと思います。

目次

これまで

これまでの UUUO の Flutter アプリのアーキテクチャは、Android 出身者が多かったためか、MVVM でやってきた人も馴染みやすい MVC + Repository パターンを採用し、Riverpod を主に Controller や Repository の DI のために利用、Flutter Hooks を UI の状態保持に利用していました。

UUUO アーキテクチャ (MVC)
UUUO アーキテクチャ (MVC)

しかし、コントローラは基本的に画面に対して1対1で作っていたため、コードが成長するに連れコントローラが肥大化して見通しが悪くなっていき、また再利用性が低くなっているという問題を抱えていました。

ex) ログイン処理が様々なログイン方法の各画面に散らばっていたり、フォロー処理は様々な場所で行われるがその画面の数だけ定義されていたり…

そこで、チームで議論を重ねた結果、小さな箇所から我々に最適なアーキテクチャを探って試していくことになりました。

アーキテクチャの検討

上記の見通しが悪い、再利用性が低いという問題点を解決するため、

  • 状態操作系のロジックを小さく切り出して閉じ込める (Widget になるべく近くする)
  • コントローラはデータの取得やアクションを担当する

という設計を目指すことにしました。
この設計は結果として宣言的 UI フレンドリーなものでもあると言えると思います。

公式が出している決定的なリファレンスがないので参考にできるものを探しにネットの海に潜っていたところ、とても良い記事 を見つけたので我々も追従してみることにしました。

アーキテクチャ概説

新しいアーキテクチャは React の設計を参考に、CQRS + Repository パターンを採用し、実現にはこれまで通り Riverpod と Flutter Hooks を使っています。

UUUO アーキテクチャ (CQRS)
UUUO アーキテクチャ (CQRS)

View は Query を介して描画に必要な情報を宣言的に購読し、必要に応じて Query に情報取得を要求したり、Mutation を発行することで状態の変化を View の外部に伝達したりします。
Mutation によって描画に必要な情報が変化した場合も、Query からの購読で伝達されます。

巨大だったコントローラも、各メソッドをそれぞれ Mutation として分解したことでコントローラ自体を消滅させることが出来ました。

以下は、Query/Mutation とそれを支えるクラスのサンプルコードです。

async_state.dart

part 'async_state.freezed.dart';

/// Riverpod の `AsyncValue` を、よりプロジェクトにとって扱いやすい形に拡張したクラス
/// 値の取得状態や処理の実行状態を型安全に表現する
@freezed
class AsyncState<T> with _$AsyncState<T> {
  /// データが読み込まれていない状態
  const factory AsyncState.ready() = AsyncStateReady<T>;

  /// データの読み込みが進行中の状態
  const factory AsyncState.loading(T? value) = AsyncStateLoading<T>;

  /// データの読み込みに成功した状態
  const factory AsyncState.success(T value) = AsyncStateSucces<T>;

  /// データの読み込みに失敗した状態
  const factory AsyncState.failure(
    AppError error, [
    T? value,
  ]) = AsyncStateFailure<T>;

  const AsyncState._();

  T? get valueOrNull =>
      asOrNull<AsyncStateLoading<T>>()?.value ??
      asOrNull<AsyncStateSucces<T>>()?.value ??
      asOrNull<AsyncStateFailure<T>>()?.value;

  AppError? get errorOrNull => asOrNull<AsyncStateFailure<T>>()?.error;
}

query.dart

abstract class Query<T extends Object> {
  // 以下の記事の `BehaviorStreamNotifier` を利用しています
  // https://aakira.app/blog/2021/04/behavior-subject-stream
  late final _streamNotifier = BehaviorStreamNotifier<AsyncState<T>>();

  Future<AsyncState<T>>? _currentFuture;

  Stream<AsyncState<T>> get stream => _streamNotifier.stream;

  Future<T> doWork();

  StreamSubscription<AsyncState<T>> listen(
    void Function(AsyncState<T> element) onData,
  ) {
    return stream.listen(onData);
  }

  Future<AsyncState<T>> refresh() async {
    if (_currentFuture != null) {
      return _currentFuture!;
    }

    final query = _newQuery();
    _currentFuture = query;

    return query.whenComplete(() {
      _currentFuture = null;
    });
  }

  void clear() {
    _streamNotifier.add(const AsyncState.ready());
  }

  Future<AsyncState<T>> _newQuery() async {
    final latestValue = _streamNotifier.latestValue?.valueOrNull;
    _streamNotifier.add(AsyncState.loading(latestValue));

    late final AsyncState<T> result;
    try {
      result = AsyncState.success(await doWork());
    } on Exception catch (e, stackTrace) {
      result = AsyncState.failure(
        AppError.from(e, stackTrace),
        latestValue,
      );
    }
    _streamNotifier.add(result);

    return result;
  }
}

mutation.dart

abstract class Mutation<P extends MutationParams> {
  // 現状では値を返すことがないので値の型は void としている
  late final _streamNotifier = BehaviorStreamNotifier<AsyncState<void>>();

  Stream<AsyncState<void>> get stream => _streamNotifier.stream;

  Future<void> doWork(P params);

  StreamSubscription<AsyncState<void>> listen(
    void Function(AsyncState<void> element) onData,
  ) {
    return stream.listen(onData);
  }

  Future<AsyncState<void>> call(P params) async {
    _streamNotifier.add(
      const AsyncState.loading(
        null,
        isRefresh: false,
      ),
    );

    late final AsyncState<void> result;
    try {
      result = AsyncState.success(await doWork(params));
    } on Exception catch (e, stackTrace) {
      result = AsyncState.failure(AppError.from(e, stackTrace));
    }
    _streamNotifier.add(result);

    return result;
  }
}

mixin MutationParams {}

アーキテクチャの効果

アーキテクチャ導入によって、

  • ロジックの小さな単位への切り出しや処理の流れの簡略化による見通し向上
  • 再利用性の向上
  • データ取得効率の向上

といった効果が得られました。

また、見通しが良くなったことに付随して、不要な処理や冗長な処理が見つけやすくなり、結果としてリファクタリングも捗りました。
今までは画面と1対1のコントローラを作っていたので、似たような処理があっても都度それぞれの画面のコントローラに書いていましたが、新アーキテクチャではそれぞれの処理ごとにクラスが独立したので再利用性も上がりました。
各処理が独立したことによって、キャッシュ戦略も立てやすくなり、データの取得効率も上がったと感じています。

一方で、ボイラープレートがやや大きくなってしまうという欠点もありましたが、トレードオフとして問題なく許容できる程度だと捉えています。

これから

まだまだ小さな箇所に導入したばかりなので、今後はこれを色々な箇所に展開していき、また何か問題にぶつかったら皆で議論しながら解決していきたいと思います。
その時には続編を書きたいと思うのでご期待ください!

まとめ

Flutter アプリ開発において設計に対する公式リファレンスは見当たらず、MVVM や MVC などをそのまま採用してもそれぞれツラみがあったりします。
そこで、React の設計を参考にして宣言的 UI フレンドリーな設計を模索した結果、現時点でかなりしっくり来る設計を導き出せたことをお伝えしました。

最後に

ウーオでは水産流通を革新するため、プロダクトを通じてあらゆるアプローチをしています。ウーオの事業やプロダクト開発にご興味がある方は、以下をぜひご覧ください 👇

uuuo.co.jp

◆カジュアル面談はこちらから◆ / 株式会社ウーオ

各ポジションの募集要項は以下をご覧ください 👇

<業務委託スタート可>ソフトウェアエンジニア(Mobile Application/Flutter) / 株式会社ウーオ

<業務委託スタート可>ソフトウェアエンジニア(Frontend/Flutter on the Web) / 株式会社ウーオ

<業務委託スタート可>ソフトウェアエンジニア(Backend/Ruby on Rails) / 株式会社ウーオ

それでは!