iOS即時通訊詳解

image
前言
  • 本文會用實例的方式,將iOS各種IM的方案都簡單的實現(xiàn)一遍觉义。并且提供一些選型测摔、實現(xiàn)細(xì)節(jié)以及優(yōu)化的建議蛮瞄。

  • 注:文中的所有的代碼示例所坯,在github中都有demo:

    iOS即時通訊,從入門到“放棄”挂捅?(demo)

    可以打開項目先預(yù)覽效果芹助,對照著進(jìn)行閱讀。

言歸正傳闲先,首先我們來總結(jié)一下我們?nèi)崿F(xiàn)IM的方式

第一種方式状土,使用第三方IM服務(wù)

對于短平快的公司,完全可以采用第三方SDK來實現(xiàn)伺糠。國內(nèi)IM的第三方服務(wù)商有很多蒙谓,類似云信、環(huán)信训桶、融云累驮、LeanCloud,當(dāng)然還有其它的很多舵揭,這里就不一一舉例了谤专,感興趣的小伙伴可以自行查閱下。

  • 第三方服務(wù)商IM底層協(xié)議基本上都是TCP午绳。他們的IM方案很成熟置侍,有了它們,我們甚至不需要自己去搭建IM后臺,什么都不需要去考慮蜡坊。

    如果你足夠懶杠输,甚至連UI都不需要自己做,這些第三方有各自一套IM的UI秕衙,拿來就可以直接用抬伺。真可謂3分鐘集成...

  • 但是缺點也很明顯,定制化程度太高灾梦,很多東西我們不可控。當(dāng)然還有一個最最重要的一點妓笙,就是太貴了...作為真正社交為主打的APP若河,僅此一點,就足以讓我們望而卻步寞宫。當(dāng)然萧福,如果IM對于APP只是一個輔助功能,那么用第三方服務(wù)也無可厚非辈赋。

另外一種方式鲫忍,我們自己去實現(xiàn)

我們自己去實現(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é)問題需要考慮智润,例如TCP的長連接如何保持,心跳機(jī)制未辆,Qos機(jī)制窟绷,重連機(jī)制等等...當(dāng)然,除此之外咐柜,我們還有一些安全問題需要考慮兼蜈。

一、傳輸協(xié)議的選擇

接下來我們可能需要自己考慮去實現(xiàn)IM炕桨,首先從傳輸層協(xié)議來說饭尝,我們有兩種選擇:TCP or UDP

image

這個問題已經(jīng)被討論過無數(shù)次了献宫,對深層次的細(xì)節(jié)感興趣的朋友可以看看這篇文章:

這里我們直接說結(jié)論吧:對于小公司或者技術(shù)不那么成熟的公司,IM一定要用TCP來實現(xiàn),因為如果你要用UDP的話涉瘾,需要做的事太多知态。當(dāng)然QQ就是用的UDP協(xié)議,當(dāng)然不僅僅是UDP立叛,騰訊還用了自己的私有協(xié)議负敏,來保證了傳輸?shù)目煽啃裕沤^了UDP下各種數(shù)據(jù)丟包秘蛇,亂序等等一系列問題其做。

總之一句話,如果你覺得團(tuán)隊技術(shù)很成熟赁还,那么你用UDP也行妖泄,否則還是用TCP為好。

二艘策、我們來看看各種聊天協(xié)議

首先我們以實現(xiàn)方式來切入蹈胡,基本上有以下四種實現(xiàn)方式:

  1. 基于Scoket原生:代表框架 CocoaAsyncSocket

  2. 基于WebScoket:代表框架 SocketRocket朋蔫。

  3. 基于MQTT:代表框架 MQTTKit罚渐。

  4. 基于XMPP:代表框架 XMPPFramework

當(dāng)然驯妄,以上四種方式我們都可以不使用第三方框架荷并,直接基于OS底層Scoket去實現(xiàn)我們的自定義封裝。下面我會給出一個基于Scoket原生而不使用框架的例子富玷,供大家參考一下璧坟。

首先需要搞清楚的是,其中MQTTXMPP為聊天協(xié)議赎懦,它們是最上層的協(xié)議雀鹃,而WebScoket是傳輸通訊協(xié)議,它是基于Socket封裝的一個協(xié)議励两。而通常我們所說的騰訊IM的私有協(xié)議黎茎,就是基于WebScoket或者Scoket原生進(jìn)行封裝的一個聊天協(xié)議。

具體這3種聊天協(xié)議的對比優(yōu)劣如下:

協(xié)議優(yōu)劣對比.png

所以說到底当悔,iOS要做一個真正的IM產(chǎn)品傅瞻,一般都是基于Scoket或者WebScoket等,再之上加上一些私有協(xié)議來保證的盲憎。

1.我們先不使用任何框架嗅骄,直接用OS底層Socket來實現(xiàn)一個簡單的IM。

我們客戶端的實現(xiàn)思路也是很簡單饼疙,創(chuàng)建Socket溺森,和服務(wù)器的Socket對接上,然后開始傳輸數(shù)據(jù)就可以了。

  • 我們學(xué)過c/c++或者java這些語言屏积,我們就知道医窿,往往任何教程,最后一章都是講Socket編程炊林,而Socket是什么呢姥卢,簡單的來說,就是我們使用TCP/IP 或者UDP/IP協(xié)議的一組編程接口渣聚。如下圖所示:
image

我們在應(yīng)用層独榴,使用socket,輕易的實現(xiàn)了進(jìn)程之間的通信(跨網(wǎng)絡(luò)的)奕枝。想想括眠,如果沒有socket,我們要直面TCP/IP協(xié)議倍权,我們需要去寫多少繁瑣而又重復(fù)的代碼。

如果有對socket概念仍然有所困惑的捞烟,可以看看這篇文章:

從問題看本質(zhì)薄声,socket到底是什么?题画。

但是這篇文章關(guān)于并發(fā)連接數(shù)的認(rèn)識是錯誤的默辨,正確的認(rèn)識可以看看這篇文章:

單臺服務(wù)器并發(fā)TCP連接數(shù)到底可以有多少

我們接著可以開始著手去實現(xiàn)IM了,首先我們不基于任何框架苍息,直接去調(diào)用OS底層-基于C的BSD Socket去實現(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ī)地址與端口號綁定,成功綁定返回0盖喷,失敗返回 -1爆办。
int bind(int socketFileDescriptor,sockaddr *addressToBind,int addressStructLength)
//接受客戶端連接請求并將客戶端的網(wǎng)絡(luò)地址信息保存到 clientAddress 中。
int accept(int socketFileDescriptor,sockaddr *clientAddress, int clientAddressStructLength)
//客戶端向特定網(wǎng)絡(luò)地址的服務(wù)器發(fā)送連接請求课梳,連接成功返回0距辆,失敗返回 -1。
int connect(int socketFileDescriptor,sockaddr *serverAddress, int serverAddressLength)
//使用 DNS 查找特定主機(jī)名字對應(yīng)的 IP 地址暮刃。如果找不到對應(yīng)的 IP 地址則返回 NULL跨算。
hostent* gethostbyname(char *hostname)
//通過 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)
//通過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)

讓我們可以對socket進(jìn)行各種操作,首先我們來用它寫個客戶端奕纫√崽眨總結(jié)一下,簡單的IM客戶端需要做如下4件事:

  1. 客戶端調(diào)用 socket(...) 創(chuàng)建socket匹层;

  2. 客戶端調(diào)用 connect(...) 向服務(wù)器發(fā)起連接請求以建立連接隙笆;

  3. 客戶端與服務(wù)器建立連接之后,就可以通過send(...)/receive(...)向客戶端發(fā)送或從客戶端接收數(shù)據(jù)升筏;

  4. 客戶端調(diào)用 close 關(guān)閉 socket撑柔;

根據(jù)上面4條大綱,我們封裝了一個名為TYHSocketManager的單例您访,來對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
{
    //每次連接前铅忿,先斷開連接
    if (_clientScoket != 0) {
        [self disConnect];
        _clientScoket = 0;
    }

    //創(chuàng)建客戶端socket
    _clientScoket = CreateClinetSocket();

    //服務(wù)器Ip
    const char * server_ip="127.0.0.1";
    //服務(wù)器端口
    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;
    //創(chuàng)建一個socket,返回值為Int。(注scoket其實就是Int類型)
    //第一個參數(shù)addressFamily IPv4(AF_INET) 或 IPv6(AF_INET6)灵汪。
    //第二個參數(shù) type 表示 socket 的類型檀训,通常是流stream(SOCK_STREAM) 或數(shù)據(jù)報文datagram(SOCK_DGRAM)
    //第三個參數(shù) protocol 參數(shù)通常設(shè)置為0,以便讓系統(tǒng)自動為選擇我們合適的協(xié)議享言,對于 stream socket 來說會是 TCP 協(xié)議(IPPROTO_TCP)峻凫,而對于 datagram來說會是 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)
{

    //生成一個sockaddr_in類型結(jié)構(gòu)體
    struct sockaddr_in sAddr={0};
    sAddr.sin_len=sizeof(sAddr);
    //設(shè)置IPv4
    sAddr.sin_family=AF_INET;

    //inet_aton是一個改進(jìn)的方法來將一個字符串IP地址轉(zhuǎn)換為一個32位的網(wǎng)絡(luò)序列IP地址
    //如果這個函數(shù)成功览露,函數(shù)的返回值非零蔚晨,如果輸入地址不正確則會返回零。
    inet_aton(server_ip, &sAddr.sin_addr);

    //htons是將整型變量從主機(jī)字節(jié)順序轉(zhuǎn)變成網(wǎng)絡(luò)字節(jié)順序肛循,賦值端口號
    sAddr.sin_port=htons(port);

    //用scoket和服務(wù)端地址铭腕,發(fā)起連接。
    //客戶端向特定網(wǎng)絡(luò)地址的服務(wù)器發(fā)送連接請求多糠,連接成功返回0累舷,失敗返回 -1。
    //注意:該接口調(diào)用會阻塞當(dāng)前線程夹孔,直到服務(wù)器返回被盈。
    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
{
    //關(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方法了一個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ù)中,我們綁定了一個sockaddr_in類型的結(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é)可以見注釋贴谎。

  • 連接成功之后汞扎,我們就可以調(diào)用send函數(shù)和recv函數(shù)進(jìn)行消息收發(fā)了,在這里擅这,我新開辟了一個常駐線程澈魄,在這個線程中一個死循環(huán)里去不停的調(diào)用recv函數(shù),這樣服務(wù)端有消息發(fā)送過來仲翎,第一時間便能被接收到痹扇。

就這樣客戶端便簡單的可以用了,接著我們來看看服務(wù)端的實現(xiàn)溯香。

一樣帘营,我們首先對服務(wù)端需要做的工作簡單的總結(jié)下:
  1. 服務(wù)器調(diào)用 socket(...) 創(chuàng)建socket;

  2. 服務(wù)器調(diào)用 listen(...) 設(shè)置緩沖區(qū)逐哈;

  3. 服務(wù)器通過 accept(...)接受客戶端請求建立連接;

  4. 服務(wù)器與客戶端建立連接之后问顷,就可以通過 send(...)/receive(...)向客戶端發(fā)送或從客戶端接收數(shù)據(jù)昂秃;

  5. 服務(wù)器調(diào)用 close 關(guān)閉 socket;

接著我們就可以具體去實現(xiàn)了

OS底層的函數(shù)是支持我們?nèi)崿F(xiàn)服務(wù)端的杜窄,但是我們一般不會用iOS去這么做(試問真正的應(yīng)用場景肠骆,有誰用iOSscoket服務(wù)器么...),如果還是想用這些函數(shù)去實現(xiàn)服務(wù)端塞耕,可以參考下這篇文章: 深入淺出Cocoa-iOS網(wǎng)絡(luò)編程之Socket蚀腿。

在這里我用node.js去搭了一個簡單的scoket服務(wù)器。源碼如下:

var net = require('net');  
var HOST = '127.0.0.1';  
var PORT = 6969;  

// 創(chuàng)建一個TCP服務(wù)器實例扫外,調(diào)用listen函數(shù)開始監(jiān)聽指定端口  
// 傳入net.createServer()的回調(diào)函數(shù)將作為”connection“事件的處理函數(shù)  
// 在每一個“connection”事件中莉钙,該回調(diào)函數(shù)接收到的socket對象是唯一的  
net.createServer(function(sock) {  

    // 我們獲得一個連接 - 該連接自動關(guān)聯(lián)一個socket對象  
    console.log('CONNECTED: ' +  
        sock.remoteAddress + ':' + sock.remotePort);  
        sock.write('服務(wù)端發(fā)出:連接成功');  

    // 為這個socket實例添加一個"data"事件處理函數(shù)  
    sock.on('data', function(data) {  
        console.log('DATA ' + sock.remoteAddress + ': ' + data);  
        // 回發(fā)該數(shù)據(jù),客戶端將收到來自服務(wù)端的數(shù)據(jù)  
        sock.write('You said "' + data + '"');  
    });  
    // 為這個socket實例添加一個"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的朋友也不用著急筛谚,在這里你可以使用任意語言c/c++/java/oc等等去實現(xiàn)后臺磁玉,這里node.js僅僅是樓主的一個選擇,為了讓我們來驗證之前寫的客戶端scoket的效果驾讲。如果你不懂node.js也沒關(guān)系蚊伞,你只需要把上述樓主寫的相關(guān)代碼復(fù)制粘貼席赂,如果你本機(jī)有node的解釋器,那么直接在終端進(jìn)入該源代碼文件目錄中輸入:

node fileName

即可運(yùn)行該腳本(fileName為保存源代碼的文件名)时迫。

我們來看看運(yùn)行效果:

handle2.gif

服務(wù)器運(yùn)行起來了颅停,并且監(jiān)聽著6969端口。

接著我們用之前寫的iOS端的例子掠拳●啵客戶端打印顯示連接成功,而我們運(yùn)行的服務(wù)器也打印了連接成功碳想。接著我們發(fā)了一條消息烧董,服務(wù)端成功的接收到了消息后,把該消息再發(fā)送回客戶端胧奔,繞了一圈客戶端又收到了這條消息逊移。至此我們用OS底層scoket實現(xiàn)了簡單的IM。

大家看到這是不是覺得太過簡單了龙填?

當(dāng)然簡單胳泉,我們僅僅是實現(xiàn)了Scoket的連接,信息的發(fā)送與接收岩遗,除此之外我們什么都沒有做扇商,現(xiàn)實中,我們需要做的處理遠(yuǎn)不止于此宿礁,我們先接著往下看案铺。接下來,我們就一起看看第三方框架是如何實現(xiàn)IM的梆靖。

分割圖.png
2.我們接著來看看基于Socket原生的CocoaAsyncSocket:

這個框架實現(xiàn)了兩種傳輸協(xié)議TCPUDP控汉,分別對應(yīng)GCDAsyncSocket類和GCDAsyncUdpSocket,這里我們重點講GCDAsyncSocket返吻。

這里Socket服務(wù)器延續(xù)上一個例子姑子,因為同樣是基于原生Scoket的框架,所以之前的Node.js的服務(wù)端测僵,該例仍然試用街佑。這里我們就只需要去封裝客戶端的實例,我們還是創(chuàng)建一個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];
}

//發(fā)送消息
- (void)sendMsg:(NSString *)msg

{
    NSData *data  = [msg dataUsingEncoding:NSUTF8StringEncoding];
    //第二個參數(shù)沐旨,請求超時時間
    [gcdSocket writeData:data withTimeout:-1 tag:110];

}

//監(jiān)聽最新的消息
- (void)pullTheMsg
{
    //監(jiān)聽讀數(shù)據(jù)的代理  -1永遠(yuǎn)監(jiān)聽,不超時榨婆,但是只收一次消息希俩,
    //所以每次接受到消息還得調(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];

    //心跳寫在這...
}

//斷開連接的時候調(diào)用
- (void)socketDidDisconnect:(GCDAsyncSocket *)sock withError:(nullable NSError *)err
{
    NSLog(@"斷開連接,host:%@,port:%d",sock.localHost,sock.localPort);

    //斷線重連寫在這...

}

//寫成功的回調(diào)
- (void)socket:(GCDAsyncSocket*)sock didWriteDataWithTag:(long)tag
{
//    NSLog(@"寫的回調(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è)置超時為-1,則永遠(yuǎn)不會調(diào)用到)
//-(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往上進(jìn)行了一層封裝璃搜,提供了OC的接口給我們使用。至于使用方法鳞上,大家看看注釋應(yīng)該就能明白这吻,這里唯一需要說的一點就是這個方法:

[gcdSocket readDataWithTimeout:-1 tag:110];

這個方法的作用就是去讀取當(dāng)前消息隊列中的未讀消息。記住篙议,這里不調(diào)用這個方法唾糯,消息回調(diào)的代理是永遠(yuǎn)不會被觸發(fā)的。而且必須是tag相同鬼贱,如果tag不同移怯,這個收到消息的代理也不會被觸發(fā)。

我們調(diào)用一次這個方法这难,只能觸發(fā)一次讀取消息的代理舟误,如果我們調(diào)用的時候沒有未讀消息,它就會等在那姻乓,直到消息來了被觸發(fā)嵌溢。一旦被觸發(fā)一次代理后,我們必須再次調(diào)用這個方法蹋岩,否則赖草,之后的消息到了仍舊無法觸發(fā)我們讀取消息的代理。就像我們在例子中使用的那樣剪个,在每次讀取到消息之后我們都去調(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)聽最新的消息
- (void)pullTheMsg
{
    //監(jiān)聽讀數(shù)據(jù)的代理秧骑,只能監(jiān)聽10秒,10秒過后調(diào)用代理方法  -1永遠(yuǎn)監(jiān)聽扣囊,不超時乎折,但是只收一次消息,
    //所以每次接受到消息還得調(diào)用一次
    [gcdSocket readDataWithTimeout:-1 tag:110];

}

除此之外如暖,我們還需要說的是這個超時timeout

這里如果設(shè)置10秒,那么就只能監(jiān)聽10秒忌堂,10秒過后調(diào)用是否續(xù)時的代理方法:

-(NSTimeInterval)socket:(GCDAsyncSocket *)sock shouldTimeoutReadWithTag:(long)tag elapsed:(NSTimeInterval)elapsed bytesDone:(NSUInteger)length

如果我們選擇不續(xù)時盒至,那么10秒到了還沒收到消息,那么Scoket會自動斷開連接士修〖纤欤看到這里有些小伙伴要吐槽了,怎么一個方法設(shè)計的這么麻煩棋嘲,當(dāng)然這里這么設(shè)計是有它的應(yīng)用場景的酒唉,我們后面再來細(xì)講。

我們同樣來運(yùn)行看看效果:
handle3.gif

至此我們也用CocoaAsyncSocket這個框架實現(xiàn)了一個簡單的IM沸移。

分割圖.png
3.接著我們繼續(xù)來看看基于webScoket的IM:

這個例子我們會把心跳,斷線重連肛捍,以及PingPong機(jī)制進(jìn)行簡單的封裝斩狱,所以我們先來談?wù)勥@三個概念:

首先我們來談?wù)勈裁词切奶?/h6>

簡單的來說,心跳就是用來檢測TCP連接的雙方是否可用癞蚕。那又會有人要問了,TCP不是本身就自帶一個KeepAlive機(jī)制嗎辉哥?

這里我們需要說明的是TCP的KeepAlive機(jī)制只能保證連接的存在桦山,但是并不能保證客戶端以及服務(wù)端的可用性.比如會有以下一種情況:

某臺服務(wù)器因為某些原因?qū)е仑?fù)載超高,CPU 100%醋旦,無法響應(yīng)任何業(yè)務(wù)請求恒水,但是使用 TCP 探針則仍舊能夠確定連接狀態(tài),這就是典型的連接活著但業(yè)務(wù)提供方已死的狀態(tài)饲齐。

這個時候心跳機(jī)制就起到作用了:

  • 我們客戶端發(fā)起心跳Ping(一般都是客戶端)钉凌,假如設(shè)置在10秒后如果沒有收到回調(diào),那么說明服務(wù)器或者客戶端某一方出現(xiàn)問題箩张,這時候我們需要主動斷開連接甩骏。

  • 服務(wù)端也是一樣,會維護(hù)一個socket的心跳間隔先慷,當(dāng)約定時間內(nèi)饮笛,沒有收到客戶端發(fā)來的心跳,我們會知道該連接已經(jīng)失效论熙,然后主動斷開連接福青。

參考文章:為什么說基于TCP的移動端IM仍然需要心跳保活脓诡?

其實做過IM的小伙伴們都知道无午,我們真正需要心跳機(jī)制的原因其實主要是在于國內(nèi)運(yùn)營商NAT超時。

那么究竟什么是NAT超時呢?

原來這是因為IPV4引起的祝谚,我們上網(wǎng)很可能會處在一個NAT設(shè)備(無線路由器之類)之后宪迟。

NAT設(shè)備會在IP封包通過設(shè)備時修改源/目的IP地址. 對于家用路由器來說, 使用的是網(wǎng)絡(luò)地址端口轉(zhuǎn)換(NAPT), 它不僅改IP, 還修改TCP和UDP協(xié)議的端口號, 這樣就能讓內(nèi)網(wǎng)中的設(shè)備共用同一個外網(wǎng)IP. 舉個例子, NAPT維護(hù)一個類似下表的NAT表:

NAT映射

NAT設(shè)備會根據(jù)NAT表對出去和進(jìn)來的數(shù)據(jù)做修改, 比如將192.168.0.3:8888發(fā)出去的封包改成120.132.92.21:9202, 外部就認(rèn)為他們是在和120.132.92.21:9202通信. 同時NAT設(shè)備會將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這一映射因為某些原因被NAT設(shè)備淘汰了, 那么外部設(shè)備就無法直接與192.168.0.3:8888通信了。

我們的設(shè)備經(jīng)常是處在NAT設(shè)備的后面, 比如在大學(xué)里的校園網(wǎng), 查一下自己分配到的IP, 其實是內(nèi)網(wǎng)IP, 表明我們在NAT設(shè)備后面, 如果我們在寢室再接個路由器, 那么我們發(fā)出的數(shù)據(jù)包會多經(jīng)過一次NAT.

國內(nèi)移動無線網(wǎng)絡(luò)運(yùn)營商在鏈路上一段時間內(nèi)沒有數(shù)據(jù)通訊后, 會淘汰NAT表中的對應(yīng)項, 造成鏈路中斷交惯。

而國內(nèi)的運(yùn)營商一般NAT超時的時間為5分鐘次泽,所以通常我們心跳設(shè)置的時間間隔為3-5分鐘。

接著我們來講講PingPong機(jī)制:

很多小伙伴可能又會感覺到疑惑了席爽,那么我們在這心跳間隔的3-5分鐘如果連接假在線(例如在地鐵電梯這種環(huán)境下)意荤。那么我們豈不是無法保證消息的即時性么?這顯然是我們無法接受的只锻,所以業(yè)內(nèi)的解決方案是采用雙向的PingPong機(jī)制玖像。

image

當(dāng)服務(wù)端發(fā)出一個Ping,客戶端沒有在約定的時間內(nèi)返回響應(yīng)的ack齐饮,則認(rèn)為客戶端已經(jīng)不在線捐寥,這時我們Server端會主動斷開Scoket連接笤昨,并且改由APNS推送的方式發(fā)送消息。

同樣的是上真,當(dāng)客戶端去發(fā)送一個消息咬腋,因為我們遲遲無法收到服務(wù)端的響應(yīng)ack包,則表明客戶端或者服務(wù)端已不在線睡互,我們也會顯示消息發(fā)送失敗根竿,并且斷開Scoket連接。

還記得我們之前CocoaSyncSockt的例子所講的獲取消息超時就斷開嗎就珠?其實它就是一個PingPong機(jī)制的客戶端實現(xiàn)寇壳。我們每次可以在發(fā)送消息成功后,調(diào)用這個超時讀取的方法妻怎,如果一段時間沒收到服務(wù)器的響應(yīng)壳炎,那么說明連接不可用,則斷開Scoket連接

最后就是重連機(jī)制:

理論上逼侦,我們自己主動去斷開的Scoket連接(例如退出賬號匿辩,APP退出到后臺等等),不需要重連榛丢。其他的連接斷開铲球,我們都需要進(jìn)行斷線重連。

一般解決方案是嘗試重連幾次晰赞,如果仍舊無法重連成功稼病,那么不再進(jìn)行重連。

接下來的WebScoket的例子掖鱼,我會封裝一個重連時間指數(shù)級增長的一個重連方式然走,可以作為一個參考。

言歸正傳戏挡,我們看完上述三個概念之后芍瑞,我們來講一個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的初始化拆檬,以及連接,關(guān)閉連接掌栅,發(fā)送消息等方法秩仆。

  • 另一部分為SRWebSocketDelegate码泛,其中包括一些回調(diào):

    收到消息的回調(diào)猾封,連接失敗的回調(diào),關(guān)閉連接的回調(diào)噪珊,收到pong的回調(diào)晌缘,是否需要把data消息轉(zhuǎn)換成string的代理方法齐莲。

接著我們還是舉個例子來實現(xiàn)以下,首先來封裝一個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è)置代理線程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超時一般為5分鐘
        heartBeat = [NSTimer scheduledTimerWithTimeInterval:3*60 repeats:YES block:^(NSTimer * _Nonnull timer) {
            NSLog(@"heart");
            //和服務(wù)端約定好發(fā)送什么作為心跳標(biāo)識选酗,盡可能的減小心跳包大小
            [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;
    }
}

//發(fā)送消息
- (void)sendMsg:(NSString *)msg
{
    [webSocket send:msg];

}

//重連機(jī)制
- (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的指數(shù)級增長
    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(@"連接成功");

    //連接成功了開始發(fā)送心跳
    [self initHeartBeat];
}

//open失敗的時候調(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);

    //如果是被用戶自己中斷的那么直接斷開連接岳枷,否則開始重連
    if (code == disConnectByUser) {
        [self disConnect];
    }else{

        [self reConnect];
    }
    //斷開連接時銷毀心跳
    [self destoryHeartBeat];

}

//sendPing的時候芒填,如果網(wǎng)絡(luò)通的話,則會收到回調(diào)空繁,但是必須保證ScoketOpen殿衰,否則會crash
- (void)webSocket:(SRWebSocket *)webSocket didReceivePong:(NSData *)pongPayload
{
    NSLog(@"收到pong回調(diào)");

}

//將收到的消息,是否需要把data轉(zhuǎn)換為NSString盛泡,每次收到消息都會被調(diào)用闷祥,默認(rèn)YES
//- (BOOL)webSocketShouldConvertTextFrameToString:(SRWebSocket *)webSocket
//{
//    NSLog(@"webSocketShouldConvertTextFrameToString");
//
//    return NO;
//}

.m文件有點長,大家可以參照github中的demo進(jìn)行閱讀傲诵,這回我們添加了一些細(xì)節(jié)的東西了凯砍,包括一個簡單的心跳,重連機(jī)制拴竹,還有webScoket封裝好的一個pingpong機(jī)制悟衩。

代碼非常簡單,大家可以配合著注釋讀一讀殖熟,應(yīng)該很容易理解局待。

需要說一下的是這個心跳機(jī)制是一個定時的間隔,往往我們可能會有更復(fù)雜實現(xiàn)菱属,比如我們正在發(fā)送消息的時候钳榨,可能就不需要心跳。當(dāng)不在發(fā)送的時候在開啟心跳之類的纽门。微信有一種更高端的實現(xiàn)方式薛耻,有興趣的小伙伴可以看看:

微信的智能心跳實現(xiàn)方式

還有一點需要說的就是這個重連機(jī)制,demo中我采用的是2的指數(shù)級別增長赏陵,第一次立刻重連饼齿,第二次2秒,第三次4秒蝙搔,第四次8秒...直到大于64秒就不再重連缕溉。而任意的一次成功的連接,都會重置這個重連時間吃型。

最后一點需要說的是证鸥,這個框架給我們封裝的webscoket在調(diào)用它的sendPing方法之前,一定要判斷當(dāng)前scoket是否連接,如果不是連接狀態(tài)枉层,程序則會crash泉褐。

客戶端的實現(xiàn)就大致如此,接著同樣我們需要實現(xiàn)一個服務(wù)端鸟蜡,來看看實際通訊效果膜赃。

webScoket服務(wù)端實現(xiàn)

在這里我們無法沿用之前的node.js例子了,因為這并不是一個原生的scoket揉忘,這是webScoket跳座,所以我們服務(wù)端同樣需要遵守webScoket協(xié)議,兩者才能實現(xiàn)通信泣矛。

其實這里實現(xiàn)也很簡單躺坟,我采用了node.jsws模塊,只需要用npm去安裝ws即可乳蓄。

什么是npm呢咪橙?舉個例子,npm之于Node.js相當(dāng)于cocospod至于iOS虚倒,它就是一個拓展模塊的一個管理工具美侦。如果不知道怎么用的可以看看這篇文章:npm的使用

我們進(jìn)入當(dāng)前腳本目錄,輸入終端命令魂奥,即可安裝ws模塊:

$ npm install ws

大家如果懶得去看npm的小伙伴也沒關(guān)系菠剩,直接下載github中的 WSServer.js這個文件運(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('開始監(jiān)聽6969端口');

代碼沒幾行耻煤,理解起來很簡單具壮。

就是監(jiān)聽了本機(jī)6969端口,如果客戶端連接了哈蝇,打印lient connected棺妓,并且向客戶端發(fā)送:你是第幾位。

如果收到客戶端消息后炮赦,打印消息怜跑,并且向客戶端發(fā)送這條收到的消息。

接著我們同樣來運(yùn)行一下看看效果:
image

運(yùn)行我們可以看到吠勘,主動去斷開的連接性芬,沒有去重連,而server端斷開的剧防,我們開啟了重連植锉。感興趣的朋友可以下載demo實際運(yùn)行一下。

分割圖.png
4.我們接著來看看MQTT:

MQTT是一個聊天協(xié)議峭拘,它比webScoket更上層俊庇,屬于應(yīng)用層搏熄。

它的基本模式是簡單的發(fā)布訂閱,也就是說當(dāng)一條消息發(fā)出去的時候暇赤,誰訂閱了誰就會受到。其實它并不適合IM的場景宵凌,例如用來實現(xiàn)有些簡單IM場景鞋囊,卻需要很大量的、復(fù)雜的處理瞎惫。

比較適合它的場景為訂閱發(fā)布這種模式的溜腐,例如微信的實時共享位置,滴滴的地圖上小車的移動瓜喇、客戶端推送等功能挺益。

首先我們來看看基于MQTT協(xié)議的框架-MQTTKit:

這個框架是c來寫的,把一些方法公開在MQTTKit類中乘寒,對外用OC來調(diào)用望众,我們來看看這個類:

@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;

這個類一共分為4個部分:初始化、連接伞辛、發(fā)布烂翰、訂閱,具體方法的作用可以先看看方法名理解下蚤氏,我們接著來用這個框架封裝一個實例甘耿。

同樣,我們封裝了一個單例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(@"錯誤的用戶名密碼");

            //....
            default:
                NSLog(@"MQTT連接失敗");

                break;
        }

    }];
}

#pragma mark - 對外的一些接口

//建立連接
- (void)connect
{
    [self initSocket];
}

//斷開連接
- (void)disConnect
{
    if (client) {
        //取消訂閱
        [client unsubscribe:client.clientID withCompletionHandler:^{
            NSLog(@"取消訂閱tuyaohui成功");

        }];
        //斷開連接
        [client disconnectWithCompletionHandler:^(NSUInteger code) {

            NSLog(@"斷開MQTT成功");

        }];

        client = nil;
    }
}

//發(fā)送消息
- (void)sendMsg:(NSString *)msg
{
    //發(fā)送一條消息于游,發(fā)送給自己訂閱的主題
    [client publishString:msg toTopic:KClientID withQos:ExactlyOnce retain:YES completionHandler:^(int mid) {

    }];
}
@end

實現(xiàn)代碼很簡單毁葱,需要說一下的是:

1)當(dāng)我們連接成功了,我們需要去訂閱自己clientID的消息贰剥,這樣才能收到發(fā)給自己的消息头谜。

2)其次是這個框架為我們實現(xiàn)了一個QOS機(jī)制,那么什么是QOS呢鸠澈?

QoS(Quality of Service柱告,服務(wù)質(zhì)量)指一個網(wǎng)絡(luò)能夠利用各種基礎(chǔ)技術(shù),為指定的網(wǎng)絡(luò)通信提供更好的服務(wù)能力, 是網(wǎng)絡(luò)的一種安全機(jī)制笑陈, 是用來解決網(wǎng)絡(luò)延遲和阻塞等問題的一種技術(shù)际度。

在這里,它提供了三個選項:

typedef enum MQTTQualityOfService : NSUInteger {
    AtMostOnce,
    AtLeastOnce,
    ExactlyOnce
} MQTTQualityOfService;

分別對應(yīng)最多發(fā)送一次涵妥,至少發(fā)送一次乖菱,精確只發(fā)送一次。

  • QOS(0),最多發(fā)送一次:如果消息沒有發(fā)送過去,那么就直接丟失窒所。

  • QOS(1),至少發(fā)送一次:保證消息一定發(fā)送過去鹉勒,但是發(fā)幾次不確定。

  • QOS(2),精確只發(fā)送一次:它內(nèi)部會有一個很復(fù)雜的發(fā)送機(jī)制吵取,確保消息送到禽额,而且只發(fā)送一次。

更詳細(xì)的關(guān)于該機(jī)制可以看看這篇文章:MQTT協(xié)議筆記之消息流QOS皮官。

同樣的我們需要一個用MQTT協(xié)議實現(xiàn)的服務(wù)端脯倒,我們還是node.js來實現(xiàn),這次我們還是需要用npm來新增一個模塊mosca捺氢。

我們來看看服務(wù)端代碼:

MQTTServer.js

var mosca = require('mosca');  

var MqttServer = new mosca.Server({  
    port: 6969  
});  

MqttServer.on('clientConnected', function(client){  
    console.log('收到客戶端連接藻丢,連接ID:', client.id);  
});  

/** 
 * 監(jiān)聽MQTT主題消息 
 **/  
MqttServer.on('published', function(packet, client) {  
    var topic = packet.topic;  
    console.log('有消息來了','topic為:'+topic+',message為:'+ packet.payload.toString());  

});  

MqttServer.on('ready', function(){  
    console.log('mqtt服務(wù)器開啟,監(jiān)聽6969端口');  
});  

服務(wù)端代碼沒幾行摄乒,開啟了一個服務(wù)悠反,并且監(jiān)聽本機(jī)6969端口。并且監(jiān)聽了客戶端連接馍佑、發(fā)布消息等狀態(tài)问慎。

接著我們同樣來運(yùn)行一下看看效果:
image

至此,我們實現(xiàn)了一個簡單的MQTT封裝挤茄。

5.XMPP:XMPPFramework框架

結(jié)果就是并沒有XMPP...因為個人感覺XMPP對于IM來說實在是不堪重用如叼。僅僅只能作為一個玩具demo,給大家練練手穷劈。網(wǎng)上有太多XMPP的內(nèi)容了笼恰,相當(dāng)一部分用openfire來做服務(wù)端,這一套東西實在是太老了歇终。還記得多年前社证,樓主初識IM就是用的這一套東西...

如果大家仍然感興趣的可以看看這篇文章:iOS 的 XMPPFramework 簡介。這里就不舉例贅述了评凝。

三追葡、關(guān)于IM傳輸格式的選擇:

引用陳宜龍大神文章(iOS程序犭袁 )中一段:

使用 ProtocolBuffer 減少 Payload

滴滴打車40%;

攜程之前分享過奕短,說是采用新的Protocol Buffer數(shù)據(jù)格式+Gzip壓縮后的Payload大小降低了15%-45%宜肉。數(shù)據(jù)序列化耗時下降了80%-90%。

采用高效安全的私有協(xié)議翎碑,支持長連接的復(fù)用谬返,穩(wěn)定省電省流量

【高效】提高網(wǎng)絡(luò)請求成功率,消息體越大日杈,失敗幾率隨之增加遣铝。

【省流量】流量消耗極少佑刷,省流量。一條消息數(shù)據(jù)用Protobuf序列化后的大小是 JSON 的1/10酿炸、XML格式的1/20瘫絮、是二進(jìn)制序列化的1/10。同 XML 相比填硕, Protobuf 性能優(yōu)勢明顯麦萤。它以高效的二進(jìn)制方式存儲,比 XML 小 3 到 10 倍廷支,快 20 到 100 倍。

【省電】省電

【高效心跳包】同時心跳包協(xié)議對IM的電量和流量影響很大栓辜,對心跳包協(xié)議上進(jìn)行了極簡設(shè)計:僅 1 Byte 恋拍。

【易于使用】開發(fā)人員通過按照一定的語法定義結(jié)構(gòu)化的消息格式,然后送給命令行工具藕甩,工具將自動生成相關(guān)的類施敢,可以支持java、c++狭莱、python僵娃、Objective-C等語言環(huán)境。通過將這些類包含在項目中腋妙,可以很輕松的調(diào)用相關(guān)方法來完成業(yè)務(wù)消息的序列化與反序列化工作默怨。語言支持:原生支持c++、java骤素、python匙睹、Objective-C等多達(dá)10余種語言。 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)用也早已在使用它(采用的是改造過的Protobuf協(xié)議)

image

如何測試驗證 Protobuf 的高性能送浊?

對數(shù)據(jù)分別操作100次梦谜,1000次,10000次和100000次進(jìn)行了測試袭景,

縱坐標(biāo)是完成時間唁桩,單位是毫秒,

反序列化

序列化

字節(jié)長度

image
image
image

數(shù)據(jù)來源耸棒。

image

數(shù)據(jù)來自:項目 thrift-protobuf-compare朵夏,測試項為 Total Time,也就是 指一個對象操作的整個時間榆纽,包括創(chuàng)建對象仰猖,將對象序列化為內(nèi)存中的字節(jié)序列捏肢,然后再反序列化的整個過程。從測試結(jié)果可以看到 Protobuf 的成績很好.

缺點:

可能會造成 APP 的包體積增大饥侵,通過 Google 提供的腳本生成的 Model鸵赫,會非常“龐大”躏升,Model 一多辩棒,包體積也就會跟著變大。

如果 Model 過多膨疏,可能導(dǎo)致 APP 打包后的體積驟增一睁,但 IM 服務(wù)所使用的 Model 非常少,比如在 ChatKit-OC 中只用到了一個 Protobuf 的 Model:Message對象佃却,對包體積的影響微乎其微者吁。

在使用過程中要合理地權(quán)衡包體積以及傳輸效率的問題,據(jù)說去哪兒網(wǎng)饲帅,就曾經(jīng)為了減少包體積复凳,進(jìn)而減少了 Protobuf 的使用。

綜上所述灶泵,我們選擇傳輸格式的時候:ProtocolBuffer > Json > XML

如果大家對ProtocolBuffer用法感興趣可以參考下這兩篇文章:

ProtocolBuffer for Objective-C 運(yùn)行環(huán)境配置及使用

iOS之ProtocolBuffer搭建和示例demo

三育八、IM一些其它問題
1.IM的可靠性:

我們之前穿插在例子中提到過:

心跳機(jī)制、PingPong機(jī)制赦邻、斷線重連機(jī)制髓棋、還有我們后面所說的QOS機(jī)制。這些被用來保證連接的可用惶洲,消息的即時與準(zhǔn)確的送達(dá)等等仲锄。

上述內(nèi)容保證了我們IM服務(wù)時的可靠性,其實我們能做的還有很多:比如我們在大文件傳輸?shù)臅r候使用分片上傳湃鹊、斷點續(xù)傳儒喊、秒傳技術(shù)等來保證文件的傳輸。

2.安全性:

我們通常還需要一些安全機(jī)制來保證我們IM通信安全币呵。

例如:防止 DNS 污染怀愧、帳號安全、第三方服務(wù)器鑒權(quán)余赢、單點登錄等等

3.一些其他的優(yōu)化:

類似微信芯义,服務(wù)器不做聊天記錄的存儲,只在本機(jī)進(jìn)行緩存妻柒,這樣可以減少對服務(wù)端數(shù)據(jù)的請求扛拨,一方面減輕了服務(wù)器的壓力,另一方面減少客戶端流量的消耗举塔。

我們進(jìn)行http連接的時候盡量采用上層API绑警,類似NSUrlSession求泰。而網(wǎng)絡(luò)框架盡量使用AFNetWorking3。因為這些上層網(wǎng)絡(luò)請求都用的是HTTP/2 计盒,我們請求的時候可以復(fù)用這些連接渴频。

更多優(yōu)化相關(guān)內(nèi)容可以參考參考這篇文章:

IM 即時通訊技術(shù)在多應(yīng)用場景下的技術(shù)實現(xiàn),以及性能調(diào)優(yōu)

四北启、音視頻通話

IM應(yīng)用中的實時音視頻技術(shù)卜朗,幾乎是IM開發(fā)中的最后一道高墻。原因在于:實時音視頻技術(shù) = 音視頻處理技術(shù) + 網(wǎng)絡(luò)傳輸技術(shù) 的橫向技術(shù)應(yīng)用集合體咕村,而公共互聯(lián)網(wǎng)不是為了實時通信設(shè)計的场钉。

實時音視頻技術(shù)上的實現(xiàn)內(nèi)容主要包括:音視頻的采集、編碼懈涛、網(wǎng)絡(luò)傳輸逛万、解碼、播放等環(huán)節(jié)肩钠。這么多項并不簡單的技術(shù)應(yīng)用泣港,如果把握不當(dāng)暂殖,將會在在實際開發(fā)過程中遇到一個又一個的坑价匠。

因為樓主自己對這塊的技術(shù)理解很淺,所以引用了一個系列的文章來給大家一個參考呛每,感興趣的朋友可以看看:

即時通訊音視頻開發(fā)(一):視頻編解碼之理論概述

即時通訊音視頻開發(fā)(二):視頻編解碼之?dāng)?shù)字視頻介紹

即時通訊音視頻開發(fā)(三):視頻編解碼之編碼基礎(chǔ)

即時通訊音視頻開發(fā)(四):視頻編解碼之預(yù)測技術(shù)介紹

即時通訊音視頻開發(fā)(五):認(rèn)識主流視頻編碼技術(shù)H.264

即時通訊音視頻開發(fā)(六):如何開始音頻編解碼技術(shù)的學(xué)習(xí)

即時通訊音視頻開發(fā)(七):音頻基礎(chǔ)及編碼原理入門

即時通訊音視頻開發(fā)(八):常見的實時語音通訊編碼標(biāo)準(zhǔn)

即時通訊音視頻開發(fā)(九):實時語音通訊的回音及回音消除?概述

即時通訊音視頻開發(fā)(十):實時語音通訊的回音消除?技術(shù)詳解

即時通訊音視頻開發(fā)(十一):實時語音通訊丟包補(bǔ)償技術(shù)詳解

即時通訊音視頻開發(fā)(十二):多人實時音視頻聊天架構(gòu)探討

即時通訊音視頻開發(fā)(十三):實時視頻編碼H.264的特點與優(yōu)勢

即時通訊音視頻開發(fā)(十四):實時音視頻數(shù)據(jù)傳輸協(xié)議介紹

即時通訊音視頻開發(fā)(十五):聊聊P2P與實時音視頻的應(yīng)用情況

即時通訊音視頻開發(fā)(十六):移動端實時音視頻開發(fā)的幾個建議

即時通訊音視頻開發(fā)(十七):視頻編碼H.264踩窖、V8的前世今生

轉(zhuǎn)載自:http://www.reibang.com/p/2dbb360886a8

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市晨横,隨后出現(xiàn)的幾起案子洋腮,更是在濱河造成了極大的恐慌,老刑警劉巖手形,帶你破解...
    沈念sama閱讀 206,968評論 6 482
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件啥供,死亡現(xiàn)場離奇詭異,居然都是意外死亡库糠,警方通過查閱死者的電腦和手機(jī)伙狐,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,601評論 2 382
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來瞬欧,“玉大人贷屎,你說我怎么就攤上這事∷一ⅲ” “怎么了唉侄?”我有些...
    開封第一講書人閱讀 153,220評論 0 344
  • 文/不壞的土叔 我叫張陵,是天一觀的道長野建。 經(jīng)常有香客問我属划,道長恬叹,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 55,416評論 1 279
  • 正文 為了忘掉前任榴嗅,我火速辦了婚禮妄呕,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘嗽测。我一直安慰自己绪励,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 64,425評論 5 374
  • 文/花漫 我一把揭開白布唠粥。 她就那樣靜靜地躺著疏魏,像睡著了一般。 火紅的嫁衣襯著肌膚如雪晤愧。 梳的紋絲不亂的頭發(fā)上大莫,一...
    開封第一講書人閱讀 49,144評論 1 285
  • 那天,我揣著相機(jī)與錄音官份,去河邊找鬼只厘。 笑死,一個胖子當(dāng)著我的面吹牛舅巷,可吹牛的內(nèi)容都是我干的羔味。 我是一名探鬼主播,決...
    沈念sama閱讀 38,432評論 3 401
  • 文/蒼蘭香墨 我猛地睜開眼钠右,長吁一口氣:“原來是場噩夢啊……” “哼赋元!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起飒房,我...
    開封第一講書人閱讀 37,088評論 0 261
  • 序言:老撾萬榮一對情侶失蹤搁凸,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后狠毯,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體护糖,經(jīng)...
    沈念sama閱讀 43,586評論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,028評論 2 325
  • 正文 我和宋清朗相戀三年嚼松,在試婚紗的時候發(fā)現(xiàn)自己被綠了嫡良。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 38,137評論 1 334
  • 序言:一個原本活蹦亂跳的男人離奇死亡惜颇,死狀恐怖皆刺,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情凌摄,我是刑警寧澤羡蛾,帶...
    沈念sama閱讀 33,783評論 4 324
  • 正文 年R本政府宣布,位于F島的核電站锨亏,受9級特大地震影響痴怨,放射性物質(zhì)發(fā)生泄漏忙干。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 39,343評論 3 307
  • 文/蒙蒙 一浪藻、第九天 我趴在偏房一處隱蔽的房頂上張望捐迫。 院中可真熱鬧,春花似錦爱葵、人聲如沸施戴。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,333評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽赞哗。三九已至,卻和暖如春辆雾,著一層夾襖步出監(jiān)牢的瞬間肪笋,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,559評論 1 262
  • 我被黑心中介騙來泰國打工度迂, 沒想到剛下飛機(jī)就差點兒被人妖公主榨干…… 1. 我叫王不留藤乙,地道東北人。 一個月前我還...
    沈念sama閱讀 45,595評論 2 355
  • 正文 我出身青樓惭墓,卻偏偏與公主長得像坛梁,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子诅妹,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 42,901評論 2 345