Реалізація потокового ответа AI у мобільному додатку
Без потока AI-асистент неприйнятний для користувачів. Очікування 5–10 секунд пустого екрана перед появою відповіді—це не «повільно», це «зламано». Потік через Server-Sent Events або WebSocket дає перший токен за 300–600 мс, і користувач бачить, що модель «думає». Технічно просто—складність у правильній обробці потока на мобілі без артефактів рендеру.
iOS: AsyncBytes та SSE-парсинг
Більшість LLM API повертають потік через SSE (Server-Sent Events)—текстовий протокол поверх HTTP. Кожна подія: рядок 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: пакет http не підтримує SSE. Використовуйте dio з ResponseType.stream або dart:io HttpClient прямо.
Рендер тексту під час потока
Тут найчастіше ошибаються: якщо у відповіді є Markdown (жирний, код, списки), рендеріть обережно. Проблема: Markdown-парсер бачить незавершені конструкції—наприклад, **жирний без закривающого **—і рендерить артефакти.
Два підходи:
- Рендерити тільки завершені блоки—буфер накопичує до закриваючого токена, потім рендерить. Чистий результат, додає затримку.
- Рендерити як простий текст під час потока, 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 не підтримує відновлення з середини. Потрібно генерувати заново, передавши в контекст уже отриману частину.
Кошторис строків
Потоковий клієнт з правильним рендером, скасуванням та обробкою помилок—4–6 днів для однієї платформи, 1–1,5 тижні для обох.







