Guía de arquitectura de apps

En esta guía, se incluyen las prácticas y la arquitectura recomendadas para desarrollar apps sólidas y de calidad.

Experiencias del usuario de apps para dispositivos móviles

Una app de Android típica consta de varios componentes de la app, como actividades, fragmentos, servicios, proveedores de contenido y receptores de emisión. A la mayoría de estos componentes los declaras en el manifiesto de la app. Luego, el SO Android usa ese archivo para decidir la integración de tu app a la experiencia del usuario general del dispositivo. Dado que una app para Android típica puede contener varios componentes y que los usuarios suelen interactuar con diferentes apps en poco tiempo, las apps deben adaptarse a distintos tipos de tareas y flujos de trabajo controlados por los usuarios.

Ten en cuenta que los dispositivos móviles tienen restricciones de recursos, de manera que, en cualquier momento, el sistema operativo podría cerrar algunos procesos de apps para hacer lugar para otros.

Según las condiciones de este entorno, es posible que los componentes de tu app se inicien de manera individual y desordenada, además de que el usuario o el sistema operativo podrían destruirlos en cualquier momento Debido a que no puedes controlar estos eventos, no debes almacenar ni mantener en la memoria ningún estado ni datos de la aplicación en los componentes de tu app, y estos elementos no deben ser interdependientes.

Principios comunes de arquitectura

Si se supone que no deberías usar los componentes de la aplicación para almacenar datos y estados, ¿cómo deberías diseñarla?

A medida que crece el tamaño de las apps para Android, es importante definir una arquitectura que permita que esta crezca, aumente su solidez y facilite su prueba.

La arquitectura de una app define los límites entre sus partes y las responsabilidades que debe tener cada una. A fin de satisfacer las necesidades que se mencionaron antes, debes diseñar la arquitectura de tu app para que cumpla con algunos principios específicos.

Separación de problemas

El principio más importante que debes seguir es el de separación de problemas. Un error común es escribir todo tu código en una Activity o un Fragment. Estas clases basadas en IU solo deberían contener lógica que se ocupe de interacciones del sistema operativo y de IU. Si mantienes estas clases tan limpias como sea posible, puedes evitar muchos problemas relacionados con el ciclo de vida de los componentes y mejorar la capacidad de prueba de estas clases.

Ten en cuenta que las implementaciones de Activity y Fragment no son de tu propiedad, sino que estas solo son clases que representan el contrato entre el SO Android y tu app. El SO puede destruirlas en cualquier momento en función de las interacciones de usuarios y otras condiciones del sistema, como memoria insuficiente. Para brindar una experiencia del usuario satisfactoria y una experiencia de mantenimiento de apps más fácil de administrar, recomendamos que dependas en menor medida de esas apps.

Cómo controlar la IU a partir de modelos de datos

Otro principio importante es que debes controlar la IU a partir de modelos de datos, preferentemente que sean de persistencia. Los modelos de datos representan los datos de una app. Son independientes de los elementos de la IU y otros componentes de la app. Por lo tanto, no están vinculados a la IU ni al ciclo de vida de esos componentes, pero se destruirán cuando el SO decida quitar el proceso de la app de la memoria.

Los modelos de persistencia son ideales por los siguientes motivos:

  • Tus usuarios no perderán datos si el SO Android destruye tu app para liberar recursos.

  • Tu app continúa funcionando cuando una conexión de red es débil o no está disponible.

Si basas la arquitectura de tu app en clases de modelos de datos, mejorarás la capacidad de prueba y la solidez de tu app.

Única fuente de información

Cuando se define un nuevo tipo de datos en tu app, debes asignarle una Única fuente de información (SSOT, por sus siglas en inglés). La SSOT es la propietaria de esos datos, y solo SSOT puede modificarlos o mutarlos. Con ese fin, la SSOT expone los datos con un tipo inmutable y, para modificarlos, expone funciones o recibe eventos a los que otros tipos pueden llamar.

Este patrón trae varios beneficios:

  • Centraliza todos los cambios en un tipo particular de datos en un solo lugar.
  • Protege los datos para que otros tipos no puedan manipularlos.
  • Hace que los cambios en los datos sean más rastreables. Por lo tanto, los errores son más fáciles de detectar.

En una aplicación que prioriza la condición sin conexión, la fuente de información de los datos de aplicación suele ser una base de datos. En otros casos, la fuente de información puede ser un ViewModel o incluso la IU.

Flujo de datos unidireccional

El principio de fuente de confianza única se suele usar en nuestras guías con el patrón de flujo de datos unidireccional (UDF). En este flujo, el estado fluye en una sola dirección. Los eventos que modifican los datos fluyen en la dirección opuesta.

En Android, el estado o los datos suelen fluir desde los tipos de jerarquía más altos a los de menor alcance. Por lo general, los eventos se activan desde los tipos de alcance inferior hasta que alcanzan la SSOT para el tipo de datos correspondiente. Por ejemplo, los datos de la aplicación generalmente fluyen desde las fuentes de datos hacia la IU. Los eventos del usuario, como presionar el botón, fluyen desde la IU hasta el SSOT, en donde los datos de la aplicación se modifican y se exponen en un tipo inmutable.

Este patrón garantiza mejor la coherencia de los datos, es menos propenso a errores, es más fácil de depurar y brinda todos los beneficios del patrón de SSOT.

En esta sección, se muestra cómo estructurar la app según las prácticas recomendadas.

Teniendo en cuenta los principios de arquitectura comunes que se mencionaron en la sección anterior, cada aplicación debe tener al menos dos capas:

  • La capa de la IU que muestra los datos de la aplicación en la pantalla.
  • La capa de datos que contiene la lógica empresarial de tu aplicación y expone sus datos.

Puedes agregar una capa adicional llamada capa de dominio para simplificar y volver a utilizar las interacciones entre la IU y las capas de datos.

En una arquitectura típica de la app, la capa de la IU obtiene los datos de la app de la capa de datos o de la capa opcional de dominio, que se encuentra entre la capa de la IU y la de datos.
Figura 1: Diagrama de una arquitectura de app típica

Arquitectura moderna de apps

Esta arquitectura moderna de apps promueve el uso de las siguientes técnicas, entre otras:

  • Arquitectura en capas y reactiva
  • Flujo unidireccional de datos (UDF) en todas las capas de la app
  • Una capa de IU con contenedores de estado para administrar la complejidad de la IU
  • Corrutinas y flujos
  • Prácticas recomendadas para la inserción de dependencias

Para obtener más información, consulta las siguientes secciones, las otras páginas de la arquitectura en el índice y la página de recomendaciones que contiene un resumen de las prácticas recomendadas más importantes.

Capa de la IU

La función de la capa de la IU (o capa de presentación) consiste en mostrar los datos de la aplicación en la pantalla. Cuando los datos cambian, ya sea debido a la interacción del usuario (como cuando presiona un botón) o una entrada externa (como una respuesta de red), la IU debe actualizarse para reflejar los cambios.

La capa de la IU consta de los siguientes dos elementos:

  • Elementos de la IU que renderizan los datos en la pantalla (puedes compilar estos elementos con las vistas o las funciones de Jetpack Compose)
  • Contenedores de estados (como las clases ViewModel) que retienen datos, los exponen a la IU y controlan la lógica
En una arquitectura típica, los elementos de la IU de la capa de la IU dependen de los contenedores de estado, que, a su vez, dependen de las clases de la capa de datos o de la capa opcional de dominio.
Figura 2: La función de la capa de la IU en la arquitectura de la app

Para obtener más información sobre esta capa, consulta la página sobre la capa de la IU.

Capa de datos

La capa de datos de una app contiene la lógica empresarial. Esta lógica es lo que le da valor a tu app. Además, está compuesta por reglas que determinan cómo tu app crea, almacena y cambia datos.

La capa de datos está formada por repositorios que pueden contener de cero a muchas fuentes de datos. Debes crear una clase de repositorio para cada tipo de datos diferente que administres en tu app. Por ejemplo, puedes crear una clase MoviesRepository para datos relacionados con películas o una clase PaymentsRepository para datos relacionados con pagos.

En una arquitectura típica, los repositorios de la capa de datos proporcionan datos al resto de la app y dependen de las fuentes de datos.
Figura 3: La función de la capa de datos en la arquitectura de la app

Las clases de repositorio son responsables de las siguientes tareas:

  • Exponer datos al resto de la app
  • Centralizar los cambios en los datos
  • Resolver conflictos entre múltiples fuentes de datos
  • Abstraer fuentes de datos del resto de la app
  • Contener la lógica empresarial

Cada clase de fuente de datos debe tener la responsabilidad de trabajar con una sola fuente de datos, que puede ser un archivo, una fuente de red o una base de datos local. Las clases de fuente de datos son el puente entre la aplicación y el sistema para las operaciones de datos.

Para obtener más información sobre esta capa, consulta la página sobre la capa de datos.

Capa de dominio

La capa de dominio es una capa opcional que se ubica entre la capa de la IU y la de datos.

La capa de dominio es responsable de encapsular la lógica empresarial compleja o la lógica empresarial simple que varios ViewModels reutilizan. Esta capa es opcional porque no todas las apps tendrán estos requisitos. Solo debes usarla cuando sea necesario; por ejemplo, para administrar la complejidad o favorecer la reutilización.

Cuando está incluida, la capa opcional de dominio brinda dependencias a la capa de la IU y depende de la capa de datos.
Figura 4: La función de la capa de dominio en la arquitectura de la app

Las clases de esta capa se denominan casos de uso o interactores. Cada caso de uso debe tener responsabilidad sobre una funcionalidad única. Por ejemplo, tu app podría tener una clase GetTimeZoneUseCase si varios ViewModels dependen de las zonas horarias para mostrar el mensaje adecuado en la pantalla.

Para obtener más información sobre esta capa, consulta la página sobre la capa del dominio.

Cómo administrar dependencias entre componentes

Las clases de tu app dependen de otras para funcionar correctamente. Puedes usar cualquiera de los siguientes patrones de diseño para recopilar las dependencias de una clase en particular:

  • Inserción de dependencia (DI): Permite que las clases definan sus dependencias sin construirlas. En el tiempo de ejecución, otra clase es responsable de proporcionar estas dependencias.
  • Localizador de servicios: Su patrón brinda un registro en el que las clases pueden obtener sus dependencias en lugar de construirlas.

Estos patrones te permiten hacer un escalamiento del código, ya que proporcionan patrones claros para administrar dependencias sin duplicar el código ni aumentar la complejidad. Además, te permiten cambiar rápidamente entre las implementaciones de prueba y de producción.

Te recomendamos seguir los patrones de inserción de dependencia y usar la biblioteca Hilt en las apps para Android. Hilt construye automáticamente objetos con un recorrido del árbol de dependencias, proporciona garantías de tiempo de compilación sobre dependencias y crea contenedores de dependencias para clases de framework de Android.

Prácticas recomendadas generales

La programación es una disciplina creativa y crear apps de Android no es una excepción. Hay muchas maneras de resolver un problema: puedes comunicar datos entre varias actividades o fragmentos, recuperar datos remotos y conservarlos a nivel local para el modo sin conexión, o bien controlar cualquier cantidad de situaciones comunes con las que pueden encontrarse las apps no triviales.

Aunque las siguientes recomendaciones no son obligatorias, en la mayoría de los casos, si las sigues, tu código base será más confiable, tendrá mayor capacidad de prueba y será más fácil de mantener a largo plazo.

No almacenes datos en los componentes de la app

Evita designar los puntos de entrada de tu app (receptores de emisiones, servicios y actividades) como fuentes de datos. En cambio, solo deben coordinar con otros componentes para recuperar el subconjunto de datos relevante para ese punto de entrada. Cada componente de la app tiene una duración relativamente corta, según la interacción que el usuario tenga con su dispositivo y el estado general del sistema en ese momento.

Reduce las dependencias de clases de Android

Los componentes de tu app deben ser las únicas clases que dependan de las APIs del SDK del framework de Android, como Context o Toast. La abstracción de otras clases en tu app fuera de ellas ayuda con la capacidad de prueba y reduce el acoplamiento dentro de la app.

Crea límites de responsabilidad bien definidos entre varios módulos de tu app.

Por ejemplo, no extiendas el código que carga datos de la red entre varias clases o paquetes en tu código base. Del mismo modo, no definas varias responsabilidades no relacionadas, como caché de datos y vinculación de datos, en la misma clase. Podría ser útil que siguieras la arquitectura de la app recomendada.

Expón lo mínimo indispensable de cada módulo

Por ejemplo, no caigas en la tentación de crear un acceso directo que exponga un detalle interno de la implementación de un módulo. Quizás ahorres algo de tiempo a corto plazo, pero tendrás más probabilidades de que se generen problemas técnicos a medida que tu código base evolucione.

Concéntrate en aquello que hace única a tu app para que se destaque del resto

No desperdicies tu tiempo reinventando algo que ya existe ni escribiendo el mismo código estándar una y otra vez. En cambio, enfoca tu tiempo y tu energía en aquello que hace que tu app sea única y deja que tanto las bibliotecas de Jetpack como las otras recomendadas se ocupen del código estándar repetitivo.

Piensa en cómo lograr que cada parte de tu app se pueda probar por separado

Por ejemplo, una API bien definida para recuperar datos de la red facilitará las pruebas que realices en el módulo que conserve esa información en la base de datos local. En cambio, si combinas la lógica de estos dos módulos en un solo lugar, o bien si distribuyes el código de red por todo tu código base, será mucho más difícil (y quizás hasta imposible) ponerlo a prueba eficazmente.

Los tipos son responsables de su política de simultaneidad

Si un tipo realiza un trabajo de bloqueo de larga duración, debería ser responsable de mover ese cálculo al subproceso correcto. Ese tipo particular conoce el tipo de procesamiento que está haciendo y en qué subproceso debe ejecutarse. Los tipos deben ser seguros para el subproceso principal, lo que significa que son seguros llamarlos desde el subproceso principal sin bloquearlo.

Conserva la mayor cantidad posible de datos relevantes y actualizados

De esa manera, los usuarios podrán aprovechar la funcionalidad de tu app, incluso cuando su dispositivo esté en modo sin conexión. Recuerda que no todos tus usuarios cuentan con una conexión de alta velocidad de manera constante y, si lo hacen, pueden tener una mala recepción en lugares muy concurridos.

Beneficios de la arquitectura

Tener una buena arquitectura implementada en tu app trae muchos beneficios a los equipos de proyectos y de ingeniería:

  • Mejora la capacidad de mantenimiento, calidad y solidez de la app en general.
  • Permite que la aplicación escale. Más personas y más equipos pueden contribuir a la misma base de código con conflictos de código mínimos.
  • Ayuda con la integración. A medida que la arquitectura aporta coherencia a tu proyecto, los miembros nuevos del equipo pueden ponerse al día rápidamente y ser más eficientes en menos tiempo.
  • Es más fácil probarlo. Una buena arquitectura fomenta tipos más simples que, en general, son más fáciles de probar.
  • Los errores se pueden investigar metódicamente con procesos bien definidos.

Invertir en arquitectura también tiene un impacto directo en tus usuarios, ya que se beneficiarán de una aplicación más estable y de más funciones gracias a un equipo de ingeniería más productivo. Sin embargo, la arquitectura también requiere una inversión de tiempo inicial. Para ayudarte a justificar este tiempo al resto de tu empresa, revisa estos casos de éxito en los que otras empresas comparten sus historias de éxito cuando tienen una buena arquitectura en su app.

Ejemplos

En los siguientes ejemplos de Google, se demuestra una buena arquitectura de la app. Explóralos para ver esta guía en práctica: