Patrones de modularización comunes

No hay una única estrategia de modularización que se ajuste a todos los proyectos. Debido a la naturaleza flexible de Gradle, existen pocas restricciones sobre cómo puedes organizar un proyecto. En esta página, se proporciona una descripción general de algunas reglas generales y patrones comunes que puedes emplear al desarrollar apps para Android de varios módulos.

Principio de alta cohesión y bajo acoplamiento

Una forma de caracterizar una base de código modular sería usar las propiedades de acoplamiento y cohesión. El acoplamiento mide el grado en el que los módulos dependen unos de otros. En este contexto, la cohesión mide cómo se relacionan funcionalmente los elementos de un solo módulo. Como regla general, debes esforzarte por lograr un acoplamiento bajo y una cohesión alta:

  • Acoplamiento bajo significa que los módulos deben ser lo más independientes posible entre sí, de modo que los cambios en un módulo tengan un impacto nulo o mínimo en otros módulos. Los módulos no deben conocer el funcionamiento interno de otros módulos.
  • Cohesión alta significa que los módulos deben incluir una colección de código que actúe como sistema. Deben tener responsabilidades definidas con claridad y mantenerse dentro de los límites de ciertos conocimientos del dominio. Considera un ejemplo de aplicación de libro electrónico. Es posible que resulte inapropiado combinar códigos relacionados con libros y pagos en el mismo módulo, ya que son dos dominios funcionales diferentes.

Tipos de módulos

La forma de organizar tus módulos depende principalmente de la arquitectura de tu app. A continuación, se muestran algunos tipos comunes de módulos que puedes introducir en tu app mientras sigues nuestra arquitectura de la app recomendada.

.

Módulos de datos

Por lo general, un módulo de datos contiene un repositorio, fuentes de datos y clases de modelos. Las tres responsabilidades principales de un módulo de datos son las siguientes:

  1. Encapsular todos los datos y la lógica empresarial de un dominio determinado: Cada módulo de datos debe ser responsable de administrar los datos que representan un dominio determinado. Puede controlar muchos tipos de datos, siempre y cuando estén relacionados.
  2. Expón el repositorio como una API externa: La API pública de un módulo de datos debe ser un repositorio, ya que es responsable de exponer los datos al resto de la app.
  3. Oculta todos los detalles de implementación y las fuentes de datos desde el exterior: Solo los repositorios del mismo módulo deberían poder acceder a las fuentes de datos. Permanecen ocultas en el exterior. Puedes aplicar esto con la palabra clave de visibilidad private o internal de Kotlin.
Figura 1: Muestras de módulos de datos y su contenido.

Módulos de funciones

Una función es una parte aislada de la funcionalidad de una app que, por lo general, corresponde a una pantalla o serie de pantallas estrechamente relacionadas, como un flujo de registro o confirmación de la compra. Si tu app tiene una barra de navegación inferior, es probable que cada destino sea una función.

Figura 2: Cada pestaña de la aplicación se puede definir como una función.

Las funciones están asociadas con pantallas o destinos de tu app. Por lo tanto, es probable que tengan una IU asociada y ViewModel para controlar su lógica y estado. No es necesario que una función se limite a una sola vista o destino de navegación. Los módulos de funciones dependen de los módulos de datos.

Figura 3: Ejemplos de módulos de funciones y su contenido.

Módulos de la app

Los módulos de la app son un punto de entrada a la aplicación. Dependen de los módulos de funciones y, por lo general, proporcionan la navegación raíz. Un solo módulo de app se puede compilar en varios objetos binarios diferentes gracias a las variantes de compilación.

Figura 4: Gráfico de dependencias de los módulos de variantes de producto "Demo" (demostración) y "Full" (completo).

Si tu app está segmentada para varios tipos de dispositivos, como autos, Wear o TV, define un módulo de app para cada uno. Esto ayuda a separar las dependencias específicas de la plataforma.

Figura 5: Gráfico de dependencia de la app para Wear

Módulos comunes

Los módulos comunes, también conocidos como módulos principales, contienen código que otros módulos usan con frecuencia. Reducen la redundancia y no representan ninguna capa específica de la arquitectura de una app. Los siguientes son ejemplos de módulos comunes:

  • Módulo de IU: Si usas elementos de la IU personalizados o un desarrollo de la marca elaborado en tu app, considera encapsular tu colección de widgets en un módulo para que todas las funciones se vuelvan a usar. Esto podría ayudar a que tu IU sea coherente en diferentes funciones. Por ejemplo, si tus temas están centralizados, puedes evitar una refactorización dolorosa cuando se produce un cambio de la marca.
  • Módulo de Analytics: A menudo, el seguimiento se determina según los requisitos comerciales y se debe tener muy en cuenta la arquitectura de software. Los rastreadores de Analytics suelen usarse en muchos componentes no relacionados. Si ese es tu caso, te recomendamos que tengas un módulo de estadísticas dedicado.
  • Módulo de red: Cuando muchos módulos requieren una conexión de red, puedes considerar tener un módulo dedicado a proporcionar un cliente HTTP. Resulta muy útil cuando tu cliente requiere una configuración personalizada.
  • Módulo de utilidad: Las utilidades, también conocidas como asistentes, por lo general son pequeñas piezas de código que se reutilizan en la aplicación. Algunos ejemplos de utilidades incluyen asistentes de prueba, una función de formato de moneda, un validador de correo electrónico o un operador personalizado.

Módulos de prueba

Los módulos de prueba son módulos de Android cuyos fines son exclusivos de evaluación. Contienen código, recursos y dependencias de prueba que solo son necesarios para ejecutar pruebas y que no son necesarios durante el tiempo de ejecución de la aplicación. Los módulos de prueba se crean para separar el código específico de prueba de la aplicación principal, lo que facilita la administración y el mantenimiento del código del módulo.

Casos de uso para módulos de prueba

En los siguientes ejemplos, se muestran situaciones en las que la implementación de módulos de prueba puede ser particularmente beneficiosa:

  • Código de prueba compartido: Si tienes varios módulos en tu proyecto y parte del código de prueba se aplica a más de un módulo, puedes crear un módulo de prueba para compartir el código. Esto puede ayudar a reducir la duplicación y facilitar el mantenimiento del código de prueba. El código de prueba compartido puede incluir clases o funciones de utilidad, como aserciones o comparadores personalizados, además de datos de prueba, como respuestas JSON simuladas.

  • Configuraciones de compilación más limpias: Los módulos de prueba te permiten tener configuraciones de compilación más limpias, ya que pueden tener su propio archivo build.gradle. No tienes que desordenar el archivo build.gradle del módulo de tu app con configuraciones que solo son relevantes para pruebas.

  • Pruebas de integración: Los módulos de prueba se pueden usar para almacenar pruebas de integración que se usan para probar interacciones entre diferentes partes de la app, como la interfaz de usuario, la lógica empresarial, las solicitudes de red y las consultas a la base de datos.

  • Aplicaciones a gran escala: Los módulos de prueba son particularmente útiles para aplicaciones a gran escala con bases de código complejas y varios módulos. En esos casos, los módulos de prueba pueden ayudar a mejorar la organización y el mantenimiento del código.

Figura 6: Los módulos de prueba se pueden usar para aislar módulos que, de otro modo, dependerían unos de otros.

Comunicación entre módulos

Los módulos rara vez existen en la separación total y, a menudo, dependen de otros y se comunican con ellos. Es importante mantener el acoplamiento bajo incluso cuando los módulos funcionan juntos e intercambian información con frecuencia. A veces, la comunicación directa entre dos módulos no es conveniente, como en el caso de restricciones de arquitectura. También puede ser imposible, como con las dependencias cíclicas.

Figura 7: Es imposible establecer una comunicación directa y bidireccional entre los módulos debido a las dependencias cíclicas. Se necesita un módulo de mediación para coordinar el flujo de datos entre otros dos módulos independientes.

Para solucionar este problema, puedes tener un tercer módulo de mediación entre otros dos módulos. El módulo mediador puede escuchar mensajes de ambos módulos y reenviarlos según sea necesario. En nuestra app de ejemplo, la pantalla de confirmación de la compra debe saber qué libro comprar, incluso aunque el evento se haya originado en otra pantalla que forme parte de una función diferente. En este caso, el mediador es el módulo que posee el gráfico de navegación (por lo general, un módulo de app). En el ejemplo, usamos la navegación para pasar los datos de la función principal a la función de confirmación de la compra mediante el componente Navigation.

navController.navigate("checkout/$bookId")

El destino de confirmación de la compra recibe un ID de libro como argumento que usa para recuperar información sobre el libro. Puedes usar el controlador de estado guardado para recuperar argumentos de navegación dentro del ViewModel de un atributo de destino.

class CheckoutViewModel(savedStateHandle: SavedStateHandle, …) : ViewModel() {

   val uiState: StateFlow<CheckoutUiState> =
      savedStateHandle.getStateFlow<String>("bookId", "").map { bookId ->
          // produce UI state calling bookRepository.getBook(bookId)
      }
      …
}

No debes pasar objetos como argumentos de navegación. En su lugar, usa IDs simples que las funciones puedan usar para acceder y cargar los recursos deseados desde la capa de datos. De esta manera, mantendrás el acoplamiento bajo y no infringirás el principio de fuente de confianza única.

En el siguiente ejemplo, ambos módulos de funciones dependen del mismo módulo de datos. Esto permite minimizar la cantidad de datos que el módulo mediador necesita reenviar y mantiene el acoplamiento entre los módulos bajo. En lugar de pasar objetos, los módulos deben intercambiar IDs básicos y cargar los recursos desde un módulo de datos compartidos.

Figura 8: Dos módulos de funciones que dependen de un módulo de datos compartidos.

Inversión de dependencias

La inversión de dependencias se produce cuando organizas tu código de modo que la abstracción sea independiente de una implementación concreta.

  • Abstracción: Es un contrato que define cómo interactúan los componentes o los módulos de la aplicación entre sí. Los módulos de abstracción definen la API de tu sistema y contienen interfaces y modelos.
  • Implementación concreta: Son módulos que dependen del módulo de abstracción y que implementan el comportamiento de una abstracción.

Los módulos que dependen del comportamiento definido en el módulo de abstracción solo deben depender de la abstracción, en lugar de las implementaciones específicas.

Figura 9: En lugar de módulos de alto nivel que dependen directamente de los módulos de bajo nivel, los de alto nivel y los de implementación dependen del módulo de abstracción.

Ejemplo

Imagina un módulo de funciones que necesita una base de datos para funcionar. El módulo de funciones no está relacionado con la forma en que se implementa la base de datos, ya sea una base de datos local de Room o una instancia remota de Firestore. Solo necesita almacenar y leer los datos de la aplicación.

Para lograrlo, el módulo de funciones depende del módulo de abstracción en lugar de una implementación de base de datos específica. Esta abstracción define la API de la base de datos de la app. En otras palabras, establece las reglas para interactuar con la base de datos. Esto permite que el módulo de funciones use cualquier base de datos sin necesidad de conocer los detalles de implementación subyacentes.

El módulo de implementación concreto proporciona la implementación real de las APIs definidas en el módulo de abstracción. Para ello, el módulo de implementación también depende del módulo de abstracción.

Inserción de dependencias

Es posible que te preguntes cómo se conecta el módulo de funciones con el módulo de implementación. La respuesta es la inserción de dependencias. El módulo de funciones no crea directamente la instancia de base de datos requerida. En cambio, especifica las dependencias que necesita. Luego, estas dependencias se proporcionan de forma externa, generalmente en el módulo de app.

releaseImplementation(project(":database:impl:firestore"))

debugImplementation(project(":database:impl:room"))

androidTestImplementation(project(":database:impl:mock"))

Beneficios

Los beneficios de separar las APIs y sus implementaciones son los siguientes:

  • Intercambiabilidad: Con una separación clara de la API y los módulos de implementación, puedes desarrollar varias implementaciones para la misma API y alternar entre ellas sin cambiar el código que usa la API. Esto podría ser particularmente beneficioso en situaciones en las que desees proporcionar diferentes capacidades o comportamiento en distintos contextos. Por ejemplo, una implementación simulada para pruebas en comparación con una implementación real para producción.
  • Separación: La separación significa que los módulos que usan abstracciones no dependen de ninguna tecnología específica. Si decides cambiar tu base de datos de Room a Firestore más adelante, sería más fácil porque los cambios solo ocurrirían en el módulo específico que realiza el trabajo (módulo de implementación) y no afectarían a otros módulos que usen la API de tu base de datos.
  • Capacidad de prueba: Separar las APIs de sus implementaciones puede facilitar mucho las pruebas. Puedes escribir casos de prueba en los contratos de la API. También puedes usar diferentes implementaciones para probar varias situaciones y casos extremos, incluidas las implementaciones simuladas.
  • Rendimiento de compilación mejorado: Cuando separas una API y su implementación en diferentes módulos, los cambios en el módulo de implementación no obligan al sistema de compilación a volver a compilar los módulos según el módulo de API. Esto genera tiempos de compilación más rápidos y aumenta la productividad, especialmente en proyectos grandes en los que los tiempos de compilación pueden ser significativos.

Cuándo separar

Es conveniente separar las APIs de sus implementaciones en los siguientes casos:

  • Varias capacidades: Si puedes implementar partes de tu sistema de varias maneras, una API clara permite el intercambio de implementaciones diferentes. Por ejemplo, puedes tener un sistema de renderización que use OpenGL o Vulkan, o bien un sistema de facturación que funcione con Play o tu API de facturación interna.
  • Varias aplicaciones: Si desarrollas varias aplicaciones con capacidades compartidas para diferentes plataformas, puedes definir APIs comunes y desarrollar implementaciones específicas por plataforma.
  • Equipos independientes: La separación permite que diferentes desarrolladores o equipos trabajen en diferentes partes de la base de código de manera simultánea. Los desarrolladores deben enfocarse en comprender los contratos de la API y usarlos correctamente. No tienen que preocuparse por los detalles de implementación de otros módulos.
  • Base de código grande: Cuando la base de código es grande o compleja, separar la API de la implementación hace que el código sea más manejable. Te permite desglosar la base de código en unidades más detalladas, comprensibles y mantenibles.

¿Cómo hacer la implementación?

Para implementar la inversión de dependencias, sigue estos pasos:

  1. Crea un módulo de abstracción: Este módulo debe contener APIs (interfaces y modelos) que definan el comportamiento de tu función.
  2. Crea módulos de implementación: Los módulos de implementación deben basarse en el módulo de API y deben implementar el comportamiento de una abstracción.
    En lugar de módulos de alto nivel que dependen directamente de los módulos de bajo nivel, los de alto nivel y los de implementación dependen del módulo de abstracción.
    Figura 10: Los módulos de implementación dependen del módulo de abstracción.
  3. Haz que los módulos de alto nivel dependan de los módulos de abstracción: En lugar de depender directamente de una implementación específica, haz que tus módulos dependan de los de abstracción. Los módulos de alto nivel no necesitan conocer los detalles de la implementación, solo necesitan el contrato (API).
    Los módulos de alto nivel dependen de las abstracciones, no de la implementación.
    Figura 11: Los módulos de alto nivel dependen de las abstracciones, no de la implementación.
  4. Proporciona un módulo de implementación: Por último, debes proporcionar la implementación real de tus dependencias. La implementación específica depende de la configuración de tu proyecto, pero el módulo de la app suele ser un buen lugar para hacerlo. Para proporcionar la implementación, especifícala como una dependencia para la variante de compilación seleccionada o un conjunto de orígenes de prueba.
    El módulo de la app proporciona una implementación real.
    Figura 12: El módulo de la app proporciona una implementación real.

Prácticas recomendadas generales

Como se mencionó al principio, no existe una única manera correcta de desarrollar una app de varios módulos. Así como existen muchas arquitecturas de software, hay varias formas de modularizar una app. Sin embargo, las siguientes recomendaciones generales pueden ayudarte a lograr que tu código sea más legible y fácil de mantener y probar.

Mantén una configuración coherente

Cada módulo presenta una sobrecarga de configuración. Si la cantidad de módulos alcanza un umbral determinado, administrar la configuración de manera coherente se convierte en un desafío. Por ejemplo, es importante que los módulos usen dependencias de la misma versión. Si necesitas actualizar una gran cantidad de módulos solo para generar una versión de dependencia, no es solo un esfuerzo, sino también un espacio para posibles errores. Para resolver este problema, puedes usar una de las herramientas de Gradle a fin de centralizar la configuración:

  • Los catálogos de versiones son una lista de dependencias de tipo seguro que genera Gradle durante la sincronización. Es un lugar central para declarar todas tus dependencias y está disponible para todos los módulos de un proyecto.
  • Usa complementos de convención para compartir la lógica de compilación entre módulos.

Expón lo menos posible

La interfaz pública de un módulo debe ser mínima y exponer solo lo esencial. No debe filtrar los detalles de la implementación al exterior. Limita todo a la menor medida posible. Usa el permiso de visibilidad private o internal de Kotlin para hacer que las declaraciones sean privadas. Cuando declares dependencias en tu módulo, prioriza implementation en lugar de api. Esta última expone dependencias transitivas a los consumidores de tu módulo. El uso de la implementación puede mejorar el tiempo de compilación, ya que reduce la cantidad de módulos que se deben volver a compilar.

Opta por módulos de Kotlin y Java

Existen tres tipos esenciales de módulos compatibles con Android Studio:

  • Los módulos de la app son un punto de entrada a tu aplicación. Pueden contener código fuente, recursos, elementos y un AndroidManifest.xml. El resultado de un módulo de app es un Android App Bundle (AAB) o un Android Application Package (APK).
  • Los módulos de biblioteca tienen el mismo contenido que los módulos de app. Otros módulos de Android los usan como dependencia. El resultado de un módulo de biblioteca es un Android Archive (AAR) estructuralmente idéntico al de los módulos de la app, pero se compilan en un archivo Android Archive (AAR) que otros módulos pueden usar como dependencia. Un módulo de biblioteca permite encapsular y reutilizar la misma lógica y los mismos recursos en muchos módulos de app.
  • Las bibliotecas de Kotlin y Java no contienen recursos, elementos ni archivos de manifiesto de Android.

Dado que los módulos de Android incluyen sobrecarga, es preferible que uses el tipo de Kotlin o Java tanto como sea posible.