2021-12-24

基于WebRTC实现P2P实时音视频

音视频技术图谱

整个音视频涉及到的技术很多,对于初学者来说有些迷茫,所以我们要先了解整个音视频的技术图谱,选择其中的某个技术点作为切入点,入门相对容易。

7D3A2DC4-EF9C-4FDE-923B-B793863624C2.png

这次我们研究WebRTC实时音视频处理。通过此次研究,我们可以学到什么?

  1. 了解WebRTC交互流程
  2. coturn服务器搭建
  3. 信令服务器开发
  4. iOS音视频客户端开发

一.了解WebRTC交互流程

什么是WebRTC?
WebRTC全称是Web Real-Time communication,是一种实时音视频通讯技术,虽然前缀是Web,但其实是跨平台技术,可以在Broswer、iOS、Android、PC上使用,通过WebRTC可以使应用端建立点对点的连接,并实时传输数据,可以进行P2P文件共享,处理大量音频数据,实现在线视频会议等等。

WEBRTC结构
完整的WebRTC框架,分为 Server端、Client端两大部分。

  • Server端:Stun服务器 : 服务器用于获取设备的外部网络地址Turn服务器 : 服务器是在点对点失败后用于通信中继信令服务器 : 负责端到端的连接。两端在连接之初,需要交换信令,如sdp、candidate等,都是通过信令服务器 进行转发交换的。
  • Client有四大应用端:Android iOS PC Broswer
2185643-466146bf36419322.png.jpg

整个webrtc连接的流程说明
其主要流程如上图所示, 具体流程说明如下:

  1. 客户端通过socket, 和服务器建立起TCP长链接, 这部分WebRTC并没有提供相应的API, 所以这里可以借助第三方框架
  2. 客户端通过信令服务器, 进行offer SDP 握手

SDP(Session Description Protocol):描述建立音视频连接的一些属性,如音频的编码格式、视频的编码格式、是否接收/发送音视频等等
SDP 是通过webrtc框架里面的PeerConnection所创建

  1. 客户端通过信令服务器, 进行Candidate 握手

Candidate:主要包含了相关方的IP信息,包括自身局域网的ip、公网ip、turn服务器ip、stun服务器ip等
Candidate 是通过webrtc框架里面的PeerConnection所创建

  1. 客户端在SDP 和Candidate握手成功后, 就建立起一个P2P端对端的链接, 视频流就能直接传输, 不需要经过服务器

二. coturn服务器搭建

在公网搭建一套 STUN/TURN 服务并不难。首先要有一台云主机,我随便买了个腾讯云。
目前比较流行的 STUN/TURN 服务器是 coturn,使用它搭建 STUN/TURN 服务非常的方便。
下面我们就来看一下它的基本步骤:

  • 获取 coturn 源码

git clone https://github.com/coturn/coturn.git

  • 编译安装

cd coturn
./configure --prefix=/usr/local/coturn
sudo make -j 4 && make install

  • 配置 coturn

修改如下:

listening-port=3478 #指定侦听的端口

external-ip=81.68.165.250 #指定云主机的公网IP地址

user=aaaaaa:bbbbbb #访问 stun/turn服务的用户名和密码

所以,只需将上面 3 行配置项写入到 /usr/local/coturn/etc/turnserver.conf 配置文件中,stun/turn 服务就配置好了。

  • 启动 stun/turn 服务

cd /usr/local/coturn/bin
turnserver -c ../etc/turnserver.conf

  • 测试 stun/turn 服务

打开 trickle-ice ,按里面的要求输入 stun/turn 地址、用户和密码后就可以探测stun/turn服务是否正常了。

以我的配置为例,输入的信息分别是:

  1. STUN or TURN URI 的值为: turn:81.68.165.250:3478
  2. 用户名为: aaaaaa
  3. 密码为: bbbbbb

测试的结果如下图所示:


D5C7F67C-E139-4145-9ABD-F3D688EBA69F.png

从上图我们可以看到该服务提供了 stun(srflx)和turn(relay)两种服务。
STUN/TURN布署好后,就可以使用它进行多媒体数据的传输了,可以解决 NAT 和防火墙的原因导致双方无法通信的问题。

三. 信令服务器开发

信令服务器用于交换三种类型的信息:

  • 会话控制消息:初始化/关闭,各种业务逻辑消息以及错误报告。
  • 网络相关:外部可以识别的IP地址和端口。
  • 媒体能力:客户端能控制的编解码器、分辩率,以及它想与谁通讯。

你可以根据自己的喜好选择服务器(如 Apache,Nginx 或 Nodejs),我使用的是Nodejs。
由于篇幅原因,具体搭建不再详细说明。有兴趣的同学可以看此文档 https://webrtc.org.cn/webrtc-tutorial-1-setup-signaling/

具体代码如下:

// 引入websocket
const websocket = require('ws');

const ws = new websocket.Server({ port: 7080 }, () => {
    console.log("ws:// 0.0.0.0:" + 7080);
});// 创建一个websocket对象,监听端口7080

// 保存连接socket对象的set容器
var clients = new Set();

// 保存会话的sesssion容器
var sessions = [];

// 刷新房间内人员信息
function updatePeers() {
    var peers = [];
    clients.forEach(function (client) {
        var peer = {};

        if (client.hasOwnProperty('id')) {
            peer.id = client.id;
        }

        if (client.hasOwnProperty('name')) {
            peer.name = client.name;
        }

        if (client.hasOwnProperty('session_id')) {
            peer.session_id = client.session_id;
        }
        peers.push(peer);
    });

    var msg = {
        type: "peers",
        data: peers
    };

    clients.forEach(function (client) {
        send(client, JSON.stringify(msg));
    });
}

// 连接处理
ws.on('connection', function connection(client_self) {
    clients.add(client_self);

    //收到消息处理
    client_self.on('message', function (message) {
        try {
            message = JSON.parse(message);
            console.log("message.type::: " + message.type + ", \n body: " + JSON.stringify(message));

        } catch (e) {
            console.log(e.message);
        }

        switch (message.type) {
            // 新成员加入
            case 'new':
                {
                    client_self.id = "" + message.id;
                    client_self.name = message.name;
                    client_self.user_agent = message.user_agent;
                    // 向客户端发送有新用户进入房间需要刷新
                    updatePeers();
                }
                break;

            // 离开房间
            case 'bye':
                {
                    var session = null;
                    sessions.forEach((sess) => {
                        if (sess.id == message.session_id) {
                            session = sess;
                        }

                    });

                    if (!session) {
                        var msg = {
                            type: "error", data: {
                                error: "Invalid session" + message.session_id,
                            }
                        };
                        send(client_self, JSON.stringify(msg));
                        return;
                    }

                    clients.forEach((client) => {
                        if (client.session_id === message.session_id) {
                            var msg = {
                                type: "bye",
                                data: {
                                    session_id: message.session_id,
                                    from: message.from,
                                    to: (client.id == session.from ? session.to : session.from),
                                }
                            };
                            send(client,JSON.stringify(msg));

                        }
                    });

                    break;
                }
            // 转发offer
            case "offer": {
                var peer = null;
                clients.forEach(function (client) {
                    if (client.hasOwnProperty('id') && client.id === "" + message.to) {
                        peer = client;
                    }
                });
                if (peer != null) {
                    msg = {
                        type: "offer",
                        data: {
                            to: peer.id,
                            from: client_self.id,
                            session_id: message.session_id,
                            description: message.description,
                        }
                    }
                    send(peer, JSON.stringify(msg));

                    peer.session_id = message.session_id;
                    client_self.session_id = message.session_id;

                    let session = {
                        id: message.session_id,
                        from: client_self.id,
                        to: peer.id
                    };
                    sessions.push(session);
                }
            }
                break;
            // 转发answer
            case 'answer':
                {
                    var msg = {
                        type: "answer",
                        data: {
                            to: message.to,
                            from: client_self.id,
                            description: message.description,
                        }
                    };

                    clients.forEach(function (client) {
                        if (client.id === "" + message.to &&
                            client.session_id === message.session_id) {
                            send(client, JSON.stringify(msg));
                        }
                    });
                }
                break;

            // 收到候选者转发 candidate
            case 'candidate':
                {
                    var msg = {
                        type: "candidate",
                        data: {
                            from: client_self.id,
                            to: message.to,
                            candidate: message.candidate
                        }
                    };

                    clients.forEach(function (client) {
                        if (client.id === "" + message.to &&
                            client.session_id === message.session_id) {
                            send(client, JSON.stringify(msg));
                        }
                    });
                }
                break;
            // keepalive 心跳
            case "keepalive":
                {
                    send(client_self, JSON.stringify({ type: 'keepalive', data: {} }));
                }
                break;
        }
    });
});


// 发送消息
function send(client, message) {
    try {
        client.send(message);
    } catch (e) {
        console.log("Send failure !:" + e);
    }
}

使用node命令部署服务

ubuntu@VM-0-13-ubuntu:~/webrtc_server$ node server.js

四. iOS音视频客户端开发

具体步骤如下:

  • 权限申请
  • 引入 WebRTC 库、socket库
  • 采集并显示本地视频
  • 信令驱动
  • 创建音视频数据通道
  • 媒体协商
  • 渲染远端视频

申请权限
需要申请麦克风、摄像头权限。

引入 WebRTC 库、socket库

pod 'SocketRocket'
pod 'GoogleWebRTC'

采集并显示本地视频

- (void)captureLocalMedia:(RTCCameraPreviewView *)localView {
    NSDictionary *mandatoryConstraints = @{};
    RTCMediaConstraints *constrains = [[RTCMediaConstraints alloc] initWithMandatoryConstraints:mandatoryConstraints optionalConstraints:nil];
    RTCAudioTrack *audioSource = [_factory audioSourceWithConstraints:constrains];
    _audioTrack = [_factory audioTrackWithSource:audioSource trackId:KARDAudioTrackId];

    NSArray<AVCaptureDevice *> *captureDevices = [RTCCameraVideoCapturer captureDevices];
    AVCaptureDevicePosition position = AVCaptureDevicePositionFront;

    if (captureDevices.count > 0) {
        AVCaptureDevice *device = captureDevices[0];
        for (AVCaptureDevice *obj in captureDevices) {
            if (obj.position == position) {
                device = obj;
                break;
            }
        }

        // 检测摄像头权限
        AVAuthorizationStatus authStatus = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo];
        if (authStatus == AVAuthorizationStatusRestricted ||
            authStatus == AVAuthorizationStatusDenied) {
            return;
        }
        if (device) {
            RTCVideoSource *videoSource = [_factory videoSource];
            _capture = [[RTCCameraVideoCapturer alloc] initWithDelegate:videoSource];
            AVCaptureDeviceFormat *format = [[RTCCameraVideoCapturer supportedFormatsForDevice:device] lastObject];
            CGFloat fps = [[format videoSupportedFrameRateRanges] firstObject].maxFrameRate;
            _videoTrack = [_factory videoTrackWithSource:videoSource trackId:KARDVideoTrackId];
            localView.captureSession = _capture.captureSession;
            [_capture startCaptureWithDevice:device format:format fps:fps];
        }
    }
}

信令驱动

在任何系统中,都可以说信令是系统的灵魂。例如,由谁来发起呼叫;媒体协商时,什么时间发哪种 SDP 都是由信令控制的。
对于本项目来说,它的信令相对还是比较简单,它包括下面几种信令:

客户端命令

  • new,新用户加入房间
  • bye,用户离开房间
  • message,端到端命令(offer、answer、candidate)

服务端命令

  • new,用户已加入
  • bye,用户已离开
  • 转发(offer、answer、candidate)
  • peers,用户更新
  • keepalive,保持心跳

创建 RTCPeerConnection

信令系统建立好后,后面的逻辑都是围绕着信令系统建立起来的。RTCPeerConnection 对象的建立也不例外。

在客户端,用户要想与远端通话,首先要发送 join 消息,也就是要先进入房间。此时,如果服务器判定用户是合法的,则会给客户端回 joined 消息。

客户端收到 joined 消息后,就要创建 RTCPeerConnection 了,也就是要建立一条与远端通话的音视频数据传输通道。

下面,我们就来看一下 RTCPeerConnection 是如何建立的:

- (RTCPeerConnection *)createPeerConnection:(RTCPeerConnectionFactory *)factory
                                 audioTrack:(RTCAudioTrack *)audioTrack
                                 videoTrack:(RTCVideoTrack *)videoTrack {
    // 媒体约束
    RTCMediaConstraints *constraints = [self defaultPeerConnectionConstraints];
    // 创建配置
    RTCConfiguration *config = [[RTCConfiguration alloc] init];
    // ICE 中继服务器地址
    NSArray *iceServers = @[ [self defaultSTUNServer] ];
    
    config.iceServers = iceServers;
    // 创建一个RTCPeerConnection
    RTCPeerConnection *peerConnection = [factory peerConnectionWithConfiguration:config constraints:constraints delegate:self];
    
    NSArray *mediaStreamLabels = @[ @"ARDAMS" ];
    // 添加视频轨
    [peerConnection addTrack:videoTrack streamIds:mediaStreamLabels];
    // 添加音频轨
    [peerConnection addTrack:audioTrack streamIds:mediaStreamLabels];
    _connection = peerConnection;
    return peerConnection;
}

对于 iOS 的 RTCPeerConnection 对象有三个参数:

  • 第一个,是 RTCConfiguration 类型的对象,该对象中最重要的一个字段是 iceservers。它里边存放了 stun/turn 服务器地址。其主要作用是用于NAT穿越。对于 NAT 穿越的知识大家可以自行学习。
  • 第二个参数,是 RTCMediaConstraints 类型对象,也就是对 RTCPeerConnection 的限制。如,是否接收视频数据?是否接收音频数据?如果要与浏览器互通还要开启 DtlsSrtpKeyAgreement 选项。
  • 第三个参数,是委拖类型。相当于给 RTCPeerConnection 设置一个观察者。这样RTCPeerConnection 可以将一个状态/信息通过它通知给观察者。但它并不属于观察者模式,这一点大家一定要清楚。

RTCPeerConnection 对象创建好后,接下来我们介绍的是整个实时通话过程中,最重要的一部分知识,那就是 媒体协商

媒体协商

我们要知道媒体协商内容使用是 SDP 协议,不了解这部分知识的同学可以自行学习。其次,我们要清楚整体媒体协商的过程。

A 与 B 进行通话,通话的发起方,首先要创建 Offer 类型的 SDP 内容。之后调用 RTCPeerConnection 对象的 setLocalDescription 方法,将 Offer 保存到本地。

紧接着,将 Offer 发送给服务器。然后,通过信令服务器中转到被呼叫方。被呼叫方收到 Offer 后,调用它的 RTCPeerConnection 对象的 setRemoteDescription 方法,将远端的 Offer 保存起来。

之后,被呼到方创建 Answer 类型的 SDP 内容,并调用 RTCPeerConnection 对象的 setLocalDescription 方法将它存储到本地。

同样的,它也要将 Answer 发送给服务器。服务器收到该消息后,不做任何处理,直接中转给呼叫方。呼叫方收到 Answer 后,调用 setRemoteDescription 将其保存起来。

通过上面的步骤,整个媒体协商部分就完成了。

下面我们就具体看看,在 iOS 端是如何实现这个逻辑的:

// 创建offer
- (void)createOfferWithCompletionHandler:(void (^)(RTCSessionDescription *_Nonnull, NSError *_Nonnull))completionHandler {
    __weak ZPPeerConnection *weakSelf = self;
    [_connection offerForConstraints:[self defaultOfferConstraints]
                   completionHandler:^(RTCSessionDescription *_Nullable sdp, NSError *_Nullable error) {
        [weakSelf.connection setLocalDescription:sdp
                               completionHandler:^(NSError *_Nullable error) {
            completionHandler(sdp, error);
        }];
    }];
}

在iOS端使用 RTCPeerConnection 对象的 offerForConstraints 方法创建 Offer SDP。它有两个参数:

  • 一个是 RTCMediaConstraints 类型的参数,该参数我们在前面创建 RTCPeerConnection 对象时介绍过,这里不在赘述。
  • 另一个参数是一个匿名回调函数。可以通过对 error 是否为空来判定 offerForConstraints 方法有没有执行成功。如果执行成功了,参数 sdp 就是创建好的 SDP 内容。

如果成功获得了 sdp,按照之前的处理流程描述,我们首先要将它只存到本地;然后再将它发送给他务器,服务器中转给另一端。

我们的代码也是严格按照这个过程来的。在上面代码中 setLocalOffer 方法就是做这件事儿。具体代码如下:

[weakSelf.connection setLocalDescription:sdp
                               completionHandler:^(NSError *_Nullable error) {
            completionHandler(sdp, error);
}];

从上面的代码可以清楚的看出,它做了两件事儿。一是调用 setLocalDescription 方法将 sdp 保存到本地;另一件事儿就是发送消息;

所以,通过上面的描述大家也就知道后面的所有逻辑了。这里我们就不一一展开来讲了。

当整个协商完成之后,紧接着,在WebRTC底层就会进行音视频数据的传输。如果远端的视频数据到达本地后,我们就需要将它展示到界面上。这又是如何做到的呢?

渲染远端视频

大家是否还记得,在我们创建 RTCPeerConnection 对象时,同时给RTCPeerConnection设置了一个委拖,在我们的项目中就是 CallViewController 对象。在该对象中我们实现了所有 RTCPeerConnection对象的代理方法。其中比较关键的有下面几个:

  • (void)peerConnection:(RTCPeerConnection *)peerConnection
    didGenerateIceCandidate:(RTCIceCandidate *)candidate;该方法用于收集可用的 Candidate。
  • (void)peerConnection:(RTCPeerConnection *)peerConnection
    didChangeIceConnectionState:(RTCIceConnectionState)newState;当 ICE 连接状态发生变化时会触发该方法
  • (void)peerConnection:(RTCPeerConnection *)peerConnection
    didAddReceiver:(RTCRtpReceiver *)rtpReceiver
    streams:(NSArray<RTCMediaStream *> *)mediaStreams;该方法在侦听到远端 track 时会触发。

那么,什么时候开始渲染远端视频呢?当有远端视频流过来的时候,就会触发 (void)peerConnection:(RTCPeerConnection *)peerConnection
didAddReceiver:(RTCRtpReceiver *)rtpReceiver
streams:(NSArray<RTCMediaStream *> *)mediaStreams 方法。所以我们只需要在该方法中写一些逻辑即可。

当上面的函数被调用后,我们可以通过 rtpReceiver 参数获取到 track。这个track有可能是音频trak,也有可能是视频trak。所以,我们首先要对 track 做个判断,看其是视频还是音频。

如果是视频的话,就将remoteVideoView加入到trak中,相当于给track添加了一个观察者,这样remoteVideoView就可以从track获取到视频数据了。在 remoteVideoView 实现了渲染方法,一量收到数据就会直接进行渲染。最终,我们就可以看到远端的视频了。

具体代码如下:

- (ZPPeerConnection *)createPeerConnection:(NSString *)peerId {
    ZPPeerConnection *pc = [[ZPPeerConnection alloc] init];
    [pc createPeerConnection:_mediaCapturer.factory audioTrack:_mediaCapturer.audioTrack videoTrack:_mediaCapturer.videoTrack];
    [_peerConnections setObject:pc forKey:peerId];
    __weak ZPConnectionVC *weakSelf = self;
    pc.onAddStream = ^(ZPPeerConnection *_Nonnull connect, RTCPeerConnection *_Nonnull peerConnection, RTCMediaStream *_Nonnull stream) {
        NSLog(@"=========didAddStream  %@", stream.streamId);
        ZPPeerConnection *pc = connect;
        dispatch_async(dispatch_get_main_queue(), ^{
            __strong ZPConnectionVC *strongSelf = weakSelf;
            if (stream.videoTracks.count > 0 && pc != nil) {
                RTCVideoTrack *remoteVideoTrack = stream.videoTracks[0];
                RTCEAGLVideoView *remoteView = strongSelf.remoteView;
                [remoteVideoTrack addRenderer:remoteView];
                pc.videoTrack = remoteVideoTrack;
            }
        });
    };

    pc.onIceCandidate = ^(ZPPeerConnection *_Nonnull connect, RTCPeerConnection *_Nonnull peerConnection, RTCIceCandidate *_Nonnull candidate) {
        NSLog(@"收集候选者后 ");
        NSDictionary *candidateDic = @{
            @"candidate" : candidate.sdp,
            @"sdpMid" : candidate.sdpMid,
            @"sdpMLineIndex" : @(candidate.sdpMLineIndex)
        };
        [weakSelf.signal send:@{ @"to" : peerId,
            @"candidate" : candidateDic,
            @"session_id" : weakSelf.sessionId }
                        event:@"candidate"];
    };

    return pc;
}

通过以上,就可以将远端传来的视频添加到视图上。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,423评论 6 491
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,147评论 2 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,019评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,443评论 1 283
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,535评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,798评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,941评论 3 407
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,704评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,152评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,494评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,629评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,295评论 4 329
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,901评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,742评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,978评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,333评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,499评论 2 348

推荐阅读更多精彩内容