iOS端的实现
iOS 端实现音视频一对一通信与 Android 端基本相同,如果说有区别的话,最大的区别可能就是语言方面的差异。下面按照与 Android 端相同的过程来介绍 iOS 端的实现。其具体步骤包括:1)申请权限;2)引入WebRTC库;3)构造 RTCPeerConnectionFactory;4)创建音视频源;5)视频采集;6)本地视频预览;7)建立信令系统;8)创建 RTCPeerConnection;9)远端视频渲染。通过上面的步骤,可以全面了解在 iOS 端如何通过 WebRTC Native 实现音视频一对一实时互动系统。
申请权限
首先我们看一下 iOS 端是如何获取访问音视频设备权限的。相比 Android 端,iOS 端获取相关权限要容易很多。如图 8.2 所示,其步骤如下:

-
1)在 XCode 中打开项目,点击左侧目录中的项目。
-
2)在左侧目录中找到 Info.plist,并将其打开。
-
3)点击右侧中看到的 “+” 号。
-
4)添加 Camera 和 Microphone 访问权限。
通过以上步骤,我们就将 iOS 端访问音视频设备的权限申请好了。申请好权限后,接下来是在 iOS 端引入 WebRTC 库。
引入WebRTC库
在 iOS 端引入 WebRTC 库有两种方法:方法一,通过 WebRTC 源码编译出 WebRTC 库,然后在项目中手动引入它——这种方法对于大多数刚入门的读者来说是比较困难的;方法二,官方会定期发布编译好的 WebRTC 库,可以使用 Pod 工具将其安装到项目中。在以下讲解中,我们使用的是第二种方法。
使用第二种方法引入 WebRTC 库非常简单,首先需要创建一个 Podfile 文件,然后在 Podfile 中指定下载 WebRTC 库的地址以及要安装的库的名字。Podfile 文件的具体格式和内容如代码 8.18 所示。
source 'https://github.com/CocoaPods/Specs.git'
platform :ios, '11.0'
target 'WebRTC4iOS2' do
pod 'GoogleWebRTC'
end
在上面的代码中,第 1 行的 source 指定了 WebRTC 库文件的下载地址,第 3 行的 platform 指明了使用该库的操作系统及操作系统版本,第 5 行的 target 指明了项目的名字,第 7 行的 pod 指定要安装哪个库。
Podfile 文件创建好后,还需要在 Podfile 文件目录下执行 pod install 命令,这样 pod 工具才会读取 Podfile 中的内容,并按照 Podfile 中的指令执行操作,最终将 WebRTC 库下载下来。
执行 pod install 命令时,pod 工具除了按要求下载 WebRTC 库之外,还会产生一个新的项目文件,即 {project}.xcworkspace
。在该文件里,已经将项目文件与刚下载好的依赖库(WebRTC)建立好关联关系。至此,WebRTC 库就算引入成功了。
构造RTCPeerConnectionFactory
iOS 端与 Android 端一样,也有 PeerConnectionFactory 类。不过它们在命名上有一点区别,在所有 WebRTC 类名的前面都增加了 RTC 前缀,所以 iOS 下 PeerConnectionFactory 类的名字就变成了 RTCPeerConnectionFactory。
RTCPeerConnectionFactory 类的重要性已在本章的开头做过介绍,可以说是 WebRTC Native 开发的 “万物的根源”。iOS 端的 RTCVideoSource、RTCVideoTrack、RTCPeer Connection 等对象,都是通过 RTCPeerConnectionFactory 创建的。接下来了解一下 iOS 端 RTCPeerConnectionFactory 对象是如何创建出来的,如代码 8.19 所示。
[RTCPeerConnectionFactory initialize];
// 如果点对点工厂为空
if (!factory) {
RTCDefaultVideoDecoderFactory *decoderFactory =
[[RTCDefaultVideoDecoderFactory alloc] init];
RTCDefaultVideoEncoderFactory *encoderFactory =
[[RTCDefaultVideoEncoderFactory alloc] init];
NSArray *codecs = [encoderFactory supportedCodecs];
[encoderFactory setPreferredCodec:codecs];
factory = [[RTCPeerConnectionFactory alloc]
initWithEncoderFactory:encoderFactory
decoderFactory:decoderFactory];
}
在上面的代码中,首先要调用 RTCPeerConnectionFactory 类的 initialize() 方法对其进行初始化,然后创建 factory 对象。需要注意的是,在创建 factory 对象时,传入了两个参数:一个是默认的编码器,另一个是默认的解码器。可以通过修改这两个参数来达到使用不同编解码器的目的。有了 factory 对象后,就可以创建其他对象了。
创建音视频源
与 Android 端一样,WebRTC 也为 iOS 端提供了音视频源(RTCVideoSource)。一方面,它作为源为 Track 提供了媒体数据;另一方面,它也是一个终点,我们从设备采集到数据后,都交由它暂存起来以备后用。
音视频源对象(RTCAudioSource/RTCVideoSource)是由上面介绍的 RTCPeerConnectionFactory 对象创建出来的,具体如代码 8.20 所示。
RTCAudioSource *audioSource = [factory audioSource];
RTCAudioTrack *audioTrack =
[factory audioTrackWithSource:audioSource trackId:@"ADRAMSa0"];
RTCVideoSource *videoSource = [factory videoSource];
RTCVideoTrack *videoTrack =
[factory videoTrackWithSource:videoSource trackId:@"ADRAMSv0"];
在上面的代码中,通过 RTCPeerConnectionFactory 对象创建了两个数据源:音频数据源(RTCAudioSource)和视频数据源(RTCVideoSource)。然后又用 RTCPeerConnectionFactory 对象创建了 RTCAudioTrack 和 RTCVideoTrack,并在创建时让它们与 RTCAudioSource 和 RTCVideoSource 进行了关联,使之分别作为 RTCAudioSource 和 RTCVideoSource 的输出。这样上层逻辑就可以通过 RTCAudioTrack 和 RTCVideoTrack 获得音/视频媒体流了。
视频采集
RTCAudioSource 和 RTCVideoSource 的输出设置好后,接下来为它们指定输入设备。与 Android 端一样,iOS 端的 RTCAudioSource 是不需要显式设置音频输入设备的,因为移动端音频设备的切换是在底层自动完成的。比如将手机放在耳边时,WebRTC 会将音频设备变成听筒模式;如果插上耳机,它又会将音频设备变成耳机模式。
那么视频设备该如何指定呢?为了能方便地控制视频设备,WebRTC 提供了一个专门用于操作设备的类,即 RTCCameraVideoCapturer。我们来看看如何通过它控制 iOS 端的视频设备。如代码 8.21 所示。
capture = [[RTCCameraVideoCapturer alloc]
initWithDelegate:videoSource];
在上面的代码中,首先通过 alloc 方法分配了一个 RTCCameraVideoCapturer 对象;然后在初始化该对象时,将 RTCVideoSource 与 RTCCameraVideoCapturer 进行了绑定。这样就可以通过 RTCCameraVideoCapturer 对象为 RTCVideoSource 指定视频输入设备了。
在 iOS 端的 WebRTC 中,可以通过 RTCCameraVideoCapturer 类获取所有的视频设备,如代码 8.22 所示。
NSArray<AVCaptureDevice *> *devices =
[RTCCameraVideoCapturer captureDevices];
AVCaptureDevice *device = devices;
在上面代码中可以看到,通过 RTCCameraVideoCapturer 类的 captureDevices() 方法可以获得 iOS 端所有的视频设备。在这些视频设备中,我们使用第一个视频设备作为默认设备。通过上面的操作,WebRTC 就将 RTCVideoSource 与 iOS 中的第一个设备联系到一起。
最后,我们还要像 Android 端一样,执行最后一步:调用 startCaptureWithDevice() 函数开启视频设备,如代码 8.23 所示。
[capture startCaptureWithDevice:device
format:format
fps:fps];
至此,iOS 端视频采集的工作就全部完成了,音视频数据流从设备采集后源源不断地经 RTCAudioSource 和 RTCVideoSource 流到 RTCAudioTrack 和 RTCVideoTrack。
本地视频预览
接下来的问题就是如何将采集到的视频在本地展示出来。在 iOS 端,WebRTC 准备了两种View:一种是 RTCCameraPreviewView,专门用于预览本地视频;另一种是 RTCEAGLVideoView,用于显示远端视频。
通过上面的介绍可以知道,在渲染本地视频时 iOS 端与 Android 端使用的方式有很大不同。在 Android 端,本地视频和远端视频使用的是同一种 View;而在 iOS 端,本地与远端使用的却是不同的 View。
由于 iOS 端本地视频和远端视频使用的 View 不同,也导致了它们获取视频数据时使用的源不同。本地视频不再从 RTCVideoTrack 获得数据,而是直接从 RTCCameraVideoCapturer 获取,这样代码的执行效率会更高。之所以有这样的差别,根本原因还是因为操作系统底层的实现方式不一样。通过代码 8.24 可以了解具体的实现。
@property (strong, nonatomic) RTCCameraPreviewView *localVideoView;
- (void)viewDidLoad {
CGRect bounds = self.view.bounds;
self.localVideoView = [[RTCCameraPreviewView alloc]
initWithFrame:CGRectZero];
[self.view addSubview:self.localVideoView];
CGRect localVideoFrame = CGRectMake(0, 0, bounds.size.width, bounds.size.height);
[self.localVideoView setFrame:localVideoFrame];
}
在上面的代码中,viewDidLoad() 函数对理解整个代码起着至关重要的作用。viewDidLoad() 是 iOS 中非常关键的一个函数,在应用程序启动后被调用,属于应用程序生命周期的开始阶段,大部分变量的初始化工作都是由该函数完成的。对 iOS 开发不熟悉的读者可以自行学习 iOS 应用生命周期的内容。
在代码 8.24 中,首先定义了一个 RTCCameraPreviewView 类型的 View,即 localVideoView;然后在 viewDidLoad() 函数中(也就是应用程序启动后),使用 alloc() 函数对其分配内存空间并进行初始化;之后将 localVideoView 实例添加到应用程序的 Main View 中(第 10 行代码),这样本地视频 View 才有机会被显示出来;最后对视频 View 的大小和显示的位置进行设置(第 13∼17 行代码)。
当 localVideoView 准备就绪后,就可以将它与 RTCCameraVideoCapturer 关联到一起了。它们关联到一起的方法非常的简单,只需要在调用 capture 的 startCaptureWithDevice 方法之前执行代码 8.25 即可。
self.localVideoView.captureSession = capture.captureSession;
将 RTCCameraVideoCapturer 的 session 赋值给 localVideoView 的 captureSession 之后,localVideoView 就可以从 RTCCameraVideoCapturer 上获取数据并对其进行渲染。此时,通过 localVideoView 就可以预览本地视频。
建立信令系统
在任何系统中,信令都是系统的灵魂。音视频互动系统也不例外,如通信双方发起呼叫的顺序、媒体协商、Candidate 交换等操作都是由信令系统控制的。
为了实现多种终端的互联互通,各终端必须使用同一套信令系统。因此,iOS 端使用的信令与第 5 章介绍的信令是一模一样的。
除了信令相同外,在 iOS 端我们仍然使用 socket.io 库与信令服务器对接。之所以选择 socket.io 作为信令通信的基础库有两方面的原因:一方面,由于 socket.io 是一个跨平台的通信库,所以使用它可以让代码在各平台上保持相同的实现逻辑,这样的开发成本最低;另一方面,socket.io 使用简单,功能又非常强大,所以使用 socket.io 作为信令的通信库是一个特别好的选择。
不过需要注意的是,iOS 端的 socket.io 是用 Swift 语言实现的,而我们要实现的音视频互动系统则是用 Object-C 实现的。这就带来一个问题,OC(Object-C)可以直接调用 Swift 语言开发的库吗?实际上,苹果的 XCode 开发工具已经提供了解决方案,只需要在 Podfile 中增加 use_frameworks!指令即可,这样 OC 就可以直接使用 Swift 语言开发的库了。所以现在音视频互动项目中的 Podfile 文件应该如代码 8.26 所示。
source 'https://github.com/CocoaPods/Specs.git'
use_frameworks!
target 'WebRTC4iOS2' do
pod 'Socket.IO-Client-Swift', '~> 13.3.0'
end
当 socket.io 库成功引入后,接下来是在 iOS 端使用 socket.io。在 iOS 下使用 socket.io 分为三步:
-
第一步,通过 url 获取 SocketIOClient 对象。有了 SocketIOClient 之后就可以建立与服务器的连接了。
-
第二步,注册侦听的消息,并为每个侦听的消息绑定一个处理函数。当收到服务器的消息后,随之会触发绑定的函数。
-
第三步,通过 SocketIOClient 建立的连接发送消息。
下面看看它们是如何实现的。首先是 socket.io 如何通过 url 获取 SocketIOClient 对象。在 iOS 端使用 socket.io 获取 SocketIOClient 非常简单,如代码 8.27 所示。
SocketIOClient *socket;
NSURL *url = [[NSURL alloc] initWithString:addr];
manager = [[SocketManager alloc]
initWithSocketURL:url
config:@{
@"log": @YES,
@"forcePolling": @YES,
@"forceWebsockets": @YES
}];
socket = manager.defaultSocket;
使用 socket.io 获取 SocketIOClient 对象只需要上面代码中的三个 API 就可以了,这也是 socket.io 获取 SocketIOClient 的固定方法。
接下来是第二步,为 socket.io 注册侦听消息。使用 socket.io 注册侦听消息的格式如代码 8.28 所示。
[socket on:@"joined"
callback:^(NSArray *data, SocketAckEmitter *ack) {
NSString *room = [data objectAtIndex:0];
NSLog(@"joined room(%@)", room);
// Your implementation here
}];
在上面的代码中为 socket.io 注册了一个 joined 消息,并为该消息绑定了一个匿名的回调函数。当 iOS 端收到服务端发来的 joined 消息后,对应的回调函数就会被触发。在回调函数中,可以通过 data 参数获取服务端发送 joined 消息时带的 roomID 参数。
同理,若想侦听一些其他的消息,只要按照上面注册 joined 消息的格式逐一将这些消息注册到 socket.io 里就可以了。
最后是第三步,通过 SocketIOClient 建立连接并发送消息。这个就更简单了,直接调用 SocketIOClient 对象的 connect 方法即可,如代码 8.29 所示。
[socket connect];
socket.io 的连接创建好后,就可以利用它来发送消息了。代码 8.30 就是使用 socket.io 发送消息的例子。
if (socket.status == SocketIOStatusConnected) {
[socket emit:@"join" with:@[room]];
}
与 JS 中使用的 socket.io 一样,它也是使用 emit 方法发送消息。在发送消息时,也可以让它带一些参数,这些参数都被放在一个数组里。当然在发送消息前,最好先判断一下 socket 是否已经处于连接状态,只有 socket 处于连接状态时,消息才能被真正地发送出去。
创建RTCPeerConnection
信令系统建立好后,后面的逻辑都是围绕着信令系统建立起来的。RTCPeerConnection 对象的建立也不例外。
两个客户端之间要进行通话,必须先要加入同一个房间,即每个客户端都要向服务器发送 join 消息。服务器收到消息后,如果判定用户是合法的,则会给客户端返回 joined 消息。客户端收到 joined 消息后,就要创建 RTCPeerConnection 了,也就是要建立一条与远端通话的音视频数据传输通道。那么 RTCPeerConnection 对象是如何建立起来的呢?如代码 8.31 所示。
if (!ICEServers) {
ICEServers = [NSMutableArray array];
[ICEServers addObject:[self defaultSTUNServer]];
}
RTCConfiguration *configuration = [[RTCConfiguration alloc] init];
[configuration setIceServers:ICEServers];
RTCPeerConnection *conn = [factory
peerConnectionWithConfiguration:configuration
constraints:[self defaultPeerConnConstraints]
delegate:self];
上面的代码就是 RTCPeerConnection 对象创建的过程。首先 RTCPeerConnection 对象是由 factory 创建的,这与 Android 端一样;其次 RTCPeerConnection 对象有三个参数。
-
第一个参数,是 RTCConfiguration 类型的对象。该对象中最重要的字段是 iceServers,里边存放了 stun/turn 服务器地址,用于 NAT 穿越。
-
第二个参数,是 RTCMediaConstraints 类型对象。其作用是限制 RTCPeerConnection 对象的行为,如是否接收视频数据?是否接收音频数据?如果要与浏览器互通还要开启 DtlsSrtpKeyAgreement 选项,等等。
-
第三个参数,是委托类型。可以认为它是 RTCPeerConnection 对象的观察者,RTCPeerConnection 对象可以将一些状态或任务交给它处理。
通过上面的步骤,我们就将 RTCPeerConnection 对象创建好了。但此时通信的双方仍然不能进行音视频数据的互传,因为 RTCPeerConnection 对象之间还没有进行媒体协商,当然也就没有建立物理连接。
在 iOS 端如何让通信的双方建立起物理连接?这部分内容已经在第 5 章中介绍过了,只有通信双方在完成媒体协商并交换 Candidate 之后,RTCPeerConnection 才会真正将物理连接建立起来。在 iOS 端,媒体协商过程与 JS 端是一模一样的,代码 8.32 是具体的实现过程。
[pc offerForConstraints:[self defaultPeerConnConstraints]
completionHandler:^(RTCSessionDescription *sdp, NSError *error) {
if (error) {
// Handle error
} else {
// Handle success
}
}];
在上面的代码中,RTCPeerConnection 对象调用其 offerForConstraints 方法创建 Offer SDP。该方法有两个参数:一个是 RTCMediaConstraints 类型的参数,已在前面介绍创建 RTCPeerConnection 对象时讲过;另一个是匿名回调函数,当 WebRTC 底层创建 SDP 有结果后,会回调该函数。
可以通过判断回调函数中的 error 是否为空来判定 offerForConstraints 方法有没有执行成功。如果 error 为空,说明 SDP 创建成功了,回调函数的 sdp 参数里就保存着创建好的 SDP 内容。此时,可以调用 RTCPeerConnection 对象的 setLocalOffer 方法将生成的 SDP 在本地保存起来,具体参见代码 8.33。
[pc setLocalDescription:sdp
completionHandler:^(NSError * _Nullable error) {
if (!error) {
// Handle success
} else {
// Handle error
}
}];
当调用 setLocalDescription 函数将 SDP 保存到本地后,就可以通过 socket.io 将 SDP 发送给服务器并由服务器中转给对端了。
当协商完成之后,紧接着会交换 Candidate,此时 WebRTC 底层开始建立物理连接。物理连接完成后,双方就开始音视频数据传输。
远端视频渲染
在 iOS 端,远端视频渲染与本地视频预览的逻辑是完全不同的。对于 iOS 端而言,渲染远端视频其实是比较容易的,只要将 RTCEAGLVideoView 对象与远端视频的 Track 关联到一起即可,具体参见代码 8.34。
RTCEAGLVideoView *remoteVideoView;
// 该方法在侦听到远端track时会触发
- (void)peerConnection:…
didAddReceiver:… rtpReceiver
streams:… {
RTCMediaStreamTrack *track = rtpReceiver.track;
if ([track.kind isEqualToString:kRTCMediaStreamTrackKindVideo]) {
if (!self.remoteVideoView) {
// Initialize remoteVideoView
}
remoteVideoTrack = (RTCVideoTrack *)track;
[remoteVideoTrack addRenderer:self.remoteVideoView];
}
}
在上面的代码中,peerConnection:didAddReceiver:streams 函数与 JS 中的 ontrack 类似,当有远端的流过来时,WebRTC 底层会调用该函数。peerConnection:didAddReceiver:streams 的第二个参数 rtpReceiver 非常重要,可以通过它获得远端的 track(代码第 7 行)。获得 track 后,将它添加到 remoteVideoTrack 中即可,这样 remoteVideoView 就可以从 track 中获取视频数据了。
实际上,WebRTC 为 remoteVideoView 实现了渲染方法,一旦它收到视频数据后,视频就会被渲染出来。最终,我们就可以看到远端的视频了。