Performance Profiling en Flutter: Por Qué Tu App Es Lenta (Y Cómo Arreglarlo)
Una guía exhaustiva sobre performance profiling en Flutter: cómo usar DevTools correctamente, identificar cuellos de botella reales, evitar anti-patrones comunes, y optimizar apps que se sienten lentas. Con 40+ ejemplos de código Dart y casos de desastre reales.
Performance Profiling en Flutter: Por Qué Tu App Es Lenta (Y Cómo Arreglarlo)
La Verdad Incómoda Sobre Performance en Flutter
🎯 Introducción: El Problema Real
Te has visto en esta situación:
Usuario: "Tu app es muy lenta."
Tú: "Pero Flutter es rápido..."
Usuario: "Igual es lenta."
Tú: *inserta aquí 3 horas de debugging sin saber por dónde empezar*
Aquí está la verdad: Flutter no es lento. Pero tu app probablemente sí.
Y no es tu culpa (completamente). El problema es que performance profiling en Flutter es una habilidad que casi nadie enseña.
¿Por Qué las Apps Flutter Son Lentas?
Existen exactamente cuatro razones:
- Rebuilds innecesarios - Redibujando widgets que no cambiaron
- Main thread bloqueado - Trabajo pesado en el thread de UI
- Jank en animaciones - Frames perdidos (60fps → 30fps)
- Memory leaks - Memoria sin liberar
90% de apps “lentas” tienen uno de estos problemas. No necesitas optimización avanzada, necesitas identificar dónde está el cuello de botella.
¿Qué Aprenderás Aquí?
✅ DevTools correctamente - No es solo un bonito dashboard ✅ Medir realmente - 60fps vs 120fps vs 240fps ✅ Identificar problemas - Qué herramientas usar para cada caso ✅ Anti-patrones comunes - Errores que rompen performance ✅ Optimizaciones reales - Que funcionan, no teóricas ✅ Casos de desastre - Historias de horror y cómo se arreglaron
📊 Parte 1: Entender Las Métricas
El Frame Budget (El Concepto Clave)
Flutter debe completar cada frame en menos de 16.67ms (60fps) o 8.33ms (120fps).
Si toma más, usuarios ven jank (tartamudeo).
60fps: 1000ms / 60 = 16.67ms por frame
120fps: 1000ms / 120 = 8.33ms por frame
240fps: 1000ms / 240 = 4.16ms por frame
En la práctica:
✅ 60fps: Sentido fluido
⚠️ 45fps: Empieza a sentirse mal
❌ 30fps: Visiblemente tartamudo
💀 <20fps: Inutilizable
Las Dos Fases de Cada Frame
Cada frame pasa por exactamente dos fases:
Fase 1: Build (Construcción)
Tu código Dart corre acá.
- build() es ejecutado
- Widgets se construyen
- State actualiza
Si esto toma más de 16.67ms, el frame se pierde.
Fase 2: Rasterize (Rasterización)
Motor de rendering de Flutter corre acá.
- Paints (dibuja)
- Compositor (organiza capas)
- Envía a GPU
Si esto toma más de 16.67ms, el frame se pierde.
La verdad: Tú controlas el tiempo de Build. Flutter controla Rasterize.
// ❌ MALO: Build toma 50ms
build(BuildContext context) {
// Esto se ejecuta CADA frame
for (int i = 0; i < 1000; i++) {
computeExpensiveCalculation(); // 50ms total
}
return Container();
}
// ✅ BUENO: Build es instantáneo
build(BuildContext context) {
// El cálculo ya fue hecho, solo mostramos
return Text(expensiveResult);
}
🛠️ Parte 2: DevTools - La Herramienta Correcta
Abrir DevTools
# En tu terminal, con la app corriendo
flutter pub global activate devtools
devtools
# Luego en otra terminal
flutter run -d emulator
O más simple: Abre Flutter DevTools en VS Code
Press Ctrl+Shift+P → “Open DevTools”
La Pestaña Performance (El Corazón)
Es aquí donde ves TODO.
┌─────────────────────────────────┐
│ Performance Timeline │
├─────────────────────────────────┤
│ Frame 1: Build: 2ms Raster: 4ms ✅
│ Frame 2: Build: 1ms Raster: 3ms ✅
│ Frame 3: Build: 45ms Raster: 2ms ❌ JANK!
│ Frame 4: Build: 2ms Raster: 2ms ✅
└─────────────────────────────────┘
Qué significa:
- Build: 45ms = Tu código tardó demasiado
- Si ves frames rojos = Algo está mal
- Si ves muchos frames lentos = Problema sistemático
Iniciando el Profiler
// En tu main.dart
void main() {
// Iniciar profiling automático
debugPrintBeginFrameBanner = true;
debugPrintEndFrameBanner = true;
runApp(const MyApp());
}
Ahora en logs ves:
I/flutter: ▄▄▄▄▄▄▄▄▄▄▄▄▄▄ Frame 1234 (build: 12ms) ▄▄▄▄▄▄▄▄▄▄▄▄▄▄
I/flutter: ▄▄▄▄▄▄▄▄▄▄▄▄▄▄ Frame 1235 (build: 45ms) ▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ← JANK!
I/flutter: ▄▄▄▄▄▄▄▄▄▄▄▄▄▄ Frame 1236 (build: 11ms) ▄▄▄▄▄▄▄▄▄▄▄▄▄▄
El Trick Avanzado: Additive Sampling
// Cuando DevTools no es suficiente
import 'dart:developer' as developer;
void myFunction() {
final stopwatch = Stopwatch()..start();
// Tu código aquí
doSomethingExpensive();
stopwatch.stop();
developer.Timeline.finishSync();
print('Tardó: ${stopwatch.elapsedMilliseconds}ms');
}
Luego ves esto en la timeline exactamente dónde pasó.
🔴 Parte 3: Los 4 Problemas Principales
Problema 1: Rebuilds Innecesarios (60% de los casos)
Aquí es donde 90% del jank viene.
Ejemplo de Desastre
// ❌ ANTI-PATTERN: Todo se rebuilda
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: const MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage();
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int counter = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
// ❌ PROBLEMA: Esto rebuilda CADA vez que counter cambia
ExpensiveCalculationWidget(),
Text('Counter: $counter'),
FloatingActionButton(
onPressed: () {
setState(() => counter++);
},
child: const Icon(Icons.add),
),
],
),
);
}
}
class ExpensiveCalculationWidget extends StatelessWidget {
const ExpensiveCalculationWidget();
@override
Widget build(BuildContext context) {
// ❌ Esto se ejecuta CADA rebuild, aunque counter no cambió!
final result = expensiveCalculation(); // Toma 30ms
return Text('Result: $result');
}
}
¿Qué pasó?
- Usuario presiona botón
setState()es llamado- Toda la columna rebuilda
ExpensiveCalculationWidgetrebuildaexpensiveCalculation()corre de nuevo (¡aunque no cambió nada!)- Frame toma 30ms+, resulta en jank visible
La Solución: Separar Widgets
// ✅ CORRECTO: Widgets separados
class _MyHomePageState extends State<MyHomePage> {
int counter = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
// Este NO rebuilda
const ExpensiveCalculationWidget(),
// Solo esto rebuilda
_CounterDisplay(counter: counter),
// Y solo esto cuando presionas
_IncrementButton(
onPressed: () {
setState(() => counter++);
},
),
],
),
);
}
}
// ✅ Este widget nunca rebuilda (es const)
class ExpensiveCalculationWidget extends StatelessWidget {
const ExpensiveCalculationWidget();
@override
Widget build(BuildContext context) {
// Se ejecuta UNA sola vez
final result = expensiveCalculation();
return Text('Result: $result');
}
}
// ✅ Solo esto rebuilda, y es rápido
class _CounterDisplay extends StatelessWidget {
final int counter;
const _CounterDisplay({required this.counter});
@override
Widget build(BuildContext context) {
return Text('Counter: $counter');
}
}
class _IncrementButton extends StatelessWidget {
final VoidCallback onPressed;
const _IncrementButton({required this.onPressed});
@override
Widget build(BuildContext context) {
return FloatingActionButton(
onPressed: onPressed,
child: const Icon(Icons.add),
);
}
}
Resultado:
- Frame 1: Contador rebuilda (1ms) ✅
- Frame 2: Contador rebuilda (1ms) ✅
- Frame 3: Contador rebuilda (1ms) ✅
vs
- Frame 1: TODO rebuilda (35ms) ❌
- Frame 2: TODO rebuilda (35ms) ❌
- Frame 3: TODO rebuilda (35ms) ❌
Truco: Const Constructors
// ✅ Esto es tu mejor amigo
const MyWidget();
// ✅ Usa const en listas
const children = [
Text('Hello'),
Icon(Icons.add),
];
// ✅ Const en Padding, SizedBox, etc.
const SizedBox(height: 16),
// ❌ EVITA crear cosas dentro de build()
build(context) {
return SizedBox(
// ❌ Esto crea widget NUEVO cada frame
child: MyWidget(),
);
}
// ✅ HAZLO AFUERA
class MyPageState extends State<MyPage> {
// ✅ Se crea una sola vez
late final Widget _child = MyWidget();
build(context) {
return SizedBox(child: _child);
}
}
Problema 2: Listas Infinitas (ScrollView Performance)
Cuando tienes 10,000 items y todos buildan.
Ejemplo de Desastre
// ❌ ANTI-PATTERN: Build TODOS los widgets
class UserListPage extends StatelessWidget {
final List<User> users; // 10,000 items
const UserListPage({required this.users});
@override
Widget build(BuildContext context) {
return ListView(
children: users.map((user) {
// ❌ Esto crea 10,000 widgets (¡aunque solo ves 10!)
return UserTile(user: user);
}).toList(),
);
}
}
class UserTile extends StatelessWidget {
final User user;
const UserTile({required this.user});
@override
Widget build(BuildContext context) {
// ❌ Cada tile rebuilda con la scroll physics
return Container(
height: 80,
child: Text(user.name),
);
}
}
¿Qué pasó?
- ListView crea 10,000 widgets en memoria
- Cada scroll dispara rebuilds
- App es lentísima, usa 500MB RAM
La Solución: ListView.builder
// ✅ CORRECTO: Solo build visible items
class UserListPage extends StatelessWidget {
final List<User> users;
const UserListPage({required this.users});
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: users.length,
// ✅ Solo crea widgets que VES
itemBuilder: (context, index) {
return UserTile(user: users[index]);
},
);
}
}
Resultado:
- Memoria: 10MB (vs 500MB)
- Scroll: 60fps (vs 15fps)
Truco: Cachear Height
// ✅ Si sabes el height, díselo
ListView.builder(
itemCount: users.length,
itemExtent: 80, // Flutter no necesita medir cada item
itemBuilder: (context, index) {
return UserTile(user: users[index]);
},
)
Mejora: Scroll es 2x más rápido.
Problema 3: Main Thread Bloqueado (I/O en Build)
Cuando haces database queries en build().
Ejemplo de Desastre
// ❌ ANTI-PATTERN: Esperar BD en build()
class UserProfilePage extends StatelessWidget {
final int userId;
const UserProfilePage({required this.userId});
@override
Widget build(BuildContext context) {
// ❌ Esto BLOQUEA el thread
final user = getUserFromDatabase(userId); // 500ms
return Text(user.name);
}
}
¿Qué pasó?
build()se ejecuta- Database query toma 500ms
- UI está congelada por 500ms
- Usuario ve pantalla en blanco
La Solución: FutureBuilder
// ✅ CORRECTO: Load asíncrono
class UserProfilePage extends StatelessWidget {
final int userId;
const UserProfilePage({required this.userId});
@override
Widget build(BuildContext context) {
return FutureBuilder<User>(
// ✅ Se ejecuta en background
future: getUserFromDatabase(userId),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const LoadingWidget();
}
if (snapshot.hasError) {
return ErrorWidget(error: snapshot.error!);
}
final user = snapshot.data!;
return Text(user.name);
},
);
}
}
Resultado:
- UI es responsiva instantáneamente
- Datos cargan en background
- Usuario ve loading widget
Mejor Aún: Compute (Offload Work)
import 'dart:isolate';
// ❌ LENTO: Cálculo pesado en main thread
int expensiveCalculation(int value) {
// Toma 2 segundos
for (int i = 0; i < 1000000000; i++) {
// ...
}
return value * 2;
}
// ✅ RÁPIDO: Compute en background
Future<int> expensiveCalculationAsync(int value) {
return compute(expensiveCalculation, value);
}
// Uso en widget
class MyWidget extends StatefulWidget {
@override
State<MyWidget> createState() => _MyWidgetState();
}
class _MyWidgetState extends State<MyWidget> {
late Future<int> _result;
@override
void initState() {
super.initState();
// ✅ Se ejecuta en isolate separado (otro core)
_result = expensiveCalculationAsync(42);
}
@override
Widget build(BuildContext context) {
return FutureBuilder<int>(
future: _result,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const CircularProgressIndicator();
}
return Text('Result: ${snapshot.data}');
},
);
}
}
Resultado:
- Main thread nunca se bloquea
- Cálculo usa otro core
- UI siempre 60fps
Problema 4: Animaciones Janky
60fps teoría, 30fps práctica.
Ejemplo de Desastre
// ❌ ANTI-PATTERN: Hacer trabajo en AnimationController
class AnimatedCounterPage extends StatefulWidget {
@override
State<AnimatedCounterPage> createState() => _AnimatedCounterPageState();
}
class _AnimatedCounterPageState extends State<AnimatedCounterPage>
with TickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(seconds: 1),
vsync: this,
);
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
// ❌ Esto se ejecuta 60 VECES POR SEGUNDO
// Si hace trabajo pesado, jank
return Transform.scale(
scale: 1 + _controller.value,
child: Container(
// ❌ PROBLEMA: Painting pesado CADA frame
decoration: BoxDecoration(
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1 * _controller.value),
blurRadius: 10 * _controller.value,
spreadRadius: 5 * _controller.value,
),
],
),
child: const Text('Animating'),
),
);
},
);
}
}
¿Qué pasó?
- AnimationController dispara 60 veces/segundo
- Cada frame recalcula sombra con blur
- GPU no puede mantener 60fps
- Resulta en jank visible
La Solución: Usar Opcodes Baratos
// ✅ CORRECTO: Transformaciones GPU baratas
class AnimatedCounterPage extends StatefulWidget {
@override
State<AnimatedCounterPage> createState() => _AnimatedCounterPageState();
}
class _AnimatedCounterPageState extends State<AnimatedCounterPage>
with TickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(seconds: 1),
vsync: this,
);
_scaleAnimation = Tween<double>(begin: 1, end: 1.5).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _scaleAnimation,
builder: (context, child) {
// ✅ Transform.scale es GPU-accelerated (muy barato)
return Transform.scale(
scale: _scaleAnimation.value,
child: Container(
// ✅ Sombra ESTÁTICA (no cambia)
decoration: BoxDecoration(
boxShadow: const [
BoxShadow(
color: Color.fromARGB(25, 0, 0, 0),
blurRadius: 10,
spreadRadius: 5,
),
],
),
child: const Text('Animating'),
),
);
},
);
}
}
Resultado:
- GPU maneja Transform (muy eficiente)
- Shadow es estático
- 60fps limpio
Optimizaciones de Animación
// ✅ Usar estos (GPU-accelerated):
Transform.scale()
Transform.translate()
Transform.rotate()
Opacity()
ScaleTransition()
TranslateTransition()
// ❌ EVITAR estos (CPU-intensive):
Container(
decoration: BoxDecoration(
boxShadow: [...] // Rebuilda shadow cada frame
),
)
ClipPath() // Clipping es caro
RotatedBox() // Diferente a Transform.rotate
🎯 Parte 4: DevTools Deep Dive
El Timeline Tab
Aquí ves TODO en detalle.
┌──────────────────────────────────┐
│ Frames Raster Build │
├──────────────────────────────────┤
│ ▮▮▮▮▮▮ (Frame 1: 15ms) │
│ ├─ Build: 7ms │
│ └─ Raster: 8ms │
│ │
│ ▮▮▮▮▮▮▮▮▮▮▮▮ (Frame 2: 45ms) │
│ ├─ Build: 40ms ← PROBLEMA! │
│ └─ Raster: 5ms │
└──────────────────────────────────┘
Hacer clic en un frame lento:
Frame 2 Details:
├─ _MyHomePageState.build() - 15ms
│ ├─ ExpensiveCalculationWidget.build() - 12ms
│ └─ ListView.builder() - 2ms
├─ Metrics building - 5ms
└─ Compositing - 20ms
El CPU Profiler Tab
Ves exactamente qué función toma tiempo.
Function Name Time % Total
─────────────────────────────────────
build() 45ms 60%
expensiveCalculation() 40ms 53%
ListView.builder() 3ms 4%
paint() 25ms 33%
drawShadow() 20ms 27%
layout() 5ms 7%
Estrategia:
- Abre CPU Profiler
- Realiza acción lenta
- Detén el profiler
- Busca función con mayor tiempo
- Optimiza esa
El Memory Tab
Identificar memory leaks.
┌──────────────────────────────────┐
│ Memory Usage Over Time │
├──────────────────────────────────┤
│ 500MB ▂▄▆█████████████ (leak!) │
│ 400MB ▁▂▃▄▅▆ │
│ 300MB ▁▂▃▄▅▆▇█ (normal) │
│ 200MB │
│ 100MB │
│ 0MB └──────────────────────── │
└──────────────────────────────────┘
Señales de problema:
- Línea siempre sube (nunca baja)
- Después de navegar, memoria no libera
- App lenta después de usar mucho
🔥 Parte 5: Anti-Patrones Comunes
Anti-Patrón 1: Streams Sin Unsubscribe
// ❌ MEMORIA LEAK
class UserListPage extends StatefulWidget {
@override
State<UserListPage> createState() => _UserListPageState();
}
class _UserListPageState extends State<UserListPage> {
late StreamSubscription _subscription;
@override
void initState() {
super.initState();
// ❌ Nunca cancelas
_subscription = userStream.listen((user) {
setState(() {
// update
});
});
}
@override
void dispose() {
// ❌ ¡FALTA ESTO!
// _subscription.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Center(
child: Text('User list'),
);
}
}
¿Qué pasó?
- User navega a esta página
- Stream se subscribe
- User navega atrás
- Stream SIGUE escuchando
- Memory leak + setState en widget muerto
La Solución
// ✅ CORRECTO
class _UserListPageState extends State<UserListPage> {
late StreamSubscription _subscription;
@override
void initState() {
super.initState();
_subscription = userStream.listen((user) {
if (mounted) { // ✅ Chequea si widget está vivo
setState(() {
// update
});
}
});
}
@override
void dispose() {
_subscription.cancel(); // ✅ IMPORTANTE
super.dispose();
}
@override
Widget build(BuildContext context) {
return Center(
child: Text('User list'),
);
}
}
O mejor aún:
// ✅ MEJOR: Usar StreamBuilder
class UserListPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return StreamBuilder<List<User>>(
stream: userStream,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const LoadingWidget();
}
return ListView(
children: snapshot.data!.map((user) {
return UserTile(user: user);
}).toList(),
);
},
);
}
}
// ✅ Se auto-limpia cuando widget se destruye
Anti-Patrón 2: Global State Rebuild Everything
// ❌ ANTI-PATTERN: Un Provider para TODO
class AppProvider extends ChangeNotifier {
User user;
List<Post> posts;
Theme theme;
Locale locale;
// 50 campos más...
void updateUser(User newUser) {
user = newUser;
notifyListeners(); // ❌ REBUILDA TODA LA APP
}
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Consumer<AppProvider>(
builder: (context, provider, child) {
return MaterialApp(
// ❌ Todo esto rebuilda cuando usuario cambia
home: HomePage(user: provider.user),
theme: provider.theme,
locale: provider.locale,
);
},
);
}
}
Resultado:
- Cambias usuario → toda app rebuilda
- Cambias tema → usuario reloads
- Cascada de rebuilds
La Solución: Split por Dominio
// ✅ CORRECTO: Providers separados
class UserProvider extends ChangeNotifier {
User user;
void updateUser(User newUser) {
user = newUser;
notifyListeners(); // ✅ Solo afecta UserProvider
}
}
class ThemeProvider extends ChangeNotifier {
ThemeData theme;
void updateTheme(ThemeData newTheme) {
theme = newTheme;
notifyListeners(); // ✅ Solo afecta ThemeProvider
}
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
// ✅ Cada cual rebuilda cuando cambia
home: Consumer<UserProvider>(
builder: (context, userProvider, _) {
return HomePage(user: userProvider.user);
},
),
theme: Consumer<ThemeProvider>(
builder: (context, themeProvider, _) {
return themeProvider.theme;
},
),
);
}
}
Anti-Patrón 3: Rebuild en AnimationController.addListener
// ❌ ANTI-PATTERN: setState en listener
class _AnimationPageState extends State<AnimationPage> {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(seconds: 1),
vsync: this,
);
// ❌ setState se ejecuta 60 veces por segundo
_controller.addListener(() {
setState(() {
// Rebuilda TODA la página
});
});
}
@override
Widget build(BuildContext context) {
// ❌ Esto rebuilda completo, AUNQUE solo necesitas actualizar escala
return Container(
color: Colors.blue,
child: Transform.scale(
scale: _controller.value,
child: const Text('Animating'),
),
);
}
}
Resultado:
- 60 rebuilds/segundo
- Container rebuilda
- Color rebuilda
- Text rebuilda
- Jank garantizado
La Solución: AnimatedBuilder
// ✅ CORRECTO: AnimatedBuilder
class _AnimationPageState extends State<AnimationPage>
with TickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(seconds: 1),
vsync: this,
);
}
@override
Widget build(BuildContext context) {
return Container(
color: Colors.blue,
// ✅ Solo lo adentro rebuilda
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.scale(
scale: _controller.value,
child: child,
);
},
child: const Text('Animating'), // ✅ Se construye una sola vez
),
);
}
}
Anti-Patrón 4: Imágenes No Cacheadas
// ❌ ANTI-PATTERN: Descargar imagen cada vez
class ImageListPage extends StatelessWidget {
final List<String> imageUrls;
const ImageListPage({required this.imageUrls});
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: imageUrls.length,
itemBuilder: (context, index) {
return Image.network(
imageUrls[index],
// ❌ Cada scroll rebuilda imagen
// ❌ Se descarga de nuevo
);
},
);
}
}
Resultado:
- Descarga cada imagen 10 veces
- Cachés no se usan
- Red congestionada
- App lenta
La Solución: Image Cache Policy
// ✅ CORRECTO: Cachear inteligentemente
class ImageListPage extends StatelessWidget {
final List<String> imageUrls;
const ImageListPage({required this.imageUrls});
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: imageUrls.length,
itemBuilder: (context, index) {
return Image.network(
imageUrls[index],
// ✅ Flutter cachea automáticamente
cacheHeight: 200, // ✅ Resaltar antes de cachear
cacheWidth: 200,
);
},
);
}
}
O con caché personalizado:
import 'package:cached_network_image/cached_network_image.dart';
class ImageListPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListView.builder(
itemBuilder: (context, index) {
return CachedNetworkImage(
imageUrl: imageUrls[index],
placeholder: (context, url) =>
const CircularProgressIndicator(),
errorWidget: (context, url, error) =>
const Icon(Icons.error),
// ✅ Cachea en disco automáticamente
);
},
);
}
}
📈 Parte 6: Profiling Workflow Paso a Paso
Paso 1: Identificar el Problema
// Paso 1: Abre DevTools Performance tab
// Paso 2: Realiza acción que se siente lenta
// Paso 3: Busca frames ROJOS
// Si ves rojo en Build → Problema en build()
// Si ves rojo en Raster → Problema en paint()
Paso 2: Aislar
// ¿Qué widget es el culpable?
// Estrategia: Temporalmente reemplaza widgets
class MyPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: [
// ❌ ¿Es esta?
ExpensiveWidget1(),
// ❌ ¿O esta?
ExpensiveWidget2(),
// ❌ ¿O esta?
ExpensiveWidget3(),
],
);
}
}
// Test removiendo una:
class MyPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: [
// const SizedBox(), // ← Temporalmente deshabilitado
ExpensiveWidget2(),
ExpensiveWidget3(),
],
);
}
}
// Si performance mejora, ¡encontraste el culpable!
Paso 3: Profilear el Culpable
// Abre DevTools CPU Profiler
// Realiza acción
// Busca función con mayor tiempo
// Ejemplo: expensiveCalculation() toma 40ms
// Pregunta: ¿Realmente necesita 40ms?
// - ¿Puedo cachear resultado?
// - ¿Puedo mover a background (compute)?
// - ¿Puedo optimizar el algoritmo?
Paso 4: Medir Mejora
// Antes de optimizar:
// - Frame rate: 45fps
// - Build time: 40ms
// Después de optimizar:
// - Frame rate: 60fps
// - Build time: 5ms
// ✅ Mejora confirmada
💎 Parte 7: Casos de Desastre Reales
Caso 1: “La App Se Congela”
Problema reportado:
Usuario: "Cuando presiono este botón, app congela 1 segundo."
Investigación:
// ❌ Código problemático encontrado
FloatingActionButton(
onPressed: () {
// ❌ Se ejecuta en main thread
final users = fetchAllUsersFromDatabase(); // 1000ms
// Procesa
final processed = users.map((u) =>
u.name.toUpperCase() + computeValue(u)).toList();
setState(() {
displayUsers = processed;
});
},
child: const Icon(Icons.add),
)
Solución:
// ✅ Ofload a background
FloatingActionButton(
onPressed: () async {
// ✅ Se ejecuta sin bloquear UI
showLoadingDialog(context);
final processed = await compute(
_processUsers,
null,
);
if (mounted) {
Navigator.pop(context);
setState(() {
displayUsers = processed;
});
}
},
child: const Icon(Icons.add),
)
Future<List<ProcessedUser>> _processUsers(_) async {
final users = await fetchAllUsersFromDatabase();
return users.map((u) =>
ProcessedUser(
name: u.name.toUpperCase(),
value: computeValue(u),
),
).toList();
}
Resultado: App nunca congela, UI siempre responsiva.
Caso 2: “Memory Leak Invisible”
Problema reportado:
Usuario: "Después de usar app 30 minutos, está lenta. Reinicio ayuda."
Investigación:
// Abre DevTools Memory tab
// Navega: Home → UserList → DetailPage → atrás → Home (repetir)
// Ves: Memory siempre sube, nunca baja
// ❌ Encontrado culpable: StreamController no cancelado
class UserListPage extends StatefulWidget {
@override
State<UserListPage> createState() => _UserListPageState();
}
class _UserListPageState extends State<UserListPage> {
late StreamController _controller; // ❌ Nunca cancela
@override
void initState() {
super.initState();
_controller = StreamController.broadcast();
_controller.stream.listen((event) {
if (mounted) setState(() {});
});
}
// ❌ FALTA dispose
}
Solución:
class _UserListPageState extends State<UserListPage> {
late StreamController _controller;
@override
void initState() {
super.initState();
_controller = StreamController.broadcast();
_controller.stream.listen((event) {
if (mounted) setState(() {});
});
}
@override
void dispose() {
_controller.close(); // ✅ Cierra stream
super.dispose();
}
}
Resultado: Memory estable, no sube más.
Caso 3: “ListView Scroll es Janky”
Problema reportado:
Usuario: "Scroll es tartamudo cuando hay muchisimos items."
Investigación:
// DevTools Timeline muestra: Raster toma 30ms
// ❌ Encontrado: Cada tile tiene gradient + shadow complejo
class UserTile extends StatelessWidget {
final User user;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
// ❌ CARO: Gradient en cada item
gradient: LinearGradient(...),
// ❌ CARO: Shadow con blur en cada item
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 15,
spreadRadius: 5,
),
],
),
child: Text(user.name),
);
}
}
Solución:
// ✅ Optimizar: Usar Material Card con elevation
class UserTile extends StatelessWidget {
final User user;
@override
Widget build(BuildContext context) {
return Card(
// ✅ Material optimiza shadows internamente
elevation: 2,
child: ListTile(
title: Text(user.name),
),
);
}
}
// Si necesitas gradient:
// ✅ Usa background image en lugar de gradient
class UserTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
// ✅ Precachear imagen
decoration: BoxDecoration(
image: const DecorationImage(
image: AssetImage('assets/gradient.png'),
fit: BoxFit.cover,
),
),
child: Text(user.name),
);
}
}
Resultado: Scroll 60fps limpio.
🎯 Parte 8: Checklist de Performance
Antes de Lanzar
- Profiled en dispositivo real (no solo emulador)
- 60fps consistente en acciones comunes
- Ningún memory leak (DevTools Memory tab)
- Build time <16ms (60fps) o <8ms (120fps)
- Raster time <16ms (60fps) o <8ms (120fps)
Durante Desarrollo
- Usar const constructors donde sea posible
- ListView.builder para listas (nunca .toList())
- Async para I/O (nunca bloquear main thread)
- AnimatedBuilder para animaciones (nunca setState)
- Cancelar streams y subscriptions en dispose
En Producción
- Monitoreo de performance (Firebase Performance Monitoring)
- Crashes logging (Sentry, Firebase Crashlytics)
- Alertas para jank (si frame rate cae)
- Recolectar feedback de usuarios sobre velocidad
🚀 Parte 9: Herramientas Avanzadas
Firebase Performance Monitoring
// main.dart
import 'package:firebase_performance/firebase_performance.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
// ✅ Monitoreo automático
FirebasePerformance.instance.setPerformanceCollectionEnabled(true);
runApp(const MyApp());
}
Ves en Firebase Console:
App Startup Time: 2.5s
Time to Interactive: 3.2s
Frame Rate: 58fps (promedio)
Memory Usage: 125MB (máximo)
Custom Traces
Future<void> loadUserData() async {
final trace = FirebasePerformance.instance.newTrace('load_user_data');
await trace.start();
try {
final user = await fetchUser();
// ...
} finally {
await trace.stop();
}
}
Sentry para Excepciones
import 'package:sentry_flutter/sentry_flutter.dart';
Future<void> main() async {
await SentryFlutter.init(
(options) {
options.dsn = 'https://...';
options.tracesSampleRate = 1.0;
},
appRunner: () => runApp(const MyApp()),
);
}
Alertas automáticas cuando:
- Frame rate cae
- Exception ocurre
- Memory leak detectado
📝 Conclusiones: Tu Workflow Ideal
Cuando App Está Lenta
- Abre DevTools Performance (5 minutos)
- Identifica frame rojo (Build o Raster)
- Abre CPU Profiler (5 minutos)
- Encuentra función culpable (2 minutos)
- Optimiza esa función (30 minutos)
- Verifica mejora en DevTools (5 minutos)
Total: 50 minutos para encontrar y arreglar la mayoría de problemas.
Las 5 Optimizaciones Más Efectivas
- Const constructors - -30% rebuilds
- ListView.builder - -80% memory
- AsyncTask/compute - Responsive UI
- AnimatedBuilder - -50% jank
- Separar providers - -60% cascading rebuilds
La Verdad
90% de apps “lentas” NO necesitan optimización avanzada.
Necesitan:
- Entender DevTools
- Encontrar dónde toma tiempo
- Optimizar EL LUGAR CORRECTO
La mayoría de equipos optimizan lugares donde no hay problema, ignorando el real.
🎓 Lecciones Finales
Performance Es Iterativo
No es “optimizar una vez”. Es:
- Medir
- Optimizar
- Medir de nuevo
- Repetir
Dispositivo Real Es Crítico
Emulador:
- CPU: 8 cores
- RAM: 8GB
- GPU: GPU de computadora (muy rápida)
Dispositivo real:
- CPU: 4-6 cores (2-3 años atrás)
- RAM: 3GB
- GPU: GPU móvil (lenta)
Lo que corre 60fps en emulador corre 30fps en dispositivo.
No Todas Las Optimizaciones Valen
// ¿Vale la pena?
// - Eliminar 1 widget rebuild: ✅ Sí
// - Cambiar de List a LinkedList: ❌ No
// - Usar compute() para I/O: ✅ Sí
// - Escribir custom shader: ❌ Raramente
Regla: Si toma <5ms, no optimices.
🔗 Recursos
- DevTools Docs: flutter.dev/tools/devtools
- Performance Best Practices: flutter.dev/perf
- Dart Performance: dart.dev/perf
- Firebase Performance Monitoring: firebase.google.com/docs/perf-mon
¡Ahora tienes las herramientas para encontrar y arreglar performance en Flutter! 🚀
Tags
Artículos relacionados
De Flutter a Android Nativo: Cómo Cambiar Tu Chip de Programador
Guía profunda sobre el cambio de mentalidad necesario para pasar de Flutter a Android nativo con Kotlin y Jetpack Compose. Rompe hábitos, aprende nuevas formas de pensar, y abraza la cultura Android.
Flutter: La plataforma que unifica el desarrollo móvil empresarial
Una guía completa sobre Flutter explicada en lenguaje humano: qué es, cómo funciona, por qué reduce costos, cómo escalar y por qué debería ser parte de tu stack empresarial.
Go Concurrente: Goroutines, Channels y Context - De Cero a Experto
Guía exhaustiva sobre programación concurrente en Go 1.25: goroutines, channels, context, patrones, race conditions, manejo de errores, y arquitectura profesional. Desde principiante absoluto hasta nivel experto con ejemplos prácticos.