Retrofit com Coroutines e LiveData no Android

Retrofit com Coroutines e LiveData no Android
Alex Felipe
Alex Felipe

Compartilhe

Neste artigo veremos como implementar o Retrofit com Coroutines e LiveData, demonstrando as diferenças em relação ao uso de callbacks.

Introdução ao projeto de exemplo

Ao desenvolver Apps, é muito comum a implementação de requisições HTTP para consumir APIs. Como por exemplo, o App Onde fica, é possível buscar endereços a partir do viacep, um serviço online que oferece uma API REST para consultar endereços a partir do CEP:

buscador de cep

Nesta demonstração, ao clicar em BUSCAR, é feita uma requisição HTTP por meio do Retrofit e integrada com o LiveData para atualizar a tela. A implementação é feita da seguinte maneira:

  • Utilizamos o serviço de endereço que faz a busca na API do viacep.
interface EnderecoService {

    @GET("{cep}/json")
    fun buscaEndereco(@Path("cep") cep: String): Call<Endereco>

}
  • Então usamos um repositório para realizar a chamada a partir do enqueue() da Call<Endereco>:
class EnderecoRepository(
    private val service: EnderecoService
) {

    fun buscaEndereco(cep: String): LiveData<Endereco?> {
        val liveData = MutableLiveData<Endereco?>()
        service.buscaEndereco(cep).enqueue(object : Callback<Endereco?> {

            override fun onResponse(
                call: Call<Endereco?>,
                response: Response<Endereco?>
            ) {
                liveData.postValue(response.body())
            }

            override fun onFailure(call: Call<Endereco?>, t: Throwable) {
                Log.e("EnderecoRepository", "onFailure: falha ao buscar o endereço", t)
                liveData.postValue(null)
            }

        })
        return liveData
    }

}

Se for a sua primeira vez com o uso do Retrofit, recomendo conferir este artigo que explica com mais detalhes essa implementação.

Basicamente, devolvemos o endereço quando temos uma resposta de sucesso e um null caso contrário para limpar a tela. Nesta implementação, utilizamos o Callback devido ao uso do enqueue() que permite a execução uma thread paralela.

Ao utilizar callbacks o nosso código tende a aumentar a verbosidade, pois para cada requisição precisamos usar um boilerplate similar.

Banner de divulgação da Imersão IA da Alura em colaboração com o Google. Mergulhe em Inteligência artificial com a Alura e o Google. Serão cinco aulas gratuitas para você aprender a usar IA na prática e desenvolver habilidades essenciais para o mercado de trabalho. Inscreva-se gratuitamente agora!

Coroutines como alternativa de callbacks

Como alternativa de implementação, podemos utilizar as coroutines no Android que permite o mesmo comportamento sem a necessidade de callback. Você pode adicionar as Coroutines com essa dependência

implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9'

Um exemplo de código, é executando a Call diretamente dentro do escopo de uma Coroutine:

fun buscaEndereco(cep: String): LiveData<Endereco?> {
    val liveData = MutableLiveData<Endereco?>()
    CoroutineScope(Dispatchers.IO).launch {
        val resposta = service.buscaEndereco(cep).execute()
        liveData.postValue(resposta.body())
    }
    return liveData
}

Esse código funciona, porém, no execute() o Android Studio apresenta a seguinte mensagem Inappropriate blocking method call, indicando que essa chamada não é apropriada, pois é um método que bloqueia a thread.

Entendendo o problema de bloquear a thread em Coroutine

O grande problema desse tipo de chamada em uma Coroutine, é que pode impactar a performance, pois, ao bloquear threads, corremos o risco de travar outras Coroutines e impedir o processo de suspensão.

É possível simular e visualizar esse comportamento criando várias coroutines e executando Thread.sleep() (chamada equivalente a uma instrução de bloqueio) em um CoroutineContext específico, nesse caso, o Dispatchers.IO. Vamos considerar o seguinte exemplo:

fun buscaEndereco(cep: String): LiveData<Endereco?> {
    val liveData = MutableLiveData<Endereco?>()
    repeat(100) {
        CoroutineScope(IO).launch {
            Thread.sleep(10000)
            Log.i("EnderecoRepository", "buscaEndereco: finalizando o sleep")
        }
    }
    CoroutineScope(Dispatchers.IO).launch {
        Log.i("EnderecoRepository", "buscaEndereco: executando o http")
                val resposta = service.buscaEndereco(cep).execute()
        liveData.postValue(resposta.body())
    }
    return liveData
}

Ao testar e visualizar o logcat, notamos que a requisição HTTP, que deveria ser executada em paralelo, só é chamada após a finalização de algumas Coroutines. Isso demonstra o problema de executar instruções que podem bloquear threads dentro de Coroutines.

Quanto maior a quantidade de execução simultâneas, mais fácil é visualização do problema... Além da quantidade, o processador também influencia no resultado, quanto menos potente, mais fácil de simular o problema.

Suspend functions nos serviços do Retrofit

Por conta disso, a partir da versão 2.6 do Retrofit, temos o suporte ao uso de Coroutines, o que permite implementar os métodos dos serviços como uma função de suspensão (suspend function):

interface EnderecoService {

    @GET("{cep}/json")
    suspend fun buscaEndereco(@Path("cep") cep: String): Response<Endereco?>

}

Além de evitar o problema de bloquear a thread, lidamos diretamente com a referência Response:

fun buscaEndereco(cep: String): LiveData<Endereco?> {
    val liveData = MutableLiveData<Endereco?>()
    CoroutineScope(IO).launch {
        liveData.postValue(service.buscaEndereco(cep).body())
    }
    return liveData
}

Dessa forma, temos o mesmo resultado, a diferença é que evitamos o risco de travar a Coroutine :)

LiveData com escopo de Coroutine no KTX

Inclusive, podemos simplificar mais ainda a nossa implementação, utilizando o KTX para o LiveData, que permite acessar um LiveDataScope, que herda de CoroutineScope e permite executar suspend functions. Você pode adicioná-lo a partir desta dependência

implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.2.0'

E então podemos implementar o mesmo código da seguinte maneira:

fun buscaEndereco(cep: String) = liveData {
    emit(service.buscaEndereco(cep).body())
}

A diferença é que não precisamos mais criar um LiveData ou CoroutinesScope e utilizamos o método emit() para atualizar o valor do LiveData. Dessa forma, reduzimos bastante o código para executar uma requisição HTTP e atualizar a tela com o LiveData!

Por mais que a ideia seja simplificar, apenas essa implementação não é o suficiente para atender aos casos comuns de uma comunicação web.

Lidando com possíveis erros na comunicação HTTP

Além dos casos que temos o caminho feliz (tudo funcionando como esperado), nesse tipo de comunicação precisamos lidar com as situações excepcionais.

Uma das técnicas utilizadas para isso, é envolver a resposta da comunicação em uma outra classe capaz de identificar o resultado como um sucesso ou falha.

Uma implementação demonstrada até pela documentação do Android Developers é a classe Resultado:

sealed class Resultado<out R> {
    data class Sucesso<out T>(val dado: T?) : Resultado<T?>()
    data class Erro(val exception: Exception) : Resultado<Nothing>()
}

Com esta classe, temos a possibilidade de atualizar o LiveData com sucesso ou erro. No nosso código podemos fazer o seguinte:

class EnderecoRepository(
    private val service: EnderecoService
) {

    fun buscaEndereco(cep: String) = liveData {
        val resposta = service.buscaEndereco(cep)
        if(resposta.isSuccessful){
            emit(Resultado.Sucesso(dado = resposta.body()))
        } else {
            emit(Resultado.Erro(exception = Exception("Falha ao buscar o endereco")))
        }
    }

}

Então precisamos ajustar o retorno no ViewModel:

class EnderecoViewModel(private val repository: EnderecoRepository) : ViewModel() {

    fun buscaEnderecoPelo(cep: String): LiveData<Resultado<Endereco?>> =
        repository.buscaEndereco(cep)

}

E quem consome o LiveData precisa implementar o comportamento de ambas as situações:

private fun buscaEndereco(cep: String) {
    binding.progresso.show()
    viewModel.buscaEnderecoPelo(cep).observe(this) {
        binding.progresso.hide()
        val enderecoVisivel = it?.let { resultado ->
            when (resultado) {
                is Resultado.Sucesso -> {
                    resultado.dado?.let { endereco ->
                        preencheEndereco(endereco)
                        true
                    } ?: false
                }
                is Resultado.Erro -> {
                    Snackbar.make(
                        binding.coordinatorLayout,
                        resultado.exception.message.toString(),
                        Snackbar.LENGTH_SHORT
                    ).show()
                    false
                }
            }
        } ?: false

        binding.constraintLayoutInfoEndereco.visibility =
            if (enderecoVisivel) {
                VISIBLE
            } else {
                GONE
            }
    }
}

private fun preencheEndereco(endereco: Endereco) {
    binding.logradouro.text = endereco.logradouro
    binding.bairro.text = endereco.bairro
    binding.cidade.text = endereco.localidade
    binding.estado.text = endereco.uf
}

Com esse ajuste, somos capazes de apresentar o endereço quando a requisição é sucedida ou uma mensagem específica, quando apresenta um problema:

buscador de cep com erro

Evitando problemas de comunicação com try catch

Além dos casos que ocorrem a falha devido à resposta da API, também há casos em que o endereço da API está errado, ou ocorre um timeout ou qualquer problema de uma falha de comunicação. Nesses casos, precisamos envolver todo o código que chama o service em um try catch:

fun buscaEndereco(cep: String) = liveData {
    try {
        val resposta = service.buscaEndereco(cep)
        if(resposta.isSuccessful){
            emit(Resultado.Sucesso(dado = resposta.body()))
        } else {
            emit(Resultado.Erro(exception = Exception("Falha ao buscar o endereco")))
        }
    } catch (e: Exception) {
        emit(Resultado.Erro(exception = e))
    }
}

Fazendo a simulação de um endereço inválido (https://teste.com.br/ws/), temos a seguinte mensagem para o nosso usuário final:

falha na conexão

Essa mensagem é identificada pela Exception java.net.ConnectionException, portanto, podemos identificá-la e apresentar uma mensagem mais específica:

try {
    // restante do código
} catch (e: ConnectException) {
    emit(Resultado.Erro(exception = Exception("Falha na comunicação com API")))
}
catch (e: Exception) {
    emit(Resultado.Erro(exception = e))
}

E para evitar futuras mensagens do gênero, exceptions que ainda não identificamos, podemos apresentar uma mensagem genérica caso seja uma exception diferente:

try {
    // restante do código
} catch (e: ConnectException) {
    emit(Resultado.Erro(exception = Exception("Falha na comunicação com API")))
}
catch (e: Exception) {
    Log.e("EnderecoRepository", "buscaEndereco: ", e)
    emit(Resultado.Erro(exception = Exception("Ocorreu uma falha desconhecida")))
}

Além de evitar uma mensagem muito estranha para o usuário final, temos a possibilidade de identificar por meio de um log.

O ideal nas exceptions é utilizar técnicas de log que permitam monitorar quando as exceções ocorrem nos dispositivos dos usuários.

Conclusão

Com a possibilidade de utilizar Coroutines, evitamos o uso excessivo de callbacks em chamadas assíncronas e conseguimos escrever um código de maneira objetiva para realizar operações assíncronas

Código fonte

Você pode consultar o código fonte do artigo a partir deste repositório do GitHub.

Alex Felipe
Alex Felipe

Alex é instrutor e desenvolvedor e possui experiência em Java, Kotlin, Android. Atualmente cria conteúdo no canal https://www.youtube.com/@AlexFelipeDev.

Veja outros artigos sobre Mobile