Flutterの状態管理の基礎
概要
この記事は、Qiita Advent Calendar Flutter6日目の記事です。
Flutterの初学習者が最初につまづく可能性が高いのは、StatelessWidget
/StatefulWidget
などの状態管理に関することではないかと思います。
私自身Flutterを触り始めて1ヶ月弱なのですがStatelessWidget
/StatefulWidget
のところで詰まり、ここが最初の鬼門だなと感じました。
そこで今回はStatelessWidget
/StatefulWidget
、そしてそこから派生してInheritedWidget
について、これらがどういうものでどういう役割を持っているかを見ていきたいと思います。
状態管理とは
まず状態管理とはどういうものを指しているのか、状態
・状態管理
について定義を改めて確認しておきます。
状態
状態とは、UIを(再)構築するために必要なデータのことを指しています。
宣言的UIでは、UI = f(state)
という関数のようにUIが構築されます。
Flutterにおいて、fはWidget、fはbuild()
、stateがデータ(状態)になります。
stateつまり状態が変化するとUIが再構築されます。
また状態には大きく分けて2種類あります。
一つはEphemeral state(ローカル状態)、もう一つはApp state(共有状態) です。
Ephemeral state
とは、画面(クラス)間で共有しないなど、スコープが閉じたデータ(状態)のことです。
例えばBottomNavigationBar
の選択中のタブのインデックスの状態は、基本的に他の画面(クラス)から参照されることはないのでEphemeral state
と言えます。
一方App State
とは、複数の画面(クラス)間で共有するなど、複数で共有されるデータ(状態)のことです。
例えばショッピングアプリのカートの状態やログイン状態は、複数箇所から参照され、状態が共有される可能性があるのでApp State
と言えます。
状態管理
状態管理とは、状態の整合性が破綻しないように管理することです。
例えば旅館を予約して予約一覧画面では予約済みのステータスになっているのに、予約確認画面では予約済みになっていない場合、これは状態の整合性が取れておらず、ユーザーを困惑させてしまいます。このような問題を起こさないために状態管理は重要となってきます。
ここから本題のStatelessWidget
/StatefulWidget
について見ていきます。まずはStatelessWidget
についてです。
StatelessWidget
StatelessWidget
はFlutterにおいて最も基本的なWidgetであり、Flutterプロジェクトを作成した際に生成されるコードの中でも使われています。
StatelessWidgetの性質と役割
StatelessWidget
は名前から状態を持たないWidgetと推測できますが、これは半分正解で半分不正解です。正確にはミュータブルな状態を持たないWidgetです。
実際にコードを見てみましょう。
mypage.dart
class MyPage extends StatelessWidget {
final title = "Setting"; // finalをつけることでimmutableの要件を満たしている。また本来は`_`をつけてプライベートにしておくほうが良い。
// String title = "Setting"; // immutableの要件を満たしていない
const MyPage({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(title)
),
body: ListView(
children: const [
ListTile(title: Text("Profile"),),
ListTile(title: Text("Setting"),),
ListTile(title: Text("Privacy policy"),),
ListTile(title: Text("Terms"),),
],
)
);
}
}
ここで重要なのは、状態はすべて定数として持たなければならないということです。
String title = "Setting"
のように、変数として定義するとコンパイルエラーが発生します。
この理由はStatelessWidget
の親クラスである、Widget
という抽象クラスに@immutable
というアノテーションがついているためです。
@immutable
をつけたクラスはイミュータブルな要件を満たさないといけません。
前述のように上記コードをString title = "Setting"
とした場合、title
の状態が外部からも内部からも変更される可能性があります。つまりこのクラスはイミュータブルな要件を満たしていないということになります。
StatelessWidget
は設定一覧画面など、常に同じ状態で良い画面、つまり静的な画面に使うとよさそうです。
StatefulWidget
次にStatefulWidget
についてです。
こちらもStatelessWidget
と同様に最も基本的なWidgetであり、Flutterプロジェクトを作成した際に生成されるコードの中でも使われています。
StatefulWidgetの性質と役割
StatefulWidget
は名前から状態を持つWidgetと推測できますが、こちらも半分正解で半分不正解です。正確にはミュータブルな状態も持つこともできるWidgetであるが、実際はStatefulWidgetではミュータブルな状態を持つことができないと言えます。
何を言っているんだ、と思うかもしれませんが、実際のコードを見ていきましょう。
myhome_page.dart
// StatefulWidgetを継承したクラスではイミュータブルな状態を持つことはできない。
class MyHomePage extends StatefulWidget {
const MyHomePage({Key? key, required this.title}) : super(key: key);
final String title;
State<MyHomePage> createState() => _MyHomePageState();
}
// State<T extends StatefulWidget>を継承したクラスで状態を持つ。
// カウンターアプリの場合は、カウント状態をこのクラスで持っている。
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() => setState(() => _counter++);
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.headline4,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}
上記はFlutterプロジェクトを作成したときに実装されているカウンターアプリのコードそのままです。まずMyHomePageが継承しているStatefulWidgetの継承関係について見ていきます。
StatefulWidget
はStatelessWidget
同様、Widget
を継承しています。
つまりStatefulWidget
もミュータブルな状態を持つことができません。
実際にミュータブルな状態を持っているのはStatefulWidget
と1対1で対応しているState<T extends StatefulWidget>
という抽象クラスです。
このState
を継承したクラスでミュータブルな状態を持ち、カウンターの状態を変更しています。
またこのアプリで持っているカウンターの状態はEphemeral state
であり、変更があっても外部(その他の画面など)に影響を与えることはありません。
StatefulWidget
はミュータブルな状態を持ちたいが、状態は閉じたスコープである画面に用いると良さそうです。(具体例が思いつきませんでした。。)
今回はカウンターアプリというシンプルな仕様のアプリをStatefulWidget
で実装しましたが、もう少し複雑なアプリをStatefulWidget
で実装した場合にどうなるでしょうか?
次はStatefulWidget
で簡単なスムージーアプリを実装してみたいと思います。
スムージーアプリ by StatefulWidget
まず簡単に実装したいスムージーアプリの仕様を見ていきます。
仕様
スムージーのメニューの画面があり、各アイテムの追加ボタンをタップすると、カートにスムージーが追加されます。追加したスムージーの一覧は右上のカートボタンをタップすると見ることができます。とてもシンプルなアプリケーションです。
Menu画面 | Cart画面 |
---|---|
このアプリケーションを実装する上でのポイントは、Menu画面とCart画面で状態を共有しているということです。
Menu画面のチェッマークがついているアイテムはカートに入っていることを指しており、Cart画面でそのアイテムの詳細が一覧化されています。つまりカートはApp Stateに該当します。
ではStatefulWidget
でApp State
をどうやって持つのかコードを見ていきます。
home_page.dart
class HomePage extends StatefulWidget {
const HomePage({Key? key}) : super(key: key);
HomePageState createState() => HomePageState();
}
class HomePageState extends State<HomePage> {
Set<Smoothie> _favoritesSmoothies = {}; // ※1. お気に入りしている商品一覧
Set<Smoothie> get favoritesSmoothies => _favoritesSmoothies;
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Menu"),
actions: [
IconButton(
onPressed: () async {
var result = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MyCartPage(smoothies: _favoritesSmoothies,),
fullscreenDialog: true,
)
);
final smoothies = result as Set<Smoothie>;
setState(() => _favoritesSmoothies = smoothies);
},
icon: const Icon(Icons.shopping_cart_rounded))
],
),
body: const MenuPage(),
);
}
void insert(Smoothie smoothie) => _favoritesSmoothies.add(smoothie);
void remove(Smoothie smoothie) => _favoritesSmoothies.remove(smoothie);
}
HomePageというMenu画面の上位Widgetで、お気に入りされている商品の状態を持っています。そしてMenu画面とCart画面からそれぞれHomePageで持っているお気に入り状態を参照するようにします。
Menu画面
Menu画面の+ボタンをタップするとカートに追加され、✔をタップするとカートから削除される処理は以下のように実装しています。
smoothie_row.dart
class _SmoothieRowState extends State<SmoothieRow> {
Widget build(BuildContext context) {
final state = context.findAncestorStateOfType<HomePageState>()!;
return Container(
margin: const EdgeInsets.only(bottom: 16.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(・・・),
Flexible(・・・),
IconButton(
onPressed: () {
setState(() {
// ボタンをタップされた時にお気に入りの状態によって追加・削除を判断している
state.favoritesSmoothies.contains(widget.smoothie)
? state.remove(widget.smoothie)
: state.insert(widget.smoothie);
});
},
// お気に入り状態によって表示アイコンを変更している
icon: state.favoritesSmoothies.contains(widget.smoothie)
? const Icon(Icons.check)
: const Icon(Icons.add)
)
],
)
);
}
}
ポイントは、
final state = context.findAncestorStateOfType<HomePageState>()!;
です。
findAncestorStateOfType
メソッドを使用することで、State<T extends StatfulWidget>
を取得することができます。今回の場合はHomePageState
のインスタンスを取得しています。
ただfindAncestorStateOfType
は以下2つの問題があるので、使用には注意が必要です。
findAncestorStateOfTypeの注意点
計算量がO(N)
NはWidgetのツリーの深さです。なので深さが大きくなればなるほどfindAncestorStateOfType
の処理は重くなってしまいます。
同じWidgetツリーに存在しているStateしか参照できない
同じWidgetツリーに存在している祖先のStateしか参照することはできません。存在しないStateを取得しようとした場合はNullが返ってきます。
Cart画面
Cart画面は以下のように実装しています。
mycart_page.dart
class MyCartPage extends StatefulWidget {
final Set<Smoothie> smoothies;
const MyCartPage({
Key? key,
required this.smoothies,
}) : super(key: key);
_MyCartPageState createState() => _MyCartPageState();
}
class _MyCartPageState extends State<MyCartPage> {
late Set<Smoothie> smoothies;
// 商品詳細をリスト化するWidgetを生成する
Widget myCartList() {
return ListView.separated(・・・);
}
Widget build(BuildContext context) {
smoothies = widget.smoothies;
return Scaffold(
appBar: AppBar(・・・),
body: WillPopScope(
onWillPop: () {
Navigator.of(context).pop(smoothies);
return Future.value(false);
},
child: Center(
child: smoothies.isEmpty
? const Text("Your cart is empty")
: myCartList()
),
),
);
}
}
ここでのポイントは、以下2点です。
-
MyCartPage
のイニシャライザにお気に入りしているスムージーのリストを渡している -
Navigator.of(context).pop(smoothies)
で前画面に戻る時に、Cart画面で操作したお気に入り状態を渡している
なぜこのようなことをしているのでしょうか。Menu画面と同様findAncestorStateOfType
を使って状態の取得・変更ができそうな気もします。
理由は、MyCartPageをモーダル表示したいために遷移処理が以下のようになっているためです。
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MyCartPage(smoothies: _favoritesSmoothies,),
fullscreenDialog: true,
)
)
MaterialPageRoute
を使って遷移するとMyCartPage
はHomePage
と異なるWidgetツリーに属すことになります。そうなるとfindAncestorStateOfType
は同一Widgetツリーの祖先しか取得できないためMyCartPage
からHomePageState
は取得できません。
そのためHomePageState
でMyCartPageにお気に入り状態を引数にしたり、お気に入り状態の更新を行わざる負えません。
このようにApp State
をStatefulWidget
のみで実現しようとするといくつか悩ましいところがあります。以下はここまで見てきたStatefulWidgetの問題点のまとめです。
StatefulWidgetの問題点
-
findAncestorStateOfType
を使ってしまっている -
findAncestorStateOfType
ではアクセスできないStateを操作したい場合は、書き方が冗長にならざる負えない - 状態を伝播できないので、状態を共有する実装をするには工夫が必要になることがある
ではこれらの問題を解決するにはどうすればよいでしょうか?
InheritedWidget
Flutterが提供するWidgetにInheritedWidget
というものがあります。
InheritedWidget
の特徴は、以下のとおりです。
- 直近のInheritedWidgetにO(1) でアクセスすることができる
- 状態の変化を下位ツリーに効率的に伝播することができる
直近のInheritedWidgetにO(1) でアクセスすることができる
通常、祖先のWidgetを取得したい場合は、findAncestorWidgetOfExactTypeというメソッドを用います。ただfindAncestorWidgetOfExactType
の計算量はO(N)
であり、findAncestorStateOfType
同様の問題点があります。
しかしdependOnInheritedWidgetOfExactType
というメソッドを使うと直近のInheritedWidget
をO(1) でアクセスすることができ、Widgetツリーが深くなってしまうような複雑なアプリケーションでも心配することはありません。
状態の変化を効率的に下位ツリーに伝播することができる
また必要な時に状態の変更を下位ツリーに伝播することができます。
以下はカートの状態を管理しているクラスのコードです。
mycart.dart
class MyCart extends StatefulWidget {
final Widget child;
const MyCart({
Key? key,
required this.child,
}) : super(key: key);
// 慣例的に`of`という命名のメソッドを生やし、状態をInheritedWidgetを返している。
// InheritedWidgetを取得するためのメソッドに`dependOnInheritedWidgetOfExactType`を
// 使っているので、取得コストは`O(1)`となっている。
static MyCartState of(BuildContext context) {
return context
.dependOnInheritedWidgetOfExactType<InheritedMyCart>()!
.data;
}
MyCartState createState() => MyCartState();
}
class MyCartState extends State<MyCart> {
Set<Smoothie> _smoothies = {};
Set<Smoothie> get smoothies => _smoothies;
Widget build(BuildContext context) {
return InheritedMyCart(
data: this,
child: widget.child,
);
}
void insert(Smoothie smoothie) {
setState(() => _smoothies.add(smoothie));
}
void remove(Smoothie smoothie) {
setState(() => _smoothies.remove(smoothie));
}
}
class InheritedMyCart extends InheritedWidget {
final MyCartState data;
const InheritedMyCart({
Key? key,
required this.data,
required Widget child,
}) : super(key: key, child: child);
// データを変更を下位ツリーに伝播するか決定することができる
bool updateShouldNotify(InheritedMyCart old) => true;
}
ポイントはupdateShouldNotify
というクラスです。
このメソッドの返り値であるBool
がtrue
の場合は下位ツリーのWidgetをリビルドしますが、
false
の場合はリビルドさせないようにすることができます。
上記のコードではすべての変更に対してリビルドするようにしていますが、例えばこのウィジェットが保持するデータがoldWidgetが保持するデータと同じである場合、Widgetを再構築する必要はありません。
このように効率的に下位ツリーのWidgetにデータを伝播しリビルドさせることができます。
また実際に状態を管理しているのはStatefulWidget
を継承したMyCart
というクラスですが、
このクラスをInheritedWidget
を継承したInheritedMyCart
というクラスでラップすることで、
O(1)
で下位ツリーから状態を管理しているクラスにアクセスすることができるようになりました。
以下のようにMyCart画面を表すクラスから、MyCartの状態をO(1)
で取得しています。
mycart_page.dart
class MyCartPage extends StatelessWidget {
const MyCartPage({Key? key}) : super(key: key);
Widget myCartList(BuildContext context) {
final state = MyCart.of(context);
return ListView.separated(・・・);
}
Widget build(BuildContext context) {
// マイカートの状態を管理しているクラス`MyCartState`のインスタンスを取得。
final myCart = MyCart.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('MyCart'),
),
body: Center(
// カートに入っているスムージーが存在するか否かでUIを変更している。
child: myCart.smoothies.isEmpty
? const Text("Your cart is empty")
: myCartList(context),
)
);
}
}
InheritedWidget
を用いると複雑なアプリケーションでも、パフォーマンスを落とすこと無くアプリケーションを作っていけそうです。
ただInheritedWidget
を用いて実装すると、コードが少し冗長になってしまいます。
そこでInheritedWidget
が必要となるような複雑なアプリケーションを組む場合は、Riverpod
などの状態管理フレームワークを使う場合が多いみたいです。
まとめ
ここまでStatelessWidget
/StatefeulWidget
/InheritedWidget
について見てきましたが、それぞれが異なる役割を持っているように感じました。
執筆前はStatefeulWidget
の代替がInheritedWidget
やRiverpod
などの状態管理フレームワークだと思っていましたが、そうではないように思います。
Ephemeral State
を持つ画面はStatefulWidget
でも問題なく、App State
を必要とする場合にInheritedWidget
やRiverpod
などの導入を検討する段階に入ると思います。
またそもそも状態を持たない画面については、StatelessWidget
で十分です。
つまりStatelessWidget
/StatefeulWidget
/InheritedWidget
やriverpod
は共存するものではないかと考えています。
Discussion
Flutter標準のWidgetをまず理解できるよい記事と思いました!このあたりはどんな状態管理パッケージを使う場合も大事になる基礎的な部分だと思います。
ものすごい細かい部分ですが(Zennにも編集リクエスト機能がほしい)
正確には f は build()メソッド かと思います。その上の画像にも書かれている通りですね。
ここは、「つまりStatefulWidgetも ミュータブルな 状態を持つことができません。」の誤植かと思います。
これについては、
Chip
(がビルドするRawChip
)などのStatefulWidgetを継承したFlutter標準のWidgetを参考にすると活用方法が思い浮かぶかと思います。ざっくりいうと、「細かな状態はWidget自身で管理し、操作や変化の結果だけを親のWidgetにコールバックする」ようなWidgetですかね。アニメーションするWidgetなんかに多い気がします。(アニメーションが終わったらその結果変化した状態をコールバックする、みたいな)
長々とすみません。よい記事と思ってじっくり読んでしまいました!
コメントありがとうございます!
誤った認識だったので、ご指摘ありがとうございます!
おっしゃる通りご誤植でした。。
またStatefulWidgetの具体系な活用方法も教えていただきありがとうございます!
内部で状態を管理し結果をコールバックするようなWidgetの作成時に活用できるという例は、分かりやすく、「確かに使えそう」とすぐイメージできました。
余談ですが、
Chip
の実装を見る前はChip
とRawChip
は継承関係にあるのかなとイメージしていたのですが、実際は
Chip
がRawChip
をラップしているだけなのは少し意外でした。こういう組み方もあるのかと勉強になりました。