iOS WebRTC 使用

早期使用webRTC 问题记录, 后来留下的问题都经过升级版本得到解决,这篇文章意义不是很大了

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

准备

  • Turn Server (47.93.21.132:3478)

    用来打洞的服务器, 这个是我自己搭建的, 可以使用谷歌的(需要梯子. stun:stun.l.google.com:19302)
    用户名: u1
    密码: p1

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // 代码注解
    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 使用

    1
    pod 'libjingle_peerconnection'

    项目阶段

    单例类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    + (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工厂
    1
    2
    3
    4
    5
    - (void)setup {
    [RTCPeerConnectionFactory initializeSSL];
    _factory = [[RTCPeerConnectionFactory alloc] init];//全局变量.
    _isInititor = false;//这个需要用来判断是否是发起者. 收到init 方为false. 收到answerInit为true.
    }

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

  3. A给B发送消息准备发送音视频消息(本文采用TCP, B是否在线是可以得到的. 而且双方都必须在线才可以. 此处不做代码注解, 即发送TCP消息.B解析出来即可. 发送类型为init)
  4. 假设B同意的情况下. 收到消息使用TCP返回同意消息(answerInit)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
     @weakify(self);
    [self.delegate dspersonReceiveInvitationVideoWithOther:other agree:^(BOOL isAgree) {
    @strongify(self);
    if (isAgree) {
    //同意 发送tcp消息
    //模拟代码
    //[_tcp send:init(同意)];
    } else {
    //发送Bye
    //[_tcp send:Byet(不同意)];
    }
    }];
  5. A收到B发送的同意信息
  • 创建RTCPeerConnection
    1
    2
    3
    4
    5
    6
    7
    RTCPeerConnection *connection = [_factory peerConnectionWithICEServers:stunServerArray 
    constraints:[self peerConnectionConstraints]
    delegate:self];//根据约束创建. 并且将RTCPeerConnection代理RTCPeerConnectionDelegate放在self中
    //全局变量.可以不定义成全局变量. 添加到数组中(本文为了需要改为全局
    //方便使用.⚠️⚠️⚠️ 如果你添加到数组中, 从数组中删除中前,一定要先调用 [connection close]; 否则崩溃)
    _peerConnection = connection;

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    //创建本地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
    1
    2
    3
    //创建远程SDP. 会调用RTCSessionDescriptionDelegate 代理2. 此时_isInititor = false
    [_peerConnection setRemoteDescriptionWithDelegate:self sessionDescription:sdp];
    //sdp 是收到消息出来并创建的解析创建的RTCSessionDescription
  • SDP 创建
    1
    2
    3
    4
    5
    6
    7
    8
    + (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
2
3
4
5
6
//代理1: 新的 ICE Candidate 被发现时调用 需要将信息返回给Socket服务器
- (void)peerConnection:(RTCPeerConnection *)peerConnection
gotICECandidate:(RTCICECandidate *)candidate {
// 需要将这些ice 发给对方客户端
//[_tcp send:candidate]
}
  1. A或者B.收到ICE Candidate
    1
    [_peerConnection addICECandidate:ice_candidate];
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    //代理2: 状态变化
    - (void)peerConnection:(RTCPeerConnection *)peerConnection
    iceConnectionChanged:(RTCICEConnectionState)newState {
    switch (newState) {
    case RTCICEConnectionConnected:
    {
    //除了这个别的都是没打开的状态
    }
    break;
    case RTCICEConnectionFailed:
    {
    //这个状态就可以发送Bye了
    }
    break;
    }
    }

媒体

媒体流_meidaStream全局变量

1
2
3
4
5
6
- (RTCMediaStream *)meidaStream {
if (!_meidaStream) {
_meidaStream = [_factory mediaStreamWithLabel:@"ARDAMS"];//`ARDAMS`固定就这么写
}
return _meidaStream;
}

视频

  1. 创建
    1
    2
    //position : AVCaptureDevicePosition. 摄像头方向
    RTCVideoTrack *videoTrack = [self createVideoTrackWithDirecion:position];
    1
    2
    3
    4
    5
    6
    7
    8
    - (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 懒加载的方式创建

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    - (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;
    }

    媒体约束. 这是安卓给我的.. 添不添加 效果感觉不出来😓

    1
    2
    3
    4
    5
    6
    - (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]];
    }
  2. 添加到媒体流中
    1
    [self.meidaStream addVideoTrack:videoTrack];

    音频

  3. 创建音频并添加到媒体流中
    1
    2
     RTCAudioTrack *audio = [kApp.factory audioTrackWithID:@"ARDAMSa0"];
    [self.meidaStream addAudioTrack:audio];

    添加到P2P通道中

    1
    [_peerConnection addStream:self.meidaStream];

    接收音视频

    回到RTCPeerConnectionDelegate代理中

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    - (void)peerConnection:(RTCPeerConnection *)peerConnection
    addedStream:(RTCMediaStream *)stream {
    //收到远程流.RTCMediaStream这类中包含audioTracks, videoTracks.
    //拿到视频流. 这流需要使用RTCEAGLVideoView 这类来渲染.使用起来很简单. 但是记得
    //- (void)videoView:(RTCEAGLVideoView*)videoView didChangeVideoSize:(CGSize)size; 这个回调
    //当改变尺寸时候会调用.调用时机为初始化调用一次.每次改变尺寸调用.比如说技巧问题的时候
    //可以使用代理发送到界面上.这也是真正意义上音视频打洞完成.
    RTCVideoTrack *videoTrack = [stream.videoTracks firstObject];
    //音频流不用拿到,直接播放就可以了
    }

    提供size改变部分代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    - (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不能打通的原因

  4. 创建

    和音视频很像, 只需要创建_peerConnection 添加进去即可.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    //注释部分是参数填写, 可以不必填写.
    // 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
  5. 发送消息
    1
    2
    3
    NSData *data = [@"Hello World!" dataUsingEncoding:NSUTF8StringEncoding];
    RTCDataBuffer *buffer = [[RTCDataBuffer alloc] initWithData:data isBinary:false];//这个地方一定要选false. 安卓那边要求.具体不明
    [_dataChannel sendData:buffer];
  6. RTCDataChannelDelegate 详解
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    //代理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对面发送过来的消息. 自己去解析就好
    }
  7. 关闭

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

    RTCDataChannel坑

    刚接触RTCDataChannel 的时候, 运行别人的Demo, 发现一个问题**. 发起者发起, 接受者接受, 成功, DataChannel 开启成功, 发起者可以发送, 接受者可以收到反之则不行. 经过测试安卓和iOS都出现了这个问题(自己跟自己测试, 即iOS->iOS, Android->Android).有意思的是, 安卓和iOS可以. 经过对比iOS采用双方都采用初始化赋值给全局变量**. 安卓采用都采用初始化后不赋值方式, 在协议回调中赋值给全局变量的方式,随之改为全部初始化, 但是接收端在协议回调中重新再次赋值一次, 发起端不赋值的方式, DataChannel 可以使用. 原因不明.如有知道的请告知.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    //创建方式不变 在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ㄒ)/~~)
    1
    RTCVideoSource *videoSource = [_factory videoSourceWithCapturer:capturer constraints:mediaConstraints];
  3. RTCDataChannel 坑问题(在👆)
  4. 如果你出现了崩溃并且找不到原因, 记得看一看是不是未调用close .却移除了缓存
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    - (void)close {
    [_peerConnection close];
    _peerConnection = nil;
    _peerConnection = NULL;
    _state = kDSP2PStateDisconnect;
    if (!_dataChannel) {//视频的时候不存在_dataChannel
    return;
    }
    [_dataChannel close];
    _dataChannel = nil;
    _dataChannel = NULL;
    }

    技巧

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

    最大问题

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

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

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


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!