Understanding Flutter rendering pipeline: Build phase

Understanding Flutter rendering pipeline: Build phase

FlutterPulse

This article was translated specially for the channel FlutterPulseYou'll find lots of interesting things related to Flutter on this channel. Don't hesitate to subscribe!🚀

Flutter's rendering pipeline is a highly optimized process that transforms your declarative UI code into pixels on the screen. With a deep…

Flutter's rendering pipeline is a highly optimized process that transforms your declarative UI code into pixels on the screen. With a deep understanding of the rendering pipeline you can make smarter choices about widget structure and state management, leading to more maintainable and scalable code.

1. Rendering pipeline phases

  1. Build: Widgets are constructed and configured based on the app's current state.
  2. Layout: Determines the size and position of every widget (parent constraints guide child dimensions).
  3. Paint: Draws visual elements (e.g., colors, text, borders) onto a canvas.
  4. Compositing: Organizes painted widgets into layers
  5. Rasterize: Converts these painted layers into GPU-friendly pixels for final display.

If you open the DevTools like that, in the middle you will see a result of the build phase, on the right you will see a result of layout phase.

Flutter uses the event loop to schedule frame callbacks. When Flutter determines that a new frame is needed — due to user interactions, animations, or state changes — it calls methods like scheduleFrame on the WidgetsBinding. This schedules a callback that gets added to Dart's event loop.

Once the frame callback is executed, Flutter goes through its rendering pipeline. Each phase may schedule additional work (for example, microtasks or further frame callbacks), and these are all coordinated via the event loop.

If you want to execute some logic right after the rendering pipeline, you can use post frame callbacks. The most common case is to run some context-aware logic in the initState method:

@override
void initState() {
super.initState();
context; // <- fails here, widget is not attached yet
}

@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
context; // <- works
});
}

2. What happens during the build phase

During the build phase, the work is done primarily in Widgets and Elements, while managed mostly by BuildOwner.

  • Widgets:
    Flutter calls the build() method of each widget to create a new widget tree that reflects the current state and configuration of your UI. When the build() is running, the state is locked, meaning that calling setState() is not allowed.
  • Element Layer:
    The Element layer acts as an intermediary between the immutable widget layer and the mutable render objects. During the build phase, Flutter uses elements to reconcile the new widget tree (source?) with the previous one, ensuring that only the necessary parts of the UI are updated, so that the next rendering phases won't do excess work, which makes the rendering complexity sublinear.

While the render objects arecrucial for the actual layout and painting of your UI, it is constructed and updated in subsequent phases (layout and paint), not directly during the build phase.

3. Sublinear complexity

When we say Flutter's rendering is sublinear, we mean that the computational effort required to render the UI grows slower than linearly with the number of widgets or elements. In other words, if you double the number of widgets, the time or work required to render the frame doesn't necessarily double.

How does Flutter achieve that?

Widget Tree Diffing

When the state changes, Flutter compares the new widget tree with the previous one and only updates the parts that have changed. However, it is our job to notify that state has been changed and to provide the optimal widget update scope by calling setState in a correct context (or properly using any other state management). Updating scope takes an Element as an argument, which is exactly what BuildContextencapsulates.

Even if the widget diffing was efficient and there hadn't been a lot of excess rendering, running all of the build methods still takes some computational power.

Layer-Based Rendering

Flutter renders UI elements into layers, which are then composited by the GPU. If only a small part of the UI changes, only the corresponding layer(s) need to be updated. Our job here is to isolate different parts of the UI to the composited layers in an optimal way.

Caching and Lazy Updates

Many widgets are immutable and can be cached. If a widget or its subtree hasn't changed, Flutter reuses the existing render information. This caching strategy avoids redundant computations and drawing operations. This is the part when using const widgets does its purpose.

4. Widgets

Let's break down what happens on the widget layer:

Invoking the build method:

When a widget needs to update (due to state changes, parent changes, or other triggers), its build() method is called. This method returns a new configuration of widgets that describe the UI at that moment.

Creating an immutable widget tree:

Widgets in Flutter are immutable. The new widget tree produced by the build() method acts as a blueprint for what the UI should look like. This tree is lightweight and only contains configuration data, not actual UI elements.

Utilizing keys:

Widgets can use keys to maintain their identity across rebuilds. This is especially important in lists or dynamic interfaces, as keys help Flutter match new widget instances with their corresponding elements in the existing tree.

After building a new widget tree, Flutter hands it off to the element layer. If you want to read more on how Flutter translates widgets to elements, here's a documentation link.

5. Elements

It is important to understand what happens on the Element level to keep the Element tree optimal and avoid excess allocations.

  • Elements are created, mounted and updated during this phase. The framework calls mount to add the newly created element to the Element tree. This is what BuildContext.mounted refers to. If the Element was not added (or removed) from the Element tree, then the BuildContext is invalid to use.
  • Once an Element is unmounted, it cannot be mounted back into the tree. This is a one-way transition. The Element is considered "defunct" and will not be incorporated into the tree in the future.
  • While the unmounted Element itself cannot be reused, if it had a GlobalKey, that key can be reused with a new Element. The new Element would be completely separate from the unmounted one.
  • To schedule a (re)build, Flutter marks updated Elements as dirty, and then during the build phase Flutter iterates over them and calls their performRebuild() method. This method, in turn, calls the Element's build() method to generate a new widget subtree, which in turn calls Widget's build() method and passes itself as BuildContext.

Elements are unmounted in several scenarios. Here are the main cases when Elements get unmounted, and then we will compare them to scenarios when Elements are deactivated.

End of Frame Cleanup

At the end of the frame, Flutter's cleanup process iterates over the element tree, finds elements that are no longer attached (i.e., orphaned), and calls their unmount() method to detach them and dispose of any associated resources. That could happen because of parent removal or using a conditional widget.

Widget build(BuildContext context) {
return isVisible
? MyWidget() // When isVisible becomes false:
// 1. MyWidget's Element is deactivated
// 2. Later unmounted at end of frame
: SizedBox(); // New Element is created
}

When a route is fully popped and removed, the entire Element tree of that route is unmounted after the animation completes

Navigator.pop(context);

Now let's have a look on when is the Element deactivated and then reactivated again:

Offstage Widget

The Element remains active even when hidden, unlike conditional rendering which would deactivate it.

Offstage(
offstage: isHidden,
child: MyWidget(),
);

Moving a widget between parents using a GlobalKey

BuildOwner contains all the global keys associated with corresponding elements. When inflating the widgets, it retakes the deactivated element if there is a global key, otherwise, creates a new element.

Reordering List Items

When you reorder items in a ListView, Flutter's reconciliation algorithm uses the unique keys assigned to each widget to reuse their associated Elements.

During the build phase, Flutter compares the new widget list with the previous one. For each widget, it checks the key:

  • If a widget with the same key exists in the previous list, Flutter reuses its Element.
  • The existing Element is then updated with the new widget configuration (if necessary), but its state and other associated data remain intact.

However, don't expect a performance boost by just adding keys to list items if you don't intent to reorder them.

AnimatedSwitcher with the same child type

        AnimatedSwitcher(
duration: Duration(milliseconds: 500),
child: showFirst
? Container(
key: childKey, // Element deactivates during animation
color: Colors.blue,
height: 100,
width: 100,
)
: Container(
key: childKey, // and reactivates in new position
color: Colors.red,
height: 100,
width: 100,
),
),

Differences:

  • Deactivated Elements maintain their inherited widget relationships
  • Deactivation is temporary (lasts until end of frame)
  • Unmounted Elements clear all relationships
  • Unmounting is permanent

Hope you've learned something new. I will update this article whenever I find something useful. Next part will cover the layout phase. Follow me on Twitter or Telegram to stay updated.

Report Page