Bindings are singletons that connect the engine to the framework, one per area, and WidgetsFlutterBinding mixes them all into one class. Each mixin owns one job, from pointer events to the build owner.
**GestureBinding: receives pointer events from the engine, runs hit testing and dispatches the event along the hit path to the gesture arena
SchedulerBinding**: frame callbacks and vsync scheduling: scheduleFrame, then handleBeginFrame and handleDrawFrame
**ServicesBinding: platform channels (the binary messenger), asset loading, keyboard and lifecycle messages
PaintingBinding: the image cache and shader warm-up
SemanticsBinding: turns the accessibility tree on and off
RendererBinding**: owns the render tree and the root PipelineOwner; its drawFrame flushes layout, paint and semantics
**WidgetsBinding**: owns the BuildOwner and the root element, implements runApp and delivers app lifecycle events to observers
**ensureInitialized()**: runApp calls it for you; call it yourself only when main() uses plugins first (details in the fundamentals topic). Tests install TestWidgetsFlutterBinding, and the first binding created wins
FlutterSenior
Flutter Internals
Three trees, RenderObject, Element tree, BuildOwner, rendering pipeline
Questions closed
0 / 22
- 0Review
- 4Asked often
Questions
Widgets are cheap immutable descriptions rebuilt all the time, Elements are the long-lived instances that hold state and position, and RenderObjects do the expensive layout and painting. Splitting them lets Flutter rebuild descriptions freely while reusing the costly objects.
Widget: the blueprint you return from build(); thrown away and recreated on every rebuild
Element: the slot in the tree that holds the current widget, the State and the inherited dependencies; the BuildContext you get in build() is this Element
RenderObject: sizes, positions, paints and hit-tests; updated in place through setters instead of being recreated
How they meet: on rebuild each Element compares the new widget with the old one: same runtimeType and same key means it keeps itself and its RenderObject and just takes the new configuration
Two kinds of Element: ComponentElement (for StatelessWidget, StatefulWidget, InheritedWidget) only builds other widgets and owns no RenderObject; RenderObjectElement (for Padding, Text's RichText, Row) owns exactly one
Why not one tree: if the description and the drawing object were the same thing, every setState would recreate render objects and redo layout and paint for the whole subtree
Flutter never asks the platform for a button or a text view: it paints every pixel itself with its own renderer into a surface the platform provides. The OS only hosts that surface and delivers input and lifecycle events; the AOT-compiled Dart code runs in the engine inside your app process.
Framework (Dart): widgets, layout, gestures and animation, all built on dart:ui
Engine (C++): runs the Dart VM, exposes dart:ui (canvas, text, scheduler) and rasterizes frames with Impeller, the default on iOS and modern Android; Skia still backs the web renderers, Windows and Linux desktop and older Android devices
Embedder: the native shell: FlutterActivity on Android, FlutterViewController on iOS, a window and event loop on desktop, CanvasKit or skwasm on the web
Why it still feels native
Machine code: release builds are AOT-compiled to ARM, no interpreter or bridge per frame
Platform services stay native: text input talks to the system keyboard, the semantics tree is translated into the OS accessibility tree, and a real native view (map, WebView) comes in as a platform view, at a compositing cost
The trade-off: pixel-identical UI on every OS, but Material and Cupertino widgets are re-implementations that must track platform look and behaviour themselves
How it boots
The native entry point creates a FlutterEngine, the engine starts the Dart VM, loads the AOT snapshot and calls main(); runApp attaches the root widget and schedules the first frame. In add-to-app you pre-warm an engine (or a FlutterEngineGroup on Android and iOS) so the first Flutter screen does not open blank.
Because the widget at that slot changes type. When _padded flips, the Column's second child goes from Counter to Padding; Widget.canUpdate fails, so the old element and its State are thrown away and a fresh Counter is inflated one level deeper.
Element.updateChild handles every child slot with four cases:
Identical widget instance: nothing to do, the whole subtree is skipped
**canUpdate is true** (same runtimeType and same key): the element is kept and receives the new widget; State survives
**canUpdate is false**: the old element is deactivated (its State disposed at the end of the frame) and a new one is inflated
Child removed or added: deactivate or inflate
Matching is per slot and per depth: Flutter does not look one level down to find the old Counter, and const does not help, because the element at that slot changed type. Fixes: keep the tree shape stable (always wrap in Padding, switch only the padding value), lift the count into the parent or a state holder, or as a last resort a GlobalKey, which lets the element move but costs a reparent.
An Element is mounted when its widget first appears, updated in place while the widget keeps the same type and key, deactivated when it leaves the tree, and unmounted at the end of that frame unless a GlobalKey claimed it back. State.dispose runs only at unmount, not at removal.
mount: createElement, then mount attaches it to its parent slot; a RenderObjectElement creates and attaches its render object; for a StatefulWidget this is where initState, didChangeDependencies and the first build run
update: a new widget with the same type and key arrives; State.didUpdateWidget(oldWidget) runs, then build
deactivate: the parent dropped it: the render object is detached, the element goes to the BuildOwner's inactive list and State.deactivate runs down the subtree
activate: a GlobalKey took the element back in the same frame; State.activate runs, the element rebuilds, and didChangeDependencies runs if it had inherited dependencies; the State survives the move
unmount: finalizeTree() at the end of the frame unmounts whatever is still inactive, and that is where State.dispose runs; the element is now defunct
The practical rules: release controllers and subscriptions in dispose, never free in deactivate what you do not recreate in activate, and check mounted in async callbacks, because setState after dispose throws setState() called after dispose().
setState only marks an element dirty and requests a frame. On the next vsync Flutter runs a fixed pipeline (animate, build, layout, paint, composite on the UI thread, then rasterize on the raster thread), and each stage must fit into one refresh interval: about 16 ms at 60 Hz, 8 ms at 120 Hz.
Vsync: the scheduler wakes up for a requested frame
Animate: tickers and AnimationControllers advance to the frame timestamp
Build: dirty elements run build() and reconcile their children
Layout: constraints go down, sizes come back up, parents set child offsets
Paint: render objects record drawing commands into layers, not pixels
Composite: the layer tree becomes a scene and is handed to the engine
Raster: the raster thread turns the scene into GPU commands
Two threads, two kinds of jank: DevTools shows a UI bar and a raster bar per frame; on Android and iOS the UI thread has been the platform main thread since Flutter 3.29
Slow UI bar: rebuilding too much, heavy work in build(), unbounded lists, intrinsic layout
Slow raster bar: huge images, saveLayer from Opacity, ShaderMask, BackdropFilter and some clips
Measure in profile mode: debug builds are JIT-compiled and full of asserts, their timings mean nothing
When vsync arrives, SchedulerBinding runs three kinds of callback in a fixed order: transient ones first, then persistent ones that build the frame, then post-frame ones once it is finished.
Who asks for a frame: an idle app draws nothing; setState, a dirty render object or a running Ticker requests one
Transient: one-shot, run first with the frame timestamp; every Ticker, and so every AnimationController, lives here
Persistent: registered once at startup and never removed; drawFrame is one of them, so build, layout, paint and compositing happen here
Post-frame: added with addPostFrameCallback, run once after the frame is finished
Why it matters: sizes exist only after layout, so measuring a widget or calling setState because of a measurement belongs in a post-frame callback, not in build()
It fires once: re-register for the next frame, and check mounted, because it can run after the widget is gone
Positioned draws nothing. It writes top/left into the child render object's parentData slot, and only a Stack puts a slot there that has those fields.
Every render object carries a parentData field that belongs to its parent: RenderStack installs a StackParentData, RenderFlex a FlexParentData (flex, fit). A ParentDataWidget has no render object of its own; it finds the nearest descendant render object, writes its fields there and tells the parent to lay out again.
The error is a type check: inside a Column the slot is a FlexParentData, so a Positioned that needs StackParentData asserts
"Directly inside" means render objects: a Builder or an InheritedWidget in between is fine; a Padding is not, because its own render object takes the slot
Same mechanism: Expanded/Flexible for Row/Column, LayoutId for CustomMultiChildLayout; your own multi-child render object can define one too
Two things: it calls markNeedsLayout where markNeedsPaint is enough, and it has no equality guard. Since updateRenderObject re-assigns every property on every rebuild, each rebuild re-runs layout up to the nearest relayout boundary, and paint after it, even when the colour did not change.
A render object has one mark per pipeline phase, and a setter should use the cheapest one its change actually invalidates:
**markNeedsLayout**: geometry changed; climbs to the relayout boundary, re-runs performLayout, and paint follows
**markNeedsPaint: only pixels changed; climbs to the nearest repaint boundary and repaints there, sizes untouched
markNeedsSemanticsUpdate: only a label, flag or action changed
Guard first**: if (value == _color) return; makes a rebuild that changed nothing cost nothing
The fix is two lines: add the guard and switch to markNeedsPaint(). debugRepaintRainbowEnabled and debugPrintMarkNeedsLayoutStacks show what is really being redone.
StatusDot never overrides updateRenderObject, so a new color never reaches the render object. The element keeps the RenderStatusDot it created on the first build, and that keeps painting the original color until the widget is remounted.
Why it happens: on rebuild Widget.canUpdate matches (same type, same key), so the RenderObjectElement keeps its render object and calls widget.updateRenderObject; the base implementation is empty, and createRenderObject runs only once, at mount
The fix: override updateRenderObject and assign every field the widget passes to createRenderObject; the setter's equality guard keeps that free when nothing changed
Why review misses it: anything that remounts the widget hides the bug: a new key, a hot restart, leaving the screen and coming back
How to catch it: a widget test that pumps the widget twice with different values and checks the render object (tester.renderObject<RenderStatusDot>(...)) or a golden; keep createRenderObject and updateRenderObject next to each other so a new field lands in both
On pointer down Flutter walks the render tree from the root and asks each RenderBox whether the point is inside it; everything that says yes is collected into a HitTestResult, deepest first, and the event is dispatched along that path.
The check: RenderBox.hitTest first tests size.contains(position), then hitTestChildren, then hitTestSelf; a box that is not hit never asks its children
Hence the overflow bug: a child painted outside its parent's bounds (a Stack with clipBehavior: Clip.none, a negative offset) is never reached, because the parent already said no
Order: multi-child parents test children in reverse paint order, so the one painted on top wins; transforms convert the position into the child's coordinates
Done once per pointer: the path is computed on down (and on hover), then move and up events go to the same path even if the finger leaves the widget
Who decides: every hit entry gets the event; GestureDetector recognizers then compete in the gesture arena; HitTestBehavior, IgnorePointer and AbsorbPointer change who gets hit at all
No. The Overlay builds everything hidden behind an opaque route inside TickerMode(enabled: false), and a muted ticker stops calling its callback, so the animation freezes until the route is popped.
Routes are overlay entries. Below the first opaque entry, entries are either not built at all (maintainState: false) or kept with their tickers muted. SingleTickerProviderStateMixin listens to TickerMode directly, so nothing of yours rebuilds.
Muted is not stopped: isActive stays true, the callback just never fires
It jumps on resume: elapsed time keeps counting, so on unmute the controller catches up in one step
**await controller.forward() never completes while the route is covered: the classic hang when navigation or logic is chained onto an animation
Offstage and IndexedStack mute nothing**: their hidden children keep ticking; Visibility(maintainAnimation: false) or your own TickerMode does
debugFillProperties is the Diagnosticable hook that lists an object's properties for debugging tools. Override it in your widget, State or render object and add typed properties; the Inspector, debugDumpApp and framework error messages will then show them.
Who has it: widgets, State, render objects and most framework classes mix in Diagnosticable; for a plain class add with Diagnosticable
Typed properties: IntProperty, DoubleProperty, StringProperty, FlagProperty, EnumProperty, ColorProperty, IterableProperty, or DiagnosticsProperty<T> for anything else, all re-exported by package:flutter/material.dart (ColorProperty comes from painting.dart, the rest from foundation.dart)
**defaultValue: a property equal to its default is hidden from the summary, which keeps the Inspector readable
Call super first: otherwise you drop the inherited properties such as the key
Cost**: it runs only when a tool asks for diagnostics, never during a normal frame; the diagnostic_describe_all_properties lint can enforce it in a design-system package
Because rebuilding widgets only creates small immutable objects and compares them; the expensive Elements and RenderObjects are reused and touched only when something actually changed. A rebuild costs Dart allocations and calls, not layout and paint.
What keeps it cheap
Short-lived objects are nearly free: Dart allocates them by bumping a pointer in the young generation, and the scavenger collects dead ones without touching the old heap
Identical instances stop the walk: updateChild returns early when the new widget is the same instance as the old one, so a const subtree or a child passed through unchanged is skipped entirely
Reuse, not recreation: a matching widget only updates its element; updateRenderObject assigns properties, and setters with equality guards dirty nothing when values are equal
Layout and paint are separate: they run only for render objects marked dirty, bounded by relayout and repaint boundaries
When it stops being true
**Work inside build(): sorting, parsing, formatting dates or creating controllers runs on every rebuild
Rebuilding high in the tree every frame**: an animation calling setState on a page instead of an AnimatedBuilder scoped to the moving part
Broad dependencies: MediaQuery.of(context) or a whole-object context.watch rebuilds on unrelated changes
Type or key churn: a widget that changes type or gets a new key each build destroys elements and render objects instead of reusing them
Once. BuildOwner sorts the dirty list by depth, so the parent builds first, and the descendant's own dirty flag is consumed by that same pass.
setState only calls markNeedsBuild: the element is flagged dirty, handed to BuildOwner.scheduleBuildFor, and a frame is requested. Nothing builds right away. On the frame, the build scope sorts dirty elements shallowest-first and walks them:
The parent rebuilds and hands a new widget down through updateChild
The child updates in place: the types match, so the update ends in a rebuild right there with the new configuration, and clears the dirty flag
The child's own entry comes later and is skipped because it is no longer dirty; if the parent removed the child instead, the entry is inactive and skipped too
That order also explains the setState() or markNeedsBuild() called during build assert: dirtying a descendant of the element currently building is allowed, because the walk will still reach it, while dirtying an ancestor or a sibling would require walking backwards. The fix for the second case is to defer the change to a post-frame callback or move it out of build() entirely.
A GlobalKey is a registry entry: one key points at exactly one live element. The error means two subtrees claimed the same entry in one frame, for example the outgoing and incoming page during a push animation.
When the framework is about to inflate a widget that carries a GlobalKey, it looks the key up and tries to take the existing element: detach it from its old parent and activate it under the new one. The subtree moves with its State, scroll offsets and render objects intact; that is reparenting.
The move is not free: deactivate and activate run, the subtree rebuilds and didChangeDependencies fires again when the subtree has inherited dependencies, since it may now sit under a different Theme or Navigator
It works only within one frame: once finalizeTree has unmounted the element, there is nothing left to take
Why the duplicate fires: a key in a static or top-level field used by two pages, the same key twice in one list, or one key shared by two Navigators
**Never create a key in build()**: a new key every build means a new element every build, which silently loses state; keep it in a State field
A widget instance itself can appear twice: the same const widget in two places is fine; it is the GlobalKey that must be unique
They are the two managers of a frame: BuildOwner drives the Element tree through the build phase, PipelineOwner drives the RenderObject tree through layout, compositing bits, paint and semantics.
**BuildOwner**: keeps the dirty-element list; in buildScope it sorts it by depth, calls build() and reconciles children; it also keeps the inactive-element list, finalizes the tree at the end of the frame and tracks GlobalKey registrations
**PipelineOwner**: keeps the needs-layout, needs-compositing-bits, needs-paint and needs-semantics sets, and each frame flushes them in exactly that order: flushLayout, flushCompositingBits, flushPaint, flushSemantics
Why split: building is a cheap diff of immutable descriptions, while layout and paint mutate heavy objects under strict ordering rules (no paint before layout has settled); separate owners let each phase run only over what it dirtied
Two independent paths: setState goes through BuildOwner.scheduleBuildFor; markNeedsLayout or markNeedsPaint goes to the PipelineOwner; both end up requesting a frame, and drawFrame runs build first, then the pipeline
Consequence: a markNeedsPaint on a leaf costs a repaint, not a rebuild of the widget that created it, which is why animations driven straight into render objects (FadeTransition, CustomPainter with repaint:) are cheap
Where you meet them: WidgetsBinding.instance.buildOwner, RendererBinding.instance.rootPipelineOwner; tests, offscreen rendering and multi-view embedders create additional pipeline owners
It climbs to the nearest relayout boundary and stops there. That is the only reason a size change deep inside a list does not re-lay-out the whole screen.
markNeedsLayout sets the dirty flag; if this object is not a boundary it calls the same method on its parent, level by level. The boundary adds itself to the PipelineOwner's layout list and requests a frame; next frame flushLayout re-runs performLayout from there down, never above it.
A render object becomes a relayout boundary at its last layout if any of these held:
Tight constraints: its size cannot change whatever happens inside
**parentUsesSize: false: the parent never read the child's size, so it has nothing to recompute
sizedByParent**: the size depends on the constraints alone and is set in performResize
So child.layout(constraints) without parentUsesSize: true is not just a flag; it isolates that child. A SizedBox with both width and height (tight constraints) gives the same isolation for free, while Row/Column children, even Expanded ones, do not, because RenderFlex reads their size (parentUsesSize: true); IntrinsicHeight does the opposite and adds a speculative layout pass over the subtree. To see who dirtied what, set debugPrintMarkNeedsLayoutStacks = true.
performLayout reads child.size but lays the child out without parentUsesSize: true. Debug builds throw "RenderBox.size accessed beyond the scope of…"; in release, when the child later changes size, the caption keeps its old size, so content overflows or leaves a gap.
The contract: parentUsesSize says whether the parent's layout depends on the child's size; with false the parent promises it does not, so the child becomes a relayout boundary
What breaks: when the text inside changes, the child's markNeedsLayout stops at the child itself; flushLayout re-lays out only the child and never re-runs the parent's performLayout, so the parent's size is left from the previous frame
The fix: pass parentUsesSize: true whenever the parent reads child.size after layout; the child's dirt then travels up to the parent. Leave it false only when the parent ignores the child's size, which is exactly where the boundary pays off
How to catch it: the debug assert fires in any widget test or debug run, so the stale-size symptom only shows in release; the cheap guard is to keep parentUsesSize: true next to every child.size read (review rule) and to run widget tests that mount the render object at all
Paint does not produce a bitmap: it records drawing commands into a tree of layers, and compositing turns that layer tree into a scene for the raster thread. A separate layer lets a subtree be repainted, moved or faded without repainting its neighbours.
Who creates layers
**RepaintBoundary: always gets its own layer; its subtree's recorded picture is reused until something inside marks it dirty
Effects that need one**: Opacity (0 < value < 1), ShaderMask, BackdropFilter, platform views and Texture
Only when needed: ClipRRect, ClipPath and Transform paint straight into the canvas unless a descendant already has its own layer; flushCompositingBits recomputes the needsCompositing flag for nodes that changed
What it costs
Repaint isolation: a dirty render object repaints up to the nearest repaint boundary only, so a ticking clock inside one does not repaint the page around it
Memory and bookkeeping: every boundary keeps its picture alive and adds a layer to composite, so wrap a part that repaints constantly next to expensive static content (or the reverse), not everything
**saveLayer is the expensive part**: Opacity over a complex subtree, ShaderMask, BackdropFilter and Clip.antiAliasWithSaveLayer render into an offscreen buffer; a colour with alpha (Colors.black.withValues(alpha: 0.6)) avoids it; FadeTransition or AnimatedOpacity only save the rebuild, the offscreen buffer stays
How to see it
debugRepaintRainbowEnabled (DevTools "Highlight repaints") flashes each layer that repaints, debugPaintLayerBordersEnabled outlines layers, and the raster bar in the performance view shows what the extra layers cost.
Because the builder does not run in the build phase. It runs inside performLayout, in a build scope the framework opens while layout is already in progress, and by then the ancestors' build is over.
LayoutBuilder's element does not join the frame's dirty list; its rebuilds are scheduled as a layout callback on its render object. During layout, performLayout runs that callback: the builder runs with the real constraints, the subtree is reconciled, and only then is the child laid out.
Everything outside that scope is locked: dirtying an ancestor would need a build after its layout has begun, which is the setState() or markNeedsBuild() called during build assert
It re-runs whenever the constraints change: under an animating parent or a resizing keyboard inset, that is a subtree rebuild every frame
Intrinsics and dry layout cannot pass through it: both throw, so a LayoutBuilder under IntrinsicHeight or a baseline-aligned Row is a crash, not a slow path
Reach for the smaller tool first: MediaQuery.sizeOf(context) for the screen size, FractionallySizedBox or Flexible for proportions; LayoutBuilder only when the parent's constraints really are the input
You write a RenderObjectWidget that creates and updates a RenderBox, and the box implements layout, paint and hit testing itself. Reach for it only when you need a layout rule no combination of existing widgets expresses, or need to avoid extra rebuilds and layers on a hot path.
When it is the right tool
Composition first: Row, Stack, Flow and CustomMultiChildLayout cover most layouts; CustomPainter covers custom drawing without children
Custom render object: children whose positions depend on each other's sizes in a way those cannot express, baseline-aware layouts, or a widget used in hundreds of list rows where every rebuild and layer counts
The contract
Widget side: createRenderObject builds the box, updateRenderObject assigns properties on every rebuild; setters guard on equality and call markNeedsLayout or markNeedsPaint
Layout: performLayout lays out children, sets size within constraints and writes child offsets into parentData; pass parentUsesSize: true only when you read the child's size
Dry layout and intrinsics: implement computeDryLayout with the same sizing rule: containers that measure a child without laying it out (Wrap intrinsics, ListTile, a WidgetSpan in Text) call getDryLayout, and the default asserts "does not implement computeDryLayout". IntrinsicHeight uses the separate computeMinIntrinsic*/computeMaxIntrinsic*, which silently return 0.0 until you override them
Paint and hit testing: paint draws the object itself and paints children at their offsets; hitTestChildren/hitTestSelf decide what receives pointers
Children: RenderObjectWithChildMixin for one; ContainerRenderObjectMixin plus RenderBoxContainerDefaultsMixin (default paint and hit testing) and your own ContainerBoxParentData for many
Write the sizing rule once and call it from both passes, and turn on debugCheckIntrinsicSizes in tests to catch dry and real layout drifting apart.