InfoQ 推荐 ( ) • 2021-06-03 11:20

本文主要讲解及解决在开发中的实际问题

webRTC的基本概念webRTC中Peerconnection , SDP , ICE,NAT,TURN ,STUN的概念web端基本实现与屏幕共享功能实现的方式ios端基本实现与屏幕共享功能的实现

本文重点讲述内容

webRTC的基础重要的几个关键概念web端实现屏幕共享发送到远端ios端实现屏幕共享发送共享视频到远端的方法(这个会着重讲,因为实现真的很不容易)

引言

接触webRTC主要是公司基于现有mcu与网关需要实现一套全新的能连接多端,实现音视频通话与会议功能,实现web端 ,ios端,安卓端,会议终端,实现点对点的通话,与组会,

前言

webRTC是一个由Google发布的实时通讯解决方案,集成了视频的采集编码 传输,通信,解码,音视频的显示,通过webRTC我们可以很迅速的实现音视频通话功能,大大节约了成本,能迅速的构建起web与移动端的音视频通讯功能,项目开源,能实现全平台的互通

webRTC的架构模型图如下

1.PeerConnection的构建过程

ClientA和ClientB均通过双向通信方式如WebSocket连接到Signaling Server上;ClientA在本地首先通GetMedia访问本地的media接口和数据,并创建PeerConnection对象,调用其AddStream方法把本地的Media添加到PeerConnection对象中。对于ClientA而言,既可以在与Signaling Server建立连接之前就创建并初始化PeerConnection如阶段1,也可以在建立Signaling Server连接之后创建并初始化PeerConnection如阶段2;ClientB既可以在上图的1阶段也可以在2阶段做同样的事情,访问自己的本地接口并创建自己的PeerConnection对象通信由ClientA发起,所以ClientA调用PeerConnection的CreateOffer接口创建自己的SDP offer,然后把这个SDP Offer信息通过Signaling Server通道中转发给ClientB;ClientB收到Signaling Server中转过来的ClientA的SDP信息也就是offer后,调用CreateAnswer创建自己的SDP信息也就是answer,然后把这个answer同样通过Signaling server转发给ClientA;ClientA收到转发的answer消息以后,两个peers就做好了建立连接并获取对方media streaming的准备;ClientA通过自己PeerConnection创建时传递的参数等待来自于ICE server的通信,获取自己的candidate,当candidate available的时候会自动回掉PeerConnection的OnIceCandidate;ClientA通过Signling Server发送自己的Candidate给ClientB,ClientB依据同样的逻辑把自己的Candidate通过Signaling Server中转发给ClientA,至此ClientA和ClientB均已经接收到对方的Candidate,通过PeerConnection建立连接。至此P2P通道建立

2.SDP的基本概念及内容

SDP(Session Description Protocol)是一种通用的会话描述协议,主要用来描述多媒体会话,用途包括会话声明、会话邀请、会话初始化等。

WebRTC主要在连接建立阶段用到SDP,连接双方通过信令服务交换会话信息,包括音视频编解码器(codec)、主机候选地址、网络传输协议等

描述两个客户端各自的音视频能力集,通信信息,ice信息,通过对sdp内容的修改(音视频编解码能力)可以指定双方采用的编码格式,默认双方协商会是VP8视频编解码格式,opus或者G722的音频编解码格式,以信息在前为准(首先出现的编解码能力,首先协商),指定编解码格式只需对信息进行替换

=rtcp-mux a=rtpmap:111 opus/48000/2 a=rtcp-fb:111 transport-cc a=fmtp:111 minptime=10;useinbandfec=1 a=rtpmap:103 ISAC/16000 a=rtpmap:104 ISAC/32000 a=rtpmap:9 G722/8000 音频编码格式 G722 a=rtpmap:0 PCMU/8000 音频编码格式G711U a=rtpmap:8 PCMA/8000 音频编码格式G711A a=rtpmap:106 CN/32000 a=rtpmap:105 CN/16000 a=rtpmap:13 CN/8000 a=rtpmap:110 telephone-event/48000 a=rtpmap:112 telephone-event/32000 a=rtpmap:113 telephone-event/16000 a=rtpmap:126 telephone-event/8000 a=ssrc:360623571 cname:Hnq/JpW3Hk8HUdjp a=ssrc:360623571 msid:m8vgZoloLydQ91c3by9VTuO4yO74NBeUg5rW 5f044497-b759-422a-b2a6-473ecef0ef78 a=ssrc:360623571 mslabel:m8vgZoloLydQ91c3by9VTuO4yO74NBeUg5rW a=ssrc:360623571 label:5f044497-b759-422a-b2a6-473ecef0ef78 m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 102 121 c=IN IP4 0.0.0.0 a=rtcp:9 IN IP4 0.0.0.0 //ice协商过程中的安全验证信息 a=ice-ufrag:n8sq a=ice-pwd:9RapOjHE8lxxXVAygEjU2WIT a=ice-options:trickle //dtls协商过程中需要的认证信息 a=fingerprint:sha-256 05:13:93:45:52:6C:1D:AE:D6:2A:D8:ED:B8:D1:97:71:E2:6D:E3:C7:2A:06:C8:09:7B:C8:3E:98:21:44:98:AB a=setup:actpass a=mid:1 a=extmap:14 urn:ietf:params:rtp-hdrext:toffset a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01 a=extmap:12 http://www.webrtc.org/experiments/rtp-hdrext/playout-delay a=extmap:11 http://www.webrtc.org/experiments/rtp-hdrext/video-content-type a=extmap:7 http://www.webrtc.org/experiments/rtp-hdrext/video-timing a=extmap:8 http://www.webrtc.org/experiments/rtp-hdrext/color-space a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:mid a=extmap:5 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id a=extmap:6 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id a=sendrecv a=msid:m8vgZoloLydQ91c3by9VTuO4yO74NBeUg5rW 6cd136f0-e30c-45c8-9262-7696d760d224 a=rtcp-mux a=rtcp-rsize a=rtpmap:96 VP8/90000 视频编码格式 a=rtcp-fb:96 goog-remb a=rtcp-fb:96 transport-cc a=rtcp-fb:96 ccm fir a=rtcp-fb:96 nack a=rtcp-fb:96 nack pli a=rtpmap:97 rtx/90000 a=fmtp:97 apt=96 a=rtpmap:98 VP9/90000 视频编码格式 a=rtcp-fb:98 goog-remb a=rtcp-fb:98 transport-cc a=rtcp-fb:98 ccm fir a=rtcp-fb:98 nack a=rtcp-fb:98 nack pli a=fmtp:98 profile-id=0 a=rtpmap:99 rtx/90000 a=fmtp:99 apt=98 a=rtpmap:102 H264/90000 视频编码格式 a=rtcp-fb:102 goog-remb a=rtcp-fb:102 transport-cc a=rtcp-fb:102 ccm fir a=rtcp-fb:102 nack a=rtcp-fb:102 nack pli a=fmtp:102 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f a=rtpmap:121 rtx/90000 a=fmtp:121 apt=102

3.ICE是什么

互动式连接建立提供的是一种框架,使各种NAT穿透技术(STUN,TURN...)可以实现统一。该技术可以让客户端成功地穿透远程用户与网络之间可能存在的各类防火墙

4.NAT

网路地址转换可为你的装置提供公用IP地址。路由器具备公用IP地址,而连上路由器的所有装置则具备私有IP地址。接着针对请求,从装置的私有IP对应到路由器的公用IP与专属的通讯端口。如此一来,各个装置不需占用专属的公用IP,亦可在网路上被清楚识别

5.STUN

NAT 的UDP简单穿越是一种网络协议,它允许位于NAT(或多重NAT)后的客户端找出自己的公网地址,查出自己位于哪种类型的NAT之后以及NAT为某一个本地端口所绑定的Internet端端口。这些信息被用来在两个同时处于NAT路由器之后的主机之间建立UDP通信

6.TURN

中继NAT实现的穿透(Traversal Using Relays around NAT)就是通过TURN服务器开启连线并转送所有数据,进而绕过Symmetric NAT的限制。你可通过TURN服务器建立连线,再告知所有端点传送封包至该服务器,最后让服务器转送封包给你。这个方法更耗时且更占频宽,因此在没有其他替代方案时才会使用这个方法

上面就是一些主要需要了解的内容,建立连接过程,与通信打洞的原理

介绍web端实现内容

var ice = {"iceServers": [ {"url": "stun:stun.l.google.com:19302"}, {"url": "turn:turnserver.com", "username": "user", "credential": "pass"} ]}; var signalingChannel = new SignalingChannel(); var pc = new RTCPeerConnection(ice); navigator.mediaDevices.getUserMedia(gumConstraints) .then(function(stream) { var videotrack = stream.getVideoTracks()[0]; if(videotrack){ videotrack.applyConstraints({ frameRate: { max: globalVariable.video_call_property.video_framerate} }); } pluginHandle.consentDialog(false); streamsDone(handleId, jsep, media, callbacks, stream); }).catch(function(error) { pluginHandle.consentDialog(false); if (error.name == "OverconstrainedError") { //表示音视频的能力,可以实现本地是否发送音频或者视频 gumConstraints = { audio: (audioExist && !media.keepAudio) ? audioSupport : false, video: (videoExist && !media.keepVideo) ? videoSupport : false }; getMedia(gumConstraints, handleId, jsep, media, callbacks, stream, audioExist, videoExist, audioSupport, videoSupport, pluginHandle); } else { callbacks.error({code: error.code, name: error.name, message: error.message}); } }); // 添加视频,这里获取到远端的音视频流 pc.onaddstream = function (remoteStream) { pluginHandle.onremotestream(remoteStream.stream); };

实现屏幕共享功能的代码如下

//获取浏览器的共享画面 var screenMedia = navigator.mediaDevices.getDisplayMedia({ video: {width:1280,height:720}, audio: true }) .then(function(stream) { stream.getTracks().forEach(track => { track.onended = function () { backState(false) } }) navigator.mediaDevices.getUserMedia({ audio: true, video: false }) .then(function (audioStream) { stream.addTrack(audioStream.getAudioTracks()[0]); //返回值标志,用于标志视频成功获取共享画面,此处用于页面交互的状态改变 backState(true) CloseLocalVideo() ScreenStream = stream //记录共享画面流信息 localConfig.myStream = stream videoCallpluginHandle.onlocalstream(stream) //改变本地显示的画面 var videoTransceiver = null; var transceivers = localConfig.pc.getTransceivers(); //找出当前的音频流信息 if(transceivers && transceivers.length > 0) { for(var i in transceivers) { var t = transceivers[i]; if((t.sender && t.sender.track && t.sender.track.kind === "audio") || (t.receiver && t.receiver.track && t.receiver.track.kind === "audio")) { audioTransceiver = t; break; } } } //这段代码为屏幕共享的关键所在,替换当前会话句柄的音频源 if(audioTransceiver && audioTransceiver.sender) { audioTransceiver.sender.replaceTrack(stream.getAudioTracks()[0]); } else { config.pc.addTrack(stream.getAudioTracks()[0], stream); } if(transceivers && transceivers.length > 0) { for(var i in transceivers) { var t = transceivers[i]; if((t.sender && t.sender.track && t.sender.track.kind === "video") || (t.receiver && t.receiver.track && t.receiver.track.kind === "video")) { videoTransceiver = t; break; } } } //替换会话句柄发送者的视频源 if(videoTransceiver && videoTransceiver.sender) { videoTransceiver.sender.replaceTrack(stream.getVideoTracks()[0]); } else { localConfig.addTrack(stream.getVideoTracks()[0], stream); } }) // } }, function (error) { // pluginHandle.consentDialog(false); // VideoCallBack.error(error); console.log('==******==') backState(false) //此处标志调取屏幕共享失败或者用户未授权共享或者取消了屏幕共享 },function(canceled){ console.log('==stopped==') });

web屏幕共享在同一个文件中可以写方法实现调用屏幕共享,获取音视频信息,在本文件中即可将相应的流通过RTC的会话句柄发送者,发送到远端,和在本地显示,不需要对流进行任何的操作,主要代码也是固定调用,难点在于通过发送者发送带远端和修改本地显示视频,其中的难点代码如下,对peerconnection的会话句柄做全局变量处理videoCallpluginHandle,localConfig.pc为当前初始化的peerconnection对象,

videoCallpluginHandle.onlocalstream(stream) //改变本地显示的画面 var videoTransceiver = null; var transceivers = localConfig.pc.getTransceivers(); //找出当前的音频流信息 if(transceivers && transceivers.length > 0) { for(var i in transceivers) { var t = transceivers[i]; if((t.sender && t.sender.track && t.sender.track.kind === "audio") || (t.receiver && t.receiver.track && t.receiver.track.kind === "audio")) { audioTransceiver = t; break; } } } //这段代码为屏幕共享的关键所在,替换当前会话句柄的音频源 if(audioTransceiver && audioTransceiver.sender) { audioTransceiver.sender.replaceTrack(stream.getAudioTracks()[0]); } else { config.pc.addTrack(stream.getAudioTracks()[0], stream); } if(transceivers && transceivers.length > 0) { for(var i in transceivers) { var t = transceivers[i]; if((t.sender && t.sender.track && t.sender.track.kind === "video") || (t.receiver && t.receiver.track && t.receiver.track.kind === "video")) { videoTransceiver = t; break; } } } //替换会话句柄发送者的视频源 if(videoTransceiver && videoTransceiver.sender) { videoTransceiver.sender.replaceTrack(stream.getVideoTracks()[0]); } else { localConfig.addTrack(stream.getVideoTracks()[0], stream); }

iOS端主要实现

//生成解码的生成器 RTCDefaultVideoDecoderFactory *decoder = [[RTCDefaultVideoDecoderFactory alloc] init]; //生成编码的生成器 RTCDefaultVideoEncoderFactory *encoder = [[RTCDefaultVideoEncoderFactory alloc] init]; //储存编码 H264 VP8 VP9 ... NSArray *codes = [encoder supportedCodecs]; //取的编码是第二个 [encoder setPreferredCodec:codes[2]]; //把编码放进pc生成器中 _factory = [[RTCPeerConnectionFactory alloc] initWithEncoderFactory:encoder decoderFactory:decoder]; // 媒体约束 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]; // 添加视频轨 [peerConnection addStream:stream];

初始化本地视频流信息

NSDictionary *mandatoryConstraints = @{}; //媒体约束 RTCMediaConstraints *constrains = [[RTCMediaConstraints alloc] initWithMandatoryConstraints:mandatoryConstraints optionalConstraints:nil]; //通过生成器拿到音频资源id RTCAudioSource * audioSource = [_factory audioSourceWithConstraints:constrains]; //获取媒体设备 NSArray *captureDevices = [RTCCameraVideoCapturer captureDevices]; //前置摄像头Front 后置back AVCaptureDevicePosition position = AVCaptureDevicePositionFront; _mediaStream = [_factory mediaStreamWithStreamId:KARDMediaStreamId]; [_mediaStream addAudioTrack:_audioTrack]; /*这里很重要 */ localView.captureSession = _capture.captureSession; [_capture startCaptureWithDevice:device format:format fps:fps];

初始化PeerConnect,通过webRTC中的方法获取本地视流,自动会发送到远端,获取远端视频流信息,通过代理方法可以获取

- (void)peerConnection:(nonnull RTCPeerConnection *)peerConnection didAddStream:(nonnull RTCMediaStream *)stream { NSLog(@"==009==="); if (self.onAddStream != NULL) { self.onAddStream(self, peerConnection, stream); } }

在屏幕共享的过程中需要用到打开和关闭本地摄像头的功能,webRTC中实现了摄像头的开关功能,这里处理了一个线程问题

- (void)startCapture{ if(_Running){ return; } _Running = true; AVCaptureDeviceFormat *format = [self selectFormatForDevice:self.LocalDevice withTargetWidth:640 withTargetHeight:480]; dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); [self.capture startCaptureWithDevice:_LocalDevice format:format fps:15 completionHandler:^(NSError * err) { dispatch_semaphore_signal(semaphore); }]; dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER); } //关闭摄像头,在共享的时候需要关闭本地摄像头的采集, - (void)stopCapture{ if(!_Running){ return; } _Running = false; // Stopping the capture happens on another thread. Wait for it. dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); [_capture stopCaptureWithCompletionHandler:^{ dispatch_semaphore_signal(semaphore); }]; dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER); } //打开摄像头需要重新获取 - (AVCaptureDeviceFormat *)selectFormatForDevice:(AVCaptureDevice *)device withTargetWidth:(int)targetWidth withTargetHeight:(int)targetHeight { NSArray *formats = [RTCCameraVideoCapturer supportedFormatsForDevice:device]; AVCaptureDeviceFormat *selectedFormat = nil; int currentDiff = INT_MAX; for (AVCaptureDeviceFormat *format in formats) { CMVideoDimensions dimension = CMVideoFormatDescriptionGetDimensions(format.formatDescription); FourCharCode pixelFormat = CMFormatDescriptionGetMediaSubType(format.formatDescription); int diff = abs(targetWidth - dimension.width) + abs(targetHeight - dimension.height); if (diff < currentDiff) { selectedFormat = format; currentDiff = diff; } else if (diff == currentDiff && pixelFormat == [self.capture preferredOutputPixelFormat]) { selectedFormat = format; } } return selectedFormat; }

上面是iOS端实现创建本地视频流发送到远端,与获取远端视频流的过程,下面开始说屏幕共享功能

iOS端实现屏幕共享功能的难点问题

ios12以后苹果推出了replaykit2可以实现桌面(系统内)的共享,但是需要在extensionApp中实现录制,extensionApp寄生于主App,两者在不同的进程中,需要使用进程间的通信才可以实现通信,信息交换传递extensionApp苹果只有50M的空间,超过就会被系统杀死录制的进程,所以我们在extensionApp中操作数据需要关注运行内存的优化处理extensionApp的录制数据如果不能再本进程中编码传到远端,或者本地需要显示,我们就需要将录制的视频传回到主App中,苹果没有给出直接能传CMSampleBufferRef数据的进程间通信,所以需要编码然后在解码传输

解决方案

通过socket 与CFNotificationCenterRef实现进程间的通信,socket实现传输音视频数据,CFNotificationCenterRef实现状态信息的传递通过对当先所占内存的统计检测,实现在内存大的时候即使释放录制的音视频数据,带数据处理完,内存下降时在对数据进行编码使用libyuv NTESI420Frame 对录制数据进行yuv数据转换 将视频信息转换为NSData数据通过socket进行回传到主app,通过替换webRTC的视频源实现将共享画面发送到远端

//extensionAPP中发送代码 - (void)sendVideoBufferToHostApp:(CMSampleBufferRef)sampleBuffer { if (!self.socket) { return; } CFRetain(sampleBuffer); dispatch_async(self.videoQueue, ^{ // queue optimal @autoreleasepool { if (self.frameCount > 1000) { CFRelease(sampleBuffer); return; } self.frameCount ++ ; CVPixelBufferRef pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer); //获取当前视频流信息的方向信息 CFStringRef RPVideoSampleOrientationKeyRef = (__bridge CFStringRef)RPVideoSampleOrientationKey; NSNumber *orientation = (NSNumber *)CMGetAttachment(sampleBuffer, RPVideoSampleOrientationKeyRef,NULL); switch ([orientation integerValue]) { case 1: self.orientation = NTESVideoPackOrientationPortrait; break; case 6: self.orientation = NTESVideoPackOrientationLandscapeRight; break; case 8: self.orientation = NTESVideoPackOrientationLandscapeLeft; break; default: break; } // To data NTESI420Frame *videoFrame = nil; videoFrame = [NTESYUVConverter pixelBufferToI420:pixelBuffer withCrop:self.cropRate targetSize:self.targetSize andOrientation:self.orientation]; CFRelease(sampleBuffer); // To Host App if (videoFrame){ NSData *raw = [videoFrame bytes]; //NSData *data = [NTESSocketPacket packetWithBuffer:raw]; NSData *headerData = [NTESSocketPacket packetWithBuffer:raw]; if (!_enterBack) { if (self.connected) { [self.socket writeData:headerData withTimeout:-1 tag:0]; [self.socket writeData:raw withTimeout:-1 tag:0]; } } } self.frameCount --; }; }); }

接收端,接收到视频处理方法

- (void)onRecvData:(NSData *)data { dispatch_async(dispatch_get_main_queue(), ^{ NTESI420Frame *frame = [NTESI420Frame initWithData:data]; CMSampleBufferRef sampleBuffer = [frame convertToSampleBuffer]; if (sampleBuffer == NULL) { return; } if(!_isStart){ if (_startScreenBlock) { _startScreenBlock(true); } // if(!_StartScreen){ // // }else{ // // if (_startScreenBlock) { // _startScreenBlock(false); // } // } _isStart = true; _Running = false; [_capture stopCapture]; }else{ } if([UIScreen mainScreen].isCaptured){ if(_Running){ _isStart = true; _Running = false; [_capture stopCapture]; //暂停本地视频 if (_startScreenBlock) { _startScreenBlock(true);//状态回调 } } } // if (self.StartScreen) { //NSEC_PER_SEC 此句颇为重要不能掉,不然不能暂停本地视频导致视频画面频繁的闪 int64_t timeStampNs = CMTimeGetSeconds(CMSampleBufferGetPresentationTimeStamp(sampleBuffer)) * NSEC_PER_SEC; CVPixelBufferRef rtcPixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer); RTCCVPixelBuffer *cpvX = [[RTCCVPixelBuffer alloc]initWithPixelBuffer:rtcPixelBuffer]; RTCVideoFrame *aframe = [[RTCVideoFrame alloc]initWithBuffer:cpvX rotation:self.rotation timeStampNs:timeStampNs]; //使用代理方法实现替换视频源 [_videoSource capturer:_capture didCaptureVideoFrame:aframe]; // } CFRelease(sampleBuffer); }); }

解决extension app只有50M空间问题

主要问题在于extension app 负责录制屏幕,运行空间只有50M 超过立即会被系统杀死,录制结束,由于录制屏幕是根据屏幕内容变化才会启动录制流,当屏幕内容变化快的时候单位时间内的流帧数会变大,导致数据处理压力变大,在将流转换为I420 yuv数据的时候所需的缓冲去便会变大,导致内存不断增大,最终被系统杀死录制进程,解决方案就是监控当前运行所占内存,超过一定范围后就不在将录制数据编码,从而控制缓存区的内存增长,保持地缓存,具体代码如下

在extension app 中写下如下代码

//获取当前运行内存空间 - (double)getCurrentMemory { task_basic_info_data_t taskInfo; mach_msg_type_number_t infoCount = TASK_BASIC_INFO_COUNT; kern_return_t kernReturn = task_info(mach_task_self(), TASK_BASIC_INFO, (task_info_t)&taskInfo, &infoCount); if (kernReturn != KERN_SUCCESS ) { return NSNotFound; } NSLog(@"%f",taskInfo.resident_size / 1024.0 / 1024.0); return taskInfo.resident_size / 1024.0 / 1024.0; }

当前文件下调用

long curMem = [self getCurrentMemory]; if ((self.eventMemory > 0 && ((curMem - self.eventMemory) > 5)) || curMem > 40) { //当前内存暴增5M以上,或者总共超过40M,则不处理 CFRelease(sampleBuffer); return; };

如上所示即可解决50M内存限制,使用过程工能保证录制功能不会意外退出,当然相应的在接收端或者本地预览端会有画面没有连续更新的问题,这也是鱼与熊掌不可兼得,

由于项目中代码写的不是很规范,demo等整理出来在添加链接,供参考