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:
- Napisanie Macrobenchmarku, aby uzyskać obiektywne i powtarzalne sery wydajności.
- Analiza śladów (traces) w interfejsie Perfetto przy użyciu zapytań SQL w celu znalezienia wąskiego gardła.
- Sformułowanie i przetestowanie hipotezy dotyczącej przyczyny spadków FPS.
- Implementacja rozwiązania (throttling przerysowań) w oparciu o uzyskane dane.
- 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ść),P95iP99to 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:
- Nadal używamy
ValueAnimator, aby obliczyć pozycję gradientu. - 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). - 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
- Nie ufaj swoim odczuciom, mierz. Android Macrobenchmark to potężne narzędzie do uzyskiwania obiektywnych danych o wydajności interfejsu użytkownika.
- 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.
- Perfetto SQL to supermoc. Możliwość tworzenia precyzyjnych zapytań do danych śledzenia pozwala na szybkie testowanie hipotez i unikanie zgadywania.
- 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.