Надёжная загрузка видео в React Native: как пережить сворачивание, убийство приложения и сотовую сеть
Обычный fetch() для загрузки видео умирает, как только пользователь свернёт приложение. Разбираем архитектуру, которая переживёт убийство процесса, потерю сети и перезапуск — и не превратит интерфейс в «вечный прогресс-бар на 37%».
Обещание «загрузка завершена на 100%» — это ложь, если видео на сервере ещё не готово к воспроизведению. Настоящая надёжность начинается с признания: ваш JavaScript-контекст — эфемерная сущность, а единственный долговечный объект — запись в базе данных и состояние на сервере.
Проблема, о которой предпочитают не думать
Почти каждый туториал по загрузке файлов в React Native предлагает примерно одну и ту же конструкцию: вызвать fetch() с телом файла, отловить прогресс, обновить UI. Выглядит чисто. Работает — пока никто не свернёт приложение.
Стоит пользователю нажать «домой» или заблокировать экран, как iOS приостанавливает процесс, а Android может его убить вовсе. JavaScript-контекст уничтожается вместе со всеми промисами, замыканиями и обработчиками. Когда человек возвращается в приложение — никакой ошибки, никакого отката. Просто зависший прогресс-бар, который больше никуда не денется.
Это не баг конкретной библиотеки. Это фундаментальное ограничение мобильных платформ: JavaScript-рантайм не имеет права удерживать ресурсы ОС, когда приложение не на переднем плане. И пока разработчики пытаются обойти это «костылями» на уровне JS, проблема будет повторяться.
Что умеют (и чего не умеют) платформы
Чтобы построить надёжное решение, нужно сначала понять рамки. iOS и Android по-разному решают задачу фоновой передачи данных, и у каждого подхода есть жёсткие ограничения.
iOS предоставляет URLSession с фоновой конфигурацией — сессия, которая живёт отдельно от процесса приложения и управляется операционной системой. Но есть нюансы:
- загружать можно только из файла на диске — потоки (streams) и объекты
Dataне поддерживаются; - нельзя использовать удобные completion-handler API — только делегаты;
- путь к файлу должен быть стабильным URL, который ОС сможет прочитать даже после убийства вашего процесса.
Android использует foreground-сервис с типом dataSync, который показывает уведомление и удерживает процесс. Но и тут не всё гладко:
- начиная с Android 14, тип сервиса обязателен;
- Android 15 вводит лимит в 6 часов на каждые сутки для
dataSyncиmediaProcessing— после чего система вызываетService.onTimeout(); - если бюджет исчерпан, попытка запуска бросит
ForegroundServiceStartNotAllowedException.
Важный нюанс: Service.onTimeout(int, int) не существует на Android 14 и ниже. Там система молча «замораживает» сервис без колбэка — не стоит строить логику восстановления, зависящую от этого механизма.
Архитектура: запись вместо промиса
Ключевая идея решения, которое предлагает разработчик Mason в своём гайде, сводится к простому принципу: промис эфемерен, а запись в базе — персистентна.
Прежде чем отправить первый байт, приложение создаёт запись в локальной SQLite-базе. Эта запись содержит идентификатор загрузки, путь к локальному файлу, URL для загрузки на сервер и текущее состояние. Весь жизненный цикл загрузки — это конечный автомат, описанный состояниями:
pending— ожидает началаuploading— идёт передачаuploaded— байты на сервере, но видео ещё не обработаноprocessing— сервер транскодируетready— можно воспроизводитьfailed— ошибка
Когда процесс умирает и приложение перезапускается, запись никуда не исчезает. При старте приложение читает из базы все незавершённые загрузки и сверяет их состояние с сервером — не доверяя локальным данным.
Нативный транспорт — не опция, а необходимость
В экосистеме Expo есть несколько путей для загрузки файлов. Современный expo/fetch — удобный, но не поддерживает фоновые сессии. Старый FileSystem.uploadAsync имел параметр для фонового режима, но он объявлен deprecated начиная с SDK 54 и, по имеющимся данным, давал сбои на больших файлах.
Для загрузок, которые должны пережить сворачивание, остаётся один путь — нативные модули. Автор использует react-native-background-upload, который под капотом использует iOS URLSession и Android foreground-сервис. На Android модуль создаёт канал уведомлений, на iOS — работает через делегаты.
Здесь есть важное предупреждение для тех, кто выбирает библиотеку: начиная с React Native 0.82 возможность отказаться от New Architecture была удалена, а Expo SDK 55 (React Native 0.83) работает только на New Architecture. Если нативный модуль не адаптирован — это не «временное неудобство», а тупик. Прежде чем интегрировать библиотеку для фоновых загрузок, стоит проверить её статус совместимости.
Согласование после перезапуска
Самая хитрая часть — не сама загрузка, а поведение при возврате. После убийства процесса все подписки на события (progress, completed, error) исчезают. Прогресс-бар молчит. Что произошло с загрузкой — знает только сервер.
Алгоритм reconciliation выглядит так:
- При запуске приложения читаем из SQLite все записи с состояниями
pending,uploading,uploadedилиprocessing. - Если у записи нет
asset_id(загрузка не успела создать ресурс на сервере) — переводим вpendingи ставим в очередь заново. - Если
asset_idесть — делаем запрос к API и спрашиваем сервер, в каком состоянии ресурс. - Серверные состояния маппим в локальные и обновляем запись.
Автор формулирует принцип просто: доверяйте серверу, а не локальному состоянию. Это классический паттерн reconciliation — тот же подход, что используется в React при перерисовке DOM и в блокчинах при достижении консенсуса.
«100%» — не «готово»
Пожалуй, самый полезный из описанных паттернов — это разделение понятий «байты доставлены» и «видео готово». Когда прогресс-бар доходит до 100% на этапе uploaded, пользователь считает, что всё. Но на сервере ещё идёт транскодирование — HLS-сегментация, генерация превью, извлечение метаданных.
Если показать «Готово!» на этапе uploaded, а потом видео окажется недоступно — это категория тикетов в техподдержку, которую можно было не создавать. Правильный подход: состояние uploaded отображается как «Загрузка завершена, подготавливаем видео», и только переход в ready (подтверждённый вебхуком или поллингом от сервера) означает реальную готовность.
Автор приводит простую функцию маппинга состояний в текстовые метки — ничего революционного, но именно такие «мелочи» определяют качество UX.
Тест, который действительно проверяет архитектуру
Стандартные unit-тесты здесь бесполезны. Автор предлагает конкретный сценарий ручного тестирования:
- Начать загрузку файла ~200 МБ.
- На ~30% свернуть приложение.
- Заблокировать телефон на 2 минуты.
- Включить и выключить режим полёта (имитация потери и восстановления сети).
- Принудительно убить приложение.
- Открыть заново.
Корректное поведение: загрузка либо возобновилась в фоне, либо запись вернулась в состояние pending и была поставлена в очередь. Некорректное: прогресс-бар застыл на 30%, или на сервере создался дубликат ресурса.
Размер решения и дальнейшие шаги
Весь описанный подход укладывается примерно в 150 строк кода — не считая нативных модулей. Это не «минимально жизнеспособный продукт», а полная архитектура: хранилище, транспорт, reconciliation и UI.
Для продакшена автор рекомендует два дополнения:
- Протокол с возобновлением (tus или S3 multipart upload). Разница между «загрузка пошла с нуля» и «загрузка продолжилась с места обрыва» на мобильной сети — это разница между функцией, которой пользуются, и функцией, от которой отказались.
- Метрики на стороне сервера. Клиентские отчёты об успешности загрузок учитывают только те, которые дошли до конца — неудачи происходят в процессе, которого уже не существует. Считать нужно соотношение созданных ресурсов к тем, что дошли до состояния
ready.
Для тех, кто работает на Expo и рассматривает написание собственного нативного модуля, стоит обратить внимание на технический пост команды Expo о создании модуля загрузки видео с использованием Expo Modules — это ближайшее к референсной реализации, что существует в открытом доступе.