Flutterで次のレベルへ!中級者向けRiverpod NotifierProviderの使い方
はじめに
こんにちは、株式会社viviON アプリユニットのDiegoです
Flutterでアプリの開発担当してます。
皆さんFlutterの開発楽しんでいますでしょうか?
私は最近のFlutterの進化に驚きつつ楽しく開発してます。
今年、私が思う一番の進化(変更)はRiverpodの2.0へのupgradeです。
(正確には、去年末から変更になっていますが、、、)
Riverpodが2.0になったことで以下のような変更が入っています。
- riverpod generatorによるコード生成
- StateNotifierからNotifierへの変更
- ドキュメントの充実(機能とは違うけど重要、とても便利になりました。)
私的には、ドキュメントの充実が特に嬉しかったです。
ですが、今回は2番目のNotifierへの変更を深掘りして、中級者向けの使い方を備忘録的に記載したいと思います。
※ ドキュメントが充実したことによって、私のこの記事の内容もドキュメントに記載されてます。都度参照先を入れていきます。
NotifierProviderとは
詳細な説明は公式に譲ります。
一言で表すと、「変更される可能性のある状態(State)を管理するためのProvider」です。
深い説明はしませんが、NotifierProviderは自分自身および外からNotifierProvierの管理しているの状態(State)を変更することができます。
また、変更があったことをref.watchおよびref.listenしている他のProviderに通知することができます。
以下、sampleです。
公式から引っ張ってきました。説明は割愛します。
class Todo with _$Todo {
factory Todo({
required String id,
required String description,
required bool completed,
}) = _Todo;
}
class Todos extends _$Todos {
List<Todo> build() {
return [];
}
void addTodo(Todo todo) {
state = [...state, todo];
}
void removeTodo(String todoId) {
state = [
for (final todo in state)
if (todo.id != todoId) todo,
];
}
void toggle(String todoId) {
state = [
for (final todo in state)
if (todo.id == todoId)
todo.copyWith(completed: !todo.completed)
else
todo,
];
}
}
似たようなProvider
NotifierProviderを一言で言うと「変更される可能性のある状態(State)を管理するためのProvider」と説明しましたが
同じような使い道のProviderが以下のようにいくつかあります。
こちらも公式ページ貼っとくので確認してみてください。
今後上記のProviderは使用する必要はないと思っています。上記のProviderを書く場合は、一旦立ち止まって考えましょう。
NotifierProvider,AsyncNotifierProviderの違い
ここは説明する必要は無いかもしれませんが、初期化が非同期かどうかだけの違いです。
非同期の場合はAsyncNotifierで、非同期じゃ無い場合はNotifierです。
riverpod_generatorを使用している方は、buildの関数が非同期かどうかで自動で分岐されます。
NotifierProviderのtips
NorifierProviderで、こうしたいけどどうしたら良いの?などのtipsを記載します。
- 初期化(build)が終わる前に、methodを実行したいが、その中でbuildが終了するのを待ちたい。(futureの説明)
- 特定のproviderが更新されたら再buildしたい。(watchの説明)
- 特定のproviderが更新されたら、Stateの値だけ書き換えたい。listenの説明
- 自分自身のStateが変更したことを感知して、特定の処理を実行したい。(listenSelfの説明)
初期化(build)が終わる前に、methodを実行したいが、その中でbuildが終了するのを待ちたい。(futureの説明)
こちらはAsyncNotiifier限定の話ですが、AsyncNotifierのbuildが終わる前にAsyncNotifierのmethodを実行することが可能です。
以下TodoのListを管理しているAsyncNotifierを考えたときに、Todoをfetchしている間にも、新しいTodoを作成したいと言う要望があるとします。
このとき、Todoを作成する際に、名前が被ってはいけないと言うルールがあるため、Todoの作成自体は受け入れるが、実際に作るのは既存のTodoを取得した後に検証してから作成をしたい。と言う要望がある場合、以下のように記載することができます。
class AsyncTodos extends _$AsyncTodos {
Future<List<Todo>> _fetchTodo() async {
final response = await Dio().get('api/todos');
final todos = jsonDecode(response.data) as List<Map<String, dynamic>>;
return todos.map(Todo.fromJson).toList();
}
FutureOr<List<Todo>> build() async {
return _fetchTodo();
}
Future<void> addTodo(Todo todo) async {
// buildが終わっていない可能性があるので、buildが終わるまで待機する。
final currentState = await future;
// 同じdescriptionのtodoがあるかチェック
if (currentState
.any((element) => element.description == todo.description)) {
throw Exception('同じタイトルのtodoがあります。');
}
// Todoを追加の処理を実行する。
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
await Dio().post('api/todos', data: todo.toJson());
return _fetchTodo();
});
}
}
このコードのキモとなるのは、final currentState = await future
です。
futureはbuildが終わるのを待機するmethodになっています。
特定のproviderが更新されたら再buildしたい。(watchの説明)
NotifierProviderが特定のproviderに依存しており、そのproviderの値が変更されたときに
再度初期化(build)を行いたい場合、Notifierのbuild関数内で、ref.watchを使用することで
watch対象のproviderが変更された場合、現在のNotifierProviderは破棄され新しいNotifierProviderが発行されます。
以下、sampleでユーザの資産状況を管理するwallet Notifierを用意しました。
wallet Notifierはいくつかの銀行口座の残高をwatchしており、銀行口座の残高が変更される度に
Providerが破棄され、buildが実行され、最新の状態を保持します。
// 銀行の口座を表すProvider
class BankAccountBallance extends _$BankAccountBallance {
FutureOr<int> build(
{required String bankId, required String accountId}) async {
final resp = await Dio()
.get('https://example.com/api/bank/$bankId/account/$accountId');
return resp.data['balance'] as int;
}
// 入金
Future<void> deposit(int money) async {
final resp = await Dio().post(
'https://example.com/api/bank/$bankId/account/$accountId/deposit',
data: {'money': money},
);
state = AsyncData(resp.data['balance'] as int);
}
// 出金
Future<void> withDraw(int money) async {
final resp = await Dio().post(
'https://example.com/api/bank/$bankId/account/$accountId/withDraw',
data: {'money': money},
);
state = AsyncData(resp.data['balance'] as int);
}
}
// ユーザのすべての口座の残高を合計した値を返すProvider
class Wallet extends _$Wallet {
FutureOr<int> build() async {
// ここでbank1とbank2の口座の残高を取得して合計する
final balance1 = await ref.watch(
bankAccountBallanceProvider(bankId: "bank1", accountId: "account1")
.future);
final balance2 = await ref.watch(
bankAccountBallanceProvider(bankId: "bank2", accountId: "account2")
.future);
// タンス貯金の金額を取得
final piggyBank = await Dio().get('https://example.com/api/piggyBank');
final balance3 = piggyBank.data['balance'] as int;
return balance1 + balance2 + balance3;
}
}
特定のproviderが更新されたら、Stateの値だけ書き換えたい。(listenの説明)
「1.特定のproviderが更新されたら再buildしたい。」でsampleで示したコードには1点問題があります。
- balance1が変更になった際に、buildが再実行されるため、関係のないタンス貯金の金額まで再取得することになることです。
上記の問題を解消するために、ref.listenを使用して、以下のように書き換えます。
class Wallet extends _$Wallet {
FutureOr<int> build() async {
// ここでbank1とbank2の口座の残高を取得して合計する
final balance1 = await ref.read(
bankAccountBallanceProvider(bankId: "bank1", accountId: "account1")
.future);
// balance1の値が更新されたときの処理
ref.listen(
bankAccountBallanceProvider(bankId: "bank1", accountId: "account1"),
(previous, next) async {
// 現在のstateを取得
final currentState = await future;
// previousは前回の値、nextは更新後の値
// ここで前回の値と更新後の値の差分を計算してstateを更新する
// ※ next,previouseの値をちゃんとハンドリングしてないので実際のコードとしては動きません。
final diff = next.requireValue - previous!.requireValue;
state = AsyncData(currentState + diff);
});
final balance2 = await ref.read(
bankAccountBallanceProvider(bankId: "bank2", accountId: "account2")
.future);
// balance2の値が更新されたときの処理
ref.listen(
bankAccountBallanceProvider(bankId: "bank2", accountId: "account2"),
(previous, next) async {
final currentState = await future;
final diff = next.requireValue - previous!.requireValue;
state = AsyncData(currentState + diff);
});
// タンス貯金の金額を取得
final piggyBank = await Dio().get('https://example.com/api/piggyBank');
final balance3 = piggyBank.data['balance'] as int;
return balance1 + balance2 + balance3;
}
}
先ほどのコードからの変更点は以下です。
- watchをreadに変更
- readした後に、listenを作成し、値の変更を検知してstateを変更する処理を追加
上記のようにすると、watchしているproviderが変更されたとしてもbuildは実行されず特定のStateを変更することができます。
listenについては、buildが終わる前に実行される可能性があるので、念の為final currentState = await future
を実行し初期buildの完了を待った方が良いです。
ref.listenの注意点
ref.listenの戻り値でsubscriptionが帰ってきますが、
手動で破棄することもできますが、providerが破棄される際に、自動で削除されるため、
特に手動で破棄する必要はありません。
手動で破棄する場合は、以下のようになります。
// ユーザのすべての口座の残高を合計した値を返すProvider
class Wallet extends _$Wallet {
FutureOr<int> build() async {
-----------省略--------
// balance1の値が更新されたときの処理
final balance1Subscription = ref.listen(
bankAccountBallanceProvider(bankId: "bank1", accountId: "account1"),
(previous, next) async {
// 現在のstateを取得
final currentState = await future;
// previousは前回の値、nextは更新後の値
// ここで前回の値と更新後の値の差分を計算してstateを更新する
// ※ next,previouseの値をちゃんとハンドリングしてないので実際のコードとしては動きません。
final diff = next.requireValue - previous!.requireValue;
state = AsyncData(currentState + diff);
});
// balance2の値が更新されたときの処理
final balance2Subscription = ref.listen(
bankAccountBallanceProvider(bankId: "bank2", accountId: "account2"),
(previous, next) async {
final currentState = await future;
final diff = next.requireValue - previous!.requireValue;
state = AsyncData(currentState + diff);
});
// onDispose内でcloseを実行
ref.onDispose(() {
balance1Subscription.close();
balance2Subscription.close();
});
-----------省略--------
}
}
自分自身のStateが変更したことを感知して、特定の処理を実行したい。(listenSelfの説明)
ref.listenは外部のProviderの変更を感知しましたが、
自分自身の変更を感知するためには、ref.listenSelfを使用します。
以下、sampleで自分自身が変更されたときに、変更前と変更後の値を比較して
残高が増加したか、減少したかprintで表示する処理を追加しました。
// ユーザのすべての口座の残高を合計した値を返すProvider
class Wallet extends _$Wallet {
FutureOr<int> build() async {
// ここでbank1とbank2の口座の残高を取得して合計する
final balance1 = await ref.watch(
bankAccountBallanceProvider(bankId: "bank1", accountId: "account1")
.future);
final balance2 = await ref.watch(
bankAccountBallanceProvider(bankId: "bank2", accountId: "account2")
.future);
// タンス貯金の金額を取得
final piggyBank = await Dio().get('https://example.com/api/piggyBank');
final balance3 = piggyBank.data['balance'] as int;
// 自分自身を監視して、特定の処理を実行します。
ref.listenSelf((previous, next) {
// ここで残高の変化を監視して、変化があったら通知する
final previousValue = previous?.value;
final nextValue = next.value;
if (previousValue != null && nextValue != null) {
if (previousValue < nextValue) {
print("残高が増えました。");
} else {
print("残高が減りました。");
}
}
});
return balance1 + balance2 + balance3;
}
}
最後に
以上、RiverpodのNotifierProviderのtipsでした。
他のProviderについても、今後深掘りしていきたいです。
次は DartのRecord型とかswitchとかDart3.0で便利になった部分の記事書きます。
FlutterおよびDartの今後の進化が楽しみなエンジニアのDiegoからでした。
一応ですが、vivionでは一緒に働くメンバー募集中です。
Flutterのエンジニアも募集しています。メンバー増えると嬉しいです。
興味ある方は以下よりぜひ!!
Discussion