Czy Kotlin Coroutine to na pewno kolejny krok dla programisty RxJava?
W poprzednim artykule opisywałem Kotlin jako kolejny krok dla programisty Java. Wspomniałem tam też, że Kotlin daje nowe możliwości asynchronicznej komunikacji – Coroutine. Co kryje się pod tym enigmatycznym stwierdzeniem i komu to potrzebne?
Spis treści
Kim jesteśmy, dokąd zmierzamy i dlaczego tytuł artykułu jest błędny?
Zacznijmy od odpowiedzi na drugie pytanie, czyli po co stworzono nowy mechanizm, skoro mamy już inne? Istnieją np. AsyncTaski, ale ich API jest słabo przystosowane do stosowania w świecie Androida i jego Lifecycle. Możemy też operować na „surowych” wątkach (klasa Thread), ale to raczej zabawa dla prawdziwych „hardkorów”.
Wreszcie powstała RxJava, która dla mnie była jak objawienie. Wprowadzenie mechanizmu strumieni bardzo mocno zmieniło moje podejście do programowania i pozwoliło na dużo łatwiejsze pisanie „czystych” architektonicznie projektów. Świetnie się sprawdza przy oddzielaniu warstw aplikacji w projekcie i tworzeniu przepływu danych między nimi. Krótko mówiąc – RxJava to spełnienie marzeń każdego programisty i odpowiedź na wiele jego problemów.
Tylko skoro jest tak dobrze, to po co coś zmieniać?
RxJava jest implementacją biblioteki ReactiveX dla języka Java i dalej opiera się o mechanizmy wątków Javy. Sama koncepcja powstała początkowo w języku C# i po bardzo ciepłym przyjęciu została przeniesiona do innych języków. My, co jest założeniem tego artykułu, chcemy jednak być programistami Kotlina i używać czegoś, co zostało stworzone dla tego języka. RxKotlin? Istnieje, ale jest to tylko zbiór extension function, które pozwalają łatwiej korzystać z RxJavy w Kotlinie.
Do tej pory Kotlin korzystał z wątków na tej samej zasadzie co Java, więc nie było potrzeby tworzenia nowej implementacji. Chyba też już wiecie, dlaczego pytanie w tytule jest błędne. Kotlin Coroutine nie konkuruje z RxJavą. Nic nie stoi na przeszkodzie, aby stworzyć RxKotlin w oparciu o Kotlin Coroutine. Kotlin jest nowym językiem, więc zasługuje na nową implementację RxKotlin. Tylko czy on tego potrzebuje?
Czym są Kotlin Coroutine?
Zacznijmy od początku — od wujka Google i cioci Wikipedii. Czym jest Coroutine — tłumacząc na język polski — współprogram?
„Współprogram cechuje się posiadaniem ciągu instrukcji do wykonania i ponadto możliwością zawieszania wykonywania jednego współprogramu A i przenoszenia wykonywania do innego współprogramu B.”
Fajnie, ale można prościej – Coroutine ułatwia pisanie asynchronicznego kodu, poprzez możliwość pisania tego kodu w sposób sekwencyjny. Sekwencyjny – czyli bez żadnych callbacków. Zapomnijcie o „callback hell” – wszystko teraz będzie proste 🙂 Nawet RxJava nie dawała takich możliwości, bo ciągle mamy callbacki onNext() i onError().
Technicznie Coroutine są przetwarzane na etapie kompilacji i działają dzięki transformacji kodu, więc nie musimy przejmować się wymaganiami związanymi ze wsparciem maszyny wirtualnej lub systemu operacyjnego.
Coroutine ciągle są jeszcze w wersji eksperymentalnej, ale są już gotowe do działania w produkcyjnych aplikacjach. JetBrains zapewnia, że wszystkie nowe wersje będą kompatybilne wstecz, a teraz większość pracy odbywa się „pod spodem”. W skrócie główna różnica pomiędzy wersją eksperymentalną, a normalną jest taka, że w normalnej nowe funkcje nie mogą być dodane w mniejszych aktualizacjach, natomiast w wersji eksperymentalnej można dodawać nowe rzeczy, ale nie można niczego usuwać. Zachęcam do przeczytania ich wpisu na StackOverflow.
Trochę teorii
Przejdźmy teraz przez podstawowe pojęcia.
Suspend function – funkcja, która może zostać zawieszona i wykorzystana w ramach Coroutine. Co to oznacza? Można zawiesić wykonanie funkcji do późniejszego wykonania bez blokowania wątku. Blokowanie jest bardzo kosztowne (mamy też ograniczoną ilość wątków), natomiast operacja zawieszenia jest praktycznie darmowa i nie wymaga zmiany kontekstu lub innej akcji systemu operacyjnego.
Coroutine builder – służy do tworzenia Coroutine. Mamy kilka podstawowych możliwości:
- launch() – tworzy Coroutine i zapomina o niej. Zwraca natomiast obiekt typu Job, który pozwala na późniejsze anulowanie wykonywanie danej Coroutine. Wszystkie niezłapane wyjątki spowodują crash aplikacji.
- async() — tworzy Coroutine i zwraca obiekt typu Deferred<out T> : Job. Obiekt jest „obietnicą”, że w przyszłości zostanie zwrócona wartość T. Aby mieć do niej dostęp musimy wykonać na nim await(). Ewentualnie będzie zawierać wyjątek, który wystąpił podczas wykonywania operacji.
- withContext() – tworzy wewnętrzną Coroutine, która zostanie wykonana na określonym wątku.
- runBlocking() – tworzy nową Coroutine i blokuje obecny wątek (przydatny w trakcie pisania testów).
Coroutine context – przekazuje wykonanie Coroutine na odpowiedni wątek.
Podstawowe dostępne konteksty (wątki) to:
- CommonPool – wywołuje funkcję na wątku „w tle” (aktualnie jest to domyślny kontekst).
- UI – wywołuje funkcję na głównym wątku Androida.
- Unconfined – wywołuje funkcję na obecnym wątku (przydatny w trakcie pisania testów).
Punkt wyjścia – jak wyglądała aplikacja przed wprowadzeniem nowości?
W tym artykule przedstawiam realny przykład przejścia na Coroutine w jednej z pisanych przeze mnie aplikacji. Żebyście zrozumieli kontekst muszę założyć, że macie już pewną wiedzę i wstępnie zaznaczyć kilka rzeczy, które były zastosowane w projekcie:
- RxJava – wykorzystywanej do asynchronicznych zapytań.
- MVP – wzorzec projektowy łączący warstwę prezentacji (framework Android) z pozostałymi warstwami. W skrócie Activity/Fragment to warstwa widoku implementująca interfejs View, poprzez który Prezenter wykonuje operacje na widoku.
- The Clean Architecture – aplikacja została napisana zgodnie z założeniami „czystej architektury” proponowanej przez Uncle Boba. Będziemy tego potrzebować, aby zrozumieć co, gdzie ma swoje miejsce w aplikacji. Wspomniany wcześniej Prezenter jest mostem do widoku, który wykonuje logikę biznesową zawartą w klasach UseCase.
Grafika pochodzi z 8thlight.com
Co musimy mieć, aby móc pracować z Coroutine
Żeby móc zacząć pracę Coroutine musimy dodać do gradle kilka rzeczy:
kotlin { experimental { coroutines "enable" } } dependencies { implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:0.23.0' implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:0.23.0" }
Jeżeli korzystamy z Retrofit warto dodać również adapter, który został stworzony przez Jake Whartona:
implementation 'com.jakewharton.retrofit:retrofit2-kotlin-coroutines-experimental-adapter:1.0.0'
Od teraz możemy już cieszyć się nowymi dobrodziejstwami.
Teraz przyjemności – czyli klasyczne porównanie przed i po
W tej części porównam kilka podstawowych klas i omówię, jak zmieniły się poprzez przejście z RxJavy, na wykorzystanie surowych Coroutine. Zachęcam do samodzielnego bezpośredniego porównania, a później do przeczytania komentarzy zamieszczonych pod każdą parą.
RemoteDataSource – interfejs, na podstawie którego Retrofit, tworzy klasy odpowiadające za pobieranie danych z API.
RxJava
interface RemoteDataSource{ @GET("url") fun getNews() : Single<NewsResponse> }
Coroutine
interface RemoteDataSource { @GET("url") fun getNews(): Deferred<NewsResponse> }
BasePresenter – klasa abstrakcyjna, anuluje asynchroniczne zapytania, gdy prezenter już nie jest zainteresowany ich rezultatem.
RxJava
abstract class RxBasePresenter<V : MvpView> : MvpBasePresenter<V>() { private val subscriptions: CompositeDisposable = CompositeDisposable() override fun detachView(retainInstance: Boolean) { super.detachView(retainInstance) if(!retainInstance){ subscriptions.clear() } } fun Disposable.registerInPresenter() { subscriptions.add(this) } }
Coroutine
abstract class RxBasePresenter<V : MvpView> : MvpBasePresenter<V>() { private var parentJob = Job() override fun detachView(retainInstance: Boolean) { super.detachView(retainInstance) if (!retainInstance) { parentJob.cancel() } } fun RxBasePresenter<V>.launch(context: CoroutineContext = DefaultDispatcher, start: CoroutineStart = CoroutineStart.DEFAULT, parent: Job? = null, onCompletion: CompletionHandler? = null, block: suspend CoroutineScope.() -> Unit) = kotlinx.coroutines.experimental.launch( context = context, start = start, parent = parent ?: parentJob, onCompletion = onCompletion, block = block) }
Na co warto zwrócić uwagę?
Przede wszystkim nie ma czegoś takiego jak zbiór CompositDisposable. W świecie Coroutine wykorzystana jest hierarchia rodzic – dziecko, przez co każdy prezenter ma nadrzędny obiekt typu Job, który po wywołaniu funkcji cancel() anuluje wszystkie jego dzieci. W celu łatwiejszego wywoływania stworzyłem extension function launch(), które domyślnie ustawia odpowiedniego rodzica.
UseCase – klasa realizująca logikę biznesową, a poniższa jej funkcja pobiera dane z repozytorium i zwraca je zmapowane do pożądanego obiektu.
RxJava
fun load(): Single<List<NewsEntity>> = api.getNews().map { it.results }
Coroutine
suspend fun load(): Resources<List<NewsEntity>> = withContext(bgContext) { val response = api.getNews().awaitResources() //czekaj na odpowiedź return@withContext Resources(response.data?.results, response.exception) //mapowanie na pożądany obiekt }
suspend fun <T> Deferred<T>.awaitResources() : Resources<T>{ return try { Resources(await(),null) }catch (e : Exception){ Resources(null,e) } }
data class Resources<out T>( val data: T?, val throwable: Throwable? )
Na co warto zwrócić uwagę?
Tutaj sytuacja się trochę skomplikowała. O ile w przypadku RxJavy po prostu mapujemy obiekt, o tyle w przypadku Coroutine zdecydowałem się na dodatkowe owrapowanie danych w obiekt typu Resource w celu czytelniejszej obsługi błędów i stworzenia jednolitego API.
W przypadku Coroutine przechwytywanie wyjątków domyślnie odbywa się to poprzez owrapowanie wywołania blokiem try/catch albo pobraniem wyjątku z obiektu Deferred.
Coroutine dostarcza niskopoziomowe funkcjonalności języka, dlatego niektóre rzeczy musimy sobie dodatkowo dopisać. Pewnie to jest kwestia czasu, aż powstaną „jedyne słuszne biblioteki” realizujące to.
Presenter – klasa łącząca widok z pozostałymi warstwami, a poniżej funkcja pobierająca dane z repozytorium.
RxJava
fun loadData() { view?.showLoading() loadNews.load() .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe({ view?.showContent(it) }, { view?.showError(it) }).registerInPresenter() }
Coroutine
fun loadData() { launch(uiContext) { view?.showLoading() loadNews.load().apply { if (data != null) { view?.showContent(data) } else { view?.showError(throwable) } } } }
Na co warto zwrócić uwagę?
W obu przypadkach zupełnie inaczej odbywa się obsługa błędu. RxJava domyślnie wdrapuje wszystkie wyjątki i ich obsługa odbywa się w callbacku onError(). Coroutine, jak wspomniałem wcześniej, trochę inaczej obsługuje wyjątki. W tym przypadku dodatkowe owrapowanie zwracanej wartości ułatwia obsługę błędów.
Coś ekstra — kilka prostych przykładów
Wykonanie dwóch funkcji sekwencyjnie
fun loadData2Sequentially() { launch(uiContext) { val response1 = withContext(bgContext) { loadNewsData() } val response2 = withContext(bgContext) { loadNewsData() } val result = response1 + response2 view?.showContent(result) } }
Wykonanie dwóch funkcji równolegle
fun loadData2Parallel() { launch(uiContext) { val response1 = async(bgContext) { loadNewsData() } val response2 = async(bgContext) { loadNewsData() } val result = response1.await() + response2.await() view?.showContent(result) } }
Wykonanie funkcji z limitem czasowym
fun loadDataWithTimeout() { launch(uiContext) { val response = async(bgContext) { loadNewsData() } val result = withTimeoutOrNull(2, TimeUnit.SECONDS) { response.await()} ?: emptyList() view?.showContent(result) } }
Coś ekstra – kilka bardziej skomplikowanych rzeczy
Channels – służące do transferowania strumieni wartości. Możemy zdefiniować pojemność kanały i dać mu większy bufor (domyślny i tak jest 1). W poniższym kodzie mamy oznaczoną kolejność wykonywania. Po wysłaniu elementu do kanału metodą send(), Coroutine zostaje zawieszona do czasu, aż kanał zostanie opróżniony metodą receive().
val channel = Channel<Int>(1) // kanał o pojemności 1 fun channelSend() = launch { channel.send(1) //1 channel.send(1) //3 } fun channelReceive() = launch { val value1 = channel.receive() //2 val value2 = channel.receive() //4 }
Produce i Actor – są to dwie bliźniacze klasy tworzące Coroutine wraz z kanałem. Pierwsza zajmuje się tylko wysyłaniem danych, a druga tylko odbieraniem. Benefitem tego rozwiązania jest to, że tylko wewnątrz danej Coroutine można mieć dostęp do kanału.
val producer = produce{ send(1) //1 send(1) //3 } fun receive() { launch { val value1 = producer.receive() //2 val value2 = producer.receive() //4 } }
val subscriber = actor<Int> { for(i in channel) { /*oczekuje na elementy aż do zamknięcia kanału*/ } } fun send() { launch { subscriber.send(1) subscriber.send(2) } }
Wydajność – wstępne testy pokazują, że wykorzystanie Coroutine może być bardziej wydajne pamięciowo, niż korzystanie z RxJava. Zwłaszcza jeżeli będziemy wykonywać bardzo dużo operacji na danych w „strumieniu”. Musimy mieć też na uwadze, że jest to ciągle eksperymentalna część języka i jeszcze może dużo zyskać. Zachęcam do przeczytania więcej na ten temat tutaj.
Podsumowanie
Kotlin Coroutine daje nam nowe możliwości tworzenia asynchronicznych zapytań. Możemy pozbyć się wszelkich callbacków i pisać nasz kod w sposób bardziej „płaski” (sekwencyjny).
W tym artykule wspomniałem tylko o kilku podstawowych funkcjach i przed wami jeszcze dużo do zgłębienia. Zachęcam do zapoznania się z materiałami:
- https://github.com/Kotlin/kotlin-coroutines/ — dokumentacja związana z obecną wersją Coroutine i przykłady.
- https://github.com/Kotlin/kotlinx.coroutines — biblioteka Coroutine.
- https://github.com/Kotlin/kotlinx.coroutines/tree/master/ui — wskazówki dla UI.
- https://github.com/Kotlin/kotlinx.coroutines/tree/master/reactive — gdy ciągle chcesz wspierać Rxy, a przy tym korzystać z Coroutine.
Muszę przyznać, że podczas pisania aplikacji przechodziłem kilka faz od zachwytu po poczucie totalnej niemocy i smutku, aż do ponownego zachwytu. Ogólnie jestem zadowolony, że do mojego „stacku technologicznego” dodałem nową umiejętność. Czy zastosowałbym w swoich projektach? Chyba jeszcze nie, a na pewno nie w produkcyjnych aplikacjach. Głównie dlatego, że ciągle bardzo, bardzo lubię koncepcję Rxów i chętnie widziałbym je w wersji opartej o Coroutine.
Widzę za to ogromny potencjał w Coroutine dla cross-platformowych aplikacji. Możliwość pisania wspólnego kodu dla Androida, iOS i Web. Zdecydowanie to jest miejsce, w którym Coroutine może odnieść ogromny sukces. Musimy jednak pamiętać, że obie funkcje języka (Coroutine i cross-platformowość) są jeszcze w eksperymentalnej fazie.
Zdjęcie główne artykułu pochodzi z proandroiddev.com