Реализация отправки изображений в чате мобильного приложения
Пользователь нажимает на скрепку, выбирает фото из галереи — и ждёт. Если за это время ничего не произошло, он нажимает ещё раз. Классический сценарий, когда загрузка изображения реализована без очереди и прогресс-индикатора. Дальше — дубли в чате, крэш на slow network, отзыв в 1 звезду.
Что идёт не так при наивной реализации
Самая частая ошибка — загружать оригинал напрямую. На современных смартфонах фото из камеры весит 4–12 МБ. Отправить такое в чат через multipart/form-data без предварительного сжатия означает: долгое ожидание, зависание UI на main thread (если сжатие вынесено туда же), и дублирование при повторной отправке после таймаута.
На iOS типичная реализация через PHPickerViewController (iOS 14+) отдаёт NSItemProvider, из которого нужно асинхронно получить UIImage. Если сделать это синхронно в completion handler и сразу вызвать ImageIO для ресайза — интерфейс подвиснет на ~300 мс на iPhone 12, и ещё дольше на SE 2nd gen. Правильный путь: получить данные в фоне через loadObject(ofClass:), затем пробросить в DispatchQueue.global(qos: .userInitiated) для сжатия через vImage или UIGraphicsImageRenderer с target size под экран получателя.
На Android аналогичная проблема с ActivityResultContracts.GetContent(): если декодировать Bitmap на главном потоке через BitmapFactory.decodeStream() без inSampleSize — OutOfMemoryError на устройствах с 2 ГБ RAM при выборе нескольких фото подряд.
Как выстроить надёжный pipeline
Полная цепочка выглядит так: выбор → валидация → сжатие → upload с прогрессом → сохранение ссылки → отображение thumbnail.
Выбор и валидация. На iOS — PHPickerViewController с filter: .images, лимит выбора через selectionLimit. Проверяем MIME-тип через UTType до загрузки данных. На Android — PhotoPicker API (Android 13+) или Intent(Intent.ACTION_PICK) для более старых версий; проверка через ContentResolver.getType().
Сжатие. Цель — не более 1 МБ для превью в чате. На iOS: UIGraphicsImageRenderer с target size 1280×1280, jpegData(compressionQuality: 0.75). На Android: Bitmap.createScaledBitmap() + compress(Bitmap.CompressFormat.JPEG, 80, outputStream). В Flutter используем flutter_image_compress — он вызывает нативный кодек, поэтому не блокирует Dart isolate.
Upload. Многочастная загрузка через URLSession.uploadTask(with:from:) на iOS с делегатом urlSession(_:task:didSendBodyData:) для прогресса. На Android — OkHttp с RequestBody.create() и кастомным CountingRequestBody. В React Native удобнее axios с onUploadProgress, но нужно помнить: прогресс-событие срабатывает на JS-потоке, обновление state надо дебаунсировать.
Оптимистичный UI. Показываем thumbnail сразу после выбора, статус "загружается" — пока идёт upload. Если запрос упал — не удаляем сообщение, а показываем кнопку retry. Для этого у каждого сообщения должен быть локальный localId и статус (pending / sent / failed).
Полноэкранный просмотр. На iOS — UIScrollView + UIImageView с pinch-to-zoom через UIPinchGestureRecognizer. Ленивая загрузка оригинала при открытии через SDWebImage или Kingfisher. На Android — PhotoView library или ZoomableImageView из coil + accompanist.
Хранение и CDN
Изображения не хранятся в базе данных. Загружаем в S3-совместимое хранилище (AWS S3, Cloudflare R2, MinIO), в чат пишем только URL. Для превью генерируем thumbnail на стороне сервера через Lambda/Cloud Function при загрузке — это избавляет клиент от повторного сжатия при отображении списка сообщений.
Подписанные URL (presigned URL) с TTL 1–24 часа — обязательно, если чат приватный. На клиенте кэшируем через NSCache (iOS) или DiskLruCache (Android).
Процесс и сроки
Базовая реализация (выбор, сжатие, upload, thumbnail, полноэкранный просмотр) — 2–3 дня при готовом бэкенде. Если нужен мультивыбор (до 10 фото), очередь загрузки с паузой/возобновлением и поддержка GIF — ещё 1–2 дня. Стоимость рассчитывается индивидуально после анализа требований.







