27
18

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Flutter】Riverpodの注意点と改善ポイント:初心者から中級者へのステップアップガイド

Last updated at Posted at 2024-06-08

はじめに

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で保存)

【Flutter】riverpodを使ったDIとレイヤードアーキテクチャ、特にデータ層について(仮).gif

ソースコード

以前書いた記事

1. watchするのはできるだけ小さなWidget単位にする

状態を監視して、状態によってWidgetを出し分けたり表示を変えたりすることがあります。
しかし、それを監視する場所を考えないと思いもよらぬパフォーマンス低下につながります。
題目にある通り、プロバイダーをwatchするのはできるだけ画面などの全体で行うのは避けましょう。
何故なら全ての状態を全体でwatchした場合、同じく全体のbuiledが走ってしまうからです。
以下で実験してみましょう。

1-1. 単体で状態を監視している場合

MyHomePageは画面全体のWidgetです。
そこで各設定を表示するInfoListTileConsumerWidgetでラップしています。

Consumerでラップしている理由は以下の例で言うとiconSettingProviderをこのConsumerでラップしているWidgetだけで監視したいからです。

その他のConsumerでラップしている場所ではそれぞれbackgroundColorNumbertitleTextcustomSettingを監視しています。

lib/presentations/my_home_page/my_home_page.dart

/// ホーム画面
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の挙動を見てみます。
InfoListTilebuiledにはloggerを出すようにしています。

lib/presentations/shared/info_list_tile.dart
class InfoListTile extends StatelessWidget {

  // 省略

  /// Tileのタイプ
  final TileType type;
  @override
  Widget build(BuildContext context) {
    logger.d('${type.title}のタイルをビルド');

    // 省略
    

アプリを立ち上げた最初のログ

最初に画面全体のbuiledが走ります。

次にそれぞれのInfoListTilebuiledされています。

最後に各プロバイダの初期値が流れたことによって再度builedが走ります。
今回watchしているプロバイダーはStream型です。
Stream型は最初に監視が始まると初期値が流れる仕組みになっています。
よって今回はInfoListTilewatchしている値が全てStream型のため全てが再ビルドされています。

スクリーンショット 2024-06-08 14.59.41.png

アイコンの設定を変えてみる

一旦ログを削除した状態で、アイコン設定を変えてみます。

アイコン設定変更GIF.gif

するとアイコン設定を監視しているInfoListTileだけがbuiledされました。

スクリーンショット 2024-06-08 15.21.56.png

1-2. 画面全体で監視してみる

次に画面全体で監視した場合です。
isWatchProviderFromPageというフラグをtrueに変更すると、
画面全体でfinal iconSetting = ref.watch(iconSettingProvider);を実行し、
その内容を表示するtestIconSettingWidgetを表示するようにしています。

lib/presentations/my_home_page/my_home_page.dart

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.gif

ログを見てみると、明らかに違いますね。
画面全体でプロバイダーをwatchすると値の変更があるたびに画面全体が再builedされてしまいます。
画面全体のbuiledがされてしまうので、アイコン設定のInfoListTileだけではなく
その配下の背景色、タイトル、JSONなどの値を監視しているInfoListTileまで再度builedされてしまっている事がわかります。

スクリーンショット 2024-06-08 16.12.48.png

1-3. 結論

watchする場合はその値の変更に影響があるWidget内で行うようにしよう!

※ 画面全体に再度builedを実行させる必要があるばあいはもちろん画面全体でwatchする必要があります。

2. AsyncValuewhenvalueOrNullを使い分けよう

2-1. AsyncValueとは?

今回扱っているプロバイダーはAsyncValueで定義しています。
AsyncValueは簡単に説明すると、Future型またはStream型で定義されたプロバイダーです。

lib/data/repositories/key_value_repository/provider.dart

/// アイコン設定の値を提供する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 : 値を取得している最中の場合
lib/presentations/my_home_page/my_home_page.dart

          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です。
valueOrNullAsyncValueから直接値を参照するメソッドです。
似たようなものでvalueがありますが、これは失敗した場合exceptionthrowされるため、
基本的にはvalueOrNullを利用するのがベターでしょう。

lib/presentations/my_home_page/my_home_page.dart

              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. 総括

対象の値がどんな処理によって生成されるのかを見極めて、whenvalueOrNullを使い分けよう

3. 値を引数で渡したり、useStateでの管理を検討する

1. watchするのはできるだけWidget単位にする でも触れたように、
状態を取得するのに全てをwatchするとパフォーマンス低下につながる場合もあります。
その状態を監視ではなくて、その値を引き渡すだけでいい場合もあります。

また、一時的に保持したいだけならStatefullWidegtstateか、
flutter_hooksパッケージによるHookWidgetuseStateを検討しましょう。

今回はCustomSettingの編集保存の場合を例に説明します。
また、一時保存にはHookWidgetuseStateを採用しています。

以下が主な機能です。

  1. ホーム画面のボタンを押すとCustomSettingを編集する専用のページに遷移します
  2. 編集画面ではすでに保存されているCustomSettingの値を元に表示をします
  3. 編集している最中の値は一時的に編集画面で保持します
  4. 保存ボタンを押すと変更内容が保存されます

以下のGifではちょっとわかりづらいですが、編集画面で一度保存せずにホーム画面へ戻っています。
その後もう一度編集画面で編集後に保存ボタンを押してホーム画面に戻ると保存内容が表示されています。

タイトルなし.gif

3-1. 状態を引数で渡す


編集画面に必要なのは現在の設定値です。
今回のCustomSettingの状態は保存されている値です。
保存されていない編集中に状態が変わることはあり得ないので、watchする必要がない(してはいけない)です。
よって編集画面であるEditCustomSettingPagecustomSettingProviderwatchするのではなく遷移前に現時点での値を引数で渡してあげるのが良いと考えます。

lib/presentations/my_home_page/my_home_page.dart

              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. 一時的な状態を保持する

最初に述べた、このアプリの機能で次のものがあります。

編集している最中の値は一時的に編集画面で保持します

つまり一時的に設定内容を保持するのですが、その場合はHookWidegtuseStateが便利です。
EditCustomSettingPageのコンストラクタで受け取った各設定値を画面のビルド時にuseEffect内でuseStateに設定します。

各設定値を表示する場合はそれぞれのuseStateの値を使って表示します。

lib/presentations/edit_custom_setting_page/edit_custom_setting_page.dart

/// 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に渡していきます。

lib/presentations/edit_custom_setting_page/edit_custom_setting_page.dart

            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の値を渡して保存すれば完了です。

lib/presentations/edit_custom_setting_page/edit_custom_setting_page.dart

            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);
                        }
                      },
                    ),
                  );
                },
              ),
            ),
            

lib/presentations/edit_custom_setting_page/edit_custom_setting_page_view_model.dart

/// 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を使った開発の助けとなれば幸いです。

何か質問や追加の情報が必要な場合は、遠慮なくお知らせください。

27
18
2

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
27
18

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?