Meilleures applications Android utilisant MVVM avec une architecture propre

Publié: 2022-03-11

Si vous ne choisissez pas la bonne architecture pour votre projet Android, vous aurez du mal à la maintenir à mesure que votre base de code grandit et que votre équipe s'agrandit.

Ce n'est pas seulement un tutoriel Android MVVM. Dans cet article, nous allons combiner MVVM (Model-View-ViewModel ou parfois stylisé "le modèle ViewModel") avec Clean Architecture. Nous allons voir comment cette architecture peut être utilisée pour écrire du code découplé, testable et maintenable.

Pourquoi MVVM avec une architecture propre ?

MVVM sépare votre vue (c'est-à-dire Activity s et Fragment s) de votre logique métier. MVVM est suffisant pour les petits projets, mais lorsque votre base de code devient énorme, vos ViewModel commencent à gonfler. La séparation des responsabilités devient difficile.

MVVM avec Clean Architecture est assez bon dans de tels cas. Cela va encore plus loin dans la séparation des responsabilités de votre base de code. Il résume clairement la logique des actions qui peuvent être effectuées dans votre application.

Remarque : Vous pouvez également combiner l'architecture propre avec l'architecture modèle-vue-présentateur (MVP). Mais comme les composants d'architecture Android fournissent déjà une classe ViewModel , nous optons pour MVVM plutôt que MVP - aucun framework MVVM n'est requis !

Avantages de l'utilisation d'une architecture propre

  • Votre code est encore plus facilement testable qu'avec MVVM ordinaire.
  • Votre code est encore découplé (le plus grand avantage.)
  • La structure du package est encore plus facile à naviguer.
  • Le projet est encore plus facile à maintenir.
  • Votre équipe peut ajouter de nouvelles fonctionnalités encore plus rapidement.

Inconvénients de l'architecture propre

  • Il a une courbe d'apprentissage légèrement raide. La façon dont toutes les couches fonctionnent ensemble peut prendre un certain temps à comprendre, surtout si vous venez de modèles tels que MVVM simple ou MVP.
  • Il ajoute beaucoup de classes supplémentaires, il n'est donc pas idéal pour les projets peu complexes.

Notre flux de données ressemblera à ceci :

Le flux de données de MVVM avec Clean Architecture. Les données circulent de View à ViewModel, de Domain à Data Repository, puis à une source de données (locale ou distante).

Notre logique métier est complètement découplée de notre interface utilisateur. Cela rend notre code très facile à maintenir et à tester.

L'exemple que nous allons voir est assez simple. Il permet aux utilisateurs de créer de nouveaux messages et de voir une liste des messages créés par eux. Je n'utilise aucune bibliothèque tierce (comme Dagger, RxJava, etc.) dans cet exemple par souci de simplicité.

Les couches de MVVM avec une architecture propre

Le code est divisé en trois couches distinctes :

  1. Couche de présentation
  2. Couche de domaine
  3. Couche de données

Nous aborderons plus en détail chaque couche ci-dessous. Pour l'instant, notre structure de package résultante ressemble à ceci :

MVVM avec structure de package Clean Architecture.

Même au sein de l'architecture d'application Android que nous utilisons, il existe de nombreuses façons de structurer votre hiérarchie de fichiers/dossiers. J'aime regrouper les fichiers de projet en fonction des fonctionnalités. Je le trouve clair et concis. Vous êtes libre de choisir la structure de projet qui vous convient.

La couche de présentation

Cela inclut nos Activity s, Fragment s et ViewModel s. Une Activity doit être aussi stupide que possible. Ne mettez jamais votre logique métier dans Activity s.

Une Activity parlera à un ViewModel et un ViewModel parlera à la couche de domaine pour effectuer des actions. Un ViewModel ne communique jamais directement avec la couche de données.

Ici, nous passons un UseCaseHandler et deux UseCase à notre ViewModel . Nous y reviendrons plus en détail bientôt, mais dans cette architecture, un UseCase est une action qui définit comment un ViewModel interagit avec la couche de données.

Voici à quoi ressemble notre code 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) } }) } }

La couche de domaine

La couche de domaine contient tous les cas d'utilisation de votre application. Dans cet exemple, nous avons UseCase , une classe abstraite. Tous nos UseCase étendront cette 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) } }

Et UseCaseHandler gère l'exécution d'un UseCase . Nous ne devons jamais bloquer l'interface utilisateur lorsque nous récupérons des données de la base de données ou de notre serveur distant. C'est l'endroit où nous décidons d'exécuter notre UseCase sur un thread d'arrière-plan et de recevoir la réponse sur le thread 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!! } } }

Comme son nom l'indique, GetPosts UseCase est responsable de l'obtention de tous les messages d'un utilisateur.

 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 }

Le but des UseCase s est d'être un médiateur entre vos ViewModel s et Repository s.

Disons qu'à l'avenir, vous décidez d'ajouter une fonction "modifier la publication". Tout ce que vous avez à faire est d'ajouter un nouveau EditPost UseCase et tout son code sera complètement séparé et découplé des autres UseCase s. Nous l'avons tous vu plusieurs fois : de nouvelles fonctionnalités sont introduites et elles cassent par inadvertance quelque chose dans le code préexistant. La création d'un UseCase séparé aide énormément à éviter cela.

Bien sûr, vous ne pouvez pas éliminer cette possibilité à 100 %, mais vous pouvez certainement la minimiser. C'est ce qui distingue Clean Architecture des autres modèles : le code est tellement découplé que vous pouvez traiter chaque couche comme une boîte noire.

La couche de données

Cela a tous les référentiels que la couche de domaine peut utiliser. Cette couche expose une API de source de données à des classes extérieures :

 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 implémente PostDataSource . Il décide si nous récupérons les données d'une base de données locale ou d'un serveur distant.

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

Le code est la plupart du temps explicite. Cette classe a deux variables, localDataSource et remoteDataSource . Leur type est PostDataSource , nous ne nous soucions donc pas de la manière dont ils sont réellement implémentés sous le capot.

D'après mon expérience personnelle, cette architecture s'est avérée inestimable. Dans l'une de mes applications, j'ai commencé avec Firebase en arrière-plan, ce qui est idéal pour créer rapidement votre application. Je savais que je devrais éventuellement passer à mon propre serveur.

Quand je l'ai fait, tout ce que j'avais à faire était de changer l'implémentation dans RemoteDataSource . Je n'ai pas eu à toucher à une autre classe même après un changement aussi énorme. C'est l'avantage du code découplé. La modification d'une classe donnée ne devrait pas affecter les autres parties de votre code.

Certaines des classes supplémentaires que nous avons sont:

 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 est responsable de l'exécution des tâches de manière asynchrone à l'aide 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!! } } }

Ceci est notre ViewModelFactory . Vous devez le créer pour passer des arguments dans votre constructeur ViewModel .

Injection de dépendance

Je vais expliquer l'injection de dépendance avec un exemple. Si vous regardez notre classe PostDataRepository , elle a deux dépendances, LocalDataSource et RemoteDataSource . Nous utilisons la classe Injection pour fournir ces dépendances à la classe PostDataRepository .

L'injection de dépendance présente deux avantages principaux. La première est que vous pouvez contrôler l'instanciation des objets à partir d'un emplacement central au lieu de la répartir sur l'ensemble de la base de code. Une autre est que cela nous aidera à écrire des tests unitaires pour PostDataRepository car maintenant nous pouvons simplement passer des versions simulées de LocalDataSource et RemoteDataSource au constructeur PostDataRepository au lieu des valeurs réelles.

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

Remarque : Je préfère utiliser Dagger 2 pour l'injection de dépendances dans des projets complexes. Mais avec sa courbe d'apprentissage extrêmement abrupte, cela dépasse le cadre de cet article. Donc, si vous souhaitez approfondir, je recommande fortement l'introduction de Hari Vignesh Jayapalan à Dagger 2.

MVVM avec une architecture propre : une combinaison solide

Notre objectif avec ce projet était de comprendre MVVM avec une architecture propre, nous avons donc ignoré quelques éléments que vous pouvez essayer de l'améliorer davantage :

  1. Utilisez LiveData ou RxJava pour supprimer les rappels et le rendre un peu plus propre.
  2. Utilisez des états pour représenter votre interface utilisateur. (Pour cela, consultez cet incroyable discours de Jake Wharton.)
  3. Utilisez Dagger 2 pour injecter des dépendances.

C'est l'une des architectures les meilleures et les plus évolutives pour les applications Android. J'espère que vous avez apprécié cet article et j'ai hâte de savoir comment vous avez utilisé cette approche dans vos propres applications !

En relation : Xamarin Forms, MVVMCross et SkiaSharp : la Sainte Trinité du développement d'applications multiplateformes