Widget は画面ではない
Widget は画面そのものではなく、状態から UI を導く不変の設計図。build() が何度も呼ばれてよい理由、BuildContext が表す「ツリー上の位置」、そして小さな Widget へ分ける基準を整理する。
前回、runApp() にルート Widget を載せたところまで追いました。ここから先、画面上のすべては
Widget です。ところが Flutter を読み始めて最初につまずくのが、この「Widget」という言葉の
とらえ方です。
Widget は、画面に表示されている四角い部品そのもの——ではありません。Widget は
「いまの状態なら UI はこう見えるはず」を記述した、不変の設計図です。この一点がずれていると、
build() や BuildContext の挙動がずっと不思議なものに見え続けます。今回はそこをほどきます。
§ 01IMMUTABLEWidget は「設計図」であって実体ではない
Widget はイミュータブル(不変)なオブジェクトです。一度作ったら中身は変わりません。色を変えたい・ 文字を差し替えたいときは、既存の Widget を書き換えるのではなく、新しい Widget を作り直します。
これは無駄に見えるかもしれませんが、Widget はとても軽く、使い捨て前提で設計されています。 Flutter は状態が変わるたびに Widget を大量に作り直し、古いものを捨てます。
建築にたとえると、Widget は「設計図」で、実際に画面に描かれているピクセルは「建物」です。 設計図を何枚描き直しても建物を建て直すわけではない——この対応が後で効いてきます。
Widget が不変だからこそ、多くの Widget は const で作れます。
class PriceTag extends StatelessWidget {const PriceTag({super.key, required this.label, required this.amount});final String label;final int amount;@overrideWidget build(BuildContext context) {final theme = Theme.of(context);return Row(children: [Text(label, style: theme.textTheme.bodyMedium),const SizedBox(width: 8),Text('¥$amount', style: theme.textTheme.titleMedium),],);}}
const PriceTag(...) と書ける Widget は、同じ引数なら同一インスタンスとして扱われます1Dart の const は同じ内容の値を正規化(canonicalize)するため、同一の const 式は identical() で真になる。Flutter はこれを使い、再構築時に「同じ Widget」と判定して差分計算を打ち切れる。。
親が作り直されても、const な子は「変わっていない」と即座に判定され、再構築がスキップされます。
const は飾りではなく、無駄な再構築を減らすための実利です。
§ 02THREE-TREESWidget・Element・RenderObject
「設計図と建物」の対応を、もう少しだけ具体化します。Flutter は内部で 3 つのツリーを持ちます。
- Widget ツリー … あなたが書く設計図。不変・使い捨て
- Element ツリー … 設計図をツリー上の位置に結びつけた「生きた実体」。状態や位置を保持する
- RenderObject ツリー … 実際のレイアウト計算と描画を担う、重いオブジェクト
普段さわるのは Widget だけです。なお、この 3 層はきれいな 1 対 1 ではありません。Widget と
Element はおおむね対応しますが、StatelessWidget などは自前の RenderObject を持たず、
RenderObject ツリーはより疎で、形も一致しません。
状態が変わって Widget を作り直したとき、Flutter は新旧の Widget を runtimeType と key で
「同じ位置の Element を更新できるか」判定し、更新できるなら Element と RenderObject を
使い回します。設計図を描き直しても建物を建て直さない、というのはこの仕組みのことです。だから
build() を何度呼んでも、毎回ゼロから画面を作り直すような重さにはなりません2この 3 ツリーの対応は Flutter DevTools の Widget Inspector で実際に確認できる。Widget を選ぶと対応する Element / RenderObject をたどれる。。
Element / RenderObject を直接いじる場面はほとんどありません。今は「Widget の裏にこの 2 層がいて、 再構築を安くしている」とだけ押さえれば十分です。
§ 03BUILDbuild() は何度でも呼ばれる
build(BuildContext context) は、いまの状態に対応する UI の設計図(Widget のサブツリー)を返す
関数です。ここで重要なのは、build() は1 秒に何度も呼ばれうるということです。アニメーション、
親の再構築、状態更新——きっかけはいくらでもあります。
だから build() は、軽く・副作用なしでなければなりません。
- ネットワーク通信や DB 書き込みをしない
- 状態を書き換えない(
buildの中で値を変更しない) - 毎回やると困る処理を置かない
build() は「同じ入力なら同じ Widget を返す」純粋な関数として書きます。データの取得や状態の更新は
別の場所(次回以降に出てくる State や Provider)に置き、build() はその結果を描くだけにします。
これを守ると、いつ何度呼ばれても破綻しません。
§ 04CONTEXTBuildContext は「ツリー上の位置」
build() が受け取る BuildContext は、その Widget が Element ツリーのどこにいるかを指す
ハンドルです。「文脈」と訳されますが、実体は「位置情報」と考えると腑に落ちます。
この位置情報が何に使われるかというと、祖先をたどって値を見つけるためです。
final theme = Theme.of(context);final l10n = AppLocalizations.of(context);final media = MediaQuery.of(context);
Theme.of(context) は、context の位置からツリーを上へさかのぼり、最も近い祖先の Theme を
探して返します3この「祖先をたどって値を得る」仕組みの正体は InheritedWidget。.of(context) は毎回ツリーを歩くのではなく、依存関係を登録して該当祖先が変わったときだけ再構築されるため効率的。。MediaQuery、Navigator、ローカライズなども同じ仕組みです。だから
「どの位置の context か」が結果を変えます。
初学者がよくハマるのがここです。目的の祖先(Theme や Navigator、あるいは状態を配っている
スコープ)より上にある context を渡すと、探しても見つからずエラーになります。「context が
違う」というのは、この位置ずれのことです。
もうひとつ、非同期処理をまたいだ context も要注意ですが、これは mounted の話とセットなので
次々回に扱います。今は「context = ツリー上の自分の位置。祖先を探すのに使う」を持ち帰ってください。
§ 05COMPOSE小さな Widget に分ける基準
build() が長くなってきたら、サブツリーを別の Widget に切り出します。判断基準は 3 つです。
- 可読性 … ネストが深く読みにくい塊を名前付きの部品にする
- 再利用 … 2 か所以上で使うなら部品化する
- 再構築の範囲 … 頻繁に更新される部分を別 Widget にすると、再構築をそこだけに閉じ込められる
ここで、_buildHeader() のようなメソッドに切り出すのと、_Header というWidget クラスに
切り出すのは意味が違います。メソッドは呼び出し元の build() と一緒に毎回走りますが、Widget
クラスに切り出せば const にできる余地が生まれ、独立した再構築の境界を持てます。再利用・状態・
再構築の観点では、メソッドよりも Widget クラスへの分割が効きます。
§ 06KEYKey は「これは同じものだ」と伝える
最後に Key です。普段は指定しなくても動きますが、リストの要素が並び替わる・挿入されるときに
必要になります。
Flutter は再構築のとき、新旧の Widget を位置と型で対応づけます。ふつうはこれで十分ですが、 状態を持つ Widget が並ぶリストで順序が変わると、位置ベースの対応づけが災いして、状態が別の 要素にくっついてしまうことがあります(チェック状態やアニメーションが隣の項目にずれる、など)。
そこで Key を使い、「位置ではなく、この識別子で対応づけてほしい」と伝えます。
ListView(children: [for (final item in items)TodoRow(key: ValueKey(item.id), item: item),],)
覚え方はシンプルで、並び替わる・状態を持つ要素には Key を付ける。多くの静的な UI では
不要ですが、状態の保持のほかにも、並べ替え・挿入削除時の identity、アニメーション、テストでの
要素特定などで使います4例外は GlobalKey。ツリー全体で一意な identity を与え、Widget の状態を別の場所へ移すことすらできるが、コストが高く濫用は避ける。ValueKey / ObjectKey で足りることがほとんど。。
§ 07SUMMARY読むときの地図
- Widget は不変の設計図。安く作り直せるよう
constを活かす - 裏で Element / RenderObject が実体を持ち、再構築を安くしている
build()は何度でも呼ばれる純粋関数。重い処理・副作用を置かないBuildContextはツリー上の位置。祖先(Theme/Navigator/ ローカライズ)を探すのに使う- 部品はメソッドではなく Widget クラスに分けると
constと再構築境界が得られる Keyは動いて状態を持つ要素の同一性を保つために付ける
Widget ツリーを「設計図の階層」として読めるようになったら、次の問いは「では、変化する状態は
誰が持つのか」です。次回は StatelessWidget / StatefulWidget と setState() を扱い、
状態の置き場所を決められるようにします。
- [1] Dart の
constは同じ内容の値を正規化(canonicalize)するため、同一のconst式はidentical()で真になる。Flutter はこれを使い、再構築時に「同じ Widget」と判定して差分計算を打ち切れる。 ↩ - [2] この 3 ツリーの対応は Flutter DevTools の Widget Inspector で実際に確認できる。Widget を選ぶと対応する Element / RenderObject をたどれる。 ↩
- [3] この「祖先をたどって値を得る」仕組みの正体は
InheritedWidget。.of(context)は毎回ツリーを歩くのではなく、依存関係を登録して該当祖先が変わったときだけ再構築されるため効率的。 ↩ - [4] 例外は
GlobalKey。ツリー全体で一意な identity を与え、Widget の状態を別の場所へ移すことすらできるが、コストが高く濫用は避ける。ValueKey/ObjectKeyで足りることがほとんど。 ↩