¡Hola! Bienvenido a este taller de Android creado por @AndreAndyP para Facebook Developer Circles Ciudad de México..

Última actualización: Octubre del 2021.

Esta es la parte 3 del taller. Si quieres ver la parte 2, ve a este enlace, ya que necesitas primero completar la segunda parte antes de continuar con la tercera.

Si perdiste el código de la parte 2, no te preocupes. Accede a este repositorio para descargar el código. Este taller empieza a partir de la rama modernas-2.

Antes de comenzar, agrega al archivo app/build.gradle todas las dependencias que utilizaremos en esta parte del taller y haz sync:

implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1"
implementation "androidx.fragment:fragment-ktx:1.3.6"
implementation "io.coil-kt:coil:1.3.2"
implementation "io.coil-kt:coil-svg:1.3.2"

Comencemos.

En la última parte logramos conectar el repositorio de datos con la interfaz de usuario. Gracias a esta última capa que creamos, ahora tenemos un manejo de errores que evita que la app simplemente "explote" cuando suceda un error.

La app funciona, pero seguimos teniendo otro problema: la función runBlocking. No deberíamos usar esa función, puesto que ese no es su uso. Necesitamos un elemento adicional que comunique la interfaz de usuario con la capa de datos (repositorios y fuentes de datos). Ese elemento lo construiremos ahora y se llama ViewModel.

Hablemos más del ViewModel. Este elemento, cuyas funciones son tomadas por otro elemento llamado presenter en otras arquitecturas, será en el encargado de "hablar" con la capa de datos, de recibir eventos provenientes de la capa de UI (Activity/Fragmemts) y de proveer elementos que contengan los datos de la app.

Estos ViewModels que crearemos forman parte de Android Jetpack y heredarán de una clase llamada ViewModel. Un gran beneficio que nos otorga esta clase es que vive durante todo el ciclo de vida de las Activities y de los Fragments, por lo que nuestros datos no desaparecerán cuando exista un cambio de configuración (Cambiar el tema, girar la pantalla, recibir una llamada).

También utilizaremos una clase llamada LiveData. Esta clase nos permite proveer datos a la capa de UI y que ésta se suscriba a cualquier cambio que los datos tengan. De esta manera, los datos "manejan" la vista, es decir, la vista cambia cuando los datos cambien.

Para que un elemento se pueda suscribir a los cambios de LiveData, es necesario que dicho elemento tenga un ciclo de vida; en palabras técnicas, que implemente la clase LifecycleOwner. Así, un elemento LiveData sabrá cuando enviar y cuando no enviar actualizaciones al elemento suscrito.

Crea un nuevo paquete llamado viewmodels. Adentro de este paquete, crea un nuevo archivo llamado CountryViewModel.kt. Adentro, crea la clase CountryViewModel y haz que extienda de ViewModel (androidx.lifecycle). También añade CountriesRepository al constructor del ViewModel, así:

class CountryViewModel(
    private val countriesRepository: CountriesRepository
) : ViewModel() {
}

Ahora, crearemos los atributos LiveData. Como no queremos que sean cambiados por elementos fuera de la clase, necesitamos 2 propiedades: una que contenga los datos y otra que permita leerlos. La primera debe ser creada como propiedad privada de la clase, así:

private val _countries = MutableLiveData<List<Country>>(emptyList())

Aquí estamos instanciando la clase MutableLiveData, especificando que tendrá datos de tipo List e inicializando el valor como lista vacía. La segunda propiedad debe ser así:

val countries: LiveData<List<Country>> = _countries

Aquí creamos una propiedad que será de tipo LiveData con datos de tipo List y que cuyo valor será la propiedad _countries creada anteriormente.

También necesitamos crear las mismas 2 propiedades para el estado de la operación. Añade 2 propiedades más cuyo valor del LiveData sea DataResult, de la siguiente manera:

private val _status = MutableLiveData<DataResult<List<Country>>>()
val status: LiveData<DataResult<List<Country>>> = _status

La clase debe quedar así:

class CountryViewModel(
    private val countriesRepository: CountriesRepository
) : ViewModel() {
    private val _countries = MutableLiveData<List<Country>>()
    val countries: LiveData<List<Country>> = _countries

    private val _status = MutableLiveData<DataResult<List<Country>>>()
    val status: LiveData<DataResult<List<Country>>> = _status
}

Adentro de la clase, crea una función llamada fetchCountries(). En el cuerpo de la función, llama a viewModelScope.launch {}. Dentro de la lambda, llama a countriesRepository.getAllCountries(), así:

fun fetchCountries(forceUpdate: Boolean = false) {
    viewModelScope.launch {
        countriesRepository.getAllCountries(forceUpdate)
    }
}

Finalmente, debems debemos guardar los datos que regresa el repositorio en una variable y ésta asignarla a la propiedad value de _countries solamente en caso de que el resultado sea exitoso, así:

val result = countriesRepository.getAllCountries(forceUpdate)
if (result is DataResult.DataSuccess) {
    _countries.value = result.data!!
}  else {
    _countries.value = emptyList()
}

Para manejar el estado, antes de llamar a viewModelScope.launch, asigna al valor de status el dato DataResult.Loading, así:

_status.value = DataResult.Loading

Después de comprobar que el resultado fue exitoso, asigna la variable result al valor de status, así:

_status.value = result

La función debería quedar así:

fun fetchCountries(forceUpdate: Boolean = false) {
    _status.value = DataResult.Loading
    viewModelScope.launch {
        val result = countriesRepository.getAllCountries(forceUpdate)
        if (result is DataResult.DataSuccess) {
            _countries.value = result.data!!
        }  else {
            _countries.value = emptyList()
        }

        _status.value = result
    }
}

Añade un bloque init al ViewModel, después de las propiedades de LiveData en el que llames a la función fetchCountries():

init {
    fetchCountries()
}

Ahora ve al paquete viewmodels y crea un archivo llamado CountryViewModelFactory.kt. En este archivo, añade el siguiente código:

@Suppress("UNCHECKED_CAST")
class CountryViewModelFactory(
    private val countriesRepository: CountriesRepository
) : ViewModelProvider.Factory {
    override fun <T : ViewModel?> create(modelClass: Class<T>): T {
        return CountryViewModel(countriesRepository) as T
    }
}

Ahora ve al archivo CountryFragment y crea una función privada llamada createViewModelFactory() que devuelva CountryViewModelFactory. Dentro de este método, regresa una instancia de CountryViewModelFactory a la cuál le debes mandar la instancia de countriesRepository. El método debe quedar así:

private fun createViewModelFactory(): CountryViewModelFactory {
    return CountryViewModelFactory(countriesRepository)
}

Ahora, añade la siguiente línea después del repositorio:

private val countryViewModel by viewModels<CountryViewModel> { createViewModelFactory() }

Ve ahora al método onViewCreated y después de llamar al método de la superclase, llama a countryViewModel.countries y a countryViewModel.status, así:

countryViewModel.countries
countryViewModel.status

Tanto countries como status tienen un método llamado observe que recibe 2 parámetros: una clase que implemente LifecycleOwner y una lamba a partir de la cuál podemos acceder a los datos del LiveData. Llama al método observe en ambas clases enviándole viewLifecycleOwner (representa la vista del fragment) y una lambda vacía, así:

countryViewModel.countries.observe(viewLifecycleOwner) {}
countryViewModel.status.observe(viewLifecycleOwner) {}

Con esto, nuestro fragment ya podrá acceder a los datos desde el ViewModel.

Actualicemos la forma en la que la vista recibe los datos. Crea una propiedad lateinit para el adapter antes del servicio:

private lateinit var adapter: CountryAdapter

Luego crea un método privado llamado setupRecyclerViewen el que se inicializará la variableadapter` y se le asignará al RecyclerView, así:

private fun setupRecyclerView() {
    adapter = CountryAdapter(emptyList())
    binding.countriesRecyclerView.adapter = adapter
}

Llama a este método antes de llamar a las propiedades del ViewModel. Ahora ve a la clase CountryAdapter y elimina el modificador private del parámero values del constructor y conviértela a var. Ahora ve a la lambda de countryViewModel.countries y añade lo siguiente:

adapter.values = it!!
adapter.notifyDataSetChanged()

El código debe quedar así:

countryViewModel.countries.observe(viewLifecycleOwner) {
    adapter.values = it!!
    adapter.notifyDataSetChanged()
}

Dentro de la lambda de countryViewModel.status, evalúa el resultado (it) con un when de la siguiente manera:

  1. Si es Loading, asigna a la propiedad isRefreshing de countriesSwipeRefresh el valor true.
  2. Si es DataSuccess, asigna a la propiedad isVisible de noInternetTextView el valor false.
  3. Si es DataError, asigna a la propiedad isVisible de noInternetTextView el valor true.
  4. En los 2 últimos casos, también asigna a la propiedad isRefreshing de countriesSwipeRefresh el valor false.

De tal forma que el código quede así:

countryViewModel.status.observe(viewLifecycleOwner) {
    when (it) {
        is DataResult.Loading -> {
            binding.countriesSwipeRefresh.isRefreshing = true
        }
        is DataResult.DataSuccess -> {
            binding.countriesSwipeRefresh.isRefreshing = false
            binding.noInternetTextView.isVisible = false
        }
        is DataResult.DataError -> {
            binding.countriesSwipeRefresh.isRefreshing = false
            binding.noInternetTextView.isVisible = true
        }
    }
}

Al elemento countriesSwipeRefresh añádele un OnRefreshListener que llame a countryViewModel.fetchCountries(true), así:

binding.countriesSwipeRefresh.setOnRefreshListener { countryViewModel.fetchCountries(true) }

Para finalizar, borra todo el bloque runBlocking y la sentencia donde deshabilitamos el countriesSwipeRefresh.

Si todo salió bien, al ejecutar la app debería suceder lo siguiente:

  1. Se muestra el círculo girando del countriesSwipeRefresh.
  2. Al lanzarla sin internet, se debería mostrar el texto "No tienes conexión a internet".
  3. El swipe to refresh funciona adecuadamente.
  4. Al lanzarla con internet y luego recargar los datos sin internet, a lista debería desaparecer y mostrar el texto "No tienes conexión a internet".

Si es así, ¡felicidades! 🎉 Haz conectado el ViewModel a la UI exitosamente.

Agrega las siguientes líneas al TextView cuyo ID es no_internet_text_view para que el texto sea más fácil de ver:

android:textColor="@android:color/holo_red_light"
android:textSize="16sp"

Paremos un momento para al fin hablar de las famosas corrutinas. Las corrutinas de Kotlin nos permiten manejar operaciones asíncronas: lectura/escritura, manejo de archivos, operaciones de red, operaciones de CPU de uso intensivo, etc.

Estas corrutinas son fáciles de utilizar y son aún más ligeras que los hilos. Antes, en Android, se utilizaba la clase AsyncTask. Si bien, esta clase se puede utilizar, se marcó como obsoleta desde Android 11.

Para lanzar una corrutina, necesitamos lanzarla desde una función suspend o desde un contexto de corrutinas (CoroutineContext), este último ligado a un alcance de corrutina (CoroutineScope). Igualmente, podemos lanzar las corrutinas especificando algún despachador (CoroutineDispatcher) y haciendo un cambio (switch) entre ellos sin que sea computacionalmente costoso.

Otra ventaja de las corrutinas es que son cancelables y actualmente tienen un gran soporte entre varias de las bibliotecas de Android Jetpack. Analicemos el flujo de la app:

  1. Desde el ViewModel, utilizamos el método launch de viewModelScope para llamar a countriesRepository.getAllCountries(). viewModelScope es un contexto de corrutinas (CoroutineContext) que está ligado a un alcance de corrutinas (CoroutineScope), en este caso, el alcance del ViewModel. De esta manera, cuando el ViewModel sea eliminado, el alcance será el que cancele a todas las corrutinas que hayan sido lanzadas por viewModelScope.
  2. Dentro de la función del repositorio, llamamos a retrofitCountriesDataSource.getAllCountries() con ayuda de withContext(dispatcher). Aquí hacemos un cambio entre el contexto actual (el de viewModelScope) a un contexto que utilice un despachador (CoroutineDispatcher) llamado Dispatchers.IO, que está optimizado para operaciones de entrada/salida.
  3. Dentro de la fuente de datos, llamamos a countriesService.getAllCountries().
  4. El servicio llama a la API.
  5. La fuente de datos convierte los DTOs a DDOs.
  6. El repositorio guarda los datos en CountriesSuccess o en CountriesError si hubo algún error.
  7. El ViewModel, utilizando el contexto de viewModelScope, actualiza los datos de los LiveData. viewModelScope de forma predeterminada usa Dispatchers.Main, el despachador que equivale al hilo Main del sistema Android.
  8. LiveData notifica los cambios a la UI y ésta se actualiza.

Al principio se utilizaba la clase Job y derivadas para hacer el cancelamiento manualmente, sin embargo, gracias a viewModelScope, esto se hace automáticamente.

Finalmente, hablemos de runBlocking. Como su nombre lo dice, esta función llama a las corrutinas bloqueando el hilo principal y de forma secuencial, a pesar de que hagamos un cambio de contexto en el repositorio. Esta función es muy utilizada en pruebas unitarias, porque buscamos que las llamadas asíncronas se vuelvan síncronas; sin embargo, ese es otro tema.

Como ves, las corrutinas son fáciles de usar y quizá un poco difíciles de entender, pero te aseguro que te ayudarán a manejar adecuadamente las operaciones asíncronas.

Nos falta un detalle: mostrar las imágenes de los países. Para esto, vamos a utilizar una biblioteca llamada Coil, que nos permitirá descargar y mostrar las imágenes del servidor.

Ve al archivo CountryFragment.kt y crea una nueva propiedad privada lateinit después de la declaración del adapter:

private lateinit var imageLoader: ImageLoader

Inicializa la propiedad en el método onViewCreated, antes de setupRecyclerView(), de la siguiente manera:

imageLoader = ImageLoader.Builder(requireContext())
    .componentRegistry {
        add(SvgDecoder(requireContext()))
    }
    .build()

Envía dicha propiedad al adapter, dentro de la función setupRecyclerView:

adapter = CountryAdapter(imageLoader, emptyList())

Ahora ve al constructor de CountryAdapter y añade la propiedad al constructor:

class CountryAdapter(
    private val imageLoader: ImageLoader,
    var values: List<Country>,
) : RecyclerView.Adapter<CountryAdapter.ViewHolder>() {
    // Resto del código...
}

Reemplaza la línea en la que asignamos el ícono del launcher por el siguiente código:

holder.countryFlag.load(item.flag, imageLoader)

Finalmente, ve al directorio drawable dentro de la carpeta res y haz click derecho. Selecciona New > Vector asset.

  1. En Asset type selecciona Clip art.
  2. Da click en Clip Art
  3. Busca el ícono cloud download y selecciónalo.
  4. Da click en Next y luego en Finish.

Repite los mismos pasos pero ahora seleccionando el ícono cloud off.

Ahora vuelve a la línea donde llamamos al método load y abre una lambda. Dentro de esa lambda añade los íconos que creamos como valores de las funciones placeholder y error, así:

holder.countryFlag.load(item.flag, imageLoader) {
    placeholder(R.drawable.ic_baseline_cloud_download_24)
    error(R.drawable.ic_baseline_cloud_off_24)
}

Si todo salió bien, ejecuta la app. ¡Ahora se ven las banderas!

Lista de países con banderas

Y si pruebas a reducir la velocidad de internet, verás al ícono cloud download antes de mostrar la imagen.

Lista de países con banderas

Si apagas el internet después de haber descargado la lista de países, podrás ver el ícono cloud off.

Lista de países con banderas

¡Felicidades! 🎉 ahora ya tenemos la lista de países con imágenes y estados. Hemos llegado al fin de la tercera parte de este taller de Android. Nos falta mucho menos por agregar a la app, estamos cerca de finalizarla. Un detalle adicional: ya puedes borrar el paquete placeholder y todo su contenido. También borra (o readapta) el JavaDoc de la clase CountryAdapter.

Si quieres ver todo el código de este taller, ve a este repositorio de GitHub y navega hacia la rama modernas-3.

¿Recuerdas el diagrama de arquitectura que vimos al principio? ¡En esta tercera sesión hemos añadido el elemento ViewModel y readaptado el elemento Activity/Fragment!

Ejemplo de arquitectura

¡Nos vemos en la siguiente parte!