前言
-
本文会用实例的方式,将iOS各种IM的方案都简单的实现一遍。并且提供一些选型、实现细节以及优化的建议。
-
注:文中的所有的代码示例,在github中都有demo:
iOS即时通讯,从入门到“放弃”?(demo)
可以打开项目先预览效果,对照着进行阅读。
言归正传,首先我们来总结一下我们去实现IM的方式
第一种方式,使用第三方IM服务
对于短平快的公司,完全可以采用第三方SDK来实现。国内IM的第三方服务商有很多,类似云信、环信、融云、LeanCloud,当然还有其它的很多,这里就不一一举例了,感兴趣的小伙伴可以自行查阅下。
- 第三方服务商IM底层协议基本上都是
TCP
。他们的IM方案很成熟,有了它们,我们甚至不需要自己去搭建IM后台,什么都不需要去考虑。
如果你足够懒,甚至连UI都不需要自己做,这些第三方有各自一套IM的UI,拿来就可以直接用。真可谓3分钟集成... - 但是缺点也很明显,定制化程度太高,很多东西我们不可控。当然还有一个最最重要的一点,就是太贵了...作为真正社交为主打的APP,仅此一点,就足以让我们望而却步。当然,如果IM对于APP只是一个辅助功能,那么用第三方服务也无可厚非。
另外一种方式,我们自己去实现
我们自己去实现也有很多选择:
1)首先面临的就是传输协议的选择,TCP
还是UDP
?
2)其次是我们需要去选择使用哪种聊天协议:
- 基于
Scoket
或者WebScoket
或者其他的私有协议、 MQTT
- 还是广为人诟病的
XMPP
?
3)我们是自己去基于OS
底层Socket
进行封装还是在第三方框架的基础上进行封装?
4)传输数据的格式,我们是用Json
、还是XML
、还是谷歌推出的ProtocolBuffer
?
5)我们还有一些细节问题需要考虑,例如TCP的长连接如何保持,心跳机制,Qos机制,重连机制等等...当然,除此之外,我们还有一些安全问题需要考虑。
一、传输协议的选择
接下来我们可能需要自己考虑去实现IM,首先从传输层协议来说,我们有两种选择:TCP
or UDP
?
这个问题已经被讨论过无数次了,对深层次的细节感兴趣的朋友可以看看这篇文章:
-
这里我们直接说结论吧:对于小公司或者技术不那么成熟的公司,IM一定要用
TCP
来实现,因为如果你要用UDP
的话,需要做的事太多。当然QQ就是用的UDP
协议,当然不仅仅是UDP
,腾讯还用了自己的私有协议,来保证了传输的可靠性,杜绝了UDP下各种数据丢包,乱序等等一系列问题。
总之一句话,如果你觉得团队技术很成熟,那么你用UDP
也行,否则还是用TCP
为好。
二、我们来看看各种聊天协议
首先我们以实现方式来切入,基本上有以下四种实现方式:
- 基于
Scoket
原生:代表框架CocoaAsyncSocket
。 - 基于
WebScoket
:代表框架SocketRocket
。 - 基于
MQTT
:代表框架MQTTKit
。 - 基于
XMPP
:代表框架XMPPFramework
。
当然,以上四种方式我们都可以不使用第三方框架,直接基于OS
底层Scoket
去实现我们的自定义封装。下面我会给出一个基于Scoket
原生而不使用框架的例子,供大家参考一下。
首先需要搞清楚的是,其中MQTT
和XMPP
为聊天协议,它们是最上层的协议,而WebScoket
是传输通讯协议,它是基于Socket
封装的一个协议。而通常我们所说的腾讯IM的私有协议,就是基于WebScoket
或者Scoket
原生进行封装的一个聊天协议。
具体这3种聊天协议的对比优劣如下:
所以说到底,iOS要做一个真正的IM产品,一般都是基于Scoket
或者WebScoket
等,再之上加上一些私有协议来保证的。
1.我们先不使用任何框架,直接用OS
底层Socket
来实现一个简单的IM。
我们客户端的实现思路也是很简单,创建Socket
,和服务器的Socket
对接上,然后开始传输数据就可以了。
-
我们学过c/c++或者java这些语言,我们就知道,往往任何教程,最后一章都是讲
Socket
编程,而Socket
是什么呢,简单的来说,就是我们使用TCP/IP
或者UDP/IP
协议的一组编程接口。如下图所示:我们在应用层,使用
socket
,轻易的实现了进程之间的通信(跨网络的)。想想,如果没有socket
,我们要直面TCP/IP
协议,我们需要去写多少繁琐而又重复的代码。如果有对
socket
概念仍然有所困惑的,可以看看这篇文章:
从问题看本质,socket到底是什么?。
但是这篇文章关于并发连接数的认识是错误的,正确的认识可以看看这篇文章:
单台服务器并发TCP连接数到底可以有多少
我们接着可以开始着手去实现IM了,首先我们不基于任何框架,直接去调用OS
底层-基于C的BSD Socket
去实现,它提供了这样一组接口:
//socket 创建并初始化 socket,返回该 socket 的文件描述符,如果描述符为 -1 表示创建失败。
int socket(int addressFamily, int type,int protocol)
//关闭socket连接
int close(int socketFileDescriptor)
//将 socket 与特定主机地址与端口号绑定,成功绑定返回0,失败返回 -1。
int bind(int socketFileDescriptor,sockaddr *addressToBind,int addressStructLength)
//接受客户端连接请求并将客户端的网络地址信息保存到 clientAddress 中。
int accept(int socketFileDescriptor,sockaddr *clientAddress, int clientAddressStructLength)
//客户端向特定网络地址的服务器发送连接请求,连接成功返回0,失败返回 -1。
int connect(int socketFileDescriptor,sockaddr *serverAddress, int serverAddressLength)
//使用 DNS 查找特定主机名字对应的 IP 地址。如果找不到对应的 IP 地址则返回 NULL。
hostent* gethostbyname(char *hostname)
//通过 socket 发送数据,发送成功返回成功发送的字节数,否则返回 -1。
int send(int socketFileDescriptor, char *buffer, int bufferLength, int flags)
//从 socket 中读取数据,读取成功返回成功读取的字节数,否则返回 -1。
int receive(int socketFileDescriptor,char *buffer, int bufferLength, int flags)
//通过UDP socket 发送数据到特定的网络地址,发送成功返回成功发送的字节数,否则返回 -1。
int sendto(int socketFileDescriptor,char *buffer, int bufferLength, int flags, sockaddr *destinationAddress, int destinationAddressLength)
//从UDP socket 中读取数据,并保存发送者的网络地址信息,读取成功返回成功读取的字节数,否则返回 -1 。
int recvfrom(int socketFileDescriptor,char *buffer, int bufferLength, int flags, sockaddr *fromAddress, int *fromAddressLength)
让我们可以对socket进行各种操作,首先我们来用它写个客户端。总结一下,简单的IM客户端需要做如下4件事:
- 客户端调用 socket(...) 创建socket;
- 客户端调用 connect(...) 向服务器发起连接请求以建立连接;
- 客户端与服务器建立连接之后,就可以通过send(...)/receive(...)向客户端发送或从客户端接收数据;
- 客户端调用 close 关闭 socket;
根据上面4条大纲,我们封装了一个名为TYHSocketManager
的单例,来对socket
相关方法进行调用:
TYHSocketManager.h
#import <Foundation/Foundation.h>
@interface TYHSocketManager : NSObject
+ (instancetype)share;
- (void)connect;
- (void)disConnect;
- (void)sendMsg:(NSString *)msg;
@end
TYHSocketManager.m
#import "TYHSocketManager.h"
#import <sys/types.h>
#import <sys/socket.h>
#import <netinet/in.h>
#import <arpa/inet.h>
@interface TYHSocketManager()
@property (nonatomic,assign)int clientScoket;
@end
@implementation TYHSocketManager
+ (instancetype)share
{
static dispatch_once_t onceToken;
static TYHSocketManager *instance = nil;
dispatch_once(&onceToken, ^{
instance = [[self alloc]init];
[instance initScoket];
[instance pullMsg];
});
return instance;
}
- (void)initScoket
{
//每次连接前,先断开连接
if (_clientScoket != 0) {
[self disConnect];
_clientScoket = 0;
}
//创建客户端socket
_clientScoket = CreateClinetSocket();
//服务器Ip
const char * server_ip="127.0.0.1";
//服务器端口
short server_port=6969;
//等于0说明连接失败
if (ConnectionToServer(_clientScoket,server_ip, server_port)==0) {
printf("Connect to server error\\n");
return ;
}
//走到这说明连接成功
printf("Connect to server ok\\n");
}
static int CreateClinetSocket()
{
int ClinetSocket = 0;
//创建一个socket,返回值为Int。(注scoket其实就是Int类型)
//第一个参数addressFamily IPv4(AF_INET) 或 IPv6(AF_INET6)。
//第二个参数 type 表示 socket 的类型,通常是流stream(SOCK_STREAM) 或数据报文datagram(SOCK_DGRAM)
//第三个参数 protocol 参数通常设置为0,以便让系统自动为选择我们合适的协议,对于 stream socket 来说会是 TCP 协议(IPPROTO_TCP),而对于 datagram来说会是 UDP 协议(IPPROTO_UDP)。
ClinetSocket = socket(AF_INET, SOCK_STREAM, 0);
return ClinetSocket;
}
static int ConnectionToServer(int client_socket,const char * server_ip,unsigned short port)
{
//生成一个sockaddr_in类型结构体
struct sockaddr_in sAddr={0};
sAddr.sin_len=sizeof(sAddr);
//设置IPv4
sAddr.sin_family=AF_INET;
//inet_aton是一个改进的方法来将一个字符串IP地址转换为一个32位的网络序列IP地址
//如果这个函数成功,函数的返回值非零,如果输入地址不正确则会返回零。
inet_aton(server_ip, &sAddr.sin_addr);
//htons是将整型变量从主机字节顺序转变成网络字节顺序,赋值端口号
sAddr.sin_port=htons(port);
//用scoket和服务端地址,发起连接。
//客户端向特定网络地址的服务器发送连接请求,连接成功返回0,失败返回 -1。
//注意:该接口调用会阻塞当前线程,直到服务器返回。
if (connect(client_socket, (struct sockaddr *)&sAddr, sizeof(sAddr))==0) {
return client_socket;
}
return 0;
}
#pragma mark - 新线程来接收消息
- (void)pullMsg
{
NSThread *thread = [[NSThread alloc]initWithTarget:self selector:@selector(recieveAction) object:nil];
[thread start];
}
#pragma mark - 对外逻辑
- (void)connect
{
[self initScoket];
}
- (void)disConnect
{
//关闭连接
close(self.clientScoket);
}
//发送消息
- (void)sendMsg:(NSString *)msg
{
const char *send_Message = [msg UTF8String];
send(self.clientScoket,send_Message,strlen(send_Message)+1,0);
}
//收取服务端发送的消息
- (void)recieveAction{
while (1) {
char recv_Message[1024] = {0};
recv(self.clientScoket, recv_Message, sizeof(recv_Message), 0);
printf("%s\\n",recv_Message);
}
}
如上所示:
- 我们调用了
initScoket
方法,利用CreateClinetSocket
方法了一个scoket
,就是就是调用了socket函数:ClinetSocket = socket(AF_INET, SOCK_STREAM, 0);
-
然后调用了
ConnectionToServer
函数与服务器连接,IP地址为127.0.0.1
也就是本机localhost
和端口6969
相连。在该函数中,我们绑定了一个sockaddr_in
类型的结构体,该结构体内容如下:struct sockaddr_in { __uint8_t sin_len; sa_family_t sin_family; in_port_t sin_port; struct in_addr sin_addr; char sin_zero[8]; };
里面包含了一些,我们需要连接的服务端的
scoket
的一些基本参数,具体赋值细节可以见注释。 -
连接成功之后,我们就可以调用
send
函数和recv
函数进行消息收发了,在这里,我新开辟了一个常驻线程,在这个线程中一个死循环里去不停的调用recv
函数,这样服务端有消息发送过来,第一时间便能被接收到。
就这样客户端便简单的可以用了,接着我们来看看服务端的实现。
一样,我们首先对服务端需要做的工作简单的总结下:
- 服务器调用 socket(...) 创建socket;
- 服务器调用 listen(...) 设置缓冲区;
- 服务器通过 accept(...)接受客户端请求建立连接;
- 服务器与客户端建立连接之后,就可以通过 send(...)/receive(...)向客户端发送或从客户端接收数据;
- 服务器调用 close 关闭 socket;
接着我们就可以具体去实现了
OS
底层的函数是支持我们去实现服务端的,但是我们一般不会用iOS
去这么做(试问真正的应用场景,有谁用iOS
做scoket
服务器么...),如果还是想用这些函数去实现服务端,可以参考下这篇文章: 深入浅出Cocoa-iOS网络编程之Socket。
在这里我用node.js
去搭了一个简单的scoket
服务器。源码如下:
var net = require(\'net\');
var HOST = \'127.0.0.1\';
var PORT = 6969;
// 创建一个TCP服务器实例,调用listen函数开始监听指定端口
// 传入net.createServer()的回调函数将作为”connection“事件的处理函数
// 在每一个“connection”事件中,该回调函数接收到的socket对象是唯一的
net.createServer(function(sock) {
// 我们获得一个连接 - 该连接自动关联一个socket对象
console.log(\'CONNECTED: \' +
sock.remoteAddress + \':\' + sock.remotePort);
sock.write(\'服务端发出:连接成功\');
// 为这个socket实例添加一个"data"事件处理函数
sock.on(\'data\', function(data) {
console.log(\'DATA \' + sock.remoteAddress + \': \' + data);
// 回发该数据,客户端将收到来自服务端的数据
sock.write(\'You said "\' + data + \'"\');
});
// 为这个socket实例添加一个"close"事件处理函数
sock.on(\'close\', function(data) {
console.log(\'CLOSED: \' +
sock.remoteAddress + \' \' + sock.remotePort);
});
}).listen(PORT, HOST);
console.log(\'Server listening on \' + HOST +\':\'+ PORT);
看到这不懂node.js
的朋友也不用着急,在这里你可以使用任意语言c/c++/java/oc等等去实现后台,这里node.js
仅仅是楼主的一个选择,为了让我们来验证之前写的客户端scoket
的效果。如果你不懂node.js
也没关系,你只需要把上述楼主写的相关代码复制粘贴,如果你本机有node的解释器,那么直接在终端进入该源代码文件目录中输入:
node fileName
即可运行该脚本(fileName为保存源代码的文件名)。
我们来看看运行效果:
服务器运行起来了,并且监听着6969端口。
接着我们用之前写的iOS端的例子。客户端打印显示连接成功,而我们运行的服务器也打印了连接成功。接着我们发了一条消息,服务端成功的接收到了消息后,把该消息再发送回客户端,绕了一圈客户端又收到了这条消息。至此我们用OS
底层scoket
实现了简单的IM。
大家看到这是不是觉得太过简单了?
当然简单,我们仅仅是实现了Scoket的连接,信息的发送与接收,除此之外我们什么都没有做,现实中,我们需要做的处理远不止于此,我们先接着往下看。接下来,我们就一起看看第三方框架是如何实现IM的。
2.我们接着来看看基于Socket
原生的CocoaAsyncSocket
:
这个框架实现了两种传输协议TCP
和UDP
,分别对应GCDAsyncSocket
类和GCDAsyncUdpSocket
,这里我们重点讲GCDAsyncSocket
。
这里Socket服务器延续上一个例子,因为同样是基于原生Scoket的框架,所以之前的Node.js的服务端,该例仍然试用。这里我们就只需要去封装客户端的实例,我们还是创建一个TYHSocketManager
单例。
TYHSocketManager.h
#import <Foundation/Foundation.h>
@interface TYHSocketManager : NSObject
+ (instancetype)share;
- (BOOL)connect;
- (void)disConnect;
- (void)sendMsg:(NSString *)msg;
- (void)pullTheMsg;
@end
TYHSocketManager.m
#import "TYHSocketManager.h"
#import "GCDAsyncSocket.h" // for TCP
static NSString * Khost = @"127.0.0.1";
static const uint16_t Kport = 6969;
@interface TYHSocketManager()<GCDAsyncSocketDelegate>
{
GCDAsyncSocket *gcdSocket;
}
@end
@implementation TYHSocketManager
+ (instancetype)share
{
static dispatch_once_t onceToken;
static TYHSocketManager *instance = nil;
dispatch_once(&onceToken, ^{
instance = [[self alloc]init];
[instance initSocket];
});
return instance;
}
- (void)initSocket
{
gcdSocket = [[GCDAsyncSocket alloc] initWithDelegate:self delegateQueue:dispatch_get_main_queue()];
}
#pragma mark - 对外的一些接口
//建立连接
- (BOOL)connect
{
return [gcdSocket connectToHost:Khost onPort:Kport error:nil];
}
//断开连接
- (void)disConnect
{
[gcdSocket disconnect];
}
//发送消息
- (void)sendMsg:(NSString *)msg
{
NSData *data = [msg dataUsingEncoding:NSUTF8StringEncoding];
//第二个参数,请求超时时间
[gcdSocket writeData:data withTimeout:-1 tag:110];
}
//监听最新的消息
- (void)pullTheMsg
{
//监听读数据的代理 -1永远监听,不超时,但是只收一次消息,
//所以每次接受到消息还得调用一次
[gcdSocket readDataWithTimeout:-1 tag:110];
}
#pragma mark - GCDAsyncSocketDelegate
//连接成功调用
- (void)socket:(GCDAsyncSocket *)sock didConnectToHost:(NSString *)host port:(uint16_t)port
{
NSLog(@"连接成功,host:%@,port:%d",host,port);
[self pullTheMsg];
//心跳写在这...
}
//断开连接的时候调用
- (void)socketDidDisconnect:(GCDAsyncSocket *)sock withError:(nullable NSError *)err
{
NSLog(@"断开连接,host:%@,port:%d",sock.localHost,sock.localPort);
//断线重连写在这...
}
//写成功的回调
- (void)socket:(GCDAsyncSocket*)sock didWriteDataWithTag:(long)tag
{
// NSLog(@"写的回调,tag:%ld",tag);
}
//收到消息的回调
- (void)socket:(GCDAsyncSocket *)sock didReadData:(NSData *)data withTag:(long)tag
{
NSString *msg = [[NSString alloc]initWithData:data encoding:NSUTF8StringEncoding];
NSLog(@"收到消息:%@",msg);
[self pullTheMsg];
}
//分段去获取消息的回调
//- (void)socket:(GCDAsyncSocket *)sock didReadPartialDataOfLength:(NSUInteger)partialLength tag:(long)tag
//{
//
// NSLog(@"读的回调,length:%ld,tag:%ld",partialLength,tag);
//
//}
//为上一次设置的读取数据代理续时 (如果设置超时为-1,则永远不会调用到)
//-(NSTimeInterval)socket:(GCDAsyncSocket *)sock shouldTimeoutReadWithTag:(long)tag elapsed:(NSTimeInterval)elapsed bytesDone:(NSUInteger)length
//{
// NSLog(@"来延时,tag:%ld,elapsed:%f,length:%ld",tag,elapsed,length);
// return 10;
//}
@end
这个框架使用起来也十分简单,它基于Scoket往上进行了一层封装,提供了OC的接口给我们使用。至于使用方法,大家看看注释应该就能明白,这里唯一需要说的一点就是这个方法:
[gcdSocket readDataWithTimeout:-1 tag:110];
这个方法的作用就是去读取当前消息队列中的未读消息。记住,这里不调用这个方法,消息回调的代理是永远不会被触发的。而且必须是tag相同,如果tag不同,这个收到消息的代理也不会被处罚。
我们调用一次这个方法,只能触发一次读取消息的代理,如果我们调用的时候没有未读消息,它就会等在那,直到消息来了被触发。一旦被触发一次代理后,我们必须再次调用这个方法,否则,之后的消息到了仍旧无法触发我们读取消息的代理。就像我们在例子中使用的那样,在每次读取到消息之后我们都去调用:
//收到消息的回调
- (void)socket:(GCDAsyncSocket *)sock didReadData:(NSData *)data withTag:(long)tag
{
NSString *msg = [[NSString alloc]initWithData:data encoding:NSUTF8StringEncoding];
NSLog(@"收到消息:%@",msg);
[self pullTheMsg];
}
//监听最新的消息
- (void)pullTheMsg
{
//监听读数据的代理,只能监听10秒,10秒过后调用代理方法 -1永远监听,不超时,但是只收一次消息,
//所以每次接受到消息还得调用一次
[gcdSocket readDataWithTimeout:-1 tag:110];
}
除此之外,我们还需要说的是这个超时timeout
这里如果设置10秒,那么就只能监听10秒,10秒过后调用是否续时的代理方法:
-(NSTimeInterval)socket:(GCDAsyncSocket *)sock shouldTimeoutReadWithTag:(long)tag elapsed:(NSTimeInterval)elapsed bytesDone:(NSUInteger)length
如果我们选择不续时,那么10秒到了还没收到消息,那么Scoket
会自动断开连接。看到这里有些小伙伴要吐槽了,怎么一个方法设计的这么麻烦,当然这里这么设计是有它的应用场景的,我们后面再来细讲。
我们同样来运行看看效果:
至此我们也用CocoaAsyncSocket
这个框架实现了一个简单的IM。
3.接着我们继续来看看基于webScoket
的IM:
这个例子我们会把心跳,断线重连,以及PingPong机制进行简单的封装,所以我们先来谈谈这三个概念:
首先我们来谈谈什么是心跳
简单的来说,心跳就是用来检测TCP连接的双方是否可用。那又会有人要问了,TCP不是本身就自带一个KeepAlive
机制吗?
这里我们需要说明的是TCP的KeepAlive
机制只能保证连接的存在,但是并不能保证客户端以及服务端的可用性.比如会有以下一种情况:
某台服务器因为某些原因导致负载超高,CPU 100%,无法响应任何业务请求,但是使用 TCP 探针则仍旧能够确定连接状态,这就是典型的连接活着但业务提供方已死的状态。
这个时候心跳机制就起到作用了:
- 我们客户端发起心跳Ping(一般都是客户端),假如设置在10秒后如果没有收到回调,那么说明服务器或者客户端某一方出现问题,这时候我们需要主动断开连接。
- 服务端也是一样,会维护一个socket的心跳间隔,当约定时间内,没有收到客户端发来的心跳,我们会知道该连接已经失效,然后主动断开连接。
其实做过IM的小伙伴们都知道,我们真正需要心跳机制的原因其实主要是在于国内运营商NAT
超时。
那么究竟什么是NAT
超时呢?
原来这是因为IPV4引起的,我们上网很可能会处在一个NAT设备(无线路由器之类)之后。
NAT设备会在IP封包通过设备时修改源/目的IP地址. 对于家用路由器来说, 使用的是网络地址端口转换(NAPT), 它不仅改IP, 还修改TCP和UDP协议的端口号, 这样就能让内网中的设备共用同一个外网IP. 举个例子, NAPT维护一个类似下表的NAT表:
NAT设备会根据NAT表对出去和进来的数据做修改, 比如将192.168.0.3:8888
发出去的封包改成120.132.92.21:9202
, 外部就认为他们是在和120.132.92.21:9202
通信. 同时NAT设备会将120.132.92.21:9202
收到的封包的IP和端口改成192.168.0.3:8888
, 再发给内网的主机, 这样内部和外部就能双向通信了, 但如果其中192.168.0.3:8888
== 120.132.92.21:9202
这一映射因为某些原因被NAT设备淘汰了, 那么外部设备就无法直接与192.168.0.3:8888
通信了。
我们的设备经常是处在NAT设备的后面, 比如在大学里的校园网, 查一下自己分配到的IP, 其实是内网IP, 表明我们在NAT设备后面, 如果我们在寝室再接个路由器, 那么我们发出的数据包会多经过一次NAT.
国内移动无线网络运营商在链路上一段时间内没有数据通讯后, 会淘汰NAT表中的对应项, 造成链路中断。
而国内的运营商一般NAT超时的时间为5分钟,所以通常我们心跳设置的时间间隔为3-5分钟。
接着我们来讲讲PingPong机制:
很多小伙伴可能又会感觉到疑惑了,那么我们在这心跳间隔的3-5分钟如果连接假在线(例如在地铁电梯这种环境下)。那么我们岂不是无法保证消息的即时性么?这显然是我们无法接受的,所以业内的解决方案是采用双向的PingPong
机制。
当服务端发出一个Ping
,客户端没有在约定的时间内返回响应的ack
,则认为客户端已经不在线,这时我们Server
端会主动断开Scoket
连接,并且改由APNS
推送的方式发送消息。
同样的是,当客户端去发送一个消息,因为我们迟迟无法收到服务端的响应ack包,则表明客户端或者服务端已不在线,我们也会显示消息发送失败,并且断开Scoket
连接。
还记得我们之前CocoaSyncSockt
的例子所讲的获取消息超时就断开吗?其实它就是一个PingPong
机制的客户端实现。我们每次可以在发送消息成功后,调用这个超时读取的方法,如果一段时间没收到服务器的响应,那么说明连接不可用,则断开Scoket
连接
最后就是重连机制:
理论上,我们自己主动去断开的Scoket
连接(例如退出账号,APP退出到后台等等),不需要重连。其他的连接断开,我们都需要进行断线重连。
一般解决方案是尝试重连几次,如果仍旧无法重连成功,那么不再进行重连。
接下来的WebScoket
的例子,我会封装一个重连时间指数级增长的一个重连方式,可以作为一个参考。
言归正传,我们看完上述三个概念之后,我们来讲一个WebScoket
最具代表性的一个第三方框架SocketRocket
。
我们首先来看看它对外封装的一些方法:
@interface SRWebSocket : NSObject <NSStreamDelegate>
@property (nonatomic, weak) id <SRWebSocketDelegate> delegate;
@property (nonatomic, readonly) SRReadyState readyState;
@property (nonatomic, readonly, retain) NSURL *url;
@property (nonatomic, readonly) CFHTTPMessageRef receivedHTTPHeaders;
// Optional array of cookies (NSHTTPCookie objects) to apply to the connections
@property (nonatomic, readwrite) NSArray * requestCookies;
// This returns the negotiated protocol.
// It will be nil until after the handshake completes.
@property (nonatomic, readonly, copy) NSString *protocol;
// Protocols should be an array of strings that turn into Sec-WebSocket-Protocol.
- (id)initWithURLRequest:(NSURLRequest *)request protocols:(NSArray *)protocols allowsUntrustedSSLCertificates:(BOOL)allowsUntrustedSSLCertificates;
- (id)initWithURLRequest:(NSURLRequest *)request protocols:(NSArray *)protocols;
- (id)initWithURLRequest:(NSURLRequest *)request;
// Some helper constructors.
- (id)initWithURL:(NSURL *)url protocols:(NSArray *)protocols allowsUntrustedSSLCertificates:(BOOL)allowsUntrustedSSLCertificates;
- (id)initWithURL:(NSURL *)url protocols:(NSArray *)protocols;
- (id)initWithURL:(NSURL *)url;
// Delegate queue will be dispatch_main_queue by default.
// You cannot set both OperationQueue and dispatch_queue.
- (void)setDelegateOperationQueue:(NSOperationQueue*) queue;
- (void)setDelegateDispatchQueue:(dispatch_queue_t) queue;
// By default, it will schedule itself on +[NSRunLoop SR_networkRunLoop] using defaultModes.
- (void)scheduleInRunLoop:(NSRunLoop *)aRunLoop forMode:(NSString *)mode;
- (void)unscheduleFromRunLoop:(NSRunLoop *)aRunLoop forMode:(NSString *)mode;
// SRWebSockets are intended for one-time-use only. Open should be called once and only once.
- (void)open;
- (void)close;
- (void)closeWithCode:(NSInteger)code reason:(NSString *)reason;
// Send a UTF8 String or Data.
- (void)send:(id)data;
// Send Data (can be nil) in a ping message.
- (void)sendPing:(NSData *)data;
@end
#pragma mark - SRWebSocketDelegate
@protocol SRWebSocketDelegate <NSObject>
// message will either be an NSString if the server is using text
// or NSData if the server is using binary.
- (void)webSocket:(SRWebSocket *)webSocket didReceiveMessage:(id)message;
@optional
- (void)webSocketDidOpen:(SRWebSocket *)webSocket;
- (void)webSocket:(SRWebSocket *)webSocket didFailWithError:(NSError *)error;
- (void)webSocket:(SRWebSocket *)webSocket didCloseWithCode:(NSInteger)code reason:(NSString *)reason wasClean:(BOOL)wasClean;
- (void)webSocket:(SRWebSocket *)webSocket didReceivePong:(NSData *)pongPayload;
// Return YES to convert messages sent as Text to an NSString. Return NO to skip NSData -> NSString conversion for Text messages. Defaults to YES.
- (BOOL)webSocketShouldConvertTextFrameToString:(SRWebSocket *)webSocket;
@end
方法也很简单,分为两个部分:
- 一部分为
SRWebSocket
的初始化,以及连接,关闭连接,发送消息等方法。 - 另一部分为
SRWebSocketDelegate
,其中包括一些回调:
收到消息的回调,连接失败的回调,关闭连接的回调,收到pong的回调,是否需要把data消息转换成string的代理方法。
接着我们还是举个例子来实现以下,首先来封装一个TYHSocketManager
单例:
TYHSocketManager.h
#import <Foundation/Foundation.h>
typedef enum : NSUInteger {
disConnectByUser ,
disConnectByServer,
} DisConnectType;
@interface TYHSocketManager : NSObject
+ (instancetype)share;
- (void)connect;
- (void)disConnect;
- (void)sendMsg:(NSString *)msg;
- (void)ping;
@end
TYHSocketManager.m
#import "TYHSocketManager.h"
#import "SocketRocket.h"
#define dispatch_main_async_safe(block)\\
if ([NSThread isMainThread]) {\\
block();\\
} else {\\
dispatch_async(dispatch_get_main_queue(), block);\\
}
static NSString * Khost = @"127.0.0.1";
static const uint16_t Kport = 6969;
@interface TYHSocketManager()<SRWebSocketDelegate>
{
SRWebSocket *webSocket;
NSTimer *heartBeat;
NSTimeInterval reConnectTime;
}
@end
@implementation TYHSocketManager
+ (instancetype)share
{
static dispatch_once_t onceToken;
static TYHSocketManager *instance = nil;
dispatch_once(&onceToken, ^{
instance = [[self alloc]init];
[instance initSocket];
});
return instance;
}
//初始化连接
- (void)initSocket
{
if (webSocket) {
return;
}
webSocket = [[SRWebSocket alloc]initWithURL:[NSURL URLWithString:[NSString stringWithFormat:@"ws://%@:%d", Khost, Kport]]];
webSocket.delegate = self;
//设置代理线程queue
NSOperationQueue *queue = [[NSOperationQueue alloc]init];
queue.maxConcurrentOperationCount = 1;
[webSocket setDelegateOperationQueue:queue];
//连接
[webSocket open];
}
//初始化心跳
- (void)initHeartBeat
{
dispatch_main_async_safe(^{
[self destoryHeartBeat];
__weak typeof(self) weakSelf = self;
//心跳设置为3分钟,NAT超时一般为5分钟
heartBeat = [NSTimer scheduledTimerWithTimeInterval:3*60 repeats:YES block:^(NSTimer * _Nonnull timer) {
NSLog(@"heart");
//和服务端约定好发送什么作为心跳标识,尽可能的减小心跳包大小
[weakSelf sendMsg:@"heart"];
}];
[[NSRunLoop currentRunLoop]addTimer:heartBeat forMode:NSRunLoopCommonModes];
})
}
//取消心跳
- (void)destoryHeartBeat
{
dispatch_main_async_safe(^{
if (heartBeat) {
[heartBeat invalidate];
heartBeat = nil;
}
})
}
#pragma mark - 对外的一些接口
//建立连接
- (void)connect
{
[self initSocket];
//每次正常连接的时候清零重连时间
reConnectTime = 0;
}
//断开连接
- (void)disConnect
{
if (webSocket) {
[webSocket close];
webSocket = nil;
}
}
//发送消息
- (void)sendMsg:(NSString *)msg
{
[webSocket send:msg];
}
//重连机制
- (void)reConnect
{
[self disConnect];
//超过一分钟就不再重连 所以只会重连5次 2^5 = 64
if (reConnectTime > 64) {
return;
}
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(reConnectTime * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
webSocket = nil;
[self initSocket];
});
//重连时间2的指数以上是关于iOS 即时通讯,从入门到 “放弃”?的主要内容,如果未能解决你的问题,请参考以下文章
开源即时通讯IM框架MobileIMSDK的微信小程序端开发快速入门