你好,歡迎來到IOS教程網

 Ios教程網 >> IOS編程開發 >> IOS開發基礎 >> iOS下音視頻通信的實現

iOS下音視頻通信的實現

編輯:IOS開發基礎

QQ截圖20170306095551.png

前言:

WebRTC,名稱源自網頁實時通信(Web Real-Time Communication)的縮寫,簡而言之它是一個支持網頁浏覽器進行實時語音對話或視頻對話的技術。

它為我們提供了視頻會議的核心技術,包括音視頻的采集、編解碼、網絡傳輸、顯示等功能,並且還支持跨平台:windows,linux,mac,android,iOS。

它在2011年5月開放了工程的源代碼,在行業內得到了廣泛的支持和應用,成為下一代視頻通話的標准。

本文將站在巨人的肩膀上,基於WebRTC去實現不同客戶端之間的音視頻通話。這個不同的客戶端,不局限於移動端和移動端,還包括移動端和Web浏覽器之間。

2702646-1e74a6b70c80e577.png

目錄:

一.WebRTC的實現原理。

二.iOS下WebRTC環境的搭建

三.介紹下WebRTC的API,以及實現點對點連接的流程。

四.iOS客戶端的詳細實現,以及服務端信令通道的搭建。

正文:

一.WebRTC的實現原理。

WebRTC的音視頻通信是基於P2P,那麼什麼是P2P呢?

它是點對點連接的英文縮寫。

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

一般我們傳統的連接方式,都是以服務器為中介的模式:

  • 類似http協議:客戶端?服務端(當然這裡服務端返回的箭頭僅僅代表返回請求數據)。

  • 我們在進行即時通訊時,進行文字、圖片、錄音等傳輸的時候:客戶端A?服務器?客戶端B。

而點對點的連接恰恰數據通道一旦形成,中間是不經過服務端的,數據直接從一個客戶端流向另一個客戶端:

客戶端A?客戶端B ... 客戶端A?客戶端C ...(可以無數個客戶端之間互聯)

這裡可以想想音視頻通話的應用場景,我們服務端確實是沒必要去獲取兩者通信的數據,而且這樣做有一個最大的一個優點就是,大大的減輕了服務端的壓力。

而WebRTC就是這樣一個基於P2P的音視頻通信技術。

2.WebRTC的服務器與信令。

講到這裡,可能大家覺得WebRTC就不需要服務端了麼?這是顯然是錯誤的認識,嚴格來說它僅僅是不需要服務端來進行數據中轉而已。

WebRTC提供了浏覽器到浏覽器(點對點)之間的通信,但並不意味著WebRTC不需要服務器。暫且不說基於服務器的一些擴展業務,WebRTC至少有兩件事必須要用到服務器:

  • 浏覽器之間交換建立通信的元數據(信令)必須通過服務器。

  • 為了穿越NAT和防火牆。

第1條很好理解,我們在A和B需要建立P2P連接的時候,至少要服務器來協調,來控制連接開始建立。而連接斷開的時候,也需要服務器來告知另一端P2P連接已斷開。這些我們用來控制連接的狀態的數據稱之為信令,而這個與服務端連接的通道,對於WebRTC而言就是信令通道。

QQ截圖20170306095814.png

圖中signalling就是往服務端發送信令,然後底層調用WebRTC,WebRTC通過服務端得到的信令,得知通信對方的基本信息,從而實現虛線部分Media通信連接。

當然信令能做的事還有很多,這裡大概列了一下:

  • 用來控制通信開啟或者關閉的連接控制消息

  • 發生錯誤時用來彼此告知的消息

  • 媒體流元數據,比如像解碼器、解碼器的配置、帶寬、媒體類型等等

  • 用來建立安全連接的關鍵數據

  • 外界所看到的的網絡上的數據,比如IP地址、端口等

在建立連接之前,客戶端之間顯然沒有辦法傳遞數據。所以我們需要通過服務器的中轉,在客戶端之間傳遞這些數據,然後建立客戶端之間的點對點連接。但是WebRTC API中並沒有實現這些,這些就需要我們來實現了。

而第2條中的NAT這個概念,我們之前在iOS即時通訊,從入門到“放棄”? ,中也提到過,不過那個時候我們是為了應對NAT超時,所造成的TCP連接中斷。在這裡我們就不展開去講了,感興趣的可以看看:NAT百科

這裡我簡要說明一下,NAT技術的出現,其實就是為了解決IPV4下的IP地址匮乏。舉例來說,就是通常我們處在一個路由器之下,而路由器分配給我們的地址通常為192.168.0.1 、192.168.0.2如果有n個設備,可能分配到192.168.0.n,而這個IP地址顯然只是一個內網的IP地址,這樣一個路由器的公網地址對應了n個內網的地址,通過這種使用少量的公有IP 地址代表較多的私有IP 地址的方式,將有助於減緩可用的IP地址空間的枯竭。

但是這也帶來了一系列的問題,例如這裡點對點連接下,會導致這樣一個問題:

如果客戶端A想給客戶端B發送數據,則數據來到客戶端B所在的路由器下,會被NAT阻攔,這樣B就無法收到A的數據了。

但是A的NAT此時已經知道了B這個地址,所以當B給A發送數據的時候,NAT不會阻攔,這樣A就可以收到B的數據了。這就是我們進行NAT穿越的核心思路。

於是我們就有了以下思路:

我們借助一個公網IP服務器,a,b都往公網IP/PORT發包,公網服務器就可以獲知a,b的IP/PORT,又由於a,b主動給公網IP服務器發包,所以公網服務器可以穿透NAT A,NAT B送包給a,b。

所以只要公網IP將b的IP/PORT發給a,a的IP/PORT發給b。這樣下次a和b互相消息,就不會被NAT阻攔了。

而WebRTC的NAT/防火牆穿越技術,就是基於上述的一個思路來實現的:

建立點對點信道的一個常見問題,就是NAT穿越技術。在處於使用了NAT設備的私有TCP/IP網絡中的主機之間需要建立連接時需要使用NAT穿越技術。以往在VoIP領域經常會遇到這個問題。目前已經有很多NAT穿越技術,但沒有一項是完美的,因為NAT的行為是非標准化的。這些技術中大多使用了一個公共服務器,這個服務使用了一個從全球任何地方都能訪問得到的IP地址。在RTCPeeConnection中,使用ICE框架來保證RTCPeerConnection能實現NAT穿越

QQ截圖20170306095915.png

這裡提到了ICE協議框架,它大約是由以下幾個技術和協議組成的:STUN、NAT、TURN、SDP,這些協議技術,幫助ICE共同實現了NAT/防火牆穿越。

小伙伴們可能又一臉懵逼了,一下子又出來這麼多名詞,沒關系,這裡我們暫且不去管它們,等我們後面實現的時候,還會提到他們,這裡提前感興趣的可以看看這篇文章:WebRTC protocols

二.iOS下WebRTC環境的搭建:

首先,我們需要明白的一點是:WebRTC已經在我們的浏覽器中了。如果我們用浏覽器,則可以直接使用js調用對應的WebRTC的API,實現音視頻通信。

然而我們是在iOS平台,所以我們需要去官網下載指定版本的源碼,並且對其進行編譯,大概一下,其中源碼大小10個多G,編譯過程會遇到一系列坑,而我們編譯完成最終形成的webrtc的.a庫大概有300多m。

這裡我們不寫編譯過程了,感興趣的可以看看這篇文章:

WebRTC(iOS)下載編譯

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


QQ截圖20170306095949.png

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

接著我們把整個WebRTC文件夾添加到工程中,並且添加以下系統依賴庫:

QQ截圖20170306095958.png

至此,一個iOS下的WebRTC環境就搭建完畢了

三.介紹下WebRTC的API,以及實現點對點連接的流程。

1.WebRTC主要實現了三個API,分別是:

  • MediaStream:通過MediaStream的API能夠通過設備的攝像頭及話筒獲得視頻、音頻的同步流

  • RTCPeerConnection:RTCPeerConnection是WebRTC用於構建點對點之間穩定、高效的流傳輸的組件

  • RTCDataChannel:RTCDataChannel使得浏覽器之間(點對點)建立一個高吞吐量、低延時的信道,用於傳輸任意數據。

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

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

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

我們之前講到過WebRTC用ICE協議來保證NAT穿越,所以它有這麼一個流程:我們需要從STUN Server中得到一個ice candidate,這個東西實際上就是公網地址,這樣我們就有了客戶端自己的公網地址。而這個STUN Server所做的事就是之前所說的,把保存起來的公網地址,互相發送數據包,防止後續的NAT阻攔。

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

對了,這裡補充一下SDP這個概念,它是會話描述協議Session Description Protocol (SDP) 是一個描述多媒體連接內容的協議,例如分辨率,格式,編碼,加密算法等。所以在數據傳輸時兩端都能夠理解彼此的數據。本質上,這些描述內容的元數據並不是媒體流本身。

講到這我們來捋一捋建立P2P連接的過程:

1.A和B連接上服務端,建立一個TCP長連接(任意協議都可以,WebSocket/MQTT/Socket原生/XMPP),我們這裡為了省事,直接采用WebSocket,這樣一個信令通道就有了。

2.A從ice server(STUN Server)獲取ice candidate並發送給Socket服務端,並生成包含session description(SDP)的offer,發送給Socket服務端。

3.Socket服務端把A的offer和ice candidate轉發給B,B會保存下A這些信息。

4.然後B發送包含自己session description的answer(因為它收到的是offer,所以返回的是answer,但是內容都是SDP)和ice candidate給Socket服務端。

5.Socket服務端把B的answer和ice candidate給A,A保存下B的這些信息。

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

這裡理解整個P2P連接的流程是非常重要的,否則後面代碼實現部分便難以理解。

四.iOS客戶端的詳細實現,以及服務端信令通道的搭建。

聊天室中的信令

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

用戶操作

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

1.打開頁面連接到服務器上

2.進入聊天室

3.與其他所有已在聊天室的用戶建立點對點的連接,並輸出在頁面上

4.若有聊天室內的其他用戶離開,應得到通知,關閉與其的連接並移除其在頁面中的輸出

5.若又有其他用戶加入,應得到通知,建立於新加入用戶的連接,並輸出在頁面上

6.離開頁面,關閉所有連接

從上面可以看出來,除了點對點連接的建立,還需要服務器至少做如下幾件事:

1.新用戶加入房間時,發送新用戶的信息給房間內的其他用戶

2.新用戶加入房間時,發送房間內的其他用戶信息給新加入房間的用戶

3.用戶離開房間時,發送離開用戶的信息給房間內的其他用戶

實現思路

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

1.客戶端與服務器建立WebSocket連接

2.發送一個加入聊天室的信令(join),信令中需要包含用戶所進入的聊天室名稱

3.服務器根據用戶所加入的房間,發送一個其他用戶信令(peers),信令中包含聊天室中其他用戶的信息,客戶端根據信息來逐個構建與其他用戶的點對點連接

4.若有用戶離開,服務器發送一個用戶離開信令(remove_peer),信令中包含離開的用戶的信息,客戶端根據信息關閉與離開用戶的信息,並作相應的清除操作

5.若有新用戶加入,服務器發送一個用戶加入信令(new_peer),信令中包含新加入的用戶的信息,客戶端根據信息來建立與這個新用戶的點對點連接

6.用戶離開頁面,關閉WebSocket連接

這樣有了基本思路,我們來實現一個基於WebRTC的視頻聊天室。

我們首先來實現客戶端實現,先看看WebRTCHelper.h:

@protocol WebRTCHelperDelegate;

@interface WebRTCHelper : NSObject(SRWebSocketDelegate)(此處圓括號替換尖括號使用)

+ (instancetype)sharedInstance;

@property (nonatomic, weak)id(WebRTCHelperDelegate) delegate;(此處圓括號替換尖括號使用)

/**
 *  與服務器建立連接
 *
 *  @param server 服務器地址
 *  @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

這裡我們對外的接口很簡單,就是一個生成單例的方法,一個代理,還有一個與服務器連接的方法,這個方法需要傳3個參數過去,分別是server的地址、端口號、以及房間號。還有一個退出房間的方法。

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

1.本地設置流的回調,可以用來顯示本地的視頻圖像。

2.遠程流到達的回調,可以用來顯示對方的視頻圖像。

3.WebRTC連接關閉的回調,注意這裡關閉僅僅與當前userId的連接關閉,而如果你除此之外還與聊天室其他的人建立連接,是不會有影響的。

接著我們先不去看如何實現的,先運行起來看看效果吧:

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

僅僅需要設置代理為自己,然後連接上socket服務器即可。

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

- (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和關閉按鈕不做處理
        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;
        }
    }];
}

代碼很簡單,基本核心的是調用了WebRTC的API的那幾行:

這裡我們得到本地流和遠程流的時候,就可以用這個流來設置視頻圖像了,而音頻是自動輸出的(遠程的音頻會輸出,自己本地的音頻則不會)。

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

1.創建一個RTCEAGLVideoView類型的實例。

2.從代理回調中拿到RTCMediaStream類型的stream,從stream中拿到RTCVideoTrack實例:

_localVideoTrack = [stream.videoTracks lastObject];

3.用這個_localVideoTrack為RTCEAGLVideoView實例設置渲染:

[_localVideoTrack addRenderer:localVideoView];

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

這裡切記需要注意的是RTCVideoTrack實例我們必須持有它(這裡我們本機設置為屬性了,而遠程的添加到數組中,都是為了這麼個目的)。否則有可能會導致視頻圖像無法顯示。

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

QQ截圖20170306100623.png

Paste_Image.png

這是我用手機截的圖,因為模擬器無法調用mac攝像頭,第一個????是本地視頻圖像,而後面的????則是遠端用戶傳過來的,如果有n個遠程用戶,則會一直往下排列。

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

接著我們來講講WebRTCHelper的實現:

首先前面順著應用這個類的順序來,我們首先調用了單例,設置了代理:

+ (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的。

接著我們調用了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服務器,這裡我們使用的是webScoekt,使用的框架是谷歌的SocketRocket,至於它的用法我就不贅述了,不熟悉的可以看看樓主的iOS即時通訊,從入門到“放棄”? 。

這裡我們設置代理為自己,並且建立連接,然後連接成功後,回調到成的代理:

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

成功的連接後,我們調用了加入房間的方法,加入我們一開始設置的房間號:

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

加入房間,我們僅僅是把這個一個json數據用socket發給服務端,類型為__join。

接著就是服務端的邏輯了,服務端拿到這個類型的數據,會給我們發送這麼一條消息:

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

這條消息類型是_peers,意思為房間新用戶,並且把我們在這個房間的id返回給我們,拿到這條消息,說明我們加入房間成功,我們就可以去做一系列的初始化了。而connections這個字段為空,說明當前房間沒有人,如果已經有人的話,會返回這麼一串:

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

其中connections裡面裝的是已在房間用戶的id。

接著就是我們整個類運轉的核心代理方法,就是收到socket消息後的處理:

#pragma mark--SRWebSocketDelegate
- (void)webSocket:(SRWebSocket *)webSocket didReceiveMessage:(id)message
{
    NSLog(@"收到服務器消息:%@",message);
    NSDictionary *dic = [NSJSONSerialization JSONObjectWithData:[message dataUsingEncoding:NSUTF8StringEncoding] options:NSJSONReadingMutableContainers error:nil];
    NSString *eventName = dic[@"eventName"];
    
    //1.發送加入房間後的反饋
    if ([eventName isEqualToString:@"_peers"])
    {
        //得到data
        NSDictionary *dataDic = dic[@"data"];
        //得到所有的連接
        NSArray *connections = dataDic[@"connections"];
        //加到連接數組中去
        [_connectionIdArray addObjectsFromArray:connections];
        //拿到給自己分配的ID
        _myId = dataDic[@"you"];
        //如果為空,則創建點對點工廠
        if (!_factory)
        {
            //設置SSL傳輸
            [RTCPeerConnectionFactory initializeSSL];
            _factory = [[RTCPeerConnectionFactory alloc] init];
        }
        //如果本地視頻流為空
        if (!_localStream)
        {
            //創建本地流
            [self createLocalStream];
        }
        //創建連接
        [self createPeerConnections];
        //添加
        [self addStreams];
        [self createOffers];
    }
    //接收到新加入的人發了ICE候選,(即經過ICEServer而獲取到的地址)
    else if ([eventName isEqualToString:@"_ice_candidate"])
    {
        NSDictionary *dataDic = dic[@"data"];
        NSString *socketId = dataDic[@"socketId"];
        NSInteger sdpMLineIndex = [dataDic[@"label"] integerValue];
        NSString *sdp = dataDic[@"candidate"];
        //生成遠端網絡地址對象
        RTCICECandidate *candidate = [[RTCICECandidate alloc] initWithMid:nil index:sdpMLineIndex sdp:sdp];
        
        //拿到當前對應的點對點連接
        RTCPeerConnection *peerConnection = [_connectionDic objectForKey:socketId];
        //添加到點對點連接中
        [peerConnection addICECandidate:candidate];
    }
    //其他新人加入房間的信息
    else if ([eventName isEqualToString:@"_new_peer"])
    {
        NSDictionary *dataDic = dic[@"data"];
        //拿到新人的ID
        NSString *socketId = dataDic[@"socketId"];
        //再去創建一個連接
        RTCPeerConnection *peerConnection = [self createPeerConnection:socketId];
        if (!_localStream)
        {
            [self createLocalStream];
        }
        //把本地流加到連接中去
        [peerConnection addStream:_localStream];
        //連接ID新加一個
        [_connectionIdArray addObject:socketId];
        //並且設置到Dic中去
        [_connectionDic setObject:peerConnection forKey:socketId];
    }
    //有人離開房間的事件
    else if ([eventName isEqualToString:@"_remove_peer"])
    {
        //得到socketId,關閉這個peerConnection
        NSDictionary *dataDic = dic[@"data"];
        NSString *socketId = dataDic[@"socketId"];
        [self closePeerConnection:socketId];
    }
    //這個新加入的人發了個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];
        //根據類型和SDP 生成SDP描述對象
        RTCSessionDescription *remoteSdp = [[RTCSessionDescription alloc] initWithType:type sdp:sdp];
        //設置給這個點對點連接
        [peerConnection setRemoteDescriptionWithDelegate:self sessionDescription:remoteSdp];
        //把當前的ID保存下來
        _currentId = socketId;
        //設置當前角色狀態為被呼叫,(被發offer)
        _role = RoleCallee;
    }
    //收到別人的offer,而回復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打斷點看看,由於各種事件導致收到消息的順序組合比較多,展開講會很亂,所以這裡我們僅僅按照代碼的順序來講。

1.收到_peers:

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

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

創建本地視頻流:

//如果本地視頻流為空
if (!_localStream)
{
    //創建本地流
    [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];
    //檢測攝像頭權限
    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(@"該設備不能打開攝像頭");
            if ([_delegate respondsToSelector:@selector(webRTCHelper:setLocalStream:userId:)])
            {
                [_delegate webRTCHelper:self setLocalStream:nil userId:_myId];
            }
        }
    }
}

這裡利用了系統的AVCaptureDevice、AVAuthorizationStatus,以及RTC的RTCVideoCapturer、RTCVideoSource、RTCVideoTrack等一系列類完成了_localStream本地流的初始化,至於具體用法,大家看看代碼吧,還是比較簡單,我就不講了。

我們接著創建了點對點連接核心對象:

[self createPeerConnections];
/**
 *  創建所有連接
 */
 - (void)createPeerConnections
{
    //從我們的連接數組裡快速遍歷
    [_connectionIdArray enumerateObjectsUsingBlock:^(NSString *obj, NSUInteger idx, BOOL * _Nonnull stop) {
        //根據連接ID去初始化 RTCPeerConnection 連接對象
        RTCPeerConnection *connection = [self createPeerConnection:obj];
        //設置這個ID對應的 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]];
    }

    //用工廠來創建連接
    RTCPeerConnection *connection = [_factory peerConnectionWithICEServers:ICEServers constraints:[self peerConnectionConstraints] delegate:self];
    return connection;
}

大概就是用這兩個方法,創建了RTCPeerConnection實例,並且設置了RTCPeerConnectionDelegate代理為自己。最後把它保存在我們的_connectionDic,對應的key為對方id。

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

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

最後,因為是新加入房間的用戶,所以我們創建了offer:

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

我們去遍歷連接字典,去給每一個連接都去創建一個offer,角色設置為發起者RoleCaller。

createOfferWithDelegate是RTCPeerConnection的實例方法,創建一個offer,並且設置設置代理為自己RTCSessionDescriptionDelegate代理為自己。

看到這我們發現除了SRWebSocket的代理外,又多了兩個代理,一個是創建點對點連接的RTCPeerConnectionDelegate,一個是創建offer的RTCSessionDescriptionDelegate。

相信大家看到這會覺得有點凌亂,我們收到socket消息的代理還沒有講完,一下子又多出這麼多代理,沒關系,我們一步步來看。

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

QQ截圖20170306101436.png

一共如圖這麼多,一共隸屬於socket,點對點連接對象,還有SDP(offer或者answer)。

相信前兩者需要代理,大家能明白為什麼,因為是網絡回調,所以使用了代理,而SDP為什麼要使用代理呢?帶著疑惑,我們先來看看RTCSessionDescriptionDelegate的兩個代理方法:

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

上面是第一個代理方法,當我們創建了一個SDP就會被調用,因為我們也僅僅只能創建本機的SDP,我們之前調用createOfferWithDelegate這個方法,創建成功後就會觸發這個代理,在這個代理中我們給這個連接設置了這個SDP。

然而調用setLocalDescriptionWithDelegate設置本地SDP,則會觸發它的第二代理方法(與之相呼應的還有一個setRemoteDescriptionWithDelegate設置遠程的SDP):

//當一個遠程或者本地的SDP被設置就會調用
- (void)peerConnection:(RTCPeerConnection *)peerConnection
didSetSessionDescriptionWithError:(NSError *)error
{
    NSLog(@"%s",__func__);
    //判斷,當前連接狀態為,收到了遠程點發來的offer,這個是進入房間的時候,尚且沒人,來人就調到這裡
    if (peerConnection.signalingState == RTCSignalingHaveRemoteOffer)
    {
        //創建一個answer,會把自己的SDP信息返回出去
        [peerConnection createAnswerWithDelegate:self constraints:[self offerOranswerConstraint]];
    }
    //判斷連接狀態為本地發送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];
        }
        //發送者,發送自己的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];
        }
    }
}

這個方法無論是設置本地,還是遠程的SDP,設置成功後都會調用,這裡我們根據_role的不同,來判斷是應該生成offer還是answer類型的數據來包裹SDP。最後用_socket把數據發送給服務端,服務端在轉發給我們指定的socketId的用戶。

注意:這個socketId是在我們進入房間後,connections裡獲取到的,或者我們已經在房間裡,收到別人的offer拿到的。

這樣我們一個SDP生成、綁定、發送的流程就結束了。

接著我們還是回到SRWebSocketDelegate的didReceiveMessage方法中來。

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

這個事件,我們在原理中講過,其實它的數據就是一個對方客戶端的一個公網IP,只不過這個公網IP是由STU Server下發的,為了NAT/防火牆穿越。

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

我們接著來看看代碼:

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

我們在這裡創建了一個RTCICECandidate實例candidate,這個實例用來標識遠端地址。並且把它添加到對應ID的peerConnection中去了。

這裡我們僅僅看到接受到遠端的_ice_candidate,但是要知道這個地址同樣是我們客戶端發出的,那麼發送是在什麼地方呢?

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

//創建peerConnection之後,從server得到響應後調用,得到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];
}

當我們創建peerConnection的時候,就會去我們一開始初始化的時候,添加的ICEServers數組中,去ICE Server地址中去請求,得到ICECandidate就會調用這個代理方法,我們在這裡用socket把自己的網絡地址發送給了對端。

講到這個ICEServers,我們這裡提一下,這裡需要一個STUN服務器,這裡我們用的是谷歌的:

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服務器可能被牆,下面這些提供給大家備用,或者可以自行搭建:

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"];
    //再去創建一個連接
    RTCPeerConnection *peerConnection = [self createPeerConnection:socketId];
    if (!_localStream)
    {
        [self createLocalStream];
    }
    //把本地流加到連接中去
    [peerConnection addStream:_localStream];
    //連接ID新加一個
    [_connectionIdArray addObject:socketId];
    //並且設置到Dic中去
    [_connectionDic setObject:peerConnection forKey:socketId];
}

這個_new_peer表示你已經在房間,這時候有新的用戶加入,這時候你需要為這個用戶再去創建一個點對點連接對象peerConnection。

並且把本地流加到這個新的對象中去,然後設置_connectionIdArray和_connectionDic。

4.第4種信令事件:_remove_peer

//有人離開房間的事件
else if ([eventName isEqualToString:@"_remove_peer"])
{
    //得到socketId,關閉這個peerConnection
    NSDictionary *dataDic = dic[@"data"];
    NSString *socketId = dataDic[@"socketId"];
    [self closePeerConnection:socketId];
}

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

/**
 *  關閉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];
        }
    });
}

關閉peerConnection,並且從_connectionIdArray、_connectionDic中移除,然後對外調用關閉連接的代理。

5.第5種信令事件:_offer

這個事件,是別人新加入房間後,會發出的offer,提出與我們建立點對點連接。

我們來看看處理:

//這個新加入的人發了個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];
    //根據類型和SDP 生成SDP描述對象
    RTCSessionDescription *remoteSdp = [[RTCSessionDescription alloc] initWithType:type sdp:sdp];
    //設置給這個點對點連接
    [peerConnection setRemoteDescriptionWithDelegate:self sessionDescription:remoteSdp];

    //把當前的ID保存下來
    _currentId = socketId;
    //設置當前角色狀態為被呼叫,(被發offer)
    _role = RoleCallee;
}

這裡我們從offer中拿到SDP,並且調用我們之前提到的setRemoteDescriptionWithDelegate設置遠端的SDP,這個設置成功後,又調回到SDP的代理方法:didSetSessionDescriptionWithError中去了。

在這代理方法我們生成了一個answer,把本機的SDP包裹起來傳了過去。如此形成了一個閉環。

6.第6種信令事件:_answer

這個事件是自己發出offer後,得到別人的awser回答,這時候我們需要做的僅僅是保存起來遠端SDP即可,到這一步兩端互相有了對方的SDP。

而兩端的事件,是當SDP和ICE  Candidate,都交換完成後,點對點連接才建立完成。

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

這個類基本上核心的東西就這些了,其他的一些零碎的小細節,包括連接成功後,遠端的流過來調用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];
        }
    });
}

在這裡我們僅僅是把這個視頻流用主線程回調出去給外部代理處理,而點對點連接關閉的時候也是這麼處理的,這樣就和我們之前提到的對外代理方法銜接起來了。

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

接著我們客戶端講完了,這裡我們略微帶過一下我們的WebSocket服務端,這裡我們仍然用的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('創建新連接');
});
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("發生錯誤:" + error.message);
});

基本上,用了這個框架,我們除了打印之外,沒有做任何的處理,所有的消息轉發,都是由框架內部識別並且處理完成的。

這裡需要提一下的是,由於作者沒有那麼富帥,沒那麼多手機,所以在這裡用浏覽器來充當一部分的客戶端,所以你會看到,這裡用了http框架,監聽了本機3000端口,如果誰調用網頁的則去渲染當前文件下的index.html。

在這裡,用index.html和SkyRTC-client.js兩個文件實現了浏覽器端的WebRTC通信,這樣就可以移動端和移動端、移動端和浏覽器、浏覽器與浏覽器之間在同一個聊天室進行視頻通話了。

至於源碼我就不講了,大家可以到demo中去查看,這個浏覽器端的代碼是我從下面文章的作者github中找來的:

  • WebRTC的RTCDataChannel

  • 使用WebRTC搭建前端視頻聊天室——信令篇

  • 使用WebRTC搭建前端視頻聊天室——入門篇

提倡大家去看看,他很詳細的講了WebRTC在Web端的實現,和iOS端實現的基本原理、流程是一樣的,只是API略有不同。

本文demo地址:WebRTC_iOS

大家在運行demo的時候需要注意以下幾點:

1.運行WebSocket服務端前,你需要用Node.js的NPM去安裝依賴包,直接用命令行CD到server.js所在目錄下:

21.png

執行npm install即可。(NPM類似Cocopods)

然後等待下載完依賴庫後,直接命令行中執行

node server.js

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

localhost:3000#100

此3000為端口號,100為聊天室房間號,如果出現以下圖像,說明Socket服務端和Web客戶端已完成。

QQ截圖20170306102157.png

2.接著我們要去運行iOS的客戶端了,首先我們需要去百度網盤下載 WebRTC頭文件和靜態庫.a。

下載完成,解壓縮,直接按照本文第二條中:iOS下WebRTC環境的搭建即可。

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

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

這裡的server地址,如果你是用和本機需要替換成localhost,而如果你是用手機等,則需要和電腦同處一個局域網(wifi下),並且IP地址一致才行。

在這裡由於我的電腦IP地址是192.168.0.7:

22.png

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

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

寫在結尾:

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

WebRTC開源之前,實時音視頻通信聽起來好高級:回聲消除、噪聲抑制……對於看到傅裡葉變換都頭疼的工程師很難搞定這些專業領域的問題。

Google收購了GIPS,開源了WebRTC項目之後,開發者可以自己折騰出互聯網音視頻通信了。下載、編譯、集成之後,第一次聽到通過互聯網傳過來的喂喂喂,工程師會非常興奮,demo到萬人直播現場只差一步了。

但是,電信行業要求可用性4個9,而剛剛讓人興奮的“喂喂喂”,1個9都到不了。某公司在展會上演示跨國音視頻,多次呼叫無法接通,自嘲說我們還沒有做網絡優化嘛。這就等於互聯網全民創業時期的”就差個程序員了“,本質上是和demo與真正產品之間的差距,是外行與內行之間的差距。

IM的路還有很長,一萬個WebRTC已經走過了一個?

  1. 上一頁:
  2. 下一頁:
蘋果刷機越獄教程| IOS教程問題解答| IOS技巧綜合| IOS7技巧| IOS8教程
Copyright © Ios教程網 All Rights Reserved