Перейти к основному содержимому
Мобилка36ГЛАВА 36Каталог: конверт, страницы, поискpage 1 · perPage 5totalItems 12 · totalPages 3itemsконверт, не список

Каталог магазина: конверт, страницы, поиск

Прочитано0%
Квизы0/5
Уровень 1 · Новичок

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

До сих пор сеть в главах была учебной: мы разбирали, как устроен HTTP, как Retrofit превращает интерфейс в запросы и что делать с ошибками. Сервер при этом был условным.

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

Важная деталь, которая в тексте задания не написана, но звучит от экспертов: сервер сделан на PocketBase, а клиентские библиотеки pocketbase и supabase использовать нельзя. Запросы пишутся руками — тем самым Retrofit, который мы уже умеем. Это значит, что повадки PocketBase придётся знать: он отвечает не так, как «учебный REST из статьи», и половина потерянных на площадке баллов — это непонимание его ответов.

Стенд, на котором мы работаем​

Настоящий адрес из задания — api.matule.ru — сейчас не отвечает: его поднимают на время чемпионата. Поэтому для тренировки развёрнут точно такой же сервер:

https://style-matule.apis.alspio.com/api

Это PocketBase 0.40.4 с коллекциями по спецификации задания: products, news, cart, orders, project и users. Все примеры в этой и следующих главах сняты именно с него — включая коды ошибок и тексты сообщений.

Проверить, что он жив, можно прямо из терминала, ещё не написав ни строчки Kotlin:

curl -s "https://style-matule.apis.alspio.com/api/collections/products/records?perPage=2"
Совет

Начинать работу с сетевым слоем всегда стоит с curl. Если запрос не работает из терминала, искать ошибку в Kotlin бессмысленно — и наоборот: когда curl отдаёт нужное тело, любая последующая проблема точно в клиенте.

Адрес коллекции строится по одному правилу: /api/collections/{имя}/records. Имя — то, как коллекция названа в базе. Никаких /products, /api/v1/goods и прочего: PocketBase раздаёт все коллекции одинаково.

Конверт вместо списка​

Первое, обо что спотыкаются почти все: запрос каталога возвращает не массив товаров. Вот настоящий ответ на запрос выше, целиком:

{
"items": [
{
"approximateCost": "10 500 ₽",
"collectionId": "pbc_4092854851",
"collectionName": "products",
"description": "Лёгкие кроссовки с большой воздушной подушкой",
"id": "8ezt4dmrek7plpz",
"price": 10500,
"title": "Nike Air Max 270",
"type": "мужская",
"typeCloses": "кроссовки"
},
{
"approximateCost": "9 800 ₽",
"collectionId": "pbc_4092854851",
"collectionName": "products",
"description": "Классика, которая не выходит из моды",
"id": "auzbdtixz85k04y",
"price": 9800,
"title": "Nike Air Force 1",
"type": "унисекс",
"typeCloses": "кроссовки"
}
],
"page": 1,
"perPage": 2,
"totalItems": 12,
"totalPages": 6
}

Снаружи объект с пятью ключами. Четыре из них — числа про страницы, и только items содержит товары.

ОТВЕТ ПРИХОДИТ НЕ СПИСКОМ, А КОНВЕРТОМ{"page": 1,"perPage": 2,"totalItems": 12,"totalPages": 6,"items": [{ "id": "8ezt4dm…", "title": "Nike Air Max 270" },…]четыре числа — для пагинацииtotalPages говорит, когда останавливатьсяtotalItems — сколько всего, а не сколько пришлоitems — то, что рисует экранраспарсить сразу в List<Product> нельзя:снаружи объект, а не массивPageResponse<ProductDto>
Ответ PocketBase: четыре числа снаружи, товары внутри

Разберём числа по одному, потому что их путают:

  • page — какая страница пришла. Та, что запросили.
  • perPage — сколько записей на странице. Тоже та, что запросили.
  • totalItems — сколько записей всего в коллекции после фильтра. Не сколько пришло! В примере пришло 2, а totalItems равен 12.
  • totalPages — на сколько страниц это бьётся при текущем perPage. 12 товаров по 2 — шесть страниц.
Важно

totalItems — это счётчик всей выборки, а не длина items. Если написать «показано totalItems товаров» под списком из двух карточек, экран соврёт. Длина списка — это items.size.

Проверим разбор этого тела на настоящем компиляторе. Тело ниже — сокращённая копия ответа выше, а классы — те самые, что поедут в проект:

import kotlinx.serialization.* import kotlinx.serialization.json.* @Serializable data class ProductDto( val id: String, val title: String, val description: String = "", val price: Int = 0, val typeCloses: String = "", ) @Serializable data class PageResponse<T>( val page: Int, val perPage: Int, val totalPages: Int, val totalItems: Int, val items: List<T>, ) val body = """ {"items":[{"approximateCost":"10 500 ₽","collectionId":"pbc_4092854851","collectionName":"products", "description":"Лёгкие кроссовки с большой воздушной подушкой","id":"8ezt4dmrek7plpz","price":10500, "title":"Nike Air Max 270","type":"мужская","typeCloses":"кроссовки"}], "page":1,"perPage":1,"totalItems":12,"totalPages":12} """ fun main() { val json = Json { ignoreUnknownKeys = true } val page = json.decodeFromString<PageResponse<ProductDto>>(body) println("страница ${page.page} из ${page.totalPages}, всего ${page.totalItems}") page.items.forEach { println(" ${it.title} — ${it.price} ₽ (${it.typeCloses})") } }

Вывод настоящего прогона:

страница 1 из 12, всего 12
Nike Air Max 270 — 10500 ₽ (кроссовки)
Отвечено вопросов: 0 из 2
Каталог вернул тело, где items содержит 3 записи, а totalItems равен 12. Сколько товаров в коллекции?
Почему ответ каталога нельзя разобрать сразу в List<ProductDto>?
Отвечено верно: 0 из 2

Что подключаем​

Ничего нового относительно главы про Retrofit — тот же набор, никаких библиотек PocketBase:

dependencies {
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
}

Плюс плагин сериализации в том же файле, без него аннотация @Serializable не породит код разбора:

plugins {
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21"
}

И разрешение в манифесте — про него забывают каждый раз:

<uses-permission android:name="android.permission.INTERNET" />
Интересный факт

Версия плагина сериализации должна совпадать с версией Kotlin в проекте. При расхождении сборка падает с сообщением о несовместимом компиляторном плагине, и найти причину по тексту ошибки тяжело — проще держать обе версии рядом в одном месте.

Описываем товар​

Теперь класс под одну запись. Смотрим на настоящее тело и переносим поля:

@Serializable
data class ProductDto(
val id: String,
val title: String,
val description: String = "",
val price: Int = 0,
val typeCloses: String = "",
val type: String = "",
val approximateCost: String = "",
)
Нажми на часть выражения

Имена свойств совпадают с именами полей сервера — typeCloses и approximateCost написаны именно так, «верблюдом» и по-английски, как их назвали авторы задания. Если хочется в коде свои имена, это делается аннотацией @SerialName("typeCloses"), а не переименованием свойства.

Важно

Решение, какие поля сделать обязательными, — не формальность. Обязательное поле, которого сервер вдруг не прислал, роняет весь список, а не одну карточку: исключение вылетает из разбора всего тела. Безопасная стратегия для конкурсного дня — обязательными оставить id и то, без чего карточка бессмысленна, остальным дать значения по умолчанию.

Один конверт на все коллекции​

Конверт у PocketBase одинаковый для любой коллекции: и у товаров, и у акций, и у заказов снаружи те же пять ключей. Значит, описывать его отдельно под каждый тип не надо — достаточно одного обобщённого класса:

@Serializable
data class PageResponse<T>(
val page: Int,
val perPage: Int,
val totalPages: Int,
val totalItems: Int,
val items: List<T>,
)
Нажми на часть выражения

Это та самая обобщённость (generics), которую разбирали в главах про Kotlin. Здесь видно, зачем она нужна на практике: без неё пришлось бы писать ProductPage, NewsPage, OrderPage — пять одинаковых классов, отличающихся одной строкой.

Отвечено вопросов: 0 из 2
Зачем PageResponse сделан обобщённым?
Что произойдёт, если у поля price убрать значение по умолчанию, а сервер его не пришлёт?
Отвечено верно: 0 из 2

Интерфейс запросов​

Дальше описываем, какие запросы умеет наш клиент. Пока два: список товаров и один товар по идентификатору.

interface MatuleApi {

@GET("collections/products/records")
suspend fun catalog(
@Query("page") page: Int = 1,
@Query("perPage") perPage: Int = 30,
@Query("filter") filter: String? = null,
): PageResponse<ProductDto>

@GET("collections/products/records/{id}")
suspend fun product(@Path("id") id: String): ProductDto
}
Нажми на часть выражения
ОДИН ВЫЗОВ — ЧЕТЫРЕ ПРЕВРАЩЕНИЯapi.catalog(1, 30)вызов методаRetrofitсобирает URLOkHttpшлёт по сетиkotlinxразбирает JSONGET /api/collections/products/records?page=1&perPage=30весь этот путь описан аннотациями — руками не собирается ни строка адреса, ни разбор телаошибка в имени поля DTO компилятор не поймает: она всплывёт при первом запуске
Путь одного вызова: от метода интерфейса до разобранного объекта

Обратите внимание на асимметрию: список приходит в конверте, одна запись — без конверта. Это не наша прихоть, так устроен PocketBase, и забыть об этом — верный способ получить ошибку разбора на карточке товара.

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

Интерфейс — это только описание. Реализацию делает Retrofit:

private const val BASE = "https://style-matule.apis.alspio.com/api/"

val json = Json { ignoreUnknownKeys = true }

val api: MatuleApi = Retrofit.Builder()
.baseUrl(BASE)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.create(MatuleApi::class.java)
Нажми на часть выражения
Совет

Объект Json создаётся один раз и переиспользуется. Если писать Json { … } внутри каждой функции, компилятор Kotlin выдаст предупреждение «Redundant creation of Json format. Creating instances for each usage can be slow» — и будет прав: настройка формата стоит дороже, чем кажется.

Первый прогон​

Собранный клиент, вызванный против живого стенда, печатает вот что:

каталог: всего 12, страниц 4, на странице 3
Nike Air Max 270 — 10500 ₽ (кроссовки, мужская)
Nike Air Force 1 — 9800 ₽ (кроссовки, унисекс)
Adidas Superstar — 8700 ₽ (кроссовки, унисекс)
товар по id: Nike Air Max 270, 10 500 ₽

Смотреть здесь надо на строку «всего 12, страниц 4»: perPage был равен трём, поэтому двенадцать товаров разложились на четыре страницы. Число страниц не постоянное свойство коллекции — оно зависит от того, о каком размере страницы мы попросили.

Интересный факт

Поле approximateCost приходит уже отформатированным: "10 500 ₽" — строка с неразрывным пробелом и знаком рубля. А price — число 10500. Для сортировки и подсчёта суммы корзины нужен price; approximateCost годится только на показ.

Арифметика страниц​

Пагинация — место, где ошибаются молча. Сервер не сообщает «страницы кончились» отдельным признаком: он просто отдаёт меньше записей или вовсе пустой список.

12 ТОВАРОВ ПРИ perPage = 5page=1items: 5page=2items: 5page=3items: 2page=9items: 0page=9 → 200 OK · items: [] · totalItems: 12 · totalPages: 3страница за пределом — не ошибка: код 200 и пустой список, проверять нужно totalPages
Двенадцать товаров при perPage = 5: три полные страницы и запрос за пределом

Вот как это выглядит на настоящих числах. Запрос ?page=9&perPage=5 при двенадцати товарах возвращает:

{
"items": [],
"page": 9,
"perPage": 5,
"totalItems": 12,
"totalPages": 3
}

Код ответа при этом — 200 OK. Не 404, не ошибка. Пустой список за пределом диапазона — нормальный ответ, и отличить его от «ничего не нашлось» можно только по числам.

Ту же арифметику легко проверить локально, не поднимая сервер:

data class Page<T>( val page: Int, val perPage: Int, val totalItems: Int, val totalPages: Int, val items: List<T>, ) val catalog = listOf( "Nike Air Max 270", "Nike Air Force 1", "Adidas Superstar", "Adidas Ultraboost 22", "Puma Suede Classic", "New Balance 574", "Reebok Club C 85", "Converse Chuck 70", "Vans Old Skool", "Asics Gel-Lyte III", "Salomon XT-6", "Timberland 6-Inch", ) fun slice(page: Int, perPage: Int): Page<String> { val from = (page - 1) * perPage val items = catalog.drop(maxOf(from, 0)).take(perPage) val totalPages = (catalog.size + perPage - 1) / perPage return Page(page, perPage, catalog.size, totalPages, items) } fun main() { for (n in listOf(1, 2, 3, 9)) { val p = slice(n, 5) println("page=${p.page} perPage=${p.perPage} totalItems=${p.totalItems} totalPages=${p.totalPages}") println(" items: ${p.items.size} ${p.items}") } println() println("последняя страница? " + (slice(3, 5).let { it.page >= it.totalPages })) println("последняя страница? " + (slice(1, 5).let { it.page >= it.totalPages })) }

Вывод совпадает с тем, что отдаёт сервер:

page=1 perPage=5 totalItems=12 totalPages=3
items: 5 [Nike Air Max 270, Nike Air Force 1, Adidas Superstar, Adidas Ultraboost 22, Puma Suede Classic]
page=2 perPage=5 totalItems=12 totalPages=3
items: 5 [New Balance 574, Reebok Club C 85, Converse Chuck 70, Vans Old Skool, Asics Gel-Lyte III]
page=3 perPage=5 totalItems=12 totalPages=3
items: 2 [Salomon XT-6, Timberland 6-Inch]
page=9 perPage=5 totalItems=12 totalPages=3
items: 0 []

последняя страница? true
последняя страница? false

Отсюда единственное правило подгрузки, которое стоит запомнить:

val hasMorePages = page < totalPages

Не items.isNotEmpty(), не «пока приходит ровно perPage записей». Только сравнение с totalPages — оно верно и на последней неполной странице, и за пределом диапазона.

Отвечено вопросов: 0 из 3
Запрос ?page=9&perPage=5 при 12 товарах. Какой код ответа?
Как правильно понять, что страницы кончились?
Сколько страниц у 12 товаров при perPage = 3?
Отвечено верно: 0 из 3

Поиск​

Поиск в PocketBase — это не отдельный эндпоинт, а параметр filter у того же запроса каталога. Синтаксис свой:

?filter=(title ?~ 'Nike')
Нажми на часть выражения

Запрос ?filter=(title ?~ 'Nike') на стенде возвращает:

{
"items": [
{ "id": "8ezt4dmrek7plpz", "title": "Nike Air Max 270", "price": 10500, "…": "…" },
{ "id": "auzbdtixz85k04y", "title": "Nike Air Force 1", "price": 9800, "…": "…" }
],
"page": 1,
"perPage": 30,
"totalItems": 2,
"totalPages": 1
}

Главное про пустой результат:

Важно

Когда ничего не найдено, сервер отвечает 200 с totalItems: 0, а не 404. Значит, обрабатывать «ничего не найдено» надо не в ветке ошибки, а в ветке успеха: проверкой items.isEmpty() после удачного запроса. Экран «Ничего не найдено» и экран «Ошибка сети» — это разные экраны, и путать их нельзя.

В коде поиск получается коротким — параметр уже описан в интерфейсе:

val found = api.catalog(filter = "(title ?~ '$query')")

Живой прогон:

поиск Nike: 2 → [Nike Air Max 270, Nike Air Force 1]

Рядом полезны ещё два параметра того же запроса. Фильтр по точному совпадению — ?filter=(typeCloses='кеды') — на стенде отдаёт четыре записи. Сортировка — ?sort=-price, где минус означает «по убыванию»: первым приходит Salomon XT-6 за 18 400.

Интересный факт

Сортировать можно только по существующим полям. Запрос ?sort=created на этом стенде даёт 400 — системного поля created у коллекции нет, хотя в документации PocketBase оно упоминается. Проверять доступные поля надо по настоящему ответу, а не по документации.

Типичные ошибки​

Все четыре сообщения ниже сняты с настоящего прогона: Kotlin 2.0.21, Retrofit 2.11.0, kotlinx.serialization 1.7.3.

1. Конверт разбирают как список. Самая частая: в интерфейсе написано : List<ProductDto> вместо : PageResponse<ProductDto>.

kotlinx.serialization.json.internal.JsonDecodingException
Unexpected JSON token at offset 0: Expected start of the array '[', but had '{' instead at path: $
JSON input: {"items":[{"approximateCost":".....

Сообщение прямое: ждали [, получили {. «Offset 0» означает, что ошибка на самом первом символе тела, — то есть дело не в отдельной записи, а в форме всего ответа.

2. Опечатка в имени поля DTO. Написали titl вместо title:

kotlinx.serialization.MissingFieldException
Field 'titl' is required for type with serial name 'probe.Broken', but it was missing at path: $.items[0]

Обратите внимание: компилятор такую ошибку не поймает — с точки зрения Kotlin класс безупречен. Она всплывает только при первом запуске, и путь $.items[0] показывает, на какой именно записи.

3. Базовый адрес без завершающего слэша.

java.lang.IllegalArgumentException
baseUrl must end in /: https://style-matule.apis.alspio.com/api

Эта ошибка вылетает при сборке клиента, до всякой сети. Хорошая новость: заметно сразу, а не на площадке.

4. Хост не отвечает.

java.net.UnknownHostException
api.matule.ru: nodename nor servname provided, or not known

Так выглядит опечатка в адресе и так же выглядит отсутствие интернета у устройства. Разбирать эти случаи будем в отдельной главе про отказы — пока важно знать, что это исключение, а не код ответа: у него нет ни 404, ни 500, потому что до сервера дело не дошло.

Важно

К этим четырём добавляется пятая, у которой вообще нет текста: приложение падает с SecurityException, если в манифесте нет <uses-permission android:name="android.permission.INTERNET" />. Код при этом выглядит правильным, и искать в нём нечего.

Отвечено вопросов: 0 из 3
Разбор упал с «Expected start of the array '[', but had '{' instead». Где ошибка?
MissingFieldException с путём $.items[0]. Что это значит?
Когда вылетает «baseUrl must end in /»?
Отвечено верно: 0 из 3

Что засчитывает эксперт​

По критериям регионального задания в этой главе закрыты два запроса из четырнадцати: получение списка товаров и получение товара по идентификатору. Эксперт смотрит на результат, а не на код, поэтому проверяется обычно так:

  • список открывается и показывает данные сервера, а не зашитые;
  • карточка товара открывается по нажатию и показывает того самого, на кого нажали;
  • поиск что-то находит и при пустом результате не выглядит как ошибка;
  • при выключенной сети приложение не падает.

Последний пункт формально относится к другому критерию, но проверяется на этом же экране — самолётным режимом. Поэтому даже в первой сетевой главе имеет смысл сразу обернуть вызов в try, а полноценную обработку отказов сделать позже.

Совет

На площадке размер страницы удобно ставить большим — perPage=100. Двенадцать товаров придут одной страницей, бесконечную подгрузку делать не придётся, а критерий «список показывает данные сервера» будет закрыт. Пагинацию добавляйте, когда закрыты все запросы: она не отдельный критерий, а часть хорошего списка.

Куда углубиться​

  • ?fields=title,price — попросить только нужные поля. Ответ становится заметно короче, и DTO может быть меньше.
  • ?expand= — подтянуть связанные записи одним запросом вместо двух.
  • Сложные фильтры: (price > 8000 && typeCloses = 'кеды'), экранирование кавычек и кодирование кириллицы в URL.
  • Paging 3 — библиотека Android под бесконечные списки: сама просит следующую страницу и хранит состояние.
  • MockWebServer — поднять фальшивый сервер в тесте и проверить, что клиент собрал правильный адрес.

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

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

Видео по теме

ВидеоКурс по Retrofit в Android Studio + Kotlin | Урок 1Neco Ru
ВидеоThe Ultimate Retrofit Crash CoursePhilipp Lackner
ВидеоPocketBase Tutorial #1 - Intro to PocketBaseJames Yap Codes
ВидеоJetpack Paging 3. Пагинация на AndroidAndroid Broadcast. Все об Андроид разработке
ВидеоHow To Generate Types And Use API Rules In PocketbaseProgramonaut

Комментарии

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