🍗

【Flutter】 StatelessWidget が静的であるという誤解

2020/12/09に公開

Flutter アプリ開発を学び初めて、まず最初に出会うのが StatelessWidgetStatefulWidget を使った UI の構築方法だと思います。

この 2 つの Widget の違いは、よく「静的か、動的か」という点で説明されます。実際 公式ドキュメント にも以下のように記載されています。

A stateless widget never changes. Icon, IconButton, and Text are examples of stateless widgets. Stateless widgets subclass StatelessWidget.

A stateful widget is dynamic: for example, it can change its appearance in response to events triggered by user interactions or when it receives data. Checkbox, Radio, Slider, InkWell, Form, and TextField are examples of stateful widgets. Stateful widgets subclass StatefulWidget.

確かに学び始めの段階ではこの説明は適切だと思います。 Icon や Text のように動きがなければ Stateless、Radio や TextField のように UI の変化があれば Stateful、ということで分かりやすく初学者でも使い所が判断しやすい良い説明だと思います。

しかし、アプリ開発が進んで InheritedWidgetElement など、 Flutter で登場する他のいろいろな要素について理解し始めると、先ほどの理解では説明がつかない場合が出てきます。

典型的な例としては、 Provider パッケージ を使って状態の管理を State ではなく ChangeNotifier を継承した別クラスへ移した時が挙げられます。 Provider を使うことで StatelessWidget でも「動きのある」画面が作れてしまうからです。プロジェクトによっては「StatefulWidget は使わず、全て StatelessWidget と Provider で作ろう」という方針もあるそうです。

このように、必ずしも StatelessWidget が静的でない場合があるのはなぜなのか、この記事では StatelessWidget と StatefulWidget の API ドキュメントに記載されている説明を中心に読みながら、その理由を考えてみたいと思います。

公式の API ドキュメントを読んでみる

まずは、 StatelessWidget と StatefulWidget の正確な理解のために以下の公式ドキュメントを読んでみます。

これを読んでみると、それぞれ以下の説明が記載されています。

StatelessWidget の説明

StatelessWidget のページでは、 StatelessWidget は他の Widget を組み合わせて具体的な UI を構築するための Widget である、という説明の後に以下の説明が続きます。

Stateless widget are useful when the part of the user interface you are describing does not depend on anything other than the configuration information in the object itself and the BuildContext in which the widget is inflated.

ざっと訳すと、 StatelessWidget は StatelessWidget 自身が保持するデータや BuildContext 以外に依存せずに UI (の一部)を構築したい時に便利な Widget である、ということです。

StatefulWidget の説明

一方で StatefulWidget はどうでしょうか。こちらもドキュメントを読んでみると、

Stateful widgets are useful when the part of the user interface you are describing can change dynamically, e.g. due to having an internal clock-driven state, or depending on some system state.

と書かれていて、ざっと訳すと、 StatefulWidget は 時間やシステムの状態によって動的に変化する UI (の一部)を構築したい時に便利な Widget であるとのことです。

さらに続けて以下のようにも書いてあります。

For compositions that depend only on the configuration information in the object itself and the BuildContext in which the widget is inflated, consider using StatelessWidget.

先ほどの StatelessWidget の記述と同様、 Widget 自身や BuildContext のみ のデータを使って UI を構築する場合は StatelessWidget の利用を検討することが書かれています。

StatelessWidget = 「静的」 とは書いていない

この説明を見ると分かる通り、 StatelessWidget が「静的(static)」であるということはどこにも書いてありません。

代わりに書いてあるのは、 StatelessWidget は自分自身や BuildContext が保持するデータに依存して UI を構築する ということです。つまり、そのデータが変化すれば(そしてその変化に応じてリビルドが発生すれば) StatelessWidget であっても「動的」な UI になります。

最初のチュートリアルページにこそ "A stateless widget never changes" と書かれているものの、同様の表現が API ドキュメントに登場しないことはその後の段落を読んでみても分かると思います。おそらくこれは、導入としての分かりやすさのための説明と、実態に合わせたより正確な説明の表現の違いだと考えています。

とにかく、 Flutter によるアプリ開発にある程度慣れてきた開発者が読むことを想定している API ドキュメントの説明からは、 必ずしも StatelessWidget が「静的な UI を作るための Widget」ではない ことが読み取れます。

では、 「自分自身や BuildContext が保持するデータに依存して動的な UI を構築する」方法として例えばどのようなものがあるのでしょうか。そのうちの一つが InheritedWidget を使う方法です。

InheritedWidget とは

InheritedWidget は、主に祖先の Widget が保持するデータを子孫の Widget で利用できるようにするために使われる Widget です。

以下の記事に書いた通り、 Element を経由して InheritedWidget のデータにアクセスすることで、祖先の Widget が保持するデータを元に子孫の Widget UI が構築できるようになっています。またそのデータに変更が入ったら即座に Element に通知され Widget がリビルドされるようにもなっています。

https://zenn.dev/chooyan/articles/bd8b5990eb210f

先ほどの StatelessWidget の説明で登場した BuildContext の実体は Element ですので[1]、 Element 経由でこの InheritedWidget を使って StatelessWidget で構築する UI を動的に変更できることは、 API ドキュメントに記載されていた "depend on anything other than ... the BuildContext" にも合致します。

パフォーマンスの違い

原則的に、StatelessWidget と StatefulWidget ではパフォーマンスに大きな差はありません。[2] StatelessWidget も StatefulWidget も、リビルドが発生したら Element によって新しいオブジェクトが生成され、新旧の Widget オブジェクト(およびそのサブツリー)の内容によって Element が使いまわされたり再生成されたりする、という基本的な処理内容は変わらないためです。

むしろ、 State に一度生成した Widget オブジェクトをキャッシュできる場合などは StatefulWidget を使った方がパフォーマンスを改善できることなどが、 API ドキュメントの "Performance considerations" の欄にも記載されています。

Consider refactoring the stateless widget into a stateful widget so that it can use some of the techniques described at StatefulWidget, such as caching common parts of subtrees and using GlobalKeys when changing the tree structure.

API ドキュメントには StatelessWidget, StatefulWidget それぞれを使う場合のパフォーマンス改善方法が箇条書きされていますので、どちらのページも読んでおくとより品質の高いアプリを作るのに役立つでしょう。

このドキュメントに記載された内容をさらに詳しく日本語で解説した記事として、 以下の記事もとても勉強になりますので、併せて読んでみると良いと思います。

https://medium.com/flutter-jp/state-performance-7a5f67d62edd

Widget オブジェクトは常に immutable

最後に注意したいのは、 Widget オブジェクト自体は StatelessWidget であろうが StatefulWidget であろうがどちらも必ず不変(immutable) であることです。これは親クラスである Widget クラスのドキュメントにも記載されています。

A widget is an immutable description of part of a user interface.
... Widgets themselves have no mutable state (all their fields must be final)

Widget オブジェクトは一度生成されたらフィールドに保持する内容を変えることはできません。必ずリビルド[3]によってオブジェクトが再生成され、その生成処理(つまり build() メソッド)の中で State なり InheritedWidget なりが保持する値を使って生成する UI を決定します。

さらに言うと、 StatefulWidget 自体は build() メソッドを持たず、代わりに createState() で生成した State オブジェクトが子孫となる Widget を生成します。その State オブジェクトは可変(mutable) で任意のデータを保持できるため、ユーザー操作などに応じてデータを変化させつつ State.setState で能動的にリビルドを発生させられるのが StatefulWidget である、というのがより厳密な説明になるかと思います。

まとめ

ここまで説明してきた通り、 StatelessWidget は必ずしも「静的」な UI を構築するための Widget ではありません。 InheritedWidget や、そのラッパーである Provider パッケージを使うことで、 StatelessWidget でも動きのある UI を実現することは十分可能です。

StatefulWidget の利点は「動的」なことではなく、 InheritedWidget などの他の Widget に依存することなく、自身と1対1でペアになっている State にデータを保持しておくことができる、という点です。その利点を活用してパフォーマンスを改善する工夫もドキュメントでは説明されています。

ある程度アプリ開発に慣れてきたら、「静的だから Stateless、動的だから Stateful」という単純な使い分けではなく、 State オブジェクトを必要とするかどうか、 State オブジェクトを利用することでよりパフォーマンスの向上などが見込めるか、などを考慮した上で適切な方を選択できるようになると、より品質の高いアプリが開発できるようになるのではないかと思います。

脚注
  1. 詳しくは以前書いた記事 「【Flutter】Navigator.of(context) から理解する 3つのツリー」 を参照してください。 ↩︎

  2. 厳密には、 StatefulWidget が生成する StatefulElement は State のライフサイクルを管理するための処理など StatelessElement にはない処理を持っているため多少は StatefulWidget の方がステップ数が多くなりますが、多くの場合はそれによるパフォーマンスの差は無視できる程度しかありません。 ↩︎

  3. 例えば StatefulWidget は State.setState によってリビルドを発生させることができます。詳しくは以前書いた記事「【Flutter】 setState() とは何か」を参照してください。 ↩︎

Discussion