Menü
Android Animation Jank bändigen: Redraw-Throttling mit Perfetto & Macrobenchmark

Android Animation Jank bändigen: Redraw-Throttling mit Perfetto & Macrobenchmark

von 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 über die Ursachenanalyse bis hin zur Anwendung eines gezielten Workarounds – Frame-Throttling – um sichtbare Ruckler zu eliminieren.

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, einen Workaround anwenden und anschließend erneut messen, um die Kompromisse zu bewerten.

In diesem Artikel zeige ich den gesamten Prozess der Leistungsuntersuchung von ShimmerView:

  1. Schreiben eines Macrobenchmarks, um objektive und reproduzierbare Performance-Metriken zu erhalten.
  2. Analyse von Traces in der Perfetto-UI mittels SQL-Abfragen, um den Flaschenhals zu finden.
  3. Formulierung und Testen einer Hypothese über die Ursache der FPS-Einbrüche.
  4. Implementierung eines Workarounds (Redraw-Throttling) basierend auf den gewonnenen Daten.
  5. 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), P95 und P99 sind 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: Der Workaround – Adaptives Redraw-Throttling

Da die Grundursache – der Overhead jedes Draw-Calls und die Konkurrenz um den GPU-Pipeline – schwer zu beseitigen ist, ohne die Komponente grundlegend umzuschreiben, wenden wir einen pragmatischen Workaround an: Throttling der Redraw-Frequenz, damit die Rendering-Pipeline nicht überlastet wird. Das ist im Wesentlichen ein Hack: Wir machen jeden Frame nicht günstiger, sondern fordern einfach weniger Frames an.

Wir implementieren einen adaptiven Frame-Skipping-Mechanismus (Throttling). Die Logik ist wie folgt:

  1. Wir nutzen weiterhin einen ValueAnimator, um die Position des Gradients zu berechnen.
  2. Aber wir rufen postInvalidateOnAnimation() nicht mehr bedingungslos auf. Wir rufen es nur auf, wenn seit der letzten Zeichnung genügend Zeit vergangen ist (z. B. > 16 ms).
  3. Um uns an die Last anzupassen, 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, hören wir auf, zusätzliche Updates "durchzudrücken" – das würde die Situation nur verschlimmern.

Hier ist das entscheidende Fragment des Throttling-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 %

Durch das Throttling der Redraws haben wir die Anzahl der gerenderten Frames (frameCount) um ein Drittel reduziert, was den Druck auf die GPU-Pipeline verringerte. Das 99. Perzentil der Frame-Zeit fiel auf 15,4 ms und liegt damit im Budget von 16,6 ms für 60 FPS.

Wichtiger Vorbehalt: Das ist ein Kompromiss, kein Gratisgewinn. Wir haben das Rendern jedes einzelnen Frames nicht schneller gemacht – wir haben lediglich aufgehört, mehr Frames anzufordern, als die Pipeline verarbeiten kann. Die Animation wirkt flüssiger, weil die Jank-Spitzen verschwunden sind, aber technisch gesehen rendern wir weniger einzigartige Frames pro Sekunde. Auf diesem Gerät ist der visuelle Unterschied kaum wahrnehmbar, aber es ist entscheidend zu verstehen: Das ist ein Workaround, keine fundamentale Optimierung.

Fazit

  1. Vertrauen Sie nicht Ihrem Gefühl, messen Sie. Android Macrobenchmark ist ein leistungsstarkes Werkzeug, um objektive Daten zur UI-Performance zu erhalten.
  2. 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.
  3. 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.
  4. Wenn man nicht beschleunigen kann, drosselt man. Manchmal liegt ein Performance-Problem nicht darin, was man zeichnet, sondern darin, dass man die Rendering-Pipeline mit übermäßigen Draw-Anfragen überlastet. Frame-Throttling ist ein pragmatischer Hack – er macht nichts schneller, verhindert aber, dass das System ins Stocken gerät. Kennen Sie die Kompromisse und setzen Sie ihn bewusst ein.

Ein systematischer, datengestützter Ansatz – auch wenn das Ergebnis ein Hack statt einer sauberen Lösung ist – vermittelt ein tieferes Verständnis dafür, wie das Rendering-System in Android funktioniert. Zu verstehen, warum der Workaround funktioniert, ist genauso wertvoll wie der Workaround selbst. Dieses Wissen wird sich in zukünftigen Projekten auszahlen, wo Sie möglicherweise einen Weg finden, die Grundursache direkt zu beheben.