はじめに
Riverpodは非常に便利な状態管理パッケージですが、適切に使用しないと思わぬエラーやパフォーマンス低下を招いてしまうことがあります。
この記事では、Riverpodを使用する際に注意すべきポイントについて、最近学んだ知識を元に詳しく解説していきます。
今回はRiverpodをbuild_runnerで自動生成する前提ですのでその点ご了承ください。
記事の対象者
- Riverpodの基本的な使い方を理解している方
- Riverpodの利用における注意点を知りたい方
記事を執筆時点での筆者の環境
[✓] Flutter (Channel stable, 3.22.1, on macOS 14.3.1 23D60 darwin-arm64, locale ja-JP)
[✓] Android toolchain - develop for Android devices (Android SDK version 34.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 15.2)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2023.3)
[✓] VS Code (version 1.90.0)
サンプルプロジェクト
今回は、以前の記事でも取り扱っていたプロジェクトを使用して説明していきます。
このプロジェクトは、各種の値を保存・表示する単純なアプリです。
設定する値は以下の通りです。
- bool値の設定
- intの設定
- Stringの設定
- CustomSetting型の設定(JSONに変換してStringで保存)
ソースコード
以前書いた記事
1. watch
するのはできるだけ小さなWidget
単位にする
状態を監視して、状態によってWidget
を出し分けたり表示を変えたりすることがあります。
しかし、それを監視する場所を考えないと思いもよらぬパフォーマンス低下につながります。
題目にある通り、プロバイダーをwatch
するのはできるだけ画面などの全体で行うのは避けましょう。
何故なら全ての状態を全体でwatch
した場合、同じく全体のbuiled
が走ってしまうからです。
以下で実験してみましょう。
1-1. 単体で状態を監視している場合
MyHomePage
は画面全体のWidget
です。
そこで各設定を表示するInfoListTile
をConsumer
Widgetでラップしています。
Consumer
でラップしている理由は以下の例で言うとiconSettingProvider
をこのConsumer
でラップしているWidgetだけで監視したいからです。
その他のConsumer
でラップしている場所ではそれぞれbackgroundColorNumber
、titleText
、customSetting
を監視しています。
/// ホーム画面
class MyHomePage extends HookConsumerWidget {
/// ホーム画面のコンストラクタ
const MyHomePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// 省略
logger.d('画面全体のビルドです');
return Scaffold(
body: SafeArea(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 省略
Flexible(
child: Consumer(
builder: (context, ref, child) {
final iconSetting = ref.watch(iconSettingProvider);
return InfoListTile(
value: iconSetting.valueOrNull,
type: TileType.iconSetting,
);
},
),
),
Flexible(
child: Consumer(
builder: (context, ref, child) {
final backgroundColorNumber =
ref.watch(backgroundColorNumberProvider);
return InfoListTile(
value: backgroundColorNumber.valueOrNull,
type: TileType.backgroundColorNumber,
);
},
),
),
Flexible(
child: Consumer(
builder: (context, ref, child) {
final titleText = ref.watch(titleTextProvider);
return InfoListTile(
value: titleText.valueOrNull,
type: TileType.titleText,
);
},
),
),
Flexible(
child: Consumer(
builder: (context, ref, child) {
final customSetting = ref.watch(customSettingProvider);
return InfoListTile(
value: customSetting.valueOrNull,
type: TileType.customSetting,
);
},
),
),
//省略
builed
の挙動を確認してみる
ここでアイコン設定を変更した場合のbuiled
の挙動を見てみます。
InfoListTile
のbuiled
にはlogger
を出すようにしています。
class InfoListTile extends StatelessWidget {
// 省略
/// Tileのタイプ
final TileType type;
@override
Widget build(BuildContext context) {
logger.d('${type.title}のタイルをビルド');
// 省略
アプリを立ち上げた最初のログ
最初に画面全体のbuiled
が走ります。
次にそれぞれのInfoListTile
がbuiled
されています。
最後に各プロバイダの初期値が流れたことによって再度builed
が走ります。
今回watchしているプロバイダーはStream型です。
Stream型は最初に監視が始まると初期値が流れる仕組みになっています。
よって今回はInfoListTile
がwatch
している値が全てStream型のため全てが再ビルドされています。
アイコンの設定を変えてみる
一旦ログを削除した状態で、アイコン設定を変えてみます。
するとアイコン設定を監視しているInfoListTile
だけがbuiled
されました。
1-2. 画面全体で監視してみる
次に画面全体で監視した場合です。
isWatchProviderFromPage
というフラグをtrue
に変更すると、
画面全体でfinal iconSetting = ref.watch(iconSettingProvider);
を実行し、
その内容を表示するtestIconSettingWidget
を表示するようにしています。
class MyHomePage extends HookConsumerWidget {
/// ホーム画面のコンストラクタ
const MyHomePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
/// 画面全体で値をwatchするテストを行うかどうかのフラグ
final isWatchProviderFromPage = useState(false);
/// テスト用のwidget
Widget? testIconSettingWidget;
// もしisWatchProviderFromPageがtrueの場合は画面全体でiconSettingProviderをwatchし、
// watchした値を反映するwidgetをtestIconSettingWidgetに設定する
if (isWatchProviderFromPage.value) {
final iconSetting = ref.watch(iconSettingProvider);
testIconSettingWidget = Column(
children: [
iconSetting.when(
data: (data) {
logger.d('画面でwatchした場合のビルドです');
return ListTile(
title: Text('画面でwatchした値の${TileType.iconSetting.title}'),
trailing: switch (data) {
true => const Icon(Icons.power),
false => const Icon(Icons.power_off),
null => const Text('値がnullです')
},
);
},
error: (error, stack) {
logger.e(
'エラー',
error: error,
stackTrace: stack,
);
return const Text('エラーです');
},
loading: () => const CircularProgressIndicator(),
),
const Divider(),
],
);
}
logger.d('画面全体のビルドです');
return Scaffold(
body: SafeArea(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// isWatchProviderFromPageがtrueだったら生成する
if (isWatchProviderFromPage.value &&
testIconSettingWidget != null)
testIconSettingWidget,
// 省略
ElevatedButton(
style: ButtonStyle(
backgroundColor: WidgetStateProperty.all(
isWatchProviderFromPage.value ? Colors.yellow : Colors.grey,
),
),
onPressed: () {
final currentIsWatch = isWatchProviderFromPage.value;
final newValue = !currentIsWatch;
isWatchProviderFromPage.value = newValue;
},
child: Text(
'画面全体でのWatchを'
'${isWatchProviderFromPage.value ? '停止' : '開始'}',
),
),
画面全体での監視して値を変更する
一番下のボタンをタップして画面全体でのwatch
を有効にし、アイコン設定を変更してみます。
ログを見てみると、明らかに違いますね。
画面全体でプロバイダーをwatch
すると値の変更があるたびに画面全体が再builed
されてしまいます。
画面全体のbuiled
がされてしまうので、アイコン設定のInfoListTile
だけではなく
その配下の背景色、タイトル、JSONなどの値を監視しているInfoListTile
まで再度builed
されてしまっている事がわかります。
1-3. 結論
watch
する場合はその値の変更に影響があるWidget
内で行うようにしよう!
※ 画面全体に再度builed
を実行させる必要があるばあいはもちろん画面全体でwatch
する必要があります。
2. AsyncValue
はwhen
とvalueOrNull
を使い分けよう
2-1. AsyncValue
とは?
今回扱っているプロバイダーはAsyncValue
で定義しています。
AsyncValue
は簡単に説明すると、Future型またはStream型で定義されたプロバイダーです。
/// アイコン設定の値を提供するStreamを生成
/// `IconSettingRef`を通じてリポジトリにアクセスし、現在のアイコン設定を取得し、
/// その後、アイコン設定が変更されるたびに新しい値を提供する
@riverpod
Stream<bool?> iconSetting(IconSettingRef ref) async* {
// キー値リポジトリのプロバイダーからリポジトリオブジェクトを取得
final repository = ref.read(keyValueRepositoryProvider);
// 最初のアイコン設定の値を取得し、yieldを使用してStreamに出力
yield await repository.getIconSetting();
// リポジトリの値変更通知を購読し、アイコン設定キーの変更のみにフィルターをかける
await for (final _ in repository.onValueChange
.where((key) => key == KeyValueRepository.iconSettingKey)) {
// アイコン設定キーが変更されたとき、新しいアイコン設定の値を取得し、yieldでStreamに出力
yield await repository.getIconSetting();
}
}
AsyncValue
についてこちらの記事で丁寧に解説されています。
2-2. when
メソッドでWidgetを出し分ける
AsyncValue
を使ってWidgetを表示しようとすると一般的にはwhenで実装することになると思います。
AsyncValue
は非同期で値を受け取るので、その場合のハンドリングが行いやすいです。
-
data
: 値の取得に成功した場合 -
error
: 値の取得に失敗した場合 -
loading
: 値を取得している最中の場合
final iconSetting = ref.watch(iconSettingProvider);
iconSetting.when(
data: (data) {
logger.d('画面でwatchした場合のビルドです');
return ListTile(
title: Text('画面でwatchした値の${TileType.iconSetting.title}'),
trailing: switch (data) {
true => const Icon(Icons.power),
false => const Icon(Icons.power_off),
null => const Text('値がnullです')
},
);
},
error: (error, stack) {
logger.e(
'エラー',
error: error,
stackTrace: stack,
);
return const Text('エラーです');
},
loading: () => const CircularProgressIndicator(),
),
2-3. valueOrNull
で実装する
when
は確かに出し分けるのに大変わかりやすいのですが、コード量が増えます。
さらにいうと今回の例の状態はネットワーク通信をしているわけでもなく、画像処理などの時間のかかる処理をしているわけではありません。
値を取得する上でエラーになる可能性も少ないです。
なのでloadingやerrorを省きたいです。
そこで登場するのがvalueOrNull
です。
valueOrNull
はAsyncValue
から直接値を参照するメソッドです。
似たようなものでvalue
がありますが、これは失敗した場合exception
がthrow
されるため、
基本的にはvalueOrNull
を利用するのがベターでしょう。
Flexible(
child: Consumer(
builder: (context, ref, child) {
final iconSetting = ref.watch(iconSettingProvider);
return InfoListTile(
value: iconSetting.valueOrNull,
type: TileType.iconSetting,
);
},
),
),
ここではiconSetting.valueOrNull
だけを渡していますが、注意しければいけないのはnull許容だという事です。
今回でいうとInfoListTile
の方でnullだった場合はハンドリングしているのでそのまま渡しています。
そうではない場合、デフォルト値を渡しておくのが安全です。
value: iconSetting.valueOrNull ?? false,
2-4. 総括
対象の値がどんな処理によって生成されるのかを見極めて、when
とvalueOrNull
を使い分けよう
3. 値を引数で渡したり、useStateでの管理を検討する
1. watchするのはできるだけWidget単位にする でも触れたように、
状態を取得するのに全てをwatch
するとパフォーマンス低下につながる場合もあります。
その状態を監視ではなくて、その値を引き渡すだけでいい場合もあります。
また、一時的に保持したいだけならStatefullWidegt
のstate
か、
flutter_hooksパッケージによるHookWidget
のuseState
を検討しましょう。
今回はCustomSettingの編集保存の場合を例に説明します。
また、一時保存にはHookWidget
のuseState
を採用しています。
以下が主な機能です。
- ホーム画面のボタンを押すとCustomSettingを編集する専用のページに遷移します
- 編集画面ではすでに保存されているCustomSettingの値を元に表示をします
- 編集している最中の値は一時的に編集画面で保持します
- 保存ボタンを押すと変更内容が保存されます
以下のGifではちょっとわかりづらいですが、編集画面で一度保存せずにホーム画面へ戻っています。
その後もう一度編集画面で編集後に保存ボタンを押してホーム画面に戻ると保存内容が表示されています。
3-1. 状態を引数で渡す
編集画面に必要なのは現在の設定値です。
今回のCustomSettingの状態は保存されている値です。
保存されていない編集中に状態が変わることはあり得ないので、watch
する必要がない(してはいけない)です。
よって編集画面であるEditCustomSettingPage
でcustomSettingProvider
をwatch
するのではなく遷移前に現時点での値を引数で渡してあげるのが良いと考えます。
ElevatedButton(
child: const Text('複数の条件を変更'),
onPressed: () async {
// customSettingProviderはAsyncValueなので現時点での値を読み取る場合は
// .futureで待つ必要がある
final customSetting =
await ref.read(customSettingProvider.future);
if (context.mounted) {
await Navigator.push(
context,
MaterialPageRoute<EditCustomSettingPage>(
builder: (builder) => EditCustomSettingPage(
iconSetting: customSetting?.iconSetting,
backgroundColorNumber:
customSetting?.backgroundColorNumber,
titleText: customSetting?.titleText,
),
),
);
}
},
),
3-2. 一時的な状態を保持する
最初に述べた、このアプリの機能で次のものがあります。
編集している最中の値は一時的に編集画面で保持します
つまり一時的に設定内容を保持するのですが、その場合はHookWidegt
のuseState
が便利です。
EditCustomSettingPage
のコンストラクタで受け取った各設定値を画面のビルド時にuseEffect
内でuseState
に設定します。
各設定値を表示する場合はそれぞれのuseState
の値を使って表示します。
/// CustomSettingを編集する画面
class EditCustomSettingPage extends HookWidget {
/// CustomSettingを編集する画面
const EditCustomSettingPage({
this.iconSetting,
this.backgroundColorNumber,
this.titleText,
super.key,
});
/// アイコン設定
final bool? iconSetting;
/// 背景色番号
final int? backgroundColorNumber;
/// タイトルテキスト
final String? titleText;
@override
Widget build(BuildContext context) {
// 一時的に変更内容を保持するためのステート達
final iconSettingState = useState<bool?>(null);
final backgroundColorNumberState = useState<int?>(null);
final titleTextState = useState<String?>(null);
// 画面が生成された時にそれぞれの設定内容をuseStateに代入
useEffect(
() {
iconSettingState.value = iconSetting;
backgroundColorNumberState.value = backgroundColorNumber;
titleTextState.value = titleText;
return null;
},
[],
);
return Scaffold(
appBar: AppBar(
title: const Text('カスタム設定編集'),
),
body: Center(
child: Column(
children: [
Flexible(
child: InfoListTile(
value: iconSettingState.value,
type: TileType.iconSetting,
),
),
Flexible(
child: InfoListTile(
value: backgroundColorNumberState.value,
type: TileType.backgroundColorNumber,
),
),
Flexible(
child: InfoListTile(
value: titleTextState.value,
type: TileType.iconSetting,
),
),
const Divider(),
その後は各ボタンで設定を変更した場合はその値を受け取ってuseState
に渡していきます。
Flexible(
child: ElevatedButton(
child: const Text('アイコンの設定を変更'),
onPressed: () async {
final result =
await showSelectIconSettingBottomSheet(context);
iconSettingState.value = result;
},
),
),
Flexible(
child: ElevatedButton(
child: const Text('背景色番号を設定'),
onPressed: () async {
final result = await showSelectColorBottomSheet(context);
backgroundColorNumberState.value = result;
},
),
),
Flexible(
child: ElevatedButton(
child: const Text('タイトルの文字を設定'),
onPressed: () async {
final result = await showSelectTitleBottomSheet(context);
titleTextState.value = result;
},
),
),
const Gap(40),
最終的には保存ボタンを押すときにuseState
の値を渡して保存すれば完了です。
Flexible(
child: Consumer(
builder: (context, ref, child) {
return SizedBox(
width: 300,
child: ElevatedButton(
child: const Text('保 存'),
onPressed: () async {
await ref
.read(editCustomSettingPageProvider.notifier)
.saveCustomSetting(
iconSetting: iconSettingState.value,
backgroundColorNumber:
backgroundColorNumberState.value,
titleText: titleTextState.value,
);
if (context.mounted) {
Navigator.pop(context);
}
},
),
);
},
),
),
/// EditCustomSettingPageの処理を司るクラス
@riverpod
class EditCustomSettingPage extends _$EditCustomSettingPage {
@override
Future<void> build() async {}
/// カスタム設定を保存する
Future<void> saveCustomSetting({
required bool? iconSetting,
required int? backgroundColorNumber,
required String? titleText,
}) async {
const setting = CustomSetting();
final updateSetting = setting.copyWith(
iconSetting: iconSetting,
backgroundColorNumber: backgroundColorNumber,
titleText: titleText,
);
await ref.read(keyValueRepositoryProvider).setCustomSetting(updateSetting);
}
}
終わりに
この記事では、Riverpodを利用する際の注意点について具体例を交えながら解説しました。
特に、状態の監視の範囲を適切に設定すること、AsyncValueの使い分け、一時的な状態の管理方法について詳しく見てきました。これらのポイントを意識することで、アプリケーションのパフォーマンスを向上させ、よりスムーズなユーザー体験を提供することが可能になります。
Riverpodは非常に強力な状態管理パッケージですが、その特性を理解し、正しく利用することで、さらにその効果を最大限に引き出すことができます。
今回の内容が、Riverpodを使った開発の助けとなれば幸いです。
何か質問や追加の情報が必要な場合は、遠慮なくお知らせください。