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
andwindow.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
andwindow.onTextScaleFactorChanged
. It serves as a bridge between the render tree and the Flutter engine.WidgetsBinding: Provides callbacks like
window.onLocaleChanged
andonBuildScheduled
. 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:
Rebuild the widget tree.
Update the layout.
Update the compositing information.
Redraw.
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
:
The current element's
markNeedsBuild
method is invoked, marking it as dirty.The
scheduleBuildFor
method is called, adding the current element to thedirtyElements
list of the pipeline owner.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 .
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.