BuildContext について書けるだけ書く
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 {
}
BuildContext
を implements
していることがわかります。つまり、BuildContext
はインターフェースであり、その実装が Element
であるということです。言い換えれば、我々が触れる context
の実体はこの Widget ツリーを形作る Element
です。
BuildContext というインターフェース
先述の通り、Element
の役割は多岐にわたります。Widget ツリーを形成するために親子の参照を保持するだけでなく、リビルドの最適化や Element
に紐づく Widget や RenderObject
の管理なども行います。
そのため、Element
をアプリ開発者がそのまま触るには少々「重い」オブジェクトと言えます。Everything is a Widget というコンセプトも崩れてしまいます。
ただ一方で、Widget ツリーを辿って実現できる機能はアプリ開発者に提供する必要がある、そこで登場するのが BuildContext
というインターフェースです。
BuildContext
は Element
の仕事の中でも、「Widget ツリーを辿って何かする」ことに関するメソッドが主に定義されています。[1] dependOnInheritedWidgetOfExactType
や findAncestorStateOfType
などはその代表的な例と言えるでしょう。
実体は Element
であったとしても、build()
メソッドの引数などでは BuildContext
型として引き渡しつつ、実体が Element
であることを意識させないことにより、「よくわからないけど BuildContext
を使えば Widget ツリーに基づく処理が呼び出せる」という仕組みを実現しているわけです。[2]
BuildContext がやっていること
では、ここまでを踏まえた上で、冒頭で例にあげた Navigator.of(context)
や showDialog(context: context)
が何をやっているのかを見てみましょう。
Navigator.of(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
のサブクラスです。また、StatefulElement
は state
というフィールドに State<T>
クラス(われわれも StatefulWidget
を使う時に書いているクラス)のオブジェクトを保持します。
つまり context
に紐づく Widget が NavigatorState
かどうかを検証しているのが最初の条件分岐と読み取れるでしょう。つまり NavigatorState
自身が Navigator.of(context)
を呼び出そうとした場合にこの条件分岐に入るような作りになっています。(あまりわれわれアプリ開発者は関係のない分岐と言えそうです)
次は引数に渡された rootNavigator
が true
だった場合です。
ここで呼ばれているのが 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?;
}
ancestor
が null
になるまで ancestor._parent
を辿って(つまり Widget ツリーを上に遡って)祖先に存在する NavigatorState
を探し続けます。途中で見つかったとしてもループは止めず、Widget ツリー上に複数の NavigatorState
が存在する中で一番祖先に配置されたものを最終的に返却する、という実装になっています。findRoot
のメソッド名の通りですね。
UI に下タブなどが用いられる場合、Navigator
をネストさせることも珍しくはないかと思います。この rootNavigator
が true
の場合はこの仕組みを使って一番祖先にある 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 において、タブも含めた画面全体を遷移したい場合は rootNavigator
を true
にすることで 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()
は from
の context
から to
の context
までに存在するすべての 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;
}
_inheritedElements
は Type
をキーとする 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)
です。
もし子孫から参照されることを期待する InheritedWidget
や StatefulWidget
を自作する際は、一緒に .of(context)
メソッドも用意すると使い勝手が良くなるでしょう。
NotificationListener
さて話は変わって、Widget ツリーを利用した仕組みは他にもあります。そのひとつが NotificationListener
と context.dispatchNotification()
です。
context.dispatchNotification()
メソッドは、引数に渡した Notification
オブジェクト(実際は Notification
を継承した任意のクラスのオブジェクト)を Widget ツリーの祖先へ通知するメソッドです。
context
の祖先に NotificationListener
が存在すれば、その onNotification
引数に渡したコールバック関数が呼ばれます。その関数の戻り値で false
を返却すると、さらに祖先にある NotificationListener
の onNotification
関数も呼び出され、以下ツリーの根元に至るまでそれが繰り返されます。
これは「誰が利用するかわからないけど、子孫から祖先に対してイベントを通知したい」という状況でよく使われる仕組みで、たとえば標準の 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 アプリで参照するための WidgetRef
が BuildContext
と同一のオブジェクトであるということはご存知でしょうか。
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;
}
まず、ConsumerWidget
は StatefulWidget
のサブクラスです。StatefulWidget
のサブクラスなので、ペアになる State
オブジェクトがあるわけなのですが、それが上記の ConsumerState
です。
そして、その ref
フィールドを見ると context as WidgetRef
と書かれており、たしかに context
と ref
は型をキャストしただけの同一のオブジェクトであることがわかります。
つまり、正確には「Riverpod(の Provider)は BuildContext
に依存しない。でも ConsumerWidget
はがっつり BuildContext
に依存している」という理解になるかと思います。
たしかに riverpod
パッケージは一切 BuildContext
をはじめとする Flutter への依存はありませんが、Flutter アプリのための flutter_riverpo
はがっつり BuildContext
に依存しているため、無条件にそのメリットを享受できることはあまりないでしょう。もちろん、設計をうまくやればその特徴を活用できますので、いずれにしても正確な理解が重要と言えそうです。
なお、BuildContext
と WidgetRef
は同一のオブジェクトですので、ref
を利用する場合も context
と同様に必要に応じて context.mounted
チェックが必要です。await
がついた処理の後に ref
を使いたい場合は必ず context.mounted
チェックを入れてあげましょう。
以上です。
BuildContext
について自分が書けることをひたすら書き出してみました。あまりひとつのテーマを説明するために構成を考えて書いているわけではないため雑多な内容の寄せ集めという感じの記事になってしまったかと思いますが、ここまで読んで何かひとつでも役立つ情報が提供できていれば嬉しいです。
他にも BuildContext
について書けそうなことが見つかったら随時追記します。
-
RenderObject
を扱うためのfindRenderObject()
なども同時に用意されています。 ↩︎ -
余談ですが、フレームワークやパッケージ等の共通利用される仕組みの開発側は、「何ができるか」と同じくらい「何をできないようにするか」に気を遣います。できることが多ければ多いほど利用側は使い方を理解しづらいですし、誤用による不具合を誘発します。そのためインターフェースによって利用可能なメソッドを絞るという発想に至るのだと思います。 ↩︎
-
ちなみに、Widget ツリー上に
Navigator
がたとえば 3 つある場合、その中間のNavigator
を取得するような仕組みはBuildContext
には用意されていません。「直近」か「一番祖先」かのどちらかです。何かの都合でそういったことをしたい場合はGlobalKey
をうまく使うことになります。 ↩︎ -
DefaultTextStyle
やIconTheme
、ButtonTheme
などテーマに関わる Widget がこのInheritedTheme
のサブクラスです。 ↩︎ -
ただし、ここまでの話からわかる通り、利用する
context
を間違えるとうまくテーマが反映されない場合がありますので注意が必要です。 ↩︎
Discussion
👏