Aplicații Android mai bune folosind MVVM cu arhitectură curată

Publicat: 2022-03-11

Dacă nu alegeți arhitectura potrivită pentru proiectul dvs. Android, veți avea dificultăți să o mențineți pe măsură ce baza de cod crește și echipa se extinde.

Acesta nu este doar un tutorial Android MVVM. În acest articol, vom combina MVVM (Model-View-ViewModel sau uneori stilizat „modelul ViewModel”) cu Clean Architecture. Vom vedea cum poate fi folosită această arhitectură pentru a scrie cod decuplat, testabil și menținut.

De ce MVVM cu arhitectură curată?

MVVM vă separă vizualizarea (de exemplu, Activity și Fragment ) de logica dvs. de afaceri. MVVM este suficient pentru proiecte mici, dar când baza de cod devine uriașă, ViewModel -urile încep să se baloneze. Separarea responsabilităților devine dificilă.

MVVM cu arhitectură curată este destul de bun în astfel de cazuri. Se face un pas mai departe în separarea responsabilităților bazei de cod. Abstrage în mod clar logica acțiunilor care pot fi efectuate în aplicația dvs.

Notă: Puteți combina arhitectura curată și arhitectura model-view-presenter (MVP). Dar, deoarece Android Architecture Components oferă deja o clasă ViewModel , mergem cu MVVM peste MVP - nu este necesar un cadru MVVM!

Avantajele utilizării arhitecturii curate

  • Codul dvs. este chiar mai ușor de testat decât cu MVVM simplu.
  • Codul dvs. este decuplat în continuare (cel mai mare avantaj.)
  • Structura pachetului este chiar mai ușor de navigat.
  • Proiectul este și mai ușor de întreținut.
  • Echipa ta poate adăuga noi funcții și mai rapid.

Dezavantajele arhitecturii curate

  • Are o curbă de învățare ușor abruptă. Înțelegerea modului în care toate straturile funcționează împreună poate dura ceva timp, mai ales dacă veniți de la modele precum MVVM sau MVP simplu.
  • Adaugă o mulțime de clase suplimentare, așa că nu este ideal pentru proiecte cu complexitate redusă.

Fluxul nostru de date va arăta astfel:

Fluxul de date al MVVM cu arhitectură curată. Datele circulă de la View la ViewModel la domeniu la depozitul de date și apoi la o sursă de date (locală sau la distanță).

Logica noastră de afaceri este complet decuplată de interfața noastră de utilizare. Face codul nostru foarte ușor de întreținut și testat.

Exemplul pe care îl vom vedea este destul de simplu. Permite utilizatorilor să creeze postări noi și să vadă o listă de postări create de ei. Nu folosesc nicio bibliotecă terță parte (cum ar fi Dagger, RxJava etc.) în acest exemplu de dragul simplității.

Straturile MVVM cu arhitectură curată

Codul este împărțit în trei straturi separate:

  1. Stratul de prezentare
  2. Stratul de domeniu
  3. Stratul de date

Vom intra în mai multe detalii despre fiecare strat de mai jos. Pentru moment, structura noastră de pachet rezultat arată astfel:

MVVM cu structura pachetului Clean Architecture.

Chiar și în arhitectura aplicației Android pe care o folosim, există multe modalități de a vă structura ierarhia fișierelor/dosarelor. Îmi place să grupez fișierele de proiect pe baza caracteristicilor. Mi se pare îngrijit și concis. Sunteți liber să alegeți orice structură de proiect vi se potrivește.

Stratul de prezentare

Acestea includ Activity noastre, Fragment și ViewModel de vizualizare. O Activity ar trebui să fie cât mai stupidă posibil. Nu puneți niciodată logica afacerii dvs. în Activity s.

O Activity va vorbi cu un ViewModel și un ViewModel va vorbi cu stratul de domeniu pentru a efectua acțiuni. Un ViewModel nu vorbește niciodată direct cu stratul de date.

Aici transmitem un UseCaseHandler și două UseCase la ViewModel . Vom intra în asta în detaliu în curând, dar în această arhitectură, un UseCase este o acțiune care definește modul în care un ViewModel interacționează cu stratul de date.

Iată cum arată codul nostru 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) } }) } }

Stratul de domeniu

Stratul de domeniu conține toate cazurile de utilizare ale aplicației dvs. În acest exemplu, avem UseCase , o clasă abstractă. Toate cazurile noastre de UseCase vor extinde această clasă.

 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) } }

Și UseCaseHandler se ocupă de execuția unui UseCase . Nu ar trebui să blocăm niciodată interfața de utilizare atunci când preluăm date din baza de date sau din serverul nostru la distanță. Acesta este locul în care decidem să executăm UseCase pe un thread de fundal și să primim răspunsul pe firul principal.

 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!! } } }

După cum sugerează și numele, GetPosts UseCase este responsabil pentru obținerea tuturor postărilor unui utilizator.

 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 }

Scopul UseCase -urilor este de a fi un mediator între ViewModel -urile dvs. și Repository -urile.

Să presupunem că, în viitor, decideți să adăugați o funcție de „editare postare”. Tot ce trebuie să faceți este să adăugați un nou EditPost UseCase și tot codul acestuia va fi complet separat și decuplat de alte UseCase . Cu toții l-am văzut de multe ori: sunt introduse noi funcții și sparg din greșeală ceva în codul preexistent. Crearea unui UseCase separat ajută enorm la evitarea acestui lucru.

Desigur, nu poți elimina această posibilitate 100%, dar cu siguranță o poți minimiza. Acesta este ceea ce separă Arhitectura curată de alte modele: codul este atât de decuplat încât puteți trata fiecare strat ca o cutie neagră.

Stratul de date

Acesta are toate depozitele pe care le poate folosi stratul de domeniu. Acest strat expune o sursă de date API la clase externe:

 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 implementează PostDataSource . Acesta decide dacă preluăm date dintr-o bază de date locală sau dintr-un server la distanță.

 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 } }

Codul se explică în mare parte de la sine. Această clasă are două variabile, localDataSource și remoteDataSource . Tipul lor este PostDataSource , așa că nu ne interesează cum sunt implementate de fapt sub capotă.

Din experiența mea personală, această arhitectură s-a dovedit a fi neprețuită. Într-una dintre aplicațiile mele, am început cu Firebase în partea din spate, ceea ce este excelent pentru a vă construi rapid aplicația. Știam că în cele din urmă va trebui să trec pe propriul meu server.

Când am făcut-o, tot ce trebuia să fac a fost să schimb implementarea în RemoteDataSource . Nu a trebuit să ating nicio altă clasă nici după o schimbare atât de mare. Acesta este avantajul codului decuplat. Modificarea oricărei clase nu ar trebui să afecteze alte părți ale codului dvs.

Unele dintre clasele suplimentare pe care le avem sunt:

 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 este responsabil pentru executarea sarcinilor în mod asincron folosind 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!! } } }

Acesta este ViewModelFactory nostru. Trebuie să creați acest lucru pentru a trece argumente în constructorul dvs. ViewModel .

Injecție de dependență

Voi explica injecția de dependență cu un exemplu. Dacă te uiți la clasa noastră PostDataRepository , are două dependențe, LocalDataSource și RemoteDataSource . Folosim clasa Injection pentru a furniza aceste dependențe clasei PostDataRepository .

Injectarea dependenței are două avantaje principale. Una este că puteți controla instanțiarea obiectelor dintr-un loc central în loc să o răspândiți pe întreaga bază de cod. O alta este că acest lucru ne va ajuta să scriem teste unitare pentru PostDataRepository , deoarece acum putem doar să transmitem versiuni batjocorite ale LocalDataSource și RemoteDataSource constructorului PostDataRepository în loc de valorile reale.

 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() }

Notă: prefer să folosesc Dagger 2 pentru injectarea dependenței în proiecte complexe. Dar, cu curba sa de învățare extrem de abruptă, este dincolo de scopul acestui articol. Deci, dacă sunteți interesat să aprofundați, vă recomand cu căldură introducerea lui Hari Vignesh Jayapalan în Dagger 2.

MVVM cu arhitectură curată: o combinație solidă

Scopul nostru cu acest proiect a fost să înțelegem MVVM cu arhitectură curată, așa că am omis peste câteva lucruri pe care le puteți încerca să le îmbunătățiți în continuare:

  1. Utilizați LiveData sau RxJava pentru a elimina apelurile inverse și pentru a le face puțin mai ordonat.
  2. Utilizați state pentru a vă reprezenta interfața de utilizare. (Pentru asta, vezi această discuție uimitoare a lui Jake Wharton.)
  3. Utilizați Dagger 2 pentru a injecta dependențe.

Aceasta este una dintre cele mai bune și mai scalabile arhitecturi pentru aplicațiile Android. Sper că v-a plăcut acest articol și aștept cu nerăbdare să aud cum ați folosit această abordare în propriile aplicații!

Înrudit : Xamarin Forms, MVVMCross și SkiaSharp: Sfânta Treime a dezvoltării aplicațiilor multiplatforme