Migliori app Android che utilizzano MVVM con un'architettura pulita

Pubblicato: 2022-03-11

Se non scegli l'architettura giusta per il tuo progetto Android, avrai difficoltà a mantenerla man mano che la tua base di codice cresce e il tuo team si espande.

Questo non è solo un tutorial per Android MVVM. In questo articolo, combineremo MVVM (Model-View-ViewModel o talvolta stilizzato "il modello ViewModel") con Clean Architecture. Vedremo come questa architettura può essere utilizzata per scrivere codice disaccoppiato, testabile e manutenibile.

Perché MVVM con architettura pulita?

MVVM separa la tua vista (es. Activity e Fragment s) dalla tua logica aziendale. MVVM è sufficiente per piccoli progetti, ma quando la tua base di codice diventa enorme, i tuoi ViewModel iniziano a gonfiarsi. Separare le responsabilità diventa difficile.

MVVM con Clean Architecture è abbastanza buono in questi casi. Fa un ulteriore passo avanti nel separare le responsabilità della tua base di codice. Astrae chiaramente la logica delle azioni che possono essere eseguite nella tua app.

Nota: puoi anche combinare Clean Architecture con l'architettura Model View Presenter (MVP). Ma poiché i componenti dell'architettura Android forniscono già una classe ViewModel integrata, utilizzeremo MVVM su MVP, non è richiesto alcun framework MVVM!

Vantaggi dell'utilizzo di un'architettura pulita

  • Il tuo codice è ancora più facilmente verificabile rispetto a un semplice MVVM.
  • Il tuo codice è ulteriormente disaccoppiato (il più grande vantaggio).
  • La struttura del pacchetto è ancora più facile da navigare.
  • Il progetto è ancora più facile da mantenere.
  • Il tuo team può aggiungere nuove funzionalità ancora più rapidamente.

Svantaggi dell'architettura pulita

  • Ha una curva di apprendimento leggermente ripida. Il modo in cui tutti i livelli lavorano insieme potrebbe richiedere del tempo per capire, soprattutto se provieni da schemi come semplici MVVM o MVP.
  • Aggiunge molte classi extra, quindi non è l'ideale per progetti a bassa complessità.

Il nostro flusso di dati sarà simile a questo:

Il flusso di dati di MVVM con Clean Architecture. Flussi di dati da View a ViewModel al dominio al repository di dati e quindi a un'origine dati (locale o remota).

La nostra logica aziendale è completamente disaccoppiata dalla nostra interfaccia utente. Rende il nostro codice molto facile da mantenere e testare.

L'esempio che vedremo è abbastanza semplice. Consente agli utenti di creare nuovi post e visualizzare un elenco di post creati da loro. Non sto usando nessuna libreria di terze parti (come Dagger, RxJava, ecc.) In questo esempio per semplicità.

I livelli di MVVM con architettura pulita

Il codice è diviso in tre livelli separati:

  1. Livello di presentazione
  2. Livello di dominio
  3. Livello dati

Entreremo in maggiori dettagli su ogni livello di seguito. Per ora, la nostra struttura del pacchetto risultante è simile a questa:

MVVM con struttura del pacchetto Clean Architecture.

Anche all'interno dell'architettura dell'app Android che stiamo utilizzando, ci sono molti modi per strutturare la gerarchia di file/cartelle. Mi piace raggruppare i file di progetto in base alle caratteristiche. Lo trovo pulito e conciso. Sei libero di scegliere la struttura del progetto che fa per te.

Il livello di presentazione

Ciò include i nostri Activity s, Fragment s e ViewModel s. Activity dovrebbe essere il più stupida possibile. Non mettere mai la tua logica aziendale in Activity s.

Activity parlerà con un ViewModel e un ViewModel parlerà con il livello di dominio per eseguire azioni. Un ViewModel non dialoga mai direttamente con il livello dati.

Qui stiamo passando un UseCaseHandler e due UseCase al nostro ViewModel . Ne parleremo più in dettaglio presto, ma in questa architettura, un UseCase è un'azione che definisce come un ViewModel interagisce con il livello dati.

Ecco come appare il nostro codice 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) } }) } }

Il livello di dominio

Il livello di dominio contiene tutti i casi d'uso della tua applicazione. In questo esempio, abbiamo UseCase , una classe astratta. Tutti i nostri UseCase estenderanno questa classe.

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

E UseCaseHandler gestisce l'esecuzione di un UseCase . Non dovremmo mai bloccare l'interfaccia utente quando recuperiamo i dati dal database o dal nostro server remoto. Questo è il luogo in cui decidiamo di eseguire il nostro UseCase su un thread in background e ricevere la risposta sul thread principale.

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

Come suggerisce il nome, GetPosts UseCase è responsabile della ricezione di tutti i post di un utente.

 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 }

Lo scopo di UseCase s è quello di essere un mediatore tra i tuoi ViewModel s e Repository s.

Diciamo che in futuro deciderai di aggiungere una funzione di "modifica post". Tutto quello che devi fare è aggiungere un nuovo EditPost UseCase tutto il suo codice sarà completamente separato e disaccoppiato da altri UseCase s. L'abbiamo visto tutti molte volte: vengono introdotte nuove funzionalità che inavvertitamente rompono qualcosa nel codice preesistente. La creazione di un UseCase separato aiuta immensamente a evitarlo.

Ovviamente, non puoi eliminare questa possibilità al 100 percento, ma puoi sicuramente ridurla al minimo. Questo è ciò che separa Clean Architecture da altri modelli: il codice è così disaccoppiato che puoi trattare ogni livello come una scatola nera.

Il livello dati

Questo ha tutti i repository che il livello di dominio può utilizzare. Questo livello espone un'API di origine dati a classi esterne:

 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 implementa PostDataSource . Decide se recuperiamo i dati da un database locale o da un server remoto.

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

Il codice è per lo più autoesplicativo. Questa classe ha due variabili, localDataSource e remoteDataSource . Il loro tipo è PostDataSource , quindi non ci interessa come vengono effettivamente implementati sotto il cofano.

Nella mia esperienza personale, questa architettura si è rivelata preziosa. In una delle mie app, ho iniziato con Firebase sul back-end, il che è ottimo per creare rapidamente la tua app. Sapevo che alla fine avrei dovuto passare al mio server.

Quando l'ho fatto, tutto ciò che dovevo fare era modificare l'implementazione in RemoteDataSource . Non ho dovuto toccare nessun'altra classe anche dopo un cambiamento così grande. Questo è il vantaggio del codice disaccoppiato. La modifica di una determinata classe non dovrebbe influire su altre parti del codice.

Alcune delle classi extra che abbiamo sono:

 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 è responsabile dell'esecuzione delle attività in modo asincrono utilizzando 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!! } } }

Questa è la nostra ViewModelFactory . Devi crearlo per passare argomenti nel tuo costruttore ViewModel .

Iniezione di dipendenza

Spiegherò l'iniezione di dipendenza con un esempio. Se guardi la nostra classe PostDataRepository , ha due dipendenze, LocalDataSource e RemoteDataSource . Usiamo la classe Injection per fornire queste dipendenze alla classe PostDataRepository .

L'iniezione di dipendenza ha due vantaggi principali. Uno è che puoi controllare l'istanziazione degli oggetti da una posizione centrale invece di diffonderla sull'intera base di codice. Un altro è che questo ci aiuterà a scrivere unit test per PostDataRepository perché ora possiamo semplicemente passare versioni derise di LocalDataSource e RemoteDataSource al costruttore PostDataRepository invece dei valori effettivi.

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

Nota: preferisco usare Dagger 2 per l'inserimento delle dipendenze in progetti complessi. Ma con la sua curva di apprendimento estremamente ripida, va oltre lo scopo di questo articolo. Quindi, se sei interessato ad approfondire, consiglio vivamente l'introduzione di Hari Vignesh Jayapalan a Dagger 2.

MVVM con architettura pulita: una solida combinazione

Il nostro scopo con questo progetto era comprendere MVVM con Clean Architecture, quindi abbiamo saltato alcune cose che puoi provare a migliorarlo ulteriormente:

  1. Usa LiveData o RxJava per rimuovere i callback e renderlo un po' più ordinato.
  2. Usa gli stati per rappresentare la tua interfaccia utente. (Per questo, dai un'occhiata a questo fantastico discorso di Jake Wharton.)
  3. Usa Dagger 2 per iniettare dipendenze.

Questa è una delle architetture migliori e più scalabili per le app Android. Spero che questo articolo ti sia piaciuto e non vedo l'ora di sapere come hai utilizzato questo approccio nelle tue app!

Correlati: Xamarin Forms, MVVMCross e SkiaSharp: The Holy Trinity of Cross-Platform App Development