Menü
Android Animation Jank beheben mit Perfetto & Macrobenchmark

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:

  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 einer Optimierung 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: 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:

  1. Wir nutzen weiterhin einen ValueAnimator, um die Position des Gradients zu berechnen.
  2. 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).
  3. 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

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