这两年来,WebRTC 越来越多地出现在人们的视野,在在线教育,在线医疗等领域的应用也越来越多。大家研究 WebRTC 的热情也越来越高涨,不过 WebRTC 的入门门槛个人觉得稍微有些高,特别是各种概念,比如 NAT 穿越,ICE,STUN,TURN,Signaling server等等,刚开始可能会觉得比较繁杂,不易理解。然后建立连接的整个过程,异步调用比较多,很容易搞混。那么这篇文章里我们会根据 WebRTC 的官方 demo AppRTC 的 iOS 版本来分析一下 WebRTC 从进入房间到建立音视频连接的过程,为了便于了解,我们本次的讨论不涉及到底层的具体实现。
我们首先来简单地了解几个概念:
因为 WebRTC 是 P2P 的,很多时候 peer 是隐藏在 NAT 之后的,没有外网的 IP 地址,如果两个 peer 都在 NAT 后面,都没有外网的 IP (或者说都不知道自己的外网 IP),是无法建立连接的。那么 NAT 穿越就是用来解决这个问题的,NAT 穿越也俗称 “P2P 打洞”。常见的两种穿越方式是 STUN 和 TURN。
最新的 STUN 定义是 Session Traversal Utilities for NAT
,可以参考 RFC5389 (https://tools.ietf.org/html/rfc5389),顾名思义,他是一个 NAT 穿越的工具,既然上面我们知道了 NAT 之后的 peer 可能不知道自己的外网 IP 是多少,那么 STUN 这个工具就可以帮助内网的主机拿到,并告诉他外网对应的 IP 地址。
TURN 是 Traversal Using Relays around NAT
的缩写,可以参考 RFC5766 (https://tools.ietf.org/html/rfc5766)。有些内网类型比较复杂,比如对称型的 NAT,STUN server 拿到的外网对应的 IP 之后,还是无法通信,这时候就需要一个服务器来做数据的中转 (也叫中继,或者 relay),这个中转服务器就叫做 TURN server。
根据统计数据表明,STUN 可以解决 85% 左右情况下的 NAT 问题,剩余的就需要 TURN 来解决。这两种穿越方式对比来看,STUN 更简单,服务器的消耗和成本比较低,但是能解决问题的场景受限制,TURN 服务器可以解决几乎所有场景下的问题(包含 STUN 可以解决的场景),但是因为需要做数据中转,所以对服务器的性能要求比较高,成本也会比较高。一般情况下会两者兼用,首先尝试 STUN,STUN 解决不了的 case 用 TURN。
ICE 是 Interactive Connectivity Establishment
的缩写,可以参考最新的RFC8445 (https://tools.ietf.org/html/rfc8445) 规范。顾名思义,ICE 就是 交互式连接建立 的意思,ICE 描述了一种使用 STUN 和 TURN 来穿越 NAT 建立 P2P 连接的一种规范。
每个 peer 可以收集到 3 种服务器地址,一个是自己网卡上绑定的 IP 地址,也叫Local Address
, 第二个是 STUN server 告诉自己的外网的地址,比如路由器上绑定的外网IP地址,叫做 Server Reflexive Address
,第三个是 TURN server 给自己创建的中转服务器 IP 地址,叫做Relayed Address
。
在 ICE 标准里,每个 peer 收集所有上述三种种类的 IP 地址和端口,并发送到对方 peer(体现了“交互”),对方也收集所有三种类型的 IP 地址和端口,然后发送给自己。这样,自己和对方都有了彼此的所有 IP 地址和端口之后,开始按照优先级建立连通性检查,一旦找到一个可以互通的连接,就开始用该连接进行音视频数据传输。
ICE 候选人,可以简单理解为就是上面所说的每个 peer 收集到的 IP 地址和端口(实际要比这个复杂,包含传输方式等等)。收集的过程,叫做 ICE candidate gathering.
然后我们说一下 SDP,SDP 是 Session Description Protocol 的缩写,可以参考 RFC4566 (https://tools.ietf.org/html/rfc4566) 规范。在介绍这个之前,我们来思考一个问题,如果我们要用 WebRTC 来进行 P2P 的视频通话,可能两端所支持的音视频格式集合不完全一致,比如一端支持 H264 和 VP8,另一端支持 VP8 和 VP9,那如何选择呢?SDP 就是来描述每个 peer 所支持的音视频格式,以及如何决定传输的音视频格式的。
上面提到的 ICE candidate 和 SDP 都需要传给对方,因为没办法直接传给对方,所以一般通过服务器来中转,这个中转的过程,并不在 WebRTC 规范里,所以使用者可以自己来实现。一般来说,可以使用 WebSocket 服务器来实现。比如建立连接的A、B 双方都连接到同样一个 WebSocket 服务器,A 发到服务器的 ICE candidate 或者 SDP,服务器都直接转发给 B,同理也会把 B 的消息转发给 A,达到交换的目的。
假设有两个人,我们姑且称作为 Alice 和 Bob,通过 AppRTC 这个 demo app,进入了同一个房间。下面这个序列图就是客户端上的整体流程,为了简化理解,这里只设定了 5 个参与者,首先是两端(Alice 和 Bob),然后是 Signaling server & webserver,最后是两个端的底层 SDK 或者所在的浏览器(浏览器场景和客户端很类似,所以放一起了)。
我们逐步的来说:
此后,Alice 和 Bob 的底层就开始进行连通性测试和协商,一旦找到一对互通的传输地址,就开始传输音视频数据,彼此就能看到对方了。
实际中的流程比这个要复杂,像 candidate gathering 是异步的,可能穿插在整个过程里面。所以有些状态需要判断。
OK 既然大致的流程我们看完了,我们来看看具体的代码实现。AppRTC 源码可以在官网 (https://webrtc.org/native-code/ios/) 上找到,这里不详细地介绍如何下载源码了
PS: AppRTC 和 Web 端 Demo (https://appr.tc) 是互通的。STUN 和 TURN server 默认用的都是 Google 提供的。
主要的逻辑集中在 ARDAppClient
这个类里面。当进入 app,输入房间号,点 Call Room 之后就执行到了 connectToRoomWithId:settings:isLoopback:
方法里。
下图是 AppRTC 的主界面
ARDAppClient: connectToRoomWithId:settings:isLoopback:
//创建peerConnectionFactory,以及对应的 video codec factory
RTCDefaultVideoDecoderFactory *decoderFactory = [[RTCDefaultVideoDecoderFactory alloc] init];
RTCDefaultVideoEncoderFactory *encoderFactory = [[RTCDefaultVideoEncoderFactory alloc] init];
encoderFactory.preferredCodec = [settings currentVideoCodecSettingFromStore];
_factory = [[RTCPeerConnectionFactory alloc] initWithEncoderFactory:encoderFactory decoderFactory:decoderFactory];
在这里,首先初始化视频编解码器的工厂类,并通过他们创建出一个 RTCPeerConnectionFactory 的实例。
随后,通过调用 appr.tc 的 API,获得 ICE server 地址,这个后面会用到。
// Request TURN.
__weak ARDAppClient *weakSelf = self;
[_turnClient requestServersWithCompletionHandler:^(NSArray *turnServers,
NSError *error) {
//此处省略错误检查代码
ARDAppClient *strongSelf = weakSelf;
[strongSelf.iceServers addObjectsFromArray:turnServers];
strongSelf.isTurnComplete = YES;
[strongSelf startSignalingIfReady];
}];
加入房间,获得 room id,有服务端返回自己是发起者还是接受者(先加入房间的是发起者,后加入的是非发起者),并连接 websocket. 这里发起者和接受者的逻辑是一样的。
// Join room on room server.
[_roomServerClient joinRoomWithRoomId:roomId
isLoopback:isLoopback
completionHandler:^(ARDJoinResponse *response, NSError *error) {
ARDAppClient *strongSelf = weakSelf;
// 此处省略错误检查代码
RTCLog(@"Joined room:%@ on room server.", roomId);
strongSelf.roomId = response.roomId; //获得roomId
strongSelf.clientId = response.clientId;
strongSelf.isInitiator = response.isInitiator; //服务器决定谁是发起者(先加入房间的是发起者,后加入的是非发起者)
for (ARDSignalingMessage *message in response.messages) {
if (message.type == kARDSignalingMessageTypeOffer ||
message.type == kARDSignalingMessageTypeAnswer) {
strongSelf.hasReceivedSdp = YES;
[strongSelf.messageQueue insertObject:message atIndex:0];
} else {
[strongSelf.messageQueue addObject:message];
}
}
//获得 WebSocket 地址
strongSelf.webSocketURL = response.webSocketURL;
strongSelf.webSocketRestURL = response.webSocketRestURL;
[strongSelf registerWithColliderIfReady]; // 连接WebSocket 服务器
[strongSelf startSignalingIfReady];
}];
在上面的最后一个方法调用里,也就是startSignalingIfReady
方法,开始主要的流程:
//RTCMediaConstraints 主要是描述音视频媒体的参数,比如分辨率大小,音频声道数等等
RTCMediaConstraints *constraints = [self defaultPeerConnectionConstraints];
RTCConfiguration *config = [[RTCConfiguration alloc] init];
RTCCertificate *pcert = [RTCCertificate generateCertificateWithParams:@{
@"expires" : @100000,
@"name" : @"RSASSA-PKCS1-v1_5"
}];
config.iceServers = _iceServers; //上面从服务器获得的 ICE server 地址
config.sdpSemantics = RTCSdpSemanticsUnifiedPlan; //这里使用 unified plan
config.certificate = pcert;
//创建 PeerConnection!!!
❶_peerConnection = [_factory peerConnectionWithConfiguration:config
constraints:constraints
delegate:self];
❷[self createMediaSenders];
if (_isInitiator) { //只有发起方创建 offer,接收方只需等待对方的offer,然后创建answer即可
// Send offer.
__weak ARDAppClient *weakSelf = self;
❸[_peerConnection offerForConstraints:[self defaultOfferConstraints]
completionHandler:^(RTCSessionDescription *sdp,
NSError *error) {
ARDAppClient *strongSelf = weakSelf;
[strongSelf peerConnection:strongSelf.peerConnection
didCreateSessionDescription:sdp
error:error];
}];
} else {
// Check if we've received an offer.
❹[self drainMessageQueueIfReady];
}
❶ 通过 RTCPeerConnectionFactory
创建 RTCPeerConnection
的实例,RTCPeerConnection 是核心类之一,把控从采集到发送这个流程。
❷ createMediaSenders
方法的实现如下,首先根据 constraints 创建 source,接着用 source 创建 track,最后把创建好的 track 添加到 peer connection 里,到这一步,底层会自动创建 media sender,所以这个方法的名字叫 ‘createMediaSenders’
source 可以理解为音视频源,track 用来描述音视频轨道,sender 是用来发送音视频数据的类,这三个概念对于音视频是独立的。这里不多做描述,有兴趣的可以看一下源码。
RTCCameraVideoCapturer
类负责视频的采集,音频的采集是使用 SDK 默认的方法,这里没有单独创建。
- (void)createMediaSenders {
//创建 local audio track,并添加到 peerconnection 中
RTCMediaConstraints *constraints = [self defaultMediaAudioConstraints];
RTCAudioSource *source = [_factory audioSourceWithConstraints:constraints];
RTCAudioTrack *track = [_factory audioTrackWithSource:source
trackId:kARDAudioTrackId];
[_peerConnection addTrack:track streamIds:@[ kARDMediaStreamId ]];
//创建 local video track 并添加到 peerconnection 中(最终会添加到 peerconnection.transceiver.sender 里)
_localVideoTrack = [self createLocalVideoTrack];
if (_localVideoTrack) {
[_peerConnection addTrack:_localVideoTrack streamIds:@[ kARDMediaStreamId ]];
[_delegate appClient:self didReceiveLocalVideoTrack:_localVideoTrack];
// We can set up rendering for the remote track right away since the transceiver already has an
// RTCRtpReceiver with a track. The track will automatically get unmuted and produce frames
// once RTP is received.
RTCVideoTrack *track = (RTCVideoTrack *)([self videoTransceiver].receiver.track);
[_delegate appClient:self didReceiveRemoteVideoTrack:track];
}
}
//只保留了核心逻辑,其他省略了
- (RTCVideoTrack *)createLocalVideoTrack {
//factory 创建一个新的 source
RTCVideoSource *source = [_factory videoSource];
//用 source 创建一个 RTCCameraVideoCapturer,RTCCameraVideoCapturer 负责 iOS 上的采集,
//采集到的视频帧发送给 delegate。RTCVideoSource 实现了 RTCVideoCapturerDelegate,
//所以可以接收到 RTCCameraVideoCapturer 采集到的视频帧
RTCCameraVideoCapturer *capturer = [[RTCCameraVideoCapturer alloc] initWithDelegate:source];
//这里的delegate 就是 ARDVideoCallViewController,它收到消息后,开始通过 capturer 进行视频采集
[_delegate appClient:self didCreateLocalCapturer:capturer];
return [_factory videoTrackWithSource:source trackId:kARDVideoTrackId];
}
❸ 发起方创建 offer,callback 里拿到 SDP,然后调 delegate 的 didCreateSessionDescription
回调方法,delegate 里setLocalDescription
,然后通过 signaling server(WebSocket)发送给 remote peer. setLocalDescription
之后,就启动了 ICE candidate gathering,gather 之后 delegate 就会收到 - (void)peerConnection:(RTCPeerConnection *)peerConnection didGenerateIceCandidate:(RTCIceCandidate *)candidate
方法的调用❺。
- (void)peerConnection:(RTCPeerConnection *)peerConnection
didCreateSessionDescription:(RTCSessionDescription *)sdp
error:(NSError *)error {
dispatch_async(dispatch_get_main_queue(), ^{
//...省略错误检查
__weak ARDAppClient *weakSelf = self;
//设置 setLocalDescription
[self.peerConnection setLocalDescription:sdp
completionHandler:^(NSError *error) {
ARDAppClient *strongSelf = weakSelf;
[strongSelf peerConnection:strongSelf.peerConnection
didSetSessionDescriptionWithError:error];
}];
ARDSessionDescriptionMessage *message =
[[ARDSessionDescriptionMessage alloc] initWithDescription:sdp];
//发送 SDP 到 remote peer
[self sendSignalingMessage:message];
//设置 video sender 的最大码率
[self setMaxBitrateForPeerConnectionVideoSender];
});
}
❹ 接收方在 drainMessageQueueIfReady
方法里处理 WebSocket 的消息,如果有发起方发来的 Offer 消息的话,则创建 Answer 发给对方。
case kARDSignalingMessageTypeAnswer: {
ARDSessionDescriptionMessage *sdpMessage =
(ARDSessionDescriptionMessage *)message;
RTCSessionDescription *description = sdpMessage.sessionDescription;
__weak ARDAppClient *weakSelf = self;
//把对方的 SDP 设置为 remote SDP
[_peerConnection setRemoteDescription:description
completionHandler:^(NSError *error) {
ARDAppClient *strongSelf = weakSelf;
//回调 delegate
[strongSelf peerConnection:strongSelf.peerConnection
didSetSessionDescriptionWithError:error];
}];
break;
}
接收方 create answer,并把 SDP 发送给发起方。
- (void)peerConnection:(RTCPeerConnection *)peerConnection
didSetSessionDescriptionWithError:(NSError *)error {
dispatch_async(dispatch_get_main_queue(), ^{
//省略错误处理
// If we're answering and we've just set the remote offer we need to create
// an answer and set the local description.
if (!self.isInitiator && !self.peerConnection.localDescription) {
RTCMediaConstraints *constraints = [self defaultAnswerConstraints];
__weak ARDAppClient *weakSelf = self;
//创建 Answer
[self.peerConnection answerForConstraints:constraints
completionHandler:^(RTCSessionDescription *sdp, NSError *error) {
ARDAppClient *strongSelf = weakSelf;
//回调 delegate 的 didCreateSessionDescription 方法(和发起者创建完 offer 回调的方法一致,行为也一致,
//首先 setLocalDescription,然后发送到 remote peer,然后设置最大发送码率)
[strongSelf peerConnection:strongSelf.peerConnection
didCreateSessionDescription:sdp
error:error];
}];
}
});
}
- (void)peerConnection:(RTCPeerConnection *)peerConnection
didCreateSessionDescription:(RTCSessionDescription *)sdp
error:(NSError *)error {
dispatch_async(dispatch_get_main_queue(), ^{
//...省略错误检查
__weak ARDAppClient *weakSelf = self;
//设置 setLocalDescription
[self.peerConnection setLocalDescription:sdp
completionHandler:^(NSError *error) {
ARDAppClient *strongSelf = weakSelf;
[strongSelf peerConnection:strongSelf.peerConnection
didSetSessionDescriptionWithError:error];
}];
ARDSessionDescriptionMessage *message =
[[ARDSessionDescriptionMessage alloc] initWithDescription:sdp];
//发送 SDP 到 remote peer
[self sendSignalingMessage:message];
//设置 video sender 的最大码率
[self setMaxBitrateForPeerConnectionVideoSender];
});
}
❺ didGenerateIceCandidate
回调里,不管事发起方还是接收方,都把获取到的 ICE candidate,通过 signaling server 发给 remote peer 即可, 对于获取到的 ICE candidate 无需做其他任何的处理。
- (void)peerConnection:(RTCPeerConnection *)peerConnection
didGenerateIceCandidate:(RTCIceCandidate *)candidate {
dispatch_async(dispatch_get_main_queue(), ^{
ARDICECandidateMessage *message =
[[ARDICECandidateMessage alloc] initWithCandidate:candidate];
[self sendSignalingMessage:message];
});
}
对方收到之后,添加到 peer connection 里,也无需做其他的处理。
- (void)processSignalingMessage:(ARDSignalingMessage *)message {
NSParameterAssert(_peerConnection ||
message.type == kARDSignalingMessageTypeBye);
switch (message.type) {
//...省略
case kARDSignalingMessageTypeCandidate: {
ARDICECandidateMessage *candidateMessage =
(ARDICECandidateMessage *)message;
[_peerConnection addIceCandidate:candidateMessage.candidate];
break;
}
//...省略
}
}
同理,对方在 setLocalDescription
之后,也开始 gather ICE candidate,并把收集到的 ICE candidate 发给对方。当两端都有了双方的 candidate,就开始做连通性检查,找到一条双方都可以通信的通道之后(这一部分底层帮我们做了,如果是 Web 端程序,是浏览器做的,如果对连通性检查这块感兴趣,可以参考 rfc-8445 - 2.2 Connectivity Checks (https://tools.ietf.org/html/rfc8445#section-2.2)),双方即可相互传递发送数据了,至此彼此也可以看到对方了。
如果发生异常情况,比如网络条件恶化,单方或者双方建议切换到低分辨率或者修改编码器,将会触发新的一轮 ICE candidate 的收集和交换。
Web 端上的整个过程和这个很相似,区别是采集阶段,获取摄像头和麦克风的输入调用的是 getUserMedia() 方法,app 里是用的 native 的方式,比如 iOS 是用 RTCCameraVideoCapturer 采集视频的(内部实现是通过 AVCaptureSession 来实现的)。