Una app Flutter que va a 60fps en el emulador puede ir a trompicones en un móvil de gama media. El jank (esos fotogramas que se pierden y hacen que las animaciones no sean fluidas) tiene causas concretas y herramientas concretas para detectarlas. Flutter DevTools es la suite de diagnóstico oficial, y con un poco de práctica se convierte en la brújula para cualquier problema de rendimiento.
Qué es el jank y cuándo aparece
Flutter intenta renderizar a 60fps (o 120fps en dispositivos compatibles). Eso significa que cada fotograma tiene un presupuesto de 16.67ms (o 8.33ms a 120fps). Si el hilo de UI o el hilo de renderizado tarda más de ese tiempo, el frame se pierde y el usuario ve un tirón.
Las causas más comunes son:
- Trabajo pesado en el hilo principal: cálculos síncronos, deserialización de JSON grande, operaciones de fichero.
- Reconstrucciones innecesarias de widgets:
build()llamándose en widgets que no han cambiado. - Imágenes no optimizadas: imágenes de alta resolución que se decodifican sin cachear.
- Animaciones complejas en el hilo de renderizado.
Abrir Flutter DevTools
Si ejecutas la app desde VS Code o Android Studio, DevTools se abre con un botón en la barra de herramientas. Desde la línea de comandos:
flutter run --profile # En otra terminal: dart devtools
Usa siempre el modo profile, no debug. En modo debug, Flutter hace comprobaciones adicionales que ralentizan la app artificialmente. El modo profile es el más cercano al comportamiento real en release.
La vista Performance
La vista Performance de DevTools muestra el timeline de frames. Cada barra representa un fotograma: la parte azul es el tiempo del hilo de UI (Dart), la parte verde es el tiempo del hilo de renderizado (GPU). Si alguna barra supera la línea de 16ms, el frame se marca en rojo.
El flujo habitual es: ejecuta la parte de la app que va lenta, graba unos segundos en DevTools, y busca los frames rojos. Haz clic en un frame rojo y verás el call stack exacto que consumió ese tiempo.
Evitar reconstrucciones innecesarias
Uno de los problemas más frecuentes es que build() se llama en demasiados widgets cuando cambia el estado. Flutter tiene herramientas para diagnosticarlo:
// Activar el overlay de reconstrucciones (solo en debug/profile)
import 'package:flutter/rendering.dart';
void main() {
debugRepaintRainbowEnabled = true; // colorea widgets que se repintan
runApp(const MyApp());
}
La solución habitual es dividir el árbol de widgets en piezas más pequeñas y asegurarse de que el estado solo afecta a los widgets que realmente necesitan reconstruirse. Con Riverpod, ref.watch solo reconstruye el widget que lo llama. Con Bloc, BlocBuilder con el parámetro buildWhen permite filtrar reconstrucciones:
BlocBuilder<CounterBloc, CounterState>(
buildWhen: (previous, current) => previous.count != current.count,
builder: (context, state) {
return Text('${state.count}');
},
)
Mover trabajo pesado a un Isolate
Dart es de un solo hilo por defecto, como JavaScript. Si necesitas hacer cálculos pesados sin bloquear la UI, usa compute() o un Isolate directamente:
import 'package:flutter/foundation.dart';
// compute() es la forma más sencilla
List<int> parseNumbers(String data) {
// Operación potencialmente lenta
return data.split(',').map(int.parse).toList();
}
void main() async {
final numbers = await compute(parseNumbers, '1,2,3,4,5');
print(numbers);
}
// Para tareas más largas, Isolate.run() (Dart 2.19+)
import 'dart:isolate';
Future<List<int>> parseInBackground(String data) async {
return await Isolate.run(() {
return data.split(',').map(int.parse).toList();
});
}
Optimizar imágenes
Las imágenes son otra fuente frecuente de problemas. En Flutter, la decodificación de imágenes ocurre en el hilo de UI por defecto. Algunos consejos prácticos:
- Usa
cacheWidthycacheHeightenImage.networkpara que Flutter decodifique la imagen al tamaño que realmente se muestra, no al tamaño original. - El widget
Imagecachea automáticamente las imágenes descargadas, pero si cargas muchas imágenes en una lista, consideracached_network_imagepara control más fino del caché. - Para listas largas con imágenes, usa
ListView.builder(noListViewcon children fijos) para que los widgets fuera de pantalla se descarten.
// Decodificar al tamaño de display, no al original Image.network( 'https://ejemplo.com/foto.jpg', cacheWidth: 200, // píxeles lógicos cacheHeight: 200, )
El Memory Profiler
Además de la vista Performance, DevTools tiene un Memory Profiler que permite detectar fugas de memoria. El patrón más común en Flutter son los StreamSubscription o los AnimationController que no se cancelan en dispose():
class MyWidget extends StatefulWidget { ... }
class _MyWidgetState extends State<MyWidget> with SingleTickerProviderStateMixin {
late AnimationController _controller;
late StreamSubscription _sub;
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this, duration: const Duration(seconds: 1));
_sub = someStream.listen((_) {});
}
@override
void dispose() {
_controller.dispose(); // siempre
_sub.cancel(); // siempre
super.dispose();
}
// ...
}
Para entender mejor qué hace el motor de renderizado cuando aparece jank, el artículo sobre Impeller y el renderizado en Flutter explica cómo funciona el pipeline gráfico por debajo. Y si quieres automatizar la detección de regresiones de rendimiento, el artículo sobre CI/CD con GitHub Actions y Fastlane cubre cómo integrar tests de rendimiento en el pipeline.
Imagen: Pexels / Andrey Matveev
