Flutter 中基于 Dio 实现 SSE 流式通信:从原理到实战
项目背景:AI 健康助手需要实时展示大模型的流式响应。我们选择了 SSE(Server-Sent Events)协议,并基于 Dio 构建了一套完整的 SSE 客户端。
一、为什么选 SSE 而不是 WebSocket?
| 维度 | SSE | WebSocket |
|---|---|---|
| 方向 | 服务端 → 客户端(单向) | 双向 |
| 协议 | HTTP 长连接 | WS 协议(需单独握手) |
| 断线重连 | 浏览器原生支持;Dio 侧可自行实现 | 需手动实现 |
| 适用场景 | AI 流式输出、新闻推送、实时报价 | IM 聊天、协作编辑、游戏 |
| HTTP/2 | 多路复用,同域多 SSE 共用连接 | 独立连接 |
AI 聊天场景的本质是:客户端发送一个请求,服务端以 token 粒度持续推流。单向流 + HTTP 原生 = SSE 天然适配。
二、核心挑战
Dio 的常规用法是「发请求 → 等完整响应 → 解析 JSON」。SSE 要求的是「发请求 → 持续读取流 → 逐事件解析」。
关键改造点:
- 让 Dio 返回原始字节流,而非缓冲整个响应体
- 处理 UTF-8 跨 chunk 拆分,一个中文字符可能被拆到两个 chunk 中
- 处理 SSE 事件跨 chunk 拆分 ,
data:和空行可能不在同一个 chunk - 断线自动重连,指数退避 + 最大延迟
三、架构总览
markdown
┌─────────────────────────────────────────────────┐
│ UI / Controller │
│ StreamUpdateManager → 打字机效果 + 防抖 │
└──────────────────────┬──────────────────────────┘
│ SSEEvent stream
┌──────────────────────▼──────────────────────────┐
│ DioSSEClient │
│ ┌────────────┐ ┌───────────┐ ┌─────────────┐ │
│ │ 跨 chunk │ │ SSE 事件 │ │ 自动重连 │ │
│ │ UTF-8 解码 │ │ 解析器 │ │ 指数退避 │ │
│ └────────────┘ └───────────┘ └─────────────┘ │
└──────────────────────┬──────────────────────────┘
│ ResponseType.stream
┌──────────────────────▼──────────────────────────┐
│ Dio (HTTP) │
│ httpDio / dioWithHeaderToken │
│ + RequestHeaderInterceptor │
│ + TokenRefreshInterceptor │
└─────────────────────────────────────────────────┘
四、核心实现详解
4.1 让 Dio 返回原始流
SSE 的第一个关键:将 Dio 的 responseType 设为 ResponseType.stream。此时 response.data 不再是 Map,而是 ResponseBody------一个包含 Stream<List<int>> 的对象。
dart
final options = Options(
headers: {
'Accept': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
responseType: ResponseType.stream,
receiveTimeout: const Duration(hours: 1), // SSE 长连接,需大超时
);
final response = await dio.post(url, data: body, options: options);
final responseBody = response.data as ResponseBody;
// 逐 chunk 读取字节流
await for (final chunk in responseBody.stream) {
_processRawChunk(chunk, ...);
}
踩坑点 :Dio 默认的
receiveTimeout是 60 秒,SSE 连接可能持续几分钟甚至更久。必须延长超时,否则连接会在 60 秒后被强制中断。
4.2 跨 Chunk UTF-8 解码
TCP 层面没有"消息边界"的概念。服务端推来的 List<int> chunk 可能在任意字节位置断开。如果一个 3 字节的 UTF-8 字符(如中文)刚好跨了两个 chunk,直接 utf8.decode(chunk) 会抛 FormatException。
解决方案:维护一个原始字节缓冲区,遇到解码失败时回退到最后一个合法的 UTF-8 前缀位置:
dart
final Map<String, List<int>> _rawBuffers = {};
void _processRawChunk(String connectionId, List<int> chunk, ...) {
// 追加到已有的字节缓冲区
final buffer = (_rawBuffers[connectionId] ?? <int>[])..addAll(chunk);
// 尝试解码:从末尾逐步回退,找到最长的合法 UTF-8 前缀
String decoded = '';
int validLength = buffer.length;
while (validLength > 0) {
try {
decoded = utf8.decode(buffer.sublist(0, validLength));
break;
} on FormatException {
validLength--;
}
}
if (validLength == 0) {
// 还没有足够的合法字节,等下一个 chunk
_rawBuffers[connectionId] = buffer;
return;
}
// 保存未解码的尾部(不完整的多字节序列),留给下一个 chunk
if (validLength < buffer.length) {
_rawBuffers[connectionId] = buffer.sublist(validLength);
} else {
_rawBuffers.remove(connectionId);
}
_parseSSEText(connectionId, decoded, ...);
}
4.3 SSE 事件解析器
SSE 协议格式很简洁:
vbnet
id: 123
event: message
data: {"token": "你好"}
: 这是注释
但难点在于:同一个 chunk 中可能包含多个事件,也可能只包含半个事件。比如:
swift
chunk1: "id: 1\ndata: Hel"
chunk2: "lo world\n\n"
需要跨 chunk 累积字段:
dart
class _ParseState {
String textBuffer = ''; // 上一个 chunk 残留的不完整行
String? id;
String? event;
String? data;
int? retry;
}
void _parseSSEText(String connectionId, String text, ...) {
final state = _parseStates[connectionId] ?? _ParseState();
_parseStates[connectionId] = state;
// 合并上一个 chunk 残留的文本
final fullText = state.textBuffer + text;
final lines = fullText.split('\n');
// 输入不以换行结尾时,最后一行是不完整的------留给下一个 chunk
if (!text.endsWith('\n')) {
state.textBuffer = lines.last;
lines.removeLast();
} else {
state.textBuffer = '';
}
for (final line in lines) {
if (line.isEmpty || line.startsWith(':')) {
// 空行 → 事件分隔符,分发已累积的事件
if (state.data != null || state.event != null || state.id != null) {
final parsedData = _tryParseJson(state.data);
controller.add(SSEEvent(id: state.id, event: state.event, data: parsedData));
state.id = null;
state.event = null;
state.data = null;
}
continue;
}
final colonIndex = line.indexOf(':');
if (colonIndex == -1) continue;
final field = line.substring(0, colonIndex);
final value = line.substring(colonIndex + 1).trim();
switch (field) {
case 'id':
state.id = value;
case 'event':
state.event = value;
case 'data':
// 多行 data 用换行拼接
state.data = state.data == null ? value : '${state.data}\n$value';
case 'retry':
state.retry = int.tryParse(value);
}
}
}
4.4 断线自动重连
连接异常时自动重连,使用指数退避(exponential backoff),避免重试风暴:
dart
Future<void> _startStreaming(
String connectionId, SSERequest request, StreamController controller,
CancelToken cancelToken, [int retryCount = 0],
) async {
try {
// ... 正常连接逻辑
} on DioException catch (e) {
if (request.retry && retryCount < request.maxRetries) {
final nextRetry = retryCount + 1;
// 指数退避:500ms → 1s → 2s → 4s → ... → 10s
final delayMs = min(
SSEConfig.initialRetryDelay.inMilliseconds * (1 << (nextRetry - 1)),
SSEConfig.maxRetryDelay.inMilliseconds,
);
await Future.delayed(Duration(milliseconds: delayMs));
// 创建新的 CancelToken,递归重试
final newCancelToken = CancelToken();
_cancelTokens[connectionId] = newCancelToken;
await _startStreaming(connectionId, request, controller, newCancelToken, nextRetry);
return;
}
// 重试耗尽,包装为 SERetryExhaustedException
final sseError = SERetryExhaustedException(
message: e.message ?? 'Network error',
attemptCount: retryCount,
maxRetries: request.maxRetries,
cause: _mapDioErrorToSSEException(e),
);
controller.addError(sseError);
}
}
重试策略配置:
dart
abstract final class SSEConfig {
static const int maxRetries = 5;
static const Duration initialRetryDelay = Duration(milliseconds: 500);
static const Duration maxRetryDelay = Duration(seconds: 10);
static const bool enableAutoRetry = true;
}
4.5 异常分类体系
不同异常需要不同的处理策略。用 sealed class + 模式匹配:
dart
sealed class SSEException implements Exception {
const SSEException({required this.message, this.cause});
final String message;
final Object? cause;
}
final class SENetworkException extends SSEException { ... } // 断网、DNS 失败
final class SEHttpStatusException extends SSEException { ... } // 401/403/500
final class SETimeoutException extends SSEException { ... } // 连接超时
final class SECancelledException extends SSEException { ... } // 主动取消
final class SEParseException extends SSEException { ... } // JSON 解析失败
final class SEBusinessException extends SSEException { ... } // 业务错误码
final class SERetryExhaustedException extends SSEException { ... } // 重试耗尽
final class SEUnexpectedException extends SSEException { ... } // 未知兜底
Controller 中可以精确匹配:
dart
onError: (error) {
if (error is SENetworkException) {
_showNetworkError();
} else if (error is SERetryExhaustedException) {
_showRetryExhausted(error.attemptCount);
} else {
_showGenericError();
}
}
五、UI 层:打字机效果
SSE 数据到 UI 之间还有最后一道关卡:高频更新导致的掉帧 。AI 模型可能每秒推 20+ 个 token,如果每个 token 都触发 setState/update(),界面会抖动。
StreamUpdateManager 的核心思路:
- 增量计算 :只取新到达的内容(
fullContent.length - lastReceived.length),追加到待显示队列 - 首屏快速响应:前 100ms 内积累够 50 个字符就立即刷新
- 双阶段定时器:快速阶段(30ms 间隔)→ 稳定阶段(80ms 间隔)
- 打字机效果:每次只显示 15 个字符,产生渐进的视觉效果
dart
class StreamUpdateManager {
void addChunk(String fullContent, String fullThinkContent) {
// 计算增量
final contentDelta = fullContent.substring(_lastReceivedContent.length);
_pendingContent += contentDelta;
_lastReceivedContent = fullContent;
// 首屏强制刷新
if (_isFastPhase && _shouldForceFirstScreen()) {
_flush();
return;
}
// 等待定时器触发
if (_updateTimer == null) _scheduleUpdate();
}
void _flush() {
// 每次显示 charsPerUpdate 个字符
final contentToShow = _pendingContent.substring(0, min(_pendingContent.length, charsPerUpdate));
_pendingContent = _pendingContent.substring(contentToShow.length);
_displayedContent += contentToShow;
onUpdate(_displayedContent, _displayedThinkContent);
}
}
六、完整使用示例
dart
// 1. 获取 SSE 客户端实例
final sseClient = DioSSEClient(DioProvider.dioWithHeaderToken);
// 2. 构造请求
final request = SSERequest(
url: 'https://api.example.com/chat/completions',
method: 'POST',
headers: {'Authorization': 'Bearer $token'},
data: {'messages': [{'role': 'user', 'content': '你好'}]},
onData: (event) async {
final data = event.data; // 已自动解析为 Map
final token = data['choices'][0]['delta']['content'];
// 更新 UI
_updateContent(token);
},
onError: (error) => _handleError(error),
onDone: () => _onStreamComplete(),
retry: true,
);
// 3. 连接并监听
final stream = sseClient.connect('chat-1', request);
final subscription = stream.listen((event) { /* 备用监听 */ });
// 4. 页面销毁时关闭
sseClient.close('chat-1');
七、关键设计决策
7.1 为什么不用 flutter_http_sse?
项目之前使用的是自定义 fork 的 flutter_http_sse。痛点:
- 无法复用 Dio 拦截器 :
RequestHeaderInterceptor(自动附加 Token)、TokenRefreshInterceptor(401 自动刷新)都无法生效 - 异常处理粗糙:没有异常分类,难以做精细化的 UI 提示
自建 DioSSEClient 后,这些拦截器自动生效,且与项目现有网络层完全统一。
7.2 sseClientProvider vs DioProvider.dioWithHeaderToken
dart
// 无认证的 SSE 客户端(sseClientProvider)
DioSSEClient sseClient → httpDio → 无 Token
// 有认证的 SSE 客户端(Controller 中直接创建)
DioSSEClient sseClient → dioWithHeaderToken → RequestHeaderInterceptor + TokenRefreshInterceptor
AI 聊天需要认证,所以 Controller 中直接用 dioWithHeaderToken 构造。sseClientProvider 的无认证实例保留给不需要 Token 的场景(如公共信息流)。
7.3 CancelToken 的生命周期
每个 SSE 连接对应一个 CancelToken。重试时需要新建一个 CancelToken,因为已取消的 CancelToken 不能复用:
dart
// 错误:用同一个 CancelToken 重试,会立即被取消
await _startStreaming(..., oldCancelToken, retryCount);
// 正确:重试前创建新的 CancelToken
final newCancelToken = CancelToken();
_cancelTokens[connectionId] = newCancelToken;
await _startStreaming(..., newCancelToken, retryCount);
八、踩坑清单
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 中文乱码 | UTF-8 多字节字符被 chunk 截断 | 维护字节缓冲区,回退到合法 UTF-8 前缀 |
| 事件丢失 | SSE 事件跨 chunk,直接按行解析会丢数据 | _ParseState 持久化解析状态 |
| 60 秒断开 | Dio 默认 receiveTimeout 太短 |
设为 1 小时 |
| 高频更新掉帧 | 每个 token 都触发 UI 更新 | StreamUpdateManager 批处理 + 打字机效果 |
| 重试风暴 | 失败后立即重试 | 指数退避,上限 10 秒 |
| 重试不生效 | CancelToken 已取消后复用 |
每次重试创建新 CancelToken |
| 认证头丢失 | 用了无认证的 httpDio |
用 dioWithHeaderToken 或手动拼 header |
九、总结
用 Dio 实现 SSE 的核心就三件事:
responseType: ResponseType.stream--- 让 Dio 返回原始字节流- 跨 chunk 解码 + 跨 chunk 事件解析 --- 处理 TCP 分片
- 指数退避重连 --- 网络波动时自动恢复
整个 DioSSEClient 约 480 行代码,零外部依赖(只依赖 Dio),与项目现有网络层完全兼容------拦截器、认证、日志全部复用。