Перейти к основному содержимому
Мобилка39ГЛАВА 39Картинки и загрузка файлов/api/files/…/banner.pngmultipart · image/pngимя, а не ссылка

Картинки и загрузка файлов

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

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

Магазин без картинок не бывает. В спецификации задания есть два места, где живут файлы: акции (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 относительно текущего адреса, и картинка не найдётся.

В ЗАПИСИ ЛЕЖИТ ИМЯ ФАЙЛА, А НЕ ССЫЛКА"collectionId": "pbc_987692768""id": "yzzw0qwd6b6bo0c""newsImage": "banner_y2zhtudxtd.png"три поля одной записи акции/api/files/pbc_987692768/yzzw0qwd6b6bo0c/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 — тот самый, с интерцептором. На сервере задания это не нужно, но знать полезно.

Отвечено вопросов: 0 из 2
Что лежит в поле newsImage записи акции?
Поле файла пришло пустой строкой. Что делать?
Отвечено верно: 0 из 2

Уменьшенные копии​

У PocketBase есть параметр ?thumb=ШИРИНАxВЫСОТА, который отдаёт уменьшенную версию. Выглядит как бесплатная экономия трафика — но работает не всегда.

?thumb= РАБОТАЕТ НЕ ДЛЯ ЛЮБОГО РАЗМЕРА?thumb=100x100226 байтразмер объявлен?thumb=300x120633 байтаразмер объявлен?thumb=50x50810 байтпришёл ОРИГИНАЛнезаявленный размер не даёт ошибки — молча приходит файл целикомнадёжнее просить оригинал и уменьшать на устройстве: Coil делает это сам
Три запроса одного файла: два размера уменьшены, третий вернул оригинал

Проверено на стенде, файл 600×240:

ЗапросОтвет
без параметра810 байт — оригинал
?thumb=100x100226 байт
?thumb=300x120633 байта
?thumb=50x50810 байт — оригинал
Важно

Размеры уменьшенных копий объявляются в настройках поля коллекции. Запрос незаявленного размера не даёт ошибки — молча приходит файл целиком. То есть ?thumb= может «работать» в том смысле, что картинка показывается, и при этом не экономить ни байта.

На чужом сервере узнать список заявленных размеров нельзя. Поэтому надёжнее просить оригинал: Coil всё равно масштабирует картинку под размер области и кэширует уже уменьшенную.

Загрузка: почему не @Body​

Создание проекта в спецификации описано как multipart/form-data. Это другой формат тела: вместо одного JSON — несколько частей, разделённых строкой-границей, и каждая со своим именем.

ТЕЛО ЗАПРОСА С ФАЙЛОМ--границаname="title"Летний забег--границаname="image"; filename="moy-proekt.png"Content-Type: image/png‹1290 байт двоичных данных›--граница--обычные поля и файл едут вместе, каждое своей частью@Part("title") RequestBodyобычное поле — с именемстроку сначала в RequestBody@Part MultipartBody.Partфайл — БЕЗ имени в скобкахимя внутри createFormDataимя в скобках у файла — частая ошибка: часть уедет дважды и сервер её не примет
Части multipart-запроса и аннотации, которые их описывают

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

Отсюда правило: адрес картинки строится по имени из ответа, а не по тому, что отправляли. Приложение, которое «помнит», как назвало файл, покажет битую ссылку.

Отвечено вопросов: 0 из 3
Почему файл нельзя отправить обычным @Body с JSON?
Чем @Part("image") отличается от @Part для файла?
Отправили файл moy-proekt.png, в ответе пришло moy_proekt_8sdyyvnieu.png. Почему?
Отвечено верно: 0 из 3

Когда файл не приняли​

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

Отвечено вопросов: 0 из 3
Что приходит из PickVisualMedia?
Нужно ли разрешение READ_MEDIA_IMAGES для системного выбора картинки?
Зачем поток оборачивают в use { … }?
Отвечено верно: 0 из 3

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

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

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

Видео по теме

ВидеоUpload Image to Server using Retrofit in Android Studio Kotlin Tutorial | Part 2Generic Apps
ВидеоRetrofit Tutorial — How to Upload Multiple Files to ServerFuture Studio
ВидеоHow to load a network image in Jetpack Compose! (Coil)The Android Factory
ВидеоTHIS Is How You Use the New Android 13 Photo Picker (It's AMAZING!)Philipp Lackner
ВидеоImage loading in Android via Coil | Bored Ape Yacht Club | Kotlin 2022The Android Factory

Комментарии

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