Перейти к основному содержимому
Мобилка30ГЛАВА 30Сеть: тот же экран, но с настоящими даннымиappGET200настоящие данные

Сеть: тот же экран, но с настоящими данными

Прочитано0%
Квизы0/5
Тренажёрыне пройден0/1
Уровень 1 · Новичок

Зачем это нужно​

В прошлой главе мы поставили репозиторий и подменили его реализацию — из фейковой в сетевую. Само подключение к серверу тогда осталось за кадром: 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 — сервер упал
ЧТО УХОДИТ НА СЕРВЕРPOST/api/basketContent-Type: application/jsonAuthorization: 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)
}
Нажми на часть выражения
ИНТЕРФЕЙС ВМЕСТО КОДА ЗАПРОСАпишем руками в каждом методеurl + путь + заголовки + телоразбор ответа, коды, ошибкизабыл заголовок в одном из двадцати —поймаешь на защитеописываем один раз@GET("products/{id}")suspend fun get(@Path id: Int)адрес и заголовки — общие для всехRetrofit сам собирает запрос по описанию и сам разбирает ответ в объектваше дело — правильно описать, а не правильно склеить строку
Описание запроса вместо его сборки: общая часть задаётся один раз, а не повторяется в каждом методе
Отвечено вопросов: 0 из 2
Почему ProductApi — это интерфейс, а не класс?
Что делает аннотация @Path("id")?
Отвечено верно: 0 из 2

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) — не перестраховка. Сервер может не прислать поле вовсе, и без значения по умолчанию разбор упадёт с ошибкой о пропущенном поле. Мы уже видели в прошлой главе, что бывает дальше: маппер превращает пустоту в нули и пустые строки, и остальной код работает с заполненными полями.

ПУТЬ ОТВЕТА ДО ЭКРАНАJSON{"product_name":…}DTOProductDtoDomainProductЭкранProductCardконвертермапперсостояниеграницы слоёв — это места, где данные меняют формудальше DTO не проходит: экран не должен знать, как поле называлось на сервере
Данные меняют форму на границах слоёв: JSON становится DTO, DTO — доменной моделью, та — состоянием экрана

Сборка клиента​

Три части: сам 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)» закрыта заменой одного класса.

Отвечено вопросов: 0 из 2
Зачем ignoreUnknownKeys = true?
Что изменится во ViewModel при переходе с фейкового репозитория на сетевой?
Отвечено верно: 0 из 2

Токен авторизации: один раз для всех запросов​

Почти каждый запрос требует заголовок с токеном. Писать @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, искать причину придётся среди двадцати мест вместо одного.

Отвечено вопросов: 0 из 2
Запрос падает с SecurityException, код выглядит правильным. Что проверить?
Сервер отвечает 404 на все запросы, хотя пути правильные. Что вероятнее всего?
Отвечено верно: 0 из 2

Что дальше​

Сейчас код написан так, будто сеть всегда работает. В жизни она не работает: интернет пропадает, сервер отвечает ошибкой, ответ приходит в неожиданном формате. Следующая глава — про то, что делать с каждым из этих случаев и почему их нельзя ловить одним catch.

Отвечено вопросов: 0 из 1
Почему токен добавляют перехватчиком, а не аннотацией @Header в каждом методе?
Отвечено верно: 0 из 1

Углубиться​

  • @Multipart и загрузка файлов: как отправить выбранное фото на сервер.
  • Пагинация: @Query("page"), @Query("limit") и как понять, что страницы кончились.
  • MockWebServer — поднять фальшивый сервер в тесте и проверить, что ушло и что разобралось.
  • Кэш OkHttp: заголовки Cache-Control и когда ответ можно не запрашивать заново.
  • CallAdapter — свой тип возвращаемого значения вместо голого объекта, например Result.

Сводная проверка по всей главе: 7 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.

Куда дальше: проверенные ресурсы​

Видео по теме

ВидеоThe Ultimate Retrofit Crash CoursePhilipp Lackner
ВидеоCoroutines with Retrofit - Kotlin CoroutinesPhilipp Lackner
ВидеоThe Only Retrofit Tutorial You'll Ever Need! (with Jetpack Compose)Android Knowledge
ВидеоRetrofit Setup - MVVM Pokédex App with Jetpack ComposePhilipp Lackner
ВидеоRetrofit Android Tutorial - Make API CallsAhmed Guedmioui

Комментарии

Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.