🐦

Flutter Widget設計再考:状態管理ライブラリ(Riverpod)との健全な向き合い方

に公開

はじめに:FlutterのWidgetは「関数」なのか?宣言的UIと状態管理の課題

FlutterのUI構築は「宣言的 (Declarative)」アプローチを採用しています。これは、「現在の状態がこうであれば、UIはこうあるべきだ」という最終的な見た目をコードで記述する方式です。開発者は、与えられた状態に基づいてUIを構成するWidgetツリーを構築します。

特にStatelessWidgetは、コンストラクタで受け取った不変の引数(Props)のみに基づいてbuildメソッド内でUI(他のWidget)を返すため、引数を取ってUI要素を返す純粋な関数のようなものと考えることができます。この考え方は、UIの構造やデータの流れを理解する上で非常に役立ちます。

しかし、アプリケーションがインタラクティブになるにつれて、「状態」という概念が重要になります。ユーザー操作や非同期処理の結果に応じてUIを変化させる必要があるためです。StatefulWidgetはWidget自身が変化する状態(State)を持つことを可能にしますが、その状態は基本的にそのWidgetとその子孫に閉じたものです(ローカル状態)。

アプリケーションが複雑化し、複数の画面やコンポーネント間で状態を共有したり、より洗練された方法で状態を管理したりする必要が出てくると、StatefulWidgetだけでは限界が見えてきます。ここで登場するのが、状態管理ライブラリです。

この記事では、特に人気のあるRiverpodを念頭に置きつつ、状態管理ライブラリとFlutterのWidget設計、特に個々のWidgetをどのように設計すべきかについて、一般的な考え方に疑問を呈しながら、より「健全」で保守性の高いアプローチを探求していきます。

「バケツリレー」は悪者か?Propsによるデータ伝搬を再考する

状態管理ライブラリ導入の大きな理由の一つとして、「Propsのバケツリレー(Prop Drilling)」問題の回避が挙げられます。これは、Widgetツリーの深い階層にある子孫Widgetにデータを渡すために、その経路上にある多くの中間Widgetが、自身は利用しないデータをただ受け取って下に流すためだけにPropsとして定義・受け渡しを行う状況を指します。これはコードの冗長性を招き、データ構造の変更時に多くの中間Widgetを修正する必要が生じるため、保守性を低下させると言われています。

しかし、この「バケツリレー=悪」という単純な図式は、少し立ち止まって考える必要があります。

反論1:単一責任の原則(SRP)と「意図された受け渡し」

ソフトウェア設計の基本原則である 単一責任の原則(SRP: Single Responsibility Principle) に従い、各Widgetが明確な一つの役割を持つように適切に分割されていればどうでしょうか?

例えば、ユーザープロフィールを表示するUserProfileCard Widgetがあり、その中にユーザー名を表示するUserNameDisplay Widgetと、アイコンを表示するUserAvatar Widgetがあるとします。UserProfileCardUserオブジェクトを受け取り、UserNameDisplayにはuser.nameを、UserAvatarにはuser.avatarUrlを渡すのは、それぞれのWidgetが自身の責務を果たすために必要なデータを親から受け取る、意図された健全なデータの受け渡しです。これを「バケツリレー」と呼んで問題視する必要はないはずです。

反論2:Widgetの「構成的責任」

FlutterのWidgetは 構成的(Composable) です。親Widgetは、自身が表示する内容だけでなく、childchildrenとして持つ子孫Widgetを含めて、一つのUI要素、あるいはUIの「部分」を構成します。

この観点から見ると、親Widgetは子孫Widgetが適切に機能するために必要なデータを提供するという「構成的責任」の一部を担っていると言えます。つまり、中間Widgetが子孫のためにデータを渡すのは、「自身は利用しないデータを運んでいる」のではなく、「自身の構成要素が機能するために必要なデータを供給している」と解釈できます。関数が内部で別の関数(サブモジュール)を呼び出し、それに必要な引数を渡すのと同じような、自然な構造と捉えることも可能です。

では、何が問題なのか?

問題の本質は、データの受け渡し行為そのものではなく、伝搬経路の長さと中間Widgetの負担にあります。

  • 深い階層: データが必要なWidgetがツリーの非常に深い場所にある場合。
  • 無関係な中間Widget: 経路上の中間Widgetが、自身の表示ロジックとは全く無関係なデータを、ただ下流に流すためだけにインターフェース(コンストラクタ)に含めなければならない状況。

これが積み重なると、以下の問題が発生します。

  • 結合度の増加: 状態の提供元と利用箇所が、多くの中間Widgetを介して結合してしまう。
  • 変更容易性の低下: データ形式の変更や利用箇所の変更が、多くの中間Widgetの修正を必要とする。
  • コードの見通しの悪化: Widgetの本来の責務が、データ伝搬のコードによって曖昧になる。

Theme.of(context)MediaQuery.of(context)のように、フレームワークレベルの環境データはInheritedWidgetの仕組みで効率的にアクセスできますが、アプリケーション固有の状態を同様に扱う標準的な仕組みは限定的です。状態管理ライブラリは、このアプリケーション固有の状態伝搬の課題を解決する手段を提供します。

状態管理ライブラリ(Riverpod)への期待と現実:依存性と結合度

Riverpodのような状態管理ライブラリは、サービスロケーター依存性注入(DI: Dependency Injection)コンテナとしても機能し、Widgetツリーのどこからでも必要な「状態」や「サービス」にアクセスする手段を提供します。ref.watchref.readを使うことで、Widgetはコンストラクタ引数として渡されなくても、Provider経由で必要なデータを取得できます。これにより、「バケツリレー」問題を回避し、コードを簡潔にできると期待されます。

しかし、これには考慮すべきトレードオフが存在します。

  • ライブラリへの依存: アプリケーション全体が特定の状態管理ライブラリの概念やAPIに依存することになります。将来的なライブラリの変更、移行、あるいは利用方針の転換が、アプリケーション全体、特にUIレイヤーに大きな影響を与える可能性があります。
  • 暗黙的な依存関係: WidgetがどのProviderに依存しているかは、そのWidgetのコンストラクタを見ただけでは分かりません。buildメソッドの実装内部を確認する必要があります。これにより、Widgetの外部インターフェースが不明瞭になり、依存関係の把握が難しくなる可能性があります。これは、明示的にPropsで依存関係を示すアプローチとは対照的です。
  • 結合度の変化: Propsによる結合(親子間の直接的な結合)が、Providerを介した結合(状態提供元と利用箇所がライブラリ経由で結合)に置き換わります。これにより中間Widgetの負担は減りますが、状態提供側の変更が、予期せぬ広範囲の利用側Widgetに影響を与える可能性も依然として残ります(ただし、適切に設計されていれば影響範囲は限定的になります)。

UIレイヤー内で完結させたい場合、builder関数パターン(ListView.builderなど)のように、UIの構造や描画ロジックの一部を外部から関数として注入する手法もあります。これは依存性の注入の一形態であり、特定のUIパーツのカスタマイズ性や再利用性を高めるのに有効ですが、アプリケーション全体の状態を管理・伝搬する目的には、これだけでは不十分な場合が多いです。

リビルド制御は銀の弾丸か?パフォーマンス神話と設計原則

状態管理ライブラリ、特にRiverpodがもたらすもう一つの大きなメリットとして、きめ細やかなリビルド制御が挙げられます。ref.watchProvider全体を監視するだけでなく、ref.watch(myProvider.select((state) => state.value))のようにselectを使うことで、状態オブジェクトの特定の部分が変化したときだけWidgetをリビルドさせることができます。これにより、不要なリビルドを防ぎ、パフォーマンスを最適化できると考えられています。

しかし、これも過度に期待したり、リビルド制御自体を目的にしたりするのは危険です。

  • Flutterのレンダリング効率: Flutterは、WidgetツリーからElementツリー、そしてRenderObjectツリーへと変換し、実際の描画を行います。buildメソッドが再実行されても、生成されるWidgetツリーに差分がなければ、ElementツリーやRenderObjectツリーの更新は最小限に抑えられ、コストの高いレイアウト計算やペイント処理は発生しません。const Widgetの活用や適切なkeyの使用も効率化に貢献します。したがって、buildメソッドの実行回数自体が、必ずしもパフォーマンスのボトルネックになるとは限りません。
  • 設計原則による本来の解決策:
    • buildメソッドはシンプルに: buildメソッド内で行うべきなのは、現在の状態に基づいたUI構造の宣言的な記述です。複雑な計算、重いデータ変換、ビジネスロジックなどは、buildメソッドの外(ViewModel、サービスクラス、あるいはProviderのロジック部分など)で行い、結果だけをbuildメソッドに渡すべきです。buildメソッド自体が軽量であれば、再実行コストはそれほど問題になりません。
    • リストは遅延構築で: 大量のアイテムを表示する場合、buildメソッド内で直接リストを生成するのではなく、ListView.builderGridView.builderなど、画面に見えている部分だけを構築する遅延構築メカニズムを使うのが基本です。これにより、一度に大量のWidgetインスタンスが生成されるのを防げます。
    • 局所状態はWidget内で: アニメーションの進行度、テキストフィールドの入力内容、チェックボックスのON/OFFなど、そのWidget内部で完結するUI固有の状態は、StatefulWidgetsetState、あるいはflutter_hooksを使って管理するのが最も自然で効率的です。これらをグローバルな状態管理に含める必要はありません。

これらの健全な設計原則を適用すれば、状態管理ライブラリによる微細なリビルド制御に頼らなければならない場面は、実はかなり限定的になるはずです。パフォーマンスの問題が発生した場合は、まずDevToolsでボトルネックを特定し、これらの設計原則が見直せないかを確認する方が先決でしょう。

Widget設計の原則に立ち返る:独立性・再利用性・テスト容易性の追求

アプリケーション全体のアーキテクチャから一旦離れ、「個々のWidgetをどう設計すべきか」という原点に立ち返ってみましょう。独立性、再利用性、テスト容易性の高い、いわゆる「良い」Widgetコンポーネントとはどのようなものでしょうか?

  • 明確なインターフェース(Props): Widgetが必要とするデータやコールバックは、コンストラクタ引数(Props)として明示的に定義されているべきです。これにより、Widgetの責務と依存関係が外部から一目瞭然になります。
  • 自己完結性(可能な限り): Widgetは、受け取ったPropsと、必要であれば自身の内部状態(StatefulWidgethooksで管理)だけで動作するように設計されているのが理想です。外部の特定のProviderやグローバルな状態管理システムの存在を暗黙的に前提とすべきではありません。
  • 再利用性: 上記の原則に従えば、Widgetは特定のアプリケーションの文脈から切り離され、異なる画面や、場合によっては異なるプロジェクトでも容易に再利用できるようになります。UIカタログツール(Widgetbookなど)での管理も容易になります。
  • テスト容易性: Propsで依存関係が明示されていれば、単体テストは非常に簡単です。必要なPropsをモックしてWidgetを生成し、期待通りに描画されるか、あるいはコールバックが呼ばれるかを確認するだけです。状態管理ライブラリの複雑なモックやセットアップは不要になります。

これらの原則を重視するならば、個々のWidget、特にアプリケーションの基盤となるような再利用可能なコンポーネント(Atomic DesignでいうAtomsやMoleculesなど)の内部で、状態管理ライブラリのAPI(ref.watchなど)を直接呼び出すことは、避ける方が望ましいと言えます。なぜなら、それはWidgetの独立性、再利用性、テスト容易性を損なう可能性があるからです。

提案:健全なWidget設計と状態管理ライブラリの「使い分け」

これまでの議論を踏まえると、「状態管理ライブラリを使うべきか、使わないべきか」という二元論ではなく、「どのように使い分けるか」が重要になります。

提案するアプローチ:

  1. 個々のWidget設計:
    • 原則: Widget(特にAtoms, Moleculesレベル)は、Propsのみに依存する「純粋な」UI部品として設計する。外部の状態管理ライブラリへの直接的な依存は避ける。
    • 局所状態: Widget固有のUI状態はStatefulWidgetまたはflutter_hooksで管理する。
    • buildメソッド: シンプルに保ち、ロジックは外部に切り出す。
  2. 状態管理ライブラリの適用範囲:
    • 上位レイヤーでの状態アクセス: Atomic DesignでいうOrganisms画面レベル(Pages/Templates)のWidgetで、状態管理ライブラリ(RiverpodのProviderなど)にアクセスする。ここで必要なデータの取得、加工、ビジネスロジックの呼び出しを行い、その結果を下位のWidgetにはPropsとして渡す
    • アプリケーションワイドな状態共有: 認証状態、ユーザー設定、テーマ、カート情報など、複数の画面や機能で共有される必要のある状態の管理と伝搬に利用する。
    • 状態管理ロジックのカプセル化: API通信、データベースアクセス、複雑な状態遷移などのロジックをProvider(や関連するNotifier/Bloc/Cubitなど)にカプセル化し、UIから分離する。これによりロジックの再利用性やテスト容易性が向上する。
    • 非同期処理の管理: FutureProviderStreamProviderなどを活用し、非同期処理のロード中・データ有り・エラーといった状態を宣言的に扱い、UIに反映させる。
    • 依存性注入(DI)基盤: 状態だけでなく、リポジトリ、サービスクラス、APIクライアントなど、アプリケーションに必要な様々な依存オブジェクトの生成・ライフサイクル管理・注入に利用する。

MoleculesレベルでのProviderアクセス:原則と例外

ここで、「MoleculesレベルのWidgetでもProviderを直接参照すべきか?」という疑問が生じるかもしれません。

  • 原則: No. この記事で推奨する方針の核心は、MoleculesやAtomsの独立性・再利用性・テスト容易性を最大化することにあります。そのため、これらの下位コンポーネントはProps駆動を徹底し、Providerへの直接アクセスは避けるべきです。必要なものは上位(Organisms/Pages)からPropsとして受け取ります。

  • 例外(慎重な検討が必要): 絶対的なルールではありませんが、以下のような稀なケースでは、トレードオフを十分に理解し、チーム内で合意した上で、例外的にMoleculesからProviderを参照することが検討される可能性があります。

    • 再利用を意図しない密結合: 特定のアプリケーション状態と強く結びつき、他での再利用が現実的でないMoleculeにおいて、Propsでの受け渡しが過度に冗長になる場合。(ただし、再利用性を犠牲にする判断です。)
    • アクション発行の簡略化: 表示データはPropsで受け取りつつ、ボタン押下時のアクション(Notifierメソッド呼び出しなど)をref.readで直接行う場合。コールバックPropsの伝搬を省略できますが、テスト容易性は低下します。ref.watch(状態購読)よりは結合度が低いと言えますが、依然としてコールバックPropsの方が望ましいです。
    • 証明されたパフォーマンスボトルネック: DevTools等で、Props経由では回避不可能な深刻なリビルド問題が明確に証明され、かつselectによるピンポイント監視でしか解決できない場合。(これは最終手段であり、通常は他の方法で解決可能です。)
  • 推奨: まずは原則に従うことを強く推奨します。 安易に例外を認めると、設計原則が崩れ、コンポーネントの結合度が高まり、保守性が低下するリスクがあります。例外を検討するのは、原則に従った上でどうしても解決できない課題に直面した場合のみに留めるべきです。

riverpod_generator導入時の注意点と設計パターン

Riverpod v2から導入されたriverpod_generator@riverpod@Riverpodアノテーション)は、Provider定義の記述量を大幅に削減し、非常に便利です。しかし、これをアプリケーション全体で無秩序に使うと、依然としてUIコンポーネントが特定のProvider実装に強く結合してしまうリスクがあります。

@riverpod多用による結合問題の回避策:

  • アクセスポイントの限定: 前述の通り、@riverpodで生成されたProviderへのアクセス(ref.watch等)は、原則としてOrganismsや画面レベルのWidgetに限定します。下位のWidgetにはPropsでデータを渡します。
  • 抽象(Interface)への依存: 複雑な状態ロジックを持つ場合、Notifier(特にAsyncNotifier)の実装クラスに対するProviderを直接UIから参照するのではなく、そのNotifierが実装する**抽象クラス(Interface)**に対するProviderを定義し、UIは抽象に依存するようにします。(ただし、Riverpod v2のNotifierは継承を推奨しないため、DIコンテナとしての利用や、Providerが返すStateクラスのインターフェースを定義するなどの工夫が必要になる場合があります。)
  • Providerの責務分割: 一つのProviderが多くの責務を持ちすぎないように、適切に分割します。例えば、データの取得、加工、状態更新のロジックを分離するなどです。

Notifier / AsyncNotifier 設計パターン:

Riverpod v2以降では、状態(State)とその状態を変更するためのロジック(メソッド)をカプセル化するためにNotifierAsyncNotifierの使用が推奨されます。

  • Notifier<State>: 同期的な状態管理に適しています。状態を変更するメソッドを内部に持ち、状態が変更されるとリスナーに通知します。
  • AsyncNotifier<State>: 非同期的な状態管理(APIからのデータ取得など)に適しています。状態はAsyncValue<State>loading, data, error)として表現され、非同期処理のライフサイクルを管理しやすくなります。

これらのNotifierを使う場合でも、UIから直接これらのNotifierのメソッドを呼び出す箇所は、Organismsや画面レベルに限定するのが望ましいでしょう。Notifier自体が複雑なビジネスロジックを持つ場合は、さらにそのロジックを別のサービスクラス等に分離し、Notifierは状態管理とUIへの公開に専念させるのがクリーンです。

ドメインロジックの置き場所:レイヤー化の考え方

アプリケーションのロジックをどこに配置するかは重要な設計上の意思決定です。クリーンアーキテクチャなどの考え方を参考に、以下のようなレイヤー構造を意識すると見通しが良くなります。

  • Presentation (UI) Layer: Flutter Widgetで構成されます。状態を表示し、ユーザー入力を受け付け、Applicationレイヤーのアクションを呼び出します。Props駆動の原則を重視します。
  • Application Layer: UIからの要求を受け、Infrastructureレイヤーが提供するデータアクセス部品(Repositoryなど)を利用して、ドメインレイヤーのロジックを組み合わせてユースケースを実現します。RiverpodのNotifierProviderはこの層に位置し、UIに公開する状態の管理や、関連するビジネスロジックの実行を担います。
  • Domain Layer: アプリケーションの核となるビジネスルール、エンティティ、値オブジェクトなどが含まれます。特定のUIやフレームワーク、データソースに依存しない、純粋なロジックです。
  • Infrastructure Layer: データベースアクセス、外部API呼び出し、プラットフォーム固有機能など、外部システムとの具体的な連携を担当します。Applicationレイヤーが利用するRepositoryパターンなどの実装や、データソースとの通信ロジックを提供します。

ドメインの意思決定ロジックは、主にDomain Layerに配置されます。Application Layerは、Domain Layerのロジックを呼び出して利用し、UIが必要とする形にデータを加工したり、状態として管理したりします。UIレイヤーには、原則としてドメインロジックを含めません。

UIカタログとの親和性:デザイナー/QAとの協働促進

本記事で提案している「Props駆動の独立したUIコンポーネント」という設計アプローチは、Widgetbook のようなUIカタログツールと非常に相性が良いです。

  • カタログ化の容易さ: Propsでインターフェースが定義されたWidgetは、カタログツール上で様々なPropsの組み合わせを与えて表示・確認するのが容易です。状態管理ライブラリの複雑なセットアップなしに、純粋なUIコンポーネントとして扱えます。
  • デザイナーとの連携: デザイナーはカタログを通じて実装されたUIコンポーネントを確認し、デザインとの整合性をチェックしたり、インタラクションを確認したりできます。
  • QAとの連携: QAエンジニアは、カタログ上でコンポーネントの様々な状態(エラー表示、ローディング表示など)を個別にテストできます。
  • 開発効率の向上: 開発者は、カタログでコンポーネントを一覧・検索し、再利用可能な部品を見つけやすくなります。

このように、UIコンポーネントの独立性を高める設計は、開発チーム内およびチーム間のコミュニケーションと協働を円滑にし、開発プロセス全体の効率化にも貢献します。

おわりに:バランス感覚を持って、より良いFlutterアプリケーション設計へ

FlutterのWidget設計と状態管理ライブラリの関係について、様々な角度から深く掘り下げ、実践的なトピックを追加してきました。

結論として、個々のWidgetは可能な限り独立性と再利用性を重視して設計し(Props駆動)、状態管理ライブラリは主にアプリケーションの上位レイヤーで、状態共有、ロジック分離、非同期処理、依存性注入といったアーキテクチャ上の課題を解決するために活用する、という「使い分け」のアプローチが、多くの場合において健全なバランスをもたらし、保守性やテスト容易性の高いアプリケーション構築に繋がると考えられます。

もちろん、これは一つの考え方であり、絶対的な「正解」ではありません。アプリケーションの規模、複雑さ、チームの経験や好み、開発速度の要求など、様々な要因を考慮し、プロジェクトのコンテキストに最も適したアーキテクチャを意識的に選択することが肝要です。

この記事が、皆さんがより良いFlutterアプリケーションを設計するための一助となり、さらなる議論のきっかけとなれば幸いです。

合同会社CAPH TECH

Discussion