Menú
Android: Throttling de redibujados — cómo eliminar el jank con Macrobenchmark y Perfetto

Android: Throttling de redibujados — cómo eliminar el jank con Macrobenchmark y Perfetto

por Nikolay Vlasov

Cuando la interfaz de usuario empieza a ir lenta, no puedes encontrar la causa a simple vista: necesitas herramientas potentes. En este artículo, te mostraremos cómo utilizar el arsenal profesional de un desarrollador Android — Macrobenchmark y Perfetto — para cazar los "fotogramas jank" en una animación de vista. Pasarás por todo el proceso: desde medir el problema hasta identificar su causa y aplicar una solución específica — el throttling de fotogramas — para eliminar los tirones visibles.

¡Hola! En mi artículo anterior, integramos LeakCanary para capturar fugas de memoria, utilizando un ShimmerView como sujeto de prueba. Este componente cumplió su función perfectamente, pero en algunos dispositivos empecé a notar tirones en la animación: esos "fotogramas jank" que arruinan la experiencia del usuario.

Simplemente mirar una interfaz lenta no es nuestro estilo. Como ingenieros, debemos abordar el problema de forma sistemática: medir, analizar, encontrar la causa y aplicar una solución provisional, para luego medir de nuevo y evaluar los resultados.

En este artículo, mostraré todo el proceso de investigación del rendimiento de ShimmerView:

  1. Escribir un Macrobenchmark para obtener métricas de rendimiento objetivas y reproducibles.
  2. Analizar trazas en la interfaz de Perfetto utilizando consultas SQL para encontrar el cuello de botella.
  3. Formular y probar una hipótesis sobre la causa de las caídas de FPS.
  4. Implementar una solución (throttling de redibujados) basada en los datos obtenidos.
  5. Verificar el resultado y resumir los hallazgos.

Capítulo 1: Medir el problema con Macrobenchmark

Para entender cómo de mal están las cosas, necesitamos números concretos. La herramienta ideal para esto es Android Macrobenchmark. Nos permite ejecutar escenarios de interfaz de usuario en dispositivos reales y recopilar métricas de rendimiento, como FrameTimingMetric, que rastrea el tiempo de renderizado de cada fotograma.

En un mundo perfecto, para obtener una imagen completa del rendimiento, las pruebas deberían ejecutarse en una amplia gama de dispositivos: desde modelos económicos hasta buques insignia. Esto ayuda a entender cómo se comporta la aplicación con diferentes potencias de procesador, tamaños de RAM y resoluciones de pantalla.

Sin embargo, para una evaluación inicial e identificar los problemas más obvios, basta con utilizar un dispositivo representativo. En mi caso, la elección recayó en un Huawei Honor 10i con Android 9. Este smartphone, lanzado en 2019, está equipado con un procesador Kirin 710 y 4 GB de RAM, lo que hoy día puede considerarse una especificación modesta. Si una animación "va lenta" en un dispositivo así, es probable que el problema sea notable también en modelos más potentes, aunque en menor medida. Así pues, probar en un dispositivo deliberadamente más débil permite identificar eficazmente los cuellos de botella del rendimiento.

Aquí está mi prueba para la versión básica y no optimizada de ShimmerView:

@RunWith(AndroidJUnit4::class)
class AnimationBenchmark {

    @get:Rule
    val benchmarkRule = MacrobenchmarkRule()

    @Test
    fun shimmerAnimationBasic() {
        measureAnimation(
            testName = "shimmer_basic",
            stringResName = "basic_shimmer",
            viewToWaitForId = "basic_shimmer_view"
        )
    }

    // ...

    private fun measureAnimation(
        testName: String,
        stringResName: String,
        viewToWaitForId: String
    ) {
        benchmarkRule.measureRepeated(
            packageName = "com.ndev.android.ui.sample",
            metrics = listOf(FrameTimingMetric()),
            iterations = 3,
            startupMode = StartupMode.WARM, // Simular el lanzamiento desde la memoria
            setupBlock = {
                // Preparación: iniciar la actividad, navegar a la pantalla correcta
                startActivityAndWait()
                navigateToScreen(stringResName, viewToWaitForId)
                ensureAnimationStopped()
            }
        ) {
            // Medición: iniciar la animación y esperar
            startAnimation()
            Thread.sleep(35_000L) // ANIMATION_DURATION_MS
            stopAnimation()
        }
    }
    // ... resto del código de la prueba
}

¿Por qué la animación dura 35 segundos?

Muchos problemas de rendimiento son acumulativos. Una prueba corta (1-2 segundos) podría no revelar problemas relacionados con el throttling de la CPU debido al calor, la presión sobre el Garbage Collector u otros efectos que se manifiestan con el tiempo. Una medición larga nos da una imagen más completa y honesta.

Tras ejecutar shimmerAnimationBasic, obtenemos los siguientes resultados:

frameCount           min 2,179.0,   median 2,184.0,   max 2,187.0
frameDurationCpuMs   P50     12.2,   P90     27.1,   P95     28.6,   P99     29.2
Traces: Iteration 0 1 2

Análisis de los resultados:

  • El P50 (mediana) a 12,2 ms se ve bien. Es menor que los 16,6 ms necesarios para 60 FPS.
  • Pero el P90 (el 90% de los fotogramas se renderizan más lento que este valor), el P95 y el P99 son un desastre. 29,2 ms en el percentil 99 significa que en el peor de los casos, nuestros FPS caen a ~34 (1000 / 29,2). Estos son los "janks" de los que hablamos.

Ahora no tenemos solo una sensación, sino números concretos y una traza para un análisis profundo.

Capítulo 2: Bucear en la traza con Perfetto

Abramos una de las trazas generadas por el benchmark en la interfaz de Perfetto. Nos encontramos ante una enorme cantidad de datos. Para darles sentido, tenemos que hacer las preguntas adecuadas.

Navegador de investigación: Wall Time vs. CPU Time

La primera y más importante pregunta es: ¿está el hilo ocupado con trabajo o está esperando algo?

  • Wall Time (o tiempo de reloj) es el tiempo total que ha transcurrido desde el principio hasta el final de la ejecución de una función. "El tiempo en un reloj de pared".
  • CPU Time (o tiempo de CPU) es el tiempo que el procesador dedicó realmente a ejecutar las instrucciones de ese hilo.

Si Wall Time ≈ CPU Time, significa que el procesador estaba totalmente cargado con cálculos. El problema está en nuestro código: es demasiado "pesado". Tenemos que encontrar qué métodos específicos (onMeasure, onDraw, inflate) están tardando mucho tiempo y optimizarlos.

Si Wall Time >> CPU Time, significa que el hilo estuvo inactivo la mayor parte del tiempo, esperando. Podría estar esperando una respuesta del disco (E/S), de la red, de otro proceso (vía Binder) o, como suele ser el caso en la interfaz de usuario, de la GPU.

Para comprobarlo, utilizaremos una consulta SQL. Primero, buscaremos todos los fotogramas "problemáticos" (aquellos que duraron más de 16,6 ms) y luego veremos qué estaba haciendo el hilo principal durante ese tiempo.

-- Paso 1: Buscar todos los fotogramas "janky" en el hilo de UI de nuestra aplicación
WITH janky_frames AS (
  SELECT
    ts, -- timestamp inicial
    dur -- duración
  FROM slice
  JOIN thread_track ON slice.track_id = thread_track.id
  JOIN thread ON thread_track.utid = thread.utid
  JOIN process ON thread.upid = process.upid
  WHERE
    process.name = 'com.ndev.android.ui.sample'
    AND slice.name = 'Choreographer#doFrame'
    AND slice.dur > 16666666 -- Duración > 16,6 ms (en nanosegundos)
)
-- Paso 2: Agregar todas las operaciones en el hilo principal que ocurrieron DENTRO de estos fotogramas janky
SELECT
  slice.name AS operation_name,
  SUM(slice.dur) / 1000000 AS total_wall_duration_ms,
  SUM(
    (SELECT SUM(sched.dur)
     FROM sched
     WHERE
       sched.utid = thread.utid
       AND sched.ts >= slice.ts AND sched.ts < slice.ts + slice.dur
    )
  ) / 1000000 AS total_cpu_duration_ms,
  COUNT(*) AS frequency
FROM slice
JOIN thread_track ON slice.track_id = thread_track.id
JOIN thread ON thread_track.utid = thread.utid
JOIN janky_frames ON
  slice.ts >= janky_frames.ts AND slice.ts < janky_frames.ts + janky_frames.dur
WHERE
  thread.is_main_thread = 1
  AND slice.dur > 1000000 -- Ignorar operaciones muy cortas
GROUP BY
  slice.name
ORDER BY
  total_wall_duration_ms DESC
LIMIT 25;

El resultado de esta consulta es muy revelador:

name total_wall_duration_ms total_cpu_duration_ms frequency
Choreographer#doFrame 619 67 37
traversal 591 65 32
draw 575 51 32
onMessageReceived 545 189 113
handleMessageRefresh 251 87 51
... ... ... ...

Mirando la primera fila: Choreographer#doFrame, la operación raíz para dibujar un fotograma. ¡El Wall Time (619 ms) es casi 10 veces mayor que el CPU Time (67 ms)!

Nuestra hipótesis: El hilo de UI no está ocupado trabajando; está esperando. Dado que ShimmerView es un componente puramente visual que se redibuja constantemente, el culpable más probable es la GPU. El hilo de UI está enviando comandos de dibujo con demasiada frecuencia y está esperando a que el RenderThread y la GPU los gestionen.

Capítulo 3: Verificar la hipótesis de carga de la GPU

Pongamos a prueba esta hipótesis. Escribiremos una consulta que examine en qué estaba ocupado el RenderThread en los momentos en que el hilo de UI experimentaba jank.

WITH janky_frames AS (
  -- (igual que en la consulta anterior)
  SELECT ts, dur FROM slice
  JOIN thread_track ON slice.track_id = thread_track.id
  JOIN thread ON thread_track.utid = thread.utid
  JOIN process ON thread.upid = process.upid
  WHERE
    process.name = 'com.ndev.android.ui.sample'
    AND slice.name = 'Choreographer#doFrame'
    AND slice.dur > 16666666
),
render_thread AS (
  -- Buscar el RenderThread de nuestra aplicación
  SELECT utid
  FROM thread
  WHERE name = 'RenderThread' AND upid = (
    SELECT upid FROM process WHERE name = 'com.ndev.android.ui.sample' LIMIT 1
  )
)
SELECT
  slice.name,
  SUM(slice.dur) / 1000000 AS total_duration_ms,
  COUNT(*) as frequency
FROM slice
JOIN janky_frames ON
  -- Buscar operaciones que se SOLAPAN en el tiempo con los fotogramas janky
  slice.ts < janky_frames.ts + janky_frames.dur AND slice.ts + slice.dur > janky_frames.ts
WHERE
  -- Solo nos interesan los slices del RenderThread
  slice.track_id = (SELECT id FROM thread_track WHERE utid = (SELECT utid FROM render_thread))
GROUP BY
  slice.name
ORDER BY
  total_duration_ms DESC
LIMIT 20;

Los resultados confirman nuestra teoría:

name total_duration_ms frequency
DrawFrame 1021 58
binder transaction 409 188
dequeueBuffer 382 57
eglSwapBuffersWithDamageKHR 226 29
queueBuffer 97 29
... ... ...

Aquí vemos el conjunto completo de operaciones gráficas "pesadas": DrawFrame, dequeueBuffer, eglSwapBuffers. Esta es una prueba directa de que el RenderThread está trabajando activamente con la GPU para renderizar nuestros fotogramas.

Conclusión: El problema no es la complejidad del propio ShimmerView (el tiempo de CPU era bajo), sino la frecuencia de sus redibujados. Nuestro ValueAnimator llama a postInvalidateOnAnimation() en cada actualización de valor, forzando al sistema de renderizado a trabajar hasta el agotamiento.

Capítulo 4: La Solución — Throttling adaptativo de redibujado

Dado que la causa raíz — la sobrecarga de cada llamada de dibujo y la contención del pipeline de la GPU — es difícil de eliminar sin reescribir el componente por completo, aplicaremos una solución pragmática: el throttling de la frecuencia de redibujado para que el pipeline de renderizado nunca se vea desbordado. Esto es esencialmente un hack: en lugar de hacer que cada fotograma sea más barato, simplemente solicitamos menos.

Implementaremos un mecanismo de omisión de fotogramas (throttling) adaptativo. La lógica es la siguiente:

  1. Seguimos utilizando un ValueAnimator para calcular la posición del degradado.
  2. Pero no siempre llamamos a postInvalidateOnAnimation(). Solo lo llamamos si ha pasado suficiente tiempo desde el último redibujado (por ejemplo, > 16 ms).
  3. Para ser más flexibles, utilizaremos una Media Móvil Exponencial (EMA) para calcular el tiempo real entre fotogramas. Si el sistema está bajo carga y los fotogramas se renderizan lentamente, nos adaptamos y dejamos de intentar "empujar" actualizaciones extra, lo que solo empeoraría las cosas.

Aquí está el fragmento clave del código de throttling en ValueAnimator.addUpdateListener:

addUpdateListener { animation ->
    shimmerTranslate = animation.animatedValue as Float

    val now = System.nanoTime()
    // Actualizar EMA para calcular el tiempo medio de fotograma
    if (lastAnimatorUpdateNs != 0L) {
        val delta = (now - lastAnimatorUpdateNs).toDouble()
        emaFrameNs = emaFrameNs * (1.0 - emaAlpha) + delta * emaAlpha
    }
    lastAnimatorUpdateNs = now

    // Determinar cuánto tiempo esperar hasta el siguiente fotograma
    // Ya sea nuestro valor adaptativo o el objetivo (16,6 ms)
    val adaptiveFrameNs = if (useAdaptiveThrottling) {
        emaFrameNs.coerceAtLeast(targetFrameNs.toDouble()).toLong()
    } else {
        targetFrameNs
    }
    // Aplicar un límite estricto para evitar detenerse por completo
    val allowedFrameNs = adaptiveFrameNs.coerceAtLeast(minFrameNsHardLimit)

    // Llamar a invalidate solo si ha pasado suficiente tiempo
    if (now - lastInvalidateNs >= allowedFrameNs) {
        lastInvalidateNs = now
        postInvalidateOnAnimation()
    }
}

Capítulo 5: Verificar los resultados

Con el nuevo código, ejecutamos de nuevo el benchmark shimmerAnimationOpt. Los resultados hablan por sí solos:

AnimationBenchmark_shimmerAnimationOpt
frameCount           min 1,446.0,   median 1,447.0,   max 1,449.0
frameDurationCpuMs   P50     11.0,   P90     12.2,   P95     13.3,   P99     15.4

Comparemos el "antes" y el "después":

Métrica shimmerAnimationBasic (Antes) shimmerAnimationOpt (Después) Cambio
frameCount (mediana) 2,184 1,447 -33,7%
frameDurationCpuMs P95 28,6 ms 13,3 ms -53,5%
frameDurationCpuMs P99 29,2 ms 15,4 ms -47,3%

Al aplicar el throttling a los redibujados, redujemos el número de fotogramas renderizados (frameCount) en un tercio, lo que alivió la presión sobre el pipeline de la GPU. El percentil 99 del tiempo de fotograma bajó a 15,4 ms, ajustándose al presupuesto de 16,6 ms para 60 FPS.

Advertencia importante: esto es una compensación, no una victoria gratuita. No hemos hecho que cada fotograma se renderice más rápido, simplemente hemos dejado de solicitar más fotogramas de los que el pipeline puede gestionar. La animación parece más fluida porque ya no hay picos de jank, pero técnicamente estamos renderizando menos fotogramas únicos por segundo. En este dispositivo, la diferencia visual es imperceptible, pero es esencial entender que esto es una solución temporal (workaround), no una optimización fundamental.

Conclusiones

  1. No confíes en tus sensaciones, mide. Android Macrobenchmark es una herramienta potente para obtener datos objetivos sobre el rendimiento de la interfaz de usuario.
  2. Wall Time vs CPU Time es tu primer paso en el análisis de trazas. Este enfoque muestra al instante si la raíz del problema es un código "pesado" o tiempos de espera.
  3. Perfetto SQL es un superpoder. La capacidad de realizar consultas precisas a los datos de las trazas permite probar rápidamente hipótesis y evita las suposiciones.
  4. Si no puedes acelerarlo, redúcelo. A veces, un problema de rendimiento no tiene que ver con qué dibujas, sino con que el pipeline de renderizado se ve desbordado por un exceso de peticiones de dibujo. El throttling de fotogramas es un hack pragmático: no acelera nada, pero evita que el sistema se ahogue. Conoce las compensaciones y utilízalo conscientemente.

Un enfoque sistemático y basado en datos — incluso cuando el resultado final es un hack en lugar de una solución limpia — proporciona una comprensión más profunda de cómo funciona el sistema de renderizado en Android. Entender por qué funciona tu solución temporal es tan valioso como la propia solución. Este conocimiento dará sus frutos en futuros proyectos, donde quizás encuentres la forma de abordar la causa raíz directamente.