OC HTTP SSE客户端

使用AFNETWORK进行SSE请求时返回的Data是一整包数据,导致并没有按照期待的蹦蹦跳跳的效果来显示UI~用户体验就很差劲,特此实现一套SSE请求类库。

一、SSE 协议概述

Server-Sent Events(SSE) 是 HTML5 标准中定义的一种基于 HTTP 的服务器向客户端单向推送实时数据的协议。其核心特性包括:

  • 单向通信:服务器主动推送数据至客户端,客户端无需轮询;
  • 自动重连:连接中断后客户端自动尝试恢复;
  • 文本流式传输 :数据格式为 text/event-stream,支持纯文本消息;
  • 事件类型支持 :可自定义事件名称(如 event: update)。

二、SSE 与其他技术的对比

特性 SSE WebSocket 长轮询
协议 HTTP 自定义 TCP 协议 HTTP
通信方向 单向(服务器 → 客户端) 双向(服务器 ↔ 客户端) 单向(请求-响应)
复杂度 简单,无需握手 复杂,需维护长连接 高延迟,频繁请求
兼容性 主流浏览器支持(IE 不支持) 广泛支持(含旧版本) 完全兼容
适用场景 流式输出、通知推送 实时游戏、协作编辑 旧系统兼容

典型选择建议

  • 大模型流式输出(如 ChatGPT 的逐字生成)首选 SSE ;
  • 双向高频交互(如在线游戏)使用 WebSocket;
  • 兼容性优先(如企业内网)采用长轮询。

说白了,就是他本质还是一个HTTP,只是建立连接后,服务端可以持续推数据流过来。而一般的HTTP请求到响应一次性完结结束。

头文件:NSHttp2SSELInk.h

objectivec 复制代码
//
//  NSHttp2SSELInk.h
//  QGB_IM2
//
//  Created by carbonzhao on 2026/6/9.
//

#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN
// SSE 事件模型
@interface SSEEvent : NSObject
@property (nonatomic, copy, nullable) NSString *eventId;
@property (nonatomic, copy, nullable) NSString *eventName;
@property (nonatomic, copy) NSString *data;
@property (nonatomic, assign) NSInteger retryInterval; // 毫秒
@end

@interface NSHttp2SSELInk : NSObject
@property (nonatomic, strong, readonly) NSString *url;
@property (nonatomic, strong, readonly) NSMutableDictionary *headers;
@property (nonatomic, strong, readonly) NSMutableDictionary *bodyData;
@property (nonatomic, strong, readonly) NSMutableDictionary *urlParams;

- (instancetype)initWithURL:(NSString *)url;
- (instancetype)initWithURL:(NSString *)url headers:(NSMutableDictionary *)headers bodyData:(NSMutableDictionary *)bodyData urlParams:(NSMutableDictionary *)urlParams;
//data sign ,when value is not null,you need implent ApiDataSign callback
- (NSHttp2SSELInk *)signKey:(NSString *)akey;

- (void)connect:(void (^)(void))connectedBlock receiveEventBlock:(void (^)(SSEEvent *event))receiveEventBlock failWithErrorBlock:(void (^)(NSError *NSError))failWithErrorBlock disConnectBlock:(void (^)(void))connectedBlock;

- (void)disconnect;

@end

NS_ASSUME_NONNULL_END

实现文件NSHttp2SSELInk.m

objectivec 复制代码
//
//  NSHttp2SSELInk.m
//  QGB_IM2
//
//  Created by carbonzhao on 2026/6/9.
//

#import "NSHttp2SSELInk.h"
#import <UIKit/UIKit.h>
#import <Foundation/Foundation.h>
#import "NSExtentionSloter.h"
#import "ApiDataSign.h"
#import "march.h"

@implementation SSEEvent


@end

@interface NSHttp2SSELInk () <NSURLSessionDataDelegate>

@property (nonatomic, strong) NSURLSession *session;
@property (nonatomic, strong) NSURLSessionDataTask *crrTask;
@property (nonatomic, strong) NSMutableData *buffer;
@property (nonatomic, copy) NSString *lastEventId;
@property (nonatomic, assign) BOOL isCancelled; // 标记是否主动取消

@property (nonatomic, strong) NSString *signKey;
@property (nonatomic, strong) NSString *url;

@property (nonatomic, copy) dispatch_block_t didConnectedBlock;
@property (nonatomic, copy) void (^didReceivedEventBlock)(SSEEvent *event);
@property (nonatomic, copy) void (^didErrorBlock)(NSError *error);
@property (nonatomic, copy) dispatch_block_t didDisConnectBlock;

@end

@implementation NSHttp2SSELInk

- (instancetype)initWithURL:(NSString *)url {
    self = [super init];
    if (self) {
        _url = url;
        _buffer = [NSMutableData data];
        _isCancelled = NO;
    }
    return self;
}


- (instancetype)initWithURL:(NSString *)url headers:(NSMutableDictionary *)headers bodyData:(NSMutableDictionary *)bodyData urlParams:(NSMutableDictionary *)urlParams
{
    self = [self initWithURL:url];
    
    if (self)
    {
        _headers = [NSMutableDictionary dictionaryWithDictionary:headers];
        _bodyData = [NSMutableDictionary dictionaryWithDictionary:bodyData];
        _urlParams = [NSMutableDictionary dictionaryWithDictionary:urlParams];
    }
    return self;
}

- (void)connect:(void (^)(void))connectedBlock receiveEventBlock:(void (^)(SSEEvent *event))receiveEventBlock failWithErrorBlock:(void (^)(NSError *NSError))failWithErrorBlock disConnectBlock:(void (^)(void))disConnectBlock
{
    self.didConnectedBlock = connectedBlock;
    self.didReceivedEventBlock = receiveEventBlock;
    self.didErrorBlock = failWithErrorBlock;
    self.didDisConnectBlock = disConnectBlock;
    
    [self connect];
}


- (void)connect{
    if (!self.crrTask)
    {
        // 避免重复连接
        self.isCancelled = NO;
        
        // 1. 配置 Session
        NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration];
        config.requestCachePolicy = NSURLRequestReloadIgnoringLocalCacheData;
        config.URLCache = nil;
        
        // 2. 创建带有 Delegate 的 Session
        // 注意:delegateQueue 设为 nil 表示使用内部串行队列,或者指定 mainQueue 以便直接更新 UI
        self.session = [NSURLSession sessionWithConfiguration:config delegate:self delegateQueue:[NSOperationQueue mainQueue]];
        
        NSString *url = self.url;
        NSMutableDictionary *headerValue = self.headers;
        NSMutableDictionary *requestParams = self.urlParams;
//以上为数据签名过程类,若用到可留言索取,亦可按照你们后端要求自行开发,若不需要则将整个IF块删除
        if (self.signKey)
        {
            ApiDataSignEntity *et = [[ApiDataSignEntity alloc] init];
            [et setUrl:url];
            [et setMethod:@"get"];
            [et setHeaders:headerValue];
            [et setParams:requestParams];
            [ApiDataSign callSign:self.signKey requestData:et];
            
            url = et.url;
            requestParams = et.params;
            headerValue = et.headers;
        }
        
        if (requestParams && [requestParams allKeys].count > 0)
        {
            NSArray *keys = [requestParams allKeys];
            NSArray *sortedArray = [keys sortedArrayUsingComparator:^NSComparisonResult(id obj1, id obj2)
                                    {
                return [obj1 compare:obj2 options:NSNumericSearch];
            }];
            
            NSString *fstr = nil;
            for (NSString *key in sortedArray)
            {
                NSString *value = [requestParams stringForKey:key];
                value = [self urlEncoded:value];
                if (!fstr)
                {
                    fstr = [NSString stringWithFormat:@"%@=%@",key,value];
                }
                else
                {
                    fstr = [fstr stringByAppendingFormat:@"&%@=%@",key,value];
                }
            }
            
            url = [url stringByAppendingFormat:@"?%@",fstr];
        }
        // 3. 构建 Request
        self.url = url;
        NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:url]];
        request.HTTPMethod = @"GET";
        //    if (headerValue && headerValue.allKeys.count > 0)
        //    {
        //        [headerValue enumerateKeysAndObjectsUsingBlock:^(id  _Nonnull key, id  _Nonnull obj, BOOL *_Nonnull stop)
        //        {
        //            [request setValue:obj forHTTPHeaderField:key];
        //        }];
        //    }
        [request setValue:@"text/event-stream" forHTTPHeaderField:@"Accept"];
        [request setValue:@"no-cache" forHTTPHeaderField:@"Cache-Control"];
        [request setValue:@"text/event-stream" forHTTPHeaderField:@"Content-Type"];
        // 如果有上次的事件 ID,添加 Last-Event-ID 头以实现断点续传
        if (self.lastEventId.length > 0) {
            [request setValue:self.lastEventId forHTTPHeaderField:@"Last-Event-ID"];
        }
        
        // 4. 启动任务
        self.crrTask = [self.session dataTaskWithRequest:request];
        [self.crrTask resume];
    }
}


//data sign ,when value is not null,you need implent ApiDataSign callback
- (NSHttp2SSELInk *)signKey:(NSString *)akey
{
    self.signKey = akey;
    return self;
}

- (void)disconnect
{
    self.isCancelled = YES;
    [self.crrTask cancel];
    self.crrTask = nil;
    [self.session invalidateAndCancel];
    self.session = nil;
    [self.buffer setLength:0];
    
    if (self.didDisConnectBlock)
    {
        self.didDisConnectBlock();
    }
}

#pragma mark - URLSessionDataDelegate
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveResponse:(NSURLResponse *)response completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler
{
    // 校验 Content-Type
    if ([response.MIMEType isEqualToString:@"text/event-stream"])
    {
        completionHandler(NSURLSessionResponseAllow);
        if (self.didConnectedBlock) {
            self.didConnectedBlock();
        }
    }
    else
    {
        // 如果不是 SSE 流,取消任务
        DLog(@"sse ulr = %@",self.url);
        completionHandler(NSURLSessionResponseCancel);
        NSError *error = [NSError errorWithDomain:@"SSEClientError" code:-1 userInfo:@{NSLocalizedDescriptionKey: @"Invalid Content-Type"}];
        if (self.didErrorBlock)
        {
            self.didErrorBlock(error);
        }
    }
}

- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data {
    
    // 将新数据追加到缓冲区
    [self.buffer appendData:data];
    
    // 尝试解析缓冲区中的完整事件
    [self parseBuffer];
}

- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error {
    
    self.crrTask = nil;
    
    if (error) {
        // 如果是主动取消,不报错
        if (error.code != NSURLErrorCancelled) {
            if (self.didErrorBlock)
            {
                self.didErrorBlock(error);
            }
            
            // 这里可以实现简单的自动重连逻辑
            if (!self.isCancelled) {
                NSLog(@"SSE Connection lost. Reconnecting in 3 seconds...");
                dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
                    if (!self.isCancelled) {
                        [self connect];
                    }
                });
            }
        }
    }
    else
    {
        // 正常完成(服务器主动关闭连接)
        if (self.didDisConnectBlock)
        {
            self.didDisConnectBlock();
        }
    }
}

#pragma mark - Parsing Logic

- (void)parseBuffer {
    // 将缓冲区转换为字符串
    NSString *content = [[NSString alloc] initWithData:self.buffer encoding:NSUTF8StringEncoding];
    if (!content) return;
    
    // SSE 事件由 \n\n 分隔
    NSRange separatorRange = [content rangeOfString:@"\n\n"];
    
    while (separatorRange.location != NSNotFound)
    {
        // 提取一个完整的事件块
        NSString *eventBlock = [content substringToIndex:separatorRange.location];
        
        // 从缓冲区中移除已处理的部分
        NSUInteger nextStartIndex = separatorRange.location + separatorRange.length;
        NSData *remainingData = [[content substringFromIndex:nextStartIndex] dataUsingEncoding:NSUTF8StringEncoding];
        self.buffer = [NSMutableData dataWithData:remainingData ? remainingData : [NSData data]];
        
        // 解析单个事件块
        SSEEvent *event = [self parseEventBlock:eventBlock];
        if (event) {
            // 更新 Last-Event-ID
            if (event.eventId.length > 0) {
                self.lastEventId = event.eventId;
            }
            
            if (self.didReceivedEventBlock) {
                self.didReceivedEventBlock(event);
            }
        }
        
        // 继续检查缓冲区中是否还有下一个事件
        content = [[NSString alloc] initWithData:self.buffer encoding:NSUTF8StringEncoding];
        if (!content)
        {
            break;
        }
        separatorRange = [content rangeOfString:@"\n\n"];
    }
}

- (SSEEvent *)parseEventBlock:(NSString *)block {
    SSEEvent *event = [[SSEEvent alloc] init];
    event.data = @"";
    event.retryInterval = -1;
    
    NSArray *lines = [block componentsSeparatedByCharactersInSet:[NSCharacterSet newlineCharacterSet]];
    
    for (NSString *line in lines) {
        if (line.length == 0) continue;
        
        // 跳过注释行
        if ([line hasPrefix:@":"]) continue;
        
        // 分割 key 和 value
        NSRange colonRange = [line rangeOfString:@":"];
        if (colonRange.location == NSNotFound) continue;
        
        NSString *key = [line substringToIndex:colonRange.location];
        NSString *value = @"";
        
        // 处理 value:如果冒号后有空格,通常忽略第一个空格
        NSUInteger valueStartIndex = colonRange.location + 1;
        if (valueStartIndex < line.length) {
            if ([line characterAtIndex:valueStartIndex] == ' ') {
                valueStartIndex++;
            }
            if (valueStartIndex < line.length) {
                value = [line substringFromIndex:valueStartIndex];
            }
        }
        
        if ([key isEqualToString:@"id"]) {
            event.eventId = value;
        }
        else if ([key isEqualToString:@"event"]) {
            event.eventName = value;
        }
        else if ([key isEqualToString:@"data"]) {
            // data 字段可能有多行,需要拼接
            if (event.data.length > 0) {
                event.data = [event.data stringByAppendingString:@"\n"];
            }
            event.data = [event.data stringByAppendingString:value];
        }
        else if ([key isEqualToString:@"retry"]) {
            event.retryInterval = [value integerValue];
        }
    }
    
    // 如果只有空数据且无其他字段,视为无效事件
    if (event.data.length == 0 && !event.eventId && !event.eventName) {
        return nil;
    }
    
    return event;
}


- (NSString*)urlEncoded:(NSString *)string
{
    NSCharacterSet *charset = [[NSCharacterSet characterSetWithCharactersInString:@"!'();:@&=+$,/?%#[]^%"]invertedSet];
    NSString *encodedString = [string stringByAddingPercentEncodingWithAllowedCharacters:charset];
    return encodedString;
}
@end

演示效果:

演示代码:

objectivec 复制代码
NSString *url = [NSString stringWithFormat:@"%@chat/stream", [IMDataConfigTools instance].aiUrlApiConfigBlock()];
    NSMutableDictionary *params = [NSMutableDictionary dictionaryWithCapacity:0];
    [params setString:weakSelf.currentSession.sessionId forKey:@"sessionId"];
    [params setString:text forKey:@"message"];
    weakSelf.sseLink = [[NSHttp2SSELInk alloc] initWithURL:url headers:@{} bodyData:@{} urlParams:params];
    [weakSelf.sseLink signKey:@"ai"];
    [weakSelf.sseLink connect:^{
        NSLog(@"");
    } receiveEventBlock:^(SSEEvent * _Nonnull event) {
        NSLog(@"");
        if ([event.eventName isEqualToString:@"reasoning"])
        {
            if (!n.thinking || n.thinking.length == 0)
            {
                n.thinking = [NSString stringWithString:event.data];
            }
            else
            {
                n.thinking = [n.thinking stringByAppendingString:event.data];
            }
        }
        else if ([event.eventName isEqualToString:@"answer"])
        {
            if (!n.content || n.content.length == 0)
            {
                n.content = [NSString stringWithString:event.data];
            }
            else
            {
                n.content = [n.content stringByAppendingString:event.data];
            }
        }
        [n reset];
        [weakSelf.listView updateMessage:n withAnimation:NO completeBlock:^{
            
        }];
    } failWithErrorBlock:^(NSError * _Nonnull NSError) {
        failedBlock();
    } disConnectBlock:^{
        [n setIsThinking:NO];
        [weakSelf.listView updateMessage:n withAnimation:NO completeBlock:^{
            
        }];
        
        completeBlock();
    }];
相关推荐
人月神话-Lee2 小时前
WWDC26 深度解析:如何在 iOS 27 中打造“秒开”的相机体验
ios·swift·相机·wwdc·用户体验
2501_915909062 小时前
iOS IPA文件反编译与打包操作方法详解
android·ios·小程序·https·uni-app·iphone·webview
卡卡西Sensei2 小时前
2026 苹果 WWDC 完整总结
macos·ios·wwdc
weixin_427771613 小时前
http 请求body加密
网络·网络协议·http
健了个平_2416 小时前
iOS 27 适配笔记
ios·xcode·wwdc
Tr2e16 小时前
🐱 从 0 到 1:用 Swift 手搓一个 macOS 桌面宠物(附源码)
macos·ios·swift
iOS开发上架哦19 小时前
Jenkins 自动上传 IPA 到 App Store 把发布步骤融入 CI/CD
后端·ios
代码中介商20 小时前
HTTPS加密原理:图解安全传输全流程
网络协议·http·https
“初生”20 小时前
Codex 桌面端新会话 5 次 Reconnecting 怎么办?HTTP/SSE 完美修复方案(2026最新)
网络·网络协议·http