iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🪑

A Deep Dive into BuildContext

に公開1

I doubt there is anyone working with Flutter who hasn't heard the class name BuildContext. It’s that object passed as an argument to the build() method.

@override
Widget build(BuildContext context) { // <- This one
}

You might use it in things like Navigator.of(context) for screen transitions or showDialog(context: context) for displaying dialogs. In this article, I want to write as much as I can about what this BuildContext really is.

As a side note, this article is written somewhat impulsively without a tight structure, so I recommend skimming through it in your spare time.

BuildContext is an Element

To explain BuildContext, we must first talk about Element.

Although Flutter is designed with the concept that Everything is a Widget, Widgets are actually just interfaces for app developers—immutable classes that only hold values. The actual internal build-related processing is handled by a different class called Element.

An Element is created within the Flutter framework to have a one-to-one correspondence with a Widget (although it is the Widget that possesses the creation method). Unlike Widgets, which are frequently discarded and recreated during rebuilds, Elements are reused as much as possible.

Rebuild processing is performed by these reused Elements. Therefore, even if we rebuild an entire page using setState(), the actual rebuilding and layout recalculation processes are limited to the parts that changed before and after the rebuild. This design ensures that the scope and frequency of rebuilds do not significantly impact performance.

The role of an Element is not limited to optimizing the rebuild scope. It holds its own parent in a field called Element? _parent and has a method named visitChildren() to access its children. In other words, Element is what actually forms the tree structure we imagine when we say "Widget Tree."

The mechanism of the Widget tree is that each Element generated one-to-one from a Widget maintains references to its immediate parent and children. By following these references, any Element on the Widget tree—and the Widget associated with it—can be accessed.

Now, if we look at the class definition of Element, it looks like this:

abstract class Element extends DiagnosticableTree implements BuildContext {
}

As you can see, it implements BuildContext. This means that BuildContext is an interface, and its implementation is Element. In other words, the actual entity of the context we interact with is the Element that forms this Widget tree.

The BuildContext Interface

As mentioned previously, the role of an Element is multifaceted. Not only does it maintain parent-child references to form the Widget tree, but it also manages rebuild optimization and the Widgets or RenderObjects associated with the Element.

Therefore, an Element can be considered a bit "heavy" for app developers to interact with directly. Direct interaction would also break the Everything is a Widget concept.

However, the functionalities achieved by traversing the Widget tree still need to be provided to app developers. That's where the BuildContext interface comes in.

BuildContext primarily defines methods related to "doing something by traversing the Widget tree" among the tasks of an Element.[1] Representative examples include dependOnInheritedWidgetOfExactType and findAncestorStateOfType.

Even if the underlying entity is an Element, by passing it as a BuildContext type in arguments like the build() method while not making the developer conscious of its identity as an Element, the framework realizes a mechanism where one can think, "I don't fully understand it, but I can call processes based on the Widget tree if I use BuildContext."[2]

What BuildContext is Doing

With that in mind, let's look at what Navigator.of(context) and showDialog(context: context)—which I mentioned as examples at the beginning—are actually doing.

The implementation of Navigator.of(context) is as follows. Processes unrelated to the main logic, such as assert statements, have been omitted.

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!;
}

Let's focus on how the context passed as an argument is being used.

First, let's look at the first if conditional branch.

As mentioned earlier, the actual entity of BuildContext is an Element. Element has even more detailed implementation classes depending on the Widget it creates. StatefulElement is a subclass of Element created by a StatefulWidget. Also, StatefulElement holds an object of the State<T> class (the class we write when using a StatefulWidget) in a field called state.

In other words, you can see that the first conditional branch verifies whether the Widget associated with the context is a NavigatorState. This is designed so that the execution enters this branch if NavigatorState itself attempts to call Navigator.of(context). (This branch seems relatively irrelevant for us app developers.)

Next is the case where the rootNavigator passed as an argument is true.

What is called here is a method named findRootAncestorStateOfType<NavigatorState>(). I will also quote the implementation of this method.

@override
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?;
}

It continues to search for a NavigatorState among ancestors by traversing ancestor._parent (in other words, going up the Widget tree) until ancestor becomes null. Even if one is found along the way, the loop does not stop; it is implemented to ultimately return the one located at the very top of the Widget tree, even if multiple NavigatorStates exist. This is exactly as the method name findRoot suggests.

In UIs where bottom tabs are used, it is not uncommon to nest Navigators. When rootNavigator is true, this mechanism is used to retrieve the NavigatorState that is the furthest ancestor.[3]

Now, the last conditional branch in Navigator.of(context) calls findAncestorStateOfType<NavigatorState>(). Let's quote its implementation as well.

@override
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?;
}

Unlike the findRootAncestorStateOfType we saw earlier, this one breaks the loop as soon as the specified NavigatorState is found and returns it. In other words, it retrieves the nearest NavigatorState.

In a tab-based UI, if you want to transition the entire screen including the tabs, you can set rootNavigator to true to get the NavigatorState at the very top of the Widget tree. If you want to transition only within the current tab, you can use the nearest NavigatorState by not specifying rootNavigator (leaving it at its default false). You can see how this distinction is implemented here.

As you can see from the code, findRootAncestorStateOfType() and findAncestorStateOfType() traverse the Widget tree "one by one" to check for the target State. This means the computational complexity is O(n), and performance decreases as the Widget tree grows deeper. Therefore, while there is no issue using them for actions that occur once, such as button taps, it is better to avoid using them within the build() method, which can be called many times in quick succession. Standard Widgets like Navigator.of() and Scaffold.of() are built on this mechanism, but they are generally not intended to be used inside a build() method.

showDialog(context: context)

Now, what about showDialog(context: context)?

Let's quote its implementation as well. In this quote, I have omitted arguments and other details that are not necessary for the explanation.

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,
  ));
}

Let's explain this not in order from top to bottom, but starting from the parts that are easier to discuss.

First is Navigator.of(context). As you can see, in Flutter, displaying a dialog is achieved using Navigator.of(context).push(), just like a screen transition. The difference is that instead of the MaterialPageRoute we commonly use, we pass a DialogRoute as an argument.

In screen transitions, subclasses of this Route are responsible for the transition animation. Unlike MaterialPageRoute, which reproduces standard OS animations, DialogRoute implements an animation that "appears as an overlay on the existing screen." This is why the movement covering the dialog screen is realized.

Now, looking at other parts, it's also used as an argument for InheritedTheme.capture(). The from parameter is passed the context as is, and to is passed the context associated with the NavigatorState returned as a result of Navigator.of(context).

InheritedTheme.capture() is a method that collects all InheritedThemes[4] existing from the from context to the to context and returns them as a List.

Through this, the theme set on the screen before the dialog appeared is also applied inside the dialog, creating a design that doesn't make it feel like a "separate screen."[5]

In the implementation of showDialog(context: context), we also see the code Theme.of(context). The code for Theme.of(context) is as follows:

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));
}

Unlike Navigator.of(context), this uses context to call a method named dependOnInheritedWidgetOfExactType<T>(). To explain this mechanism, we first need to discuss InheritedWidget, so let's shift our focus to InheritedWidget for a moment.

InheritedWidget

In Flutter app development, there is a distinction between ephemeral state, which is contained within a single Widget, and app state, which is shared across multiple Widgets.

The standard Widget for managing ephemeral state is StatefulWidget, and the standard Widget for managing app state is InheritedWidget.

While InheritedWidget might not be used frequently by app developers due to the boilerplate code it requires, it is used extensively within the Flutter framework itself. Therefore, understanding how it works is very important.

InheritedElement, which is generated by InheritedWidget, is treated specially among Elements. Every Element maintains a direct reference to the InheritedElements existing among its ancestors in a Map field called _inheritedElements.

PersistentHashMap<Type, InheritedElement>? _inheritedElements;

This allows an Element to access an InheritedWidget existing in its ancestry with O(1) computational complexity.

The build() method, which can be called as frequently as 60fps (or 120fps depending on the device), needs its processing to be extremely fast to maintain performance. In such a situation, if the Element had to traverse its parents "one by one" to find the target Element or Widget, performance would degrade in proportion to the size of the app.

Therefore, Widgets that hold data (state) necessary for UI generation are designed so that they can be retrieved without performance loss by using these "direct references."

And the method that uses those direct references to retrieve the desired InheritedWidget is the previously mentioned dependOnInheritedWidgetOfExactType<T>().

I will quote the implementation, but it is very simple.

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;
}

Since _inheritedElements is a Map type with Type as the key, you can see that it retrieves the target InheritedElement using the process _inheritedElements![T]. It’s O(1).

Now, it doesn't just return the target InheritedElement as is once it's found. InheritedWidget has another role: "remember the Elements that accessed it via dependOn and rebuild those Elements if it undergoes a change itself." The method that handles this is dependOnInheritedElement().

In Flutter, the UI is updated immediately when there is a change in state. By remembering "who is using its data to build the UI," InheritedWidget can keep track of which Elements should be targeted for rebuilding when its state changes.

The .of() Method

So far, I have explained methods for accessing ancestors from descendants, such as findAncestorStateOfType<T>() and dependOnInheritedElement<T>(). However, as you can see, these methods are a bit cumbersome to call directly for the following reasons:

  • The method names are simply long.
  • It is difficult to determine what should be specified for <T>.
  • They require handling cases where the object is not found, so it is not just "call and you're done."

A pattern frequently used in standard Flutter Widgets to solve these issues is the static method .of(context).

The idea behind .of(context) is to implement the necessary logic on the ancestor side for when it is accessed by descendants, allowing those descendants—who may not know the implementation details—to simply call .of(context). This approach reduces the burden on the user side.

If you create your own InheritedWidget or StatefulWidget that expects to be referenced by descendants, providing an .of(context) method alongside it will improve its usability.

NotificationListener

Moving on, there are other mechanisms that utilize the Widget tree. One of them is NotificationListener and context.dispatchNotification().

The context.dispatchNotification() method notifies ancestors in the Widget tree of a Notification object (actually, an object of any class that inherits from Notification) passed as an argument.

If a NotificationListener exists among the ancestors of the context, the callback function provided to its onNotification argument is invoked. If that function returns false, the onNotification function of a NotificationListener further up the tree is also called, and this process repeats all the way up to the root of the tree.

This mechanism is often used when you want to notify ancestors of an event without knowing specifically which ancestor will handle it. For example, in standard Widgets, various scrollable Widgets that inherit from ScrollView use this to notify their ancestors of the scroll position/offset.

Let's examine the implementation of dispatchNotification.

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

Here, a new kind of tree called _notificationTree is introduced.

_NotificationNode? _notificationTree;

A _NotificationNode corresponds to the _NotificationElement created by a NotificationListener. In other words, it is generated when a NotificationListener is placed in the Widget tree, and an Element maintains a reference to its nearest _NotificationNode.

Furthermore, a _NotificationNode holds a reference to its parent _NotificationNode, forming a small tree structure similar to BuildContext.

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

  _NotificationNode? parent;
}

dispatchNotification() traverses these _NotificationNodes toward the ancestors, executing callback functions until the "bubbling" is stopped by the return value of an onNotification. The documentation explicitly uses the term "bubbling" for this behavior—visualize it as bubbles rising upward.

In this way, the role of BuildContext extends beyond finding specific Widgets or Elements; it also involves transporting arbitrary data to ancestors through an auxiliary tree structure.

Riverpod and BuildContext

"Riverpod does not depend on BuildContext. Therefore, it is easy to handle." is a phrase often used when introducing Riverpod. It is true that Riverpod providers do not require BuildContext. However, did you know that WidgetRef, which is used to reference them in a Flutter app, is actually the same object as 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;
}

First, ConsumerWidget is a subclass of StatefulWidget. Since it's a subclass of StatefulWidget, it has a paired State object, which is the ConsumerState shown above.

Then, if you look at the ref field, it's written as context as WidgetRef, showing that context and ref are indeed the same object, just cast to a different type.

In other words, the more accurate understanding would be: "Riverpod (the Provider) does not depend on BuildContext. However, ConsumerWidget depends heavily on BuildContext."

While it's true that the riverpod package itself has no dependencies on Flutter or BuildContext, flutter_riverpod for Flutter apps is fundamentally dependent on BuildContext. Thus, you may not always benefit from that independence unconditionally. Of course, with proper design, you can take advantage of those characteristics, so an accurate understanding is important either way.

Furthermore, since BuildContext and WidgetRef are the same object, you must perform context.mounted checks when using ref, just as you would with context. If you want to use ref after a process involving await, be sure to include a context.mounted check.

That's all.

I've written down everything I can think of about BuildContext. Since I didn't write this with a strictly planned structure for explaining a single theme, it turned out to be a bit of a collection of miscellaneous topics, but I hope you found at least one piece of useful information.

I'll add more whenever I find anything else worth writing about BuildContext.

脚注
  1. findRenderObject() for handling RenderObject is also available. ↩︎

  2. As an aside, the developers of widely used frameworks and packages often care as much about "what to disable" as "what to enable." The more features there are, the harder it is for users to understand how to use them, which can lead to bugs through misuse. This is likely why the approach of limiting available methods via an interface is taken. ↩︎

  3. Incidentally, if there are, say, three Navigators in the Widget tree, BuildContext doesn't provide a way to get a Navigator in the middle. It's either the "nearest" or the "root-most" one. If you need to do that for some reason, you'll have to use GlobalKey. ↩︎

  4. Theme-related widgets such as DefaultTextStyle, IconTheme, and ButtonTheme are subclasses of InheritedTheme. ↩︎

  5. However, as you can see from the discussion so far, be aware that the theme may not be applied correctly if you use the wrong context. ↩︎

GitHubで編集を提案

Discussion