【Flutter】状態管理ライブラリRiverpodの概要と基本的な使い方まとめ

FlutterにおけるRiverpodの概要と基本的な使い方についてまとめました。

Flutter 3.19.4
flutter_riverpod 2.5.1

Riverpodとは?

Flutterに限らず、GUIアプリケーションの実際の開発では、保守性やテスタビリティ、開発効率といった観点から、ビューから状態やそれを更新するロジックを分離することが求められます。

そしてこれらを分離するということは、分離したもの同士を連携させるための設計も必要になるということです。
すなわち、状態とその変更をビューに伝えて更新する手段が必要であり、またビューから状態を変更するロジックにアクセスする手段も必要です。

Riverpodはこの辺りをFlutterの仕組みにうまく組み込む形でカバーするフレームワークです。

riverpod.dev

本記事ではFlutterにおけるRiverpodの基本的な使い方についてまとめます。

セットアップ

Riverpodをインストールするには以下を実行します。
下の二つはオプションのlinterなので必要であれば入れてください。

flutter pub add flutter_riverpod
flutter pub add riverpod_annotation
flutter pub add dev:riverpod_generator
flutter pub add dev:build_runner
flutter pub add dev:custom_lint
flutter pub add dev:riverpod_lint

riverpod_lintを入れた場合にはanalysis_options.yamlに以下を追加する必要があります。

analyzer:
  plugins:
    - custom_lint

簡単なアプリケーションを作ってみる

それではまずRiverpodを使って、ボタンを押したら数値が上昇するだけの簡単なアプリケーションを作ってみます。

まず状態を保持・変更するクラスを作成します。

// count.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

// Riverpodには手で書くコードが少なくて済むようにコード生成機能が実装されている
// 以下の名前で分割されたファイルとして生成されるので、これを使えるようにpartディレクティブを書いておく
part 'count.g.dart';

@riverpod
class Count extends _$Count {
  // buildメソッドで初期状態を返す。この例のように引数を受け取ることも可能
  // 戻り値の型が状態の型を表す
  // 今回はint型の状態を持っているが、任意の型にできる
  @override
  int build(int initialValue) {
    return initialValue;
  }

  // 状態を更新するメソッドを自由に定義できる
  void add(int value) {
    // stateを上書きすると状態が変更されたとみなされ、リスナに通知が送られる
    state = state + value;
  }
}

説明はコメントに書いた通りです。

ここまで記述すると、コンパイルエラーが発生していることが確認できると思います。
コメントにも書きましたが、Riverpod にはコード生成機能があります。
コンパイルエラーはこのコード生成がまだ行われていないために発生しているので、次にコード生成をしていきます。

ターミナルから以下のコマンドでコード生成をすることで不足しているコードが生成され、コンパイルエラーが解消されます。

$ dart run build_runner build

次にビューを構築していきます。
Riverpod では、状態をビュー(など)に提供するクラスを Provider と呼び、状態に変更を加えてそれを通知するためのクラスを Notifier と呼びます。
以下はこれらを使って状態を取得・変更するコードです。

// main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import 'count.dart';

void main() {
  runApp(
    // ProviderScopeで囲った部分がRiverpodが有効になる範囲となる(今回はアプリ全体)
    ProviderScope(child: MyApp()),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(home: Home());
  }
}

class Home extends StatelessWidget {
  const Home({super.key});

  @override
  Widget build(BuildContext context) {
    // Consumerで囲った部分が、状態変更時にリビルドされる範囲となる
    // 以下の例であればwatchしているcountProviderの状態が変更されるとリビルドされる
    // Consumerを使わずにStatelessWidgetの代わりにConsumerWidgetを継承する方法もあるが、全体がリビルドされちゃうので注意
    return Consumer(
      builder: (context, ref, _) {
        // 状態を監視するにはref.watch(providerName)を使う
        // これにより、当該プロバイダーの持つ状態が変更されるとConsumer以下がリビルドされる
        final count = ref.watch(countProvider(123));
        return Scaffold(
          appBar: AppBar(title: const Text('Example')),
          body: Center(
            child: Text('$count'),
          ),
          floatingActionButton: FloatingActionButton(
            // 状態を更新するメソッドを呼ぶには以下のようにする
            onPressed: () => ref.read(countProvider(123).notifier).add(3),
            child: const Icon(Icons.add),
          ),
        );
      },
    );
  }
}

ざっくりした説明はコメントに記載しました。

新しい概念として、ConsumerWidgetRef(上記のコードのref)が登場しています。
これらは「Riverpodとは?」の説で書いた、「分離したもの同士を連携させるため」の仕組みです。

上記のコードでは、ref.watch(countProvider(123)))により状態の変更をビューから監視し、ref.read(countProvider(123).notifier)によりビューから状態を更新する処理(Countクラス)にアクセスしています。

このようにWidgetRefを使うことでビューから状態にアクセスできるものの、WidgetRefをビューにどう渡すのかという問題が残りますが、これを担うのがConsumerということになります。

これを実行すると下図の結果が得られます。

実行結果

正常に処理が行われていることを確認できました。

なお、上記の例ではクラスに対して riverpod アノテーションをつけましたが、そうではなくメソッドに対してアノテーションをつける方法もあります。
更新処理(上述のaddメソッドなど)を定義しない場合はこちらも使えますが、大体のケースで状態は更新すると思うので本記事ではこの方法は省略します。
公式ドキュメントにも以下のように書いてあるので気になる方はこのあたりを参照してください。

A Notifier with no method outside of build is identical to using the previously seen syntax.

The syntax shown in Make your first provider/network request can be considered as a shorthand for notifiers with no way to be modified from the UI.

非同期処理で状態を更新する

前節では同期的に状態を更新する処理を作成しました。
実際のアプリケーションでは、通信など非同期処理を行うことが考えられるので、次に非同期処理で状態を更新します。

// count.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'count.g.dart';

@riverpod
class Count extends _$Count {
  @override
  // 戻り値をFutureにして非同期メソッドに変更
  Future<int> build(int initialValue) async {
    //1秒待機してから値を返す
    await Future.delayed(const Duration(seconds: 1));
    return initialValue;
  }

  Future<void> add(int value) async {
    // ロード状態にする
    state = const AsyncLoading();

    // 1秒待機
    await Future.delayed(const Duration(seconds: 1));

    // AsyncDataを生成して新しい値をセット
    var newValue = state.value! + value;
    state = AsyncData(newValue);
    
    // もし再ビルドしたいだけの場合(buildeに書かれたリクエスト処理を再度走らせたいだけどか)にはこのようにキャッシュを削除すれば再ビルドされる
    //ref.invalidateSelf();
    //await future; // さらに再ビルドの終わりまで待ちたい場合はこう
  }
}

前節からの変更点と説明はコメントとして記載しました。

変更を加えたので改めてTerminalからコード生成を行います。

コード生成が終わりコンパイルエラーが消えたら、main.dartを修正していきます。

// main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import 'count.dart';

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

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(home: Home());
  }
}

class Home extends StatelessWidget {
  const Home({super.key});

  @override
  Widget build(BuildContext context) {
    return Consumer(
      builder: (context, ref, _) {
        final count = ref.watch(countProvider(123));
        return Scaffold(
          appBar: AppBar(title: const Text('Example')),
          body: Center(
            // ここだけ状態によりUIを出し分けるように変更
            child: switch (count) {
              AsyncData(:final value) => Text('$value'), // 正常に結果が得られた時
              AsyncError() => const Text('ERROR'), // エラー時
              _ => const CircularProgressIndicator(), // ローディング中
            },
          ),
          floatingActionButton: FloatingActionButton(
            onPressed: () => ref.read(countProvider(123).notifier).add(3),
            child: const Icon(Icons.add),
          ),
        );
      },
    );
  }
}

修正点はコメントに書いた通りです。
非同期処理の場合、結果を表示する状態に加えて、ローディング中とエラーの表示状態が必要なのでこれらを加えています。

これを実行すると下図の結果が得られます。

実行結果

正常に非同期処理が行えていることを確認できました。

Streamによる非同期処理を扱う

ウェブソケットとの通信を行うケースなど、Streamによる非同期処理を扱うこともできます。

// count.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'count.g.dart';

@riverpod
class Count extends _$Count {
  // Streamを返す
  @override
  Stream<int> build(int initialValue) async* {
    // 1秒ごとに適当に値を返すStreamを作る
    for (var i = 0; i < 10; i++) {
      yield i;
      await Future<void>.delayed(const Duration(seconds: 1));
    }
  }
}

使い方は前節の非同期処理と変わりません。

// main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import 'count.dart';

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

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(home: Home());
  }
}

class Home extends StatelessWidget {
  const Home({super.key});

  @override
  Widget build(BuildContext context) {
    return Consumer(
      builder: (context, ref, _) {
        final count = ref.watch(countProvider(123));
        return Scaffold(
          appBar: AppBar(title: const Text('Example')),
          body: Center(
            child: switch (count) {
              // 前節の非同期処理と同じように書ける
              AsyncData(:final value) => Text('$value'),
              AsyncError() => const Text('ERROR'),
              _ => const CircularProgressIndicator(),
            },
          ),
        );
      },
    );
  }
}

これを実行すると下図の結果が得られます。

実行結果

正常に処理されていることを確認できました。

状態のキャッシュについて

Riverpodでは、Providerの型とその引数の組み合わせごとに状態がキャッシュされます。
すなわち、同じ型と引数のProviderであれば、異なるビューから取得しても同じ結果が得られます。
また、非同期処理を行うProviderを2回目以降にwatchする場合には、(処理が終わっていれば)即座にキャッシュされた状態を得ることができます。
この仕様はリクエスト結果をキャッシュしておきたい場合などに有用です。

キャッシュは便利な機能ですが、なんらかの条件で破棄されないと延々とメモリに残り続けます。   そのためキャッシュされた状態は、リスナが存在しなくなったら(リスナ存在しないフレームがあったら)破棄される仕様になっています。
もしリスナがいなくなっても破棄させたくない場合には@riverpodアノテーションの代わりに@Riverpod(keepAlive: true)をつけます(ただしメモリ管理は自分でやる必要があります)。

// count.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'count.g.dart';

@Riverpod(keepAlive: true)
class Count extends _$Count {
  @override
  int build() {
    return 123;
  }
}

キャッシュを手動で破棄するにはref.invalidateref.invalidateSelfを使います。

// count.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'count.g.dart';

@riverpod
class Count extends _$Count {
  @override
  int build() {
    return 123;
  }
  
  void test()
  {
    // 自身のキャッシュを破棄
    ref.invalidateSelf();
    // 他のプロバイダーのキャッシュを破棄
    // ref.invalidate(fooProvider);
  }
}

また、以下のように状態が破棄されたりリスナが登録・登録解除された時のコールバックを登録することもできます。

// count.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'count.g.dart';

@riverpod
class Count extends _$Count {
  @override
  int build() {
    return 123;
  }

  void test() {
    // 状態が破棄された時の処理
    // 状態が切り替わった時も呼ばれる(古い状態が破棄されるため)
    ref.onDispose(() {});

    // リスナがすべて登録解除された時の処理
    ref.onCancel(() {});
    
    // 初めてのリスナが登録された時の処理
    ref.onResume(() {});
  }
}

参考

riverpod.dev