本文主要讲解及解决在开发中的实际问题
本文重点讲述内容
引言
接触 webRTC 主要是公司基于现有 mcu 与网关需要实现一套全新的能连接多端,实现音视频通话与会议功能,实现 web 端 ,ios 端,安卓端,会议终端,实现点对点的通话,与组会,
前言
webRTC 是一个由 Google 发布的实时通讯解决方案,集成了视频的采集编码 传输,通信,解码,音视频的显示,通过 webRTC 我们可以很迅速的实现音视频通话功能,大大节约了成本,能迅速的构建起 web 与移动端的音视频通讯功能,项目开源,能实现全平台的互通
webRTC 的架构模型图如下
1.PeerConnection 的构建过程
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...)可以实现统一。该技术可以让客户端成功地穿透远程用户与网络之间可能存在的各类防火墙
网路地址转换可为你的装置提供公用 IP 地址。路由器具备公用 IP 地址,而连上路由器的所有装置则具备私有 IP 地址。接着针对请求,从装置的私有 IP 对应到路由器的公用 IP 与专属的通讯端口。如此一来,各个装置不需占用专属的公用 IP,亦可在网路上被清楚识别
NAT 的 UDP 简单穿越是一种网络协议,它允许位于 NAT(或多重 NAT)后的客户端找出自己的公网地址,查出自己位于哪种类型的 NAT 之后以及 NAT 为某一个本地端口所绑定的 Internet 端端口。这些信息被用来在两个同时处于 NAT 路由器之后的主机之间建立 UDP 通信
中继 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<AVCaptureDevice *> *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<AVCaptureDeviceFormat *> *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 端实现屏幕共享功能的难点问题
解决方案
//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 等整理出来在添加链接,供参考
领取专属 10元无门槛券
私享最新 技术干货