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 de producción.

Además, se asume que estás familiarizado con los aspectos básicos del framework de Android. Si no tienes experiencia en el desarrollo de apps para Android, consulta nuestras guías para desarrolladores a fin de comenzar y obtener más información sobre los conceptos que se mencionan en esta guía.

Si te interesa la arquitectura de las apps y deseas ver el material de esta guía desde la perspectiva de la programación de Kotlin, consulta el curso Cómo desarrollar apps para Android con Kotlin de Udacity.

Experiencias del usuario de apps para dispositivos móviles

En la mayoría de los casos, las apps de escritorio tienen un solo punto de entrada desde un escritorio o un selector de programas y, luego, se ejecutan como un solo proceso monolítico. Por otro lado, las apps para Android tienen una estructura mucho más compleja. 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 cómo integrar tu app a la experiencia del usuario general del dispositivo. Debido a que una app de Android escrita correctamente incluye varios componentes y dado que los usuarios suelen interactuar con varias apps en un período breve, las aplicaciones deben adaptarse a distintos tipos de tareas y flujos de trabajo controlados por los usuarios.

Por ejemplo, piensa en lo que sucede cuando compartes una foto en la app de tu red social favorita:

  1. La app activa un intent de cámara. Luego, el SO Android inicia una app de cámara para responder a la solicitud. En este momento, el usuario deja la app de la red social, pero su experiencia está perfectamente integrada.
  2. La app de cámara, a su vez, puede activar otros intents, como iniciar el selector de archivos, que puede iniciar otra app.
  3. Eventualmente, el usuario regresa a la app de la red social y comparte la foto.

En cualquier momento del proceso, el usuario podría recibir una llamada o una notificación. Después de realizar la acción que corresponda, el usuario espera poder volver al proceso de compartir fotos y reanudarlo. Este comportamiento de pasar de una app a la otra es muy común en los dispositivos móviles, de manera que tu app debe poder manejar estos flujos correctamente.

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 app a fin de hacer lugar para otros.

Dadas 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 finalizarlos en cualquier momento. Debido a que no puedes controlar estos eventos, no deberías almacenar datos ni estados 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 app para almacenar datos y estados, ¿cómo deberías diseñarla?

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.

Ten en cuenta que no eres el propietario de las implementaciones de Activity y Fragment, sino que estas solo son clases que representan el contrato entre el SO Android y tu app. El SO puede finalizarlas en cualquier momento en función de las interacciones de usuarios y otras condiciones del sistema, como memoria insuficiente. Te recomendamos reducir la dependencia de estas clases para que puedas brindar una experiencia del usuario satisfactoria y una experiencia de mantenimiento más fácil de administrar.

Controlar la IU a partir de un modelo

Otro principio importante es que debes controlar la IU a partir de un modelo, preferentemente uno de persistencia. Los modelos son componentes responsables de administrar los datos de una app. Son independientes de los componentes de la app y los objetos View, de modo que no se ven afectados por el ciclo de vida de la app y los problemas asociados.

El modelo de persistencia es ideal debido a los siguientes motivos:

  • Tus usuarios no perderán datos si el SO Android cierra tu app para liberar recursos.
  • Tu app continuará funcionando cuando la conexión de red sea inestable o no esté disponible.

Si tu app está basada en clases de modelos con una responsabilidad bien definida para la administración de datos, tu app será más consistente y será más fácil realizar pruebas en ella.

En esta sección, analizamos un caso práctico para demostrar cómo estructurar una app con los componentes de la arquitectura.

Imagina que estamos creando una IU que muestra el perfil de un usuario. Usamos un backend privado y una API de REST para obtener los datos de un perfil determinado.

Descripción general

Para comenzar, observa el siguiente diagrama, que muestra cómo todos los módulos deberían interactuar entre sí una vez diseñada la app:

Observa que cada componente solo depende del componente que está un nivel más abajo. Por ejemplo, las actividades y los fragmentos solo dependen de un modelo de vista. El repositorio es la única clase que depende de otras clases. En este ejemplo, el repositorio depende de un modelo de datos persistente y de una fuente de datos de backend remota.

Este diseño crea una experiencia del usuario consistente y agradable. Independientemente de que el usuario vuelva a la app varios minutos después de cerrarla por última vez o varios días más tarde, verá al instante la información del usuario de que la app persiste a nivel local. Si estos datos están inactivos, el módulo de repositorio comienza a actualizar los datos en segundo plano.

Cómo crear la interfaz de usuario

La IU consta de un fragmento, UserProfileFragment y su archivo de diseño correspondiente, user_profile_layout.xml.

Para controlar la IU, nuestro modelo de datos necesita retener los siguientes elementos de datos:

  • ID de usuario: El identificador del usuario. Conviene trasladar esta información al fragmento mediante los argumentos correspondientes. Si el SO Android finaliza nuestros procesos, se conservará esta información para que el ID esté disponible la próxima vez que se reinicie nuestra app.
  • Objeto del usuario: Una clase de datos que retiene información sobre el usuario.

Usamos un UserProfileViewModel, basado en el componente de arquitectura de ViewModel para conservar esta información.

Un objeto ViewModel proporciona los datos para un componente de IU específico, como un fragmento o una actividad, y también incluye lógica empresarial de manejo de datos para comunicarse con el modelo. Por ejemplo, ViewModel puede llamar a otros componentes para cargar los datos y puede desviar solicitudes de usuarios para modificar la información. ViewModel no conoce los componentes de IU, de manera que no se ve afectado por los cambios de configuración, como la recreación de una actividad debido a la rotación del dispositivo.

Ya definimos los siguientes archivos:

  • user_profile.xml: Es la definición de diseño de IU para la pantalla.
  • UserProfileFragment: Es el controlador de IU que controla los datos.
  • UserProfileViewModel: Es la clase que prepara los datos para su visualización en UserProfileFragment y reacciona a las interacciones del usuario.

Los siguientes fragmentos de código muestran el contenido de inicio de estos archivos. (El archivo de diseño se omite para mayor simplicidad).

UserProfileViewModel

class UserProfileViewModel : ViewModel() {
   val userId : String = TODO()
   val user : User = TODO()
}

UserProfileFragment

class UserProfileFragment : Fragment() {
   // To use the viewModels() extension function, include
   // "androidx.fragment:fragment-ktx:latest-version" in your app
   // module's build.gradle file.
   private val viewModel: UserProfileViewModel by viewModels()

   override fun onCreateView(
       inflater: LayoutInflater, container: ViewGroup?,
       savedInstanceState: Bundle?
   ): View {
       return inflater.inflate(R.layout.main_fragment, container, false)
   }
}

Ahora que tenemos estos módulos de código, ¿cómo los conectamos? Después de todo, cuando el campo user está configurado en la clase UserProfileViewModel, necesitamos un método para informar la IU.

Para obtener el user, nuestro ViewModel necesita acceder a los argumentos del fragmento. Podemos enviarlos desde el fragmento o, mejor aún, usar el módulo SavedState para que nuestro ViewModel lea el argumento directamente:

// UserProfileViewModel
class UserProfileViewModel(
   savedStateHandle: SavedStateHandle
) : ViewModel() {
   val userId : String = savedStateHandle["uid"] ?:
          throw IllegalArgumentException("missing user id")
   val user : User = TODO()
}

// UserProfileFragment
private val viewModel: UserProfileViewModel by viewModels(
   factoryProducer = { SavedStateVMFactory(this) }
   ...
)

Ahora debemos informarle a nuestro fragmento cuándo se obtiene el objeto del usuario. En ese momento, entra en juego el componente LiveData.

LiveData es una clase que retiene datos observables. Otros componentes de tu app pueden supervisar cambios en objetos que usan este titular sin crear rutas de dependencia explícitas y rígidas entre ellos. El componente LiveData también respeta el estado del ciclo de vida de los componentes de tu app, como las actividades, los fragmentos y los servicios, e incluye lógica de limpieza para evitar las fugas de objetos y el consumo de memoria excesivo.

Para incorporar el componente de LiveData a nuestra app, cambiamos el tipo de campo en UserProfileViewModel a LiveData<User>. Ahora, UserProfileFragment recibe una notificación cuando se actualizan los datos. Además, debido a que este campo de LiveData está optimizado para los ciclos de vida, las referencias se borran automáticamente cuando ya no son necesarias.

UserProfileViewModel

class UserProfileViewModel(
   savedStateHandle: SavedStateHandle
) : ViewModel() {
   val userId : String = savedStateHandle["uid"] ?:
          throw IllegalArgumentException("missing user id")
   val user : LiveData<User> = TODO()
}

Ahora modificamos UserProfileFragment para observar los datos y actualizar la IU:

UserProfileFragment

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
   super.onViewCreated(view, savedInstanceState)
   viewModel.user.observe(viewLifecycleOwner) {
       // update UI
   }
}

Cada vez que se actualicen los datos de perfil del usuario, se invocará la devolución de llamada onChanged() y se actualizará la IU.

Si estás familiarizado con otras bibliotecas en las que se usan devoluciones de llamadas observables, quizás hayas notado que no necesitamos anular el método onStop() del fragmento para dejar de observar los datos. Este paso no es necesario con LiveData porque reconoce el ciclo de vida, lo que significa que no invoca la devolución de llamada onChanged(), a menos que el fragmento esté activo. Es decir, que haya recibido onStart(), pero aún no onStop(). LiveData también quita automáticamente el observador cuando se llama al método onDestroy() de un fragmento.

Tampoco agregamos lógica para controlar los cambios de configuración, como cuando un usuario rota la pantalla del dispositivo. UserProfileViewModel se restaura de manera automática cuando cambia la configuración. Por lo tanto, el nuevo fragmento recibirá la misma instancia de ViewModel apenas esté creado y se invocará al instante la devolución de llamada con los datos de ese momento. Dado que se espera que los objetos ViewModel duren más que los objetos View correspondientes que se actualizan, no debes incluir referencias directas a objetos View dentro de tu implementación de ViewModel. Si deseas obtener más información sobre cómo el ciclo de vida de un ViewModel se relaciona con el ciclo de vida de los componentes de IU, consulta El ciclo de vida de un ViewModel.

Cómo obtener datos

Ahora que usamos LiveData para conectar UserProfileViewModel a UserProfileFragment, ¿cómo podemos recuperar los datos de perfil del usuario?

En este ejemplo, suponemos que nuestro backend proporciona una API de REST. Usaremos la biblioteca de Retrofit para acceder a nuestro backend, aunque puedes usar una biblioteca diferente que sirva para el mismo objetivo.

Esta es nuestra definición de Webservice que se comunica con nuestro backend:

Webservice

interface Webservice {
   /**
    * @GET declares an HTTP GET request
    * @Path("user") annotation on the userId parameter marks it as a
    * replacement for the {user} placeholder in the @GET path
    */
   @GET("/users/{user}")
   suspend fun getUser(@Path("user") userId: String): User
}

Una idea inicial para implementar ViewModel podría consistir en llamar directamente a Webservice a fin de recuperar los datos y asignarlos a nuestro objeto LiveData. Aunque este método funciona, el mantenimiento de nuestra app se complica a medida que crece. Asigna demasiada responsabilidad a la clase UserProfileViewModel, lo que infringe el principio de separación de problemas. Además, el alcance de un ViewModel está vinculado a un ciclo de vida de Activity o Fragment, lo que significa que los datos del Webservice se pierden cuando finaliza el ciclo de vida del objeto de IU asociado. Este comportamiento crea una experiencia del usuario no deseable.

En cambio, nuestro ViewModel delega el proceso de obtención de datos a un nuevo módulo, un repositorio.

Los módulos de repositorio manejan las operaciones de datos. Proporcionan una API limpia para que el resto de la app pueda recuperar estos datos fácilmente. Saben de dónde obtener los datos y qué llamadas de API deben hacer cuando se actualizan los datos. Puedes considerar a los repositorios como mediadores entre diferentes fuentes de datos, como modelos persistentes, servicios web y memorias caché.

Nuestra clase UserRepository, que se muestra en el siguiente fragmento de código, usa una instancia de WebService para obtener los datos de un usuario.

UserRepository

class UserRepository {
   private val webservice: Webservice = TODO()
   // ...
   suspend fun getUser(userId: String) =
       // This isn't an optimal implementation because it doesn't take into
       // account caching. We'll look at how to improve upon this in the next
       // sections.
       webservice.getUser(userId)
}

Aunque el módulo de repositorio parece innecesario, tiene un objetivo fundamental: abstraer las fuentes de datos del resto de la app. Ahora bien, nuestro UserProfileViewModel no sabe cómo se adquieren los datos, de manera que podemos proporcionarle la información de varias implementaciones de obtención de datos diferentes.

Cómo administrar dependencias entre componentes

La clase UserRepository anterior necesita una instancia de Webservice para obtener los datos del usuario. Podría simplemente crearla, pero para hacerlo, también necesitaría conocer las dependencias de la clase Webservice. Además, es probable que UserRepository no sea la única clase que necesite un Webservice. Esta situación nos obliga a duplicar el código, ya que cada clase que necesita una referencia a Webservice debe saber cómo construirla con sus dependencias. Si cada clase crea un WebService nuevo, nuestra app tendría un alto consumo de recursos.

Puedes usar los siguientes patrones de diseño para solucionar este problema:

  • Inyecció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: Proporciona 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, estos patrones te permiten cambiar rápidamente entre las implementaciones de obtención de datos de producción y de prueba.

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 mediante un recorrido del árbol de dependencias, proporciona garantías de tiempo de compilación sobre dependencias y crea contenedores de dependencias para clases de marco de trabajo de Android.

Nuestra app de ejemplo usa Hilt para administrar las dependencias del objeto Webservice.

Cómo conectar ViewModel y el repositorio

Ahora, modificamos nuestro UserProfileViewModel para usar el objeto UserRepository:

UserProfileViewModel

@HiltViewModel
class UserProfileViewModel @Inject constructor(
   savedStateHandle: SavedStateHandle,
   userRepository: UserRepository
) : ViewModel() {
   val userId: String = savedStateHandle["uid"] ?:
          throw IllegalArgumentException("missing user id")

   private val _user = MutableLiveData<User>()
   val user: LiveData<User> = _user

   init {
       viewModelScope.launch {
           _user.value = userRepository.getUser(userId)
       }
   }
}

Datos de caché

La implementación de UserRepository abstrae la llamada al objeto Webservice, pero no es muy flexible, ya que depende de una sola fuente de datos.

El problema clave con la implementación de UserRepository es que después de obtener los datos de nuestro backend, no los almacena en ningún lugar. Por lo tanto, si el usuario abandona el objeto UserProfileFragment y más tarde regresa a él, nuestra app debe volver a obtener los datos, incluso si no cambiaron.

Este diseño es subóptimo debido a los siguientes motivos:

  • Desperdicia ancho de banda de la red.
  • Obliga al usuario a esperar que se complete la búsqueda nueva.

Para solucionar este problema, agregamos una nueva fuente de datos a nuestro UserRepository, que almacena los objetos User en la memoria caché.

UserRepository

// @Inject tells Hilt how to create instances of this type
// and the dependencies it has.
class UserRepository @Inject constructor(
   private val webservice: Webservice,
   // Simple in-memory cache. Details omitted for brevity.
   private val userCache: UserCache
) {
   suspend fun getUser(userId: String): User {
       val cached: User = userCache.get(userId)
       if (cached != null) {
           return cached
       }
       // This implementation is still suboptimal but better than before.
       // A complete implementation also handles error cases.
       val freshUser = webservice.getUser(userId)
       userCache.put(userId, freshUser)
       return freshUser
   }
}

Cómo conservar los datos

Con nuestra implementación actual, si el usuario rota el dispositivo o abandona la app, y vuelve a ella inmediatamente, la IU existente estará visible al instante porque el repositorio recupera los datos de la memoria caché integrada.

Sin embargo, ¿qué sucede si el usuario abandona la app y vuelve a ella horas más tarde, cuando el SO Android ya finalizó el proceso? Con la implementación tal cual como está, necesitaremos volver a obtener los datos de la red. Esto no solo representa una mala experiencia del usuario, sino que también es un desperdicio, ya que consume valiosos datos móviles.

Para solucionar este problema puedes almacenar las solicitudes web en la memoria caché, pero esto crea un nuevo problema: ¿qué sucede si a partir de otro tipo de solicitud, como obtener una lista de amigos, se muestran los mismos datos del usuario? La app mostrará datos inconsistentes, lo que es confuso en el mejor de los casos. Por ejemplo, nuestra app podría mostrar dos versiones diferentes de los mismos datos del usuario si el usuario creó la solicitud de lista de amigos y la solicitud de un solo usuario en momentos diferentes. Nuestra app tendría que averiguar cómo combinar estos datos inconsistentes.

La manera correcta de manejar esta situación es usar un modelo persistente. En estos casos, interviene la biblioteca de persistencias de Room.

Room es una biblioteca de mapeo de objetos que ofrece persistencia de datos locales con la cantidad mínima de código estándar. En el momento de la compilación, valida cada búsqueda con la información en el esquema de datos, de manera que las búsquedas de SQL rotas ocasionan errores de tiempo de compilación en lugar de fallas de tiempo de ejecución. Room abstrae algunos de los detalles de implementación subyacentes por el trabajo con tablas y búsquedas SQL sin procesar. Además, permite observar cambios en la base de datos (incluidas colecciones y solicitudes para unirse), lo que expone estos cambios mediante objetos LiveData. Además, define de manera explícita restricciones de ejecución que abordan problemas comunes, como acceder al almacenamiento en el subproceso principal.

Para usar Room, necesitamos definir nuestro esquema local. Primero, agregamos la anotación @Entity a nuestra clase de modelo de datos de User y una anotación @PrimaryKey al campo id de la clase. Estas anotaciones marcan User como una tabla en nuestra base de datos y id como la clave principal de la tabla:

User

@Entity
data class User(
   @PrimaryKey private val id: String,
   private val name: String,
   private val lastName: String
)

Luego, creamos una clase de base de datos implementando RoomDatabase para nuestra app:

UserDatabase

@Database(entities = [User::class], version = 1)
abstract class UserDatabase : RoomDatabase()

Ten en cuenta que UserDatabase es abstracto. Room proporciona una implementación de la base de datos automáticamente. Consulta la documentación de Room para obtener más detalles.

Ahora necesitamos una manera de insertar los datos del usuario en la base de datos. Para esto, necesitaremos crear un objeto de acceso de datos (DAO).

UserDao

@Dao
interface UserDao {
   @Insert(onConflict = REPLACE)
   fun save(user: User)

   @Query("SELECT * FROM user WHERE id = :userId")
   fun load(userId: String): Flow<User>
}

Ten en cuenta que el método load muestra un objeto de tipo Flow<User>. Si usas Flow with Room, puedes obtener actualizaciones en vivo, por lo que cada vez que haya un cambio en la tabla user, se emitirá un nuevo User.

Una vez definida la clase UserDao, hacemos una referencia al DAO a partir de nuestra clase de base de datos:

UserDatabase

@Database(entities = [User::class], version = 1)
abstract class UserDatabase : RoomDatabase() {
   abstract fun userDao(): UserDao
}

Ahora podemos modificar nuestro UserRepository para incorporar la fuente de datos de Room:

class UserRepository @Inject constructor(
   private val webservice: Webservice,
   // Simple in-memory cache. Details omitted for brevity.
   private val executor: Executor,
   private val userDao: UserDao
) {
   fun getUser(userId: String): Flow<User> {
       refreshUser(userId)
       // Returns a Flow object directly from the database.
       return userDao.load(userId)
   }

   private suspend fun refreshUser(userId: String) {
       // Check if user data was fetched recently.
       val userExists = userDao.hasUser(FRESH_TIMEOUT)
       if (!userExists) {
           // Refreshes the data.
           val response = webservice.getUser(userId)

           // Check for errors here.

           // Updates the database. Since `userDao.load()` returns an object of
           // `Flow<User>`, a new `User` object is emitted every time there's a
           // change in the `User`  table.
           userDao.save(response.body()!!)
       }
   }

   companion object {
       val FRESH_TIMEOUT = TimeUnit.DAYS.toMillis(1)
   }
}

Una vez que getUser muestra un objeto de Flow<User>, debes actualizar UserProfileViewModel para controlar el nuevo tipo de datos que se muestra de Flow<User>:

@HiltViewModel
class UserProfileViewModel @Inject constructor(
   savedStateHandle: SavedStateHandle,
   userRepository: UserRepository
) : ViewModel() {
   val userId : String = savedStateHandle["uid"] ?:
          throw IllegalArgumentException("missing user id")

   // asLiveData() is part of lifecycle-livedata-ktx
   // https://developer.android.com/kotlin/ktx#livedata
   val user = userRepository.getUser(userId).asLiveData()
}

Ten en cuenta que, aunque cambiaste el origen de los datos en UserRepository, no tuviste que cambiar UserProfileFragment. Esta actualización de alcance limitado demuestra la flexibilidad que ofrece esta arquitectura de apps. También es excelente para las pruebas, ya que puedes proporcionar una instancia falsa de UserRepository y probar tu UserProfileViewModel de producción al mismo tiempo.

Si pasan algunos días hasta que un usuario vuelve a una app que utiliza esta arquitectura, es probable que vea información desactualizada hasta que el repositorio pueda actualizarla. Según tu caso práctico, quizás prefieras no mostrar esta información desactualizada. En su lugar, puedes mostrar datos de un marcador de posición, que proporciona valores de ejemplo y señala cuando tu app está recuperando y cargando información actualizada.

Fuente de confianza única

Es común que varios extremos de la API de REST muestren los mismos datos. Por ejemplo, si nuestro backend tiene otro extremo que muestra una lista de amigos, el mismo objeto de usuario podría provenir de dos extremos de la API diferentes, quizás con distintos niveles de detalle. Si el UserRepository muestra la respuesta de la solicitud de Webservice sin modificaciones y sin verificar la coherencia, nuestras IU podrían mostrar información confusa, ya que la versión y el formato de los datos del repositorio dependerían del extremo al que se llamó por última vez.

Por este motivo, nuestra implementación de UserRepository guarda las respuestas del servicio web en la base de datos. Luego, los cambios en la base de datos activan llamadas de devolución en los objetos LiveData activos. Con este modelo, la base de datos funciona como la única fuente de confianza y otras partes de la app acceden a ella a través de nuestro UserRepository. Independientemente de que uses una caché de disco o no, recomendamos que tu repositorio designe una fuente de datos como fuente de confianza única para el resto de tu app.

Cómo mostrar las operaciones en curso

En algunos casos prácticos, como deslizar hacia abajo para actualizar, es importante que la IU le muestre al usuario que hay una operación de red en curso. Recomendamos separar la acción de IU de los datos reales, ya que podrían actualizarse por varios motivos. Por ejemplo, si obtenemos una lista de amigos, podría obtenerse el mismo usuario de forma programática, lo cual activaría una actualización de LiveData<User>. Desde el punto de vista de la IU, el hecho de que haya una solicitud en curso es solamente otro punto de datos, similar a cualquier otro dato del objeto User.

Podemos usar una de las siguientes estrategias para mostrar un estado de actualización de datos coherente en la IU, independientemente del lugar de donde provino la solicitud para actualizar los datos:

  • Cambia getUser() para que muestre un objeto de tipo LiveData. Este objeto incluirá el estado de la operación de red.
    Para ver un ejemplo, consulta la implementación de NetworkBoundResource en el proyecto de GitHub sobre componentes de la arquitectura de Android.
  • Proporcionar otra función pública en la clase de UserRepository que pueda mostrar el estado actualizado del User. Esta es la mejor opción si quieres mostrar el estado de red en tu IU solo cuando el proceso de obtención de datos se haya originado como respuesta a una acción de usuario explícita (como deslizar hacia abajo para actualizar).

Cómo probar cada componente

En la sección de separación de problemas, mencionamos que uno de los beneficios clave de usar este principio es la capacidad de prueba.

La siguiente lista muestra cómo probar cada módulo de código de nuestro ejemplo extendido:

  • Interfaz de usuario e interacciones: usa una prueba de instrumentación de IU de Android. La mejor manera de crear esta prueba es usar la biblioteca de Espresso. Puedes crear el fragmento y proporcionarle un UserProfileViewModel ficticio. Debido a que el fragmento solo se comunica con el UserProfileViewModel, crear una clase ficticia es suficiente para probar la IU de tu app por completo.
  • ViewModel: puedes probar la clase de UserProfileViewModel mediante una prueba JUnit. Solo necesitas crear una clase ficticia, UserRepository.
  • UserRepository: También puedes probar UserRepository con una prueba JUnit. Necesitas crear una versión ficticia de Webservice y UserDao. En estas pruebas, verifica que ocurra lo siguiente:
    • El repositorio hace las llamadas de servicio web correctas.
    • El repositorio guarda los resultados en la base de datos.
    • El repositorio no hace solicitudes innecesarias si los datos están en la memoria caché y actualizados.
  • Dado que tanto Webservice como UserDao son interfaces, puedes simularlas o crear implementaciones ficticias para casos de prueba más complejos.
  • UserDao: Prueba las clases DAO con pruebas de instrumentación. Dado que estas pruebas de implementación no requieren componentes de IU, se ejecutan rápidamente. Para cada prueba, crea una base de datos en la memoria a fin de garantizar que la prueba no tenga efectos secundarios (como cambiar los archivos de base de datos en el disco).

    Precaución: Room te permite especificar la implementación de la base de datos, de manera que puedes probar tu DAO si proporcionas la implementación de JUnit de SupportSQLiteOpenHelper. Sin embargo, este enfoque no se recomienda, ya que la versión de SQLite que se ejecuta en el dispositivo podría diferir de la versión de SQLite en tu máquina de desarrollo.

  • Webservice: En estas pruebas, evita hacer llamadas de red a tu backend. Es importante que tengas esto en cuenta para todas las pruebas, especialmente para las basadas en la Web, a fin de que sean independientes del mundo exterior. Varias bibliotecas, como MockWebServer, pueden ayudarte a crear un servidor local falso para estas pruebas.

  • Probar artefactos: Los componentes de la arquitectura proporcionan un artefacto Maven para controlar sus subprocesos en segundo plano. El artefacto androidx.arch.core:core-testing contiene las siguientes reglas JUnit:

    • InstantTaskExecutorRule: usa esta regla para ejecutar de manera instantánea cualquier operación en segundo plano en el subproceso de llamada.
    • CountingTaskExecutorRule: usa esta regla para esperar las operaciones en segundo plano de los componentes de la arquitectura. También puedes asociar esta regla con Espresso como un recurso inactivo.

Prácticas recomendadas

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

Aunque las siguientes recomendaciones no son obligatorias, nuestra experiencia nos demuestra que, 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.

Evita designar los puntos de entrada de tu app (receptores de transmisiones, 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.

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.

Expón tan poco como sea posible de cada módulo.

No caigas en la tentación de crear accesos directos que expongan detalles internos de la implementación de un módulo. Quizás ahorres algo de tiempo a corto plazo, pero tendrás muchos problemas técnicos a medida que tu código base evolucione.

Piensa en cómo lograr que cada módulo se pueda probar por separado.

Por ejemplo, una API bien definida para obtener 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 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.

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 los componentes de la arquitectura de Android y otras bibliotecas recomendadas se ocupen del código estándar repetitivo.

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.

Asigna una fuente de datos como la única fuente de confianza.

Cada vez que tu app necesite acceso a estos datos, debería originarse a partir de una fuente de confianza única.

Anexo: Cómo exponer el estado de la red

En la sección sobre arquitectura de app recomendada anterior, omitimos intencionalmente los errores de red y los estados de carga para mantener los fragmentos de código simples.

En esta sección, se muestra cómo exponer el estado de la red mediante una clase Resource que encapsula tanto los datos como su estado.

El siguiente fragmento de código proporciona una implementación de muestra de Resource:

Recurso

// A generic class that contains data and status about loading this data.
sealed class Resource<T>(
   val data: T? = null,
   val message: String? = null
) {
   class Success<T>(data: T) : Resource<T>(data)
   class Loading<T>(data: T? = null) : Resource<T>(data)
   class Error<T>(message: String, data: T? = null) : Resource<T>(data, message)
}

Debido a que es común cargar los datos desde la red al mismo tiempo que se muestra la copia en el disco de esos datos, recomendamos crear una clase de ayuda que puedas volver a usar en varios lugares. Para este ejemplo, creamos una clase llamada NetworkBoundResource.

En el siguiente diagrama, se muestra el árbol de decisión de NetworkBoundResource:

Para comenzar, observa la base de datos del recurso. Cuando la entrada se carga desde la base de datos por primera vez, NetworkBoundResource verifica si el resultado es suficientemente bueno para despacharlo o si debería volver a obtenerse de la red. Ten en cuenta que los dos casos pueden suceder al mismo tiempo, ya que es probable que quieras mostrar los datos en la memoria caché al mismo tiempo que los actualizas desde la red.

Si la llamada de red se completa de forma correcta, la respuesta se guarda en la base de datos y se vuelve a inicializar el flujo. Si la solicitud de red falla, NetworkBoundResource despacha una falla directamente.

Nota: Después de guardar datos nuevos en el disco, volvemos a inicializar el flujo desde la base de datos. Sin embargo, generalmente no es necesario hacerlo, ya que la base de datos despachará el cambio.

Ten en cuenta que confiar en la base de datos para que despache el cambio implica confiar en los efectos secundarios asociados, lo cual no es recomendable, ya que podría ocurrir un comportamiento indefinido si la base de datos termina sin despachar los cambios debido a que los datos no cambiaron.

Tampoco es recomendable despachar el resultado que se obtuvo de la red, ya que eso infringiría el principio de fuente de confianza única. Después de todo, quizás la base de datos incluye activadores que cambian los valores de los datos durante una operación de "guardar". Del mismo modo, no despaches "SUCCESS" sin los datos nuevos, ya que el cliente recibiría la versión incorrecta de los datos.

En el siguiente fragmento de código, se muestra la API pública que proporciona la clase NetworkBoundResource para sus subclases:

NetworkBoundResource.kt

// ResultType: Type for the Resource data.
// RequestType: Type for the API response.
abstract class NetworkBoundResource<ResultType, RequestType> {
   // Called to save the result of the API response into the database
   @WorkerThread
   protected abstract suspend fun saveCallResult(item: RequestType)

   // Called with the data in the database to decide whether to fetch
   // potentially updated data from the network.
   @MainThread
   protected abstract fun shouldFetch(data: ResultType?): Boolean

   // Called to get the cached data from the database.
   @MainThread
   protected abstract suspend fun loadFromDb(): Flow<ResultType>

   // Called to create the API call.
   @MainThread
   protected abstract fun createCall(): Flow<ApiResponse<RequestType>>

   // Called when the fetch fails. The child class may want to reset components
   // like rate limiter.
   protected open fun onFetchFailed() {}
}

Ten en cuenta esta información importante sobre la definición de la clase:

  • Define dos tipos de parámetros, ResultType y RequestType, ya que el tipo de datos que mostró la API podría no coincidir con el tipo de datos que se usa a nivel local.
  • Usa una clase llamada ApiResponse para las solicitudes de red. ApiResponse es un wrapper sencillo alrededor de la clase Retrofit2.Call que convierte las respuestas en instancias de LiveData.

La implementación completa de la clase NetworkBoundResource se muestra como parte del proyecto de GitHub sobre componentes de la arquitectura de Android.

Después de crear NetworkBoundResource, podemos usarlo para escribir el código de nuestras implementaciones de User vinculadas a la red y de disco en la clase UserRepository:

UserRepository

class UserRepository @Inject constructor(
   private val webservice: Webservice,
   private val userDao: UserDao
) {
   fun getUser(userId: String) =
       object : NetworkBoundResource<User, User>() {
           override suspend fun saveCallResult(item: User) {
               userDao.save(item)
           }

           override fun shouldFetch(data: User?): Boolean {
               return rateLimiter.canFetch(userId) && (data == null || !isFresh(data))
           }

           override suspend fun loadFromDb(): Flow<User> {
               return userDao.load(userId)
           }

           override fun createCall(): Flow<ApiResponse<User>> {
               return webservice.getUser(userId)
           }
       }
}