iOS下音視頻通信-基于WebRTC

前言:

WebRTC,名稱源自網(wǎng)頁實時通信(Web Real-Time Communication)的縮寫奔坟,簡而言之它是一個支持網(wǎng)頁瀏覽器進行實時語音對話或視頻對話的技術(shù)壳坪。
它為我們提供了視頻會議的核心技術(shù),包括音視頻的采集尝抖、編解碼毡们、網(wǎng)絡(luò)傳輸、顯示等功能昧辽,并且還支持跨平臺:windows漏隐,linux,mac奴迅,android青责,iOS。
它在2011年5月開放了工程的源代碼取具,在行業(yè)內(nèi)得到了廣泛的支持和應(yīng)用脖隶,成為下一代視頻通話的標準。

本文將站在巨人的肩膀上暇检,基于WebRTC去實現(xiàn)不同客戶端之間的音視頻通話产阱。這個不同的客戶端,不局限于移動端和移動端块仆,還包括移動端和Web瀏覽器之間构蹬。

目錄:

  • 一.WebRTC的實現(xiàn)原理。
  • 二.iOS下WebRTC環(huán)境的搭建悔据。
  • 三.介紹下WebRTC的API庄敛,以及實現(xiàn)點對點連接的流程。
  • 四.iOS客戶端的詳細實現(xiàn)科汗,以及服務(wù)端信令通道的搭建藻烤。

正文:

一.WebRTC的實現(xiàn)原理。

WebRTC的音視頻通信是基于P2P头滔,那么什么是P2P呢怖亭?
它是點對點連接的英文縮寫。

1.我們從P2P連接模式來講起:

一般我們傳統(tǒng)的連接方式坤检,都是以服務(wù)器為中介的模式:

  • 類似http協(xié)議:客戶端?服務(wù)端(當然這里服務(wù)端返回的箭頭僅僅代表返回請求數(shù)據(jù))兴猩。
  • 我們在進行即時通訊時,進行文字早歇、圖片倾芝、錄音等傳輸?shù)臅r候:客戶端A?服務(wù)器?客戶端B。

而點對點的連接恰恰數(shù)據(jù)通道一旦形成缺前,中間是不經(jīng)過服務(wù)端的蛀醉,數(shù)據(jù)直接從一個客戶端流向另一個客戶端:

客戶端A?客戶端B ... 客戶端A?客戶端C ...(可以無數(shù)個客戶端之間互聯(lián))

這里可以想想音視頻通話的應(yīng)用場景,我們服務(wù)端確實是沒必要去獲取兩者通信的數(shù)據(jù)衅码,而且這樣做有一個最大的一個優(yōu)點就是拯刁,大大的減輕了服務(wù)端的壓力。

WebRTC就是這樣一個基于P2P的音視頻通信技術(shù)逝段。

2.WebRTC的服務(wù)器與信令垛玻。

講到這里割捅,可能大家覺得WebRTC就不需要服務(wù)端了么?這是顯然是錯誤的認識帚桩,嚴格來說它僅僅是不需要服務(wù)端來進行數(shù)據(jù)中轉(zhuǎn)而已亿驾。

WebRTC提供了瀏覽器到瀏覽器(點對點)之間的通信,但并不意味著WebRTC不需要服務(wù)器账嚎。暫且不說基于服務(wù)器的一些擴展業(yè)務(wù)莫瞬,WebRTC至少有兩件事必須要用到服務(wù)器:

  1. 瀏覽器之間交換建立通信的元數(shù)據(jù)(信令)必須通過服務(wù)器。
  2. 為了穿越NAT和防火墻郭蕉。

第1條很好理解疼邀,我們在A和B需要建立P2P連接的時候,至少要服務(wù)器來協(xié)調(diào)召锈,來控制連接開始建立旁振。而連接斷開的時候,也需要服務(wù)器來告知另一端P2P連接已斷開涨岁。這些我們用來控制連接的狀態(tài)的數(shù)據(jù)稱之為信令拐袜,而這個與服務(wù)端連接的通道,對于WebRTC而言就是信令通道梢薪。

圖中signalling就是往服務(wù)端發(fā)送信令蹬铺,然后底層調(diào)用WebRTCWebRTC通過服務(wù)端得到的信令沮尿,得知通信對方的基本信息丛塌,從而實現(xiàn)虛線部分Media通信連接。

當然信令能做的事還有很多畜疾,這里大概列了一下:
  1. 用來控制通信開啟或者關(guān)閉的連接控制消息
  2. 發(fā)生錯誤時用來彼此告知的消息
  3. 媒體流元數(shù)據(jù),比如像解碼器印衔、解碼器的配置啡捶、帶寬、媒體類型等等
  4. 用來建立安全連接的關(guān)鍵數(shù)據(jù)
  5. 外界所看到的的網(wǎng)絡(luò)上的數(shù)據(jù)奸焙,比如IP地址瞎暑、端口等

在建立連接之前,客戶端之間顯然沒有辦法傳遞數(shù)據(jù)与帆。所以我們需要通過服務(wù)器的中轉(zhuǎn)了赌,在客戶端之間傳遞這些數(shù)據(jù),然后建立客戶端之間的點對點連接玄糟。但是WebRTC API中并沒有實現(xiàn)這些勿她,這些就需要我們來實現(xiàn)了。

而第2條中的NAT這個概念阵翎,我們之前在iOS即時通訊逢并,從入門到“放棄”之剧?
,中也提到過砍聊,不過那個時候我們是為了應(yīng)對NAT超時背稼,所造成的TCP連接中斷。在這里我們就不展開去講了玻蝌,感興趣的可以看看:NAT百科

這里我簡要說明一下蟹肘,NAT技術(shù)的出現(xiàn),其實就是為了解決IPV4下的IP地址匱乏俯树。舉例來說帘腹,就是通常我們處在一個路由器之下,而路由器分配給我們的地址通常為192.168.0.1 聘萨、192.168.0.2如果有n個設(shè)備竹椒,可能分配到192.168.0.n,而這個IP地址顯然只是一個內(nèi)網(wǎng)的IP地址米辐,這樣一個路由器的公網(wǎng)地址對應(yīng)了n個內(nèi)網(wǎng)的地址胸完,通過這種使用少量的公有IP 地址代表較多的私有IP 地址的方式,將有助于減緩可用的IP地址空間的枯竭翘贮。

但是這也帶來了一系列的問題赊窥,例如這里點對點連接下,會導(dǎo)致這樣一個問題:
如果客戶端A想給客戶端B發(fā)送數(shù)據(jù)狸页,則數(shù)據(jù)來到客戶端B所在的路由器下锨能,會被NAT阻攔,這樣B就無法收到A的數(shù)據(jù)了芍耘。
但是A的NAT此時已經(jīng)知道了B這個地址址遇,所以當B給A發(fā)送數(shù)據(jù)的時候,NAT不會阻攔斋竞,這樣A就可以收到B的數(shù)據(jù)了倔约。這就是我們進行NAT穿越的核心思路。

于是我們就有了以下思路:
我們借助一個公網(wǎng)IP服務(wù)器,a,b都往公網(wǎng)IP/PORT發(fā)包,公網(wǎng)服務(wù)器就可以獲知a,b的IP/PORT坝初,又由于a,b主動給公網(wǎng)IP服務(wù)器發(fā)包浸剩,所以公網(wǎng)服務(wù)器可以穿透NAT A,NAT B送包給a,b。
所以只要公網(wǎng)IP將b的IP/PORT發(fā)給a,a的IP/PORT發(fā)給b鳄袍。這樣下次a和b互相消息绢要,就不會被NAT阻攔了。

而WebRTC的NAT/防火墻穿越技術(shù)拗小,就是基于上述的一個思路來實現(xiàn)的:

建立點對點信道的一個常見問題重罪,就是NAT穿越技術(shù)。在處于使用了NAT設(shè)備的私有TCP/IP網(wǎng)絡(luò)中的主機之間需要建立連接時需要使用NAT穿越技術(shù)。以往在VoIP領(lǐng)域經(jīng)常會遇到這個問題蛆封。目前已經(jīng)有很多NAT穿越技術(shù)唇礁,但沒有一項是完美的,因為NAT的行為是非標準化的惨篱。這些技術(shù)中大多使用了一個公共服務(wù)器盏筐,這個服務(wù)使用了一個從全球任何地方都能訪問得到的IP地址。在RTCPeeConnection中砸讳,使用ICE框架來保證RTCPeerConnection能實現(xiàn)NAT穿越

這里提到了ICE協(xié)議框架琢融,它大約是由以下幾個技術(shù)和協(xié)議組成的:STUN、NAT簿寂、TURN漾抬、SDP,這些協(xié)議技術(shù)常遂,幫助ICE共同實現(xiàn)了NAT/防火墻穿越纳令。

小伙伴們可能又一臉懵逼了,一下子又出來這么多名詞克胳,沒關(guān)系平绩,這里我們暫且不去管它們,等我們后面實現(xiàn)的時候漠另,還會提到他們捏雌,這里提前感興趣的可以看看這篇文章:WebRTC protocols

二.iOS下WebRTC環(huán)境的搭建:

首先,我們需要明白的一點是:WebRTC已經(jīng)在我們的瀏覽器中了笆搓。如果我們用瀏覽器性湿,則可以直接使用js調(diào)用對應(yīng)的WebRTC的API,實現(xiàn)音視頻通信满败。
然而我們是在iOS平臺肤频,所以我們需要去官網(wǎng)下載指定版本的源碼,并且對其進行編譯算墨,大概一下着裹,其中源碼大小10個多G,編譯過程會遇到一系列坑米同,而我們編譯完成最終形成的webrtc.a庫大概有300多m。
這里我們不寫編譯過程了摔竿,感興趣的可以看看這篇文章:
WebRTC(iOS)下載編譯

最終我們編譯成功的文件如下WebRTC


其中包括一個.a文件面粮,和include文件夾下的一些頭文件。(大家測試的時候可以直接使用這里編譯好的文件继低,但是如果以后需要WebRTC最新版熬苍,就只能自己動手去編譯了)

接著我們把整個WebRTC文件夾添加到工程中,并且添加以下系統(tǒng)依賴庫:

依賴庫

至此,一個iOS下的WebRTC環(huán)境就搭建完畢了

三.介紹下WebRTC的API柴底,以及實現(xiàn)點對點連接的流程婿脸。
1.WebRTC主要實現(xiàn)了三個API,分別是:
  • MediaStream:通過MediaStream的API能夠通過設(shè)備的攝像頭及話筒獲得視頻柄驻、音頻的同步流
  • RTCPeerConnectionRTCPeerConnection是WebRTC用于構(gòu)建點對點之間穩(wěn)定狐树、高效的流傳輸?shù)慕M件
  • RTCDataChannelRTCDataChannel使得瀏覽器之間(點對點)建立一個高吞吐量、低延時的信道鸿脓,用于傳輸任意數(shù)據(jù)抑钟。

其中RTCPeerConnection是我們WebRTC的核心組件。

2.WebRTC建立點對點連接的流程:

我們在使用WebRTC來實現(xiàn)音視頻通信前野哭,我們必須去了解它的連接流程在塔,否則面對它的API將無從下手。

我們之前講到過WebRTC用ICE協(xié)議來保證NAT穿越拨黔,所以它有這么一個流程:我們需要從STUN Server中得到一個ice candidate蛔溃,這個東西實際上就是公網(wǎng)地址,這樣我們就有了客戶端自己的公網(wǎng)地址篱蝇。而這個STUN Server所做的事就是之前所說的贺待,把保存起來的公網(wǎng)地址,互相發(fā)送數(shù)據(jù)包态兴,防止后續(xù)的NAT阻攔狠持。

而我們之前講過,還需要一個自己的服務(wù)端瞻润,來建立信令通道喘垂,控制A和B什么時候建立連接,建立連接的時候告知互相的ice candidate(公網(wǎng)地址)是什么绍撞、SDP是什么正勒。還包括什么時候斷開連接等等一系列信令。

對了傻铣,這里補充一下SDP這個概念章贞,它是會話描述協(xié)議Session Description Protocol (SDP) 是一個描述多媒體連接內(nèi)容的協(xié)議,例如分辨率非洲,格式鸭限,編碼,加密算法等两踏。所以在數(shù)據(jù)傳輸時兩端都能夠理解彼此的數(shù)據(jù)败京。本質(zhì)上,這些描述內(nèi)容的元數(shù)據(jù)并不是媒體流本身梦染。

講到這我們來捋一捋建立P2P連接的過程:
  1. A和B連接上服務(wù)端赡麦,建立一個TCP長連接(任意協(xié)議都可以朴皆,WebSocket/MQTT/Socket原生/XMPP),我們這里為了省事泛粹,直接采用WebSocket遂铡,這樣一個信令通道就有了。
  2. A從ice server(STUN Server)獲取ice candidate并發(fā)送給Socket服務(wù)端晶姊,并生成包含session description(SDP)的offer扒接,發(fā)送給Socket服務(wù)端。
  3. Socket服務(wù)端把A的offer和ice candidate轉(zhuǎn)發(fā)給B帽借,B會保存下A這些信息珠增。
  4. 然后B發(fā)送包含自己session descriptionanswer(因為它收到的是offer,所以返回的是answer砍艾,但是內(nèi)容都是SDP)和ice candidate給Socket服務(wù)端蒂教。
  5. Socket服務(wù)端把B的answerice candidate給A,A保存下B的這些信息脆荷。

至此A與B建立起了一個P2P連接凝垛。

這里理解整個P2P連接的流程是非常重要的,否則后面代碼實現(xiàn)部分便難以理解蜓谋。

四.iOS客戶端的詳細實現(xiàn)梦皮,以及服務(wù)端信令通道的搭建。
聊天室中的信令

上面是兩個用戶之間的信令交換流程桃焕,但我們需要建立一個多用戶在線視頻聊天的聊天室剑肯。所以需要進行一些擴展,來達到這個要求

用戶操作

首先需要確定一個用戶在聊天室中的操作大致流程:

  1. 打開頁面連接到服務(wù)器上
  2. 進入聊天室
  3. 與其他所有已在聊天室的用戶建立點對點的連接,并輸出在頁面上
  4. 若有聊天室內(nèi)的其他用戶離開,應(yīng)得到通知拴清,關(guān)閉與其的連接并移除其在頁面中的輸出
  5. 若又有其他用戶加入,應(yīng)得到通知溃睹,建立于新加入用戶的連接,并輸出在頁面上
  6. 離開頁面胰坟,關(guān)閉所有連接
從上面可以看出來因篇,除了點對點連接的建立,還需要服務(wù)器至少做如下幾件事:
  1. 新用戶加入房間時笔横,發(fā)送新用戶的信息給房間內(nèi)的其他用戶
  2. 新用戶加入房間時竞滓,發(fā)送房間內(nèi)的其他用戶信息給新加入房間的用戶
  3. 用戶離開房間時,發(fā)送離開用戶的信息給房間內(nèi)的其他用戶
實現(xiàn)思路

以使用WebSocket為例吹缔,上面用戶操作的流程可以進行以下修改:

  1. 客戶端與服務(wù)器建立WebSocket連接
  2. 發(fā)送一個加入聊天室的信令(join)虽界,信令中需要包含用戶所進入的聊天室名稱
  3. 服務(wù)器根據(jù)用戶所加入的房間,發(fā)送一個其他用戶信令(peers)涛菠,信令中包含聊天室中其他用戶的信息,客戶端根據(jù)信息來逐個構(gòu)建與其他用戶的點對點連接
  4. 若有用戶離開,服務(wù)器發(fā)送一個用戶離開信令(remove_peer)俗冻,信令中包含離開的用戶的信息礁叔,客戶端根據(jù)信息關(guān)閉與離開用戶的信息,并作相應(yīng)的清除操作
  5. 若有新用戶加入迄薄,服務(wù)器發(fā)送一個用戶加入信令(new_peer)琅关,信令中包含新加入的用戶的信息,客戶端根據(jù)信息來建立與這個新用戶的點對點連接
  6. 用戶離開頁面讥蔽,關(guān)閉WebSocket連接
這樣有了基本思路涣易,我們來實現(xiàn)一個基于WebRTC的視頻聊天室。

我們首先來實現(xiàn)客戶端實現(xiàn)冶伞,先看看WebRTCHelper.h

@protocol WebRTCHelperDelegate;

@interface WebRTCHelper : NSObject<SRWebSocketDelegate>

+ (instancetype)sharedInstance;

@property (nonatomic, weak)id<WebRTCHelperDelegate> delegate;

/**
 *  與服務(wù)器建立連接
 *
 *  @param server 服務(wù)器地址
 *  @param room   房間號
 */
- (void)connectServer:(NSString *)server port:(NSString *)port room:(NSString *)room;
/**
 *  退出房間
 */
- (void)exitRoom;
@end

@protocol WebRTCHelperDelegate <NSObject>

@optional
- (void)webRTCHelper:(WebRTCHelper *)webRTChelper setLocalStream:(RTCMediaStream *)stream userId:(NSString *)userId;
- (void)webRTCHelper:(WebRTCHelper *)webRTChelper addRemoteStream:(RTCMediaStream *)stream userId:(NSString *)userId;
- (void)webRTCHelper:(WebRTCHelper *)webRTChelper closeWithUserId:(NSString *)userId;

@end

這里我們對外的接口很簡單新症,就是一個生成單例的方法,一個代理响禽,還有一個與服務(wù)器連接的方法徒爹,這個方法需要傳3個參數(shù)過去,分別是server的地址芋类、端口號隆嗅、以及房間號。還有一個退出房間的方法侯繁。

說說代理部分吧胖喳,代理有3個可選的方法,分別為:

  1. 本地設(shè)置流的回調(diào)贮竟,可以用來顯示本地的視頻圖像丽焊。
  2. 遠程流到達的回調(diào),可以用來顯示對方的視頻圖像坝锰。
  3. WebRTC連接關(guān)閉的回調(diào)粹懒,注意這里關(guān)閉僅僅與當前userId的連接關(guān)閉,而如果你除此之外還與聊天室其他的人建立連接顷级,是不會有影響的凫乖。

接著我們先不去看如何實現(xiàn)的,先運行起來看看效果吧:
VideoChatViewController.m:

[WebRTCHelper sharedInstance].delegate = self;
[[WebRTCHelper sharedInstance]connectServer:@"192.168.0.7" port:@"3000" room:@"100"];

僅僅需要設(shè)置代理為自己弓颈,然后連接上socket服務(wù)器即可帽芽。

我們來看看我們對代理的處理:

- (void)webRTCHelper:(WebRTCHelper *)webRTChelper setLocalStream:(RTCMediaStream *)stream userId:(NSString *)userId
{
    RTCEAGLVideoView *localVideoView = [[RTCEAGLVideoView alloc] initWithFrame:CGRectMake(0, 0, KVedioWidth, KVedioHeight)];
    //標記本地的攝像頭
    localVideoView.tag = 100;
    _localVideoTrack = [stream.videoTracks lastObject];
    [_localVideoTrack addRenderer:localVideoView];
    
    [self.view addSubview:localVideoView];
    
    NSLog(@"setLocalStream");
}
- (void)webRTCHelper:(WebRTCHelper *)webRTChelper addRemoteStream:(RTCMediaStream *)stream userId:(NSString *)userId
{
    //緩存起來
    [_remoteVideoTracks setObject:[stream.videoTracks lastObject] forKey:userId];
    [self _refreshRemoteView];
    NSLog(@"addRemoteStream");
    
}
- (void)webRTCHelper:(WebRTCHelper *)webRTChelper closeWithUserId:(NSString *)userId
{
    //移除對方視頻追蹤
    [_remoteVideoTracks removeObjectForKey:userId];
    [self _refreshRemoteView];
    NSLog(@"closeWithUserId");
}

- (void)_refreshRemoteView
{
    for (RTCEAGLVideoView *videoView in self.view.subviews) {
        //本地的視頻View和關(guān)閉按鈕不做處理
        if (videoView.tag == 100 ||videoView.tag == 123) {
            continue;
        }
        //其他的移除
        [videoView removeFromSuperview];
    }
    __block int column = 1;
    __block int row = 0;
    //再去添加
    [_remoteVideoTracks enumerateKeysAndObjectsUsingBlock:^(id  _Nonnull key, RTCVideoTrack *remoteTrack, BOOL * _Nonnull stop) {
        
        RTCEAGLVideoView *remoteVideoView = [[RTCEAGLVideoView alloc] initWithFrame:CGRectMake(column * KVedioWidth, 0, KVedioWidth, KVedioHeight)];
        [remoteTrack addRenderer:remoteVideoView];
        [self.view addSubview:remoteVideoView];
        
        //列加1
        column++;
        //一行多余3個在起一行
        if (column > 3) {
            row++;
            column = 0;
        }
    }];
}

代碼很簡單,基本核心的是調(diào)用了WebRTC的API的那幾行:
這里我們得到本地流和遠程流的時候翔冀,就可以用這個流來設(shè)置視頻圖像了导街,而音頻是自動輸出的(遠程的音頻會輸出,自己本地的音頻則不會)纤子。

基本上顯示視頻圖像只需要下面3步:

  1. 創(chuàng)建一個RTCEAGLVideoView類型的實例搬瑰。
  2. 從代理回調(diào)中拿到RTCMediaStream類型的stream款票,從stream中拿到RTCVideoTrack實例:
_localVideoTrack = [stream.videoTracks lastObject];
  1. 用這個_localVideoTrackRTCEAGLVideoView實例設(shè)置渲染:
[_localVideoTrack addRenderer:localVideoView];

這樣一個視頻圖像就呈現(xiàn)在RTCEAGLVideoView實例上了,我們只需要把它添加到view上顯示即可泽论。

這里切記需要注意的是RTCVideoTrack實例我們必須持有它(這里我們本機設(shè)置為屬性了艾少,而遠程的添加到數(shù)組中,都是為了這么個目的)翼悴。否則有可能會導(dǎo)致視頻圖像無法顯示缚够。

就這樣,一個簡單的WebRTC客戶端就搭建完了鹦赎,接下來我們先忽略掉Socket服務(wù)端(先當作已實現(xiàn))谍椅,和WebRTCHelper的實現(xiàn),我們運行運行demo看看效果:

Paste_Image.png

這是我用手機截的圖古话,因為模擬器無法調(diào)用mac攝像頭雏吭,第一個是本地視頻圖像,而后面的則是遠端用戶傳過來的煞额,如果有n個遠程用戶思恐,則會一直往下排列。

等我們整個講完膊毁,大家可以運行下github上的demo胀莹,嘗試嘗試這個視頻聊天室。

接著我們來講講WebRTCHelper的實現(xiàn):

首先前面順著應(yīng)用這個類的順序來婚温,我們首先調(diào)用了單例描焰,設(shè)置了代理:

+ (instancetype)sharedInstance
{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        instance = [[[self class] alloc] init];
        [instance initData];

    });
    return instance;
}

- (void)initData
{
    _connectionDic = [NSMutableDictionary dictionary];
    _connectionIdArray = [NSMutableArray array];

}

很簡單,就是初始化了實例栅螟,并且初始化了兩個屬性荆秦,其中是_connectionDic用來裝RTCPeerConnection實例的。_connectionIdArray是用來裝已連接的用戶id的力图。

接著我們調(diào)用了connectServer:

//初始化socket并且連接
- (void)connectServer:(NSString *)server port:(NSString *)port room:(NSString *)room
{
    _server = server;
    _room = room;
    
    NSURLRequest *request = [NSURLRequest requestWithURL:[NSURL URLWithString:[NSString stringWithFormat:@"ws://%@:%@",server,port]] cachePolicy:NSURLRequestUseProtocolCachePolicy timeoutInterval:10];
    _socket = [[SRWebSocket alloc] initWithURLRequest:request];
    _socket.delegate = self;
    [_socket open];
}

這個方法連接到了我們的socket服務(wù)器步绸,這里我們使用的是webScoekt,使用的框架是谷歌的SocketRocket吃媒,至于它的用法我就不贅述了瓤介,不熟悉的可以看看樓主的iOS即時通訊,從入門到“放棄”赘那? 刑桑。

這里我們設(shè)置代理為自己,并且建立連接募舟,然后連接成功后祠斧,回調(diào)到成的代理:

- (void)webSocketDidOpen:(SRWebSocket *)webSocket
{
    NSLog(@"websocket建立成功");
    //加入房間
    [self joinRoom:_room];
}

成功的連接后,我們調(diào)用了加入房間的方法拱礁,加入我們一開始設(shè)置的房間號:

- (void)joinRoom:(NSString *)room
{
    //如果socket是打開狀態(tài)
    if (_socket.readyState == SR_OPEN)
    {
        //初始化加入房間的類型參數(shù) room房間號
        NSDictionary *dic = @{@"eventName": @"__join", @"data": @{@"room": room}};
        
        //得到j(luò)son的data
        NSData *data = [NSJSONSerialization dataWithJSONObject:dic options:NSJSONWritingPrettyPrinted error:nil];
        //發(fā)送加入房間的數(shù)據(jù)
        [_socket send:data];
    }
}

加入房間琢锋,我們僅僅是把這個一個json數(shù)據(jù)用socket發(fā)給服務(wù)端辕漂,類型為__join

接著就是服務(wù)端的邏輯了吩蔑,服務(wù)端拿到這個類型的數(shù)據(jù)钮热,會給我們發(fā)送這么一條消息:

{
    data =     {
        connections =         (
        );
        you = "e297f0c0-fda5-4e67-b4dc-3745943d91bd";
    };
    eventName = "_peers";
}

這條消息類型是_peers,意思為房間新用戶烛芬,并且把我們在這個房間的id返回給我們,拿到這條消息飒责,說明我們加入房間成功赘娄,我們就可以去做一系列的初始化了。而connections這個字段為空宏蛉,說明當前房間沒有人遣臼,如果已經(jīng)有人的話,會返回這么一串:

{
    data =     {
        connections =         (
            "85fc08a4-77cb-4f45-81f9-c0a0ef1b6949"
        );
        you = "4b73e126-e9c4-4307-bf8e-20a5a9b1f133";
    };
    eventName = "_peers";
}

其中connections里面裝的是已在房間用戶的id拾并。

接著就是我們整個類運轉(zhuǎn)的核心代理方法揍堰,就是收到socket消息后的處理:

#pragma mark--SRWebSocketDelegate
- (void)webSocket:(SRWebSocket *)webSocket didReceiveMessage:(id)message
{
    NSLog(@"收到服務(wù)器消息:%@",message);
    NSDictionary *dic = [NSJSONSerialization JSONObjectWithData:[message dataUsingEncoding:NSUTF8StringEncoding] options:NSJSONReadingMutableContainers error:nil];
    NSString *eventName = dic[@"eventName"];

    //1.發(fā)送加入房間后的反饋
    if ([eventName isEqualToString:@"_peers"])
    {
        //得到data
        NSDictionary *dataDic = dic[@"data"];
        //得到所有的連接
        NSArray *connections = dataDic[@"connections"];
        //加到連接數(shù)組中去
        [_connectionIdArray addObjectsFromArray:connections];
        
        //拿到給自己分配的ID
        _myId = dataDic[@"you"];
      
        //如果為空,則創(chuàng)建點對點工廠
        if (!_factory)
        {
            //設(shè)置SSL傳輸
            [RTCPeerConnectionFactory initializeSSL];
            _factory = [[RTCPeerConnectionFactory alloc] init];
        }
        //如果本地視頻流為空
        if (!_localStream)
        {
            //創(chuàng)建本地流
            [self createLocalStream];
        }
        //創(chuàng)建連接
        [self createPeerConnections];
        
        //添加
        [self addStreams];
        [self createOffers];
    }
    //接收到新加入的人發(fā)了ICE候選嗅义,(即經(jīng)過ICEServer而獲取到的地址)
    else if ([eventName isEqualToString:@"_ice_candidate"])
    {
        NSDictionary *dataDic = dic[@"data"];
        NSString *socketId = dataDic[@"socketId"];
        NSInteger sdpMLineIndex = [dataDic[@"label"] integerValue];
        NSString *sdp = dataDic[@"candidate"];
        //生成遠端網(wǎng)絡(luò)地址對象
        RTCICECandidate *candidate = [[RTCICECandidate alloc] initWithMid:nil index:sdpMLineIndex sdp:sdp];
        //拿到當前對應(yīng)的點對點連接
        RTCPeerConnection *peerConnection = [_connectionDic objectForKey:socketId];
        //添加到點對點連接中
        [peerConnection addICECandidate:candidate];
    }
    //其他新人加入房間的信息
    else if ([eventName isEqualToString:@"_new_peer"])
    {
        NSDictionary *dataDic = dic[@"data"];
        //拿到新人的ID
        NSString *socketId = dataDic[@"socketId"];
        //再去創(chuàng)建一個連接
        RTCPeerConnection *peerConnection = [self createPeerConnection:socketId];
        if (!_localStream)
        {
            [self createLocalStream];
        }
        //把本地流加到連接中去
        [peerConnection addStream:_localStream];
        //連接ID新加一個
        [_connectionIdArray addObject:socketId];
        //并且設(shè)置到Dic中去
        [_connectionDic setObject:peerConnection forKey:socketId];
    }
    //有人離開房間的事件
    else if ([eventName isEqualToString:@"_remove_peer"])
    {
        //得到socketId屏歹,關(guān)閉這個peerConnection
        NSDictionary *dataDic = dic[@"data"];
        NSString *socketId = dataDic[@"socketId"];
        [self closePeerConnection:socketId];
    }
    //這個新加入的人發(fā)了個offer
    else if ([eventName isEqualToString:@"_offer"])
    {
        NSDictionary *dataDic = dic[@"data"];
        NSDictionary *sdpDic = dataDic[@"sdp"];
        //拿到SDP
        NSString *sdp = sdpDic[@"sdp"];
        NSString *type = sdpDic[@"type"];
        NSString *socketId = dataDic[@"socketId"];
        
        //拿到這個點對點的連接
        RTCPeerConnection *peerConnection = [_connectionDic objectForKey:socketId];
        //根據(jù)類型和SDP 生成SDP描述對象
        RTCSessionDescription *remoteSdp = [[RTCSessionDescription alloc] initWithType:type sdp:sdp];
        //設(shè)置給這個點對點連接
        [peerConnection setRemoteDescriptionWithDelegate:self sessionDescription:remoteSdp];
        
        //把當前的ID保存下來
        _currentId = socketId;
        //設(shè)置當前角色狀態(tài)為被呼叫,(被發(fā)offer)
        _role = RoleCallee;
    }
    //收到別人的offer之碗,而回復(fù)answer
    else if ([eventName isEqualToString:@"_answer"])
    {
        NSDictionary *dataDic = dic[@"data"];
        NSDictionary *sdpDic = dataDic[@"sdp"];
        NSString *sdp = sdpDic[@"sdp"];
        NSString *type = sdpDic[@"type"];
        NSString *socketId = dataDic[@"socketId"];
        RTCPeerConnection *peerConnection = [_connectionDic objectForKey:socketId];
        RTCSessionDescription *remoteSdp = [[RTCSessionDescription alloc] initWithType:type sdp:sdp];
        [peerConnection setRemoteDescriptionWithDelegate:self sessionDescription:remoteSdp];
    }
}

這里蝙眶,我們對6種事件進行了處理,這6種事件就是我們之前說了半天的信令事件褪那,不過這僅僅是其中的一部分而已幽纷。

簡單的談一下這里對6種信令事件的處理:

注意:這里6種事件的順序希望大家能自己運行demo打斷點看看,由于各種事件導(dǎo)致收到消息的順序組合比較多博敬,展開講會很亂友浸,所以這里我們僅僅按照代碼的順序來講。

1.收到_peers:

證明我們新加入房間偏窝,我們就需要對本地的一些東西初始化收恢,其中包括往_connectionIdArray添加房間已有用戶ID。初始化點對點連接對象的工廠:

 if (!_factory)
        {
            //設(shè)置SSL傳輸
            [RTCPeerConnectionFactory initializeSSL];
            _factory = [[RTCPeerConnectionFactory alloc] init];
        }

創(chuàng)建本地視頻流:

//如果本地視頻流為空
if (!_localStream)
{
    //創(chuàng)建本地流
    [self createLocalStream];
}
 - (void)createLocalStream
{
    _localStream = [_factory mediaStreamWithLabel:@"ARDAMS"];
    //音頻
    RTCAudioTrack *audioTrack = [_factory audioTrackWithID:@"ARDAMSa0"];
    [_localStream addAudioTrack:audioTrack];
    //視頻
    
    NSArray *deviceArray = [AVCaptureDevice devicesWithMediaType:AVMediaTypeVideo];
    AVCaptureDevice *device = [deviceArray lastObject];
    //檢測攝像頭權(quán)限
    AVAuthorizationStatus authStatus = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo];
    if(authStatus == AVAuthorizationStatusRestricted || authStatus == AVAuthorizationStatusDenied)
    {
        NSLog(@"相機訪問受限");
        if ([_delegate respondsToSelector:@selector(webRTCHelper:setLocalStream:userId:)])
        {
            
            [_delegate webRTCHelper:self setLocalStream:nil userId:_myId];
        }
    }
    else
    {
        if (device)
        {
            RTCVideoCapturer *capturer = [RTCVideoCapturer capturerWithDeviceName:device.localizedName];
            RTCVideoSource *videoSource = [_factory videoSourceWithCapturer:capturer constraints:[self localVideoConstraints]];
            RTCVideoTrack *videoTrack = [_factory videoTrackWithID:@"ARDAMSv0" source:videoSource];
            
            [_localStream addVideoTrack:videoTrack];
            if ([_delegate respondsToSelector:@selector(webRTCHelper:setLocalStream:userId:)])
            {
                [_delegate webRTCHelper:self setLocalStream:_localStream userId:_myId];
            }
        }
        else
        {
            NSLog(@"該設(shè)備不能打開攝像頭");
            if ([_delegate respondsToSelector:@selector(webRTCHelper:setLocalStream:userId:)])
            {
                [_delegate webRTCHelper:self setLocalStream:nil userId:_myId];
            }
        }
    }
}

這里利用了系統(tǒng)的AVCaptureDevice囚枪、AVAuthorizationStatus派诬,以及RTC的RTCVideoCapturerRTCVideoSource链沼、RTCVideoTrack等一系列類完成了_localStream本地流的初始化默赂,至于具體用法,大家看看代碼吧括勺,還是比較簡單缆八,我就不講了曲掰。

我們接著創(chuàng)建了點對點連接核心對象:

[self createPeerConnections];
/**
 *  創(chuàng)建所有連接
 */
 - (void)createPeerConnections
{
    //從我們的連接數(shù)組里快速遍歷
    [_connectionIdArray enumerateObjectsUsingBlock:^(NSString *obj, NSUInteger idx, BOOL * _Nonnull stop) {
        
        //根據(jù)連接ID去初始化 RTCPeerConnection 連接對象
        RTCPeerConnection *connection = [self createPeerConnection:obj];
        
        //設(shè)置這個ID對應(yīng)的 RTCPeerConnection對象
        [_connectionDic setObject:connection forKey:obj];
    }];
}
 - (RTCPeerConnection *)createPeerConnection:(NSString *)connectionId
{
    //如果點對點工廠為空
    if (!_factory)
    {
        //先初始化工廠
        [RTCPeerConnectionFactory initializeSSL];
        _factory = [[RTCPeerConnectionFactory alloc] init];
    }
    
    //得到ICEServer
    if (!ICEServers) {
        ICEServers = [NSMutableArray array];
        [ICEServers addObject:[self defaultSTUNServer]];
    }
    
    //用工廠來創(chuàng)建連接
    RTCPeerConnection *connection = [_factory peerConnectionWithICEServers:ICEServers constraints:[self peerConnectionConstraints] delegate:self];
    return connection;
}

大概就是用這兩個方法,創(chuàng)建了RTCPeerConnection實例奈辰,并且設(shè)置了RTCPeerConnectionDelegate代理為自己栏妖。最后把它保存在我們的_connectionDic,對應(yīng)的key為對方id

然后我們給所有RTCPeerConnection實例添加了流:

[self addStreams];
/**
 *  為所有連接添加流
 */
 - (void)addStreams
{
    //給每一個點對點連接奖恰,都加上本地流
    [_connectionDic enumerateKeysAndObjectsUsingBlock:^(NSString *key, RTCPeerConnection *obj, BOOL * _Nonnull stop) {
        if (!_localStream)
        {
            [self createLocalStream];
        }
        [obj addStream:_localStream];
    }];
}

最后吊趾,因為是新加入房間的用戶,所以我們創(chuàng)建了offer:

[self createOffers];
- (void)createOffers
{
    //給每一個點對點連接瑟啃,都去創(chuàng)建offer
    [_connectionDic enumerateKeysAndObjectsUsingBlock:^(NSString *key, RTCPeerConnection *obj, BOOL * _Nonnull stop) {
        _currentId = key;
        _role = RoleCaller;
        [obj createOfferWithDelegate:self constraints:[self offerOranswerConstraint]];
    }];
}

我們?nèi)ケ闅v連接字典论泛,去給每一個連接都去創(chuàng)建一個offer,角色設(shè)置為發(fā)起者RoleCaller蛹屿。
createOfferWithDelegateRTCPeerConnection的實例方法屁奏,創(chuàng)建一個offer,并且設(shè)置設(shè)置代理為自己RTCSessionDescriptionDelegate代理為自己错负。

看到這我們發(fā)現(xiàn)除了SRWebSocket的代理外坟瓢,又多了兩個代理,一個是創(chuàng)建點對點連接的RTCPeerConnectionDelegate犹撒,一個是創(chuàng)建offerRTCSessionDescriptionDelegate折联。

相信大家看到這會覺得有點凌亂,我們收到socket消息的代理還沒有講完油航,一下子又多出這么多代理崭庸,沒關(guān)系,我們一步步來看谊囚。

我們先來看看所有的代理方法:

一共如圖這么多怕享,一共隸屬于socket,點對點連接對象镰踏,還有SDP(offer或者answer)函筋。

相信前兩者需要代理,大家能明白為什么奠伪,因為是網(wǎng)絡(luò)回調(diào)跌帐,所以使用了代理,而SDP為什么要使用代理呢绊率?帶著疑惑谨敛,我們先來看看RTCSessionDescriptionDelegate的兩個代理方法:

//創(chuàng)建了一個SDP就會被調(diào)用,(只能創(chuàng)建本地的)
- (void)peerConnection:(RTCPeerConnection *)peerConnection
didCreateSessionDescription:(RTCSessionDescription *)sdp
                 error:(NSError *)error
{
    NSLog(@"%s",__func__);
    //設(shè)置本地的SDP
    [peerConnection setLocalDescriptionWithDelegate:self sessionDescription:sdp];
    
}

上面是第一個代理方法滤否,當我們創(chuàng)建了一個SDP就會被調(diào)用脸狸,因為我們也僅僅只能創(chuàng)建本機的SDP,我們之前調(diào)用createOfferWithDelegate這個方法,創(chuàng)建成功后就會觸發(fā)這個代理炊甲,在這個代理中我們給這個連接設(shè)置了這個SDP泥彤。

然而調(diào)用setLocalDescriptionWithDelegate設(shè)置本地SDP,則會觸發(fā)它的第二代理方法(與之相呼應(yīng)的還有一個setRemoteDescriptionWithDelegate設(shè)置遠程的SDP):

//當一個遠程或者本地的SDP被設(shè)置就會調(diào)用
- (void)peerConnection:(RTCPeerConnection *)peerConnection
didSetSessionDescriptionWithError:(NSError *)error
{
    NSLog(@"%s",__func__);
    //判斷卿啡,當前連接狀態(tài)為吟吝,收到了遠程點發(fā)來的offer,這個是進入房間的時候颈娜,尚且沒人剑逃,來人就調(diào)到這里
    if (peerConnection.signalingState == RTCSignalingHaveRemoteOffer)
    {
        //創(chuàng)建一個answer,會把自己的SDP信息返回出去
        [peerConnection createAnswerWithDelegate:self constraints:[self offerOranswerConstraint]];
    }
    //判斷連接狀態(tài)為本地發(fā)送offer
    else if (peerConnection.signalingState == RTCSignalingHaveLocalOffer)
    {
        if (_role == RoleCallee)
        {
            NSDictionary *dic = @{@"eventName": @"__answer", @"data": @{@"sdp": @{@"type": @"answer", @"sdp": peerConnection.localDescription.description}, @"socketId": _currentId}};
            NSData *data = [NSJSONSerialization dataWithJSONObject:dic options:NSJSONWritingPrettyPrinted error:nil];
            [_socket send:data];
        }
        //發(fā)送者,發(fā)送自己的offer
        else if(_role == RoleCaller)
        {
            NSDictionary *dic = @{@"eventName": @"__offer", @"data": @{@"sdp": @{@"type": @"offer", @"sdp": peerConnection.localDescription.description}, @"socketId": _currentId}};
            NSData *data = [NSJSONSerialization dataWithJSONObject:dic options:NSJSONWritingPrettyPrinted error:nil];
            [_socket send:data];
        }
    }
    else if (peerConnection.signalingState == RTCSignalingStable)
    {
        if (_role == RoleCallee)
        {
            NSDictionary *dic = @{@"eventName": @"__answer", @"data": @{@"sdp": @{@"type": @"answer", @"sdp": peerConnection.localDescription.description}, @"socketId": _currentId}};
            NSData *data = [NSJSONSerialization dataWithJSONObject:dic options:NSJSONWritingPrettyPrinted error:nil];
            [_socket send:data];
        }
    }
}

這個方法無論是設(shè)置本地,還是遠程的SDP官辽,設(shè)置成功后都會調(diào)用炕贵,這里我們根據(jù)_role的不同,來判斷是應(yīng)該生成offer還是answer類型的數(shù)據(jù)來包裹SDP野崇。最后用_socket把數(shù)據(jù)發(fā)送給服務(wù)端,服務(wù)端在轉(zhuǎn)發(fā)給我們指定的socketId的用戶亩钟。
注意:這個socketId是在我們進入房間后乓梨,connections里獲取到的,或者我們已經(jīng)在房間里清酥,收到別人的offer拿到的扶镀。

這樣我們一個SDP生成、綁定焰轻、發(fā)送的流程就結(jié)束了臭觉。

接著我們還是回到SRWebSocketDelegatedidReceiveMessage方法中來。

2.我們來講第2種信令事件:_ice_candidate

這個事件辱志,我們在原理中講過蝠筑,其實它的數(shù)據(jù)就是一個對方客戶端的一個公網(wǎng)IP,只不過這個公網(wǎng)IP是由STU Server下發(fā)的揩懒,為了NAT/防火墻穿越什乙。

我們收到這種事件,需要把對端的IP保存在點對點連接對象中已球。

我們接著來看看代碼:

//接收到新加入的人發(fā)了ICE候選臣镣,(即經(jīng)過ICEServer而獲取到的地址)
else if ([eventName isEqualToString:@"_ice_candidate"])
{
    NSDictionary *dataDic = dic[@"data"];
    NSString *socketId = dataDic[@"socketId"];
    NSInteger sdpMLineIndex = [dataDic[@"label"] integerValue];
    NSString *sdp = dataDic[@"candidate"];
    //生成遠端網(wǎng)絡(luò)地址對象
    RTCICECandidate *candidate = [[RTCICECandidate alloc] initWithMid:nil index:sdpMLineIndex sdp:sdp];
    //拿到當前對應(yīng)的點對點連接
    RTCPeerConnection *peerConnection = [_connectionDic objectForKey:socketId];
    //添加到點對點連接中
    [peerConnection addICECandidate:candidate];
}

我們在這里創(chuàng)建了一個RTCICECandidate實例candidate,這個實例用來標識遠端地址智亮。并且把它添加到對應(yīng)ID的peerConnection中去了忆某。

這里我們僅僅看到接受到遠端的_ice_candidate,但是要知道這個地址同樣是我們客戶端發(fā)出的阔蛉,那么發(fā)送是在什么地方呢弃舒?

我們來看看RTCPeerConnectionDelegate,有這么一個代理方法:

//創(chuàng)建peerConnection之后馍忽,從server得到響應(yīng)后調(diào)用棒坏,得到ICE 候選地址
- (void)peerConnection:(RTCPeerConnection *)peerConnection
       gotICECandidate:(RTCICECandidate *)candidate
{
    NSLog(@"%s",__func__);
    NSDictionary *dic = @{@"eventName": @"__ice_candidate", @"data": @{@"label": [NSNumber numberWithInteger:candidate.sdpMLineIndex], @"candidate": candidate.sdp, @"socketId": _currentId}};
    NSData *data = [NSJSONSerialization dataWithJSONObject:dic options:NSJSONWritingPrettyPrinted error:nil];
    [_socket send:data];
}

當我們創(chuàng)建peerConnection的時候燕差,就會去我們一開始初始化的時候,添加的ICEServers數(shù)組中坝冕,去ICE Server地址中去請求徒探,得到ICECandidate就會調(diào)用這個代理方法,我們在這里用socket把自己的網(wǎng)絡(luò)地址發(fā)送給了對端喂窟。

講到這個ICEServers,我們這里提一下测暗,這里需要一個STUN服務(wù)器,這里我們用的是谷歌的:

static NSString *const RTCSTUNServerURL = @"stun:stun.l.google.com:19302";

//初始化STUN Server (ICE Server)
- (RTCICEServer *)defaultSTUNServer {
    NSURL *defaultSTUNServerURL = [NSURL URLWithString:RTCSTUNServerURL];
    return [[RTCICEServer alloc] initWithURI:defaultSTUNServerURL
                                    username:@""
                                    password:@""];
}

有些STUN服務(wù)器可能被墻磨澡,下面這些提供給大家備用碗啄,或者可以自行搭建:

stun.l.google.com:19302
stun1.l.google.com:19302
stun2.l.google.com:19302
stun3.l.google.com:19302
stun4.l.google.com:19302
stun01.sipphone.com
stun.ekiga.net
stun.fwdnet.net
stun.ideasip.com
stun.iptel.org
stun.rixtelecom.se
stun.schlund.de
stunserver.org
stun.softjoys.com
stun.voiparound.com
stun.voipbuster.com
stun.voipstunt.com
stun.voxgratia.org
stun.xten.com
3.我們回到didReceiveMessage代理來講第3種信令事件:_new_peer
else if ([eventName isEqualToString:@"_new_peer"])
{
    NSDictionary *dataDic = dic[@"data"];
    //拿到新人的ID
    NSString *socketId = dataDic[@"socketId"];
    //再去創(chuàng)建一個連接
    RTCPeerConnection *peerConnection = [self createPeerConnection:socketId];
    if (!_localStream)
    {
        [self createLocalStream];
    }
    //把本地流加到連接中去
    [peerConnection addStream:_localStream];
    //連接ID新加一個
    [_connectionIdArray addObject:socketId];
    //并且設(shè)置到Dic中去
    [_connectionDic setObject:peerConnection forKey:socketId];
}

這個_new_peer表示你已經(jīng)在房間,這時候有新的用戶加入稳摄,這時候你需要為這個用戶再去創(chuàng)建一個點對點連接對象peerConnection稚字。
并且把本地流加到這個新的對象中去,然后設(shè)置_connectionIdArray_connectionDic厦酬。

4.第4種信令事件:_remove_peer
//有人離開房間的事件
else if ([eventName isEqualToString:@"_remove_peer"])
{
    //得到socketId胆描,關(guān)閉這個peerConnection
    NSDictionary *dataDic = dic[@"data"];
    NSString *socketId = dataDic[@"socketId"];
    [self closePeerConnection:socketId];
}

這個事件是有人離開了,我們則需要調(diào)用closePeerConnection:

/**
 *  關(guān)閉peerConnection
 *
 *  @param connectionId <#connectionId description#>
 */
- (void)closePeerConnection:(NSString *)connectionId
{
    RTCPeerConnection *peerConnection = [_connectionDic objectForKey:connectionId];
    if (peerConnection)
    {
        [peerConnection close];
    }
    [_connectionIdArray removeObject:connectionId];
    [_connectionDic removeObjectForKey:connectionId];
    dispatch_async(dispatch_get_main_queue(), ^{
        if ([_delegate respondsToSelector:@selector(webRTCHelper:closeWithUserId:)])
        {
            [_delegate webRTCHelper:self closeWithUserId:connectionId];
        }
    });
}

關(guān)閉peerConnection仗阅,并且從_connectionIdArray昌讲、_connectionDic中移除,然后對外調(diào)用關(guān)閉連接的代理减噪。

5.第5種信令事件:_offer

這個事件短绸,是別人新加入房間后,會發(fā)出的offer筹裕,提出與我們建立點對點連接醋闭。
我們來看看處理:

//這個新加入的人發(fā)了個offer
else if ([eventName isEqualToString:@"_offer"])
{
    NSDictionary *dataDic = dic[@"data"];
    NSDictionary *sdpDic = dataDic[@"sdp"];
    //拿到SDP
    NSString *sdp = sdpDic[@"sdp"];
    NSString *type = sdpDic[@"type"];
    NSString *socketId = dataDic[@"socketId"];
    
    //拿到這個點對點的連接
    RTCPeerConnection *peerConnection = [_connectionDic objectForKey:socketId];
    //根據(jù)類型和SDP 生成SDP描述對象
    RTCSessionDescription *remoteSdp = [[RTCSessionDescription alloc] initWithType:type sdp:sdp];
    //設(shè)置給這個點對點連接
    [peerConnection setRemoteDescriptionWithDelegate:self sessionDescription:remoteSdp];
    
    //把當前的ID保存下來
    _currentId = socketId;
    //設(shè)置當前角色狀態(tài)為被呼叫,(被發(fā)offer)
    _role = RoleCallee;
}

這里我們從offer中拿到SDP饶碘,并且調(diào)用我們之前提到的setRemoteDescriptionWithDelegate設(shè)置遠端的SDP目尖,這個設(shè)置成功后,又調(diào)回到SDP的代理方法:didSetSessionDescriptionWithError中去了扎运。

在這代理方法我們生成了一個answer瑟曲,把本機的SDP包裹起來傳了過去。如此形成了一個閉環(huán)豪治。

6.第6種信令事件:_answer

這個事件是自己發(fā)出offer后洞拨,得到別人的awser回答,這時候我們需要做的僅僅是保存起來遠端SDP即可负拟,到這一步兩端互相有了對方的SDP烦衣。

而兩端的事件,是當SDPICE Candidate,都交換完成后花吟,點對點連接才建立完成秸歧。

至此6種信令事件講完了,通過這些信令衅澈,我們完成了加入房間键菱,退出房間,建立連接等控制過程今布。

這個類基本上核心的東西就這些了经备,其他的一些零碎的小細節(jié),包括連接成功后部默,遠端的流過來調(diào)用RTCPeerConnectionDelegate代理等等:

// Triggered when media is received on a new stream from remote peer.
- (void)peerConnection:(RTCPeerConnection *)peerConnection
           addedStream:(RTCMediaStream *)stream
{
    NSLog(@"%s",__func__);
    dispatch_async(dispatch_get_main_queue(), ^{
        if ([_delegate respondsToSelector:@selector(webRTCHelper:addRemoteStream:userId:)])
        {
            [_delegate webRTCHelper:self addRemoteStream:stream userId:_currentId];
        }
    });
}

在這里我們僅僅是把這個視頻流用主線程回調(diào)出去給外部代理處理侵蒙,而點對點連接關(guān)閉的時候也是這么處理的,這樣就和我們之前提到的對外代理方法銜接起來了傅蹂。

其他的大家可以自己去demo中查看吧纷闺。

接著我們客戶端講完了,這里我們略微帶過一下我們的WebSocket服務(wù)端份蝴,這里我們?nèi)匀挥玫?code>Node.js急但,為什么用用它呢?因為太多好用的簡單好用的框架了搞乏,簡直不用動腦子...

這里我們用了skyrtc框架,具體代碼如下:

var express = require('express');
var app = express();
var server = require('http').createServer(app);
var SkyRTC = require('skyrtc').listen(server);
var path = require("path");

var port = process.env.PORT || 3000;
server.listen(port);
app.use(express.static(path.join(__dirname, 'public')));
app.get('/', function(req, res) {
    res.sendfile(__dirname + '/index.html');
});
SkyRTC.rtc.on('new_connect', function(socket) {
    console.log('創(chuàng)建新連接');
});
SkyRTC.rtc.on('remove_peer', function(socketId) {
    console.log(socketId + "用戶離開");
});
SkyRTC.rtc.on('new_peer', function(socket, room) {
    console.log("新用戶" + socket.id + "加入房間" + room);
});
SkyRTC.rtc.on('socket_message', function(socket, msg) {
    console.log("接收到來自" + socket.id + "的新消息:" + msg);
});
SkyRTC.rtc.on('ice_candidate', function(socket, ice_candidate) {
    console.log("接收到來自" + socket.id + "的ICE Candidate");
});
SkyRTC.rtc.on('offer', function(socket, offer) {
    console.log("接收到來自" + socket.id + "的Offer");
});
SkyRTC.rtc.on('answer', function(socket, answer) {
    console.log("接收到來自" + socket.id + "的Answer");
});
SkyRTC.rtc.on('error', function(error) {
    console.log("發(fā)生錯誤:" + error.message);
});

基本上戒努,用了這個框架请敦,我們除了打印之外,沒有做任何的處理储玫,所有的消息轉(zhuǎn)發(fā)侍筛,都是由框架內(nèi)部識別并且處理完成的。

這里需要提一下的是撒穷,由于作者沒有那么富帥匣椰,沒那么多手機,所以在這里用瀏覽器來充當一部分的客戶端端礼,所以你會看到禽笑,這里用了http框架,監(jiān)聽了本機3000端口蛤奥,如果誰調(diào)用網(wǎng)頁的則去渲染當前文件下的index.html佳镜。

在這里,用index.htmlSkyRTC-client.js兩個文件實現(xiàn)了瀏覽器端的WebRTC通信凡桥,這樣就可以移動端和移動端蟀伸、移動端和瀏覽器、瀏覽器與瀏覽器之間在同一個聊天室進行視頻通話了。

至于源碼我就不講了啊掏,大家可以到demo中去查看蠢络,這個瀏覽器端的代碼是我從下面文章的作者github中找來的:
WebRTC的RTCDataChannel
使用WebRTC搭建前端視頻聊天室——信令篇
使用WebRTC搭建前端視頻聊天室——入門篇

提倡大家去看看,他很詳細的講了WebRTCWeb端的實現(xiàn)迟蜜,和iOS端實現(xiàn)的基本原理刹孔、流程是一樣的,只是API略有不同小泉。

本文demo地址:WebRTC_iOS
大家在運行demo的時候需要注意以下幾點:
  1. 運行WebSocket服務(wù)端芦疏,直接用命令行CD到server.js所在目錄下:
    Paste_Image.png

直接命令行中執(zhí)行(需要安裝nodejs環(huán)境)

node server.js

這樣Socket服務(wù)端就運行起來了,此時你可以打開瀏覽器輸入

localhost:3000#100

此3000為端口號微姊,100為聊天室房間號酸茴,如果出現(xiàn)以下圖像,說明Socket服務(wù)端和Web客戶端已完成兢交。

  1. 接著我們要去運行iOS的客戶端了薪捍,首先我們需要去百度網(wǎng)盤下載 WebRTC頭文件和靜態(tài)庫.a。
    下載完成配喳,解壓縮酪穿,直接按照本文第二條中:iOS下WebRTC環(huán)境的搭建即可。

程序能運行起來后晴裹,接著我們需要替換VideoChatViewController中的server地址:

[[WebRTCHelper sharedInstance]connectServer:@"192.168.0.7" port:@"3000" room:@"100"];

這里的server地址被济,如果你是用和本機需要替換成localhost,而如果你是用手機等涧团,則需要和電腦同處一個局域網(wǎng)(wifi下)只磷,并且IP地址一致才行。

在這里由于我的電腦IP地址是192.168.0.7

所以我在手機上運行泌绣,連接到這個server钮追,也就是連接到電腦。

至此就可以看到iOS端的視頻聊天效果了阿迈,大家可以多開幾個Web客戶端看看效果元媚。

寫在結(jié)尾:

引用這篇文章:從demo到實用,中間還差1萬個WebRTC里的一段話來結(jié)尾吧:

WebRTC開源之前苗沧,實時音視頻通信聽起來好高級:回聲消除刊棕、噪聲抑制……對于看到傅里葉變換都頭疼的工程師很難搞定這些專業(yè)領(lǐng)域的問題。
  Google收購了GIPS待逞,開源了WebRTC項目之后鞠绰,開發(fā)者可以自己折騰出互聯(lián)網(wǎng)音視頻通信了。下載飒焦、編譯蜈膨、集成之后屿笼,第一次聽到通過互聯(lián)網(wǎng)傳過來的喂喂喂,工程師會非常興奮翁巍,demo到萬人直播現(xiàn)場只差一步了驴一。
  但是,電信行業(yè)要求可用性4個9灶壶,而剛剛讓人興奮的“喂喂喂”肝断,1個9都到不了。某公司在展會上演示跨國音視頻驰凛,多次呼叫無法接通胸懈,自嘲說我們還沒有做網(wǎng)絡(luò)優(yōu)化嘛。這就等于互聯(lián)網(wǎng)全民創(chuàng)業(yè)時期的”就差個程序員了“恰响,本質(zhì)上是和demo與真正產(chǎn)品之間的差距趣钱,是外行與內(nèi)行之間的差距。

IM的路還有很長胚宦,一萬個WebRTC已經(jīng)走過了一個首有?

注:源代碼運行后有小伙伴反映移動端連接黑屏的問題,經(jīng)張速同學(xué)的提醒枢劝,原因如下:
Paste_Image.png
Paste_Image.png

修改的地方大致如上圖所述井联,主要是發(fā)送ICE的時候添加了一個id字段的數(shù)據(jù),這個字段的內(nèi)容為candidate.stpMid您旁。

官方對這個stpMid字段的解釋是:

// If present, this contains the identifier of the "media stream
// identification" as defined in [RFC 3388] for m-line this candidate is
// associated with.

意思是這個字段是用來標識流媒體的id烙常,這個字段需要和ICE綁定在一起。
至于瀏覽器端為什么不會有影響鹤盒,原因應(yīng)該是web端和移動端的SDK差異所導(dǎo)致的军掂。

所以除了客戶端需要添加這個字段外,在我們server端昨悼,找到SkyRTC.js,也需要添加這個id字段跃洛,把它轉(zhuǎn)發(fā)給另一個客戶端率触,添加上后,移動端之間視頻聊天應(yīng)該就不會有問題了汇竭。

github上的代碼我已經(jīng)修改過了葱蝗,重新拉一下代碼即可。

除此之外细燎,如果不同網(wǎng)段之間两曼,出現(xiàn)視頻聊天黑屏的問題,那么很可能是STUN服務(wù)器導(dǎo)致的玻驻,建議多嘗試幾個STUN試試悼凑,也可以自行搭建偿枕。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市户辫,隨后出現(xiàn)的幾起案子渐夸,更是在濱河造成了極大的恐慌,老刑警劉巖渔欢,帶你破解...
    沈念sama閱讀 206,839評論 6 482
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件墓塌,死亡現(xiàn)場離奇詭異,居然都是意外死亡奥额,警方通過查閱死者的電腦和手機苫幢,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,543評論 2 382
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來垫挨,“玉大人韩肝,你說我怎么就攤上這事“舴鳎” “怎么了诗舰?”我有些...
    開封第一講書人閱讀 153,116評論 0 344
  • 文/不壞的土叔 我叫張陵陋桂,是天一觀的道長。 經(jīng)常有香客問我,道長郎汪,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 55,371評論 1 279
  • 正文 為了忘掉前任抖格,我火速辦了婚禮冕房,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘牢屋。我一直安慰自己且预,他們只是感情好,可當我...
    茶點故事閱讀 64,384評論 5 374
  • 文/花漫 我一把揭開白布烙无。 她就那樣靜靜地躺著锋谐,像睡著了一般。 火紅的嫁衣襯著肌膚如雪截酷。 梳的紋絲不亂的頭發(fā)上涮拗,一...
    開封第一講書人閱讀 49,111評論 1 285
  • 那天,我揣著相機與錄音迂苛,去河邊找鬼三热。 笑死,一個胖子當著我的面吹牛三幻,可吹牛的內(nèi)容都是我干的就漾。 我是一名探鬼主播,決...
    沈念sama閱讀 38,416評論 3 400
  • 文/蒼蘭香墨 我猛地睜開眼念搬,長吁一口氣:“原來是場噩夢啊……” “哼抑堡!你這毒婦竟也來了摆出?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 37,053評論 0 259
  • 序言:老撾萬榮一對情侶失蹤夷野,失蹤者是張志新(化名)和其女友劉穎懊蒸,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體悯搔,經(jīng)...
    沈念sama閱讀 43,558評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡骑丸,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,007評論 2 325
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了妒貌。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片通危。...
    茶點故事閱讀 38,117評論 1 334
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖灌曙,靈堂內(nèi)的尸體忽然破棺而出菊碟,到底是詐尸還是另有隱情,我是刑警寧澤在刺,帶...
    沈念sama閱讀 33,756評論 4 324
  • 正文 年R本政府宣布逆害,位于F島的核電站,受9級特大地震影響蚣驼,放射性物質(zhì)發(fā)生泄漏魄幕。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 39,324評論 3 307
  • 文/蒙蒙 一颖杏、第九天 我趴在偏房一處隱蔽的房頂上張望纯陨。 院中可真熱鬧,春花似錦留储、人聲如沸翼抠。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,315評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽阴颖。三九已至,卻和暖如春丐膝,著一層夾襖步出監(jiān)牢的瞬間量愧,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,539評論 1 262
  • 我被黑心中介騙來泰國打工尤误, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人结缚。 一個月前我還...
    沈念sama閱讀 45,578評論 2 355
  • 正文 我出身青樓损晤,卻偏偏與公主長得像,于是被迫代替她去往敵國和親红竭。 傳聞我的和親對象是個殘疾皇子尤勋,可洞房花燭夜當晚...
    茶點故事閱讀 42,877評論 2 345

推薦閱讀更多精彩內(nèi)容