基於CocoaAsyncSocket的即時通訊實現

Github地址:https://github.com/AlanZhangQ/Alan_cocoaSocket.git,如果有用,請點star.

之前做的項目有IM部分,在考慮了環信和融雲等已經比較通用的IMSDK,發現它們自定義程度不是很符合我們,想要自由約束,就需要自己定義一份網絡協議,在CSDN,CocoaChina等網站收集整理信息後,決定使用CocoaAsyncSocket搭建IM。

一.CocoaAsyncSocket介紹

CocoaAsyncSocket中主要包含兩個類:

1.GCDAsyncSocket.

1

2

用GCD搭建的基於TCP/IP協議的socket網絡庫

GCDAsyncSocket is a TCP/IP socket networking library built atop Grand Central Dispatch. -- 引自CocoaAsyncSocket.

2.GCDAsyncUdpSocket.

1

2

用GCD搭建的基於UDP/IP協議的socket網絡庫.

GCDAsyncUdpSocket is a UDP/IP socket networking library built atop Grand Central Dispatch..-- 引自CocoaAsyncSocket.

二.下載CocoaAsyncSocket

  • 首先,需要到這裏下載CocoaAsyncSocket.

  • 下載後可以看到文件所在位置.

3284707-680f66e017b5023b.png

文件路徑

  • 這裏只要拷貝以下兩個文件到項目中.

3284707-a9e6ad00224a4797.png

TCP開發使用的文件

三.CocoaAsyncSocket的具體使用

1.繼承GCDAsyncSocketDelegate協議.

@interface ChatHandler ()<GCDAsyncSocketDelegate>

2.初始化聊天Handler單例,並將其設置成接收TCP信息的代理。

#pragma mark - 初始化聊天handler單例
+ (instancetype)shareInstance
{
    static ChatHandler *handler = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        handler = [[ChatHandler alloc]init];
    });
    return handler;
}

- (instancetype)init
{
    if(self = [super init]) {
        //將handler設置成接收TCP信息的代理
        _chatSocket = [[GCDAsyncSocket alloc] initWithDelegate:self delegateQueue:dispatch_get_main_queue()];
        //設置默認關閉讀取
        [_chatSocket setAutoDisconnectOnClosedReadStream:NO];
        //默認狀態未連接
        _connectStatus = SocketConnectStatus_UnConnected;
    }
    return self;
}

3.連接測試或正式服務器端口

#pragma mark - 連接服務器端口
- (void)connectServerHost
{
    NSError *error = nil;
    [_chatSocket connectToHost:@"此處填寫服務器IP" onPort:8080 error:&error];
    if (error) {
        NSLog(@"----------------連接服務器失敗----------------");
    }else{
        NSLog(@"----------------連接服務器成功----------------");
    }
}

4.服務器端口連接成功,TCP連接正式建立,配置SSL 相當於https 保證安全性 , 這裏是單向驗證服務器地址 , 僅僅需要驗證服務器的IP即可

#pragma mark - TCP連接成功建立 ,配置SSL 相當於https 保證安全性 , 這裏是單向驗證服務器地址 , 僅僅需要驗證服務器的IP即可
- (void)socket:(GCDAsyncSocket *)sock didConnectToHost:(NSString *)host port:(uint16_t)port
{
    // 配置 SSL/TLS 設置信息
    NSMutableDictionary *settings = [NSMutableDictionary dictionaryWithCapacity:3];
    //允許自簽名證書手動驗證
    [settings setObject:@YES forKey:GCDAsyncSocketManuallyEvaluateTrust];
    //GCDAsyncSocketSSLPeerName
    [settings setObject:@"此處填服務器IP地址" forKey:GCDAsyncSocketSSLPeerName];
    [_chatSocket startTLS:settings];
}

5.SSL驗證成功,發送登錄驗證,開啓讀入流

#pragma mark - TCP成功獲取安全驗證
- (void)socketDidSecure:(GCDAsyncSocket *)sock
{
    //登錄服務器
    ChatModel *loginModel  = [[ChatModel alloc]init];
    //此版本號需和後臺協商 , 便於後臺進行版本控制
    loginModel.versionCode = TCP_VersionCode;
    //當前用戶ID
    loginModel.fromUserID  = [Account account].myUserID;
    //設備類型
    loginModel.deviceType  = DeviceType;
    //發送登錄驗證
    [self sendMessage:loginModel timeOut:-1 tag:0];
    //開啓讀入流
    [self beginReadDataTimeOut:-1 tag:0];
}

6.發送消息給服務端

#pragma mark - 發送消息
- (void)sendMessage:(ChatModel *)chatModel timeOut:(NSUInteger)timeOut tag:(long)tag
{
    //將模型轉換爲json字符串
    NSString *messageJson = chatModel.mj_JSONString;
    //以"\n"分割此條消息 , 支持的分割方式有很多種例如\r\n、\r、\n、空字符串,不支持自定義分隔符,具體的需要和服務器協商分包方式 , 這裏以\n分包
    /*
     如不進行分包,那麼服務器如果在短時間裏收到多條消息 , 那麼就會出現粘包的現象 , 無法識別哪些數據爲單獨的一條消息 .
     對於普通文本消息來講 , 這裏的處理已經基本上足夠 . 但是如果是圖片進行了分割發送,就會形成多個包 , 那麼這裏的做法就顯得並不健全,嚴謹來講,應該設置包頭,把該條消息的外信息放置於包頭中,例如圖片信息,該包長度等,服務器收到後,進行相應的分包,拼接處理.
     */
    messageJson           = [messageJson stringByAppendingString:@"\n"];
    //base64編碼成data
    NSData  *messageData  = [[NSData alloc]initWithBase64EncodedString:messageJson options:NSDataBase64DecodingIgnoreUnknownCharacters];
    //寫入數據
    [_chatSocket writeData:messageData withTimeout:1 tag:1];
}

聲明:cocoaAsyncSocket主要是通過- (void)writeData:(NSData *)data withTimeout:(NSTimeInterval)timeout tag:(long)tag這個方法由客戶端發送數據給服務器。

7.接收服務器端消息

#pragma mark - 接收到消息
- (void)socket:(GCDAsyncSocket *)sock didReadData:(NSData *)data withTag:(long)tag
{
    //轉爲明文消息
    NSString *secretStr  = [data base64EncodedStringWithOptions:NSDataBase64Encoding64CharacterLineLength];
    //去除'\n'
    secretStr            = [secretStr stringByReplacingOccurrencesOfString:@"\n" withString:@""];
    //轉爲消息模型(具體傳輸的json包裹內容,加密方式,包頭設定什麼的需要和後臺協商,操作方式根據項目而定)
    ChatModel *messageModel = [ChatModel mj_objectWithKeyValues:secretStr];
    
    //接收到服務器的心跳
    if ([messageModel.beatID isEqualToString:TCP_beatBody]) {
        
        //未接到服務器心跳次數置爲0
        _senBeatCount = 0;
        NSLog(@"------------------接收到服務器心跳-------------------");
        return;
    }

8.socket已經斷開連接.

#pragma mark - TCP已經斷開連接
- (void)socketDidDisconnect:(GCDAsyncSocket *)sock withError:(NSError *)err
{
    //    //如果是主動斷開連接
    //    if (_connectStatus == SocketConnectStatus_DisconnectByUser) return;
    //置爲未連接狀態
    _connectStatus  = SocketConnectStatus_UnConnected;
    //自動重連
    if (autoConnectCount) {
        [self connectServerHost];
        NSLog(@"-------------第%ld次重連--------------",(long)autoConnectCount);
        autoConnectCount -- ;
    }else{
        NSLog(@"----------------重連次數已用完------------------");
    }
}

9.心跳連接的建立

- (dispatch_source_t)beatTimer
{
    if (!_beatTimer) {
        _beatTimer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_main_queue());
        dispatch_source_set_timer(_beatTimer, DISPATCH_TIME_NOW, TCP_BeatDuration * NSEC_PER_SEC, 0 * NSEC_PER_SEC);
        dispatch_source_set_event_handler(_beatTimer, ^{
            //發送心跳 +1
            _senBeatCount++;
            //超過三次未收到服務器心跳, 設置爲未連接狀態
            if (_senBeatCount > TCP_MaxBeatMissCount) {
                _connectStatus = SocketConnectStatus_UnConnected;
            } else {
                //發送心跳
                NSData *beatData = [[NSData alloc] initWithBase64EncodedString: [TCP_beatBody stringByAppendingString:@"\n"] options:NSDataBase64DecodingIgnoreUnknownCharacters];
                [_chatSocket writeData:beatData withTimeout:-1 tag:9999];
                NSLog(@"------------------發送了心跳------------------");
            }
        });
    }
    return _beatTimer;
}

聲明:心跳連接是確認服務器端和客戶端是否建立連接的測試,需要服務器端和客戶端確定心跳包和心跳間隔。

四.IM中UI的具體實現

1.文字消息,這裏不用多說,主要涉及到圖文混排(可以看Github:https://github.com/AlanZhangQ/CoreTextLabel.git),實現效果如圖:

2.語音消息,主要是分爲錄音,轉換格式(由PCM格式等轉爲MP3格式),播放。實現效果如下:

錄音的主要代碼:

- (void)setSesstion
{
    _session = [AVAudioSession sharedInstance];
    NSError *sessionError;
    [_session setCategory:AVAudioSessionCategoryPlayAndRecord error:&sessionError];

    if (_session == nil)
    {
        NSLog(@"Error creating session: %@", [sessionError description]);
    }
    else
    {
        [_session setActive:YES error:nil];
    }
}
- (void)setRecorder
{
    _recorder = nil;
    NSError             *recorderSetupError = nil;
    NSURL               *url                = [NSURL fileURLWithPath:[self cafPath]];
    NSMutableDictionary *settings           = [[NSMutableDictionary alloc] init];
    //錄音格式 無法使用
    [settings setValue:[NSNumber numberWithInt:kAudioFormatLinearPCM] forKey:AVFormatIDKey];
    //採樣率
    [settings setValue:[NSNumber numberWithFloat:11025.0] forKey:AVSampleRateKey]; //44100.0
    [settings setValue:[NSNumber numberWithFloat:38400.0] forKey:AVEncoderBitRateKey];
    //通道數
    [settings setValue:[NSNumber numberWithInt:2] forKey:AVNumberOfChannelsKey];
    //音頻質量,採樣質量
    [settings setValue:[NSNumber numberWithInt:AVAudioQualityMin] forKey:AVEncoderAudioQualityKey];
//    [];
    _recorder = [[AVAudioRecorder alloc] initWithURL:url settings:settings error:&recorderSetupError];
    if (recorderSetupError)
    {
        NSLog(@"%@", recorderSetupError);
    }
    _recorder.meteringEnabled = YES;
    _recorder.delegate        = self;
    [_recorder prepareToRecord];
}

轉換格式的主要代碼:

- (void)audio_PCMtoMP3:(NSTimeInterval)recordTime
{
    NSString *cafFilePath = [self cafPath];
    NSString *mp3FilePath = [self mp3Path];

    // remove the old mp3 file
    [self deleteMp3Cache];

    NSLog(@"MP3轉換開始");
    if (_delegate && [_delegate respondsToSelector:@selector(beginConvert)])
    {
        [_delegate beginConvert];
    }
    @try
    {
        int read, write;

        FILE *pcm = fopen([cafFilePath cStringUsingEncoding:1], "rb"); //source 被轉換的音頻文件位置
        fseek(pcm, 4 * 1024, SEEK_CUR);                                //skip file header
        FILE *mp3 = fopen([mp3FilePath cStringUsingEncoding:1], "wb"); //output 輸出生成的Mp3文件位置

        const int     PCM_SIZE = 8192;
        const int     MP3_SIZE = 8192;
        short int     pcm_buffer[PCM_SIZE * 2];
        unsigned char mp3_buffer[MP3_SIZE];

        lame_t lame = lame_init();
        lame_set_in_samplerate(lame, 11025.0);
        lame_set_VBR(lame, vbr_default);
        lame_init_params(lame);

        do
        {
            read = fread(pcm_buffer, 2 * sizeof(short int), PCM_SIZE, pcm);
            if (read == 0)
            {
                write = lame_encode_flush(lame, mp3_buffer, MP3_SIZE);
            }
            else
            {
                write = lame_encode_buffer_interleaved(lame, pcm_buffer, read, mp3_buffer, MP3_SIZE);
            }

            fwrite(mp3_buffer, write, 1, mp3);

        }
        while (read != 0);

        lame_close(lame);
        fclose(mp3);
        fclose(pcm);
    }
    @catch (NSException *exception)
    {
        NSLog(@"%@", [exception description]);
    }
    @finally
    {
        [[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategorySoloAmbient error:nil];
    }

    [self deleteCafCache];
    NSLog(@"MP3轉換結束");
    if (_delegate && [_delegate respondsToSelector:@selector(endConvertWithData:seconds:)])
    {
        NSData *voiceData = [NSData dataWithContentsOfFile:[self mp3Path]];
        [_delegate endConvertWithData:voiceData seconds:recordTime];
    }
}

播放的主要代碼如下:

- (instancetype)initWithPath:(NSString *)path
{
    if (self = [super init]) {
        self.item   = [[AVPlayerItem alloc]initWithURL:[NSURL fileURLWithPath:path]];
        self.player = [[AVPlayer alloc]initWithPlayerItem:self.item];
        UInt32 sessionCategory = kAudioSessionCategory_MediaPlayback;
        AudioSessionSetProperty(kAudioSessionProperty_AudioCategory,
                                sizeof(sessionCategory),
                                &sessionCategory);
        
        UInt32 audioRouteOverride = kAudioSessionOverrideAudioRoute_Speaker;
        AudioSessionSetProperty (kAudioSessionProperty_OverrideAudioRoute,
                                 sizeof (audioRouteOverride),
                                 &audioRouteOverride);
        
        AVAudioSession *audioSession = [AVAudioSession sharedInstance];
        //靜音模式依然播放
        [audioSession setCategory:AVAudioSessionCategoryPlayback error:nil];
        [audioSession setActive:YES error:nil];
    }
    return self;
}


- (void)play
{
    [self.player play];
}

3.圖片和視頻消息,主要是通過阿里巴巴的TZImagerPicker框架實現存取,實現效果如下:

五.IM整體邏輯和問題的梳理

關於Socket連接

登錄 -> 連接服務器端口 -> 成功連接 -> SSL驗證 -> 發送登錄TCP請求(login) -> 收到服務端返回登錄成功回執(loginReceipt) ->發送心跳 -> 出現連接中斷 ->斷網重連3次 -> 退出程序主動斷開連接

關於連接狀態監聽

1. 普通網絡監聽

由於即時通訊對於網絡狀態的判斷需要較爲精確 ,原生的Reachability實際上在很多時候判斷並不可靠 。 
主要體現在當網絡較差時,程序可能會出現連接上網絡 , 但並未實際上能夠進行數據傳輸 。 
開始嘗試着用Reachability加上一個普通的網絡請求來雙重判斷實現更加精確的網絡監聽 , 但是實際上是不可行的 。 
如果使用異步請求依然判斷不精確 , 若是同步請求 , 對性能的消耗會很大 。 
最終採取的解決辦法 , 使用RealReachability ,對網絡監聽同時 ,PING服務器地址或者百度 ,網絡監聽問題基本上得以解決

2. TCP連接狀態監聽:

TCP的連接狀態監聽主要使用服務器和客戶端互相發送心跳 ,彼此驗證對方的連接狀態 。 
規則可以自己定義 , 當前使用的規則是 ,當客戶端連接上服務器端口後 ,且成功建立SSL驗證後 ,向服務器發送一個登陸的消息(login)。 
當收到服務器的登陸成功回執(loginReceipt)開啓心跳定時器 ,每一秒鐘向服務器發送一次心跳 ,心跳的內容以安卓端/iOS端/服務端最終協商後爲準 。 
當服務端收到客戶端心跳時,也給服務端發送一次心跳 。正常接收到對方的心跳時,當前連接狀態爲已連接狀態 ,當服務端或者客戶端超過3次(自定義)沒有收到對方的心跳時,判斷連接狀態爲未連接。

關於本地緩存

1. 數據庫緩存

建議每個登陸用戶創建一個DB ,切換用戶時切換DB即可 。 
搭建一個完善IM體系 , 每個DB至少對應3張表 。 
一張用戶存儲聊天列表信息,這裏假如它叫chatlist ,即微信首頁 ,用戶存儲每個羣或者單人會話的最後一條信息 。來消息時更新該表,並更新內存數據源中列表信息。或者每次來消息時更新內存數據源中列表信息 ,退出程序或者退出聊天列表頁時進行數據庫更新。後者避免了頻繁操作數據庫,效率更高。 
一張用戶存儲每個會話中的詳細聊天記錄 ,這裏假如它叫chatinfo。該表也是如此 ,要麼接到消息立馬更新數據庫,要麼先存入內存中,退出程序時進行數據庫緩存。 
一張用於存儲好友或者羣列表信息 ,這裏假如它叫myFriends ,每次登陸或者退出,或者修改好友備註,刪除好友,設置星標好友等操作都需要更新該表。

2. 沙盒緩存

當發送或者接收圖片、語音、文件信息時,需要對信息內容進行沙盒緩存。 
沙盒緩存的目錄分層 ,個人建議是在每個用戶根據自己的userID在Cache中創建文件夾,該文件夾目錄下創建每個會話的文件夾。 
這樣做的好處在於 , 當你需要刪除聊天列表會話或者清空聊天記錄 ,或者app進行內存清理時 ,便於找到該會話的所有緩存。大致的目錄結構如下 
../Cache/userID(當前用戶ID)/toUserID(某個羣或者單聊對象)/…(圖片,語音等緩存)

關於消息分發

全局咱們設定了一個ChatHandler單例,用於處理TCP的相關邏輯 。那麼當TCP推送過來消息時,我該將這些消息發給誰?誰註冊成爲我的代理,我就發給誰。 
ChatHandler單例爲全局的,並且生命週期爲整個app運行期間不會銷燬。在ChatHandler中引用一個數組 ,該數組中存放所有註冊成爲需要收取消息的代理,當每來一條消息時,遍歷該數組,並向所有的代理推送該條消息.

聊天UI的搭建

1. 聊天列表UI(微信首頁)

這個頁面沒有太多可說的 , 一個tableView即可搞定 。需要注意的是 ,每次收到消息時,都需要將該消息置頂 。每次進入程序時,拉取chatlist表存儲的每個會話的最後一條聊天記錄進行展示 。

2. 會話頁面

該頁面tableView或者collectionView均可實現 ,看個人喜好 。這裏是我用的是tableView . 
根據消息類型大致分爲普通消息 ,語音消息 ,圖片消息 ,文件消息 ,視頻消息 ,提示語消息(以上爲打招呼內容,xxx已加入羣,xxx撤回了一條消息等)這幾種 ,固cell的註冊差不多爲5種類型,每種消息對應一種消息。 
視頻消息和圖片消息cell可以複用 。 
不建議使用過少的cell類型 ,首先是邏輯太多 ,不便於處理 。其次是效率並不高。

發送消息

1. 文本消息/表情消息

直接調用咱們封裝好的ChatHandler的sendMessage方法即可 , 發送消息時 ,需要存入或者更新chatlist和chatinfo兩張表。若是未連接或者發送超時 ,需要重新更新數據庫存儲的發送成功與否狀態 ,同時更新內存數據源 ,刷新該條消息展示即可。 
若是表情消息 ,傳輸過程也是以文本的方式傳輸 ,比如一個大笑的表情 ,可以定義爲[大笑] ,當然規則自己可以和安卓端web端協商,本地根據plist文件和表情包匹配進行圖文混排展示即可 。 
https://github.com/coderMyy/MYCoreTextLabel ,圖文混排地址 , 如果覺得有用 , 請star一下 ,好人一生平安

2. 語音消息

語音消息需要注意的是 ,多和安卓端或者web端溝通 ,找到一個大家都可以接受的格式 ,轉碼時使用同一種格式,避免某些格式其他端無法播放,個人建議Mp3格式即可。 
同時,語音也需要做相應的降噪 ,壓縮等操作。 
發送語音大約有兩種方式 。 
一是先對該條語音進行本地緩存 , 然後全部內容均通過TCP傳輸並攜帶該條語音的相關信息,例如時長,大小等信息,具體的你得測試一條壓縮後的語音體積有多大,若是過大,則需要進行分割然後以消息的方法時發送。接收語音時也進行拼接。同時發送或接收時,對chatinfo和chatlist表和內存數據源進行更新 ,超時或者失敗再次更新。 
二是先對該條語音進行本地緩存 , 語音內容使用http傳輸,傳輸到服務器生成相應的id ,獲取該id再附帶該條語音的相關信息 ,以TCP方式發送給對方,當對方收到該條消息時,先去下載該條信息,並根據該條語音的相關信息進行展示。同時發送或接收時,對chatinfo和chatlist表和內存數據源進行更新 ,超時或者失敗再次更新。

3. 圖片消息

圖片消息需要注意是 ,通過拍照或者相冊中選擇的圖片應當分成兩種大小 , 一種是壓縮得非常小的狀態,一種是圖片本身的大小狀態。 聊天頁面展示的 ,僅僅是小圖 ,只有點擊查看時纔去加載大圖。這樣做的目的在於提高發送和接收的效率。 
同樣發送圖片也有兩種方式 。 
一是先對該圖片進行本地緩存 , 然後全部內容均通過TCP傳輸 ,並攜帶該圖片的相關信息 ,例如圖片的大小 ,名字 ,寬高比等信息 。同樣如果過大也需要進行分割傳輸。同時發送或接收時,對chatinfo和chatlist表和內存數據源進行更新 ,超時或者失敗再次更新。 
二是先對該圖片進行本地緩存 , 然後通過http傳輸到服務器 ,成功後發送TCP消息 ,並攜帶相關消息 。接收方根據你該條圖片信息進行UI佈局。同時發送或接收時,對chatinfo和chatlist表和內存數據源進行更新 ,超時或者失敗再次更新。

4. 視頻消息

視頻消息值得注意的是 ,小的視頻沒有太多異議,跟圖片消息的規則差不多 。只是當你從拍照或者相冊中獲取到視頻時,第一時間要獲取到視頻第一幀用於展示 ,然後再發送視頻的內容。大的視頻 ,有個問題就是當你選擇一個視頻時,首先做的是緩存到本地,在那一瞬間 ,可能會出現內存峯值問題 。只要不是過大的視頻 ,現在的手機硬件配置完全可以接受的。而上傳採取分段式讀取,這個問題並不會影響太多。

視頻消息我個人建議是走http上傳比較好 ,因爲內容一般偏大 。TCP部分僅需要傳輸該視頻封面以及相關信息比如時長,下載地址等相關信息即可。接收方可以通過視頻大小判斷,如果是小視頻可以接收到後默認自動下載,自動播放 ,大的視頻則只展示封面,只有當用戶手動點擊時纔去加載。具體的還是需要根據項目本身的設計而定。

5. 文件消息

文件方面 ,iOS端並不如安卓端那種可操作性強 ,安卓可以完全獲取到用戶裏的所有文件,iOS則有保護機制。通常iOS端發送的文件 ,基本上僅僅侷限於當前app自己緩存的一些文件 ,原理跟發送圖片類似。

6. 撤回消息

撤回消息也是消息內容的一種類型 。例如 A給B發送了一條消息 “你好” ,服務端會對該條消息生成一個messageID ,接收方收到該條消息的messageID和發送方的該條消息messageID一致。如果發送端需要撤回該條消息 ,僅僅需要拿到該條消息messageID ,設置一下消息類型 ,發送給對方 ,當收到撤回消息的成功回執(repealReceipt)時,移除該會話的內存數據源和更新chatinfo和chatlist表 ,並加載提示類型的cell進行展示例如“你撤回了一條消息”即可。接收方收到撤回消息時 ,同樣移除內存數據源 ,並對數據庫進行更新 ,再加載提示類型的cell例如“張三撤回了一條消息”即可。

7. 提示語消息

提示語消息通常來說是服務器做的事情更多 ,除了撤回消息是需要客戶端自己做的事情並不多。 
當有人退出羣 ,或者自己被羣主踢掉 ,時服務端推送一條提示語消息類型,並附帶內容,客戶端僅僅需要做展示即可,例如“張三已經加入羣聊”,“以上爲打招呼內容”,“你已被踢出該羣”等。 
當然 ,撤回消息也可以這樣實現 ,這樣提示消息類型邏輯就相當統一,不會顯得很亂 。把主要邏輯交於了服務端來實現。

消息刪除

這裏需要注意的一點是 ,類似微信的長按消息操作 ,我採用的是UIMenuController來做的 ,實際上有一點問題 ,就是第一響應者的問題 ,想要展示該menu ,必須將該條消息的cell置爲第一響應者,然後底部的鍵盤失去第一響應者,會降下去 。所以該長按出現menu最好還是自定義 ,根據計算相對frame進行佈局較好,自定義程度也更好。

消息刪除大概分爲刪除該條消息 ,刪除該會話 ,清空聊天記錄幾種 
刪除該條消息僅僅需要移除本地數據源的消息模型 ,更新chatlist和chatinfo表即可。 
刪除該會話需要移除chatlist和chatinfo該會話對應的列 ,並根據當前登錄用戶的userID和該會話的toUserID或者groupID移除沙盒中的緩存。 
清空聊天記錄,需要更新chatlist表最後一條消息內容 ,刪除chatinfo表,並刪除該會話的沙盒緩存.

消息拷貝

這個不用多說 ,一兩句話搞定

消息轉發

拿到該條消息的模型 ,並創建新的消息 ,把內容賦值到新消息 ,然後選擇人或者羣發送即可。

值得注意的是 ,如果是轉發圖片或者視頻 ,本地沙盒中的緩存也應當copy一份到轉發對象所對應的沙盒目錄緩存中 ,不能和被轉發消息的會話共用一張圖或者視頻 。因爲比如 :A給B發了一張圖 ,A把該圖轉發給了C ,A移除掉A和B的會話 ,那麼如果是共用一張圖的話 ,A和C的會話中就再也無法找到這張圖進行展示了。

重新發送

這個沒有什麼好說的。

標記已讀

功能實現比較簡單 ,僅僅需要修改數據源和數據庫的該條會話的未讀數(unreadCount),刷新UI即可。

以下爲發送消息具體大致的實現步驟

文本/表情消息 :

方式一: 輸入 ->發送 -> 消息加入聊天數據源 -> 更新數據庫 -> 展示到聊天會話中 -> 調用TCP發送到服務器(若超時,更新聊天數據源,更新數據庫 ,刷新聊天UI) ->收到服務器成功回執(normalReceipt) ->修改數據源該條消息發送狀態(isSend) -> 更新數據庫

方式二: 輸入 ->發送 -> 消息加入聊天數據源 -> 展示到聊天會話中 -> 調用TCP發送到服務器(若超時,更新聊天數據源,刷新聊天UI) ->收到服務器成功回執(normalReceipt) ->修改數據源該條消息發送狀態(isSend) ->退出app或者頁面時 ,更新數據庫

語音消息 :(這裏以http上傳,TCP原理一致)

方式一: 長按錄製 ->壓縮轉格式 -> 緩存到沙盒 -> 更新數據庫->展示到聊天會話中,展示轉圈發送中狀態 -> 調用http分段式上傳(若失敗,刷新UI展示) ->調用TCP發送該語音消息相關信息(若超時,刷新聊天UI) ->收到服務器成功回執 -> 修改數據源該條消息發送狀態(isSend) ->修改數據源該條消息發送狀態(isSend)-> 更新數據庫-> 刷新聊天會話中該條消息UI

方式二: 長按錄製 ->壓縮轉格式 -> 緩存到沙盒 ->展示到聊天會話中,展示轉圈發送中狀態 -> 調用http分段式上傳(若失敗,更新聊天數據源,刷新UI展示) ->調用TCP發送該語音消息相關信息(若超時,更新聊天數據源,刷新聊天UI) ->收到服務器成功回執 -> 修改數據源該條消息發送狀態(isSend -> 刷新聊天會話中該條消息UI - >退出程序或者頁面時進行數據庫更新

圖片消息 :(兩種考慮,一是展示和http上傳均爲同一張圖 ,二是展示使用壓縮更小的圖,http上傳使用選擇的真實圖片,想要做到精緻,方法二更爲可靠)

方式一: 打開相冊選擇圖片 ->獲取圖片相關信息,大小,名稱等,根據用戶是否選擇原圖,考慮是否壓縮 ->緩存到沙盒 -> 更新數據庫 ->展示到聊天會話中,根據上傳顯示進度 ->http分段式上傳(若失敗,更新聊天數據,更新數據庫,刷新聊天UI) ->調用TCP發送該圖片消息相關信息(若超時,更新聊天數據源,更新數據庫,刷新聊天UI)->收到服務器成功回執 -> 修改數據源該條消息發送狀態(isSend) ->更新數據庫 -> 刷新聊天會話中該條消息UI

方式二:打開相冊選擇圖片 ->獲取圖片相關信息,大小,名稱等,根據用戶是否選擇原圖,考慮是否壓縮 ->緩存到沙盒 ->展示到聊天會話中,根據上傳顯示進度 ->http分段式上傳(若失敗,更細聊天數據源 ,刷新聊天UI) ->調用TCP發送該圖片消息相關信息(若超時,更新聊天數據源 ,刷新聊天UI)->收到服務器成功回執 -> 修改數據源該條消息發送狀態(isSend) -> 刷新聊天會話中該條消息UI ->退出程序或者離開頁面更新數據庫

視頻消息:需要注意的是 ,不要太過於頻繁的去刷新進度 , 最好控制在2秒刷新一次即可

方式一:打開相冊或者開啓相機錄製 -> 壓縮轉格式 ->獲取視頻相關信息,第一幀圖片,時長,名稱,大小等信息 ->緩存到沙盒 ->更新數據庫 ->第一幀圖展示到聊天會話中,根據上傳顯示進度 ->http分段式上傳(若失敗,更新聊天數據,更新數據庫,刷新聊天UI) ->調用TCP發送該視頻消息相關信息(若超時,更新聊天數據源,更新數據庫,刷新聊天UI)->收到服務器成功回執 -> 修改數據源該條消息發送狀態(isSend) ->更新數據庫 -> 刷新聊天會話中該條消息UI

方式二:打開相冊或者開啓相機錄製 ->壓縮轉格式 ->獲取視頻相關信息,第一幀圖片,時長,名稱,大小等信息 ->緩存到沙盒 ->第一幀圖展示到聊天會話中,根據上傳顯示進度 ->http分段式上傳(若失敗,更細聊天數據源 ,刷新聊天UI) ->調用TCP發送該視頻消息相關信息(若超時,更新聊天數據源 ,刷新聊天UI)->收到服務器成功回執 -> 修改數據源該條消息發送狀態(isSend) -> 刷新聊天會話中該條消息UI ->退出程序或者離開頁面更新數據庫

文件消息: 
跟上述一致 ,需要注意的是,如果要實現該功能 ,接收到的文件需要在沙盒中單獨開闢緩存。比如接收到web端或者安卓端的文件

消息丟失問題

消息爲什麼會丟失 ?

最主要原因應該歸結於服務器對客戶端的網絡判斷不準確。儘管客戶端已經和服務端建立了心跳驗證 , 但是心跳始終是有間隔的,且TCP的連接中斷也是有延遲的。例如,在此時我向服務器發送了一次心跳,然後網絡失去了連接,或者網絡信號不好。服務器接收到了該心跳 ,服務器認爲客戶端是處於連接狀態的,向我推送了某個人向我發送的消息 ,然而此時我卻不能收到消息,所以出現了消息丟失的情況。

解決辦法 :客戶端向服務端發送消息,服務端會給客戶端返回一個回執,告知該條消息已經發送成功。所以,客戶端有必要在收到消息時,也向服務端發送一個回執,告知服務端成功收到了該條消息。而客戶端,默認收到的所有消息都是離線的,只有收到客戶端的接收消息的成功回執後,纔會移除掉該離線消息緩存,否則將會把該條消息以離線消息方式推送。離線消息後面會做解釋。此時的雙向回執,可以把消息丟失概率降到非常低。

消息亂序問題

消息爲什麼會亂序 ?

客戶端發送消息,該消息會默認賦值當前時間戳 ,收到安卓端或者web端發來的消息時,該時間戳是安卓和web端獲取,這樣就可能會出現時間戳的誤差情況。比如當前聊天展示順序並沒有什麼問題,因爲展示是收到一條展示一條。但是當退出頁面重新進入時,如果拉取數據庫是根據時間戳的降序拉取 ,那麼就很容易出現混亂。 
解決辦法 :表結構設置自增ID ,消息的順序展示以入庫順序爲準 ,拉取數據庫獲取消息記錄時,根據自增ID降序拉取 。這樣就解決了亂序問題 ,至少保證了,展示的消息順序和我聊天時的一樣。儘管時間戳可能並不一樣是按照嚴謹的降序排列的。

離線消息

進入後臺,接收消息提醒:

解決方式要麼採用極光推送進行解決 ,要麼讓自己服務器接蘋果的服務器也行。畢竟極光只是作爲一箇中間者,最終都是通過蘋果服務器推送到每個手機。

進入程序加載離線消息:此處需要注意的是,若服務器僅僅是把每條消息逐個推送過來,那麼客戶端會出現一些小問題,比如角標數爲每次增加1,最後一條消息不斷更新 ,直到離線消息接收到完畢,造成一種不好的體驗。

解決辦法:離線消息服務端全部進行拼接或者以jsonArray方式,並協議分割方式,客戶端收到後僅需僅需切割,直接在角標上進行總數的相加,並直接更新最後一條消息即可。亦或者,設置包頭信息,告知每條消息長度,切割方式等。

版本兼容性問題處理

其實 , 做IM遇到最麻煩的問題之一 , 就應當是版本兼容問題 . 即時通訊的功能點有很多 , 項目不可能一期所有的功能全部做完 , 那麼就會涉及到新老版本兼容的問題 . 當然如果服務端經驗足夠豐富 , 版本兼容的問題可以交於服務端來完成 , 客戶端並不需要做太多額外的事情 . 如果是並行開發 , 服務端思路不夠長遠 ,或者產品需求變更頻繁且比較大.那麼客戶端也需要做一些相應的版本兼容問題 . 處理版本兼容問題並不難 , 主要問題在於當增加一個新功能時 , 服務端或許會推送過來更多的字段 , 而老版本的項目數據庫如果沒有預留足夠的字段 , 就涉及到了數據庫升級 . 而當收到高版本新功能的消息時 , 客戶端也應當對該消息做相應的處理 . 例如,老版本的app不支持消息撤回 , 而新版本支持消息撤回 , 當新版本發送消息撤回時 , 老版本可以攔截到這條未知的消息類型 , 做相應的處理 , 比如替換成一條提示”該版本暫不支持,請前往appstore下載新版本”等. 而當必要時 , 如果整個IM結構沒有經過深思熟慮 , 還可能會涉及到強制升級。

以上僅爲大體的思路 , 實際上搭建IM , 更多的難點在於邏輯的處理和各種細節問題 . 比如數據庫,本地緩存,和服務端的通信協議,和安卓端私下通信協議.以及聊天UI的細節處理,例如聊天背景實時拉高,圖文混排等等一系列麻煩的事.沒辦法寫到很詳細 ,都需要自己仔細的去思考.難度並不算很大,只是比較費心。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章