Mobile

Prosta implementacja MVVM w aplikacji na Androida

Całkiem niedawno Google, za sprawą Architecture Components, mianował MVVM podstawowym wzorcem w architekturze aplikacji androidowych. Trzeba przyznać, że trochę im zeszło z tym wyborem. Tak naprawdę jednak MVVM nie jest czymś zupełnie nowym, bo sam wzorzec funkcjonuje od jakiegoś czasu choćby na platformie .NET. Tak się składa, że to właśnie inżynierowie Microsoftu są jego twórcami.

Grzegorz Kwaśniewski. Android & iOS Developer w itCraft. Entuzjasta platform mobilnych. Lubi pisać kod w Swift’cie oraz Kotlinie. Wielki fan testów jednostkowych. W wolnych chwilach prowadzi bloga devmobile.pl oraz próbuje napisać swoją pierwszą książkę.


MVVM według wielu jest remedium na przeciążone kontrolery w architekturze opartej o MVC. Moim skromnym zdaniem, upychanie całości logiki w jednym pliku nie jest winą samego wzorca, a raczej niewłaściwej interpretacji ze strony programistów. Odkładając jednak dywagacje na bok, trzeba przyznać, że MVVM stanowi bardzo ciekawą alternatywę i warto się nad nim pochylić. Nie stanowi on jednak rozwiązania wszystkich problemów i podobnie jak MVC może zostać użyty w niewłaściwy sposób. Czyli jak zawsze, należy zachować zdrowy rozsądek.

We wpisie tym skupię się na przedstawieniu MVVM od strony praktycznej. Pokażę Wam jak, krok po korku, zaimplementować architekturę MVVM w prostej aplikacji androidowej.

Zacznijcie od pobrania projektu startowego znajdującego się pod tym linkiem.

Możecie sobie skompilować projekt, ale w tej chwili nie będzie on robił nic ciekawego. Za chwilę to oczywiście zmienimy.

Struktura projektu

Dla łatwego dostępu do poszczególnych komponentów struktura aplikacji powinna zostać utworzona w oparciu o poszczególne widoki.

Zgodnie z podziałem wzorca MVVM powinniśmy utworzyć osobne paczki dla modeli (model) widoków (view) oraz view modeli. W dodatkowym folderze będziemy przechowywali web service’y dla naszego widoku. Dzięki takiemu podziałowi będziemy mieli pewność, że dany web service nie będzie przesadnie rozrośnięty, co pomoże nie tylko łatwiej go zrozumieć, ale także ułatwi pisanie testów jednostkowych.

MainView

Implementację naszej aplikacji rozpoczniemy od głównego widoku. Otwórzcie sobie plik o nazwieMainActivity. W klasie znajdują się już dwa pola – disposable (odpowiedzialne za czyszczenie zasobów po obiektach Rx’owych) oraz linearLazoutManager, które posłuży nam do konfiguracji listy recycler view. Funkcje znajdujące się w obiekcie są w większości puste, a ich implementacją zajmiemy się za chwilę. Teraz skupimy się na obiekcie view model.

W przypadku platform takich jak na przykład iOS, podczas implementacji wzorca MVVM zdani jesteśmy całkowicie na siebie. Apple nie udostępnia żadnych bibliotek, czy też systemowego wsparcia, tak więc sposób implementacji jest w całości zależny od nas. Trochę inaczej sprawa ma się w przypadku platformy Android, dla której Google przygotowało autorską implementację wspieraną systemowo. Ma to kilka istotnych zalet, jak na przykład powiązanie obiektu view model z cyklem życia aktywności, o czym więcej będzie trochę później.

Zanim przejdziemy do właściwej implementacji, jeszcze mała dygresja odnośnie dependency injection, testowania oraz warstwy view. Podejście, które stosuję zakłada, że obiekty umieszczone w warstwie view powinny być całkowicie pozbawione logiki biznesowej. Ta powinna być przeniesiona do obiektu view model. Widoki powinny być odpowiedzialne jedynie za prezentację UI i obsługę interakcji z użytkownikiem aplikacji.

Takie podejście pozwala zachować odpowiednią separację pomiędzy poszczególnymi obiektami, a w konsekwencji ułatwi pisanie właściwych testów. Jeżeli założymy, że obiekt view nie będzie pokryty testami jednostkowymi, to możemy pominąć dependency injection dla obiektu view model. Idąc krok dalej, w przypadku niewielkich projektów, możemy zrezygnować z frameworków do DI (jak choćby Dagger), na rzecz prostszego rozwiązana jakim jest pure dependency injection. Więcej o tym rozwiązaniu przeczytacie w części poświęconej implementacji samego view modelu.

Dodajcie teraz kod poniżej zmiennej disposable:

private val viewModel by lazy {
    	ViewModelProviders.of(this).get(MainViewModel::class.java)
	}

Korzystając z obiektu ViewModelProviders tworzymy nową instancję obiektu MainViewModel. Właściwy kod zostanie wykonany w bloku lazy, dzięki czemu choć trochę odciążymy MainActivity podczas inicjalizacji. Obiekt viewModel zostanie utworzony dopiero w chwili, w której zostanie użyty po raz pierwszy. Jego nadpisanie nie będzie możliwe. Dlatego viewModel możemy zdefiniować jako stałą (val).

Jedna bardzo istotna uwaga. Tak utworzony obiekt viewModel będzie utrzymywany w pamięci do momentu zakończenia cyklu życia danego widoku, w tym wypadku głównej aktywności. W przypadku zmiany konfiguracji ekranu (na przykład przy obrocie ekranu) zachowana zostanie pierwotna instancja obiektu viewModel. Oznacza to, że nie musimy korzystać już z przekazywania danych na przykład za pomocą Bundla w onSaveInstanceState(). Wszystkie modele (czyli nasze dane) będę odporne na zmiany w cyklu życia danego widoku. Jak tylko widok będzie ponownie aktywny, UI zostanie zapełnione odpowiednimi danymi.

Idziemy dalej. W onCreate() wywołujemy metodę ibindUIData(), która w tej chwili jest pusta. Umieśćcie w niej poniższy kod:

viewModel.coins.subscribe(this, ::showAllCoins)
viewModel.progress.subscribe(this, ::updateProgress)
viewModel.errors.subscribe(this, ::showErrorMessage)

Za pomocą funkcji subscribe zapisujemy się do “nasłuchiwania” zmian zachodzących w modelach danych przechowywanych w obiekcie viewModel. O samych modelach powiemy sobie jak już dojdziemy do implementacji obiektu MainViewModel. Funkcja subscribe jest w rzeczywistości sprytnym rozszerzeniem, które pozwala na dokonanie subskrypcji w bardziej przyjazny sposób, niż standardowe rozwiązanie systemowe. Do funkcji musimy przekazać dwa parametry: właściciela cyklu życia (lifecycle owner) oraz referencję do funkcji, która ma zostać wykonana w przypadku aktualizacji danego modelu przechowywanego przez view model. Implementacje poszczególnych funkcji są oczywiste, dlatego nie będziemy się na nich skupiać.

Do uzupełnienia pozostała nam jeszcze tylko funkcja bindUIGestures(), za pomocą której przypiszemy odpowiednią akcję dla przycisku uruchamiającego pobieranie danych z serwera. Korzystać będziemy tutaj z dwóch bardzo przydatnych narzędzi — Kotlin Android Extensions oraz RxBinding.

Pierwsze z nich pozwoli nam szybkie powiązanie widoku z layoutu (bez konieczności wywoływania findViewById()), natomiast drugie pozwoli nam na przypisanie do buttona odpowiedniej akcji w stylu Rx’owym. Dodajcie teraz poniższy kod wewnątrz funkcji bindGestures():

 disposable = downloadButton.clicks()
        	.observeOnMainThread()
        	.subscribe {
            	viewModel.getCoinsData()
        	}

Z racji tego, że “kliknięcie” w przycisk będzie traktowane jako standardowa subskrypcja Rx’owa, konieczne będzie przypisanie jej do obiektu disposable, który w odpowiednim momencie usunie z pamięci urządzenia zbędne zasoby. W tym konkretnym wypadku stanie się to w chwili wywołania metody onPause()obiektu MainActivity.

Interakcja użytkownika z przyciskiem spowoduje wywołanie na obiekcie viewModel metody getCoinsData() odpowiedzialnej za pobranie danych z serwera (za chwilę do niej zajrzymy). Waszą uwagę mógł zwrócić fakt, że korzystamy z obiektu downloadButton, choć tak naprawdę nigdzie nie widać jego inicjalizacji. Ta sztuczka dostępna jest właśnie dzięki Kotlin Android Extensions (KTX), które to rozszerzenia pozwalają nam na redukcję powtarzalnego kodu i skupienie się na rzeczach naprawdę istotnych. W tym konkretnym przypadku musimy podać jedynie id, które zostało przypisane danemu widokowi w naszym layoucie, a Android Studio magicznie doda na samej górze stosowny import:

import kotlinx.android.synthetic.main.activity_main.*

Gwiazdka na końcu informuje oczywiście o tym, że chcemy uzyskać dostęp do wszystkich widoków znajdujących się w danym layoucie. KTX potrafią jednak znacznie więcej, a poczytać o nich możecie sobie w oficjalnej dokumentacji: https://kotlinlang.org/docs/tutorials/android-plugin.html.

Jeżeli chcecie sprawdzić jak rozszerzenia działają pod spodem, to proponuję skompilować projekt do java bytecode, a następnie zdekompilować do Javy. Wynik może Was zaskoczyć 😉

Na koniec tej części jeszcze tylko szybka wzmianka odnośnie metody observeOnMainThread(). Z racji tego, że pracować będziemy w elementami UI, musimy mieć pewność, że wszystkie zdarzenia będą dostarczane na wątku głównym aplikacji. Metoda observeOnMainThread() jest tylko rozszerzeniem (taki syntactic sugar), który zapewni nam obserwowanie subskrypcji na właściwym wątku.

Implementacja MainViewModel

Plik MainViewModel jest zupełnie pusty, ponieważ tym razem całą implementację wykonamy całkowicie od zera. Zaczniemy od utworzenia konstruktora, w którym będziemy “wstrzykiwali” naszą zależność w postaci ApiService, który został oparty o abstrakcję w postaci ApiServiceInterface (nazwa nie jest zbyt trafiona, ale chodzi tutaj tylko o zaprezentowanie samej koncepcji). Wstawcie teraz poniższy kod do pliku MainViewModel:

class MainViewModel(
	private val apiService: ApiServiceInterface = ApiService()
): ViewModel() {

}

Taki sposób wstrzykiwania zależności określany jest jako Pure Dependency Injection, z racji tego, że nie korzystamy tutaj z żadnych dodatkowych bibliotek. Wstrzykiwanie zależności połączone z abstrakcją w postaci interfejsu okaże się bardzo przydatne podczas pisania testów jednostkowych (do tego też dojedziemy).

Z racji tego, że będziemy korzystali z systemowego rozwiązania, obiekt MainViewModel musi dziedziczyć po obiekcie ViewModel wchodzącego w skład paczki androidx.lifecycle.ViewModel. Dzięki temu nasz view model będzie z automatu wyposażony w takie rozwiązania jak na przykład możliwość obserwowania cyklu życia obiektu, do którego został przypisany.

Zanim przejdziemy do dalszej implementacji musimy powiedzieć sobie kilka słów o LiveData. Obiekty LiveData są kolejnym (obok ViewModel) rozwiązaniem dostarczonym przez Google’a, które pomóc ma w uporządkowaniu architektury w systemie Android. W obiektach LiveData przechowywane są modele danych, a inne obiekty (takie jak na przykład Aktywności) mogą zapisać się do nasłuchiwania zmian zachodzących w danym modelu. To właśnie robiliśmy w MainActivity wywołując funkcję subscribe.

LiveData jest świadomy tego w jakim stanie znajduje się aktualnie obiekt (jest life cycle aware), dla którego ma dostarczyć dane. Dlatego aktualizacja elementów UI nie będzie dokonywana na przykład w chwili, gdy aktywność nie będzie widoczna na ekranie. Dany komponent po wybudzeniu zawsze dostanie najbardziej aktualne dane. To właśnie dlatego LiveData tak dobrze komponuje się ze wzorcem MVVM.

Wróćmy teraz do samej implementacji. Dodajcie teraz poniższy kod tuż pod konstruktorem:

	val coins: LiveData<List<CoinModel>>
    	get() = coinsData

	val progress: LiveData<Boolean>
    	get() = progressData

	val errors: LiveData<ErrorMessage>
    	get() = errorsData

	val coinsCount: Int
    	get() = coinsData.value?.count() ?: 0

	private val coinsData = MutableLiveData<List<CoinModel>>()
	private val progressData = MutableLiveData<Boolean>(false)
	private val errorsData = MutableLiveData<ErrorMessage>()

Stosujemy tutaj zabezpieczenie w postaci publicznych, niemutowalnych obiektów LiveData, które będą pod spodem udostępniały prywatny, mutowalny odpowiednik. Takie rozwiązanie da nam pewność, że komponent korzystający z LiveData nie będzie mógł przypadkowo zaktualizować naszych modeli. W architekturze MVVM obiekty znajdujące się w koszyku View nie powinny zajmować się manipulacją na modelach danych. Do tego właśnie przeznaczony jest obiekt ViewModel.

coinsData będzie odpowiadało za przechowywanie danych o aktualnych notowaniach kryptowalut, progressData pomoże nam w określeniu, czy pokazywać wskaźnik aktywności, natomiast errorsData będzie przechowywało customowy obiekt zawierający informacje o błędach, jakie potencjalnie mogą wystąpić podczas połączenia ze zdalnym serwerem.

Możemy teraz zająć się implementacją funkcji getCoinsData(). Wewnątrz funkcji wstawcie poniższy kod:

disposable?.dispose()

    	disposable = apiService.getAllCoins()
        	.subscribeOnIOThread()
        	.observeOnMainThread()
        	.withProgress(progressData)
        	.showErrorMessages(errorsData)
        	.subscribe {
            	coinsData.value = it
        	}

W pierwszym kroku upewniamy się, że nasz disposable został wyczyszczony ze wszystkich zbędnych zasobów, a następnie przypisujemy do niego nową subskrypcję, która pod spodem korzysta ze standardowego rozwiązania oferowanego przez Retrofit.

subscribeOnIOThread(), podobnie do observeOnMainThread() (z którym spotkaliśmy się już w Main Activity) jest prostym rozszerzeniem, które upiększa trochę standardowy zapis stosowany podczas pracy z rx’owymi subskrypcjami.

withProgress(progressData) oraz showErrorMessages(errorsData) stanowią sprytne połączenie RxJavy z LiveData. W przypadku pierwszego tworzymy customowy obiekt Observable, który w chwili wykonania subskrypcji (czyli w chwili, w której zaczniemy pobieranie danych z serwera) zaktualizuje obiekt LiveData o wartość true. W przypadku, gdy subskrypcja zostanie zakończona, obiekt LiveData otrzyma wartość false, co będzie oznaczało, że można schować wskaźnik aktywności.

showErrorMessages(errorsData) działa na podobnej zasadzie, z tą tylko różnicą, że będzie on aktualizował obiekt LivaData błędem, który spowoduje przerwanie subskrypcji. Zostanie on przekazany do widoku głównego, a ostatecznie wyświetlony na ekranie za pomocą standardowego snack bara. Dla uproszczenia przykładu obsługujemy tylko podstawową wersję błędu.

Końcowym krokiem jest oczywiście wykonanie właściwej subskrypcji, a następnie aktualizowanie obiektu coinData o nową wartość.

Api Webservie

Zanim przejdziemy do przykładowych testów, rzucimy jeszcze szybko okiem na implementację obiektu ApiService. Została ona oparta na abstrakcji, co przyda nam się podczas pisania testów (łatwo będzie wstawić mocka do obiektu view model). W konstruktorze ponownie korzystamy ze wstrzykiwania zależności, tym razem jednak wstrzykując adres url, który wskazuje miejsce zasobów na serwerze. Z łatwością będziemy mogli podać adres do lokalnego serwera, które będzie przesyłał nam na sztywno zdefiniowane dane do testów. Implementacja funkcji getAllCoins() stanowi standardowe rozwiązania oferowane przez Retrofit.

Testy jednostkowe

Pozostało nam jeszcze tylko napisać kilka prostych testów jednostkowych dla obiektu viewmodel praz api service. Dzięki temu, że zachowaliśmy odpowiednią separację poszczególnych komponentów, podczas testowania nie będziemy musieli korzystać z zależności Androidowych. Dla zwiększenia czytelności przykładu, wszystkie testy zostały umieszczone w tym samym pliku. W wersji produkcyjnej apki testy te powinny znaleźć się oczywiście w osobnych klasach.

Zaczniemy od utworzenia prostego mocka dla obiektu ApiService. Wstawcie poniższy kod wewnątrz klas ApiServiceMock:

override fun getAllCoins(): Observable<List<CoinModel>> {

    	val coinModel1 = CoinModel(
        	"",
        	"",
        	"",
        	"",
        	"",
        	"",
        	"Bitcoin test'.
    	)

    	val coinModel2 = CoinModel(
        	"",
        	"",
        	"",
        	"",
        	"",
        	"",
        	"Ethereum test'.
    	)

    	val coins = mutableListOf(coinModel1, coinModel2)

    	return Observable.just(coins)
	}

Nadpisujemy jedyną funkcję, która została umieszczona w interfejsie ApiServiceInterface. Funkcja będzie zwracała na sztywno zdefiniowane dane, które posłużą nam do testowania obiektu MainViewModel.

Otwórzcie teraz sobie plik ExampleUnitTest i dodajcie na samej górze obiektu poniższy kod:

@get:Rule
val rule = InstantTaskExecutorRule()

Powyższa reguła JUnit została zawarta w pakiecie candroid.arch.core:core-testing:1.1.1. Będzie ona potrzebna podczas testowania obiektów LiveData. Istnieją dwa sposoby aktualizowania danych w obiekcie LiveData. Pierwszym z nich jest funkcja setValue(), która dostarcza dane natychmiast do aktywnych obserwerów, korzystając z wątku głównego aplikacji. Natomiast druga w postaci postValue() dostarcza jedynie na wątek główny odpowiednie zadanie, informując go, że dane czekają na aktualizację. Korzystając z zasady InstantTaskExecutorRule, będziemy mieli pewność, że wszystkie dane dostarczane będą synchronicznie, co ułatwi nam wykonywanie testów.

Wstawcie teraz podany kod tuż pod definicją reguły:

private val viewModel = MainViewModel(ApiServiceMock())
private val apiService = ApiService("http://localhost:1234/androidmvvmdemo/v1/ticker/")
private val testObserver = TestObserver<List<CoinModel>>()

viewModel jest instancją obiektu MainViewModel, do której zostaje wstrzyknięta zmockowana implementacja ApiService. Dzięki temu nie będzie konieczności nawiązywania połączenia z prawdziwym serwerem, aby otrzymać dane potrzebne do testowania.

apiService jest instancją obiektu ApiService, do której został wstrzyknięty adres url serwera testowego. Taki serwer może zostać napisany na przykład za pomocą Node.js. Można też skorzystać z gotowych i łatwych w implementacji rozwiązań, jak na przykład Wiremock. Ważne jest tylko, aby serwer był uruchamiany lokalnie, tak aby podczas testowania obiektu ApiService nie było konieczności polegania na stanie połączenia internetowego. Taki serwer można również bardzo łatwo zintegrować z wybranym przez nas rozwiązaniem Continuous Integration.

testObserver jest instancją obiektu TestObserver, który wykorzystywany jest do testowania subskrypcji Rx’owych. Na samym dole klasy ExampleUnitTest znajduje się statyczny obiekt, bez którego podczas wykonywania testów zostałby wywołany błąd “Method getMainLooper in android.os.Looper not mocked”. Dzieje się tak dlatego, że podczas tworzenia subskrypcji rx’owej w obiekcie viewModel korzystamy z AndroidSchedulers.mainThread(), który zwraca nam instancję LooperScheduler. Obiekt ten korzysta z zależności Androidowych, niedostępnych podczas wykonywania testów jednostkowych. Wspomniane rozwiązanie znalazłem w tej odpowiedzi na StackOverflow.

Pozostało nam już tylko dodać kilka przykładowych testów. Wstawcie poniższy kod pod definicją testObservera:

@Test
	fun viewModelTest() {

    	val initialCount = viewModel.coinsCount

    	viewModel.getCoinsData()

    	assertTrue(viewModel.coinsCount > initialCount)

    	assertFalse(viewModel.progress.value!!)
    	assertEquals(viewModel.errors.value?.getMessage(), null)
	}

	@Test
	fun apiTest() {
    	apiService.getAllCoins()
        	.subscribe {
            	assertTrue(it.count() > 0)
        	}
	}

	@Test
	fun observableApiTest() {
    	apiService.getAllCoins()
        	.subscribe(testObserver)

    	testObserver.assertSubscribed()
        	.assertComplete()
        	.assertNoErrors()
	}

Za pomocą testu viewModelTest() sprawdzamy, czy wywołanie funkcji getCoinsData() wywoła prawidłową aktualizację danych przechowywanych w obiektach LiveData. Warto tutaj przypomnieć, że MainViewModel będzie korzystał z danych dostarczanych mu przez zmockowany obiekt ApiService.

Test apiTest() sprawdzi, czy obiekt ApiService prawidłowo pobiera dane, natomiast observableApiTest() sprawdzi, czy subskrypcja wykona się tak jak powinna. Zarówno jeden jak i drugi test nie wykona się oczywiście poprawnie, jeżeli nie będziecie mieli uruchomionego lokalnego serwera do testowania.

Powyższe testy stanowią jedynie bardzo małą próbkę tego, jak można podejść do testowania poszczególnych komponentów wchodzących w skład wzorca MVVM. Wystarczą one jednak do zobrazowania podstawowej koncepcji, która może zostać rozszerzona następnie o bardziej szczegółowe przypadki.

Podsumowanie

Za pomocą tego trochę przydługiego wpisu, pokazałem Wam jedno z możliwych rozwiązań, które można zastosować podczas implementacji wzorca MVVM w aplikacji Androidowej. Sprawdzi się ono bardzo dobrze w niewielkich projektach, w których nie chcemy korzystać z rozbudowanych bibliotek zewnętrznych (na przykład w postaci Daggera), ale jednocześnie chcemy zapewnić sobie możliwość pisania testów jednostkowych. W przypadku większych projektów rozwiązanie to będzie wymagało pewnego rozbudowania na przykład o data binding.

Ukończony projekt możecie znaleźć pod tym adresem.


Artykuł został pierwotnie opublikowany na itcraftapps.com. Zdjęcie główne artykułu pochodzi z unsplash.com.

Wraz z Tomaszem Gańskim jestem współtwórcą justjoin.it - największego job boardu dla polskiej branży IT. Portal daje tym samym największy wybór spośród branżowych stron na polskim rynku. Rozwijamy go organicznie, serdecznie zapraszam tam również i Ciebie :)

Podobne artykuły

[wpdevart_facebook_comment curent_url="https://geek.justjoin.it/prosta-implementacja-mvvm-w-aplikacji-na-androida/" order_type="social" width="100%" count_of_comments="8" ]