Фото из галереи и отправка файла

натив≈ 15 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Прошлая статья оставила в подложке модель системы разрешений и пообещала её камере. К подходу в «Подходе» прикрепляется фото — снимок техники жима или кадр из галереи. Пикер отвечает не картинкой, а адресом файла на диске телефона, и из этого вытекает всё остальное: как отправить файл на сервер, не превращая его в строку base64; почему FormData в React Native принимает объект, которого в браузере не существует; и что делать с фото в зале, где нет сети. Пикер и натив соберём моделями по исходникам expo-image-picker 57 и React Native 0.86, а тело multipart-запроса, собранное руками, проверит настоящий разборщик браузера.

Разделы темы:·

Этаж — Expo: пакет expo-image-picker 57.0.13. У него два главных метода — launchImageLibraryAsync открывает галерею, launchCameraAsync камеру, — а нативный модуль за ними называется ExponentImagePicker: тот самый, что был в задаче статьи «Модуль — это запись в реестре». Оба метода отвечают одним типом:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Главное поле — uri. Картинку пикер в JavaScript не отдаёт. На Android MediaHandler.kt копирует выбранный снимок в папку ImagePicker внутри кеша приложения, под именем из UUID, и возвращает адрес копии; на iOS MediaHandler.swift делает то же в своём кеше. Содержимое строкой — поле base64 — появится, только если его попросить. В подложке — модель ExponentImagePicker, которая ведёт себя как Android: кладёт «снимок» на модель диска и отвечает адресом. Олег прикрепляет фото к двум подходам, ко второму передумал. Прогноз: что напечатают обе попытки и есть ли в ответе сама картинка?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Картинки в ответе нет: base64: null, потому что его не просили. Есть адрес копии в cache/ImagePicker, размеры, имя и MIME-тип — всё, чтобы отправить файл, ни разу его не прочитав. Отмена — не исключение: canceled: true и assets: null. Тип ответа — размеченное объединение по canceled, поэтому до проверки компилятор не пустит к result.assets[0]; как работает сужение, разобрано в части III.

Галерее разрешение не нужно: в документации пакета оно требуется для выбора фото только на iOS 10, а дальше система показывает свой экран выбора, и приложение видит лишь то, что выбрал человек. Камере нужно, и тут платформы расходятся. На iOS launchCameraAsync без разрешения сразу отклоняет промис с «Missing camera or camera roll permission» (ImagePickerModule.swift). На Android вызов просит разрешение сам, изнутри, и при отказе бросает «User rejected permissions» (ensureCameraPermissionsAreGranted в ImagePickerModule.kt). В подложке — модель системы разрешений из прошлой статьи, а модель пикера ведёт себя как Android. Прогноз: сколько системных окон увидит Олег и чем кончится каждая попытка?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Отказ в разрешении пришёл отклонённым промисом, а не отменой: canceled: true бывает, когда человек сам закрыл экран камеры. Поэтому вызов камеры живёт в try/catch, а отмена проверяется отдельно. Вторая попытка снова показала окно — на Android их два, как в прошлой статье, — и после «разрешить» пришёл снимок с адресом того же вида. На iOS первая попытка упала бы без всякого окна, поэтому там разрешение спрашивают заранее хуком ImagePicker.useCameraPermissions() — той самой фабрикой createPermissionHook.

Веб. Файл ExponentImagePicker.web.ts открывает браузерный выбор файла через скрытый <input type="file">. uri там — blob:-адрес из URL.createObjectURL, а в ответе есть поле, которого на телефоне нет, — file, настоящий File браузера. Разрешения на вебе всегда granted. И браузер не покажет выбор файла без жеста пользователя, поэтому пикер зовут из onPress, а не из эффекта: эффект — синхронизация, а не ответ на нажатие (часть IV).

Android и fileSize. Имя и размер MediaHandler.kt берёт у галереи — OpenableColumns.DISPLAY_NAME и SIZE исходного снимка. При quality меньше 1 в кеш ложится пережатая копия, и файл по uri может оказаться меньше, чем обещает fileSize. Считать по нему трафик не стоит.

Превью. На телефоне <Image source={{ uri: photo.uri }} /> покажет снимок прямо из кеша. В предпросмотре такого файла нет, а адреса file:/// песочнице недоступны, поэтому примеры статьи ничего не рисуют, а печатают.

Серверу нужен файл, а у нас адрес. На этаже React Native это решено договором, которого в вебе нет. FormData в React Native — не браузерный класс, а полифил Libraries/Network/FormData.js, и его append принимает объект { uri, name, type }. Файл он не открывает — только складывает части и описывает их заголовками:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Дальше fetch и XMLHttpRequest превращают тело в { formData: form.getParts() } — это convertRequestBody.js — и отдают нативу. На iOS тело собирает RCTNetworking.mm, на Android — NetworkingModule.kt через okhttp3.MultipartBody, и оба сами открывают файл по uri. В память JavaScript не попадает ни байта снимка: пятимегабайтное фото не становится пятимегабайтной строкой.

В песочнице FormData браузерный, а сервер загрузки из подложки разбирает тело настоящим разборщиком браузера — так же, как разобрал бы его сервер. Прогноз: что сервер получит в поле file от кода, который на телефоне отправил бы снимок, и сколько ошибок покажет компилятор?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Компилятор промолчал, а сервер получил не файл, а строку "[object Object]": браузерный append по спецификации превращает в строку всё, что не Blob. В типах react-native 0.86 у FormData свои методы — append(key: string, value: any), getAll и getParts, — поэтому объект проходит проверку, а getParts, которого в браузере нет, падает TypeError. Это знакомая ловушка «типы шире рантайма», только тише прежних: запрос ушёл, ответ пришёл, и о пропаже файла знает один сервер.

В настоящем проекте Expo с типами наоборот. expo/tsconfig.base включает lib: ["DOM", "ESNext"], перегрузки append из DOM ждут строку или Blob, и на объект { uri, name, type } компилятор отвечает «No overload matches this call». Поэтому в коде на Expo его приводят as unknown as Blob. Веб-версия «Подхода» — тот же браузер, что песочница, и у неё пикер отдаёт настоящий File в поле asset.file. С этого места подложка так и делает: её photoPart отдаёт File с модели диска. Что класть в FormData на телефоне с Expo SDK 57 — вопрос с подвохом, к нему вернёмся в последнем разделе.

Что натив пишет в тело? Формат тот же, что в статье «FormData и отправка на сервер» части II: граница, заголовки части, пустая строка, содержимое. Новое одно: тело собирает не JavaScript, а натив, из частей getParts, и файл по uri он читает сам. В RCTNetworking.mm это буквально цикл по частям:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Границу выбирает тот, кто собирает тело. У RCTNetworking.mm это 70 случайных символов из набора выше. На Android NetworkingModule.kt своей не передаёт, и её выбирает okhttp3.MultipartBody.Builder. У expo/fetch — ----ExpoFetchFormBoundary и ещё 16 символов. Соберём то же в песочнице: части — в том виде, в каком их отдаёт getParts, файл по uri читает модель натива readDisk. Для сравнения попросим у пикера ещё и base64 и отправим тот же снимок JSON-ом — так, как отправил бы его tRPC или любой JSON-API. Прогноз: разберёт ли сервер самодельное тело и насколько JSON тяжелее?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Разборщик браузера, который про наш код ничего не знает, принял самодельное тело: имя файла, тип и 245 760 байт — ровно размер снимка. На провод ушло 246 033 байта: 273 сверху — границы и заголовки частей. JSON с base64 весит 327 709 байт, на треть больше, как и обещала часть II. Но в приложении размер — меньшая из бед:

multipart с uriJSON с base64
На проводефайл плюс сотни байт на границы и заголовкив 4/3 раза больше файла
В памяти JavaScriptничего: файл читает нативвся строка — и та, что пришла от пикера, и та, что собрал JSON.stringify
На серверефайл с именем и типом, его можно писать на диск по частямстроку надо принять целиком и раскодировать
В журнале local-firstссылка в десятки байтснимок внутри операции, которая повторяется при каждом обрыве

Поэтому в приложении автора фото загружается мимо tRPC. Комментарий в upload-photo.tsx говорит прямо: tRPC сериализует тело в JSON, и картинка ехала бы base64 — на треть больше и целиком в памяти. Для файла у сервера отдельный адрес POST /api/upload/photo с multipart и полем file — тот, что изображает подложка.

Файл не должен проходить через JavaScript. uri — договор с нативом: байты возьми сам.

Всё выше предполагало сеть. В зале её нет, а главная копия дневника живёт в телефоне — это глава 6. Схема «сначала загрузить фото, потом записать адрес в подход» — та же заглушка «сервер главный», с которой глава 6 начиналась: без сети фото не прикрепится вовсе, а человек со штангой будет смотреть на спиннер. Local-first разводит две вещи. В журнал ложится ссылка — id фото, рождённый на телефоне, как id строк; операция с ним весит десятки байт и уезжает обычной синхронизацией. Файл встаёт в отдельную очередь загрузки и уходит своим multipart-запросом, когда появится сеть.

Своя очередь нужна не только из-за размера. Пачка синхронизации повторяется при каждом обрыве, и снимок внутри неё тащил бы сотни килобайт за каждой правкой веса. Загрузку файла можно показать прогрессом, отложить до Wi-Fi, поставить на паузу — операциям журнала ничего этого не нужно. А сервер синхронизации не обязан уметь принимать файлы.

В подложке — журнал «Подхода» в самом коротком виде: id «устройство + счётчик», строка и операция пишутся одним шагом. У сервера загрузки — рубильник сети. Прогноз: что будет у подхода сразу после прикрепления без сети и сколько раз очередь попробует загрузить снимок?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Фото прикрепилось сразу: в строке подхода photo: "A-2" — id с телефона, и экран, подписанный на журнал, может показать снимок по локальному uri, не дожидаясь сервера. Первая попытка загрузки упала на сети, но фото осталось первым в очереди: shift стоит после ответа сервера. Это правило цикла синхронизации из главы 7 — убирать только подтверждённое. Когда сеть появилась, очередь довезла файл сама.

Сервер хранит файл под тем же photoId, и это делает повтор безвредным. Если сервер файл принял, а ответ потерялся, очередь отправит снимок ещё раз — и сервер перезапишет A-2.jpg, а не заведёт второй файл: тот же приём с id с телефона, что в статье «Ответ потерялся». Второе устройство, получив операцию с photo: "A-2", до конца загрузки покажет «фото ещё едет», а потом возьмёт /photos/A-2.jpg.

Адрес от пикера указывает в кеш. В expo-file-system про эту папку сказано прямо: сюда кладут файлы, которые система может удалить, когда на устройстве мало места. Снимок, который ждёт загрузки несколько дней, должен лежать в документах приложения — Paths.document, — поэтому на телефоне его сначала копируют, а в очередь ставят путь копии. И сама очередь, как журнал, живёт на диске, иначе перезапуск её потеряет (глава 6). В подложке диск — это Map, копировать там нечего; код для телефона — в следующем разделе.

Всё, что выполнялось выше, — модели и браузер. На телефоне их место занимают пакеты Expo, и дальше — листинги: в предпросмотр пакеты Expo не собираются, expo-modules-core тянет TurboModuleRegistry, которого у react-native-web нет. Сигнатуры сверены по исходникам expo-image-picker 57.0.13 и expo-file-system 57.0.5. Выбор снимка и копия в документы:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Отправлять файл на Expo не обязательно через FormData. У File из expo-file-system есть свой метод upload: нативная задача собирает multipart сама и сообщает о прогрессе через onProgress. На iOS сессия по умолчанию фоновая (sessionType: "background"), и передача может продолжиться в свёрнутом приложении. Но в документации метода оговорено: если процесс убит, JavaScript-задача не восстановится — ни промиса, ни прогресса. Поэтому о том, что ещё не загружено, судит очередь на диске, а не живой промис.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Опасаться стоит другого — привычного по документации React Native fetch с объектом { uri, name, type }. В Expo SDK 57 глобальный fetch — уже не полифил React Native, а expo/fetch, и тело multipart он собирает сам, в JavaScript:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Объект { uri, name, type } — не строка, не Blob и без метода bytes(), поэтому промис fetch отклонится с «Unsupported FormDataPart implementation» ещё до отправки. В пакете есть тест, который ждёт ровно эту ошибку: «should throw an error if the react-native FormData passing an uri». Настоящие файлы — FormData.js из react-native 0.86 с заплаткой installFormDataPatch и convertFormDataAsync из expo 57.0.16 — выполнены в Node: часть с uri дала эту самую ошибку, а getParts той же формы по-прежнему отдаёт uri для натива. Это не запуск на устройстве, поэтому проверьте на своём до выпуска. Способы отправить файл на SDK 57:

Как отправитьЭтажНа Expo SDK 57, по исходникам
fetch + FormData с { uri, name, type }React Nativeexpo/fetch такую часть отвергает; с EXPO_PUBLIC_USE_RN_FETCH=1 глобальным остаётся fetch React Native, и часть уходит в натив
XMLHttpRequest + тот же FormDataReact NativeXMLHttpRequest Expo не подменяет: тело идёт через getParts в натив, файл читает натив
fetch + FormData с File из expo-file-systemExpoexpo/fetch читает файл через bytes() — то есть целиком в память JavaScript
file.upload(url, { uploadType: UploadType.MULTIPART })Expoнативная задача: multipart, прогресс, фоновая сессия на iOS

И правило из части II — Content-Type для multipart руками не ставят — в React Native нарушается незаметно. На iOS RCTNetworking.mm для multipart заменяет заголовок своим: в исходнике прямо сказано, что тип от вызывающего кода переписывается, потому что граница есть только в своём. expo/fetch тоже подставляет свой. Браузер так не делает, и веб-версия «Подхода» с тем же кодом отправит заголовок без границы — сервер тело не разберёт. Код, проверенный только на iPhone, ломается в вебе.

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

  • Пикер (expo-image-picker, этаж Expo) отвечает { canceled, assets }. asset.uri — адрес копии снимка в cache/ImagePicker, сама картинка в JS не приходит; base64 — только по просьбе. Отмена — canceled: true и assets: null, а не исключение.
  • Галерее разрешение не нужно, камере нужно. На iOS launchCameraAsync без разрешения отклоняется сразу, на Android просит сам и при отказе бросает «User rejected permissions».
  • FormData React Native принимает { uri, name, type }: getParts отдаёт части нативу, и он сам читает файл и пишет multipart со своей случайной границей (на iOS — 70 символов). В браузере тот же объект станет строкой "[object Object]", а getParts упадёт TypeError — при нуле ошибок компилятора.
  • Формат multipart тот же, что в части II, но в React Native тело собирает натив: из частей getParts, со своей границей, читая файл по uri сам. JSON с base64 на треть тяжелее и держит снимок строкой в памяти JavaScript.
  • Local-first: в журнал сразу ложится id фото, файл уходит отдельной очередью загрузки и снимается с неё только после ответа сервера; сервер хранит файл под id с телефона, а на телефоне копия лежит в документах, а не в кеше.
  • На Expo SDK 57 глобальный fetch — expo/fetch, и его настоящий код отвергает часть { uri, name, type } (проверено в Node, не на устройстве). Файл уходит через file.upload из expo-file-system или через XMLHttpRequest. Content-Type для multipart руками не ставят: на iPhone ошибку скроет RCTNetworking, в вебе — нет.

1/4Что лежит в asset.uri после выбора фото из галереи на Android?

2/4Код с form.append("file", { uri, name, type }) в предпросмотре компилируется без ошибок. Что получит сервер в поле file?

3/4Почему снимок отправляют multipart-ом с uri, а не JSON-ом с base64?

4/4Олег в зале без сети прикрепил фото к подходу. Как «Подход» поступает в local-first?

Очередь, которая теряет фото

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

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Смотрите на первую задачу, не вынимая её, и убирайте только после ответа сервера:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Теперь без сети задача остаётся в очереди («ждут: 1»), а после «сеть появилась» очередь довозит снимок: «загружено: A-1 → /photos/A-1.jpg», ждут загрузки 0. Цена правила — возможный повтор: сервер файл принял, ответ потерялся, и фото уйдёт второй раз. Поэтому сервер хранит файл под photoId с телефона — повтор перезапишет тот же файл. Остаётся дыра, которую стоит видеть: ответ 400 или 500 тоже снимет задачу с очереди. Отличать «сервер отверг» от «сервер принял» по response.ok приходится отдельно — как вердикты в главе 7.

Снимок внутри операции журнала

Кто-то решил не заводить вторую очередь и положил снимок прямо в операцию журнала — строкой base64. Пока есть сеть, это даже работает. Но пачка синхронизации — всё неотправленное, и без сети она уходит заново при каждой попытке, со снимком внутри. Перенесите фото в очередь загрузки: в журнал — ссылка, сам файл — в uploads.

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Не просите у пикера base64 вовсе и разведите ссылку и файл, как в разделе про local-first:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Пачка синхронизации сжалась с 327 872 символов до 195, а в очереди загрузки одно фото: «ждут загрузки: 1». Заметьте, что правка веса до 82 кг ничего не изменила: в плохом варианте снимок уехал бы заново вместе с ней, потому что сидит в той же неотправленной пачке. Id фото рождён на телефоне тем же счётчиком, что id операций, поэтому ссылка в журнале верна ещё до загрузки файла, а сервер потом положит файл под этим же id.