脱MVVM! Flutterらしいアーキテクチャとは?

ogp

はじめに

enechain でソフトウェアエンジニアをしている@kkagurazakaです。

enechain が提供している eSquare というプロダクトは、電力を筆頭に、あらゆる企業が様々なエネルギー商品を売り買いできるオンライントレーディングプラットフォームです。そして、そのモバイルアプリは Flutter で開発されています。

今回の記事では、その eSquare アプリで絶賛進行中のリアーキテクチャについて、「何故そのような判断に至ったのか」という点も含めてお伝えします。

これまでのアーキテクチャ

リアーキテクチャ前の eSquare アプリでは、Android のアプリアーキテクチャガイドをベースとした MVVM の構成を採用していました。これは初期メンバーが全員 Android アプリ開発の豊富な経験を持っていたことと、アプリが世に出ていないというゼロ価値状態をいち早く脱するための決定でした。

その結果、スピーディーに対応したことでユーザーの潜在ニーズを引き出して価値を届け、事業的な成果を創出できたため、当時の判断としては正しかったと考えています。

一方でひとまず価値を届けられたので改めて設計についてじっくり考えると、今後とも継続的に開発をしていく上での新たな課題が見えてきました。

課題

メンバーの学習コスト

MVVM はモバイルのネイティブエンジニア、特に Android アプリエンジニアには非常に馴染みがある概念ですが、バックグラウンドが異なる方の場合、新しく勉強が必要になります。もちろん、それが Flutter としてベストであればむしろ学習すべきとなりますが、前述のとおり MVVM の採用はその観点で選択されたものではありません。

今後の採用などを踏まえると、モバイルのネイティブを経験したことのないエンジニアがスムーズに活躍できるようなコードベースになっていることが望ましいです。

宣言的でない記述

eSquare アプリでは ViewModel が持つ状態を初期化するために init() のような初期化メソッドを call once で呼び出していました。しかしながら、このような「初回表示時だけ副作用を実行する」というのは命令的なアプローチであり、宣言的 UI を採用している Flutter にフィットしているとはいえません。

方針

これらの課題を解決するために、どのようなアーキテクチャにすべきかをチームで考えました。議論した 2023 年 7 月時点で、Flutter が公式として推奨するアーキテクチャなどはドキュメントに明示されていません。また、Flutter コミュニティにおいても「広く使われているアーキテクチャはこれだ」というコンセンサスも見つけられませんでした。

そこで、我々は宣言的 UI の先駆者である React の事例を参考にすることにしました。Flutter の API デザインは随所で React の影響を受けているため、アイデアを転用しやすい点も幸いでした。

これからのアーキテクチャ

前述の方針をもとに設計した新アーキテクチャをトピックごとに紹介します。

状態管理

公式ドキュメントの記事 Differentiate between ephemeral state and app state に倣い、ephemeral state と app state に分けて考えます。

ephemeral state

ephemeral state は和訳すると「一時的な状態」となりますが、例えばタブの選択状態や入力中のテキストなど、ユーザーが UI を使う間に維持される状態のことを指します。前述の公式ドキュメントでは StatefulWidget を利用するように書かれていますが、我々は Flutter Hooks を採用することにしました。実装の中身的には StatefulWidget と同等のことを、 StatelessWidget と同じような記法で書くためのものなので、本質的には等価です。

  • StatefulWidgetState にプロパティとして持たせたいものなどは useState を使います
  • TextEditingController などの、状態と変更通知リスナーがセットになっているものも useTextEditingController などを使って Hooks 経由で取得します
    • 自分たちで作った Widget が同じように状態&変更通知リスナーが必要な場合、Controller クラスを定義した上で Hooks も作ります
  • 必要があればカスタム Hooks を作成します
    • eSquare アプリで使っている実例としては、ラムダの実行を debounce する useDebounce や、スクロール末尾に到達したときに特定のコールバックを実行する useInfiniteScrollController などがあります

元となった React でも Hooks が登場するまでは setState が活用されていましたが、今となっては Hooks 一色となっているので、公式ライブラリではないものの Flutter でも積極的に採用できると思います。

app state

app state は ephemeral state ではない状態のことです。ここでは app state を更に API レスポンスのキャッシュである api state と、それ以外を表す global state に分けて考えます。

api state

React では api state を宣言的に表現するためにTanStack QuerySWRがよく用いられます。これらは命令的に API を呼び出してデータをフェッチするのではなく、API 呼び出しの結果となるリソースを宣言的に取り扱うというパラダイムを採用しています。

同様のことを Flutter で行うには、RiverpodFutureProvider を利用します。

@riverpod
Future<Model> detail(DetailRef ref, {required String itemId}) async {
  final apiClient = ref.watch(apiClientProvider);
  return await apiClient.getDetail(itemId: itemId);
}

この Provider は最初に呼び出されたときには getDetail API をコールし、以後そのレスポンスをキャッシュします。利用例は次のようになります。

class _Description extends ConsumerWidget {
  const _Description({
    super.key,
    required String itemId,
  }) : _itemId = itemId;

  final String _itemId;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final theme = Theme.of(context);
    final description = ref.watch(
      detailProvider(itemId: _itemId)
          .select((item) => item.valueOrNull?.description),
    );

    return Text(
      description ?? '',
      style: theme.textTheme.bodyLarge,
    );
  }
}

UI を作成している部分は本質的ではないので読み飛ばしてください。大事なポイントは次の 2 点です。

  • 非同期に取得する item を副作用を使わずに取り扱っている
    • 結果は AsyncValue として表現されるので、レスポンスだけではなく読み込み中かどうか、エラーかどうかなども宣言的に判断可能
  • watch / select によって必要なときに再描画される
    • 再読み込みをした際に API レスポンスの値が変わっていた場合などは、当然画面を更新したいでしょう
    • select を活用すると再描画を最小限に抑えられるので、パフォーマンス的に優位になります。この例では _Description Widget はローディング・エラー表示を責務として持っていないため、「ロード中」→「エラー」と AsyncValue の状態が変わったとしても再描画されません。
global state

global state についても概ね api state と同じで Riverpod を活用します。使うのは StateProvider または NotifierProvider になるでしょう。よくあるカウンターの状態を例にコード例を示します。

final simpleCounterProvider = StateProvider((ref) => 0);

class CounterPage extends ConsumerWidget {
  const CounterPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final theme = Theme.of(context);
    final count = ref.watch(simpleCounterProvider);

    increment() {
      ref.read(simpleCounterProvider.notifier).update((prev) => prev + 1);
    }

    return Scaffold(
        appBar: AppBar(title: const Text('Counter')),
        body: Center(
          child: Text('$count', style: theme.textTheme.headlineMedium),
        ),
        floatingActionButton: FloatingActionButton(
          onPressed: increment,
          tooltip: 'Increment',
          child: const Icon(Icons.add),
        ));
  }
}

このように StateProvider の場合、任意の値を状態として設定可能です。それでも問題ないシーンではこちらを採用するとシンプルでしょう。

一方でカウンターの例では、状態変更は常にインクリメントのみに制限したくなると思います。その場合は NotifierProvider を採用します。

@riverpod
class Counter extends _$Counter {
  @override
  int build() {
    return 0; // 初期値
  }

  void increment() {
    state = state + 1;
  }
}

class CounterPage extends ConsumerWidget {
  const CounterPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final theme = Theme.of(context);
    final count = ref.watch(counterProvider);

    increment() {
      ref.read(counterProvider.notifier).increment();
    }

    return Scaffold(...); // 中身は前と同じ
  }
}

StateProvider で取り扱えるようなシンプルなケースは ephemeral state に該当することの方が多いので、メインで取り扱うのは NotifierProvider になると思います。

状態を統合しない

NotifierProvider を使う上での注意点は、ViewModel のような複数の状態を内包する存在にしないことです。あくまでも 1 つの状態とその変更を行う I/F のみに保ち、利用側が必要なものだけを組み合わせて使えるようにします。

画面に 1 対 1 対応する State Holder はシンプルで扱いやすいですが、複数の画面間で状態を共有する必要がでてきたときに黒魔術が埋め込まれやすいです。状態を画面単位で捉えることをやめ、アプリに必要な状態をグローバルに分割して定義し、必要な場所で必要なものを購読することで、シンプルに状態を管理できます。

その場合気になるのは不要なリソースを確保し続けてしまうことだと思いますが、Riverpod の場合はデフォルトで keepAlive=false となっており、どこからも購読されていない Provider は自動でリソースが解放されます。

更新系の扱い

SNS にメッセージを投稿したり、自身のプロフィール文章を書き換えたりなど、ユーザーの更新アクションに応じて画面が更新されるなどの更新系についても考える必要があります。

更新系の API コールはレスポンスとして更新後のデータを返すパターンが多いです。命令的なアプローチでは、そのレスポンスを使って状態を更新するという書き方をすることになると思います。

一方、 FutureProvider などで状態を宣言的に扱っている場合、そもそも状態が read only なので更新すること自体ができません。この場合、Provider を invalidate して再取得させるのがもっともシンプルな宣言的アプローチです。

class _Body extends ConsumerWidget {
  const _Body({
    super.key,
    required String itemId,
  }) : _itemId = itemId;

  final String _itemId;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final hasValue = ref.watch(
      detailProvider(itemId: _itemId).select((item) => item.hasValue),
    );

    if (hasValue) {
      return SizedBox.expand(
        child: RefreshIndicator(
          onRefresh: () async {
            ref.invalidate(detailProvider(itemId: _itemId));
          },
          child: _ScrollArea(itemId: _itemId),
        ),
      );


    return const Center(child: CircularProgressIndicator());
  }
}

※ 例のため、エラーハンドリングはサボっています

ここで、api cache の Provider を invalidate すると、再読込中にローディング表示が出てしまわないか気になる方もいるのではないでしょうか。しかしながら、Riverpod はそのユースケースについて解を用意しています。

FutureProvider などの AsyncValue を返す Provider は、一度取得に成功または失敗すると、invalidate しても再び AsyncLoading 状態にはなりません。そのため、 isLoading=true かつ hasValue=true といった状態が発生します。上記の例では hasValue のときにコンテンツを描画するようになっているため、再取得中に CircularProgressIndicator が表示されることはありません。適切に select を使って依存する状態を絞り込んでいれば、データが更新されたウィジェットのみ再描画させられるので、画面全体が再読み込みして UX を損ねることはありません。

また、初回読み込み時は CircularProgressIndicator を表示し、再取得中はコンテンツの確認を妨げないような indicator 表示をする、といったことも可能です。

class DetailPage extends ConsumerWidget {
  const DetailPage({
    super.key,
    required String itemId,
  }) : _itemId = itemId;

  final String _itemId;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final isRefreshing = ref.watch(
      detailProvider(itemId: _itemId).select((item) => item.isRefreshing),
    );
    return Scaffold(
      appBar: AppBar(
          title: _Title(itemId: _itemId),
          bottom: isRefreshing
              ? const PreferredSize(
                  preferredSize: Size.fromHeight(1),
                  child: LinearProgressIndicator(),
                )
              : null),
      body: _Body(itemId: _itemId),
    );
  }
}

isRefreshing は初回読み込み時には false で再読込時に true になるプロパティです。このように AsyncValue が持つ各種状態を参照することで、望みの UI を宣言的に記述できます。

また、パフォーマンスや UX の最大化という観点で invalidate を避けたい場合には NotifierProvider のほうを使い、更新の窓口を生やしてあげれば OK です。

ページネーションの扱い

モバイルアプリにおいて、リストのページネーションは頻出パターンです。Web とは異なり、タップによるページ遷移ではなく、スクロールすると自動で要素が追加される「infinite scrolling」が一般的でしょう。

現時点では Riverpod の Provider にはページネーションを直接サポートするものはありません。このようにちょっと複雑な状態を持たせたい場合も NotifierProvider の出番です。

typedef PaginationRef<T> = AutoDisposeNotifierProviderRef<PaginationState<T>>;

typedef Fetcher<T> = Future<List<T>> Function(int page);
typedef FetcherFactory<T> = Fetcher<T> Function(PaginationRef<T> ref);

AutoDisposeNotifierProvider<Pagination<T>, PaginationState<T>>
    paginationProvider<T>(FetcherFactory<T> createFetcher) {
  return NotifierProvider.autoDispose(
    () => Pagination<T>(create: createFetcher),
  );
}

@freezed
class PaginationState<T> with _$PaginationState<T> {
  const factory PaginationState({
    required AsyncValue<List<T>> items,
    @Default(0) int currentPage,
    @Default(false) bool isCompleted,
  }) = _PaginationState;
}

class Pagination<T> extends AutoDisposeNotifier<PaginationState<T>> {
  Pagination({
    required FetcherFactory<T> create,
  }) : _create = create;

  final _initState = PaginationState<T>(items: AsyncValue<List<T>>.loading());

  final FetcherFactory<T> _create;
  Future<List<T>> Function(int page) _fetcher = (page) async => [];
  bool _isInitialized = false;

  @override
  PaginationState<T> build() {
    _fetcher = _create(ref);
    unawaited(_init());
    return _initState;
  }

  Future<void> _init() async {
    if (_isInitialized) {
      return;
    }
    _isInitialized = true;

    // Yield order in event queue
    await Future.sync(() {});

    await _fetch();
  }

  Future<void> fetchNext() async {
    if (state.items.isLoading || state.isCompleted) {
      return;
    }
    await _fetch();
  }

  Future<void> refetch() async {
    if (state.items.isLoading) {
      return;
    }
    state = _initState;
    await _fetch();
  }

  Future<void> _fetch() async {
    state = state.copyWith(
      items: AsyncValue<List<T>>.loading().copyWithPrevious(state.items),
    );

    try {
      final page = state.currentPage + 1;
      final fetchedItems = await _fetcher(page);

      final prevItems = state.items.valueOrNull ?? [];
      final List<T> newItems = [...prevItems, ...fetchedItems];

      state = state.copyWith(
        items: AsyncValue<List<T>>.data(newItems).copyWithPrevious(state.items),
        currentPage: page,
        isCompleted: fetchedItems.isEmpty,
      );
    } catch (e, stackTrace) {
      state = state.copyWith(
        items: AsyncValue<List<T>>.error(e, stackTrace)
            .copyWithPrevious(state.items),
      );
    }
  }
}

紹介したコードは少々長いですが、中身としてはシンプルです。 AsyncValue にリストを持たせておいて、Notifier の fetchNext() が呼ばれるたびに新しいページを fetcth し、追記させています。また、API コールに必要な currentPage も状態として保持させています。

このように、状態とその操作をカプセル化する必要がある場合の多くは NotifierProvider を利用できるので、 FutureProvider で不足だった場合はまず試してみるのがオススメです。

おわりに

今回の記事では、我々が直近取り組んだ Flutter アプリのリアーキテクチャについて説明しました。紹介したアーキテクチャについては、直接参考にしていただいても勿論大丈夫ですが、どんな課題を解決すべきなのかは事業と組織によって異なります。そのため、我々の判断の根拠であったり、アプローチの仕方などが少しでもお役に立てれば幸いです。

また、参考にした React は近年サーバーサイドレンダリング (SSR) が主流になりつつあり、シングルページアプリケーション (SPA) については相対的に発展が遅くなることが予想されます。ネイティブのモバイルアプリはいってしまえば SPA なので、今後は React の後追いをするのではなく、我々 Flutter エンジニアが自ら開拓していく必要があるでしょう。

enechain では、事業拡大のために共に技術力で道を切り拓いていく仲間を募集しています。

herp.careers