Реалізація мультимодального AI-введення (текст + зображення) у мобільному додатку
Коли користувач фотографує етикетку товару і хоче одразу отримати розшифровку складу—це мультимодальне введення. Не «завантаж фото, потім напиши питання в іншому полі», а єдиний потік: знімок і контекст йдуть у модель одним запитом. Реалізувати це правильно складніше, ніж здається на старті.
Де ломаються перші прототипи
Найчастіша помилка—відправити зображення окремим запитом, отримати текстовий опис, потім склеїти з запитанням користувача. Це не мультимодальність, це цепочка з двох викликів з втратою контексту. GPT-4o, Claude 3, Gemini 1.5 підтримують image_url прямо у messages[]—використовуйте це.
На Android типова проблема: Bitmap після BitmapFactory.decodeFile() на крупному знімку з камери важить 15–20 МБ. Base64 від такого зображення розду вається до 25+ МБ, і API повертає 400 Bad Request з невнятним image_too_large. Рішення—масштабувати через Bitmap.createScaledBitmap() до 1024×1024 або використовувати BitmapRegionDecoder для кропу до відправки. Компресія у JPEG 85% звичайно достатня.
На iOS історія та ж, але з іншими граблями: UIImagePickerController повертає UIImage з поворотом imageOrientation != .up, і модель отримує зображення вверх ногами. ImageIO або CGImagePropertyOrientation потрібно застосувати до кодування у base64—інакше розпізнавання тексту погіршується.
Як будується реальна інтеграція
Протокол обміну. OpenAI-сумісний формат (messages з content типу array) працює у більшості провайдерів. Будуємо абстракцію MultimodalMessage, яка вміст упаковувати List<ContentPart>—текст, зображення, опціонально документ—в один payload. Це дозволяє перемикати провайдера (OpenAI → Anthropic → Google) заміною одного адаптера.
// Android (Kotlin)
data class ImagePart(val base64: String, val mimeType: String = "image/jpeg")
data class TextPart(val text: String)
fun buildPayload(text: String, bitmap: Bitmap): RequestBody {
val scaled = Bitmap.createScaledBitmap(bitmap, 1024, 1024, true)
val stream = ByteArrayOutputStream()
scaled.compress(Bitmap.CompressFormat.JPEG, 85, stream)
val b64 = Base64.encodeToString(stream.toByteArray(), Base64.NO_WRAP)
// упаковка у messages[]
}
Потіковой ответ. Для довгих ответів (аналіз медичного знімка, розбір рахунку-фактури) stream: true з Server-Sent Events дає користувачу відчуття живого ответу. На Android—OkHttp з EventSource, на iOS—URLSession + AsyncSequence. Без потока при аналізі щільного документа користувач дивиться в пустий екран 8–12 секунд.
Кеш і повторні запити. Якщо користувач відправив те ж зображення з іншим питанням—перекодувати не потрібно. Кешуємо base64-рядок за хешем Bitmap (MD5 від масиву пікселів або Uri файлу) у LruCache на 10–20 МБ. На iOS—NSCache з аналогічною логікою.
Складності на рівні UX та архітектури
Дозволи камери та галереї на Android 13+ розділені: READ_MEDIA_IMAGES замість старого READ_EXTERNAL_STORAGE. На iOS—NSPhotoLibraryUsageDescription та NSCameraUsageDescription у Info.plist, причому з iOS 14 працює PHPickerViewController без запиту повного доступу до бібліотеки. Не використовуйте UIImagePickerController для нових проектів—Apple його застарією.
Багато команд недооцінюють обробку помилок моделі. Якщо зображення розмито, надто темне або містить заборонений контент—провайдер повертає finish_reason: content_filter або пустий content. UI повинен це розрізняти й давати користувачу зрозумілий фідбек, а не вічний індикатор завантаження.
Стек та інструменти
| Компонент | Android | iOS |
|---|---|---|
| Захист зображення | CameraX 1.3+ | AVFoundation / PHPickerViewController |
| Кодування | Base64 (java.util) |
Data.base64EncodedString() |
| HTTP-клієнт | OkHttp 4 + Retrofit | URLSession / Alamofire |
| Потік | OkHttp EventSource | AsyncStream / Combine |
| Кеш | LruCache / Coil | NSCache / Kingfisher |
Flutter: image_picker → dart:convert (base64Encode) → http або dio з chunked streaming. Архітектурно—провайдер або BLoC для управління станом завантаження/потока.
Етапи роботи
Аудит поточної архітектури додатку та вибір AI-провайдера → проектування протоколу MultimodalMessage та абстракції провайдера → реалізація захопу, кодування та відправки → інтеграція потокового рендеринга ответу → тестування edge-cases (портрет/ландшафт, HDR, великі файли) → нагрузкове тестування (паралельні запити, скасування, reconnect) → вибиття та моніторинг через Firebase Crashlytics + кастомні eventos.
Сроки: MVP з базовим text+image введенням—1–2 тижні. Повна реалізація з потоком, кешем, обробкою помилок та підтримкою кількох провайдерів—3–5 тижнів у залежності від існуючої кодової бази.







