歡迎光臨
每天分享高質量文章

iOS端IM開發從入門到填坑

作者:給策
連結:https://www.jianshu.com/p/b1d54fd570ef

讓App聊起來

 

IM開發從入門到填坑Demo

https://github.com/wyk125/iOS-IM-Socket

IM的實現方式

拿來主義,使用第三方IM服務

 

IM的第三方服務商國內有很多,底層協議基本上都是基於TCP的,類似有網易雲信、環信、融雲、極光IM、LeanCloud、雲通訊IM(騰訊)、雲旺IM(阿裡)、容聯雲、小能、美洽等等,技術也相對比較成熟,提供後臺管理和定製化的UI,拿來主義,半小時整合。

 

缺點也很明顯:定製化程度太高,需要二次開發,很多東西我們不可控,關鍵是太貴了。如果IM對於APP只是一個輔助功能,如客服系統、訊息推送等,也基本夠用。

 

自己動手,切合業務自己實現

 

幾乎所有網際網路IM產品都用伺服器中轉方式進行訊息傳輸。自己去實現也會面臨許多選擇:

 

1、傳輸協議的選擇:TCP還是UDP?
2、選擇哪種聊天協議進行開發:MQTT、XMPP、基於 Socket 原生或 WebSocket 的私有協議?
3、傳輸資料的格式:用JSON、還是XML、還是谷歌推出的ProtocolBuffer?
4、我們還有一些細節問題需要考慮,例如TCP的長連線如何保持,心跳機制,Qos機制,重連機制等等。另外,還有一些安全問題需要考慮。

一、傳輸協議的選擇

移動端IM的傳輸協議選型:TCP還是UDP?

 

TCP:基於連線的可靠協議的全雙工的可靠通道,有流量控制、差錯控制等,佔用系統資源較多,傳輸效率相對低
UDP:基於無連線的不可靠協議,沒有足夠的控制手段,傳輸效率高,有丟包問題

 

TCP和UDP的最完整的區別

https://blog.csdn.net/Li_Ning_/article/details/52117463

 

基於UDP協議開發成本較高,容易各種丟包或亂序,一般小公司或技術不成熟或即時性要求不高的公司,多用TCP開發。

QQ-IM的私有協議:登入等安全性操作使用TCP協議,好友之間發訊息主要使用UDP協議,內網傳輸檔案採用了P2P技術,另外騰訊還用了自己的私有協議,來保證傳輸的可靠性。

二、聊天協議的選擇

首先我們以實現方式來切入,基本上有以下四種實現方式:

 

基於Socket原生:代表框架 CocoaAsyncSocket。
基於WebSocket:代表框架 SocketRocket。
基於MQTT:代表框架 MQTTKit。
基於XMPP:代表框架 XMPPFramework。

 

以上四種方式都可以不使用第三方框架,直接基於OS底層Socket去實現我們的自定義封裝。其中MQTT和XMPP為聊天協議,是最上層的協議,而WebSocket是傳輸通訊協議,它是基於Socket封裝的一個協議。而上面所說的QQ-IM的私有協議,就是基於WebSocket或者Socket原生進行封裝的一個聊天協議。

 

協議優劣對比

 

總之,iOS端要做一個真正的IM產品,一般都是基於Socket或WebSocket等,在之上加上一些私有協議來保證的。

三、實現一個簡單的IM

1。Socket概述

 

Socket其實並不是一個協議,Socket通常也稱作”套接字”,是對TCP/IP 或者UDP/IP協議封裝的一組程式設計介面,用於描述IP地址和埠,使用socket實現行程之間的通訊(跨網路的)。它工作在 OSI 模型會話層(第5層),Socket是對TCP/IP等更底層協議封裝的一個抽象層,是一個呼叫介面(API)。網路上的兩個程式透過一個雙向的通訊連線實現資料的交換,這個雙向鏈路的一端稱為一個Socket,一個Socket由一個IP地址和一個埠號唯一確定。

 

 

網路架構

 

先看下基於C的BSD Socket提供的介面:

 

//socket 建立並初始化 socket,傳回該 socket 的檔案描述符,如果描述符為 -1 表示建立失敗。
int socket(int addressFamily, int type,int protocol)
//關閉socket連線
int close(int socketFileDescriptor)
//將 socket 與特定主機地址與埠號系結,成功系結傳回0,失敗傳回 -1int bind(int socketFileDescriptor,sockaddr *addressToBind,int addressStructLength)
//接受客戶端連線請求並將客戶端的網路地址資訊儲存到 clientAddress 中。
int accept(int socketFileDescriptor,sockaddr *clientAddress, int clientAddressStructLength)
//客戶端向特定網路地址的伺服器傳送連線請求,連線成功傳回0,失敗傳回 -1int connect(int socketFileDescriptor,sockaddr *serverAddress, int serverAddressLength)
//使用 DNS 查詢特定主機名字對應的 IP 地址。如果找不到對應的 IP 地址則傳回 NULL。
hostent* gethostbyname(char *hostname)
//透過 socket 傳送資料,傳送成功傳回成功傳送的位元組數,否則傳回 -1int send(int socketFileDescriptor, char *buffer, int bufferLength, int flags)
//從 socket 中讀取資料,讀取成功傳回成功讀取的位元組數,否則傳回 -1int receive(int socketFileDescriptor,char *buffer, int bufferLength, int flags)
//透過UDP socket 傳送資料到特定的網路地址,傳送成功傳回成功傳送的位元組數,否則傳回 -1int sendto(int socketFileDescriptor,char *buffer, int bufferLength, int flags, sockaddr *destinationAddress, int destinationAddressLength)
//從UDP socket 中讀取資料,並儲存傳送者的網路地址資訊,讀取成功傳回成功讀取的位元組數,否則傳回 -1int recvfrom(int socketFileDescriptor,char *buffer, int bufferLength, int flags, sockaddr *fromAddress, int *fromAddressLength)

 

我們用基於OS底層的原生Socket來實現一個簡單的IM。

 

socket擴充套件閱讀

https://blog.csdn.net/yeyuangen/article/details/6799575

 

2、搭建IM服務端

 

服務端需要做的工作簡單的總結下:

 

1.伺服器呼叫 socket(...) 建立socket2.系結IP地址、埠等資訊到socket上,用函式bind(); 
3.伺服器呼叫 listen(...) 設定緩衝區;
4.伺服器透過 accept(...)接受客戶端請求建立連線;
5.伺服器與客戶端建立連線之後,透過 send(...)/receive(...)向客 
戶端傳送或從客戶端接收資料;
6.伺服器呼叫 close 關閉 socket

服務端可以電腦或手機等終端,也可以用多種語言c/c++/java/js等去實現後臺,當然OC也可以實現。這裡我們借用node.js實現了一個服務端,來驗證socket效果。需要在Mac上安裝node直譯器,node下載,直接下載安裝即可,也可以終端命令安裝node。


開啟伺服器:

 

1.開啟終端
2.cd到目錄 服務端(node.js)
3.node Server.js   #開啟IM伺服器

 

3、實現IM客戶端

 

IM客戶端需要做如下4件事

 

1.客戶端呼叫 socket(...) 建立socket2.系結IP地址、埠等資訊到socket上,用函式bind();
3.客戶端呼叫 connect(...) 向伺服器發起連線請求以建立連線;
4.客戶端與伺服器建立連線之後,就可以透過send(...)/receive(...)向客戶端傳送或從客戶端接收資料;
5.客戶端呼叫 close 關閉 socket

 

程式碼實現
我們採用CocoaAsyncSocket框架,封裝一個名為WYKSocketManager的單例,來對socket相關方法進行呼叫:


為了demo演示方便,程式碼中使用的時間都較短,實際開發中根據需要設定

 

#import "WYKSocketManager.h"
#import "GCDAsyncSocket.h" // for TCP

static NSString *Khost = @"127.0.0.1";
static uint16_t  Kport = 6969;
static NSInteger KPingPongOutTime  = 3;
static NSInteger KPingPongInterval = 5;

@interface WYKSocketManager()<GCDAsyncSocketDelegate>

@property (nonatomicstrong) GCDAsyncSocket *gcdSocket;
@property (nonatomicassignNSTimeInterval reConnectTime;
@property (nonatomicassignNSTimeInterval heartBeatSecond;
@property (nonatomicstrongNSTimer *heartBeatTimer;
@property (nonatomicassignBOOL socketOfflineByUser;  //!
@property (nonatomicretainNSTimer *connectTimer; // 計時器

@end

@implementation WYKSocketManager

- (void)dealloc
{
    [self destoryHeartBeat];
}

+ (instancetype)share
{
    static dispatch_once_t onceToken;
    static WYKSocketManager *instance = nil;
    dispatch_once(&onceToken;, ^{
        instance = [[self alloc] init];
        [instance initSocket];
    });
    return instance;
}

- (void)initSocket
{
    self.gcdSocket = [[GCDAsyncSocket alloc] initWithDelegate:self delegateQueue:dispatch_get_main_queue()];
}

#pragma mark - 對外的一些介面
//建立連線
- (BOOL)connect
{
    self.reConnectTime = 0;
    return [self autoConnect];
}
//斷開連線
- (void)disConnect
{
    self.socketOfflineByUser = YES;
    [self autoDisConnect];
}

//傳送訊息
- (void)sendMsg:(NSString *)msg
{
    NSData *data = [msg dataUsingEncoding:NSUTF8StringEncoding];
    //第二個引數,請求超時時間
    [self.gcdSocket writeData:data withTimeout:-1 tag:110];
}

#pragma mark - GCDAsyncSocketDelegate
//連線成功呼叫
- (void)socket:(GCDAsyncSocket *)sock didConnectToHost:(NSString *)host port:(uint16_t)port
{
    NSLog(@"連線成功,host:%@,port:%d",host,port);
    //pingPong
    [self checkPingPong];
    //心跳寫在這...
    [self initHeartBeat];
}

//斷開連線的時候呼叫
- (void)socketDidDisconnect:(GCDAsyncSocket *)sock withError:(nullable NSError *)err
{
    NSLog(@"斷開連線,host:%@,port:%d",sock.localHost,sock.localPort);
    if (self.delegate && [self.delegate respondsToSelector:@selector(showMessage:)]) {
        NSString *msg = [NSString stringWithFormat:@"斷開連線,host:%@,port:%d",sock.localHost,sock.localPort];
        [self.delegate showMessage:msg];
    }

    if (!self.socketOfflineByUser) {
        //斷線/失敗了就去重連
        [self reConnect];
    }
}

//寫的回呼
- (void)socket:(GCDAsyncSocket*)sock didWriteDataWithTag:(long)tag
{
    NSLog(@"寫的回呼,tag:%ld",tag);
    //判斷是否成功傳送,如果沒收到響應,則說明連線斷了,則想辦法重連
    [self checkPingPong];
}

//收到訊息
- (void)socket:(GCDAsyncSocket *)sock didReadData:(NSData *)data withTag:(long)tag
{
    NSString *msg = [[NSString alloc]initWithData:data encoding:NSUTF8StringEncoding];
    NSLog(@"收到訊息:%@",msg);
    if (self.delegate && [self.delegate respondsToSelector:@selector(showMessage:)]) {
        [self.delegate showMessage:[NSString stringWithFormat:@"收到:%@",msg]];
    }
    //去讀取當前訊息佇列中的未讀訊息 這裡不呼叫這個方法,訊息回呼的代理是永遠不會被觸發的
    [self pullTheMsg];
}

//為上一次設定的讀取資料代理續時 (如果設定超時為-1,則永遠不會呼叫到)
- (NSTimeInterval)socket:(GCDAsyncSocket *)sock shouldTimeoutReadWithTag:(long)tag elapsed:(NSTimeInterval)elapsed bytesDone:(NSUInteger)length
{
    NSLog(@"來延時,tag:%ld,elapsed:%f,length:%ld",tag,elapsed,length);
    if (self.delegate && [self.delegate respondsToSelector:@selector(showMessage:)]) {
        NSString *msg = [NSString stringWithFormat:@"來延時,tag:%ld,elapsed:%.1f,length:%ld",tag,elapsed,length];
        [self.delegate showMessage:msg];
    }
    return KPingPongInterval;
}

#pragma mark- Private Methods
- (BOOL)autoConnect
{
    return [self.gcdSocket connectToHost:Khost onPort:Kport error:nil];
}

- (void)autoDisConnect
{
    [self.gcdSocket disconnect];
}

//監聽最新的訊息
- (void)pullTheMsg
{
    //監聽讀資料的代理,只能監聽10秒,10秒過後呼叫代理方法  -1永遠監聽,不超時,但是隻收一次訊息,
    //所以每次接受到訊息還得呼叫一次
    [self.gcdSocket readDataWithTimeout:-1 tag:110];
}

//用Pingpong機制來看是否有反饋
- (void)checkPingPong
{
    //pingpong設定為3秒,如果3秒內沒得到反饋就會自動斷開連線
    [self.gcdSocket readDataWithTimeout:KPingPongOutTime tag:110];
}

//重連機制
- (void)reConnect
{
    //如果對一個已經連線的socket物件再次進行連線操作,會丟擲異常(不可對已經連線的socket進行連線)程式崩潰
    [self autoDisConnect];
    //重連次數 控制3次
    if (self.reConnectTime >= 5) {
        return;
    }
    __weak __typeof(self) weakSelf = self;
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(self.reConnectTime * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        __strong typeof(weakSelf) strongSelf = weakSelf;
        if (strongSelf.delegate && [strongSelf.delegate respondsToSelector:@selector(showMessage:)]) {
            NSString *msg = [NSString stringWithFormat:@"斷開重連中,%f",strongSelf.reConnectTime];
            [strongSelf.delegate showMessage:msg];
        }
        strongSelf.gcdSocket = nil;
        [strongSelf initSocket];
        [strongSelf autoConnect];
    });

    //重連時間增長
    if (self.reConnectTime == 0) {
        self.reConnectTime = 1;
    } else {
        self.reConnectTime += 2;
    }
}

//初始化心跳
- (void)initHeartBeat
{
    [self destoryHeartBeat];
    // 每隔5s像伺服器傳送心跳包
    self.connectTimer = [NSTimer scheduledTimerWithTimeInterval:5
                                                         target:self selector:@selector(longConnectToSocket)
                                                       userInfo:nil
                                                        repeats:YES];
    // 在longConnectToSocket方法中進行長連線需要向伺服器傳送的訊息
    [self.connectTimer fire];
}

// 心跳連線
-(void)longConnectToSocket
{
    // 根據伺服器要求傳送固定格式的資料,但是一般不會是這麼簡單的指令
    [self sendMsg:@"心跳連線"];
}

//取消心跳
- (void)destoryHeartBeat
{
    if (self.heartBeatTimer  && [self.heartBeatTimer isValid]) {
        [self.heartBeatTimer invalidate];
        self.heartBeatTimer = nil;
    }
}

@end

 

我們發了一條訊息,服務端成功的接收到了訊息後,把該訊息再發送回客戶端,繞了一圈客戶端又收到了這條訊息。至此我們用OS底層socket實現了簡單的IM。這裡僅僅是實現了Socket的連線並傳輸字串,我們要做的遠不止於此。

 

3、四個重要的功能:心跳機制、PingPong機制、斷線重連、訊息可達

 

(1)心跳機制

 

心跳機制是相對時間內主動向伺服器傳送心跳包訊息,用來檢測TCP連線的雙方是否可用。TCP的KeepAlive機制只能保證連線的存在,但是並不能保證客戶端以及服務端的可用性。


擴充套件閱讀:為什麼說基於TCP的移動端IM仍然需要心跳保活?

http://www.52im.net/forum.php?mod=viewthread&tid;=281

 

真正需要心跳機制的原因其實主要是在於國內運營商的網路地址轉換裝置超時,對於家用路由器來說, 使用的是網路地址埠轉換(NAPT), 它不僅改IP, 還修改TCP和UDP協議的埠號, 這樣就能讓內網中的裝置共用同一個外網IP,造成連線存在,但並不一定可用。

 

而國內的運營商一般NAT超時的時間為5分鐘,頻繁心跳會帶來耗電和耗流量的弊端,所以通常IM心跳設定的時間間隔為3-5分鐘,甚至10分鐘都行。微信有一種更高階的實現方式,有興趣的小夥伴可以看看:微信的智慧心跳實現方式

http://www.52im.net/thread-120-1-1.html

 

(2)PingPong機制

 

心跳機制是不能完全保證訊息的即時性的,業內的解決方案是輔助採用雙向的PingPong機制。

 

PingPong機制

 

當服務端發出一個Ping,客戶端沒有在約定的時間內傳迴響應的ack,則認為客戶端已經不線上,這時我們Server端會主動斷開Socket連線,並且改由APNS推送的方式傳送訊息。
同樣的是,當客戶端去傳送一個訊息,因為我們遲遲無法收到服務端的響應ack包,則表明客戶端或者服務端已不線上,我們也會顯示訊息傳送失敗,並且斷開Socket連線。

 

(3)重連機制

 

理論上,自己主動斷開的Socket連線(如退出賬號,APP退出到後臺等),不需要重連。其他的連線斷開,我們都需要進行斷線重連。一般解決方案是嘗試重連幾次,如果仍舊無法重連成功,那麼不再進行重連。

 

(4)訊息可達(即QoS機制)

 

在行動網路下,丟包、網路重連等情況非常之多,為了保證訊息的可達,一般需要做訊息回執和重發機制。


一般有三種型別:


QOS(0),最多傳送一次:如果訊息沒有傳送過去,那麼就直接丟失。

QOS(1),至少傳送一次:保證訊息一定傳送過去,但是發幾次不確定。
QOS(2),精確只傳送一次:它內部會有一個很複雜的傳送機制,確保訊息送到,而且只傳送一次。

 

參考易信,每條訊息會最多會有3次重發,超時時間為15秒,同時在傳送之前會檢測當前連線狀態,如果當前連線並沒有正確建立,快取訊息且定時檢查(每隔2秒檢查一次,檢查15次)。所以一條訊息在最差的情況下會有2分鐘左右的重試時間,以保證訊息的可達。因為重發的存在,接受端偶爾會收到重覆訊息,這種情況下就需要接收端進行去重。通用的做法是每條訊息都戴上自己唯一的message id(一般是uuid)。

 

擴充套件閱讀:
IM訊息送達保證機制實現

http://www.52im.net/thread-294-1-1.html

 

4、IM的其他實現方式

 

(1)基於WebSocket最具代表性的一個第三方框架SocketRocket

 

實現的思路和基於CocoaAsyncSocket框架類似,需要編寫遵守webSocket協議的服務端,感興趣的也可以參照實現一下。

 

(2)基於MQTT協議的框架-MQTTKit

 

MQTT是一個聊天協議,它比webSocket更上層,屬於應用層,它的基本樣式是簡單的釋出訂閱,也就是說當一條訊息發出去的時候,誰訂閱了誰就會收到訊息。其實它並不適合IM的場景,例如用來實現有些簡單IM場景,卻需要很大量的、複雜的處理。這個框架是c來寫的,把一些方法公開在MQTTKit類中,對外用OC來呼叫,這個庫有4年沒有更新了。

 

(3)基於XMPP協議的框架-MQTTKit

 

XMPP是較早的聊天協議(2000年釋出第一個公開版本),當時主要是用來打通 ICQ、MSN 等 PC 端的聊天軟體而設計的,技術比較成熟,它本身有很多優點,如開放、標準、可擴充套件,並且客戶端和伺服器端都有很多開源的實現,但是相對於移動端它也有很明顯的缺點,譬如資料負載過重、不支援二進位制,在互動中有50% 以上的流量是協議本身消耗的,需要做深度的二次開發。

三、關於IM通訊協議的選擇

1、序列化與反序列化

 

移動網際網路相對於有線網路最大特點是:頻寬低,延遲高,丟包率高和穩定性差,流量費用高。所以在私有協議的序列化上一般使用二進位制協議,而不是文字協議。


常見的二進位制序列化庫有Protocol Buffers和MessagePack,當然你也可以自己實現自己的二進位制協議序列化和反序列的過程,比如蘑菇街的TeamTalk。但是前面二者無論是可拓展性還是可讀性都完爆TeamTalk(TeamTalk連Variant都不支援,一個int傳輸時固定佔用4個位元組),所以大部分情況下還是不推薦自己去實現二進位制協議的序列化和反序列化過程。

 

一條訊息資料用Protobuf序列化後的大小是 JSON 的1/10、XML格式的1/20、是二進位制序列化的1/10。同 XML 相比, Protobuf 效能優勢明顯。它以高效的二進位制方式儲存,比 XML 小 3 到 10 倍,快 20 到 100 倍。

 

同時心跳包協議對IM的電量和流量影響很大,對心跳包協議上進行了極簡設計:僅 1 Byte 。
ProtocolBuffer可能會造成 APP 的包體積增大,透過 Google 提供的指令碼生成的 Model,會非常“龐大”,Model 一多,包體積也就會跟著變大。


如何測試驗證 Protobuf 的高效能?


對資料分別操作100次,1000次,10000次和100000次進行了測試,

縱坐標是完成時間,單位是毫秒

 

序列化

 

反序列化

 

Xml,Json,Hessian,Protocol Buffers序列化對比
選擇傳輸格式的時候:ProtocolBuffer > JSON > XML
ProtocolBuffer for Objective-C 執行環境配置及使用
iOS之ProtocolBuffer搭建和示例demo

 

2、協議格式設計

 

基於TCP的應用層協議一般都分為包頭和包體(如HTTP),IM協議也不例外。包頭一般用於表示每個請求/反饋的公共部分,如包長,請求型別,傳回碼等。而包頭則填充不同請求/反饋對應的資訊。

 

一個最簡單的包頭可以定義為:

 

struct PackHeader
{
    int32_t     length_;    //包長度
    int32_t     serial_;    //包序列號
    int32_t     command_;   //包請求型別
    int32_t     code_;      //傳回碼
};

 

以心跳包為例,假設當前的serial為1,心跳包的command為10,那麼使用MessagePack做序列化時:length=4,serial=1,command=10,code=0,每個欄位各佔一個位元組,包體為空,僅需要4個位元組。

 

當然這是最簡單的一個例子,面對真正的業務邏輯時,包體裡面會需要塞入更多地資訊,這個需要開髮根據自己的業務邏輯總結公共部分,如為了相容加入的協議版本號,為了負載均衡加入的模組id等。

四、IM一些其它問題

1、IM的可靠性:

 

除了心跳機制、PingPong機制、斷線重連機制這些被用來保證連線的可用,要提高IM服務時的可靠性,能做的還有很多:比如在大檔案傳輸的時候使用分片上傳、斷點續傳、秒傳技術、P2P技術等來保證檔案的傳輸。

 

2、安全性:

 

我們通常還需要一些安全機制來保證我們IM通訊安全。如:加密傳輸、防止 DNS 汙染、帳號安全、第三方伺服器鑒權、單點登入等。

3、一些其他的最佳化:

 

精簡心跳包,心跳包只在空閑時傳送,動態化心跳間隔。檔案上傳、下載最佳化等。類似微信,伺服器不做聊天記錄的儲存,只在本機進行快取,這樣可以減少對服務端資料的請求,一方面減輕了伺服器的壓力,另一方面減少客戶端流量的消耗。


我們進行http連線的時候儘量採用上層API,類似NSUrlSession。而網路框架儘量使用AFNetWorking3.0 以上版本。因為這些上層網路請求都用的是HTTP/2 ,我們請求的時候可以復用這些連線。

 

更多最佳化相關請參考這篇文章:
《iOS端行動網路調優的8條建議》

http://www.52im.net/thread-134-1-1.html

IM 即時通訊技術在多應用場景下的技術實現,以及效能調優( iOS 視角)

https://www.jianshu.com/p/8cd908148f9e

五、實時音影片通話

IM應用中的實時音影片技術,幾乎是IM開發中的最後一道高牆。原因在於:實時音影片技術 = 音影片處理技術 + 網路傳輸技術 的橫向技術應用集合體,而公共網際網路不是為了實時通訊設計的。


實時音影片技術上的實現內容主要包括:音影片的採集、編碼、網路傳輸、解碼、播放等環節。這麼多項並不簡單的技術應用,如果把握不當,將會在在實際開發過程中遇到一個又一個的坑。

已同步到看一看
贊(0)

分享創造快樂