Podobnie jak wielu przede mną, tak i ja na początku swojej przygody z programowaniem skupiałem się przede wszystkim na tym jak apka wygląda oraz czy ma zaimplementowane jakieś bajeranckie funkcje / biblioteki. O czymś takim jak optymalizacja oczywiście słyszałem, ale w natłoku „pilniejszych” zadań zbyt długo przy zagadnieniu nie zabawiłem. Tak to trwało cały rok (w tym czasie nie programowałem jeszcze komercyjnie) i ten rozległy temat znajdował się na mojej liście „to-do”.

Otrzeźwienie przyszło pewnego zimowego dnia, kiedy jedna z moich hobbystycznych aplikacji (w tym wypadku na iOS’a) zaczęła krótko po uruchomieniu odmawiać całkowicie posłuszeństwa. Po restarcie wszytko wracało do normy, ale tylko na chwilę. To był dla mnie moment przełomowy 😭.

Okazało się, że przez jedną linijkę kodu, za każdym razem kiedy pojawiał się widok z przedmiotami, zamiast odświeżać kolekcję, dodawałem zupełnie nowy obiekt powyżej już istniejącego. Oczywiście winne było moje niedopatrzenie. W tamtej chwili uświadomiłem sobie jak łatwo można, przez jedną niewłaściwą decyzję, powalić aplikację na kolana.

Tak zaczęła się moja przygoda z szeroko pojętą optymalizacją, która na tę chwilę obok testów jednostkowych jest tym co w mobilnych aplikacjach najbardziej mnie pociąga. W tym wpisie poświęcę trochę czasu wyciekom pamięci (memory leaks) w Androidzie, które są jedną z przyczyn znaczących spadków wydajności aplikacji.

 

 

Jeżeli chcecie poczytać jak to wygląda w przypadku iOS to zapraszam tutaj – ARC i Strong Reference Cycle w Swift – Know-how programisty iOS

 

 

Czym jest Garbage Collector

 

 

Podczas wykonywania aplikacji napisanej w języku Java nieustannie towarzyszy nam proces o nazwie Garabge Collector, który odpowiedzialny jest za porządkowanie pamięci przydzielonej naszej aplikacji. Zwykle radzi on sobie bardzo dobrze, ale my jako programiści lubimy utrudniać mu życie i skutecznie blokować możliwość sprawnego funckjonowania 😎. O GC można pisać bez końca, ale w tym wpisie skupiać się będę tylko na podstawach.

 

 

 

Powyższa grafika przedstawia podstawowe założenia funkcjonowania GC. Każda aplikacja ma jakiś punkt startowy, w którym tworzone są podstawowe obiekty oraz wywoływane są podstawowe funkcje. Możemy nazwać to rootem (korzenieniem) naszej aplikacji. Jednocześnie jest to punkt startowy dla Garabge Collector.

Aplikacji w trakcie działania tworzy kolejne obiekty w miarę potrzeb. W ten sposób powstaje drzewo pamięci, które przechowuje hierarchię wzajemnych referencji obiektów w naszej aplikacji.

Część z tych obiektów utrzymuje bezpośrednie połączenie z rootem, a pozostałe połączone są z nim pośrednio (środkowa część obrazka powyżej). Kiedy przychodzi odpowiedni moment na czyszczenie pamięci (o tym za chwilę) Garbage Collector zaczyna swoją podróż od roota aplikacji przechodząc kolejno po wszystkich połączonych ze sobą obiektach. Te, które mają połączenie z rootem (bezpośrednie lub pośrednie) oznaczane są jako aktywne, a obiekty, których GC nie odwiedził traktowane są jako nieaktywne, czyli niepotrzebne śmieci (brutalna rzeczywistość 😜). Wszystko pięknie, wpis można zakończyć 😲.

 

 

Kiedy pojawiają się problemy

 

 

Zwykle GC radzi sobie bardzo dobrze, jednak z naszą pomocą jego zadanie może zostać znacznie utrudnione.

Każdy obiekt w aplikacji ma określony cykl życia i po jego zakończeniu powinien zostać usunięty. Tak się jednak nie stanie jeżeli inny element umieszczony w drzewie pamięci będzie w dalszym ciągu utrzymywał do niego referencję (odwołanie). To tak jakby wysyłał informację, że dalej potrzebuje tego konkretnego obiektu, choć tak na prawdę nie ma już z niego pożytku. To jest właśnie moment, w którym powstają wycieki pamięci. Obiekt uparcie siedzi na swoim miejscu i nie ma możliwości usunięcia go z pamięci.

Warto jednak zaznaczyć, że nie wszystkie wycieki są jednakowo szkodliwe – niektóre będą zajmowały tylko parę kilobajtów, a nawet w samych bibliotekach Androida znajdą się takie, które „przeciekają” i nie będziemy w stanie nic z tym zrobić (w przypadku iOS i bibliotek Appla jest podobnie). Nawet nie ma sensu próbować.

Możemy jednak doprowadzić do takich wycieków, które spowolnią naszą aplikację lub w skrajnym przypadku doprowadzą do jej zamknięcia. Ważne jest, jak długo dany wyciek będzie zajmował niepotrzebnie pamięć. W przypadku wątku roboczego (worker thread) wyciek pamięci będzie trwał do momenty ukończenia określonego zadania. Oczywiście, jeżeli dany wątek będzie działał w nieskończoność, to problem zniknie dopiero po zrestartowaniu aplikacji. Chyba, że nie wprowadzimy poprawek, to wtedy nawet restart telefonu nie pomoże 😛

Różnić się może także ilość obiektów powodujących wycieki. Można na przykład tworzyć i uruchamiać nowy wątek za każdym razem, gdy tworzona jest aktywność, nie zamykając go jednocześnie podczas usuwania aktywności z pamięci. Wystarczy wtedy, że przekręcimy ekran kilka razy i już mamy kilka powtarzających się wątków 😱.

W trakcie działania aplikacji stale zwiększany jest obszar zajmowanej przez nią pamięci typu stos (stack memory). Jeżeli GC nie będzie w stanie poradzić sobie z właściwym zwalnianiem jej zasobów, to dojdzie do sytuacji, w której aplikacja przestanie być responsywna lub zwyczajnie przestanie działać – wyrzuci nam OutOfMemoryError i będzie po zabawie. Co prawda aplikacja w pierwszej kolejności poprosi system o zwiększenie zasobów, jednak te zostaną jej w końcu ograniczone.

Trzeba również pamiętać, że GC jest procesem obciążającym w dużym stopniu zasoby urządzenia, dlatego lepiej ograniczać jego wykorzystanie. Wzrost zajmowanej prze aplikację pamięci spowoduje uruchomienie tzw. krótkiego (short) procesu GC, który spróbuje usunąć niepotrzebne obiekty. Działa on współbieżnie na osobnych wątkach, dlatego spowoduje wstrzymanie procesu jedynie na 2 do 5 milisekund (właściwe rysowanie jednej ramki na wyświetlaczu zajmuje 16 milisekund), tak więc nie będzie to zauważalne dla użytkownika i nie wpłynie negatywnie na jego odczucia.

Jeżeli problem będzie bardziej złożony to uruchomi się „cięższa” wersja GC (od 50 do 100 milisekund). W ten sposób aplikacja może, z punktu widzenia użytkownika, być niezdatna do użycia.

W celu monitorowania wycieków najlepiej skorzystać z Android Porfiler lub Leak Canary. Poświęcę im w przyszłości osobny wpis, dlatego tutaj nie będę się o nich rozpisywał. Warto jednak podkreślić, że narzędzia te powinny być stosowane na bieżąco, w trakcie tworzenia aplikacji, a nie tylko na zakończenie całego procesu. Dzięki temu łatwiej będzie nam zrozumieć przyczynę konkretnego wycieku.

 

 

Powody wycieków

 

Poniżej umieściłem kilka najczęstszych powodów wycieków pamięci w aplikacjach na Androida.

 

1. Wewnętrzne klasy

 

Wewnętrzne klasy są bardzo popularne w Androidzie i nawet w dokumentacji Googla możemy znaleźć liczne przykłady. Jednak niewłaściwe ich zastosowanie może doprowadzić do spadku wydajności naszej aplikacji. Klasyczny przykład z AsyncTask:

 

 

Na pierwszy rzut oka niby nic złego się nie dzieje, ale jak się lepiej zastanowić to okaże się, że nasza klasa wewnętrzna OurAsyncTask utrzymuje niejawną referencję do klasy ją otaczającej. Jeżeli czas wykonania zadania umieszczonego w obiekcie AsyncTask będzie dłuższy niż czas cyklu życia aktywności lub użytkownik w trakcie jego wykonania obróci ekran telefonu (co spowoduje utworzenie nowej aktywności), wtedy obiekt OurAsyncTask, który nadal znajduje się w drzewie pamięci nie pozwoli, aby Garbage Collector usunął pierwszą instancję aktywności.

W powyższym przykładzie aktywność nie jest zbyt skomplikowana, dlatego nie będzie zajmowała zbyt wiele miejsca, ale wyobraźcie sobie sytuację, w której aktywność przechowuje obiekty graficzne znacznych rozmiarów. Nie będę pisał, że rozwiązanie tego problemu jest banalne, bo dla osób, które dopiero poznają Garbage Collector nie będzie to takie oczywiste. Z czasem jednak wejdzie Wam to w nawyk 😎. A rozwiązanie wygląda tak:

 

 

Pierwszą rzeczą, na którą należy zwrócić uwagę jest fakt, że klasa OurAsyncTask została przerobiona na statyczną. Wewnętrzne klasy statyczne nie przetrzymują w sposób niejawnym silnych referencji (czyli takich, które powodują wycieki pamięci) do otaczających je klas. Teraz jednak nie mamy dostępu do pola messageFromNetwork, ponieważ nie jest ono statyczne. Nie ma możliwości odwoływania się do nie-statycznych pól ze statycznego kontekstu (czyli klasy OurAsyncTask) 😕😕😕.

Rozwiązaniem tego problemu jest przekazanie pola typu TextView w konstruktorze klasy OurAsyncTask, a następnie „opakowanie” go w słabą referencję (WeakRefernce). Opisywanie, czym dokładnie jest WeakRefernce wymaga osobnego artykułu (który pewnie niebawem napiszę), ale tutaj możecie znaleźć trochę więcej informacji. Na końcu naszego przykładu, w metodzie onDestory() należy dodać jeszcze linijkę kodu, który zakończy działanie obiektu AsyncTask.

Bardzo ważna rzecz – powyższy przykład stanowi jedynie próbę przedstawienia sytuacji, w której mogą powstać wycieki pamięci. Dużo lepszym rozwiązaniem będzie jednak unikanie wykorzystywania klasy AsyncTask na rzecz bardziej eleganckich rozwiązań (jak choćby Retrofit) do asynchronicznego pobierania danych z serwera (tutaj też jednak trzeba uważać, o czym z chwilę). AsyncTask jest zalecany tylko przy pracy z tutkami z dokumentacji Googla 😋.

 

 

 

 

Jeszcze jeden, podobny przykład z wyciekiem aktywności do wątku roboczego, ale tym razem z wykorzystaniem obiektu Thread. Aktywność może już dawno ulec zniszczeniu, ale wątek roboczy, który utrzymuje do niej referencję, nie zakończył jeszcze wykonywania swojego zadnia. Rozwiązaniem będzie mianowanie klasy MyThread na statyczną oraz zatrzymanie wątku w metodzie onDestroy() aktywności:

 

 

 

2. Klasy anonimowe

 

Również bardzo popularne w świecie Androida. Zachowują się one jednak dokładnie tak samo jak niestatyczne klasy wewnętrzne. Prosty przykład umieszczenia klasy anonimowej (najczęściej występuję one w formie jakiegoś callbacka) wewnątrz innej klasy:

 

 

Sytuacja identyczna jak w przypadku pierwszego przykładu z AsyncTask. Tak zdefiniowana klasa anonimowa będzie utrzymywała niejawną, silną referencję do klasy, w której została umieszczona. Jeżeli uzyskanie odpowiedzi z serwera będzie trwało zbyt długo, a aktywność w tym czasie ulegnie zniszczeniu, to będziemy mieli do czynienia z kolejnym wyciekiem. Oczywiście całkowite zrezygnowanie z klas anonimowych jest bardzo trudne (sam z nich cały czas korzystam). Warto jednak mieć na ten problem na uwadze, aby w większym stopniu panować nad zachowaniem naszej aplikacji.

 

3. Statyczna referencja

 

Zwróćcie uwagę, że tym razem nie chodzi o statyczne klasy wewnętrzne.

Statyczne odwołania do obiektów utrzymywane są przez cały cykl życia aplikacji. Jeżeli taka referencja będzie odnosiła się w jakikolwiek sposób do aktywności, to mamy gotowy przepis na wyciek. Poniżej dwa przykłady z pośrednim i bezpośrednim odwołaniem:

 

 

Powyższe przykłady są oczywiście mocno naciągane, ale ich zadaniem jest tylko wyraźnie ukazanie problemu. Poniżej jeszcze jeden fragment kodu, tym razem przedstawiający niewłaściwe zastosowanie Context’u:

 

 

 

O różnicach między activity context oraz application context możecie przeczytać w tym krótkim wpisie.

 

 

Słowo na DROGĘ

 

Jak sami widzicie wprowadzenie zmian, które pozytywnie wpłyną na wydajność aplikacji nie wymaga dużego wysiłku, a jedynie chwili zastanowienia. Nie oznacza to jednak, że wycieki pamięci są łatwe do opanowania. Tutaj potrzebna jest praktyka (odkrywcze 😴), jak również dogłębne zrozumienie podstaw języka, w którym piszemy naszą aplikację. Nie tylko w Javie się z tym spotkamy. Na podobny problem natkniemy się w niemal każdym języku obiektowym.

Wpis ten traktujcie jedynie jako drogowskaz do dalszych poszukiwań 😎.

 


 

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *