Antipatrón de repositorio en Android

La traducción del artículo se preparó en previsión del inicio del curso "Desarrollador de Android. Profesional " .








La Guía oficial de arquitectura de aplicaciones de Android recomienda usar las clases de repositorio para "proporcionar una API limpia para que el resto de la aplicación pueda recuperar datos fácilmente". Sin embargo, en mi opinión, si usa este patrón en su proyecto, está garantizado que se empantanará en un código espagueti desordenado.



En este artículo te hablaré sobre el "Patrón de repositorio" y explicaré por qué es en realidad un anti-patrón para aplicaciones de Android.



Repositorio



La Guía de arquitectura de aplicaciones antes mencionada recomienda la siguiente estructura para organizar la lógica del nivel de presentación:







La función del objeto del repositorio en esta estructura es la siguiente: Los



módulos del repositorio manejan las operaciones de datos. Proporcionan una API limpia para que el resto de la aplicación pueda recuperar estos datos fácilmente. Saben dónde obtener los datos y qué llamadas a la API realizar cuando se actualizan. Puede pensar en los repositorios como intermediarios entre diferentes fuentes de datos, como modelos persistentes, servicios web y cachés.



Básicamente, la guía recomienda utilizar repositorios para abstraer la fuente de datos en su aplicación. Suena muy razonable e incluso útil, ¿no?



Sin embargo, no olvidemos que charlar no es arrojar bolsas (en este caso, escribir código), sino revelar temas arquitectónicos usando diagramas UML, incluso más. La verdadera prueba de cualquier patrón arquitectónico es la implementación en código y luego identificar sus ventajas y desventajas. Así que busquemos algo menos abstracto para revisar.



Repositorio en los planos de arquitectura de Android v2



Hace unos dos años, revisé la "primera versión" de los planos de arquitectura de Android. En teoría, se suponía que debían implementar un ejemplo limpio de MVP, pero en la práctica, estos planos dieron como resultado una base de código bastante sucia. Contienen interfaces denominadas Vista y Presentador, pero no establecen límites arquitectónicos, por lo que no es esencialmente MVP. Puede ver la revisión del código dado aquí .



Desde entonces, Google ha actualizado los planos arquitectónicos utilizando Kotlin, ViewModel y otras prácticas "modernas", incluidos los repositorios. Estos planos actualizados tienen el prefijo v2.



Echemos un vistazo a la interfaz TasksRepository de los planos v2:



interface TasksRepository {
   fun observeTasks(): LiveData<Result<List<Task>>>
   suspend fun getTasks(forceUpdate: Boolean = false): Result<List<Task>>
   suspend fun refreshTasks()
   fun observeTask(taskId: String): LiveData<Result<Task>>
   suspend fun getTask(taskId: String, forceUpdate: Boolean = false): Result<Task>
   suspend fun refreshTask(taskId: String)
   suspend fun saveTask(task: Task)
   suspend fun completeTask(task: Task)
   suspend fun completeTask(taskId: String)
   suspend fun activateTask(task: Task)
   suspend fun activateTask(taskId: String)
   suspend fun clearCompletedTasks()
   suspend fun deleteAllTasks()
   suspend fun deleteTask(taskId: String)
}


Incluso antes de leer el código, puede prestar atención al tamaño de esta interfaz; esto ya es una llamada de atención. Tal cantidad de métodos en una interfaz generaría preguntas incluso en grandes proyectos de Android, pero estamos hablando de una aplicación ToDo con solo 2000 líneas de código. ¿Por qué esta aplicación bastante trivial necesita una clase con una superficie de API tan grande?



El repositorio como objeto divino



La respuesta a la pregunta de la sección anterior se cubre en los nombres de los métodos TasksRepository. Puedo dividir aproximadamente los métodos de esta interfaz en tres grupos que no se superponen.



Grupo 1:



fun observeTasks(): LiveData<Result<List<Task>>>
   fun observeTask(taskId: String): LiveData<Result<Task>>


Grupo 2:



   suspend fun getTasks(forceUpdate: Boolean = false): Result<List<Task>>
   suspend fun refreshTasks()
   suspend fun getTask(taskId: String, forceUpdate: Boolean = false): Result<Task>
   suspend fun refreshTask(taskId: String)
   suspend fun saveTask(task: Task)
   suspend fun deleteAllTasks()
   suspend fun deleteTask(taskId: String)


Grupo 3:



  suspend fun completeTask(task: Task)
   suspend fun completeTask(taskId: String)
   suspend fun clearCompletedTasks()
   suspend fun activateTask(task: Task)
   suspend fun activateTask(taskId: String)


Ahora definamos las áreas de responsabilidad de cada uno de los grupos anteriores.



El grupo 1 es básicamente una implementación del patrón Observer utilizando la función LiveData. El grupo 2 es la puerta de enlace al almacén de datos más dos métodos refreshque son necesarios porque el almacén de datos remoto está oculto detrás del repositorio. El grupo 3 contiene métodos funcionales que básicamente implementan dos partes de la lógica del dominio de la aplicación (finalización de tareas y activación).



Entonces, esta interfaz tiene tres responsabilidades diferentes. No es de extrañar que sea tan grande. Y aunque se puede argumentar que la presencia del primer y segundo grupo como parte de una única interfaz es aceptable, agregar el tercero no está justificado. Si este proyecto necesita desarrollarse más y se convierte en una aplicación de Android real, el tercer grupo crecerá en proporción directa al número de flujos de dominio en el proyecto. Hmm.



Tenemos un término especial para las clases que comparten tantas responsabilidades: Objetos Divinos. Este es un anti-patrón generalizado en aplicaciones de Android. Activitie y Fragment son sospechosos estándar en este contexto, pero otras clases también pueden degenerar en objetos Divinos. Especialmente si sus nombres terminan en "Gerente", ¿verdad?



Espera ... creo que encontré un nombre mejor para TasksRepository:



interface TasksManager {
   fun observeTasks(): LiveData<Result<List<Task>>>
   suspend fun getTasks(forceUpdate: Boolean = false): Result<List<Task>>
   suspend fun refreshTasks()
   fun observeTask(taskId: String): LiveData<Result<Task>>
   suspend fun getTask(taskId: String, forceUpdate: Boolean = false): Result<Task>
   suspend fun refreshTask(taskId: String)
   suspend fun saveTask(task: Task)
   suspend fun completeTask(task: Task)
   suspend fun completeTask(taskId: String)
   suspend fun activateTask(task: Task)
   suspend fun activateTask(taskId: String)
   suspend fun clearCompletedTasks()
   suspend fun deleteAllTasks()
   suspend fun deleteTask(taskId: String)
}


¡Ahora el nombre de esta interfaz refleja mucho mejor sus responsabilidades!



Repositorios anémicos



Aquí puede preguntar: "Si saco la lógica del dominio del repositorio, ¿resolverá eso el problema?" Bueno, volvamos al "diagrama arquitectónico" del manual de Google.



Si completeTaskquisiera extraer, digamos, métodos del TasksRepository, ¿dónde los pondría? De acuerdo con la "arquitectura" recomendada por Google, deberá mover esta lógica a uno de sus ViewModels. No parece una mala decisión, pero realmente lo es.



Por ejemplo, imagina que estás poniendo esta lógica en un ViewModel. Luego, después de un mes, su administrador de cuentas quiere permitir que los usuarios completen tareas desde múltiples pantallas (esto es relevante para todos los administradores de tareas que he usado). La lógica dentro de ViewModel no se puede reutilizar, por lo que debe duplicarla o devolverla al TasksRepository. Obviamente, ambos enfoques son malos.



Un mejor enfoque sería extraer este flujo de dominio en un objeto personalizado y luego colocarlo entre ViewModel y el repositorio. Entonces, diferentes ViewModels podrán reutilizar ese objeto para ejecutar ese hilo en particular. Estos objetos se conocen como "casos de uso" o "interacciones".... Sin embargo, si agrega casos de uso a su base de código, los repositorios se vuelven esencialmente una plantilla inútil. Hagan lo que hagan, encajará mejor con los casos de uso. Gabor Varadi ya ha cubierto este tema en este artículo , por lo que no entraré en detalles. Me suscribo a casi todo lo que dijo sobre "repositorios anémicos".



Pero, ¿por qué los casos de uso son mucho mejores que los repositorios? La respuesta es simple: los casos de uso encapsulan flujos separados. Por lo tanto, en lugar de un repositorio (para cada concepto de dominio) que se convierte gradualmente en un objeto Divino, tendrá varias clases de casos de uso altamente enfocadas. Si el flujo depende de la red y los datos que se almacenan, puede pasar las abstracciones apropiadas a la clase de caso de uso y "arbitrará" entre estas fuentes.



En general, parece que la única forma de evitar la degradación de los repositorios a clases divinas mientras se evitan abstracciones innecesarias es deshacerse de los repositorios.



Repositorios fuera de Android.



Ahora puede que te preguntes si los repositorios son una invención de Google. No, ellos no son. El patrón de repositorio se describió mucho antes de que Google decidiera usarlo en su guía de arquitectura.



Por ejemplo, Martin Fowler describió los repositorios en su libro Patterns of Enterprise Application Architecture. Su blog también tiene un artículo invitado que describe el mismo concepto. Según Fowler, un repositorio es solo una envoltura alrededor del nivel de almacenamiento que proporciona una interfaz de consulta de nivel superior y posiblemente almacenamiento en caché en memoria. Diría que desde el punto de vista de Fowler, los repositorios se comportan como ORM.



Eric Evans también describió los repositorios en su libro Domain Driven Design. El escribio:



, , , — . , . , , .


Tenga en cuenta que puede reemplazar el "repositorio" en la cita anterior con "Room ORM" y seguirá teniendo sentido. Entonces, en el contexto del diseño controlado por dominio, un repositorio es un ORM (implementado a mano o utilizando un marco de terceros).



Como puede ver, el repositorio no se inventó en el mundo de Android. Este es un patrón de diseño muy sano en el que se basan todos los marcos de ORM. Sin embargo, tenga en cuenta lo que no son los repositorios: ninguno de los "clásicos" argumentó que los repositorios deberían intentar abstraerse de la distinción entre acceso a la red y acceso a la base de datos.



De hecho, estoy bastante seguro de que encontrarán esta idea ingenua y contraproducente. Para entender por qué, puede leer otro artículo, esta vez de Joel Spolsky (fundador de StackOverflow), titulado"La ley de las abstracciones con fugas" . En pocas palabras: la creación de redes es demasiado diferente del acceso a la base de datos para abstraerse sin fugas significativas.



Cómo el repositorio se convirtió en anti-patrón en Android



Entonces, ¿Google ha malinterpretado el patrón del repositorio e introducido la idea ingenua de abstraer el acceso a la red en él? Lo dudo.



Encontré el enlace más antiguo a este antipatrón en este repositorio de GitHub , que desafortunadamente es un recurso muy popular. No sé si este autor en particular inventó este antipatrón, pero parece que fue este repositorio el que popularizó la idea general dentro del ecosistema de Android. Los desarrolladores de Google probablemente lo obtuvieron de allí o de una de las fuentes secundarias.



Conclusión



Entonces, el repositorio en Android se ha convertido en un anti-patrón. Se ve bien en papel, pero se vuelve problemático incluso en aplicaciones triviales y puede generar problemas reales en proyectos más grandes.



Por ejemplo, en otro plan de Google, esta vez para componentes arquitectónicos, el uso de repositorios eventualmente llevó a gemas como NetworkBoundResource . Tenga en cuenta que el navegador de muestra GitHub sigue siendo una pequeña aplicación ~ 2 KLOC.



Por lo que puedo decir, el "patrón de repositorio" como se define en los documentos oficiales es incompatible con el código limpio y mantenible.



Gracias por leer y como de costumbre puedes dejar tus comentarios y preguntas a continuación.






All Articles