Skip to content

Extracted Features

albertoodev edited this page Aug 24, 2026 · 7 revisions

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.

How the tree is traversed

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:

  1. Root. Analysis starts at the scope's body: the build() body of a State subclass or consumer widget, or the callback body of a builder-pattern widget. Scopes nest, and the metrics overlap deliberately: a BlocBuilder callback inside a State.build() is counted in the State row (a parent rebuild re-runs it) and gets a row of its own.
  2. 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 own build() 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.build would make a Scaffold carrying only a body: count identically to one carrying everything a Scaffold can ever show. A custom StatefulWidget child has no build() of its own, so its state class's build() is what reruns on rebuild, so the traversal resolves the State class (from the createState() body, falling back to the library's extends State<Widget> declaration) and analyzes that instead. Each class is analyzed once per record, even if instantiated many times.
  3. Helper methods. A reference to a widget-returning callable inside the widget tree (nested in widget arguments or in a return expression) 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 one Widget or a collection of them. List<Widget> _buildRows() and List<DropdownMenuItem<T>> _buildItems() are widget factories: the caller splices the result straight into children: or items:, so those bodies run on every rebuild and are analyzed like any other helper. SDK collection methods are excluded even when their type says List<Widget>: in items.map(_buildRow).toList(), toList builds nothing, so only _buildRow counts. 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 the helper* 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 of build(). Helper→helper chains are followed, and helpers of custom child widgets are analyzed as well (deduped per class + member).
  4. 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.
  5. Const boundary. A const widget 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's build() 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.
  6. Non-widget allocations. Instantiations of non-Widget types (EdgeInsets, TextStyle, BorderRadius, and application models) are not counted as widgets and add no widget nesting depth. Non-const instances increment valueObjectAllocCount; const instances do not. Their arguments are still searched for widgets nested inside them. List.generate and Iterable.generate are 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.
  7. 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.
  8. 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 on followed by a capital (onPressed, onTap, onChanged, onLongPress and the rest of the family), the value of validator, onError, onDone, onCancel or confirmDismiss, and an argument to then, catchError, whenComplete, addListener, addPostFrameCallback, scheduleMicrotask, Future.delayed or Timer. 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 as Obx included), and any expression in a handler slot that is not itself a closure, since onTap: enabled ? _a : _b is evaluated while the tree is built. The rule bites hardest through rule 2: an onPressed that pushes a MaterialPageRoute used 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.) isolate prunes 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.

Identity

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).

Build tree: widget counts

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.)

Build tree: list rendering and layout

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.

Build tree: complexity and iteration

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.)

Helper methods

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().

Closure

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.

bool features are serialized as 1/0 in the JSONL. See Output Formats for the wire format.

Clone this wiki locally