エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

RiverpodとFlutter Hooksで作る、宣言的UIに適したFlutterアーキテクチャ

デジスマチームの荒谷(@_a_akira)です。最近の特技は、家の外観1枚と内装4,5枚の写真から施工したハウスメーカーを特定することです。AIにはまだ負けません。

この記事はデジスマチームブログリレーの10日目の記事です。

普段はデジスマ診療のアプリ開発を担当していますが、今年の初めから新規事業のアプリをFlutterで開発しました。このアプリは、エムスリーで7つ目のFlutterアプリとなります。

新規開発にあたり、国内外の設計事例やカンファレンスを調査しましたが、「Flutterのアーキテクチャにおける正解は何か」という問いに、未だ明確な答えがないのが現状だと認識しています。

デジスマのアプリは5年前の2020年頃に開発したため、当時はまだBLoCやProviderが主流でした。同時期にRiverpodが登場し、今後の主流になるだろうという潮流があったことを記憶しています。また、当時はネイティブアプリ開発で主流だったMVVMをFlutterでも採用する流れも出始めた頃で、私自身がAndroidエンジニア出身でMVVMに馴染みがあったことから、デジスマアプリではMVVMを採用しました。

その後、他社でもMVVMの採用事例は増え、公式ページでも解説されるなど、Flutterアーキテクチャの有力な選択肢の1つとして認知されています。しかし、宣言的UIであるFlutterにMVVMを適用することには否定的な意見もあり、依然として「Flutterのアーキテクチャにおける正解は何か」という議論は続いています。

この記事では、今回新規開発したアプリで採用した設計を解説し、MVVMと比較しながらその特徴を考察します。

アーキテクチャ

クライアントアプリの設計は、突き詰めると「状態管理」と「UI制御」をどう組み合わせるかに行き着きます。そのため、状態管理の方針がアーキテクチャ全体に大きな影響を与えます。

今回採用したのは、状態を次の2種類に分け、それぞれに適したライブラリで管理するアプローチです。

  • App State (Global state): アプリ全体で共有される状態
    riverpodで管理
  • Ephemeral state (Local state): Widget内で完結する状態
    flutter_hooksで管理

このアプローチ自体は、Flutter公式ドキュメントでも言及されています。

この構成は最近のFlutter開発では比較的よく見られるパターンかと思いますが、図にすると次のようになります。

MVHR

基本的な構造はMVVMと似ており、UI LayerとData Layerの2層に分かれています。その間を riverpodflutter_hooks を使って接続する構成です。プロジェクトによってはビジネスロジックを担うUse Case層(Logic Layer)を設けることもあります。

MVVMではViewModelが画面(View)ごとに作られるのに対し、この設計ではGlobal/Local stateがViewとは独立して存在しており、画面内の各Widgetがそれぞれの値を扱います。そのため、各Stateは複数の画面から呼ばれる可能性があります。ただし、Local stateはWidgetのライフサイクルで管理されます。

現状、このアーキテクチャに特定の名前はないようで、「Flutter + riverpod」「Flutter + hooks」のようにライブラリ名で呼ばれることが多い印象です。厳密には既存のどのアーキテクチャにも当てはまらないため、この記事では便宜上 MVHR (Model-View-Hooks-Riverpod) と呼ぶことにします。

コード解説

User情報をバックエンドから取得し、Viewに表示する一連の流れを例に解説します。

Data layer

データやデータソースの役割ごとにファイルを分割しています。ここは一般的な構成なので、詳細な説明は省略します。 新規アプリではOpenAPIを利用しており、コードは次のようになります。

class User {
  const User({required this.id, required this.name});

  final String id;
  final String name;
}

class UserRepository with ApiErrorHandler {
  UserRepository(this._openapi);

  final Openapi _openapi;

  Future<User> getUser() async {
    final response = await handleError(
      () => _openapi.getUserApi().getUser(),
    );

    // OpenAPIの定義から、アプリ内のモデルに変換
    return response.converted;
  }
}

ApiErrorHandler(handleError)では、API共通のエラーハンドリング(エラーメッセージ表示、ステータスコードに応じた強制アップデートやメンテナンス画面への振り分けなど)を行い、アプリで定義したカスタムエラーを呼び出し元にthrowしています。

Logic layer

多少冗長になる側面はありますが、UIフレームワークや状態管理ライブラリに依存しない純粋なビジネスロジックとしてユニットテストが書けるため、MVVMかMVHRかに関わらずUse Case層を設けています。

abstract class UseCase<P, R> {
  const UseCase();

  R call(P argument);
}

class GetUserUseCase extends UseCase<void, Future<User>> {
  const GetUserUseCase(this._userRepository);

  final UserRepository _userRepository;

  @override
  Future<User> call(void argument) async {
    return _userRepository.getUser();
  }
}

UI layer

State側

MVVMではViewとViewModelが基本的に1対1の関係であるのに対し、MVHRではViewをより小さなWidgetに分割し、状態をそのスコープに応じて管理します。そのため、ViewとStateは多対多の関係になります。

// Global state
@riverpod
class UserState extends _$UserState {
  @override
  Future<User> build() async {
    return ref.watch(getUserUseCaseProvider)(null);
  }
}

// Local state
AsyncSnapshot<User> useUser(Key key) {
  final ref = useWidgetRef();

  return useFuture<User>(
    useMemoized(() => ref.watch(getUserUseCaseProvider)(null), [key]),
  );
}

※説明のため同じUserモデルを使っていますが、実際はSingle Source of Truthの原則に従います

View側

riverpodで定義したStateは、whenメソッドで状態(data, error, loading)に応じて表示を切り替えられます。

class _UserWidget extends ConsumerWidget {
  const _UserWidget();

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final user = ref.watch(userStateProvider);
    return user.when(
      data: (user) => Text('name: ${user.name}'),
      error: (_, _) => Text('Error!'),
      loading: () => const CircularProgressIndicator(),
    );
 }
}

hooksの場合も、riverpodと同様の使い勝手で記述できるよう、AsyncSnapshot に後述する拡張関数を追加しました。

class _UserWidget extends HookConsumerWidget {
  _UserWidget();

  final UniqueKey refreshKey = UniqueKey();

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final user = useUser(refreshKey);
    return user.whenWithError(
      ref,
      data: (user) => Text('name: ${user.name}'),
      error: (_, _) => Text('Error!'),
      loading: () => const CircularProgressIndicator(),
    );
 }
}

エラーハンドリング

MVVMでは、各ViewModelに共通のinterfaceを実装することで、アプリ全体のエラー処理を統一できます。しかし、riverpodやhooksで管理されるStateはそれぞれが独立しているため、この方法は使えません。

そこで今回は、Stateを利用するView側でエラーハンドリングを共通化するアプローチを取りました。この方法であれば、WidgetRefを通じてSnackBarの表示や画面遷移といったUI操作を伴うエラー処理を柔軟に実装できます。

具体的には、riverpodのAsyncValueとhooksのAsyncSnapshotに、whenをラップする形の拡張関数を追加しました。

riverpodで扱う拡張関数 (AsyncValue)

whenをラップし、シンプルに内部で共通のエラー処理handleErrorを呼び出しています。

extension AsyncValueExt<T> on AsyncValue<T> {

  R whenWithError<R extends Widget>(
    WidgetRef ref, {
    bool skipLoadingOnReload = false,
    bool skipLoadingOnRefresh = true,
    bool skipError = false,
    required R Function(T data) data,
    ValueGetter<R>? loading,
    R Function(Object error, StackTrace? stackTrace)? error,
  }) {
    if (hasError && (!hasValue || !skipError)) {
      handleError(ref, error!); // 共通のエラー処理
    }

    return when(
      skipLoadingOnRefresh: skipLoadingOnRefresh,
      skipLoadingOnReload: skipLoadingOnReload,
      skipError: skipError,
      data: data,
      loading: loading ?? () => const AppProgressIndicator() as R,
      error: error ?? ((_, __) => const SizedBox.shrink() as R),
    );
  }
}

hooksで扱う拡張関数 (AsyncSnapshot)

riverpodの AsyncValue.whenと同じインタフェースで使えるように拡張関数を定義します。

extension AsyncSnapShotExt<T> on AsyncSnapshot<T> {
  R whenWithError<R>(
    WidgetRef ref, {
    required R Function(T data) data,
    ValueGetter<R>? loading,
    R Function(Object error, StackTrace? stackTrace)? error,
  }) {
    if (connectionState == ConnectionState.waiting) {
      return loading?.call() ?? const AppProgressIndicator() as R;
    }

    final err = this.error;
    if (err != null) {
      handleError(ref, err); // 共通のエラー処理
      return error?.call(err, stackTrace) ?? const SizedBox.shrink() as R;
    }

    if (hasData) {
      return data(this.data as T);
    }

    return loading?.call() as R;
  }
}

このアプローチにより、riverpodとhooksのどちらで状態を管理していても、統一された方法でエラーハンドリングを実装できました。

更新系の処理

参照系だけでなく、データの登録や更新といった「更新系」の処理はhooksを使って実装しています。宣言的UIの先進事例であるReactエコシステムのTanStack Queryを参考にすることで、Flutterに適した形で記述できます。

長くなってしまうため、今回はコードの詳細を割愛しますが、ユーザー情報をフォームの入力値で更新する例を以下に示します。

ボタンクリックでuseUpdateNameのフックを呼び出すと、フックは独自に定義した AsyncResultという値を返します。これにより、前述のAsyncValueAsyncSnapShotと同様に結果(data)・読込状態(isLoading)・エラー状態(error)に応じてUIを宣言的に切り替えられます。

class _UserWidget extends HookConsumerWidget {
  const _UserWidget();

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final user = ref.watch(userProvider);
    final name = useState<String>(user.value?.name ?? '');
    final updateName = useUpdateName();

    return Column(
      children: [
        user.whenWithError(
          ref,
          data: (user) => _TextForm(
            user,
            onChanged: (v) {
              name.value = v;
            },
          ), // 名前入力フォーム
          error: (e, _) => const Text('Error!'),
          loading: () => const CircularProgressIndicator(),
        ),
        const SizedBox(height: 48),
        updateName.whenWithError(
          ref,
          data: (_) => updateName.isLoading
              ? const CircularProgressIndicator()
              : ElevatedButton(
                  onPressed: () => updateName.execute(name: name.value),
                  child: const Text('更新する'),
                ),
        ),
      ],
    );
  }
}

2つのアーキテクチャを経験して

モバイルアプリには、Webフロントエンドとは異なる独自のライフサイクル(画面の表示/非表示、アプリのバックグラウンド/フォアグラウンド遷移など)が存在します。 こうしたライフサイクルイベントを考慮に入れる必要がある場合、画面全体の状態とロジックを管理するViewModelを持つMVVMの方が、処理をまとめて記述しやすく簡潔になるという利点を感じました。 一方でMVVMには、複雑な画面でViewModelが肥大化しやすいという課題や、ViewとViewModelが1対1に紐づくことによる画面間連携の設計が別途必要になる、といった側面もあります。加えて、ViewModelのライフサイクル管理や初期化処理が必要になる点は、宣言的なUI構築との相性が良くないと感じる部分でした。

対するMVHRは、Stateとロジックを含んだUIパーツ(Widget)の再利用がしやすく、状態に応じてUIが宣言的に構築されるFlutterの思想と非常に親和性が高いと感じます。

これらの利点から、次に新規アプリを開発する機会があっても、私はMVHRを再び選択するでしょう。もちろん、これは現在MVVMで稼働しているデジスマに大きな課題があるという意味ではありませんし、チームの状況やプロダクトの仕様を考慮すればデジスマはMVVMで実装して良かったと思っています。その上で、MVHRはアーキテクチャとしてさらに改善を重ねていける可能性を感じています。 例えば、今後はRiverpodをDI(依存性注入)の機能に特化させて使い、Global Stateの管理方法自体はより良い形を模索するなど、継続的に探求していきたいと考えています。

まとめ

本記事では、MVVMとMVHR双方の経験に基づき、それぞれのアーキテクチャが持つ利点と課題を解説しました。

アーキテクチャ選定に正解はありません。アーキテクチャ選定はプロダクトの仕様やチームのフェーズによって常に変化します。

「FlutterでMVVMはアンチパターンだ」「〇〇がベストプラクティスだ」という考え方ではなく、それぞれのアーキテクチャが持つメリット・デメリットを正しく理解し、プロダクトやチームの状況に応じて柔軟に選択・適用していくことが重要だと考えています。

We are Hiring!

エムスリーでは、こういった設計の話であったり、UI/UXの話、 アプリに関わらずフロントエンド・バックエンド、技術が大好きなエンジニアを募集しています!

エンジニア採用ページはこちら

jobs.m3.com

エンジニア新卒採用サイト! !

fresh.m3recruit.com

カジュアル面談! !

jobs.m3.com