APP内录屏直播的小结

关于ReplayKit,现今苹果开放的能力还十分有限,所以充斥着各种各样的”奇技淫巧“

屏幕录制组件

近年来,由于娱乐方式的移动化和手游化,手机屏幕录制乃至录屏直播一直都是一个呼声很高的需求。但是由于众所周知苹果的封闭保守,直到iOS10,苹果才陆续开放了
直到iOS12,才十分”吝啬“地提供了RPSystemBroadcastPickerView这个应用内发起系统级录屏的功能组件。并且,RPSystemBroadcastPickerView其实只能算是一个Button,需要添加到UIView上并让用户手动点击,然后才会触发真正的录屏控制器。

RPSystemBroadcastPickerView本身只提供了设置preferredExtension和showsMicrophoneButton这俩功能,而且这个按钮本身并不是很美观。因此大多数情况下,我们会用自己的开始按钮,然后隐藏RPSystemBroadcastPickerView,通过给里面的按钮sendAction的方式触发。

1
2
3
4
5
6
for (UIView *v in self.sysBroadcastPicker.subviews) {
if ([v isKindOfClass:[UIButton class]]) {
[((UIButton *)v) sendActionsForControlEvents:UIControlEventAllTouchEvents];
break;
}
}

用这种方式,我们可以仿佛是自己的Button调起了系统的录屏广播页面,同时配置好preferredExtension,让可用列表里面显示我们自己的APP。
(其实直接去系统组件面板开始录屏也是一样的,但是要确保勾选到自己的APP。

发起录屏后自动隐藏

在启动了系统的录屏页面后,我们选中指定的APP,会有一个3秒倒计时,倒计时结束后,我们的BroadcastExtension会收到开始录屏的回调,但是这个时候系统录屏页面并不会自动关闭,也没有提供关闭的方法,需要用户手动点击关闭,但是这样对用户体验不太友好(说好的傻瓜式操作)。那有没有什么另辟蹊径的方法,自然是有的,虽然这其中有一个大坑,这个等下再说。

上图这个页面在弹出的时候,可以看到这应该是一个系统级的页面,并不依附在我们的APP中,但是更重要的是,我注意到, 这个页面在调起的时候,我们的APP会触发
- (void)applicationDidEnterBackground:(UIApplication *)application 退到后台的方法。而在点击投屏页面后,会自动关闭并且APP进入前台。
然后我有一个大胆的想法,那我们能不能强行激活我们的APP,让我们的APP进入前台呢?
首先想到的,自然是最简单的SCHEME方式,我们配置好自己APP的的SCHEME,然后直接用openURL的方式唤醒APP。

1
2
3
4
5
6
// 因为系统开播控件会使本APP进入后台,可以用SCHEME唤醒自身 同时可以关闭系统界面
[[UIApplication sharedApplication] openURL:[NSURL URLWithString:@"XXXX://"] options:@{} completionHandler:^(BOOL success) {
if (!success) {
// 如果失败,可以用通知告诉用户单击屏幕关闭
}
}];

试一下,发现It work!
看起来非常顺利,然而事情并不简单。

iOS13.x以下系统中奇怪的调起

功能开发完成后,在进行多机型的测试中,发现有部分系统会出现无法自动关闭的问题。然后在实际测试中,发现并不是所有系统都是全屏覆盖的方式,在iOS13及以下的系统,在某些机型中会出现另外一种样式的系统录屏页面。(并不准确,因为测试机有限)

从表现上看,这个页面很像是调用了presentModalViewController的方式进行展示的,而且发现APP也没有退到后台和进入前台,因此我们openURL的方式肯定是不会生效的。
连续触发调用两次RPSystemBroadcastPickerView的button,然后出现了以下错误 Application tried to present modally an active controller,确认了的确是使用了presentModalViewController的方法。
接着通过试验,发现并非是在原本的ViewController上进行的,猜测是使用了一个新的UIWindow。

但是在打印[UIApplication sharedApplication].windows以及使用Xcode的UI运行时调试,都没有发现这个Window的出现。
这window还能隐身不成?
最后感谢万能的Reveal,终于发现了它的庐山真面目 —— RPModalPresentationWindow

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* Generated by RuntimeBrowser
Image: /System/Library/Frameworks/ReplayKit.framework/ReplayKit
*/
@interface RPModalPresentationWindow : UIApplicationRotationFollowingWindow

- (id)_presentationViewController;
- (void)dealloc;
- (id)init;
- (bool)isInternalWindow;
- (id)mainWindow;
- (void)presentAlertController:(id)arg1 animated:(bool)arg2 completion:(id /* block */)arg3;
- (void)presentViewController:(id)arg1 animated:(bool)arg2 completion:(id /* block */)arg3;

@end

RPModalPresentationWindow会直接调用presentViewController,而这个controller就是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* Generated by RuntimeBrowser
Image: /System/Library/Frameworks/ReplayKit.framework/ReplayKit
*/

@interface RPBroadcastPickerStandaloneViewController : RPSystemBroadcastPickerViewController {
<RPSystemBroadcastPickerViewControllerDelegate> * _delegate;
RPModalPresentationWindow * _presentationWindow;
}

@property (nonatomic) <RPSystemBroadcastPickerViewControllerDelegate> *delegate;

- (void).cxx_destruct;
- (id)delegate;
- (void)dismissAfter:(double)arg1;
- (void)presentAnimated:(bool)arg1 completion:(id /* block */)arg2;
- (void)setDelegate:(id)arg1;
- (void)viewControllerDidFinish;
- (void)viewDidDisappear:(bool)arg1;

@end

另外,由此可见,UIApplication的getWindows方法里面是会过滤掉到一些系统级的window的。

言归正传,虽然我们知道了系统是创建了一个RPModalPresentationWindow然后presentRPBroadcastPickerStandaloneViewController,但是我们拿不到这个window,又要如何调用dismiss呢。

这就要用到Objective-C著名的黑科技,方法Swizzle了。
其实即使我们不知道上面两个具体的window和controller,我们也可以替换掉全局的present,然后观察所有的vc,也能发现端倪。

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
@implementation UIViewController (hack)

+ (void)load
{
SEL orig_present = @selector(presentViewController:animated:completion:);
SEL swiz_present = @selector(swiz_presentViewController:animated:completion:);
[UIViewController swizzleMethods:[self class] originalSelector:orig_present swizzledSelector:swiz_present];
}

//exchange implementation of two methods
+ (void)swizzleMethods:(Class)class originalSelector:(SEL)origSel swizzledSelector:(SEL)swizSel
{
Method origMethod = class_getInstanceMethod(class, origSel);
Method swizMethod = class_getInstanceMethod(class, swizSel);

//class_addMethod will fail if original method already exists
BOOL didAddMethod = class_addMethod(class, origSel, method_getImplementation(swizMethod), method_getTypeEncoding(swizMethod));

if (didAddMethod) {
class_replaceMethod(class, swizSel, method_getImplementation(origMethod), method_getTypeEncoding(origMethod));
} else {
//origMethod and swizMethod already exist
method_exchangeImplementations(origMethod, swizMethod);
}
}

- (void)swiz_presentViewController:(UIViewController *)vc animated:(BOOL)animated completion:(void(^)(void))completion
{
if ([vc isKindOfClass:NSClassFromString(@"RPBroadcastPickerStandaloneViewController")]) {
// 保存这个vc,在适当的时机调用DismissViewController
}
[self swiz_presentViewController:vc animated:animated completion:completion];
}
@end

重新启动,顺利发现可以被捕获到,然后自然成功地Dismiss关闭了页面,大功告成。

还有个小完善

上面的代码其实有个小坑,在无意中发现如果在同一时间连续调用presentViewController方法, 会触发一个系统异常Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: 'Application tried to present modally an active controller
其实这也是通用的一个注意点,我们正好可以在这里加上一个判断,可以在全局范围内避免这个问题:

1
2
3
4
5
6
7
8
9
10
11
- (void)swiz_presentViewController:(UIViewController *)vc animated:(BOOL)animated completion:(void(^)(void))completion
{
if ([vc isKindOfClass:NSClassFromString(@"RPBroadcastPickerStandaloneViewController")]) {
// 保存这个vc,在适当的时机调用DismissViewController
}
// 只有当前没有presentedViewController的时候才能present 否则可能会
// Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: 'Application tried to present modally an active controller
if (self.presentedViewController == nil) {
[self swiz_presentViewController:vc animated:animated completion:completion];
}
}