Help us improve
Share bugs, ideas, or general feedback.
From ufil
GM Flutter performance patterns — const class vs helper method, isolate vs compute, ListView builder, RepaintBoundary, image caching, build optimization
npx claudepluginhub ghozimahdi/gm-claude-plugins --plugin ufilHow this skill is triggered — by the user, by Claude, or both
Slash command
/ufil:flutter-performanceThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
This skill teaches when to use specific patterns for performance-critical decisions in Flutter UI code.
Creates p5.js generative art with seeded randomness, noise fields, and interactive parameter exploration. Use for algorithmic art, flow fields, or particle systems.
Share bugs, ideas, or general feedback.
This skill teaches when to use specific patterns for performance-critical decisions in Flutter UI code.
const widget class over helper method when the widget is reused or rebuilt frequently.Why it matters:
const widgets are instantiated once and reused on every rebuild — Flutter skips the entire subtreeclass MyPage extends StatelessWidget {
Widget _buildPixelContainer() {
return Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: Colors.red.shade400,
borderRadius: BorderRadius.circular(1),
),
);
}
}
Problems:
Container allocated every rebuildconstclass PixelBox extends StatelessWidget {
const PixelBox({super.key});
@override
Widget build(BuildContext context) {
return Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: Color(0xFFEF5350),
borderRadius: BorderRadius.circular(1),
),
);
}
}
// Usage — Flutter caches this single instance
const PixelBox()
Benefits:
Element reuseListView/GridView)Helper methods are fine when:
String _formatPrice(...))BuildContext and ALL params come from build() — and is NOT inside an animation/list/frequently-rebuilt subtreeconst widget| Situation | Use |
|---|---|
| Widget reused in multiple places | const class |
Inside ListView.builder itemBuilder | const class |
Inside AnimatedBuilder / ValueListenableBuilder | const class |
| Widget rebuilt frequently (animations, streams) | const class |
| Static UI element (icon, divider, spacer) | const class |
One-time conditional if/else inside a single build() | helper method OK |
| Returns non-widget (String, double, etc.) | helper method OK |
const constructor// ❌ Missing const
class Spacer extends StatelessWidget {
Spacer({super.key}); // bad
}
// ✅ Const constructor
class Spacer extends StatelessWidget {
const Spacer({super.key}); // good
}
Adding const to every widget class is non-negotiable in this project — even StatefulWidgets must have const constructors.
async/await. Use compute() for heavy CPU work. Use full Isolate only for long-lived background work.Is the work CPU-bound (heavy computation)?
├── No (I/O, network, file read) → use async/await
└── Yes
├── One-shot heavy computation (>16ms) → use compute()
└── Long-lived / streaming heavy work → use Isolate.spawn() + ports
async/await (NOT isolate)Network calls, file I/O, database queries, await on Futures — these are already non-blocking on the UI thread. Do not wrap them in isolates.
// ✅ Correct — async/await is enough
Future<List<Tenant>> fetchTenants() async {
final response = await dio.get('/tenants');
return response.data;
}
compute() (one-shot CPU work)Use for: JSON parsing of large payloads, image manipulation, encryption/decryption, parsing/sorting/filtering large lists, regex on huge strings.
// ✅ Heavy JSON parse — moves work off UI thread
Future<List<Tenant>> parseTenants(String rawJson) async {
return compute(_parseTenantsSync, rawJson);
}
List<Tenant> _parseTenantsSync(String rawJson) {
final list = jsonDecode(rawJson) as List;
return list.map((e) => Tenant.fromJson(e)).toList();
}
Rules:
compute() MUST be a top-level function or static methodSendPort-compatible (primitives, lists, maps, simple objects)BuildContext, Bloc, or any object that holds Flutter framework referencesIsolate.spawn() (long-lived)Use for: continuous background processing (e.g., live image filter pipeline, real-time audio processing, long-running ML inference loop).
For this project, prefer compute() — full Isolate is rarely needed in CRUD apps.
// ❌ Don't isolate I/O — it's already async
final result = await compute(_fetchFromApi, url); // pointless
// ❌ Don't pass non-serializable objects
await compute(_doWork, context); // crash
// ❌ Don't isolate work <16ms — the spawn cost exceeds the gain
await compute(_addTwoNumbers, [1, 2]); // wasteful
.builder for lists with > ~10 items. Add const items + cache extents.ListView(
children: tenants.map((t) => TenantCard(tenant: t)).toList(),
)
ListView.builder(
itemCount: tenants.length,
itemBuilder: (context, index) => TenantCard(tenant: tenants[index]),
)
ListView.builder(
itemCount: tenants.length,
itemExtent: 80.h, // fixed height → skip layout pass
cacheExtent: 500, // pre-render off-screen items
addAutomaticKeepAlives: false,
addRepaintBoundaries: true, // default true — keep it
itemBuilder: (context, index) => TenantCard(
key: ValueKey(tenants[index].id),
tenant: tenants[index],
),
)
Rules:
itemExtent when item height is fixed → drops layout costValueKey from a stable id (NOT index) for items that can reordershrinkWrap: true unless inside another scrollable — it forces layout of all childrenUse cases:
// ✅ Animation isolated from parent's repaint
RepaintBoundary(
child: Lottie.asset('assets/loading.json'),
)
ListView.builder already adds RepaintBoundary per item by default — do NOT wrap items again.
cacheWidth/cacheHeight to decode at display size — NOT full resolutioncached_network_image for network images (already in many GM projects)ResizeImage over manual resize// ❌ Decodes 4K image into memory for a 100x100 display
Image.network('https://.../photo.jpg')
// ✅ Decodes only at display size
Image.network(
'https://.../photo.jpg',
cacheWidth: (100 * MediaQuery.of(context).devicePixelRatio).toInt(),
cacheHeight: (100 * MediaQuery.of(context).devicePixelRatio).toInt(),
)
// ✅ Or with cached_network_image
CachedNetworkImage(
imageUrl: url,
memCacheWidth: 200,
memCacheHeight: 200,
)
build() (no list mapping, no parsing, no DateTime.now())setState synchronously inside build()initState, didChangeDependencies, or memoize via late finalbuild())// ❌ Recomputed every rebuild
@override
Widget build(BuildContext context) {
final filtered = tenants.where((t) => t.active).toList(); // bad
return ListView(...);
}
// ✅ Computed once in bloc state
state.copyWith(filteredTenants: tenants.where((t) => t.active).toList())
When a widget only depends on part of a Bloc state, use BlocSelector to skip rebuilds when other state fields change.
// ❌ Whole widget rebuilds when ANY state field changes
BlocBuilder<TenantBloc, TenantState>(
builder: (context, state) => Text(state.tenant.name),
)
// ✅ Only rebuilds when name changes
BlocSelector<TenantBloc, TenantState, String>(
selector: (state) => state.tenant.name,
builder: (context, name) => Text(name),
)
AnimatedBuilder with a child parameter for static subtrees inside the animationRepaintBoundary around animated widgetsAnimatedContainer, AnimatedOpacity) over manual AnimationController when possibleTween + Curves from existing instances (don't create per build)// ✅ child is built ONCE, not per frame
AnimatedBuilder(
animation: _controller,
child: const HeavyWidget(), // built once
builder: (context, child) => Transform.rotate(
angle: _controller.value * 6.28,
child: child,
),
)
StreamBuilder / FutureBuilder for data that flows through Bloc — let the Bloc handle the lifecycleStreamSubscription in close() of Bloc, dispose() of StatefulWidgetbufferTime / debounce (rxdart) for high-frequency streams (search input, scroll events)saveLayer() Triggers (off-screen rendering)Canvas.saveLayer() — it forces the GPU to render to an off-screen buffer, then copy back. Heavy operation, FPS killer, battery drain.saveLayer() is one of the most expensive Flutter operations. The GPU normally draws directly to the screen, but saveLayer() forces it to:
That's double work per frame — multiplied by every item in a ListView or every frame of an animation, it crushes FPS.
saveLayer() under the hood| Widget | When it triggers | Mitigation |
|---|---|---|
ShaderMask | always | use static gradient Container decoration if possible |
ColorFiltered / ColorFilter | always | bake the filter into the asset/image instead |
BackdropFilter | always | use a static blurred image asset if the blur is constant |
Opacity (with child that paints) | when opacity != 1.0 && != 0.0 | use AnimatedOpacity only when needed; for images use Image(opacity:); for color use Color.withOpacity() on Container decoration |
Chip / RawChip | when disabledColor has alpha != 0xff | use full-alpha disabled color OR build a custom widget |
Text with overflow shader | overflow: TextOverflow.fade | use TextOverflow.ellipsis or clip |
ClipPath / ClipOval | always (with anti-aliasing) | use BoxDecoration.shape if possible |
saveLayer()// Forces off-screen buffer for the gradient mask
ShaderMask(
shaderCallback: (bounds) => const LinearGradient(
colors: [Colors.blue, Colors.purple],
).createShader(bounds),
child: Container(...),
)
// Disabled chip with translucent disabledColor → saveLayer() per frame
RawChip(
isEnabled: false,
disabledColor: Colors.grey.withAlpha(150), // alpha != 0xff
label: const Text('Disabled'),
)
// Opacity wrapping a complex subtree → entire subtree off-screen
Opacity(
opacity: 0.5,
child: ComplexCard(...),
)
saveLayer()// Static gradient as decoration — paints directly
Container(
decoration: BoxDecoration(
gradient: LinearGradient(colors: [Colors.blue, Colors.purple]),
),
child: ...,
)
// Use full-alpha disabled color
RawChip(
isEnabled: false,
disabledColor: Color(0xFFE0E0E0), // full alpha
label: const Text('Disabled'),
)
// Apply opacity to the color, not the widget
Container(
color: Colors.black.withOpacity(0.5),
child: ComplexCard(...),
)
Open DevTools → Performance → Timeline Events tab and filter by saveLayer. If you see the keyword appear thousands of times, you have a problem. Cross-reference with red "Raster Jank" spikes in the Frames graph.
BoxDecoration.borderRadiusClipRRect only when you MUST clip child content. For a rounded rectangle with color/gradient, use BoxDecoration.borderRadius on Container — it paints natively, no off-screen buffer.ClipRRect forces off-screen rendering. BoxDecoration is drawn natively by the GPU in a single step.
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(16),
color: Colors.blue,
child: const Text('Submit'),
),
)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(12),
),
child: const Text('Submit'),
)
ClipRRect is actually neededImage to rounded corners → use ClipRRect, OR better: Image inside Container with BoxDecoration.image + borderRadiusHero / animated child where decoration won't work| Need | Use |
|---|---|
| Rounded button / card with solid color | Container + BoxDecoration |
| Rounded gradient | Container + BoxDecoration(gradient: ..., borderRadius: ...) |
| Rounded image | Container + BoxDecoration(image: DecorationImage(...), borderRadius: ...) |
| Clipping arbitrary child widgets | ClipRRect (last resort) |
StringBuffer for loopsn-iteration accumulation, use StringBuffer. The += operator creates a new String allocation on every iteration → O(n²) complexity.In Dart, String is immutable. s += x allocates a new string of size len(s) + len(x) and copies the old content. In a loop, this is quadratic.
String result = '';
for (final user in users) {
result += 'Mr ${user.firstName} ${user.lastName}, ';
}
For 1000 users this allocates ~1000 intermediate strings, ~500K characters of throwaway memory.
final buffer = StringBuffer();
for (final user in users) {
buffer.write('Mr ${user.firstName} ${user.lastName}, ');
}
final result = buffer.toString();
+= is fine// Fine — fixed concatenation
final fullName = '${user.firstName} ${user.lastName}';
// Fine — small, fixed parts
final url = baseUrl + '/api/v1' + '/users';
Iterable.join():// ✅ Idiomatic for joining
final result = users.map((u) => 'Mr ${u.firstName} ${u.lastName}').join(', ');
| Situation | Use |
|---|---|
Accumulating in a for / while loop | StringBuffer |
| Joining a collection with separator | .map().join() |
| 2–5 fixed parts | + or interpolation |
| Building structured output (CSV, log lines) | StringBuffer |
Before approving a UI PR, verify:
StatelessWidget / StatefulWidget has const constructorListView / GridView uses .builder for dynamic listsitemExtent set when item height is fixedbuild()compute() used for heavy CPU work, not async I/OBlocSelector used when widget depends on partial stateRepaintBoundary around independent-paint widgetscacheWidth/cacheHeight or CachedNetworkImage with mem cache sizeshrinkWrap: true outside nested scrollablesValueKey for reorderable list itemsShaderMask / ColorFiltered / BackdropFilter inside list items or animated subtreesChip / RawChip with disabledColor having alpha != 0xffOpacity wrapping complex children — use Color.withOpacity() on decoration, or AnimatedOpacity only when necessaryClipRRect only when truly needed — prefer Container + BoxDecoration.borderRadiusStringBuffer (or .join()) used for string accumulation in loops — never += in for/whileText with TextOverflow.fade (triggers saveLayer) — use ellipsis / clip| Anti-pattern | Replace with |
|---|---|
Widget _buildHeader() => Container(...) | class _Header extends StatelessWidget { const _Header(); ... } |
await compute(_fetchApi, url) | await dio.get(url) |
ListView(children: list.map(...).toList()) | ListView.builder(itemCount, itemBuilder) |
Image.network(url) for thumbnails | Image.network(url, cacheWidth: ...) |
BlocBuilder for one field | BlocSelector |
Container() with no decoration | SizedBox() |
setState inside build() | move to event handler / Bloc |
Computing filtered list in build() | compute in Bloc state |
ClipRRect(borderRadius: ..., child: Container(color: ...)) | Container(decoration: BoxDecoration(color: ..., borderRadius: ...)) |
ShaderMask for static gradient | Container(decoration: BoxDecoration(gradient: ...)) |
Opacity(opacity: 0.5, child: ComplexWidget()) | Container(color: ...withOpacity(0.5), child: ...) |
RawChip(disabledColor: Colors.grey.withAlpha(150)) | RawChip(disabledColor: Color(0xFFE0E0E0)) (full alpha) |
result += '...' inside for | StringBuffer().write(...) then .toString() |
| Loop building separator-joined string | iterable.map(...).join(', ') |
Text(overflow: TextOverflow.fade) | TextOverflow.ellipsis or clip |
${CLAUDE_PLUGIN_ROOT}/docs/PERFORMANCE.md — Full developer-facing reference with profiling workflow and rationale