会议投屏直播:投屏数据流获取方案

- 不同系统版本的演进

这里只以ios为例,以后有时间会分别写一下其它平台上的实现方式。
苹果在这方面一直处于消极缓慢的演进状态,总是令开发者有点巧妇难为的感觉。即使是给出的实现方案,也因为各种不稳定,在实际调用过程中不能稳定执行。后面也会一一说明。

ios中比较正规的实现方式是用Extension来实现的,从ios9开始提供很有限的实现。在ios9之前也有通过破解协议来实现的,本文是以Extension扩展方式实现,如何添加扩展我就不一一说明了。

1. RPScreenRecorder

RPScreenRecorder是苹果最早提出的屏幕录制方案,直接上部分代码:


/*
 *  直接触发录屏,只能录取应该应用中的内容,应用进入后台或退出后,自动关闭。
 */
 #pragma mark- start record
-(IBAction)onStart:(id)sender{
    if([[RPScreenRecorder sharedRecorder] isAvailable] == YES){
        [[RPScreenRecorder sharedRecorder] startRecordingWithMicrophoneEnabled:YES handler:^(NSError * _Nullable error) {
            
        }];
    }
}

#pragma mark- stop record
- (IBAction)onStop:(id)sender{
    if([[RPScreenRecorder sharedRecorder] isAvailable] == YES){
        [[RPScreenRecorder sharedRecorder]stopRecordingWithHandler:^(RPPreviewViewController * _Nullable previewViewController, NSError * _Nullable error) {
            [self presentViewController:previewViewController animated:YES completion:^{
                
            }];
        }];
    }
}

上面这种方式当点击完成后,会模态打开系统提供的预览界面。在界面中可以选择保存共享等方式。显示然这种方式是不支持流式实时返回的,做直播显然不太合适,主要用于后期生成完整文件再上传的方式。

于是,在 ios11有时候,苹果又在这个基础上提供了一定的实时流返回的能力,但其实这个时候,已经可以不用这个方式实现了,这时大概提供一下调用方法:


// 开始录制
    if (@available(iOS 11.0, *)) {
        [[RPScreenRecorder sharedRecorder] startCaptureWithHandler:^(CMSampleBufferRef  _Nonnull sampleBuffer, RPSampleBufferType bufferType, NSError * _Nullable error) {
            NSLog(@"--- [数据]  %@", sampleBuffer);
        } completionHandler:^(NSError * _Nullable error) {
            NSLog(@"Recording started error %@",error);
        }];
    }


// 停止录制
    if (@available(iOS 11.0, *)) {
        [[RPScreenRecorder sharedRecorder] stopCaptureWithHandler:^(NSError * _Nullable error) {
            
        }];
    }
    

这时已经解决了实时流数据的返回问题,但始终无法录制应用外的内容。其实在实现这个需求,已经有了另外的实现方式。

2. Extension 实现实时流数据的返回

Extension添加过程不上图了,选择target -> add -> 选择 Broadcast Upload Extension ->Next ->点选 include UI Extension选项 -> 完成添加

这个时候,系统多出了两个文件夹,一个用于实现录屏前的ui展示(ios 12及之后,基于新的启动方式,已经不再回调)。通常可以在这个界面做一些设置或作为过度性的界面展示。


@implementation BroadcastSetupViewController

// Call this method when the user has finished interacting with the view controller and a broadcast stream can start
- (void)userDidFinishSetup {
    NSURL *broadcastURL = [NSURL URLWithString:@"http://apple.com/broadcast/streamID"];
    NSDictionary *setupInfo = @{ @"broadcastName" : @"example" };
    [self.extensionContext completeRequestWithBroadcastURL:broadcastURL setupInfo:setupInfo];
}

- (void)userDidCancelSetup {
    [self.extensionContext cancelRequestWithError:[NSError errorWithDomain:@"YourAppDomain" code:-1 userInfo:nil]];
}

@end

从注释上已经可以理解,当选择确定,调用 ** userDidFinishSetup ** ,当选择取消时,调用 ** userDidCancelSetup ** 进行录屏前的终止。

当完成上面的流程后,系统进入到录屏并返回实时流的回高阶段。回调代码如下:


#import "SampleHandler.h"

@implementation SampleHandler

- (void)broadcastStartedWithSetupInfo:(NSDictionary<NSString *,NSObject *> *)setupInfo {
    // User has requested to start the broadcast. Setup info from the UI extension can be supplied but optional. 
}

- (void)broadcastPaused {
    // User has requested to pause the broadcast. Samples will stop being delivered.
}

- (void)broadcastResumed {
    // User has requested to resume the broadcast. Samples delivery will resume.
}

- (void)broadcastFinished {
    // User has requested to finish the broadcast.
}

- (void)processSampleBuffer:(CMSampleBufferRef)sampleBuffer withType:(RPSampleBufferType)sampleBufferType {
    
    switch (sampleBufferType) {
        case RPSampleBufferTypeVideo:
            // Handle video sample buffer
            break;
        case RPSampleBufferTypeAudioApp:
            // Handle audio sample buffer for app audio
            break;
        case RPSampleBufferTypeAudioMic:
            // Handle audio sample buffer for mic audio
            break;
            
        default:
            break;
    }
}

@end

如何触Extension调用?

在ios10,系统提供了第一种方式:用户主动选择Extension

//打开系统选择框,在扩展开表中选择扩展
[RPBroadcastActivityViewController loadBroadcastActivityViewControllerWithHandler:^(RPBroadcastActivityViewController * _Nullable broadcastActivityViewController, NSError * _Nullable error) {
        [self presentViewController:broadcastActivityViewController animated:YES completion:nil];
    }];

在 ios11的时候,系统又提供了另外一种更直接的触发方式:

[RPBroadcastActivityViewController loadBroadcastActivityViewControllerWithPreferredExtension:@"com.ReplayKitDemo.TestSetupUI" handler:^(RPBroadcastActivityViewController * _Nullable controller, NSError * _Nullable error) {
        controller.delegate = self;
        [self presentViewController:controller animated:YES completion:nil];
    }];

代码中 “com.ReplayKitDemo.TestSetupUI”对应于SetupUI中的BoundID。
后来,苹果觉得上面的两种方式还是不够直接,后来又有了第三种,在ios12开始提出的方式:

if (@available(iOS 12.0, *)) {
        RPSystemBroadcastPickerView *pickView = [[RPSystemBroadcastPickerView alloc] initWithFrame:CGRectMake(0, 0, 200, 200)];
        pickView.preferredExtension = @"com.awesome.SRUtil.SRExtUpload";
        [self.view addSubview:pickView];
    }

RPSystemBroadcastPickerView是一个系统提供的控件,提供直接调用功
能。这种方式调用之下,不再调用 SetupUI进行显示,而是直接调用SampleHandler 所在扩展项目。“com.awesome.SRUtil.SRExtUpload”为SampleHandler项目中的BoundID。但到现时ios13将要推出之时,这种调用方式还不是太稳定。

- 注意事项

1. Extension 50M 内存限制

在项目实践中,针对这 50M的内存限制,直接促使我在推流方案上做出了多种方式的尝试。包括从Tcp向多端同时推流,到UDP组播发送,几乎整个过程都围绕内存限制而展开。

因为TPLine的硬性指标要求,推流不能经过服务器中转,而实现 PTP的直接送达。所以早期用Tcp实现同时多端推送过程中,修改了一开始的方案,为解决回调数据堆积速度快于Tcp多端推流的问题,果断放弃了发送数据队列的惯用实现方式。这个在后面的博文中会一一展开说明。

后面改用UDP+组播的方式发送时,的的确确解决了多端并发推流的问题,但要配合UDP实现多种差错控制的方案。这个在后面的博文中会一一展开说明。

受限于推流不能经过服务器中转,实践证明,用于局域网内的会议投屏系统,采用UDP+组播的方式才是最佳实践方式。

2. Extension 逻辑处理的时间限制

在进行扩展的debug过程中,一开始会习惯性以下断点的方式进行数据的调试。但很快你会发现,扩展很快就结束并退出了。

系统在提供扩展支持的时候,同时也限制了他的最大运行时间,以保持系统的流畅。包括编码在内的所有逻辑都在这个最大运行时间限制下运行。但实际应用说明,进行编码操作时间上还是十分足够的。

所以在进行逻辑设计时,在“processSampleBuffer”方法回调后的处理要做好多线程的处理。

3. 同步线程队列执行编码

//
//  SampleHandler.m
//  TPUpload
//
//  Created by adam on 2019/5/22.
//  Copyright © 2019 lcs. All rights reserved.
//

- (void)processSampleBuffer:(CMSampleBufferRef)sampleBuffer withType:(RPSampleBufferType)sampleBufferType {
    switch (sampleBufferType) {
        case RPSampleBufferTypeVideo:
            [[H264Encoder shareInstance] startH264EncodeWithSampleBuffer:sampleBuffer andReturnData:^(NSData *data) {
                [[UDPServerMain shareInstance] pushVideo:data];
            }];
            break;
        case RPSampleBufferTypeAudioApp:
            break;
        case RPSampleBufferTypeAudioMic:
            break;
        default:
            break;
    }
}

//
//  H264Encoder.m
//  Awesome
//
//  Created by adam on 2019/1/8.
//  Copyright © 2019年 common. All rights reserved.
//


#pragma mark- startH264EncodeWithSampleBuffer
-(void)startH264EncodeWithSampleBuffer:(CMSampleBufferRef)sampleBuffer andReturnData:(ReturnDataBlock)block{
    self.returnDataBlock = block;
    dispatch_sync(m_EncodeQueue, ^{
        [self encode:sampleBuffer];
    });
}


以上面代码为例,H264Encoder进行硬编码,而在 “startH264EncodeWithSampleBuffer”方法中,进行编码操作时,不能使用dispatch_async异步的方式进行调用。

4. RPSystemBroadcastPickerView

  • 不稳定
    发展到iOS 12后,苹果终于开放了RPSystemBroadcastPickerView给开发及用户,终于可以实现在应用内启动系统的录屏功能,但在实践中发现,其呼出的pickerView,在指定preferredExtension的情况下,会出对应该的Extension不显示的情况。
  • 外观无法自定义
    RPSystemBroadcastPickerView的外观不能订制外观,只能默认显示系统的样式,在界面高度定制的情况下很难实现和谐共存。
    于是一般使用隐性调用的方式,先把RPSystemBroadcastPickerView添加到页面,再进行隐藏。通过其它的按钮对RPSystemBroadcastPickerView实现触发,参考代码如下:
for (UIView *item in self.pickView.subviews) {
        if ([item isKindOfClass:UIButton.class] == YES) {
            actionButton = (UIButton*)item;
        }
    }
dispatch_async(dispatch_get_main_queue(), ^{
            [self->actionButton sendActionsForControlEvents:UIControlEventTouchDown];
 });

- 开发过程中的经验

1. 如何调试Extension

简单来说就是,在调试Extension的过程中,要先安装运行主项目,再选择SampleHandler项目target进行调度运行,在打开的应用列表中选择你的项目。

在代码修改过之后,也最好重复上面的步骤,确保运行的是最新的代码。

2. 如何处理奇怪的现象

很多奇怪现象都来源于几个主要因素:

  • 长期不关机
    这个恐怕是很多开发人员的习惯了
  • 长期进行调试
    可以通过重启手机,重启电脑等方式进行
  • 系统不稳定
    这个有可能是xcode的问题,也有可能是api的问题,这些情况比较麻烦。

3.SampleHandler在不同界面中,返回的流情况不尽相同。

  • ReplayKit通过监听屏幕是否有变化对SampleHandler中的processSampleBuffer方法进行回调。在测试中发现,不同手机,不同应用显示在界面上时processSampleBuffer的回调情况都会不尽相同。
  • 在ipad上运行,因为上面红色的title一直有渐变的变化,所以正常情况下,在回调方法中一直都有流回调过来。
  • 但在手机端运行时,在非桌面(打开一个应用)的情况下,应用没当前界面没有操作和变化或用户没有发生触摸操作,流的回调就会停止。所以,很多人就以为投屏数据是很少的,很多人就直接把每个帧直接转成图片传到播放端,其实这是一个误区。有效地利用IBP帧可以很有效的减少网络流量和画面的流畅度。
  • 所以在推流服务端中,最好能缓存最后一个IBP帧组和sps, pps数据,以解决有新的接入端接入时,不会因为IBP帧的推流,而产生白屏的尴尬情况, 这个也是实现秒开的办法之一。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章