🪑

BuildContext について書けるだけ書く

2024/09/20に公開1

Flutter をやっていて、 BuildContext というクラス名を聞いたことがないという方はいないのではないかと思います。build() メソッドの引数で渡されてくるあれです。


Widget build(BuildContext context) { // <- これ
}

使いどころとしては、画面遷移をするための Navigator.of(context) であったりダイアログを表示するための showDialog(context: context) であったりすると思いますが、この記事ではこの BuildContext が何者なのか、書けるだけ書いていきたいと思います。

なお、まとまりがなく勢いで書き出せるだけ書いた記事ですので、暇な時にのんびり斜め読みで読むのがおすすめです。

BuildContext は Element

BuildContext の説明をするためにはまず Element について説明しなければなりません。

Everything is a Widget というアイデアで API がデザインされている Flutter ですが、Widget はあくまでわれわれアプリ開発者向けのインターフェースであり、値を保持するのみの不変なクラスです。実際の内部的なビルド関連の処理は Element いう別クラスが担当しています。

Element は必ず Widget と 1 対 1 になるように Flutter フレームワークの中で生成され(といっても生成メソッドを持っているのは Widget ですが)、リビルドによって頻繁に破棄と再生成が繰り返される Widget と違って可能な限り再利用されます。

リビルド処理はこの再利用される Element が行うため、われわれが setState() 等でページ全体をリビルドしたとしても実際のリビルド、レイアウト再計算の処理はリビルド前後で変化のあった部分に限定され、リビルド範囲や頻度がパフォーマンスにそこまで影響しない作りになっています。

Element の役割はリビルド範囲の最適化だけではありません。自身の親を Element? _parent というフィールドで保持し、また自身の子供にアクセスするための visitChildren() というメソッドを持ちます。つまり、「Widget ツリー」という名前でイメージされる Widget のツリー構造を実際に形作るのが Element です。

Widget から 1 対 1 で生成されたひとつひとつの Element が、それぞれ自身の直近の親や子の参照を保持することで、それをたどれば Widget ツリー上の任意の Element、またそれに紐づく Widget にアクセスできる、というのが Widget ツリーのメカニズムです。

さて、ここで Element のクラス定義を見てみると、以下のようになっています。

abstract class Element extends DiagnosticableTree implements BuildContext {
}

BuildContextimplements していることがわかります。つまり、BuildContext はインターフェースであり、その実装が Element であるということです。言い換えれば、我々が触れる context の実体はこの Widget ツリーを形作る Element です。

BuildContext というインターフェース

先述の通り、Element の役割は多岐にわたります。Widget ツリーを形成するために親子の参照を保持するだけでなく、リビルドの最適化や Element に紐づく Widget や RenderObject の管理なども行います。

そのため、Element をアプリ開発者がそのまま触るには少々「重い」オブジェクトと言えます。Everything is a Widget というコンセプトも崩れてしまいます。

ただ一方で、Widget ツリーを辿って実現できる機能はアプリ開発者に提供する必要がある、そこで登場するのが BuildContext というインターフェースです。

BuildContextElement の仕事の中でも、「Widget ツリーを辿って何かする」ことに関するメソッドが主に定義されています。[1] dependOnInheritedWidgetOfExactTypefindAncestorStateOfType などはその代表的な例と言えるでしょう。

実体は Element であったとしても、build() メソッドの引数などでは BuildContext 型として引き渡しつつ、実体が Element であることを意識させないことにより、「よくわからないけど BuildContext を使えば Widget ツリーに基づく処理が呼び出せる」という仕組みを実現しているわけです。[2]

BuildContext がやっていること

では、ここまでを踏まえた上で、冒頭で例にあげた Navigator.of(context)showDialog(context: context) が何をやっているのかを見てみましょう。

Navigator.of(context) の実装は以下の通りです。assert 等の本筋に関係のない処理は省略します。

static NavigatorState of(
  BuildContext context, {
  bool rootNavigator = false,
}) {
  NavigatorState? navigator;
  if (context is StatefulElement && context.state is NavigatorState) {
    navigator = context.state as NavigatorState;
  }
  if (rootNavigator) {
    navigator = context.findRootAncestorStateOfType<NavigatorState>() ?? navigator;
  } else {
    navigator = navigator ?? context.findAncestorStateOfType<NavigatorState>();
  }
  return navigator!;
}

引数に渡された context の利用箇所に着目して確認していきましょう。

まず最初の if による条件分岐です。

先述の通り BuildContext の実体は Element です。Element は生成する Widget によってさらに細かく実装クラスが分かれています。StatefulElement というのは StatefulWidget が生成する Element のサブクラスです。また、StatefulElementstate というフィールドに State<T> クラス(われわれも StatefulWidget を使う時に書いているクラス)のオブジェクトを保持します。

つまり context に紐づく Widget が NavigatorState かどうかを検証しているのが最初の条件分岐と読み取れるでしょう。つまり NavigatorState 自身が Navigator.of(context) を呼び出そうとした場合にこの条件分岐に入るような作りになっています。(あまりわれわれアプリ開発者は関係のない分岐と言えそうです)

次は引数に渡された rootNavigatortrue だった場合です。

ここで呼ばれているのが findRootAncestorStateOfType<NavigatorState>() というメソッドです。このメソッドの実装も引用します。


T? findRootAncestorStateOfType<T extends State<StatefulWidget>>() {
  Element? ancestor = _parent;
  StatefulElement? statefulAncestor;
  while (ancestor != null) {
    if (ancestor is StatefulElement && ancestor.state is T) {
      statefulAncestor = ancestor;
    }
    ancestor = ancestor._parent;
  }
  return statefulAncestor?.state as T?;
}

ancestornull になるまで ancestor._parent を辿って(つまり Widget ツリーを上に遡って)祖先に存在する NavigatorState を探し続けます。途中で見つかったとしてもループは止めず、Widget ツリー上に複数の NavigatorState が存在する中で一番祖先に配置されたものを最終的に返却する、という実装になっています。findRoot のメソッド名の通りですね。

UI に下タブなどが用いられる場合、Navigator をネストさせることも珍しくはないかと思います。この rootNavigatortrue の場合はこの仕組みを使って一番祖先にある NavigatorState を取得してくる、というわけですね。[3]

さて、Navigator.of(context) の最後の条件分岐で呼び出されているのは findAncestorStateOfType<NavigatorState>() です。これも実装を引用します。


T? findAncestorStateOfType<T extends State<StatefulWidget>>() {
  Element? ancestor = _parent;
  while (ancestor != null) {
    if (ancestor is StatefulElement && ancestor.state is T) {
      break;
    }
    ancestor = ancestor._parent;
  }
  final StatefulElement? statefulAncestor = ancestor as StatefulElement?;
  return statefulAncestor?.state as T?;
}

先ほどの findRootAncestorStateOfType とは違い、こちらは指定した NavigatorState が見つかった時点でループを break し、それを返却して処理が終了します。つまり、直近の NavigatorState を取得してくるというわけですね。

先述のタブを利用した UI において、タブも含めた画面全体を遷移したい場合は rootNavigatortrue にすることで Widget ツリーの一番祖先にある NavigatorState を取得し、タブ内でのみ画面遷移したい場合はそのまま rootNavigator を指定せずデフォルトの false に指定することで直近の NavigatorState が利用できる、という使い分けの仕組みがここから見えてきたのではないかと思います。

なお、実装を見てわかる通り、findRootAncestorStateOfType()findAncestorStateOfType() は Widget ツリーを「ひとつずつ」遡って目的の State かどうかを確認しています。つまり、計算量が O(n) となり、Widget ツリーの深さが深くなるほどパフォーマンスが落ちていきます。そのため、ボタンタップなどのユーザー操作に合わせて 1 回だけ呼び出すような用途であれば何も問題がありませんが、高速に何度も呼び出され得る build() メソッドでは使わないようにするのが無難です。この仕組みで成り立つ標準の Widget としては Navigator.of()Scaffold.of() などがありますが、いずれも build() メソッド内での利用は想定されていないものばかりです。

showDialog(context: context)

では、showDialog(context: context) の方はどうでしょうか。

こちらも実装を引用してみましょう。この引用では引数等も説明に不要なものは割愛しています。

Future<T?> showDialog<T>({
  required BuildContext context,
  required WidgetBuilder builder,
  bool useRootNavigator = true,
}) {
  final CapturedThemes themes = InheritedTheme.capture(
    from: context,
    to: Navigator.of(
      context,
      rootNavigator: useRootNavigator,
    ).context,
  );

  return Navigator.of(context, rootNavigator: useRootNavigator).push<T>(DialogRoute<T>(
    context: context,
    builder: builder,
    barrierColor: barrierColor ?? Theme.of(context).dialogTheme.barrierColor ?? Colors.black54,
    themes: themes,
  ));
}

こちらは上から順番に、ではなく説明しやすいところから説明していきましょう。

まずは Navigator.of(context) です。見ての通り、Flutter ではダイアログの表示は画面遷移と同じ Navigator.of(context).push() で実現します。違うのはわれわれがよく使う MaterialPageRoute ではなく DialogRoute を引数に渡しているということです。

画面遷移ではこの Route のサブクラスが遷移のアニメーションを担当することになっていますが、OS 標準のアニメーションを再現する MaterialPageRoute とは違い、DialogRoute は「既存の画面に被せる形で出てくる」アニメーションを実現します。そのため、ダイアログの画面上に覆い被さる動きが実現される、というわけです。

さて、他の部分に目を向けると、InheritedTheme.capture() の引数としても利用されています。from は渡されたそのままの context を、to には Navigator.of(context) の結果返却された NavigatorState に紐づく context が渡されています。

InheritedTheme.capture()fromcontext から tocontext までに存在するすべての InheritedTheme[4] を収集し、List で返却するメソッドです。

これにより、ダイアログ表示前の画面で設定したテーマがダイアログ内でも適用されるようになっていて、「別画面」であることを感じさせない作りになっているというわけです。[5]

showDialog(context: context) の実装の中には、もうひとつ Theme.of(context) というコードも目に入ります。Theme.of(context) のコードは以下の通りです。

static ThemeData of(BuildContext context) {
  final _InheritedTheme? inheritedTheme = context.dependOnInheritedWidgetOfExactType<_InheritedTheme>();
  final MaterialLocalizations? localizations = Localizations.of<MaterialLocalizations>(context, MaterialLocalizations);
  final ScriptCategory category = localizations?.scriptCategory ?? ScriptCategory.englishLike;
  final InheritedCupertinoTheme? inheritedCupertinoTheme = context.dependOnInheritedWidgetOfExactType<InheritedCupertinoTheme>();
  final ThemeData theme = inheritedTheme?.theme.data ?? (
    inheritedCupertinoTheme != null ? CupertinoBasedMaterialThemeData(themeData: inheritedCupertinoTheme.theme.data).materialTheme : _kFallbackTheme
  );
  return ThemeData.localize(theme, theme.typography.geometryThemeFor(category));
}

こちらは Navigator.of(context) とは違って dependOnInheritedWidgetOfExactType<T>() というメソッドを呼び出すために context を利用しています。この仕組みを説明するためには、先に InheritedWidget についての説明が必要になりますので、いったん話を InheritedWidget に移しましょう。

InheritedWidget

Flutter アプリ開発における状態管理には、ひとつの Widget 内で完結する ephemeral state と、複数の Widget にまたがって共有される app state の区別があります。

ephemeral state を管理するための標準的な Widget が StatefulWidget で、app state を管理する標準の Widget が InheritedWidget です。

InheritedWidget は扱いにボイラープレートコードが必要になる都合であまりアプリ開発者が利用することは多くないかと思いますが、Flutter フレームワーク内部では積極的に利用されています。そのため、仕組みを理解することはとても重要です。

InheritedWidget が生成する InheritedElement は、Element の中でも特別扱いされていて、すべての Element は自身の祖先に存在する InheritedElement への直接の参照を _inheritedElements という Map 型のフィールドで保持しています。

PersistentHashMap<Type, InheritedElement>? _inheritedElements;

これにより、Element は O(1) の計算量で祖先に存在する InheritedWidget にアクセスできるようになっています。

60fps(端末によっては 120fps) という頻度で呼ばれ得る build() メソッドは、パフォーマンスを維持するために build() 内の処理を極限まで高速化する必要があります。という状況で、Element が持つ親を「ひとつずつ」辿って目的の Element や Widget を見つけ出すような処理をしていてはアプリの規模が大きくなるのに比例してパフォーマンスが悪くなってしまいます。

そのため、UI の生成に必要なデータ(状態)を持つ Widget はこの「直接の参照」を使うことでパフォーマンスを落とすことなく取得できるようになっているわけです。

そして、その直接の参照を使って目当ての InheritedWidget を取得するのが先述の dependOnInheritedWidgetOfExactType<T>() である、というわけです。

実装を引用しますが、非常にシンプルです。

T? dependOnInheritedWidgetOfExactType<T extends InheritedWidget>({Object? aspect}) {
  final InheritedElement? ancestor = _inheritedElements == null ? null : _inheritedElements![T];
  if (ancestor != null) {
    return dependOnInheritedElement(ancestor, aspect: aspect) as T;
  }
  _hadUnsatisfiedDependencies = true;
  return null;
}

_inheritedElementsType をキーとする Map 型ですので、_inheritedElements![T] という処理で目的の InheritedElement を取得しているのがわかります。O(1) ですね。

さて、目的の InheritedElement が見つかったらそれをそのまま返却するわけではありません。InheritedWidget にはもうひとつ役割があり、それが「dependOn でアクセスしてきた Element を覚えておき、自身に変更があればその Element をリビルドする」というものです。その処理をしているのが dependOnInheritedElement() です。

Flutter では状態に変化があれば即座に UI が更新されます。InheritedWidget はこのように「誰が自身のデータを使って UI を構築しているか」を覚えておくことで、自身の状態変更時にどの Element をリビルド対象にすれば良いかを把握しているわけですね。

.of() メソッド

ここまでに findAncestorStateOfType<T>()dependOnInheritedElement<T>() という、子孫から祖先にアクセスするためのメソッドについて説明してきましたが、みての通りこれらのメソッドは以下の理由で直接呼び出すのは少々面倒くさいです。

  • メソッド名が単純に長い
  • <T> の部分に何を指定すれば良いのか判断しづらい
  • 見つからなかった場合やその周辺の処理などが必要で、「呼び出して終わり」ではない

これらの問題を解決するために Flutter 標準の Widget でよく使われるパターンが .of(context) という static なメソッドです。

子孫からアクセスされる際に、Widget ごとに必要な処理を先祖の側で実装しておいて、実装の詳細を知らない子孫からはとりあえず .of(context) を呼び出してもらえばよい状態にしておくことで、われわれ利用側の負担を減らす工夫が .of(context) です。

もし子孫から参照されることを期待する InheritedWidgetStatefulWidget を自作する際は、一緒に .of(context) メソッドも用意すると使い勝手が良くなるでしょう。

NotificationListener

さて話は変わって、Widget ツリーを利用した仕組みは他にもあります。そのひとつが NotificationListenercontext.dispatchNotification() です。

context.dispatchNotification() メソッドは、引数に渡した Notification オブジェクト(実際は Notification を継承した任意のクラスのオブジェクト)を Widget ツリーの祖先へ通知するメソッドです。

context の祖先に NotificationListener が存在すれば、その onNotification 引数に渡したコールバック関数が呼ばれます。その関数の戻り値で false を返却すると、さらに祖先にある NotificationListeneronNotification 関数も呼び出され、以下ツリーの根元に至るまでそれが繰り返されます。

これは「誰が利用するかわからないけど、子孫から祖先に対してイベントを通知したい」という状況でよく使われる仕組みで、たとえば標準の Widget では ScrollView を継承した各種スクロール系の Widget がそのスクロール量を祖先に通知するために利用していたりします。

dispatchNotification についても実装を読んでみましょう。


void dispatchNotification(Notification notification) {
  _notificationTree?.dispatchNotification(notification);
}

ここでは _notificationTree という新しい種類のツリーが出てきました。

_NotificationNode? _notificationTree;

_NotificationNode というのは NotificationListener が生成する _NotificationElement のことです。つまり、NotificationListener を Widget ツリー上に配置することによって生成されるわけですが、 Element は直近の _NotificationNode への参照を保持しています。

さらに、_NotificationNode は自身の親となる _NotificationNode への参照を保持しており、BuildContext と同じようにちょっとしたツリー構造を形成しています。

class _NotificationNode {
  _NotificationNode(this.parent, this.current);

  _NotificationNode? parent;
}

dispatchNotification() は、この _NotificationNode を祖先の方向に辿りながら、onNotification の戻り値によるストップがかかるまでコールバック関数を実行していく動きになっているわけです。ドキュメントではこの動きを "bubbling" と表現しています。泡のように上に上っていくイメージですね。

このように、BuildContext の仕事は目当ての Widget や Element を見つけてくるだけでなく、別のツリーを利用して任意のデータを祖先に運搬する役割もあると言えるでしょう。

Riverpod と BuildContext

「Riverpod は BuildContext に依存しない。だから扱いやすい。」というのは Riverpod の紹介としてよく使われるフレーズです。確かに Riverpod の Provider たちは BuildContext を必要としません。しかし、それを Flutter アプリで参照するための WidgetRefBuildContext と同一のオブジェクトであるということはご存知でしょうか。

abstract class ConsumerState<T extends ConsumerStatefulWidget>
    extends State<T> {
  /// An object that allows widgets to interact with providers.
  late final WidgetRef ref = context as WidgetRef;
}

まず、ConsumerWidgetStatefulWidget のサブクラスです。StatefulWidget のサブクラスなので、ペアになる State オブジェクトがあるわけなのですが、それが上記の ConsumerState です。

そして、その ref フィールドを見ると context as WidgetRef と書かれており、たしかに contextref は型をキャストしただけの同一のオブジェクトであることがわかります。

つまり、正確には「Riverpod(の Provider)は BuildContext に依存しない。でも ConsumerWidget はがっつり BuildContext に依存している」という理解になるかと思います。

たしかに riverpod パッケージは一切 BuildContext をはじめとする Flutter への依存はありませんが、Flutter アプリのための flutter_riverpo はがっつり BuildContext に依存しているため、無条件にそのメリットを享受できることはあまりないでしょう。もちろん、設計をうまくやればその特徴を活用できますので、いずれにしても正確な理解が重要と言えそうです。

なお、BuildContextWidgetRef は同一のオブジェクトですので、ref を利用する場合も context と同様に必要に応じて context.mounted チェックが必要です。await がついた処理の後に ref を使いたい場合は必ず context.mounted チェックを入れてあげましょう。

以上です。

BuildContext について自分が書けることをひたすら書き出してみました。あまりひとつのテーマを説明するために構成を考えて書いているわけではないため雑多な内容の寄せ集めという感じの記事になってしまったかと思いますが、ここまで読んで何かひとつでも役立つ情報が提供できていれば嬉しいです。

他にも BuildContext について書けそうなことが見つかったら随時追記します。

脚注
  1. RenderObject を扱うための findRenderObject() なども同時に用意されています。 ↩︎

  2. 余談ですが、フレームワークやパッケージ等の共通利用される仕組みの開発側は、「何ができるか」と同じくらい「何をできないようにするか」に気を遣います。できることが多ければ多いほど利用側は使い方を理解しづらいですし、誤用による不具合を誘発します。そのためインターフェースによって利用可能なメソッドを絞るという発想に至るのだと思います。 ↩︎

  3. ちなみに、Widget ツリー上に Navigator がたとえば 3 つある場合、その中間の Navigator を取得するような仕組みは BuildContext には用意されていません。「直近」か「一番祖先」かのどちらかです。何かの都合でそういったことをしたい場合は GlobalKey をうまく使うことになります。 ↩︎

  4. DefaultTextStyleIconThemeButtonTheme などテーマに関わる Widget がこの InheritedTheme のサブクラスです。 ↩︎

  5. ただし、ここまでの話からわかる通り、利用する context を間違えるとうまくテーマが反映されない場合がありますので注意が必要です。 ↩︎

GitHubで編集を提案

Discussion