Wątki oraz sposób w jaki są one zarządzane w Androidzie to kolejny złożony temat (przynajmniej dla mnie), z którym postanowiłem się ostatnio zmierzyć. W skutek czego powstał właśnie ten wpis. Wykorzystanie wielowątkowości w aplikacjach nie jest mi zupełnie obce ponieważ już od jakiegoś czasu piszę aplikacje na iOS, w którym zagadnienie to też jest oczywiście obecne. Chociaż zasady są podobne, to w przypadku Androida zastosowano trochę inne podejście. Poszperałem po internecie i udało mi się to wszystko jakoś lepiej ogarnąć 🤩.

Każdy programista szybciej niż później zacznie budować aplikację na tyle złożoną, że będzie musiała wykonywać określone operacja na wątku innym niż główny. Bardzo prawdopodobne, że któreś z zadań wykonywanych właśnie na wątku głównym przekroczy magiczne 5 sekund, co będzie oznaczało, że na ekranie użytkownika pojawi się alert informujący o braku reakcji ze strony aplikacji. Zjawisko to nosi nazwę Aplication Not Responding (ANR) i w dużym skrócie oznacza, że nasza aplikacja ma poważne problemy z wydajnością. Prawdopodobnie w wątku głównym umieściliśmy zadanie, które wymaga długiego czasu wykonania, a do momentu zakończenia będzie blokowało pozostałe zadania czekające w kolejce.

 

 

Można uniknąć tego przykrego doświadczenia przenosząc zadania wymagające czasu i odpowiednich zasobów poza wątek główny. Przykładem takiego zadania będzie na przykład złożone zapytanie wysłane do lokalnej bazy danych lub odpytanie zdalnego serwera o jakieś dane z API. Temat wątków / procesów oraz związanych z nimi operacjami jest tematem bardzo obszernym, ale postaram się przybliżyć Wam podstawy, które ułatwią zrozumienie bardziej złożonych zagadnień.

 

Proces vs Wątek

 

Zarówno proces jak i wątek określane są mianem niezależnych sekwencji wykonania. Oznacza to, że wykonują one pewne następujące po sobie operacje, które są niezależne względem operacji wykonywanych w innych wątkach / procesach. Mianem pojedynczego procesu określa się zwykle aplikację, która została uruchomiona w danym systemie (na przykład Androidzie). Każdy proces (aplikacja) ma przydzieloną własną przestrzeń w pamięci, do której nie mają dostępu pozostałe procesy. Wymiana danych pomiędzy procesami jest możliwa, ale wymaga dodatkowego wysiłku i nie jest to powszechnie stosowane.

Wątki z kolei działają w ramach jednego procesu i zwykle działa ich kilka jednocześnie. Poszczególne wątki współdzielą pamięć w ramach tego samego procesu. Bezpośrednia komunikacja pomiędzy wątkami z różnych procesów nie jest możliwa. Podzielenie zadań aplikacji na kilka mniejszych wątków pozwala zwiększyć jej wydajność, ponieważ zdania te zostaną wykonane niezależnie, bez wzajemnego blokowania.

Kiedy uruchomiana jest aplikacja, system Android (zbudowany na bazie Linuxa) tworzy nowy proces (aplikację) z jednym wątkiem głównym, który często nazywany jest wątkiem UI (UI Thread). Wątek ten jest odpowiedzialny między innymi za komunikację z użytkownikiem poprzez interpretowanie jego aktywności, generowanie na tej podstawie odpowiednich wyników, czy też tworzenie poszczególnych widoków na ekranie (ich „rysowanie”). Strasznie sucho to brzmi, dlatego skorzystajmy z przykładu. Kiedy użytkownik naciśnie jakiś przycisk na ekranie wtedy aplikacja odpowiednio na to reaguje pokazując na przykład widok alertu lub pozwalając na wpisanie wartości w pole EditText. To tak z grubsza 🤨.

Domyślnie wszystkie komponenty aplikacji uruchamiane są w tym samym procesie oraz wątku, ale istnieje możliwości utworzenia dodatkowych wątków, które będą wykonywały określone operacje „w tle” aplikacji. W przypadku Androida bardzo ważne jest, aby nie blokować wątku głównego złożonymi zadaniami oraz nie wchodzić w interakcję z elementami UI z wątków innych niż właśnie wątek główny. Poniżej przykład złamania tej drugiej zasady:

 

 

Z pomocą powyższego kodu tworzymy nowy wątek w naszej aplikacji (procesie), który nie robi nic sensownego, jednak na samy końcu próbuje uzyskać dostęp do elementu interfejsu użytkownika. Problem polega na tym, że nowy wątek nie jest wątkiem głównym i nie ma prawa korzystać z elementów UI. Powyższy kod wywali naszą aplikację i w logach pojawi się mniej więcej taka informacja – „WrongThreadException: Only the original thread that created a view hierarchy can touch its views”. Aby tego uniknąć można zastosować poniższe rozwiązanie:

 

 

Możemy również skorzystać z jednej z dwóch metod, w które został wyposażony obiekt Viewpost(Runnable) oraz postDelayed(Runnable, long):

 

 

 

W powyższym kodzie wykorzystaliśmy funkcję runOnUIThread(), która jest częścią Android API i robi dokładnie to co nazwa wskazuje, czyli umieszczony w niej kod wykonuje na wątku głównym naszej aplikacji. Powyższe rozwiązanie jest jak najbardziej poprawne i będzie działało, ale nie jest to zalecany sposób, ponieważ w miarę jak nasza aplikacja będzie się rozrastała, zarządzanie poszczególnymi wątkami będzie coraz bardziej czasochłonne i podatne na błędy z naszej strony.

Inżynierowie Googla wyszli nam na przeciw i w ramach API Androida udostępnili narzędzie o nazwie AsyncTask, które ułatwia sposób w jaki można zarządzać wątkami działającymi w tle, czyli tzw. background threads. AsyncTask daje nam dostęp do wątków nazywanych worker threads, które przeznaczone są głównie do wykonywania zadań wymagających potencjalnie większej ilości czasu / zasobów. Po wykonaniu takiego zadania zostaje wywołana wcześniej zdefiniowana metoda, która dostarcza wyniki obliczeń z powrotem do wątku głównego. Dzięki takiemu rozwiązaniu jesteśmy zwolnieni z kłopotliwego, ręcznego zarządzania poszczególnymi wątkami.

 

 

Zanim przejdę do opisu działania obiektu AsyncTask spójrzcie najpierw na przykład jego zastosowania:

 

 

A tak będzie wyglądało przykładowe wywołanie:

 

 

Podczas definiowania klasy pochodnej AsyncTask musimy określić na jakich typach danych będziemy chcieli pracować. W naszym przykładzie posłużyliśmy się <Void, Integer, String> (bazowa klasa AsyncTask w definicji wykorzystuje typy generyczne), co oznacza, że przy wywołaniu naszego AsyncTask nie przekażemy żadnych dodatkowych danych (Void), typem zwracanym podczas śledzenia postępu naszego zadania będzie typ liczbowy, całkowity (Integer), a zadnie po wykonaniu zwróci obiekt typu String. W codziennym użyciu w miejsce Void wstawia się zwykle coś bardziej przydatnego, jak np. URL do pewnych zasobów w sieci, ale przedstawiona implementacja ma za zadnie przedstawienie koncepcji w przystępny sposób.

W skład klasy AsyncTask wchodzi kilka kluczowych metod, które muszą zostać nadpisane podczas tworzenia obiektu pochodnego (w naszym przypadku DemoAsyncTask). Metody te są odpowiedzialne za kontrolowanie cyklu życia obiektu AsyncTask i wykorzystywane do wykonania określonych operacji w odpowiednim momencie. Tak prezentuje się ich opis w kolejności wykonania:

onPreExecute() – metoda ta wywoływana jest na wątku głównym aplikacji (UI Thread) i jest wykorzystywana do wykonania określonych zadań tuż przed uruchomieniem AsyncTask. Można na przykład za jej pomocą tak ustawić elementy UI, aby wskazywały, że za chwilę zostanie rozpoczęte nowe zadanie (uruchomienie jakiegoś Progress Bar na przykład).

– doInBackground(Params…) – metoda ta jest wywoływana na wątku background zaraz po metodzie onPreExecute(). W miejsce Params… (określanym jako variadic type) wstawiamy dane potrzebne do wykonania zadania. W naszym przypadku było to Void, czyli brak konkretnych parametrów. Sygnatura Params… może być dla Was trochę myląca, ale tak na prawdę pod spodem kryje się standardowa tablica. Stosuje się ją w przypadku, gdy nie jest z góry określone ile parametrów przyjmie dana funkcja. Gdybyśmy mieli do przekazania jako argumenty adresy URL moglibyśmy zrobić to na kilka różnych sposobów:

 

 

– onProgressUpdate(Progress…) – działa na wątku głównym. Metoda ta wywoływana jest podczas działania doInBackground(Params…) i za jej pomocą możemy poinformować inne obiekty o postępach w wykonywaniu operacji. W naszym przykładzie na bieżąco aktualizujemy obiekt Progress Bar.

– onPostExecute(Result) – wywoływana jest na wątku głównym aplikacji. Metoda doInBackground(Params…) po zakończeniu swojego działania przekazuje typ zwracany (w naszym przypadku String) do funkcji onPostExecute(Result), która przyjmuje go jako argument. W naszym przykładzie wewnątrz metody sprawdzamy również, czy nasze AsyncTask nie zostało przypadkiem anulowane. Jest to związane z faktem, że AsyncTask utrzymuje referencję do obiektu Context, nawet po jego usunięciu z pamięci (kiedy na przykład użytkownik obróci ekran i Aktywność zostanie utworzona na nowo). W takim przypadku istnieje ryzyko, że obiekt AsyncTask będzie chciał skorzystać z obiektu Context, pomimo faktu, że ten nie będzie już istniał. W wyniku takiego zachowania zostanie zgłoszony wyjątek. Dobrym rozwiązaniem jest tutaj wywołanie metody cancel(true) na obiekcie AsyncTask w momencie, gdy obiekt go wykorzystujący (np. Acitivity) zostanie za chwilę usunięty z pamięci:

 

 

Inne ROZWIĄZANIA – Intent service

 

AsyncTask jest narzędziem bardzo przydatnym, ale sprawdza się raczej tylko podczas wykonywania potencjalnie krótkich zdań, które mogą zająć tylko kilka sekund. Jeżeli podejrzewany, że nasze zadanie będzie wymagało większej ilości czasu to powinniśmy skorzystać z bardziej złożonych rozwiązań jak na przykład IntentService, który postaram się Wam trochę przybliżyć.

IntentService jest komponentem przeznaczonym stricte do wykonywania złożonych zdań poza wątkiem głównym. Tworzy on tzw. worker thread (wątek traktowany jako drugorzędny względem UI Thread), na którym wykonywane są przydzielone mu zadania w kolejności ich dodania. Jego uruchomienie następuje z poziomu innego komponentu aplikacji (w późniejszym przykładzie będzie lepiej widać o co chodzi), natomiast zatrzymanie i zniszczenie obiektu IntentService następuje w momencie ukończenia wszystkich zadań. Implementacja tego rozwiązania nie jest bardzo złożona, a dużą zaletą jest fakt, że w przeciwieństwie do AsyncTask IntentService nie jest narażony na fluktuacje związane np. z cyklem życia obiektu Activity. IntentService ma jednak kilak istotnych ograniczeń, takich jak brak możliwości bezpośredniej komunikacji z UI, wykonywanie zdań w dowolnie określonej kolejności oraz brak możliwości przerwania rozpoczętej operacji. IntentService jest w rzeczywistości rozbudowaną wersją obiektu Service, która utworzy dla nas automatycznie dodatkowy wątek do wykonywania bardziej złożonych zadań. Taką samą funkcjonalność można stworzyć z wykorzystaniem podstawowego obiektu Service, jednak wymagałoby to większej ilości kodu 😅.

Tak będzie wyglądała bardzo podstawowa wersja naszego IntentService:

 

 

Wszystkie funkcje związane z obiektem Service, takie jak onStartCommand() wywoływane są w przypadku IntentService automatycznie i dokumentacja Googla zaleca, aby ich nie nadpisywać. Z drugiej jednak strony w innym przykładzie ta sama dokumentacja zaleca już nadpisanie tych samych funkcji w określonych przypadkach, więc jak zawsze wszystko zależy od kontekstu i Waszych potrzeb.

 

 

Aby wszystko działało jak należy musimy jeszcze dodać nasz IntentService to pliku manifest.xml:

 

 

Ustawienie wartości pola android:exported na false spowoduje, że dany Service będzie dostępny tylko dla naszej aplikacji (procesu) i tylko my będziemy mogli zrobić z niego użytek.

Dajmy teraz naszemu obiektowi SomeIntentService jakieś „bardzo złożone” zadnie do wykonania. Po jego zakończeniu Service zostanie zatrzymany:

 

 

A tak będzie wyglądało wywołanie z przykładowej Activity:

 

 

IntentService jest bardzo wygodnym rozwiązaniem jeżeli nie mamy za bardzo ochoty bawić się kodem tworzącym nowe wątki i zależy nam na szybkiej implementacji rozwiązania. Przy jego wyborze musimy jednak pamiętać o ograniczeniach wymienionych kilka linijek wyżej.

I to właściwie wszystko co chciałem Wam przekazać w tym wpisie. Sposobów na pracę z wątkami w Androidzie jest na prawdę bardzo dużo, a ja przedstawiłem Wam tylko te chyba najczęściej stosowane. Temat wątków w samej Javie jest tak rozbudowany, że została im nawet poświęcona osobna publikacja. Jej tytuł to „Java Concurrency in Practice”. Ostanie wydanie jest chyba z 2006 roku, ale myślę, że i tak warto się tą książką zainteresować. Wątki ogólnie są zagadnieniem bardzo złożony więc za bardzo się nie przejmujecie jeżeli na początku nie wszystko wpadnie od razu do głowy. Jak ze wszystkim w programowaniu – potrzebny jest czas i praktyka 😄 Do usłyszenia 😎


 

Dodaj komentarz

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