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 :
- L'écriture d'un Macrobenchmark pour obtenir des métriques de performance objectives et reproductibles.
- L'analyse des traces dans l'interface utilisateur de Perfetto à l'aide de requêtes SQL pour trouver le goulot d'étranglement.
- La formulation et le test d'une hypothèse sur la cause des chutes de FPS.
- La mise en œuvre d'un contournement (throttling des redessins) basé sur les données obtenues.
- 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), leP95et leP99sont 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 :
- Nous utilisons toujours un
ValueAnimatorpour calculer la position du dégradé. - 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). - 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
- 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.
- 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.
- 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.
- 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.