iOS 基于CocoaAsyncSocket(TCP)工具类封装(处理粘包、断包)

前言

做智能硬件开发的时候,app和硬件进行数据通信,一般用的是tcp通信,当频繁发送数据的时候会导致数据粘包,或者发送的数据比较大(图片、录音),会导致断包,该文章就是解决该问题。

粘包概念

当客户端同一时间发送几条数据,而服务端只能收到一条大数据(几条数据拼接在一起了)这就是所谓的粘包。

屏幕快照 2018-01-26 上午9.53.36.png

那为啥会出现粘包问题呢?这是因为tcp使用了优化方法(Nagle算法),有兴趣的可以去百度。该优化方法将多次间隔较小的且数据量较小的消息合并成一个大的数据块,然后进行封包。这么做的目的是为了减少广域网的小分组数目,从而减小网络拥塞的出现。

断包概念

断包就是我们发送一条很大的数据包,类似图片和语音,显然一次发送或者读取数据的缓冲区大小是有限的,所以我们会分段去发送和读取数据。

屏幕快照 2018-01-26 上午9.59.27.png

解决方案

无论是粘包还是断包,如果需要正确解析数据,必须和服务端商量好使用一种合理机制去解析数据,也就是定义好双方都认可的数据包格式:
1.发送数据方:封包的时候给每个数据包加一个数据长度(或者开始标记符)和消息类型(文本消息、图片...)。
2.接收数据方:拆包的时候根据数据长度后者结束符去拆分数据包。

基于CocoaAsyncSocket的封包、拆包处理

先来了解下下面几个方法:

//读取数据,有数据就会触发代理
- (void)readDataWithTimeout:(NSTimeInterval)timeout tag:(long)tag;
//直到读到这个长度的数据,才会触发代理
- (void)readDataToLength:(NSUInteger)length withTimeout:(NSTimeInterval)timeout tag:(long)tag;
//直到读到data这个边界,才会触发代理
- (void)readDataToData:(NSData *)data withTimeout:(NSTimeInterval)timeout tag:(long)tag;

这个框架每次读取数据必须调用上述这些方法,而我们大部分第一次tcp连接成功后会调用:

- (void)readDataWithTimeout:(NSTimeInterval)timeout tag:(long)tag;

之后每次收到消息,都会去调用一次上述方法,超时为-1,即设置不超时。这样每次收到消息都会触发读取消息的代理:

- (void)socket:(GCDAsyncSocket *)sock didReadData:(NSData *)data withTag:(long)tag

这么做显然没有考虑数据的拆包,如果我们一条一条的发送文字信息,自然没有问题。但是如果我们一次发送数条或者发送大图片,那么问题就出来了,我们解析出来的数据显然是不对的。这个时候需要另外两个read方法了,一个是读取到指定长度,一个是读取的指定边界。

我们通过自定义数据边界,去调用这两个方法,触发读取数据代理得到的数据才是正确的一个包的数据。

新建一个类ZWTCPManager继承NSObject, ZWTCPManager.h的内容如下:

#import <Foundation/Foundation.h>

#define IS_OPEN_DEBUG  0  //配置是否打印调试信息 1:打印信息 0:关闭打印信息

//tcp 服务端 IP、PORT 配置
#define TCP_HOST_IP @"172.20.20.105"
#define TCP_PORT 6969

//发送消息必须和后台商议,给每条发送的消息加上头部,字段如下,可以自定义扩展(为了解决tcp接收端数据粘包问题)
static NSString * const  kHeadMessageType = @"type";
static NSString * const  kHeadMessageSize = @"size";


//发送的消息类型,根据项目实际需求进行扩展
typedef enum : NSUInteger {
    ZWTcpSendMessageText,     //文本
    ZWTcpSendMessagePicture,  //图片
}ZWTcpSendMessageType;


//回调闭包
typedef void(^ResponseBlock)(NSData * responseData,ZWTcpSendMessageType type);

//tcp连接成功和失败代理
@protocol ZWTCPManagerDelegate <NSObject>
@required
-(void)tcpConnectedSuccess; //连接成功
-(void)tcpconnectedFailure; //连接失败
//收到tcp服务器主动发送的消息
-(void)recieveServerActiveReport:(NSData*)reportData MessageType:(ZWTcpSendMessageType)type;
@end

@interface ZWTCPManager : NSObject

@property (nonatomic,weak) id<ZWTCPManagerDelegate> delegate;//代理

+(instancetype)shareInstance;//单例

//连接tcp服务器
-(void)connectTcpServer;

//tcp重连接
-(void)reConnectTcpServer;

//断开tcp连接
-(void)disConnectTcpServer;

//tcp连接状态
-(BOOL)isTcpConnected;

//发送消息及后台是否回复当前消息,如果发送消息后台没有应答当前消息,请配置isAnser = NO;
-(void)sendData:(NSData*)data MessageType:(ZWTcpSendMessageType)messageType  Response:(ResponseBlock)block IsServerAnswer:(BOOL)isAnser;

@end

@interface BlockModel :NSObject<NSCopying>
@property (nonatomic,strong) NSDate * timeStamp;
@property (nonatomic,copy) ResponseBlock block;

代码内容都有注释,这里需要注意下,tcp通信格式需要和后台商量好,这样互相发送消息彼此才能正确解包。定义好的消息发送格式如下:


屏幕快照 2018-01-26 上午10.10.27.png

这个消息格式是需要客户端和服务端都需要遵守的约定,这样彼此作为接收方的时候才能正确解包。

-(void)sendData:(NSData*)data MessageType:(ZWTcpSendMessageType)messageType  Response:(ResponseBlock)block IsServerAnswer:(BOOL)isAnser;

这个方法提供了回调,一般发送消息有三种情况:1.客户端发送消息,服务端响应该消息,并且有回复。2.客户端发送消息,服务端没有回调。3.服务端主动发送消息给客户端。

变量isAnser就是用于配置服务端是否有返回的情况,服务端主动发送消息通过代理方法-(void)recieveServerActiveReport:(NSData*)reportData MessageType:提供接口供外届使用。

ZWTCPManager.m的内容如下:

@implementation BlockModel
- (id)copyWithZone:(NSZone *)zone {
    return self;
}
@end

@interface ZWTCPManager ()<GCDAsyncSocketDelegate>
{
    GCDAsyncSocket * gcdSocket;
    NSMutableArray * blockArr;
    NSDictionary * headDic;
}
@end

@implementation ZWTCPManager

+(instancetype)shareInstance
{
    static ZWTCPManager * instance = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        instance = [[ZWTCPManager alloc] init];
    });
    return instance;
}

-(instancetype)init
{
    if (self = [super init]) {
      blockArr = [NSMutableArray array];
    }
    return self;
}

//连接tcp服务器
-(void)connectTcpServer
{
    if (gcdSocket&&[gcdSocket isConnected]) {
        return;
    }
    gcdSocket = [[GCDAsyncSocket alloc] initWithDelegate:self delegateQueue:dispatch_get_main_queue()];
    
    NSError * err;
    [gcdSocket connectToHost:TCP_HOST_IP onPort:TCP_PORT error:&err];
    if (err) {
#if IS_OPEN_DEBUG
        NSLog(@"tcp connect server error:%@",err);
#endif
    }
}

//断开tcp连接
-(void)disConnectTcpServer
{
    if (gcdSocket && [gcdSocket isConnected]) {
        [gcdSocket disconnect];
    }
}

//tcp重连接
-(void)reConnectTcpServer
{
    if (gcdSocket&&[gcdSocket isConnected]) {
        [self disConnectTcpServer];
    }
    [self connectTcpServer];
}

//tcp连接状态
-(BOOL)isTcpConnected
{
    if (gcdSocket && [gcdSocket isConnected]) {
        return YES;
    }
    return NO;
}

//发送数据,数据类型可以是文本,图片,语音,文件...根据实际需要进行扩展
-(void)sendData:(NSData *)data MessageType:(ZWTcpSendMessageType)messageType Response:(ResponseBlock)block IsServerAnswer:(BOOL)isAnser
{
#if IS_OPEN_DEBUG
    if (messageType == ZWTcpSendMessageText) {
        NSString * content = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
        NSLog(@"tcp发送一次文本内容:%@",content);
    }else if (messageType == ZWTcpSendMessagePicture)
    {
        NSLog(@"tcp发送一次图片内容:%@",data);
    }
#endif
    if (![self isTcpConnected]) {
        
#if IS_OPEN_DEBUG
        NSLog(@"tcp unconnected,message send failure");
#endif
        return;
    }
    
    /*  数据组包   */
    NSUInteger contentSize = data.length;
    NSMutableDictionary * headDic = [NSMutableDictionary dictionary];
    [headDic setObject:[NSNumber numberWithInt:messageType] forKey:kHeadMessageType];
    [headDic setObject:[NSString stringWithFormat:@"%lu",(unsigned long)contentSize] forKey:kHeadMessageSize];
    NSString * headStr = [self dictionaryToJson:headDic];
    NSData * headData = [headStr dataUsingEncoding:NSUTF8StringEncoding];
    //增加头部信息
    NSMutableData * contentData = [NSMutableData dataWithData:headData];
    //增加头部信息分界
    [contentData appendData:[GCDAsyncSocket CRLFData]];//CRLFData:\r\n(换行回车),\r:回车,回到当前行的行首,\n:换行,换到当前行的下一行,不会回到行首
    //增加要发送的消息内容
    [contentData appendData:data];
    //发送消息
    [gcdSocket writeData:contentData withTimeout:-1 tag:0];
    
    if (isAnser) { //当前消息后台会应答才给block赋值,不然会影响下一次发送消息后台有应答的情况
        BlockModel * blockM = [[BlockModel alloc] init];
        blockM.timeStamp = [NSDate date];
        blockM.block = block;
        [blockArr addObject:blockM];
    }
}


#pragma mark -GCDAsyncSocketDelegate
//连接成功调用
-(void)socket:(GCDAsyncSocket *)sock didConnectToHost:(NSString *)host port:(uint16_t)port
{
#if IS_OPEN_DEBUG
    NSLog(@"tcp连接成功,host:%@,port:%d",host,port);
#endif
    
    if (_delegate && [_delegate respondsToSelector:@selector(tcpConnectedSuccess)]) {
        [_delegate tcpConnectedSuccess];
    }
    [gcdSocket readDataToData:[GCDAsyncSocket CRLFData] withTimeout:-1 tag:0];
}

//断开连接调用
-(void)socketDidDisconnect:(GCDAsyncSocket *)sock withError:(NSError *)err
{
#if IS_OPEN_DEBUG
    NSLog(@"断开连接,host:%@,port:%d,err:%@",sock.localHost,sock.localPort,err);
#endif
    
    if (_delegate && [_delegate respondsToSelector:@selector(tcpconnectedFailure)]) {
        [_delegate tcpconnectedFailure];
    }
    //清空缓存
    if (blockArr) {
        [blockArr removeAllObjects];
    }
}

//写成功回调
-(void)socket:(GCDAsyncSocket *)sock didWriteDataWithTag:(long)tag
{
#if IS_OPEN_DEBUG
    NSLog(@"写成功回调,tag:%ld",tag);
#endif
}

//读成功回调
-(void)socket:(GCDAsyncSocket *)sock didReadData:(NSData *)data withTag:(long)tag
{
    //先读取当前数据包头部信息
    if (!headDic) {
#if IS_OPEN_DEBUG
        NSString * msg = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
        NSLog(@"tcp收到当前消息的head:%@",msg);
#endif
        
        NSError * error = nil;
        headDic = [NSJSONSerialization JSONObjectWithData:data options:NSJSONReadingMutableContainers error:&error];
        if (error) {
#if IS_OPEN_DEBUG
            NSLog(@"tcp获取当前数据包head失败:%@",error);
#endif
             return;
        }
        //获取数据包头部大小
        NSUInteger packetLength = [headDic[kHeadMessageSize] integerValue];
        //读到数据包的大小
        [sock readDataToLength:packetLength withTimeout:-1 tag:0];
        return;
    }
    
    //正式包的处理
    NSUInteger packetLength = [headDic[kHeadMessageSize] integerValue];
    
   //数据校验
    if (packetLength <= 0 || data.length != packetLength) {
#if IS_OPEN_DEBUG
        NSLog(@"tcp recieve message err:当前数据包大小不正确");
#endif
        return;
    }
    
    //数据回调
    ZWTcpSendMessageType messageType = (ZWTcpSendMessageType)[headDic[kHeadMessageType] integerValue];
    
#if IS_OPEN_DEBUG
    if (messageType == ZWTcpSendMessageText) {
        NSString * msg = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
        NSLog(@"tcp收到当前消息的body:%@",msg);
    }else if (messageType == ZWTcpSendMessagePicture){
        //如果图片很大,打印的二进制信息会不全,可以写入文件保存为png或者jpg查看收到的内容
        NSLog(@"tcp收到当前消息的body(图片):%@",data);
    }
#endif
    
    //客户端发送消息,服务端响应
    if (blockArr.count > 0) {
        BlockModel * blockM = [blockArr[0] copy];
        [blockArr removeObjectAtIndex:0];
        blockM.block(data, messageType);
        
    }else
    {
        //接收服务器主动发送的消息
        if (_delegate && [_delegate respondsToSelector:@selector(recieveServerActiveReport: MessageType:)]) {
            [_delegate recieveServerActiveReport:data MessageType:messageType];
        }
    }
    
    //清空头部
    headDic = nil;
    [sock readDataToData:[GCDAsyncSocket CRLFData] withTimeout:-1 tag:0];
}

#pragma mark -工具方法

//字典转json字符串
-(NSString *)dictionaryToJson:(NSDictionary*)dic
{
    NSError * error = nil;
    NSData * jsonData = [NSJSONSerialization dataWithJSONObject:dic options:NSJSONWritingPrettyPrinted error:&error];
    return [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding];
}

这里着重讲下发送消息(数据组包)和接收消息(数据拆包)方法。

消息发送:

-(void)sendData:(NSData *)data MessageType:(ZWTcpSendMessageType)messageType Response:(ResponseBlock)block IsServerAnswer:(BOOL)isAnser

流程是首先为发送的消息添加头部信息,然后添加分隔符,最后拼接消息体。每次发送一条消息,创建一个BlockModel用于当前消息的响应回调,前提是isAnser = YES。

(void)socket:(GCDAsyncSocket *)sock didConnectToHost:(NSString *)host port:(uint16_t)port

方法触发时,说明tcp连接成功,调用

[gcdSocket readDataToData:[GCDAsyncSocket CRLFData] withTimeout:-1 tag:0];

方法,当接收端接收到消息的边界([GCDAsyncSocket CRLFData] )其实就是\r\n回车换行符,这个可以自定义,就会触发

-(void)socket:(GCDAsyncSocket *)sock didReadData:(NSData *)data withTag:(long)tag

接收回调函数,接下来按着消息格式进行解包就行了,具体去看代码,有注释。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,293评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,604评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,958评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,729评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,719评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,630评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,000评论 3 397
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,665评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,909评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,646评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,726评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,400评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,986评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,959评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,996评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,481评论 2 342

推荐阅读更多精彩内容