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 所示,其步骤如下:

image 2025 02 23 16 07 53 908
Figure 1. 图8.2 为iOS应用增加权限
  • 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 所示。

代码8.18 引入WebRTC库
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 所示。

代码8.19 构造RTCPeerConnectionFactory
[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 所示。

代码8.21 视频采集
capture = [[RTCCameraVideoCapturer alloc]
    initWithDelegate:videoSource];

在上面的代码中,首先通过 alloc 方法分配了一个 RTCCameraVideoCapturer 对象;然后在初始化该对象时,将 RTCVideoSource 与 RTCCameraVideoCapturer 进行了绑定。这样就可以通过 RTCCameraVideoCapturer 对象为 RTCVideoSource 指定视频输入设备了。

在 iOS 端的 WebRTC 中,可以通过 RTCCameraVideoCapturer 类获取所有的视频设备,如代码 8.22 所示。

代码8.22 获取视频采集设备
NSArray<AVCaptureDevice *> *devices =
    [RTCCameraVideoCapturer captureDevices];
AVCaptureDevice *device = devices;

在上面代码中可以看到,通过 RTCCameraVideoCapturer 类的 captureDevices() 方法可以获得 iOS 端所有的视频设备。在这些视频设备中,我们使用第一个视频设备作为默认设备。通过上面的操作,WebRTC 就将 RTCVideoSource 与 iOS 中的第一个设备联系到一起。

最后,我们还要像 Android 端一样,执行最后一步:调用 startCaptureWithDevice() 函数开启视频设备,如代码 8.23 所示。

代码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 所示。

代码8.26 引入socket.io库
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 所示。

代码8.27 创建socket.io
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 所示。

代码8.28 侦听socket.io中的joined事件
[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 所示。

代码8.31 创建RTCPeerConnection
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 是具体的实现过程。

代码8.32 创建Offer
[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。

代码8.33 设置本地SDP描述符
[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。

代码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 实现了渲染方法,一旦它收到视频数据后,视频就会被渲染出来。最终,我们就可以看到远端的视频了。