Android, MVVM y repositorios en el mundo real

Publicado el

(Mike DeMaso) (2 de noviembre de 2020)

¿Cómo logra una interfaz de usuario receptiva mientras mantiene su código limpio, preciso, legible y fácil de mantener? Esta es una pregunta que ha estado dando vueltas en el cerebro de los desarrolladores de Android desde 2008.

Después de muchos años de dejar que los desarrolladores de Android lo resuelvan por su cuenta, Google ha brindado recientemente algunas orientaciones sobre el tema con su Guía de arquitectura de aplicaciones que promueve una variante de MVVM . Si bien este es un gran comienzo, deja muchas preguntas pendientes para que los equipos las respondan por su cuenta. Google también proporciona bibliotecas como LiveData , Room y DataBinding para ayudarnos a reducir los repetidos y separar las preocupaciones de nuestro código. Incluso terceros nos han ayudado con RxJava y Retrofit , lo que nos permite forma de manejar el trabajo asincrónico y buscar cosas a través de la red. Con todas estas cosas flotando alrededor de la solución de pequeñas partes de la pregunta más grande, ¿cómo podemos unirlas todas para proporcionar una experiencia de interfaz de usuario fluida que sea fácil de implementar y mantener?

En este artículo, compartiremos lo que hemos aprendido de nuestros intentos anteriores de crear una arquitectura MVVM y mostrar un ejemplo simple de cómo unir todas estas piezas del rompecabezas.

Una descripción rápida de MVVM

Para proporcionarle una comprensión de lo que va a resolver un repositorio, primero profundicemos en el patrón MVVM básico.

El básico Patrón MVVM
Diagrama MVVM básico

La vista

Esta es la representación de la interfaz gráfica de usuario para tu codigo. Estos pueden estar representados por XML (archivos de diseño) o código (Jetpack Compose). Por lo general, existe algún tipo de enlace de datos que vincula la vista con el modelo de vista. Dependiendo de dónde dibuje la línea entre una vista y un archivador, el objeto Actividad y Fragmento puede considerarse uno o ambos.

El ViewModel

Este está a cargo de transformar los datos de el modelo en un formato que tenga sentido para que se muestre la vista. No se deje engañar por la conexión de doble punta de flecha entre View y ViewModel. La principal diferencia entre un modelo de vista y el presentador en MVP es que un modelo de vista no contiene una referencia a la vista. Es una relación unidireccional, lo que significa que otras cosas necesitan administrar el objeto ViewModel.

El Modelo

Esto se refiere a los datos (o modelo de dominio) que se utilizan como fuente de información que ViewModel proporciona a la Vista. Esta capa es donde las cosas se vuelven un poco borrosas, ya que algunos artículos se refieren como los datos en sí, mientras que otros se refieren a él como una capa de acceso a los datos. Aquí es donde el patrón de repositorio de Google interviene para aclarar las cosas.

Entrar en el repositorio

Google ha estado menciona este patrón desde hace algún tiempo . Sus ejemplos son una gran guía para comprender el principio básico de usar MVVM con un repositorio, pero descubrimos que les faltan algunos e importante) orientación para ayudar a las personas a traducir estos fragmentos en un proyecto más grande y complejo.

Diagrama MVVM de Google

El patrón del repositorio está diseñado para“ proporcionar una API limpia que el resto de la aplicación puede recuperar estos datos fácilmente «. Desafortunadamente, simplemente agregar un repositorio a su arquitectura no obliga a que su código esté limpio. Aún puede crear un lío enredado sin separar adecuadamente y proporcionar un contrato claro entre las capas.

En DraftKings nos hemos centrado en algunas pautas adicionales para ayudar a nuestros desarrolladores a producir código limpio de manera consistente.

Desacoplar capas con interfaces

Aquí estamos agregando interfaces para que los ingenieros piensen en lo que están exponiendo públicamente
Mejorar el diagrama de Google agregando interfaces

Establecimos que el uso de interfaces entre estas capas ayudará a los ingenieros de todos los niveles a ceñirse a los buenos principios de codificación. Esto ayuda a garantizar que nuestras pruebas unitarias realmente solo prueben una capa a la vez, reduciendo la sobrecarga de escritura y manteniendo un gran conjunto de pruebas. Además, ayuda a definir claramente las API externas y oculta los detalles de implementación de las diferentes capas. Nos pide que evaluemos lo que le estamos diciendo al mundo exterior sobre la funcionalidad de este objeto y nos brinda una oportunidad integrada para garantizar que nuestro código esté limpio.

También nos brinda la capacidad de refactorizar diferentes capas en nuestro código base de manera más efectiva. Mientras la interfaz no cambie, podemos dejar intactas las áreas de nuestra base de código que las usan. Por ejemplo, si quisiéramos migrar nuestra biblioteca de redes de Volley a Retrofit, simplemente podríamos cambiar los métodos en nuestras clases de Dagger que producir y proporcionar la interfaz por encima de la fuente de datos remota y no tener que realizar cambios en todos los repositorios que utilizan ese punto final. Esto reduce enormemente el alcance de tales cambios y disminuye la posibilidad de que introduzcamos errores en el producto final.

Aquí tenemos un ejemplo de cómo dejar que un ViewModel se aferre a la clase concreta de un repositorio puede conducir a comportamientos no deseados. El ejemplo es un poco artificial y se puede rectificar simplemente marcando el fooPublishSubject en FooRepository como private, pero esa solución es más frágil. FooRepository podría deben usarse en un alcance diferente que requiere acceso a ese parámetro, y abrir el acceso para instancias ahora confunde los es es apropiado utilizar esa variable miembro directamente.

Entrega de estas dependencias

A medida que crece la complejidad de su proyecto, más complicadas se vuelven sus relaciones de dependencia. Esto significa que las personas generalmente recurren a algún tipo de biblioteca de inyección de dependencia (como Dagger o Koin ). .

Una biblioteca DI no solo proporciona una manera limpia y fácil de recuperar sus dependencias requeridas, sino que también le permite pensar en cuántos de estos objetos necesitará en la aplicación.

Este proceso de pensamiento nos llevó a establecer las mejores prácticas de los objetos que pertenecen al gráfico de Dagger. Cualquier cosa de la que solo queramos una instancia debe residir en el gráfico raíz / global / de ámbito de aplicación. Cualquier cosa de la que pueda haber muchas instancias debe crearse a pedido y conservarse de manera adecuada.

Esto significa que nuestros nuevos objetos de repositorio pertenecen al gráfico de Dagger ya que queremos que múltiples ViewModels puedan acceder a Models a través de una sola instancia de las fuentes subyacentes de Room o Retrofit. Los ViewModels, por otro lado, deben crearse nuevos para cada Vista que los necesite. Piense en una pila de actividades como una pila de naipes y ViewModel impulsa el palo y el valor. No queremos que el hecho de agregar un 3 de tréboles en la parte superior cambie todas las cartas de abajo a un 3 de tréboles también, por lo que cada Vista necesita su propia instancia de ViewModel para preservar y aislar sus datos.

Ahora hemos definido lo que nuestra solución DI contendrá ahora.
Muestre qué objetos se espera que sean retenidos por el gráfico de Dagger

Decidimos mantener nuestros ViewModels fuera de nuestro gráfico de Dagger. Históricamente, habíamos sido menos explícitos acerca de esta elección, pero sentimos que este es el dirección correcta dado el patrón ViewModelProvider que viene en androidx.lifecycle y cómo nos ayuda a solidificar la relación entre Actividad / Fragmento / XML y ViewModel como «uno a uno». Mientras tanto, la relación ViewModel a repositorio puede ser «muchos a muchos». En la práctica, esto significa que para cada Actividad / Fragmento / XML tenemos un único ViewModel que maneja la l de esa vista ogic, pero puede llegar a muchos repositorios para obtener los datos necesarios. Como los datos generalmente se reutilizan y se muestran en la aplicación, muchos ViewModels diferentes pueden usar la misma instancia del repositorio del Dagger Graph de manera fácil y eficiente.

Conteniendo la API

En cualquier empresa a escala, se necesitan muchos ingenieros, equipos e incluso divisiones para llevar un proyecto de la pizarra a las manos del cliente. Aquí en DraftKings, eso no es diferente. Para obtener datos en la aplicación de Android, necesitamos trabajar con algunos equipos diferentes en el backend para obtener los datos de la base de datos a la API y al cliente de Android. Dado que este código a menudo pertenece a otro equipo, significa que el backend generalmente se «moverá» a un ritmo diferente al del cliente.

Esto es especialmente cierto al comienzo de un proyecto que no tener una API en un estado que podamos usar para nuestro desarrollo. Nos gustaría tomar decisiones de diseño e implementación sobre los objetos de datos que se transmiten internamente al cliente sin preocuparnos demasiado por las decisiones conflictivas que toman los ingenieros que trabajan en el backend.

Más allá de eso, tenemos un Son pocos los servicios que devuelven los mismos datos comerciales al cliente, pero debido a que son propiedad de diferentes equipos y están tratando de resolver diferentes problemas, se han separado unos de otros en la estructura y los tipos de datos devueltos a través de las API.Internamente para el cliente, en realidad representan lo mismo, por lo que ser capaz de traducir y combinar estas respuestas en algo universal para el cliente tiene mucho sentido.

Aquí, puede ver que hay dos puntos finales en la API que devuelven variaciones de un «usuario»; los puntos finales de inicio de sesión y perfil. Para fusionarlos en un tipo de datos, simplemente creamos un constructor para cada variación que queremos manejar y ahora la Aplicación puede limitar el conocimiento de los dos tipos diferentes de usuarios que la API entrega a un solo lugar. Esto hace que compartir datos (a través de la capa Modelo) entre características sea mucho más fácil y, al mismo tiempo, permite que los cambios en la estructura y los tipos de datos de la API se limiten a un solo punto final.

Estamos haciendo una distinción entre los objetos Network Response y objetos de modelo de negocio en nuestra arquitectura. Esto también ayuda a definir la función de la fuente de datos remota para tomar las respuestas de red y transformarlas en modelos comerciales para el resto de nuestra aplicación.

Definir los tipos de datos que producen estas capas, también ayuda a definir la función de la fuente de datos remota
Aclarar el tipo de datos que se envían entre las capas, lo que permite una mayor reutilización

Un ejemplo en código

Ahora vamos a sumergirnos en el ejemplo de Google UserRepository y crear nuestra propia versión ciñéndose a los cambios que hemos realizado anteriormente.

Primero, echemos un vistazo a la versión final de Google del UserRepository.

Puede ver que están usando Dagger (o Hilt ) , Kotlin Coroutines , Room y, muy probablemente, Retrofit. Proporcionar el servicio Retrofit, el ejecutor de Coroutine y el Dao (objeto de acceso a datos) al repositorio.

El flujo básico de esto es realizar una solicitud de red para los datos y devolver los datos (o algo que esté viendo para datos) de Room inmediatamente. Una vez que se complete la solicitud de red, haga todo lo que necesite hacer con los datos e inserte el nuevo objeto en la tabla. La inserción notifica automáticamente a los datos devueltos previamente que han cambiado, lo que solicita una nueva recuperación y, finalmente, una actualización de la vista.

Alguna configuración

Antes de comenzar a crear el UserRepository, primero debemos abordar algunas cosas que vamos a necesitar, como una forma útil de inyectar en qué subprocesos queremos ejecutar.

Esto es para ayudarnos con las pruebas más adelante. Configure esto en el gráfico de Dagger y ahora puede inyectar los subprocesos correctos fácilmente en todo el código base y, al mismo tiempo, cambiarlos por un TestScheduler en su unidad pruebas (está escribiendo pruebas unitarias … ¿verdad?)

Estas son nuestras clases de usuario, UserResponse es la que devuelve nuestra API a través de Retrofit y User, nuestro modelo de negocio lo transmitimos internamente. Puede ver que podemos simplificar el objeto de red e incluso usar un generador de código para crearlo, mientras que nuestros objetos comerciales pueden estar más en línea con lo que necesitamos.

Aquí estamos definiendo nuestro servicio Retrofit. Podríamos haber hecho que estos devolvieran un Observable en lugar de un Single, lo que habría simplificado un poco la lógica descendente, pero nos gustaron los paralelos de cómo funcionan las solicitudes de red y Single, tanto asincrónicas como satisfactorias o fallidas. También llevamos esa lógica a través de la capa de fuente de datos remota.

El siguiente es nuestra sala Dao. Dado que Room ya funciona con interfaces y anotaciones, no sentimos la necesidad de crear otra interfaz, clase y objeto para ofuscar cómo manejamos los datos persistentes.

Estamos usando un Observable para reaccionar a las emisiones de User objetos y hacer que nuestra acción de inserción devuelva un Completable para ayudarnos a manejar cualquier error de sala que pueda ocurrir.

Finalmente, aquí está nuestra última interfaz para el UserRepository mismo. Es muy simple y limpio. La única parte adicional más allá de lo que se requiere es el método onCleared() que nos ayudará a limpiar cualquier desechable existente en nuestras capas inferiores como ViewModel también se borra.

Implementaciones

Notarás que la firma del constructor es muy similar al ejemplo anterior de Google.Estamos proporcionando un objeto que puede recuperar datos a través de la red, un Dao y un objeto que nos dice en qué subprocesos ejecutar.

Incluso el método getUser(userId: Int) es similar en cómo funciona en el ejemplo anterior. Cree una solicitud de red asincrónica y devuelva inmediatamente un objeto que esté buscando datos de la base de datos. Cuando se complete esa solicitud de red, inserte esos datos en la tabla, lo que activará una actualización de la interfaz de usuario.

Notará que no estamos haciendo nada demasiado sofisticado con RxJava aquí. Creamos un nuevo PublishSubject por el que canalizamos nuestros datos. Lo fusionamos con la selección de la capa persistente, que devuelve un Observable y también estamos enviando errores desde la capa de red allí. Hablamos de cómo manejar la obtención de solo los errores que queremos de cada capa a la capa superior, y esta fue la solución más simple y personalizable a la que llegamos. Sí, crea observables adicionales, pero nos da un control más preciso sobre el manejo de errores.

El último pieza del rompecabezas es la fuente de datos remota. Aquí puede ver cómo estamos convirtiendo el UserResponse en un User, permitiendo que el resto de la base de código se mueva a un ritmo diferente al datos provenientes de la API.

Resumiendo todo

En este punto, debe agregar estos objetos desde arriba al gráfico Dagger. Recuerde que los métodos proporcionados deben devolver el tipo de interfaz y no las implementaciones. De esta manera, puede construir todos los objetos definidos anteriormente en el gráfico de Dagger y eso hace que sea muy fácil obtener datos de un UserRepository en cualquier lugar del código base.

Finalmente, debido a que estamos usando RxJava, debemos asegurarnos de llamar a clear() en cualquier Disposable objetos en nuestro ViewModel y también llamar a onCleared() en nuestro UserRepository instancia para limpiar cualquier Disposable objeto interno.

Ahora tenemos una implementación MVVM bastante sencilla con un repositorio que se nos proporciona a través de nuestro gráfico Dagger. Cada una de las capas que hemos creado tiene un propósito claro y está sujeta a interfaces que nos ayudan a filtrar lo que exponemos al mundo exterior. Esto también nos permite probar estas capas de forma independiente entre sí de forma aislada utilizando un RxJava TestScheduler que nos permite tener un control completo sobre la ejecución de la prueba.

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *