Menu
Android : Throttling des redessins — comment éliminer le jank avec Macrobenchmark et Perfetto

Android : Throttling des redessins — comment éliminer le jank avec Macrobenchmark et Perfetto

par Nikolay Vlasov

Bientôt, lorsque l'interface utilisateur commence à ramer, un simple coup d'œil ne suffit pas pour en trouver la cause — vous avez besoin d'outils puissants. Dans cet article, nous vous montrerons comment utiliser l'arsenal professionnel d'un développeur Android — Macrobenchmark et Perfetto — pour traquer les "frames de jank" dans une animation de vue. Vous suivrez tout le processus : de la mesure du problème à l'identification de sa cause et à l'application d'un contournement ciblé — le throttling des frames — pour éliminer les saccades visibles.

Bonjour ! Dans mon article précédent, nous avons intégré LeakCanary pour capturer les fuites de mémoire, en utilisant ShimmerView comme sujet de test. Ce composant remplissait parfaitement son rôle, mais sur certains appareils, j'ai commencé à remarquer des saccades d'animation — ces fameuses "frames de jank" qui gâchent l'expérience utilisateur.

Se contenter de regarder une interface qui lag n'est pas notre méthode. En tant qu'ingénieurs, nous devons aborder le problème de manière systématique : mesurer, analyser, trouver la cause et appliquer un contournement, puis mesurer à nouveau pour évaluer les résultats.

Dans cet article, je montrerai tout le processus d'investigation de la performance de ShimmerView :

  1. L'écriture d'un Macrobenchmark pour obtenir des métriques de performance objectives et reproductibles.
  2. L'analyse des traces dans l'interface utilisateur de Perfetto à l'aide de requêtes SQL pour trouver le goulot d'étranglement.
  3. La formulation et le test d'une hypothèse sur la cause des chutes de FPS.
  4. La mise en œuvre d'un contournement (throttling des redessins) basé sur les données obtenues.
  5. La vérification du résultat et le résumé des conclusions.

Chapitre 1 : Mesurer le problème avec Macrobenchmark

Pour comprendre à quel point la situation est critique, nous avons besoin de chiffres concrets. L'outil idéal pour cela est Android Macrobenchmark. Il nous permet d'exécuter des scénarios d'interface utilisateur sur de vrais appareils et de collecter des métriques de performance, telles que FrameTimingMetric, qui suit le temps de rendu de chaque frame.

Dans un monde parfait, pour obtenir une image complète de la performance, les tests devraient être exécutés sur une large gamme d'appareils : des modèles d'entrée de gamme aux fleurons. Cela aide à comprendre comment l'application se comporte avec différentes puissances de processeur, tailles de RAM et résolutions d'écran.

Cependant, pour une évaluation initiale et pour identifier les problèmes les plus évidents, il suffit d'utiliser un appareil représentatif. Dans mon cas, le choix s'est porté sur un Huawei Honor 10i sous Android 9. Ce smartphone, sorti en 2019, est équipé d'un procesador Kirin 710 et de 4 Go de RAM, ce qui peut être considéré comme des spécifications modestes aujourd'hui. Si une animation "lag" sur un tel appareil, le problème sera probablement perceptible sur des modèles plus puissants également, bien que dans une moindre mesure. Ainsi, tester sur un appareil délibérément plus faible permet d'identifier efficacement les goulots d'étranglement de performance.

Voici mon test pour la version de base non optimisée 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, // Simuler le lancement depuis la mémoire
            setupBlock = {
                // Préparation : démarrer l'Activité, naviguer vers le bon écran
                startActivityAndWait()
                navigateToScreen(stringResName, viewToWaitForId)
                ensureAnimationStopped()
            }
        ) {
            // Mesure : démarrer l'animation et attendre
            startAnimation()
            Thread.sleep(35_000L) // ANIMATION_DURATION_MS
            stopAnimation()
        }
    }
    // ... reste du code du test
}

Pourquoi l'animation dure-t-elle 35 secondes ?

De nombreux problèmes de performance sont cumulatifs. Un test court (1-2 secondes) pourrait ne pas révéler de problèmes liés au throttling du CPU dû à la chaleur, à la pression sur le Garbage Collector ou à d'autres effets qui se manifestent au fil du temps. Une mesure longue nous donne une image plus complète et honnête.

Après avoir exécuté shimmerAnimationBasic, nous obtenons les résultats suivants :

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

Analyse des résultats :

  • Le P50 (médiane) à 12,2 ms semble correct. C'est moins que les 16,6 ms requis pour du 60 FPS.
  • Mais le P90 (90 % des frames sont rendues plus lentement que cette valeur), le P95 et le P99 sont un désastre. 29,2 ms au 99e centile signifie qu'au pire, nos FPS tombent à environ 34 (1000 / 29,2). Ce sont ces fameux "janks" dont nous parlons.

Désormais, nous n'avons plus seulement une impression, mais des chiffres concrets et une trace pour une analyse approfondie.

Chapitre 2 : Plongée dans la trace avec Perfetto

Ouvrons l'une des traces générées par le benchmark dans l' interface utilisateur de Perfetto. Nous sommes face à une énorme quantité de données. Pour y donner du sens, nous devons poser les bonnes questions.

Navigateur d'investigation : Wall Time vs CPU Time

La première et la plus importante question est : est-ce que le thread est occupé par du travail ou attend-il quelque chose ?

  • Wall Time (ou temps réel) est le temps total qui s'est écoulé du début à la fin de l'exécution d'une fonction. "L'heure à l'horloge murale".
  • CPU Time (ou temps CPU) est le temps que le processeur a réellement passé à exécuter les instructions de ce thread.

Si Wall Time ≈ CPU Time, cela signifie que le processeur était pleinement chargé de calculs. Le problème réside dans notre code — il est trop "lourd". Nous devons trouver quelles méthodes spécifiques (onMeasure, onDraw, inflate) prennent beaucoup de temps et les optimiser.

Si Wall Time >> CPU Time, cela signifie que le thread a été inactif pendant la majeure partie du temps, en attendant. Il pourrait attendre une réponse du disque (E/S), du réseau, d'un autre processus (via Binder) ou, comme c'est souvent le cas en UI, du GPU.

Pour vérifier cela, nous utiliserons une requête SQL. Tout d'abord, trouvons toutes les frames "problématiques" (celles qui ont duré plus de 16,6 ms), puis voyons ce que le thread principal faisait pendant ce temps.

-- Étape 1 : Trouver toutes les frames "janky" dans le thread UI de notre application
WITH janky_frames AS (
  SELECT
    ts, -- timestamp de début
    dur -- durée
  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 -- Durée > 16,6 ms (en nanosecondes)
)
-- Étape 2 : Agréger toutes les opérations sur le thread principal qui ont eu lieu À L'INTÉRIEUR de ces frames de jank
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 -- Ignorer les opérations très courtes
GROUP BY
  slice.name
ORDER BY
  total_wall_duration_ms DESC
LIMIT 25;

Le résultat de cette requête est très éloquent :

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

En regardant la première ligne : Choreographer#doFrame, l'opération racine pour dessiner une frame. Le Wall Time (619 ms) est presque 10 fois supérieur au CPU Time (67 ms) !

Notre hypothèse : Le thread UI n'est pas occupé à travailler ; il attend. Étant donné que ShimmerView est un composant purement visuel qui est redessiné en permanence, le coupable le plus probable est le GPU. Le thread UI envoie des commandes de dessin trop fréquemment et attend que le RenderThread et le GPU les gèrent.

Chapitre 3 : Vérifier l'hypothèse de charge du GPU

Testons cette hypothèse. Nous allons écrire une requête qui regarde ce que le RenderThread faisait aux moments où le thread UI subissait du jank.

WITH janky_frames AS (
  -- (même chose que dans la requête précédente)
  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 (
  -- Trouver le RenderThread de notre application
  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
  -- Rechercher des opérations qui se CHEVAUCHENT dans le temps avec les frames de jank
  slice.ts < janky_frames.ts + janky_frames.dur AND slice.ts + slice.dur > janky_frames.ts
WHERE
  -- Nous ne nous intéressons qu'aux tranches du 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;

Les résultats confirment notre théorie :

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

Ici, nous voyons l'ensemble complet d'opérations graphiques "lourdes" : DrawFrame, dequeueBuffer, eglSwapBuffers. C'est une preuve directe que le RenderThread travaille activement avec le GPU pour rendre nos frames.

Conclusion : Le problème n'est pas la complexité de ShimmerView elle-même (le temps CPU était faible), mais la fréquence de ses redessins. Notre ValueAnimator appelle postInvalidateOnAnimation() à chaque mise à jour de valeur, forçant le système de rendu à travailler jusqu'à l'épuisement.

Chapitre 4 : Le Contournement — Throttling adaptatif du redessin

Comme la cause profonde — le surcoût de chaque appel de dessin et la contention du pipeline GPU — est difficile à éliminer sans réécrire entièrement le composant, nous allons appliquer un contournement pragmatique : le throttling de la fréquence de redessin afin que le pipeline de rendu ne soit jamais submergé. Il s'agit essentiellement d'un hack : au lieu de rendre chaque frame moins coûteuse, nous en demandons simplement moins.

Nous allons mettre en œuvre un mécanisme adaptatif de saut de frames (throttling). La logique est la suivante :

  1. Nous utilisons toujours un ValueAnimator pour calculer la position du dégradé.
  2. Mais nous n'appelons pas toujours postInvalidateOnAnimation(). Nous ne l'appelons que si suffisamment de temps s'est écoulé depuis le dernier redessin (par exemple, > 16 ms).
  3. Pour être plus flexible, nous utiliserons une Moyenne Mobile Exponentielle (EMA) pour calculer le temps réel entre les frames. Si le système est sous charge et que les frames sont rendues lentement, nous nous adaptons et arrêtons d'essayer de "pousser" des mises à jour supplémentaires, ce qui ne ferait qu'empirer les choses.

Voici le fragment clé du code de throttling dans ValueAnimator.addUpdateListener :

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

    val now = System.nanoTime()
    // Mettre à jour l'EMA pour calculer le temps de frame moyen
    if (lastAnimatorUpdateNs != 0L) {
        val delta = (now - lastAnimatorUpdateNs).toDouble()
        emaFrameNs = emaFrameNs * (1.0 - emaAlpha) + delta * emaAlpha
    }
    lastAnimatorUpdateNs = now

    // Déterminer combien de temps attendre jusqu'à la prochaine frame
    // Soit notre valeur adaptative, soit la cible (16,6 ms)
    val adaptiveFrameNs = if (useAdaptiveThrottling) {
        emaFrameNs.coerceAtLeast(targetFrameNs.toDouble()).toLong()
    } else {
        targetFrameNs
    }
    // Appliquer une limite stricte pour éviter de s'arrêter complètement
    val allowedFrameNs = adaptiveFrameNs.coerceAtLeast(minFrameNsHardLimit)

    // Appeler invalidate uniquement si suffisamment de temps s'est écoulé
    if (now - lastInvalidateNs >= allowedFrameNs) {
        lastInvalidateNs = now
        postInvalidateOnAnimation()
    }
}

Chapitre 5 : Vérification des résultats

Avec le nouveau code, nous relançons le benchmark shimmerAnimationOpt. Les résultats parlent d'eux-mêmes :

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

Comparons l' "avant" et l' "après" :

Métrique shimmerAnimationBasic (Avant) shimmerAnimationOpt (Après) Changement
frameCount (médiane) 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 %

En limitant les redessins, nous avons réduit le nombre de frames rendues (frameCount) d'un tiers, ce qui a soulagé la pression sur le pipeline du GPU. Le 99e centile du temps de frame est tombé à 15,4 ms, ce qui rentre dans le budget de 16,6 ms pour du 60 FPS.

Mise en garde importante : il s'agit d'un compromis, pas d'une victoire gratuite. Nous n'avons pas accéléré le rendu de chaque frame — nous avons simplement cessé de demander plus de frames que le pipeline ne pouvait en gérer. L'animation semble plus fluide car il n'y a plus de pics de jank, mais techniquement nous rendons moins de frames uniques par seconde. Sur cet appareil, la différence visuelle est imperceptible, mais il est essentiel de comprendre : il s'agit d'un contournement (workaround), pas d'une optimisation fondamentale.

Conclusions

  1. Ne vous fiez pas à vos impressions, mesurez. Android Macrobenchmark est un outil puissant pour obtenir des données objectives sur la performance de l'interface utilisateur.
  2. Wall Time vs CPU Time est votre première étape dans l'analyse des traces. Cette approche montre instantanément si la racine du problème est un code "lourd" ou des temps d'attente.
  3. Perfetto SQL est un super-pouvoir. La capacité de faire des requêtes précises sur les données de trace permet de tester rapidement des hypothèses et évite les devinettes.
  4. Quand vous ne pouvez pas accélérer, ralentissez. Parfois, un problème de performance ne concerne pas ce que vous dessinez, mais le fait de submerger le pipeline de rendu avec des requêtes de dessin excessives. Le throttling des frames est un hack pragmatique — il n'accélère rien, mais il empêche le système de s'étouffer. Connaissez les compromis et utilisez-le consciemment.

Une approche systématique, basée sur des données — même si le résultat final est un hack plutôt qu'un correctif propre — permet une compréhension plus approfondie du fonctionnement du système de rendu sous Android. Comprendre pourquoi votre contournement fonctionne est tout aussi précieux que le contournement lui-même. Ces connaissances porteront leurs fruits dans de futurs projets, où vous trouverez peut-être un moyen de traiter directement la cause profonde.