effidevFlutter · Edge de Cloudflare · Optimización de costes en la nube

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

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

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).

Este presupuesto se consume a lo largo de dos hilos.

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.

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.

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?

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.

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

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.

  1. Antes de la corrección, con la caché borrada: ejecuta flutter run --profile --purge-persistent-cache para reproducir la “primera ejecución en un dispositivo nuevo”
  2. 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