Back to Articles
Flutter Performance Optimization Handbook

Flutter Performance Optimization Handbook

Comprehensive practices for building 60-240 FPS UI with Flutter


Table of Contents

  1. Rendering Architecture
  2. Impeller vs Skia
  3. const Constructors
  4. Widget Rebuild Optimization
  5. ListView & Scrolling
  6. State Management
  7. Isolates & Concurrency
  8. Image Optimization
  9. Animations
  10. Custom Painting
  11. Layout Optimization
  12. Memory Management
  13. Platform Channels
  14. Build Optimization
  15. Profiling & Debugging
  16. Native Interop Depth
  17. App Startup Optimization
  18. Font Loading
  19. App Size Reduction
  20. Android R8/ProGuard & iOS Build Settings
  21. Web Renderers
  22. Desktop Optimization
  23. 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 FPSFrame BudgetThread Split (UI/Raster)
60 FPS16.67ms~8ms / ~8ms
90 FPS11.11ms~5ms / ~5ms
120 FPS8.33ms~4ms / ~4ms
240 FPS4.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

FeatureSkia (Legacy)Impeller (2025 Default)
Shader compilationRuntime (causes jank)AOT (pre-compiled)
Graphics APIOpenGL wrapperNative Metal/Vulkan
Rendering modeImmediateRetained (cached)
First-run performanceOften jankySmooth
iOS supportDeprecatedDefault (Flutter 3.29+)
Android supportDefault < API 29Available 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

WidgetBehaviorUse Case
ListView(children: [])Creates all at once< 20 items
ListView.builder()Creates on-demand20+ items
ListView.separated()Builder + separatorsLists with dividers
ListView.custom()Custom child managementAdvanced 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

WidgetUse Case
SliverList.builderLazy vertical list
SliverGrid.builderLazy grid
SliverAppBarCollapsing header
SliverPersistentHeaderSticky header
SliverToBoxAdapterSingle non-sliver widget
SliverFillRemainingFill remaining space
SliverPaddingPadded 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

ApproachRebuild ScopeBest For
setStateEntire StatefulWidgetLocal UI state
ValueNotifierValueListenableBuilder onlySimple reactive values
ChangeNotifierConsumer widgetsSmall-medium apps
Provider + SelectorSelected values onlyMedium apps
RiverpodPer-providerLarge apps
BlocPer-streamEvent-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

ProblemSymptomSolution
Full resolutionOOM, jankUse cacheWidth/cacheHeight
No cachingRe-downloadUse cached_network_image
Large assetsSlow startupUse resolution-aware assets
Many imagesMemory pressureLimit concurrent loads
No placeholdersLayout shiftUse 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

WidgetUse CaseSimplicity
AnimatedContainerContainer propertiesEasiest
AnimatedOpacityFade in/outEasiest
AnimatedPositionedPosition changesEasy
AnimatedSwitcherWidget transitionsEasy
AnimatedBuilderCustom animationsMedium
TweenAnimationBuilderOne-shot animationsMedium

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

AspectDebugRelease
Dart VMJIT (slow)AOT (fast)
AssertionsEnabledDisabled
DevToolsEnabledDisabled
Typical slowdown3-5xBaseline
# ❌ 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

AspectMethodChannelEventChanneldart:ffi
CommunicationRequest-responseStream-basedDirect function call
SerializationStandardMessageCodecStandardMessageCodecNone (direct memory)
ThreadingAsync, main threadAsync, main threadSynchronous by default
Overhead per call~0.1-1ms~0.05ms (after setup)~0.001-0.01ms
Use caseOne-off callsContinuous data streamsHigh-frequency/compute
Type safetyManual / PigeonManualGenerated bindings (ffigen)
Platform supportAll (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.

ScenarioBest Choice
Read battery levelMethodChannel
Get device infoMethodChannel
Listen to accelerometerEventChannel
Stream GPS updatesEventChannel
Image manipulation (pixel ops)FFI
Crypto operationsFFI
Audio signal processingFFI
Game physics engineFFI
SQLite database accessFFI
Push notification handlingMethodChannel
Bluetooth data streamingEventChannel

Platform Channel Overhead Sources

  1. Serialization/Deserialization: StandardMessageCodec converts Dart objects to binary and back
  2. Thread hopping: Messages cross from Dart isolate to platform thread
  3. Asynchronous nature: Every call involves Future/callback overhead
  4. 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 @UiThread annotation); use Handler(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

SettingDebugRelease
Optimization Level-O0 (None)-Os (Size)
Swift Optimization Level-Onone-O
Debug Information FormatDWARFDWARF+dSYM
Strip Debug SymbolsNoYes
Dead Code StrippingNoYes
Link-Time OptimizationNoYes (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

AspectCanvasKitSkwasm
Build modeDefault (any build)WebAssembly (--wasm) only
Browser supportAll modern browsersRequires WasmGC support
Download size~1.5MB additional~1.1MB additional
TechnologyFull Skia -> WebAssemblyStreamlined Skia + WASM
Multi-threadingNoYes (via web workers)
Startup speedBaselineFaster
Frame performanceBaselineBetter
FidelityHigh (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:

  1. Use dart:js_interop (not the deprecated dart:js or package:js)
  2. Use package:web for Web APIs (not the deprecated dart:html)
  3. 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

  1. Keyboard and mouse: Desktop apps receive more events per second than touch; debounce where appropriate
  2. Window resize: Use LayoutBuilder and debounce heavy rebuilds during resize
  3. Memory management: Desktop sessions run longer; watch for leaks
  4. Large viewports: Use RepaintBoundary to limit repaint regions on large screens
  5. Multi-window: Not yet natively supported; use packages like desktop_multi_window for 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

PitfallImpactFix
Missing constRebuilds every frameAdd const
ListView for large listsMemory spikeUse ListView.builder
State in parentFull tree rebuildsIsolate to child
No disposeMemory leakDispose in dispose()
Full-res imagesOOM, jankUse cacheWidth/Height
Opacity widgetSlow animationUse FadeTransition
Debug mode testingFalse performanceTest in release
Allocations in buildGC pressureMove 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.