Mejores aplicaciones de Android usando MVVM con arquitectura limpia
Publicado: 2022-03-11Si no elige la arquitectura adecuada para su proyecto de Android, tendrá dificultades para mantenerla a medida que crece su base de código y se expande su equipo.
Este no es solo un tutorial de Android MVVM. En este artículo, vamos a combinar MVVM (Model-View-ViewModel o, a veces, estilizado "el patrón ViewModel") con Clean Architecture. Vamos a ver cómo se puede usar esta arquitectura para escribir código desacoplado, comprobable y mantenible.
¿Por qué MVVM con arquitectura limpia?
MVVM separa su vista (es decir, Activity
y Fragment
) de su lógica comercial. MVVM es suficiente para proyectos pequeños, pero cuando su base de código se vuelve enorme, sus ViewModel
comienzan a hincharse. Separar responsabilidades se vuelve difícil.
MVVM con Clean Architecture es bastante bueno en tales casos. Va un paso más allá al separar las responsabilidades de su base de código. Abstrae claramente la lógica de las acciones que se pueden realizar en su aplicación.
Nota: También puede combinar Arquitectura limpia con la arquitectura modelo-vista-presentador (MVP). Pero dado que los componentes de la arquitectura de Android ya proporcionan una clase ViewModel
, vamos con MVVM sobre MVP, ¡no se requiere un marco de MVVM!
Ventajas de usar arquitectura limpia
- Su código es aún más fácil de probar que con MVVM simple.
- Su código está aún más desacoplado (la mayor ventaja).
- La estructura del paquete es aún más fácil de navegar.
- El proyecto es aún más fácil de mantener.
- Su equipo puede agregar nuevas funciones aún más rápidamente.
Desventajas de la arquitectura limpia
- Tiene una curva de aprendizaje ligeramente empinada. Puede tomar algún tiempo entender cómo funcionan todas las capas juntas, especialmente si proviene de patrones como MVVM o MVP simples.
- Agrega muchas clases adicionales, por lo que no es ideal para proyectos de baja complejidad.
Nuestro flujo de datos se verá así:
Nuestra lógica empresarial está completamente desvinculada de nuestra interfaz de usuario. Hace que nuestro código sea muy fácil de mantener y probar.
El ejemplo que vamos a ver es bastante sencillo. Permite a los usuarios crear nuevas publicaciones y ver una lista de publicaciones creadas por ellos. No estoy usando ninguna biblioteca de terceros (como Dagger, RxJava, etc.) en este ejemplo por simplicidad.
Las capas de MVVM con arquitectura limpia
El código se divide en tres capas separadas:
- Capa de presentación
- Capa de dominio
- Capa de datos
Entraremos en más detalles sobre cada capa a continuación. Por ahora, nuestra estructura de paquete resultante se ve así:
Incluso dentro de la arquitectura de aplicaciones de Android que estamos usando, hay muchas formas de estructurar la jerarquía de archivos/carpetas. Me gusta agrupar archivos de proyecto según las características. Lo encuentro limpio y conciso. Usted es libre de elegir la estructura de proyecto que más le convenga.
La capa de presentación
Esto incluye nuestros Activity
s, Fragment
s y ViewModel
s. Una Activity
debe ser lo más tonta posible. Nunca ponga su lógica de negocios en Activity
s.
Una Activity
hablará con un ViewModel
y un ViewModel
hablará con la capa de dominio para realizar acciones. Un ViewModel
nunca habla directamente con la capa de datos.
Aquí estamos pasando un UseCaseHandler
y dos UseCase
s a nuestro ViewModel
. Hablaremos de eso con más detalle pronto, pero en esta arquitectura, un UseCase
es una acción que define cómo interactúa un ViewModel
con la capa de datos.
Así es como se ve nuestro código 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 capa de dominio
La capa de dominio contiene todos los casos de uso de su aplicación. En este ejemplo, tenemos UseCase
, una clase abstracta. Todos nuestros UseCase
s extenderán esta clase.
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) } }
Y UseCaseHandler
maneja la ejecución de un UseCase
. Nunca debemos bloquear la interfaz de usuario cuando obtenemos datos de la base de datos o de nuestro servidor remoto. Este es el lugar donde decidimos ejecutar nuestro UseCase
en un subproceso en segundo plano y recibir la respuesta en el subproceso 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 su nombre lo indica, GetPosts
UseCase
es responsable de obtener todas las publicaciones de un usuario.
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 }
El propósito de UseCase
s es ser un mediador entre sus ViewModel
s y Repository
s.

Digamos que en el futuro decide agregar una función de "editar publicación". Todo lo que tiene que hacer es agregar un nuevo EditPost
UseCase
y todo su código estará completamente separado y desacoplado de otros UseCase
. Todos lo hemos visto muchas veces: se introducen nuevas funciones y, sin darse cuenta, rompen algo en el código preexistente. Crear un UseCase
separado ayuda inmensamente a evitar eso.
Por supuesto, no puede eliminar esa posibilidad al 100 por ciento, pero sí puede minimizarla. Esto es lo que separa a Clean Architecture de otros patrones: el código está tan desacoplado que puede tratar cada capa como una caja negra.
La capa de datos
Esto tiene todos los repositorios que la capa de dominio puede usar. Esta capa expone una API de origen de datos a clases 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
. Decide si obtenemos datos de una base de datos local o de un 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 } }
El código es en su mayoría autoexplicativo. Esta clase tiene dos variables, localDataSource
y remoteDataSource
. Su tipo es PostDataSource
, por lo que no nos importa cómo se implementan realmente bajo el capó.
En mi experiencia personal, esta arquitectura ha demostrado ser invaluable. En una de mis aplicaciones, comencé con Firebase en el back-end, lo cual es excelente para crear rápidamente su aplicación. Sabía que eventualmente tendría que cambiar a mi propio servidor.
Cuando lo hice, todo lo que tenía que hacer era cambiar la implementación en RemoteDataSource
. No tuve que tocar ninguna otra clase incluso después de un cambio tan grande. Esa es la ventaja del código desacoplado. Cambiar cualquier clase dada no debería afectar otras partes de su código.
Algunas de las clases extra que tenemos son:
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
es responsable de ejecutar tareas de forma asincrónica utilizando 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!! } } }
Esta es nuestra ViewModelFactory
. Tienes que crear esto para pasar argumentos en tu constructor de ViewModel
.
Inyección de dependencia
Explicaré la inyección de dependencia con un ejemplo. Si observa nuestra clase PostDataRepository
, tiene dos dependencias, LocalDataSource
y RemoteDataSource
. Usamos la clase Injection
para proporcionar estas dependencias a la clase PostDataRepository
.
Inyectar dependencia tiene dos ventajas principales. Una es que puede controlar la creación de instancias de objetos desde un lugar central en lugar de distribuirlo en todo el código base. Otra es que esto nos ayudará a escribir pruebas unitarias para PostDataRepository
porque ahora podemos simplemente pasar versiones simuladas de LocalDataSource
y RemoteDataSource
al constructor de PostDataRepository
en lugar de valores reales.
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: Prefiero usar Dagger 2 para inyección de dependencia en proyectos complejos. Pero con su curva de aprendizaje extremadamente empinada, está más allá del alcance de este artículo. Entonces, si está interesado en profundizar más, le recomiendo la introducción de Dagger 2 de Hari Vignesh Jayapalan.
MVVM con arquitectura limpia: una combinación sólida
Nuestro propósito con este proyecto era comprender MVVM con arquitectura limpia, por lo que omitimos algunas cosas que puede intentar mejorar aún más:
- Use LiveData o RxJava para eliminar las devoluciones de llamada y hacerlo un poco más ordenado.
- Usa estados para representar tu interfaz de usuario. (Para eso, echa un vistazo a esta increíble charla de Jake Wharton).
- Use Dagger 2 para inyectar dependencias.
Esta es una de las mejores y más escalables arquitecturas para aplicaciones de Android. Espero que hayas disfrutado este artículo y espero escuchar cómo has usado este enfoque en tus propias aplicaciones.