読んだページ
個人的なまとめ
- Widgetに変更があるとき、Widget自体を変更するのではなく、変更が適用されたWidgetで置き換わる。
- なので、Widget自体は不変であり、置き換わるものである。
- 他のWidgetからアクセスする必要がない状態を扱う際は
StatefulWidgetを使う。 - それ以外の場合は、
StatefulWidgetを使っても良いし、providerパッケージを使っても良い(providerが主流らしい) -
ChangeNotifierを拡張してモデルを作り、notifyListeners()で変更を通知する。 -
ChangeNotifierProviderを使って、ChangeNotifierを通知したいWidgetに渡す。 -
ChangeNotifierのモデルにアクセスしたいときは、Consumer<ModelName>を使う。 -
Consumerwidgetはなるべく深いところに置く。(再ビルドの範囲を小さくするため)
State management
Introduction
リアクティブアプリの状態管理について既に詳しい場合は、
様々なアプローチのリストを確認したいかもしれませんが、このセクションをスキップすることができます。
Flutterを探っていると、アプリ全体を通して、画面間でアプリの状態を共有する必要があるときがあります。そこには多くのアプローチと考えるべき多くの質問があります。
以降のページでは、Flutterアプリにおける状態の扱い方の基礎を学ぶことができます。
Start thinking declaratively
(Android SDKやiOS UIKitのような)命令的フレームワークからFlutterに来ている場合は、
アプリ開発について新しい視点から考える必要があります。
多くの仮定はFlutterには当てはまりません。
例えば、FlutterではUIの一部を変更する代わりに、最初から再構築しても問題ありません。
Flutterは必要に応じてすべてのフレームでさえ、十分な速度です。
Flutterは宣言的です。
つまり、アプリの現在の状態を反映したUIを構築します:
アプリの状態が変わるとき(例えば、設定画面でユーザーがスイッチを切り替えると)、状態が変わり、UIの再描画をトリガーします。
widget.setTextのように、UI自身には命令的な変更はありません。
状態を変更すると、UIは最初から再構築されます。
スタートガイドでUIプログラミングの宣言的なアプローチの詳細を見てください。
UIプログラミングの宣言的なスタイルにはたくさんのメリットがあります。
驚くべきことに、UIの任意の状態に対して、コードパスは1つしかありません。
最初は、このプログラミングスタイルは命令的スタイルほど直感的ではないように思えるかもしれません。
これがこのセクションがここにある理由です。
Differentiate between ephemeral state and app state
このドキュメントでは、アプリの状態、短命な状態、Flutterでそれぞれをどう管理するかを紹介しています。
可能な限り広い意味で、アプリの状態はアプリの実行中にメモリに存在するあらゆるものです。
これは、FlutterフレームワークがUIやアニメーションの状態、テクスチャ、フォントなどに関して保持しているアセットやすべての変数を含みます。
この可能な限り広い状態の定義は有効ですが、アプリの設計には役立ちません。
まず、一部の状態(テクスチャなど)さえ管理する必要はありません。
フレームワークが処理してくれます。
そのため、状態のより役に立つ定義は、「UIをいつでも再構築するために必要なすべてのデータ」です。
次に、自分で管理する必要がある状態は、2つの概念的タイプ、一時的な状態とアプリの状態にわけることができます。
Ephemeral state
一時的な状態(UIの状態またはローカル状態と呼ばれることもある)は1つのWidgetにきれいに含めることができる状態です。
これは意図的に曖昧な定義であるため、ここにいくつかの例を示します。
-
PageViewの現在のページ - 複雑なアニメーションの現在の進行度
-
BottomNavigationBarの現在の選択されたタブ
Widgetツリーのほかの部分は、このような状態にアクセスする必要はほとんどありません。
シリアル化する必要はなく、複雑な方法で変更されることもありません。
つまり、このような状態において状態管理技術(ScopedModel, Reduxなど)を使う必要はありません。
必要なものはStatefulWidgetだけです。
以下では、Bottom Navigation Barの現在選択されているアイテムが、どのように_MyHomepageStateの_indexフィールドで保持されるかを示しています。
この例では、_indexが一時的な状態です。
class MyHomepage extends StatefulWidget {
@override
_MyHomepageState createState() => _MyHomepageState();
}
class _MyHomepageState extends State<MyHomepage> {
int _index = 0;
@override
Widget build(BuildContext context) {
return BottomNavigationBar(
currentIndex: _index,
onTap: (newIndex) {
setState(() {
_index = newIndex;
});
},
// ... items ...
);
}
}
ここでは、setState()とStatefulWidgetのStateクラスの中でのfieldの使用は完全に自然です。
アプリの他の部分で_indexにアクセスする必要はありません。
さらに、ユーザーがアプリを閉じてアプリを再起動した場合も、_indexがゼロにリセットされても問題ありません。
App State
一時的でない状態、つまり、アプリの多くの部分を通して共有する必要のある状態、または、ユーザーのセッション間で維持したい状態は、アプリの状態と呼ぶものです(共有された状態と呼ばれることもあります)。
アプリの状態の例です:
- ユーザー設定
- ログイン情報
- ソーシャルネットワーキングアプリでの通知
- ECアプリでのカート
- ニュースアプリでの既読/未読の状態
アプリの状態を管理するには、選択肢を調査する必要があります。
どれを選ぶかはアプリの複雑さと性質、チームの以前の経験や他の多くの側面によります。
There is no clear-cut rule
わかりやすくするために、アプリですべての状態をStateとsetState()で管理することができます。
実は、Flutterチームは多くの簡単なアプリサンプルでこうしています(flutter createで作られるスターターアプリも含む)。
逆もまた然りです。
例えば、特定のアプリのコンテキストにおいて、BottomNavigationBarの選択されているタブは一時的でない状態であると決定しても良いのです。
クラスの外から変更したり、セッション間で保持したりするなどの必要があるかもしれません。
その場合は、_index変数はアプリの状態です。
ある特定の変数が一時的な状態かアプリの状態かどうかを区別する明確で普遍的なルールはありません。
ある方をもう一方にリファクタリングする必要があることもあります。
例えば、明らかに一時的な状態でスタートしますが、アプリの機能が大きくなり、アプリの状態に移行する必要があるかもしれません。
そのためには、次の図を話半分に見てください:
ReactのsetState vs Reduxのstoreについて聞かれた時、Reduxの製作者であるDan Abramovはこう答えました:
「経験則としては、扱いにくいものは何でもしてください。」
絶対どっちを使うとかではなく、やってみてだめだったらもう片方も使ってみようぜ、みたいなことか・・・?
要約すると、Flutterには2つの概念的なタイプの状態があります。
一時的な状態は、StateとsetState()を使って実装することができ、多くの場合は1つのWidgetに配置されます。
残りはアプリの状態です。
どちらのタイプもどのFlutterアプリにも使われ、2つのタイプの違いはユーザーの好みとアプリの複雑さによって異なります。
Simple app state management
宣言的UIプログラミングと、一時的な状態とアプリの状態の違いについて知り、簡単なアプリの状態管理について学習する準備ができました。
このページでは、providerパッケージを使います。
Flutterを初めて使う場合や他のアプローチ(Redux,Rx,hooksなど)を使う強い理由がなければ、これはおそらく最初に使うべきアプローチです。
providerパッケージはわかりやすく、多くのコードを使いません。
また、他のすべてのアプローチに適用できる概念を使用しています。
そうは言うものの、他のリアクティブフレームワークからの状態管理での強い背景がある場合は、選択肢のページにリストされているパッケージとチュートリアルを探すこともできます。
Our example
説明のために、次のような簡単なアプリを考えてみましょう。
アプリは2つの画面を持っています:カタログとカート(それぞれMyCatalogとMyCartwidgetで表されます)。
ショッピングアプリかもしれませんが、簡単なソーシャルネットワーキングアプリと同じ構造を想像することができます(カタログを"ウォール"に、カートを"お気に入り"に入れ替える)。
知らなかったので補足:
ウォールとは、Facebook上でユーザーが投稿を行うことができるページのことである。
ウォールとは (Wall): - IT用語辞典バイナリ
カタログ画面はカスタムアプリバー(MyAppBar)と多くのリストアイテムのスクローリングビュー(MyListItems)を含んでいる。
これが、Widgetツリーで視覚化されたアプリです。
少なくとも5つのWidgetのサブクラスがあります。
それらの多くは、他の場所に「属している」状態にアクセスする必要があります。
例えば、各MyListItemはカートに自身を追加することができる必要があります。
現在表示されているアイテムが既にカートにあるかどうかも見たいかもしれません。
Flutterでは、状態を使うWidgetの上で、その状態を管理することは理にかなっています。
なぜでしょう?
Flutterのような宣言的なフレームワークでは、UIを変えたい場合、UIを再構築する必要があります。
MyCart.updateWith(somethingNew)を持つ簡単な方法はありません。
つまり、widgetの関数を呼び出して、外側から命令的にwidgetを変更することは難しいのです。
そしてこれができたとしても、役立つものではなく、フレームワークと戦っていることになります。
// BAD: DO NOT DO THIS
void myTapHandler() {
var cartWidget = somehowGetMyCartWidget();
cartWidget.updateWith(item);
}
上記のコードが機能したとしても、MyCartwidgetで以下の対処をしなくてはならないでしょう。
// BAD: DO NOT DO THIS
Widget build(BuildContext context) {
return SomeWidget(
// The initial state of the cart.
);
}
void updateWith(Item item) {
// なんとかしてここからUIを変更する必要があります。
}
UIの現在の状態を考慮して、新しいデータを適用する必要があります。
このやり方でバグを回避するのは困難です。
Flutterでは、コンテンツが変わるたびに新しいWidgetを作成します。
MyCart.updateWith(somethingNew)(関数呼び出し)の代わりに、MyCart(contents)(コンストラクタ)を使います。
親Widgetのビルド関数でのみ新しいWidgetを構築することができるため、コンテンツを変更したい場合は、それはMyCartの親かそれ以上のところにある必要があります。
// GOOD
void myTapHandler(BuildContext context) {
var cartModel = somehowGetMyCartModel(context);
cartModel.add(item);
}
MyCartには、UIの任意のバージョンを構築するためのコードパスが1つだけあります。
// GOOD
Widget build(BuildContext context) {
var cartModel = somehowGetMyCartModel(context);
return SomeWidget(
// カートの現在の状態を使用して、UIを一度構築するだけです。
// ···
);
}
この例では、contentsはMyAppにある必要があります。
それが変更されるたびに、上からMyCartが再構築されます(詳細は後ほど)。
このおかげで、MyCartはライフサイクルについて気にする必要がなくなります。
それは、与えられたcontentsに対して何を表示するかを宣言するだけです。
それが変わった時は、古いMyCartwidgetが消え、完全に新しいものと置き換わります。
これが、Widgetが不変であると言う意味です。
Widgetは変更されません、置き換わるのです。
カートの状態をどこに置くかわかったので、それにアクセスする方法を見てみましょう。
Accessing the state
ユーザーがカタログのアイテムの1つをクリックすると、それはカートに追加されます。
カートはMyListItemの上にありますが、どうやるのでしょうか?
簡単な選択肢は、クリックしたときにMyListItemが呼び出せるコールバックを提供することです。
Dartの関数はファーストクラスオブジェクトなので、やりたいようにそれらを渡すことができます。
したがって、MyCatalogの中で以下のように定義できます:
@override
Widget build(BuildContext context) {
return SomeWidget(
// Construct the widget, passing it a reference to the method above.
MyListItem(myTapCallback),
);
}
void myTapCallback(Item item) {
print('user tapped on $item');
}
これは正常に動きますが、多くの場所で変更する必要があるアプリの状態のために、多くのコールバックを渡す必要があります。
幸いなことに、FlutterにはWidgetが子孫(つまり、子供だけでなく、下にあるどのWidgetにも)にデータとサービスを提供する仕組みがあります。
すべてがWidgetであるFlutterに期待する通り、これらの仕組みは特別な種類のWidgets(InheritedWidget,InheritedNotifier,InheritedModeなど)です。
それらはここでやろうとしていることに対して少し例レベルであるため、ここでは取りあげません。
代わりに、低レベルのWidgetで動作するが簡単に使えるパッケージを使います。
それはproviderと呼びます。
providerを使うと、コールバックやInheritedWidgetsを考慮する必要がなくなります。
しかし、3つの概念を理解する必要があります:
- ChangeNotifier
- ChangeNotifierProvider
- Consumer
ChangeNotifier
ChangeNotifierはFlutter SDKに含まれる簡単なクラスで、リスナーに変更を通知します。
つまり、何かがChangeNotifierである場合、その変更をサブスクライブできます。
(用語に精通している人にとっては、これはObservableです。)
providerでは、ChangeNotifierはアプリの状態をカプセル化する1つの方法です。
非常にシンプルなアプリの場合、1つのChangeNotifierで済みます。
複雑なアプリの場合、いくつかのモデルがあるため、いくつかのChangeNotifierを使います。
(providerでChangeNotifierを使う必要は全くありませんが、簡単に操作できるクラスです。)
ショッピングアプリの例では、ChangeNotifierでカートの状態を管理したいと思います。
それを拡張する新しいクラスを作ります:
class CartModel extends ChangeNotifier {
// 内部的、プライベートなカートの状態
final List<Item> _items = [];
// カートのアイテムの変更不可なView
UnmodifiableListView<Item> get items => UnmodifiableListView(_items);
// (すべてのアイテムが$42としたときの)すべてのアイテムの現在の合計金額
int get totalPrice => _items.length * 42;
// [item]をカートに追加します。これと[removeAll]は外側からカートを変更する唯一の方法です。
void add(Item item) {
_items.add(item);
// この呼び出しは、WidgetにこのモデルをリッスンしているWidgetに再構築を指示します。
notifyListeners();
}
/// カートからすべてのアイテムを消します。
void removeAll() {
_items.clear();
// この呼び出しは、WidgetにこのモデルをリッスンしているWidgetに再構築を指示します。
notifyListeners();
}
}
ChangeNotifier特有の唯一のコードは、notifyListeners()の呼び出しです。
アプリのUIを変更する可能性のある方法でモデルが変更されるたびに、この関数を呼び出します。
CartModelのその他すべては、モデル自身とビジネスロジックである。
ChangeNotifierはflutter:foundationの一部であり、Flutterのどの上位のクラスにも依存しません。
簡単にテストできます(widget testingを使う必要さえありません。)。
例えば、これがCartModelの簡単なユニットテストです:
test('adding item increases total cost', () {
final cart = CartModel();
final startingPrice = cart.totalPrice;
cart.addListener(() {
expect(cart.totalPrice, greaterThan(startingPrice));
});
cart.add(Item('Dash'));
});
ChangeNotifierProvider
ChangeNotifierProviderはChangeNotifierのインスタンスをその子孫に提供するWidgetです。
providerパッケージから取得されます。
ChangeNotifierProviderをどこに置くか既に知っています、それにアクセスする必要があるWidgetの上です。
CartModelの場合は、MyCartとMyCatalogの上のどこかを意味します。
ChangeNotifierProviderを(スコープを汚したくないため)必要以上に高階層に置きたくはないでしょう。
しかしこの場合、MyCartとMyCatalogの両方の上のWidgetはMyAppしかありません。
void main() {
runApp(
ChangeNotifierProvider(
create: (context) => CartModel(),
child: MyApp(),
),
);
}
CartModelの新しいインスタンスを作るbuilderを定義していることに注意してください。
ChangeNotifierProviderは絶対に必要な場合を除いて、CartModelを再構築しないほど十分にスマートです。
インスタンスがこれ以上必要ない場合は、自動でCartModelのdispose()を呼びます。
複数のクラスを提供する場合は、MultiProviderを使うことができます:
void main() {
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider(create: (context) => CartModel()),
Provider(create: (context) => SomeOtherClass()),
],
child: MyApp(),
),
);
}
Consumer
最上部のChangeNotifierProviderの宣言を通して、CartModelがアプリに提供されるようになったため、それを使い始めることができます。
これはConsumerwidgetを通して行われます。
return Consumer<CartModel>(
builder: (context, cart, child) {
return Text("Total price: ${cart.totalPrice}");
},
);
アクセスしたいモデルの型を指定する必要があります。
この場合h、CartModelにアクセスしたいので、Consumer<CartModel>と書きます。
ジェネリック(<CartModel>)を指定しない場合、providerパッケージは役に立ちません。
providerは型に基づいており、型がなければ何が必要なのかわかりません。
Consumerwidgetに唯一必要な引数はbuilderです。
BuilderはChangeNotifierが変化するたびに呼ばれる関数です(つまり、モデルでnotifyListeners()を呼び出す時、すべての対応するConsumerwidgetのbuilderメソッドが呼ばれます)。
builderは3つの引数を指定して呼び出します。
1つ目はcontextです、すべてのbuildメソッドでも取得します。
builderメソッドの2つ目の引数は、ChangeNotifierのインスタンスです。
それは、最初に求めていたものです。
モデルのデータを使用して、任意の時点でのUIの外観を定義することができます。
3つ目の引数はchildで、最適化のためにあります。
モデルが変わっても変化しないような大きなWidgetサブツリーがConsumerの下にある場合、一度それを構築して、builderを通してそれを取得できます。
return Consumer<CartModel>(
builder: (context, cart, child) => Stack(
children: [
// 毎回再構築せずに、SomeExpensiveWidgetをここで使用します。
child,
Text("Total price: ${cart.totalPrice}"),
],
),
// コストが高いWidgetをここでビルドします
child: SomeExpensiveWidget(),
);
できるだけツリーの深くにConsumerwidgetを置くことがベストプラクティスです。
一部の詳細がどこかで変更されただけで、UIの大部分を再構築したくないでしょう。
// DON'T DO THIS
return Consumer<CartModel>(
builder: (context, cart, child) {
return HumongousWidget(
// ...
child: AnotherMonstrousWidget(
// ...
child: Text('Total price: ${cart.totalPrice}'),
),
);
},
);
代わりに:
// DO THIS
return HumongousWidget(
// ...
child: AnotherMonstrousWidget(
// ...
child: Consumer<CartModel>(
builder: (context, cart, child) {
return Text('Total price: ${cart.totalPrice}');
},
),
),
);
Provider.of
場合によっては、UIを変更するためにモデル内のデータは実際には必要ありませんが、それでもアクセスする必要はあります。
例えば、ClearCartボタンによって、ユーザーがカートからすべてのものを削除することができるようにしたいです。
カートの内容を表示する必要はなく、clear()関数を呼ぶことだけ必要です。
これに対しては、Consumer<CartModel>が使えますが、無駄があります。
再構築する必要のないWidgetに再構築するようにフレームワークに依頼しています。
この使用例では、listenパラメータをfalseにセットしたProvider.ofを使うことができます。
Provider.of<CartModel>(context, listen: false).removeAll();
上記のコードをbuild関数内で使っても、notifyListeners()が呼ばれた時このWidgetは再構築をしません。
Putting it all together
この記事でカバーされている例をチェックアウトすることができます。
よりシンプルなものが必要であれば、providerで構築された簡単なカウンターアプリを見てください。
自分でproviderを使う準備ができたら、まず、pubspec.yamlにdependencyを追加するのを忘れないでください。
name: my_name
description: Blah blah blah.
# ...
dependencies:
flutter:
sdk: flutter
provider: ^3.0.0
dev_dependencies:
# ...
これで、import 'package:provider/provider.dart';が使え、ビルドを始められます。