Stateful Widget のパフォーマンスを考慮した正しい扱い方

mono 
Flutter 🇯🇵
Published in
40 min readDec 27, 2018

FlutterのStateful Widgetは唯一のStateを持つWidgetであり、アプリの状態を保ちながら画面更新する際に必須の存在です。一方、Stateful Widget の不適切な使い方はパフォーマンス劣化や思わぬバグを招きます。

本記事では具体的な例を用いて、Stateful Widgetをどう扱うのが適切なのかを説明してきます。

以下の記事を書きましたが、これを理解した前提の内容となっています(RenderObject周りはあまり関係しないのでスキップしてもOKです)。

前提としてWidgetのリビルドの数と頻度に拘りすぎない方が良い

Flutterのパフォーマンスの話で誤解されがちですが、FlutterのWidgetリビルドはあくまで表面的に観測しやすい1つの指標に過ぎず、かつそのコストは概ね低いです(高頻度・大量になってくると無視できなくなってきますが)。実際の支配的なコストは大抵の場合は再レイアウト・再描画にあることが多いです。

他の条件がまったく同じ場合にWidgetのリビルドの数と頻度を抑えられればパフォーマンスが向上するのは確かですが、その向上具合は場合によって全然違い、ほぼ無視できる程度の差にしかならないことも多いです。実際には微々たる差しか出ないにも関わらず、そのために複雑なコードを書いたりアーキテクチャーのせいにして手間のかかる変更をしたりするのはアンチパターンです。

そして、実際にパフォーマンス改善に取り組む場合は、きちんとプロファイリングを取るのが大事です。
(それ以前の問題で大きく誤用して明らかにパフォーマンスを大きく損なう書き方がされていた場合は、測定せずに単に正しい書き方にまず書き換えて改善具合を体感に頼るなどでも良いと思いますが。)

以上で前置き終わりです。

Flutterの公式ドキュメントのStatefulWidgetクラスの後半に、Performance considerationsという項目があって、これをきちんと理解できればOKなのですが、文字だけの説明ですし暗黙的な理解の前提の上で書かれているため、例えばFlutterに触れた直後に読んでも不明点があるのが普通だと思っています。

Performance considerations — StatefulWidget

というわけで、上から訳しながら読み解いていきます。

2種類に大別されるStatefulWidget

There are two primary categories of StatefulWidgets.

[訳] StatefulWidgetは2種類に大別されます。

1. State.setState を呼ばないStatefulWidget

The first is one which allocates resources in State.initState and disposes of them in State.dispose, but which does not depend on InheritedWidgets or call State.setState. Such widgets are commonly used at the root of an application or page, and communicate with subwidgets via ChangeNotifiers, Streams, or other such objects. Stateful widgets following such a pattern are relatively cheap (in terms of CPU and GPU cycles), because they are built once then never update. They can, therefore, have somewhat complicated and deep build methods.

[訳] 1種類目は、以下の特徴を持ちます。

基本的にビルドも1回限りでリビルドが発生しないので、CPU・GPU利用率の観点で比較的低コストで済みますが、その代わりに深い子Widgetと絡む複雑なビルドの仕組みを持つことがあります。

…と書き下しましたが、いきなり言われてもよく分からないのではと思います。

なぜならFlutterを触り始める際も、公式ドキュメントに従って学習を進めていた時に行き着く Write Your First Flutter App, part 2 — 5. Add interactivity においてもStatefulWidgetのsetStateを活用して状態の更新をしていますし、Flutterプロジェクトを新規作成した際の雛形のCounterアプリも同様です。

一方、この「 State.setState を呼ばないStatefulWidget」は自らサブツリーの更新をsetStateで発火させることはせずに、State(状態)を保持できるという特性を生かして、Stateとライフサイクルを合わせたいオブジェクトの保持や後述のWidgetのキャッシュなどを行います。

もし、ここでまだピンと来なくても、後の例を見てからまた戻って読み直すと理解しやすいと思います。

2. State.setState を呼ぶStatefulWidget

The second category is widgets that use State.setState or depend on InheritedWidgets. These will typically rebuild many times during the application’s lifetime, and it is therefore important to minimize the impact of rebuilding such a widget. (They may also use State.initState or State.didChangeDependencies and allocate resources, but the important part is that they rebuild.)

[訳] 1種類目とは逆に、2種類目は以下の特徴を持ちます。

このリビルドが何度もされ得るという特徴を持つため、リビルドされた時に最小限の必要な処理だけ走るようにするのが大事です。

こちらはStatefulWidgetの利用例として説明やサンプルコードが多く出回っていますし、イメージしやすいと思います。リビルドのインパクトは、Flutter の Widget ツリーの裏側で起こっていること で説明した通り、以下の処理に相当します。

  1. State.setState が呼ばれる
  2. そのState以下のWidgetサブツリー全体がリビルド(参照が保持されているWidgetは例外)
  3. Elementサブツリーへの差分適用
  4. RenderObjectサブツリーとの差分チェックをして差分があれば再レンダリング

もしもすべてのRenderObjectの差分チェックが理想的に最適化されていれば、無駄なリビルドが走っても再レンダリングはされませんが、その直前までの処理(4の「RenderObjectサブツリーとの差分チェック」まで)は毎回確実に走ります。

リビルドの影響を最小限にするテクニック

There are several techniques one can use to minimize the impact of rebuilding a stateful widget:

[訳] Stateful Widgetのリビルドの影響を最低限にするための様々なテクニックがあります。

というわけで、このあと箇条書きでそれらのテクニックが羅列されていきますが、それぞれ丁寧に見ていきます。

Stateを末端に追いやる

Push the state to the leaves. For example, if your page has a ticking clock, rather than putting the state at the top of the page and rebuilding the entire page each time the clock ticks, create a dedicated clock widget that only updates itself.

[訳] Stateを末端に追いやるとサブツリーがその末端以下となって小さくなるのでその分リビルドが軽くなります。
例えば、Widgetが時間とともに更新される時計を持っていたとして、その時計をルートで保持すると時計の更新のたびにページ全体が無駄にリビルドされることになります。その代わりに時計のWidgetを別途作って末端に置いて自律的に更新させるようにすると良いです。

なるほど、初めに書いてあるだけあって理解しやすいですし、確実に効果がありそうです。

実際に試してみます。まずは、ページ全体が無駄にリビルドされるというダメなパターンです。

Timer.periodic を使って1秒ごとにルートのsetStateを呼んで、Text Widgetを用いてDateTime.now()にて現在時刻を表示しています。

実行結果は次のようになります。時計は正しく動いていますが、PerformanceタブのRebuilds statsやコード行の左のリビルドの目印などを見ると分かる通り、毎秒ごとに ClockPage 全体がリビルドされているのが一目瞭然です。

そして次のコードがリビルドを末端に追いやった改善例です。

次の変更がなされています。

  • Clock Widgetを切り出して、そのStateのbuildメソッドでは現在時刻のText Widgetだけを返すように変更
  • ClockPage がStatefulWidgetである必要がなくなったのでStatelessWidgetに変更

実行結果を見ても、次のようにバッチリです👌元々簡素な画面なので、フレームレート的にはこの例だけではほぼ差が出ないですが、実際のアプリではsetStateするStatefulWidgetを末端に追いやる改善は効果的です。

パフォーマンスが良くなるだけでなく、時計表示に関する部分が ClocPage から Cloc に切り出され(実際にはさらに clock.dart などの別ファイルにすると思います)、それぞれのWidgetの役割分担が明確になって可読性・メンテナンス性も上がっています。

buildメソッドで返すWidgetのネストは極力減らす

Minimize the number of nodes transitively created by the build method and any widgets it creates. Ideally, a stateful widget would only create a single widget, and that widget would be a RenderObjectWidget. (Obviously this isn’t always practical, but the closer a widget gets to this ideal, the more efficient it will be.)

[訳] buildメソッドで返すWidgetツリーから生成されるノードの総数は極力減らした方が良いです。理想的にはbuildメソッドではたった1つのRenderObjectWidgetを返すようにするとパフォーマンス的に最も有利です(実際のアプリではそこまで追い込めるわけではなく、あくまで理想)。

まず、ここで言う理想的なStateful Widgetとは例えば次のようなものです。

この例ではStatefulである意味が無いですが、そこは目を瞑って、buildメソッドで返しているRichText Widgetが ”Ideal” な箇所なのでそこに注目してください。

RenderObjectWidget > LeafRenderObjectWidget > RichText という継承関係なので、「たった1つのRenderObjectWidgetを返す」という条件に合致しています。

代わりによく使うText Widgetを使った “Ideal” では無い例が以下です。

Textの内部実装のbuildメソッドではRichTextを返していて、RenderObjectツリーの結果はどちらも同じになりますが、WidgetツリーおよびElementツリーには後者の場合にはそれぞれ本来なくとも済ませられるはずのText WidgetとStatelessElementが1つ挟み込まれていて、少しだけ無駄ということです。

括弧で補足している通り、このTextを省略するという最適化はあくまでパフォーマンスを追求した場合の理想の話なので、普段は普通にText Widgetを使って書きやすさ・可読性を重視した方が良いです(そのためにフレームワークとしてRichTextをTextでラップして提供してくれているわけですし)。

UI構築のために本質的に必要なものはRenderObjectWidgetで、それ以外のWidgetはそれを補助するための手段であり、カリカリにチューニングしたい場合はRenderObjectWidget以外を削ぎ落とすといいですよ、というアドバイスとして受け取ると良いと思います。パフォーマンスを追い求める必要性が薄いのに闇雲に削ぎ落とすとデメリットの方が大きくなるので、普段はあまり意識せずで良いと思います。

実用的な例としては、Opacity WidgetなどはSingleChildRenderObjectWidgetを継承したシンプルな作りになっていて、以下のようなbuildメソッドを持つStateful WidgetのsetStateはかなり軽いです。

@override
Widget build(BuildContext context) {
Opacity(
opacity: _opacity,
child: someChild,
);
}

Opacity WidgetはRenderObjectとしてRenderOpacityを返しますが、その paint メソッドの実装は以下のようにとてもシンプルです。
(最適化・assertionをカットしています。実際の実装はこちら。)

@override
void paint(PaintingContext context, Offset offset) {
context.pushOpacity(offset, _alpha, super.paint);
}

不変なサブツリーがあればキャッシュしてリビルドごとに再利用する

If a subtree does not change, cache the widget that represents that subtree and re-use it each time it can be used. It is massively more efficient for a widget to be re-used than for a new (but identically-configured) widget to be created. Factoring out the stateful part into a widget that takes a child argument is a common way of doing this.

[訳] もしも不変なサブツリーがあれば、それをキャッシュしてリビルドごとに再利用すると良いです。この方法は、buildメソッドで毎回Widgetを new するよりずっと効率的です。さらに、statefulな部分を切り出した上で child 引数として不変(Stateless)なWidgetを受けるようにするリファクタリングも典型例です。

2種類のことを言っていて、1つめは次のようにStateが状態を持つ特性を生かして不変なWidgetをフィールドにキャッシュしておくということです。実際にこれだけで appBar のリビルドが解消できますし、このWidgetに対応するElementサブツリーもノーコストでそのまま再利用されます(リビルドされてもキャッシュされたWidgetは同じ参照を保ち、Widgetはimmutableな故にWidgetサブツリーも不変であることが保証されるため)。

さらに、statefulな部分を切り出した上で child 引数として不変(Stateless)なWidgetを受けるようにするリファクタリングも典型例です。

これも例があった方が分かりやすそうです。

ボタンを押すと真ん中の赤いContainerをフェードインさせる画面を適当に組むと次のようになります。

実行させてみると、動作は問題無いものの、単に真ん中のContainerをフェードインしたいだけなのに関わらず、ページ全体が大量にリビルドされてしまっています🤯

本来statefulであるべきなのはOpacity Widgetのopacityのみです。そこで上述のテクニックのようにそこだけをStateful Widgetに切り出してみましょう。
(後述の通り、フェードインアニメーションはもっと簡単に書ける便利なWidgetがあります。)

Opacityに関する部分をFadeInWidgetとして切り出して、さらにPage Widgetはbuild時にFadeInWidgetのchildにフェードイン対象を渡しています。こうすることでPage(ページ全体)のリビルドがされないことはもちろん、フェードイン対象の赤いContainerもリビルドされなくなります(今回はシンプルですがフェードイン対象が複雑になると大きく効いてきます)。

若干キャッシュっぽさが薄いですが、FadeInWidgetはリビルドごとに親から受け渡された同一のWidgetを使い回していて、そういう意味でキャッシュとも言えそうです。

実行結果も、次のようにリビルドはFadeInWidgetだけになってバッチリです👌

ちなみに、上では説明のために愚直にアニメーションを組みましたが、以下を使うとスマートに書けますし、

Opacityのアニメーションを簡単に書けるWidgetもあるので、実際にはこれら活用した方が良いです。

これらをお行儀よく使えば簡単に無駄の無いアニメーション実装ができます。

また、関連として、PageRouteの具象クラスを使ってアニメーション付きの画面遷移がなされますが、それらも同様のケアがされているので遷移対象の画面のリビルドが大量に走ることは無いです。

キャッシュすると思わぬバグの要因になったりしない?

Widgetはビルドの度に毎回生成するのが当たり前の感覚を持っていると、キャッシュするのが微妙な気がするかもしれません。

例えば、StateのフィールドにキャッシュしているWidgetの中身を途中で書き換えてしまうと、「更新したつもりなのに反映されない」という事態になりそうです🤔

ただ、まずWidgetは@immutableで、お行儀よく書かれていればフィールドはすべて final になっています。そのため、「Widgetの中身を途中で書き換えてしまう」操作自体ができません。

一方、次のようなWidgetのListのキャッシュの場合はどうでしょうか?

まず、この場合はボタンを押すたびに問題なく入れ替わりました。 List<Widget> 自体はWidgetではないのでそのWidget要素まで見ているようですね。

ところが、上のコードでキャッシュされたWidgetのリストを返しているColumnを同じくchildrenを持つListViewに変えると、ボタンを押しても入れ替わらなくなってしまいます。ListViewの場合はListの参照が同じ場合は更新しないという最適化が入っていてそれに阻まれているためです。

SliverChildListDelegateのソース

というわけで、キャッシュに由来するトラブルは基本的にはあまり起こらなさそうではあるものの、場合によっては少し注意かなと思います。

  • 単一のWidgetの場合はキャッシュしたことによって混乱することは無さそう(混乱招くようなコードを書けない)
  • ListなどWidgetを間接的に持っている場合は、それを受け取るWidget次第で挙動が変わったりするので注意(単一のWidgetと同様基本的には途中で弄らない方が良いと思う)

const キーワードをなるべく使う

Use const widgets where possible. (This is equivalent to caching a widget and re-using it.)

[訳] const キーワードをなるべく使う(Widgetをキャッシュして再利用するのと同等)。

括弧付けで書いてある通り、上に書いたキャッシュと同等の効果があります。

例えば、まず次のように毎秒setStateが呼ばれるPageがあったとします。NonsenseWidgetという無駄に色々入れ子にしたWidgetをbuildで返しています。

実行すると、当たり前のように毎秒ごとにNonsenseWidgetおよびその中のWidget群がリビルドされます。

上記コードに対して、次のようにするだけでリビルドされるのがPageだけになって状況が劇的に改善します。

  • NonsenseWidgetにconstコンストラクターを定義
  • _PageStateのbuildメソッドでconstを指定

const で生成できるWidgetは、コンパイル時に確定できるものに限られます。

つまり、const を付けるだけでリビルドを防げるため一見使い勝手が良く見えますが、 const コンストラクターにしてかつ利用側も const で使えるケースがある程度限定的なので、上で述べたWidgetをキャッシュする方式と併用することになると思います。

ちなみに、以下の記事ではネストの深くなってしまったbuildメソッドをプライベートメソッドに分割しても単に見た目がすっきりするだけなので、パフォーマンス向上のためにはWidgetとして切り出して const を指定する必要があるということが書かれていて、同様のことを言っています。

ネストが深くなったbuildメソッドを持つWidgetはリビルドのインパクトが大きくなりがちで、それを軽減させることは良いプラクティスなのでその通りなのですが、前述のStateのフィールドでキャッシュさせる方法に触れられていないのが惜しいです。実際には、以下の3つから適したものを選ぶのが良いと思っています。

  • プライベートメソッドに切り出す(そもそもStateに依存していたら設計を変えない限りこうするしかない)
  • Stateのフィールドでキャッシュ(メソッド分割と同程度に簡単に書ける)
  • Widgetとして切り出して const を指定する(他でも利用するようなWidgetならこれが良さそう)

const の指定し忘れを防ぐには?

実は、これまで書いてきたところの様々なところで const を指定する余地があって、基本的にはそれら指定できるところには指定しておいた方がベターだと思っています(例では他のテクニックの説明と混ざるのを避けるためにあえて無指定にしていました)。

とはいえ、Dart 2で new を省略できるようになったこともあり、つい無指定で書いてしまうことがありがちだと思いますが、それは静的解析で解決できます。

Dartプロジェクトの analysis_options.yaml をトップレベルに置くとその内容に応じて静的解析がかかりますが、以下のルールを指定すると、 const 付け忘れを指摘してくれるようになってオススメです。

prefer_const_literals_to_create_immutablesのルールが効くのは、Widget抽象クラスには@immutableが付いているからです。

const はトップレベルに1つ付ければOK

上の例では次のようにNonsenseWidgetには付けずに一番上のScaffoldに const を付けています。

return const Scaffold(
body: Center(
child: NonsenseWidget(),
),
);

これはDart 2で以下の改善がなされたからです。

  • new は常にオプション
  • const は定数コンテキスト上ではオプション

詳しくは、公式ドキュメントのコンストラクターの項目を見てください。

ちなみに、「Dart2 で new や const が省略できる」という記事で const も完全に省略してOKのような記述があったり同様な誤解をしている人が結構いそうな気がしていますが、それは誤りなはずで、 const は必要に応じて最低限の明示が必要です。

上の記事の以下の例では EdgeInsetsconst を省略してしまっていますが、そこは new のContainerコンテキスト内なので同じく new 扱いになって、 const 明示した場合と比べてパフォーマンスが落ちてしまいます。
(この記事というより元のセッション動画でのスライド時点で間違っているようですが。)

return Container(
height: 56.0,
padding: EdgeInsets.symmetric(horizontal: 8.0),
...
);

Containerなどのconstが使えないWidgetの代わりにconstを使えるWidgetが使えないか検討する

Flutterにおいて色々できて便利なContainerですが、次の理由で乱用はするべきではないです。

Containerは色々な使い方に対応できるようになっているが故に指定の組み合わせの誤用を防ぐためのコンパイル時に確定しない条件チェックの assert が含まれているため、constコンストラクターを提供できず、つまり利用時にconst指定ができません。

さらに、以下など目的に沿ったWidgetがあって、これらにはconstコンストラクターが提供されています。

  • SizedBox: 要素間にスペースを空けたい時など
  • Padding: 文字通りパディングを設けたい時
  • Align: 要素を寄せたい時

これらを使うべきシーンでContainerを使っていて、パフォーマンスも可読性も損なってしまっている例をよく見ますが、より目的に適したWidgetを使い分けるべきです。 const の有無を除いても単一の責務を持つこれらのWidgetに比べてContainerは重いです。

一方、Containerが適した場面ももちろんあり、そこで使うのは当然全く否定していません。

constのWidgetも変更に追従できる

以上のように、 const を付けると設計図にあたるWidgetはコンパイル時に確定して1回ビルドしたインスタンスがそれ以降使い回されるようになります。

こう書くと、表示が全く変わらない部分にしか使えないように感じますが、そうではなく動的な画面にも活用できます。

色々やり方はありますが、InheritedWidgetを使ったやり方が一番の基本です。次のようにconstのNonsenseWidgetにInheritedWidgetを継承したTimeInheritedを挟みます。

このTimeInheritedを介して現在時刻を渡してあげることで、constなNonsenseWidgetの更新が可能となります。

なぜこれが可能かというと、InheritedWidgetは以下の特性を持つからです。

  • inheritFromWidgetOfExactType によってO(1)のアクセスができるとともにそのcontextに対して自身の変更を伝播してリビルドを発火できる
  • updateShouldNotify により、自身の保持しているデータに応じて実際に変更を伝播するかの制御も可能

NonsenseWidgetはbuildメソッドでinheritFromWidgetOfExactTypeを使っているので、次回TimeInheritedの保持しているtimeが更新された時にまたリビルドが要求されます。

つまり、InheritedWidget依存を静的に宣言しているconst Widgetなのでそれ自体は不変だが、自身のサブツリーはInheritedWidgetからの変更通知によってリビルドできる、ということになります。

整理すると、constとInheritedWidgetの組み合わせで次のようなことが実現できるということです。

  1. constで親ツリーのリビルド伝播をせき止める
  2. InheritedWidgetでそのconstでせきとめられているツリー配下のサブツリーのリビルドができる

ちなみに、この組み合わせは、ReactのshouldComponentUpdateと同等の機能を担っているという認識です。

React’s diff algorithm より

(const) WidgetにInheritedWidgetを使って変更伝播する方式はFlutterフレームワークでもよく用いられている

例えば、次のように、Theme Widgetの中にある const のTextが、テーマの変更に応じて切り替わるのも、Theme周りの内部実装としてInheritedWidgetが使われているからです。

実際のアプリ開発でInheritedWidgetをそのまま使うことは少ない気がする

[2020/02/16 追記] 以下について現在はproviderパッケージがお勧めです(後日リライトするかもしれません)

直接InheritedWidgetを使うとコードが複雑になってくるので、実際のアプリ開発ではscoped_modelなど便利にラップしたものを使うのがおすすめです(とはいえInheritedWidgetの生の動きを把握していることも大事です)。

完全に余談ですが、scoped_modelは0.3と1.0で内部実装がガラリと変わって利用しているAnimatedBuilder内で暗黙的にsetStateする実装に変わっていて面白かったです。
(0.3以前の方がソースとしては読みやすく、1.0でテクニカルになった感じです。)

話は戻って、scoped_modelは、Fuchsiaのコードベースでも多用されている、Flutterにおける良いパターンの1つです。

scoped_modelは更新伝播のきめ細かい制御がしにくいマイナス面があります(こちらのコメントも参考にしてください)が、実際には問題ないことが多いと思います(だからこそFuchsiaに採用されているはずです)。また、もしきめ細かい更新伝播機能が必要な場面があってとしても、そのためだけにアーキテクチャー・パターン自体を変えずに単純にそれ用のStream・ValueListenableのプロパティを足すだけで済ませることもできます。

話が逸れていきますが、InheritedWidgetに頼らずにconst Widgetのサブツリーに更新伝播する方法もあって、BLoCパターンが代表的な例で、これはStreams API を利用して変更を通知する仕組みです。きちんと使いこなすには、RxDartの学習コストもかかりますが、それがクリアできればとてもおすすめなパターンです。

const で色々語り過ぎてしまいましたが、ようやく次の項目に移ります。

サブツリーの構造を変えるのを避ける

Avoid changing the depth of any created subtrees or changing the type of any widgets in the subtree. For example, rather than returning either the child or the child wrapped in an IgnorePointer, always wrap the child widget in an IgnorePointer and control the IgnorePointer.ignoring property. This is because changing the depth of the subtree requires rebuilding, laying out, and painting the entire subtree, whereas just changing the property will require the least possible change to the render tree (in the case of IgnorePointer, for example, no layout or repaint is necessary at all).

[訳] サブツリーの深さ・型を変えるのは避けましょう。例えば、条件によってあるchildがタップに反応するかどうかを変えたい時にそのchildとIgnorePointerでラップしたchildを返すことで切り替えるのではなく、その場合常にIgnorePointerでラップした上でそのignoringプロパティにて制御しましょう。サブツリーの深さを変えるとサブツリー全体のリビルド・再レイアウト・再ペイントが発生する一方、サブツリー構造を保ったままプロパティを切り替えるだけの場合はレンダーツリーのうち最低限必要な箇所の更新だけで済みます(このIgnorePointerの例の場合、再レイアウト・再ペイントは一切発生しません)。

なるほど、この場合IgnorePointerを用いて実際の振る舞いを変えたいということでその設計図であるWidgetのリビルドはいずれにしても必須です。つまりElementおよびRenderObjectのケアの話です。これら2つを再生成せずに極力使い回せるようにするのは大事です。

以下のようにそれぞれをA・Bとした時、

  • A: ツリー構造を変える(IgnorePointerでラップするかどうかを変える)
  • B: いずれにしてもIgnorePointerでラップした上でignoringプロパティで変える

Flutter の Widget ツリーの裏側で起こっていること を理解していれば、以下の予想ができます。

  • A・BともにWidgetツリーはリビルドされる
  • AはElementツリーが追従不可能なレベルにWidgetツリー構造が変わってしまうのでElemenetツリーの再生成がなされるが、BはWidgetツリー構造が不変なので既存のElementツリーが使い回されてそれぞれのElementが参照しているWidgetのみがすり替わる
  • 再レンダリングはAではElementツリーが再作成されたが故に当然なされるが、BはRenderObjectが十分最適化されていれば不要になりそう

というわけで試してみます。Bの常にIgnorePointerでラップした上でignoringプロパティを活用して切り替えるサンプルは次のようになります。

少し分かりにくいですが、初期状態は_ignoringがfalseなので真ん中のContainerをタップすると色が変わりますが右下のFABを押すと_ignoringがtrueに変わって真ん中のContainerがタップに反応しなくなります。

以下のように検証したところ、

  • Elementツリー再作成されているか検証: MyContainerのcreateElementがいつ呼ばれるかを探る
  • 再ペイント処理が走るか検証: RenderObjectのmarkNeedsPaintメソッドが呼ばれるかどうかブレークポイントをはる

結果は、次のようになり、予想通りでした。

  • いかなる操作をしてもcreateElementは起動直後の一回しか呼ばれない
  • 右下のFABタップでIgnorePointerのignoringプロパティを切り替えてリビルドしても再ペイント処理は一切走らない

一方、非推奨な、サブツリーの構造を変えるやり方にするためにbuildメソッドを次のように書き換えて試すと、右下のFABボタンを押すたびにMyContainerのcreateElementが呼ばれて、もちろん再ペイント処理もなされました。こちらも同じく予想通りの結果でした。

もしサブツリーの構造を変えざるを得ないならGlobalKeyを指定する

If the depth must be changed for some reason, consider wrapping the common parts of the subtrees in widgets that have a GlobalKey that remains consistent for the life of the stateful widget. (TheKeyedSubtree widget may be useful for this purpose if no other widget can conveniently be assigned the key.)

[訳] もし何かしらの理由でサブツリーの深さを変えたい場合、サブツリーの主要パーツを、GlobalKeyをもつStateful Widgetでラップすると良いです。(もし他のWidgetが簡便にキーが割り当てられない場合はKeyedSubtree Widgetがこの用途に適しているでしょう。)

先ほどの非推奨の例について、MyContainerにGlobalKeyを渡しているのが以下です。KeyでBuildContextを通してElementおよびRenderObjectの参照とそのサブツリーを保持するのがポイントです。

実行結果を見ると、こうやってKeyを指定するだけで、MyContainerのcreateElementは1度しか呼ばれないようになったので、ElementツリーおよびRenderObjectツリーが維持されていることが分かりました。一方、_ignoringを切り替えてsetStateするとさすがに再ペイント処理は走ってしまったので、ツリー構造を保つパターンと比べると少し劣るようです。とはいえ、ドキュメントの言う通り、構造を変えつつもパフォーマンスを維持するパターンとして有用そうです。

括弧で書かれているKeyedSubtreeは、Keyを受け付けられないWidget(お行儀悪い気がしますが)に対してKeyを差し込みたい場合に以下のように使えます。

child: KeyedSubtree(
key: _key,
child: MyContainer(color: _colors[_current % 3]),
),

GlobalKey与えるだけでパフォーマンス上がるなら、各所でもっと積極的に使っていくと良いのか?というとそんなことはなく、GlobalKeyの管理は高コストであり濫用するとむしろ悪影響を及ぼしかねないので、基本的にはそれ無しで済ませるべきです。リビルドインパクトの大きなツリーの保持をしたいがGlobalKeyを指定する以外の方法が無い時など、限定的に使いましょう。
(GlobalKeyはパフォーマンス観点以外に下位ツリーのState参照したい時などにも必要で、もちろんそういう場合もOKです。)

Global keys are relatively expensive. If you don’t need any of the features listed above, consider using a Key, ValueKey, ObjectKey, or UniqueKey instead.

https://docs.flutter.io/flutter/widgets/GlobalKey-class.html

かなり長くなりましたが、以上でようやくなぞり終えました。これまでも、Flutterの勉強をするにあたりPerformance considerationsを何回か読んでいましたが、今回この記事を書くにあたって隅々まで正確に理解しようとしながら読み直してとても勉強になりました。

パフォーマンスをどの程度意識するべきか

まず、優先度観点としては、個人的には以下程度で良いと思います。
(項目や順番は厳密なものではなく、パフォーマンスが一番下ということが主旨です。)

  1. 可読性
  2. メンテナンス性
  3. 実装の手間
  4. パフォーマンス

つまり何かを犠牲にしてまで優先させるべきものではなく、それでもどうしてもパフォーマンスを優先したい時に渋々パフォーマンスチューニングするものだと思っています。

また聞きですが、こちらの @k_katsumi さんのコメントに同意です。

…という前提で、何かを犠牲にすることなく、可読性・メンテナンス性などとパフォーマンスを両立できるものも多いと思っていて、本記事の項目の大半もそれにあたると思っています。

つまり、パフォーマンスを過度に重視する必要はない(むしろ良くない)ですが、実際にどういう処理が走るのかを意識しながら、そのケースにトータルとしてベターな書き方を選択するのは良いことだと思っています。パフォーマンス的に優れた書き方を知っている上であえて崩すのと、よく分からないままに適当に書くのとでは、天と地の差があります。

また、雑に書いていたらスクロールやアニメーションがカクカクになってしまったりする原因の大半は、パフォーマンスの優先度が低いというより技術的に至ってなかっただけなことが原因だと思っていて、それをクリアした状態で実装に取り組めば上に挙げた要件の両立につながりやすいはずです。それを怠って未熟な状態で実装を進めても、パフォーマンス周りの後追い対応に追われて結局無駄に時間食うということになりがちだと思っています。

シンプルな状態でパフォーマンスに気を付けた書き方できずに、実際の複雑なアプリでそれができるわけがないと思っていて、今回実際にシンプルなサンプルコードを書いて試すことで、ドキュメントを読むだけではよく分からない疑問点のほとんどが解消できてとても良かったです。

本記事でなぞったのは次のうち前者のStatefulの方でした。

Statefulの方が高機能なので、それをなぞればStatelessの方もついでに大体カバーできてしまった感がありますが、微妙に抜けている点もあるので、興味があればStatelessの方も読んでみてください。

ここに貼ったGistはmain.dartファイルとして完全ではないものがありますが、以下のリポジトリー内に動作する完全なサンプル群が入っています。

--

--