-
-
Notifications
You must be signed in to change notification settings - Fork 0
Extracted Features
analyze records these build-tree features for each rebuild scope: a State subclass, a
ConsumerWidget or HookConsumerWidget, or a supported state-management builder callback. The
analyzer emits 18 identity and metric fields, plus three that record which files those metrics were
read from. Output Formats lists their JSON keys and wire types.
All build metrics below are computed by one traversal (TreeExtractor); identity fields are
assigned during scope discovery. The metric scopes are easiest to understand from these
traversal rules:
-
Root. Analysis starts at the scope's body: the
build()body of aStatesubclass or consumer widget, or the callback body of a builder-pattern widget. Scopes nest, and the metrics overlap deliberately: aBlocBuildercallback inside aState.build()is counted in theStaterow (a parent rebuild re-runs it) and gets a row of its own. -
Custom child widgets. Every non-const instantiation of a custom widget class (any
class outside the Dart SDK and
package:flutter) is followed: that class's ownbuild()body is resolved and analyzed too, breadth-first, transitively through grandchildren. A widget from a package counts as custom and is followed like any other; before 0.7.0 it was queued and then dropped, because its file sits outside the analyzed roots. The framework itself is never followed, and that is a correctness decision rather than a cost one: the traversal counts every branch of a build body rather than the branch that ran, so walking_ScaffoldState.buildwould make aScaffoldcarrying only abody:count identically to one carrying everything aScaffoldcan ever show. A customStatefulWidgetchild has nobuild()of its own, so its state class'sbuild()is what reruns on rebuild, so the traversal resolves the State class (from thecreateState()body, falling back to the library'sextends State<Widget>declaration) and analyzes that instead. Each class is analyzed once per record, even if instantiated many times. -
Helper methods. A reference to a widget-returning callable inside the widget tree (nested in
widget arguments or in a
returnexpression) is treated as a helper. Three reference forms count: a direct invocation (_buildRow()), a method tear-off (items.map(_buildRow),itemBuilder: _buildRow), and an explicit widget-returning getter (_header). A helper may return oneWidgetor a collection of them.List<Widget> _buildRows()andList<DropdownMenuItem<T>> _buildItems()are widget factories: the caller splices the result straight intochildren:oritems:, so those bodies run on every rebuild and are analyzed like any other helper. SDK collection methods are excluded even when their type saysList<Widget>: initems.map(_buildRow).toList(),toListbuilds nothing, so only_buildRowcounts. Resolution is element-based, so instance methods, static methods of other classes, and top-level widget functions all resolve; plain widget fields (synthetic getters) are data, not helpers, and are excluded. Helper bodies feed thehelper*widget features, and their execution cost signals (iterations, per-element widgets, allocations, list-rendering strategy, layout-dependent builders, and decision points) merge into the corresponding aggregate output features, because helpers run as part ofbuild(). Helper→helper chains are followed, and helpers of custom child widgets are analyzed as well (deduped per class + member). -
Local functions. A function declared inside a body (
Widget row(int i) => …;) belongs to that body rather than being a helper of its own, so it is never resolved or deduped across classes. Its widgets are recorded at the call site. A row builder declared just above the loop that uses it costs one row per element, and reading the body where it is written would put those widgets outside every per-element scope. The body is read once, at the first call site; a local function that is declared and never referenced is read at the end of the traversal so it still contributes its widgets. -
Const boundary. A
constwidget instantiation is a canonicalized subtree that Flutter's reconciliation skips, so it carries no per-rebuild cost: it is counted once as a const unit, its children are not entered, and a const custom child'sbuild()is not recursed into. This holds wherever the widget is written, whether in a build body, a child build, or a helper body, so a const widget in a helper counts as a const unit rather than a helper widget. -
Non-widget allocations. Instantiations of non-
Widgettypes (EdgeInsets,TextStyle,BorderRadius, and application models) are not counted as widgets and add no widget nesting depth. Non-const instances incrementvalueObjectAllocCount; const instances do not. Their arguments are still searched for widgets nested inside them.List.generateandIterable.generateare the exception worth naming. They are factory constructors rather than method calls, but the callback still runs once per element, so they open a per-element scope like any loop does. - Broken files. A file with compile errors is skipped entirely (and reported in the summary): its unresolved types would silently classify every widget as a value object and emit a near-zero garbage row.
-
Interaction callbacks. A closure a rebuild cannot run is not rebuild cost, so it is not
entered: its widgets, allocations, iterations, helper references and decision points all stay
out, and a custom widget built inside it does not seed rule 2. Three shapes qualify: the value
of a named argument whose label is
onfollowed by a capital (onPressed,onTap,onChanged,onLongPressand the rest of the family), the value ofvalidator,onError,onDone,onCancelorconfirmDismiss, and an argument tothen,catchError,whenComplete,addListener,addPostFrameCallback,scheduleMicrotask,Future.delayedorTimer. A tear-off in one of those slots (onPressed: _submit) is treated the same way, and a local function reachable only from such a slot is dropped rather than read at the end of the traversal by rule 4. Everything else is entered as before:builder,itemBuilder,separatorBuilder, the builder callbacks of the state-management widgets (positional ones such asObxincluded), and any expression in a handler slot that is not itself a closure, sinceonTap: enabled ? _a : _bis evaluated while the tree is built. The rule bites hardest through rule 2: anonPressedthat pushes aMaterialPageRouteused to merge a whole other screen's build tree into this scope, a rebuild charged for a page it never renders. (Callbacks were entered up to 0.6.0, so every feature below was larger there by whatever the handlers held, and rows from those versions cannot be pooled with these.)isolateprunes the same slots out of the file it writes, so the two sides describe one rebuild.
"Across the build tree" below always means: the root build() plus every custom child widget's
build() reached by rule 2, and never the callback bodies rule 8 excludes.
| Feature | Type | Description |
|---|---|---|
instanceId |
String |
Stable ID for the record: a 31-bit hash of "<root-relative file path>:<class name>" for a State scope, or "<root-relative file path>:<scopeType>:<scopeName>#<n>" for any other rebuild scope (forward slashes on every platform), rendered as hex. Relative to the analyzed project root, so the ID is stable across machines and checkout directories. Used to join static features with runtime profiler events emitted under the same ID. (Hashed from the absolute path before 2026-07-20; older JSONL files carry machine-specific IDs.)
|
filePath |
String |
Source file containing the rebuild scope, relative to the analyzed project root (the same path the ID hashes). |
scopeName |
String |
Class name of the scope (e.g. _MyWidgetState), or <Widget>_builder for a builder callback (e.g. BlocBuilder_builder). (Named stateClassName before rebuild-scope support.)
|
scopeType |
String |
Kind of rebuild scope: State, ConsumerWidget, or the builder widget name (BlocBuilder, BlocSelector, BlocConsumer, Consumer, Selector, Obx, GetX, GetBuilder, Observer). |
| Feature | Type | Description |
|---|---|---|
treeNonConstWidgetCount |
int |
Total non-const widget instantiations across the build tree. Excludes const widgets (see treeConstWidgetCount), value objects, and widgets built inside helper bodies (those go to helperWidgetCount). |
treeMaxWidgetNestingDepth |
int |
Deepest widget nesting level reached across the build tree. Depth composes across classes: a child widget instantiated at depth d whose own build reaches internal depth k yields an absolute depth of d + k (for a StatefulWidget child, k is measured in its State class's build). A const widget occupies its own level but its subtree adds nothing. Custom widgets instantiated inside helper bodies restart from depth 0 (helper nesting is tracked separately in helperMaxWidgetNestingDepth). |
treeConstWidgetCount |
int |
Number of const widget instantiations across the build tree, as a raw count, not a ratio. Because of the const boundary rule, an entire const subtree counts as 1. Helper bodies are included. A const widget written in a helper is still a const unit, which is what makes a const edit inside a helper visible in the metrics at all. (Emitted as constConstructorRatio before 2026-07-18; older JSONL files use that key. Helper-body const widgets were folded into helperWidgetCount before 2026-08-15.)
|
rootBuildReturnsConstWidget |
bool (0/1) |
Whether every top-level return of the root build() is a const instantiation. Applies to return const …; statements and expression bodies (Widget build(_) => const …;) alike. Every exit has to qualify because of the guard pattern. A build that returns const SizedBox.shrink() while loading and a full tree otherwise pays the full cost on the path that actually runs, so it does not count as a const build. Returns inside closures never set it, whether the closure sits in widget arguments or in the build preamble. (Any single const return was enough before 2026-08-15.)
|
Both features aggregate across the whole tree (root build, custom child builds, and helper bodies); a match anywhere counts.
| Feature | Type | Description |
|---|---|---|
treeListRenderingStrategy |
int (0/1/2) |
The most expensive list-shaped rendering form reachable from build(), ranked by UI-thread build cost and aggregated as the max. 0 = none: no list-shaped multi-child rendering, including a fixed-arity children: literal (an if-element is an O(1) branch, not a scaling child count). 1 = lazy / viewport-bounded: a scroll list built through .builder/.separated/.custom/.useDelegate, or a list widget that is lazy by contract (AnimatedList, AnimatedGrid, and the sliver lists). 2 = eager / O(N): a scroll list fed a concrete children: (every child builds on every rebuild, no viewport culling), a sliver list fed a SliverChildListDelegate (that delegate materializes the whole child list, so the sliver is only nominally lazy), or a Column/Row/Wrap/Flex whose children: is runtime-length. The scroll lists are ListView, GridView, ReorderableListView, PageView, and ListWheelScrollView, and for all of them laziness follows the constructor rather than the class. Runtime-length is decided by the shape of the expression, not an op whitelist: a spread or for-element inside the literal, or any non-literal expression such as a bare list variable, items.map(…).toList(), List.generate(…), or a helper call. Because the aggregate is a max, one eager list anywhere in the tree hides the strategy of every other list in it. (Replaces the boolean buildUsesListViewBuilder on 2026-07-20; the boolean missed every eager form, including SingleChildScrollView over a mapped Column. Only ListView/GridView/SliverList/SliverGrid were recognized before 2026-08-15, and the sliver delegate was ignored.)
|
usesLayoutDependentBuilder |
bool (0/1) |
The tree instantiates LayoutBuilder, CustomMultiChildLayout, or Flow, widgets whose build depends on incoming layout constraints. |
| Feature | Type | Description |
|---|---|---|
treeCyclomaticComplexity |
int |
Cyclomatic complexity summed over every build body in the tree (root + custom children), plus the decision points of every analyzed helper body (each helper contributes its complexity − 1, so the shared +1 base is not re-added per helper). Each build body starts at 1 and gains +1 per if (statement or collection-if element), for (statement or collection-for element, for-in included), while, do, switch case (statement cases and switch-expression cases; statement default excluded), catch, &&, ||, ??, and ternary ?:. Because it is a sum, a tree of n build methods has a floor of n. Decision points inside an interaction callback do not count (rule 8): an if in an onPressed is not logic the build cycle runs. |
treeIterationCount |
int |
Number of iteration constructs across all build bodies and helper bodies: for / while / do statements, collection-for elements inside literals, calls to linear collection ops (forEach, map, where, any, every, reduce, fold, expand, generate, sort, firstWhere, lastWhere, singleWhere), and the List.generate / Iterable.generate constructors. Each op in a chain like .where(…).map(…) counts individually. (List.generate is a constructor, not a method call, and went uncounted before 2026-08-15.)
|
treeMaxIterationNestingDepth |
int |
Maximum lexical nesting depth of those iteration constructs within any single analyzed body, covering build bodies and helper bodies. Chained collection ops count as sequential, not nested (only the callback argument runs per element). Nesting is not composed across classes: a child widget created inside a parent's loop does not nest the child's own loops. |
helperReferenceCount |
int |
Number of reference sites of widget-returning helpers inside build bodies (root + custom children): direct invocations, method tear-offs, and explicit getter reads (rule 3 above), whether the helper returns one widget or a collection of them. Reference sites, not distinct methods: calling _buildRow() three times counts 3. Not counted: references made from inside helper bodies, linear collection ops that happen to yield a Widget (e.g. firstWhere on a List<Widget>, which is an iteration, see treeIterationCount), SDK collection methods that pass widgets through without building any (toList, cast, followedBy), any member named build (e.g. super.build(context) from the keep-alive mixin), plain widget fields (synthetic getters), and invocations of function-typed variables or closures. That last case includes a function held in a field, so a callback assigned in a constructor initializer and invoked as widget.leading(item) stays out of reach. |
iterationWidgetCount |
int |
Non-const widget instantiations inside a per-element scope, summed across build bodies and helper bodies: a loop body, a linear-collection-op callback (.map((e) => Card(...))), a List.generate callback, or the argument list of a lazy list constructor (itemBuilder runs per visible element). The per-element cost multiplier of a rebuild: distinguishes ten one-shot widgets from one widget built ten times. Widgets built by a local function count here when that function is called from inside the scope, which is the usual shape when a row builder is declared above the loop that uses it. (Added 2026-07-20; motivated by loop-cost findings in performance/energy prediction literature.)
|
valueObjectAllocCount |
int |
Non-const, non-widget instance creations such as EdgeInsets, TextStyle and BoxDecoration, across build and helper bodies. This is the allocation/GC pressure paid on every rebuild. const value objects are canonicalized and excluded, so const-ing a padding lowers this count. (Added 2026-07-20; motivated by allocation-density findings in energy prediction literature.)
|
Cover the bodies of widget-returning helpers reached from a build body (rule 3 above): helpers of
the root State class and of every analyzed custom child widget (instance methods, getters,
static methods, and top-level widget functions), returning a widget or a collection of widgets,
followed transitively through helper→helper chains, each body analyzed once.
| Feature | Type | Description |
|---|---|---|
helperWidgetCount |
int |
Non-const widget instantiations inside all analyzed helper bodies. This is the same split treeNonConstWidgetCount applies to build bodies: const units in a helper go to treeConstWidgetCount, so the three counts never overlap. Custom widgets instantiated in a helper still contribute their own build() tree to the tree-scoped metrics. (Const widgets were counted here too before 2026-08-15, which left const-ness inside a helper invisible in every feature.)
|
helperMaxWidgetNestingDepth |
int |
Maximum widget nesting depth reached inside any single helper body, measured from 0 at the helper body's root. Not composed with the depth of the call site in build(). |
These three fields are not metrics. They describe the evidence the metrics were computed from, which
the traversal rules above make necessary: a row aggregates helper bodies resolved across libraries
and the build() of every custom child widget, so it is a function of a set of files rather than of
the one that declares the scope.
| Field | Type | Description |
|---|---|---|
dependencyFiles |
List<String> |
Every file whose contents were read to produce the metrics, the declaring file included, root-relative and sorted. Files outside the analyzed project root (the SDK, the pub cache) are dropped. Use this rather than filePath when deciding whether a revision changed a scope: an edit to a child widget two files away moves the metrics with no change to filePath. |
unresolvedDependencies |
List<String> |
Closure entries the extraction could not read: a library that did not resolve, or one that resolved while carrying an error-severity diagnostic. Paths where one is known, library URIs otherwise. |
closureResolved |
bool |
unresolvedDependencies.isEmpty. |
packageVersions |
Map<String, String> |
The resolved version of every package the closure entered. Package source contributes to the metrics, so this is what makes the pin to one package_config.json auditable. |
walkedWidgetClasses |
List<String> |
The non-SDK classes whose build bodies were walked, as libraryUri#Name. Meant to be diffed against carriedUiDeclarations on the matching isolate row. |
An unreadable dependency does not fail the row, it shortens it. A child that will not resolve
contributes nothing and takes its whole subtree out of the totals, and a child that resolves while
carrying an error is refused for the opposite reason: its types come back null, so reading it would
count its widgets as value objects and make the row wrong rather than short. The scanned and skipped
counts in the analyze summary see neither, because that gate applies to the file being scanned,
not to the files its metrics are read from. A row with closureResolved false is wrong by an
unknown amount and should be excluded before the numbers are used.
boolfeatures are serialized as1/0in the JSONL. See Output Formats for the wire format.
Commands
Reference
Internals
Contributing