Картинки и загрузка файлов
Зачем это нужно
Магазин без картинок не бывает. В спецификации задания есть два места, где живут файлы: акции (news с полем newsImage) и проекты (project с полем image). Первое — только читать, второе — ещё и загружать, причём загрузка описана как multipart/form-data, то есть не обычным JSON.
Загрузка файла — отдельный навык. Она не похожа на остальные запросы ни телом, ни аннотациями, и обычный @Body здесь не работает. А про то, как показать картинку, у PocketBase своя особенность: в записи лежит имя файла, а не ссылка, и адрес нужно собрать самому.
Что приходит в записи
Запросим акции — это тот самый запрос «Акции и новости» из спецификации:
GET /api/collections/news/records
{
"items": [
{
"collectionId": "pbc_987692768",
"collectionName": "news",
"id": "yzzw0qwd6b6bo0c",
"newsImage": "banner_y2zhtudxtd.png",
"title": "Скидка 20% на всё лето"
}
],
"page": 1,
"perPage": 30,
"totalItems": 3,
"totalPages": 1
}
В поле newsImage — не адрес, а имя файла. Подставить его в загрузчик картинок напрямую нельзя: получится запрос к banner_y2zhtudxtd.png относительно текущего адреса, и картинка не найдётся.
Правило сборки одно:
/api/files/{collectionId}/{id записи}/{имя файла}
Все три части лежат в самой записи — специально для этого PocketBase и присылает служебное поле collectionId, которое в прошлых главах мы игнорировали.
data class NewsDto(
val id: String,
val collectionId: String,
val title: String,
val newsImage: String = "",
)
const val BASE = "https://style-matule.apis.alspio.com/api"
/** Собирает адрес файла или null, если файла нет. */
fun fileUrl(collectionId: String, recordId: String, name: String, thumb: String? = null): String? {
if (name.isBlank()) return null
val suffix = if (thumb == null) "" else "?thumb=$thumb"
return "$BASE/files/$collectionId/$recordId/$name$suffix"
}
fun main() {
// то, что реально пришло со стенда
val news = listOf(
NewsDto("yzzw0qwd6b6bo0c", "pbc_987692768", "Скидка 20% на всё лето", "banner_y2zhtudxtd.png"),
NewsDto("33b2r4vv4rbabdr", "pbc_987692768", "Новая коллекция уже в продаже", "banner_o8i7lwqves.png"),
NewsDto("bez0kartinki0000", "pbc_987692768", "Акция без баннера", ""),
)
news.forEach { n ->
val url = fileUrl(n.collectionId, n.id, n.newsImage)
println(n.title)
println(" " + (url ?: "картинки нет — рисуем заглушку"))
}
println()
println("уменьшенная копия:")
println(" " + fileUrl(news[0].collectionId, news[0].id, news[0].newsImage, thumb = "300x120"))
}
Настоящий вывод:
Скидка 20% на всё лето
https://style-matule.apis.alspio.com/api/files/pbc_987692768/yzzw0qwd6b6bo0c/banner_y2zhtudxtd.png
Новая коллекция уже в продаже
https://style-matule.apis.alspio.com/api/files/pbc_987692768/33b2r4vv4rbabdr/banner_o8i7lwqves.png
Акция без баннера
картинки нет — рисуем заглушку
Возвращать null при пустом имени — не педантизм. Поле файла вполне может быть пустой строкой: запись создана, файл не приложен. Если собрать адрес всё равно, получится ссылка, оканчивающаяся на слэш, загрузчик сходит за ней впустую и покажет ошибку. С null на этом месте ставится заглушка, и список выглядит целым.
Показать картинку
Картинки в Compose показывают через Coil — библиотеку, которая сама сходит в сеть, закэширует и покажет:
implementation("io.coil-kt:coil-compose:2.7.0")
AsyncImage(
model = fileUrl(news.collectionId, news.id, news.newsImage),
contentDescription = news.title,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth().height(160.dp),
)
Картинки акций и каталога отдаются без токена — они публичные. А вот файлы, лежащие в закрытых коллекциях, потребуют заголовок, и тогда Coil придётся отдать свой OkHttpClient — тот самый, с интерцептором. На сервере задания это не нужно, но знать полезно.
Уменьшенные копии
У PocketBase есть параметр ?thumb=ШИРИНАxВЫСОТА, который отдаёт уменьшенную версию. Выглядит как бесплатная экономия трафика — но работает не всегда.
Проверено на стенде, файл 600×240:
| Запрос | Ответ |
|---|---|
| без параметра | 810 байт — оригинал |
?thumb=100x100 | 226 байт |
?thumb=300x120 | 633 байта |
?thumb=50x50 | 810 байт — оригинал |
Размеры уменьшенных копий объявляются в настройках поля коллекции. Запрос незаявленного размера не даёт ошибки — молча приходит файл целиком. То есть ?thumb= может «работать» в том смысле, что картинка показывается, и при этом не экономить ни байта.
На чужом сервере узнать список заявленных размеров нельзя. Поэтому надёжнее просить оригинал: Coil всё равно масштабирует картинку под размер области и кэширует уже уменьшенную.
Загрузка: почему не @Body
Создание проекта в спецификации описано как multipart/form-data. Это другой формат тела: вместо одного JSON — несколько частей, разделённых строкой-границей, и каждая со своим именем.
Причина проста: JSON — текстовый формат, а картинка двоичная. Засунуть её в JSON можно только закодировав в base64, что раздувает объём на треть и требует раскодирования на сервере. Multipart отправляет байты как есть.
Retrofit описывает такой запрос двумя новыми аннотациями:
@Multipart
@POST("collections/project/records")
suspend fun createProject(
@Part("title") title: RequestBody,
@Part("user_id") userId: RequestBody,
@Part("category") category: RequestBody,
@Part("description_source") description: RequestBody,
@Part image: MultipartBody.Part,
): ProjectDto
Обернуть строку и файл помогают две функции-расширения из OkHttp:
private fun text(v: String) = v.toRequestBody("text/plain".toMediaType())
val part = MultipartBody.Part.createFormData(
"image", // имя части — то, что ждёт сервер
"moy-proekt.png", // имя файла
bytes.toRequestBody("image/png".toMediaType()),
)
Живой прогон загрузки:
── загружаем проект с картинкой ──
размер файла: 1290 байт
создан 0nluoj149pnafrh, поле image = moy_proekt_8sdyyvnieu.png
адрес: https://style-matule.apis.alspio.com/api/files/pbc_3202395908/0nluoj149pnafrh/moy_proekt_8sdyyvnieu.png
Мы отправили файл с именем moy-proekt.png, а в записи оказалось moy_proekt_8sdyyvnieu.png. Сервер переименовывает файл: заменяет дефисы подчёркиваниями и добавляет случайный суффикс, чтобы два файла с одним именем не затёрли друг друга.
Отсюда правило: адрес картинки строится по имени из ответа, а не по тому, что отправляли. Приложение, которое «помнит», как назвало файл, покажет битую ссылку.
Когда файл не приняли
Поле файла можно ограничить по размеру и типу. На стенде для image разрешены image/png, image/jpeg и image/webp, не больше 5 МБ. Попробуем отправить текстовый файл под видом картинки:
── чужой файл под видом картинки ──
код 400 · Failed to create record.
поле «image»: validation_invalid_mime_type — "vredonos_6d5majznwa.txt" mime type must be one of: image/png, image/jpeg, image/webp.
Ошибка приходит в том же виде, что и ошибки формы из главы про регистрацию: код validation_invalid_mime_type под ключом image в словаре data. Разбирается тем же классом ApiError — ничего нового писать не нужно.
Тип содержимого сервер определяет по самим байтам, а не по расширению и не по тому, что написано в Content-Type части. Переименовать .txt в .png не поможет: первые байты настоящего PNG — это подпись 89 50 4E 47, и её отсутствие видно сразу.
Из этого следует, что проверять файл нужно и на устройстве — до отправки:
- размер: сравнить с лимитом и сказать «Файл больше 5 МБ» сразу, а не после долгой загрузки по мобильной сети;
- тип: у выбранного через системный выбор файла тип можно спросить у
ContentResolver; - сжатие: снимок с камеры современного телефона легко весит 8–12 МБ, и его почти всегда нужно ужать перед отправкой.
Откуда берётся файл на устройстве
Выбор картинки из галереи делается системным экраном — без единого разрешения:
val picker = rememberLauncherForActivityResult(
ActivityResultContracts.PickVisualMedia(),
) { uri -> chosen = uri }
Button(onClick = {
picker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
}) { Text("Выбрать фото") }
Приходит Uri — не файл и не путь. Чтобы отправить, содержимое надо прочитать:
val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
PickVisualMedia не требует разрешения на чтение хранилища: пользователь сам выбирает файл на системном экране, и приложение получает доступ только к нему. Запрашивать READ_MEDIA_IMAGES для выбора картинки — лишнее: диалог разрешения, который можно было не показывать, на оценке работает против вас.
Типичные ошибки
1. Имя файла подставили как адрес. Картинка не грузится, ошибки в коде нет. Адрес собирается из трёх частей.
2. Не проверили пустую строку. Запись без файла даёт ссылку, оканчивающуюся слэшем, и в списке появляется битая картинка.
3. Адрес построили по отправленному имени. Сервер переименовал файл; брать надо имя из ответа.
4. Забыли @Multipart на методе. Retrofit скажет, что @Part можно применять только в multipart-запросе — при создании клиента, до всякой сети.
5. У файловой части указали имя в скобках. Имя уедет дважды, и сервер часть не примет.
6. Понадеялись на ?thumb=. Незаявленный размер молча отдаёт оригинал — экономии нет, а кажется, что есть.
7. Отправили снимок с камеры как есть. 10 МБ по мобильной сети — это десятки секунд и почти наверняка отказ по размеру.
8. Запросили разрешение на галерею. PickVisualMedia работает без него.
Что засчитывает эксперт
Здесь закрыты три запроса: акции, список проектов и создание проекта с картинкой. Смотрят на это:
- баннеры акций видны на главном экране и не «прыгают» при прокрутке;
- у акции без картинки стоит заглушка, а не пустое место и не значок ошибки;
- картинка выбирается из галереи и уходит на сервер;
- после загрузки она сразу видна в списке — то есть адрес взят из ответа;
- слишком большой или неверный файл отклоняется с понятным сообщением.
«Не прыгают при прокрутке» — это про заданную высоту у места под картинку. Без неё карточка сначала схлопывается до нуля, потом раздвигается, и список дёргается на каждой загруженной картинке. Замечают это сразу, а чинится одной строкой Modifier.height(…).
Куда углубиться
Coil:placeholder,error,crossfade— заглушка, картинка ошибки и плавное появление.- Сжатие изображения перед отправкой:
BitmapFactory.Options.inSampleSizeиBitmap.compress. - Показ прогресса загрузки: свой
RequestBody, считающий отправленные байты. FileProvider— как отдать своему же приложению файл, снятый камерой.- Кэш картинок
Coil: где он лежит, сколько занимает и как его чистить.
Сводная проверка по всей главе: 8 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
- PocketBase — работа с файлами — адреса файлов, уменьшенные копии и защищённые файлы.
- Retrofit — аннотации запросов —
@Multipart,@Partи правила их сочетания. - Coil для Compose —
AsyncImage, заглушки и настройка клиента. - Выбор фото и видео на Android — системный выбор без разрешений.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.