Retrofit com Coroutines e LiveData no Android

Alex Felipe Victor Vieira
Alex Felipe Victor Vieira

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.

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 Victor Vieira
Alex Felipe Victor Vieira

Alex é instrutor e desenvolvedor e possui experiência em Java, Kotlin, Android. Criador de mais de 40 cursos, como Kotlin, Flutter, Android, persistência de dados, comunicação com Web API, personalização de telas, testes automatizados, arquitetura de Apps e Firebase. É expert em Programação Orientada a Objetos, visando sempre compartilhar as boas práticas e tendências do mercado de desenvolvimento de software. Atuou 2 anos como editor de conteúdo no blog da Alura e hoje ainda escreve artigos técnicos.

Veja outros artigos sobre Mobile