Сеть: тот же экран, но с настоящими данными
- Зачем это нужно
- HTTP за пять минут
- Смотрим API до всякого кода
- Интерфейс вместо кода запроса
- JSON превращается в объект
- Сборка клиента
- Репозиторий меняет внутренности
- Токен авторизации: один раз для всех запросов
- Сетевой слой отдельной библиотекой
- Типичные ошибки
- Слова главы
- Тренажёр: набрать вслепую
- На чемпионате
- Что дальше
- Углубиться
- Куда дальше: проверенные ресурсы
Зачем это нужно
В прошлой главе мы поставили репозиторий и подменили его реализацию — из фейковой в сетевую. Само подключение к серверу тогда осталось за кадром: NetworkRepository печатал «сходил в сеть» и возвращал готовый список.
Теперь напишем эту реализацию по-настоящему. Задача сформулирована ровно так же, как в конкурсном спринте: экран уже готов и работает на зашитых данных, нужно, чтобы он показывал то, что отдаёт сервер. Ни ViewModel, ни разметка при этом не меняются — если предыдущая глава сделана правильно.
Сеть в заданиях весит много: модуль клиент-серверного взаимодействия вырос до четверти всех баллов, и он же требует, чтобы сетевой слой был отдельной библиотекой, подключаемой через абстракцию. К этому мы придём в конце главы.
HTTP за пять минут
Прежде чем брать библиотеку, полезно понимать, что она делает. Запрос к серверу — это текст определённого формата:
data class Request(val method: String, val path: String, val headers: Map<String, String>, val body: String?)
data class Response(val code: Int, val body: String)
fun describe(r: Request) {
println(" ${r.method} ${r.path}")
r.headers.forEach { (k, v) -> println(" $k: $v") }
if (r.body != null) println("\n ${r.body}")
}
fun main() {
println("── запрос списка товаров ──")
describe(Request("GET", "/api/products?page=1", mapOf("Accept" to "application/json"), null))
println()
println("── добавление в корзину ──")
describe(Request("POST", "/api/basket", mapOf(
"Content-Type" to "application/json",
"Authorization" to "Bearer <токен>",
), """{"productId":7,"count":1}"""))
println()
listOf(Response(200, "ок"), Response(401, "токен протух"), Response(404, "нет такого"), Response(500, "сервер упал"))
.forEach { println(" ${it.code} — ${it.body}") }
}
── запрос списка товаров ──
GET /api/products?page=1
Accept: application/json
── добавление в корзину ──
POST /api/basket
Content-Type: application/json
Authorization: Bearer <токен>
{"productId":7,"count":1}
200 — ок
401 — токен протух
404 — нет такого
500 — сервер упал
Четыре метода покрывают почти всё: GET — получить, POST — создать, PUT — заменить, DELETE — удалить. Коды ответов группируются по первой цифре: 2xx — получилось, 4xx — ошибка на стороне клиента (не туда постучались, не тот токен), 5xx — сломался сервер.
Различать 4xx и 5xx важно практически: на 4xx есть смысл что-то исправить в приложении — обновить токен, поправить параметры. На 5xx исправлять нечего, остаётся показать пользователю ошибку и предложить повторить. Это пригодится в следующей главе, целиком посвящённой ошибкам.
Смотрим API до всякого кода
Прежде чем писать, надо прочитать документацию сервера. В конкурсных заданиях список эндпоинтов выдают заранее, и выглядит он примерно так:
GET /products список товаров
GET /products/{id} один товар
GET /categories категории
GET /basket корзина текущего пользователя
POST /basket добавить товар
DELETE /basket/{id} убрать товар
POST /auth/login вход, возвращает токен
Полезная привычка: прежде чем описывать интерфейс, дёрнуть пару эндпоинтов вручную — через браузер или Postman — и посмотреть настоящий ответ. Так вы увидите реальные имена полей, а не те, что кажутся логичными. Половина ошибок в разборе JSON происходит из-за того, что поле называется product_name, а в коде написали productName.
Интерфейс вместо кода запроса
Запрос можно собирать руками, но так никто не делает. Сравним подходы:
fun manual(baseUrl: String, id: Int, token: String): String {
val sb = StringBuilder()
sb.append("GET ").append(baseUrl).append("/products/").append(id)
sb.append("\nAuthorization: Bearer ").append(token)
sb.append("\nAccept: application/json")
return sb.toString()
}
fun declared(baseUrl: String, id: Int, token: String): String {
val template = "/products/{id}"
val path = template.replace("{id}", id.toString())
return "GET $baseUrl$path\nAuthorization: Bearer $token\nAccept: application/json"
}
fun main() {
println("── руками, в каждом методе ──")
println(manual("https://api.shop.ru", 7, "abc123"))
println(" ↑ забыл заголовок в одном из двадцати методов — поймаешь на защите")
println()
println("── описанием: @GET(\"products/{id}\") ──")
println(declared("https://api.shop.ru", 7, "abc123"))
println(" ↑ заголовки и базовый адрес заданы один раз для всех методов")
}
Результат одинаковый, но во втором случае повторяющаяся часть вынесена. Именно это делает Retrofit: вы описываете, что за запрос, а склеиванием строк и разбором ответа занимается он.
interface ProductApi {
@GET("products")
suspend fun getProducts(@Query("page") page: Int = 1): List<ProductDto>
@GET("products/{id}")
suspend fun getProduct(@Path("id") id: Int): ProductDto
@POST("basket")
suspend fun addToBasket(@Body request: AddToBasketDto)
@DELETE("basket/{id}")
suspend fun removeFromBasket(@Path("id") id: Int)
}
JSON превращается в объект
Сервер отвечает текстом в формате JSON. Превращает его в объекты конвертер, а классы для этого — наши DTO из прошлой главы:
@Serializable
data class ProductDto(
@SerialName("product_id") val id: Int? = null,
@SerialName("product_name") val name: String? = null,
@SerialName("price_kopecks") val priceKopecks: Long? = null,
)
@SerialName решает главную бытовую проблему: на сервере поля называются product_name в змеином регистре, а в Kotlin принято name. Аннотация связывает одно с другим, и уродливые имена не расползаются по коду.
Значения по умолчанию (= null) — не перестраховка. Сервер может не прислать поле вовсе, и без значения по умолчанию разбор упадёт с ошибкой о пропущенном поле. Мы уже видели в прошлой главе, что бывает дальше: маппер превращает пустоту в нули и пустые строки, и остальной код работает с заполненными полями.
Сборка клиента
Три части: сам HTTP-клиент, конвертер и Retrofit поверх них.
private val json = Json {
ignoreUnknownKeys = true
coerceInputValues = true
}
private val client = OkHttpClient.Builder()
.addInterceptor(HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY })
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.build()
val api: ProductApi = Retrofit.Builder()
.baseUrl("https://api.shop.ru/")
.client(client)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.create(ProductApi::class.java)
Три настройки, которые экономят часы отладки.
ignoreUnknownKeys = true — не падать, если сервер прислал поля, которых нет в вашем DTO. Без этого добавление нового поля на сервере ломает приложение, хотя вы этим полем не пользуетесь.
HttpLoggingInterceptor — печатает в Logcat весь запрос и весь ответ. Первое, что нужно включить, и первое, куда смотреть, когда «не работает».
Слэш в конце baseUrl — обязателен. Без него склейка адреса ломается непредсказуемо, и это одна из самых частых ошибок при первом запуске.
Не забудьте разрешение в манифесте: <uses-permission android:name="android.permission.INTERNET" />. Без него запрос падает с SecurityException, а причина не очевидна — код выглядит правильным. И второе: с Android 9 запрещён незашифрованный трафик, поэтому адрес на http:// не заработает без отдельной настройки. Если тестовый сервер отдаёт http, нужен usesCleartextTraffic или конфигурация сетевой безопасности.
Репозиторий меняет внутренности
Собираем всё вместе. Интерфейс из прошлой главы не меняется ни на строку:
class ProductRepositoryImpl(
private val api: ProductApi,
) : ProductRepository {
override suspend fun getProducts(): List<Product> =
withContext(Dispatchers.IO) {
api.getProducts().map { it.toDomain() }
}
}
Три строки, и в них вся глава: сходили в сеть, получили список DTO, превратили в доменные модели.
withContext(Dispatchers.IO) переключает выполнение на поток для ввода-вывода. Строго говоря, Retrofit и сам выполняет запрос не в главном потоке, так что для одного вызова это необязательно. Но привычка полезная: как только рядом появится разбор большого ответа или работа с базой, переключение окажется нужным.
Теперь тот самый момент, ради которого строились слои: ViewModel не изменилась, экран не изменился, задача «Экран Главная (Network)» закрыта заменой одного класса.
Токен авторизации: один раз для всех запросов
Почти каждый запрос требует заголовок с токеном. Писать @Header("Authorization") в каждом методе — тот самый случай «забыл в одном из двадцати». Правильное место — перехватчик:
class AuthInterceptor(private val tokenProvider: () -> String?) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val token = tokenProvider()
val request = if (token == null) chain.request()
else chain.request().newBuilder()
.addHeader("Authorization", "Bearer $token")
.build()
return chain.proceed(request)
}
}
Перехватчик видит каждый запрос перед отправкой и может его дополнить. Токен при этом приходит функцией, а не значением: он меняется при входе и выходе, и клиент должен брать актуальный, а не тот, что был при сборке.
Сетевой слой отдельной библиотекой
В конкурсных заданиях 2026 года сетевой слой требуется вынести в отдельный проект — с собственным репозиторием и версионированием, как UI Kit. И отдельным критерием проверяется, что приложение зависит от абстракции, а не от конкретной реализации: «код зависит от IHttpClient, а не от OkHttp».
На практике это значит вот что: ProductApi и DTO живут в отдельном модуле, наружу торчит интерфейс, а Retrofit спрятан внутри. Приложение подключает модуль и знает только интерфейс.
Звучит как бюрократия, но проверить это легко: попробуйте мысленно заменить Retrofit на что-то другое. Если для этого нужно править экраны — зависимость протекла.
Типичные ошибки
Нет разрешения INTERNET. SecurityException при первом запросе. Код правильный, а причина в манифесте.
Нет слэша в конце baseUrl. Адрес склеивается не так, как ожидается, и сервер отвечает 404.
Ведущий слэш в пути аннотации. @GET("/products") при baseUrl с путём затирает базовый путь целиком.
Имена в @Path не совпали. @GET("products/{id}") с параметром @Path("productId") — падение при вызове.
Разбор упал на пропущенном поле. Сервер не прислал поле, а в DTO нет значения по умолчанию.
Адрес на http://. С Android 9 незашифрованный трафик запрещён по умолчанию — запрос не уходит вовсе.
DTO уехал в UiState. Слои формально есть, но экран получил модель сервера со всеми её null.
Слова главы
- HTTP-запрос — метод, путь, заголовки и необязательное тело.
- Код ответа —
2xxполучилось,4xxошибка клиента,5xxошибка сервера. - Retrofit — библиотека, собирающая запрос по описанию интерфейса.
@GET/@POST/@Path/@Query/@Body— описание метода, пути, параметров и тела.- Конвертер — превращает JSON в объекты и обратно.
@SerialName— связывает имя поля на сервере с именем в коде.OkHttpClient— HTTP-клиент, который выполняет запрос.- Перехватчик — код, дополняющий каждый запрос: токен, логирование.
baseUrl— общий адрес; слэш в конце обязателен.
Тренажёр: набрать вслепую
Цель: скорость ≥ 100 зн/мин, точность ≥ 88%
@GET("products/{id}") suspend fun getProduct(@Path("id") id: Int): ProductDto
На чемпионате
Клиент-серверное взаимодействие — второй по весу модуль после вёрстки, и в 2026 году он вырос почти вдвое. Что проверяют:
- Каждый эндпоинт из списка реализован. Задания перечисляют их поимённо: список товаров, категории, корзина, профиль, заказы, уведомления, вход и регистрация.
- Сетевой слой — отдельная библиотека, подключаемая через интерфейс.
- Приложение не падает без сети. Об этом целиком следующая глава.
- Тесты сетевого слоя. В заданиях есть требование покрыть тестами каждый запрос, и проверяется это их запуском.
Практический совет по порядку работы: первым делом соберите клиент и дёрните один самый простой эндпоинт, убедившись, что ответ приходит и разбирается. Только потом описывайте остальные двадцать. Если сразу написать весь интерфейс, а потом получить 404, искать причину придётся среди двадцати мест вместо одного.
Что дальше
Сейчас код написан так, будто сеть всегда работает. В жизни она не работает: интернет пропадает, сервер отвечает ошибкой, ответ приходит в неожиданном формате. Следующая глава — про то, что делать с каждым из этих случаев и почему их нельзя ловить одним catch.
Углубиться
@Multipartи загрузка файлов: как отправить выбранное фото на сервер.- Пагинация:
@Query("page"),@Query("limit")и как понять, что страницы кончились. MockWebServer— поднять фальшивый сервер в тесте и проверить, что ушло и что разобралось.- Кэш OkHttp: заголовки
Cache-Controlи когда ответ можно не запрашивать заново. CallAdapter— свой тип возвращаемого значения вместо голого объекта, напримерResult.
Сводная проверка по всей главе: 7 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
- Retrofit — официальная документация — аннотации, конвертеры и поведение методов.
- OkHttp Interceptors — как устроены перехватчики и чем они отличаются друг от друга.
- kotlinx.serialization — руководство —
@SerialName, значения по умолчанию, настройки разбора. - Сетевые запросы в Android — разрешения, безопасность трафика и работа с состоянием сети.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.