RiverpodのProviderと仲良くなるために
Riverpodは、筆者の一番好きなFlutterの状態管理ライブラリです。
この記事では、筆者が見聞きする中で、「RiverpodのProviderにおいて、ここに注目するとうまく整理できるのではないか?」と考えていることをまとめます。
はじめに
本記事においては、riverpod_generatorが導入されていることを前提とします。
そのほか、筆者がどのようにRiverpodを捉えているかについては、過去に書いた記事をご参照ください。
変化の伝播
あるProviderが変化し、それを参照しているProviderが再計算されることを考えます。例として、1秒おきに更新されるProviderと、その日付を文字列かするProviderを示します。
DateTime current(Ref ref) {
final timer = Timer(
const Duration(
seconds: 1,
), () {
ref.invalidateSelf();
},
);
ref.onDispose(timer.cancel);
return DateTime.now();
}
String currentIso8601(Ref ref) {
final current = ref.watch(currentProvider);
return current.toIso8601String();
}
currentProvider
が1秒おきに更新されるたび、currentIso8601Provider
も再計算されます。このため、currentIso8601Provider
を参照しているWidgetは1秒おきに再描画されます。結果、毎秒日付が更新されるWidgetを実現できる、というわけです。
この記事では「あるProviderが変化したとき、参照しているProviderが再計算される」という挙動を、変化の伝播と呼ぶことにします。この変化の伝播について考えることで、「Riverpodについて理解が進み仲良くなれるのではないか」と考えています。これが、本記事の趣旨です。
Functional ProviderとClass-based Provider
この記事で頻出するため、まず、Functional ProviderとClass-based Providerの2つの用語を確認します。
Functional ProviderやClass-based Providerという言葉は、次のリンク先に存在します。
この2つの区分は、riverpod_generator
を利用していないと、いまいちピンとこないかもしれません。@riverpod
アノテーションを付与する対象が、FunctionなのかClassなのかによって、riverpod_generator
が生成するコードは変わります。前者はProvidier
であり、後者はNotifier
です。
この用語は、上記Issueにて提案されました。Stateless
の代わりにFunctional
、Stateful
の代わりにClass-based
となっています。Functional
は状態を持たない、Class-based
は状態を持つという意味合いでもあります。
riverpod_generator
の実装も確認してみると、functional_provider.dart
やclass_based_provider.dart
というファイルが存在します。コードを読んでみると、エラー時にfunctional
やclass-based
を含むエラーメッセージが出力されるようです。もしかすると、コンソール上で文字を見かけた方もいるかもしれません。
[1]
https://github.com/rrousselGit/riverpod/blob/riverpod_generator
-v2.4.2/packages/riverpod_generator
/lib/src/templates/functional_provider.dart
https://github.com/rrousselGit/riverpod/blob/riverpod_generator
-v2.4.2/packages/riverpod_generator
/lib/src/templates/class_based_provider.dart
機能のシンプルさから言うとFunctional Providerの方がシンプルです。しかし、シンプルであるが故に、利用されるパターンが多くなります。このため、変化の伝播の観点から整理しようとすると、Class-based Providerの方がわかりやすいでしょう。
以上の理由から、Class-based Providerを押さえた上で、Functional Providerを整理していきます。
Class-based Provider
最もシンプルなClass-based Providerから確認しましょう。初期値を42
とし、ユーザーの操作により状態を更新するProviderが、次のコードです。
class Answer extends _$Answer {
int build() => 42;
void initialize() {
state = 42;
}
void update(
int newValue,
) {
state = newValue;
}
}
生成されるanswerProvider
は、watch
メソッドを利用して状態を参照できます。initialize
メソッドを利用すること(値を外部から指定することなく)状態を更新したり、update
メソッドを利用することで(値を外部から指定して)状態を更新できます。
先述の通り、Class-based Providerは状態を持つProviderです。
この状態が更新されていると、参照しているConsumerやProviderが再計算され、更新が反映されます。つまり変化の伝播を自身が更新された時に引き起こすことができるProviderです。
Functional Provider
最もわかりやすいFunctional Providerは、定数を返すProviderです。
String question(Ref ref) => 'What is the answer?';
Functional Providerとして定義したquestionProvider
は、常に'What is the answer?'を返します。定数を返しているので、変化の伝播を引き起こすことはありません。ほかにも変化の伝播を引き起こさないケースがあるのですが、それらは後ほどまとめて整理します。
Functional Providerは、Class-based Providerと異なり、状態を持ちません。このため、シンプルに考えれば変化の伝播を起しません。が、たとえば次のような条件に合致する時、変化の伝播を起こすProviderとなります。
- 変化の伝播を引き起こすProviderを参照している
-
Future
やStream
を返すProviderを参照している
変化の伝播を引き起こすProviderを参照している
Functional Providerが変化の伝播を引き起こすProviderを参照しているケースです。変化の伝播は波及するので、変化の伝播を引き起こすProviderを参照しているProviderも、変化の伝播を引き起こすProviderとなります。
String question(Ref ref) => 'What is the answer?';
class Answer extends _$Answer {
int build() => 42;
void initialize() {
state = 42;
}
void update(
int newValue,
) {
state = newValue;
}
}
String questionAndAnswer(Ref ref) {
final question = ref.watch(questionProvider);
final answer = ref.watch(answerProvider);
return '$question -> $answer';
}
questionAndAnswerProvider
は、questionProvider
とanswerProvider
を参照しています。このためanswerProvider
が更新されると、questionAndAnswerProvider
も再計算されます。
対して当然ではありますが、変化の伝播を引き起こさないProviderを参照している場合には、変化の伝播の性質はProviderに与えられません。answerProvider
を定数を返すFunctional Providerとして、定義し直してみます。
String question(Ref ref) => 'What is the answer?';
int answer(Ref ref) => 42;
String questionAndAnswer(Ref ref) {
final question = ref.watch(questionProvider);
final answer = ref.watch(answerProvider);
return '$question -> $answer';
}
この例では、questionAndAnswerProvider
は'What is the answer? -> 42'
を返します。answerProvider
やquestionProvider
を更新できないので、値は変化しません。
Functional Providerの性質として、Providerを合成する機能がある、と筆者は理解しています。
Future
やStream
を返すProviderを参照している
Future
やStream
を返すProviderを参照している場合、Providerは変化の伝播を引き起こします。これは、Future
やStream
が更新されるたびに、Providerが再計算されるためです。
Stream<User?> user(Ref ref)
=> FirebaseAuth.instance.authStateChanges();
authStateChangesは、ログイン状態が変更されるたびに、User
を返します。userProvider
はログイン状態が変更されるたびに再計算されることとなり、変化の伝播を引き起こすProviderです。
Riverpodが便利な理由の1つに、RiverpodがFuture
やStream
をProviderに変換する機能があります。FutureProvider
やStreamProvider
は、Future
やStream
をProviderに変換し、値の更新を変化の伝播として扱うことができます。
RiverpodのProviderは、Flutterにおいては、最終的にWidgetのbuild
メソッドで参照されます。このため、Future
やStream
をFutureProvider
やStreamProvider
に変換すると、Providerをbuild
メソッド内でwatch
できるようになります。Future
やStream
で得られた値を、ProviderとしてWidgetに反映できるわけです。
なお、Flutterの標準WidgetであるFutureBuilder
やStreamBuilder
を利用することで、同様のことは実現できます。このため、Riverpodが過剰な機能を提供しているように思えるかもしれません。
筆者の意見としては、「Future
やStream
を組み合わせないアプリケーションでは不要かもしれない」と感じます。
大抵のアプリケーションでは、Widgetのが1つのFuture
やStream
を参照するにとどまりません。適切なロジックを組み上げようとすると、ChangeNotifier
やValueNotifier
の中で、Future
やStream
を参照する必要が生じます。このFuture
やStream
、そしてChangeNotifier
を複数回組み合わせようとすると、そこそこ難解なコードを書く必要が生じます。[2]
RiverpodはFuture
とStream
をProviderに変換することで、Future
やStream
で表現される値の変更をProviderとして表現します。Provierの合成は、Functional Providerを噛ませることで、簡単に合成できます。結果、Future
やStream
の合成をProviderの合成として表現でき、シンプルにロジックを記述できます。
以下では、このProviderの組み合わせ方を考察していきます。
Providerの組み合わせ方を考慮する
より具体的な使い方を考えながら、Providerの組み合わせ方を確認します。筆者が思いつく典型的なパターンなので、他の事例もあるかもしれません。[3]
初期値を参照するClass-based Provider
Class-based Providerの頻出パターンとして、他のProviderから初期値を取得するケースを考えます。
String question(Ref ref) => 'What is the answer?';
class Answer extends _$Answer {
int build() => 42;
void initialize() {
state = 42;
}
void update(
int newValue,
) {
state = newValue;
}
}
class QuestionAndAnswer extends _$QuestionAndAnswer {
({String question, String answer}) build() {
final question = ref.watch(questionProvider);
final answer = ref.watch(answerProvider);
return (
question: question,
answer: answer,
);
}
void update(String newAnswer) {
state = (
question: state.question,
answer: newAnswer,
);
}
void save() {
ref.read(answerProvider.notifier).update(state.answer);
}
}
questionAndAnswerProvider
は、初回のアクセス時にquestionProvider
とanswerProvider
を参照して、初期値を取得します。初期値を設定した後は、update
メソッドを利用することで自身の状態を更新します。この状態はsave
メソッドを利用することで、answerProvider
に反映されます。
一方で、questionAndAnswerProvider
はanswerProvider
が更新されると、初期化されてしまいます。というのも、answerProvider
が更新されると、questionAndAnswerProvider
も再計算されるためです。この挙動は、時たま上手に利用するケースはありますが、基本的には不具合を引き起こすものになります。
Class-basaed Providerにおいて変化の伝播を持つProviderを参照するのは、初期値を取得する場合に絞った方がよいでしょう。また、そう設計していない場合には、参照中のProviderを更新するのは避けた方が良いでしょう。[4]
Singleton objectを返すProvider
定数ならコードに書けばいいので、実際にはSingletonなobjectを返すProviderの方が馴染み深いはずです。SharedPreferencesやFirebaseAuthをProvider経由で取得する場合、次のような実装になります。
Future<SharedPreferences> prefs(Ref ref) async
=> await SharedPreferences.getInstance();
FirebaseAuth auth(Ref ref) => FirebaseAuth.instance;
これらのProviderは、SingletonなObjectを返します。Singletonは常に同じインスタンスを返す設計パターンです。このため、prefsProvider
やauthProvider
は変化の伝播を引き起こすことはありません。
一方で、SharedPreferencesやFirebaseAuthが管理している状態は、アプリケーションの利用中に変化します。このため、任意のイベントが発生した時に、変化の伝播を引き起こす必要があります。この引き起こし方を、2つ紹介します。
Singleton objectとClass-based Providerとの組み合わせ
Class-based Providerでラップすることで、変化の伝播を引き起こすことができます。
class UserName extends _$UserName {
String build() {
// 色々とややこしいので、prefsProviderをsyncで取得できるように調整
return ref.watch(
prefsProvider.select(
(value) => value.getString('name'),
),
);
}
Future<void> update(String name) async {
final prefs = ref.read(prefsProvider);
prefs.setString('name', name);
state = name;
}
}
UserName
は、初回のアクセス時にprefsProvider
を参照してname
を取得します。更新時にはprefsProvider
に値を保存した上で、state
を更新します。
結果として、userNameProvider
は、name
を管理するClass-based Providerとなります。Class-based Providerであるため、state
が更新されるたびに、Providerをwatchしている箇所が再計算されます。
なおSharedPreferencesに同期的にアクセスするには、overrideWithValue
やrequiredValue
を利用する方法があります。[5]
値の変化をStreamで通知する
firebase_authのauthStateChanges
やcloud_firestoreのsnapshots
などの実装です。これらのメソッドは、SingletonなObjectが管理している状態の変化をStream
で返却しています。
Stream<User?> user(Ref ref)
=> FirebaseAuth.instance.authStateChanges();
先述の通り、Stream
を返すProviderは変化の伝播を引き起こします。Singleton objectそのものではなくobjectが管理する状態をProviderとすれば、通常のFunctional Providerと同様に利用できます。
アクセス時の最新の値を返すProvider
典型的な例として、サーバーからのレスポンスを返すProviderを考えます。他の例としては、先ほど示したcurrentProvider
があります。
Future<Plan> plan(Ref ref) async {
final response = await http.get(
Uri.parse('https://example.com/plan'),
);
return Plan.fromJson(
jsonDecode(response.body) as Map<String, dynamic>,
);
}
planProvider
は、サーバーにあるPlan
を取得するProviderです。このProviderはアクセス時にリクエストを送信し、最新のPlan
を返します。このProviderはFuture
を返すため、変化の伝播を引き起こします。ただ通常は、次のようにawait
されて参照されるでしょう。
Future<Plan> recommemedPlan(Ref ref) async {
final plan = await ref.watch(planProvider.future);
return plan.recommendedPlan;
}
このrecommemedPlanProvider
は、planProvider
のmap処理です。Plan
が変化したならば、その内容を反映したいProviderになります。つまりrecommendedPlanProvider
が最新のPlan
を反映したい時には、planProvider
を再実行し、最新のPlan
を取得する必要があります。
この時に利用できるのがinvalidate
メソッドです。
invalidate
メソッドは、Providerを強制的に再計算させるためのメソッドです。強制的に再計算させられるということは、Providerに変化が生じ、変化の伝播が引き起こされることを意味します。
Text(
'Get latest plan',
onPressed: () {
ref.read(planProvider).invalidate();
},
)
invalidate
は、ネットワークエラーによりPlan
の取得に失敗した場合にも利用できます。そのほかには、定数を返すProviderのような、通常変化の伝播を引き起こさないProviderを、一時的に変化の伝播を引き起こすProviderに変更する際にも利用できます。
クラスを返すProvider
レイヤードアーキテクチャにおける、RepositoryをProviderとして表現する場合、2つのケースが考えられます。1つはconst
な値、つまり定数として返すケースです。
UserRepository userRepository(Ref ref)
=> const UserRepository();
UserRepository
はconst
な値として返されるため、変化の伝播を引き起こしません。このようなProviderは、筆者の理解では、アプリケーション内でDIを実現するために利用されます。
対して、次のように(たとえば)Userの認証状態に応じて、UserRepository
を返すProviderも存在します。
UserRepository userRepository(Ref ref) {
final token = ref.watch(
userProvider.select(
(value) => value.token,
),
);
return UserRepository(
token: token,
);
}
この実装は、UserRepository
がUser
の認証状態に応じて変化します。ログアウトされた場合には、token
がnullや空文字に変化します。こうなると、userRepositoryProvider
は変化の伝播を引き起こします。
結果として、userRepositoryProvider
は、User
の認証状態に応じて変化するProviderとなります。
Future<UserInfo> userInfo(Ref ref) async {
final userRepository = ref.watch(userRepositoryProvider);
final userInfo = userRepository.userInfo();
ref.keepAlive();
return userInfo;
}
上記のuserInfoProvider
は、userRepositoryProvider
が更新されるたびに再計算されます。userInfoProvider
が再計算されるタイミングを考えると、ログアウトや別ユーザーとしてログインした時に、再計算されることが期待されます。
結果として、今ログインしているユーザーの情報を取得するProviderとして利用でき、アプリケーションの状態を簡潔に表現できます。
おわりに
Riverpodが提供するProviderそのものは、非常にシンプルです。問題はProviderを利用するシーンが多様であり、また、複雑である点です。このため、アプリケーションで採用しているアーキテクチャの複雑さが、一見するとRiverpodの複雑さに見えてしまうこともあります。
本記事が、適切にProviderを利用する助けになれば幸いです。
Discussion