iOS Developerios实用开发技巧iOS开发你需要知道的

iOS WebRTC 使用

2017-09-29  本文已影响215人  DSperson

这段项目使用WebRTC 的经验,拿出来分享(提醒, 原理部分请看特别感谢. 基础部分会使用模拟代码)
本文包含音视频和RTCDataChannel

准备

用来打洞的服务器, 这个是我自己搭建的, 可以使用谷歌的(需要梯子. 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请求方式.即时即可

一个外国人编译好的. 直接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

  1. 加载准备阶段Turn Server
  2. 创建唯一的P2P工厂
- (void)setup {
    [RTCPeerConnectionFactory initializeSSL];
    _factory = [[RTCPeerConnectionFactory alloc] init];//全局变量.
    _isInititor = false;//这个需要用来判断是否是发起者. 收到init 方为false. 收到answerInit为true. 
}

本文采用方式: 可以想象为A给B发送视频/音频聊天请求, B收到请求后(类型:init)进行判断是否同意, 同意返回同意(类型:answerInit). 否则返回关闭(类型:Bye). 双方关闭音视频, 处理掉相关缓存. 此种方式在音视频方便尤为重要

  1. A给B发送消息准备发送音视频消息(本文采用TCP, B是否在线是可以得到的. 而且双方都必须在线才可以. 此处不做代码注解, 即发送TCP消息.B解析出来即可. 发送类型为init)
  2. 假设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(不同意)];
                }
}];
  1. A收到B发送的同意信息
RTCPeerConnection *connection = [_factory peerConnectionWithICEServers:stunServerArray 
                                                           constraints:[self peerConnectionConstraints]  
                                                              delegate:self];//根据约束创建. 并且将RTCPeerConnection代理RTCPeerConnectionDelegate放在self中
//全局变量.可以不定义成全局变量. 添加到数组中(本文为了需要改为全局
//方便使用.⚠️⚠️⚠️ 如果你添加到数组中, 从数组中删除中前,一定要先调用 [connection close]; 否则崩溃)
_peerConnection = connection;

- (RTCMediaConstraints *)peerConnectionConstraints {
    RTCPair *pair = [[RTCPair alloc] initWithKey:@"DtlsSrtpKeyAgreement" value:@"true"];//这个是定义好的.不能更改
    RTCMediaConstraints *constraints = [[RTCMediaConstraints alloc] initWithMandatoryConstraints:nil 
                                                                             optionalConstraints:@[pair]];
    return constraints;
}
  1. 创建本地SDP
//会调用RTCPeerConnection 代理 RTCSessionDescriptionDelegate
[_peerConnection createOfferWithDelegate:self constraints:[self defaultOfferConstraints]];
- (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 有两个回调

//创建本地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]];
        }
}
  1. B收到Offer
//创建远程SDP. 会调用RTCSessionDescriptionDelegate 代理2. 此时_isInititor = false
[_peerConnection setRemoteDescriptionWithDelegate:self sessionDescription:sdp];
//sdp 是收到消息出来并创建的解析创建的RTCSessionDescription
+ (RTCSessionDescription *)ds_descriptionFromDictionary:(NSDictionary *)dic {
    if (!dic) {
        return nil;
    }
    NSString *type = dic[@"type"];
    NSString *sdp = dic[@"sdp"];
    return [[RTCSessionDescription alloc] initWithType:type sdp:sdp];
}
  1. 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]
}
  1. 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;
}

视频

  1. 创建
//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]];
}
  1. 添加到媒体流中
[self.meidaStream addVideoTrack:videoTrack];

音频

  1. 创建音频并添加到媒体流中
 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不能打通的原因

  1. 创建

和音视频很像, 只需要创建_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
  1. 发送消息
NSData *data = [@"Hello World!" dataUsingEncoding:NSUTF8StringEncoding];
RTCDataBuffer *buffer =  [[RTCDataBuffer alloc] initWithData:data isBinary:false];//这个地方一定要选false. 安卓那边要求.具体不明
[_dataChannel sendData:buffer];
  1. �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对面发送过来的消息. 自己去解析就好
}
  1. 关闭

移除之前必须关闭. 否则会在框架内崩溃.

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;
        }
    });
}

此问题我在52im中的说明和询问

问题

  1. 根据测试. 创建sdp 等方法必须在主线程内调用. 否则代理不执行. 在回调中使用异步线程无所谓.
  2. 如果你在调用此方法时候.,即未使用我使用的方法创建摄像头的方法. 崩溃了在框架中. 网上的解决方法为在主线程内创建. 还是出现崩溃的解决方法.将_factory 的创建由单例移到AppDelegate中创建具体原因不明.(我找了2天/(ㄒoㄒ)/~~)
RTCVideoSource *videoSource = [_factory videoSourceWithCapturer:capturer constraints:mediaConstraints];
  1. �RTCDataChannel 坑问题(在👆)
  2. 如果你出现了崩溃并且找不到原因, 记得看一看是不是未调用close .却移除了缓存
- (void)close {
    [_peerConnection close];
    _peerConnection = nil;
    _peerConnection = NULL;
    _state = kDSP2PStateDisconnect;
    if (!_dataChannel) {//视频的时候不存在_dataChannel
        return;
    }
    [_dataChannel close];
    _dataChannel = nil;
    _dataChannel = NULL;
}

技巧

  1. self.source //创建的时候使用默认, 如果通道打通后可以提升清晰度清晰度. 如果直接使用高清晰度,打洞速度会非常慢. 默认创建的视频大小为480X640
//切换摄像头清晰度
self.source.captureSession.sessionPreset = AVCaptureSessionPreset1280x720;
  1. 创建音视频的时候, 不建议打开RTCDataChannel, 会影响打洞速度.

最大问题

截止于2017.9.29日 仍然未解决. 发送视频的时候, iOS和iOS之间视频界面无卡顿问题. 但是和安卓之间. 打通后不久界面就会卡住. 至今原因不明. 后切换发现视频流应该是不传送了 .因为界面会黑屏. 如果您知道原因请联系我. 谢谢QQ/微信 576895195

因为项目不是我的.... 就不拿出来了. 有一个Demo是不错的. 采用WebSocket和Http方式交换信息

特别感谢@涂耀辉大婶分享的这篇入门教程

上一篇下一篇

猜你喜欢

热点阅读