Melhores aplicativos Android usando MVVM com arquitetura limpa

Publicados: 2022-03-11

Se você não escolher a arquitetura certa para seu projeto Android, terá dificuldade em mantê-la à medida que sua base de código cresce e sua equipe se expande.

Este não é apenas um tutorial do Android MVVM. Neste artigo, vamos combinar MVVM (Model-View-ViewModel ou às vezes estilizado “o padrão ViewModel”) com Clean Architecture. Veremos como essa arquitetura pode ser usada para escrever código desacoplado, testável e sustentável.

Por que MVVM com Arquitetura Limpa?

O MVVM separa sua visão (ou seja, Activity se Fragment s) de sua lógica de negócios. O MVVM é suficiente para projetos pequenos, mas quando sua base de código se torna enorme, seus ViewModel começam a inchar. Separar responsabilidades torna-se difícil.

MVVM com Clean Architecture é muito bom nesses casos. Ele vai um passo além na separação das responsabilidades de sua base de código. Ele abstrai claramente a lógica das ações que podem ser executadas em seu aplicativo.

Nota: Você também pode combinar Clean Architecture com a arquitetura Model-View-Apresentador (MVP). Mas como o Android Architecture Components já fornece uma classe ViewModel integrada, vamos usar MVVM em vez de MVP – não é necessária uma estrutura MVVM!

Vantagens de usar arquitetura limpa

  • Seu código é ainda mais facilmente testável do que com o MVVM simples.
  • Seu código é ainda mais desacoplado (a maior vantagem).
  • A estrutura do pacote é ainda mais fácil de navegar.
  • O projeto é ainda mais fácil de manter.
  • Sua equipe pode adicionar novos recursos ainda mais rapidamente.

Desvantagens da Arquitetura Limpa

  • Tem uma curva de aprendizado ligeiramente íngreme. Como todas as camadas funcionam juntas pode levar algum tempo para entender, especialmente se você estiver vindo de padrões como MVVM ou MVP simples.
  • Ele adiciona muitas classes extras, portanto, não é ideal para projetos de baixa complexidade.

Nosso fluxo de dados ficará assim:

O fluxo de dados do MVVM com Clean Architecture. Os dados fluem de View para ViewModel para Domínio para Repositório de Dados e, em seguida, para uma Fonte de Dados (Local ou Remota).

Nossa lógica de negócios é completamente dissociada de nossa interface do usuário. Isso torna nosso código muito fácil de manter e testar.

O exemplo que vamos ver é bastante simples. Ele permite que os usuários criem novas postagens e vejam uma lista de postagens criadas por eles. Não estou usando nenhuma biblioteca de terceiros (como Dagger, RxJava, etc.) neste exemplo para simplificar.

As camadas do MVVM com arquitetura limpa

O código é dividido em três camadas separadas:

  1. Camada de apresentação
  2. Camada de Domínio
  3. Camada de dados

Entraremos em mais detalhes sobre cada camada abaixo. Por enquanto, nossa estrutura de pacotes resultante se parece com isso:

MVVM com estrutura de pacotes Clean Architecture.

Mesmo dentro da arquitetura de aplicativos Android que estamos usando, há muitas maneiras de estruturar sua hierarquia de arquivos/pastas. Eu gosto de agrupar arquivos de projeto com base em recursos. Acho legal e conciso. Você é livre para escolher qualquer estrutura de projeto que mais lhe convier.

A camada de apresentação

Isso inclui nossos Activity s, Fragment s e ViewModel s. Uma Activity deve ser o mais burra possível. Nunca coloque sua lógica de negócios em Activity s.

Uma Activity conversará com um ViewModel e um ViewModel conversará com a camada de domínio para realizar ações. Um ViewModel nunca fala diretamente com a camada de dados.

Aqui estamos passando um UseCaseHandler e dois UseCase s para nosso ViewModel . Entraremos em mais detalhes em breve, mas nesta arquitetura, um UseCase é uma ação que define como um ViewModel interage com a camada de dados.

Veja como nosso código Kotlin se parece:

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

A camada de domínio

A camada de domínio contém todos os casos de uso do seu aplicativo. Neste exemplo, temos UseCase , uma classe abstrata. Todos os nossos UseCase s estenderão essa 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 lida com a execução de um UseCase . Nunca devemos bloquear a interface do usuário quando buscamos dados do banco de dados ou do nosso servidor remoto. Este é o lugar onde decidimos executar nosso UseCase em um thread em segundo plano e receber a resposta no 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!! } } }

Como o próprio nome indica, o GetPosts UseCase é responsável por obter todos os posts de um usuário.

 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 }

O objetivo dos UseCase s é ser um mediador entre seus ViewModel se Repository s.

Digamos que no futuro você decida adicionar um recurso de “editar postagem”. Tudo o que você precisa fazer é adicionar um novo EditPost UseCase e todo o seu código será completamente separado e desacoplado de outros UseCase s. Todos nós já vimos isso muitas vezes: novos recursos são introduzidos e inadvertidamente quebram algo no código preexistente. Criar um UseCase separado ajuda imensamente a evitar isso.

É claro que você não pode eliminar essa possibilidade 100%, mas com certeza pode minimizá-la. Isso é o que separa a Arquitetura Limpa de outros padrões: O código é tão desacoplado que você pode tratar cada camada como uma caixa preta.

A camada de dados

Isso tem todos os repositórios que a camada de domínio pode usar. Essa camada expõe uma API de fonte de dados para classes externas:

 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 . Ele decide se buscamos dados de um banco de dados local ou de um servidor 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 } }

O código é principalmente auto-explicativo. Essa classe tem duas variáveis, localDataSource e remoteDataSource . O tipo deles é PostDataSource , portanto, não nos importamos como eles são realmente implementados nos bastidores.

Na minha experiência pessoal, esta arquitetura provou ser inestimável. Em um dos meus aplicativos, comecei com o Firebase no back-end, o que é ótimo para criar seu aplicativo rapidamente. Eu sabia que eventualmente teria que mudar para meu próprio servidor.

Quando o fiz, tudo o que precisei fazer foi alterar a implementação em RemoteDataSource . Eu não tive que tocar em nenhuma outra classe, mesmo depois de uma mudança tão grande. Essa é a vantagem do código desacoplado. Alterar qualquer classe não deve afetar outras partes do seu código.

Algumas das aulas extras que temos são:

 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 é responsável por executar tarefas de forma assíncrona usando 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!! } } }

Este é o nosso ViewModelFactory . Você precisa criar isso para passar argumentos em seu construtor ViewModel .

Injeção de dependência

Vou explicar a injeção de dependência com um exemplo. Se você observar nossa classe PostDataRepository , ela tem duas dependências, LocalDataSource e RemoteDataSource . Usamos a classe Injection para fornecer essas dependências à classe PostDataRepository .

A injeção de dependência tem duas vantagens principais. Uma é que você pode controlar a instanciação de objetos de um local central em vez de espalhá-la por toda a base de código. Outra é que isso nos ajudará a escrever testes de unidade para PostDataRepository porque agora podemos apenas passar versões simuladas de LocalDataSource e RemoteDataSource para o construtor PostDataRepository em vez de valores reais.

 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: prefiro usar o Dagger 2 para injeção de dependência em projetos complexos. Mas com sua curva de aprendizado extremamente íngreme, está além do escopo deste artigo. Então, se você estiver interessado em ir mais fundo, eu recomendo a introdução de Hari Vignesh Jayapalan ao Dagger 2.

MVVM com arquitetura limpa: uma combinação sólida

Nosso objetivo com este projeto era entender o MVVM com Clean Architecture, então pulamos algumas coisas que você pode tentar melhorar ainda mais:

  1. Use LiveData ou RxJava para remover retornos de chamada e torná-lo um pouco mais organizado.
  2. Use estados para representar sua interface do usuário. (Para isso, confira esta palestra incrível de Jake Wharton.)
  3. Use o Dagger 2 para injetar dependências.

Esta é uma das melhores e mais escaláveis ​​arquiteturas para aplicativos Android. Espero que você tenha gostado deste artigo e estou ansioso para saber como você usou essa abordagem em seus próprios aplicativos!

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