-
-
Notifications
You must be signed in to change notification settings - Fork 0
Commands isolate
isolate extracts rebuild scopes such as a Flutter State class or a BlocBuilder callback into
standalone Dart files. The smaller files make it possible to profile one UI segment without running
the rest of the application.
spm isolate --output-dir ./isolated_widgets /path/to/project
spm isolate -o ./isolation-output -j map.jsonl /path/to/project| Flag / Option | Short | Description |
|---|---|---|
--output-dir <path> |
-o |
Required. Directory where the isolated files are saved. |
--jsonl <path> |
-j |
Output JSONL mapping original source paths → isolated file paths. |
--[no-]inline-third-party |
Carry a third-party widget's own tree into the isolated file instead of standing it in. On by default. | |
--inline-max-declarations <n> |
How many third-party declarations one scope may carry. Defaults to 2000. | |
--inline-max-characters <n> |
How much third-party source, in characters, one scope may carry. Defaults to 2000000. | |
--[no-]prune-non-rebuild |
Leave out the code a rebuild cannot run: the body of a handler closure, and any member of the scope's class build() cannot reach. On by default. |
|
--verbose |
-v |
Enable verbose logging of the isolation process. |
<dir> [dir…] |
One or more directories to scan for rebuild scopes. |
Isolation combines static analysis with source transformation.
An AST visitor scans the target directories for rebuild boundaries:
- Classes that extend
State,ConsumerWidget, orHookConsumerWidget. - Anonymous builder functions passed to
BlocBuilder,BlocSelector,BlocConsumer,Consumer,Selector,Obx,GetX,GetBuilder, orObserver.
The TransplantExtractor converts a discovered scope into a new, self-contained StatefulWidget
(GeneratedWidget).
- For a
Stateclass, SPM also finds its companionStatefulWidgetto extract fields and constructors. - For a builder function, SPM extracts parameters such as
stateormodeland converts them into fields on the new state. -
contextis never lifted, sinceStatealready supplies one. A source class that declares its owncontextfield is skipped for the same reason: copying it across shadowsState.context.
--prune-non-rebuild, on by default, leaves out the code a rebuild never executes. It is the rule
analyze already applies before it counts anything (rule 8 under Extracted
Features), asked here from the same predicate, so the isolated file and the row
it is compared with agree on what a rebuild runs. Two things go:
-
The body of a closure in a non-rebuild slot. The closure keeps its signature, its
asyncmodifier and its place in the tree, and loses its body. An empty block replaces a body that returns nothing, and athrowreplaces one that returns a value, because{}completes with null, which aString? validatortolerates and aFuture<bool> confirmDismissdoes not. The deferred hosts,then,addListener,addPostFrameCallback,scheduleMicrotask,TimerandFuture.delayed, always take the empty block: those bodies do run, moments after the scope mounts, so throwing in one would trade an analyzer error for an uncaught exception around the first frame. An argument that is not a closure is untouched, sinceonTap: enabled ? _a : _bis evaluated while the tree is built. -
A member of the scope's class that
build()cannot reach. The reachable set is the closureTreeExtractorwalks when it counts: the scope body, the helpers it calls, and the helpers those call. A member named only by a tear-off in a handler slot keeps its signature and loses its body, since the reference is evaluated while the tree is built and the name has to resolve.initStateanddidChangeDependenciesare dropped like any other unreachable member, and what they seeded moves to the fixture block in step 5.
What this buys is the crawl it prevents. A handler body is where a scope pushes a route, calls a
service and reaches a repository, so crawling it carried whole destination screens into the file and
stood in for everything they called. A stand-in whose signature then failed to type-check cost the
file outright, because analyze skips any file carrying an error-severity diagnostic.
erasedNonRebuildBodies counts the bodies emptied, and droppedUnreachableMembers names the
members left out. The second is a list rather than a count because a member missing that should not
be is the failure mode of the prune, and a count cannot say which one. --no-prune-non-rebuild
carries everything as earlier versions did, and neither field then appears on a row.
To keep the generated file compilable, SPM recursively crawls the code for every referenced symbol:
- Methods, fields, and getters used within the same class or file move into the generated file.
- A custom widget, enum, or painter from another file is extracted along with its own dependencies,
including the base class it extends. A widget whose supertype went missing would stop reading as a
widget, which changes what
analyzecounts rather than merely failing to compile. - A class that is not a widget but hands one out, such as a theme helper with a
Widget buildDivider(), is also extracted whole. Its body is part of the tree the scope builds. - Business logic, models, services and constants are not copied. They are replaced by a
declaration-only stand-in: the name, the members the scope actually reaches, and nothing else.
Parameter types are
dynamic, and so are return and field types unless the type reachesWidget, directly or as an iterable's element. Bodies hand back a value of the real type where one can be built and a_Stubwhere none can, so calling into a stand-in during build no longer crashes the frame. - A stand-in for a widget renders its
child,childrenorbodyrather than swallowing it. The constructor used to accept the argument while the class builtconst SizedBox.shrink(), so whatever tree the transplanted code passed in was constructed and then never mounted, laid out or painted.childrenwraps inStack, neverColumn: aColumnwith a non-literalchildren:pinstreeListRenderingStrategyat its ceiling for every scope that reaches such a stand-in, in the isolated file and nowhere else. - A stand-in that is not a widget declares its nearest nameable supertype, which carries generic
bounds a
dynamicparameter cannot. Members that supertype already supplies are not redeclared beside it. - Third-party packages are never imported back into the isolated file, but a third-party widget
is extracted the same way a repo-local one is, with its own tree and its companion
State. A stand-in widget has an emptybuild, so a file full of them describes a tree the app never built and cannot be read or run as the scope it came from. Everything else a package declares, meaning value objects, controllers and services, still gets a stand-in that mirrors whether the original was a widget. - Third-party extraction is bounded, unlike the repo-local kind. A scope may carry 2000 third-party
declarations or 2,000,000 characters, whichever it reaches first; past that the rest is stood in
for and the row says so. Without a bound, a scope holding one
BlocBuilderreaches a widget, and from there the crawl walks into the package's own machinery. The cap is a number to report rather than one to aim at:analyzewalks a package library in place with no cap at all, so a scope that exhausts this one measures a smaller tree than the row it is meant to be compared with, andthirdPartyInlineTruncatedis what lets such a row be excluded. - Extracting a package widget is now what makes the isolated row and the in-place row describe the
same tree. Up to 0.6.0 it did the opposite:
TreeExtractoraskedAnalysisContextCollection.contextForfor the package's file, that throws for any path outside the analyzed roots, and the child was dropped along with its subtree, so a row that carried a package widget counted more than the same scope did in place.analyzereads a package library now, and the two sides are comparable across that boundary. Where they still are not, the row says so:thirdPartyInlineTruncated,thirdPartyInlineReverted, and thecarriedUiDeclarationslist, which the analyze row'swalkedWidgetClassesis meant to be diffed against. - Extraction is also undone when it does not pay. After the output is verified, any scope that
carried third-party source and still does not analyse is extracted a second time with the code
stood in for instead, and whichever version has fewer errors is the one kept. A package widget
generic over a type bounded by one of the package's own classes is the shape this exists for:
carrying the widget brings its real bound with it, and the repo-local class that satisfies that
bound in the app is a stand-in here with no supertype at all, so a file that type-checked against
a stand-in's
dynamicstops type-checking. A reverted row carriesthirdPartyInlineReverted: true. The guarantee is exact rather than hopeful: no scope ends up with more errors than--no-inline-third-partywould have given it. - A third-party declaration whose name
package:flutter/material.dartalso exports is carried under a mangled name,Card$spmfor a package's ownCard, and only the references that resolve to it are rewritten. A local declaration shadows the import either way, and an extracted one would otherwise put a body under every use of the name, including uses that meant Flutter's. Standing it in used to be the answer and it cost the subtree; renaming honours the same rule without that.renamedThirdPartyDeclarationslists the names, because the output no longer matchesgit showbyte for byte at those points. - A stand-in carries every member of a type that declares 40 members or fewer, and only the members
the scope reaches for anything larger. Members are recorded against the type the code names, not
the type that declares them, so a member inherited from a Flutter base class such as
ChangeNotifierlands on the stand-in that needs it.
Only dart: libraries and package:flutter/ itself are imported. The trailing slash is the whole
test: a pub package whose name merely begins with flutter_ is third-party, so its code is carried
or stood in for rather than imported, because the isolated file has to resolve against a package
that depends on flutter and nothing else.
An import keeps what the source wrote it with:
| Source directive | Isolated file |
|---|---|
import 'dart:math' as math; |
import 'dart:math' as math; |
import 'dart:convert' show jsonEncode; |
import 'dart:convert' show jsonEncode; |
import 'package:some_package/some_package.dart'; |
not imported; whatever it declares that can build UI is carried as source, and the rest becomes a stand-in |
import 'dart:convert' deferred as c; |
import 'dart:convert' as c; |
The prefix matters because the transplanted body is copied verbatim: a scope that writes math.pi
only resolves if as math comes with it. Prefixes are collected from every file a transplant copied
code from, not only the file the scope was found in. deferred is dropped, since the generated
build never calls loadLibrary().
Some references have no element to work from at all. Two situations produce them:
-
An extension defined in a package the isolated file may not import.
context.read<T>(),context.watch<T>()andcontext.select<T, R>()come from state-management packages, and10.spfrom a sizing package. The member is real, but its definition is behind an import the gate refuses. -
A source project whose dependencies were never installed. If
pub getcannot run, the analyzer resolves nothing outside the project, so every third-party name in it is unresolved.
SPM rebuilds a stand-in for these from the call sites themselves:
extension _SpmBuildContextShim on BuildContext {
dynamic read<T0>() => throw UnimplementedError();
}
extension _SpmNumShim on num {
dynamic get sp => throw UnimplementedError();
}
class MissingCard extends StatelessWidget {
const MissingCard({dynamic title, dynamic count});
@override
Widget build(BuildContext context) => const SizedBox.shrink();
}
late dynamic missingGlobal;Everything reconstructed this way is typed dynamic, because syntax is all there was to read. The
exception is widget-ness: an unresolved constructor call in a widget position, such as the value of
a child: argument, is stood in for by a widget, so analyze still counts the allocation as one.
A project in the second situation is reported rather than passed over. Every mapping row produced
from it carries sourceDependenciesResolved: false, and the run logs the directory.
A scope also reads names it does not declare, and those do not travel with the transplanted source.
Two kinds are lifted onto the generated State:
-
Locals and parameters of the enclosing method. A builder callback sitting inside a method can
read that method's
bool showArchived; the declaration stays behind when the callback moves. -
Members inherited from a package supertype. A package base widget that hands its subclasses a
controllergetter is the recurring case. The transplant never emits such a class, so the member becomes a field.
Names declared inside the scope, members of the enclosing class, and top-level declarations are left alone: the first travel with the source and the other two are already handled by steps 2 and 3.
Lifting a parameter to a field costs it type promotion, which Dart applies to locals but not to
fields. Uses whose promoted type was narrower than the declared one are rewritten with an explicit
cast, so state.items inside an if (state is LoadedState) becomes
(state as LoadedState).items.
The isolated scope is no longer called with arguments, so every value its build depends on is declared in one region of the file. Three kinds of binding end up there:
- a field lifted in step 4, which never had a value here;
- a field the scope declared with its own value.
int _limit = 20;becomeslate int _limit;on the generatedStateandint fixtureLimit = 20;at the top level. The value is relocated, never replaced, so a list seeded with twenty rows still builds twenty; - a field left unseeded because the member that assigned it was dropped by the prune, which is the
usual fate of a
late finalthe originalinitStatefilled in.
SPM generates an initState that assigns each one from a conventionally named symbol:
| Binding | Assignment generated |
|---|---|
field items
|
items = fixtureItems; |
field _controller
|
_controller = fixtureController; |
cross-file project global application
|
application = applicationValue; |
Globals are assigned first, since a field initialiser may read one. Under
--no-prune-non-rebuild the transplanted class can still bring its own initState, and it keeps
it: overwriting a real one would discard setup the scope depends on, so its lifted fields must be
assigned by hand. The symbols below are still declared; only the assignments are missing.
Each of those symbols is declared in one delimited block at the bottom of the same file, with a value of its own type:
// Fixture block. These are the bindings the transplanted scope used to receive
// from the application, and this is the one region to edit when a real value is
// needed: nothing outside it has to change. Collections are generated empty, so
// a scope whose rows come from a lifted list builds none until one is filled in.
late FilterState fixtureFilter;
bool fixtureOnlyActive = false;
String fixtureHeading = '';A field stays where it was declared in three cases, and every reason is about not moving a feature.
A static or const field, because treeConstWidgetCount and rootBuildReturnsConstWidget are
features, and a static const read inside a const constructor stops that call being const the
moment it becomes a variable. A field whose initialiser needs the instance, because it cannot be
evaluated at the top level. And a field with no nameable type, because the fixture declaration has
to write the type down.
final is dropped from a hoisted binding. A member the prune keeps may still assign the field, and
late final would make that a second write to a final.
These used to be late with no value, on the argument that a fabricated default could be mistaken
for the value that was really there. The argument holds for types and not for values: the generated
initState reads every one of them, so an unassigned late is a LateInitializationError before
the first frame, and a transplant that will not run cannot be measured at all. A value is never
measured, because the features come from the shape of the build tree and that shape is fixed before
any of this executes.
Where no value of a binding's type can be built, the late form stays and the name is listed in
unseededBindings on the row, so a file that still cannot mount says which binding stopped it.
The block is the one region to edit. Replacing a value there makes the widget behave as the original did, and nothing outside it has to change.
The transplant copies the original StatefulWidget's constructor verbatim and renames it, so a
scope whose widget declared required this.arguments emits a GeneratedWidget nothing can
construct without knowing that scope's field names and types. GeneratedWidget.fixture() is emitted
beside it, defaulting every field through the same helper:
const GeneratedWidget({Key? key, required this.arguments}) : super(key: key);
GeneratedWidget.fixture({super.key}) : arguments = ChatPageArguments(peerId: '');The copied constructor stays, because it is part of the commit's source. fixtureConstructor on the
row says whether the fixture one was emitted; it is false when a field's type could not be built,
and then mounting that scope means supplying the value first.
Isolated widgets lack the original project's assets and have no network to reach, so the
Skeletonizer substitutes the image source and keeps everything else:
| Construction | Becomes |
|---|---|
AssetImage, NetworkImage, FileImage, MemoryImage
|
const AssetImage('assets/placeholder.png'), still an ImageProvider
|
Image.network, Image.file, Image.memory, Image.asset
|
Image.asset('assets/placeholder.png', …), every other argument kept |
DecorationImage, RawImage
|
kept in place; their image argument is substituted |
Replacing the whole construction, which is what this used to do, cost three things. It erased the
widget subtrees inside errorBuilder, which analyze walks and counts in place. It turned an
ImageProvider into a widget, so the same source moved out of valueObjectAllocCount into
treeNonConstWidgetCount and gained a level of depth. And it put a widget in a provider-typed slot,
which BoxDecoration(image:) and CircleAvatar(backgroundImage:) reject, manufacturing the
commonest error in the output.
loadingBuilder has no home on Image.asset and is the one argument that still drops. The file
carries a marker comment where it stood, and droppedLoadingBuilders counts them.
After the files are written, SPM runs dart format over the output directory. The transplant
concatenates fragments that keep their original indentation, so unformatted output differs between
runs in layout as well as in code. Formatting is best-effort: a scope that produced unparseable Dart is still written out for
inspection.
SPM then analyses the files it wrote, in the same process and through the same analyzer the
extraction used. This is not optional, because the number that matters is not how many scopes were
written but how many of them analyze can read: analyze skips any file carrying an
error-severity diagnostic, so a scope that was written and does not analyse contributes nothing.
The run prints the result:
[spm]: Successfully isolated 15 scopes into ./isolation-output
[spm]: 15 of 15 analyse clean (0 errors in total).
To make the isolated files resolvable where they now sit, the output directory gets a pubspec.yaml
and a .dart_tool/package_config.json copied from the source project with its package roots made
absolute. Both are left alone if they already exist.
The only edit this step makes is removing an import nothing uses. An undefined name is reported and never invented here: a stand-in has to be built where its widget-ness is known, which is steps 3 and 3b, not after the fact.
If no package config can be found for the source project, nothing is analysed and every row reports
verified: false. A run that cannot resolve package:flutter would call every import broken, and
an honest gap is better than a fabricated verdict.
output/
├── .dart_tool/
│ └── package_config.json # copied from the source project, for verification
├── pubspec.yaml # so the isolated files resolve where they sit
├── State/
│ ├── login_form_state.dart
│ └── product_list_state.dart
├── BlocBuilder/
│ └── user_profile_builder.dart
└── mapping.jsonl
Links each isolated file back to its original source. One JSON object per line:
{
"originalPath": "/abs/path/to/project/lib/ui/login_form.dart",
"nodeType": "State",
"isolatedPath": "/abs/path/to/output/State/login_form_state.dart",
"name": "LoginFormState",
"verified": true,
"errorCount": 0,
"warningCount": 0
}| Field | Description |
|---|---|
originalPath |
Absolute path of the source file the scope was extracted from. |
nodeType |
Scope kind, which is also the output subdirectory name, such as State, BlocBuilder, or Consumer. |
isolatedPath |
Absolute path of the generated standalone file. |
name |
Name of the extracted class or builder-owning widget. |
verified |
Whether the file was analysed after being written. false means the run had no package config to resolve package:flutter with, not that the file failed. |
errorCount |
Error-severity diagnostics in the isolated file. Present only when verified is true. A row with anything other than 0 here is a row analyze will skip. |
warningCount |
Warning-severity diagnostics. Present only when verified is true. |
topCodes |
The most frequent error codes in the file, most frequent first, at most five. Omitted when there are none. |
unresolvedImports |
URIs the file imports that do not exist. Omitted when there are none. |
unresolvedNames |
Names the file uses that nothing declares. Omitted when there are none. |
sourceDependenciesResolved |
Present and false only when the source project's dependencies could not be resolved, so every third-party reference in it was rebuilt from syntax. |
inlinedThirdPartyDeclarations |
How many third-party declarations were carried into the file as source. Omitted when none were. |
thirdPartyInlineTruncated |
Present and true only when the inline budget ran out, so third-party UI this scope reaches was stood in for. Such a row measures a smaller tree than the same scope would in place. |
thirdPartyInlineReverted |
Present and true only when carrying the third-party code left the file with more errors than standing it in did, so the stood-in version was kept. Such a row measures a smaller tree than the same scope would in place, so it is one to exclude rather than to compare. |
unseededBindings |
Bindings the fixture block could build no value for, by name. Reading one throws before the first frame. Omitted when there are none. |
fixtureConstructor |
Whether GeneratedWidget.fixture() was emitted. false means a field's type could not be built, so mounting the scope needs that value supplied first. |
droppedLoadingBuilders |
How many loadingBuilder arguments the image rewrite had to drop. Omitted when none were. |
carriedUiDeclarations |
The UI-producing declarations carried into the file, as libraryUri#Name. Diff it against the analyze row's walkedWidgetClasses: for every declaration analyze walks in place, isolate has to carry the source, or the two rows describe different trees. Omitted when there are none. |
renamedThirdPartyDeclarations |
Third-party names carried under a mangled name because package:flutter/material.dart exports the same one. Omitted when there are none. |
erasedNonRebuildBodies |
How many closure bodies the prune emptied. Omitted when none were, which is every row under --no-prune-non-rebuild. |
droppedUnreachableMembers |
The members of the scope's class build() could not reach, by name. Named rather than counted: a member missing that should not be is how the prune fails, and a count cannot say which one. Omitted when there are none. |
Use an isolated scope when a whole application adds unrelated work to frame timings or when a
benchmark needs direct control of the values passed into one builder. The generated
GeneratedWidget can be placed in a benchmark loop and supplied with controlled inputs.
- The stand-ins that replace non-UI and third-party dependencies resolve and now also run: a body hands back a value of the real type where one can be built and a stub where none can, and the fixture block carries values rather than throwing. What a stub cannot do is occupy a typed slot the call site chose, and it iterates over nothing, so a scope whose rows come from a lifted list builds none of them. Read the written file rather than assuming which scopes this still holds for.
- A stand-in for a widget has an empty
build, so its own subtree is not part of the isolated file. For a repo-local widget that meansanalyzereports a smaller tree there than it would against the full project; for a third-party one the in-place run could not read the subtree either, so the isolated row is the larger of the two. Widgets are extracted whole, repo-local and third-party alike, so this is now the exception rather than the rule. It is reached four ways, and the first two are visible in the row: the inline budget ran out (thirdPartyInlineTruncated), carrying the code analysed worse than standing it in (thirdPartyInlineReverted), the run was given--no-inline-third-party, or the declaration's name is onepackage:flutter/material.dartalso exports. -
--no-inline-third-partyreproduces the output of SPM 0.5.2 and earlier, where every third-party symbol was stood in for. It runs faster and writes smaller files, and every scope using a third-party widget measures smaller than it does in place. Use it to compare against older results, not to produce new ones. - The
Skeletonizersubstitutes the source of Flutter's own image constructions, because an isolated file has no assets directory and no network. Image widgets from packages are not touched: they are third-party widgets and are carried like any other.assets/placeholder.pngis in no bundle, so anything that runs an isolated file has to answer for that key. Custom fonts or localized strings may still need manual setup if critical to the widget. - An isolated file is not the scope's source. It holds what a rebuild runs, so a handler body is a
signature with nothing in it and a member
build()cannot reach is a name or is gone. Read the file to see what a rebuild costs, never to see what the widget does when it is used.--no-prune-non-rebuildgives back the fuller file, at the cost of the crawl that file drags in. - A scope taken from a project whose dependencies do not resolve is reconstructed from syntax alone.
Types that would have been mirrored from the real element become
dynamic, and a stand-in that cannot be told apart from a widget by its position in the source is not treated as one. Rows from such a project carrysourceDependenciesResolved: false; runpub getin the source project and isolate again for comparable metrics.
Commands
Reference
Internals
Contributing