Menu
Android: Throttling przerysowań — jak wyeliminować jank dzięki Macrobenchmark i Perfetto

Android: Throttling przerysowań — jak wyeliminować jank dzięki Macrobenchmark i Perfetto

przez Nikolay Vlasov

Kiedy interfejs użytkownika zaczyna lagować, nie znajdziesz przyczyny na pierwszy rzut oka — potrzebujesz potężnych narzędzi. W tym artykule pokażemy, jak wykorzystać profesjonalny arsenał programisty Androida — Macrobenchmark i Perfetto — aby wytropić „jankowe klatki” (janky frames) w animacji widoku. Przejdziesz przez cały proces: від pomiaru problemu, przez zidentyfikowanie jego przyczyny, aż po zastosowanie ukierunkowanego rozwiązania — throttlingu klatek — w celu wyeliminowania widocznych zacięć.

Cześć! W moim poprzednim artykule zintegrowaliśmy LeakCanary, aby wyłapywać wycieki pamięci, używając ShimmerView jako obiektu testowego. Ten komponent świetnie spełnił swoje zadanie, ale na niektórych urządzeniach zacząłem zauważać zacięcia animacji — te same „jankowe klatki”, które psują wrażenia użytkownika.

Samo patrzenie na lagujący interfejs to nie nasza metoda. Jako inżynierowie musimy podchodzić do problemu systematycznie: zmierzyć, przeanalizować, znaleźć przyczynę, zastosować rozwiązanie, a następnie ponownie zmierzyć, aby ocenić kompromisy.

W tym artykule pokażę cały proces badania wydajności ShimmerView:

  1. Napisanie Macrobenchmarku, aby uzyskać obiektywne i powtarzalne sery wydajności.
  2. Analiza śladów (traces) w interfejsie Perfetto przy użyciu zapytań SQL w celu znalezienia wąskiego gardła.
  3. Sformułowanie i przetestowanie hipotezy dotyczącej przyczyny spadków FPS.
  4. Implementacja rozwiązania (throttling przerysowań) w oparciu o uzyskane dane.
  5. Weryfikacja wyniku i podsumowanie wniosków.

Rozdział 1: Pomiar problemu za pomocą Macrobenchmarku

Aby zrozumieć, jak źle wygląda sytuacja, potrzebujemy konkretnych liczb. Idealnym narzędziem do tego jest Android Macrobenchmark. Pozwala nam on uruchamiać scenariusze interfejsu użytkownika na rzeczywistych urządzeniach i zbierać metryki wydajności, takie jak FrameTimingMetric, która śledzi czas renderowania każdej klatki.

W idealnym świecie, aby uzyskać pełny obraz wydajności, testy powinny być uruchamiane na szerokiej gamie urządzeń: od modeli budżetowych po flagowce. Pomaga to zrozumieć, jak aplikacja zachowuje się przy różnych mocach procesora, rozmiarach pamięci RAM i rozdzielczościach ekranu.

Jednak do wstępnej oceny i zidentyfikowania najbardziej oczywistych problemów wystarczy użyć jednego reprezentatywnego urządzenia. W moim przypadku wybór padł na Huawei Honor 10i z systemem Android 9. Ten smartfon, wydany w 2019 roku, jest wyposażony w procesor Kirin 710 i 4 GB pamięci RAM, co dziś można uznać za skromną specyfikację. Jeśli animacja „laguje” na takim urządzeniu, problem prawdopodobnie będzie zauważalny również na potężniejszych modelach, choć w mniejszym stopniu. Zatem testowanie na celowo słabszym urządzeniu pozwala na skuteczną identyfikację wąskich gardeł wydajności.

Oto mój test dla podstawowej, nieoptymalizowanej wersji 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, // Symulacja uruchomienia z pamięci
            setupBlock = {
                // Przygotowanie: start Activity, przejście do odpowiedniego ekranu
                startActivityAndWait()
                navigateToScreen(stringResName, viewToWaitForId)
                ensureAnimationStopped()
            }
        ) {
            // Pomiar: start animacji i oczekiwanie
            startAnimation()
            Thread.sleep(35_000L) // ANIMATION_DURATION_MS
            stopAnimation()
        }
    }
    // ... reszta kodu testu
}

Dlaczego animacja trwa 35 sekund?

Wiele problemów z wydajnością ma charakter kumulatywny. Krótki test (1-2 sekundy) może nie ujawnić problemów związanych z throttlingiem procesora z powodu temperatury, naciskiem na Garbage Collector lub innymi efektami, które manifestują się z czasem. Długi pomiar daje nam pełniejszy i bardziej szczery obraz.

Po uruchomieniu shimmerAnimationBasic otrzymujemy następujące wyniki:

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

Analiza wyników:

  • P50 (mediana) na poziomie 12,2 ms wygląda dobrze. To mniej niż 16,6 ms wymagane dla 60 FPS.
  • Ale P90 (90% klatek renderuje się wolniej niż ta wartość), P95 i P99 to katastrofa. 29,2 ms na 99. percentylu oznacza, że w najgorszym momencie nasz FPS spada do ~34 (1000 / 29,2). To są właśnie te „janki”.

Teraz mamy nie tylko odczucie, ale konkretne liczby i ślad do głębokiej analizy.

Rozdział 2: Nurkowanie w ślad (trace) z Perfetto

Otwórzmy jeden ze śladów wygenerowanych przez benchmark w interfejsie Perfetto. Mamy przed sobą ogromną ilość danych. Aby nadać im sens, musimy zadawać właściwe pytania.

Nawigator dochodzenia: Wall Time vs. CPU Time

Pierwsze i najważniejsze pytanie brzmi: czy wątek jest zajęty pracą, czy na coś czeka?

  • Wall Time (czas zegarowy) to całkowity czas, jaki upłynął od początku do końca wykonywania funkcji. „Czas na zegarze ściennym”.
  • CPU Time (czas procesora) to czas, który procesor faktycznie spędził na wykonywaniu instrukcji tego wątku.

Jeśli Wall Time ≈ CPU Time, oznacza to, że procesor był w pełni obciążony obliczeniami. Problem leży w naszym kodzie — jest zbyt „ciężki”. Musimy znaleźć, które konkretne metody (onMeasure, onDraw, inflate) zajmują dużo czasu i je zoptymalizować.

Jeśli Wall Time >> CPU Time, oznacza to, że wątek przez większość czasu był bezczynny i czekał. Mógł czekać na odpowiedź z dysku (I/O), sieci, innego procesu (przez Bindera) lub, jak to często bywa w przypadku UI, z GPU.

Aby to sprawdzić, użyjemy zapytania SQL. Najpierw znajdźmy wszystkie „problematyczne” klatki (te, które trwały dłużej niż 16,6 ms), a następnie zobaczmy, co robił główny wątek w tym czasie.

-- Krok 1: Znajdź wszystkie „jankowe” klatki w wątku UI naszej aplikacji
WITH janky_frames AS (
  SELECT
    ts, -- timestamp rozpoczęcia
    dur -- czas trwania
  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 -- Czas trwania > 16,6 ms (w nanosekundach)
)
-- Krok 2: Agreguj wszystkie operacje na głównym wątku, które wystąpiły WEWNĄTRZ tych jankowych klatek
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 -- Ignoruj bardzo krótkie operacje
GROUP BY
  slice.name
ORDER BY
  total_wall_duration_ms DESC
LIMIT 25;

Wynik tego zapytania jest bardzo wymowny:

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

Patrząc na pierwszy wiersz: Choreographer#doFrame, podstawowa operacja renderowania klatki. Wall Time (619 ms) jest prawie 10 razy większy niż CPU Time (67 ms)!

Nasza hipoteza: Wątek UI nie jest zajęty pracą; on czeka. Biorąc pod uwagę, że ShimmerView jest czysto wizualnym komponentem, który jest stale przerysowywany, najprawdopodobniejszym winowajcą jest GPU. Wątek UI zbyt często wysyła polecenia rysowania i czeka, aż RenderThread i GPU sobie z nimi poradzą.

Rozdział 3: Weryfikacja hipotezy o obciążeniu GPU

Przetestujmy tę hipotezę. Napiszemy zapytanie, które sprawdzi, czym zajmował się RenderThread w momentach, gdy wątek UI doświadczał janku.

WITH janky_frames AS (
  -- (tak samo jak w poprzednim zapytaniu)
  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 (
  -- Znajdź RenderThread naszej aplikacji
  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
  -- Szukaj operacji, które NAKŁADAJĄ SIĘ w czasie z jankowymi klatkami
  slice.ts < janky_frames.ts + janky_frames.dur AND slice.ts + slice.dur > janky_frames.ts
WHERE
  -- Interesują nas tylko wycinki z 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;

Wyniki potwierdzają naszą teorię:

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

Widzimy tutaj pełen zestaw „ciężkich” operacji związanych z grafiką: DrawFrame, dequeueBuffer, eglSwapBuffers. Jest to bezpośredni dowód na to, że RenderThread aktywnie współpracuje z GPU, aby renderować nasze klatki.

Wniosek: Problemem nie jest złożoność samego ShimmerView (CPU Time był niski), ale częstotliwość jego przerysowań. Nasz ValueAnimator wywołuje postInvalidateOnAnimation() przy każdej aktualizacji wartości, zmuszając system renderowania do pracy do upadłego.

Rozdział 4: Rozwiązanie — adaptacyjny throttling przerysowań

Ponieważ wyeliminowanie pierwotnej przyczyny — narzutu każdego wywołania rysowania i konkurencji o potok GPU — jest trudne bez całkowitego przepisywania komponentu, zastosujemy pragmatyczne rozwiązanie: throttling częstotliwości przerysowań, aby potok renderowania nigdy nie był przeciążony. Jest to w gruncie rzeczy hack: zamiast sprawiać, by każda klatka była tańsza, po prostu prosimy o mniej klatek.

Zaimplementujemy mechanizm adaptacyjnego pomijania klatek (throttling). Logika jest następująca:

  1. Nadal używamy ValueAnimator, aby obliczyć pozycję gradientu.
  2. Ale nie zawsze wywołujemy postInvalidateOnAnimation(). Wywołujemy go tylko wtedy, gdy od ostatniego przerysowania upłynęło wystarczająco dużo czasu (np. > 16 ms).
  3. Aby być bardziej elastycznym, użyjemy wykładniczej średniej kroczącej (EMA), aby obliczyć rzeczywisty czas między klatkami. Jeśli system jest pod obciążeniem, a klatki renderują się powoli, adaptujemy się i przestajemy próbować „popychać” dodatkowe aktualizacje, co tylko pogorszyłoby sytuację.

Oto kluczowy fragment kodu throttlingu w ValueAnimator.addUpdateListener:

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

    val now = System.nanoTime()
    // Aktualizuj EMA, aby obliczyć średni czas klatki
    if (lastAnimatorUpdateNs != 0L) {
        val delta = (now - lastAnimatorUpdateNs).toDouble()
        emaFrameNs = emaFrameNs * (1.0 - emaAlpha) + delta * emaAlpha
    }
    lastAnimatorUpdateNs = now

    // Określ, jak długo czekać do następnej klatki
    // Albo nasza wartość adaptacyjna, albo docelowa (16,6 ms)
    val adaptiveFrameNs = if (useAdaptiveThrottling) {
        emaFrameNs.coerceAtLeast(targetFrameNs.toDouble()).toLong()
    } else {
        targetFrameNs
    }
    // Zastosuj twardy limit, aby uniknąć całkowitego zatrzymania
    val allowedFrameNs = adaptiveFrameNs.coerceAtLeast(minFrameNsHardLimit)

    // Wywołaj invalidate tylko wtedy, gdy upłynęło wystarczająco dużo czasu
    if (now - lastInvalidateNs >= allowedFrameNs) {
        lastInvalidateNs = now
        postInvalidateOnAnimation()
    }
}

Rozdział 5: Weryfikacja wyników

Z nowym kodem uruchamiamy benchmark shimmerAnimationOpt. Wyniki mówią same za siebie:

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

Porównajmy „przed” i „po”:

Metryka shimmerAnimationBasic (Przed) shimmerAnimationOpt (Po) Zmiana
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%

Dzięki throttlingowi przerysowań zmniejszyliśmy liczbę renderowanych klatek (frameCount) o jedną trzecią, co zmniejszyło nacisk na potok GPU. 99. percentyl czasu klatki spadł do 15,4 ms, mieszcząc się w budżecie 16,6 ms dla 60 FPS.

Ważne zastrzeżenie: to kompromis, a nie darmowa wygrana. Nie sprawiliśmy, że każda klatka renderuje się szybciej — po prostu przestaliśmy prosić o więcej klatek, niż potok był w stanie obsłużyć. Animacja wydaje się płynniejsza, ponieważ nie ma już skoków janku, ale technicznie renderujemy mniej unikalnych klatek na sekundę. Na tym urządzeniu różnica wizualna jest niezauważalna, ale ważne jest, aby zrozumieć: to jest rozwiązanie tymczasowe (workaround), a nie fundamentalna optymalizacja.

Wnioski

  1. Nie ufaj swoim odczuciom, mierz. Android Macrobenchmark to potężne narzędzie do uzyskiwania obiektywnych danych o wydajności interfejsu użytkownika.
  2. Wall Time vs CPU Time to Twój pierwszy krok w analizie śladów. To podejście od razu pokazuje, czy źródłem problemu jest „ciężki” kod, czy czasy oczekiwania.
  3. Perfetto SQL to supermoc. Możliwość tworzenia precyzyjnych zapytań do danych śledzenia pozwala na szybkie testowanie hipotez i unikanie zgadywania.
  4. Kiedy nie możesz przyspieszyć, zwolnij. Czasami problem z wydajnością nie dotyczy tego, co rysujesz, ale przeciążenia potoku renderowania nadmiernymi żądaniami rysowania. Throttling klatek to pragmatyczny hack — nie przyspiesza niczego, ale zapobiega zadławieniu się systemu. Znaj ich kompromisy i używaj go świadomie.

Systematyczne, oparte na danych podejście — nawet jeśli końcowym rezultatem jest hack, a nie czyste rozwiązanie — zapewnia głębsze zrozumienie tego, jak działa system renderowania w Androidzie. Wiedza o tym, dlaczego Twoje rozwiązanie działa, jest równie cenna jak samo rozwiązanie. Wiedza ta zaprocentuje w przyszłych projektach, w których być może znajdziesz sposób na bezpośrednie usunięcie pierwotnej przyczyny.