Ручная работа с сетью на Android быстро приедается: собрать адрес, выписать заголовки, открыть соединение, разобрать ответ, не забыть про ошибки и отмену запроса при уходе с экрана. На двадцатом методе начинается копипаста, на двухсотом она взрывается. Библиотека Retrofit от Square переворачивает процесс: разработчик описывает API типизированным интерфейсом с аннотациями, а библиотека сама строит запросы и раздаёт ответы. Актуальная ветка 3.0.0, вышедшая в мае 2025 года, обновила внутренний транспорт до OkHttp 4.12 и осталась бинарно совместимой с кодом, собранным против 2.x. Retrofit появился у Square в 2013 году, а вторая версия 2016-го переписала механику на интерфейсы и стала долгожителем, пережившим смену поколений Android. Разберём механику от интерфейса до тестов, с кодом на Kotlin и решениями, которые принимаются в каждом проекте.

Интерфейс с аннотациями описывает API без ручных запросов

Взгляд со стороны выглядит необычно: сетевой слой объявляется как обычный интерфейс, без единой строчки тела метода. При создании клиента Retrofit генерирует исполнителя через динамический прокси, и каждый вызов метода превращается в HTTP-запрос по описанию из аннотаций. Опечатка в типе ответа ловится компилятором, а не пользователем на телефоне.

interface CatalogApi {

    @GET("v1/products")
    suspend fun listProducts(
        @Query("page") page: Int,
        @Query("size") size: Int = 30,
    ): List<Product>

    @POST("v1/products/{id}/reserve")
    suspend fun reserve(
        @Path("id") id: Long,
        @Body body: ReserveRequest,
    ): Reservation
}

Набор аннотаций покрывает весь протокол:

  1. @GET @POST @PUT @PATCH и @DELETE задают метод и путь запроса;
  2. @Path подставляет сегмент адреса из аргумента метода;
  3. @Query и @QueryMap добавляют параметры строки и собирают их из карты;
  4. @Body отдаёт объект конвертеру для сборки тела запроса;
  5. @Header и @Headers управляют заголовками, статическими и динамическими;
  6. @Multipart вместе с @Part собирает составной запрос с файлами.

Пути задаются относительно базового адреса, и один клиент обслуживает весь сервис. Отдельные эндпоинты живут своими интерфейсами, а клиент собирается один раз на приложение и раздаётся через внедрение зависимостей. Такой интерфейс читается как документация: глянув на пять строк, инженер видит и метод, и путь, и параметры, и тип ответа. Базовый адрес обязан заканчиваться косой чертой, пути в аннотациях пишутся без неё, и это единственное место, где новички спотыкаются. Для адреса, известного только при вызове, служит @Url, а для смены окружения достаточно пересобрать клиент с другим базовым адресом.

Конвертеры превращают JSON в объекты Kotlin и обратно

Сам Retrofit не разбирает JSON, он договаривается с конвертерами. Фабрика конвертера подключается при сборке клиента, и дальше библиотека гоняет значения через неё в обе стороны: объект уходит в тело запроса, тело ответа возвращается объектом. Экосистема предлагает конвертеры под все популярные механизмы: kotlinx.serialization, Moshi, Gson, Jackson, а также скаляры, protobuf и XML.

val json = Json {
    ignoreUnknownKeys = true
    encodeDefaults = true
}

val api = Retrofit.Builder()
    .baseUrl("https://api.example/")
    .client(httpClient)
    .addConverterFactory(
        json.asConverterFactory("application/json".toMediaType()))
    .build()
    .create(CatalogApi::class.java)

Для Android-проектов на Kotlin разумный выбор это kotlinx.serialization или Moshi: оба генерируют адаптеры на этапе компиляции, без рефлексии в рантайме, и не тормозят старт приложения. Gson остаётся в наследственном коде и в простых сценариях, Jackson силён на сервере, где его богатство настроек оправдано. Флаг ignoreUnknownKeys освобождает приложение от падений при появлении на сервере новых полей, а это спасает при частых релизах бэкенда. Для плоского текста и простых чисел есть конвертер скаляров, а собственная фабрика подключается для экзотических форматов, когда ни один готовый не подошёл.

Coroutine вызовы делают сетевой слой приостановляемым

Исторически Retrofit возвращал объект Call, который нужно было ставить в очередь и вручную отменять. С версии 2.6 приостановляемые функции поддерживаются из коробки, и сетевой вызов стал просто suspend-функцией: вызывающий поток не блокируется, отмена_SCOPE отменяет запрос автоматически, а последовательные вызовы выглядят как обычный линейный код без вложенных колбэков.

class CatalogViewModel(
    private val api: CatalogApi,
) : ViewModel() {

    private val _state = MutableStateFlow<CatalogState>(CatalogState.Loading)
    val state: StateFlow<CatalogState> = _state.asStateFlow()

    fun load() {
        viewModelScope.launch {
            _state.value = CatalogState.Loading
            try {
                val products = api.listProducts(page = 0)
                _state.value = CatalogState.Ready(products)
            } catch (e: IOException) {
                _state.value = CatalogState.Offline
            }
        }
    }
}

Когда экран умирает,viewModelScope гасит корутину, и HTTP-выборка отменяется вместе с ней, без утечек и робких флагов isCancelled. Для потоков значений, которые приходят страницами, применяют Paging библиотеку, а для повторов с ожиданием собственные обёртки поверх Flow. Вся механика отмены достаётся бесплатно, и это главное практическое преимущество связки Retrofit с корутинами. Старый стиль с объектом Call остаётся для наследственного кода и библиотек, ценители реактивных потоков подключают адаптер под RxJava, но в новом коде на Kotlin приостановляемые функции закрыли обе потребности. Состояние экрана удобно выражать замкнутой иерархией, где каждый исход вызова это отдельный тип:

sealed interface CatalogState {
    data object Loading : CatalogState
    data class Ready(val items: List<Product>) : CatalogState
    data object Offline : CatalogState
}

Компилятор заставит покрыть все ветви, и забытый экран загрузки не проскочит на ревью.

OkHttp под капотом принимает перехватчики таймауты и кэш

За исполнением запросов стоит OkHttp, ветеран сетевой части Android, и клиент Retrofit принимает готовый объект OkHttpClient с любой настройкой. Именно здесь живут таймауты, кэш, логирование и подстановка заголовков, потому что перехватчики срабатывают на каждом запросе, проходящем через транспорт.

val httpClient = OkHttpClient.Builder()
    .addInterceptor { chain ->
        val request = chain.request().newBuilder()
            .header("Authorization", "Bearer ${tokenProvider.current()}")
            .build()
        chain.proceed(request)
    }
    .addInterceptor(
        HttpLoggingInterceptor().apply {
            level = HttpLoggingInterceptor.Level.BASIC
        }
    )
    .connectTimeout(10, TimeUnit.SECONDS)
    .readTimeout(20, TimeUnit.SECONDS)
    .cache(Cache(cacheDir, 20L * 1024 * 1024))
    .build()

Перехватчик авторизации читает токен при каждом запросе, поэтому обновление токена не требует пересборки клиента. Логирование уровня BASIC пишет только строки запросов и ответов, без тел, и его включают избирательно, чтобы не светить персональные сведения в журнале. Кэш на двадцать мегабайт смягчает повторные заходы в каталог и работает с заголовками сервера без всякого своего кода. Отдельная любопытная деталь: перехватчики могут коротить цепочку, возвращая собственный ответ, и на этом строятся офлайн-заглушки и отладочные режимы.

Для обновления протухшего токена у OkHttp есть отдельный механизм аутентификатор, который срабатывает на ответе 401 и повторяет запрос с новым значением:

val authenticator = object : Authenticator {
    override fun authenticate(
        route: Route?, response: Response,
    ): Request? {
        val fresh = tokenProvider.refresh() ?: return null
        return response.request.newBuilder()
            .header("Authorization", "Bearer $fresh")
            .build()
    }
}

Возврат нуля из аутентификатора останавливает череду повторов, поэтому цикл обновления не крутится вечно, а пара перехватчик плюс аутентификатор закрывает всю жизненную историю токена.

Обработка ошибок и разбор ответа без исключений

Обёртка Response из Retrofit несёт код статуса, заголовки и разобранное тело, позволяя обрабатывать нештатные ответы как обычные значения, а не ловить исключения из воздуха. Для приостановляемых функций интерфейс объявляется с возвратом Response, и вызывающий код сам решает, что делать с каждым исходом.

interface CatalogApi {
    @GET("v1/products")
    suspend fun listProductsResponse(
        @Query("page") page: Int,
    ): Response<List<Product>>
}

suspend fun loadPage(): Result<List<Product>> {
    val response = api.listProductsResponse(page = 0)
    return if (response.isSuccessful) {
        Result.success(response.body() ?: emptyList())
    } else {
        Result.failure(ApiException(response.code()))
    }
}

Общепринятая схема в Android-проектах выглядит так. Сетевые сбои, обрывы и таймауты оборачиваются в Result.failure и доходят до интерфейса как состояние офлайна. Ошибки протокола разбираются по кодам: 401 ведёт к обновлению токена через аутентификатор OkHttp, 4xx превращается в понятное сообщение, 5xx уходит в общий экран сбоя. Верхнеуровневая иерархия исключений IOException помогает отличить проблемы сети от проблем разбора, и это разделение стоит держать явным.

Тестирование API через MockWebServer без живой сети

Сетевой слой тестируется без сети вообще, и это одно из главных удовольствий связки. MockWebServer из состава OkHttp поднимает локальный сервер в тесте, выдаёт заготовленные ответы по очереди и позволяет проверить, что клиент действительно отправил ожидаемый запрос.

class CatalogApiTest {

    private val server = MockWebServer()
    private lateinit var api: CatalogApi

    @Before fun setUp() {
        server.start()
        api = Retrofit.Builder()
            .baseUrl(server.url("/"))
            .addConverterFactory(testConverterFactory())
            .build()
            .create(CatalogApi::class.java)
    }

    @Test fun `загружает страницу каталога`() = runTest {
        server.enqueue(
            MockResponse().setBody("[{\"id\": 1, \"title\": \"Кружка\"}]")
        )
        val items = api.listProducts(page = 0)
        assertEquals(1, items.size)
    }

    @After fun tearDown() {
        server.shutdown()
    }
}

Один тест проверяет и интерфейс, и конвертер, и адрес запроса: путь с параметрами виден в server.takeRequest. Заготовки выдаются по очереди, поэтому сценарий из сбоя и последующего успеха разыгрывается двумя строками. Сценарии ошибок ставятся той же заготовкой с кодом 500 и пустым телом, что позволяет без живого сервера отработать офлайн-состояния и повторные вызовы.

Практическая сборка сетевого слоя от модуля до релиза

В живом проекте сетевой слой собирают отдельным модулем: интерфейсы по доменам, фабрика клиента в одном месте, конвертер выбран единожды. Клиент переживает приложение, создаётся один раз и раздаётся через Dagger или Hilt, а токен приходит провайдером, который читает свежее значение при каждом запросе:

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

    @Provides @Singleton
    fun provideApi(client: OkHttpClient): CatalogApi =
        Retrofit.Builder()
            .baseUrl(BuildConfig.API_URL)
            .client(client)
            .addConverterFactory(jsonConverterFactory())
            .build()
            .create(CatalogApi::class.java)
}

Отладочная сборка получает подробное логирование, релизная молчит, и разница прячется за флагом сборки.

Практика выкатки подтверждает несколько правил. Базовый адрес меняется между окружениями через BuildConfig, а не через правку кода. Таймауты подбираются по реальной скорости сервера, дефолтные десять секунд на соединение обычно честны. Повторные вызовы на нестабильной сети делаются обёрткой, а не пользователем. И при любой смене формата ответа сначала обновляются тесты с заготовками, и только потом едет код приложения, потому что падение в тесте дешевле падения в стор-ревью.

Итог для Android-практика. Retrofit с OkHttp и корутинами это де-факто стандарт сетевой части: типизированный интерфейс вместо конструкторов запросов, конвертеры на этапе компиляции, отмена запросов бесплатно и тесты без сети. Альтернативы, от рукописных клиентов до обёрток над системными классами, проигрывают уже после первых двадцати методов, когда счёт копипасты идёт на сотни строк, а цена ошибки в адресе растёт с каждым релизом. Переезд с ветки 2.x на 3.0.0 проходит сменой одной строки в зависимостях, потому что библиотека сохранила совместимость, а сам подход не менялся с момента появления второй версии: описал контракт, получил исполнение.