iOS WebRTC 使用
这段项目使用WebRTC 的经验,拿出来分享(提醒, 原理部分请看特别感谢. 基础部分会使用模拟代码)
本文包含音视频和RTCDataChannel
准备
- Turn Server (47.93.21.132:3478)
用来打洞的服务器, 这个是我自己搭建的, 可以使用谷歌的(需要梯子. stun:stun.l.google.com:19302)
用户名: u1
密码: p1
// 代码注解
NSArray *data = @[@"turn:139.199.190.85:3478" ,@"stun:139.199.190.85:3478"];//服务器
_stunServerArray = [NSMutableArray arrayWithCapacity:data.count];//全局变量
[data enumerateObjectsUsingBlock:^(NSString * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
NSURL *url = [NSURL URLWithString:obj];
RTCICEServer *server = [[RTCICEServer alloc] initWithURI:url username:@"u1" password:@"p1"]];
if (server) {
[self.stunServerArray addObject:server];
}
}];
- 个人服务器
用来交换A和B的打洞信息(全文都是模拟A给B发送消息. 信息包括但不仅限于sdp、 ICE Candidate...)
采用TCP(注意粘包问题. 本文采用), WebSocket方式都可以. 亦可以采用Socket加Http请求方式.即时即可
- �WebRTC库
一个外国人编译好的. 直接pod 使用
pod 'libjingle_peerconnection'
项目阶段
单例类
+ (instancetype)shareInstance {
static DSWebRTCManager *manager = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
manager = [[DSWebRTCManager alloc] init];
[manager setup];
});
return manager;
}
SDP
- 加载准备阶段Turn Server
- 创建唯一的P2P工厂
- (void)setup {
[RTCPeerConnectionFactory initializeSSL];
_factory = [[RTCPeerConnectionFactory alloc] init];//全局变量.
_isInititor = false;//这个需要用来判断是否是发起者. 收到init 方为false. 收到answerInit为true.
}
本文采用方式: 可以想象为A给B发送视频/音频聊天请求, B收到请求后(类型:init)进行判断是否同意, 同意返回同意(类型:answerInit). 否则返回关闭(类型:Bye). 双方关闭音视频, 处理掉相关缓存. 此种方式在音视频方便尤为重要
- A给B发送消息准备发送音视频消息(本文采用TCP, B是否在线是可以得到的. 而且双方都必须在线才可以. 此处不做代码注解, 即发送TCP消息.B解析出来即可. 发送类型为init)
- 假设B同意的情况下. 收到消息使用TCP返回同意消息(answerInit)
@weakify(self);
[self.delegate dspersonReceiveInvitationVideoWithOther:other agree:^(BOOL isAgree) {
@strongify(self);
if (isAgree) {
//同意 发送tcp消息
//模拟代码
//[_tcp send:init(同意)];
} else {
//发送Bye
//[_tcp send:Byet(不同意)];
}
}];
- A收到B发送的同意信息
- 创建RTCPeerConnection
RTCPeerConnection *connection = [_factory peerConnectionWithICEServers:stunServerArray
constraints:[self peerConnectionConstraints]
delegate:self];//根据约束创建. 并且将RTCPeerConnection代理RTCPeerConnectionDelegate放在self中
//全局变量.可以不定义成全局变量. 添加到数组中(本文为了需要改为全局
//方便使用.⚠️⚠️⚠️ 如果你添加到数组中, 从数组中删除中前,一定要先调用 [connection close]; 否则崩溃)
_peerConnection = connection;
- 创建RTCPeerConnection的的约束
- (RTCMediaConstraints *)peerConnectionConstraints {
RTCPair *pair = [[RTCPair alloc] initWithKey:@"DtlsSrtpKeyAgreement" value:@"true"];//这个是定义好的.不能更改
RTCMediaConstraints *constraints = [[RTCMediaConstraints alloc] initWithMandatoryConstraints:nil
optionalConstraints:@[pair]];
return constraints;
}
- 创建本地SDP
//会调用RTCPeerConnection 代理 RTCSessionDescriptionDelegate
[_peerConnection createOfferWithDelegate:self constraints:[self defaultOfferConstraints]];
- Offer约束
- (RTCMediaConstraints *)defaultOfferConstraints {
NSArray *mandatoryConstraints = @[
[[RTCPair alloc] initWithKey:@"OfferToReceiveAudio" value:@"true"],//是否含有音频
[[RTCPair alloc] initWithKey:@"OfferToReceiveVideo" value:@"true"]//是否含有视频
];
RTCMediaConstraints* constraints =
[[RTCMediaConstraints alloc]
initWithMandatoryConstraints:mandatoryConstraints
optionalConstraints:nil];
return constraints;
}
- 详解RTCSessionDescriptionDelegate
RTCSessionDescriptionDelegate 有两个回调
//创建本地SDP时候, 会调用
// 代理方法2
- (void)peerConnection:(RTCPeerConnection *)peerConnection
didCreateSessionDescription:(RTCSessionDescription *)sdp
error:(NSError *)error {
//0.判断是否出现错误
if (error) {
// DSpersonKitLog(@"\n😂😂😂😂😂😂😂😂发送本地SDP 出现错误😂😂😂😂😂😂😂😂\n%@", error);
// 出现错误就要给对方发送Bye
//[_tcp send:Bye];
return;
}
//1. 设置本地SDP. 调用此方法回调用代理方法2. WebRTC会进行内部保存,此时的代理2的方法根A其实已经没任何关系了
// 2. B创建Answer sdp发送 给A也会调用代理2 peerConnection.signalingState 状态已经发生改变.所以不会出现死循环问题
[_peerConnection setLocalDescriptionWithDelegate:self sessionDescription:sdp];
//2. 发送SDP给对方
//[ _tcp send:sdp];
}
// 代理方法2
- (void)peerConnection:(RTCPeerConnection *)peerConnection
didSetSessionDescriptionWithError:(NSError *)error {
//0.判断是否出现错误
if (error) {
// DSpersonKitLog(@"\n😂😂😂😂😂😂😂😂发送本地SDP 出现错误😂😂😂😂😂😂😂😂\n%@", error);
// 出现错误就要给对方发送Bye
//[_tcp send:Bye];
return;
}
//B正在回答A,远程Offer. 我们需要创建的answer, 和一个本地描述()
if (!_isInititor && peerConnection.signalingState == RTCSignalingHaveRemoteOffer) {
// DSpersonKitLog(@"接收到远端发来的Offer, 创建本地Answer");
//他应该在SetRemoteDescription之后调用, 否则报错.
//创建完会调用代理1. 给A发送Answer
[_peerConnection createAnswerWithDelegate:self constraints:[self defaultOfferConstraints]];
}
}
- B收到Offer
//创建远程SDP. 会调用RTCSessionDescriptionDelegate 代理2. 此时_isInititor = false
[_peerConnection setRemoteDescriptionWithDelegate:self sessionDescription:sdp];
//sdp 是收到消息出来并创建的解析创建的RTCSessionDescription
- SDP 创建
+ (RTCSessionDescription *)ds_descriptionFromDictionary:(NSDictionary *)dic {
if (!dic) {
return nil;
}
NSString *type = dic[@"type"];
NSString *sdp = dic[@"sdp"];
return [[RTCSessionDescription alloc] initWithType:type sdp:sdp];
}
- A收到Answer.
和B一样收到answer 要添加到远程sdp 中.方法同 步骤5. 但是
_isInititor = true
. 会调用RTCSessionDescriptionDelegate 代理2方法.但是没有实际效果.
ICECandidat
ICECandidat 主要和 位于RTCPeerConnectionDelegate 此代理中. 暂时讲解几个此处需要的. 无需主动调用.
//代理1: 新的 ICE Candidate 被发现时调用 需要将信息返回给Socket服务器
- (void)peerConnection:(RTCPeerConnection *)peerConnection
gotICECandidate:(RTCICECandidate *)candidate {
// 需要将这些ice 发给对方客户端
//[_tcp send:candidate]
}
- A或者B.收到ICE Candidate
[_peerConnection addICECandidate:ice_candidate];
//代理2: 状态变化
- (void)peerConnection:(RTCPeerConnection *)peerConnection
iceConnectionChanged:(RTCICEConnectionState)newState {
switch (newState) {
case RTCICEConnectionConnected:
{
//除了这个别的都是没打开的状态
}
break;
case RTCICEConnectionFailed:
{
//这个状态就可以发送Bye了
}
break;
}
}
媒体
媒体流
_meidaStream
全局变量
- (RTCMediaStream *)meidaStream {
if (!_meidaStream) {
_meidaStream = [_factory mediaStreamWithLabel:@"ARDAMS"];//`ARDAMS`固定就这么写
}
return _meidaStream;
}
视频
- 创建
//position : AVCaptureDevicePosition. 摄像头方向
RTCVideoTrack *videoTrack = [self createVideoTrackWithDirecion:position];
- (RTCVideoTrack *)createVideoTrackWithDirecion:(AVCaptureDevicePosition)position {
RTCVideoTrack *localVideoTrack = nil;
#if !TARGET_IPHONE_SIMULATOR && TARGET_OS_IPHONE
//更新方法. 和网上大多创建方法不同.
localVideoTrack = [[RTCVideoTrack alloc] initWithFactory:_factory source:self.source trackId:@"AVAMSv0"];//AVAMSv0不能更改
#endif
return localVideoTrack;
}
self.source
懒加载的方式创建
- (RTCAVFoundationVideoSource *)source {
if (!_source) {
_source = [[RTCAVFoundationVideoSource alloc] initWithFactory:_factory constraints:[self defaultMediaStreamConstraints]];
//_source.captureSession.sessionPreset = AVCaptureSessionPreset1280x720;
// if ([[UIDevice currentDevice] userInterfaceIdiom] == UIUserInterfaceIdiomPhone)
// [_source.captureSession setSessionPreset:AVCaptureSessionPreset640x480];
// else {
// [_source.captureSession setSessionPreset:AVCaptureSessionPresetPhoto];
// }
}
return _source;
}
媒体约束. 这是安卓给我的.. 添不添加 效果感觉不出来😓
- (RTCMediaConstraints *)defaultMediaStreamConstraints {
RTCPair *width = [[RTCPair alloc] initWithKey:@"MAX_VIDEO_WIDTH_CONSTRAINT" value:@"maxWidth"];
RTCPair *height = [[RTCPair alloc] initWithKey:@"MAX_VIDEO_HEIGHT_CONSTRAINT" value:@"maxHeight"];
RTCPair *rate = [[RTCPair alloc] initWithKey:@"MAX_VIDEO_FPS_CONSTRAINT" value:@"maxFrameRate"];
return [[RTCMediaConstraints alloc] initWithMandatoryConstraints:nil optionalConstraints:@[width, height, rate]];
}
- 添加到媒体流中
[self.meidaStream addVideoTrack:videoTrack];
音频
- 创建音频并添加到媒体流中
RTCAudioTrack *audio = [kApp.factory audioTrackWithID:@"ARDAMSa0"];
[self.meidaStream addAudioTrack:audio];
添加到P2P通道中
[_peerConnection addStream:self.meidaStream];
接收音视频
回到RTCPeerConnectionDelegate代理中
- (void)peerConnection:(RTCPeerConnection *)peerConnection
addedStream:(RTCMediaStream *)stream {
//收到远程流.RTCMediaStream这类中包含audioTracks, videoTracks.
//拿到视频流. 这流需要使用RTCEAGLVideoView 这类来渲染.使用起来很简单. 但是记得
//- (void)videoView:(RTCEAGLVideoView*)videoView didChangeVideoSize:(CGSize)size; 这个回调
//当改变尺寸时候会调用.调用时机为初始化调用一次.每次改变尺寸调用.比如说技巧问题的时候
//可以使用代理发送到界面上.这也是真正意义上音视频打洞完成.
RTCVideoTrack *videoTrack = [stream.videoTracks firstObject];
//音频流不用拿到,直接播放就可以了
}
提供size改变部分代码
- (void)videoView:(RTCEAGLVideoView *)videoView didChangeVideoSize:(CGSize)size {
UIDeviceOrientation orientation = [[UIDevice currentDevice] orientation];
[UIView animateWithDuration:0.4f animations:^{
CGFloat containerWidth = self.view.frame.size.width;
CGFloat containerHeight = self.view.frame.size.height;
CGSize defaultAspectRatio = CGSizeMake(4, 3);
if (videoView == self.localView) {
self.localVideoSize = size;
CGSize aspectRatio = CGSizeEqualToSize(size, CGSizeZero) ? defaultAspectRatio : size;
CGRect videoRect = self.view.bounds;
CGRect videoFrame = AVMakeRectWithAspectRatioInsideRect(aspectRatio, videoRect);
CGFloat scaleView = videoFrame.size.width/videoFrame.size.height;
CGFloat endWidth = kDeviceHeight*scaleView;
videoFrame.size.width =endWidth;
videoFrame.size.height = kDeviceHeight;
[self.locaViewTopConstraint setConstant:containerHeight/2.0f - videoFrame.size.height/2.0f];
[self.locaViewBottomConstraint setConstant:containerHeight/2.0f - videoFrame.size.height/2.0f];
[self.locaViewLeftConstraint setConstant:containerWidth/2.0f - videoFrame.size.width/2.0f]; //center
[self.locaViewRightConstraint setConstant:containerWidth/2.0f - videoFrame.size.width/2.0f]; //center
} else if (videoView == self.remoteView) {
self.remoteVideoSize = size;
CGSize aspectRatio = CGSizeEqualToSize(size, CGSizeZero) ? defaultAspectRatio : size;
CGRect videoRect = self.view.bounds;
if (self.remoteVideoTrack) {
videoRect = CGRectMake(0.0f, 0.0f, self.view.frame.size.width/4.0f, self.view.frame.size.height/4.0f);
if (orientation == UIDeviceOrientationLandscapeLeft || orientation == UIDeviceOrientationLandscapeRight) {
videoRect = CGRectMake(0.0f, 0.0f, self.view.frame.size.height/4.0f, self.view.frame.size.width/4.0f);
}
}
CGRect videoFrame = AVMakeRectWithAspectRatioInsideRect(aspectRatio, videoRect);
//Resize the localView accordingly
[self.remoteVideoWidthLayout setConstant:videoFrame.size.width];
[self.remoteVideoHeightLayout setConstant:videoFrame.size.height];
}
[self.view layoutIfNeeded];
}];
}
RTCDataChannel
建立互相发送的通道.发送数据类型为
NSData
. 经过测试数据单次发送大于为20m
左右. 但是会分三次发送.如果超出RTCDataChannel
会直接断开.RTCDataChannel
单次发送量大约为6M
左右.
这里面存在一个坑. 安卓和iOS都出现了此问题具体原因不明,这个也是大部分童鞋, RTCDataChannel不能打通的原因
- 创建
和音视频很像, 只需要创建
_peerConnection
添加进去即可.
//注释部分是参数填写, 可以不必填写.
// RTCDataChannelInit *datainit = [[RTCDataChannelInit alloc] init];
// datainit.isNegotiated = YES;
// datainit.isOrdered = YES;
// datainit.maxRetransmits = 30;
// datainit.maxRetransmitTimeMs = 30000;
// datainit.streamId = 1;
RTCDataChannelInit *config = [[RTCDataChannelInit alloc] init];
config.isOrdered = YES;
//_peerConnection 在此时必须已经创建了
_dataChannel = [_peerConnection createDataChannelWithLabel:@"commands" config:config];
_dataChannel.delegate = self;//RTCDataChannelDelegate
- 发送消息
NSData *data = [@"Hello World!" dataUsingEncoding:NSUTF8StringEncoding];
RTCDataBuffer *buffer = [[RTCDataBuffer alloc] initWithData:data isBinary:false];//这个地方一定要选false. 安卓那边要求.具体不明
[_dataChannel sendData:buffer];
- �RTCDataChannelDelegate 详解
//代理1 判断是否打开成功
- (void)channelDidChangeState:(RTCDataChannel *)channel {
switch (channel.state) {
case kRTCDataChannelStateOpen:
// DSpersonKitLog(@"DataChannel 通道打开");
break;
case kRTCDataChannelStateClosing:
break;
case kRTCDataChannelStateClosed:
//DSpersonKitLog(@"DataChannel 关闭");
{
[_tcp send:Bye];//发送失败了
}
break;
case kRTCDataChannelStateConnecting:
// DSpersonKitLog(@"DataChannel 正在开启");
break;
default:
break;
}
}
- (void)channel:(RTCDataChannel*)channel
didReceiveMessageWithBuffer:(RTCDataBuffer*)buffer {
//收到RTCDataChannel对面发送过来的消息. 自己去解析就好
}
- 关闭
移除之前必须关闭. 否则会在框架内崩溃.
RTCDataChannel坑
刚接触RTCDataChannel 的时候, 运行别人的Demo, 发现一个问题. 发起者发起, 接受者接受, 成功, DataChannel 开启成功, 发起者可以发送, 接受者可以收到反之则不行. 经过测试安卓和iOS都出现了这个问题(自己跟自己测试, 即iOS->iOS, Android->Android).有意思的是, 安卓和iOS可以. 经过对比iOS采用双方都采用初始化赋值给全局变量. 安卓采用都采用初始化后不赋值方式, 在协议回调中赋值给全局变量的方式,随之改为全部初始化, 但是接收端在协议回调中重新再次赋值一次, 发起端不赋值的方式, DataChannel 可以使用. 原因不明.如有知道的请告知.
//创建方式不变 在RTCPeerConnectionDelegate代理中重新再次赋值一次
- (void)peerConnection:(RTCPeerConnection *)peerConnection
didOpenDataChannel:(RTCDataChannel *)dataChannel {
dispatch_async_on_main_queue(^{
DSpersonKitLog(@"RTCDataChannel 通道已经打开");
//发起者和接受者都需要创建, 但是接受者需要在通道打开的时候重新赋值一次, 原因不明
if (!_isInititor) {
_dataChannel = dataChannel;
_dataChannel.delegate = self;
}
});
}
问题
- 根据测试. 创建sdp 等方法必须在主线程内调用. 否则代理不执行. 在回调中使用异步线程无所谓.
- 如果你在调用此方法时候.,即未使用我使用的方法创建摄像头的方法. 崩溃了在框架中. 网上的解决方法为在主线程内创建. 还是出现崩溃的解决方法.将_factory 的创建由单例移到AppDelegate中创建具体原因不明.(我找了2天/(ㄒoㄒ)/~~)
RTCVideoSource *videoSource = [_factory videoSourceWithCapturer:capturer constraints:mediaConstraints];
- �RTCDataChannel 坑问题(在👆)
- 如果你出现了崩溃并且找不到原因, 记得看一看是不是未调用
close
.却移除了缓存
- (void)close {
[_peerConnection close];
_peerConnection = nil;
_peerConnection = NULL;
_state = kDSP2PStateDisconnect;
if (!_dataChannel) {//视频的时候不存在_dataChannel
return;
}
[_dataChannel close];
_dataChannel = nil;
_dataChannel = NULL;
}
技巧
-
self.source
//创建的时候使用默认, 如果通道打通后可以提升清晰度清晰度. 如果直接使用高清晰度,打洞速度会非常慢. 默认创建的视频大小为480X640
//切换摄像头清晰度
self.source.captureSession.sessionPreset = AVCaptureSessionPreset1280x720;
- 创建音视频的时候, 不建议打开
RTCDataChannel
, 会影响打洞速度.
最大问题
截止于2017.9.29日 仍然未解决. 发送视频的时候, iOS和iOS之间视频界面无卡顿问题. 但是和安卓之间. 打通后不久界面就会卡住. 至今原因不明. 后切换发现视频流应该是不传送了 .因为界面会黑屏. 如果您知道原因请联系我. 谢谢QQ/微信 576895195
因为项目不是我的.... 就不拿出来了. 有一个Demo是不错的. 采用WebSocket和Http方式交换信息
特别感谢@涂耀辉大婶分享的这篇入门教程