はじめに
以前、 Flutter初見が5日間のハッカソンでアプリ開発してきた という内容で記事を書いたところたくさんの方に見ていただけたようで、Flutterのやっていきが加速しました。
ありがとうございましたmm
今回は、そのハッカソンアプリのリファクタをする際に気になったことを簡単に調べて検証した記事になります。
Flutterの状態管理でChangeNotifierを使っている場合、ChangeNotifierからの変更通知を
Provider.of<Hoge>(context).hoge
のように受け取ると思うのですが、無駄な再描画がかかってしまい変な挙動になってしまうことがありました。
無駄な再描画が起こらないようにするためには、monoさんの Stateful Widget のパフォーマンスを考慮した正しい扱い方 で言及されているように、 Stateは末端に追いやる
が正解だと思いますが、
そもそもStateに変更があった場合に、どこまで更新されるかを知っておかないと今後意図せぬ挙動を引き起こしかねません。
そこで、恒例のCountアプリを例に再描画される範囲を検証してみました。
ChangeNotifier概要
ここでは詳しく解説はしませんが、ChangeNotifierに関して少しだけ触れます。
公式ドキュメントはこちら
ChangeNotifierは、いわゆる ObserverパターンのObservable です。
ChangeNotifierを継承またはmixinしたクラスで状態を持ち、それらの値を更新した後に notifyListeners()
を呼ぶことで、Observerに変更を通知することができます。
具体例
後ほどの検証でもでてきますが、Countアプリを例にした場合はこんなふうに書きます。
class Counter extends ChangeNotifier {
int count = 0;
void increment() {
count++;
notifyListeners();
}
}
このように、状態(count) を持ち、 状態に変更があった場合に notifyListeners()
を呼ぶ書き方です。
使用する側では、
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (context) => Counter(),
child: MaterialApp(
home: HomePage(),
),
);
}
}
このように ChangeNotifierProvider
を用い、子WidgetがCounterインスタンスにアクセスできるようにしてあげます。
ちなみに ChangeNotifier
はFlutter SDKに標準で備わっていますが、 ChangeNotifierProvider
を使う場合は provider packageの依存関係を追加してあげる必要があります。
HomePageでChangeNotifierの状態(count)を監視したり、単発でメソッドを呼び出すコードはこちらです。
class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: Text('${Provider.of<Counter>(context).count}'),
floatingActionButton: FloatingActionButton(
onPressed: () => Provider.of<Counter>(context, listen: false).increment(),
),
);
}
}
Provider.of<Counter>(context).count
のように、型を指定しつつ、状態にアクセスするだけでObserve完了です。
ChangeNotifierで notifyListeners() が呼ばれるとTextがリビルドされます。
onPressedのように、イベントとしてChanegNotifierのメソッドにアクセスしたい場合は
Provider.of<Counter>(context, listen: false).increment()
と、listenをfalseにすることで無駄な再描画はされずに済みます。
ChangeNotifierの再描画範囲
ChangeNotifierの概要について触れたところで本題に入ります。
再描画が走るとき、その範囲ははたしてTextだけなのでしょうか?
答えはNoです。
Gifのように、HomePageそのものがリビルドされており、Scaffold,AppBarにまで影響が及んでいます。
自分はそのあたりの理解が薄かったため、いくつかのパターンを試して再描画範囲に関して検証してみました。
Widgetを切り出し、Stateを末端に追いやってみる
再描画が走るTextだけ、別Widgetに切り出してみました。
class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
// 略
body: const CountWidget(),
// 略
);
}
}
// Stateを別Widgetに切り出す
class CountWidget extends StatelessWidget {
const CountWidget();
@override
Widget build(BuildContext context) {
return Text('${Provider.of<Counter>(context).count}');
}
}
FloatingActionボタンを押すと今度は CountWidget
以下がリビルドされました。
ここで
CountWidgetもビルドされる
ということに疑問を抱きました。
末端に追いやったStateに再描画する必要がないWidgetを置く
class CountWidget extends StatelessWidget {
const CountWidget();
@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
Text('Count'),
Text('${Provider.of<Counter>(context).count}'),
],
);
}
}
今回は Text('Count')
はパフォーマンス的には再描画してほしくありませんが、 CountWidget
自体が再描画されているので、例に違わず再描画されてしまいます。
回避策としては const
をつけることがあげられますが、 notifyListeners() が呼ばれたときに再描画してほしくないWidgetがconstを付けられないこともあります。
根本的に仕組みを知ることで解決したいので、次のパターンを試してみました。
Consumerでラップする
Providerでは、提供されたインスタンスにアクセスするためには、
Providerよりも子である BuildContext
が必要になります。
今回であれば,
ChangeNotifierProviderを用いて Counterインスタンスを提供している MyApp
以下のBuildContextを Provider.of<T>(context)
のcontextに渡してあげる必要があります。
Consumerは、それが得られない場合に有効です。
[Flutter] package:provider の各プロバイダの詳細 も見てみると理解が深まるかもしれません。
class CountWidget extends StatelessWidget {
const CountWidget();
@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
Text('Count'),
Consumer<Counter>(
builder: (_, counter, __) {
return Text('${counter.count}');
},
),
],
);
}
}
この場合は、Consumerと、その内部のTextのみが再描画されています。
このコードは
Consumer<Counter>(
builder: (childContext, _, __) {
return Text('${Provider.of<Counter>(childContext).count}');
},
),
と書いても同じです。
この時点でなんとなくわかってきましたが、念のためもう1パターン検証してみます。
末端に追いやったWidgetのrootにConsumerを置く
class CountWidget extends StatelessWidget {
const CountWidget();
@override
Widget build(BuildContext context) {
return Consumer<Counter>(
builder: (_, counter, __) {
return Column(
children: <Widget>[
Text('Count'),
Text('${counter.count}'),
],
);
},
);
}
}
この場合も、Consumer以下が再描画されます。
Text('Count')
は再描画される必要がないため、以下のように書いてみると
class CountWidget extends StatelessWidget {
const CountWidget();
@override
Widget build(BuildContext context) {
return Consumer<Counter>(
builder: (_, counter, child) {
return Column(
children: <Widget>[
child,
Text('${counter.count}'),
],
);
},
child: Text('Count'),
);
}
}
再描画範囲はConsumer内のTextのみに限定されます。
これは、Consumerの child
で指定されたWidgetが先にビルドされ、builderの child
に渡ってくるためです。
結論
上記のように試してみた結果、再描画範囲は
Provider.of(context)で指定したcontextをもつWidget以下がリビルドされる
ことがわかりました。
つまり、無駄な再描画を抑えたいのであれば、使用するcontextに注意を払うことが大事です。
再描画を最小限に抑える
冒頭で触れた monoさんの記事に記載されていることが全てではあるのですが、
ChangeNotifierをlistenする場所を末端に追いやるか、Consumerをうまく使うことで最小限に抑えられそうです。
@override
Widget build(BuildContext context) {
final count = Provider.of<Counter>(context).count
return // 略
}
と、buildメソッドの冒頭でlisten trueで変数に代入しているサンプルをたまに見かけますが、無駄な再描画を抑えるのであればこの書き方は避けた方がよいかもしれませんね(listen falseであれば問題ないと思います)。
おわりに
ChangeNotifierはUIとロジックを分けることができ、便利ですが再描画の範囲を理解していないことで意図しない挙動に陥ってしまうことがあったため調査してみました。
今回の記事で同じような状況の方の参考になれば幸いです。
追記
ChangeNotifierをどの単位で持つべきか、いまだによくわかっていないので、よければコメントいただければ幸いです。
初めはModel単位(ブログであれば ArticleやCategory等の単位)で持ってしまっていたのですが、最近のリファクタで画面単位で持つようにしました。
現状画面単位で特に支障はないのですが、デメリットもあったりするんでしょうか...?