🦔

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

2023/12/21に公開

はじめに

こんにちは、株式会社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を記載します。

  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を取得した後に検証してから作成をしたい。と言う要望がある場合、以下のように記載することができます。


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

Discussion