Android Animation Jank beheben mit Perfetto & Macrobenchmark
by Nikolay Vlasov
Wenn die Benutzeroberfläche ins Stocken gerät, lässt sich die Ursache meist nicht auf den ersten Blick erkennen – man braucht leistungsstarke Werkzeuge. In diesem Artikel zeigen wir Ihnen, wie Sie das professionelle Arsenal eines Android-Entwicklers – Macrobenchmark und Perfetto – nutzen, um "Janky Frames" in einer View-Animation aufzuspüren. Wir begleiten Sie durch den gesamten Prozess, von der Messung des Problems bis zur Lösung, und zeigen, wie Datenanalyse ein ruckelndes Interface in ein perfekt flüssiges verwandelt.
Hallo! In meinem vorherigen Artikel haben wir LeakCanary integriert, um Speicherlecks abzufangen, wobei eine ShimmerView als Testobjekt diente. Diese Komponente erfüllte ihren Zweck perfekt, doch auf einigen Geräten bemerkte ich Animationsruckler – genau jene "Janky Frames", die das Nutzererlebnis trüben.
Eine ruckelige UI einfach nur zu beobachten, ist nicht unser Weg. Als Ingenieure müssen wir das Problem systematisch angehen: messen, analysieren, die Ursache finden, beheben und anschließend erneut messen, um den Erfolg zu bestätigen.
In diesem Artikel zeige ich den gesamten Prozess der Leistungsuntersuchung von ShimmerView:
- Schreiben eines Macrobenchmarks, um objektive und reproduzierbare Performance-Metriken zu erhalten.
- Analyse von Traces in der Perfetto-UI mittels SQL-Abfragen, um den Flaschenhals zu finden.
- Formulierung und Testen einer Hypothese über die Ursache der FPS-Einbrüche.
- Implementierung einer Optimierung basierend auf den gewonnenen Daten.
- Verifizierung des Ergebnisses und Zusammenfassung der Erkenntnisse.
Kapitel 1: Das Problem mit Macrobenchmark messen
Um zu verstehen, wie gravierend die Lage ist, benötigen wir konkrete Zahlen. Das ideale Werkzeug hierfür ist Android Macrobenchmark. Es ermöglicht uns, UI-Szenarien auf realen Geräten auszuführen und Performance-Metriken zu sammeln, wie etwa FrameTimingMetric, welche die Renderzeit jedes einzelnen Frames aufzeichnet.
In einer perfekten Welt sollten Tests auf einer breiten Palette von Geräten durchgeführt werden, um ein vollständiges Leistungsbild zu erhalten: vom Budget-Modell bis zum Flaggschiff. Dies hilft zu verstehen, wie sich die Anwendung bei unterschiedlicher Prozessorleistung, RAM-Größe und Bildschirmauflösung verhält.
Für eine erste Einschätzung und um die offensichtlichsten Probleme zu identifizieren, reicht jedoch ein repräsentatives Gerät aus. In meinem Fall fiel die Wahl auf ein Huawei Honor 10i mit Android 9. Dieses 2019 erschienene Smartphone ist mit einem Kirin 710 Prozessor und 4 GB RAM ausgestattet, was nach heutigem Standard eher bescheidene Spezifikationen sind. Wenn eine Animation auf einem solchen Gerät "laggt", wird das Problem wahrscheinlich auch auf leistungsstärkeren Modellen spürbar sein – wenn auch in geringerem Maße. Das Testen auf einem bewusst schwächeren Gerät ermöglicht es somit, Leistungsengpässe effektiv zu identifizieren.
Hier ist mein Test für die ursprüngliche, nicht optimierte Version der 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, // Start aus dem Speicher simulieren
setupBlock = {
// Vorbereitung: Activity starten, zum richtigen Screen navigieren
startActivityAndWait()
navigateToScreen(stringResName, viewToWaitForId)
ensureAnimationStopped()
}
) {
// Messung: Animation starten und warten
startAnimation()
Thread.sleep(35_000L) // ANIMATION_DURATION_MS
stopAnimation()
}
}
// ... restlicher Testcode
}
Warum dauert die Animation 35 Sekunden?
Viele Performance-Probleme sind kumulativ. Ein kurzer Test (1–2 Sekunden) deckt möglicherweise keine Probleme auf, die mit CPU-Throttling aufgrund von Hitze, Druck auf den Garbage Collector oder anderen Effekten zusammenhängen, die sich erst mit der Zeit manifestieren. Eine lange Messung liefert uns ein vollständigeres und ehrlicheres Bild.
Nach Ausführung von shimmerAnimationBasic erhalten wir folgende Ergebnisse:
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 der Ergebnisse:
P50(Median) mit 12,2 ms sieht gut aus. Das liegt unter den 16,6 ms, die für 60 FPS erforderlich sind.- Aber
P90(90 % der Frames rendern langsamer als dieser Wert),P95undP99sind ein Desaster. 29,2 ms beim 99. Perzentil bedeuten, dass unsere FPS im schlimmsten Fall auf ~34 absinken (1000 / 29,2). Das sind genau die "Janks", von denen wir sprechen.
Jetzt haben wir nicht mehr nur ein Gefühl, sondern konkrete Zahlen und einen Trace für eine tiefgehende Analyse.
Kapitel 2: Mit Perfetto in den Trace eintauchen
Öffnen wir einen der vom Benchmark generierten Traces in der Perfetto UI. Wir sehen uns einer riesigen Datenmenge gegenüber. Um diese zu verstehen, müssen wir die richtigen Fragen stellen.
Untersuchungs-Leitfaden: Wall Time vs. CPU Time
Die erste und wichtigste Frage lautet: Ist der Thread mit Arbeit beschäftigt oder wartet er auf etwas?
- Wall Time (Wandzeit) ist die Gesamtzeit, die vom Anfang bis zum Ende einer Funktionsausführung vergangen ist. "Die Zeit auf einer Wanduhr."
- CPU Time (CPU-Zeit) ist die Zeit, die der Prozessor tatsächlich mit der Ausführung der Instruktionen dieses Threads verbracht hat.
Wenn Wall Time ≈ CPU Time, bedeutet das, dass der Prozessor voll mit Berechnungen ausgelastet war. Das Problem liegt in unserem Code – er ist zu primär "lastig". Wir müssen herausfinden, welche spezifischen Methoden (onMeasure, onDraw, inflate) lange dauern und diese optimieren.
Wenn Wall Time >> CPU Time, bedeutet das, dass der Thread die meiste Zeit untätig war und gewartet hat. Er könnte auf eine Antwort von der Festplatte (I/O), dem Netzwerk, einem anderen Prozess (via Binder) oder, wie oft bei UIs der Fall, von der GPU warten.
Um dies zu überprüfen, nutzen wir eine SQL-Abfrage. Zuerst finden wir alle "problematischen" Frames (solche, die länger als 16,6 ms gedauert haben) und schauen uns dann an, was der Main Thread während dieser Zeit getan hat.
-- Schritt 1: Alle "janky" Frames im UI-Thread unserer Anwendung finden
WITH janky_frames AS (
SELECT
ts, -- Start-Zeitstempel
dur -- Dauer
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 -- Dauer > 16.6 ms (in Nanosekunden)
)
-- Schritt 2: Alle Operationen auf dem Main-Thread aggregieren, die INNERHALB dieser janky Frames stattfanden
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 -- Sehr kurze Operationen ignorieren
GROUP BY
slice.name
ORDER BY
total_wall_duration_ms DESC
LIMIT 25;
Das Ergebnis dieser Abfrage ist sehr aufschlussreich:
| 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 |
| ... | ... | ... | ... |
Betrachten wir die erste Zeile: Choreographer#doFrame, die Wurzeloperation für das Zeichnen eines Frames. Die Wall Time (619 ms) ist fast 10-mal höher als die CPU Time (67 ms)!
Unsere Hypothese: Der UI-Thread ist nicht mit Arbeit beschäftigt; er wartet. Da die ShimmerView eine rein visuelle Komponente ist, die ständig neu gezeichnet wird, ist die GPU der wahrscheinlichste Übeltäter. Der UI-Thread sendet zu häufig Zeichenbefehle und wartet darauf, dass der RenderThread und die GPU diese verarbeiten.
Kapitel 3: Überprüfung der GPU-Last-Hypothese
Testen wir diese Hypothese. Wir schreiben eine Abfrage, die untersucht, womit der RenderThread in den Momenten beschäftigt war, in denen der UI-Thread Verzögerungen (Jank) aufwies.
WITH janky_frames AS (
-- (Gleich wie in der vorherigen Abfrage)
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 (
-- RenderThread unserer Anwendung finden
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
-- Nach Operationen suchen, die sich zeitlich mit den janky Frames ÜBERSCHNEIDEN
slice.ts < janky_frames.ts + janky_frames.dur AND slice.ts + slice.dur > janky_frames.ts
WHERE
-- Uns interessieren nur Slices vom 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;
Die Ergebnisse bestätigen unsere Theorie:
| Name | total_duration_ms | Frequency |
|---|---|---|
| DrawFrame | 1021 | 58 |
| binder transaction | 409 | 188 |
| dequeueBuffer | 382 | 57 |
| eglSwapBuffersWithDamageKHR | 226 | 29 |
| queueBuffer | 97 | 29 |
| ... | ... | ... |
Hier sehen wir das gesamte Set "schwerer" Grafikoperationen: DrawFrame, dequeueBuffer, eglSwapBuffers. Dies ist ein direkter Beweis dafür, dass der RenderThread aktiv mit der GPU zusammenarbeitet, um unsere Frames zu rendern.
Fazit: Das Problem ist nicht die Komplexität der ShimmerView an sich (die CPU-Zeit war niedrig), sondern die Häufigkeit ihrer Neuzeichnungen. Unser ValueAnimator ruft bei jedem Wert-Update postInvalidateOnAnimation() auf und zwingt das Rendering-System zur Erschöpfung.
Kapitel 4: Die Lösung – Adaptive Kontrolle der Zeichenfrequenz
Da das Problem in exzessiven Neuzeichnungen liegt, besteht die Lösung darin, diese auf ein vernünftiges Maß zu reduzieren. Wir müssen den Frame nicht öfter aktualisieren, als es das Display erlaubt (normalerweise 60 Hz oder 16,6 ms pro Frame).
Wir implementieren einen adaptiven Mechanismus zum Überspringen von Frames. Die Logik ist wie folgt:
- Wir nutzen weiterhin einen
ValueAnimator, um die Position des Gradients zu berechnen. - Aber wir rufen nicht mehr bedingungslos
postInvalidateOnAnimation()auf. Wir rufen es nur auf, wenn seit der letzten Zeichnung genügend Zeit vergangen ist (z. B. > 16 ms). - Um flexibler zu sein, verwenden wir einen exponentiell gleitenden Durchschnitt (EMA), um die tatsächliche Zeit zwischen den Frames zu berechnen. Wenn das System unter Last steht und Frames langsamer gerendert werden, passen wir uns an und versuchen nicht, zusätzliche Updates "durchzudrücken", was die Situation nur verschlimmern würde.
Hier ist das entscheidende Fragment des optimierten Codes in ValueAnimator.addUpdateListener:
addUpdateListener { animation ->
shimmerTranslate = animation.animatedValue as Float
val now = System.nanoTime()
// EMA aktualisieren, um die durchschnittliche Frame-Zeit zu berechnen
if (lastAnimatorUpdateNs != 0L) {
val delta = (now - lastAnimatorUpdateNs).toDouble()
emaFrameNs = emaFrameNs * (1.0 - emaAlpha) + delta * emaAlpha
}
lastAnimatorUpdateNs = now
// Bestimmen, wie lange bis zum nächsten Frame gewartet werden soll
// Entweder unser adaptiver Wert oder das Ziel (16,6 ms)
val adaptiveFrameNs = if (useAdaptiveThrottling) {
emaFrameNs.coerceAtLeast(targetFrameNs.toDouble()).toLong()
} else {
targetFrameNs
}
// Hartes Limit anwenden, um einen vollständigen Stillstand zu vermeiden
val allowedFrameNs = adaptiveFrameNs.coerceAtLeast(minFrameNsHardLimit)
// Invalidate nur aufrufen, wenn genügend Zeit vergangen ist
if (now - lastInvalidateNs >= allowedFrameNs) {
lastInvalidateNs = now
postInvalidateOnAnimation()
}
}
Kapitel 5: Verifizierung der Ergebnisse
Mit dem neuen Code führen wir den Macrobenchmark shimmerAnimationOpt aus. Die Ergebnisse sprechen für sich:
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
Vergleichen wir "Vorher" und "Nachher":
| Metrik | shimmerAnimationBasic (Vorher) |
shimmerAnimationOpt (Nachher) |
Änderung |
|---|---|---|---|
frameCount (Median) |
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 % |
Wir haben die Anzahl der gerenderten Frames (frameCount) signifikant reduziert, was die Last auf die GPU direkt senkte. Am wichtigsten ist, dass selbst das 99. Perzentil der Frame-Zeit nun bei 15,4 ms liegt, was vollständig in das Budget von 16,6 ms für 60 FPS passt. Die Animation ist nun flüssig.
Fazit
- Vertrauen Sie nicht Ihrem Gefühl, messen Sie. Android Macrobenchmark ist ein leistungsstarkes Werkzeug, um objektive Daten zur UI-Performance zu erhalten.
- Wall Time vs. CPU Time ist Ihr erster Schritt in der Trace-Analyse. Dieser Ansatz zeigt sofort, ob die Ursache des Problems "lastiger" Code oder Wartezeiten sind.
- Perfetto SQL ist eine Superkraft. Die Fähigkeit, präzise Abfragen an Trace-Daten zu stellen, ermöglicht schnelles Testen von Hypothesen und vermeidet Rätselraten.
- Häufigkeit ist wichtiger als Komplexität. Manchmal liegt ein Performance-Problem nicht darin, was man zeichnet, sondern wie oft man es zeichnet. Intelligentes Management der Bildwiederholrate kann einen gewaltigen Leistungsschub bewirken.
Ein systematischer, datengestützter Optimierungsansatz löst nicht nur spezifische Probleme, sondern vermittelt auch ein tieferes Verständnis dafür, wie das Rendering-System in Android funktioniert. Dieses Wissen wird sich in zukünftigen Projekten auszahlen.