方言を話すおしゃべり猫型ロボット『ミーア』をリリースしました(こちらをクリック)

【Flutter】状態管理:providerからRiverpodへの移行と利点

この記事は約20分で読めます。

はじめに

前回こちらの記事で、状態管理に関して、statefulWidget + setStateからProviderまでを記載した。今回は、ProviderからRiverpodまでを見ていく。

Providerの課題

  1. コンテキスト依存: ProviderBuildContextに強く依存しており、これがコードのテストや再利用を難しくしている。特にウィジェットツリーの外や非UIのコードから状態にアクセスしようとする場合、この依存性が問題となる。
  2. 不変性: Providerを使う場合、変更可能な状態を持つことができるが、これがバグの原因になり得る。理想的には、状態は不変であるべきだが、Providerだけではこれを強制することができない。
  3. 初期化の複雑さ: 特定の状態が依存している他の状態がある場合、Providerでこれを扱うのは比較的複雑。

カウンターアプリの例で見ていく

Dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (context) => CounterProvider(),
      child: MaterialApp(
        home: MyHomePage(),
      ),
    );
  }
}

class CounterProvider with ChangeNotifier {
  int _count = 0;
  int get count => _count;

  void increment() {
    _count++;
    notifyListeners();
  }
}

class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final counter = Provider.of<CounterProvider>(context);

    return Scaffold(
      appBar: AppBar(
        title: Text('Provider Counter Example'),
      ),
      body: Center(
        child: Text('Counter: ${counter.count}'),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => counter.increment(),
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ),
    );
  }
}

コンテキスト依存

MyHomePageBuildContextを使ってCounterProviderにアクセスしている。このコンテキスト依存は、特に状態にアクセスしたいがウィジェットツリー内の位置が不明確な場合に問題を引き起こす。

不変性の問題=オブジェクトの上書き

CounterProvider内で_countが変更可能な状態。Flutterは反応的なフレームワークなので、通常はnotifyListeners()を呼び出してウィジェットツリーに変更を通知する。しかし、もし何らかの理由でnotifyListeners()が呼ばれなかった場合、UIは実際の状態と同期しなくなる。

初期化の複雑さ

このシンプルなカウンターアプリでは明確には示されないが、複数のカウンターが相互に依存しているより複雑なシナリオでは、Providerでこれらの依存関係を管理するのは煩雑になる。例えば、あるカウンターが別のカウンターの値に依存して初期化されるべき場合、この依存関係を正確にコード化する必要があるが、Providerだけではその管理が直感的ではない可能性がある。

Riverpodの書き方

Riverpodはこれらの問題を解決しようと設計されたライブラリ。Providerの作者によって開発され、Providerのアイデアを基にしながら改善された。

Riverpodを使用して同様のカウンターアプリを実装すると、以下のようになる。

ルートにProviderScopeを追加する

ProviderScope で上位ツリーのWidgetを囲むと、下位ツリーのWidgetでProviderを呼び出すことができるようになる。以下のコードではMyApp() をProviderScope で囲むことでMyApp以降のWidgetでProviderを呼び出すことができるようにしている。

Dart
void main() {
  runApp(
    ProviderScope(child: MyApp()),
  );
}

Providerをグローバル変数として定義する

グローバル変数としてのProvider

  • counterProviderはアプリのどこからでもアクセス可能なグローバル変数。これにより、状態を管理するCounterNotifierへの参照がどこからでも可能になる
  • StateNotifierProvider を定義する時はStateNotifierProvider の後に<管理するStateNotifier のサブクラス名, StateNotifier で管理するデータの型>が必要。
  • counterProviderStateNotifierProvider<CounterNotifier, int>型のオブジェクトで、その実体はCounterNotifierクラスのインスタンス。CounterNotifierStateNotifier<int>を継承しており、整数型の状態を管理する。

コールバック関数

  • StateNotifierProviderのコンストラクタに渡されるコールバック関数では、CounterNotifierのインスタンスが作成され返される。この関数は、プロバイダーが初めて呼び出される際に実行され、状態の初期化を行う。

ProviderRef

  • コールバック関数の引数refは、他のプロバイダーからの状態やサービスを参照する際に使用されるProviderRefオブジェクト。

StateNotifier

  • CounterNotifierStateNotifierを継承したクラスで、カウンターの状態(この場合は整数値)を管理する。コンストラクタでsuper(0)としているのは、カウンターの初期値を0に設定していることを意味する。

状態の更新

  • incrementメソッドは、カウンターの値をインクリメントする。stateは現在のカウンター値を表し、state++により状態が更新される。これはイミュータブルな状態管理であり、状態を更新するために新しい状態オブジェクトが作成され、古い状態オブジェクトは破棄されるStateNotifierは状態が更新されると、それを購読しているウィジェットに通知し、UIの再描画をトリガーする。
Dart
final counterProvider = StateNotifierProvider<CounterNotifier, int>((ref) {
  return CounterNotifier();
});

class CounterNotifier extends StateNotifier<int> {
  CounterNotifier() : super(0);

  void increment() {
    state++;
  }
}

refの使用例

例えば、CounterNotifierが起動時に設定プロバイダーから初期値を取得する場合、以下のようにrefを使用してその値にアクセスすることができる。

この例では、CounterNotifierのコンストラクタで初期値を受け取り、その初期値はsettingsProviderから取得される。ref.readメソッドを使ってsettingsProviderの現在の値にアクセスし、その値をCounterNotifierの初期化に使用している。refはプロバイダーのコールバック内で他のプロバイダーにアクセスするためのキーとなるオブジェクト

Dart
final settingsProvider = Provider<Settings>((ref) => Settings());

final counterProvider = StateNotifierProvider<CounterNotifier, int>((ref) {
  // settingsProviderから設定を取得
  final settings = ref.read(settingsProvider);
  // 設定から初期カウンター値を取得して、CounterNotifierを初期化
  return CounterNotifier(settings.initialCounterValue);
});

class CounterNotifier extends StateNotifier<int> {
  CounterNotifier(int initialCounter) : super(initialCounter);

  void increment() {
    state++;
  }
}

Providerからデータを取得する

ConsumerWidget

  • ConsumerWidgetはRiverpodで提供される特別なウィジェットで、これを継承することで、ウィジェットがProviderからデータを読み取り、そのデータの変更に基づいて再ビルドするように設定できる。

WidgetRef

  • buildメソッドに渡されるWidgetRef引数は、Providerからデータを読み取るために使用される。WidgetRefは、BuildContextの代わりに使用され、Providerのデータへのアクセスや、ウィジェットのライフサイクルに関する追加の機能を提供する。

watch() と read()

  • ref.watch(), ref.read()メソッドに指定する引数は、グローバルに定義されたプロバイダーのオブジェクト。今回の場合、ref.watch(counterProvider)を呼び出すことで、counterProviderが管理する状態(この場合はカウンターの値)にアクセスし、その値の変更を監視することができる。
  • ref.watch():UIがデータの変更を監視する必要がある時
  • ref.read():UIがデータの変更を監視する必要がない時。アクション(例えばボタンタップ時の処理)をトリガーするときなど。

increment()の呼び出し

  • FloatingActionButtononPressedref.read(counterProvider.notifier).increment()が呼び出されることで、カウンターの値が増加する。
  • StateNotifier のサブクラスはWidgetRef クラスのwatch() またはread() の引数にStateNotifierProvider インスタンスの.notifier を指定することで取得できる。
  • StateNotifier は2つの異なるオブジェクトを提供していて、状態(state)に関しては自身が直接管理しアクセスするため.notifier は不要だが、その状態を管理するサブクラスのメソッド(状態を変更するロジックなど)にアクセスするには、.notifier が必要。
Dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';


class MyApp extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return MaterialApp(
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(counterProvider);

    return Scaffold(
      appBar: AppBar(
        title: Text('Riverpod Counter Example'),
      ),
      body: Center(
        child: Text('Counter: $count'),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => ref.read(counterProvider.notifier).increment(),
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ),
    );
  }
}

Provider→Riverpodで何が変わり、何が変わらなかったか?

ConsumerWidgetの役割:同じ

  • どちらの場合も、ConsumerWidgetはデータの変更に基づいてUIを更新する役割を持つ。ConsumerWidgetを使用することで、ウィジェットが依存しているデータが変更された際にウィジェットが再構築されるようになる。

WidgetRefの導入

  • Providerでは、状態にアクセスする際にBuildContextが必要。これは、Flutterウィジェットツリー内でのウィジェットの位置を基にして、状態を見つけ出し参照する。つまり、Provider.of<T>(context)context.watch<T>()のように、BuildContextを使用して状態を取得する。
  • RiverpodではWidgetRefの導入により、開発者はウィジェットの位置やBuildContextを意識することなく、ref.read(provider)を使って、状態を読み取ったり、 ref.watch(provider)を使って状態の変更を監視したりできる。この際、状態の変更が必要なウィジェットがどこにあるか(従来のBuildContextが果たしていた役割)に関しては、Riverpodが内部的に処理している。

Riverpodの利点

コンテキスト非依存(WidgefRefの導入による)

  • Riverpodでは、BuildContextに依存せずに状態にアクセスできる。ProviderScopeを使用してアプリ全体に状態を提供し、WidgetRefを使って状態にアクセスする。これにより、どこからでも容易に状態を参照でき、テストのしやすさも向上する。

不変性の強化=オブジェクトを上書きではなく、捨てて新規作成

  • StateNotifierを使うことで、状態の不変性を保証できる。stateはプライベートに保持され、変更する際はstateプロパティを通じてのみ行う。これにより、UIと状態の一貫性が保たれ、バグのリスクが減少する。

依存関係の明確な管理

  • Riverpodでは、プロバイダー間の依存関係が明確に定義できる。プロバイダーを参照するには、ref.watchまたはref.readを使用し、これにより状態の依存関係が明確かつ簡潔に管理できる。

CosumerWidgetとConsumerStatefulWidgetの違いと使い分け

ConsumerWidget

  • ConsumerWidgetはイミュータブル(不変)であり、内部状態を持たない。状態の変更があると、ウィジェット全体が再ビルドされる。ConsumerWidgetを継承したクラスでは、buildメソッドがWidgetRefを引数として受け取る。
  • 状態管理が不要なケース(静的なコンテンツの表示・外部データの単純な表示など)はConsumerWidgetを使う方がシンプルでメンテナンスが容易。
  • StatelessWidgetWidgetでプロバイダーを使いたい場合に使用。Consumerでラップせずに使うとWidget全体が再ビルドの対象になる点に注意。
Dart
class MyConsumerWidget extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final counter = ref.watch(counterProvider);
    return Text('$counter');
  }
}

ConsumerStatefulWidget

  • ConsumerStatefulWidgetConsumerStateStatefulWidgetとそのStateのRiverpod対応版。主な違いは、ConsumerStaterefオブジェクトがプロパティとして組み込まれている点。これにより、ConsumerState内でいつでもrefを使ってRiverpodの機能にアクセスできるようになる。
  • ConsumerWidgetではbuildメソッドの引数としてrefが渡されるが、ConsumerStatefulWidgetではそのような引数は渡されない。refConsumerStateのプロパティなので、ConsumerStateクラス内でrefを直接使える。
  • ref.watchが複数あり、状態更新が頻繁に行われる場合ConsumerWidgetはその都度再ビルドされるので、パフォーマンスの面で効率が悪くなる可能性がる(Consumerでラップせずに使えば、状態変更の部分が再ビルドの対象になるので、防ぐことはできる)。このようなシナリオでは、ConsumerStatefulWidgetを使用する方が適切。
  • StatefulWidgetWidgetでプロバイダーを使いたい場合に使用。
Dart
class MyConsumerStatefulWidget extends ConsumerStatefulWidget {
  @override
  _MyConsumerStatefulWidgetState createState() => _MyConsumerStatefulWidgetState();
}

class _MyConsumerStatefulWidgetState extends ConsumerState<MyConsumerStatefulWidget> {
  @override
  Widget build(BuildContext context) {
    final counter = ref.watch(counterProvider);
    return Text('$counter');
  }
}

Providerの種類6つ

Provider名管理するデータの型目的
Provider任意外部から変更できないデータを提供し、アプリ全体でアクセス可能にする
StateNotifierProviderStateNotifier のサブクラス状態の変更とビジネスロジックをカプセル化した状態管理を提供する
FutureProvider任意の Future非同期操作の結果を扱い、未来のある時点で利用可能になるデータを提供する
StreamProvider任意の Streamストリームからのデータを購読し、時間と共に変化するデータを提供する
StateProvider任意シンプルな状態の読み書きを提供し、ウィジェット内で簡単に状態を管理する
ChangeNotifierProviderChangeNotifier のサブクラスChangeNotifierを使用して状態の変更をリッスンし、複数のリスナーに状態の変更を通知する

Provider

  • 目的: 外部から変更不可能なデータを提供。プロバイダーの利用者に値の操作はさせず、参照のみをさせたい場合に使用
  • 使用例: アプリケーション全体で共有される設定情報を提供。
Dart
final configProvider = Provider((ref) => AppConfig(apiBaseUrl: '<https://api.example.com>'));
final config = ref.watch(configProvider);

StateProvider

  • 目的: シンプルな状態の読み書きを提供し、ウィジェット内で簡単に状態を管理。プロバイダーの利用者に値の操作と参照をさせたい場合に使用。
  • 使用例: UIのトグル状態を管理。
Dart
final toggleProvider = StateProvider<bool>((ref) => false);
ref.read(toggleProvider.notifier).state = !ref.read(toggleProvider);

StateNotifierProvider

  • 目的: 状態の変更とビジネスロジックをカプセル化した状態管理を提供。
  • 使用例: カウンターアプリの状態管理。
Dart
final counterProvider = StateNotifierProvider<CounterNotifier, int>((ref) => CounterNotifier());
ref.read(counterProvider.notifier).increment();

FutureProvider

  • 目的: 非同期操作の結果を扱い、将来のある時点で利用可能になるデータを提供。
  • 使用例: ネットワークリクエストからデータをフェッチ。
Dart
final fooProvicer = FutureProvider<String>((ref) async {
  await Future.delayed(const Duration(seconds: 3));
  return "foo";
});

class FooWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) => Consumer(
    builder: (context, ref, child) => ref.watch(fooProvicer).when(
      data: (foo) => Text(foo),
      error: (err, st) => const Text("Error"),
      loading: () => const Text("Loading"),
    ),
  );
}

StreamProvider

  • 目的: ストリームからのデータを購読し、時間と共に変化するデータを提供。FutureProviderのStream版
  • 使用例: リアルタイムの株価情報を提供。
Dart
final fooProvicer = FutureProvider<String>((ref) async {
  await Future.delayed(const Duration(seconds: 3));
  return "foo";
});

class FooWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) => Consumer(
    builder: (context, ref, child) => ref.watch(fooProvicer).when(
      data: (foo) => Text(foo),
      error: (err, st) => const Text("Error"),
      loading: () => const Text("Loading"),
    ),
  );
}

ChangeNotifierProvider

  • 目的: ChangeNotifierを使用して状態の変更をリッスンし、複数のリスナーに状態の変更を通知。
Dart
final formStateProvider = ChangeNotifierProvider<FormStateNotifier>((ref) => FormStateNotifier());

コメント

タイトルとURLをコピーしました