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

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)

分享創造快樂