前言
本文會(huì)用實(shí)例的方式,將iOS各種IM的方案都簡(jiǎn)單的實(shí)現(xiàn)一遍。并且提供一些選型、實(shí)現(xiàn)細(xì)節(jié)以及優(yōu)化的建議。
注:文中的所有的代碼示例,在github中都有demo:
iOS即時(shí)通訊,從入門(mén)到“放棄”?(demo)
可以打開(kāi)項(xiàng)目先預(yù)覽效果,對(duì)照著進(jìn)行閱讀。
言歸正傳,首先我們來(lái)總結(jié)一下我們?nèi)?shí)現(xiàn)IM的方式
第一種方式,使用第三方IM服務(wù)
對(duì)于短平快的公司,完全可以采用第三方SDK來(lái)實(shí)現(xiàn)。國(guó)內(nèi)IM的第三方服務(wù)商有很多,類(lèi)似云信、環(huán)信、融云、LeanCloud,當(dāng)然還有其它的很多,這里就不一一舉例了,感興趣的小伙伴可以自行查閱下。
- 第三方服務(wù)商IM底層協(xié)議基本上都是
TCP
。他們的IM方案很成熟,有了它們,我們甚至不需要自己去搭建IM后臺(tái),什么都不需要去考慮。
如果你足夠懶,甚至連UI都不需要自己做,這些第三方有各自一套IM的UI,拿來(lái)就可以直接用。真可謂3分鐘集成... - 但是缺點(diǎn)也很明顯,定制化程度太高,很多東西我們不可控。當(dāng)然還有一個(gè)最最重要的一點(diǎn),就是太貴了...作為真正社交為主打的APP,僅此一點(diǎn),就足以讓我們望而卻步。當(dāng)然,如果IM對(duì)于APP只是一個(gè)輔助功能,那么用第三方服務(wù)也無(wú)可厚非。
另外一種方式,我們自己去實(shí)現(xiàn)
我們自己去實(shí)現(xiàn)也有很多選擇:
1)首先面臨的就是傳輸協(xié)議的選擇,TCP
還是UDP
?
2)其次是我們需要去選擇使用哪種聊天協(xié)議:
- 基于
Scoket
或者WebScoket
或者其他的私有協(xié)議、 MQTT
- 還是廣為人詬病的
XMPP
?
3)我們是自己去基于OS
底層Socket
進(jìn)行封裝還是在第三方框架的基礎(chǔ)上進(jìn)行封裝?
4)傳輸數(shù)據(jù)的格式,我們是用Json
、還是XML
、還是谷歌推出的ProtocolBuffer
?
5)我們還有一些細(xì)節(jié)問(wèn)題需要考慮,例如TCP的長(zhǎng)連接如何保持,心跳機(jī)制,Qos機(jī)制,重連機(jī)制等等...當(dāng)然,除此之外,我們還有一些安全問(wèn)題需要考慮。
一、傳輸協(xié)議的選擇
接下來(lái)我們可能需要自己考慮去實(shí)現(xiàn)IM,首先從傳輸層協(xié)議來(lái)說(shuō),我們有兩種選擇:TCP
or UDP
?
這個(gè)問(wèn)題已經(jīng)被討論過(guò)無(wú)數(shù)次了,對(duì)深層次的細(xì)節(jié)感興趣的朋友可以看看這篇文章:
這里我們直接說(shuō)結(jié)論吧:對(duì)于小公司或者技術(shù)不那么成熟的公司,IM一定要用TCP
來(lái)實(shí)現(xiàn),因?yàn)槿绻阋?code>UDP的話(huà),需要做的事太多。當(dāng)然QQ就是用的UDP
協(xié)議,當(dāng)然不僅僅是UDP
,騰訊還用了自己的私有協(xié)議,來(lái)保證了傳輸?shù)目煽啃裕沤^了UDP下各種數(shù)據(jù)丟包,亂序等等一系列問(wèn)題。
總之一句話(huà),如果你覺(jué)得團(tuán)隊(duì)技術(shù)很成熟,那么你用UDP
也行,否則還是用TCP
為好。
二、我們來(lái)看看各種聊天協(xié)議
首先我們以實(shí)現(xiàn)方式來(lái)切入,基本上有以下四種實(shí)現(xiàn)方式:
- 基于
Scoket
原生:代表框架CocoaAsyncSocket
。 - 基于
WebScoket
:代表框架SocketRocket
。 - 基于
MQTT
:代表框架MQTTKit
。 - 基于
XMPP
:代表框架XMPPFramework
。
當(dāng)然,以上四種方式我們都可以不使用第三方框架,直接基于OS
底層Scoket
去實(shí)現(xiàn)我們的自定義封裝。下面我會(huì)給出一個(gè)基于Scoket
原生而不使用框架的例子,供大家參考一下。
首先需要搞清楚的是,其中MQTT
和XMPP
為聊天協(xié)議,它們是最上層的協(xié)議,而WebScoket
是傳輸通訊協(xié)議,它是基于Socket
封裝的一個(gè)協(xié)議。而通常我們所說(shuō)的騰訊IM的私有協(xié)議,就是基于WebScoket
或者Scoket
原生進(jìn)行封裝的一個(gè)聊天協(xié)議。
具體這3種聊天協(xié)議的對(duì)比優(yōu)劣如下:
所以說(shuō)到底,iOS要做一個(gè)真正的IM產(chǎn)品,一般都是基于Scoket
或者WebScoket
等,再之上加上一些私有協(xié)議來(lái)保證的。
1.我們先不使用任何框架,直接用OS
底層Socket
來(lái)實(shí)現(xiàn)一個(gè)簡(jiǎn)單的IM。
我們客戶(hù)端的實(shí)現(xiàn)思路也是很簡(jiǎn)單,創(chuàng)建Socket
,和服務(wù)器的Socket
對(duì)接上,然后開(kāi)始傳輸數(shù)據(jù)就可以了。
- 我們學(xué)過(guò)c/c++或者java這些語(yǔ)言,我們就知道,往往任何教程,最后一章都是講
Socket
編程,而Socket
是什么呢,簡(jiǎn)單的來(lái)說(shuō),就是我們使用TCP/IP
或者UDP/IP
協(xié)議的一組編程接口。如下圖所示:
我們?cè)趹?yīng)用層,使用socket
,輕易的實(shí)現(xiàn)了進(jìn)程之間的通信(跨網(wǎng)絡(luò)的)。想想,如果沒(méi)有socket
,我們要直面TCP/IP
協(xié)議,我們需要去寫(xiě)多少繁瑣而又重復(fù)的代碼。
如果有對(duì)socket
概念仍然有所困惑的,可以看看這篇文章:
從問(wèn)題看本質(zhì),socket到底是什么?。
但是這篇文章關(guān)于并發(fā)連接數(shù)的認(rèn)識(shí)是錯(cuò)誤的,正確的認(rèn)識(shí)可以看看這篇文章:
單臺(tái)服務(wù)器并發(fā)TCP連接數(shù)到底可以有多少
我們接著可以開(kāi)始著手去實(shí)現(xiàn)IM了,首先我們不基于任何框架,直接去調(diào)用OS
底層-基于C的BSD Socket
去實(shí)現(xiàn),它提供了這樣一組接口:
//socket 創(chuàng)建并初始化 socket,返回該 socket 的文件描述符,如果描述符為 -1 表示創(chuàng)建失敗。
int socket(int addressFamily, int type,int protocol)
//關(guān)閉socket連接
int close(int socketFileDescriptor)
//將 socket 與特定主機(jī)地址與端口號(hào)綁定,成功綁定返回0,失敗返回 -1。
int bind(int socketFileDescriptor,sockaddr *addressToBind,int addressStructLength)
//接受客戶(hù)端連接請(qǐng)求并將客戶(hù)端的網(wǎng)絡(luò)地址信息保存到 clientAddress 中。
int accept(int socketFileDescriptor,sockaddr *clientAddress, int clientAddressStructLength)
//客戶(hù)端向特定網(wǎng)絡(luò)地址的服務(wù)器發(fā)送連接請(qǐng)求,連接成功返回0,失敗返回 -1。
int connect(int socketFileDescriptor,sockaddr *serverAddress, int serverAddressLength)
//使用 DNS 查找特定主機(jī)名字對(duì)應(yīng)的 IP 地址。如果找不到對(duì)應(yīng)的 IP 地址則返回 NULL。
hostent* gethostbyname(char *hostname)
//通過(guò) socket 發(fā)送數(shù)據(jù),發(fā)送成功返回成功發(fā)送的字節(jié)數(shù),否則返回 -1。
int send(int socketFileDescriptor, char *buffer, int bufferLength, int flags)
//從 socket 中讀取數(shù)據(jù),讀取成功返回成功讀取的字節(jié)數(shù),否則返回 -1。
int receive(int socketFileDescriptor,char *buffer, int bufferLength, int flags)
//通過(guò)UDP socket 發(fā)送數(shù)據(jù)到特定的網(wǎng)絡(luò)地址,發(fā)送成功返回成功發(fā)送的字節(jié)數(shù),否則返回 -1。
int sendto(int socketFileDescriptor,char *buffer, int bufferLength, int flags, sockaddr *destinationAddress, int destinationAddressLength)
//從UDP socket 中讀取數(shù)據(jù),并保存發(fā)送者的網(wǎng)絡(luò)地址信息,讀取成功返回成功讀取的字節(jié)數(shù),否則返回 -1 。
int recvfrom(int socketFileDescriptor,char *buffer, int bufferLength, int flags, sockaddr *fromAddress, int *fromAddressLength)
讓我們可以對(duì)socket進(jìn)行各種操作,首先我們來(lái)用它寫(xiě)個(gè)客戶(hù)端。總結(jié)一下,簡(jiǎn)單的IM客戶(hù)端需要做如下4件事:
- 客戶(hù)端調(diào)用 socket(...) 創(chuàng)建socket;
- 客戶(hù)端調(diào)用 connect(...) 向服務(wù)器發(fā)起連接請(qǐng)求以建立連接;
- 客戶(hù)端與服務(wù)器建立連接之后,就可以通過(guò)send(...)/receive(...)向客戶(hù)端發(fā)送或從客戶(hù)端接收數(shù)據(jù);
- 客戶(hù)端調(diào)用 close 關(guān)閉 socket;
根據(jù)上面4條大綱,我們封裝了一個(gè)名為TYHSocketManager
的單例,來(lái)對(duì)socket
相關(guān)方法進(jìn)行調(diào)用:
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
{
//每次連接前,先斷開(kāi)連接
if (_clientScoket != 0) {
[self disConnect];
_clientScoket = 0;
}
//創(chuàng)建客戶(hù)端socket
_clientScoket = CreateClinetSocket();
//服務(wù)器Ip
const char * server_ip="127.0.0.1";
//服務(wù)器端口
short server_port=6969;
//等于0說(shuō)明連接失敗
if (ConnectionToServer(_clientScoket,server_ip, server_port)==0) {
printf("Connect to server error\n");
return ;
}
//走到這說(shuō)明連接成功
printf("Connect to server ok\n");
}
static int CreateClinetSocket()
{
int ClinetSocket = 0;
//創(chuàng)建一個(gè)socket,返回值為Int。(注scoket其實(shí)就是Int類(lèi)型)
//第一個(gè)參數(shù)addressFamily IPv4(AF_INET) 或 IPv6(AF_INET6)。
//第二個(gè)參數(shù) type 表示 socket 的類(lèi)型,通常是流stream(SOCK_STREAM) 或數(shù)據(jù)報(bào)文datagram(SOCK_DGRAM)
//第三個(gè)參數(shù) protocol 參數(shù)通常設(shè)置為0,以便讓系統(tǒng)自動(dòng)為選擇我們合適的協(xié)議,對(duì)于 stream socket 來(lái)說(shuō)會(huì)是 TCP 協(xié)議(IPPROTO_TCP),而對(duì)于 datagram來(lái)說(shuō)會(huì)是 UDP 協(xié)議(IPPROTO_UDP)。
ClinetSocket = socket(AF_INET, SOCK_STREAM, 0);
return ClinetSocket;
}
static int ConnectionToServer(int client_socket,const char * server_ip,unsigned short port)
{
//生成一個(gè)sockaddr_in類(lèi)型結(jié)構(gòu)體
struct sockaddr_in sAddr={0};
sAddr.sin_len=sizeof(sAddr);
//設(shè)置IPv4
sAddr.sin_family=AF_INET;
//inet_aton是一個(gè)改進(jìn)的方法來(lái)將一個(gè)字符串IP地址轉(zhuǎn)換為一個(gè)32位的網(wǎng)絡(luò)序列IP地址
//如果這個(gè)函數(shù)成功,函數(shù)的返回值非零,如果輸入地址不正確則會(huì)返回零。
inet_aton(server_ip, &sAddr.sin_addr);
//htons是將整型變量從主機(jī)字節(jié)順序轉(zhuǎn)變成網(wǎng)絡(luò)字節(jié)順序,賦值端口號(hào)
sAddr.sin_port=htons(port);
//用scoket和服務(wù)端地址,發(fā)起連接。
//客戶(hù)端向特定網(wǎng)絡(luò)地址的服務(wù)器發(fā)送連接請(qǐng)求,連接成功返回0,失敗返回 -1。
//注意:該接口調(diào)用會(huì)阻塞當(dāng)前線(xiàn)程,直到服務(wù)器返回。
if (connect(client_socket, (struct sockaddr *)&sAddr, sizeof(sAddr))==0) {
return client_socket;
}
return 0;
}
#pragma mark - 新線(xiàn)程來(lái)接收消息
- (void)pullMsg
{
NSThread *thread = [[NSThread alloc]initWithTarget:self selector:@selector(recieveAction) object:nil];
[thread start];
}
#pragma mark - 對(duì)外邏輯
- (void)connect
{
[self initScoket];
}
- (void)disConnect
{
//關(guān)閉連接
close(self.clientScoket);
}
//發(fā)送消息
- (void)sendMsg:(NSString *)msg
{
const char *send_Message = [msg UTF8String];
send(self.clientScoket,send_Message,strlen(send_Message)+1,0);
}
//收取服務(wù)端發(fā)送的消息
- (void)recieveAction{
while (1) {
char recv_Message[1024] = {0};
recv(self.clientScoket, recv_Message, sizeof(recv_Message), 0);
printf("%s\n",recv_Message);
}
}
如上所示:
- 我們調(diào)用了
initScoket
方法,利用CreateClinetSocket
方法了一個(gè)scoket
,就是就是調(diào)用了socket函數(shù):
ClinetSocket = socket(AF_INET, SOCK_STREAM, 0);
- 然后調(diào)用了
ConnectionToServer
函數(shù)與服務(wù)器連接,IP地址為127.0.0.1
也就是本機(jī)localhost
和端口6969
相連。在該函數(shù)中,我們綁定了一個(gè)sockaddr_in
類(lèi)型的結(jié)構(gòu)體,該結(jié)構(gòu)體內(nèi)容如下:
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];
};
里面包含了一些,我們需要連接的服務(wù)端的scoket
的一些基本參數(shù),具體賦值細(xì)節(jié)可以見(jiàn)注釋。
- 連接成功之后,我們就可以調(diào)用
send
函數(shù)和recv
函數(shù)進(jìn)行消息收發(fā)了,在這里,我新開(kāi)辟了一個(gè)常駐線(xiàn)程,在這個(gè)線(xiàn)程中一個(gè)死循環(huán)里去不停的調(diào)用recv
函數(shù),這樣服務(wù)端有消息發(fā)送過(guò)來(lái),第一時(shí)間便能被接收到。
就這樣客戶(hù)端便簡(jiǎn)單的可以用了,接著我們來(lái)看看服務(wù)端的實(shí)現(xiàn)。
一樣,我們首先對(duì)服務(wù)端需要做的工作簡(jiǎn)單的總結(jié)下:
- 服務(wù)器調(diào)用 socket(...) 創(chuàng)建socket;
- 服務(wù)器調(diào)用 listen(...) 設(shè)置緩沖區(qū);
- 服務(wù)器通過(guò) accept(...)接受客戶(hù)端請(qǐng)求建立連接;
- 服務(wù)器與客戶(hù)端建立連接之后,就可以通過(guò) send(...)/receive(...)向客戶(hù)端發(fā)送或從客戶(hù)端接收數(shù)據(jù);
- 服務(wù)器調(diào)用 close 關(guān)閉 socket;
接著我們就可以具體去實(shí)現(xiàn)了
OS
底層的函數(shù)是支持我們?nèi)?shí)現(xiàn)服務(wù)端的,但是我們一般不會(huì)用iOS
去這么做(試問(wèn)真正的應(yīng)用場(chǎng)景,有誰(shuí)用iOS
做scoket
服務(wù)器么...),如果還是想用這些函數(shù)去實(shí)現(xiàn)服務(wù)端,可以參考下這篇文章: 深入淺出Cocoa-iOS網(wǎng)絡(luò)編程之Socket。
在這里我用node.js
去搭了一個(gè)簡(jiǎn)單的scoket
服務(wù)器。源碼如下:
var net = require('net');
var HOST = '127.0.0.1';
var PORT = 6969;
// 創(chuàng)建一個(gè)TCP服務(wù)器實(shí)例,調(diào)用listen函數(shù)開(kāi)始監(jiān)聽(tīng)指定端口
// 傳入net.createServer()的回調(diào)函數(shù)將作為”connection“事件的處理函數(shù)
// 在每一個(gè)“connection”事件中,該回調(diào)函數(shù)接收到的socket對(duì)象是唯一的
net.createServer(function(sock) {
// 我們獲得一個(gè)連接 - 該連接自動(dòng)關(guān)聯(lián)一個(gè)socket對(duì)象
console.log('CONNECTED: ' +
sock.remoteAddress + ':' + sock.remotePort);
sock.write('服務(wù)端發(fā)出:連接成功');
// 為這個(gè)socket實(shí)例添加一個(gè)"data"事件處理函數(shù)
sock.on('data', function(data) {
console.log('DATA ' + sock.remoteAddress + ': ' + data);
// 回發(fā)該數(shù)據(jù),客戶(hù)端將收到來(lái)自服務(wù)端的數(shù)據(jù)
sock.write('You said "' + data + '"');
});
// 為這個(gè)socket實(shí)例添加一個(gè)"close"事件處理函數(shù)
sock.on('close', function(data) {
console.log('CLOSED: ' +
sock.remoteAddress + ' ' + sock.remotePort);
});
}).listen(PORT, HOST);
console.log('Server listening on ' + HOST +':'+ PORT);
看到這不懂node.js
的朋友也不用著急,在這里你可以使用任意語(yǔ)言c/c++/java/oc等等去實(shí)現(xiàn)后臺(tái),這里node.js
僅僅是樓主的一個(gè)選擇,為了讓我們來(lái)驗(yàn)證之前寫(xiě)的客戶(hù)端scoket
的效果。如果你不懂node.js
也沒(méi)關(guān)系,你只需要把上述樓主寫(xiě)的相關(guān)代碼復(fù)制粘貼,如果你本機(jī)有node的解釋器,那么直接在終端進(jìn)入該源代碼文件目錄中輸入:
node fileName
即可運(yùn)行該腳本(fileName為保存源代碼的文件名)。
我們來(lái)看看運(yùn)行效果:
服務(wù)器運(yùn)行起來(lái)了,并且監(jiān)聽(tīng)著6969端口。
接著我們用之前寫(xiě)的iOS端的例子。客戶(hù)端打印顯示連接成功,而我們運(yùn)行的服務(wù)器也打印了連接成功。接著我們發(fā)了一條消息,服務(wù)端成功的接收到了消息后,把該消息再發(fā)送回客戶(hù)端,繞了一圈客戶(hù)端又收到了這條消息。至此我們用
OS
底層scoket
實(shí)現(xiàn)了簡(jiǎn)單的IM。
大家看到這是不是覺(jué)得太過(guò)簡(jiǎn)單了?
當(dāng)然簡(jiǎn)單,我們僅僅是實(shí)現(xiàn)了Scoket的連接,信息的發(fā)送與接收,除此之外我們什么都沒(méi)有做,現(xiàn)實(shí)中,我們需要做的處理遠(yuǎn)不止于此,我們先接著往下看。接下來(lái),我們就一起看看第三方框架是如何實(shí)現(xiàn)IM的。
2.我們接著來(lái)看看基于Socket
原生的CocoaAsyncSocket
:
這個(gè)框架實(shí)現(xiàn)了兩種傳輸協(xié)議TCP
和UDP
,分別對(duì)應(yīng)GCDAsyncSocket
類(lèi)和GCDAsyncUdpSocket
,這里我們重點(diǎn)講GCDAsyncSocket
。
這里Socket服務(wù)器延續(xù)上一個(gè)例子,因?yàn)橥瑯邮腔谠鶶coket的框架,所以之前的Node.js的服務(wù)端,該例仍然試用。這里我們就只需要去封裝客戶(hù)端的實(shí)例,我們還是創(chuàng)建一個(gè)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 - 對(duì)外的一些接口
//建立連接
- (BOOL)connect
{
return [gcdSocket connectToHost:Khost onPort:Kport error:nil];
}
//斷開(kāi)連接
- (void)disConnect
{
[gcdSocket disconnect];
}
//發(fā)送消息
- (void)sendMsg:(NSString *)msg
{
NSData *data = [msg dataUsingEncoding:NSUTF8StringEncoding];
//第二個(gè)參數(shù),請(qǐng)求超時(shí)時(shí)間
[gcdSocket writeData:data withTimeout:-1 tag:110];
}
//監(jiān)聽(tīng)最新的消息
- (void)pullTheMsg
{
//監(jiān)聽(tīng)讀數(shù)據(jù)的代理 -1永遠(yuǎn)監(jiān)聽(tīng),不超時(shí),但是只收一次消息,
//所以每次接受到消息還得調(diào)用一次
[gcdSocket readDataWithTimeout:-1 tag:110];
}
#pragma mark - GCDAsyncSocketDelegate
//連接成功調(diào)用
- (void)socket:(GCDAsyncSocket *)sock didConnectToHost:(NSString *)host port:(uint16_t)port
{
NSLog(@"連接成功,host:%@,port:%d",host,port);
[self pullTheMsg];
//心跳寫(xiě)在這...
}
//斷開(kāi)連接的時(shí)候調(diào)用
- (void)socketDidDisconnect:(GCDAsyncSocket *)sock withError:(nullable NSError *)err
{
NSLog(@"斷開(kāi)連接,host:%@,port:%d",sock.localHost,sock.localPort);
//斷線(xiàn)重連寫(xiě)在這...
}
//寫(xiě)成功的回調(diào)
- (void)socket:(GCDAsyncSocket*)sock didWriteDataWithTag:(long)tag
{
// NSLog(@"寫(xiě)的回調(diào),tag:%ld",tag);
}
//收到消息的回調(diào)
- (void)socket:(GCDAsyncSocket *)sock didReadData:(NSData *)data withTag:(long)tag
{
NSString *msg = [[NSString alloc]initWithData:data encoding:NSUTF8StringEncoding];
NSLog(@"收到消息:%@",msg);
[self pullTheMsg];
}
//分段去獲取消息的回調(diào)
//- (void)socket:(GCDAsyncSocket *)sock didReadPartialDataOfLength:(NSUInteger)partialLength tag:(long)tag
//{
//
// NSLog(@"讀的回調(diào),length:%ld,tag:%ld",partialLength,tag);
//
//}
//為上一次設(shè)置的讀取數(shù)據(jù)代理續(xù)時(shí) (如果設(shè)置超時(shí)為-1,則永遠(yuǎn)不會(huì)調(diào)用到)
//-(NSTimeInterval)socket:(GCDAsyncSocket *)sock shouldTimeoutReadWithTag:(long)tag elapsed:(NSTimeInterval)elapsed bytesDone:(NSUInteger)length
//{
// NSLog(@"來(lái)延時(shí),tag:%ld,elapsed:%f,length:%ld",tag,elapsed,length);
// return 10;
//}
@end
這個(gè)框架使用起來(lái)也十分簡(jiǎn)單,它基于Scoket往上進(jìn)行了一層封裝,提供了OC的接口給我們使用。至于使用方法,大家看看注釋?xiě)?yīng)該就能明白,這里唯一需要說(shuō)的一點(diǎn)就是這個(gè)方法:
[gcdSocket readDataWithTimeout:-1 tag:110];
這個(gè)方法的作用就是去讀取當(dāng)前消息隊(duì)列中的未讀消息。記住,這里不調(diào)用這個(gè)方法,消息回調(diào)的代理是永遠(yuǎn)不會(huì)被觸發(fā)的。而且必須是tag相同,如果tag不同,這個(gè)收到消息的代理也不會(huì)被處罰。
我們調(diào)用一次這個(gè)方法,只能觸發(fā)一次讀取消息的代理,如果我們調(diào)用的時(shí)候沒(méi)有未讀消息,它就會(huì)等在那,直到消息來(lái)了被觸發(fā)。一旦被觸發(fā)一次代理后,我們必須再次調(diào)用這個(gè)方法,否則,之后的消息到了仍舊無(wú)法觸發(fā)我們讀取消息的代理。就像我們?cè)诶又惺褂玫哪菢樱诿看巫x取到消息之后我們都去調(diào)用:
//收到消息的回調(diào)
- (void)socket:(GCDAsyncSocket *)sock didReadData:(NSData *)data withTag:(long)tag
{
NSString *msg = [[NSString alloc]initWithData:data encoding:NSUTF8StringEncoding];
NSLog(@"收到消息:%@",msg);
[self pullTheMsg];
}
//監(jiān)聽(tīng)最新的消息
- (void)pullTheMsg
{
//監(jiān)聽(tīng)讀數(shù)據(jù)的代理,只能監(jiān)聽(tīng)10秒,10秒過(guò)后調(diào)用代理方法 -1永遠(yuǎn)監(jiān)聽(tīng),不超時(shí),但是只收一次消息,
//所以每次接受到消息還得調(diào)用一次
[gcdSocket readDataWithTimeout:-1 tag:110];
}
除此之外,我們還需要說(shuō)的是這個(gè)超時(shí)timeout
這里如果設(shè)置10秒,那么就只能監(jiān)聽(tīng)10秒,10秒過(guò)后調(diào)用是否續(xù)時(shí)的代理方法:
-(NSTimeInterval)socket:(GCDAsyncSocket *)sock shouldTimeoutReadWithTag:(long)tag elapsed:(NSTimeInterval)elapsed bytesDone:(NSUInteger)length
如果我們選擇不續(xù)時(shí),那么10秒到了還沒(méi)收到消息,那么Scoket
會(huì)自動(dòng)斷開(kāi)連接。看到這里有些小伙伴要吐槽了,怎么一個(gè)方法設(shè)計(jì)的這么麻煩,當(dāng)然這里這么設(shè)計(jì)是有它的應(yīng)用場(chǎng)景的,我們后面再來(lái)細(xì)講。
我們同樣來(lái)運(yùn)行看看效果:
至此我們也用CocoaAsyncSocket
這個(gè)框架實(shí)現(xiàn)了一個(gè)簡(jiǎn)單的IM。
3.接著我們繼續(xù)來(lái)看看基于webScoket
的IM:
這個(gè)例子我們會(huì)把心跳,斷線(xiàn)重連,以及PingPong機(jī)制進(jìn)行簡(jiǎn)單的封裝,所以我們先來(lái)談?wù)勥@三個(gè)概念:
首先我們來(lái)談?wù)勈裁词切奶?/h6>
簡(jiǎn)單的來(lái)說(shuō),心跳就是用來(lái)檢測(cè)TCP連接的雙方是否可用。那又會(huì)有人要問(wèn)了,TCP不是本身就自帶一個(gè)KeepAlive
機(jī)制嗎?
這里我們需要說(shuō)明的是TCP的KeepAlive
機(jī)制只能保證連接的存在,但是并不能保證客戶(hù)端以及服務(wù)端的可用性.比如會(huì)有以下一種情況:
某臺(tái)服務(wù)器因?yàn)槟承┰驅(qū)е仑?fù)載超高,CPU 100%,無(wú)法響應(yīng)任何業(yè)務(wù)請(qǐng)求,但是使用 TCP 探針則仍舊能夠確定連接狀態(tài),這就是典型的連接活著但業(yè)務(wù)提供方已死的狀態(tài)。
這個(gè)時(shí)候心跳機(jī)制就起到作用了:
- 我們客戶(hù)端發(fā)起心跳Ping(一般都是客戶(hù)端),假如設(shè)置在10秒后如果沒(méi)有收到回調(diào),那么說(shuō)明服務(wù)器或者客戶(hù)端某一方出現(xiàn)問(wèn)題,這時(shí)候我們需要主動(dòng)斷開(kāi)連接。
- 服務(wù)端也是一樣,會(huì)維護(hù)一個(gè)socket的心跳間隔,當(dāng)約定時(shí)間內(nèi),沒(méi)有收到客戶(hù)端發(fā)來(lái)的心跳,我們會(huì)知道該連接已經(jīng)失效,然后主動(dòng)斷開(kāi)連接。
參考文章:為什么說(shuō)基于TCP的移動(dòng)端IM仍然需要心跳保活?
其實(shí)做過(guò)IM的小伙伴們都知道,我們真正需要心跳機(jī)制的原因其實(shí)主要是在于國(guó)內(nèi)運(yùn)營(yíng)商NAT
超時(shí)。
那么究竟什么是NAT
超時(shí)呢?
原來(lái)這是因?yàn)镮PV4引起的,我們上網(wǎng)很可能會(huì)處在一個(gè)NAT設(shè)備(無(wú)線(xiàn)路由器之類(lèi))之后。
NAT設(shè)備會(huì)在IP封包通過(guò)設(shè)備時(shí)修改源/目的IP地址. 對(duì)于家用路由器來(lái)說(shuō), 使用的是網(wǎng)絡(luò)地址端口轉(zhuǎn)換(NAPT), 它不僅改IP, 還修改TCP和UDP協(xié)議的端口號(hào), 這樣就能讓內(nèi)網(wǎng)中的設(shè)備共用同一個(gè)外網(wǎng)IP. 舉個(gè)例子, NAPT維護(hù)一個(gè)類(lèi)似下表的NAT表:
NAT設(shè)備會(huì)根據(jù)NAT表對(duì)出去和進(jìn)來(lái)的數(shù)據(jù)做修改, 比如將
192.168.0.3:8888
發(fā)出去的封包改成120.132.92.21:9202
, 外部就認(rèn)為他們是在和120.132.92.21:9202
通信. 同時(shí)NAT設(shè)備會(huì)將120.132.92.21:9202
收到的封包的IP和端口改成192.168.0.3:8888
, 再發(fā)給內(nèi)網(wǎng)的主機(jī), 這樣內(nèi)部和外部就能雙向通信了, 但如果其中192.168.0.3:8888
== 120.132.92.21:9202
這一映射因?yàn)槟承┰虮籒AT設(shè)備淘汰了, 那么外部設(shè)備就無(wú)法直接與192.168.0.3:8888
通信了。
我們的設(shè)備經(jīng)常是處在NAT設(shè)備的后面, 比如在大學(xué)里的校園網(wǎng), 查一下自己分配到的IP, 其實(shí)是內(nèi)網(wǎng)IP, 表明我們?cè)贜AT設(shè)備后面, 如果我們?cè)趯嬍以俳觽€(gè)路由器, 那么我們發(fā)出的數(shù)據(jù)包會(huì)多經(jīng)過(guò)一次NAT.
國(guó)內(nèi)移動(dòng)無(wú)線(xiàn)網(wǎng)絡(luò)運(yùn)營(yíng)商在鏈路上一段時(shí)間內(nèi)沒(méi)有數(shù)據(jù)通訊后, 會(huì)淘汰NAT表中的對(duì)應(yīng)項(xiàng), 造成鏈路中斷。
而國(guó)內(nèi)的運(yùn)營(yíng)商一般NAT超時(shí)的時(shí)間為5分鐘,所以通常我們心跳設(shè)置的時(shí)間間隔為3-5分鐘。
接著我們來(lái)講講PingPong機(jī)制:
很多小伙伴可能又會(huì)感覺(jué)到疑惑了,那么我們?cè)谶@心跳間隔的3-5分鐘如果連接假在線(xiàn)(例如在地鐵電梯這種環(huán)境下)。那么我們豈不是無(wú)法保證消息的即時(shí)性么?這顯然是我們無(wú)法接受的,所以業(yè)內(nèi)的解決方案是采用雙向的PingPong
機(jī)制。
當(dāng)服務(wù)端發(fā)出一個(gè)Ping
,客戶(hù)端沒(méi)有在約定的時(shí)間內(nèi)返回響應(yīng)的ack
,則認(rèn)為客戶(hù)端已經(jīng)不在線(xiàn),這時(shí)我們Server
端會(huì)主動(dòng)斷開(kāi)Scoket
連接,并且改由APNS
推送的方式發(fā)送消息。
同樣的是,當(dāng)客戶(hù)端去發(fā)送一個(gè)消息,因?yàn)槲覀冞t遲無(wú)法收到服務(wù)端的響應(yīng)ack包,則表明客戶(hù)端或者服務(wù)端已不在線(xiàn),我們也會(huì)顯示消息發(fā)送失敗,并且斷開(kāi)Scoket
連接。
還記得我們之前CocoaSyncSockt
的例子所講的獲取消息超時(shí)就斷開(kāi)嗎?其實(shí)它就是一個(gè)PingPong
機(jī)制的客戶(hù)端實(shí)現(xiàn)。我們每次可以在發(fā)送消息成功后,調(diào)用這個(gè)超時(shí)讀取的方法,如果一段時(shí)間沒(méi)收到服務(wù)器的響應(yīng),那么說(shuō)明連接不可用,則斷開(kāi)Scoket
連接
最后就是重連機(jī)制:
理論上,我們自己主動(dòng)去斷開(kāi)的Scoket
連接(例如退出賬號(hào),APP退出到后臺(tái)等等),不需要重連。其他的連接斷開(kāi),我們都需要進(jìn)行斷線(xiàn)重連。
一般解決方案是嘗試重連幾次,如果仍舊無(wú)法重連成功,那么不再進(jìn)行重連。
接下來(lái)的WebScoket
的例子,我會(huì)封裝一個(gè)重連時(shí)間指數(shù)級(jí)增長(zhǎng)的一個(gè)重連方式,可以作為一個(gè)參考。
言歸正傳,我們看完上述三個(gè)概念之后,我們來(lái)講一個(gè)WebScoket
最具代表性的一個(gè)第三方框架SocketRocket
。
我們首先來(lái)看看它對(duì)外封裝的一些方法:
@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
方法也很簡(jiǎn)單,分為兩個(gè)部分:
- 一部分為
SRWebSocket
的初始化,以及連接,關(guān)閉連接,發(fā)送消息等方法。 - 另一部分為
SRWebSocketDelegate
,其中包括一些回調(diào):
收到消息的回調(diào),連接失敗的回調(diào),關(guān)閉連接的回調(diào),收到pong的回調(diào),是否需要把data消息轉(zhuǎn)換成string的代理方法。
接著我們還是舉個(gè)例子來(lái)實(shí)現(xiàn)以下,首先來(lái)封裝一個(gè)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;
//設(shè)置代理線(xiàn)程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;
//心跳設(shè)置為3分鐘,NAT超時(shí)一般為5分鐘
heartBeat = [NSTimer scheduledTimerWithTimeInterval:3*60 repeats:YES block:^(NSTimer * _Nonnull timer) {
NSLog(@"heart");
//和服務(wù)端約定好發(fā)送什么作為心跳標(biāo)識(shí),盡可能的減小心跳包大小
[weakSelf sendMsg:@"heart"];
}];
[[NSRunLoop currentRunLoop]addTimer:heartBeat forMode:NSRunLoopCommonModes];
})
}
//取消心跳
- (void)destoryHeartBeat
{
dispatch_main_async_safe(^{
if (heartBeat) {
[heartBeat invalidate];
heartBeat = nil;
}
})
}
#pragma mark - 對(duì)外的一些接口
//建立連接
- (void)connect
{
[self initSocket];
//每次正常連接的時(shí)候清零重連時(shí)間
reConnectTime = 0;
}
//斷開(kāi)連接
- (void)disConnect
{
if (webSocket) {
[webSocket close];
webSocket = nil;
}
}
//發(fā)送消息
- (void)sendMsg:(NSString *)msg
{
[webSocket send:msg];
}
//重連機(jī)制
- (void)reConnect
{
[self disConnect];
//超過(guò)一分鐘就不再重連 所以只會(huì)重連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];
});
//重連時(shí)間2的指數(shù)級(jí)增長(zhǎng)
if (reConnectTime == 0) {
reConnectTime = 2;
}else{
reConnectTime *= 2;
}
}
//pingPong
- (void)ping{
[webSocket sendPing:nil];
}
#pragma mark - SRWebSocketDelegate
- (void)webSocket:(SRWebSocket *)webSocket didReceiveMessage:(id)message
{
NSLog(@"服務(wù)器返回收到消息:%@",message);
}
- (void)webSocketDidOpen:(SRWebSocket *)webSocket
{
NSLog(@"連接成功");
//連接成功了開(kāi)始發(fā)送心跳
[self initHeartBeat];
}
//open失敗的時(shí)候調(diào)用
- (void)webSocket:(SRWebSocket *)webSocket didFailWithError:(NSError *)error
{
NSLog(@"連接失敗.....\n%@",error);
//失敗了就去重連
[self reConnect];
}
//網(wǎng)絡(luò)連接中斷被調(diào)用
- (void)webSocket:(SRWebSocket *)webSocket didCloseWithCode:(NSInteger)code reason:(NSString *)reason wasClean:(BOOL)wasClean
{
NSLog(@"被關(guān)閉連接,code:%ld,reason:%@,wasClean:%d",code,reason,wasClean);
//如果是被用戶(hù)自己中斷的那么直接斷開(kāi)連接,否則開(kāi)始重連
if (code == disConnectByUser) {
[self disConnect];
}else{
[self reConnect];
}
//斷開(kāi)連接時(shí)銷(xiāo)毀心跳
[self destoryHeartBeat];
}
//sendPing的時(shí)候,如果網(wǎng)絡(luò)通的話(huà),則會(huì)收到回調(diào),但是必須保證ScoketOpen,否則會(huì)crash
- (void)webSocket:(SRWebSocket *)webSocket didReceivePong:(NSData *)pongPayload
{
NSLog(@"收到pong回調(diào)");
}
//將收到的消息,是否需要把data轉(zhuǎn)換為NSString,每次收到消息都會(huì)被調(diào)用,默認(rèn)YES
//- (BOOL)webSocketShouldConvertTextFrameToString:(SRWebSocket *)webSocket
//{
// NSLog(@"webSocketShouldConvertTextFrameToString");
//
// return NO;
//}
.m文件有點(diǎn)長(zhǎng),大家可以參照github中的demo進(jìn)行閱讀,這回我們添加了一些細(xì)節(jié)的東西了,包括一個(gè)簡(jiǎn)單的心跳,重連機(jī)制,還有webScoket
封裝好的一個(gè)pingpong
機(jī)制。
代碼非常簡(jiǎn)單,大家可以配合著注釋讀一讀,應(yīng)該很容易理解。
需要說(shuō)一下的是這個(gè)心跳機(jī)制是一個(gè)定時(shí)的間隔,往往我們可能會(huì)有更復(fù)雜實(shí)現(xiàn),比如我們正在發(fā)送消息的時(shí)候,可能就不需要心跳。當(dāng)不在發(fā)送的時(shí)候在開(kāi)啟心跳之類(lèi)的。微信有一種更高端的實(shí)現(xiàn)方式,有興趣的小伙伴可以看看:
微信的智能心跳實(shí)現(xiàn)方式
還有一點(diǎn)需要說(shuō)的就是這個(gè)重連機(jī)制,demo中我采用的是2的指數(shù)級(jí)別增長(zhǎng),第一次立刻重連,第二次2秒,第三次4秒,第四次8秒...直到大于64秒就不再重連。而任意的一次成功的連接,都會(huì)重置這個(gè)重連時(shí)間。
最后一點(diǎn)需要說(shuō)的是,這個(gè)框架給我們封裝的webscoket
在調(diào)用它的sendPing
方法之前,一定要判斷當(dāng)前scoket
是否連接,如果不是連接狀態(tài),程序則會(huì)crash
。
客戶(hù)端的實(shí)現(xiàn)就大致如此,接著同樣我們需要實(shí)現(xiàn)一個(gè)服務(wù)端,來(lái)看看實(shí)際通訊效果。
webScoket服務(wù)端實(shí)現(xiàn)
在這里我們無(wú)法沿用之前的node.js例子了,因?yàn)檫@并不是一個(gè)原生的scoket
,這是webScoket
,所以我們服務(wù)端同樣需要遵守webScoket
協(xié)議,兩者才能實(shí)現(xiàn)通信。
其實(shí)這里實(shí)現(xiàn)也很簡(jiǎn)單,我采用了node.js
的ws
模塊,只需要用npm
去安裝ws
即可。
什么是npm
呢?舉個(gè)例子,npm
之于Node.js
相當(dāng)于cocospod
至于iOS
,它就是一個(gè)拓展模塊的一個(gè)管理工具。如果不知道怎么用的可以看看這篇文章:npm的使用
我們進(jìn)入當(dāng)前腳本目錄,輸入終端命令,即可安裝ws
模塊:
$ npm install ws
大家如果懶得去看npm的小伙伴也沒(méi)關(guān)系,直接下載github中的 WSServer.js
這個(gè)文件運(yùn)行即可。
該源文件代碼如下:
var WebSocketServer = require('ws').Server,
wss = new WebSocketServer({ port: 6969 });
wss.on('connection', function (ws) {
console.log('client connected');
ws.send('你是第' + wss.clients.length + '位');
//收到消息回調(diào)
ws.on('message', function (message) {
console.log(message);
ws.send('收到:'+message);
});
// 退出聊天
ws.on('close', function(close) {
console.log('退出連接了');
});
});
console.log('開(kāi)始監(jiān)聽(tīng)6969端口');
代碼沒(méi)幾行,理解起來(lái)很簡(jiǎn)單。
就是監(jiān)聽(tīng)了本機(jī)6969端口,如果客戶(hù)端連接了,打印lient connected,并且向客戶(hù)端發(fā)送:你是第幾位。
如果收到客戶(hù)端消息后,打印消息,并且向客戶(hù)端發(fā)送這條收到的消息。
接著我們同樣來(lái)運(yùn)行一下看看效果:
運(yùn)行我們可以看到,主動(dòng)去斷開(kāi)的連接,沒(méi)有去重連,而server端斷開(kāi)的,我們開(kāi)啟了重連。感興趣的朋友可以下載demo實(shí)際運(yùn)行一下。
4.我們接著來(lái)看看MQTT:
MQTT是一個(gè)聊天協(xié)議,它比webScoket
更上層,屬于應(yīng)用層。
它的基本模式是簡(jiǎn)單的發(fā)布訂閱,也就是說(shuō)當(dāng)一條消息發(fā)出去的時(shí)候,誰(shuí)訂閱了誰(shuí)就會(huì)受到。其實(shí)它并不適合IM的場(chǎng)景,例如用來(lái)實(shí)現(xiàn)有些簡(jiǎn)單IM場(chǎng)景,卻需要很大量的、復(fù)雜的處理。
比較適合它的場(chǎng)景為訂閱發(fā)布這種模式的,例如微信的實(shí)時(shí)共享位置,滴滴的地圖上小車(chē)的移動(dòng)、客戶(hù)端推送等功能。
首先我們來(lái)看看基于MQTT
協(xié)議的框架-MQTTKit
:
這個(gè)框架是c來(lái)寫(xiě)的,把一些方法公開(kāi)在MQTTKit
類(lèi)中,對(duì)外用OC來(lái)調(diào)用,我們來(lái)看看這個(gè)類(lèi):
@interface MQTTClient : NSObject {
struct mosquitto *mosq;
}
@property (readwrite, copy) NSString *clientID;
@property (readwrite, copy) NSString *host;
@property (readwrite, assign) unsigned short port;
@property (readwrite, copy) NSString *username;
@property (readwrite, copy) NSString *password;
@property (readwrite, assign) unsigned short keepAlive;
@property (readwrite, assign) BOOL cleanSession;
@property (nonatomic, copy) MQTTMessageHandler messageHandler;
+ (void) initialize;
+ (NSString*) version;
- (MQTTClient*) initWithClientId: (NSString *)clientId;
- (void) setMessageRetry: (NSUInteger)seconds;
#pragma mark - Connection
- (void) connectWithCompletionHandler:(void (^)(MQTTConnectionReturnCode code))completionHandler;
- (void) connectToHost: (NSString*)host
completionHandler:(void (^)(MQTTConnectionReturnCode code))completionHandler;
- (void) disconnectWithCompletionHandler:(void (^)(NSUInteger code))completionHandler;
- (void) reconnect;
- (void)setWillData:(NSData *)payload
toTopic:(NSString *)willTopic
withQos:(MQTTQualityOfService)willQos
retain:(BOOL)retain;
- (void)setWill:(NSString *)payload
toTopic:(NSString *)willTopic
withQos:(MQTTQualityOfService)willQos
retain:(BOOL)retain;
- (void)clearWill;
#pragma mark - Publish
- (void)publishData:(NSData *)payload
toTopic:(NSString *)topic
withQos:(MQTTQualityOfService)qos
retain:(BOOL)retain
completionHandler:(void (^)(int mid))completionHandler;
- (void)publishString:(NSString *)payload
toTopic:(NSString *)topic
withQos:(MQTTQualityOfService)qos
retain:(BOOL)retain
completionHandler:(void (^)(int mid))completionHandler;
#pragma mark - Subscribe
- (void)subscribe:(NSString *)topic
withCompletionHandler:(MQTTSubscriptionCompletionHandler)completionHandler;
- (void)subscribe:(NSString *)topic
withQos:(MQTTQualityOfService)qos
completionHandler:(MQTTSubscriptionCompletionHandler)completionHandler;
- (void)unsubscribe: (NSString *)topic
withCompletionHandler:(void (^)(void))completionHandler;
這個(gè)類(lèi)一共分為4個(gè)部分:初始化、連接、發(fā)布、訂閱,具體方法的作用可以先看看方法名理解下,我們接著來(lái)用這個(gè)框架封裝一個(gè)實(shí)例。
同樣,我們封裝了一個(gè)單例MQTTManager
。
MQTTManager.h
#import <Foundation/Foundation.h>
@interface MQTTManager : NSObject
+ (instancetype)share;
- (void)connect;
- (void)disConnect;
- (void)sendMsg:(NSString *)msg;
@end
MQTTManager.m
#import "MQTTManager.h"
#import "MQTTKit.h"
static NSString * Khost = @"127.0.0.1";
static const uint16_t Kport = 6969;
static NSString * KClientID = @"tuyaohui";
@interface MQTTManager()
{
MQTTClient *client;
}
@end
@implementation MQTTManager
+ (instancetype)share
{
static dispatch_once_t onceToken;
static MQTTManager *instance = nil;
dispatch_once(&onceToken, ^{
instance = [[self alloc]init];
});
return instance;
}
//初始化連接
- (void)initSocket
{
if (client) {
[self disConnect];
}
client = [[MQTTClient alloc] initWithClientId:KClientID];
client.port = Kport;
[client setMessageHandler:^(MQTTMessage *message)
{
//收到消息的回調(diào),前提是得先訂閱
NSString *msg = [[NSString alloc]initWithData:message.payload encoding:NSUTF8StringEncoding];
NSLog(@"收到服務(wù)端消息:%@",msg);
}];
[client connectToHost:Khost completionHandler:^(MQTTConnectionReturnCode code) {
switch (code) {
case ConnectionAccepted:
NSLog(@"MQTT連接成功");
//訂閱自己ID的消息,這樣收到消息就能回調(diào)
[client subscribe:client.clientID withCompletionHandler:^(NSArray *grantedQos) {
NSLog(@"訂閱tuyaohui成功");
}];
break;
case ConnectionRefusedBadUserNameOrPassword:
NSLog(@"錯(cuò)誤的用戶(hù)名密碼");
//....
default:
NSLog(@"MQTT連接失敗");
break;
}
}];
}
#pragma mark - 對(duì)外的一些接口
//建立連接
- (void)connect
{
[self initSocket];
}
//斷開(kāi)連接
- (void)disConnect
{
if (client) {
//取消訂閱
[client unsubscribe:client.clientID withCompletionHandler:^{
NSLog(@"取消訂閱tuyaohui成功");
}];
//斷開(kāi)連接
[client disconnectWithCompletionHandler:^(NSUInteger code) {
NSLog(@"斷開(kāi)MQTT成功");
}];
client = nil;
}
}
//發(fā)送消息
- (void)sendMsg:(NSString *)msg
{
//發(fā)送一條消息,發(fā)送給自己訂閱的主題
[client publishString:msg toTopic:KClientID withQos:ExactlyOnce retain:YES completionHandler:^(int mid) {
}];
}
@end
實(shí)現(xiàn)代碼很簡(jiǎn)單,需要說(shuō)一下的是:
1)當(dāng)我們連接成功了,我們需要去訂閱自己clientID
的消息,這樣才能收到發(fā)給自己的消息。
2)其次是這個(gè)框架為我們實(shí)現(xiàn)了一個(gè)QOS機(jī)制,那么什么是QOS呢?
QoS(Quality of Service,服務(wù)質(zhì)量)指一個(gè)網(wǎng)絡(luò)能夠利用各種基礎(chǔ)技術(shù),為指定的網(wǎng)絡(luò)通信提供更好的服務(wù)能力, 是網(wǎng)絡(luò)的一種安全機(jī)制, 是用來(lái)解決網(wǎng)絡(luò)延遲和阻塞等問(wèn)題的一種技術(shù)。
在這里,它提供了三個(gè)選項(xiàng):
typedef enum MQTTQualityOfService : NSUInteger {
AtMostOnce,
AtLeastOnce,
ExactlyOnce
} MQTTQualityOfService;
分別對(duì)應(yīng)最多發(fā)送一次,至少發(fā)送一次,精確只發(fā)送一次。
- QOS(0),最多發(fā)送一次:如果消息沒(méi)有發(fā)送過(guò)去,那么就直接丟失。
- QOS(1),至少發(fā)送一次:保證消息一定發(fā)送過(guò)去,但是發(fā)幾次不確定。
- QOS(2),精確只發(fā)送一次:它內(nèi)部會(huì)有一個(gè)很復(fù)雜的發(fā)送機(jī)制,確保消息送到,而且只發(fā)送一次。
更詳細(xì)的關(guān)于該機(jī)制可以看看這篇文章:MQTT協(xié)議筆記之消息流QOS。
同樣的我們需要一個(gè)用MQTT協(xié)議實(shí)現(xiàn)的服務(wù)端,我們還是node.js來(lái)實(shí)現(xiàn),這次我們還是需要用npm
來(lái)新增一個(gè)模塊mosca
。
我們來(lái)看看服務(wù)端代碼:
MQTTServer.js
var mosca = require('mosca');
var MqttServer = new mosca.Server({
port: 6969
});
MqttServer.on('clientConnected', function(client){
console.log('收到客戶(hù)端連接,連接ID:', client.id);
});
/**
* 監(jiān)聽(tīng)MQTT主題消息
**/
MqttServer.on('published', function(packet, client) {
var topic = packet.topic;
console.log('有消息來(lái)了','topic為:'+topic+',message為:'+ packet.payload.toString());
});
MqttServer.on('ready', function(){
console.log('mqtt服務(wù)器開(kāi)啟,監(jiān)聽(tīng)6969端口');
});
服務(wù)端代碼沒(méi)幾行,開(kāi)啟了一個(gè)服務(wù),并且監(jiān)聽(tīng)本機(jī)6969端口。并且監(jiān)聽(tīng)了客戶(hù)端連接、發(fā)布消息等狀態(tài)。
接著我們同樣來(lái)運(yùn)行一下看看效果:
至此,我們實(shí)現(xiàn)了一個(gè)簡(jiǎn)單的MQTT封裝。
5.XMPP:XMPPFramework框架
結(jié)果就是并沒(méi)有XMPP...因?yàn)閭€(gè)人感覺(jué)XMPP對(duì)于IM來(lái)說(shuō)實(shí)在是不堪重用。僅僅只能作為一個(gè)玩具demo,給大家練練手。網(wǎng)上有太多XMPP的內(nèi)容了,相當(dāng)一部分用openfire來(lái)做服務(wù)端,這一套東西實(shí)在是太老了。還記得多年前,樓主初識(shí)IM就是用的這一套東西...
如果大家仍然感興趣的可以看看這篇文章:iOS 的 XMPPFramework 簡(jiǎn)介。這里就不舉例贅述了。
三、關(guān)于IM傳輸格式的選擇:
引用陳宜龍大神文章(iOS程序犭袁 )中一段:
使用 ProtocolBuffer 減少 Payload
滴滴打車(chē)40%;
攜程之前分享過(guò),說(shuō)是采用新的Protocol Buffer數(shù)據(jù)格式+Gzip壓縮后的Payload大小降低了15%-45%。數(shù)據(jù)序列化耗時(shí)下降了80%-90%。
采用高效安全的私有協(xié)議,支持長(zhǎng)連接的復(fù)用,穩(wěn)定省電省流量
【高效】提高網(wǎng)絡(luò)請(qǐng)求成功率,消息體越大,失敗幾率隨之增加。
【省流量】流量消耗極少,省流量。一條消息數(shù)據(jù)用Protobuf序列化后的大小是 JSON 的1/10、XML格式的1/20、是二進(jìn)制序列化的1/10。同 XML 相比, Protobuf 性能優(yōu)勢(shì)明顯。它以高效的二進(jìn)制方式存儲(chǔ),比 XML 小 3 到 10 倍,快 20 到 100 倍。
【省電】省電
【高效心跳包】同時(shí)心跳包協(xié)議對(duì)IM的電量和流量影響很大,對(duì)心跳包協(xié)議上進(jìn)行了極簡(jiǎn)設(shè)計(jì):僅 1 Byte 。
【易于使用】開(kāi)發(fā)人員通過(guò)按照一定的語(yǔ)法定義結(jié)構(gòu)化的消息格式,然后送給命令行工具,工具將自動(dòng)生成相關(guān)的類(lèi),可以支持java、c++、python、Objective-C等語(yǔ)言環(huán)境。通過(guò)將這些類(lèi)包含在項(xiàng)目中,可以很輕松的調(diào)用相關(guān)方法來(lái)完成業(yè)務(wù)消息的序列化與反序列化工作。語(yǔ)言支持:原生支持c++、java、python、Objective-C等多達(dá)10余種語(yǔ)言。 2015-08-27 Protocol Buffers v3.0.0-beta-1中發(fā)布了Objective-C(Alpha)版本, 2016-07-28 3.0 Protocol Buffers v3.0.0正式版發(fā)布,正式支持 Objective-C。
【可靠】微信和手機(jī) QQ 這樣的主流 IM 應(yīng)用也早已在使用它(采用的是改造過(guò)的Protobuf協(xié)議)
如何測(cè)試驗(yàn)證 Protobuf 的高性能?
對(duì)數(shù)據(jù)分別操作100次,1000次,10000次和100000次進(jìn)行了測(cè)試,
縱坐標(biāo)是完成時(shí)間,單位是毫秒,
反序列化
序列化
字節(jié)長(zhǎng)度
數(shù)據(jù)來(lái)自:項(xiàng)目 thrift-protobuf-compare,測(cè)試項(xiàng)為 Total Time,也就是 指一個(gè)對(duì)象操作的整個(gè)時(shí)間,包括創(chuàng)建對(duì)象,將對(duì)象序列化為內(nèi)存中的字節(jié)序列,然后再反序列化的整個(gè)過(guò)程。從測(cè)試結(jié)果可以看到 Protobuf 的成績(jī)很好.
缺點(diǎn):
可能會(huì)造成 APP 的包體積增大,通過(guò) Google 提供的腳本生成的 Model,會(huì)非常“龐大”,Model 一多,包體積也就會(huì)跟著變大。
如果 Model 過(guò)多,可能導(dǎo)致 APP 打包后的體積驟增,但 IM 服務(wù)所使用的 Model 非常少,比如在 ChatKit-OC 中只用到了一個(gè) Protobuf 的 Model:Message對(duì)象,對(duì)包體積的影響微乎其微。
在使用過(guò)程中要合理地權(quán)衡包體積以及傳輸效率的問(wèn)題,據(jù)說(shuō)去哪兒網(wǎng),就曾經(jīng)為了減少包體積,進(jìn)而減少了 Protobuf 的使用。
綜上所述,我們選擇傳輸格式的時(shí)候:ProtocolBuffer
> Json
> XML
如果大家對(duì)ProtocolBuffer
用法感興趣可以參考下這兩篇文章:
ProtocolBuffer for Objective-C 運(yùn)行環(huán)境配置及使用
iOS之ProtocolBuffer搭建和示例demo
三、IM一些其它問(wèn)題
1.IM的可靠性:
我們之前穿插在例子中提到過(guò):
心跳機(jī)制、PingPong機(jī)制、斷線(xiàn)重連機(jī)制、還有我們后面所說(shuō)的QOS機(jī)制。這些被用來(lái)保證連接的可用,消息的即時(shí)與準(zhǔn)確的送達(dá)等等。
上述內(nèi)容保證了我們IM服務(wù)時(shí)的可靠性,其實(shí)我們能做的還有很多:比如我們?cè)诖笪募鬏數(shù)臅r(shí)候使用分片上傳、斷點(diǎn)續(xù)傳、秒傳技術(shù)等來(lái)保證文件的傳輸。
2.安全性:
我們通常還需要一些安全機(jī)制來(lái)保證我們IM通信安全。
例如:防止 DNS 污染、帳號(hào)安全、第三方服務(wù)器鑒權(quán)、單點(diǎn)登錄等等
3.一些其他的優(yōu)化:
類(lèi)似微信,服務(wù)器不做聊天記錄的存儲(chǔ),只在本機(jī)進(jìn)行緩存,這樣可以減少對(duì)服務(wù)端數(shù)據(jù)的請(qǐng)求,一方面減輕了服務(wù)器的壓力,另一方面減少客戶(hù)端流量的消耗。
我們進(jìn)行http連接的時(shí)候盡量采用上層API,類(lèi)似NSUrlSession
。而網(wǎng)絡(luò)框架盡量使用AFNetWorking3
。因?yàn)檫@些上層網(wǎng)絡(luò)請(qǐng)求都用的是HTTP/2 ,我們請(qǐng)求的時(shí)候可以復(fù)用這些連接。
更多優(yōu)化相關(guān)內(nèi)容可以參考參考這篇文章:
IM 即時(shí)通訊技術(shù)在多應(yīng)用場(chǎng)景下的技術(shù)實(shí)現(xiàn),以及性能調(diào)優(yōu)
四、音視頻通話(huà)
IM應(yīng)用中的實(shí)時(shí)音視頻技術(shù),幾乎是IM開(kāi)發(fā)中的最后一道高墻。原因在于:實(shí)時(shí)音視頻技術(shù) = 音視頻處理技術(shù) + 網(wǎng)絡(luò)傳輸技術(shù) 的橫向技術(shù)應(yīng)用集合體,而公共互聯(lián)網(wǎng)不是為了實(shí)時(shí)通信設(shè)計(jì)的。
實(shí)時(shí)音視頻技術(shù)上的實(shí)現(xiàn)內(nèi)容主要包括:音視頻的采集、編碼、網(wǎng)絡(luò)傳輸、解碼、播放等環(huán)節(jié)。這么多項(xiàng)并不簡(jiǎn)單的技術(shù)應(yīng)用,如果把握不當(dāng),將會(huì)在在實(shí)際開(kāi)發(fā)過(guò)程中遇到一個(gè)又一個(gè)的坑。
因?yàn)闃侵髯约簩?duì)這塊的技術(shù)理解很淺,所以引用了一個(gè)系列的文章來(lái)給大家一個(gè)參考,感興趣的朋友可以看看:
《即時(shí)通訊音視頻開(kāi)發(fā)(一):視頻編解碼之理論概述》
《即時(shí)通訊音視頻開(kāi)發(fā)(二):視頻編解碼之?dāng)?shù)字視頻介紹》
《即時(shí)通訊音視頻開(kāi)發(fā)(三):視頻編解碼之編碼基礎(chǔ)》
《即時(shí)通訊音視頻開(kāi)發(fā)(四):視頻編解碼之預(yù)測(cè)技術(shù)介紹》
《即時(shí)通訊音視頻開(kāi)發(fā)(五):認(rèn)識(shí)主流視頻編碼技術(shù)H.264》
《即時(shí)通訊音視頻開(kāi)發(fā)(六):如何開(kāi)始音頻編解碼技術(shù)的學(xué)習(xí)》
《即時(shí)通訊音視頻開(kāi)發(fā)(七):音頻基礎(chǔ)及編碼原理入門(mén)》
《即時(shí)通訊音視頻開(kāi)發(fā)(八):常見(jiàn)的實(shí)時(shí)語(yǔ)音通訊編碼標(biāo)準(zhǔn)》
《即時(shí)通訊音視頻開(kāi)發(fā)(九):實(shí)時(shí)語(yǔ)音通訊的回音及回音消除?概述》
《即時(shí)通訊音視頻開(kāi)發(fā)(十):實(shí)時(shí)語(yǔ)音通訊的回音消除?技術(shù)詳解》
《即時(shí)通訊音視頻開(kāi)發(fā)(十一):實(shí)時(shí)語(yǔ)音通訊丟包補(bǔ)償技術(shù)詳解》
《即時(shí)通訊音視頻開(kāi)發(fā)(十二):多人實(shí)時(shí)音視頻聊天架構(gòu)探討》
《即時(shí)通訊音視頻開(kāi)發(fā)(十三):實(shí)時(shí)視頻編碼H.264的特點(diǎn)與優(yōu)勢(shì)》
《即時(shí)通訊音視頻開(kāi)發(fā)(十四):實(shí)時(shí)音視頻數(shù)據(jù)傳輸協(xié)議介紹》
《即時(shí)通訊音視頻開(kāi)發(fā)(十五):聊聊P2P與實(shí)時(shí)音視頻的應(yīng)用情況》
《即時(shí)通訊音視頻開(kāi)發(fā)(十六):移動(dòng)端實(shí)時(shí)音視頻開(kāi)發(fā)的幾個(gè)建議》
《即時(shí)通訊音視頻開(kāi)發(fā)(十七):視頻編碼H.264、V8的前世今生》
寫(xiě)在最后:
本文內(nèi)容為原創(chuàng),且僅代表樓主現(xiàn)階段的一些思想,如果有什么錯(cuò)誤,歡迎指正~