天天看點

高仿微信聊天界面:基于CocoaAsyncSocket的即時通訊實作(IM 微信)

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

之前做的項目有IM部分,在考慮了環信和融雲等已經比較通用的IMSDK,發現它們自定義程度不是很符合我們,想要自由限制,就需要自己定義一份網絡協定,在CSDN,CocoaChina等網站收集整理資訊後,決定使用CocoaAsyncSocket搭建IM。話不多說,先看效果(包括發送文字,表情,語音,圖檔,視訊,還可以拍照,錄制視訊,撤回,删除消息等)。

高仿微信聊天界面:基于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.
  • 下載下傳後可以看到檔案所在位置.
高仿微信聊天界面:基于CocoaAsyncSocket的即時通訊實作(IM 微信)

檔案路徑

  • 這裡隻要拷貝以下兩個檔案到項目中.
高仿微信聊天界面:基于CocoaAsyncSocket的即時通訊實作(IM 微信)

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),實作效果如圖:

高仿微信聊天界面:基于CocoaAsyncSocket的即時通訊實作(IM 微信)

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

高仿微信聊天界面:基于CocoaAsyncSocket的即時通訊實作(IM 微信)

錄音的主要代碼:

- (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架構實作存取,實作效果如下:

高仿微信聊天界面:基于CocoaAsyncSocket的即時通訊實作(IM 微信)

五.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/AlanZhangQ/Alan_cocoaSocket.git ,圖文混排位址 , 如果覺得有用 , 請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的細節處理,例如聊天背景實時拉高,圖文混排等等一系列麻煩的事.沒辦法寫到很詳細 ,都需要自己仔細的去思考.難度并不算很大,隻是比較費心。

繼續閱讀