Реализация потокового ответа AI (Streaming Response) в мобильном приложении
Без стриминга 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-парсер видит незавершённые конструкции — например, **жирный без закрывающего ** — и рендерит артефакты.
Два подхода:
- Рендерить только завершённые блоки — буфер накапливает до закрывающего токена, потом рендерит. Даёт чистый результат, но добавляет задержку.
- Рендерить как 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 не поддерживает возобновление с середины. Нужно генерировать заново, передав в контекст уже полученную часть ответа.
Ориентиры по срокам
Стриминговый клиент с правильным рендером, отменой и обработкой ошибок — 4–6 дней для одной платформы, 1–1,5 недели для обеих.







