iOS ReplayKit 屏幕共享研究

对于iOS端,实现屏幕共享需要两个关键技术:屏幕内容采集和媒体流广播。前者需要系统提供相关权限,可以让开发者采集到app或者整个系统层面的屏幕上的内容,后者需要系统提供采集到实时的视频流和音频流,这样才能通过推流到服务器,实现媒体流的广播。

iOS 各个系统版本实现屏幕共享

iOS系统屏幕共享技术,因系统版本而异。根据apple官方数据,截止到 2020 年 2 月,iOS13 占有率为70%,iOS12 占有率为 23%,其他更低版本为 7% 。 现在对各系统版本的实现方式和限制等方面进行比较说明。

iOS8 及之前

实现方式:iOS8 系统不提供相关SDK,开发者只能通过一些trick的方式(例如通过破解苹果用于无线传输的airplay协议,使用协议的私有api相关功能),实现屏幕共享的直播。

存在的问题:存在系统兼容性和发布可靠性两个方面问题。由于使用私有api,无法保证系统更新之后还能继续使用,通常系统更新后需要重新适配api,并且可能无法通过appstore的代码审核,只能通过企业版本发布应用。

iOS9

实现方式: iOS9系统考虑到开发者在屏幕录制共享方面需求,禁用了之前被开发者使用的实现屏幕共享的私有api,提供了 ReplayKit SDK,并且通过这个SDK, 开发者可以将当前app中(仅支持app内录制)的操作屏幕画面录制下来,完成后可以进行查看、编辑、通过指定方式分享出去。通过上传到服务器,实现分享和直播的功能。

存在的问题:该方案只能将当前app(而不是整个手机上)的屏幕内容录制下来,并且无法将实时的音视频流提供出来,只能将最终录制完毕的整个mp4文件提供给开发者,所以实际上并非真正的屏幕的直播共享,无法保证实时性。

关键代码:

1
2
3
4
5
6
7
8
9
10
11
12
// 启动录制

[[RPScreenRecorder sharedRecorder] startRecordingWithMicrophoneEnabled:YES handler:^(NSError * _Nullable error) {
NSLog(@"%@", error);
}];


// 停止录制

[[RPScreenRecorder sharedRecorder] stopRecordingWithHandler:^(RPPreviewViewController * _Nullable previewViewController, NSError * _Nullable error) {

}];

⚠️

  1. 使用 [RPScreenRecorder sharedRecorder] 启动录制,会首先请求用户同意使用摄像头和麦克风,主要考虑用户的隐私和权限,如果用户拒绝了,将无法进行录制。
  2. 录制的内容不会包含系统的UI,比如上方导航栏;
  3. 录制的内容会经过音视频编码,而不是原始的yuv或pcm数据;
  4. 录制的内容无法直接查看,必须通过RPPreviewViewController才能查看预览,或者分享,或者保存到本地相册中。而这个RPPreviewViewController在停止录制的接口回调中才能获取,也就是说,只有停止录制之后才能通过RPPreviewViewController操作录制的音视频。

iOS10

首先介绍一个概念:App Extention

App Extention 是 iOS8 和 OSX 10.10 新增的一个很有意思的功能,可以对现有app添加扩展进程,这在一定程度上弥补iOS的沙盒机制对应用间通信的限制。App Extention 的出现,为用户提供了在其它app中使用我们提供的服务的快捷方式,比如用户可以在 Today 的 widgets(小部件) 中查看应用展示的简略信息,而不用再进到我们的应用中,这将是一种全新的用户体验;但是,extension 的出现可能会减少用户启动应用的次数,同时还会增大开发者的工作量。

在一个App中创建一个Extention,这个App称为Extention的 Containing App,Extension不能单独存在和发布,随 Containing App 的安装而安装,随Containing App的发布而发布,一个Containing App 可以添加多个Extension。

Extension 的运行是独立的一个进程,Containing App 在没有启动的状态下,Extension仍然可以被启动和运行。

Extension 可以被系统直接调用,例如下拉通知栏查看同花顺行情时,就是由通知中心启动调用同花顺提供的extension。Extension 也可以被其它应用间接调用,例如在某个应用中调用搜狗输入法。

Extension 有自己的 Bundle Identifier,需要在开发者账号中注册App id 和创建 Provisioning profile。Extension 的 Bundle Identifier 必须以Containing App 的 Bundle Identifier为前缀。
例如:Containing App 的 Bundle Identifier 为 com.demo.app
Extension 的 Bundle Identifier 必须为 com.demo.app.xxx

Extension 不是一个App,所以生命周期和运行环境和App不同。在多数情况下,Extension是由用户在某一个App的界面或者某一个活动的控制器中启动,这个启动Extension的App被称为 Host App。Host App 提供Extension运行所需的上下文并通过发送一个Request的方式开启Extension的生命周期,Extension 在完成 Host App请求的任务后结束运行。



一般Extension 主要跟host app 进行通信,两者可以通过 ExtensionContext 直接通信:

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
//  RPBroadcastExtension.h

/*!
@category NSExtensionContext (RPBroadcastExtension)
@abstract Category which defines the method to call from on an extension context object when user interaction is complete during the broadcast setup flow.
*/
@interface NSExtensionContext (RPBroadcastExtension)

/*! @abstract Load information about the broadcasting app.
@param handler block which will be supplied a bundleID, displayName and an optional appIcon.
*/
- (void)loadBroadcastingApplicationInfoWithCompletion:(void(^)(NSString *bundleID, NSString *displayName, UIImage * __nullable appIcon))handler;

/*! @abstract Method to be called when the extension should finish. Deprecated.
@param broadcastURL URL that can be used to redirect the user to the ongoing or completed broadcast. This URL is made available to the running application via a property in RPBroadcastController.
@param broadcastConfiguration Configuration to use for generating movie clips
@param setupInfo Dictionary that can be used to share any setup information required by the upload extension. The values and keys in this dictionary are to be defined by the extension developer.
*/
- (void)completeRequestWithBroadcastURL:(NSURL *)broadcastURL broadcastConfiguration:(RPBroadcastConfiguration *)broadcastConfiguration setupInfo:(nullable NSDictionary <NSString *, NSObject <NSCoding> *> *)setupInfo API_DEPRECATED("No longer supported", ios(10.0,11.0),tvos(10.0,11.0));

/*! @abstract Method to be called when the extension should finish.
@param broadcastURL URL that can be used to redirect the user to the ongoing or completed broadcast. This URL is made available to the running application via a property in RPBroadcastController.
@param setupInfo Dictionary that can be used to share any setup information required by the upload extension. The values and keys in this dictionary are to be defined by the extension developer.
*/
- (void)completeRequestWithBroadcastURL:(NSURL *)broadcastURL setupInfo:(nullable NSDictionary <NSString *, NSObject <NSCoding> *> *)setupInfo API_AVAILABLE(ios(11.0),tvos(11.0));

@end


Extension 跟 Containing App 一般不进行通信。甚至Extension在运行的时候,Containing App 都没有启动和运行。Extension 无法直接读取Containing App 的沙盒数据。

A Today widget (and no other app extension type) can ask the system to open its containing app by calling the openURL:completionHandler: method of the NSExtensionContext class.

Extension 和 Containing App 可以通过共同读写一个被称为 Shared Resources 的存储区域共享本地存储数据,这是通过 App Groups 实现的。

An app extension can communicate indirectly with its containing app



iOS10 系统推出了屏幕共享广播的 Extention,分别是 Broadcast Upload Extention 和 Broadcast Setup UI Extention。通过这两个 extention,可以为我们的app添加屏幕共享功能:将音视频流进行处理分发,并实时直播。

实现方式: iOS10 增加了音视频流实时广播功能,可以让我们实时的获取音视频流。支持代码控制录制的启动,想要录制当前app内的内容,必须通过其他app的extension,而启动这个extension必须通过集成 Replaykit 的 api。

存在的问题: 只能录制当前app内的内容,所以当app切到后台,录制内容将停止。这样会限制一些应用的使用场景。

关键代码:

iOS10 中屏幕录制 extention 作为一个独立进程,可供所有app调用。不能通过代码直接启动录制 Extention 进程。而是需要被录制端(Host App)通过下面代码弹出支持录制 Extention 的列表sheet:

被录制端(Host App,游戏或应用)的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#import <ReplayKit/ReplayKit.h>

// 弹出extention列表界面

- (void)startScreenShareLive {
[RPBroadcastActivityViewController loadBroadcastActivityViewControllerWithHandler:^(RPBroadcastActivityViewController * _Nullable broadcastActivityViewController, NSError * _Nullable error) {
// 设置代理,通过代理方法的回调我们才能启动录制进程
broadcastActivityViewController.delegate = self;
[self presentViewController:broadcastActivityViewController animated:YES completion:nil];
}];
}


MARK: - RPBroadcastActivityViewControllerDelegate

- (void)broadcastActivityViewController:(RPBroadcastActivityViewController *)broadcastActivityViewController didFinishWithBroadcastController:(nullable RPBroadcastController *)broadcastController error:(nullable NSError *)error {
[broadcastActivityViewController dismissViewControllerAnimated:YES completion:nil];
[broadcastController startBroadcastWithHandler:^(NSError * _Nullable error) {
if (error) {
NSLog(@"startBroadcastWithHandler error: %@", error);
}
}];
}

如果已经安装支持 Upload Extention 的App,上面步骤会弹出选择 extention 的 sheet 列表,点击一个 extention,系统会启动一个UI界面进程:Broadcast Setup UI Extention,这个进程通常用于让用户输入一些鉴权信息,或者自定义的其他页面,在启动录制进程 Broadcast Upload Extention 之前插入一个交互界面。

直播端(Containing App)的实现:

很多直播 App 本身已经支持通过摄像头进行视频流上传、直播,新增对 ReplayKit Live 的支持,只需要创建两个扩展的 target,分别是 Broadcast Setup UI Extension 和 Broadcast Upload Extension

Broadcast Setup UI Extention 负责广播前的一些交互工作,让用户填写直播平台的账号密码直播标题等信息。该 UI 界面什么时候会弹出,上面已经提到。

实现代码如下:

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
40
41
42
43
#import "BroadcastSetupViewController.h"

@implementation BroadcastSetupViewController

- (void)viewDidLoad {
[super viewDidLoad];
//自定义交互view
}

- (void)viewDidAppear:(BOOL)animated {
[super viewDidAppear:animated];
//userDidFinishSetup 需要被调用,可以在这里调用,或者自定义的其他action方法中调用
[self userDidFinishSetup];
}

// Call this method when the user has finished interacting with the view controller and a broadcast stream can start
- (void)userDidFinishSetup {

// URL of the resource where broadcast can be viewed that will be returned to the application
NSURL *broadcastURL = [NSURL URLWithString:@"http://apple.com/broadcast/streamID"];

// Dictionary with setup information that will be provided to broadcast extension when broadcast is started
NSDictionary *setupInfo = @{ @"broadcastName" : @"example" };

// Tell ReplayKit that the extension is finished setting up and can begin broadcasting
[self.extensionContext completeRequestWithBroadcastURL:broadcastURL setupInfo:setupInfo];

/*
* For iOS 10, use the following codes instead.

RPBroadcastConfiguration * broadcastConfig = [[RPBroadcastConfiguration alloc] init];
[self.extensionContext completeRequestWithBroadcastURL:broadcastURL broadcastConfiguration:broadcastConfig setupInfo:setupInfo];
*/

}


- (void)userDidCancelSetup {
// Tell ReplayKit that the extension was cancelled by the user
[self.extensionContext cancelRequestWithError:[NSError errorWithDomain:@"YourAppDomain" code:-1 userInfo:nil]];
}

@end

在 Broadcast Setup UI Extention 的 BroadcastSetupViewController 类中,必须调用 userDidFinishSetup,才能回调到被录制端(Host App)中的代理 RPBroadcastActivityViewControllerDelegate,在该代理中调用 startBroadcastWithHandler 接口,则会启动 Broadcast Upload Extention 进程。Upload Extention 作用是接收并处理 Broadcast UI 传过来的用户信息,以及处理 RPBroadcastController 传过来的实时音视频流数据。

代码如下:

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
40
41
42
43
44
45
46
47
48
49
50
#import <ReplayKit/ReplayKit.h>

@interface SampleHandler : RPBroadcastSampleHandler

@end

...


// To handle samples with a subclass of RPBroadcastSampleHandler set the following in the extension's Info.plist file:
// - RPBroadcastProcessMode should be set to RPBroadcastProcessModeSampleBuffer
// - NSExtensionPrincipalClass should be set to this class

@implementation SampleHandler

- (void)broadcastStartedWithSetupInfo:(NSDictionary<NSString *,NSObject *> *)setupInfo {

// User has requested to start the broadcast. Setup info from the UI extension will be supplied.
NSLog(@"broadcastStartedWithSetupInfo: %@", setupInfo);
//初始化,比如进程间通知的监听
[[RKStreamer shared] setupWithInfo:setupInfo];
}

- (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 {
s witch (sampleBufferType) {
case RPSampleBufferTypeVideo:
[RKStreamer shared] pushVideoSampleBuffer:sampleBuffer];
break;
case RPSampleBufferTypeAudioApp:
[[RKStreamer shared] pushAudioSampleBuffer:sampleBuffer ofAudioChannel:kReplayKitAudioChannelApp];
break;
case RPSampleBufferTypeAudioMic:
[[RKStreamer shared] pushAudioSampleBuffer:sampleBuffer ofAudioChannel:kReplayKitAudioChannelMic];
break;
}
}

@end

首先会回调到 broadcastStartedWithSetupInfoc方法,这里我们通常进行了一些初始化,例如进程间通知的监听等。下面的几个方法 broadcastPausedbroadcastResumedbroadcastFinished 表示了录制进程的变化,通常我们会在其中添加进程通知,通知 Host App 这些变化。最后的 processSampleBuffer 方法就是最终采集到的音频、视频原始数据。其中音频未做混音,包括麦克音频pcm和app音频pcm,而视频输出为yuv数据。

以上这几个方法中的代码不能阻塞(例如写文件等慢操作),否则导致录制进程停止。

⚠️ RKStreamer 是一个单例类,封装了登录鉴权、处理音视频流、直播推流功能。使用单例,而不是在 SampleHandler 里处理,因为 SampleHandler 并不是 Broadcast Upload Extension 里的唯一一个实例,Upload Extension 会不断地创建很多个 SampleHandler 来处理 CMSampleBufferRef,而我们为了保存一些内部状态,必须使用一个固定的类实例来实现。

下面是 Host App 和 Extention 之间的交互图,可以更直观的看到他们是怎么工作的:

iOS11

这个版本的iOS提供了 ReplayKit2 这个升级的SDK,最重大的升级就是解决了iOS10中无法录制整个手机屏幕内容(只能录制当前app)的弊病,并且进一步提高集成sdk和实现屏幕录制直播的可用性。

实现方式: 支持录制App内和整个系统,若要仅录制当前App内的屏幕,直接使用iOS 10 的方法即可,不过iOS11增加了新接口,可以直接启动想要的录制 Extention 进程,跳过弹出列表sheet再点击选择 Extention 的过程。若要录制整个系统,iOS11不允许开发直接调用api来启动系统界别的录制,必须是用户通过手动启动。启动方法很复杂:用户点击进入手机设置页面-> 控制中心-> 自定义 , 找到屏幕录制的功能按钮,将其添加到控制中心。添加成功后,我们可以在手机上滑唤出控制界面,看到这个圆形启动录屏按钮,长按录屏按钮,弹出选择 Extention 界面,选中我们的 Extention 进程进行录制。

存在的问题: iOS11不允许开发直接调用api来启动系统级别的录制,必须是用户通过手动启动。而且手动启动的过程也比较复杂,门槛较高。

由于iOS11录制的启动为手动操作,并且开发者启动录制进程的App也无从知道是否已经启动,所以通常我们会在 broadcastStartedWithSetupInfo 中发出进程级通知,告知App,录制已经启动。

iOS12

苹果WWDC 2018全球开发者大会宣布iOS12系统将要正式发布,大会在“ live screen broadcast with replaykit ”主题演讲中对iOS12系统将升级的ReplayKit2 SDK做了重点描述,其中提到将对iOS11系统中的ReplayKit2问题进行优化,使开发者可以通过接口直接启动屏幕录制,并完成直播,从而解决了iOS11系统还需要用户进行一系列复杂操作的问题。

实现方式:支持api控制启动录制,iOS12还是会考虑用户的感知性,要求开发者必须通过replaykit提供的 RPSystemBroadcastPickerView 来展示启动的view,然后通过点击view上面的按钮才能启动。

1
2
3
4
5
6
7
8
9
10
if (@available(iOS 12.0, *)) {
RPSystemBroadcastPickerView *picker = [[RPSystemBroadcastPickerView alloc] initWithFrame:CGRectMake(0, 0, 100, 200)];
picker.showsMicrophoneButton = YES;
//你的app对用upload extension的 bundle id, 必须要填写对
picker.preferredExtension = @"com.ReplayKit2.ReplayKit2Liveios12.BroadcastUpload";
[self.view addSubview:picker];
picker.center = self.view.center;
} else {
// Fallback on earlier versions
}

总结

最后是总结,Fuck the apple!