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

  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
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;
}
  1. 创建本地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]];
        }
}
  1. 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];
}
  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方式交换信息

特别感谢@涂耀辉大婶分享的这篇入门教程
自己编译的 MacOS WebRTC Framework

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270

推荐阅读更多精彩内容