Wstęp

Widziałeś kiedyś ten złowrogi dialog? Ten z komunikatem „Aplikacja nie odpowiada — zaczekać czy zamknąć?”. To jest ANR — Application Not Responding. To jedna z najgorszych rzeczy, jakie mogą spotkać twoją aplikację na produkcji. Użytkownicy tego nienawidzą. Sklep Play to śledzi. ANR-y niszczą oceny aplikacji i jej pozycję w rankingach. Ale rzecz w tym, że ANR nie jest przypadkowy. To nie jest zagadka. To precyzyjna, mierzalna awaria o konkretnej przyczynie. System wysłał wiadomość do twojego main thread, a main thread nie przetworzył jej na czas. Nic więcej. Gdy zrozumiesz mechanikę — timery, progi, kolejkę wiadomości, o której mówiliśmy w poprzednim filmie — możesz dokładnie przewidzieć, kiedy wystąpi ANR, i za każdym razem mu zapobiec.

Jak naprawdę działają ANR-y

Opiszmy mechanizm bardzo precyzyjnie. System wysyła wiadomość do main thread twojej aplikacji — zdarzenie wejściowe, takie jak dotknięcie ekranu, intent broadcastu albo polecenie uruchomienia serwisu. W tym samym momencie uruchamia timer. Dla zdarzeń wejściowych timer wynosi 5 sekund. Dla broadcast receiverów — 10 sekund. Dla serwisów foreground jest to krótsze okno, które określa system. Jeśli main thread przetworzy wiadomość i zakończy obsługę, zanim timer wygaśnie, wszystko jest w porządku. Timer zostaje anulowany. Ale jeśli timer odpali, zanim main thread skończy przetwarzać wiadomość, system zgłasza ANR. Zbiera informacje diagnostyczne — w tym stack trace'y wszystkich wątków twojego procesu — zapisuje je do pliku, a potem pokazuje użytkownikowi dialog „nie odpowiada”. Najważniejsze jest to, że system nie obserwuje użycia CPU ani pamięci. Obserwuje, czy main thread przetwarza określone wiadomości w określonych oknach czasowych. To jedyne kryterium.

Zasada 5 sekund dla zdarzeń wejściowych

Najczęstszym wyzwalaczem ANR jest input dispatch timeout, czyli przekroczenie czasu na dostarczenie zdarzenia wejściowego. Gdy użytkownik dotyka ekranu, system wysyła to zdarzenie dotyku do twojej aplikacji. Jeśli minie 5 sekund, a aplikacja nie skończy obsługiwać dotyku, dostajesz ANR. W rzeczywistości jest to jednak bardziej złożone. System nie uruchamia timera w chwili, gdy aplikacja otrzymuje zdarzenie dotyku. Uruchamia go wtedy, gdy próbuje dostarczyć kolejne zdarzenie wejściowe i stwierdza, że poprzednie nie zostało potwierdzone. Jeśli więc użytkownik dotknie ekranu, a aplikacja utknie na przetwarzaniu czegoś, ANR nie wystąpi dokładnie 5 sekund po dotknięciu. Wystąpi wtedy, gdy system spróbuje dostarczyć następne zdarzenie — na przykład drugie dotknięcie albo nawet zdarzenie klawisza — i zorientuje się, że poprzednie wciąż czeka. Oznacza to, że do wykrycia ANR ze zdarzeń wejściowych potrzebne jest drugie zdarzenie wejściowe. Jedno dotknięcie zamrożonej aplikacji nie wystarczy — użytkownik musi wykonać kolejną interakcję, żeby system to zauważył.

Co powoduje ANR-y

Co więc sprawia, że main thread utyka na 5 sekund lub dłużej? Przejdźmy przez zwykłych podejrzanych. Numer jeden: wywołania sieciowe na main thread. Wysyłasz żądanie HTTP, serwer odpowiada wolno, a main thread blokuje się na 10 sekund w oczekiwaniu na odpowiedź. To tak częste, że Android ma do wykrywania takich sytuacji StrictMode. Numer dwa: ciężkie operacje na bazie danych na main thread. Zapytanie Room, które skanuje dużą tabelę. Zapytanie do SQLite bez indeksu. Nawet „szybkie” zapytania mogą być wolne, jeśli dysk jest obciążony przez inne procesy. Numer trzy: rywalizacja o blokady (lock contention). Main thread próbuje przejąć blokadę synchronized, ale trzyma ją wątek w tle, który wykonuje ciężką pracę. Main thread blokuje się w oczekiwaniu na blokadę. Ten przypadek jest podstępny, bo właściwa ciężka praca odbywa się w wątku w tle — ten wątek po prostu trzyma blokadę, której potrzebuje main thread. Numer cztery: zbyt wiele wiadomości w kolejce. Nawet jeśli każda pojedyncza wiadomość jest szybka, przy tysiącach zaległych wiadomości łączny czas przetwarzania może przekroczyć próg. Numer pięć: commit SharedPreferences na main thread. Metoda apply jest asynchroniczna, ale commit blokuje.

ANR-y w broadcast receiverach

Broadcast receivery mają własne reguły ANR. Gdy system dostarcza broadcast do twojej aplikacji, oczekuje, że obsłużysz go szybko. Jeśli metoda onReceive twojego BroadcastReceiver nie zakończy się w ciągu 10 sekund, system zgłasza ANR. 10 sekund brzmi hojnie, ale pamiętaj — onReceive działa na main thread. Jeśli main thread jest już zajęty inną pracą, broadcast czeka w kolejce. Zanim Looper do niego dotrze, mogło już minąć kilka sekund. Potem twój handler onReceive wykonuje jakąś pracę i zanim się zakończy, przekraczasz próg 10 sekund. Rozwiązanie: onReceive musi być maksymalnie lekki. Jeśli w odpowiedzi na broadcast musisz wykonać prawdziwą pracę, uruchom korutynę, zaplanuj zadanie WorkManager albo uruchom serwis foreground. Nie wykonuj tej pracy w onReceive.

ANR traces: kopalnia wiedzy diagnostycznej

Gdy wystąpi ANR, system zapisuje trace'y diagnostyczne do pliku na urządzeniu: data/anr/traces.txt. Ten plik to prawdziwa kopalnia złota. Zawiera stack trace każdego wątku twojego procesu z chwili wykrycia ANR. Najważniejszy jest wątek main — wątek numer jeden. Jego stack trace mówi dokładnie, co main thread robił, gdy utknął. Czy był w wywołaniu bazy danych? Zobaczysz na stosie metody SQLite. Czy czekał na odpowiedź z sieci? Zobaczysz metody odczytu z socketu. Czy był zablokowany na blokadzie? Zobaczysz „waiting to lock” z adresem monitora i wątkiem, który go trzyma. Możesz też sprawdzić, co robią inne wątki — może wątek w tle trzyma blokadę, na którą czeka main thread. Umiejętność czytania ANR traces odróżnia seniorów Androida od całej reszty. Stack trace nie kłamie — mówi dokładnie, co się stało.

Jak czytać ANR trace

Pokażę ci, jak go czytać. Otwierasz plik z trace'ami i szukasz sekcji wątku main. Znajdziesz tam coś w rodzaju "main" prio=5 tid=1, a potem stan. Stan jest kluczowy. Jeśli to „Blocked”, main thread czeka na przejęcie blokady monitora. Szukaj „waiting to lock” z adresem szesnastkowym i „held by thread” z ID wątku. Znajdź ten wątek i sprawdź, co robi — tam leży źródło problemu. Jeśli stan to „Waiting”, main thread może być w Object.wait i czekać na warunek, który nigdy nie nastąpi. Jeśli stan to „Runnable” albo „Native”, main thread aktywnie wykonuje pracę. Spójrz na stack trace, żeby sprawdzić jaką — czy to twój kod, który wykonuje obliczenia? Metoda frameworka, która wykonuje IO? Biblioteka natywna? Stos czyta się od dołu do góry — na dole jest miejsce, w którym zaczęło się wykonanie, a na górze to, w którym wykonanie jest teraz. Zacznij od góry i szukaj pierwszej ramki, która należy do twojego kodu albo do znanej ci biblioteki. Na niej się skup.

StrictMode: wczesne wykrywanie problemów

StrictMode to twój system wczesnego ostrzegania. To narzędzie deweloperskie, które w trakcie developmentu wykrywa typowe naruszenia na main thread, żebyś mógł je naprawić, zanim staną się ANR-ami na produkcji. Włączasz je w Application.onCreate za pomocą StrictMode.setThreadPolicy. Możesz skonfigurować wykrywanie odczytów z dysku, zapisów na dysk, operacji sieciowych i wolnych wywołań na main thread. Gdy StrictMode wykryje naruszenie, możesz je zalogować, mignąć ekranem, pokazać dialog albo wymusić crash aplikacji. W trakcie developmentu polecam crash — zmusza cię do natychmiastowej naprawy, zamiast ignorowania komunikatu w logu. StrictMode ma też VM policy, która wykrywa wycieki obiektów Closeable, nieoznaczone sockety (untagged sockets) i inne problemy z zasobami systemowymi. W trakcie developmentu włącz obie polityki. W buildach produkcyjnych je wyłącz — albo przynajmniej zmień karę na logowanie. StrictMode to w gruncie rzeczy kanarek w kopalni. Wyłapuje drobne naruszenia, zanim zamienią się w pięciosekundowe zawieszenia.

Korutyny cię nie uratują

Muszę wprost odnieść się do pewnego błędnego przekonania. Niektórzy developerzy myślą, że skoro używają korutyn, ANR-y im nie grożą. To nieprawda. Korutyny pomagają przenieść pracę poza main thread, ale nie zamieniają magicznie kodu blokującego w nieblokujący. Jeśli napiszesz withContext(Dispatchers.Main), a potem wywołasz metodę synchronized, która blokuje na 6 sekund, masz ANR. Korutyna jest zawieszona w sensie frameworka korutyn, ale wątek pod spodem — main thread — jest zablokowany. Dispatchers.Main oznacza „uruchom to na main thread”. Jeśli kod uruchomiony na main thread blokuje, main thread jest zablokowany. Kropka. Podobnie jeśli uruchomisz korutynę na Dispatchers.IO, ale użyjesz w niej Mutex, który próbuje przejąć również main thread, możesz doprowadzić do deadlocka, który skończy się ANR-em. Korutyny zmieniają sposób, w jaki wyrażasz współbieżność. Nie zmieniają podstawowych zasad działania wątków. Main thread to wciąż jednopasmowa droga i jeśli zaparkujesz na niej samochód, nic więcej nie przejedzie.

Monitorowanie ANR-ów na produkcji

W trakcie developmentu wyłapujesz ANR-y wzrokiem — aplikacja się zawiesza i to zauważasz. Na produkcji potrzebujesz jednak monitoringu. Play Console pokazuje wskaźnik ANR twojej aplikacji i oznacza ją, jeśli przekroczy progi — ANR w ponad 0,47 procent sesji to już złe zachowanie (bad behavior). Google Play Vitals pokazuje klastry ANR, w tym stack trace'y main thread pogrupowane według podobieństwa. Narzędzia do raportowania crashy, takie jak Firebase Crashlytics, również zbierają raporty ANR. Najbardziej proaktywne podejście polega jednak na monitorowaniu responsywności main thread w czasie rzeczywistym. Możesz wysłać wiadomość do main thread i zmierzyć, ile czasu zajmuje jej wykonanie. Jeśli regularnie trwa to ponad 100 milisekund, twoja kolejka jest zapchana. Niektóre SDK do monitorowania wydajności robią dokładnie to — cyklicznie wysyłają wiadomość-kanarka i alarmują, gdy czas odpowiedzi main thread przekroczy próg. Dzięki temu możesz wykryć i usunąć przeciążenie kolejki, zanim zamieni się w pełny ANR.

Jak zapobiegać ANR-om

Oto konkretna lista kontrolna. Jeden: nigdy nie wykonuj sieciowego IO na main thread. Zawsze używaj korutyny z Dispatchers.IO albo asynchronicznej biblioteki HTTP. Dwa: nigdy nie wykonuj dyskowego IO na main thread. Używaj DataStore zamiast SharedPreferences i pilnuj, żeby zapytania Room działały na dispatcherze w tle. Trzy: ograniczaj rywalizację o blokady. Jeśli musisz używać bloków synchronized, niech będą jak najmniejsze, i nigdy nie trzymaj blokady podczas wolnej pracy. Rozważ współbieżne struktury danych zamiast synchronizacji. Cztery: BroadcastReceiver.onReceive musi być szybki. Ciężką pracę deleguj do serwisu albo korutyny. Pięć: w trakcie developmentu używaj StrictMode, żeby wcześnie wyłapywać naruszenia. Sześć: regularnie profiluj. Używaj CPU profilera w Android Studio, żeby widzieć, co robi main thread na kluczowych ścieżkach użytkownika. Siedem: obserwuj dashboard Play Vitals. Jeśli wskaźnik ANR rośnie, natychmiast zbadaj przyczynę.

Podsumowanie i co dalej

Podsumujmy. ANR występuje, gdy system wysyła wiadomość do main thread, a main thread nie przetworzy jej przed upływem timeoutu — 5 sekund dla zdarzeń wejściowych, 10 sekund dla broadcastów. Typowe przyczyny to sieciowe lub dyskowe IO na main thread, rywalizacja o blokady i nadmierne zapchanie kolejki. System zapisuje ANR traces do data/anr/traces.txt i pokazuje w nich stack trace każdego wątku w chwili awarii. StrictMode wyłapuje naruszenia w trakcie developmentu. Korutyny pomagają przenieść kod poza main thread, ale nie chronią przed blokowaniem, jeśli używasz ich nieprawidłowo. W ostatnim filmie tej części porozmawiamy o czymś jeszcze bardziej zaskakującym niż ANR-y — o śmierci procesu (process death). Twoja aplikacja jest zabijana znacznie częściej, niż myślisz, a większość błędów w Androidzie bierze się z założenia, że tak nie jest. Do zobaczenia.