Si tu app Flutter solo va a tirones en la primera ejecución: domina de una vez el Shader Compilation Jank

Una animación que corría con fluidez a 60fps en modo debug empieza a recibir reportes de que “se traba” en dispositivos nuevos justo después de subir el build de release a la tienda. Cuando intentas reproducirlo en tu propio teléfono de desarrollo, todo funciona perfecto. Revisas los logs que te pasó el equipo de QA y tampoco hay ninguna excepción evidente. Añades const a todos los widgets que puedes, cambias a ListView.builder, incluso metes un RepaintBoundary… y el síntoma sigue exactamente igual. Si este patrón te resulta familiar, lo más probable es que el culpable no esté en el árbol de widgets, sino en el shader compilation jank (tartamudeo por compilación de shaders).
Este artículo va más allá de consejos genéricos como “usa widgets const” o “aligera tu método build”. Es un registro práctico, contrastado directamente con el código fuente y la documentación oficial, que explica por qué este problema nunca se manifiesta en modo debug y solo aparece en builds profile/release sobre dispositivos reales, qué hay que mirar exactamente en DevTools, y cómo ha cambiado en la práctica el conocido procedimiento de warmup de SkSL en las versiones recientes de Flutter.
Resumen clave
- El shader compilation jank es un stall que ocurre porque la GPU tiene que crear el shader/pipeline en tiempo de ejecución y de forma específica para cada dispositivo cuando encuentra una operación de dibujo que nunca antes había visto. Una vez compilado, no vuelve a repetirse.
- El modo debug está basado en JIT, así que este problema queda oculto de otra manera desde el principio; y los modos profile/release están directamente deshabilitados en emuladores/simuladores, por lo que hay que verificarlo sí o sí en un dispositivo real.
- En el Frame chart de la vista Performance de DevTools, los frames marcados en rojo oscuro (dark red) son la firma característica de un pico de compilación de shaders.
- El clásico procedimiento de warmup de SkSL con
flutter run --profile --cache-skslseguido de--bundle-sksl-pathfue eliminado por completo de la CLI a partir de Flutter 3.32 (stable de mayo de 2025). Si sigues este comando tal cual hoy, obtendrás un error. - Con Impeller como renderer por defecto, buena parte de este problema desapareció de forma estructural, pero todavía quedan trampas: shaders de fragmento personalizados, o la ruta de fallback en dispositivos Android antiguos, entre otras.
Qué es el jank y qué significa en la práctica el presupuesto de frame
La documentación oficial de Flutter define el jank así: “Si un frame tarda mucho más de lo habitual y se descarta, la animación se ve entrecortada. Por ejemplo, si un frame tarda diez veces más de lo normal, es probable que se descarte, y como resultado la animación se percibe con tirones.” La clave es que basta un solo frame ocasional que tarda demasiado para arruinar toda la percepción de rendimiento. El indicador real no es el tiempo de frame promedio, sino el peor frame (worst frame).
- Pantallas de 60Hz: según la documentación oficial, cada frame debe terminar de renderizarse en “unos 16ms” para que no haya jank.
- Pantallas de 120Hz: en dispositivos compatibles, Flutter también apunta a 120fps, lo que reduce aritméticamente el presupuesto de frame a unos 8.3ms (1000ms ÷ 120). Como el presupuesto se reduce a menos de la mitad, el mismo stall de compilación de shaders se percibe como un jank todavía más severo en un dispositivo de 120Hz.
Este presupuesto se consume a lo largo de dos hilos.
- Hilo UI: ejecuta el código de la app y del framework de Flutter sobre la Dart VM. Construye el widget, hace el layout y arma un layer tree con los comandos de pintado, que luego pasa al hilo raster. La documentación oficial advierte explícitamente: “no bloquees este hilo”.
- Hilo raster: recibe el layer tree y es quien realmente dibuja sobre la GPU. Skia e Impeller operan en este hilo. No se puede intervenir directamente sobre él, y si va lento, en última instancia es consecuencia de lo que el código Dart le pidió dibujar. Como dato curioso, este hilo antes se llamaba “GPU thread”, y ese historial de renombrado todavía se puede rastrear en el texto de ayuda del flag
--trace-skia, que conserva la frase “raster thread (formerly known as the GPU thread)”.
El shader compilation jank ocurre casi siempre en el hilo raster. El patrón distintivo de este problema es que el gráfico del hilo UI se ve perfectamente normal mientras solo el gráfico GPU/Raster muestra una barra roja enorme.
Por qué no se reproduce en modo debug
Este es el punto donde más se malinterpreta el problema. Los modos de build de Flutter no son un simple interruptor de optimización: son rutas de ejecución completamente distintas.
- Modo debug: la documentación oficial especifica que “se compila pensando en un ciclo rápido de desarrollo y ejecución, y no está optimizado para velocidad de ejecución, tamaño del binario ni distribución”. Los
assertestán activos y DevTools puede conectarse para depuración a nivel de código fuente. - Modo profile: se define como un modo que “conserva cierta capacidad de depuración, pero suficiente para el profiling de rendimiento”, y en móvil es prácticamente idéntico al modo release (solo se activan de más el tracing y algunas extensiones de servicio). Y, esto es lo decisivo: “el modo profile está deshabilitado en emuladores y simuladores, porque el comportamiento en esos entornos no representa el rendimiento real.”
- Modo release: se eliminan los
asserty la información de depuración, y se optimiza para “arranque rápido, ejecución rápida y un tamaño de paquete reducido”.
Es decir, como el modo debug ni siquiera tiene como objetivo optimizar la “velocidad de ejecución”, los problemas de rendimiento se manifiestan de otra forma o directamente no aparecen. Además, la compilación de shaders depende fundamentalmente del hardware GPU y los drivers concretos de cada dispositivo. El comentario de documentación de la clase ShaderWarmUp del framework de Flutter lo explica así:
“Este warmup debe ejecutarse individualmente en cada dispositivo, porque la compilación de shaders depende del hardware GPU y los drivers específicos de ese dispositivo. Como el engine es independiente del dispositivo, no se puede precalcular en el momento de compilar el Flutter engine.”
El mismo documento especifica de forma concreta cuánto tarda en compilarse un solo shader: “la compilación puede ser lenta (20ms-200ms)”. Esto significa que basta con que una sola pantalla concentre cinco o seis combinaciones nunca vistas de gradientes, blur, sombras o ShapeBorder personalizados para que, aritméticamente, aparezca un pico de raster de entre 100 y 1000ms (esto es simplemente el rango teórico de multiplicar el costo de compilación por shader que documenta la especificación). Eso equivale a consumir de golpe decenas de presupuestos de frame de 16ms (a 60Hz), así que para el usuario se ve como si “la pantalla se congelara un instante y luego volviera a reproducirse”.
Y esta compilación ocurre por dispositivo, y solo en el momento en que ese dispositivo encuentra por primera vez esa operación de dibujo concreta. El teléfono de pruebas que el desarrollador ya conoce de memoria puede tener la caché caliente porque ya abrió esa pantalla varias veces (aunque esa caché se pierde al reinstalar la app, actualizarla o limpiar la caché del sistema operativo), pero un dispositivo nuevo que acaba de descargar la app desde la tienda está en un estado “frío”: se encuentra con todos los shaders por primera vez. De ahí que un reporte de “solo pasa en dispositivos nuevos” no sea en realidad un problema de reproducibilidad, sino un resultado estructural exactamente predecible.
Encontrar la causa real en la vista Performance de DevTools
Paso 1: ejecuta siempre en modo profile y en un dispositivo real
flutter run --profile
Como el modo profile está deshabilitado directamente en emuladores y simuladores, hay que conectarse a un dispositivo real (a ser posible, uno de gama baja o nuevo, similar al que reportó el problema).
Paso 2: busca los frames en rojo oscuro en el Frame chart
Al abrir la pestaña Performance de DevTools, cada frame se muestra como un par de barras: una del hilo UI y otra del hilo raster. La documentación oficial lo explica así: “los frames que realizan compilación de shaders se muestran en rojo oscuro (dark red)”. Ese color es exactamente la huella visual del shader compilation jank. En un jank normal (sobrecarga de build/layout), la barra que se pone roja primero es la del hilo UI; en cambio, en el shader compilation jank el patrón es que solo la barra del hilo raster se dispara, en solitario, cientos de ms.
Paso 3: revisa el call stack en Timeline events
Al hacer clic en el frame sospechoso y entrar a la pestaña Timeline events, puedes encontrar el mismo patrón que describe la documentación de la clase ShaderWarmUp: si durante la animación aparece una llamada a GrGLProgramBuilder::finalize que se extiende de forma anormalmente larga, y como llamada padre aparece el nombre de una operación de dibujo con forma XyzOp (como FillRectOp o CircularRRectOp), eso significa que esa operación de dibujo disparó la compilación de un shader nuevo. (Estos nombres de traza corresponden al backend de Skia; en Impeller aparecen como eventos de creación de pipeline con otros nombres.)
Paso 4: aumenta la resolución con Enhance tracing
El desplegable “Enhance tracing” de la pestaña Performance tiene tres opciones.
- Track Widget Builds: muestra en la timeline los eventos del método
build()y el nombre de cada widget - Track Layouts: muestra los eventos de layout de los render objects
- Track Paints: muestra los eventos de paint de los render objects
Ahora bien, tal como advierte la documentación oficial, “activar estas opciones puede afectar el propio frame time”. Actívalas solo mientras acotas la causa, y apágalas cuando midas los números reales. En “More debugging options”, dentro de la misma pestaña, puedes desactivar por separado las capas de Clip / Opacity / Physical Shape para aislar si un clipping excesivo o un efecto de sombra está disparando un pipeline nuevo.
Paso 5: asegura la reproducibilidad con --trace-skia y --purge-persistent-cache
flutter run --profile --trace-skia
El texto de ayuda especifica que --trace-skia es “útil para depurar el hilo raster (antes llamado hilo GPU)”, y viene desactivado por defecto porque su overhead es alto. Pero en la práctica, el flag realmente importante es otro.
flutter run --profile --purge-persistent-cache
Este flag sigue presente tal cual en el Flutter stable actual, y su ayuda dice exactamente esto: “elimina toda caché persistente existente. Esto permite reproducir el shader compilation jank que normalmente solo ocurre en la primera ejecución de la app, o probar de forma confiable una corrección de compilation jank (por ejemplo, un shader warmup).” Es decir, es el mecanismo oficial para forzar el estado de “primera ejecución en un dispositivo nuevo” incluso en el teléfono de pruebas más manoseado del desarrollador. Si en QA o en pruebas de regresión solo se ejecuta la app repetidamente sin este flag, lo único que se verá es la caché ya caliente, y el problema puede pasarse por alto para siempre.
La caché de warmup de SkSL: la solución clásica, y la trampa con la que te topas en la práctica
La mayoría de los tutoriales que circulan por internet, llegados a este punto, indican el siguiente procedimiento.
# 1. Ejecutar en modo profile registrando los shaders que realmente se encuentran
flutter run --profile --cache-sksl
# 2. Interactuar con la app en todas partes para disparar el máximo de animaciones/transiciones, y luego cerrarla
# → se genera un archivo de caché .sksl.json bajo build/
# 3. Empaquetar esa caché en el build de release
flutter build apk --release --bundle-sksl-path flutter_01.sksl.json
flutter build appbundle --release --bundle-sksl-path flutter_01.sksl.json
flutter build ios --release --bundle-sksl-path flutter_01.sksl.json
Este procedimiento existía para sortear un problema estructural de la era de Skia: “la GPU compila en tiempo de ejecución los shaders que nunca antes vio en un dispositivo nuevo”. La idea era capturar de antemano las combinaciones de shaders que realmente se usaban durante la ejecución de la app, meterlas como caché en el build, y así prepararlas todas de una vez al arrancar la app, eliminando los stalls durante las animaciones.
Aquí está la trampa en la práctica. El comando de arriba, ejecutado tal cual en un Flutter reciente, no funciona. De hecho, en el issue de GitHub (flutter/flutter#171585) queda registrado el caso de un desarrollador que, intentando usar --bundle-sksl-path en Flutter 3.32 o superior, recibió el error Could not find an option named '--bundle-sksl-path'. Al revisar directamente el historial de commits del repositorio de Flutter, la causa queda clara: el commit fusionado el 10 de febrero de 2025 (33a4c95de07, “remove SkSL bundling and dump skp on compilation”) eliminó por completo las opciones relacionadas con el bundling de SkSL en flutter run, flutter build apk/appbundle/ios y flutter drive. El mensaje del commit lo explica así:
“La precompilación de SkSL solo tenía sentido en iOS desde un principio. En otras plataformas no se recomendaba su uso porque Skia genera los shaders según la arquitectura de destino, así que podían ser inválidos en otro dispositivo. Y ahora Skia ya ni siquiera está disponible en iOS.”
Este cambio está incluido desde Flutter 3.32.0 (stable de mayo de 2025). De hecho, si revisas flutter run --help -v, flutter build apk --help -v y flutter build web --help -v con un SDK de Flutter 3.44 instalado localmente, las cadenas cache-sksl y bundle-sksl-path ya no aparecen en ningún lugar. Es decir, el propio consejo de “genera y empaqueta una caché de warmup de SkSL” ya no es un procedimiento ejecutable en las versiones recientes de Flutter. La versión más reciente de la documentación oficial (docs.flutter.dev/perf/rendering-performance) también reemplazó el consejo para móvil sobre el shader compilation jank por una sola frase: “verifica que estés usando Impeller, el renderer por defecto de Flutter”.
Entonces, ¿qué hacer si te topas con este problema hoy?
- Antes que nada, deja Impeller tal cual está. A menos que lo desactives con
--no-enable-impeller, en las versiones recientes de Flutter Impeller ya es el valor por defecto. Como se explica en la siguiente sección, Impeller hace de forma estructural el trabajo que antes hacía la caché de SkSL. - La API
ShaderWarmUpsigue presente en el framework. Si registras una subclase personalizada deShaderWarmUpenPaintingBinding.shaderWarmUpantes de llamar arunApp, puedes dibujar de antemano operaciones de dibujo representativas en un canvas offscreen al arrancar la app, trasladando el costo de compilación al retraso de arranque. Sin embargo, la propia documentación de esta API está escrita en términos de conceptos de la era de Skia, comoGrGLProgramBuildero--trace-skia, así que en la ruta de Impeller no garantiza un efecto tan determinante como antes. - En un proyecto legacy, el procedimiento antiguo sigue siendo válido. Si el proyecto está fijado a una versión anterior a Flutter 3.32, o usa explícitamente
--no-enable-impelleren Android para quedarse con la ruta antigua de Skia/OpenGL, la combinación--cache-sksl/--bundle-sksl-pathsigue funcionando dentro de esa versión. Aun así, no se recomienda seguir dependiendo de esta ruta sin un plan de migración.
Qué cambió tras la llegada de Impeller y qué límites siguen quedando
Impeller no “arregla” el shader compilation jank; su enfoque consiste en desplazar el momento mismo en que ocurre el problema. Los cuatro objetivos de diseño que expone la documentación oficial son estos.
- Predecible (Predictable): las decisiones de compilación y caching quedan fijadas de antemano, antes de la ejecución
- Instrumentable: etiqueta los recursos gráficos para facilitar el profiling y la captura
- Portable: los shaders se escriben una sola vez y se convierten al formato de cada backend (Metal/Vulkan/GLES, etc.)
- Moderno y concurrente (Modern & concurrent): aprovecha las APIs gráficas modernas y distribuye el trabajo entre varios hilos
El estado actual por plataforma es el siguiente (según la documentación oficial docs.flutter.dev/perf/impeller).
| Plataforma | Estado |
|---|---|
| iOS | Impeller es el único renderer soportado. No se puede volver a Skia |
| Android | Activado por defecto en API 29 o superior (desde Flutter 3.27). En dispositivos con API menor a 29 o sin soporte de Vulkan, hace fallback automático al backend OpenGL legacy de Impeller |
| macOS | En estado opt-in mediante el flag --enable-impeller; se planea eliminar la opción de opt-out en futuras versiones |
| Web | Todavía usa Skia (CanvasKit). Se menciona una posible adopción futura de Impeller |
Un dato curioso: la propia ayuda de la CLI de Flutter conserva un texto desactualizado. Si miras la descripción de --enable-impeller con flutter run --help -v, todavía dice “Impeller is the default renderer on iOS. On Android, Impeller is available but not the default”. Si rastreas la función addEnableImpellerFlag del código fuente de flutter_tools con git blame, ese texto se escribió en marzo de 2023, justo cuando Impeller recién se estaba introduciendo, y nunca se actualizó desde entonces. Como la documentación oficial y las release notes sí reflejan con precisión el cambio real de valor por defecto, este texto de la ayuda de la CLI debe entenderse no como el comportamiento real, sino como una cadena descriptiva abandonada. Incluso una inconsistencia tan pequeña como esta, en la práctica, termina generando dudas del tipo “¿de verdad tengo Impeller activado en mi proyecto ahora mismo?”.
El mecanismo real por el que Impeller elimina el jank
La clave está en que “la mayoría de los shaders se precompilan offline en el momento de compilar el engine”. Yendo un paso más allá, en el código fuente del engine de Impeller (shell/common/switch_defs.h, common/settings.h) existe un switch interno llamado impeller-lazy-shader-mode, cuyo comentario explica lo siguiente.
“Si se debe retrasar la inicialización de todos los PSO (Pipeline State Object) que necesita el backend de Impeller. El valor por defecto es false.”
Es decir, con el valor por defecto (false), Impeller inicializa de forma eager (inmediata) todos los PSO necesarios en el momento de arrancar la app. Lo que antes el desarrollador hacía a mano escribiendo un ShaderWarmUp —trasladar el costo de compilación del momento de la animación al momento de arranque— ahora Impeller lo hace por defecto a nivel de engine. Si se pone en true el metadato io.flutter.embedding.android.ImpellerLazyShaderInitialization en el AndroidManifest.xml de Android, se puede retrasar esa inicialización para acelerar un poco el cold start, pero a cambio puede reaparecer, en el primer uso, un stall parecido al de la vieja era de SkSL. La forma correcta de entenderlo no es “con Impeller nunca hay jank”, sino que cambió el valor por defecto del trade-off.
Los límites que todavía quedan
- Shaders de fragmento personalizados: los assets
.fragregistrados enshaders:dentro depubspec.yamlson compilados offline porimpellercal formato de cada backend en el momento de build. Sin embargo, combinaciones poco frecuentes de blend modes,BackdropFiltero rutas de composición conPlatformViewpueden quedar fuera del conjunto de pipelines precompilados, dejando en casos raros un stall en el primer uso. El equipo de Flutter pide reportar estas regresiones como issues con el prefijo[Impeller]. - Android antiguo sin soporte de Vulkan: la ruta de fallback OpenGL de Impeller no está tan optimizada como la ruta de Vulkan.
- Web: al estar basado en CanvasKit, todavía conserva las características de la era de Skia.
Verificar la mejora con datos de antes y después del profiling
Si una corrección funcionó de verdad hay que confirmarlo con números, no con intuición. Define un escenario reproducible (por ejemplo: abrir la app → entrar a una pantalla de detalle con muchos gradientes y sombras → hacer scroll de la lista tres veces). Ejecuta ese escenario en cada una de las dos condiciones siguientes y compara la lista de frames de la pestaña Performance de DevTools.
- Antes de la corrección, con la caché borrada: ejecuta
flutter run --profile --purge-persistent-cachepara reproducir la “primera ejecución en un dispositivo nuevo” - Después de la corrección, en las mismas condiciones: aplica las medidas de verificación de Impeller o de warmup, y vuelve a ejecutar con
--purge-persistent-cache
Lo que hay que mirar en la comparación no es el promedio, sino el tiempo de raster del peor frame (worst frame). Antes de la mejora, ciertos frames superan el presupuesto (16ms u 8.3ms) por varias veces y se muestran en rojo oscuro; después de la mejora, hay que verificar en el mismo escenario, a simple vista en el Frame chart y a nivel de call stack en Timeline events, que ese pico desaparece y que todos los frames caben dentro del presupuesto. Si quieres integrar una prueba de regresión de rendimiento en CI, la clave es automatizar el mismo escenario con flutter drive y forzar --purge-persistent-cache en cada ejecución, para evitar que una “caché ya caliente” te haga pasar por alto una regresión.
Checklist
- ¿Intentaste reproducir el problema únicamente en un build profile o release sobre un dispositivo real? (en emulador o modo debug no se ve, de entrada)
- ¿Confirmaste en la pestaña Performance de DevTools los frames en rojo oscuro del hilo raster?
- ¿Probaste forzando el estado de “primera ejecución en dispositivo nuevo” con
--purge-persistent-cache? - ¿Verificaste que Impeller esté realmente activado en tu versión actual de Flutter (que no lo hayas desactivado con
--no-enable-impeller)? - ¿Tienes claro que
--cache-sksl/--bundle-sksl-pathfueron eliminados a partir de Flutter 3.32? - Si hay pantallas que usan shaders
.fragpersonalizados o blend modes/BackdropFilter poco habituales, ¿las estás sospechando por separado? - ¿Comparaste el antes y el después según el tiempo de raster del peor frame?