Реалізація отправки зображень у чаті мобільного додатку
Користувач натискає на скрепку, вибирає фото з галереї — і чекає. Якщо за цей час нічого не відбулось, він натискає ще раз. Класичний сценарій, коли завантаження зображення реалізовано без черги та прогресс-індикатора. Дальше — дубли в чаті, креш на 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 дні. Вартість рассчитується індивідуально після аналізу вимог.







