対象者
- Flutterの状態管理パッケージを調べている方
- Flutterの基本の勉強は終わった方
- Riverpodの初心者から中級者
- RIverpodをFlutterでhooks_vierpodなしで使用する方
この記事のメリット
- 全てのProviderについて、コード付きで書かれている
- 作者が2年以上Riverpodを経験している
- 作者がRiverpodを使用したFlutter講座をUdemyで出している(840名 2023/01/22現在)。そのため、受講生から質問を頂いているので、疑問になる点を把握している
Riverpodとは
Riverpodは「Remi Rousselet」さんが開発しているFlutterの状態管理パッケージです。Flutter標準の状態管理は「StatefulWidget + setState」ですが、アプリが複雑になると、この組み合わせでの管理が大変になります。そこで状態管理パッケージの出番です。
元々Google様も推奨していたProviderという別パッケージも作られてましたが、以下の欠点をなくすため新しくRiverpodを作成した、という経緯があります。
コンパイルセーフであること
Providerであれば実行してから、指定のクラスの値を設定してなかった、ということが起こりえました。しかし、Riverpodでは初期値なども定義しますので、コンパイルエラー時に発見できます。(後述するProviderScopeを付け忘れて、RIverpod対応直後にエラーが発生するのはお約束ですが、それ以外は大丈夫)
型に依存しないこと
Providerでは同じ型の値を同時に使うことができませんでした。そのため、複数のStringを同時に使おうとすると、別のクラスを定義する必要がありました。
Flutterに依存しないこと
Dartだけでも使えます。そのため、テストでも使用することができます。
ちなみに、名前の由来は「Provider」のアナグラムと言われています。
アナグラム: 言葉遊びの一つで、単語または文の中の文字をいくつか入れ替えることによって、全く別の意味にさせる遊び by wiki
他パッケージとの比較
作者は以下のような状態管理のパッケージを使用しましたが、以下の理由で最終Riverpodに落ち着きました。
setState
忘れる(笑) 使用できる範囲がStatefulWidget内になり、MVVMでのViewModelでの利用に手間が掛かる
GetX
データの維持が挙動不明。すぐにメモリから消去したがるので、どうすればキープできるかの戦いだった
Bloc
複数のデータを同時に使用したいときに、データの取り扱いが難しかった
以上の結果から今はRiverpodに落ち着いてます。
Riverpod 共通の設定
それでは、実際に導入していきましょう。通常通りのFlutterのアプリを新規作成して、カウントアップアプリを作成しましょう。まず、共通の設定をします。
Riverpodのインストール
いつものパッケージのインストール通り、以下を実行して「flutter_riverpod」をインストールします。
flutter pub add flutter_riverpod
fluter pub get
ProviderScopeを追加
import 'package:flutter_riverpod/flutter_riverpod.dart';
void main() {
runApp(const ProviderScope(child: MyApp()));
}
Providerとは
Providerとは、Riverpod内で実際の「状態」(数字や文字列、クラスのインスタンスなど)を管理します。6種類あり、管理する対象や更新方法などで適切なProviderを選択して使用します。
Provider
紛らわしくて申し訳ありません。Provider群の中に、Privoderというクラスがあります。
「定数」のようなProviderです。一度定義すると状態が外部からは変更できません。
初期化時点で、他のProviderの値を参照して、その値を加工した値を自分の値として持つことができます。この例では、他のProviderの値を2倍にした値を保持する設定にしています。
- 定義
final _counterProvider = ChangeNotifierProvider((ref) => Counter());
late final _doubleProvider =
Provider<int>((ref) => ref.watch(_counterProvider).counter * 2);
- 参照
Text(
'${ref.watch(_doubleProvider)}',
),
StateProvider
一番基本になるProviderです。
変数のようなProviderです。Providerのnotifierを参照して、直接値を変更します。
- 定義
final _counterProvider = StateProvider((ref) => 0);
- 参照
Text(
'${ref.watch(_counterProvider)}',
),
- 更新
更新方法は以下の二通りあります。updateは今の値を加工した結果を設定したいときに使います。stateは今の値が必要ないときに使用します
ref.read(_counterProvider.notifier).update((state) => state + 1);
ref.read(_counterProvider.notifier).state = 0;
StateNotifierProvider
変数とメソッドを持つProviderです。
StateNotifierを継承したクラスを作成します。状態を表す変数(state)と、状態を更新するメソッドを持ちます。
StateNotifierProviderはStateNotifierを継承したクラスを監視し、状態に変化があれば、画面の更新を行います。
- 定義
class CounterNotifier extends StateNotifier<int> {
CounterNotifier() : super(0);
void countUp() {
state++;
}
}
final _counterProvider =
StateNotifierProvider<CounterNotifier, int>((ref) => CounterNotifier());
- 参照
Text(
'${ref.watch(_counterProvider)}',
style: Theme.of(context).textTheme.headline4,
),
- 更新
ref.read(_counterProvider.notifier).countUp(),
ChangeNotifierProvider
Riverpodの前身のパッケージ「Provider」との互換性のためにあるProviderです。非推奨です。
特徴としてはミュータブルなクラスの状態管理に使える、という点です。しかしその点に関してパッケージの作者は「ミュータブルなクラスではなく、イミュータブルなクラスに修正すべき」と記載してます。
ChangeNotifierを継承したクラスを監視します。
- 定義
class Counter extends ChangeNotifier {
int _counter = 0;
get counter => _counter;
void countUp() {
_counter++;
notifyListeners();
}
}
final _counterProvider = ChangeNotifierProvider((ref) => Counter());
- 参照
Text(
'${ref.watch(_counterProvider).counter}',
style: Theme.of(context).textTheme.headline4,
),
- 更新
ref.read(_counterProvider).countUp()
FutureProvier
Future型を取り扱うProvider。データと取得状態を管理するAsyncValueを取得できる。
WebAPIやSharedPreferencesなど非同期処理で取得されるデータに対して使用する。
AsyncValue
FutureProviderやStreamProviderでは、AsyncValueでデータと取得状態を管理している。
データの取得状態によって出力するWidgetが自動で切り替わる。状態には以下の3つがある。
loading
データ取得を開始し、取得完了するまでの待機状態
error
データ取得に失敗した状態
data
データ取得が正常に完了した状態
- 定義
final _futureProvider = FutureProvider((ref) async {
final sharedPreferences = await SharedPreferences.getInstance();
return sharedPreferences.getInt(kKey) ?? 0;
});
- 参照
ref.watch(_futureProvider).when(
loading: () => const CircularProgressIndicator(),
error: (error, stack) => const Text('error'),
data: (data) => Text(
'${ref.watch(_futureProvider).value}',
style: Theme.of(context).textTheme.headline4,
),
)
- 更新(データの再取得)
ref.invalidate(_futureProvider)
StreamProvider
Future型を取り扱うProvider。データと取得状態を管理するAsyncValueを取得できる。
StreamControllerやFirestoreのスナップショットなどのストリームで提供されるデータに対して使用する。
- 定義
final _streamController = StreamController<int>();
late final _streamProvider = StreamProvider<int>((ref) {
return _streamController.stream;
});
- 参照
ref.watch(_streamProvider).when(
loading: () => const CircularProgressIndicator(),
error: (error, stack) => const Text('error'),
data: (data) => Text(
'${ref.watch(_streamProvider).value}',
style: Theme.of(context).textTheme.headline4,
),
),
- 更新(StreamProviderの更新ではなく、Streamの更新)
_streamController.sink.add(++_count)
表示するためのWidget
Riverpodで管理するProviderの値を参照するためのWidgetは以下の3つです。
Consumer
Builderの書き方をする。Consumer内でrefが参照できる。
影響範囲を小さくできる。
Consumer(builder: (context, ref, child) {
return Text(
'${ref.watch(_counterProvider)}',
style: Theme.of(context).textTheme.headline4,
);
}),
Builderのため、IDEの「remove this widget」が使えなく、削除するときに苦労してます(Android Studioの場合。VisualCodeだといけるのか)。
ConsumerWidget
Riverpod版のStatelessWidget。buildメソッド内でrefが参照できる。
class MyHomePage extends ConsumerWidget {
MyHomePage({super.key, required this.title});
final String title;
final _counterProvider = StateProvider((ref) => 0);
@override
Widget build(BuildContext context, WidgetRef ref) {
(中略)
}
}
ConsumerStatefulWidget
Riverpod版のStatefulWidget。State内からrefが参照できる。
class MyHomePage extends ConsumerStatefulWidget {
@override
ConsumerState<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends ConsumerState<MyHomePage> {
final _counterProvider = StateProvider((ref) => 0);
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: () =>
ref.read(_counterProvider.notifier).update((state) => state + 1),
child: const Icon(Icons.add),
),
);
}
}
イミュータブルとミュータブル
インスタンスの種類
インスタンスは以下の2種類に分けられます。
イミュータブル = 不変
イミュータブルなインスタンスとは、インスタンスを作成した時点からデータの内容が変わらないことを意味します。コンストラクタで渡された引数の値を保持し続け、インスタンスが消滅するまで引数の値を保持し続けます。
「でも、変数って変わるもんでしょ?」と思われるかもしれません。インスタンス内の値を変更したければ、値を変更したインスタンスを新しく作成します。
不便に感じますが、値が変わらないことで、思わぬ副作用を減らし、影響範囲を少なくできます。
ミュータブル = 可変
ミュータブルなインスタンスとは、インスタンスを作成した時点からデータの内容が換えらられることを意味します。意識せずクラスを作成すると、ミュータブルになります。
ミュータブルでやらかすケース
ミュータブルを意識しなかった場合には、意図通りにRiverpodは動作しません。よくある例を挙げます。
List
RIverpodが初めてであれば、この記事にたどり着いたのは「本当に」幸運です。この記事を読んでなかったら、間違いなくListが更新できなくて困ります。この項目だけで、確実に1時間は節約できます。(体験者は語る)
さて、リストはイミュータブルではありません。意図してイミュータブルな動作をするようにしないといけません。
- Riverpod的にダメなListの追加方法
Riverpodで初めてListを扱うと多分こういう風に書いて、更新され、Google様に質問することになります。
final listInNormalUsage = ref.read(_normalListProvider);
listInNormalUsage.add(0);
ref.read(_normalListProvider.notifier).state = listInNormalUsage;
addを使って状態を変えているので、ミュータブルなリストになってます。そのため、Riverpodがデータの変化を検知できず、画面が更新されません。リストの他のメソッド(removeなど)でも同様に更新されません。
- RIverpod的に正しいListの追加方法
上記のListのコードは以下のようにします。
final list = ref.read(_immutableListProvider);
final newList = [...list, 0];
ref.read(_immutableListProvider.notifier).state = newList;
// 上記の3文を1文で実施
ref.read(_immutableListProvider.notifier).update((state) => [...state, 0]);
配列の中で「…」(Spread 記法)とやると、リストを展開します。そのため、新しくできるリストの最初の部分は前のリストと同じで、末尾にだけ新しい値を追加したリストが新規作成されます。前のリストと新しくできたリストは別のリストのため、Riverpodは検知してくれます。そして画面が更新されます。
コード全体
ミュータブルなクラス
- Riverpod的に間違えたクラスの書き方
まあ、普通はこういう風にコード書きますよねぇ、、、ミュータブルなため、画面更新されません。
// クラス定義
class MutableData {
int count = 0;
void countUp() {
count++;
}
}
// Provider定義
final _mutableProvider = StateProvider<MutableData>((ref) => MutableData());
// 更新
final mutableData = ref.read(_mutableProvider);
mutableData.countUp();
ref.read(_mutableProvider.notifier).state = mutableData;
- Riverpod的に正しいクラスの書き方
クラスのメンバー変数にはfinalをつけます。値を変更するメソッドは、新しい値を入れたインスタンスを返します。
// クラス定義
class ImmutableData {
ImmutableData(this.count);
final int count;
ImmutableData countUp() {
return ImmutableData(count + 1);
}
}
// Provider定義
final _immutableProvider =
StateProvider<ImmutableData>((ref) => ImmutableData(0));
// 更新
final oldImmutableData = ref.read(_immutableProvider);
final newImmutableData = oldImmutableData.countUp();
ref.read(_immutableProvider.notifier).state = newImmutableData;
もしくは、Freezedでクラスを作成します。Freezedについては、以下の記事をご覧下さい。
select
Providerで監視した対象の特定の項目を表示する場合があります。もしそのとき、特定の項目以外が更新されてもWidgetを更新しないならselect を使うと良いです。不要な更新を避けられます。
Consumer(builder: (context, ref, child) {
return Text(
'${ref.watch(_immutableProvider.select((e) => e.sameValue))}',
style: Theme.of(context).textTheme.headline4,
);
}),
値を参照するメソッド
Providerの値を参照するために、refには以下の3つのメソッドがあります。
read
readは取得時点での状態を取得するのに使います。値を変更しても、画面が更新されません(バグです)。
onTapなどのイベント内や、ライフサイクルのイベント(StatefulWidgetのinitStateなど)内で使用します。
watch
watchは状態を取得し、監視続けます。値に変更があれば、画面に更新します。
Widgetにリアクティブな値を埋め込む場合に使用します。
listen
状態が変更した場合に実行したいイベントを定義する。Consumerのbuilder内、ConsumerWidget, ConsumerStatefulWidget のbuild内で設定する。
build内で、以下のように定義する。
ref.listen(_stateProvider, (previous, next) {
print('previous: $previous next: $next');
});
(注) ConsumerStatefulWidgetのinitStateで実行できない
Provider修飾子とメソッド
Provider修飾子は、Providerに機能を追加します。
family
メソッドに「引数」を渡せるProviderを作成します。
autoDispose
Providerの監視対象がなくなったときに、Providerの状態を破棄させるようにします。
ref.invalidate
指定のProviderを無効化する。そのため、Providerが再度初期化されて、再度データが読み込まれる。
サンプル
サンプルとして、GithubAPIからレポジトリ名を取得するFutureProviderを作成します。
GitHubAPIのデフォルトでは、1ページ30レポジトリが取得できます。カウントアップアプリの「+」ボタンを押すたびに、取得するページ数を増やしていきます。カウンタの代わりに、ページ毎の最初のレポジトリのプロジェクト名を画面に表示するようにしています。
以下の流れを想定してます。
- 画面が表示される
- 1ページ目の最初のレポジトリ名が表示される
- 「+」を押してページ数を上げて、1ページ目のFutureProviderを無効にする
- 2ページ目の最初のレポジトリ名が表示される
- 3から4の繰り返し
ページ数を渡すのにfamilyを使用し、autoDisposeで前のページのFutureProviderを破棄します。また、ref.invalidateで新しいページのFutureProviderを読み込みます。
- 定義
final _apiProvider =
FutureProvider.autoDispose.family<String, int>((ref, page) async {
String url =
'https://api.github.com/search/repositories?q=flutter&page=$page';
final client = http.Client();
final response = await client.get(Uri.parse(url));
final data = json.decode(response.body);
final projectName = data['items'][0]['name'];
return projectName;
});
- 表示
ref.watch(_apiProvider(_counter)).when(
loading: () => const CircularProgressIndicator(),
error: (_, __) => const Text('error'),
data: (data) => Text(data),
)
- 更新(無効化して、再読み込み)
ref.invalidate(_apiProvider);
ライフサイクルイベント
Providerの初期化
Providerの定義時ではなく、ConsumerWidgetのbuild内などから実際に呼び出されたときに初期化される。
ref.onDispose
Providerが破棄されるときのイベントを登録します。
ref.onCancel
Providerの最後のリスナーが削除されたときに実行される。
ref.onResume
Providerの最後のリスナーが削除された後、再度監視が開始されるときに実行される。
サンプルコード
ref.onDispose(() => print('provider was disposed (page=$page)'));
ref.onCancel(() => print('provider was canceled (page=$page)'));
ref.onResume(() => print('provider was resumed (page=$page)'));
WidgetRefの正体
結局WidgetRefは何でしょうか。実は、contextの別名です。以下のように「${context == ref}」を試すと true になります。
Widget build(BuildContext context, WidgetRef ref) {
print('context == ref: ${context == ref}');
「At least listener of ~ threw an exception when the notifier tried to update」の例外
「At least listener of the StateNotifier Instance of ‘StateController
when the notifier tried to update」といった例外が発生するときがあります。これは、build, initState, dispose, didUpdateWidget, didChangeDepedencies といった画面の描画やライフサイクルの中でRiverpodのProviderを更新したときに発生します。
これを防ぐためには「描画が終わった後に、Providerを更新する」ようにします。
具体的には、WidgetsBinding.instance.addPostFrameCallback を使って、以下のように書きます。そうすると、描画後に値が変更されます。(ただ、build内で実施すると、値が増え続けて、残念な感じになる)
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(_stateProvider.notifier).update((state) => state + 1);
});
まとめ
Riverpodの基本的な使用方法から応用的な方法、つまりやすいところまで解説しました。
ただ、Riverpodだけをマスターしても、メンテナンスしやすいコーディングが書ける、という訳ではありません。私が4ヶ月試行錯誤し、1年以上たった今でも、以下の講座で説明している「Riverpod + MVVMパターン」で実践しています。色々他の方のソースを見ましたが、「FlutterのWidgetのレイアウトを綺麗に作れる」という点で、これ以上のものは見つかってません(あったら、教えてください)
ということで、RIverpodをマスターした上で、下記の講座でMVVMパターンもマスターして頂ければ、色々と応用のできるコーディングができるようになるでしょう。