Awarie

Aplikacja na Androida ulega awarii, gdy nastąpi nieoczekiwane zamknięcie spowodowane nieobsłużonym wyjątkiem lub sygnałem. Aplikacja napisana w języku Java lub Kotlin ulega awarii, jeśli zgłosi nieobsłużony wyjątek reprezentowany przez klasę Throwable. Aplikacja napisana w kodzie maszynowym lub C++ ulega awarii, jeśli podczas jej wykonywania wystąpi nieobsłużony sygnał, np. SIGSEGV.

Gdy aplikacja ulegnie awarii, Android kończy jej proces i wyświetla okno, w którym informuje użytkownika, że aplikacja przestała działać (rysunek 1).

awaria aplikacji na urządzeniu z Androidem,
Rysunek 1. Awaria aplikacji na urządzeniu z Androidem.

Aplikacja nie musi być uruchomiona na pierwszym planie, aby ulec awarii. Każdy komponent aplikacji, nawet komponenty takie jak odbiorniki transmisji czy dostawcy treści, które działają w tle, może spowodować awarię aplikacji. Te awarie często dezorientują użytkowników, ponieważ nie korzystali oni aktywnie z aplikacji.

Jeśli aplikacja ulega awariom, możesz skorzystać z informacji na tej stronie, aby zdiagnozować i rozwiązać problem.

Wykrywanie problemu

Nie zawsze możesz wiedzieć, że użytkownicy doświadczają awarii podczas korzystania z Twojej aplikacji. Jeśli aplikacja została już opublikowana, możesz użyć Android Vitals, aby sprawdzić częstotliwość awarii.

Android Vitals

Android Vitals może pomóc Ci monitorować i poprawiać częstotliwość awarii aplikacji. Android Vitals mierzy kilka wskaźników awaryjności:

  • Częstotliwość awarii: odsetek liczby aktywnych użytkowników dziennie, u których wystąpiła dowolnego typu awaria.
  • Częstotliwość awarii widocznych dla użytkowników: odsetek liczby aktywnych użytkowników dziennie, u których wystąpiła co najmniej 1 awaria widoczna dla użytkowników podczas aktywnego korzystania z aplikacji. Aplikacja jest uznawana za aktywnie używaną, jeśli wyświetla jakąkolwiek aktywność lub wykonuje usługę na pierwszym planie.

  • Częstotliwość wielokrotnych awarii: odsetek liczby aktywnych użytkowników dziennie, u których wystąpiły co najmniej 2 awarie.

Aktywny użytkownik dziennie to unikalny użytkownik, który korzysta z aplikacji w ciągu 1 dnia na 1 urządzeniu, potencjalnie w ramach wielu sesji. Jeśli użytkownik korzysta z aplikacji na więcej niż 1 urządzeniu w ciągu 1 dnia, każde z tych urządzeń wpłynie na liczbę aktywnych użytkowników w danym dniu. Jeśli wielu użytkowników korzysta z tego samego urządzenia w ciągu 1 dnia, jest to liczone jako 1 aktywny użytkownik.

Częstotliwość awarii widocznych dla użytkowników jest podstawowym wskaźnikiem, co oznacza, że wpływa na możliwość odkrycia Twojej aplikacji w Google Play. Jest to ważne, ponieważ zliczane awarie występują zawsze, gdy użytkownik korzysta z aplikacji, co powoduje najwięcej zakłóceń.

W przypadku tych danych Play określa 2 progi niewłaściwego działania:

  • Próg niewłaściwego działania na poziomie ogólnym: co najmniej 1, 09% liczby aktywnych użytkowników dziennie widzi awarię widoczną dla użytkowników na wszystkich modelach urządzeń.
  • Próg niewłaściwego działania na poziomie urządzenia: co najmniej 8% liczby aktywnych użytkowników dziennie widzi awarię widoczną dla użytkowników na 1 modelu urządzenia.

Jeśli aplikacja przekracza ogólny próg niewłaściwego działania, prawdopodobnie będzie trudniejsza do odkrycia na wszystkich urządzeniach. Jeśli aplikacja przekracza próg niewłaściwego działania na urządzeniu na niektórych urządzeniach, może być trudniej ją znaleźć na tych urządzeniach, a na jej stronie w Sklepie może wyświetlać się ostrzeżenie.

Android Vitals może wysyłać alerty w Konsoli Play za każdym razem, gdy w aplikacji wystąpi nadmierna liczba awarii.

Więcej informacji o tym, jak Google Play zbiera dane Android Vitals, znajdziesz w dokumentacji Konsoli Play.

Diagnozowanie awarii

Gdy stwierdzisz, że aplikacja zgłasza awarie, musisz je zdiagnozować. Rozwiązywanie problemów z awariami może być trudne. Jeśli jednak uda Ci się ustalić główną przyczynę awarii, najprawdopodobniej znajdziesz rozwiązanie tego problemu.

Awaria aplikacji może być spowodowana wieloma sytuacjami. Niektóre przyczyny są oczywiste, np. sprawdzanie wartości null lub pustego ciągu znaków, ale inne są bardziej subtelne, np. przekazywanie nieprawidłowych argumentów do interfejsu API lub nawet złożone interakcje wielowątkowe.

Awarie na Androidzie powodują utworzenie zrzutów stosu, czyli zapisu sekwencji funkcji zagnieżdżonych wywołanych w programie do momentu awarii. Zrzuty stosu awarii możesz wyświetlić w Android Vitals.

Jak odczytywać ślad stosu

Pierwszym krokiem w naprawie błędu jest ustalenie, gdzie on występuje. Jeśli korzystasz z Konsoli Play, możesz użyć śladu stosu dostępnego w szczegółach raportu. Możesz też użyć danych wyjściowych narzędzia logcat. Jeśli nie masz dostępnego śladu stosu, odtwórz lokalnie awarię, ręcznie testując aplikację lub kontaktując się z użytkownikami, których dotyczy problem, i odtwórz ją za pomocą logcat.

Poniższy ślad pokazuje przykład awarii aplikacji napisanej przy użyciu Jetpack Compose:

--------- beginning of crash
AndroidRuntime: FATAL EXCEPTION: main
Process: com.android.developer.crashsample, PID: 3686
java.lang.NullPointerException
    at com.android.developer.crashsample.ComposableSingletons$MainActivityKt.lambda$0(MainActivity.kt:27)
    at androidx.compose.foundation.ClickableNode.handleUpEvent(Clickable.kt:958)
    at androidx.compose.foundation.ClickableNode.onPointerEvent-H0pRuoY(Clickable.kt:895)
    at androidx.compose.ui.input.pointer.Node.dispatchMainEventPass(HitPathTracker.kt:446)
    at androidx.compose.ui.input.pointer.HitPathTracker.dispatchChanges(HitPathTracker.kt:181)
    at androidx.compose.ui.input.pointer.PointerInputEventProcessor.process-BIzXfog(PointerInputEventProcessor.kt:118)
    at androidx.compose.ui.platform.AndroidComposeView.dispatchTouchEvent(AndroidComposeView.android.kt:2650)
    at android.view.ViewGroup.dispatchTouchEvent(ViewGroup.java:2969)
    at android.app.Activity.dispatchTouchEvent(Activity.java:4683)
    at android.os.Looper.loop(Looper.java:398)
    at android.app.ActivityThread.main(ActivityThread.java:9569)
    at java.lang.reflect.Method.invoke(Native Method)
    at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:918)

Zrzut stosu zawiera 2 rodzaje informacji, które są kluczowe do debugowania awarii:

  • Typ zgłoszonego wyjątku.
  • Fragment kodu, w którym występuje wyjątek.

Rodzaj zgłoszonego wyjątku zwykle bardzo dobrze wskazuje, co poszło nie tak. Sprawdź, czy jest to IOException, OutOfMemoryError czy coś innego, i znajdź dokumentację klasy wyjątku.

Klasa, metoda, plik i numer wiersza pliku źródłowego, w którym wystąpił wyjątek, są wyświetlane w drugim wierszu śladu stosu. Dla każdej wywołanej funkcji kolejna linia pokazuje poprzednie miejsce wywołania (nazywane ramką stosu).

Przechodząc w górę stosu i sprawdzając kod, możesz znaleźć miejsce, w którym przekazywana jest nieprawidłowa wartość. Jeśli Twój kod nie pojawia się w zrzucie stosu, prawdopodobnie w jakimś miejscu przekazano nieprawidłowy parametr do operacji asynchronicznej. Często możesz się dowiedzieć, co się stało, analizując każdy wiersz śladu stosu, znajdując użyte klasy interfejsu API i sprawdzając, czy przekazane parametry były prawidłowe i czy wywołanie nastąpiło z dozwolonego miejsca.

Ślady stosu w przypadku aplikacji z kodem w językach C i C++ działają podobnie.

*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
Build fingerprint: 'google/foo/bar:10/123.456/78910:user/release-keys'
ABI: 'arm64'
Timestamp: 2020-02-16 11:16:31+0100
pid: 8288, tid: 8288, name: com.example.testapp  >>> com.example.testapp <<<
uid: 1010332
signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0
Cause: null pointer dereference
    x0  0000007da81396c0  x1  0000007fc91522d4  x2  0000000000000001  x3  000000000000206e
    x4  0000007da8087000  x5  0000007fc9152310  x6  0000007d209c6c68  x7  0000007da8087000
    x8  0000000000000000  x9  0000007cba01b660  x10 0000000000430000  x11 0000007d80000000
    x12 0000000000000060  x13 0000000023fafc10  x14 0000000000000006  x15 ffffffffffffffff
    x16 0000007cba01b618  x17 0000007da44c88c0  x18 0000007da943c000  x19 0000007da8087000
    x20 0000000000000000  x21 0000007da8087000  x22 0000007fc9152540  x23 0000007d17982d6b
    x24 0000000000000004  x25 0000007da823c020  x26 0000007da80870b0  x27 0000000000000001
    x28 0000007fc91522d0  x29 0000007fc91522a0
    sp  0000007fc9152290  lr  0000007d22d4e354  pc  0000007cba01b640

backtrace:
  #00  pc 0000000000042f89  /data/app/com.example.testapp/lib/arm64/libexample.so (com::example::Crasher::crash() const)
  #01  pc 0000000000000640  /data/app/com.example.testapp/lib/arm64/libexample.so (com::example::runCrashThread())
  #02  pc 0000000000065a3b  /system/lib/libc.so (__pthread_start(void*))
  #03  pc 000000000001e4fd  /system/lib/libc.so (__start_thread)

Jeśli w natywnych śladach stosu nie widzisz informacji na poziomie klasy i funkcji, może być konieczne wygenerowanie natywnego pliku symboli debugowania i przesłanie go do Konsoli Google Play. Więcej informacji znajdziesz w artykule Usuwanie zaciemnienia kodu ze zrzutów stosu w przypadku awarii. Ogólne informacje o awariach natywnych znajdziesz w artykule Diagnozowanie awarii natywnych.

Wskazówki dotyczące odtwarzania awarii

Może się zdarzyć, że nie uda Ci się odtworzyć problemu, uruchamiając emulator lub podłączając urządzenie do komputera. Środowiska deweloperskie zwykle mają więcej zasobów, takich jak przepustowość, pamięć i miejsce na dane. Użyj typu wyjątku, aby określić, który zasób może być rzadki, lub znajdź korelację między wersją Androida, typem urządzenia lub wersją aplikacji.

Błędy pamięci

Jeśli masz OutOfMemoryError, możesz utworzyć emulator o małej pojemności pamięci, aby przeprowadzić testy. Ilustracja 2 przedstawia ustawienia menedżera AVD, w których możesz kontrolować ilość pamięci na urządzeniu.

Ustawienie pamięci w Menedżerze AVD
Rysunek 2. Ustawienie pamięci w menedżerze AVD.

Wyjątki dotyczące sieci

Użytkownicy często wchodzą w zasięg sieci komórkowej lub sieci Wi-Fi i go opuszczają, dlatego w aplikacji wyjątki sieciowe zwykle nie powinny być traktowane jako błędy, ale raczej jako normalne warunki działania, które występują nieoczekiwanie.

Jeśli chcesz odtworzyć wyjątek sieciowy, np. UnknownHostException, włącz tryb samolotowy, gdy aplikacja próbuje użyć sieci.

Możesz też obniżyć jakość sieci w emulatorze, wybierając emulację szybkości sieci, opóźnienie sieci lub obie te opcje. Możesz użyć ustawień SzybkośćOpóźnienie w Menedżerze AVD lub uruchomić emulator z flagami -netdelay-netspeed, jak pokazano w tym przykładzie wiersza poleceń:

emulator -avd [your-avd-image] -netdelay 20000 -netspeed gsm

W tym przykładzie ustawiono opóźnienie 20 sekund dla wszystkich żądań sieciowych oraz szybkość przesyłania i pobierania na poziomie 14,4 kb/s. Więcej informacji o opcjach wiersza poleceń emulatora znajdziesz w artykule Uruchamianie emulatora z wiersza poleceń.

Odczytywanie za pomocą narzędzia Logcat

Gdy uda Ci się odtworzyć awarię, możesz użyć narzędzia takiego jak logcat, aby uzyskać więcej informacji.

W danych wyjściowych logcat zobaczysz inne wydrukowane komunikaty logu, a także komunikaty z systemu. Nie zapomnij wyłączyć dodatkowych instrukcji Log, które zostały dodane, ponieważ ich drukowanie marnuje zasoby procesora i baterii podczas działania aplikacji.

Zapobieganie awariom spowodowanym przez wyjątki wskaźnika zerowego

Wyjątki wskaźnika zerowego (identyfikowane przez typ błędu środowiska wykonawczego NullPointerException) występują, gdy próbujesz uzyskać dostęp do obiektu, który ma wartość null, zwykle przez wywołanie jego metod lub uzyskanie dostępu do jego elementów. Wyjątki wskaźnika zerowego są najczęstszą przyczyną awarii aplikacji w Google Play. Wartość null oznacza, że obiektu brakuje, np. nie został jeszcze utworzony ani przypisany.

Aby uniknąć wyjątków wskaźnika o wartości null, przed wywołaniem metod na odwołaniach do obiektów lub próbą uzyskania dostępu do ich elementów musisz się upewnić, że odwołania do obiektów, z którymi pracujesz, nie mają wartości null. Jeśli odwołanie do obiektu ma wartość null, odpowiednio obsłuż ten przypadek (np. wyjdź z metody przed wykonaniem jakichkolwiek operacji na odwołaniu do obiektu i zapisz informacje w dzienniku debugowania).

Nie chcesz sprawdzać, czy każdy parametr każdej wywoływanej metody ma wartość null, więc możesz polegać na IDE lub typie obiektu, aby wskazać dopuszczalność wartości null.

Kotlin

W Kotlinie dopuszczalność wartości null jest częścią systemu typów. Na przykład zmienna musi być od początku zadeklarowana jako dopuszczająca wartość null lub nie. Typy dopuszczające wartość null są oznaczone symbolem ?:

// non-null
var s: String = "Hello"

// null
var s: String? = "Hello"

Zmiennym, które nie dopuszczają wartości null, nie można przypisać wartości null, a zmienne dopuszczające wartość null muszą być sprawdzane pod kątem dopuszczalności wartości null, zanim zostaną użyte jako zmienne, które nie dopuszczają wartości null.

Jeśli nie chcesz sprawdzać wartości null w sposób jawny, możesz użyć operatora ?. bezpiecznego wywołania:

val length: Int? = string?.length  // length is a nullable int
                                   // if string is null, then length is null

Sprawdzoną metodą jest obsługa przypadku wartości null dla obiektu dopuszczającego wartość null, ponieważ w przeciwnym razie aplikacja może przejść w nieoczekiwany stan. Jeśli aplikacja nie będzie już ulegać awarii z powodu NullPointerException, nie będziesz wiedzieć o istnieniu tych błędów.

Oto kilka sposobów sprawdzenia, czy wartość jest pusta:

  • if kontroli

    val length = if(string != null) string.length else 0
    

    Dzięki inteligentnemu rzutowaniu i sprawdzaniu wartości null kompilator Kotlin wie, że wartość ciągu znaków nie jest wartością null, więc pozwala na bezpośrednie użycie odwołania bez konieczności używania operatora bezpiecznego wywołania.

  • ?: Operator Elvis

    Ten operator umożliwia określenie, że „jeśli obiekt nie jest wartością null, zwróć obiekt; w przeciwnym razie zwróć coś innego”.

    val length = string?.length ?: 0
    

W języku Kotlin nadal możesz uzyskać NullPointerException. Oto najczęstsze sytuacje:

  • Gdy wyraźnie rzucasz NullPointerException.
  • Gdy używasz operatora asercji wartości null!!. Ten operator przekształca dowolną wartość w typ niepusty, zgłaszając wyjątek NullPointerException, jeśli wartość jest pusta.
  • Podczas uzyskiwania dostępu do odwołania o wartości null typu platformy.

Typy platform

Typy platform to deklaracje obiektów pochodzące z Javy. Te typy są traktowane w specjalny sposób. Sprawdzanie wartości null nie jest tak rygorystyczne, więc gwarancja wartości niepustej jest taka sama jak w języku Java. Gdy uzyskujesz dostęp do odwołania do typu platformy, Kotlin nie generuje błędów kompilacji, ale te odwołania mogą prowadzić do błędów w czasie działania. Oto przykład z dokumentacji języka Kotlin:

val list = ArrayList<String>() // non-null (constructor result) list.add("Item")
val size = list.size // non-null (primitive int) val item = list[0] // platform
type inferred (ordinary Java object) item.substring(1) // allowed, may throw an
                                                       // exception if item == null

Kotlin korzysta z wnioskowania o typie, gdy wartość platformy jest przypisywana do zmiennej w Kotlinie. Możesz też określić, jakiego typu oczekujesz. Najlepszym sposobem na zapewnienie prawidłowego stanu dopuszczalności wartości null w przypadku odwołania pochodzącego z Javy jest użycie w kodzie Java adnotacji dotyczących dopuszczalności wartości null (np. @Nullable). Komplilator Kotlin będzie reprezentować te odwołania jako rzeczywiste typy dopuszczające lub niedopuszczające wartości null, a nie jako typy platformy.

Interfejsy Java Jetpack API zostały w razie potrzeby oznaczone symbolami @Nullable lub @NonNull. Podobne podejście zastosowano w pakiecie SDK do Androida 11. Typy pochodzące z tego pakietu SDK, które są używane w języku Kotlin, będą reprezentowane jako prawidłowe typy dopuszczające lub niedopuszczające wartości null.

System typów w Kotlinie znacznie zmniejsza liczbę awarii NullPointerException. Na przykład w aplikacji Google Home liczba awarii spowodowanych przez wyjątki wskaźnika null zmniejszyła się o 30% w roku, w którym przeniesiono tworzenie nowych funkcji do języka Kotlin.