Retrofit com Coroutines e LiveData no Android
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:
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()
daCall<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:
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:
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.