webrtc-可编程的实时音视频API-欢迎使用zwight个人网站系统
×

webrtc-可编程的实时音视频API

上文分享了三个视频传输协议,在分享的最后提到了一个技术就是WebRTC,在此准备分享一下对该技术的一些理解以及使用。

一、什么是WebRTC

WebRTC是“网络实时通信”(Web Real-Time Communication)的缩写。它最初是为了解决浏览器上视频通话而提出的,即两个浏览器之间直接进行视频和音频的通信,不经过服务器。后来发展到除了音频和视频,还可以传输文字和其他数据。在实时通信中,音视频的采集和处理是一个很复杂的过程。比如音视频流的编解码、降噪和回声消除等,但是在 WebRTC 中,这一切都交由浏览器的底层封装来完成。我们可以直接拿到优化后的媒体流,然后将其输出到本地屏幕和扬声器,或者转发给其对等端。

虽然其名为WebRTC,但是实际上它不光支持Web之间的音视频通讯,还支持Android以及IOS端,此外由于该项目是开源的,我们也可以通过编译C++代码,从而达到全平台的互通。

所以,我们可以在不需要任何第三方插件的情况下,实现一个浏览器到浏览器的点对点(P2P)连接,从而进行音视频实时通信。当然,WebRTC 提供了一些 API 供我们使用,在实时音视频通信的过程中,我们主要用到以下三个:

  • getUserMedia                  获取音频和视频(MediaStream)

  • RTCPeerConnection       进行音频和视频通信

  • RTCDataChannel            进行任意数据的通信

不过,虽然浏览器给我们解决了大部分音视频处理问题,但是从浏览器请求音频和视频时,我们还是需要特别注意流的大小和质量。因为即便硬件能够捕获高清质量流,CPU 和带宽也不一定可以跟上,这也是我们在建立多个对等连接时,不得不考虑的问题。

二、API的使用

接下来,我们通过分析上文提到的 API,来逐步弄懂 WebRTC 实时通信实现的流程。

1、getUserMedia

getUserMedia 这个 API 大家可能并不陌生,因为常见的 H5 录音等功能就需要用到它,主要就是用来获取设备的媒体流(即 MediaStream)。它可以接受一个约束对象 constraints 作为参数,用来指定需要获取到什么样的媒体流。

let constraints = {
    video: true,
    audio: true
}
navigator.mediaDevices.getUserMedia(constraints)
    .then(gotLocalMediaStream)
    .catch((err) => {
		    console.log('getUserMedia 错误', err);
		    //创建点对点连接对象
 		});


对于 constraints 约束对象,我们可以用来指定一些和媒体流有关的属性。比如指定是否获取某种流、指定视频流的宽高、帧率以及理想值,对于移动设备来说,还可以指定获取前摄像头,或者后置摄像头。更多相关设置可查看MDN

我们简单看一下获取到的 MediaStream:

可以看到它有很多属性,我们只需要了解一下就好,更多信息可以查看 MDN

* id [String]: 对当前的 MS 进行唯一标识。所以每次刷新浏览器或是重新获取 MS,id 都会变动。
* active [boolean]: 表示当前 MS 是否是活跃状态(就是是否可以播放)。
* onactive: 当 active 为 true 时,触发该事件。

这里也可以通过 getAudioTracks()、getVideoTracks() 来查看获取到的流的某些信息,更多信息查看 MDN

2、RTCPeerConnection

RTCPeerConnection 作为创建点对点连接的 API,是我们实现音视频实时通信的关键。在点对点通信的过程中,需要交换一系列信息,通常这一过程叫做 — 信令(signaling)。在信令阶段需要完成的任务:

  • 为每个连接端创建一个 RTCPeerConnection,并添加本地媒体流。

  • 获取并交换本地和远程描述:SDP 格式的本地媒体元数据。

  • 获取并交换网络信息:潜在的连接端点称为 ICE 候选者。

       我们虽然把 WebRTC 称之为点对点的连接,但并不意味着,实现过程中不需要服务器的参与。因为在点对点的信道建立起来之前,二者之间是没有办法通信的。这也就意味着,在信令阶段,我们需要一个通信服务来帮助我们建立起这个连接。WebRTC 本身没有指定信令服务,所以我们可以使用但不限于使用websocket、socket、xhr等来做信令交换所需的服务。

为了完成媒体流信息的交换,我们用 RTCPeerConnectioncreateOffer() 方法生成一个 Offer,它是以 SDP(Session Description Protocol,会话描述协议)格式传送的。对方收到 Offer 后,应该生成一个 Answer 并发回,这个 Answer 同样是 SDP 格式的。通信的双方通过调用setLocalDescription() 方法,把自己生成的 SDP 设置成本地描述;通过调用setRemoteDescription() 方法,把对方发给自己的 SDP 设置成远程描述。以上的这个过程,被统称为JSEP(JavaScript Session Establishment Protocol,JavaScript 会话建立协议)

在交换SDP后,webrtc就开始真正的连接来传输音视频数据。这个建立连接的过程相当复杂,原因是webrtc既要保证高效的传输性,又要保证稳定的连通性。由于浏览器客户端之间所处的位置往往是相当复杂的,可能处于同一个内网段内,也可能处于两个不同的位置,所处的NAT网关也可能很复杂。因此需要一种机制找到一条传输质量最优的道路,而WebRTC正具备这种能力。

首先简单了解以下三个概念。

  • ICE Canidate(ICE 候选者):包含远端通信时使用的协议、IP 地址和端口、候选者类型等信息。

  • STUN/TURN:STUN实现P2P型连接,TRUN实现中继型连接。两者实现均有标准协议。

  • NAT穿越:NAT即网络地址转换,由于客户端并不能分配到公网IP,需要内网IP与公网IP端口做映射才能与外网通信。而NAT穿越就是位于层层Nat网关背后的客户端之间发现对方并建立连接。


ICE连接大致的原理及步骤如下:

  1. 发起收集ICE Canidate任务。

  2. 本机能收集host类型(内网IP端口)的candidate。

  3. 通过STUN服务器收集srflx类型(NAT映射到外网的IP端口)的candiate。

  4. 通过TUN服务器收集relay类型的(中继服务器的 IP 和端口)的candidate。

  5. 开始尝试NAT穿越,按照host类型、srflx类型、relay类型的优先级去连接。


以上,WebRTC便能找到一条传输质量最优的连接道路。 当然实际情况并不是这么简单,整个过程包含着更复杂的底层细节。我们通过下图来说明WebRTC的处理步骤。


初始化RTCPeerConnection:

const config = { // ICE配置
    iceServers: [
        { urls: "stun:stun.l.google.com:19302" },  // 无需密码的
        { urls: "stun:zwight.cn:3478" },
        {   
            urls: 'turn:zwight.cn:3478',
            username:"admin",
            credential:"123456"
        }
    ],
};
const createConnection = () => {
        peerConnection = new RTCPeerConnection(config)
        if (localStream) {
            // 视频轨道
            const videoTracks = localStream.getVideoTracks();
            // 音频轨道
            const audioTracks = localStream.getAudioTracks();
            // 判断视频轨道是否有值
            if (videoTracks.length > 0) {
                console.log(`使用的设备为: ${videoTracks[0].label}.`);
            }
            // 判断音频轨道是否有值
            if (audioTracks.length > 0) {
                console.log(`使用的设备为: ${audioTracks[0].label}.`);
            }
            localStream.getTracks().forEach((track:any) => {
                peerConnection.addTrack(track, localStream)
            })
        }
        // 监听返回的 Candidate
        peerConnection.addEventListener('icecandidate', handleConnection);
        // 监听 ICE 状态变化
        peerConnection.addEventListener('iceconnectionstatechange', handleConnectionChange)
        //拿到流的时候调用
        peerConnection.addEventListener('track', gotRemoteMediaStream);
    }
		// 3.端与端建立连接
    const handleConnection = (event:any) =>  {
        // 获取到触发 icecandidate 事件的 RTCPeerConnection 对象
        // 获取到具体的Candidate
        console.log("handleConnection", event)
        const icecandidate = event.candidate;
        if (icecandidate) {
            socket.send(JSON.stringify({
                'userId': userId,
                'remoteId': remoteId,
                'message': {
                    type: 'icecandidate',
                    icecandidate: icecandidate
                }
            }));
        }
    }
   	// 4.显示远端媒体流
    const gotRemoteMediaStream = (event: any) => {
        console.log('remote 开始接受远端流', event)

        if (event.streams[0]) {
            remoteVideo.srcObject = event.streams[0];
        }
    }


创建连接:

//准备连接
startHandle().then(() => {
   //发送给远端开启请求
   socket.send(JSON.stringify({ 'userId': userId, 'remoteId': remoteId, 'message': {'type': 'connect'}}))
})
const onmessage = (e: any) =>  { // websokcet
        heartCheck.restart();      //简单心跳,拿到任何消息都说明当前连接是正常的
        const json = JSON.parse(e.data)
        const description = json.message;
        console.log('message', json)
        if(description.type === 'ping') return;
  
        setRemoteId(json.userId)
        const remoteId = json.userId;
        
        switch (description.type) {
            case 'connect':
                // eslint-disable-next-line no-restricted-globals
                if(confirm(remoteId + '请求连接!')){
                    //准备连接
                    startHandle().then(() => {
                        socket.send(JSON.stringify({ 'userId': userId, 'remoteId': remoteId, 'message': {'type': 'start'} }));
                    })
                }
                break;
            case 'start':
                //同意连接之后开始连接
                startConnection(remoteId)
                break;
            case 'offer':
                peerConnection.setRemoteDescription(new RTCSessionDescription(description)).catch((err: any) => {
                    console.log('local 设置远端描述信息错误', err);
                });
                peerConnection.createAnswer().then((answer: any) => {
                    peerConnection.setLocalDescription(answer).then(() => {
                        console.log('设置本地answer成功!');
                    }).catch((err: any) => {
                        console.error('设置本地answer失败', err);
                    });
                    socket.send(JSON.stringify({ 'userId': userId, 'remoteId': remoteId, 'message': answer }));
                }).catch((e: any) => {
                    console.error(e)
                });
                break;
            case 'icecandidate':
                // 创建 RTCIceCandidate 对象
                let newIceCandidate = new RTCIceCandidate(description.icecandidate);

                // 将本地获得的 Candidate 添加到远端的 RTCPeerConnection 对象中
                peerConnection.addIceCandidate(newIceCandidate).then(() => {
                    console.log(`addIceCandidate 成功`);
                }).catch((error: any) => {
                    console.log(`addIceCandidate 错误:\n${error.toString()}.`);
                });
                break;
            case 'answer':

                peerConnection.setRemoteDescription(new RTCSessionDescription(description)).then(() => {
                    console.log('设置remote answer成功!');
                }).catch((err: any) => {
                    console.log('设置remote answer错误', err);
                });
                break;
            default:
                break;
        }
    }

    //创建发起方会话描述对象(createOffer),设置本地SDP(setLocalDescription),并通过信令服务器发送到对等端,以启动与远程对等端的新WebRTC连接。
    const startConnection = (remoteId: any) => {
        setConnected(true);
        // 发送offer
        peerConnection.createOffer().then((description: any) => {
            console.log(`本地创建offer返回的sdp:\n${description.sdp}`)
            // 将 offer 保存到本地
            peerConnection.setLocalDescription(description).then(() => {
                console.log('local 设置本地描述信息成功');
                // 本地设置描述并将它发送给远端
                socket.send(JSON.stringify({ 'userId': userId, 'remoteId': remoteId, 'message': description }));
            }).catch((err: any) => {
                console.log('local 设置本地描述信息错误', err)
            });
        }).catch((err: any) => {
            console.log('createdOffer 错误', err);
        });
    }


以上为本实例部分主要代码,主要使用了websokcet作为信令交换服务器的连接桥梁,WebRTC在内网中无需依赖STURN中继服务器即可实现端到端的连接。详细代码请转这里

3、RTCDataChannel

RTCDataChannel的作用是在点对点之间,传播任意数据。它的API与WebSockets的API相同,RTCPeerConnection他进行强大和灵活的端到端通讯。通讯直接发生在浏览器之间.所以RTCDataChannel在使用了TURN中继服务来应对网络穿透失败的情况下还可以比WebSocket更快。

var peerConnection = new webkitRTCPeerConnection(servers,
  {optional: [{RtpDataChannels: true}]});

peerConnection.ondatachannel = function(e) {
  receiveChannel = e.channel;
  receiveChannel.onmessage = function(event){
    console.log(event)
  };
};
sendChannel = peerConnection.createDataChannel("sendDataChannel", {reliable: false});
sendChannel.send(data);

三、WebRTC发展和总结

2010年5月,谷歌以6820万美元收购GIPS。当然谷歌不光收购了GIPS,还收购了On2,得到了VPx系列视频编解码器。于是在2011年,webrtc项目诞生,融合了GIPS的音视频引擎、VPx视频编解码器,P2P穿洞技术等,而且开源。

经历了6年的时间,2017 年 11 月 2 日 ,W3C WebRTC 1.0 草案正式定稿,webrtc加入W3C大家族。随后,各大浏览器厂商跟紧支持。

2021 年 1 月 26 日,W3C(万维网联盟) 和 IETF (互联网工程任务组) 同时宣布 WebRTC(Web Real-Time Communications,Web 实时通信) 现发布为正式标准。

由于WebRTC的传输是基于公共互联网,而公共互联网并不是为了实时通信而设计的,因此在网络协议、跨区域带宽、跨运营商、用户设备、网络架构、文档支持等方面都会对WebRTC的开发有牵制,从而会导致实时音视频等传输质量没办法得到有效的保证。因此,可以说如果 WebRTC 直接拿过来商用的话,几乎是不太可能的,当下普遍的解决方案是自研,根据自身的业务场景进行二次定制开发,或者更简单一点使用第三方 SDK。

  • 本文示例源码库: webrtc


当前共有 123 条留言

最新
最热
最早