Consumo de API com Retrofit em Kotlin: modelos, chamadas, interceptors e parsing

Capítulo 12

Tempo estimado de leitura: 14 minutos

+ Exercício

O que é Retrofit e como ele se encaixa no app

Retrofit é uma biblioteca para consumo de APIs HTTP que transforma endpoints REST em funções Kotlin. Ele trabalha em conjunto com o OkHttp (cliente HTTP) e com um conversor (Moshi ou Gson) para transformar JSON em objetos Kotlin. Em um app bem organizado, o Retrofit fica na camada de dados (data), dentro de um remote datasource e/ou repositório, e expõe funções suspend para serem chamadas por use cases ou diretamente pelo repositório.

Componentes principais

  • OkHttpClient: configura timeouts, cache, interceptors, headers e logs.
  • Retrofit: define baseUrl, conversor e cria a interface de API.
  • Converter (Moshi/Gson): faz parsing do JSON para DTOs.
  • DTO (Data Transfer Object): modelo que representa o JSON da API.
  • Modelo de domínio: modelo usado no app (UI/negócio), independente do formato da API.

Dependências (Gradle) para Retrofit, OkHttp e conversor

Escolha Moshi ou Gson. Moshi costuma ser uma escolha moderna e segura com Kotlin. Abaixo um exemplo com Moshi.

// build.gradle (Module: app) - trechos relevantes

dependencies {
    implementation("com.squareup.retrofit2:retrofit:2.11.0")
    implementation("com.squareup.retrofit2:converter-moshi:2.11.0")

    implementation("com.squareup.okhttp3:okhttp:4.12.0")
    implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")

    implementation("com.squareup.moshi:moshi:1.15.1")
    implementation("com.squareup.moshi:moshi-kotlin:1.15.1")
}

Se preferir Gson, substitua o conversor e remova Moshi:

implementation("com.squareup.retrofit2:converter-gson:2.11.0")
implementation("com.google.code.gson:gson:2.11.0")

Configuração do OkHttp: timeouts, logs, headers e cache

Timeouts

Timeouts evitam que o app fique esperando indefinidamente. Ajuste conforme o tipo de API e rede esperada.

import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit

val okHttpClient = OkHttpClient.Builder()
    .connectTimeout(15, TimeUnit.SECONDS)
    .readTimeout(20, TimeUnit.SECONDS)
    .writeTimeout(20, TimeUnit.SECONDS)
    .build()

Interceptor de logs (útil em debug)

O HttpLoggingInterceptor imprime request/response no Logcat. Use nível BODY apenas em debug para evitar vazamento de dados em produção.

Continue em nosso aplicativo e ...
  • Ouça o áudio com a tela desligada
  • Ganhe Certificado após a conclusão
  • + de 5000 cursos para você explorar!
ou continue lendo abaixo...
Download App

Baixar o aplicativo

import okhttp3.logging.HttpLoggingInterceptor

val logging = HttpLoggingInterceptor().apply {
    level = HttpLoggingInterceptor.Level.BODY
}

val okHttpClient = OkHttpClient.Builder()
    .addInterceptor(logging)
    .build()

Interceptor para headers (ex.: token, idioma, user-agent)

Interceptors permitem alterar a requisição antes de enviá-la. Um caso comum é adicionar Authorization e Accept.

import okhttp3.Interceptor

class HeadersInterceptor(
    private val tokenProvider: () -> String?
) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): okhttp3.Response {
        val original = chain.request()
        val token = tokenProvider()

        val request = original.newBuilder()
            .header("Accept", "application/json")
            .apply {
                if (!token.isNullOrBlank()) {
                    header("Authorization", "Bearer $token")
                }
            }
            .build()

        return chain.proceed(request)
    }
}

Cache HTTP básico (quando a API permite)

OkHttp suporta cache baseado em headers HTTP (Cache-Control, ETag). Para funcionar bem, o servidor precisa cooperar. Ainda assim, você pode adicionar um cache local e, opcionalmente, forçar comportamento em offline.

import okhttp3.Cache
import java.io.File

fun provideOkHttpCache(cacheDir: File): Cache {
    val cacheSize = 10L * 1024L * 1024L // 10 MB
    return Cache(File(cacheDir, "http_cache"), cacheSize)
}
val okHttpClient = OkHttpClient.Builder()
    .cache(provideOkHttpCache(context.cacheDir))
    .build()

Se você quiser um cache “mais agressivo” para listas (ex.: 60s), pode adicionar um interceptor que injeta Cache-Control na resposta. Use com cuidado, pois pode mostrar dados desatualizados.

import okhttp3.Interceptor

class CacheControlInterceptor : Interceptor {
    override fun intercept(chain: Interceptor.Chain): okhttp3.Response {
        val response = chain.proceed(chain.request())
        return response.newBuilder()
            .header("Cache-Control", "public, max-age=60")
            .build()
    }
}

Configuração do Retrofit com Moshi (parsing)

O Retrofit precisa de uma baseUrl (terminando com /) e de um conversor. Com Moshi, é comum adicionar o KotlinJsonAdapterFactory para lidar melhor com data classes.

import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory

val moshi = Moshi.Builder()
    .add(KotlinJsonAdapterFactory())
    .build()

val retrofit = Retrofit.Builder()
    .baseUrl("https://api.github.com/")
    .client(okHttpClient)
    .addConverterFactory(MoshiConverterFactory.create(moshi))
    .build()

Com Gson, a ideia é a mesma, trocando o converter:

import retrofit2.converter.gson.GsonConverterFactory

val retrofit = Retrofit.Builder()
    .baseUrl("https://api.github.com/")
    .client(okHttpClient)
    .addConverterFactory(GsonConverterFactory.create())
    .build()

Modelando DTOs e mapeando para modelos de domínio

DTOs devem refletir o JSON da API (nomes, campos opcionais, estruturas). Já o domínio deve refletir o que o app precisa para exibir e tomar decisões, com nomes mais claros e tipos mais convenientes.

Prática com API pública: GitHub Search

Vamos consumir a API pública do GitHub para buscar repositórios e abrir detalhes. Endpoint:

  • GET /search/repositories?q=kotlin&page=1&per_page=20
  • GET /repos/{owner}/{repo}

Exemplo de JSON (simplificado) do search:

{
  "total_count": 123,
  "items": [
    {
      "id": 1,
      "full_name": "JetBrains/kotlin",
      "description": "...",
      "stargazers_count": 100,
      "owner": { "login": "JetBrains", "avatar_url": "..." }
    }
  ]
}

DTOs (Moshi)

import com.squareup.moshi.Json

data class SearchReposResponseDto(
    @Json(name = "total_count") val totalCount: Int,
    val items: List<RepoDto>
)

data class RepoDto(
    val id: Long,
    @Json(name = "full_name") val fullName: String,
    val description: String?,
    @Json(name = "stargazers_count") val stars: Int,
    val owner: OwnerDto
)

data class OwnerDto(
    val login: String,
    @Json(name = "avatar_url") val avatarUrl: String
)

DTO para detalhe (pode ser mais completo, mas foque no necessário):

data class RepoDetailDto(
    val id: Long,
    @Json(name = "full_name") val fullName: String,
    val description: String?,
    @Json(name = "stargazers_count") val stars: Int,
    @Json(name = "forks_count") val forks: Int,
    @Json(name = "open_issues_count") val openIssues: Int,
    val owner: OwnerDto
)

Modelos de domínio

No domínio, você pode simplificar nomes e agrupar o que a UI precisa.

data class Repo(
    val id: Long,
    val name: String,
    val ownerLogin: String,
    val ownerAvatarUrl: String,
    val description: String,
    val stars: Int
)

data class RepoDetail(
    val id: Long,
    val name: String,
    val ownerLogin: String,
    val ownerAvatarUrl: String,
    val description: String,
    val stars: Int,
    val forks: Int,
    val openIssues: Int
)

Mapeamento DTO -> Domínio

Crie funções de extensão para manter o código limpo e testável.

fun RepoDto.toDomain(): Repo = Repo(
    id = id,
    name = fullName,
    ownerLogin = owner.login,
    ownerAvatarUrl = owner.avatarUrl,
    description = description.orEmpty(),
    stars = stars
)

fun RepoDetailDto.toDomain(): RepoDetail = RepoDetail(
    id = id,
    name = fullName,
    ownerLogin = owner.login,
    ownerAvatarUrl = owner.avatarUrl,
    description = description.orEmpty(),
    stars = stars,
    forks = forks,
    openIssues = openIssues
)

Definindo a interface da API no Retrofit

Retrofit usa anotações para descrever endpoints, query params e path params. Com coroutines, você define funções suspend.

import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query

interface GitHubApi {

    @GET("search/repositories")
    suspend fun searchRepositories(
        @Query("q") query: String,
        @Query("page") page: Int,
        @Query("per_page") perPage: Int
    ): SearchReposResponseDto

    @GET("repos/{owner}/{repo}")
    suspend fun getRepositoryDetail(
        @Path("owner") owner: String,
        @Path("repo") repo: String
    ): RepoDetailDto
}

Crie a instância:

val api = retrofit.create(GitHubApi::class.java)

Chamadas assíncronas com coroutines e tratamento de erros

Quando você chama uma função suspend do Retrofit, ela executa a request fora da thread principal (desde que você esteja usando coroutines corretamente). O principal ponto é lidar com falhas de rede, HTTP e parsing.

Erros comuns

  • IOException: sem internet, timeout, falha de conexão.
  • HttpException: status HTTP 4xx/5xx (quando você usa Response<T> ou quando o Retrofit lança em certas configurações).
  • JsonDataException / JsonEncodingException (Moshi) ou JsonSyntaxException (Gson): JSON inesperado.

Preferindo Response<T> para controlar status e body

Você pode retornar Response<T> e decidir como tratar cada caso, especialmente útil para mensagens de erro e paginação.

import retrofit2.Response

interface GitHubApi {
    @GET("search/repositories")
    suspend fun searchRepositories(
        @Query("q") query: String,
        @Query("page") page: Int,
        @Query("per_page") perPage: Int
    ): Response<SearchReposResponseDto>
}

Exemplo de chamada no repositório:

import retrofit2.HttpException
import java.io.IOException

sealed class AppError {
    data class Network(val message: String) : AppError()
    data class Http(val code: Int, val message: String) : AppError()
    data class Parsing(val message: String) : AppError()
    data class Unknown(val message: String) : AppError()
}

sealed class Result<out T> {
    data class Success<T>(val data: T) : Result<T>()
    data class Failure(val error: AppError) : Result<Nothing>()
}

class ReposRepository(
    private val api: GitHubApi
) {
    suspend fun search(query: String, page: Int, perPage: Int): Result<List<Repo>> {
        return try {
            val response = api.searchRepositories(query, page, perPage)
            if (response.isSuccessful) {
                val body = response.body()
                if (body == null) {
                    Result.Failure(AppError.Parsing("Resposta vazia"))
                } else {
                    Result.Success(body.items.map { it.toDomain() })
                }
            } else {
                Result.Failure(AppError.Http(response.code(), response.message()))
            }
        } catch (e: IOException) {
            Result.Failure(AppError.Network(e.message ?: "Falha de rede"))
        } catch (e: HttpException) {
            Result.Failure(AppError.Http(e.code(), e.message()))
        } catch (e: Exception) {
            Result.Failure(AppError.Unknown(e.message ?: "Erro inesperado"))
        }
    }
}

Paginação simples (page/per_page) e estado de lista

Muitas APIs usam paginação por página. A estratégia mais simples:

  • Manter currentPage e isLoading.
  • Carregar a primeira página ao iniciar ou ao mudar a busca.
  • Carregar próxima página quando o usuário chega perto do fim da lista.
  • Parar quando vier uma página vazia (ou quando total_count indicar fim).

Estrutura de estado para a lista

data class ReposListState(
    val query: String = "kotlin",
    val items: List<Repo> = emptyList(),
    val page: Int = 1,
    val isLoading: Boolean = false,
    val endReached: Boolean = false,
    val errorMessage: String? = null
)

Funções de carregar primeira página e próxima página

Exemplo de lógica (pode ficar em ViewModel, mas aqui focamos na regra):

private const val PER_PAGE = 20

suspend fun loadFirstPage(
    repository: ReposRepository,
    state: ReposListState
): ReposListState {
    val newState = state.copy(isLoading = true, errorMessage = null, page = 1, endReached = false)

    return when (val result = repository.search(newState.query, 1, PER_PAGE)) {
        is Result.Success -> {
            val end = result.data.isEmpty()
            newState.copy(items = result.data, isLoading = false, endReached = end)
        }
        is Result.Failure -> newState.copy(isLoading = false, errorMessage = result.error.toString())
    }
}

suspend fun loadNextPage(
    repository: ReposRepository,
    state: ReposListState
): ReposListState {
    if (state.isLoading || state.endReached) return state

    val nextPage = state.page + 1
    val loadingState = state.copy(isLoading = true, errorMessage = null)

    return when (val result = repository.search(state.query, nextPage, PER_PAGE)) {
        is Result.Success -> {
            val newItems = state.items + result.data
            val end = result.data.isEmpty()
            loadingState.copy(items = newItems, page = nextPage, isLoading = false, endReached = end)
        }
        is Result.Failure -> loadingState.copy(isLoading = false, errorMessage = result.error.toString())
    }
}

Na UI, o gatilho de paginação pode ser: ao renderizar a lista, quando o usuário chega nos últimos itens (ex.: índice >= items.size - 5), chamar loadNextPage.

Prática completa: lista e detalhe consumindo GitHub API

1) Criar o módulo de rede (NetworkModule simples)

Sem entrar em frameworks de DI, você pode criar um objeto/fábrica para centralizar a criação do Retrofit e API.

object NetworkModule {

    fun createOkHttpClient(
        tokenProvider: () -> String?,
        cache: okhttp3.Cache? = null,
        isDebug: Boolean = true
    ): okhttp3.OkHttpClient {
        val logging = okhttp3.logging.HttpLoggingInterceptor().apply {
            level = if (isDebug) okhttp3.logging.HttpLoggingInterceptor.Level.BODY
                    else okhttp3.logging.HttpLoggingInterceptor.Level.NONE
        }

        return okhttp3.OkHttpClient.Builder()
            .connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
            .readTimeout(20, java.util.concurrent.TimeUnit.SECONDS)
            .writeTimeout(20, java.util.concurrent.TimeUnit.SECONDS)
            .apply { if (cache != null) cache(cache) }
            .addInterceptor(HeadersInterceptor(tokenProvider))
            .addInterceptor(logging)
            .build()
    }

    fun createRetrofit(okHttpClient: okhttp3.OkHttpClient): retrofit2.Retrofit {
        val moshi = com.squareup.moshi.Moshi.Builder()
            .add(com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory())
            .build()

        return retrofit2.Retrofit.Builder()
            .baseUrl("https://api.github.com/")
            .client(okHttpClient)
            .addConverterFactory(retrofit2.converter.moshi.MoshiConverterFactory.create(moshi))
            .build()
    }

    fun createGitHubApi(retrofit: retrofit2.Retrofit): GitHubApi =
        retrofit.create(GitHubApi::class.java)
}

2) Repositório com busca e detalhe

class ReposRepository(
    private val api: GitHubApi
) {
    suspend fun search(query: String, page: Int, perPage: Int): Result<List<Repo>> {
        return try {
            val response = api.searchRepositories(query, page, perPage)
            if (!response.isSuccessful) {
                return Result.Failure(AppError.Http(response.code(), response.message()))
            }
            val body = response.body() ?: return Result.Failure(AppError.Parsing("Resposta vazia"))
            Result.Success(body.items.map { it.toDomain() })
        } catch (e: java.io.IOException) {
            Result.Failure(AppError.Network(e.message ?: "Falha de rede"))
        } catch (e: Exception) {
            Result.Failure(AppError.Unknown(e.message ?: "Erro inesperado"))
        }
    }

    suspend fun getDetail(owner: String, repo: String): Result<RepoDetail> {
        return try {
            val dto = api.getRepositoryDetail(owner, repo)
            Result.Success(dto.toDomain())
        } catch (e: java.io.IOException) {
            Result.Failure(AppError.Network(e.message ?: "Falha de rede"))
        } catch (e: Exception) {
            Result.Failure(AppError.Unknown(e.message ?: "Erro inesperado"))
        }
    }
}

Note que getRepositoryDetail aqui retorna diretamente o DTO (sem Response<T>). Se quiser tratar status HTTP explicitamente, mude para Response<RepoDetailDto> como feito na busca.

3) Extraindo owner/repo a partir do fullName

O endpoint de detalhe precisa de owner e repo. Se você guardou full_name como JetBrains/kotlin, pode separar:

fun splitFullName(fullName: String): Pair<String, String> {
    val parts = fullName.split("/")
    require(parts.size == 2)
    return parts[0] to parts[1]
}

4) Exibindo lista e abrindo detalhe (fluxo de dados)

Na tela de lista, você carrega a primeira página e renderiza Repo. Ao clicar em um item, navega para a tela de detalhe passando fullName (ou owner/repo). Na tela de detalhe, chama getDetail e renderiza RepoDetail.

TelaEntradaChamadaSaída
Listaquery, pagesearchRepositoriesList<Repo>
Detalheowner, repogetRepositoryDetailRepoDetail

Interceptors na prática: autenticação opcional e rate limit

A API do GitHub tem limites de requisição. Se você usar um token (personal access token), o limite melhora. O HeadersInterceptor mostrado antes permite inserir o token sem espalhar lógica pela base.

Uma estratégia simples é armazenar o token em uma fonte segura (ex.: armazenamento seguro) e fornecer via tokenProvider. Em ambientes de teste, você pode retornar null para não enviar header.

Parsing e compatibilidade com campos opcionais

APIs mudam e nem sempre entregam todos os campos. Para reduzir crashes:

  • Marque campos como anuláveis no DTO quando não forem garantidos (String?, Int?).
  • Use orEmpty() ou valores padrão no mapeamento para domínio.
  • Evite usar o DTO diretamente na UI; o domínio pode garantir invariantes (ex.: description nunca nula).

Exemplo com valor padrão no DTO (quando fizer sentido):

data class RepoDto(
    val id: Long,
    @Json(name = "full_name") val fullName: String,
    val description: String? = null,
    @Json(name = "stargazers_count") val stars: Int = 0,
    val owner: OwnerDto
)

Cache básico: quando usar e como aplicar sem complicar

Existem dois tipos comuns de cache:

  • Cache HTTP (OkHttp): automático, depende de headers do servidor. Bom para reduzir tráfego e acelerar.
  • Cache de aplicação: você guarda o último resultado em memória (ou persistência) para reuso rápido.

Cache em memória para detalhe (simples e útil)

Detalhes de um repositório mudam pouco. Um cache em memória evita múltiplas chamadas ao abrir/voltar rapidamente.

class RepoDetailCache {
    private val map = mutableMapOf<String, RepoDetail>()

    fun get(key: String): RepoDetail? = map[key]
    fun put(key: String, value: RepoDetail) { map[key] = value }
}
class ReposRepository(
    private val api: GitHubApi,
    private val detailCache: RepoDetailCache
) {
    suspend fun getDetail(owner: String, repo: String): Result<RepoDetail> {
        val key = "$owner/$repo"
        detailCache.get(key)?.let { return Result.Success(it) }

        return try {
            val dto = api.getRepositoryDetail(owner, repo)
            val domain = dto.toDomain()
            detailCache.put(key, domain)
            Result.Success(domain)
        } catch (e: java.io.IOException) {
            Result.Failure(AppError.Network(e.message ?: "Falha de rede"))
        } catch (e: Exception) {
            Result.Failure(AppError.Unknown(e.message ?: "Erro inesperado"))
        }
    }
}

Esse cache é volátil (perde ao fechar o app), mas já melhora a experiência. Se você quiser cache persistente, uma abordagem comum é salvar em banco local e definir uma política de expiração; como persistência já foi tratada em outro capítulo, aqui o foco fica no cache HTTP e em memória.

Checklist de implementação (passo a passo)

  • Adicionar dependências de Retrofit, OkHttp, logging interceptor e Moshi/Gson.
  • Criar OkHttpClient com timeouts.
  • Adicionar interceptors: headers (token/opcionais) e logs (apenas debug).
  • Configurar cache do OkHttp (tamanho e diretório).
  • Criar Retrofit com baseUrl e converter.
  • Definir interface GitHubApi com endpoints e parâmetros.
  • Modelar DTOs conforme JSON e mapear para modelos de domínio.
  • Implementar repositório com coroutines, tratamento de erro e paginação simples.
  • Implementar fluxo de lista (search + paginação) e detalhe (getDetail) consumindo a API pública.
  • Adicionar cache em memória para detalhe (e/ou cache HTTP para respostas quando aplicável).

Agora responda o exercício sobre o conteúdo:

Em um app bem organizado, qual é a principal razão para mapear DTOs (formato da API) para modelos de domínio antes de usar os dados na UI?

Você acertou! Parabéns, agora siga para a próxima página

Você errou! Tente novamente.

O DTO deve refletir o JSON, que pode mudar e ter campos opcionais. Ao mapear para o domínio, o app fica menos acoplado à API e a UI recebe dados em formatos mais convenientes, com valores padrão (ex.: orEmpty()) e invariantes.

Próximo capitúlo

Tratamento de erros e estados de tela no Android: resiliência e experiência do usuário

Arrow Right Icon
Capa do Ebook gratuito Android para Iniciantes com Kotlin: construindo seu primeiro app moderno
80%

Android para Iniciantes com Kotlin: construindo seu primeiro app moderno

Novo curso

15 páginas

Baixe o app para ganhar Certificação grátis e ouvir os cursos em background, mesmo com a tela desligada.