Lepsze aplikacje na Androida przy użyciu MVVM z czystą architekturą

Opublikowany: 2022-03-11

Jeśli nie wybierzesz odpowiedniej architektury dla swojego projektu Androida, będziesz mieć trudności z utrzymaniem jej w miarę wzrostu bazy kodu i powiększania się zespołu.

To nie jest tylko samouczek Android MVVM. W tym artykule połączymy MVVM (Model-View-ViewModel lub czasami stylizowany „wzorzec ViewModel”) z Czystą Architekturą. Zobaczymy, jak tę architekturę można wykorzystać do pisania rozłącznego, testowalnego i konserwowalnego kodu.

Dlaczego MVVM z czystą architekturą?

MVVM oddziela widok (tj. Activity s i Fragment ) od logiki biznesowej. MVVM jest wystarczający dla małych projektów, ale kiedy baza kodu staje się ogromna, Twój ViewModel zaczyna się powiększać. Rozdzielenie obowiązków staje się trudne.

MVVM z czystą architekturą jest w takich przypadkach całkiem niezły. Idzie o krok dalej w rozdzielaniu obowiązków Twojej bazy kodu. Wyraźnie przedstawia logikę działań, które można wykonać w Twojej aplikacji.

Uwaga: Czystą architekturę można również połączyć z architekturą model-widok-prezenter (MVP). Ale ponieważ komponenty architektury Androida zapewniają już wbudowaną klasę ViewModel , korzystamy z MVVM over MVP — nie jest wymagana struktura MVVM!

Zalety korzystania z czystej architektury

  • Twój kod jest jeszcze łatwiej testowalny niż w przypadku zwykłego MVVM.
  • Twój kod jest dalej oddzielony (największa zaleta).
  • Struktura pakietu jest jeszcze łatwiejsza w nawigacji.
  • Projekt jest jeszcze łatwiejszy w utrzymaniu.
  • Twój zespół może jeszcze szybciej dodawać nowe funkcje.

Wady Czystej Architektury

  • Ma nieco stromą krzywą uczenia się. Zrozumienie, w jaki sposób wszystkie warstwy współpracują ze sobą, może zająć trochę czasu, zwłaszcza jeśli pochodzisz z wzorców takich jak prosty MVVM lub MVP.
  • Dodaje wiele dodatkowych klas, więc nie jest idealny do projektów o niskiej złożoności.

Nasz przepływ danych będzie wyglądał tak:

Przepływ danych MVVM z czystą architekturą. Dane przepływają z widoku do modelu ViewModel do domeny do repozytorium danych, a następnie do źródła danych (lokalnego lub zdalnego).

Nasza logika biznesowa jest całkowicie oddzielona od naszego interfejsu użytkownika. Dzięki temu nasz kod jest bardzo łatwy w utrzymaniu i testowaniu.

Przykład, który zobaczymy, jest dość prosty. Umożliwia użytkownikom tworzenie nowych postów i przeglądanie listy postów przez nich utworzonych. W tym przykładzie nie używam żadnej biblioteki innej firmy (takiej jak Dagger, RxJava, itp.) dla uproszczenia.

Warstwy MVVM z czystą architekturą

Kod podzielony jest na trzy oddzielne warstwy:

  1. Warstwa prezentacji
  2. Warstwa domeny
  3. Warstwa danych

Poniżej omówimy bardziej szczegółowo każdą warstwę. Na razie nasza wynikowa struktura pakietu wygląda tak:

MVVM ze strukturą pakietu Clean Architecture.

Nawet w architekturze aplikacji na Androida, której używamy, istnieje wiele sposobów na uporządkowanie hierarchii plików/folderów. Lubię grupować pliki projektów na podstawie cech. Uważam, że jest schludny i zwięzły. Możesz wybrać dowolną strukturę projektu, która Ci odpowiada.

Warstwa prezentacji

Obejmuje to nasze Activity , Fragment i ViewModel . Activity powinno być tak głupie, jak to tylko możliwe. Nigdy nie umieszczaj logiki biznesowej w Activity s.

Activity będzie komunikować się z ViewModel , a ViewModel będzie komunikować się z warstwą domeny w celu wykonania działań. ViewModel nigdy nie komunikuje się bezpośrednio z warstwą danych.

Tutaj przekazujemy UseCaseHandler i dwa UseCase s do naszego ViewModel . Wkrótce zajmiemy się tym bardziej szczegółowo, ale w tej architekturze UseCase to akcja, która definiuje sposób interakcji ViewModel z warstwą danych.

Oto jak wygląda nasz kod Kotlin:

 class PostListViewModel( val useCaseHandler: UseCaseHandler, val getPosts: GetPosts, val savePost: SavePost): ViewModel() { fun getAllPosts(userId: Int, callback: PostDataSource.LoadPostsCallback) { val requestValue = GetPosts.RequestValues(userId) useCaseHandler.execute(getPosts, requestValue, object : UseCase.UseCaseCallback<GetPosts.ResponseValue> { override fun onSuccess(response: GetPosts.ResponseValue) { callback.onPostsLoaded(response.posts) } override fun onError(t: Throwable) { callback.onError(t) } }) } fun savePost(post: Post, callback: PostDataSource.SaveTaskCallback) { val requestValues = SavePost.RequestValues(post) useCaseHandler.execute(savePost, requestValues, object : UseCase.UseCaseCallback<SavePost.ResponseValue> { override fun onSuccess(response: SavePost.ResponseValue) { callback.onSaveSuccess() } override fun onError(t: Throwable) { callback.onError(t) } }) } }

Warstwa domeny

Warstwa domeny zawiera wszystkie przypadki użycia Twojej aplikacji. W tym przykładzie mamy UseCase , klasę abstrakcyjną. Wszystkie nasze UseCase rozszerzą tę klasę.

 abstract class UseCase<Q : UseCase.RequestValues, P : UseCase.ResponseValue> { var requestValues: Q? = null var useCaseCallback: UseCaseCallback<P>? = null internal fun run() { executeUseCase(requestValues) } protected abstract fun executeUseCase(requestValues: Q?) /** * Data passed to a request. */ interface RequestValues /** * Data received from a request. */ interface ResponseValue interface UseCaseCallback<R> { fun onSuccess(response: R) fun onError(t: Throwable) } }

A UseCaseHandler obsługuje wykonanie UseCase . Nigdy nie powinniśmy blokować interfejsu użytkownika, gdy pobieramy dane z bazy danych lub naszego zdalnego serwera. To jest miejsce, w którym decydujemy się wykonać nasz UseCase w wątku w tle i otrzymać odpowiedź w wątku głównym.

 class UseCaseHandler(private val mUseCaseScheduler: UseCaseScheduler) { fun <T : UseCase.RequestValues, R : UseCase.ResponseValue> execute( useCase: UseCase<T, R>, values: T, callback: UseCase.UseCaseCallback<R>) { useCase.requestValues = values useCase.useCaseCallback = UiCallbackWrapper(callback, this) mUseCaseScheduler.execute(Runnable { useCase.run() }) } private fun <V : UseCase.ResponseValue> notifyResponse(response: V, useCaseCallback: UseCase.UseCaseCallback<V>) { mUseCaseScheduler.notifyResponse(response, useCaseCallback) } private fun <V : UseCase.ResponseValue> notifyError( useCaseCallback: UseCase.UseCaseCallback<V>, t: Throwable) { mUseCaseScheduler.onError(useCaseCallback, t) } private class UiCallbackWrapper<V : UseCase.ResponseValue>( private val mCallback: UseCase.UseCaseCallback<V>, private val mUseCaseHandler: UseCaseHandler) : UseCase.UseCaseCallback<V> { override fun onSuccess(response: V) { mUseCaseHandler.notifyResponse(response, mCallback) } override fun onError(t: Throwable) { mUseCaseHandler.notifyError(mCallback, t) } } companion object { private var INSTANCE: UseCaseHandler? = null fun getInstance(): UseCaseHandler { if (INSTANCE == null) { INSTANCE = UseCaseHandler(UseCaseThreadPoolScheduler()) } return INSTANCE!! } } }

Jak sama nazwa wskazuje, GetPosts UseCase jest odpowiedzialny za pobranie wszystkich postów użytkownika.

 class GetPosts(private val mDataSource: PostDataSource) : UseCase<GetPosts.RequestValues, GetPosts.ResponseValue>() { protected override fun executeUseCase(requestValues: GetPosts.RequestValues?) { mDataSource.getPosts(requestValues?.userId ?: -1, object : PostDataSource.LoadPostsCallback { override fun onPostsLoaded(posts: List<Post>) { val responseValue = ResponseValue(posts) useCaseCallback?.onSuccess(responseValue) } override fun onError(t: Throwable) { // Never use generic exceptions. Create proper exceptions. Since // our use case is different we will go with generic throwable useCaseCallback?.onError(Throwable("Data not found")) } }) } class RequestValues(val userId: Int) : UseCase.RequestValues class ResponseValue(val posts: List<Post>) : UseCase.ResponseValue }

Celem UseCase jest bycie mediatorem między Twoim ViewModel i Repository .

Załóżmy, że w przyszłości zdecydujesz się dodać funkcję „edytuj post”. Wszystko, co musisz zrobić, to dodać nowy przypadek UseCase EditPost cały jego kod będzie całkowicie oddzielny i oddzielony od innych UseCase . Wszyscy widzieliśmy to wiele razy: wprowadzane są nowe funkcje, które nieumyślnie psują coś w istniejącym kodzie. Tworzenie oddzielnego UseCase pomaga ogromnie w uniknięciu tego.

Oczywiście nie można wyeliminować tej możliwości w 100 procentach, ale na pewno można ją zminimalizować. To właśnie odróżnia czystą architekturę od innych wzorców: kod jest tak oddzielony, że każdą warstwę można traktować jako czarną skrzynkę.

Warstwa danych

Zawiera wszystkie repozytoria, z których może korzystać warstwa domeny. Ta warstwa udostępnia interfejs API źródła danych zewnętrznym klasom:

 interface PostDataSource { interface LoadPostsCallback { fun onPostsLoaded(posts: List<Post>) fun onError(t: Throwable) } interface SaveTaskCallback { fun onSaveSuccess() fun onError(t: Throwable) } fun getPosts(userId: Int, callback: LoadPostsCallback) fun savePost(post: Post) }

PostDataRepository implementuje PostDataSource . Decyduje o tym, czy pobieramy dane z lokalnej bazy danych, czy ze zdalnego serwera.

 class PostDataRepository private constructor( private val localDataSource: PostDataSource, private val remoteDataSource: PostDataSource): PostDataSource { companion object { private var INSTANCE: PostDataRepository? = null fun getInstance(localDataSource: PostDataSource, remoteDataSource: PostDataSource): PostDataRepository { if (INSTANCE == null) { INSTANCE = PostDataRepository(localDataSource, remoteDataSource) } return INSTANCE!! } } var isCacheDirty = false override fun getPosts(userId: Int, callback: PostDataSource.LoadPostsCallback) { if (isCacheDirty) { getPostsFromServer(userId, callback) } else { localDataSource.getPosts(userId, object : PostDataSource.LoadPostsCallback { override fun onPostsLoaded(posts: List<Post>) { refreshCache() callback.onPostsLoaded(posts) } override fun onError(t: Throwable) { getPostsFromServer(userId, callback) } }) } } override fun savePost(post: Post) { localDataSource.savePost(post) remoteDataSource.savePost(post) } private fun getPostsFromServer(userId: Int, callback: PostDataSource.LoadPostsCallback) { remoteDataSource.getPosts(userId, object : PostDataSource.LoadPostsCallback { override fun onPostsLoaded(posts: List<Post>) { refreshCache() refreshLocalDataSource(posts) callback.onPostsLoaded(posts) } override fun onError(t: Throwable) { callback.onError(t) } }) } private fun refreshLocalDataSource(posts: List<Post>) { posts.forEach { localDataSource.savePost(it) } } private fun refreshCache() { isCacheDirty = false } }

Kod jest w większości oczywisty. Ta klasa ma dwie zmienne, localDataSource i remoteDataSource . Ich typ to PostDataSource , więc nie obchodzi nas, w jaki sposób są faktycznie zaimplementowane pod maską.

Z mojego osobistego doświadczenia ta architektura okazała się nieoceniona. W jednej z moich aplikacji zacząłem od Firebase na zapleczu, co świetnie nadaje się do szybkiego tworzenia aplikacji. Wiedziałem, że w końcu będę musiał przejść na własny serwer.

Kiedy to zrobiłem, wszystko, co musiałem zrobić, to zmienić implementację w RemoteDataSource . Nie musiałem dotykać żadnych innych zajęć nawet po tak dużej zmianie. To jest zaleta kodu oddzielonego. Zmiana dowolnej klasy nie powinna wpływać na inne części kodu.

Niektóre z naszych dodatkowych zajęć to:

 interface UseCaseScheduler { fun execute(runnable: Runnable) fun <V : UseCase.ResponseValue> notifyResponse(response: V, useCaseCallback: UseCase.UseCaseCallback<V>) fun <V : UseCase.ResponseValue> onError( useCaseCallback: UseCase.UseCaseCallback<V>, t: Throwable) } class UseCaseThreadPoolScheduler : UseCaseScheduler { val POOL_SIZE = 2 val MAX_POOL_SIZE = 4 val TIMEOUT = 30 private val mHandler = Handler() internal var mThreadPoolExecutor: ThreadPoolExecutor init { mThreadPoolExecutor = ThreadPoolExecutor(POOL_SIZE, MAX_POOL_SIZE, TIMEOUT.toLong(), TimeUnit.SECONDS, ArrayBlockingQueue(POOL_SIZE)) } override fun execute(runnable: Runnable) { mThreadPoolExecutor.execute(runnable) } override fun <V : UseCase.ResponseValue> notifyResponse(response: V, useCaseCallback: UseCase.UseCaseCallback<V>) { mHandler.post { useCaseCallback.onSuccess(response) } } override fun <V : UseCase.ResponseValue> onError( useCaseCallback: UseCase.UseCaseCallback<V>, t: Throwable) { mHandler.post { useCaseCallback.onError(t) } } }

UseCaseThreadPoolScheduler odpowiada za wykonywanie zadań asynchronicznie przy użyciu ThreadPoolExecuter .

 class ViewModelFactory : ViewModelProvider.Factory { override fun <T : ViewModel> create(modelClass: Class<T>): T { if (modelClass == PostListViewModel::class.java) { return PostListViewModel( Injection.provideUseCaseHandler() , Injection.provideGetPosts(), Injection.provideSavePost()) as T } throw IllegalArgumentException("unknown model class $modelClass") } companion object { private var INSTANCE: ViewModelFactory? = null fun getInstance(): ViewModelFactory { if (INSTANCE == null) { INSTANCE = ViewModelFactory() } return INSTANCE!! } } }

To jest nasz ViewModelFactory . Musisz to stworzyć, aby przekazać argumenty w twoim konstruktorze ViewModel .

Wstrzykiwanie zależności

Wyjaśnię wstrzykiwanie zależności na przykładzie. Jeśli spojrzysz na naszą klasę PostDataRepository , ma ona dwie zależności, LocalDataSource i RemoteDataSource . Używamy klasy Injection , aby dostarczyć te zależności do klasy PostDataRepository .

Wstrzykiwanie zależności ma dwie główne zalety. Jednym z nich jest to, że możesz kontrolować tworzenie instancji obiektów z centralnego miejsca, zamiast rozkładać je na całą bazę kodu. Innym jest to, że pomoże nam to pisać testy jednostkowe dla PostDataRepository , ponieważ teraz możemy po prostu przekazywać symulowane wersje LocalDataSource i RemoteDataSource do konstruktora PostDataRepository zamiast rzeczywistych wartości.

 object Injection { fun providePostDataRepository(): PostDataRepository { return PostDataRepository.getInstance(provideLocalDataSource(), provideRemoteDataSource()) } fun provideViewModelFactory() = ViewModelFactory.getInstance() fun provideLocalDataSource(): PostDataSource = LocalDataSource.getInstance() fun provideRemoteDataSource(): PostDataSource = RemoteDataSource.getInstance() fun provideGetPosts() = GetPosts(providePostDataRepository()) fun provideSavePost() = SavePost(providePostDataRepository()) fun provideUseCaseHandler() = UseCaseHandler.getInstance() }

Uwaga: wolę używać Daggera 2 do wstrzykiwania zależności w złożonych projektach. Ale dzięki niezwykle stromej krzywej uczenia się wykracza to poza zakres tego artykułu. Więc jeśli jesteś zainteresowany głębią, gorąco polecam wprowadzenie Hari Vignesha Jayapalana do Dagger 2.

MVVM z czystą architekturą: solidna kombinacja

Naszym celem w tym projekcie było zrozumienie MVVM z czystą architekturą, więc pominęliśmy kilka rzeczy, które możesz spróbować ulepszyć:

  1. Użyj LiveData lub RxJava, aby usunąć wywołania zwrotne i sprawić, by był trochę schludniejszy.
  2. Użyj stanów do reprezentowania swojego interfejsu użytkownika. (W tym celu obejrzyj tę niesamowitą przemowę Jake'a Whartona.)
  3. Użyj Daggera 2, aby wstrzyknąć zależności.

Jest to jedna z najlepszych i najbardziej skalowalnych architektur dla aplikacji na Androida. Mam nadzieję, że podobał Ci się ten artykuł i nie mogę się doczekać, aby usłyszeć, jak wykorzystałeś to podejście we własnych aplikacjach!

Powiązane: Xamarin Forms, MVVMCross i SkiaSharp: The Holy Trinity of Cross-Platform App Development