🙆

【Flutter】BuildContextとKeyの役割を理解して内部構造を意識する

に公開

はじめに

Flutterのウィジェットツリーを深く理解する上で、この2つの概念は欠かせません。Flutterがどのウィジェットを再利用し、どの状態を維持しているのかという、再構築の舞台裏で活躍するKeyとBuildContextについてご紹介いたします。

BuildContextとは?

要点

  • ウィジェットツリーにおける「ウィジェットの住所」
    • そのウィジェットの親子関係を把握しているオブジェクト
  • 正確にはウィジェットツリー上のElementへの参照そのもの
    • ElementBuildContextインターフェースを実装している
    • buildメソッドに渡されるBuildContextは、そのウィジェットに対応するElementオブジェクトそのもの
  • 各ウィジェットは独自のBuildContextを持つ
  • BuildContext を使って先祖ウィジェットに関する機能を利用

ウィジェットツリーにおける「ウィジェットの住所」

BuildContextを最も平易に言い表すと、それはウィジェットツリーにおけるウィジェットの「住所」です。

Flutterの画面は、MaterialAppを頂点とする巨大なウィジェットツリー(部品の階層構造)で構成されています。この広大なツリーの中で、あるボタンやテキストが「自分は今どこにいて、親にはどんな設定がされているのか?」を知るために使うのが BuildContext です。

ウィジェットが持つ BuildContext は、そのウィジェット自身の情報だけでなく、ツリー構造における親子関係の脈絡(文脈)をすべて把握しているオブジェクトです。

正確にはウィジェットツリー上のElementへの参照そのもの

より正確に言うと、BuildContextは、単なる住所を示す文字列ではありません。

Flutterの裏側では、ツリー上のすべてのWidgetに対応するElementというものが存在します。BuildContextというのは、このElementへの参照そのものです。

各ウィジェットは独自のBuildContextを持つ

ウィジェットの build メソッドで BuildContext context という引数を受け取るのは、そのウィジェットに対応する Element オブジェクトを直接受け取っていることになります。

ウィジェット一つひとつは、独自のBuildContextを持っていることになります。

BuildContext を使って先祖ウィジェットに関する機能を利用

BuildContextは、ウィジェットが「親から何かをもらう」、あるいは「親(または先祖)が持つ機能を利用する」ための窓口です。

例えば、アプリの色やフォントの設定(テーマ)を使いたい場合、画面遷移をしたい場合、状態管理のデータにアクセスしたい場合、すべての操作は context を通じて行われます。

BuildContextは、ウィジェットツリーという複雑な環境において、ウィジェットが「文脈」を理解し、その環境に合わせた機能を利用するために不可欠な概念です。

次のセクションでは、この BuildContext が具体的なコードの中でどのように使われているかを詳しく見ていきましょう。

参考

https://api.flutter.dev/flutter/widgets/BuildContext-class.html

BuildContextの使い方(コード例)

要点

  • ウィジェットのbuildメソッドの引数にcontext が渡される
  • 祖先ウィジェットを参照する操作でよく利用する
    • Theme.of(context)Navigator.of(context)のようなメソッドは、context(つまり、Element)を使ってツリーを上方向に探索し、特定の型のInheritedWidget(例:ThemeNavigator)を見つけ出す
  • よくある例1:アプリのテーマ情報
    • Theme.of(context)
  • よくある例2:画面遷移
    • Navigator.of(context)

buildメソッドの引数にcontext が渡される

build メソッドには、必ず引数として BuildContext context が渡されます。


Widget build(BuildContext context) {
  // ... ここで context を使って様々な操作を行う
}

祖先ウィジェットを参照する操作でよく利用する

context を受け取ることで、ウィジェットは次の2つの重要な情報を得られます。

  1. ツリー内の自分の位置: context は、ウィジェットがツリーのどの階層にいるかという位置情報を提供します。
  2. 祖先からのデータ: この位置情報を使って、ツリーの上方向(親や祖先)に設定されている、共有のデータや機能にアクセスできるようになります。

最も一般的な使い方は、「ツリーを上方向へ探索し、特定の機能やデータを見つけ出す」ことです。この探索は、ほとんどの場合、Xxx.of(context) という形式で行われます。

よくある例1:アプリのテーマ情報

すべてのアプリには、色やフォントといったデザイン設定を定義するテーマがあります。

  • Theme.of(context) を呼び出すと、Flutterはあなたのウィジェットの住所(context)からツリーを上方向にたどり、最も近くにある Theme ウィジェットを見つけ出します。

よくある例2:画面遷移

ボタンタップなどで別の画面に移動したいときにも context は欠かせません。

  • Navigator.of(context) を呼び出すと、あなたのウィジェットの context からツリーを上方向にたどり、画面の管理機能を提供する Navigator(ナビゲーター)ウィジェットを見つけ出します。

ここまでBuildContextについて確認してきましたが、ツリーの変更によってウィジェットの順序が入れ替わった際に、どのElementと紐付けるか、BuildContextだけでは追跡できない動的な変化に対応するのが、次に紹介するKeyの役割です。

Keyとは?

要点

  • Keyはウィジェットの「身分証明書」のようにユニークに特定する値
  • ツリー再構築時に、古いウィジェットと新しいウィジェットが同じものかどうかを判断するために利用される
  • Keyの種類と使い分け
    • ValueKey: シンプルな文字列、整数、または値。最も一般的。
    • ObjectKey: オブジェクト自体をKeyとして使用。参照が異なる場合は別のKeyと見なされる。
    • UniqueKey: 再描画のたびに一意なKeyを生成。意図的に特定のウィジェットの状態をリセットしたい場合に便利。
    • GlobalKey: ウィジェットツリー全体で一意なKey。ウィジェットツリーの離れた場所にあるウィジェットにアクセスする際に利用。

Keyはウィジェットの「身分証明書」のようにユニークに特定する値

Flutterで動的なリストやフォームを扱うとき、BuildContext(住所)だけでは解決できない問題があります。それは、「住所が変わっても、このウィジェットは本当に同じものなのか?」という問いです。

このウィジェットのアイデンティティ(身分)を保証するのが、Key(キー)の役割です。

ツリー再構築時に、古いウィジェットと新しいウィジェットが同じものかどうかを判断するために利用される

Keyは、Flutterが画面を更新(再構築)する際に、古いウィジェットと新しいウィジェットが同一のものかどうかを判断するために使う、ユニークな(一意な)識別値です。

Flutterは、画面が更新されるたびに新しいツリーが生成されます。Keyがない場合、Flutterは「ツリー上の位置」と「ウィジェットの種類」でしか判断できません。しかし、Keyがあれば、FlutterはウィジェットをそのKeyで同一性を判断できます。

この仕組みのおかげで、Flutterは、無駄にウィジェットをすべて作り直すのではなく、Keyを頼りに既存のウィジェットのインスタンス(Element)を賢く再利用し、そのウィジェットに紐づいた状態(State)を維持できるのです。

Keyの理解が不可欠な最大の理由は、動的なリストを扱う際にバグを防ぐためです。

Keyの種類とその使い分け

Keyには、用途に応じて大きく分けて4つの種類があり、それぞれ異なる目的で使われます。

Keyの種類 役割(身分証明書のイメージ) 主な使い道
ValueKey 「データID」:データが持つ固有の値に基づいたID。 リストアイテムの追跡(例:ToDoタスクのID)。最も一般的で利用頻度が高い。
ObjectKey 「参照ID」:メモリ上でオブジェクト自体が同じかどうかを判断するためのID。 非常に厳密なインスタンスの同一性チェックが必要な、稀なケース。
UniqueKey 「使い捨てID」:毎回異なる値を生成する、一回限りのID。 ウィジェットの古い状態を強制的に破棄し、完全に新しい状態から再構築したい場合。
GlobalKey 「グローバルID」:アプリケーション全体でどこでも通用する特別なID フォームの状態管理や、ツリーの離れた場所にあるウィジェットへのアクセス。

参考

https://api.flutter.dev/flutter/foundation/Key-class.html

なぜKeyが必要なのか?(動的リストの例)

要点

  • 新しいウィジェットがどのElementと紐づくか特定するために使われる
    • WidgetTreeは不変であり構造が変わると再生成されるため、状態の紐付けにkeyが利用される
    • WidgetStateの間にはElementが仲介
  • ListViewのような動的なリストでKeyを使わないと、意図しない再構築が行われる可能性がある
  • keyがない場合:ウィジェットの種類と位置で同一性を判断し、紐づくElementを決定
  • keyがある場合:Keyも加えて同一性を判断

新しいウィジェットがどのElementと紐づくか特定するために使われる

Flutterのアプリは、不変(immutable)であるウィジェットの設計図(Widgetツリー)を元に、画面を構成しています。画面上のボタンを押したり、リストのアイテムが動いたりすると、新しいツリーが再構築されます。

WidgetStateの間にはElementが仲介

ここで重要なのが、状態(State)です。チェックボックスのオン/オフや、入力フォームの現在のテキストといった「状態」は、ウィジェット自体ではなく、その裏側にあるElementに紐づいています。この Element が、ウィジェットと状態を仲介する役目を担っています。

ListViewのような動的なリストでKeyを使わないと、意図しない再構築が行われる可能性がある

ToDoリストのような動的なリストでアイテムを削除・追加したとき、KeyがないとFlutterは混乱してしまいます。

keyがない場合:ウィジェットの種類と位置で同一性を判断し、紐づくElementを決定

Keyがない場合、Flutterは新しいウィジェットを既存の Element に紐づける際、以下の情報だけで判断します。

  1. ウィジェットの種類(runtimeType
  2. ツリー上の位置(インデックス)

リストの先頭のアイテムを削除すると、残りのアイテムはツリー上で位置が一つずつずれます。Flutterは「種類」と「位置」が同じであれば Element を再利用しようとするため、「位置が変わった」という事実を無視し、既存の Element を再利用してしまいます。

その結果、Item A の Element に紐づいていたチェックの状態が、位置がずれて Item B となったウィジェットに誤って引き継がれてしまうのです。これは、まるで引っ越しで荷物を整理するときに、「場所」だけを見て中身を確認しなかったために、前の住人の家具がそのまま残ってしまったような状態です。

keyがある場合:Keyも加えて同一性を判断

ここで Key が登場します。Keyは、新しいウィジェットが生成されたとき、「このウィジェットのIDは〇〇だ」と主張します。

Keyがある場合、Flutterは以下の情報で紐づけを判断します。

  1. ウィジェットの種類(runtimeType
  2. Key(一意の識別子)

リストの順序が変わっても、FlutterはKeyを見て、「このIDのウィジェットは以前の Element のものと一致する」と判断し、Item B の Element を追跡して Item B の新しい位置へ移動させます。削除された Item A の Element は破棄されます。これにより、状態が正しく Item B に紐づいたまま維持されます。

BuildContextとKeyの関係性

要点

  • KeyとBuildContextは直接的に協力し合うというより、ウィジェットツリーの更新プロセスという大きな流れの中で、それぞれの役割を果たしている
    • Keyは、ツリー再構築時に新しいWidget Treeと既存のElmemt Ereeの中からペアを探すための値
    • BuildContextは、ペアになったWidgetとElementにツリーの構造の情報を与え、継続してstateの値を利用できるようにする
  • Flutterは、ツリーを更新する際(setStateが呼ばれるなど)
    • Widget Tree を作り直す
    • 既存のElement Treeが持つKeyと新しいWidget Treeが持つKeyでペアを探す(runtimeType(ウィジェットの種類)も一致)
    • ペアになったWidgetとElementは引き続きそのElementに紐づくBuildContextを利用する
    • Keyが一致するペアが見つからない場合、古いElementは破棄され新しいElementが生成される
      • BuildContextも新しいものが与えられる

KeyとBuildContextは直接的に協力し合うというより、ウィジェットツリーの更新プロセスという大きな流れの中で、それぞれの役割を果たしている

結論から言うと、KeyとBuildContextは直接的に協力し合うわけではありませんが、役割を分担することで、アプリのパフォーマンスと安定性を保証しています。

Keyは、ツリー再構築時に新しいWidget Treeと既存のElmemt Treeの中からペアを探すための値

画面が更新されるとき、Flutterはまず新しいウィジェットの設計図(Widget Tree)を作ります。この新しい設計図と、現在画面に表示されている古いウィジェットのインスタンス(Element Tree)を比較するのが、Keyの仕事です。

  • Keyの仕事:Keyは、新しいウィジェットが持つIDと、古いインスタンスが持つIDを照合し、「どの古いインスタンスを再利用すべきか?」を決定します。ウィジェットの種類(runtimeType)も一致すれば、ペアリングが成立します。

Keyは、このペアリングの決定という、一歩目の最も重要な仕事を担っています。

BuildContextは、ペアになったWidgetとElementにツリーの構造の情報を与え、継続してstateの値を利用できるようにする

Keyによるペアリングが完了した後、BuildContextの役割が明確になります。BuildContextは、ペアリングを行う主体ではなく、ペアリングの結果によって運命が決まるオブジェクトです。

  • BuildContextの仕事:再利用されることが決まった古いインスタンス(Element)に紐づいている BuildContext は、引き続き有効になります。この有効な BuildContext が、ウィジェットの新しい位置情報(住所)を提供し続け、そのウィジェットの状態(State)や、親からのテーマ情報を失わずに利用できることを保証します。

ツリー更新のフローチャート

このプロセスを、KeyとBuildContextの視点からまとめると、以下の流れになります。

  1. 新しい設計図の作成setState()により、新しいWidget Treeが生成されます。
  2. Keyによるペア探し:Flutterは、新しいウィジェットのKeyと既存のインスタンスのKeyを照合し、ペアを探します。
  3. ペアが成立した場合:既存のインスタンスが再利用され、それに紐づいた BuildContext が引き続き使われます。これにより、そのウィジェットの状態が維持されます。
  4. ペアが不成立の場合:古いインスタンスは破棄され、新しいウィジェットのために新しいインスタンスが生成されます。この新しいインスタンスには、まったく新しい BuildContext が与えられます。

KeyとBuildContextが役割を分担しながら、動作しています。

参考

Keyの活用例:ValueKey

要点

  • ValueKey: 単純な値(String, intなど)を使用
  • 同じ値を持つValueKeyは同一と見なされる
  • ユースケース例:Todoリストのような操作のあるリスト

ここでは、Keyの中で最も利用頻度が高く、Flutter開発者にとって必須知識である ValueKey に焦点を当てて解説します。

ValueKey: 単純な値(String, intなど)を使用

ValueKeyは、その名の通り、ウィジェットに特定の「値」を割り当てて識別するKeyです。この「値」には、単純な文字列(String)や整数(int)など、ウィジェットが表すデータに紐づく一意なIDが使われます。

同じ値を持つValueKeyは同一と見なされる

ValueKey は、設定した値に基づいてウィジェットのペアリングを行います。たとえば、ValueKey(42) と設定されたウィジェットは、ツリー内のどこに移動しても「ID 42 のウィジェット」として扱われます。

同じ値を持つ ValueKey は、Flutterによって同一のウィジェットとして認識されます。

ユースケース例:Todoリストのような操作のあるリスト

ValueKeyの重要性が最も明確になるのは、ToDoリストのような、アイテムが追加・削除・並び替えされる動的なリストを扱う場合です。

Keyがない場合、リストは「場所」でアイテムを判断します。
リストの先頭のタスクを削除すると、下のタスクが一つずつ上にズレます。もし Item B のタスクにチェックが入っていたとしても、Flutterは「位置情報」を見て判断するため、チェックの状態が Item C のタスクに誤って引き継がれてしまうというバグ(状態の不整合)が発生します。

記述例

親ウィジェットが子ウィジェット(MyWidget)を構築する際、Keyプロパティに具体的な値(ここではValueKey(999))を明示的に渡しています。

// 親側でのウィジェット配置
MyWidget(ValueKey(999));

子ウィジェット(MyWidget)のコンストラクタは、親から渡されたKeyを受け取ります。super.key は、受け取ったkeyプロパティの値を、親クラスであるStatelessWidgetまたはStatefulWidgetのコンストラクタ(super)へ引き渡すためのFlutter/Dartの慣例的な書き方です。

Keyを受け取るウィジェット自体(MyWidget)はKeyを使って何かをするわけではありませんが、このKeyを親クラスへ渡すことで、Flutterのフレームワーク(Element Tree)がそのKeyにアクセスし、再構築プロセス(ペアリング)に使用できるようになります。

required を付けることで、親がこの key を渡し忘れることを防げます。動的なリストを扱うウィジェットでは、Keyの利用が必須となるため、このように明示することが推奨されます。

// 子側のコンストラクタ
MyWidget({required super.key});

https://api.flutter.dev/flutter/foundation/ValueKey-class.html

ValueKeyの利用例

このコードは、動的に変化するリスト(ここではアイテムの削除)において、ValueKeyがいかにStatefulWidgetの状態を維持するために不可欠であるかを示した例です。

ValueKeyがない場合の挙動 (バグの発生)

ListItemWidgetでは key: ValueKey(id), の行がコメントアウトされており、Keyが渡されていません。

  1. Item ID: 3 のチェックボックスをオンにします。
  2. 「リストの先頭を削除」ボタンを押して、リストの先頭にあった Item ID: 1 を削除します。

このとき、Item ID: 2 のチェックボックスがオンに変わってしまいます。これは深刻なバグです。

これはチェックボックスの状態を「ツリー上の位置」で判断しているため、削除された先頭の要素のチェック状態が次の要素に誤って引き継がれてしまっています。

親の記述の抜粋

child: ListView(
  children: itemIds.map((id) {
    return ListItemWidget(
      // key: ValueKey(id),
      id: id,
    );
  }).toList(),

子の記述の抜粋

class ListItemWidget extends StatefulWidget {
  final int id;
  // keyはrequiredを外して省略可能にしている
  const ListItemWidget({super.key, required this.id});

ValueKeyがある場合の挙動 (状態の維持)

親がウィジェットを配置する際にkey: ValueKey(id), を有効にします。

  1. Item ID: 3 のチェックボックスをオンにします。
  2. 「リストの先頭を削除」ボタンを押して、リストの先頭にあった Item ID: 1 を削除します。

今度は、Item ID: 3 のチェック状態は、削除後も正しく維持されます。

ValueKeyを使うことで、Flutterはウィジェットを「種類」と「Key(識別子)」で識別します。ウィジェットの削除によりツリー内の位置は変わりましたが、Keyが一致するため、既存の Element をそのまま再利用します。その結果チェックが維持されます。

親の記述の抜粋

child: ListView(
  children: itemIds.map((id) {
    // --------------------------------------------------
    // 【重要】ここで ValueKey を使います
    // --------------------------------------------------
    return ListItemWidget(
      // ValueKeyを使うことで、FlutterはIDに基づいてウィジェットを追跡します
      key: ValueKey(id),
      id: id,
    );
  }).toList(),

子の記述の抜粋

class ListItemWidget extends StatefulWidget {
  final int id;
  // Keyを必須の引数として受け取ることを明示
  const ListItemWidget({required super.key, required this.id});

コードの全体像(DartPadにコピペで確認可能)

import 'package:flutter/material.dart';

void main() {
  runApp(const KeyExampleApp());
}

class KeyExampleApp extends StatelessWidget {
  const KeyExampleApp({super.key});

  
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: Scaffold(
        body: Center(
          child: SimpleListWidget(),
        ),
      ),
    );
  }
}

// --------------------------------------------------
// 状態を持つリストウィジェット
// --------------------------------------------------
class SimpleListWidget extends StatefulWidget {
  const SimpleListWidget({super.key});

  
  State<SimpleListWidget> createState() => _SimpleListWidgetState();
}

class _SimpleListWidgetState extends State<SimpleListWidget> {
  // リストのデータ(一意のIDとして整数を使用)
  final List<int> itemIds = [1, 2, 3, 4];

  void _removeItem() {
    setState(() {
      // リストの先頭からアイテムを削除
      if (itemIds.isNotEmpty) {
        itemIds.removeAt(0);
      }
    });
  }

  
  Widget build(BuildContext context) {
    return Column(
      children: [
        Padding(
          padding: const EdgeInsets.all(16.0),
          child: ElevatedButton(
            onPressed: _removeItem,
            child: const Text('リストの先頭を削除'),
          ),
        ),
        // リストアイテムの表示
        Expanded(
          child: ListView(
            children: itemIds.map((id) {
              // --------------------------------------------------
              // 【重要】ここで ValueKey を使います
              // --------------------------------------------------
              return ListItemWidget(
                // ValueKeyを使うことで、FlutterはIDに基づいてウィジェットを追跡します
//                 key: ValueKey(id),
                id: id,
              );
            }).toList(),
          ),
        ),
      ],
    );
  }
}

// --------------------------------------------------
// 状態を持つリストアイテムウィジェット
// --------------------------------------------------
class ListItemWidget extends StatefulWidget {
  final int id;
  // Keyを必須の引数として受け取ることを明示
//   const ListItemWidget({required super.key, required this.id});
  const ListItemWidget({super.key, required this.id});

  
  State<ListItemWidget> createState() => _ListItemWidgetState();
}

class _ListItemWidgetState extends State<ListItemWidget> {
  // ウィジェットの状態(チェックボックス)
  bool _isChecked = false;

  
  Widget build(BuildContext context) {
    return ListTile(
      tileColor: Colors.grey[100],
      title: Text('Item ID: ${widget.id}'),
      trailing: Checkbox(
        value: _isChecked,
        onChanged: (bool? newValue) {
          setState(() {
            _isChecked = newValue!;
          });
        },
      ),
    );
  }
}

おわりに

本記事を通じて、Flutterの画面更新の舞台裏で行われる、KeyBuildContextの鮮やかな連携をご紹介してきました。

  • BuildContextは、ウィジェットの「住所」として、常にそのウィジェットがツリー内のどこにいるか、何にアクセスできるかという文脈を保証します。
  • Keyは、ウィジェットの「身分証明書」として、ツリーが変化する際に、どのインスタンス(Element)が再利用されるべきかを判断する鍵となります。

特に、ValueKeyを使った動的なリストの検証では、Keyがない場合に状態(State)が誤って引き継がれてしまうバグを、Keyを使うことで簡単に解決できる点は重要な点です。

Discussion