Architektura aplikacji Redux

Autor: Damian Chodorek • Opublikowany: 12 sierpnia 2017 • Kategoria: android, kursy

W poprzedniej części poznałeś dosyć zaawansowaną architekturę VIPER, która pozwala podzielić kod aplikacji na mniejsze części, z których każda posiada jasno zdefiniowaną odpowiedzialność. Żaden wzorzec nie jest jednak idealny. W tym artykule omówię problemy architektur takich jak MVP czy VIPER oraz opowiem jak je rozwiązać przy pomocy Reduxa.

Spójrz na poniższy schemat.

wzorzec MVP

Pokazuje on podstawowy problem wzorców takich jak MVP czy VIPER. Gdy zaczynamy wykonywać kilka równoległych operacji, to zarządzanie stanem widoku staje się coraz trudniejsze. W powyższym przypadku mamy aplikację z dwoma przyciskami. Każdy z nich rozpoczyna asynchroniczną operację. Chcemy pokazać kręcący się spinner, gdy rozpocznie się przynajmniej jedna z nich oraz chcemy go schować, gdy zakończą się wszystkie. Dla uproszczenia przyjąłem, że widok komunikuje się z prezenterem przy pomocy eventów. Obojętnie czy są to callbacki, eventy znane z biblioteki EventBus, czy RxJava. W tym momencie nie jest to istotne.

Rozpoczęcie każdej asynchronicznej operacji wiąże się z rozpoczęciem jakiegoś niezależnego ciągu zdarzeń: pokaż spinner -> połącz się z backendem -> schowaj spinner. Zsynchronizowanie niezależnych operacji asynchronicznych jest trudnym zagadnieniem. W najbardziej naiwnym przypadku możemy dla każdej operacji asynchronicznej stworzyć flagę, która będzie mówić o tym, czy operacja trwa, czy nie. Gdy wszystkie flagi są false, to chowamy spinner. Gdy choć jedna jest na true, to pokazujemy spinner.

Takie rozwiązanie jest jednak nieeleganckie. Zmniejsza czytelność kodu oraz zwiększa stopień jego skomplikowania. Ponadto im więcej będzie dziać się w warstwie widoku podczas operacji asynchronicznych, tym trudniej będzie nam zapanować nad kodem. Nie mówię, że nie da się tego zrobić. Chodzi o to, że rozwiązania tego rodzaju są mało profesjonalne, ich poprawne zaimplementowanie trwa długo, ciężko je przetestować, a ponadto są podatne na błędy.

Redux

Powyższe problemy znikają w przypadku architektury Redux. To wzorzec wywodzący się z weba, służący głównie do tworzenia front-endów. Oficjalną dokumentację i szczegółowy opis znajdziesz pod tym adresem. Redux powstał z myślą o skomplikowanym stanie widoku. Wyobraź sobie sytuację, w której widok dokonuje zmian modelu, który z kolei wpływa na inny model, który następnie powoduje aktualizację innego widoku.

W takiej sytuacji łatwo stracić zrozumienie i kontrolę nad tym, co dzieje się w aplikacji. Brak determinizmu oraz dwuznaczności, znacznie utrudniają rozwój aplikacji oraz reprodukcję błędów. Redux to architektura, której celem jest zapewnienie przewidywalności zmian stanu. To jedna z architektur, kierujących się jednokierunkowym przepływem danych. Spójrzmy na poniższy schemat.

wzorzec Redux

Jednokierunkowy przepływ danych

Wszystko, co dzieje się w aplikacji jest przewidywalne, a kierunek przepływu danych jest jeden. Zaczynając od prawej strony mamy warstwę widoku. Może to być aktywność. W całej aplikacji istnieje również jeden obiekt przechowujący jej stan. Nazywamy go Store. Widok wywołuje na nim metodę dispatch(), przekazując jako argument obiekty akcji (Actions). Obiekty te to często zwykłe POJO. Ich celem jest jedynie poinformowanie Store, jaką akcje wykonał właśnie użytkownik. Mogą zawierać dodatkowe informacje o tej akcji.

Następnie Store, przekazuje do Reducera aktualny stan oraz akcję, w postaci argumentów metody reduce(). Reducer to obiekt, który posiada jedno zadanie. Na podstawie akcji i stanu, musi wyprodukować nowy stan. Metoda reduce() powinna być czysta, bez żadnych efektów ubocznych jak komunikacja z API lub bazą danych. Ważne jest, że reduce() zwraca zmodyfikowaną kopię stanu. Stan nigdy nie może być zmodyfikowany. Zawsze musi powstać jego kopia.

Ze schematu wynika, że Reducer może zawierać pod-reducery, którym może oddelegować zmodyfikowanie części stanu. Załóżmy, że stanem jest obiekt State, który posiada w sobie dwie flagi: flagOne oraz flagTwo. Główny Reducer może więc do pierwszego sub-reducera przekazać flagOne oraz aktualny stan, a wynik operacji przypisać do kopii głównego stanu, zmieniając mu jedynie tę flagę. Analogicznie może postąpić z drugim sub-reducerem, który zredukuje flagę flagTwo. Z punktu widzenia Store istnieje tylko jeden Reducer.

Gdy nowy stan będzie gotów, zastąpi on aktualny stan w obiekcie Store. Ten zaś poinformuje wszystkich obserwatorów, że pojawił się nowy stan. Przykładowym obserwatorem może być warstwa widoku, która po otrzymaniu nowego stanu, spróbuje przetłumaczyć go na UI – flagi flagOne i flagTwo mogą być zaprezentowane jako checkboxy. Przykładowy kod poniżej.

checkBoxOne.setChecked( state.getFlagOne() );
checkBoxTwo.setChecked( state.getFlagTwo() );

Efekty uboczne

Analizując powyższy schemat, być może zadałeś sobie pytanie Gdzie obsługiwane są zapytania do serwera?. To bardzo dobre pytanie. Poniżej znajduje się zaktualizowany schemat.

Action creator

Redux wprowadza warstwę middleware, w której mogą znajdować się różne efekty uboczne. Najpopularniejszym z nich są właśnie zapytania do serwera. Wyróżniamy więc warstwę ActionCreators. Widok nie tworzy bezpośrednio akcji, ale korzysta z obiektów, które to robią za niego. ActionCreator może więc wykonać zapytanie i stworzyć akcję np. RequestComplete w przypadku sukcesu lub akcję RequestFailed w przypadku porażki.

W aplikacji może więc dziać się mnóstwo asynchronicznych operacji, ale zawsze tylko jedna jest przetwarzana w danym momencie przez Store. Dzięki temu stan aplikacji jest zawsze dobrze zdefiniowany. W każdym momencie wiemy, co się dzieje, a w przypadku crasha możemy sprawdzić, jak wyglądał stan (np. wyświetlając go w konsoli lub wysyłając do odpowiedniego systemu), a tym samym co działo się w aplikacji. Wszystkie błędy staną się w ten sposób łatwiejsze do odtworzenia.

Redux przełożony na Androida

W zasadzie mógłbym zakończyć artykuł w tym momencie, ponieważ zaprezentowałem Ci architekturę Redux oraz jej właściwości. Pójdę jednak krok dalej i pokażę Ci jak zaimplementować tę architekturę w środowisku Androida. Najpierw schemat, później wyjaśnienia.

Redux with presenter on Android

Podział logiki

Pierwsza rzecz, jaka rzuca się w oczy, to obecność prezenterów. To obiekty znane z architektury MVP, którą opisałem w jednym z poprzednich postów. Nic nie stoi na przeszkodzie, aby pod naszą warstwę widoku podpiąć więcej niż jeden prezenter. W klasycznym MVP mówi się o jednym prezenterze, jednak gdy widok jest skomplikowany, to nasz prezenter także zaczyna się rozrastać i komplikować.

Aby elegancko podzielić odpowiedzialności, zmniejszyć stopień skomplikowania i zwiększyć czytelność kodu, możemy zastosować kilka prezenterów, które podzielą się odpowiedzialnościami. Jeden może reagować na przycisk A, drugi na przycisk B. Na powyższym schemacie zaprezentowałem dwa prezentery, z których jeden reaguje na przycisk, wykonuje zapytania do serwera oraz rozsyła akcje. Zastępuje on więc w pewnym sensie ActionCreatora. Drugi prezenter obserwuje Store. Dostaje więc nowy stan, gdy tylko nastąpi jego zmiana, a następnie na jego podstawie wywołuje odpowiednie metody na widoku. Stąd nazwa RenderPresenter.

Gdzie umieścimy Store?

Jak zaimplementować Store w aplikacji? Możemy pokierować się wskazówkami Reduxa i stworzyć globalny obiekt, który będzie przechowywany w klasie Application. W ten sposób będzie on dostępny we wszystkich miejscach aplikacji. Na stronie Reduxa istnieje również wskazówka, że jeśli nasza aplikacja składa się z sub-aplikacji albo z niezależnych części, to dla każdej z nich możemy zastosować osobny Store. Myślę, że androidowe aktywności idealnie wpisują się w tę definicję, jako że są komponentami wysoce rozdzielnymi.

Moje podejście jest takie, aby każda aktywność miała swój własny obiekt Store. Podczas zmiany orientacji ekranu następuje zniszczenie i stworzenie aktywności na nowo. Jak w takiej sytuacji radzę sobie z zachowaniem nienaruszonego Store'a? Korzystam z klasy ViewModel, która jest częścią Architecture Components, które zostały zaprezentowane na Google I/O 2017. Owszem ViewModel jest w momencie pisania tego posta w wersji alfa, ale spełnia moje oczekiwania. W onCreate() robię więc ViewModelProviders.of(this).get(MyViewModel.class), aby stworzyć lub uzyskać istniejącą instancję obiektu, który przechowuje Store aktywności.

Podział widoku

Wyobraź sobie widok, na którym dzieje się naprawdę dużo. Wiele przycisków i wiele możliwych operacji. Multiprezenter pomaga nam rozbić logikę jednego widoku na mniejsze kawałki. Co jednak z samym widokiem? Czy musimy pogodzić się z faktem, że nasza aktywność będzie dużym obiektem o wielu odpowiedzialnościach (pomyśl o dostępie do SharedPreferences, zarządzaniu widokami i komunikacji z innymi aktywnościami)? Otóż nie. Aktywność również możemy elegancko podzielić na kawałki.

Redux with composite Android

Dodaliśmy jeszcze jedną warstwę abstrakcji. Widok możemy podzielić na pluginy. Są to komponenty z biblioteki Composite Android. Plugin jest niczym innym, jak delegatem, który posiada te same publiczne metody co widok, np. aktywność. Możemy stworzyć kilka takich delegatów, z których każdy będzie odpowiadać za co innego. Przykładowo pierwszy może dokonać inicjalizacji aktywności (setContentView() w onCreate() itp.), drugi może reagować na pojawienie się widoku na ekranie, np. onResume(), a trzeci może udostępniać swojemu prezenterowi metody do aktualizacji widoku. Jak zauważyłeś, delegaty mają swoje prezentery. Mogą mieć ich po kilka.

Jeszcze większy podział

Nie musimy traktować tylko aktywności jako odrębnych całości. Poszczególne jej pod-widoki, również możemy zaimplementować jako osobne komponenty, z własną architekturą Redux.

Multi view Redux

Powyższy schemat przedstawia dwa niezależne widoki, należące do tej samej aktywności. Nic nie stoi jednak na przeszkodzie, aby posiadały one wspólny Store. W ten sposób jeden widok wpływa na stan aktywności. Drugi widok może zareagować na tę zmianę. Wyobraź sobie sytuację, gdzie użytkownik może zaznaczać elementy listy, a na toolbarze wyświetlona jest liczba zaznaczonych. Toolbar i lista są osobnymi widokami, które współdzielą wspólny Store. W ten sposób jesteśmy w stanie zaimplementować czystą, zrozumiałą i spójną komunikację pomiędzy wieloma niezależnymi komponentami, która elegancko radzi sobie z asynchronicznością.

Multi view Redux with shared Store

Reasumując

Poznałeś dziś nowoczesną architekturę Redux, która jest bardzo popularna wśród programistów weba i zdobywa popularność na Androidzie. Dzieje się tak, ponieważ upraszcza logikę aplikacji, która bez dobrej architektury staje się niemożliwa do opanowania. Zwłaszcza gdy zmiany widoku zależne są od asynchronicznych operacji. Oczywiście Redux nie jest jedynym rozwiązaniem. Dosyć popularna staje się także architektura MVI (Model-View-Intent), którą postaram się opisać w przyszłości.

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany.