El presupuesto por fotograma: 16 ms, 8 ms y el jank
Una interfaz fluida no es la que va rápido sino la que llega puntual, y esa puntualidad se rige por un presupuesto rígido que impone la frecuencia de refresco de la pantalla: unos 16 milisegundos a 60 hercios, unos 8 a 120. Esta lección desarrolla qué cabe realmente dentro de ese presupuesto, que no es solo tu código sino toda la cadena desde la entrada táctil hasta la composición final, define con precisión qué es el `jank` y en qué se diferencia de un fotograma congelado, analiza en profundidad sus tres causas dominantes, que son el trabajo en el hilo principal, el `overdraw` y la recomposición innecesaria, y establece cómo instrumentar y leer una traza para atribuir la pérdida a la causa correcta.
El rendimiento de una interfaz gráfica no se parece en nada al rendimiento de un algoritmo, y confundir ambos modelos es la raíz de casi todos los diagnósticos equivocados. En un algoritmo, ir más rápido siempre es mejor y la métrica relevante es el tiempo total. En una interfaz, el tiempo total es irrelevante y lo único que importa es la puntualidad: la pantalla se refresca a intervalos fijos e implacables, y el sistema no tiene forma de pedirle que espere. Si el fotograma no está listo cuando llega el instante de refresco, la pantalla vuelve a mostrar el anterior y el usuario percibe un tirón. No importa que el fotograma se terminase un milisegundo tarde o veinte: el resultado visible es idéntico. Esta lógica de plazo duro convierte el rendimiento gráfico en un problema de planificación en tiempo real blando, con un presupuesto fijo por unidad de trabajo y una penalización discreta al incumplirlo.
- Calcular el presupuesto por fotograma según la frecuencia de refresco y saber qué gastos caben dentro.
- Definir
janky fotograma congelado, y distinguirlos de una interfaz simplemente lenta. - Atribuir una pérdida de fluidez a trabajo en el hilo principal, a
overdrawo a recomposición innecesaria. - Instrumentar la aplicación para observar la distribución de duraciones de fotograma en laboratorio y en producción.
El presupuesto y lo que realmente cabe dentro
A sesenta hercios el presupuesto es de unos dieciséis milisegundos y medio; a noventa baja a algo más de once; a ciento veinte queda en poco más de ocho, y a ciento cuarenta y cuatro en menos de siete. La primera consecuencia práctica de las pantallas de alta frecuencia es contraintuitiva y conviene enunciarla: una aplicación que iba justa a sesenta hercios no se comporta igual a ciento veinte, sino que produce el doble de fotogramas perdidos, porque el mismo trabajo se enfrenta a la mitad de plazo. La frecuencia de refresco adaptativa complica más el cuadro, porque el sistema puede bajar la frecuencia cuando detecta poca actividad y subirla al empezar un desplazamiento, justo en el momento en que tu código tiene más trabajo.
El segundo malentendido es creer que el presupuesto es para tu código. No lo es. Dentro de esos milisegundos deben caber la entrega de los eventos de entrada, la evaluación de las animaciones, la fase de medida, la de colocación, la de dibujo entendida como grabación de instrucciones, la subida de recursos a la memoria de la tarjeta gráfica, la ejecución real de esas instrucciones en el hilo de dibujo y la composición final que el sistema realiza con todas las ventanas visibles. Tu código propio ocupa una fracción de ese total, y una fracción que compite con el resto.
60 hercios
Unos 16,7 milisegundos por fotograma. El plazo clásico y el que casi todo el mundo tiene en la cabeza.
120 hercios
Unos 8,3 milisegundos. El mismo trabajo produce el doble de incumplimientos si estaba ajustado.
Jank
Fotograma que incumple el plazo. La pantalla repite el anterior y el usuario percibe un tirón discreto.
Fotograma congelado
Incumplimiento largo, del orden de cientos de milisegundos. Se percibe como bloqueo y erosiona la confianza.
Conviene fijar el vocabulario con precisión porque la palabra se usa con ligereza. Un jank es un fotograma concreto que incumplió su plazo, y su unidad natural es el porcentaje de fotogramas perdidos sobre el total. Un fotograma congelado es un incumplimiento de tal magnitud que deja de percibirse como tirón y pasa a percibirse como bloqueo. Y una aplicación lenta puede no producir ningún jank en absoluto: si tarda tres segundos en cargar pero mantiene una animación de espera fluida, el problema es de latencia, no de fluidez, y se trata con otras herramientas.
Las tres causas dominantes
La primera y más común es el trabajo en el hilo principal. Cualquier operación bloqueante ejecutada en el hilo que produce los fotogramas consume presupuesto directamente: lectura o escritura en disco, incluida la aparentemente inocua de preferencias, análisis de documentos de intercambio, decodificación de imágenes, consultas a la base de datos sin envolver, criptografía y toda espera síncrona sobre una operación asíncrona. Existe además una variante indirecta que se pasa por alto con frecuencia: la presión de memoria. Reservar objetos dentro de un bucle de dibujo o dentro de una función que se ejecuta en cada fotograma no cuesta lo que cuesta la reserva, sino lo que cuesta la recolección de basura que la reserva provoca, y esa recolección puede caer en mitad de cualquier fotograma posterior sin relación aparente con el código culpable.
La segunda es el overdraw, que es el fenómeno por el cual un mismo píxel se pinta varias veces en un mismo fotograma. Ocurre siempre que hay contenedores opacos superpuestos: un fondo en la ventana, otro en el contenedor raíz, otro en la tarjeta y otro en la fila producen cuatro escrituras sobre el mismo píxel, de las cuales solo la última es visible. El coste es de velocidad de relleno de la tarjeta gráfica y escala con el área, de modo que se agrava en pantallas grandes y en densidades altas justamente donde el usuario espera más fluidez. Es una causa que casi nunca aparece en los perfiles de procesador, lo que la convierte en invisible para quien solo mira ahí.
La tercera, específica de las interfaces declarativas, es la recomposición innecesaria. El sistema recompone cuando cambia un estado que se leyó durante la composición, y la unidad de recomposición es la función que hizo esa lectura. De ahí salen dos patologías simétricas. Una es leer el estado demasiado arriba en el árbol, de modo que un cambio menor recompone una rama entera. Otra es que una función no pueda omitirse porque recibe parámetros que el compilador no puede considerar estables, típicamente colecciones mutables, clases de otro módulo sin información de estabilidad o funciones anónimas que capturan estado cambiante.
// Lectura demasiado arriba: cada desplazamiento recompone toda la pantalla
@Composable
fun Pantalla(estado: LazyListState) {
val indice = estado.firstVisibleItemIndex // se lee en composicion
Cabecera(visible = indice == 0)
Lista(estado)
}
// Lectura diferida: solo se recompone quien de verdad depende del valor
@Composable
fun PantallaCorrecta(estado: LazyListState) {
val enLaCima by remember {
derivedStateOf { estado.firstVisibleItemIndex == 0 }
}
Cabecera(visible = enLaCima)
Lista(estado)
}
Antes de intentar que una función se ejecute más rápido, conviene comprobar si necesita ejecutarse. Muchas pérdidas de fluidez en interfaces declarativas no se deben a que algo sea caro sino a que algo barato se repite cientos de veces por segundo. Diferir la lectura de estado hasta la fase de colocación o de dibujo, usando las variantes de modificador que reciben una función en lugar de un valor, elimina la recomposición por completo y deja el trabajo en la fase donde es más barato. La derivación de estado cumple el mismo papel cuando lo que interesa no es el valor continuo sino una condición que cambia rara vez.
flowchart LR A[Evento de entrada] --> B[Animaciones y callbacks del planificador] B --> C[Composicion o recomposicion] C --> D[Medida] D --> E[Colocacion] E --> F[Dibujo: grabacion de instrucciones] F --> G[Sincronizacion y subida a la tarjeta grafica] G --> H[Hilo de dibujo ejecuta las instrucciones] H --> I[Composicion del sistema con todas las ventanas] I --> J[Refresco de pantalla] C --> K[Recomposicion innecesaria] F --> L[Overdraw por capas opacas superpuestas] B --> M[Trabajo bloqueante en el hilo principal] style K fill:#f38ba8,color:#11111b style L fill:#f38ba8,color:#11111b style M fill:#f38ba8,color:#11111b style J fill:#a6e3a1,color:#11111b
Medir antes de tocar nada
La regla que más tiempo ahorra es negarse a optimizar sin una traza delante. Las tres causas producen el mismo síntoma perceptivo y exigen soluciones opuestas, de modo que actuar por intuición tiene una probabilidad razonable de empeorar las cosas: quien sospecha de la recomposición y añade memorización por todas partes en una pantalla limitada por velocidad de relleno introduce coste de comparación sin tocar el problema real.
El primer nivel de instrumentación es gratuito y vive en la terminal. El volcado de información gráfica del paquete ofrece la distribución de duraciones de los últimos fotogramas desglosada por fase, y basta para distinguir si el tiempo se va en el hilo principal o en el de dibujo, que es la primera bifurcación del diagnóstico. Las opciones de desarrollador incluyen además la visualización de sobredibujado, que colorea la pantalla según cuántas veces se pinta cada píxel y convierte un problema invisible en algo que se ve de un vistazo.
# Distribucion de duraciones por fase de los ultimos fotogramas
adb shell dumpsys gfxinfo com.ejemplo.app framestats
# Restablecer los contadores antes de una prueba limpia
adb shell dumpsys gfxinfo com.ejemplo.app reset
# Activar la visualizacion de sobredibujado desde la terminal
adb shell setprop debug.hwui.overdraw show
El segundo nivel es la traza de sistema, que muestra en una única línea temporal lo que ocurre en el hilo principal, en el de dibujo, en el compositor del sistema y en el resto de hilos del proceso, junto con las recolecciones de basura y las transiciones del planificador. Es la única herramienta que permite responder a la pregunta que de verdad importa, que no es cuánto tardó una función sino qué estaba impidiendo que el fotograma se entregase a tiempo.
El tercer nivel es la producción, y es el que falta en la mayoría de los equipos. Existe una biblioteca dedicada a reportar la duración de cada fotograma con la posibilidad de asociarle etiquetas de estado, de modo que se puede saber que el jank no ocurre en general sino durante el desplazamiento de una pantalla concreta con un tipo de contenido concreto. Sin esa atribución, los porcentajes agregados de la consola de publicación indican que hay un problema pero no dónde.
// Atribucion de jank en produccion con etiquetas de estado
val estadisticas = JankStats.createAndTrack(window) { frame ->
if (frame.isJank) {
Telemetria.registrarJank(
duracionNanos = frame.frameDurationUiNanos,
estados = frame.states.associate { it.key to it.value },
)
}
}
// Etiquetar el estado para poder atribuir despues
PerformanceMetricsState.getHolderForHierarchy(vistaRaiz)
.state?.putState("pantalla", "detalle_producto")
La duración media de fotograma es una métrica casi inútil, porque una interfaz que pierde el dos por ciento de los fotogramas se percibe como defectuosa y ese dos por ciento no mueve la media de forma apreciable. Las métricas con sentido son el percentil noventa, el noventa y cinco y el noventa y nueve de duración, junto con el porcentaje de fotogramas que incumplen el plazo y el recuento absoluto de fotogramas congelados. Un percentil noventa y nueve muy alto con una mediana excelente describe con precisión el perfil más frecuente en la práctica: una aplicación fluida que da tirones en momentos concretos y reproducibles, que son exactamente los que hay que buscar.
Hay una diferencia filosófica entre optimizar para ir rápido y optimizar para llegar a tiempo, y es la misma diferencia que separa un sistema de cálculo de un sistema de tiempo real. En el primero, el rendimiento es una magnitud continua y cualquier mejora se traduce en valor: cada milisegundo ahorrado es un milisegundo ganado. En el segundo, el rendimiento es una función escalonada con una discontinuidad brutal en el plazo: terminar en siete milisegundos y terminar en dos producen exactamente el mismo resultado percibido, y terminar en nueve produce un resultado cualitativamente distinto. Una interfaz gráfica pertenece a la segunda familia, y asumirlo cambia por completo el criterio de ingeniería. Deja de tener sentido preguntar cuánto tarda una función y empieza a tenerlo preguntar cuánta varianza introduce, porque el enemigo no es el coste medio sino la cola de la distribución. Deja de tener sentido celebrar una optimización que reduce un veinte por ciento el tiempo de una operación que ya cabía holgadamente en el presupuesto, y pasa a tenerlo eliminar la recolección de basura ocasional que arruina un fotograma cada varios segundos. Y sobre todo, deja de tener sentido pensar el rendimiento como una propiedad del código y hay que empezar a pensarlo como una propiedad de la cadena completa, en la que tu función compite por un plazo compartido con el sistema de entrada, el compositor, otras aplicaciones visibles y un planificador que puede decidir en cualquier momento moverte a un núcleo más lento para ahorrar batería. Esa perspectiva explica por qué los equipos que miden solo el tiempo de sus funciones nunca terminan de arreglar la fluidez: están optimizando una magnitud que no es la que el usuario percibe. Lo que el usuario percibe no es velocidad, es regularidad, y la regularidad se defiende recortando la cola, no la media.
- Registra la distribución de duraciones de fotograma durante un desplazamiento largo y anota mediana, percentil noventa y nueve y porcentaje de incumplimientos.
- Determina con esos datos si el tiempo se pierde en el hilo principal o en el de dibujo, y justifica la conclusión con las cifras.
- Activa la visualización de sobredibujado y elimina al menos una capa opaca redundante de tu pantalla más densa.
- Localiza una función componible que se recomponga durante el desplazamiento sin necesidad y corrígela difiriendo la lectura de estado.
- Instrumenta la atribución de fotogramas perdidos en producción con etiquetas por pantalla y compáralo con lo que medías en el laboratorio.