Comprehensive practices for building 60-240 FPS UI with Flutter
Table of Contents
- Rendering Architecture
- Impeller vs Skia
- const Constructors
- Widget Rebuild Optimization
- ListView & Scrolling
- State Management
- Isolates & Concurrency
- Image Optimization
- Animations
- Custom Painting
- Layout Optimization
- Memory Management
- Platform Channels
- Build Optimization
- Profiling & Debugging
- Native Interop Depth
- App Startup Optimization
- Font Loading
- App Size Reduction
- Android R8/ProGuard & iOS Build Settings
- Web Renderers
- Desktop Optimization
- Quick Reference
1. Rendering Architecture
Flutter’s Rendering Pipeline
┌─────────────────────────────────────────────────────────────────────────────┐
│ FLUTTER RENDERING PIPELINE │
├─────────────┬─────────────┬─────────────┬─────────────┬─────────────────────┤
│ BUILD │ LAYOUT │ PAINT │ COMPOSITE │ RASTER │
│ (Widgets) │ (RenderObj)│ (to layers) │ (combine) │ (to pixels) │
├─────────────┼─────────────┼─────────────┼─────────────┼─────────────────────┤
│ Create │ Calculate │ Record │ Merge │ Convert to │
│ widget tree │ sizes & │ drawing │ layers │ GPU commands │
│ │ positions │ commands │ │ │
├─────────────┴─────────────┴─────────────┴─────────────┴─────────────────────┤
│ UI THREAD │ RASTER THREAD │
└────────────────────────────────────────────────────────┴─────────────────────┘
Frame Budget
| Target FPS | Frame Budget | Thread Split (UI/Raster) |
|---|---|---|
| 60 FPS | 16.67ms | ~8ms / ~8ms |
| 90 FPS | 11.11ms | ~5ms / ~5ms |
| 120 FPS | 8.33ms | ~4ms / ~4ms |
| 240 FPS | 4.17ms | ~2ms / ~2ms |
Two Threads
// UI Thread (Dart)
// - Widget builds
// - Layout calculations
// - Paint command generation
// Raster Thread (C++)
// - Executes paint commands
// - GPU rendering
// - Compositing
2. Impeller vs Skia
Comparison
| Feature | Skia (Legacy) | Impeller (2025 Default) |
|---|---|---|
| Shader compilation | Runtime (causes jank) | AOT (pre-compiled) |
| Graphics API | OpenGL wrapper | Native Metal/Vulkan |
| Rendering mode | Immediate | Retained (cached) |
| First-run performance | Often janky | Smooth |
| iOS support | Deprecated | Default (Flutter 3.29+) |
| Android support | Default < API 29 | Available API 29+ |
Enabling/Disabling Impeller
# iOS: Impeller is default, cannot disable (Flutter 3.29+)
# Android: Enable Impeller
flutter run --enable-impeller
# Android: Disable Impeller (if issues)
flutter run --no-enable-impeller
# Check at runtime
import 'dart:ui' as ui;
bool isImpellerEnabled = ui.platformDispatcher.impellerEnabled;
AndroidManifest Configuration
<!-- Enable Impeller permanently -->
<meta-data
android:name="io.flutter.embedding.android.EnableImpeller"
android:value="true" />
Impeller Benefits
Performance Improvements:
├── 70%+ reduction in dropped frames
├── Eliminated shader compilation jank
├── Consistent 120 FPS on supported devices
├── Lower memory usage for rendering
└── Better battery efficiency
3. const Constructors
The Most Important Optimization
// ❌ BAD: Rebuilt every frame
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.all(16), // New instance
child: Text('Hello'), // New instance
decoration: BoxDecoration( // New instance
color: Colors.blue,
),
);
}
// ✅ GOOD: Cached and reused forever
Widget build(BuildContext context) {
return const Container(
padding: EdgeInsets.all(16), // Compile-time constant
child: Text('Hello'), // Compile-time constant
decoration: BoxDecoration( // Compile-time constant
color: Colors.blue,
),
);
}
Impact
- Up to 70% reduction in widget rebuilds
- Lower memory allocation
- Faster widget tree comparison
- No GC pressure from temporary objects
Creating const-Friendly Widgets
// ✅ const constructor
class MyCard extends StatelessWidget {
const MyCard({
super.key,
required this.title,
this.subtitle,
});
final String title;
final String? subtitle;
@override
Widget build(BuildContext context) {
return Card(
child: Column(
children: [
Text(title),
if (subtitle != null) Text(subtitle!),
],
),
);
}
}
// Usage
const MyCard(title: 'Hello', subtitle: 'World')
Lint Rules
# analysis_options.yaml
linter:
rules:
# Enforce const where possible
prefer_const_constructors: true
prefer_const_declarations: true
prefer_const_literals_to_create_immutables: true
unnecessary_const: true
const Propagation
// ❌ Breaks const propagation
Widget build(BuildContext context) {
final color = Theme.of(context).primaryColor; // Runtime value
return Container(
color: color, // Cannot be const
child: const Text('Hello'), // This can still be const
);
}
// ✅ Preserve const where possible
Widget build(BuildContext context) {
return Container(
color: Theme.of(context).primaryColor,
child: const Column( // Entire subtree is const
children: [
Text('Hello'),
SizedBox(height: 8),
Text('World'),
],
),
);
}
4. Widget Rebuild Optimization
Understanding Rebuilds
// When does build() run?
// 1. Parent rebuilds
// 2. setState() called
// 3. InheritedWidget dependency changes
// 4. State.didUpdateWidget() triggers rebuild
Isolate Rebuild Scope
// ❌ BAD: Entire screen rebuilds on counter change
class BadScreen extends StatefulWidget {
@override
State<BadScreen> createState() => _BadScreenState();
}
class _BadScreenState extends State<BadScreen> {
int _counter = 0;
@override
Widget build(BuildContext context) {
return Column(
children: [
const ExpensiveHeader(), // Rebuilds!
Text('Count: $_counter'),
const ExpensiveFooter(), // Rebuilds!
ElevatedButton(
onPressed: () => setState(() => _counter++),
child: const Text('Increment'),
),
],
);
}
}
// ✅ GOOD: Only counter widget rebuilds
class GoodScreen extends StatelessWidget {
const GoodScreen({super.key});
@override
Widget build(BuildContext context) {
return const Column(
children: [
ExpensiveHeader(), // Never rebuilds
CounterWidget(), // Only this rebuilds
ExpensiveFooter(), // Never rebuilds
],
);
}
}
class CounterWidget extends StatefulWidget {
const CounterWidget({super.key});
@override
State<CounterWidget> createState() => _CounterWidgetState();
}
class _CounterWidgetState extends State<CounterWidget> {
int _counter = 0;
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('Count: $_counter'),
ElevatedButton(
onPressed: () => setState(() => _counter++),
child: const Text('Increment'),
),
],
);
}
}
Split Widgets by Rebuild Frequency
// ✅ Separate static and dynamic parts
class ProductCard extends StatelessWidget {
const ProductCard({super.key, required this.product});
final Product product;
@override
Widget build(BuildContext context) {
return Card(
child: Column(
children: [
// Static content - extracted to const widget
const _ProductCardHeader(),
// Dynamic content - separate widget
_ProductCardContent(product: product),
// Interactive content - separate stateful widget
_ProductCardActions(productId: product.id),
],
),
);
}
}
RepaintBoundary
// Isolate repaint regions
class AnimatedSection extends StatelessWidget {
const AnimatedSection({super.key});
@override
Widget build(BuildContext context) {
return Column(
children: [
const StaticHeader(),
// ✅ Wrap animated content
RepaintBoundary(
child: AnimatedWidget(),
),
const StaticFooter(),
],
);
}
}
When to Use RepaintBoundary
// ✅ Good use cases:
// - Animations that update frequently
// - Canvas/CustomPaint widgets
// - Video players
// - Charts that animate
// ❌ Don't overuse:
// - Every widget (overhead of layer management)
// - Static content
// - Widgets that rarely change
5. ListView & Scrolling
ListView Variants
| Widget | Behavior | Use Case |
|---|---|---|
ListView(children: []) | Creates all at once | < 20 items |
ListView.builder() | Creates on-demand | 20+ items |
ListView.separated() | Builder + separators | Lists with dividers |
ListView.custom() | Custom child management | Advanced cases |
ListView.builder (Essential)
// ❌ BAD: All 10,000 widgets created immediately
ListView(
children: items.map((item) => ItemWidget(item)).toList(),
)
// ✅ GOOD: Only visible widgets created
ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) => ItemWidget(items[index]),
)
Optimized ListView Configuration
ListView.builder(
itemCount: items.length,
// ✅ Pre-build items for smoother scrolling
cacheExtent: 250.0,
// ✅ Keys for stateful items
itemBuilder: (context, index) {
return ItemWidget(
key: ValueKey(items[index].id),
item: items[index],
);
},
// ✅ Fixed extent if items are same height
itemExtent: 72.0, // Or use prototypeItem
// ✅ Disable if not needed
addAutomaticKeepAlives: false,
addRepaintBoundaries: true,
)
SliverList & CustomScrollView
// ✅ Most efficient for complex scrolling
CustomScrollView(
slivers: [
const SliverAppBar(
floating: true,
title: Text('Title'),
),
SliverList.builder(
itemCount: items.length,
itemBuilder: (context, index) => ItemWidget(items[index]),
),
SliverGrid.count(
crossAxisCount: 2,
children: gridItems,
),
const SliverToBoxAdapter(
child: Footer(),
),
],
)
Sliver Widgets Reference
| Widget | Use Case |
|---|---|
SliverList.builder | Lazy vertical list |
SliverGrid.builder | Lazy grid |
SliverAppBar | Collapsing header |
SliverPersistentHeader | Sticky header |
SliverToBoxAdapter | Single non-sliver widget |
SliverFillRemaining | Fill remaining space |
SliverPadding | Padded sliver |
Keys in Lists
// ❌ BAD: No keys - state gets mixed up
ListView.builder(
itemBuilder: (context, index) => ItemWidget(items[index]),
)
// ❌ BAD: Index as key - wrong after reorder
ListView.builder(
itemBuilder: (context, index) => ItemWidget(
key: ValueKey(index), // Wrong!
item: items[index],
),
)
// ✅ GOOD: Stable unique key
ListView.builder(
itemBuilder: (context, index) => ItemWidget(
key: ValueKey(items[index].id),
item: items[index],
),
)
Scroll Performance
// ✅ Notify scroll listeners efficiently
NotificationListener<ScrollNotification>(
onNotification: (notification) {
// Only process specific notifications
if (notification is ScrollEndNotification) {
loadMoreIfNeeded();
}
return false; // Allow notification to continue
},
child: ListView.builder(...),
)
// ✅ Throttle scroll callbacks
class _MyWidgetState extends State<MyWidget> {
final _scrollController = ScrollController();
DateTime? _lastUpdate;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
}
void _onScroll() {
final now = DateTime.now();
if (_lastUpdate == null ||
now.difference(_lastUpdate!) > const Duration(milliseconds: 100)) {
_lastUpdate = now;
_handleScroll();
}
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
}
6. State Management
Performance Comparison
| Approach | Rebuild Scope | Best For |
|---|---|---|
setState | Entire StatefulWidget | Local UI state |
ValueNotifier | ValueListenableBuilder only | Simple reactive values |
ChangeNotifier | Consumer widgets | Small-medium apps |
Provider + Selector | Selected values only | Medium apps |
Riverpod | Per-provider | Large apps |
Bloc | Per-stream | Event-driven apps |
setState Scope Isolation
// ❌ BAD: Entire widget rebuilds
class _MyWidgetState extends State<MyWidget> {
int _counter = 0;
String _title = 'Title';
@override
Widget build(BuildContext context) {
return Column(
children: [
ExpensiveWidget(title: _title), // Rebuilds!
Text('$_counter'),
ElevatedButton(
onPressed: () => setState(() => _counter++),
child: const Text('Increment'),
),
],
);
}
}
// ✅ GOOD: Extract to separate StatefulWidget
class _MyWidgetState extends State<MyWidget> {
final String _title = 'Title';
@override
Widget build(BuildContext context) {
return Column(
children: [
ExpensiveWidget(title: _title), // Never rebuilds
const CounterSection(), // Isolated rebuilds
],
);
}
}
ValueNotifier & ValueListenableBuilder
// ✅ Minimal rebuild scope
class CounterPage extends StatefulWidget {
const CounterPage({super.key});
@override
State<CounterPage> createState() => _CounterPageState();
}
class _CounterPageState extends State<CounterPage> {
final _counter = ValueNotifier<int>(0);
@override
void dispose() {
_counter.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
const ExpensiveHeader(), // Never rebuilds
// ✅ Only this rebuilds
ValueListenableBuilder<int>(
valueListenable: _counter,
builder: (context, count, child) {
return Text('Count: $count');
},
),
ElevatedButton(
onPressed: () => _counter.value++,
child: const Text('Increment'),
),
const ExpensiveFooter(), // Never rebuilds
],
);
}
}
Provider with Selector
// ✅ Rebuild only when specific value changes
class CartScreen extends StatelessWidget {
const CartScreen({super.key});
@override
Widget build(BuildContext context) {
return Column(
children: [
// Only rebuilds when itemCount changes
Selector<CartModel, int>(
selector: (_, cart) => cart.items.length,
builder: (context, itemCount, child) {
return Text('$itemCount items');
},
),
// Only rebuilds when total changes
Selector<CartModel, double>(
selector: (_, cart) => cart.totalPrice,
builder: (context, total, child) {
return Text('Total: \$${total.toStringAsFixed(2)}');
},
),
],
);
}
}
Consumer with child
// ✅ Preserve static content
Consumer<ThemeModel>(
builder: (context, theme, child) {
return Container(
color: theme.backgroundColor,
child: child, // Static, never rebuilt
);
},
child: const ExpensiveStaticContent(),
)
Riverpod for Fine-Grained Reactivity
// Define providers
final counterProvider = StateProvider<int>((ref) => 0);
final doubledProvider = Provider<int>((ref) {
return ref.watch(counterProvider) * 2; // Derived state
});
// Widget only rebuilds when doubled value changes
class DoubledDisplay extends ConsumerWidget {
const DoubledDisplay({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final doubled = ref.watch(doubledProvider);
return Text('Doubled: $doubled');
}
}
// Select specific fields
class UserName extends ConsumerWidget {
const UserName({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Only rebuilds when name changes, not entire user
final name = ref.watch(userProvider.select((user) => user.name));
return Text(name);
}
}
7. Isolates & Concurrency
When to Use Isolates
// Use isolates for:
// ✅ JSON parsing of large payloads (> 10KB)
// ✅ Image processing
// ✅ Cryptography
// ✅ Data transformations on large datasets
// ✅ File I/O intensive operations
// ✅ Any computation > 16ms
compute() for Simple Tasks
// ✅ One-shot background computation
Future<List<Item>> processItems(List<RawItem> rawItems) async {
// Runs in isolate, returns result
return await compute(_processItemsIsolate, rawItems);
}
// Top-level or static function required
List<Item> _processItemsIsolate(List<RawItem> rawItems) {
return rawItems.map((raw) => Item.fromRaw(raw)).toList();
}
Isolate.run() (Dart 2.19+)
// ✅ Modern syntax with closures
Future<ParsedData> parseJson(String json) async {
return await Isolate.run(() {
// Runs in separate isolate
final map = jsonDecode(json) as Map<String, dynamic>;
return ParsedData.fromJson(map);
});
}
Long-Running Isolates
// ✅ Persistent isolate with communication
class BackgroundProcessor {
late Isolate _isolate;
late ReceivePort _receivePort;
late SendPort _sendPort;
Future<void> start() async {
_receivePort = ReceivePort();
_isolate = await Isolate.spawn(
_isolateEntry,
_receivePort.sendPort,
);
// Get send port from isolate
_sendPort = await _receivePort.first as SendPort;
}
Future<Result> process(Data data) async {
final responsePort = ReceivePort();
_sendPort.send(ProcessMessage(data, responsePort.sendPort));
return await responsePort.first as Result;
}
void dispose() {
_isolate.kill();
_receivePort.close();
}
static void _isolateEntry(SendPort mainSendPort) {
final receivePort = ReceivePort();
mainSendPort.send(receivePort.sendPort);
receivePort.listen((message) {
if (message is ProcessMessage) {
final result = _heavyProcessing(message.data);
message.replyPort.send(result);
}
});
}
}
Isolate Pool Pattern
// ✅ Reuse isolates for multiple tasks
class IsolatePool {
final int size;
final List<Isolate> _isolates = [];
final List<SendPort> _sendPorts = [];
int _currentIndex = 0;
IsolatePool({this.size = 4});
Future<void> initialize() async {
for (int i = 0; i < size; i++) {
final receivePort = ReceivePort();
final isolate = await Isolate.spawn(_worker, receivePort.sendPort);
_isolates.add(isolate);
_sendPorts.add(await receivePort.first as SendPort);
}
}
Future<R> execute<T, R>(T data, R Function(T) computation) async {
final sendPort = _sendPorts[_currentIndex];
_currentIndex = (_currentIndex + 1) % size;
final responsePort = ReceivePort();
sendPort.send(_Task(data, computation, responsePort.sendPort));
return await responsePort.first as R;
}
void dispose() {
for (final isolate in _isolates) {
isolate.kill();
}
}
static void _worker(SendPort sendPort) {
final receivePort = ReceivePort();
sendPort.send(receivePort.sendPort);
receivePort.listen((message) {
if (message is _Task) {
final result = message.computation(message.data);
message.replyPort.send(result);
}
});
}
}
compute_heavy Package Alternative
// For complex isolate management
import 'package:compute_heavy/compute_heavy.dart';
final pool = ComputeHeavy(isolatesCount: 4);
Future<Result> process(Data data) async {
return await pool.compute(expensiveFunction, data);
}
8. Image Optimization
Common Issues
| Problem | Symptom | Solution |
|---|---|---|
| Full resolution | OOM, jank | Use cacheWidth/cacheHeight |
| No caching | Re-download | Use cached_network_image |
| Large assets | Slow startup | Use resolution-aware assets |
| Many images | Memory pressure | Limit concurrent loads |
| No placeholders | Layout shift | Use placeholder widgets |
Sized Image Loading
// ❌ BAD: Loads full 4K image
Image.network(url)
// ✅ GOOD: Resize in memory
Image.network(
url,
cacheWidth: 200, // Decode width
cacheHeight: 200, // Decode height
fit: BoxFit.cover,
)
// ✅ Asset images with resolution
Image.asset(
'assets/image.png',
cacheWidth: 200,
cacheHeight: 200,
)
cached_network_image Package
// ✅ Disk + memory caching with placeholder
CachedNetworkImage(
imageUrl: url,
memCacheWidth: 200,
memCacheHeight: 200,
placeholder: (context, url) => const ShimmerPlaceholder(),
errorWidget: (context, url, error) => const Icon(Icons.error),
fadeInDuration: const Duration(milliseconds: 200),
)
// With builder for more control
CachedNetworkImage(
imageUrl: url,
imageBuilder: (context, imageProvider) => Container(
decoration: BoxDecoration(
image: DecorationImage(
image: imageProvider,
fit: BoxFit.cover,
),
),
),
)
Precaching Images
// ✅ Precache critical images
@override
void didChangeDependencies() {
super.didChangeDependencies();
precacheImage(
const AssetImage('assets/hero.png'),
context,
);
}
// Precache network images
Future<void> precacheNetworkImages(List<String> urls) async {
for (final url in urls) {
await precacheImage(
CachedNetworkImageProvider(url),
context,
);
}
}
Resolution-Aware Assets
assets/
├── images/
│ ├── logo.png (1x - mdpi)
│ ├── 2.0x/
│ │ └── logo.png (2x - xhdpi)
│ ├── 3.0x/
│ │ └── logo.png (3x - xxhdpi)
│ └── 4.0x/
│ └── logo.png (4x - xxxhdpi)
// Flutter automatically picks correct resolution
Image.asset('assets/images/logo.png')
Memory-Efficient Image Grid
// ✅ Limit concurrent image loads
class ImageGrid extends StatelessWidget {
const ImageGrid({super.key, required this.urls});
final List<String> urls;
@override
Widget build(BuildContext context) {
return GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
),
itemCount: urls.length,
itemBuilder: (context, index) {
return CachedNetworkImage(
imageUrl: urls[index],
memCacheWidth: 150,
memCacheHeight: 150,
maxWidthDiskCache: 300,
maxHeightDiskCache: 300,
fit: BoxFit.cover,
);
},
);
}
}
9. Animations
Built-in Animation Widgets
| Widget | Use Case | Simplicity |
|---|---|---|
AnimatedContainer | Container properties | Easiest |
AnimatedOpacity | Fade in/out | Easiest |
AnimatedPositioned | Position changes | Easy |
AnimatedSwitcher | Widget transitions | Easy |
AnimatedBuilder | Custom animations | Medium |
TweenAnimationBuilder | One-shot animations | Medium |
Implicit Animations (Preferred)
// ✅ Simplest animation approach
class AnimatedCard extends StatelessWidget {
const AnimatedCard({super.key, required this.isExpanded});
final bool isExpanded;
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
height: isExpanded ? 200 : 100,
width: isExpanded ? 300 : 200,
decoration: BoxDecoration(
color: isExpanded ? Colors.blue : Colors.grey,
borderRadius: BorderRadius.circular(isExpanded ? 16 : 8),
),
child: const Center(child: Text('Tap me')),
);
}
}
AnimatedSwitcher for Crossfade
// ✅ Smooth widget transitions
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, animation) {
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.1),
end: Offset.zero,
).animate(animation),
child: child,
),
);
},
child: _showFirst
? const FirstWidget(key: ValueKey('first'))
: const SecondWidget(key: ValueKey('second')),
)
Explicit Animations with AnimationController
class PulsingWidget extends StatefulWidget {
const PulsingWidget({super.key});
@override
State<PulsingWidget> createState() => _PulsingWidgetState();
}
class _PulsingWidgetState extends State<PulsingWidget>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 500),
vsync: this,
);
_scaleAnimation = Tween<double>(begin: 1.0, end: 1.2).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
_controller.repeat(reverse: true);
}
@override
void dispose() {
_controller.dispose(); // ✅ Always dispose
super.dispose();
}
@override
Widget build(BuildContext context) {
// ✅ Use AnimatedBuilder to minimize rebuilds
return AnimatedBuilder(
animation: _scaleAnimation,
child: const Icon(Icons.favorite, size: 50), // Static child
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: child, // Reused, not rebuilt
);
},
);
}
}
Avoid Expensive Animation Widgets
// ❌ BAD: Opacity widget is expensive
Opacity(
opacity: _animation.value,
child: ExpensiveWidget(),
)
// ✅ GOOD: AnimatedOpacity handles it better
AnimatedOpacity(
opacity: _visible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 300),
child: ExpensiveWidget(),
)
// ✅ BEST: FadeTransition with controller
FadeTransition(
opacity: _animation,
child: ExpensiveWidget(),
)
Transform for GPU Animations
// ✅ GPU-accelerated transforms
Transform(
transform: Matrix4.identity()
..translate(x, y)
..rotateZ(angle)
..scale(scale),
child: child,
)
// ✅ Use Transform constructors for common cases
Transform.scale(
scale: _scaleAnimation.value,
child: child,
)
Transform.rotate(
angle: _rotationAnimation.value,
child: child,
)
Transform.translate(
offset: Offset(_xAnimation.value, _yAnimation.value),
child: child,
)
Performance Tips
// ✅ Wrap animated widgets in RepaintBoundary
RepaintBoundary(
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.rotate(
angle: _controller.value * 2 * pi,
child: child,
);
},
child: const ExpensiveWidget(),
),
)
// ✅ Use child parameter in builders
AnimatedBuilder(
animation: _controller,
child: const ExpensiveWidget(), // Built once
builder: (context, child) {
return Opacity(
opacity: _controller.value,
child: child, // Reused
);
},
)
10. Custom Painting
Efficient CustomPainter
class OptimizedPainter extends CustomPainter {
OptimizedPainter({required this.data}) : super();
final ChartData data;
// ✅ Pre-allocate paint objects
static final Paint _linePaint = Paint()
..color = Colors.blue
..strokeWidth = 2
..style = PaintingStyle.stroke;
static final Paint _fillPaint = Paint()
..color = Colors.blue.withOpacity(0.3)
..style = PaintingStyle.fill;
// ✅ Reuse path objects
final Path _linePath = Path();
final Path _fillPath = Path();
@override
void paint(Canvas canvas, Size size) {
// ✅ Reset paths instead of creating new
_linePath.reset();
_fillPath.reset();
// Build paths
_buildPaths(size);
// Draw
canvas.drawPath(_fillPath, _fillPaint);
canvas.drawPath(_linePath, _linePaint);
}
void _buildPaths(Size size) {
// Path building logic
}
@override
bool shouldRepaint(OptimizedPainter oldDelegate) {
// ✅ Only repaint when data changes
return data != oldDelegate.data;
}
}
shouldRepaint Optimization
// ❌ BAD: Always repaints
@override
bool shouldRepaint(MyPainter oldDelegate) => true;
// ❌ BAD: Deep comparison every frame
@override
bool shouldRepaint(MyPainter oldDelegate) {
return listEquals(data, oldDelegate.data); // O(n) every frame
}
// ✅ GOOD: Reference comparison
@override
bool shouldRepaint(MyPainter oldDelegate) {
return data != oldDelegate.data; // Fast reference check
}
// ✅ GOOD: Specific field comparison
@override
bool shouldRepaint(MyPainter oldDelegate) {
return data.version != oldDelegate.data.version;
}
RepaintBoundary for CustomPaint
// ✅ Isolate custom paint repaints
class ChartWidget extends StatelessWidget {
const ChartWidget({super.key, required this.data});
final ChartData data;
@override
Widget build(BuildContext context) {
return RepaintBoundary(
child: CustomPaint(
painter: ChartPainter(data: data),
size: const Size(300, 200),
),
);
}
}
Canvas Operations
// ✅ Use save/restore for transformations
void paint(Canvas canvas, Size size) {
canvas.save();
try {
canvas.translate(size.width / 2, size.height / 2);
canvas.rotate(angle);
_drawContent(canvas);
} finally {
canvas.restore();
}
}
// ✅ Clip efficiently
void paint(Canvas canvas, Size size) {
final rect = Rect.fromLTWH(0, 0, size.width, size.height);
canvas.save();
canvas.clipRect(rect); // Clip before complex drawing
_drawComplexContent(canvas);
canvas.restore();
}
11. Layout Optimization
Avoid Deep Nesting
// ❌ BAD: 10+ levels deep
Column(
children: [
Container(
child: Padding(
padding: EdgeInsets.all(8),
child: Row(
children: [
Expanded(
child: Column(
children: [
// More nesting...
],
),
),
],
),
),
),
],
)
// ✅ GOOD: Flat structure
CustomMultiChildLayout(
delegate: MyLayoutDelegate(),
children: [
LayoutId(id: 'header', child: Header()),
LayoutId(id: 'content', child: Content()),
LayoutId(id: 'footer', child: Footer()),
],
)
Avoid Unnecessary Widgets
// ❌ BAD: Unnecessary Container
Container(
child: Text('Hello'),
)
// ✅ GOOD: Direct Text
const Text('Hello')
// ❌ BAD: Container for just padding
Container(
padding: EdgeInsets.all(16),
child: Text('Hello'),
)
// ✅ GOOD: Use Padding directly
const Padding(
padding: EdgeInsets.all(16),
child: Text('Hello'),
)
// ❌ BAD: SizedBox + Container
SizedBox(
width: 100,
height: 100,
child: Container(
color: Colors.blue,
),
)
// ✅ GOOD: Single Container
Container(
width: 100,
height: 100,
color: Colors.blue,
)
Lazy Layout Widgets
// ✅ Use lazy layout for large content
LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth > 600) {
return const WideLayout();
} else {
return const NarrowLayout();
}
},
)
// ✅ Visibility widget for conditional display
Visibility(
visible: showWidget,
maintainState: true, // Keep state alive
maintainSize: false, // Don't reserve space
child: ExpensiveWidget(),
)
// ✅ Offstage for invisible but alive
Offstage(
offstage: !isVisible,
child: ExpensiveWidget(), // Still in tree, just not painted
)
12. Memory Management
Dispose Everything
class _MyWidgetState extends State<MyWidget> {
late final AnimationController _animationController;
late final ScrollController _scrollController;
late final TextEditingController _textController;
late final FocusNode _focusNode;
StreamSubscription? _subscription;
Timer? _timer;
@override
void initState() {
super.initState();
_animationController = AnimationController(vsync: this);
_scrollController = ScrollController();
_textController = TextEditingController();
_focusNode = FocusNode();
_subscription = stream.listen((_) {});
_timer = Timer.periodic(duration, (_) {});
}
@override
void dispose() {
// ✅ Dispose in reverse order of creation
_timer?.cancel();
_subscription?.cancel();
_focusNode.dispose();
_textController.dispose();
_scrollController.dispose();
_animationController.dispose();
super.dispose();
}
}
Image Memory
// ✅ Clear image cache when needed
void clearImageCache() {
imageCache.clear();
imageCache.clearLiveImages();
}
// ✅ Evict specific image
void evictImage(String url) {
final provider = NetworkImage(url);
imageCache.evict(provider);
}
// ✅ Configure cache size
void configureImageCache() {
imageCache.maximumSize = 100; // Max 100 images
imageCache.maximumSizeBytes = 50 << 20; // 50 MB
}
Avoiding Memory Leaks
// ❌ BAD: Listener not removed
class _MyWidgetState extends State<MyWidget> {
@override
void initState() {
super.initState();
someNotifier.addListener(_onChanged); // Leak!
}
void _onChanged() { /* ... */ }
}
// ✅ GOOD: Listener removed
class _MyWidgetState extends State<MyWidget> {
@override
void initState() {
super.initState();
someNotifier.addListener(_onChanged);
}
@override
void dispose() {
someNotifier.removeListener(_onChanged);
super.dispose();
}
void _onChanged() { /* ... */ }
}
WeakReference for Caches
class ImageCache {
final Map<String, WeakReference<Uint8List>> _cache = {};
Uint8List? get(String key) {
return _cache[key]?.target;
}
void put(String key, Uint8List data) {
_cache[key] = WeakReference(data);
}
}
13. Platform Channels
Efficient Channel Usage
// ✅ Use BasicMessageChannel for streaming data
final channel = BasicMessageChannel<dynamic>(
'streaming_channel',
StandardMessageCodec(),
);
// ✅ Use EventChannel for continuous streams
final eventChannel = EventChannel('sensor_events');
final stream = eventChannel.receiveBroadcastStream();
// ✅ Batch multiple calls
// Instead of:
for (final item in items) {
await platform.invokeMethod('processItem', item); // N calls
}
// Do:
await platform.invokeMethod('processItems', items); // 1 call
Background Isolate Channels
// ✅ Register background channel for isolate usage
@pragma('vm:entry-point')
void backgroundEntry() {
WidgetsFlutterBinding.ensureInitialized();
final channel = MethodChannel('background_channel');
channel.setMethodCallHandler((call) async {
switch (call.method) {
case 'process':
return processData(call.arguments);
default:
throw UnimplementedError();
}
});
}
14. Build Optimization
Debug vs Release Performance
| Aspect | Debug | Release |
|---|---|---|
| Dart VM | JIT (slow) | AOT (fast) |
| Assertions | Enabled | Disabled |
| DevTools | Enabled | Disabled |
| Typical slowdown | 3-5x | Baseline |
# ❌ Don't test performance in debug
flutter run
# ✅ Profile mode for profiling
flutter run --profile
# ✅ Release mode for real performance
flutter run --release
Build Configuration
# Optimized release build
flutter build apk --release --shrink --obfuscate --split-debug-info=./debug-info
flutter build ios --release
# Web optimizations
flutter build web --release --web-renderer canvaskit
Tree Shaking
// ✅ Import specific items, not entire libraries
import 'package:flutter/material.dart' show MaterialApp, Scaffold, Text;
// ✅ Use show/hide
import 'package:mypackage/utils.dart' show formatDate, parseDate;
Deferred Loading (Code Splitting)
// ✅ Load heavy features on demand
import 'heavy_feature.dart' deferred as heavy;
Future<void> loadHeavyFeature() async {
await heavy.loadLibrary();
Navigator.push(
context,
MaterialPageRoute(builder: (_) => heavy.HeavyScreen()),
);
}
15. Profiling & Debugging
DevTools
# Open DevTools
flutter pub global activate devtools
flutter pub global run devtools
Performance Overlay
MaterialApp(
showPerformanceOverlay: true, // Frame timing bars
checkerboardRasterCacheImages: true, // Cached images
checkerboardOffscreenLayers: true, // SaveLayer usage
)
Timeline Tracing
import 'dart:developer';
void expensiveOperation() {
Timeline.startSync('ExpensiveOperation');
try {
// Work
} finally {
Timeline.finishSync();
}
}
// Async tracing
void asyncOperation() async {
final flow = Flow.begin();
Timeline.startSync('AsyncOperation', flow: flow);
await doWork();
Timeline.finishSync();
Flow.end(flow.id);
}
Rebuild Debugging
// Log widget rebuilds
class DebugWidget extends StatelessWidget {
const DebugWidget({super.key});
@override
Widget build(BuildContext context) {
debugPrint('DebugWidget rebuilt at ${DateTime.now()}');
return const SizedBox();
}
}
// In debug mode only
class _DebugBuildCounter extends StatefulWidget {
@override
State<_DebugBuildCounter> createState() => _DebugBuildCounterState();
}
class _DebugBuildCounterState extends State<_DebugBuildCounter> {
int _buildCount = 0;
@override
Widget build(BuildContext context) {
_buildCount++;
return Stack(
children: [
widget.child,
if (kDebugMode)
Positioned(
right: 0,
top: 0,
child: Text('$_buildCount', style: TextStyle(color: Colors.red)),
),
],
);
}
}
Frame Callback Debugging
void debugFrameTiming() {
SchedulerBinding.instance.addTimingsCallback((timings) {
for (final timing in timings) {
if (timing.totalSpan > const Duration(milliseconds: 16)) {
debugPrint('Slow frame: ${timing.totalSpan.inMilliseconds}ms');
debugPrint(' Build: ${timing.buildDuration.inMilliseconds}ms');
debugPrint(' Raster: ${timing.rasterDuration.inMilliseconds}ms');
}
}
});
}
16. Native Interop Depth
MethodChannel vs EventChannel vs FFI
| Aspect | MethodChannel | EventChannel | dart:ffi |
|---|---|---|---|
| Communication | Request-response | Stream-based | Direct function call |
| Serialization | StandardMessageCodec | StandardMessageCodec | None (direct memory) |
| Threading | Async, main thread | Async, main thread | Synchronous by default |
| Overhead per call | ~0.1-1ms | ~0.05ms (after setup) | ~0.001-0.01ms |
| Use case | One-off calls | Continuous data streams | High-frequency/compute |
| Type safety | Manual / Pigeon | Manual | Generated bindings (ffigen) |
| Platform support | All (incl. web) | All (incl. web) | All except web |
MethodChannel — each call serializes args, crosses the thread boundary, deserializes, executes, then serializes the result back. Typical latency 0.1-1ms per invocation; fixed overhead regardless of payload size. Good for settings changes, one-off queries, low-frequency operations.
EventChannel — pays setup cost once, then delivers a continuous stream with lower per-event overhead than repeated MethodChannel calls. Good for sensor data, location updates, real-time streams.
dart:ffi — direct C function calls with no serialization, roughly 100x-1000x faster than MethodChannel for simple calls. Good for image processing, crypto, audio processing, game logic, ML inference. Only works with the C ABI and is not available on web.
| Scenario | Best Choice |
|---|---|
| Read battery level | MethodChannel |
| Get device info | MethodChannel |
| Listen to accelerometer | EventChannel |
| Stream GPS updates | EventChannel |
| Image manipulation (pixel ops) | FFI |
| Crypto operations | FFI |
| Audio signal processing | FFI |
| Game physics engine | FFI |
| SQLite database access | FFI |
| Push notification handling | MethodChannel |
| Bluetooth data streaming | EventChannel |
Platform Channel Overhead Sources
- Serialization/Deserialization: StandardMessageCodec converts Dart objects to binary and back
- Thread hopping: Messages cross from Dart isolate to platform thread
- Asynchronous nature: Every call involves Future/callback overhead
- Main thread requirement: Native handlers must run on the platform’s main thread
Choosing the Right Codec
const channel = MethodChannel('com.app/data');
const binaryChannel = BasicMessageChannel('com.app/binary', BinaryCodec());
const jsonChannel = BasicMessageChannel('com.app/json', JSONMessageCodec());
Background Thread Handlers (Android)
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
val taskQueue = binding.binaryMessenger.makeBackgroundTaskQueue()
channel = MethodChannel(
binding.binaryMessenger,
"com.app/heavy",
StandardMethodCodec.INSTANCE,
taskQueue
)
channel.setMethodCallHandler(this)
}
FFI Setup and Usage
import 'dart:ffi';
import 'dart:io' show Platform;
final DynamicLibrary nativeLib = Platform.isAndroid
? DynamicLibrary.open('libnative_add.so')
: DynamicLibrary.process();
typedef NativeAdd = Int32 Function(Int32, Int32);
typedef DartAdd = int Function(int, int);
final add = nativeLib.lookupFunction<NativeAdd, DartAdd>('native_add');
int result = add(3, 5);
Generate Bindings Automatically with ffigen
# pubspec.yaml
dev_dependencies:
ffigen: ^9.0.0
# ffigen.yaml
name: NativeBindings
description: Auto-generated bindings
output: lib/src/native_bindings.dart
headers:
entry-points:
- src/native_lib.h
dart run ffigen
Pigeon for Type-Safe Codegen
// pigeon/messages.dart
import 'package:pigeon/pigeon.dart';
class DataRequest {
final String id;
final int limit;
DataRequest({required this.id, required this.limit});
}
class DataResponse {
final List<String> items;
final int total;
DataResponse({required this.items, required this.total});
}
@HostApi()
abstract class DataApi {
@async
DataResponse fetchData(DataRequest request);
}
dart run pigeon --input pigeon/messages.dart
Platform Channels from Background Isolates
import 'dart:isolate';
import 'package:flutter/services.dart';
void main() {
final rootIsolateToken = RootIsolateToken.instance!;
Isolate.spawn(_backgroundWork, rootIsolateToken);
}
Future<void> _backgroundWork(RootIsolateToken token) async {
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
const channel = MethodChannel('com.app/data');
final result = await channel.invokeMethod('heavyOperation');
}
Threading Requirements
- Android: Must invoke on the UI thread (use the
@UiThreadannotation); useHandler(Looper.getMainLooper()).post { }to hop to the UI thread - iOS: Must invoke on the main thread; use
DispatchQueue.main.async { }to hop to the main thread
17. App Startup Optimization
Deferred Components (Android & Web)
Split the app into downloadable modules that load on demand.
# pubspec.yaml
flutter:
deferred-components:
- name: heavyFeature
libraries:
- package:my_app/heavy_feature.dart
assets:
- assets/heavy_images/
import 'heavy_feature.dart' deferred as heavy;
class FeatureLoader extends StatefulWidget {
const FeatureLoader({super.key});
@override
State<FeatureLoader> createState() => _FeatureLoaderState();
}
class _FeatureLoaderState extends State<FeatureLoader> {
late final Future<void> _loadFuture;
@override
void initState() {
super.initState();
_loadFuture = heavy.loadLibrary();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<void>(
future: _loadFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
if (snapshot.hasError) return Text('Error: ${snapshot.error}');
return const heavy.HeavyWidget();
}
return const CircularProgressIndicator();
},
);
}
}
Android setup requires the Play Core dependency:
// build.gradle.kts
dependencies {
implementation("com.google.android.play:core:1.8.0")
}
<application
android:name="io.flutter.embedding.android.FlutterPlayStoreSplitApplication">
<meta-data
android:name="io.flutter.embedding.engine.deferredcomponents.DeferredComponentManager.loadingUnitMapping"
android:value="2:heavyFeature"/>
</application>
flutter build appbundle
java -jar bundletool.jar build-apks --bundle=build/app/outputs/bundle/release/app-release.aab --output=app.apks --local-testing
java -jar bundletool.jar install-apks --apks=app.apks
Lazy Initialization Patterns
class AppConfig {
static late final Database _db;
static Future<void> init() async {
_db = await Database.open('app.db');
}
}
class HeavyService {
static HeavyService? _instance;
static HeavyService get instance => _instance ??= HeavyService._();
HeavyService._();
}
class ServiceLocator {
static final Map<Type, Future<dynamic> Function()> _factories = {};
static final Map<Type, dynamic> _instances = {};
static void registerLazy<T>(Future<T> Function() factory) {
_factories[T] = factory;
}
static Future<T> get<T>() async {
if (_instances.containsKey(T)) return _instances[T] as T;
final instance = await (_factories[T]!() as Future<T>);
_instances[T] = instance;
return instance;
}
}
Minimize main() Work
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const MyApp());
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_initializeServices();
});
}
Future<void> _initializeServices() async {
await Future.wait([
_initDatabase(),
_initAnalytics(),
_initRemoteConfig(),
]);
}
}
Push heavy parsing into a background isolate so it never blocks the first frame:
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final config = await Isolate.run(() {
return parseConfig(rawConfigData);
});
runApp(MyApp(config: config));
}
18. Font Loading
Subset Fonts (Reduce Size)
pip install fonttools
pyftsubset MyFont.ttf --text="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" --output-file=MyFont-subset.ttf
Variable Fonts
A single variable font file replaces multiple weight/style files:
flutter:
fonts:
- family: Inter
fonts:
- asset: fonts/Inter-Variable.ttf
Precache Fonts
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final fontLoader = FontLoader('CustomFont');
fontLoader.addFont(rootBundle.load('fonts/CustomFont.ttf'));
await fontLoader.load();
runApp(const MyApp());
}
Google Fonts Optimization
import 'package:google_fonts/google_fonts.dart';
void main() {
GoogleFonts.config.allowRuntimeFetching = false;
runApp(const MyApp());
}
Bundle the fonts locally and declare them in pubspec.yaml to avoid a network fetch at runtime.
Supported Formats
.ttf(TrueType) — universal support.otf(OpenType) — universal support.ttc(TrueType Collection) — universal support.woff/.woff2— NOT supported on desktop platforms
Avoid Font Simulation
flutter:
fonts:
- family: Roboto
fonts:
- asset: fonts/Roboto-Regular.ttf
weight: 400
- asset: fonts/Roboto-Bold.ttf
weight: 700
- asset: fonts/Roboto-Italic.ttf
style: italic
If you use FontWeight.w700 but only provide the Regular file, Flutter simulates bold — slower and lower quality than a real bold face.
19. App Size Reduction
Build with —split-debug-info
The single most impactful optimization; extracts debug symbols out of the binary:
flutter build apk --split-debug-info=build/debug-info
flutter build appbundle --split-debug-info=build/debug-info
flutter build ipa --split-debug-info=build/debug-info
Obfuscate Dart Code
flutter build apk --obfuscate --split-debug-info=build/debug-info
flutter build ipa --obfuscate --split-debug-info=build/debug-info
Analyze App Size
flutter build apk --analyze-size
flutter build appbundle --analyze-size
flutter build ipa --analyze-size
Generates a JSON file loadable in DevTools for a treemap visualization down to function level.
Split APK by Architecture
flutter build apk --split-per-abi
Produces three smaller APKs instead of one fat APK:
app-armeabi-v7a-release.apk(ARM 32-bit)app-arm64-v8a-release.apk(ARM 64-bit)app-x86_64-release.apk(x86 64-bit)
Use App Bundles
flutter build appbundle
Google Play automatically filters assets by device DPI, filters native libraries by CPU architecture, and delivers only what the device needs.
Shared Object Compression (Android)
<!-- AndroidManifest.xml -->
<application
android:extractNativeLibs="false">
</application>
Trade-off: a larger APK download, but smaller on-device storage as .so files load directly from the APK without extraction.
20. Android R8/ProGuard & iOS Build Settings
R8 is Enabled by Default
In recent Flutter versions, R8 is always enabled for release builds and cannot be disabled. R8 replaces ProGuard and provides code shrinking, obfuscation, optimization, and resource shrinking.
Custom ProGuard/R8 Rules
Create android/app/proguard-rules.pro:
-keep class io.flutter.app.** { *; }
-keep class io.flutter.plugin.** { *; }
-keep class io.flutter.util.** { *; }
-keep class io.flutter.view.** { *; }
-keep class io.flutter.** { *; }
-keep class io.flutter.plugins.** { *; }
-keepattributes *Annotation*
-keepclasseswithmembernames class * {
native <methods>;
}
-keep class com.google.firebase.** { *; }
-keep class com.google.gson.** { *; }
-keepattributes Signature
android {
buildTypes {
release {
minifyEnabled true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
Resource Shrinking
android {
buildTypes {
release {
shrinkResources true
minifyEnabled true
}
}
}
Multidex
For apps exceeding 64K methods (minSdk < 21):
android {
defaultConfig {
multiDexEnabled true
}
}
iOS: Bitcode Deprecated
As of Xcode 14+, bitcode is deprecated and no longer used. Apple removed bitcode support, and Flutter builds do not include bitcode.
Xcode Build Optimization Levels
| Setting | Debug | Release |
|---|---|---|
| Optimization Level | -O0 (None) | -Os (Size) |
| Swift Optimization Level | -Onone | -O |
| Debug Information Format | DWARF | DWARF+dSYM |
| Strip Debug Symbols | No | Yes |
| Dead Code Stripping | No | Yes |
| Link-Time Optimization | No | Yes (Thin) |
Flutter’s build system sets these automatically for release builds.
App Thinning
The App Store automatically performs slicing (variant bundles per device type), on-demand resources, and asset catalog optimization so each device downloads only what it needs.
iOS Build and Deploy
flutter build ipa
flutter build ipa --obfuscate --split-debug-info=build/debug-info
flutter build ipa --export-method ad-hoc
flutter build ipa --export-method development
iOS Minimum Deployment Target
# ios/Podfile
platform :ios, '13.0'
Higher minimum targets let the compiler use more modern APIs and optimizations.
21. Web Renderers
CanvasKit vs Skwasm
| Aspect | CanvasKit | Skwasm |
|---|---|---|
| Build mode | Default (any build) | WebAssembly (--wasm) only |
| Browser support | All modern browsers | Requires WasmGC support |
| Download size | ~1.5MB additional | ~1.1MB additional |
| Technology | Full Skia -> WebAssembly | Streamlined Skia + WASM |
| Multi-threading | No | Yes (via web workers) |
| Startup speed | Baseline | Faster |
| Frame performance | Baseline | Better |
| Fidelity | High (matches mobile) | High (matches mobile) |
Build Commands
flutter build web
flutter build web --wasm
At runtime, --wasm builds try Skwasm first and fall back to CanvasKit if the browser lacks WasmGC.
Choose Renderer at Runtime
<body>
<script>
{{flutter_js}}
{{flutter_build_config}}
const config = {
renderer: "canvaskit",
};
_flutter.loader.load({ config: config });
</script>
</body>
WebAssembly Compatibility Requirements
To use --wasm builds:
- Use
dart:js_interop(not the deprecateddart:jsorpackage:js) - Use
package:webfor Web APIs (not the deprecateddart:html) - Ensure numeric type compatibility with Dart VM behavior
22. Desktop Optimization
Desktop apps use the same rendering pipeline as mobile, with extra considerations: Impeller is available on macOS behind an experimental flag, while Windows/Linux default to Skia with Impeller support coming.
macOS
flutter run --enable-impeller
<!-- Info.plist -->
<key>FLTEnableImpeller</key>
<true/>
flutter build macos
Ensure com.apple.security.network.client entitlement is set for network access; missing entitlements fail silently and can appear as performance issues.
Windows
import 'dart:ffi';
import 'package:ffi/ffi.dart';
final user32 = DynamicLibrary.open('user32.dll');
Distribution: include Visual C++ redistributables (msvcp140.dll, vcruntime140.dll, vcruntime140_1.dll); use MSIX packaging for Store distribution.
Linux
sudo apt-get install libgtk-3-0 libblkid1 liblzma5
flutter build linux --release
final lib = DynamicLibrary.open('libexample.so');
Desktop-Wide Best Practices
- Keyboard and mouse: Desktop apps receive more events per second than touch; debounce where appropriate
- Window resize: Use
LayoutBuilderand debounce heavy rebuilds during resize - Memory management: Desktop sessions run longer; watch for leaks
- Large viewports: Use
RepaintBoundaryto limit repaint regions on large screens - Multi-window: Not yet natively supported; use packages like
desktop_multi_windowfor workarounds
23. Quick Reference
Performance Checklist
- Using const constructors everywhere possible
- ListView.builder for lists > 20 items
- Keys on stateful list items
- Isolate state to smallest scope
- Dispose all controllers
- Images sized appropriately
- RepaintBoundary for animations
- Heavy computation in isolates
- Tested in release mode
- Impeller enabled (Android)
240fps Additional Checks
- const constructors are mandatory, not optional
- Pre-compute ALL data before build()
- RepaintBoundary used aggressively on animated subtrees
- No allocations in build() method
- SchedulerBinding.scheduleFrameCallback for manual frame sync
- Shader warm-up for custom effects
Cheat Sheet
// const everywhere
const Text('Hello')
const SizedBox(height: 8)
const EdgeInsets.all(16)
// Lazy list
ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) => Item(key: ValueKey(items[index].id)),
)
// Isolated state
ValueListenableBuilder<T>(
valueListenable: notifier,
builder: (context, value, child) => Text('$value'),
)
// Background work
final result = await Isolate.run(() => heavyWork(data));
// Efficient animation
AnimatedBuilder(
animation: controller,
child: const ExpensiveWidget(), // Static
builder: (context, child) => Transform.scale(
scale: controller.value,
child: child,
),
)
Common Pitfalls
| Pitfall | Impact | Fix |
|---|---|---|
| Missing const | Rebuilds every frame | Add const |
| ListView for large lists | Memory spike | Use ListView.builder |
| State in parent | Full tree rebuilds | Isolate to child |
| No dispose | Memory leak | Dispose in dispose() |
| Full-res images | OOM, jank | Use cacheWidth/Height |
| Opacity widget | Slow animation | Use FadeTransition |
| Debug mode testing | False performance | Test in release |
| Allocations in build | GC pressure | Move to initState |
Frame Budget
120 FPS (8.33ms) 240 FPS (4.17ms)
├── Build: 2.5ms ├── Build: 1.0ms
├── Layout: 2.0ms ├── Layout: 1.0ms
├── Paint: 1.5ms ├── Paint: 0.5ms
└── Raster: 2.33ms └── Raster: 1.67ms
240fps Note: At this frame budget, every microsecond counts. Const constructors, pre-computed data, and aggressive RepaintBoundary usage are not optimizations—they are requirements.