🤔

【Flutter】RiverpodのstateProviderの再描画タイミングを理解する

2024/05/29に公開

riverpodのstateProviderについて、状態を管理できることはわかったけど、実際どこがいつ再描画されているのか疑問に思ったので検証してみた。

対象の読者

riverpodのstateProviderの概要はわかったが、再描画タイミングがよくわからない人。
riverpodを使用しているが、再描画を最適化してパフォーマンスを向上させたい人。

やってみたこと

flutterのデモアプリを少し改造して、statefullWidgetではなくstateProviderでカウントを管理し、カウントアップを行った時点でどこが再描画されるのかを検証してみた。

バージョン

  • flutter:3.19.6
  • flutter_riverpod:2.5.1

※ この記事では、ConsumerWidgetはConsumerWidgetと呼び、ConsumerというウィジェットはConsumerウィジェットと表記します。

全コードと解説

main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod_study/provider.dart';

void main() {
  runApp(ProviderScope(child: MyApp())); 
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    print("MyAppの再描画");
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends ConsumerWidget {
  final String title;
  MyHomePage({super.key, required this.title});

  
  Widget build(BuildContext context, WidgetRef ref) {
    print("build直下の再描画");

    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(title),
      ),
      body: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              Consumer(
                builder: (BuildContext context, WidgetRef ref, child) {
                  print("Consumerウィジェット。第一の数字の再描画");
                  final int firstCounter = ref.watch(firstCountProvider);
                  return Text(
                    '$firstCounter',
                    style: Theme.of(context).textTheme.headlineMedium,
                  );
                },
              ),
              ElevatedButton(
                  onPressed: () =>
                      ref.read(firstCountProvider.notifier).state++,
                  child: Text("第一のボタン!")),
            ],
          ),
          Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Builder(builder: (context) {
                print("Consumerなし。第二の数字の再描画");
                final int secondCounter = ref.watch(secondCountProvider);
                return Text(
                  '$secondCounter',
                  style: Theme.of(context).textTheme.headlineMedium,
                );
              }),
              ElevatedButton(
                  onPressed: () =>
                      ref.read(secondCountProvider.notifier).state++,
                  child: Text("第二のボタン!"))
            ],
          ),
        ],
      ),
    );
  }
}
provider.dart
//flutterのpackageは不要
import 'package:flutter_riverpod/flutter_riverpod.dart';

final firstCountProvider = StateProvider((ref) => 0);
final secondCountProvider = StateProvider((ref) => 0);

このアプリでは左右でカウントアップの機能を具備したボタンと、その値を表示するTextウィジェットが配置されている。
printを入れ込んでいる箇所は、

  • MyAppのbuildメソッド実行時
  • MyHomePageのbuildメソッド実行時
  • Consumerウィジェットで囲んだ、$firstCounterのbuilder
  • Consumerウィジェットで囲んでいない、$secondCounterのbuilder

アプリのビルド直後のコンソールは上記のような形。

第一のボタン!を押してみる。

上記のように、第一の数字のbuilderの部分だけ再描画されていることがわかる。
次に、第二のボタン!を押してみる。

第二のボタンの方ではConsumerウィジェットで囲んでいないため、MyHomePage自体が再描画されていることがわかる。
つまり、firstCounterは変更がないにも関わらず、第一のボタンの方にも再描画がされてしまっているということである。

第二のボタンはMyHomePageのウィジェットとして存在しているのでMyHomePageのbuildメソッドが再描画されてしまっている、という仮説に基づき、第二のボタン部分を別ウィジェットとして切り出してみた場合はどうか?
※ ここで一度アプリを再起動します。

main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod_study/provider.dart';

void main() {
  runApp(ProviderScope(child: MyApp())); 
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    print("MyAppの再描画");
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends ConsumerWidget {
  final String title;
  MyHomePage({super.key, required this.title});

  
  Widget build(BuildContext context, WidgetRef ref) {
    print("build直下の再描画");

    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(title),
      ),
      body: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              Consumer(
                builder: (BuildContext context, WidgetRef ref, child) {
                  print("Consumerウィジェット。第一の数字の再描画");
                  final int firstCounter = ref.watch(firstCountProvider);
                  return Text(
                    '$firstCounter',
                    style: Theme.of(context).textTheme.headlineMedium,
                  );
                },
              ),
              ElevatedButton(
                  onPressed: () =>
                      ref.read(firstCountProvider.notifier).state++,
                  child: Text("第一のボタン!")),
            ],
          ),
          SecondButtonWidget(), //ここを別ウィジェットとして切り出し
        ],
      ),
    );
  }
}

class SecondButtonWidget extends ConsumerWidget {
  const SecondButtonWidget({
    super.key,
  });

  
  Widget build(BuildContext context,WidgetRef ref) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Builder(builder: (context) {
          print("Consumerなし。第二の数字の再描画");
          final int secondCounter = ref.watch(secondCountProvider);
          return Text(
            '$secondCounter',
            style: Theme.of(context).textTheme.headlineMedium,
          );
        }),
        ElevatedButton(
            onPressed: () =>
                ref.read(secondCountProvider.notifier).state++,
            child: Text("第二のボタン!"))
      ],
    );
  }
}

ウィジェット切り出しを行い、再起動直後は下記のような形。切り出し前と変化はない。

第二のボタン!を押してみる。

すると今度は第二のボタンはConsumerウィジェットで囲んでいないにも関わらず、SecondButtonWidgetのbuildメソッドのみが再描画された。

ここまでの所感

再描画を絞り込むには、Consumerウィジェットを使用して特定の範囲だけを再描画させるようにする or 別ウィジェットとして切り出してそこで再描画させるようにする、という方法が考えられる。

別ウィジェットとなった部分も再描画されるのか?

では、MyHomePageに第三のボタンを作成して、第三のボタンを押した時にはMyHomePageを再描画するようにしてみた場合、第二のボタンは再描画されるのかを検証する。

main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod_study/provider.dart';

void main() {
  runApp(ProviderScope(child: MyApp())); //まずここ
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    print("MyAppの再描画");
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends ConsumerWidget {
  final String title;
  MyHomePage({super.key, required this.title});

  
  Widget build(BuildContext context, WidgetRef ref) {
    print("build直下の再描画");

    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(title),
      ),
      body: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              Consumer(
                builder: (BuildContext context, WidgetRef ref, child) {
                  print("Consumerウィジェット。第一の数字の再描画");
                  final int firstCounter = ref.watch(firstCountProvider);
                  return Text(
                    '$firstCounter',
                    style: Theme.of(context).textTheme.headlineMedium,
                  );
                },
              ),
              ElevatedButton(
                  onPressed: () =>
                      ref.read(firstCountProvider.notifier).state++,
                  child: Text("第一のボタン!")),
            ],
          ),
          SecondButtonWidget(),
              //ここから
              Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: <Widget>[
                  Builder(builder: (context) {
                    print("Consumerなし。第三の数字の再描画");
                    final int thirdCounter = ref.watch(thirdCountProvider);
                    return Text(
                      '$thirdCounter',
                      style: Theme.of(context).textTheme.headlineMedium,
                    );
                  }),
                  ElevatedButton(
                      onPressed: () =>
                          ref.read(thirdCountProvider.notifier).state++,
                      child: Text("第三ボタン")),
                ],
              ),
              //ここまでを追加
        ],
      ),
    );
  }
}

class SecondButtonWidget extends ConsumerWidget {
  const SecondButtonWidget({
    super.key,
  });

  
  Widget build(BuildContext context, WidgetRef ref) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Builder(builder: (context) {
          print("Consumerなし。第二の数字の再描画");
          final int secondCounter = ref.watch(secondCountProvider);
          return Text(
            '$secondCounter',
            style: Theme.of(context).textTheme.headlineMedium,
          );
        }),
        ElevatedButton(
            onPressed: () => ref.read(secondCountProvider.notifier).state++,
            child: Text("第二のボタン!"))
      ],
    );
  }
}
provider.dart
//flutterのpackageは不要
import 'package:flutter_riverpod/flutter_riverpod.dart';

final firstCountProvider = StateProvider((ref) => 0);
final secondCountProvider = StateProvider((ref) => 0);
final thirdCountProvider = StateProvider((ref) => 0);//ここを追加

(横幅の関係で第三のボタンだけ文言が違うのはご容赦ください、、、)
この状態で再起動をしてみる。

第三のボタンを押してみた時の挙動は以下の通り。

第三のボタンを押すと、もちろんMyHomePageが再描画される。その後、同クラス内の第一ボタン部分、第三ボタン部分が再描画されるのは想定通りである。
しかし、第二のボタン部分も再描画されていることがわかる。第二ボタンウィジェットを含むMyHomePageが再描画される場合、第二ボタンウィジェットは別ウィジェットとして切り出していようが再描画されることがわかった。

この状態を回避するには、SecondButtonWidgetの呼び出し部分にconstのキーワードを付与することで回避可能である。

main.dart
...省略
      body: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              Consumer(
                builder: (BuildContext context, WidgetRef ref, child) {
                  print("Consumerウィジェット。第一の数字の再描画");
                  final int firstCounter = ref.watch(firstCountProvider);
                  return Text(
                    '$firstCounter',
                    style: Theme.of(context).textTheme.headlineMedium,
                  );
                },
              ),
              ElevatedButton(
                  onPressed: () =>
                      ref.read(firstCountProvider.notifier).state++,
                  child: Text("第一のボタン!")),
            ],
          ),
          const SecondButtonWidget(), //ここにconstを付与
 ...省略
}

class SecondButtonWidget extends ConsumerWidget {
...省略
}

こうすると、第三ボタンを押してもMyHomePageは再描画されるが、第二ボタンは再描画されなくなる。

第一ボタンはConsumerウィジェットで囲んでいるため、constキーワードを付与することができない。そのため、第三ボタンを押したときには必ず第一ボタンも再描画されることになる。

まとめ

親のウィジェットで再描画すると、子、孫のウィジェットまで再描画されることわかった。そのため、上位の階層のウィジェットでriverpodを使用して再描画をする場合は、特定の箇所のみを再描画するように設計した方が良いと考える。

Consumerウィジェットは、その部分でだけで状態を監視していて、再描画もその部分に限ってくれる。ただ、親のウィジェットが再描画される場合、Consumerウィジェットも再描画される。
ウィジェットを別出しにしてconstキーワードを付与すると、切り出した部分で状態管理していれば、切り出した部分だけが再描画される。親のウィジェットが再描画される場合でも、切り出したウィジェットの呼び出し部分でconstを付与すれば、親の再描画に巻き込まれなくて済む。

(Consumerで囲むと階層深くなるし、ウィジェット別出しするとその分コード量が増えるので、再描画の範囲と相談しながらコーディングするのが吉かなと思いました)

以上、お読みいただきありがとうございました!

git:
https://github.com/12jun-jun12/flutter_riverpod_study

Discussion