Без стрімінгу AI-асистент неприйнятний для користувачів. Очікування 5–10 секунд порожнього екрана перед появою відповіді — це не «повільно», це «зламано». Згідно з дослідженням користувацького досвіду, затримка більше 2 секунд знижує залученість на 40% і збільшує відтік на 25%. Наші інженери вирішують це завдання за допомогою Server-Sent Events (SSE) або WebSocket: перший токен приходить через 300–600 мс, користувач бачить, що модель «думає». Ми реалізуємо побуквенний вивід тексту з урахуванням особливостей мобільних платформ — iOS, Android та Flutter. У цій статті розберемо ключові технічні аспекти: від парсингу SSE до рендерингу Markdown без артефактів. Наша команда має 10+ років досвіду в мобільній розробці та реалізувала 500+ проєктів, що гарантує стабільність рішення.
Як реалізувати потоковий відповідь AI у мобільному додатку?
Проблеми потокового відповіді AI у мобільному додатку
Стрімінг AI-відповіді — не просто відкрити socket. Ось реальні складнощі:
- Парсинг SSE-потоку на мобільному клієнті: iOS вимагає
AsyncBytes, Android — OkHttp +callbackFlow. Помилка в парсингу призводить до втрати даних або зависання. - Рендеринг незавершеного Markdown: якщо відповідь містить
**жирний текст, а клієнт рендерить його до закриваючого**, з'являються артефакти. Ми використовуємо буферизацію або відкладений рендеринг. - Скасування запиту: користувач натиснув «Стоп» — потрібно коректно перервати потік і зберегти вже отриманий текст в історію діалогу. На iOS
Task.cancel()автоматично скасовуєfor await, на Android —call.cancel(). - Розриви з'єднання: мобільна мережа нестабільна. При обриві ми зберігаємо частковий відповідь і пропонуємо «Продовжити», надсилаючи новий запит із контекстом.
Як працює парсинг SSE на iOS?
Більшість LLM API віддають стрімінг через SSE (визначення в MDN). Кожна подія — рядок data: {json}, порожній рядок — роздільник. Нативний спосіб на iOS — URLSession + AsyncBytes (iOS 15+):
func streamCompletion(request: URLRequest) -> AsyncThrowingStream<String, Error> {
AsyncThrowingStream { continuation in
Task {
let (bytes, response) = try await URLSession.shared.bytes(for: request)
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
continuation.finish(throwing: APIError.badStatus)
return
}
for try await line in bytes.lines {
guard line.hasPrefix("data: ") else { continue }
let payload = String(line.dropFirst(6))
guard payload != "[DONE]" else {
continuation.finish()
return
}
if let data = payload.data(using: .utf8),
let chunk = try? JSONDecoder().decode(StreamChunk.self, from: data),
let delta = chunk.choices.first?.delta.content {
continuation.yield(delta)
}
}
}
}
}
Використання в ViewModel:
func sendMessage(_ text: String) {
Task { @MainActor in
currentResponse = ""
for try await token in streamCompletion(request: buildRequest(text)) {
currentResponse += token
}
}
}
@MainActor гарантує оновлення UI на головному потоці без явного DispatchQueue.main.async.
Android: OkHttp + EventSource
На Android нативного SSE-клієнта немає. OkHttp — стандартний вибір:
class SSEClient(private val client: OkHttpClient) {
fun stream(request: Request): Flow<String> = callbackFlow {
val call = client.newCall(request)
call.enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
response.body?.source()?.let { source ->
while (!source.exhausted()) {
val line = source.readUtf8Line() ?: break
if (line.startsWith("data: ")) {
val payload = line.removePrefix("data: ")
if (payload == "[DONE]") {
close()
return
}
// parse JSON, extract delta
trySend(extractDelta(payload))
}
}
}
close()
}
override fun onFailure(call: Call, e: IOException) = close(e)
})
awaitClose { call.cancel() }
}
}
callbackFlow — правильний спосіб перетворити callback-based OkHttp у Kotlin Flow. trySend замість send — не блокує потік.
Для Flutter: використовуємо dio з ResponseType.stream або dart:io HttpClient безпосередньо.
Чому важливо буферизувати Markdown?
Якщо у відповіді є Markdown (жирний, код, списки), рендерити потрібно акуратно. Проблема: Markdown-парсер бачить незавершені конструкції — наприклад, **жирний без закриваючого ** — і рендерить артефакти.
Два підходи:
- Рендерити лише завершені блоки — буфер накопичує до закриваючого токена, потім рендерить. Дає чистий результат, але додає затримку.
- Рендерити як plain text під час стрімінгу, Markdown — після завершення — простіше та надійніше для більшості асистентів.
На iOS — AttributedString з NSMarkdownParser для фінального рендеру, Text(currentResponse) під час стрімінгу. На Android — Markwon бібліотека для фінального рендеру в TextView.
Скасування запиту та відновлення після збоїв
Користувач натиснув «Стоп» — потрібно коректно скасувати стрімінговий запит. На iOS: Task.cancel() автоматично скасовує URLSession.bytes — for await викине CancellationError. На Android: call.cancel() через OkHttp, flow.cancellation(). Після скасування ми обов'язково зберігаємо вже отриманий частковий відповідь в історію діалогу — користувач бачив текст, і він має залишитися.
Мобільна мережа нестабільна. Стрімінговий запит переривається на середині відповіді. Правильна реакція: показати те, що вже отримано, і запропонувати «Продовжити». Зберегти lastTokenIndex або останній stop_reason не можна — API не підтримує відновлення з середини. Потрібно генерувати заново, передавши в контекст уже отриману частину відповіді.
Порівняння протоколів: SSE vs WebSocket
| Критерій | SSE | WebSocket |
|---|---|---|
| Напрямок | Сервер → клієнт | Двосторонній |
| Повторне з'єднання | Вбудовано (EventSource) | Потрібно реалізувати |
| Простота реалізації | Висока | Середня |
| Підтримка на mobile | iOS: AsyncBytes, Android: OkHttp | Всі платформи |
| Потокова передача бінарних даних | Ні | Так |
Для AI-стрімінгу SSE достатньо. WebSocket виправданий, якщо потрібен двосторонній зв'язок (наприклад, стрімінг аудіо + текст).
Порівняння реалізації на платформах
| Платформа | Спосіб | Бібліотека | Ключовий клас |
|---|---|---|---|
| iOS | AsyncBytes | URLSession | AsyncThrowingStream |
| Android | OkHttp + callbackFlow | OkHttp | Flow<String> |
| Flutter | потоковий HTTP | dio / HttpClient | Stream<String> |
Етапи реалізації потокового відповіді під ключ
- Аналітика: вибір протоколу та архітектури, проектування API-запитів.
- Реалізація клієнта: парсинг SSE, керування станом, скасування запиту.
- Рендеринг: налаштування відображення тексту, підтримка Markdown.
- Тестування: перевірка стабільності на слабких мережах (3G, Edge), edge-кейси.
- Деплой: інтеграція в існуючий додаток, публікація в App Store / Google Play.
Що входить у роботу
- Вихідний код модуля стрімінгу для iOS / Android / Flutter.
- Інтеграція з обраним LLM API (OpenAI, Anthropic, локальна модель).
- Документація з доробок та підтримки.
- Налаштування аналітики для відстеження помилок та затримок.
- Навчання команди замовника.
Орієнтовні строки: 4–6 робочих днів на одну платформу, 1–1,5 тижні на обидві. Для Flutter — 5–7 днів. Вартість розраховується індивідуально, економія коштів від впровадження досягає 30–50%.
Наша команда має 10+ років досвіду в мобільній розробці та реалізувала 500+ проєктів з аудиторією від 100 000 користувачів. Гарантуємо стабільну роботу навіть при нестабільному з'єднанні. Якщо у вас є питання або ви хочете замовити реалізацію, зв'яжіться з нами — оцінимо проєкт безкоштовно. Отримайте консультацію вже сьогодні.







