iOS基於Socket.io即時通訊IM實現,WebRTC實現視訊通話

weixin_34146805發表於2017-09-14

Socket.io-FLSocketIM-iOS

基於Socket.io iOS即時通訊客戶端 iOS IM Client based on Socket.io
iOS 程式碼地址:https://github.com/fengli12321/Socket.io-FLSocketIM-iOS
伺服器端程式碼實現參照:https://github.com/fengli12321/Socket.io-FLSocketIM-Server
安卓端程式碼實現參照:https://github.com/fengli12321/Socket.io-FLSocketIM-Android
安卓簡書介紹:https://www.jianshu.com/p/cdb3b0301712

實現功能

  1. 文字傳送
  2. 圖片傳送(從相簿選取,或者拍攝)
  3. 短視訊
  4. 語音傳送
  5. 視訊通話
  6. 其他一些效果(類似QQ底部tabBar,短視訊拍攝等)
  7. 功能擴充套件中。。。。。

先看看實際效果

1965225-047438e4413dac7f.gif
文字.gif
1965225-f6eb491b4431aca1.gif
圖片.gif
1965225-3f4285fe9857f8e6.gif
定位.gif
1965225-e188d4f13f8a64a2.gif
語音.gif
1965225-3ae770e6ee393c8e.PNG
IMG_1227.PNG

使用技術

一、Socket.io

github地址

Socket.io是該專案實現即時通訊關鍵所在,非常強大;
Socket.io將Websocket和輪詢 (Polling)機制以及其它的實時通訊方式封裝成了通用的介面,並且在服務端實現了這些實時機制的相應程式碼。

先上程式碼

1.建立Socket連線,通過單例管理類FLSocketManager實現
- (void)connectWithToken:(NSString *)token success:(void (^)())success fail:(void (^)())fail {
    
    
    NSURL* url = [[NSURL alloc] initWithString:BaseUrl];
    
    /**
     log 是否列印日誌
     forceNew      這個引數設為NO從後臺恢復到前臺時總是重連,暫不清楚原因
     forcePolling  是否強制使用輪詢
     reconnectAttempts 重連次數,-1表示一直重連
     reconnectWait 重連間隔時間
     connectParams 引數
     forceWebsockets 是否強制使用websocket, 解釋The reason it uses polling first is because some firewalls/proxies block websockets. So polling lets socket.io work behind those.
     來源:https://github.com/socketio/socket.io-client-swift/issues/449
     */
    SocketIOClient* socket;
    if (!self.client) {
        socket = [[SocketIOClient alloc] initWithSocketURL:url config:@{@"log": @NO, @"forceNew" : @YES, @"forcePolling": @NO, @"reconnectAttempts":@(-1), @"reconnectWait" : @4, @"connectParams": @{@"auth_token" : token}, @"forceWebsockets" : @NO}];
    }
    else {
        socket = self.client;
        socket.engine.connectParams = @{@"auth_token" : token};
    }
    

    // 連線超時時間設定為15秒
    [socket connectWithTimeoutAfter:15 withHandler:^{
        
        fail();
    }];
    
    // 監聽一次連線成功
    [socket once:@"connect" callback:^(NSArray * _Nonnull data, SocketAckEmitter * _Nonnull ack) {
        
        success();
    }];
    
    _client = socket;
}

這個方法是在使用者登入後呼叫,主要作用是初始化Socket連線,關於socket初始化相關引數請參照socket.io文件。

2.監聽伺服器向客戶端傳送的訊息,通過單例管理類FLClientManager進行管理,然後讓代理實現功能
// 收到訊息
    [socket on:@"chat" callback:^(NSArray * _Nonnull data, SocketAckEmitter * _Nonnull ack) {
        

        if (ack.expected == YES) {
            
            [ack with:@[@"hello 我是應答"]];
        }

        
        FLMessageModel *message = [FLMessageModel yy_modelWithJSON:data.firstObject];
        
        NSData *fileData = message.bodies.fileData;
        if (fileData && fileData != NULL && fileData.length) {
            
            NSString *fileName = message.bodies.fileName;
            NSString *savePath = nil;
            switch (message.type) {
                case FLMessageImage:
                    savePath = [[NSString getFielSavePath] stringByAppendingPathComponent:[NSString stringWithFormat:@"s_%@", fileName]];
                    break;
                case FlMessageAudio:
                    savePath = [[NSString getAudioSavePath] stringByAppendingPathComponent:fileName];
                    break;
                default:
                    savePath = [[NSString getFielSavePath] stringByAppendingPathComponent:fileName];
                    break;
            }
            
            
            message.bodies.fileData = nil;
            [fileData saveToLocalPath:savePath];
        }
        
        
        id bodyStr = data.firstObject[@"bodies"];
        if ([bodyStr isKindOfClass:[NSString class]]) {
            FLMessageBody *body = [FLMessageBody yy_modelWithJSON:[bodyStr stringToJsonDictionary]];
            message.bodies = body;
        }
        
        // 訊息插入資料庫
        [[FLChatDBManager shareManager] addMessage:message];
        
        // 會話插入資料庫或者更新會話
        BOOL isChatting = [message.from isEqualToString:[FLClientManager shareManager].chattingConversation.toUser];
        [[FLChatDBManager shareManager] addOrUpdateConversationWithMessage:message isChatting:isChatting];
        
        
        // 本地推送,收到訊息新增紅點,聲音及震動提示
        [FLLocalNotification pushLocalNotificationWithMessage:message];
        
        
        
        // 代理處理
        for (FLBridgeDelegateModel  *model in self.delegateArray) {
            
            id<FLClientManagerDelegate>delegate = model.delegate;
            if (delegate && [delegate respondsToSelector:@selector(clientManager:didReceivedMessage:)]) {
                
                if (message) {
                    [delegate clientManager:self didReceivedMessage:message];
                }
                
            }
        }
    }];
    
    // 視訊通話請求
    [socket on:@"videoChat" callback:^(NSArray * _Nonnull data, SocketAckEmitter * _Nonnull ack) {
        
        UIViewController *vc = [self getCurrentVC];
        NSDictionary *dataDict = data.firstObject;
        FLVideoChatViewController *videoVC = [[FLVideoChatViewController alloc] initWithFromUser:dataDict[@"from_user"] toUser:[FLClientManager shareManager].currentUserID type:FLVideoChatCallee];
        videoVC.room = dataDict[@"room"];
        [vc presentViewController:videoVC animated:YES completion:nil];
        FLLog(@"%@============", data);
    }];
    
    // 使用者上線
    [socket on:@"onLine" callback:^(NSArray * _Nonnull data, SocketAckEmitter * _Nonnull ack) {
        
        for (FLBridgeDelegateModel  *model in self.delegateArray) {
            
            id<FLClientManagerDelegate>delegate = model.delegate;
            if (delegate && [delegate respondsToSelector:@selector(clientManager:userOnline:)]) {
                
                [delegate clientManager:self userOnline:[data.firstObject valueForKey:@"user"]];
            }
        }
    }];
    
    // 使用者下線
    [socket on:@"offLine" callback:^(NSArray * _Nonnull data, SocketAckEmitter * _Nonnull ack) {
        
        for (FLBridgeDelegateModel  *model in self.delegateArray) {
            
            id<FLClientManagerDelegate>delegate = model.delegate;
            if (delegate && [delegate respondsToSelector:@selector(clientManager:userOffline:)]) {
                
                [delegate clientManager:self userOffline:[data.firstObject valueForKey:@"user"]];
            }
        }
    }];
    

    
    // 連線狀態改變
    [socket on:@"statusChange" callback:^(NSArray * _Nonnull data, SocketAckEmitter * _Nonnull ack) {
        
        FLLog(@"%ld========================狀態改變", socket.status);
        for (FLBridgeDelegateModel  *model in self.delegateArray) {
            
            id<FLClientManagerDelegate>delegate = model.delegate;
            if (delegate && [delegate respondsToSelector:@selector(clientManager:didChangeStatus:)]) {
                
                [delegate clientManager:self didChangeStatus:socket.status];
            }
        }
    }];

- (NSUUID * _Nonnull)on:(NSString * _Nonnull)event callback:(void (^ _Nonnull)(NSArray * _Nonnull, SocketAckEmitter * _Nonnull))callback;
socket.io 提供的事件監聽方法,這裡監聽的事件包括:

  • “chat” 接收到好友訊息
  • “videoChat” 視訊通話請求
  • “onLine” 有好友上線
  • “offLine” 有好友離線
  • “statusChange” socket.io內部提供的,連線狀態改變

這部分程式碼,有個比較關鍵的需要說明一下,舉個例子,在接收到“chat”事件後,資料庫管理類需要將訊息存放到資料庫,會話列表需要更新UI,聊天列表需要顯示該訊息...也就是該事件需要多個物件響應。對於這種需求最先想到的就是使用通知的功能,畢竟可以實現一對多的訊息傳遞嘛!後來又思考,通過代理模式能否實現呢,通過制定協議程式碼質量更高?於是乎將代理存放在一個陣列中,接收到事件後遍歷陣列中的代理去響應事件。 然而出現了一個問題,我們在一般使用代理模式中,代理都是一個weak修飾屬性,代理釋放該屬性自動置nil,然而將代理放到陣列中,代理被強引用,引用計數加1,陣列不釋放,代理永遠無法釋放。這該怎麼解決呢,後來仿照一般的代理模式,建立一個橋接物件,代理陣列裡面存放橋接物件,然後橋接物件有一個weak修飾的屬性指向真正的代理。橋接物件FLBridgeDelegateModel如下:

#import <Foundation/Foundation.h>


@interface FLBridgeDelegateModel : NSObject

@property (nonatomic, weak) id delegate;

- (instancetype)initWithDelegate:(id)delegate;

@end

新增代理:

- (void)addDelegate:(id<FLClientManagerDelegate>)delegate {
    BOOL isExist = NO;
    for (FLBridgeDelegateModel *model in self.delegateArray) {
        
        if ([delegate isEqual:model.delegate]) {
            isExist = YES;
            break;
        }
    }
    if (!isExist) {
        FLBridgeDelegateModel *model = [[FLBridgeDelegateModel alloc] initWithDelegate:delegate];
        [self.delegateArray addObject:model];
    }
}

移除代理:

- (void)removeDelegate:(id<FLClientManagerDelegate>)delegate {
    
    NSArray *copyArray = [self.delegateArray copy];
    for (FLBridgeDelegateModel *model in copyArray) {
        if ([model.delegate isEqual:delegate]) {
            [self.delegateArray removeObject:model];
        }
        else if (!model.delegate) {
            [self.delegateArray removeObject:model];
        }
    }
}

通過橋接物件的方式,完美解決代理無法釋放的問題

3.訊息的傳送,通過管理類FLChatManager實現

方法:
- (OnAckCallback * _Nonnull)emitWithAck:(NSString * _Nonnull)event with:(NSArray * _Nonnull)items SWIFT_WARN_UNUSED_RESULT;

[[[FLSocketManager shareManager].client emitWithAck:@"chat" with:@[parameters]] timingOutAfter:20 callback:^(NSArray * _Nonnull data) {
        
        FLLog(@"%@", data.firstObject);
        
        if ([data.firstObject isKindOfClass:[NSString class]] && [data.firstObject isEqualToString:@"NO ACK"]) {  // 伺服器沒有應答
            
            
            message.sendStatus = FLMessageSendFail;
            // 傳送失敗
            statusChange();
            
        }
        else {  // 伺服器應答
            
            message.sendStatus = FLMessageSendSuccess;
            NSDictionary *ackDic = data.firstObject;
            message.timestamp = [ackDic[@"timestamp"] longLongValue];
            message.msg_id = ackDic[@"msg_id"];
            if (fileData) {
                NSDictionary *bodies = ackDic[@"bodies"];
                message.bodies.fileRemotePath = bodies[@"fileRemotePath"];
                message.bodies.thumbnailRemotePath = bodies[@"thumbnailRemotePath"];
            }
            if (message.type == FLMessageLoc) {
                NSDictionary *bodiesDic = ackDic[@"bodies"];
                message.bodies.fileRemotePath = bodiesDic[@"fileRemotePath"];
            }
            
            // 傳送成功
            statusChange();
            
        }
        // 更新訊息
        [[FLChatDBManager shareManager] updateMessage:message];
        
        // 資料庫新增或者重新整理會話
        [[FLChatDBManager shareManager] addOrUpdateConversationWithMessage:message isChatting:YES];
    }];

二、FMDB

主要實現離線訊息儲存,FLChatDBManager管理類中實現

三、WebRTC

WebRTC,名稱源自網頁實時通訊(Web Real-Time Communication)的縮寫,簡而言之它是一個支援網頁瀏覽器進行實時語音對話或視訊對話的技術。
它為我們提供了視訊會議的核心技術,包括音視訊的採集、編解碼、網路傳輸、顯示等功能,並且還支援跨平臺:windows,linux,mac,android,iOS。
它在2011年5月開放了工程的原始碼,在行業內得到了廣泛的支援和應用,成為下一代視訊通話的標準。

首先感謝下面大神的無私分享
作者:塗耀輝
連結:http://www.jianshu.com/p/c49da1d93df4
來源:簡書

本專案視訊通話的核心部分都是源自於此,自己將WebRTC與Socket.io予以整合,新增了部分功能

下圖為視訊通話實現的流程圖,具體邏輯請參照專案原始碼,FLVideoChatHelper工具類中實現

1965225-2515d42b4176dbcc.png
視訊通話流程圖.png

關於伺服器部分程式碼

該專案伺服器部分是通過node.js搭建,node.js真的是一門非常強大的語言,而且簡單易學,如果你有一點點js基礎相信看懂伺服器程式碼也沒有太大問題!本人週末在家看了一天node.js就上手寫伺服器端程式碼,所以有時間真滴可以認真學習一下,以後寫專案再也不用擔心沒有網路資料了,哈哈

專案安裝

1.iOS
  • pod install安裝第三方
  • 首先我們需要去百度網盤下載 WebRTC標頭檔案和靜態庫.a。下載完成,解壓縮,拖入專案中;
  • 切換連線的地址為伺服器的IP地址(RequestUrlConst.h中的baseUrl)
  • 想要測試視訊通話功能需要兩臺真機,且同時線上,處於同一區域網內
2.伺服器部分
  • 首先需要node.js環境
  • 電腦安裝MongoDB
  • npm install 安裝第三方
  • brew install imagemagick
    brew install graphicsmagick(伺服器處理圖片用到)

待實現功能

  1. 群聊天 後臺已實現,iOS客戶端待實現
  2. 短視訊傳送與播放
  3. 訊息氣泡優化
  4. 使用者頭像管理
  5. 離線訊息拉取
  6. iOS遠端推送
  7. 未讀訊息紅點管理




    第一次釋出文章,還有許多不足。如果您在文章專案中發現錯誤,請指正!同時歡迎點贊評論,有更多想法希望多溝通交流,一起提升。。。


    聯絡方式:
    qq:954751186

相關文章