En esta segunda parte de MVVM en Android vamos a implementar Retrofit, corrutinas y aplicaremos clean architecture.

https://youtu.be/7FptmAjBdsA

Clean Arquitecture en Android

A diferencia de MVP, MVVM o cualquiera de los patrones de arquitectura que habrás oído hablar, clean architecture es una meta arquitectura que podremos integrar en cualquiera de nuestras aplicaciones conjuntamente a lo anteriormente nombrado.

v
Esquema de Clean Architecture para Android.

No hay una forma correcta de aplicar esta información ya que cada uno tiene que entenderlo y aplicarlo como mejor le venga. La gracia de esto es definir el proyecto en varias capas, es decir, lo de fuera sabe lo que hay dentro pero lo de dentro no sabe lo que tiene por fuera.

Así podemos abstraer el contenido al máximo y si por ejemplo luego quisiéramos hacer esta aplicación en iOS por ejemplo solo habría que rehacer la capa exterior (y obviamente la interior habría que pasarla a swift pero el funcionamiento y la lógica de negocio sería igual).

Let’s code!

En esta necesitarás haber visto el capítulo anterior, MVVM en Android con Kotlin. Y si no haz hecho el código de la primera parte, lo puedes descargar desde el repositorio y de paso darle un fav que me ayuda muchísimo.

En esta ocasión pasaremos del QuoteProvider, la clase que nos daba las citas y las recuperaremos de internet, ya que he implementado un API para que las puedas consumir de manera gratuita.

El endpoint será este y si accedes verás que se trata de un json muy sencillo que simplemente contiene un listado de modelos author y quote.

listado de citas json
Modelo de datos que nos devolverá el API.

Antes de continuar vamos a reorganizar nuestro proyecto ya que si vuelves a ver el esquema de Clean Architecture, básicamente la capa exterior es la UI y el framework (Android) así que vamos a crear un directorio nuevo llamado UI y seleccionamos el directorio de view y viewmodel y los arrastramos dentro del nuevo directorio. Nos saldrá un menú con dos opciones, seleccionaremos la primera y al hacer clic en OK de daremos a Refactor.

Pero esto no acaba aquí ya que ahora será el turno del dominio, que se encargará de la lógica de negocio, aunque todavía no haremos nada simplemente crearemos el directorio y lo llamaremos domain.

Y por último crearemos una carpeta llamada data donde añadiremos el directorio model.

directorio android con mvvm y clean architecture
Organización de los directorios de nuestro proyecto.

Implementando Retrofit 2 en Android con Clean Architecture

En este capítulo iré algo más deprisa ya que esta parte está explicada en el capítulo 20 – Retrofit en Android con Kotlin.

Lo primero que haremos antes de nada ya que se suele olvidar será ir al AndroidManifest.xml y añadiremos el permiso de internet.

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

Suele olvidarse bastante y por eso he optado a añadirlo al principio. También te recomiendo que si ya tenías la app instalada la desinstales ya que después de añadir este permiso suele fallar.

El siguiente paso será añadir las dependencias necesarias. Así que vamos al build.gradle del directorio app y debajo de las que añadimos en el capítulo anterior meteremos Retrofit, GsonConverter y Corrutinas.

    // Retrofit
    implementation "com.squareup.retrofit2:retrofit:2.9.0"
    implementation "com.squareup.retrofit2:converter-gson:2.9.0"
    //Corrutinas
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.6'

Y sincronizamos.

Ahora es el momento de ampliar el proyecto y añadir nuevos directorios, empezando por el core, que será el encargado de almacenar clases genéricas como por ejemplo las funciones de extensión. Y dentro de este directorio añadiremos un objeto llamado RetrofitHelper.

creando objeto retrofit helper
Creando objeto RetrofitHelper.

¿Por qué un objeto? Normalmente si queremos acceder al contenido de una clase o creamos un objeto de esa clase o añadimos un companion object así que creo que esta es la forma más limpia de hacerlo.

object RetrofitHelper {
    fun getRetrofit(): Retrofit {
        return Retrofit.Builder()
            .baseUrl("https://drawsomething-59328-default-rtdb.europe-west1.firebasedatabase.app/")
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    }
}

Básicamente creamos una función que al llamarla nos devuelve una instancia de Retrofit preparada para usar.

Para esta app lo que haremos será recuperar de backend el listado completo y lo guardaremos en memoria, al cual accederemos cada vez que el usuario pulse la pantalla para recuperar una cita aleatoria.

El modelo de datos será igual que el actual (QuoteModel), pero podemos ponerle el atributo @SerializedName si el nombre puede cambiar.

data class QuoteModel(
    @SerializedName("quote") val quote: String,
    @SerializedName("author") val author: String
)

A continuación trabajaremos con la capa de data ya que el consumo de APIs forman parte de esta. Así que dentro del directorio data crearemos otro llamado network. Y dentro crearemos la interfaz QuoteApiClient.

interface QuoteApiClient {
    @GET("/.json")
    suspend fun getAllQuotes(): Response<List<QuoteModel>>
}

Se trata de una petición get que nos devolverá un listado de QuoteModel. Lo único interesante es que se trata de una función suspend ya que será a través de una corrutina.

Dentro del mismo directorio crearemos una nueva clase llamada QuoteService, que será la clase a la que llame nuestro repositorio (cuando lo creemos) cuando queramos datos de internet y esta clase ya gestionaría la llamada a Retrofit o a Firebase por ejemplo.

class QuoteService {

    private val retrofit = RetrofitHelper.getRetrofit()

    suspend fun getQuotes(): List<QuoteModel> {
        return withContext(Dispatchers.IO) {
            val response = retrofit.create(QuoteApiClient::class.java).getAllQuotes()
            response.body() ?: emptyList()
        }
    }

}

Para empezar tenemos una instancia de nuestro RetrofitHelper y luego tenemos una función que llamará a nuestra interfaz. Dentro de la función getQuotes() estamos creando una corrutina de tipo IO que serán las óptimas para hacer llamadas de red o a bases de datos y esto retornará lo que se haga dentro.

Con esta clase conseguimos abstraer la parte de Retrofit al máximo, es decir, si un día queremos cambiar los endpoints solo deberemos tocar esta clase y el resto de nuestra app quedará intacta.

Por decirlo de algún modo, esta clase será la puerta de acceso a internet y dicha puerta será llamada por el repositorio, así que en la raíz del directorio data creamos otra clase llama QuoteRepository.

class QuoteRepository {

    private val api = QuoteService()

    suspend fun getAllQuotes():List<QuoteModel>{
        val response = api.getQuotes()
        QuoteProvider.quotes = response
        return response
    }
}

Fíjate que he añadido la instancia de nuestro service, si por ejemplo tuviéramos otro service para base de datos lo añadiríamos aquí y esta clase se encargaría de ir a base de datos o a internet.

También te dará error línea 7 ya que estamos intentando almacenar la respuesta en una variable que tenemos en nuestro QuoteProvider pero es donde almacenábamos las citas localmente así que iremos a ese fichero y lo reduciremos a escombros dejándolo como te muestro a continuación.

class QuoteProvider {
    companion object {
        var quotes:List<QuoteModel> = emptyList()
    }
}

Básicamente hemos limpiado la clase para usarlo a modo de «base de datos» de lo que recuperemos de internet, por eso la primera vez que entremos a la app, recuperaremos de internet el listado completo y nuestro repositorio almacenará la respuesta en la clase anterior para que cada vez que pulsemos en la pantalla para devolvernos una cita aleatoria no tenga que hacer una petición a internet.

Capa de dominio

Llegamos al dominio, donde se almacena la lógica de negocio accederemos a este repositorio gracias a los casos de uso o interactor. Se tratan de clases que gestionan una única acción por ejemplo un caso de uso sería recuperar todas las citas y otro caso de uso sería recuperar una única aleatoria.

Dentro del directorio domain crearemos una clase llamada GetQuotesUseCase().

class GetQuotesUseCase {

    private val repository = QuoteRepository()

    suspend operator fun invoke():List<QuoteModel>? = repository.getAllQuotes()
    
}

Este sería el caso de uso más básico, el cual solo llama al repositorio para decirle que recupere de internet todas las citas. Fíjate que la función es algo extraña ya que con el operator invoke podemos llamar a esa función sin tener que darle un nombre, es decir con hacer GetQuotesUseCase() ya se estaría llamando, similar a un constructor pero sin tener que pasarle los parámetros.

Flujo completo

Para el primero de los flujos completos que será el de consumir todas las citas de internet tendremos que ir a nuestro QuoteViewModel y veremos varias cosas, la primera que la función randomQuote() está fallando ya que modificamos nuestro provider, pero borraremos todo el contenido de esta.

El siguiente paso será crear una función llamada onCreate() en nuestro ViewModel que lo llamaremos al crear la activity.

Obviamente para poder llamar a nuestro caso de uso debemos añadir una instancia de este.

    var getQuotesUseCase = GetQuotesUseCase()

Ahora si podríamos llamarlo en nuestra función onCreate() pero si recuerdas, nuestro caso de uso es suspend ya que se trata de una corrutina así que para poder lanzarlo desde aquí tendremos que usar ViewModelScope, si quieres saber más a fondo sobre su uso te recomiendo que mires el vídeo de arriba.

    fun onCreate() {
        viewModelScope.launch {
            val result = getQuotesUseCase()

            if(!result.isNullOrEmpty()){
                quoteModel.postValue(result[0])
            }
        }
    }

Nuestra función onCreate() de nuestro ViewModel quedaría así. Para darte feedback en este ejemplo simplemente compruebo si el resultado no es vacío ni null y pinto en la pantalla la primera posición (ya que anteriormente no hacía nada hasta hacer el primer clic en la pantalla).

Ahora desde el MainActivity simplemente llamamos a esta función desde su onCreate().

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        quoteViewModel.onCreate()

        quoteViewModel.quoteModel.observe(this, Observer {
            binding.tvQuote.text = it.quote
            binding.tvAuthor.text = it.author
        })

        binding.viewContainer.setOnClickListener { quoteViewModel.randomQuote() }
    }

Si ejecutas verás que funciona pero que durante 1 o 2 segundos no se ve nada ya que está cargando la información. El problema es que no le estamos dando ningún feedback al usuario así que vamos a ir la activity_main.xml y meteremos un progress bar que no es más que un circulo con animación que hace que el usuario vea que está cargando la app.

    <ProgressBar
        android:id="@+id/loading"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"/>

Ahora tenemos que jugar con la visibilidad del componente y para ello lo haremos desde el ViewModel. Crearemos un LiveData de tipo Boolean y con él controlaremos el estado de nuestro loading.

    val isLoading = MutableLiveData<Boolean>()

Y controlaremos el estado para ponerlo visible antes de la llamada al servidor y para ocultarlo al terminar.

 fun onCreate() {
        viewModelScope.launch {
            isLoading.postValue(true)
            val result = getQuotesUseCase()

            if(!result.isNullOrEmpty()){
                quoteModel.postValue(result[0])
                isLoading.postValue(false)
            }
        }
    }

Pero quedaría que nuestra MainActivity se suscriba a dichos cambios para poder cambiar la visibilidad.

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        quoteViewModel.onCreate()

        quoteViewModel.quoteModel.observe(this, Observer {
            binding.tvQuote.text = it.quote
            binding.tvAuthor.text = it.author
        })
        quoteViewModel.isLoading.observe(this, Observer {
            binding.loading.isVisible = it
        })

        binding.viewContainer.setOnClickListener { quoteViewModel.randomQuote() }

    }

Entonces cada vez que el ViewModel haga un cambio en ese boolean, automáticamente aparecerá o desaparecerá el progress bar. Si ejecutas la app verás que ahora si va perfecto.

Ahora solo queda solucionar la parte de la cita aleatoria ya que si ahora mismo haces clic en la pantalla ya no se actualizan las citas como en la primera parte. Para ello tendremos que crear otro caso de uso que se encargue de devolvernos una cita aleatoria.

Dentro del directorio domain crearemos un nuevo caso de uso y lo llamaremos GetRandomQuoteUseCase.

class GetRandomQuoteUseCase {

    operator fun invoke():QuoteModel?{
        val quotes = QuoteProvider.quotes
        if(!quotes.isNullOrEmpty()){
            val randomNumber = (quotes.indices).random()
            return quotes[randomNumber]
        }
        return null
    }
}

El comportamiento es muy sencillo (y poco correcto) ya que lo primero que estamos haciendo es acceder directamente a la memoria en vez de pasar por nuestro repositorio, pero como es una corrutina no quiero complicar más esta segunda parte. Luego comprobamos si no es null ni vacío y entonces generamos un número random entre cero (la primera posición del listado) y la última posición del listado para que devuelva una cita aleatoria o sino hay devolverá un null.

Volvemos a nuestro QuoteViewModel para completar la función randomQuote().

      var getRandomQuoteUseCase = GetRandomQuoteUseCase()

     fun randomQuote() {
        isLoading.postValue(true)
        val quote = getRandomQuoteUseCase()
        if(quote!=null){
            quoteModel.postValue(quote)
        }
        isLoading.postValue(false)
    }

Ahora ya tenemos nuestra app completa (al menos para esta segunda parte) y si ejecutas la app verás que todo funciona de maravilla.

Hasta aquí la segunda parte, si quieres ver (o descargar) el código completo puedes hacerlo desde mi GitHub y te pido por favor que le des un fav al repositorio. También te recuerdo que tienes el capítulo por escrito y explicado con mas detalle en Youtube.

Puedes seguirme en mis redes sociales:

Y si tienes dudas con este o cualquier otro artículo del blog únete al Discord de la comunidad y te ayudaremos.