Ручная работа с сетью на 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
}
Набор аннотаций покрывает весь протокол:
- @GET @POST @PUT @PATCH и @DELETE задают метод и путь запроса;
- @Path подставляет сегмент адреса из аргумента метода;
- @Query и @QueryMap добавляют параметры строки и собирают их из карты;
- @Body отдаёт объект конвертеру для сборки тела запроса;
- @Header и @Headers управляют заголовками, статическими и динамическими;
- @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 проходит сменой одной строки в зависимостях, потому что библиотека сохранила совместимость, а сам подход не менялся с момента появления второй версии: описал контракт, получил исполнение.