Flutter (92): Flutter startup process and rendering pipeline

Time: Column:Mobile & Frontend views:250

1 Application Startup

In this section, we will first introduce the startup process of Flutter, and then discuss the Flutter rendering pipeline.

The entry point of Flutter is in the main() function located in lib/main.dart, which serves as the starting point for Dart applications. In a Flutter application, the simplest implementation of the main() function looks like this:

void main() => runApp(MyApp());

As we can see, the main() function only calls the runApp() method. Let's take a look at what the runApp() method does:

void runApp(Widget app) {
  WidgetsFlutterBinding.ensureInitialized()
    ..attachRootWidget(app)
    ..scheduleWarmUpFrame();
}

The app parameter is a widget that represents the first component to be displayed after the Flutter application starts. WidgetsFlutterBinding serves as the bridge between the widget framework and the Flutter engine, defined as follows:

class WidgetsFlutterBinding extends BindingBase with GestureBinding, ServicesBinding, SchedulerBinding, PaintingBinding, SemanticsBinding, RendererBinding, WidgetsBinding {
  static WidgetsBinding ensureInitialized() {
    if (WidgetsBinding.instance == null)
      WidgetsFlutterBinding();
    return WidgetsBinding.instance;
  }
}

Here, WidgetsFlutterBinding inherits from BindingBase and mixes in various bindings. Before we introduce these bindings, let's discuss the Window. Here’s the official explanation of Window:

The most basic interface to the host operating system's user interface.

Clearly, Window is the interface that connects the Flutter framework to the host operating system. Let's take a look at part of the definition of the Window class:

class Window {
    
  // The DPI of the current device, which indicates how many physical pixels are displayed per logical pixel. 
  // A higher number results in finer display quality. DPI is a firmware attribute of the device screen, 
  // such as Nexus 6's screen DPI of 3.5 
  double get devicePixelRatio => _devicePixelRatio;
  
  // Size of the Flutter UI drawing area
  Size get physicalSize => _physicalSize;

  // The default language Locale of the current system
  Locale get locale;
    
  // The current system font scaling factor.  
  double get textScaleFactor => _textScaleFactor;  
    
  // Callback when the drawing area size changes
  VoidCallback get onMetricsChanged => _onMetricsChanged;  
  // Callback when the Locale changes
  VoidCallback get onLocaleChanged => _onLocaleChanged;
  // Callback when the system font scale changes
  VoidCallback get onTextScaleFactorChanged => _onTextScaleFactorChanged;
  // Callback before drawing, typically driven by the monitor's vertical sync signal (VSync).
  FrameCallback get onBeginFrame => _onBeginFrame;
  // Drawing callback  
  VoidCallback get onDrawFrame => _onDrawFrame;
  // Callback for pointer events
  PointerDataPacketCallback get onPointerDataPacket => _onPointerDataPacket;
  // Schedules a frame; after this method executes, onBeginFrame and onDrawFrame will be called at the appropriate time.
  // This method directly calls the Flutter engine's Window_scheduleFrame method.
  void scheduleFrame() native 'Window_scheduleFrame';
  // Updates the rendering of the application on the GPU; this method directly calls the Flutter engine's Window_render method.
  void render(Scene scene) native 'Window_render';

  // Sends platform messages
  void sendPlatformMessage(String name,
                           ByteData data,
                           PlatformMessageResponseCallback callback);
  // Callback for platform channel message processing  
  PlatformMessageCallback get onPlatformMessage => _onPlatformMessage;
  
  ... // Other properties and callbacks
}

The Window class contains information about the current device and system, as well as various callbacks for the Flutter engine. Now let's return to the various bindings mixed into WidgetsFlutterBinding. By examining the source code of these bindings, we can see that they primarily listen for and handle events from the Window object, then wrap, abstract, and distribute these events according to the framework's model. Thus, WidgetsFlutterBinding serves as the "glue" that connects the Flutter engine with the upper-level framework.

  • GestureBinding: Provides the window.onPointerDataPacket callback, linking the framework's gesture subsystem with the lower-level event model.

  • ServicesBinding: Provides the window.onPlatformMessage callback, used for binding platform message channels, mainly handling communication between native code and Flutter.

  • SchedulerBinding: Provides the window.onBeginFrame and window.onDrawFrame callbacks, listening for refresh events and binding the framework's drawing scheduling subsystem.

  • PaintingBinding: Binds the drawing library, mainly used for handling image caching.

  • SemanticsBinding: Acts as a bridge between the semantic layer and the Flutter engine, providing low-level support for accessibility features.

  • RendererBinding: Provides callbacks such as window.onMetricsChanged and window.onTextScaleFactorChanged. It serves as a bridge between the render tree and the Flutter engine.

  • WidgetsBinding: Provides callbacks like window.onLocaleChanged and onBuildScheduled. It serves as a bridge between the Flutter widget layer and the engine.

WidgetsFlutterBinding.ensureInitialized() is responsible for initializing a global singleton of WidgetsBinding, which is immediately followed by a call to WidgetsBinding's attachRootWidget method. This method is responsible for adding the root widget to the RenderView, as shown in the following code:

void attachRootWidget(Widget rootWidget) {
  _renderViewElement = RenderObjectToWidgetAdapter<RenderBox>(
    container: renderView, 
    debugShortDescription: '[root]',
    child: rootWidget
  ).attachToRenderTree(buildOwner, renderViewElement);
}

Note that the code includes two variables: renderView and renderViewElement. renderView is a RenderObject that serves as the root of the render tree, while renderViewElement is the corresponding Element object of renderView. This method primarily completes the entire association process from the root widget to the root RenderObject, and then to the root Element. Let's examine the implementation of attachToRenderTree:

RenderObjectToWidgetElement<T> attachToRenderTree(BuildOwner owner, [RenderObjectToWidgetElement<T> element]) {
  if (element == null) {
    owner.lockState(() {
      element = createElement();
      assert(element != null);
      element.assignOwner(owner);
    });
    owner.buildScope(element, () {
      element.mount(null, null);
    });
  } else {
    element._newWidget = this;
    element.markNeedsBuild();
  }
  return element;
}

This method is responsible for creating the root element, namely RenderObjectToWidgetElement, and associating the element with the widget, thereby creating the tree of element corresponding to the widget tree. If the element has already been created, it sets the associated widget in the root element to the new one. Thus, it can be seen that the element is only created once and is reused afterward.

So, what is BuildOwner? It is essentially the management class for the widget framework, tracking which widgets need to be rebuilt.

After the component tree has been built, returning to the implementation of runApp, after calling attachRootWidget, the last line invokes the scheduleWarmUpFrame() method of the WidgetsFlutterBinding instance. This method is implemented in SchedulerBinding and, once called, will immediately trigger a drawing. Before this drawing concludes, the method will lock event dispatch, meaning that Flutter will not respond to various events until the current drawing is complete. This ensures that new redraws are not triggered during the drawing process.


2 Rendering Pipeline

2-1. Frame

A single rendering process is referred to as a frame. When we mention that Flutter can achieve 60fps (Frames Per Second), it means it can trigger up to 60 redraws in one second. The higher the FPS, the smoother the interface. It’s important to note that the frame concept in Flutter does not equate to screen refresh frames; the Flutter UI framework does not trigger a frame on every screen refresh. If the UI remains unchanged for a period, there is no need to go through the rendering process again. Therefore, after the first frame rendering, Flutter employs a proactive approach to request frames only when the UI might change.

Flutter registers an onBeginFrame and an onDrawFrame callback on the window, with drawFrame being ultimately called in the onDrawFrame callback. After calling window.scheduleFrame(), the Flutter engine will call onBeginFrame and onDrawFrame at an appropriate moment (typically before the next screen refresh, depending on the Flutter engine implementation). Thus, only by explicitly calling scheduleFrame() will drawFrame execute. Consequently, when we refer to frames in Flutter, unless specified otherwise, it corresponds to the call of drawFrame(), not the screen refresh rate.

2-2. Flutter Scheduling Process SchedulerPhase

The execution process of a Flutter application can be simplified into two states: idle and frame. The idle state indicates that no frames are being processed. If the application state changes and requires UI refresh, a new frame must be requested via scheduleFrame(). Upon receiving a new frame, the state transitions into frame. The entire lifecycle of a Flutter application alternates between these two states.

Frame Processing Flow

When a new frame arrives, the processing involves executing four task queues in sequence: transientCallbacks, midFrameMicrotasks, persistentCallbacks, and postFrameCallbacks. Once all four queues have completed, the current frame concludes. In summary, Flutter divides the entire lifecycle into five states, represented by the SchedulerPhase enumeration:

enum SchedulerPhase {
  idle, // Indicates no frames are being processed.
  transientCallbacks, // Executes temporary callback tasks that can only run once.
  midFrameMicrotasks, // Executes microtasks created during transient callbacks.
  persistentCallbacks, // Executes tasks that must run for each frame, like the rendering pipeline.
  postFrameCallbacks, // Executes before the current frame ends for cleanup and new frame requests.
}

Notably, the rendering pipeline we will focus on next executes within the persistentCallbacks.

2-3. Rendering Pipeline

When a new frame arrives, the drawFrame() method of WidgetsBinding is called. Here’s its implementation:

@override
void drawFrame() {
  ...
  try {
    buildOwner.buildScope(renderViewElement); // Build first
    super.drawFrame(); // Then call the parent drawFrame method
  }
}

The crucial lines are two: first, it rebuilds the widget tree, then it calls the parent’s drawFrame. Expanding the parent’s drawFrame method:

void drawFrame() {
  buildOwner!.buildScope(renderViewElement!); // 1. Rebuild the widget tree
  pipelineOwner.flushLayout(); // 2. Update layout
  pipelineOwner.flushCompositingBits(); // 3. Update compositing bits
  pipelineOwner.flushPaint(); // 4. Redraw
  if (sendFramesToEngine) {
    renderView.compositeFrame(); // 5. Present the frame and send the drawn bit data to the GPU
    ...
  }
}

We see five main tasks:

  1. Rebuild the widget tree.

  2. Update the layout.

  3. Update the compositing information.

  4. Redraw.

  5. Present: display the drawn product on the screen.

These five steps constitute the rendering pipeline. The specific processes of these steps will be the focus of this chapter. Next, we'll outline the entire update process using the execution flow of setState as an example.

2-4. setState Execution Flow

After calling setState:

  1. The current element's markNeedsBuild method is invoked, marking it as dirty.

  2. The scheduleBuildFor method is called, adding the current element to the dirtyElements list of the pipeline owner.

  3. A new frame is requested, subsequently drawing the new frame: onBuildScheduled -> ensureVisualUpdate -> scheduleFrame(). When the new frame arrives, the rendering pipeline executes.

void drawFrame() {
  buildOwner!.buildScope(renderViewElement!); // Rebuild the widget tree
  pipelineOwner.flushLayout(); // Update layout
  pipelineOwner.flushCompositingBits(); // Update compositing information
  pipelineOwner.flushPaint(); // Update drawing
  if (sendFramesToEngine) {
    renderView.compositeFrame(); // Present the frame, sending the drawn bit data to the GPU
    pipelineOwner.flushSemantics(); // This also sends semantics to the OS.
    _firstFrameSent = true;
  }
}

Rebuilding the Widget Tree: If the dirtyElements list is not empty, each element's rebuild method is called to construct the new widget tree. Since the new widget tree uses new states, it may cause changes in widget layout information (space occupied and position). If changes occur, the markNeedsLayout method of the associated renderObject is called. This method will look upward for a relayoutBoundary node, adding it to a global nodesNeedingLayout list. If no boundary is found up to the root, the root node is added.

Updating Layout: The nodesNeedingLayout array is traversed, calling the layout method on each renderObject to determine new sizes and offsets. During this, markNeedsPaint() is invoked, similar to markNeedsLayout, looking upward until it finds a parent node with isRepaintBoundary set to true, adding it to a global nodesNeedingPaint list.

Updating Compositing Information: This will be elaborated on in section 14.8.

Updating Drawing: The nodesNeedingPaint list is traversed, calling the paint method on each node to redraw, generating Layers. Notably, in Flutter, the drawing results are stored in Layers, meaning as long as a Layer is not released, the drawing results remain cached. Thus, Layers can cache drawing results across frames to avoid unnecessary redraw costs. During the Flutter framework drawing process, a new Layer is generated only when encountering a node with isRepaintBoundary set to true. This shows that Layers and renderObjects are not one-to-one; parent and child nodes can share them.

Presenting the Frame: Once drawing is complete, we have a tree of Layers, which needs to be displayed on the screen. Since Flutter has its own rendering engine, we must submit the drawing information to the Flutter engine, accomplished by renderView.compositeFrame.

In summary, this outlines the general update process from the call of setState to UI updates, although the actual flow is more complex, involving checks to prevent setState calls during the build process and handling animations and scene rendering during frames.

2-5. Issues with Timing of setState Execution

Calling setState triggers a build, which occurs during the persistentCallbacks phase. Therefore, as long as setState is not executed during this phase, it is absolutely safe. However, this granularity is too coarse. For example, during the transientCallbacks and midFrameMicrotasks phases, if the application state changes, the best approach is to mark the component as dirty without requesting a new frame. Since the current frame has not yet reached the persistentCallbacks, the UI will refresh in the rendering pipeline of the current frame when it does. Thus, after marking dirty, setState first checks the scheduling state and only requests a new frame if it is in the idle or postFrameCallbacks phase:

void ensureVisualUpdate() {
  switch (schedulerPhase) {
    case SchedulerPhase.idle:
    case SchedulerPhase.postFrameCallbacks:
      scheduleFrame(); // Request a new frame
      return;
    case SchedulerPhase.transientCallbacks:
    case SchedulerPhase.midFrameMicrotasks:
    case SchedulerPhase.persistentCallbacks: // Note this line
      return;
  }
}

The above code generally works fine, but if we call setState during the build phase, issues arise because this will lead to a build loop. The Flutter framework will throw an error if setState is called during the build phase, such as:

@override
Widget build(BuildContext context) {
  return LayoutBuilder(
    builder: (context, c) {
      // Cannot call setState during the build phase; it will throw an error
      setState(() {
        ++index;
      });
      return Text('xx');
    },
  );
}

When run, this will throw an error, and the console will print:

==== Exception caught by widgets library ====
The following assertion was thrown building LayoutBuilder:
setState() or markNeedsBuild() called during build.

Note that if we directly call setState in the build, as follows:

@override
Widget build(BuildContext context) {
  setState(() {
    ++index;
  });
  return Text('$index');
}

It will not throw an error. This is because during the build, the current component's dirty state (in the corresponding element) is true, and it will only be set to false after the build completes. When setState executes, it first checks the current dirty value; if it is true, it will return immediately, thus avoiding an error.

We’ve only discussed how calling setState during the build phase leads to errors. In fact, it cannot be synchronously called during the entire building, layout, and painting phases. This is because calling setState during these phases could request a new frame, potentially leading to recursive calls. Therefore, if you want to update the application state during these phases, you must avoid directly calling setState.

Safe Updates

Now that we know not to call setState during the build phase, we should also avoid directly requesting a relayout or redraw during the layout and painting phases for the same reasons. So what is the correct way to update during these phases? Using setState as an example, we can do it as follows:

// Safe update during build, layout, and painting phases
void update(VoidCallback fn) {
  SchedulerBinding.instance.addPostFrameCallback((_) {
    setState(fn);
  });
}

Note that the update function should only be executed when the frame is in the persistentCallbacks phase; for other phases, you can directly call setState. The idle state is a special case; if update is called in the idle state, you need to manually call scheduleFrame() to request a new frame, otherwise, postFrameCallbacks will not be executed before the next frame (the one requested by other components) arrives. Thus, we can modify update as follows:

void update(VoidCallback fn) {
  final schedulerPhase = SchedulerBinding.instance.schedulerPhase;
  if (schedulerPhase == SchedulerPhase.persistentCallbacks) {
    SchedulerBinding.instance.addPostFrameCallback((_) {
      setState(fn);
    });
  } else {
    setState(fn);
  }
}

At this point, we have encapsulated a function that can safely update the state.

Now, recalling the section “Custom Drawing Component: CustomCheckbox” in Chapter 10, to execute animations, we requested a redraw after the painting was completed using the following code:

SchedulerBinding.instance.addPostFrameCallback((_) {
  ...
  markNeedsPaint();
});

We did not directly call markNeedsPaint(), as explained above.

3 Summary

This section introduced the main flow of a Flutter app from startup to display on the screen, focusing on Flutter's rendering process, as shown in Figure .

c128d593-0b2c-44dd-a3f8-eb413c169ce8.png

It is worth noting that the build process and layout process can be executed alternately, as explained when introducing the LayoutBuilder. Readers should have a general impression of the entire rendering process, which will be detailed later. However, before delving into the rendering pipeline, we must thoroughly understand the classes Element, BuildContext, and RenderObject.