Lepsze aplikacje na Androida przy użyciu MVVM z czystą architekturą
Opublikowany: 2022-03-11Jeś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:
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:
- Warstwa prezentacji
- Warstwa domeny
- Warstwa danych
Poniżej omówimy bardziej szczegółowo każdą warstwę. Na razie nasza wynikowa struktura pakietu wygląda tak:
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ć:
- Użyj LiveData lub RxJava, aby usunąć wywołania zwrotne i sprawić, by był trochę schludniejszy.
- Użyj stanów do reprezentowania swojego interfejsu użytkownika. (W tym celu obejrzyj tę niesamowitą przemowę Jake'a Whartona.)
- 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!