はじめに
公式がかなりしっかり書いてくれているので基本はそちらを見るようにする
対象読者は Riverpod 初心者 ~ 雰囲気で Riverpod を使っている人
Riverpod とは
アプリの状態を管理するためのパッケージ
ここでいう状態は int とか String とか ユーザー定義のクラスとか、値のことを指す
メリットの具体的なことは公式を見る
従来の StatefulWidget は State を持っており、そこで状態を管理している
他の StatefulWidget からその状態を取得しようとすると複雑なコードになってしまう
これを解決するため、flutter_riverpod を用いる
riverpod はアプリケーションのどこからでも provider としてアクセス可能とし、画面間での状態の共有を容易にした
この provider には様々な種類がある
provider に何があるのか、どう使うか
provider に何があるのか
- Provider
- StateProvider
- StateNotifierProvider
- ChangeNotifierProvider
- FutureProvider
- StreamProvider
結論から言えば以下のようになる
- 基本は StateNotifierProvider 、変更されたくないなら Provider
- StateProvider は StateNotifierProvider と用途は同じだが、StateProvider は値が容易に書き換えられるためあまり推奨できない
- ChangeNotifier は Navigator2.0 などの特定の用途以外使わないように
- FutureProvider や StreamProvider はその名の通り非同期用に使う
Provider
最も基本的な provider
全ての provider に共通していることだが、ref.watch を Widget の内部で使用すると provider が提供する値を取得することができ、提供される値が更新されると widget を自動で再構築してくれる。他にも ref.read や ref.listen があるが、それについては後述する。
// 定義
final pProvider = Provider<int>((ref) => 0);
// 参照
ref.watch(pProvideer) // == 0
また、他の Provider と違う特徴として、結果の値が変わらないなら更新を通知しないというものがある。
逆に言えば他の provider は値が更新されて結果が同じ値でも変更の通知が飛んでしまう。
更新通知が来た widget は見た目が変わらなくても再構築してしまうので、その分無駄な処理が走ってしまう。
例を示す。
以下のようなコードの場合 hogeProvider の値が 0 とそれ以外の値で返り値が変わることになる。
この返り値が同じ場合は pProvider を watch している widget に通知しない。
final pProvider = Provider<bool>((ref) {
return ref.watch(hogeProvider) == 0;
}
Widget build(BuildContext context, WidgetRef ref) {
final isZero = ref.watch(pProvider);
return isZero ? Text('zero!!!') : Text('aaa');
}
このような実装なら hogeProvider が 10 から 100 などに変更されても widget の再構築が走らない。
仮に下のように直接 hogeProvider を参照した widget の場合は 10 から 100 に変更された場合にも再構築が走る。
Widget build(BuildContext context, WidgetRef ref) {
final isZero = ref.watch(hogeProvider) == 0;
return isZero ? Text('zero!!!') : Text('aaa');
}
以上のように、 Provider は適切に使用することでパフォーマンスの改善につながる。
StateProvider
Provider のように値を提供する
Provider との大きな違いは値を外部から変更できることにある
final spProvider = StateProvider<int>((ref) => 0);
ref.watch(spProvider) // == 0
ref.read(spProvider.notifier).update((state) => state = ++state);
ref.watch(spProvider) // == 1
3 行目のように update を呼び出すことで値の更新をする
更新された場合 ref.watch で spProvider を監視している場所で再描画が行われる
デメリットとして、state が様々な場所から自由に書き換えられることにある。
型の範囲でどんな値でも問題ないような場合、つまり int や String、enum のようなものを監視する場合はこれを使うといい
ただし、int 型だとしても +1 ずつ更新していくことを期待しているような場合には向かない。
その場合には StateNotifierProvider を使用する。
StateNotifierProvider
StateProvider との違いは値の更新するメソッドを外部に公開することにある。
class CounterNotifier extends StateNotifier<int> {
CounterNotifier(int initialState) : super(initialState);
void increment() {
state = ++state;
}
}
final snpProvider = StateNotifierProvider<CounterNotifier, int>(
(ref) => CounterNotifier(0));
ref.read(snpProvider.notifier).increment(); // increment 呼び出し
StateProvider との違いは 外部に公開する情報を制限することにある
StateProvider では値を自由に書き換えられるが、StateNotifierProvider では状態を更新するメソッドを外部に公開する
恐らく厳密には値を自由に変更できるようになっており、警告が出るにとどまっている。そのため、実装者の裁量次第では StateProvider と変わらない
例で示したコードでは state は常に increment() を呼び出され、+1 ずつ更新されることを期待している。
多少でも複雑な状態(クラスなど)を持ちたい場合は StateNotifierProvider を使うといい。
状態更新のロジックを監視している側 (widget) から分離できるので、コードが煩雑化しない点もメリットと言える
ChangeNotifierProvider
通常の状態管理での使用は推奨されないためここでは割愛する。
特別なユースケースではない限り使用しないようにする。
FutureProvider
非同期で値を取得するための provider
他の provider との違いは watch する側からのデータの処理方法である
final fProvider = FutureProvider((ref) async {
await Future.delayed(const Duration(seconds: 2));
return 0;
});
ref.watch(fProvider).when(
data: (data) => Text(data.toString()),
error: (error, stackTrace) => const Text('Error'),
loading: () => const CircularProgressIndicator())
ref.watch で取得するのは AsyncValue というもの (AsyncValue は後述する)
これには値の他にロード中、エラーという状態があり、現在の状態に合わせて返り値を変化させることができる
watch で監視している値に対して、when 句で data、error、loading を定義し、
data は データが取得完了した場合の値
error は データ取得中(async 処理中)でエラーが起きた時の値
loading は データ取得中の値
になる。
StreamProvider
その名の通り Stream を処理するための provider
主に一定時間ごとに値を更新する処理をするためのもの
final stProvider = StreamProvider<int>((ref) {
int index = 0;
return Stream.periodic(
const Duration(seconds: 1),
(computationCount) => ++index,
);
});
final stProvider = StreamProvider<int>((ref) {
Stream<int> fetchCounter() async* {
for (int i = 0; i < 10; i++) {
await Future.delayed(const Duration(seconds: 1));
yield i;
}
}
return fetchCounter();
});
// 監視
ref.watch(stProvider).when(
data: (data) => Text(data.toString()),
error: (error, stackTrace) => const Text('Error'),
loading: () => const CircularProgressIndicator())
AsyncValue について
FutureProvider を watch すると帰ってくる値は AsyncValue でラップされている。
when 句で各状態で返す値をハンドリングできるのは AsyncValue を使用しているため。
他の provider でも AsyncValue を直接使うことができる。
以下は AsyncValue を直接使った StateNotifierProvider の例
class FutureCounterNotifier extends StateNotifier<AsyncValue<int>> {
FutureCounterNotifier() : super(const AsyncLoading()) {
fetchData();
}
void fetchData() async {
await Future.delayed(const Duration(seconds: 2));
state = const AsyncData(10);
}
}
final snpProvider = StateNotifierProvider<FutureCounterNotifier, AsyncValue<int>> (
(ref) => FutureCounterNotifier());
当然これは FutureProvider と同じで when 句で状態に応じて返り値を変えられる。
ref.watch(snpProvider).when(
data: (data) => Text(data.toString()),
error: (error, stackTrace) => const Text('Error'),
loading: () => const CircularProgressIndicator())
例では AsyncLoading() で初期化しており、when の判定は loading、AsyncData(10) を state に代入したタイミングで data 判定になる。data の引数は実際の値である 10 が入ることになる。
ちなみに AsyncError を使うことでエラーを投げることもできる。
ただ、いちいち try catch して error stacktrace を取って AsyncError(error, stacktrace) で返すというもの面倒
なので AsyncValue.guard を使う
state = await AsyncValue.guard(() async {
await Future.delayed(const Duration(seconds: 1)); // ここでエラーが発生する可能性のある処理を行う。dio.get など
return 10;
});
これで勝手に try catch してくれて エラーが発生したら AsyncError に発生したエラーの情報を渡して state に入れる。
when の error でその情報を受け取れるようになる。正常に動作したら当然 AsyncData が入る。
copyWithPrevious
AsyncValue には直前の状態を保持する性質がある。
初回ローディングの後に更新をかけても前の値を持ちつつ更新するため、前の値の状態を画面に表示しつつ更新することができる
state = AsyncLoading().copyWithPrevious(state)
のように使う。
状態を保持しながらの移行では元の状態に isLoading を true にした状態となる
isLoading が true だと言っても when による判定は loading にはならない
もし widget に変化を持たせたいなら ref.watch(xxx).isLoading ? hoge : fuga のようなものを data に配置しておく必要がある
isRefreshing もあり、実装は (hasValue || hasError) && isLoading
となっている
AsyncValue を使ったサンプル
class SampleView extends ConsumerWidget {
const SampleView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
body: Center(
child: ref.watch(snpProvider).when(
data: (data) => Text(data.toString()),
error: (error, stackTrace) => Text(stackTrace.toString()),
loading: () => const CircularProgressIndicator())),
floatingActionButton: FloatingActionButton(
onPressed: () async {
ref.read(snpProvider.notifier).fetchMore();
},
),
);
}
}
class FutureCounterNotifier extends StateNotifier<AsyncValue<int>> {
FutureCounterNotifier() : super(const AsyncLoading()) {
_fetchData();
}
void _fetchData() async {
// 初回データ取得
await Future.delayed(const Duration(seconds: 2));
state = const AsyncData<int>(0);
}
void fetchMore() async {
// 現在の状態を保持しつつ isLoading を true にする
state = const AsyncLoading<int>().copyWithPrevious(state);
// 更新
state = await AsyncValue.guard(() async {
await Future.delayed(const Duration(seconds: 1));
return 10;
});
}
}
final snpProvider =
StateNotifierProvider<FutureCounterNotifier, AsyncValue<int>>(
(ref) => FutureCounterNotifier());
データの取り出し
when
data, error, loading の 3 状態を全て定義する
ref.watch(sampleProvider).when(
data: (data) => Text(data.toString()),
error: (error, stackTrace) => const Text('Error'),
loading: () => const CircularProgressIndicator())
mayBeWhen
loading, error をまとめた when
ref.watch(sampleProvider).mayBeWhen(
orElse: CircularProgressIndicator(),
data: (value) => Text(value.toString())
);
whenOrNull
loading error の場合は null を返す when
ref.watch(sampleProvider).whenOrNull(
data: (value) => Text(value.toString())
);
watch, read, listen, refresh などの使い方
watch
watch は監視と値の取得を行う。
値が更新されれば widget を再構築する。
final newValue = ref.watch(sampleProvider);
この watch と後述する listen で有効な select というものがある。
これは監視範囲を絞るためのものである。
例えば以下のような User クラスを公開する provider があるとする
class User {
User({required this.id, required this.name});
final String id;
final String name;
}
以下のように select を使用することで、name の更新で変更を通知せず、id の更新でのみ通知するようになる。
final value = ref.watch(userProvider.select((value) => value.id);
final isID = ref.watch(userProvider.select((value) => value.id == 'ID');
read
read はその時の値を読み取るだけで監視はしない。
例としては StateNotifierProvider の更新関数を呼び出すような場合に値の監視は必要ないので read を使う
ref.read(sampleProvider.notifier).increment();
listen
listen は監視と値の取得を行う。
watch は更新された後の値を返すものだが、
listen は返り値が void のコールバック関数を定義でき、値が変更された時にその処理が実行される。
また、そのコールバック関数は引数が更新される前の値と後の値となる。
build 外で使用する場合は代わりに listenManual を使用する。
ref.listen(sampleProvider, (prev, next) {
print('sampleProvider is update!');
});
refresh
provider を再評価、リフレッシュする。
refreshの戻り値を気にしないのであれば、代わりにinvalidateを使用する
final newValue = ref.refresh(sampleProvider);
invalidate
provider の状態を無効化してリフレッシュさせる。
ref.invalidate(sampleProvider);
exists
基本的に非推奨。プロバイダが初期化されているか否かを判断する。
.autoDispose について
参照するものがいなくなれば破棄されるという修飾
autoDispose がない場合、最初に参照されてから参照するものがいなくなった後に再度参照し始めるときに最初の状態が維持されたままとなる。
画面遷移してまた戻ってきたときに再度値を取得し直したい場合などに使う
final sampleProvider = FutureProvider.autoDispose((ref) async{
await Future.delayed(const Duration(seconds: 2));
ref.onDispose(() {
// 破棄されるタイミングの処理
});
return 10;
});
.family について
パラメータから一意のプロバイダを作成する。
似たような動作をする provider を複数用意するのに便利
定義方法は以下のようになる。ちなみに family と autodispose は併用できる
final familyProvider = FutureProvider.family<int, String>((ref, id)async {
return dio.get('ttps://xxxxxxxxxxxxxx/$id');
});
この定義方法だと familyProvider に id を渡すことで id によって変わる provider を作成できる。
これを参照する際には以下のように id に相当する String を渡す。
Widget build(BuildContext context, WidgetRef ref) {
final res = ref.watch(familyProvider('sampleID'));
// ...
}
Riverpod を使用した Flutter アプリアーキテクチャパターン考察
これは個人の考察であって正解ではありません。
※ Flutter のアーキテクチャパターンについて、これだ!という正解があれば教えていただけると助かります。
MVVM
基本的に MVVM が根幹になり、Data 層を追加する
Data, Model, (Repository,) Provider, View
Data
freezed パッケージで作成するような、どの層でも使用するようなクラスを Data 層として定義しておく
Model
Model 層部分では ネットワーク通信、ローカルストレージなどとの通信を書き、
それを制御するクラスのインスタンスを Provider で公開する
ビジネスロジックのためのクラスもここに該当し、Provider で公開する
Repository
Repository 層部分では、Model 層を ref.read することで処理を呼び出し、データの保存を行う
これもインスタンスを Provider で公開する
ref を渡すことで 内部で model 層のインスタンスを read することができる
final sampleRepositoryProvider = Provider((ref) => SampleRepository(ref));
// SampleRepository class の実装は省略
Provider
いわゆる ViewModel 層で、Repository 層と View 層の橋渡しと、状態の保持を行う。
ViewModel 層という名前にしないのは VSCode のフォルダ順番を上から model -> provider -> view という順番にしたいから
Provider は 各種 provider を使用して、View 層に公開する
データの保存命令を行う処理も公開する
class CounterStateNotifier extends StateNotifier<int> {
CounterStateNotifier(this._ref) : super(0);
StateNotifierProviderRef _ref;
void storeData() {
_ref.read(sampleRepositoryProvider).store(state);
}
}
final counterStateProvider = StateNotifierProvider<CounterStateNotifier,int>(
(ref) => CounterStateNotifier(ref));
View
Provider 層に公開されている provider を参照、監視して画面の構成を定義する
class SampleView extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
Scaffold(
body: Center(
child: Text(ref.watch(counterStateProvider).toString())
),
floatingActionButton: FloatingActionButton(
onPressed: () {
ref.read(counterStateProvider.notifier).storeData();
},
child: const Icon(Icons.add),
);
)
}
}