【徹底解説】なぜRiverpod?SetState Providerとの比較

【徹底解説】なぜRiverpod?SetState Providerとの比較

はじめに

Riverpod?Provider?setState?

状態管理の議論は尽きないでしょう。

Riverpodはわかりにくい、

setStateで何がダメなの?

Providerで困っていない。

会社のやり方が〇〇だから。

プロジェクトの規模によって変わるよね。

たしかに、その通りなのです。

しかし、だからと言って、新しい技術を知らず、ただ見送って同じものを使い続けるのは良くないはずです。

プロジェクトにおいて、

  • バグが減る
  • 工数が削減できる
  • 保守性・可読性・パフォーマンスが上がる

これらのメリットがあるならば、既存プロジェクトを書き換える必要はなくても、新規プロジェクトからライブラリを乗り換えても良いのではないでしょうか?

Anycloudでは、Flutterにおける状態管理として、どのプロジェクトでもsetStateと最低限のProviderを使用しています。

実際に、プロジェクトを通して大きな問題はありませんが、viewのコードがカオスになってしまったり、setStateProviderだけで戦い続けるのはかなりきついと感じています。

ということで今回は、Provider・setStateの課題・問題からRiverpodがおすすめな理由を解説していきます。

setStateにおける課題・問題

まずは、setStateにおける課題・問題を3つ挙げていきます。

1.スコープが限定されている

setStateはウィジェット内のローカル状態を更新するのに適していますが、アプリケーション全体の状態を管理するのには適していません。

2.過剰なUI再構築

setStateが呼ばれると、そのウィジェットとその子ウィジェット全体が再構築されるので、小さな状態変更が不要なUIの更新を引き起こすことがあり、パフォーマンスに悪影響を与えます。

  • レスポンス速度の低下
    • 頻繁に大量のウィジェットを再構築する場合、CPUリソースが大量に消費され、アプリの反応が遅くなります
  • フレームレートの低下
    • 特にアニメーションやスクロールといった動的な操作において、フレームレートの低下を引き起こす可能性があり、いわゆる画面がカクカクと不自然に動く挙動が発生します
  • バッテリー消費の増加
    • 頻繁なUIの再構築はプロセッサ(指示を処理したり、メモリなどを制御したりする装置)を過剰に使用するため、端末のバッテリー消費が増大します

実際の例として良くあるケースが、動的フォームの管理です。

複数の入力フィールドを持つフォームで、onChange内のsetStateなどで各フィールドの値の変更が全フィールドの再構築を引き起こすような場合、これもまたsetStateの過剰使用が原因でパフォーマンス問題を引き起こします。

フィールドの一つを更新するだけで全フィールドが再描画され、特にフォームが長い場合、タイピングのレスポンスが非常に悪くなります。

3.状態の共有が困難

複数のウィジェット間で状態を共有する場合、その状態を効果的に伝達するためにリフトアップ(親ウィジェットへの状態の移動)やコールバックが必要になりますが、これは複雑でエラーを引き起こしやすいです。

例えば、以下のように、親Widgetにカウンターと2つの子Widgetがあり、これらの子ウィジェットは、それぞれカウンターをインクリメントするボタンを持ちます。

状態は親によって管理され、子ウィジェットは親から状態を受け取って表示します。

※子Widgetでの状態更新は親には伝搬しません

import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: ParentWidget(),
    );
  }
}

class ParentWidget extends StatefulWidget {
  @override
  _ParentWidgetState createState() => _ParentWidgetState();
}

class _ParentWidgetState extends State<ParentWidget> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("State Management Example"),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text('You have pushed the button this many times:'),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headline4,
            ),
            ChildWidget(
              incrementCounter: _incrementCounter,
            ),
            ChildWidget(
              incrementCounter: _incrementCounter,
            )
          ],
        ),
      ),
    );
  }
}

class ChildWidget extends StatelessWidget {
  final VoidCallback incrementCounter;

  ChildWidget({required this.incrementCounter});

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: incrementCounter,
      child: Text('Increment'),
    );
  }
}

Providerにおける課題・問題

Providerにおける課題・問題を4つ挙げていきます。

1.コンテキストへの依存

Providerはコンテキスト(BuildContext)に依存しています。

ライフサイクルの問題

BuildContextが特定のウィジェットに紐づいているため、そのウィジェットがウィジェットツリーから削除された場合には無効になる可能性があります。

これにより、非アクティブなコンテキストを通じてデータにアクセスしようとするとエラーが発生してしまいます。

以下の例では、UserProfilePageウィジェットがユーザーデータを非同期に読み込んでいます。

データの読み込みには2秒かかると設定しており、その間にユーザーがこの画面を離れると、ウィジェットはツリーから削除されます。

非同期処理が完了した後にsetStateを呼び出す際に、そのウィジェットが既にツリーから削除されている場合、setStateを呼び出すとエラーが発生するので、setStateを呼び出す前にmountedプロパティをチェックして、ウィジェットがまだツリーに存在するかを確認する必要があります。

import 'package:flutter/material.dart';

class UserProfilePage extends StatefulWidget {
  @override
  _UserProfilePageState createState() => _UserProfilePageState();
}

class _UserProfilePageState extends State<UserProfilePage> {
  String _username = "";

  @override
  void initState() {
    super.initState();
    _loadUserData();
  }

  Future<void> _loadUserData() async {
    try {
      // 擬似的にユーザーデータの読み込みを非同期で模擬
      await Future.delayed(Duration(seconds: 2));
      if (mounted) {
        setState(() {
          _username = "John Doe"; // ユーザーデータが読み込まれたと仮定
        });
      }
    } catch (e) {
      print('Error loading user data: $e');
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("User Profile"),
      ),
      body: Center(
        child: Text("Username: $_username"),
      ),
    );
  }
}

非UIロジックの制限

BuildContextはウィジェットツリーに紐づくため、UI以外のロジックでの利用が難しいです。

例えば、ビジネスロジックやバックグラウンドサービスなど、UIコンポーネントから独立して動作する部分でProviderを使用する場合、BuildContextが必要となります。

ちなみに、BuildContextを引数として渡すことが設計上の問題を引き起こす可能性について、さらに詳しく説明しておきます。

BuildContextは、Flutterのウィジェットツリーの特定の位置を表すキーであり、ウィジェットが存在する場所に依存して様々なデータやサービスにアクセスするために使用されます。

ウィジェット外でのデータロードやビジネスロジックにBuildContextを使用する場合、以下のような問題があります。

  • カプセル化の損失
    • BuildContextをウィジェット外の関数やロジックに渡すことは、そのロジックがUIの詳細(具体的にはウィジェットツリーの構造)に依存することを意味していて、ロジックの再利用性が低下し、テストが難しくなる場合があります。
  • 適用範囲の問題
    • ビジネスロジックやデータ取得のロジックは、通常、UIとは独立して存在するべきですので、BuildContextを関数に渡すことは、UIの詳細がビジネスロジックに漏れることを意味しています。
    • ※MVC、MVVM、またはClean Architectureなどの設計パターンの原則に反することにもなる?

ビジネスロジックやデータアクセス層をUI層から完全に分離することは、アプリケーションの保守性と拡張性を向上させるために重要です。

2.不透明なエラーハンドリング

Providerを使用する際、不正な方法でプロバイダーにアクセスした場合(たとえば、プロバイダーが提供されていない場所で呼び出した場合など)、Flutterは実行時エラーを投げますが、Providerでこれはデバッグが難しく(どのWidgetで起きているかなど)、さらにエラーメッセージが直接的でないため、原因の特定が難しくなってしまいます。

3.ボイラープレートと複雑性

Providerを使用する際には、プロバイダーのスコープを管理するためのボイラープレートコードが必要です。

※ボイラープレートコード:アプリケーションの異なる部分で繰り返し使用されるコードのことで特に初期設定や設定保持に多くのコードが必要な場合のこと

例えば、各プロバイダーを定義し、適切なスコープで提供するためには、MultiProviderChangeNotifierProviderなどが必要です。

各状態やビジネスロジックのために別々のプロバイダーを設定し、それぞれに対応するクラスを定義する必要があり、コードの量が増加し、管理が煩雑になる原因となります。

4.グローバルアクセスとテストの容易さ

グローバルアクセスが制限されることがあり、テストが難しくなることがあります。

また、状態のモック化やオーバーライドが複雑になる場合があります。

(※テストコードを触った経験ないのでGPTより)

Riverpodの具体的なメリットは何か?

setStateやProviderにおける課題・問題は理解できましたか?

ちなみに、Provider vs Riverpod論争の答えは、ドキュメントにも明確に記載されています。

以上を踏まえて、Riverpodによるメリットを解説していきたいと思います。

実は、以前からRiverpodとProviderについての記事を出しており、各記事の中でRiverpodの利点を具体的なコードを交えて解説しています。

なので、本記事では、詳細な説明は割愛して、何がメリットなのかをリスト形式で簡易的に紹介だけさせてもらいます。

詳細を知りたい方は、是非記事も読んでください。

では改めて、Riverpodのメリットは以下になります。

  1. APIのロジック自体をProvider化することによるAPIを無駄に叩かない、バケツリレーをなくすことができる
  2. AsyncValueのAPIを介して、前のデータ値と新しいロード中の値の 2 つの値を同時に吐き出すので、新しいデータをフェッチしながら既存のデータを保持することができる
  3. BuildContextに依存しないため、Widgetのライフサイクルから独立して動作する
  4. グローバルにアクセス可能なものとして扱うことで、Widgetの場所に依存することなく安全にアクセスすることができる
  5. Widgetが破棄された時に、プロバイダーの状態を自動的に破棄することで、不要なリソースの消費を防ぎ、メモリリークのリスクを減らすことができる
  6. 外部のパラメータをもとに一意のprovider を宣言できる
  7. Providerを使って何らかの値が変化したときにユーザーに通知するような副作用のトリガーが簡単になる

Provider から Riverpod への移行は非常に簡単

Riverpodへの乗り換えは簡単に始めることができます。

また、Riverpod と Provider は共存できるので、同時に使用することも可能です。

実際に行う場合、コードベース内の各 Provider インポートに対してエイリアスを使用すればOKです。

まとめ

Riverpodは柔軟性、拡張性、堅牢な設計により、多くのFlutter開発者から支持されています。

一方で、setStateProviderBlocReduxなど他の手法もそれぞれに適したシナリオがあり、プロジェクトの要件や開発チームのスキルに応じて最適な選択が異なる場合があります。

しかし、様々なアプリケーションや複雑な状態を持つアプリケーションにおいて、Riverpodは非常に強力なツールとなるため、多くのシナリオでの採用が推奨されているのが現状です。

Riverpodを活用して、より優れたアプリケーション開発ができると良いですね。

Anycloudではプロダクト開発の支援を行っています

プロダクト開発をお考えの方はぜひAnycloudにご相談ください。

まずは相談する

記事を書いた人

Matsuura

エンジニア

Matsuura

Twitter

Anycloudでエンジニアしてます!主にFlutterやWebフロントをやっていて、最近はバックエンドやインフラに挑戦・苦戦中。