前言
- 本文會用例項的方式,將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/推送系統的協議選型:UDP還是TCP?這裡我們直接說結論吧:對於小公司或者技術不那麼成熟的公司,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到底是什麼?。
我們接著可以開始著手去實現IM了,首先我們不基於任何框架,直接去呼叫OS
底層-基於C的BSD Socket
去實現,它提供了這樣一組介面:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
//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
1 2 3 4 5 6 7 8 |
#import @interface TYHSocketManager : NSObject + (instancetype)share; - (void)connect; - (void)disConnect; - (void)sendMsg:(NSString *)msg; @end |
TYHSocketManager.m
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 |
#import "TYHSocketManager.h" #import #import #import #import @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函式:
1ClinetSocket = socket(AF_INET, SOCK_STREAM, 0); - 然後呼叫了
ConnectionToServer
函式與伺服器連線,IP地址為127.0.0.1
也就是本機localhost
和埠6969
相連。在該函式中,我們繫結了一個sockaddr_in
型別的結構體,該結構體內容如下:
1234567struct 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
伺服器。原始碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
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的直譯器,那麼直接在終端進入該原始碼檔案目錄中輸入:
1 |
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
1 2 3 4 5 6 7 8 9 10 11 12 |
#import @interface TYHSocketManager : NSObject + (instancetype)share; - (BOOL)connect; - (void)disConnect; - (void)sendMsg:(NSString *)msg; - (void)pullTheMsg; @end |
TYHSocketManager.m
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 |
#import "TYHSocketManager.h" #import "GCDAsyncSocket.h" // for TCP static NSString * Khost = @"127.0.0.1"; static const uint16_t Kport = 6969; @interface TYHSocketManager() { 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的介面給我們使用。至於使用方法,大家看看註釋應該就能明白,這裡唯一需要說的一點就是這個方法:
1 |
[gcdSocket readDataWithTimeout:-1 tag:110]; |
這個方法的作用就是去讀取當前訊息佇列中的未讀訊息。記住,這裡不呼叫這個方法,訊息回撥的代理是永遠不會被觸發的。而且必須是tag相同,如果tag不同,這個收到訊息的代理也不會被處罰。
我們呼叫一次這個方法,只能觸發一次讀取訊息的代理,如果我們呼叫的時候沒有未讀訊息,它就會等在那,直到訊息來了被觸發。一旦被觸發一次代理後,我們必須再次呼叫這個方法,否則,之後的訊息到了仍舊無法觸發我們讀取訊息的代理。就像我們在例子中使用的那樣,在每次讀取到訊息之後我們都去呼叫:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
//收到訊息的回撥 - (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秒過後呼叫是否續時的代理方法:
1 |
-(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
。
我們首先來看看它對外封裝的一些方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 |
@interface SRWebSocket : NSObject @property (nonatomic, weak) id 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 // 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
#import typedef enum : NSUInteger { disConnectByUser , disConnectByServer, } DisConnectType; @interface TYHSocketManager : NSObject + (instancetype)share; - (void)connect; - (void)disConnect; - (void)sendMsg:(NSString *)msg; - (void)ping; @end |
TYHSocketManager.m
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 |
#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() { 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的指數級增長 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(@"伺服器返回收到訊息:%@",message); } - (void)webSocketDidOpen:(SRWebSocket *)webSocket { NSLog(@"連線成功"); //連線成功了開始傳送心跳 [self initHeartBeat]; } //open失敗的時候呼叫 - (void)webSocket:(SRWebSocket *)webSocket didFailWithError:(NSError *)error { NSLog(@"連線失敗.....\n%@",error); //失敗了就去重連 [self reConnect]; } //網路連線中斷被呼叫 - (void)webSocket:(SRWebSocket *)webSocket didCloseWithCode:(NSInteger)code reason:(NSString *)reason wasClean:(BOOL)wasClean { NSLog(@"被關閉連線,code:%ld,reason:%@,wasClean:%d",code,reason,wasClean); //如果是被使用者自己中斷的那麼直接斷開連線,否則開始重連 if (code == disConnectByUser) { [self disConnect]; }else{ [self reConnect]; } //斷開連線時銷燬心跳 [self destoryHeartBeat]; } //sendPing的時候,如果網路通的話,則會收到回撥,但是必須保證ScoketOpen,否則會crash - (void)webSocket:(SRWebSocket *)webSocket didReceivePong:(NSData *)pongPayload { NSLog(@"收到pong回撥"); } //將收到的訊息,是否需要把data轉換為NSString,每次收到訊息都會被呼叫,預設YES //- (BOOL)webSocketShouldConvertTextFrameToString:(SRWebSocket *)webSocket //{ // NSLog(@"webSocketShouldConvertTextFrameToString"); // // return NO; //} |
.m檔案有點長,大家可以參照github中的demo進行閱讀,這回我們新增了一些細節的東西了,包括一個簡單的心跳,重連機制,還有webScoket
封裝好的一個pingpong
機制。
程式碼非常簡單,大家可以配合著註釋讀一讀,應該很容易理解。
需要說一下的是這個心跳機制是一個定時的間隔,往往我們可能會有更復雜實現,比如我們正在傳送訊息的時候,可能就不需要心跳。當不在傳送的時候在開啟心跳之類的。微信有一種更高階的實現方式,有興趣的小夥伴可以看看:
微信的智慧心跳實現方式
還有一點需要說的就是這個重連機制,demo中我採用的是2的指數級別增長,第一次立刻重連,第二次2秒,第三次4秒,第四次8秒…直到大於64秒就不再重連。而任意的一次成功的連線,都會重置這個重連時間。
最後一點需要說的是,這個框架給我們封裝的webscoket
在呼叫它的sendPing
方法之前,一定要判斷當前scoket
是否連線,如果不是連線狀態,程式則會crash
。
客戶端的實現就大致如此,接著同樣我們需要實現一個服務端,來看看實際通訊效果。
webScoket服務端實現
在這裡我們無法沿用之前的node.js例子了,因為這並不是一個原生的scoket
,這是webScoket
,所以我們服務端同樣需要遵守webScoket
協議,兩者才能實現通訊。
其實這裡實現也很簡單,我採用了node.js
的ws
模組,只需要用npm
去安裝ws
即可。
什麼是npm
呢?舉個例子,npm
之於Node.js
相當於cocospod
至於iOS
,它就是一個擴充模組的一個管理工具。如果不知道怎麼用的可以看看這篇文章:npm的使用
我們進入當前指令碼目錄,輸入終端命令,即可安裝ws
模組:
1 |
$ npm install ws |
大家如果懶得去看npm的小夥伴也沒關係,直接下載github中的 WSServer.js
這個檔案執行即可。
該原始檔程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
var WebSocketServer = require('ws').Server, wss = new WebSocketServer({ port: 6969 }); wss.on('connection', function (ws) { console.log('client connected'); ws.send('你是第' + wss.clients.length + '位'); //收到訊息回撥 ws.on('message', function (message) { console.log(message); ws.send('收到:'+message); }); // 退出聊天 ws.on('close', function(close) { console.log('退出連線了'); }); }); console.log('開始監聽6969埠'); |
程式碼沒幾行,理解起來很簡單。
就是監聽了本機6969埠,如果客戶端連線了,列印lient connected,並且向客戶端傳送:你是第幾位。
如果收到客戶端訊息後,列印訊息,並且向客戶端傳送這條收到的訊息。
接著我們同樣來執行一下看看效果:
執行我們可以看到,主動去斷開的連線,沒有去重連,而server端斷開的,我們開啟了重連。感興趣的朋友可以下載demo實際執行一下。
4.我們接著來看看MQTT:
MQTT是一個聊天協議,它比webScoket
更上層,屬於應用層。
它的基本模式是簡單的釋出訂閱,也就是說當一條訊息發出去的時候,誰訂閱了誰就會受到。其實它並不適合IM的場景,例如用來實現有些簡單IM場景,卻需要很大量的、複雜的處理。
比較適合它的場景為訂閱釋出這種模式的,例如微信的實時共享位置,滴滴的地圖上小車的移動、客戶端推送等功能。
首先我們來看看基於MQTT
協議的框架-MQTTKit
:
這個框架是c來寫的,把一些方法公開在MQTTKit
類中,對外用OC來呼叫,我們來看看這個類:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
@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個部分:初始化、連線、釋出、訂閱,具體方法的作用可以先看看方法名理解下,我們接著來用這個框架封裝一個例項。
同樣,我們封裝了一個單例MQTTManager
。
MQTTManager.h
1 2 3 4 5 6 7 8 9 10 11 12 |
#import @interface MQTTManager : NSObject + (instancetype)share; - (void)connect; - (void)disConnect; - (void)sendMsg:(NSString *)msg; @end |
MQTTManager.m
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 |
#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) { //收到訊息的回撥,前提是得先訂閱 NSString *msg = [[NSString alloc]initWithData:message.payload encoding:NSUTF8StringEncoding]; NSLog(@"收到服務端訊息:%@",msg); }]; [client connectToHost:Khost completionHandler:^(MQTTConnectionReturnCode code) { switch (code) { case ConnectionAccepted: NSLog(@"MQTT連線成功"); //訂閱自己ID的訊息,這樣收到訊息就能回撥 [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; } } //傳送訊息 - (void)sendMsg:(NSString *)msg { //傳送一條訊息,傳送給自己訂閱的主題 [client publishString:msg toTopic:KClientID withQos:ExactlyOnce retain:YES completionHandler:^(int mid) { }]; } @end |
實現程式碼很簡單,需要說一下的是:
1)當我們連線成功了,我們需要去訂閱自己clientID
的訊息,這樣才能收到發給自己的訊息。
2)其次是這個框架為我們實現了一個QOS機制,那麼什麼是QOS呢?
QoS(Quality of Service,服務質量)指一個網路能夠利用各種基礎技術,為指定的網路通訊提供更好的服務能力, 是網路的一種安全機制, 是用來解決網路延遲和阻塞等問題的一種技術。
在這裡,它提供了三個選項:
1 2 3 4 5 |
typedef enum MQTTQualityOfService : NSUInteger { AtMostOnce, AtLeastOnce, ExactlyOnce } MQTTQualityOfService; |
分別對應最多傳送一次,至少傳送一次,精確只傳送一次。
- QOS(0),最多傳送一次:如果訊息沒有傳送過去,那麼就直接丟失。
- QOS(1),至少傳送一次:保證訊息一定傳送過去,但是發幾次不確定。
- QOS(2),精確只傳送一次:它內部會有一個很複雜的傳送機制,確保訊息送到,而且只傳送一次。
更詳細的關於該機制可以看看這篇文章:MQTT協議筆記之訊息流QOS。
同樣的我們需要一個用MQTT協議實現的服務端,我們還是node.js來實現,這次我們還是需要用npm
來新增一個模組mosca
。
我們來看看服務端程式碼:
MQTTServer.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
var mosca = require('mosca'); var MqttServer = new mosca.Server({ port: 6969 }); MqttServer.on('clientConnected', function(client){ console.log('收到客戶端連線,連線ID:', client.id); }); /** * 監聽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伺服器開啟,監聽6969埠'); }); |
服務端程式碼沒幾行,開啟了一個服務,並且監聽本機6969埠。並且監聽了客戶端連線、釋出訊息等狀態。
接著我們同樣來執行一下看看效果:
至此,我們實現了一個簡單的MQTT封裝。
5.XMPP:XMPPFramework框架
結果就是並沒有XMPP…因為個人感覺XMPP對於IM來說實在是不堪重用。僅僅只能作為一個玩具demo,給大家練練手。網上有太多XMPP的內容了,相當一部分用openfire來做服務端,這一套東西實在是太老了。還記得多年前,樓主初識IM就是用的這一套東西…
如果大家仍然感興趣的可以看看這篇文章:iOS 的 XMPPFramework 簡介。這裡就不舉例贅述了。
三、關於IM傳輸格式的選擇:
引用陳宜龍大神文章(iOS程式犭袁 )中一段:
使用 ProtocolBuffer 減少 Payload
滴滴叫車40%;
攜程之前分享過,說是採用新的Protocol Buffer資料格式+Gzip壓縮後的Payload大小降低了15%-45%。資料序列化耗時下降了80%-90%。
採用高效安全的私有協議,支援長連線的複用,穩定省電省流量
【高效】提高網路請求成功率,訊息體越大,失敗機率隨之增加。
【省流量】流量消耗極少,省流量。一條訊息資料用Protobuf序列化後的大小是 JSON 的1/10、XML格式的1/20、是二進位制序列化的1/10。同 XML 相比, Protobuf 效能優勢明顯。它以高效的二進位制方式儲存,比 XML 小 3 到 10 倍,快 20 到 100 倍。
【省電】省電
【高效心跳包】同時心跳包協議對IM的電量和流量影響很大,對心跳包協議上進行了極簡設計:僅 1 Byte 。
【易於使用】開發人員通過按照一定的語法定義結構化的訊息格式,然後送給命令列工具,工具將自動生成相關的類,可以支援java、c++、python、Objective-C等語言環境。通過將這些類包含在專案中,可以很輕鬆的呼叫相關方法來完成業務訊息的序列化與反序列化工作。語言支援:原生支援c++、java、python、Objective-C等多達10餘種語言。 2015-08-27 Protocol Buffers v3.0.0-beta-1中釋出了Objective-C(Alpha)版本, 2016-07-28 3.0 Protocol Buffers v3.0.0正式版釋出,正式支援 Objective-C。
【可靠】微信和手機 QQ 這樣的主流 IM 應用也早已在使用它(採用的是改造過的Protobuf協議)
如何測試驗證 Protobuf 的高效能?
對資料分別操作100次,1000次,10000次和100000次進行了測試,
縱座標是完成時間,單位是毫秒,
反序列化
序列化
位元組長度
資料來源。
資料來自:專案 thrift-protobuf-compare,測試項為 Total Time,也就是 指一個物件操作的整個時間,包括建立物件,將物件序列化為記憶體中的位元組序列,然後再反序列化的整個過程。從測試結果可以看到 Protobuf 的成績很好.
缺點:
可能會造成 APP 的包體積增大,通過 Google 提供的指令碼生成的 Model,會非常“龐大”,Model 一多,包體積也就會跟著變大。
如果 Model 過多,可能導致 APP 打包後的體積驟增,但 IM 服務所使用的 Model 非常少,比如在 ChatKit-OC 中只用到了一個 Protobuf 的 Model:Message物件,對包體積的影響微乎其微。
在使用過程中要合理地權衡包體積以及傳輸效率的問題,據說去哪兒網,就曾經為了減少包體積,進而減少了 Protobuf 的使用。
綜上所述,我們選擇傳輸格式的時候:ProtocolBuffer
> Json
> XML
如果大家對ProtocolBuffer
用法感興趣可以參考下這兩篇文章:
ProtocolBuffer for Objective-C 執行環境配置及使用
iOS之ProtocolBuffer搭建和示例demo
三、IM一些其它問題
1.IM的可靠性:
我們之前穿插在例子中提到過:
心跳機制、PingPong機制、斷線重連機制、還有我們後面所說的QOS機制。這些被用來保證連線的可用,訊息的即時與準確的送達等等。
上述內容保證了我們IM服務時的可靠性,其實我們能做的還有很多:比如我們在大檔案傳輸的時候使用分片上傳、斷點續傳、秒傳技術等來保證檔案的傳輸。
2.安全性:
我們通常還需要一些安全機制來保證我們IM通訊安全。
例如:防止 DNS 汙染、帳號安全、第三方伺服器鑑權、單點登入等等
3.一些其他的優化:
類似微信,伺服器不做聊天記錄的儲存,只在本機進行快取,這樣可以減少對服務端資料的請求,一方面減輕了伺服器的壓力,另一方面減少客戶端流量的消耗。
我們進行http連線的時候儘量採用上層API,類似NSUrlSession
。而網路框架儘量使用AFNetWorking3
。因為這些上層網路請求都用的是HTTP/2 ,我們請求的時候可以複用這些連線。
更多優化相關內容可以參考參考這篇文章:
IM 即時通訊技術在多應用場景下的技術實現,以及效能調優
四、音視訊通話
IM應用中的實時音視訊技術,幾乎是IM開發中的最後一道高牆。原因在於:實時音視訊技術 = 音視訊處理技術 + 網路傳輸技術 的橫向技術應用集合體,而公共網際網路不是為了實時通訊設計的。
實時音視訊技術上的實現內容主要包括:音視訊的採集、編碼、網路傳輸、解碼、播放等環節。這麼多項並不簡單的技術應用,如果把握不當,將會在在實際開發過程中遇到一個又一個的坑。
因為樓主自己對這塊的技術理解很淺,所以引用了一個系列的文章來給大家一個參考,感興趣的朋友可以看看:
《即時通訊音視訊開發(一):視訊編解碼之理論概述》
《即時通訊音視訊開發(二):視訊編解碼之數字視訊介紹》
《即時通訊音視訊開發(三):視訊編解碼之編碼基礎》
《即時通訊音視訊開發(四):視訊編解碼之預測技術介紹》
《即時通訊音視訊開發(五):認識主流視訊編碼技術H.264》
《即時通訊音視訊開發(六):如何開始音訊編解碼技術的學習》
《即時通訊音視訊開發(七):音訊基礎及編碼原理入門》
《即時通訊音視訊開發(八):常見的實時語音通訊編碼標準》
《即時通訊音視訊開發(九):實時語音通訊的迴音及迴音消除概述》
《即時通訊音視訊開發(十):實時語音通訊的迴音消除技術詳解》
《即時通訊音視訊開發(十一):實時語音通訊丟包補償技術詳解》
《即時通訊音視訊開發(十二):多人實時音視訊聊天架構探討》
《即時通訊音視訊開發(十三):實時視訊編碼H.264的特點與優勢》
《即時通訊音視訊開發(十四):實時音視訊資料傳輸協議介紹》
《即時通訊音視訊開發(十五):聊聊P2P與實時音視訊的應用情況》
《即時通訊音視訊開發(十六):移動端實時音視訊開發的幾個建議》
《即時通訊音視訊開發(十七):視訊編碼H.264、V8的前世今生》
寫在最後:
本文內容為原創,且僅代表樓主現階段的一些思想,如果有什麼錯誤,歡迎指正~