Performance optimization patterns for Flutter applications including widget optimization, memory management, profiling, and 60 FPS best practices
Optimizes Flutter app performance with widget optimization, memory management, and profiling techniques.
/plugin marketplace add Kaakati/rails-enterprise-dev/plugin install reactree-flutter-dev@manifest-marketplaceThis skill inherits all available tools. When active, it can use any tool Claude has access to.
Complete guide to building high-performance Flutter applications that maintain 60 FPS (or 120 FPS on capable devices).
Const widgets are built once and reused:
// ❌ BAD - Widget rebuilt every time
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.all(16.0),
child: Text('Static Text'),
);
}
// ✅ GOOD - Widget built once, reused
Widget build(BuildContext context) {
return const Padding(
padding: EdgeInsets.all(16.0),
child: Text('Static Text'),
);
}
// ✅ GOOD - Individual widgets const
Widget build(BuildContext context) {
return Column(
children: const [
Icon(Icons.home),
SizedBox(height: 8),
Text('Home'),
],
);
}
Rule: If a widget's properties don't change, make it const.
Use Obx strategically to limit rebuild scope:
// ❌ BAD - Entire Column rebuilds
Obx(() => Column(
children: [
Text(controller.title.value),
ExpensiveWidget(),
AnotherExpensiveWidget(),
],
))
// ✅ GOOD - Only Text rebuilds
Column(
children: [
Obx(() => Text(controller.title.value)),
const ExpensiveWidget(),
const AnotherExpensiveWidget(),
],
)
Keys help Flutter identify which widgets to reuse:
// ❌ BAD - No keys, Flutter may rebuild unnecessarily
ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
return ListTile(title: Text(items[index].name));
},
)
// ✅ GOOD - ValueKey helps Flutter track items
ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
return ListTile(
key: ValueKey(items[index].id),
title: Text(items[index].name),
);
},
)
// When to use keys:
// - ObjectKey: Compare entire object
// - ValueKey: Compare single value (id, index)
// - UniqueKey: Force rebuild
// - GlobalKey: Access widget state from anywhere (expensive, use sparingly)
Extract complex widgets to reduce rebuild scope:
// ❌ BAD - All items rebuild when list changes
class MyList extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Obx(() => ListView.builder(
itemCount: controller.items.length,
itemBuilder: (context, index) {
final item = controller.items[index];
return Container(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Text(item.title),
Text(item.subtitle),
Row(
children: [
Icon(Icons.star),
Text('${item.rating}'),
],
),
],
),
);
},
));
}
}
// ✅ GOOD - Extract item widget
class MyList extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Obx(() => ListView.builder(
itemCount: controller.items.length,
itemBuilder: (context, index) {
return ItemWidget(item: controller.items[index]);
},
));
}
}
class ItemWidget extends StatelessWidget {
final Item item;
const ItemWidget({Key? key, required this.item}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Text(item.title),
Text(item.subtitle),
Row(
children: [
const Icon(Icons.star),
Text('${item.rating}'),
],
),
],
),
);
}
}
Never use ListView(children: [...]) for large lists:
// ❌ BAD - All items created upfront
ListView(
children: items.map((item) => ItemWidget(item: item)).toList(),
)
// ✅ GOOD - Items created on demand
ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) => ItemWidget(item: items[index]),
)
// ✅ GOOD - Separated items (for sticky headers)
ListView.separated(
itemCount: items.length,
itemBuilder: (context, index) => ItemWidget(item: items[index]),
separatorBuilder: (context, index) => const Divider(),
)
// Add cacheExtent for smoother scrolling
ListView.builder(
cacheExtent: 500, // Render items 500px off-screen
itemCount: items.length,
itemBuilder: (context, index) => ItemWidget(item: items[index]),
)
// Use addAutomaticKeepAlives: false if items don't need to preserve state
ListView.builder(
addAutomaticKeepAlives: false,
addRepaintBoundaries: true,
itemCount: items.length,
itemBuilder: (context, index) => ItemWidget(item: items[index]),
)
class ProductListController extends GetxController {
final _items = <Product>[].obs;
List<Product> get items => _items;
final _isLoading = false.obs;
bool get isLoading => _isLoading.value;
int _currentPage = 1;
bool _hasMore = true;
final ScrollController scrollController = ScrollController();
@override
void onInit() {
super.onInit();
loadItems();
scrollController.addListener(_onScroll);
}
@override
void onClose() {
scrollController.dispose();
super.onClose();
}
void _onScroll() {
if (scrollController.position.pixels >=
scrollController.position.maxScrollExtent - 200) {
if (!_isLoading.value && _hasMore) {
loadMore();
}
}
}
Future<void> loadItems() async {
_isLoading.value = true;
final result = await repository.getProducts(page: 1);
result.fold(
(failure) => {},
(products) {
_items.value = products;
_hasMore = products.length >= 20; // Assuming page size of 20
},
);
_isLoading.value = false;
}
Future<void> loadMore() async {
_isLoading.value = true;
_currentPage++;
final result = await repository.getProducts(page: _currentPage);
result.fold(
(failure) => _currentPage--,
(products) {
_items.addAll(products);
_hasMore = products.length >= 20;
},
);
_isLoading.value = false;
}
}
import 'package:cached_network_image/cached_network_image.dart';
// ❌ BAD - No caching, re-downloads every time
Image.network('https://example.com/image.jpg')
// ✅ GOOD - Cached, with placeholders
CachedNetworkImage(
imageUrl: 'https://example.com/image.jpg',
placeholder: (context, url) => const CircularProgressIndicator(),
errorWidget: (context, url, error) => const Icon(Icons.error),
fadeInDuration: const Duration(milliseconds: 300),
memCacheWidth: 400, // Resize for memory efficiency
)
// Specify dimensions to avoid unnecessary rendering
CachedNetworkImage(
imageUrl: product.imageUrl,
width: 200,
height: 200,
fit: BoxFit.cover,
memCacheWidth: 200 * 2, // 2x for high DPI displays
memCacheHeight: 200 * 2,
)
// For list thumbnails, use lower resolution
CachedNetworkImage(
imageUrl: product.thumbnailUrl, // Server-side thumbnail
width: 50,
height: 50,
memCacheWidth: 100,
memCacheHeight: 100,
)
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Precache images that will be needed soon
precacheImage(
CachedNetworkImageProvider(product.imageUrl),
context,
);
}
class MyController extends GetxController {
late final StreamSubscription _subscription;
late final ScrollController scrollController;
late final TextEditingController textController;
@override
void onInit() {
super.onInit();
scrollController = ScrollController();
textController = TextEditingController();
_subscription = someStream.listen((data) {
// Handle data
});
}
@override
void onClose() {
// CRITICAL: Dispose all resources
scrollController.dispose();
textController.dispose();
_subscription.cancel();
super.onClose();
}
}
// ❌ BAD - Permanent controller never disposed
Get.put(MyController(), permanent: true);
// ✅ GOOD - Controller disposed when not needed
Get.lazyPut(() => MyController());
// ✅ GOOD - Explicitly control lifecycle
Get.put(MyController(), tag: 'unique-tag');
// Later: Get.delete<MyController>(tag: 'unique-tag');
class MyController extends GetxController {
Timer? _timer;
@override
void onInit() {
super.onInit();
// Use WeakReference to avoid keeping controller alive
_timer = Timer.periodic(Duration(seconds: 1), (timer) {
if (!isClosed) {
updateData();
}
});
}
@override
void onClose() {
_timer?.cancel();
super.onClose();
}
}
// feature_a.dart - Large feature module
import 'package:flutter/material.dart';
class FeatureAPage extends StatelessWidget {
// Heavy feature implementation
}
// main.dart - Lazy load feature
import 'feature_a.dart' deferred as feature_a;
void navigateToFeatureA() async {
await feature_a.loadLibrary(); // Load code on demand
Get.to(() => feature_a.FeatureAPage());
}
// ❌ BAD - All controllers loaded at startup
void main() {
Get.put(HomeController());
Get.put(ProfileController());
Get.put(SettingsController());
runApp(MyApp());
}
// ✅ GOOD - Controllers loaded when needed
class HomeBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut(() => HomeController());
}
}
GetPage(
name: '/home',
page: () => HomePage(),
binding: HomeBinding(), // Loaded only when route accessed
)
// ❌ BAD - Rebuilds entire widget tree
class MyWidget extends StatefulWidget {
@override
State<MyWidget> createState() => _MyWidgetState();
}
class _MyWidgetState extends State<MyWidget>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
Widget build(BuildContext context) {
return Transform.scale(
scale: _controller.value,
child: ExpensiveWidget(), // Rebuilt every frame!
);
}
}
// ✅ GOOD - Only animated part rebuilds
class ScaleTransition extends AnimatedWidget {
const ScaleTransition({
required Animation<double> scale,
required this.child,
}) : super(listenable: scale);
final Widget child;
@override
Widget build(BuildContext context) {
final animation = listenable as Animation<double>;
return Transform.scale(
scale: animation.value,
child: child,
);
}
}
// Usage
ScaleTransition(
scale: _controller,
child: const ExpensiveWidget(), // Not rebuilt!
)
// ❌ BAD - Too many simultaneous animations
class MyPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: List.generate(100, (index) =>
AnimatedContainer(
duration: Duration(seconds: 1),
// Each container animates independently
),
),
);
}
}
// ✅ GOOD - Stagger animations, limit concurrent
class MyPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return AnimatedList(
initialItemCount: items.length,
itemBuilder: (context, index, animation) {
return FadeTransition(
opacity: animation,
child: ItemWidget(item: items[index]),
);
},
);
}
}
# Run app in profile mode (not debug!)
flutter run --profile
# Open DevTools
# Press 'w' in terminal or visit: http://localhost:9100
Key DevTools Features:
// Add debug print to measure build time
@override
Widget build(BuildContext context) {
final stopwatch = Stopwatch()..start();
final widget = ExpensiveWidget();
if (kDebugMode) {
print('Build took: ${stopwatch.elapsedMilliseconds}ms');
}
return widget;
}
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
showPerformanceOverlay: true, // Shows FPS graphs
home: HomePage(),
);
}
}
const constructors wherever possibleObx scope to smallest widgetsetState or update() callsListView.builder for dynamic listscacheExtent for smoother scrollingaddAutomaticKeepAlives: false when appropriatecached_network_image for network imagesonClose()lazyPut instead of put for GetX controllersAnimatedWidget for custom animationsRepaintBoundary for expensive widgetsAnimatedContainer, AnimatedOpacity)// ❌ Obx wrapping entire screen
Obx(() => Scaffold(
body: Column(children: [
Text(controller.title.value),
LargeWidget(),
]),
))
// ✅ Obx only on changing widget
Scaffold(
body: Column(children: [
Obx(() => Text(controller.title.value)),
const LargeWidget(),
]),
)
// ❌ Heavy computation in build
@override
Widget build(BuildContext context) {
final processedData = heavyComputation(rawData); // Runs every build!
return Text(processedData);
}
// ✅ Compute once, cache result
class MyController extends GetxController {
final _processedData = ''.obs;
@override
void onInit() {
super.onInit();
_processedData.value = heavyComputation(rawData);
}
}
// ❌ Memory leak - controller never disposed
class MyController extends GetxController {
final StreamController<int> _controller = StreamController();
// Missing onClose() to dispose!
}
// ✅ Proper disposal
class MyController extends GetxController {
final StreamController<int> _controller = StreamController();
@override
void onClose() {
_controller.close();
super.onClose();
}
}
| Metric | Target | Tool |
|---|---|---|
| Frame time | < 16ms (60 FPS) | DevTools Performance |
| Build time | < 5ms for simple widgets | Debug prints |
| Memory usage | < 100MB typical screen | DevTools Memory |
| App startup | < 2 seconds | Stopwatch |
| Image load | < 1 second | Network tab |
| API response | < 500ms | Network tab |
This skill should be used when the user asks to "create a slash command", "add a command", "write a custom command", "define command arguments", "use command frontmatter", "organize commands", "create command with file references", "interactive command", "use AskUserQuestion in command", or needs guidance on slash command structure, YAML frontmatter fields, dynamic arguments, bash execution in commands, user interaction patterns, or command development best practices for Claude Code.
This skill should be used when the user asks to "create an agent", "add an agent", "write a subagent", "agent frontmatter", "when to use description", "agent examples", "agent tools", "agent colors", "autonomous agent", or needs guidance on agent structure, system prompts, triggering conditions, or agent development best practices for Claude Code plugins.
This skill should be used when the user asks to "create a hook", "add a PreToolUse/PostToolUse/Stop hook", "validate tool use", "implement prompt-based hooks", "use ${CLAUDE_PLUGIN_ROOT}", "set up event-driven automation", "block dangerous commands", or mentions hook events (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, UserPromptSubmit, PreCompact, Notification). Provides comprehensive guidance for creating and implementing Claude Code plugin hooks with focus on advanced prompt-based hooks API.