Zacina się — to opis sytuacji przez użytkownika, gdy aplikacja mobilna działa wolno i niestabilnie: to reaguje normalnie, to nagle zawiesza się na kilka sekund. W kontekście technicznym „zacina się” oznacza kombinację lagów i mikro-zawieszeń spowodowaną częstymi pauzami GC, blokowaniem głównego wątku przez operacje synchroniczne i nieoptymalnymi strukturami danych. Według Android Performance Benchmarking Guide, zmniejszenie czasu odpowiedzi z 300 ms do 100 ms zwiększa utrzymanie użytkowników o 25%. Diagnostyka zacięć wymaga połączenia profilowania CPU i Memory z analizą częstotliwości zbierania śmieci.
Najważniejsze
Zacina się — nieformalny termin, którym użytkownicy opisują subiektywnie wolne działanie aplikacji. W przeciwieństwie do laga, który objawia się jako stałe opóźnienie, zacięcie to nieregularne zawieszanie: aplikacja może działać idealnie przez kilka sekund, a następnie „zamyślić się” na 1–3 sekund.
Z punktu widzenia profilowania, zacina się objawia się jako seria pominiętych klatek (jank) z szczytowymi opóźnieniami powyżej 100 ms. Na wykresie FPS wygląda to jak gwałtowne spadki: 60 → 20 → 55 → 10 klatek na sekundę. W przeciwieństwie do laga z równomiernie niskim FPS, zacięcie ma wyraźną zmienność.
Gdy aplikacja się zacina, użytkownik nie rozumie logiki spowolnień: ekran może przewijać się płynnie, a następnie nagle zatrzymać się na sekundę. Powoduje to frustrację i obniża zaufanie do aplikacji. Według Google, 53% użytkowników opuszcza stronę lub aplikację, jeśli ładowanie trwa dłużej niż 3 sekundy.
Nieregularny charakter zacięć wskazuje, że problem jest spowodowany czynnikami zdarzeniowymi, a nie stałym przeciążeniem. Rozważmy typowe scenariusze.
Na Android w środowisku ART zbieranie śmieci zatrzymuje wszystkie wątki aplikacji. Jeśli w kodzie tworzonych jest wiele tymczasowych obiektów — na przykład przy każdym wywołaniu onBindViewHolder tworzony jest nowy String przez konkatenację — GC uruchamia się częściej. Pauza może trwać 5–50 ms w zależności od rozmiaru sterty i pokolenia obiektów. Użytkownik odczuwa to jako nagłe „zamyślenie”.
Room na Android i Core Data na iOS obsługują zapytania asynchroniczne, ale programiści często wywołują getValue() lub wykonują zapytanie przez runBlocking dla uproszczenia. Ciężki SELECT z joinami na tabeli z 10 000 wierszy może zająć 200–500 ms, całkowicie blokując UI na ten czas.
Załadowanie obrazu z aparatu (12 Mp, 4000x3000 px) bez skalowania zajmuje do 200 ms na dekodowanie do Bitmap. Jeśli obrazy są ładowane asynchronicznie, ale bez puli wątków z ograniczeniem, jednoczesne uruchomienie 5–6 dekodowań może przeciążyć CPU, powodując migrujące spowolnienia.
Diagnostyka nieregularnych spowolnień jest trudniejsza niż diagnostyka stałych lagów, ponieważ problem może nie występować w każdym uruchomieniu. Wymagane jest zbieranie statystyk przez dłuższy okres.
Android Studio Memory Profiler pokazuje nie tylko użycie pamięci, ale także zdarzenia GC: częstotliwość, typ (Concurrent, Full), czas trwania. Jeśli GC występuje częściej niż 1 raz na 5 sekund w stanie spokojnym — to oznaka nadmiernej alokacji. Zapis heap dump w momencie zacięcia pozwala zobaczyć, które obiekty zajmują pamięć.
Na iOS użyj szablonu Allocations w Instruments do śledzenia tworzenia i zwalniania obiektów. Włącz generacje (Generations) — pozwalają one na robienie zrzutów sterty między akcjami i widzenie, które obiekty pozostają w pamięci. Persistent objects, które nie są zwalniane — źródło gromadzenia pamięci i późniejszych pauz.
JankStats — biblioteka Androida, która zbiera metryki pominiętych klatek w czasie rzeczywistym. Wiąże każdy jank z bieżącym scenariuszem (np. „przewijanie listy”, „otwieranie ekranu”), co pozwala zrozumieć, przy której konkretnie czynności występuje zacięcie.
Przykład integracji JankStats do śledzenia zawieszeń na Android:
class MainActivity : AppCompatActivity() {
private lateinit var jankStats: JankStats
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
jankStats = JankStats.create(this.window.decorView) { frameData ->
if (frameData.isJank()) {
Log.w("Zacinanie", "Duration=${frameData.durationMs}ms")
}
}
}
}
Eliminacja zacięć wymaga celowej pracy z każdą przyczyną. Nie ma uniwersalnego rozwiązania — potrzebna jest analiza konkretnych profili wydajności.
Jeśli lista zawiera 1000+ elementów i wszystkie są ładowane od razu — to gwarantowane zacięcie. Paging 3 na Android i NSFetchedResultsController na iOS ładują dane porcjami podczas przewijania. Użytkownik widzi tylko pierwsze 10–20 elementów, reszta jest ładowana w tle.
Room pozwala profilować zapytania przez Inspection Tool w Android Studio: widać czas wykonania, liczbę zwróconych wierszy i plan zapytania. Dodanie indeksów na kolumny WHERE i ORDER BY może skrócić czas zapytania z 300 ms do 5 ms. Na iOS analogiczne sprawdzenie wykonuje Core Data Profiler w Instruments.
Synchronizacje w tle, ładowanie plików, przetwarzanie danych — wszystko to powinno być wykonywane przez WorkManager (Android) lub Background Tasks (iOS). Jeśli synchronizacja jest uruchamiana w wątku UI, aplikacja będzie się zacinać na czas wykonania. WorkManager gwarantuje wykonanie w wątku tła z uwzględnieniem stanu baterii i sieci.
Przykład synchronizacji w tle przez WorkManager na Android:
class SyncWorker(context: Context, params: WorkerParameters)
: CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
return try {
Log.d("Sync", "Synchronizacja danych w wątku tła")
syncData()
Result.success()
} catch (e: Exception) {
Result.retry()
}
}
}
Zapobiec zacięciom można na etapie pisania kodu, przestrzegając zasad efektywnej pracy z pamięcią i wątkami.
Baseline Profiles — to lista klas i metod, które Android kompiluje z góry (AOT), a nie JIT. Bez profilu każdy nowy ekran jest kompilowany przy pierwszym otwarciu, powodując opóźnienie 100–500 ms. Przygotuj Baseline Profile dla kluczowych ekranów i włącz generację w Gradle przez baseline-profile-gradle-plugin.
Hot path — to kod, który wykonuje się przy każdej klatce: onBindViewHolder, draw, layoutSubviews. Unikaj tworzenia obiektów w tych metodach: używaj puli obiektów, StringBuilder zamiast konkatenacji, cache'uj sformatowane stringi i formaty. Każda dodatkowa alokacja przybliża następny GC.
Dodaj do pipeline'u CI uruchomienie Macrobenchmark ze scenariuszem przewijania listy i otwierania ekranu. Ustaw próg: 99. percentyl czasu klatki nie powinien przekraczać 16 ms. Jeśli próg jest przekroczony — build jest odrzucany do czasu optymalizacji.
Często zadawane pytania
Lag — to stałe opóźnienie (np. 200 ms na każde naciśnięcie). Zacina się — nieregularnie: aplikacja działa normalnie, następnie nagle zwalnia na 1–3 sekundy, potem znów normalnie. Przyczyna — czynniki zdarzeniowe, takie jak pauzy GC lub synchroniczne zapytania do bazy danych.
Użyj Memory Profiler w Android Studio: zakładka Memory pokazuje zdarzenia GC z czasem trwania. Do monitorowania produkcyjnego podłącz Firebase Performance Monitoring z niestandardowymi trasami. Na iOS włącz Malloc Debug i oznacz pokolenia alokacji w Instruments.
Pośrednio — tak. Jeśli odpowiedź serwera przychodzi z opóźnieniem, a UI oczekuje na nią synchronicznie, aplikacja się zawiesza. Jeśli zapytanie jest asynchroniczne, ale przetwarzanie odpowiedzi odbywa się w wątku UI — to również spowoduje zacięcie. Rozwiązanie — asynchroniczne przetwarzanie z korutynami i wskaźnikami postępu.
Przy nieprawidłowym użyciu KMP może generować nadmiarowe obiekty-opakowania dla interoperacyjności. Na iOS zwiększa to częstotliwość alokacji, a w konsekwencji pauzy ARC. Używaj @ObjCName, optymalizuj expect/actual i unikaj częstych wywołań kodu współdzielonego z gorących ścieżek UI.
Zwiększenie sterty przez android:largeHeap="true" opóźnia GC, ale nie eliminuje przyczyny alokacji. Kiedy GC w końcu się uruchomi, pauza będzie dłuższa, ponieważ trzeba przejść przez więcej obiektów. Rozwiązanie — zmniejszyć liczbę alokacji, a nie rozszerzać stertę.
Podsumowanie
Opracujemy aplikację mobilną pod klucz
IT Sectr tworzy aplikacje na iOS i Androida dla startupów i firm od 2017 roku. Doradzimy Ci i zaproponujemy najlepsze rozwiązanie.
Przeczytaj również