Performance Profiling en Flutter: Por Qué Tu App Es Lenta (Y Cómo Arreglarlo)

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.

Por Omar Flores

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:

  1. Rebuilds innecesarios - Redibujando widgets que no cambiaron
  2. Main thread bloqueado - Trabajo pesado en el thread de UI
  3. Jank en animaciones - Frames perdidos (60fps → 30fps)
  4. 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ó?

  1. Usuario presiona botón
  2. setState() es llamado
  3. Toda la columna rebuilda
  4. ExpensiveCalculationWidget rebuilda
  5. expensiveCalculation() corre de nuevo (¡aunque no cambió nada!)
  6. 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ó?

  1. ListView crea 10,000 widgets en memoria
  2. Cada scroll dispara rebuilds
  3. 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ó?

  1. build() se ejecuta
  2. Database query toma 500ms
  3. UI está congelada por 500ms
  4. 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ó?

  1. AnimationController dispara 60 veces/segundo
  2. Cada frame recalcula sombra con blur
  3. GPU no puede mantener 60fps
  4. 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:

  1. Abre CPU Profiler
  2. Realiza acción lenta
  3. Detén el profiler
  4. Busca función con mayor tiempo
  5. 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ó?

  1. User navega a esta página
  2. Stream se subscribe
  3. User navega atrás
  4. Stream SIGUE escuchando
  5. 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

  1. Abre DevTools Performance (5 minutos)
  2. Identifica frame rojo (Build o Raster)
  3. Abre CPU Profiler (5 minutos)
  4. Encuentra función culpable (2 minutos)
  5. Optimiza esa función (30 minutos)
  6. Verifica mejora en DevTools (5 minutos)

Total: 50 minutos para encontrar y arreglar la mayoría de problemas.

Las 5 Optimizaciones Más Efectivas

  1. Const constructors - -30% rebuilds
  2. ListView.builder - -80% memory
  3. AsyncTask/compute - Responsive UI
  4. AnimatedBuilder - -50% jank
  5. 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

#flutter #performance #profiling #optimization #devtools #dart #mobile