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

Última actualización: Agosto del 2021.

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

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

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

implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-moshi:2.9.0'
implementation "com.squareup.moshi:moshi-kotlin:1.12.0"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.1'

Comencemos.

Retrofit es un cliente HTTP que nos permite enviar solicitudes a APIs de forma muy sencilla. No es el único cliente HTTP para Android, existen muchos más como Volley, Fuel o Ktor. Nosotros usaremos Retrofit porque es muy práctico y sencillo, además de ser casi un estándar en la industria.

Retrofit nos sirve como cliente para conectarnos a un servidor. Ahora necesitamos "algo" que nos ayude a leer la respuesta que entrega un servidor, necesitamos un convertidor. Ahí es donde entra Moshi, una biblioteca que nos permite parsear respuestas en formato JSON y convertirlas en objetos de Kotlin. Yo elegí esta biblioteca por gusto y por una recomendación, pero existen otras como Jackson o incluso Gson que es muy usada.

Para obtener la información de los países, utilizaremos la API de REST Countries, una API que no se actualiza desde hace tiempo pero que es gratuita y sigue activa. Te invito a darle un vistazo rápido para que veas todos los endpoints que tiene.

Si ya agregaste las dependencias de Retrofit y Moshi e hiciste sync, entoonces ya podemos usar ambas bibliotecas.

Lo siguiente será añadir el permiso de internet a Android. Para esto, ve al archivo AndroidManifest.xml que se encuentra en la carpeta de manifests y antes de la etiqueta application, añade la siguiente línea:

<uses-permission android:name="android.permission.INTERNET" />

Con todo esto, ya tenemos todo listo para empezar.

Comencemos por crear las instancias de Retrofit y Moshi. Abre el árbol de archivos de Android Studio y haz click derecho sobre el paquete que contiene todas las clases que hemos hecho (en mi caso com.andreandyp.tallerdevcirclescdmx), luego selecciona New > Package. Crea un nuevo paquete llamado network.

Ya que lo hayas creado, selecciona el paquete y escoge New > Kotlin Class/File. El nombre del archivo será CountriesAPI. En vez de ser una clase, lo que haremos será crear un object, de la siguiente manera:

object CountriesAPI {
    
}

Aquí necesitamos 4 cosas:

  1. Una constante cuyo valor sea la URL base de la API.
  2. La instancia de Moshi.
  3. La instancia de Retrofit.
  4. El servicio para acceder a la API.

La constante se debe declarar así:

private const val BASE_URL = "https://restcountries.com/v2/"

La instancia de Moshi se crea así:

private val moshi = Moshi.Builder()
    .addLast(KotlinJsonAdapterFactory())
    .build()

Ese KotlinJsonAdapterFactory que añadimos es lo que nos provee compatibilidad con Kotlin.

La instancia de Retrofit se crea así:

private val retrofit = Retrofit.Builder()
    .baseUrl(BASE_URL)
    .addConverterFactory(MoshiConverterFactory.create(moshi))
    .build()

Aquí añadimos la URL base y añadimos el converter que nos ayudará a convertir el JSON que nos entregue la API en una clase de Kotlin gracias a Moshi.

Finalmente necesitamos añadir el servicio, pero antes necesitamos crearlo. Al igual que como creaste el archivo CountriesAPI, crea un nuevo archivo llamado CountriesService. Necesitamos que sea una interface, no una clase. El archivo debe tener lo siguiente:

interface CountriesService {
    @GET("all")
    suspend fun getAllCountries()

    @GET("alpha/{code}")
    suspend fun getCountryByCode(@Path("code") code: String)
}

Tanto las anotaciones @GET como @Path vienen de retrofit2.http. En este archivo creamos 2 funciones.

getAllCountries() es una función que llamará a https://restcountries.com/v2/all mediante el método GET de HTTP. Gracias a que especificamos la URL Base, solo es necesario escribir all como parámetro de la anotación.

Mientras que getCountryByCode() llamará a https://restcountries.com/v2/alpha/{code} igualmente mediante el método GET de HTTP. Con la anotación @Path le estamos indicado a retrofit que el parámetro code: String se deberá incrustar en la path de la URL (lo que va entre las diagonales de las URL). Con el parámeto code de la anotación, le estamos indicando que ese parámetro de la función debe reemplazar al texto {code} de la URL.

Por ejemplo, cuando llamemos a la función así: getCountryByCode("MX"), lo que hará retrofit es llamar a https://restcountries.com/v2/alpha/MX.

Finalmente, el modificador suspend nos ayudará a llamar esta función mediante corutinas. Este tema lo veremos más adelante.

Lo último que nos falta es instanciar ese servicio y hacerlo accesible. Esto lo hacemos en el archivo CountriesAPI de la siguiente manera:

val countriesService: CountriesService by lazy {
    retrofit.create(CountriesService::class.java)
}

Así, el archivo CountriesAPI debería quedarte así:

object CountriesAPI {
    private const val BASE_URL = "https://restcountries.com/v2/"

    private val moshi = Moshi.Builder()
        .addLast(KotlinJsonAdapterFactory())
        .build()

    private val retrofit = Retrofit.Builder()
        .baseUrl(BASE_URL)
        .addConverterFactory(MoshiConverterFactory.create(moshi))
        .build()

    val countriesService: CountriesService by lazy {
        retrofit.create(CountriesService::class.java)
    }
}

Creando el DTO

Nos falta una cosa: crear un Data Transfer Object, un objeto que nos ayude a transferir información. En este caso, será la clase que que se usará para acceder a los datos del JSON.

El DTO debe guardarse en un archivo llamado CountryNetwork.kt dentro del paquete network y debe ser así:

@JsonClass(generateAdapter = true)
data class CountryNetwork(
    @Json(name = "name")
    val name: String,
    @Json(name = "capital")
    val capital: String?,
    @Json(name = "population")
    val population: String,
    @Json(name = "area")
    val area: String?,
    @Json(name = "flag")
    val flag: String,
    @Json(name = "alpha2Code")
    val alpha2Code: String,
    @Json(name = "alpha3Code")
    val alpha3Code: String,
)

Veamos qué cualidades tiene esta clase:

  1. Tiene el sufijo -Network. Esto nos ayudará a distinguirlo de otros objetos especiales que crearemos.
  2. Es una data class de Kotlin, por lo que podemos aprovechar sus características al máximo.
  3. La clase es anotada con @JsonClass. Esto le ayuda a identificar a Moshi que esta clase será utilizada para guardar los datos.
  4. generateAdapter se añade por cuestiones de cómo Moshi crea las clases. Más información aquí.
  5. Cada atributo es anotado con @Json. Esto le indica a Moshi qué propiedad del JSON debe ir a qué propiedad. Útil cuando las propiedades del JSON tienen una notación diferente a la de Kotlin, por ejemplo, leer una propiedad llamada top_level_domain y almacenarla en una propiedad llamada topLevelDomain.

¿Cómo hacemos para indicarle a retrofit que al hacer la llamada a la API debe guardar los datos en esa clase. Muy fácil: indicándolo en el servicio:

interface CountriesService {
    @GET("all")
    suspend fun getAllCountries(): List<CountryNetwork>

    @GET("alpha/{code}")
    suspend fun getCountryByCode(@Path("code") code: String): CountryNetwork
}

Nota cómo ahora cada función tiene un tipo de regreso, en este caso, nuestra clase. Eso es todo, no hay que hacer más.

¿Listo para probar el servicio junto con los datos? ¡Vamos!

Ve al archivo CountryFragment.kt y en el método onViewCreated() añade lo siguiente después de llamar a la superclase:

runBlocking {
    val countries = CountriesAPI.countriesService.getAllCountries()
}

Luego, mueve la línea que llama al RecyclerView a adentro de las llaves del método launch y reemplaza PlaceholderContent.ITEMS por la variable countries, de forma que quede así:

runBlocking {
    val countries = CountriesAPI.countriesService.getAllCountries()
    binding.countriesRecyclerView.adapter = CountryAdapter(countries)
}

Posteriormente, ve al archivo CountryAdapter.kt y reemplaza el tipo values del constructor por List, de forma que quede así:

class CountryAdapter(
    private val values: List<CountryNetwork>
)

Ahora ve al método onBindViewHolder() y en vez de llamar a las propiedades id y content de item, llama a las propiedades name y capital, respectivamente. El método debe verse así:

override fun onBindViewHolder(holder: ViewHolder, position: Int) {
    val item = values[position]
    holder.countryFlag.setImageResource(R.mipmap.ic_launcher)
    holder.countryName.text = item.name
    holder.capitalName.text = item.capital
}

Ahora compila y ejecuta la app. Si todo salió bien, debería verse así:

Aplicación ejecutándose

¡FELICIDADES! 🎉 Pudiste conectar la app con un servicio web, descargar datos desde ahí y mostrárlos en la app.

Seguramente este último paso te llenó de algunas dudas como:

Resolvamos una por una:

Las corrutinas es un tema con cierta complejidad. La respuesta corta es que una forma de escribir código asíncrono que parezca síncrono. Veremos todo el tema de corrutinas más adelante, no te preocupes.

Por mientras, veamos el diagrama de arquitectura recomendada de nuevo:

Ejemplo de arquitectura

El elemento Activity/Fragment está completo. No hemos visto el elemento ViewModel ni el elemento Repository ni el lado izquierdo del diagrama. En cuanto al lado derecho, hemos hablado del elemento webservice y Retrofit.

¿Necesitamos obligatoriamente crear los elementos Remote Data Source, Repository y ViewModel para que nuestra app funcione? La respuesta corta es . La respuesta larga es no del todo.... La app muestra la lista de países como queremos, pero la app y cualquier software bueno requiere de una arquitectura para que la app sea fácil de mantener (y posteriormente, fácil de probar).

Prueba la app pero sin conexión a internet, verás que ésta se cierra al momento de lanzarse. Esto sucede porque no estamos haciendo un manejo adecuado de los errores. Podríamos manejarlos dentro del fragmento, pero si tuviéramos más fragmentos en los que se llamara al servicio (como CountryDetailsFragment), tendríamos que acordarnos de manejar ahí también los errores. Necesitamos manejarlos en algún lugar lejos de los fragmentos.

Hay muchos patrones de arquitectura en el software, Google recomienda MVVM pero no quiere decir que solo con ese patrón de arquitectura puede funcionar una app de Android. Nosotros seguiremos este patrón, pero si encuentras en el mercado alguna app con un patrón diferente, no te preocupes, es correcto siempre y cuando ese patrón esté bien implementado.

Sigamos creando los elementos faltantes de nuestra arquitectura y veamos cuáles son sus responsabilidades.

Ahora que ya tenemos forma de acceder a la API remota, requerimos crear "algo" que nos permita acceder a ella desde otros puntos de la app. Necesitamos una zona de comunicación, una interfaz para comunicarnos con la fuente de datos.

Dentro del paquete network, crea un nuevo llamado RetrofitCountriesDataSource.kt. Adentro de ese archivo crea la clase RetrofitCountriesDataSource. Ahora, añade al constructor de la clase un parámetro: el servicio CountriesService. Este parámetro deberá ser un atributo privado.

La clase debe verse así:

class RetrofitCountriesDataSource(
    private val countriesService: CountriesService
) {
}

Ahora añade los 2 métodos que llamarán a cada uno de los 2 métodos del servicio, es decir, añade los métodos getAllCountries() y getCountryByCode(code: String). Anótalos como suspend. Los métodos deben quedar así:

class RetrofitCountriesDataSource(
    private val countriesService: CountriesService
) {
    suspend fun getAllCountries(): List<CountryNetwork> {}
    suspend fun getCountryByCode(code: String): CountryNetwork {}
}

Cada método se encargará de llamar al respectivo método del servicio. Para getAllCountries(), el cuerpo debe quedar así:

suspend fun getAllCountries(): List<CountryNetwork> {
    return countriesService.getAllCountries()
}

Mientras que para getCountryByCode(code: String), debe quedar así:

suspend fun getCountryByCode(code: String): CountryNetwork {
    return countriesService.getCountryByCode(code)
}

Creando los DDO.

La última vez, creamos Data Transfer Objects para acceder a los datos de la API remota. Ahora, nos toca construir Data Domain Objects, objetos que nos permitirán acceder a la información de dominio de la aplicación, es decir, a la información que utilizamos en la app.

Afuera del paquete network, crea un paquete llamado domain. Adentro de este, crea un nuevo archivo simplemente llamado Country.kt. Añade una data class al archivo y añade los siguientes parámetros: name, capital, population y area. La clase debe quedar así:

data class Country(
    val name: String,
    val capitalName: String?,
    val population: String,
    val area: String?,
    val flag: String,
    val alpha2Code: String,
    val alpha3Code: String,
)

Cambia la firma de los métodos de la fuente de datos, para que devuelvan Country en vez de CountryNetwork, así:

suspend fun getAllCountries(): List<Country> {
    return countriesService.getAllCountries()
}

suspend fun getCountryByCode(code: String): Country {
    return countriesService.getCountryByCode(code)
}

Ahora necesitamos transformar los datos, pasar de CountryNetwork a Country. Para hacer esto, añade un nuevo archivo llamado NetworkMappers.kt al paquete network. No añadas nada al archivo, no necesitamos una clase ni un objeto ni una interface.

Vamos a añadir una función que convierta los datos aprovechando las ventajas de Kotlin. Crea una función de extensión sobre CountryNetwork llamada asDomain() que devuelva Country. Debe quedar de la siguiente manera:

fun CountryNetwork.asDomain(): Country {}

Dentro de esta función, devuelve un objeto Country pasándole como parámetro los valores de CountryNetwork, así:

fun CountryNetwork.asDomain(): Country {
    return Country(
        name, capital, population, area, flag, alpha2Code, alpha3Code
    )
}

Podemos hacer esto aún más expresivo y fácil de leer haciendo lo siguiente:

  1. Pon el mouse sobre return y presiona Alt (Options en Mac) + Enter y selecciona Convert to expression body. Se seleccionó el texto : Country. Bórralo.
  2. Pon el mouse sobre Country(, presiona Alt (Options en Mac) + Enter y selecciona Add names to call argument.
  3. Pon el mouse sobre name =, presiona Alt (Options en Mac) + Enter y selecciona Put arguments to separate lines.

La función debe verse así:

fun CountryNetwork.asDomain() = Country(
    name = name,
    capitalName = capital,
    population = population,
    area = area,
    flag = flag,
    alpha2Code = alpha2Code,
    alpha3Code = alpha3Code
)

¿Más sencillo de leer, no?

Finalmente ve a la fuente de datos y haz lo siguiente:

  1. En la función getAllCountries() al final del return, llama a la función map y llama a la función asDomain() del parámetro it.
  2. En la función getCountryByCode(code: String), al final del return llama a la función asDomain().

Las funciones deben quedar así:

suspend fun getAllCountries(): List<Country> {
    return countriesService.getAllCountries().map { it.asDomain() }
}

suspend fun getCountryByCode(code: String): Country {
    return countriesService.getCountryByCode(code).asDomain()
}

Para terminar con la capa de datos, necesitamos añadir una entidad más: el repositorio. Esta entidad se encargará de llamar a las fuentes de datos, de proveer el acceso a los datos y de atrapar los errores. Los repositorios dan acceso a un solo tipo de objetos y pueden llamar a múltiples fuentes de datos.

Crea un nuevo paquete llamado data y adentro, crea un nuevo archivo llamado CountriesRepository.kt, añade una clase llamada CountriesRepository y en el constructor de la clase añade un atributo privado, el cuál será la fuente de datos. La clase debe quedar así:

class CountriesRepository(
    private val retrofitCountriesDataSource: RetrofitCountriesDataSource,
) {
}

Crea 2 métodos iguales a los de la fuente de datos de la siguiente manera:

class CountriesRepository(
    private val retrofitCountriesDataSource: RetrofitCountriesDataSource,
) {
    suspend fun getAllCountries(forceUpdate: Boolean = false): List<Country> {
        
    }

    suspend fun getCountryByCode(code: String, forceUpdate: Boolean = false): Country {
        
    }
}

Ahora crea 2 métodos privados adicionales con el mismo nombre + el prefijo fromRemote, así:

private suspend fun getAllCountriesFromRemote() {}
private suspend fun getCountryByCodeFromRemote(code: String) {}

Dentro de estos métodos, llama a la fuente de datos:

private suspend fun getAllCountriesFromRemote() {
    return retrofitCountriesDataSource.getAllCountries()
}

private suspend fun getCountryByCodeFromRemote(code: String) {
    return retrofitCountriesDataSource.getCountryByCode(code)
}

Ahora debemos agregar el despachador que nos permitirá ejecutar este código en un "entorno" optimizado para operaciones de entrada/salida (I/O). Añade al constructor de la clase CountriesRepository el siguiente parámetro:

private val dispatcher: CoroutineDispatcher = Dispatchers.IO

De manera que el constructor quede así:

class CountriesRepository(
    private val retrofitCountriesDataSource: RetrofitCountriesDataSource,
    private val dispatcher: CoroutineDispatcher = Dispatchers.IO
)

Finalmente, añade la siguiente expresión a ambas funciones privadas, antes de la llave de apertura:

= withContext(dispatcher) 

Quita también el return. Las funciones deben verse así:

private suspend fun getAllCountriesFromRemote() = withContext(dispatcher) {
    retrofitCountriesDataSource.getAllCountries()
}

private suspend fun getCountryByCodeFromRemote(code: String) = withContext(dispatcher) {
    retrofitCountriesDataSource.getCountryByCode(code)
}

Ahora, sustituye el return por un bloque try/catch. La llamada a la fuente de datos debe quedar adentro del try y el catch debe atrapar una java.io.IOException, Hazlo con ambas funciones. Debe quedar así:

private suspend fun getAllCountriesFromRemote() = withContext(dispatcher) {
    try {
        retrofitCountriesDataSource.getAllCountries()
    } catch (e: IOException) {
        
    }
}
private suspend fun getCountryByCodeFromRemote(code: String) = withContext(dispatcher) {
    try {
        retrofitCountriesDataSource.getCountryByCode(code)
    } catch (e: IOException) {
        
    }
}

El estado de la aplicación a través de eventos

Necesitamos una clase que nos permita manejar el estado de la aplicación cuando suceden eventos por una petición asíncrona como la que acabamos de crear. Gracias a las sealed classes de Kotlin, podemos manejar este estado de forma fácil.

Crea un nuevo archivo llamado DataResult.kt adentro del paquete data y añade una sealed class. La clase debe quedar así:

sealed class DataResult {}

Adentro de esta clase vamos a tener 3 clases (o 3 eventos):

  1. Una clase que represente un estado exitoso: data class DataSuccess.
  2. Una clase que represente un estado de error: data class DataError.
  3. Un objeto que represente un estado de "cargando": object Loading.

Cada una de estas 3 clases deben heredar de DataResult, así:

sealed class DataResult {
    data class DataSuccess() : DataResult()
    data class DataError() : DataResult()
    object Loading : DataResult()
}

Necesitamos exponer tanto los datos como el error a través de estas clases. Para eso, añade el tipo de dato T como genérico de la clase DataResult. Haz lo mismo con la llamada a DataResult de DataSuccess y añade un parámetro al constructor de DataSuccess llamado data de tipo Country de la siguiente manera:

sealed class DataResult<out T> {
    data class DataSuccess<out T>(val data: T) : DataResult<T>()
    // resto del código
}

El parámetro data del constructor nos permitirá acceder a los datos. Para DataError, añade un parámetro llamado exception de tipo Exception. Y en el caso de la llamada a DataResult, añade Nothing, de la siguiente manera:

data class DataError(val exception: Exception) : DataResult<Nothing>()

Finalmente, añade Nothing a la llamada de object. La clase completa debe quedar así:

sealed class DataResult<out T> {
    data class DataSuccess<out T>(val data: T) : DataResult<T>()
    data class DataError(val exception: Exception) : DataResult<Nothing>()
    object Loading : DataResult<Nothing>()
}

Utilizando la clase.

Vamos a usar la clase de la siguiente manera:

  1. Las funciones públicas van a devolver DataResult, donde T será Country o List.
  2. Las funciones privadas van a devolver DataSuccess en el bloque try.
  3. En el bloque catch, devolverán DataError.

De tal manera que las funciones privadas se deben ver así:

private suspend fun getAllCountriesFromRemote() = withContext(dispatcher) {
    try {
        val result = retrofitCountriesDataSource.getAllCountries()
        DataResult.DataSuccess(result)
    } catch (e: IOException) {
        DataResult.DataError(e)
    }
}

private suspend fun getCountryByCodeFromRemote(code: String) = withContext(dispatcher) {
    try {
        val result = retrofitCountriesDataSource.getCountryByCode(code)
        DataResult.DataSuccess(result)
    } catch (e: IOException) {
        DataResult.DataError(e)
    }
}

Mientras que las funciones públicas se verán así:

suspend fun getAllCountries(forceUpdate: Boolean = false): DataResult<List<Country>> {
    return getAllCountriesFromRemote()
}

suspend fun getCountryByCode(code: String, forceUpdate: Boolean = false): DataResult<Country> {
    return getCountryByCodeFromRemote(code)
}

Vuelve al archivo CountryFragment.kt, y crea 3 propiedades privadas:

  1. Una llamada countriesService que llame al servicio countriesService
  2. Otra que haga una instancia de la fuente de datos.
  3. Y otra más para el repositorio.

Añade cada elemento al constructor de cada propiedad, debe quedar así:

private val countriesService = CountriesAPI.countriesService
private val retrofitCountriesDataSource = RetrofitCountriesDataSource(countriesService)
private val countriesRepository = CountriesRepository(retrofitCountriesDataSource)

Ahora ve a donde llamamos a runBlocking. En vez de llamar a getAllCountries() de CountriesAPI.countriesService, llama al método del repositorio y renombra la variable a result, así:

val result = countriesRepository.getAllCountries()

Vamos a hacer lo siguiente:

Así:

if (result is DataResult.DataSuccess) {
    binding.countriesRecyclerView.adapter = CountryAdapter(result.data)
} else {
    Toast.makeText(requireContext(), "Sucedió un error", Toast.LENGTH_LONG).show()
}

Finalmente, vamos a CountryAdapter.kt y cambiamos el tipo del constructor a List:

private val values: List<Country>

Ahora vamos al método onBindViewHolder y cambiamos capital por capitalName:

holder.countryName.text = item.name
holder.capitalName.text = item.capitalName

Si todo salió bien, deberías ver la app ejecutándose de nuevo normalmente.

Aplicación ejecutándose de nuevo

Y si pruebas a ejecutar la app sin internet, verás un pequeño Toast en pantalla:

Aplicación ejecutándose de nuevo

¡Felicidades! 🎉 la app se ejecuta exitosamente. Hemos llegado al fin de la segunda parte de este taller de Android. Nos falta menos por agregar a la app, ya casi podremos prbarla. Si quieres ver todo el código de este taller, ve a este repositorio de GitHub y navega hacia la rama modernas-2.

¿Recuerdas el diagrama de arquitectura que vimos al principio? ¡En esta segunda sesión hemos terminado el elemento Repository y la mitad derecha!

Ejemplo de arquitectura

¡Nos vemos en la siguiente parte!