Stateful Widgetを再描画したいけど、自分以外の別のクラスから再描画を発動する方法(setStateを実行する方法)が分からない。どうしたらいいの?
という方向けの記事です。
例えば、AppBarとbody(Scaffoldのbodyプロパティ)のWidgetを別クラスにしたとき、
AppBar上のボタンを押したら、bodyのWidgetを再描画して更新したい
みたいな場合です。
このようなケースでは、プログラムの構造を
- View層(UI・見た目)
- ViewModel層(View層とModel層のつなぎ・View層への更新通知)
- Model層(ロジック・計算)
の3層に分割した「MVVM(Model-View-ViewModel)」モデルにし、ViewModel層経由でView層に更新を通知する方法が、1つの対処法かと思います。
しかし、ViewModel層からView層に更新を通知して、Widgetを再描画するには、「Provider」や「Riverpod」などの状態管理パッケージを使う必要があり、使い方が初学者には難しい印象です。。。
自分自身、リリースしたアプリでは、一応、全て「Provider」か「Riverpod」を使っていますが、今だに難しく感じています。。
そこで、以前から、
簡単なアプリだったら、Stateful WidgetとsetStateだけで、何とかならないだろうか?
と考えていました。
ややマニアック、またはアンチパターン(やってはいけないこと)かもしれないのですが、、、ネットを調べると、Flutterの状態管理に関する情報は多くあり、近い情報は見つかったものの、明確に今回の疑問に合致する情報は見つけられなかったので、自分で考察してみました。
なお、MVVMを使わず、View層に全てのロジックも持たせ、setStateで全体を再描画する手もありますが、前述の例では、AppBarとbodyのWidgetを同一クラスにする必要があり、簡単なアプリとは言え、コードが肥大化して、メンテナンスしづらくなってしまいます。
結論として、以下のような方法で検証したら実現できたので、その例(サンプルコード)を共有したいと思います。
- View層は、再描画したい単位でStateful Widgetに分ける(分け方は粗目でも可とする)
- 各Stateful WidgetのsetStateを、Function型のプロパティとしてViewModel層に渡す
- ViewModel層で、渡されたsetStateを、再描画したいタイミングで実行する
理解が未熟ゆえ、おかしい点が多々あるかと思いますので、お気づきの点があれば、ご指摘くださいm(_ _)m。
40代からプログラミング(Flutter)を始めて、GooglePlayとAppStoreにアプリを公開しているhalzo appdevです。
作成したアプリはこちら↓ 全てFlutterで開発したアプリです。
サンプルコードの前提となるWidgetの構造
以下のような、単純な例を考えました。
実行時の画面は、「Aを更新」ボタンを押すと、WidgetAのカウンターだけが増え、「Bを更新」ボタンを押すと、WidgetBのカウンターだけが増え、「両方を更新」ボタンを押すと、両方のカウンターが増えるというものです。
一般に、これをStateful WidgetとsetStateだけで実現(状態管理:変数の変更に伴うWidgetの再描画)をしようとすると、以下のようなデメリットがあるかと思います。
- AppBarWidgetOからBodyWidgetAOだけを再描画しようとすると、両クラスは親子関係にないため(Widgetツリーが異なるため)、BodyWidgetAOのsetStateを発動できない。
- 下位のBodyWidgetAOから上位のWidget(例えばSampleScreenO)を再描画しようとすると、上位のWidgetのsetStateを引数で下位のWidgetに渡していく必要があり、コーディングが煩雑になる。
- SampleScreenO以下を全て一つのクラスにし、再描画を1つのsetStateで処理する方法もあるが、
- Widgetの階層構造が深くなり、表示(UI)と論理(ロジック)が一体化したコードになるので、メンテナンスしにくくなる。
- 無駄な再描画が増えるので、処理が重くなる。
そのため、下記のとおり、MVVMモデルを導入し、変数の更新(カウントアップする計算)はModel層で行い、ViewModel層では、更新したいWidgetに対してのみ、再描画のための更新通知を行う構造としました。
ここで、一般的には「Provider」や「Riverpod」などの状態管理パッケージを導入し、ViewModel層から、「notifyListeners()」や、「state」プロパティの更新を通じて、更新通知を行うと思います。
しかし、今回はこれらのパッケージを使わずに、Stateful WidgetとsetStateだけで実現する方法を考えました。
Stateful WidgetとsetStateだけで状態管理する方法
全体像は下図のとおりです。
大まかには、
- View層では、再描画する可能性のあるWidgetは、全てStateful Widgetとし、
- 各WidgetのsetStateメソッドを、ViewModel層に渡す(initStateメソッドの中で実行)。
- ViewModel層では、更新したいWidgetから渡されたsetStateメソッドを実行することで、Model層の計算結果を、当該Widgetに反映し、再描画する
という感じです。
一般に非推奨とされるグローバル変数は、使わないようにしました。
この方法で、ProviderやRiverpodを使わずに、実現できました。
サンプルコードの全体像
今回作成したサンプルコードの全体像を掲載します。
「main.dart」にそのままコピペいただけば、挙動を確認いただけます。
本来は、ViewModel層のクラス(SampleViewModelO)やModel層のクラス(SampleModelO)は、別のdartファイルにすべきですが、1回のコピペで済むように、あえて1つのdartファイルに集約しました。
// クラス名、メソッド名、プロパティ名(変数名)について、筆者が作成したもの(名前変更可のもの) // の名前の末尾には、大文字のオー「O」をつけています // ※ライブラリ(パッケージ)で予め決められているもの(名前の変更不可のもの)と、 // 自分で作成したもの(名前の変更可のもの)の区別をしやすくするため import 'package:flutter/material.dart'; void main() => runApp(MyApp()); class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: "setState and MVVM Demo", theme: ThemeData.light(), home: SampleScreenO(), ); } } /// View層 /////////////////////////////////////////////////// class SampleScreenO extends StatefulWidget { @override _SampleScreenOState createState() => _SampleScreenOState(); } class _SampleScreenOState extends State<SampleScreenO> { @override Widget build(BuildContext context) { debugPrint("全体を描画"); return Scaffold( appBar: AppBar(title: AppBarWidgetO()), body: BodyWidgetO(), ); } } // AppBar部分のクラス // AppBarを再描画しない場合は、StatelessWidgetでも良い class AppBarWidgetO extends StatefulWidget { const AppBarWidgetO({Key? key}) : super(key: key); @override _AppBarWidgetOState createState() => _AppBarWidgetOState(); } class _AppBarWidgetOState extends State<AppBarWidgetO> { @override Widget build(BuildContext context) { debugPrint("AppBarを描画"); // テキストボタンのスタイルを設定 ButtonStyle buttonStyleO = ButtonStyle(backgroundColor: MaterialStateProperty.all<Color>(Theme.of(context).primaryColorLight)); return Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ // WidgetAを更新するボタン TextButton( child: Text("Aを更新", style: Theme.of(context).textTheme.button), style: buttonStyleO, onPressed: () { // ボタンを押したら、ViewModel内のWidgetAのカウンターを加算表示するメソッドを実行 // ※ViewModelはシングルトン化しており、staticのインスタンス経由でアクセスするため、 // クラス名.インスタンス名.メソッド名で書く SampleViewModelO.instanceO.countUpForWidgetAO(); }, ), // WidgetBを更新するボタン TextButton( child: Text("Bを更新", style: Theme.of(context).textTheme.button), style: buttonStyleO, onPressed: () { // ボタンを押したら、ViewModel内のWidgetBのカウンターを加算表示するメソッドを実行 SampleViewModelO.instanceO.countUpForWidgetBO(); }, ), // bodyのWidget全体(=WidgetAとB両方)を更新するボタン TextButton( child: Text("両方更新", style: Theme.of(context).textTheme.button), style: buttonStyleO, onPressed: () { // ボタンを押したら、ViewModel内のWidgetAとB両方のカウンターを加算表示するメソッドを実行 SampleViewModelO.instanceO.countUpForBothO(); }, ), ], ); } } // body部分のウィジェット class BodyWidgetO extends StatefulWidget { const BodyWidgetO({Key? key}) : super(key: key); @override State<BodyWidgetO> createState() => _BodyWidgetOState(); } class _BodyWidgetOState extends State<BodyWidgetO> { // bodyのWidget全体(=WidgetAとBの両方)を再描画するメソッドを定義 void rebuildBothWidgetsO(){ setState(() {}); } @override void initState() { // ViewModel内に、上記で定義した再描画メソッドを渡す SampleViewModelO.instanceO.setRebuildMethodBothO(rebuildBothWidgetsO); super.initState(); } @override Widget build(BuildContext context) { return Center( child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ BodyWidgetAO(), BodyWidgetBO(), ], ), ); } } // WidgetAのクラス class BodyWidgetAO extends StatefulWidget { const BodyWidgetAO({Key? key}) : super(key: key); @override _BodyWidgetAOState createState() => _BodyWidgetAOState(); } class _BodyWidgetAOState extends State<BodyWidgetAO> { // WidgetAを再描画するメソッドを定義 void rebuildWidgetAO(){ setState(() {}); } @override void initState() { // ViewModel内に、上記で定義した再描画メソッドを渡す SampleViewModelO.instanceO.setRebuildMethodAO(rebuildWidgetAO); super.initState(); } @override Widget build(BuildContext context) { debugPrint("WidgetAを描画"); // ViewModel内のカウンター変数を参照して表示 int displayNumberO = SampleViewModelO.instanceO.countNumberO; return Text("WidgetA\nCount\n$displayNumberO", textAlign: TextAlign.center); } } // WidgetBのクラス class BodyWidgetBO extends StatefulWidget { const BodyWidgetBO({Key? key}) : super(key: key); @override _BodyWidgetBOState createState() => _BodyWidgetBOState(); } class _BodyWidgetBOState extends State<BodyWidgetBO> { // WidgetBを再描画するメソッドを定義 void rebuildWidgetBO(){ setState(() {}); } @override void initState() { // ViewModel内に、上記で定義した再描画メソッドを渡す SampleViewModelO.instanceO.setRebuildMethodBO(rebuildWidgetBO); super.initState(); } @override Widget build(BuildContext context) { debugPrint("WidgetBを描画"); // ViewModel内のカウンター変数を参照して表示 int displayNumberO = SampleViewModelO.instanceO.countNumberO; return Text("WidgetB\nCount\n$displayNumberO", textAlign: TextAlign.center); } } /// ViewModel層のクラス /////////////////////////////////////////////////// class SampleViewModelO { // インスタンスを1つしか生成しないようにするため // シングルトン化する SampleViewModelO._(); static final instanceO = SampleViewModelO._(); // Model層のインスタンスを定義 SampleModelO modelO = SampleModelO(); // カウンター変数を定義(初期値は0) int countNumberO = 0; // View層から受け取った再描画メソッドを代入するFunction型プロパティ late Function rebuildWidgetAFromVMO; late Function rebuildWidgetBFromVMO; late Function rebuildBothFromVMO; // WidgetAを再描画するメソッドをViewModel内のプロパティに代入 void setRebuildMethodAO(Function methodO) { rebuildWidgetAFromVMO = methodO; } // WidgetBを再描画するメソッドをViewModel内のプロパティに代入 void setRebuildMethodBO(Function methodO) { rebuildWidgetBFromVMO = methodO; } // bodyのWidget全体(=WidgetAとB両方)を再描画するメソッドをViewModel内のプロパティに代入 void setRebuildMethodBothO(Function methodO) { rebuildBothFromVMO = methodO; } void countUpForWidgetAO() { // カウンター変数をModel層の計算メソッドに渡し、計算結果を返り値で取得 int calculatedNumberO = modelO.countUpO(countNumberO); countNumberO = calculatedNumberO; // WidgetAの再描画メソッドを実行 rebuildWidgetAFromVMO(); } void countUpForWidgetBO() { // カウンター変数をModel層の計算メソッドに渡し、計算結果を返り値で取得 int calculatedNumberO = modelO.countUpO(countNumberO); countNumberO = calculatedNumberO; // WidgetBの再描画メソッドを実行 rebuildWidgetBFromVMO(); } void countUpForBothO() { // カウンター変数をModel層の計算メソッドに渡し、計算結果を返り値で取得 int calculatedNumberO = modelO.countUpO(countNumberO); countNumberO = calculatedNumberO; // bodyのWidget全体(=WidgetAとBの両方)の再描画メソッドを実行 rebuildBothFromVMO(); } } /// Model層のクラス /////////////////////////////////////////////////// class SampleModelO { // カウントアップ計算を実行し、結果をViewModel層に返す int countUpO(int countNumberO) { countNumberO = countNumberO + 1; return countNumberO; } }
実行したときのイメージを再掲します。
特定のWidgetのみ更新されることが確認できるよう、WidgetAとWidgetBには、あえて同じカウンター変数を参照させています。
そのため、WidgetAだけを更新したときは、WidgetBの更新は止まり、その後、WidgetBも更新すると、WidgetAの値まで一気に追いつく挙動になります(AとBが逆の場合も同様です)。
また、両方を更新するボタンを押すと、親WidgetであるBodyWidgetOが再描画されるので、WidgetAとBの両方が更新されます。
詳細な説明はコード中にも記載していますが、主な要点は以下のとおりです。
- 各Stateful Widget内で、setStateを実行するメソッドを定義(setStateを実行するだけのFunction型のメソッドを定義)する(104〜106行目、139〜141行目、169〜171行目)
- View層の各Widgetは、ViewModel層のクラス(SampleViewModelO)内の同じメソッド・プロパティにアクセス(取得・更新)できる必要があるため、SampleViewModelOは、インスタンスを1つしか生成できないよう、シングルトン化しておく(194〜195行目)
- 各Stateful WidgetのinitState内で、上記で定義したメソッドを、ViewModel内のメソッドに、引数で渡す(111、146、176行目)
- ViewModel内のメソッドでは、引数で渡されたFunction型のプロパティを、ViewModel内で定義したFunction型のプロパティに代入し、ViewModel内から、View層の各Widgetに紐付いたsetStateを利用できるようにする(209〜221行目)
シングルトンを導入する部分は、こちらの記事が大変参考になりました。ありがとうございます!
なお、本来、Model層は、データベースやストレージへのアクセス処理を書き、単純な計算レベルは、ViewModel層で処理することも多いと思われますが、今回はケースを単純化するため、データベースやストレージへのアクセスは想定せず、単純な計算のみをModel層で実行する形としました。
また、今回はViewModel層のクラスとModel層のクラスが1つずつのため、Model層はシングルトン化していませんが、複数のViewModel層から、同一のModel層を参照する場合は、Model層もシングルトン化する必要があります。
Provider、Riverpodと比較した感想
Providerでは、notifyListenersメソッドで再描画を通知しますが、この通知がView層のどの部分にヒットするのか、分かりづらいと感じていました。
特に、複数のViewから同じViewModelを参照していると、notifyListenersで想定外のViewが更新されることもあり、影響範囲の確認が大変でした(自分の使い方が下手なだけなのでしょうけどw)。
また、Riverpodは、「state」というプロパティを更新したときに、再描画が通知されるので、ピンポイントで更新できるメリットは大きいのですが、stateという名称で統一されているので、どの変数を更新しているのか、分かりづらいと感じていました(もっと上手い方法を知らないだけかもしれませんがw)。
モデルクラスを導入してcopyWithで更新すれば、どの変数を更新したか分かりやすくはなりますが、ピンポイントで更新される分、該当の変数がコード内に散在していると、更新箇所を検索するのに手間がかかります。
一方、今回検証した方法では、ピンポイントでの再描画はできないものの、再描画対象のWidget(クラス)が明確なので、コードを書いていて分かりやすい(再描画の影響範囲が分かりやすい)、というメリットを感じました。
ただ、留意点として、上位のWidgetを再描画した場合は、クラスを分けていたとしても、下位のWidgetは全て再描画されます。
そのため、再描画の範囲を限定したい場合は、再描画対象のWidgetを、できるだけWidgetツリーの下位のクラスに配置する必要があります。
追加サンプルコード(AppBarも更新する場合)
追加のサンプルコードとして、カウンターの値が10に達すると、AppBarのボタン色が変わる(暗くなる)、というケースも作成したので、ご参考に掲載します。
これも「main.dart」にコピペするだけで確認いただけます。
前回のサンプルから追加・修正した部分だけ、「↓追加・修正部分」というコメントを付しています。
import 'package:flutter/material.dart'; void main() => runApp(MyApp()); class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: "setState and MVVM Demo", theme: ThemeData.light(), home: SampleScreenO(), ); } } /// View層 /////////////////////////////////////////////////// class SampleScreenO extends StatefulWidget { @override _SampleScreenOState createState() => _SampleScreenOState(); } class _SampleScreenOState extends State<SampleScreenO> { @override Widget build(BuildContext context) { debugPrint("全体を描画"); return Scaffold( appBar: AppBar(title: AppBarWidgetO()), body: BodyWidgetO(), ); } } class AppBarWidgetO extends StatefulWidget { const AppBarWidgetO({Key? key}) : super(key: key); @override _AppBarWidgetOState createState() => _AppBarWidgetOState(); } class _AppBarWidgetOState extends State<AppBarWidgetO> { // ↓追加・修正部分 // AppBarのウィジェットを再描画するメソッドを定義 void rebuildAppBarO(){ setState(() {}); } // ↓追加・修正部分 @override void initState() { // ViewModel内に、上記で定義した再描画メソッドを渡す SampleViewModelO.instanceO.setRebuildAppBarO(rebuildAppBarO); super.initState(); } @override Widget build(BuildContext context) { debugPrint("AppBarを描画"); // ↓追加・修正部分 // カウンターが10以上になったらテキストボタンのスタイルを変更 ButtonStyle buttonStyleO = (SampleViewModelO.instanceO.countNumberO >= 10) ? ButtonStyle(backgroundColor: MaterialStateProperty.all<Color>(Theme.of(context).primaryColorDark)) : ButtonStyle(backgroundColor: MaterialStateProperty.all<Color>(Theme.of(context).primaryColorLight)); return Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ TextButton( child: Text("Aを更新", style: Theme.of(context).textTheme.button), style: buttonStyleO, onPressed: () { SampleViewModelO.instanceO.countUpForWidgetAO(); }, ), TextButton( child: Text("Bを更新", style: Theme.of(context).textTheme.button), style: buttonStyleO, onPressed: () { SampleViewModelO.instanceO.countUpForWidgetBO(); }, ), TextButton( child: Text("両方更新", style: Theme.of(context).textTheme.button), style: buttonStyleO, onPressed: () { SampleViewModelO.instanceO.countUpForBothO(); }, ), ], ); } } class BodyWidgetO extends StatefulWidget { const BodyWidgetO({Key? key}) : super(key: key); @override State<BodyWidgetO> createState() => _BodyWidgetOState(); } class _BodyWidgetOState extends State<BodyWidgetO> { void rebuildBothWidgetsO(){ setState(() {}); } @override void initState() { SampleViewModelO.instanceO.setRebuildMethodBothO(rebuildBothWidgetsO); super.initState(); } @override Widget build(BuildContext context) { return Center( child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ BodyWidgetAO(), BodyWidgetBO(), ], ), ); } } class BodyWidgetAO extends StatefulWidget { const BodyWidgetAO({Key? key}) : super(key: key); @override _BodyWidgetAOState createState() => _BodyWidgetAOState(); } class _BodyWidgetAOState extends State<BodyWidgetAO> { void rebuildWidgetAO(){ setState(() {}); } @override void initState() { SampleViewModelO.instanceO.setRebuildMethodAO(rebuildWidgetAO); super.initState(); } @override Widget build(BuildContext context) { debugPrint("WidgetAを描画"); int displayNumberO = SampleViewModelO.instanceO.countNumberO; return Text("WidgetA\nCount\n$displayNumberO", textAlign: TextAlign.center); } } class BodyWidgetBO extends StatefulWidget { const BodyWidgetBO({Key? key}) : super(key: key); @override _BodyWidgetBOState createState() => _BodyWidgetBOState(); } class _BodyWidgetBOState extends State<BodyWidgetBO> { void rebuildWidgetBO(){ setState(() {}); } @override void initState() { SampleViewModelO.instanceO.setRebuildMethodBO(rebuildWidgetBO); super.initState(); } @override Widget build(BuildContext context) { debugPrint("WidgetBを描画"); int displayNumberO = SampleViewModelO.instanceO.countNumberO; return Text("WidgetB\nCount\n$displayNumberO", textAlign: TextAlign.center); } } /// ViewModel層のクラス /////////////////////////////////////////////////// class SampleViewModelO { SampleViewModelO._(); static final instanceO = SampleViewModelO._(); SampleModelO modelO = SampleModelO(); int countNumberO = 0; late Function rebuildWidgetAFromVMO; late Function rebuildWidgetBFromVMO; late Function rebuildBothFromVMO; // ↓追加・修正部分 late Function rebuildAppBarFromVMO; void setRebuildMethodAO(Function methodO) { rebuildWidgetAFromVMO = methodO; } void setRebuildMethodBO(Function methodO) { rebuildWidgetBFromVMO = methodO; } void setRebuildMethodBothO(Function methodO) { rebuildBothFromVMO = methodO; } // ↓追加・修正部分 // AppBarを再描画するメソッドをViewModel内のプロパティに代入 void setRebuildAppBarO(Function() methodO) { rebuildAppBarFromVMO = methodO; } void countUpForWidgetAO() { int calculatedNumberO = modelO.countUpO(countNumberO); countNumberO = calculatedNumberO; rebuildWidgetAFromVMO(); // ↓追加・修正部分 // カウンターが10になったらAppBarを再描画するメソッドを実行 if (countNumberO == 10) rebuildAppBarFromVMO(); } void countUpForWidgetBO() { int calculatedNumberO = modelO.countUpO(countNumberO); countNumberO = calculatedNumberO; rebuildWidgetBFromVMO(); // ↓追加・修正部分 if (countNumberO == 10) rebuildAppBarFromVMO(); } void countUpForBothO() { int calculatedNumberO = modelO.countUpO(countNumberO); countNumberO = calculatedNumberO; rebuildBothFromVMO(); // ↓追加・修正部分 if (countNumberO == 10) rebuildAppBarFromVMO(); } } /// Model層のクラス /////////////////////////////////////////////////// class SampleModelO { int countUpO(int countNumberO) { countNumberO = countNumberO + 1; return countNumberO; } }
その他補足(Widgetのクラスをどの程度分けるか)
今回、検証した方法では、再描画の範囲を限定したい場合、Widgetを別クラスに分ける必要があり、Stateful Widgetの数が多くなります。
しかし、大規模なアプリでなければ、Widgetのクラス分けは大まかで良く、多少、クラス内に再描画不要なWidgetが含まれていても問題ないと思われます。
実際、クラス分けを細かくした場合と、大まかにした場合とで再描画速度の違いを比べましたが、Flutterの処理速度は早いので、速度差は体感できませんでした。
こちらの記事でも、大変分かりやすく解説くださっており、再描画によるコスト(負荷)はさほど重要ではなく、むしろシッカリconstをつけておくことの方が重要とされています(大変勉強になりました!)。
最後に
今回はシンプルなコードだったので、MVVMを用いることで、他クラスからのsetStateによる再描画を実現できましたが、複雑化すると不具合も出そうなので、引き続き検証したいと思います。
どなたかのご参考になれば幸いです。
最後までお読みいただき、ありがとうございました。
個人アプリ開発で役立ったもの
おすすめの学習教材
\超初心者向けでオススメな元Udemyの講座/
\キャンペーン時を狙えば安価で網羅的な内容が学べる(日本語訳あり)/
\Gitの基礎について無料で学べる/
おすすめの学習書籍
\実用的。image_pickerに関してかなり助けられた/
\Dartの基礎文法を素早くインプットできる/
コメント