8
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

viviONAdvent Calendar 2023

Day 21

Flutterで次のレベルへ!中級者向けRiverpod NotifierProviderの使い方

Last updated at Posted at 2023-12-20

はじめに

こんにちは、株式会社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です。
公式から引っ張ってきました。説明は割愛します。

@freezed
class Todo with _$Todo {
  factory Todo({
    required String id,
    required String description,
    required bool completed,
  }) = _Todo;
}

@riverpod
class Todos extends _$Todos {
  @override
  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を記載します。

  1. 初期化(build)が終わる前に、methodを実行したいが、その中でbuildが終了するのを待ちたい。(futureの説明)
  2. 特定のproviderが更新されたら再buildしたい。(watchの説明)
  3. 特定のproviderが更新されたら、Stateの値だけ書き換えたい。listenの説明
  4. 自分自身のStateが変更したことを感知して、特定の処理を実行したい。(listenSelfの説明)

初期化(build)が終わる前に、methodを実行したいが、その中でbuildが終了するのを待ちたい。(futureの説明)

こちらはAsyncNotiifier限定の話ですが、AsyncNotifierのbuildが終わる前にAsyncNotifierのmethodを実行することが可能です。
以下TodoのListを管理しているAsyncNotifierを考えたときに、Todoをfetchしている間にも、新しいTodoを作成したいと言う要望があるとします。

このとき、Todoを作成する際に、名前が被ってはいけないと言うルールがあるため、Todoの作成自体は受け入れるが、実際に作るのは既存のTodoを取得した後に検証してから作成をしたい。と言う要望がある場合、以下のように記載することができます。

@riverpod
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();
  }

  @override
  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
@riverpod
class BankAccountBallance extends _$BankAccountBallance {
  @override
  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
@riverpod
class Wallet extends _$Wallet {
  @override
  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を使用して、以下のように書き換えます。

@riverpod
class Wallet extends _$Wallet {
 @override
 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
@riverpod
class Wallet extends _$Wallet {
  @override
  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
@riverpod
class Wallet extends _$Wallet {
  @override
  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のエンジニアも募集しています。メンバー増えると嬉しいです。
興味ある方は以下よりぜひ!!
https://hrmos.co/pages/vivion/jobs/0000428

8
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?