基于 Dio 实现 SSE 流式通信

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 要求的是「发请求 → 持续读取流 → 逐事件解析」。

关键改造点:

  1. 让 Dio 返回原始字节流,而非缓冲整个响应体
  2. 处理 UTF-8 跨 chunk 拆分,一个中文字符可能被拆到两个 chunk 中
  3. 处理 SSE 事件跨 chunk 拆分data: 和空行可能不在同一个 chunk
  4. 断线自动重连,指数退避 + 最大延迟

三、架构总览

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 的核心思路:

  1. 增量计算 :只取新到达的内容(fullContent.length - lastReceived.length),追加到待显示队列
  2. 首屏快速响应:前 100ms 内积累够 50 个字符就立即刷新
  3. 双阶段定时器:快速阶段(30ms 间隔)→ 稳定阶段(80ms 间隔)
  4. 打字机效果:每次只显示 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 的核心就三件事:

  1. responseType: ResponseType.stream --- 让 Dio 返回原始字节流
  2. 跨 chunk 解码 + 跨 chunk 事件解析 --- 处理 TCP 分片
  3. 指数退避重连 --- 网络波动时自动恢复

整个 DioSSEClient 约 480 行代码,零外部依赖(只依赖 Dio),与项目现有网络层完全兼容------拦截器、认证、日志全部复用。

相关推荐
ZC跨境爬虫1 小时前
跟着 MDN 学 HTML day_40:(DOMImplementation 接口完全解析)
前端·ui·html·媒体
Highcharts.js1 小时前
Highcharts 纯 JavaScript 图表库深度使用评测
开发语言·前端·javascript·功能测试·ecmascript·highcharts·技术评测
码码哈哈0.01 小时前
基于 RSA 非对称加密与挑战码机制的前端登录安全方案
前端·安全·状态模式
ZC跨境爬虫1 小时前
跟着 MDN 学 HTML day_39:(DOMException 异常接口完全解析)
前端·javascript·html·媒体
渐儿2 小时前
NestJS 教程 Part 2 — 数据层、API 设计与业务异步
前端
渐儿2 小时前
Next.js 教程 Part 2 — 数据获取、Server Actions 与状态
前端
用户125758524362 小时前
XYGo Admin ArtTable 表格组件:一行代码搞定加载、刷新与分页
前端
gogoing2 小时前
Prettier 配置说明
前端·javascript