iOS直播画中画预研

一、背景

苹果在 WWDC 2015 上展示了一项多任务能力,在播放视频时即使用户将 App 切到后台,画面也能继续以画中画的形式在小窗口中播放。此能力最早在 iOS 9 上对 iPad 开放,而如今苹果将这能力扩展到了 iPhone 上,只要升级到 iOS 14 即具备此能力。

二、实现方式

先翻看一下 WWDC 2015 的 Keynote,要实现画中画的功能,目前可以通过以下几种方式:

  1. AVPlayerViewController
  2. AVPictureInPictureController
  3. WKWebView

其中 AVPlayerViewController 和 WKWebView 由于当前直播间并未通过这两种方式来实现,因此直接可以忽略了。剩下的 AVPictureInPictureController 需要与 AVPlayerLayer 绑定,而 AVPlayer 亦可播放通过 HLS 协议实现的直播流,那就尝试从这里入手吧。

三、尝试实现

简单起见,直接在用户退出直播间时,使用 AVPictureInPictureController 启动画中画。其中 AVPlayerLayer 设置为 hidden,是因为实际上我们并不需要真正展示这个 layer 出来,画中画开启后,原本需绘制在 AVPlayerLayer 上的数据将被系统统一接管并展示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
- (void)startPip:(NSString *)url
{
NSError *err = nil;
[[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryPlayAndRecord error:&err];
[AVAudioSession.sharedInstance setActive:YES error:&err];

if (!AVPictureInPictureController.isPictureInPictureSupported || err || url.length == 0) {
return;
}
self.pipItem = [AVPlayerItem playerItemWithURL:[NSURL URLWithString:url]];
self.pipPlayer = [[AVPlayer alloc] initWithPlayerItem:self.pipItem];
self.avLayer = [AVPlayerLayer playerLayerWithPlayer:self.pipPlayer];
self.avLayer.hidden = YES;
self.avLayerContainer = [[UIView alloc] init];
[self.avLayerContainer.layer addSublayer:self.avLayer];
[[KSAppDelegate shareInstance].window addSubview:self.avLayerContainer];

self.pipController = [[AVPictureInPictureController alloc] initWithPlayerLayer:self.avLayer];
self.pipController.delegate = self;
[self.pipPlayer play];

[self.pipController startPictureInPicture];
}

代码很简单,也一切就绪,却发现直播间是退出了,然而画中画窗口却没有出现。打断点到 AVPictureInPictureController 的各个 delegate 方法,却 没有 收到任何一个回调。

3.1 无法开启

既然开启不了,又没有任何的回调,一时间看不出个所以然来,那不妨先看看别的 App 是如何实现的。于是翻看了一下目前市面上已上线画中画功能的 App,首先找到了电竞。

看起来电竞的画中画是在退出直播间后,经过了一段时间的等待才将画中画弹窗开起来的,那我们也尝试加一个延时。

1
2
3
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[self.pipController startPictureInPicture];
});

加完延时后,画中画弹窗是可以展示出来了,但这个延后的时长要怎么定呢?于是在调用 startPictureInPicture 前打个断点,对比一下延时前后的区别:

可以看到两者唯一的区别,就是 pictureInPicturePossible 在延时后变成了 true ,那是否能够加个 KVO 监听,等待 pictureInPicturePossible 变为 true 后再开启画中画呢?

1
2
3
4
5
6
7
8
9
10
[self.pipController addObserver:self forKeyPath:@"pictureInPicturePossible" options:NSKeyValueObservingOptionNew context:nil];

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context
{
if (object == self.pipController && [keyPath isEqualToString:@"pictureInPicturePossible"]) {
if (self.pipController.pictureInPicturePossible && !self.pipController.pictureInPictureActive) {
[self.pipController startPictureInPicture];
}
}
}

然而还是不行,同样是完全没有收到任何 delegate 回调,也打不开画中画。但是碰巧尝试在 KVO 触发时额外加个 dispatch_async 再去 startPictureInPicture,这时画中画竟然就可以打开了。看了一下触发 KVO 的也同样是主线程,为何却需要再 dispatch_async 一下呢。另一个问题是为何 pictureInPicturePossible 为 false 时,调用 startPictureInPicture 开启失败却一点回调都没有呢?这里先存疑。

3.2 开启等待

现在是能够打开了,但等待 pictureInPicturePossible 变为 true 的过程也太久了。期间什么反馈都没有,并不是一个好的体验。

由于这里是拉的远端视频流,那么这里会不会是因为 AVPictureInPictureController 需要在视频已经准备好,可以播放时,才会被置为 true 呢?于是做了个试验,将 AVPlayerItem 的 status 也加个检测:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context
{
if (object == self.pipController && [keyPath isEqualToString:@"pictureInPicturePossible"]){
if (self.pipController.pictureInPicturePossible && !self.pipController.pictureInPictureActive) {
KINFO(@"welkin: possible");
[self.pipController removeObserver:self forKeyPath:@"pictureInPicturePossible"];
dispatch_async(dispatch_get_main_queue(), ^{
[self.pipController startPictureInPicture];
});
}
} else if (object == self.pipItem && [keyPath isEqualToString:@"status"]) {
if (self.pipItem.status == AVPlayerItemStatusReadyToPlay) {
KINFO(@"welkin: ready");
}
}
}

而结果也在预测之内,画中画需要在视频可以开始播放时才允许打开

因此这里就可以想到一个优化方案:先循环播放一个本地的 loading 视频来开启画中画,同时也启动直播视频的拉取,等待直播可以开始播放时,再将画中画的画面切换成真正的直播内容。 那就搞起来。

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
51
52
53
54
55
56
57
58
59
- (void)startPip:(NSString *)url
{
NSError *err = nil;
[[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryPlayAndRecord error:&err];
[AVAudioSession.sharedInstance setActive:YES error:&err];
if (!AVPictureInPictureController.isPictureInPictureSupported || err || url.length == 0) {
return;
}

// loading视频
NSString *loadingMp4Path = [[NSBundle.mainBundle pathForResource:@"PublishDefaultThemeModels" ofType:@"bundle"] stringByAppendingPathComponent:@"xxx.mp4"];
self.loadingItem = [AVPlayerItem playerItemWithURL:[NSURL fileURLWithPath:loadingMp4Path]];
self.loadingPlayer = [[AVPlayer alloc] initWithPlayerItem:self.loadingItem];
__weak typeof(self) weakSelf = self;
[self.loadingPlayer addPeriodicTimeObserverForInterval:CMTimeMake(1, 1) queue:dispatch_get_main_queue() usingBlock:^(CMTime time) {
float current = CMTimeGetSeconds(time);
float total = CMTimeGetSeconds([weakSelf.loadingItem duration]);
if (current >= total - 1) {
[weakSelf.loadingPlayer seekToTime:CMTimeMake(0, 1)];
}
}];
self.avLayerContainer = [[UIView alloc] init];
[[KSAppDelegate shareInstance].window addSubview:self.avLayerContainer];
self.avLayer = [AVPlayerLayer playerLayerWithPlayer:self.loadingPlayer];
self.avLayer.hidden = YES;
[self.avLayerContainer.layer addSublayer:self.avLayer];

self.pipController = [[AVPictureInPictureController alloc] initWithPlayerLayer:self.avLayer];
[self.pipController addObserver:self forKeyPath:@"pictureInPicturePossible" options:NSKeyValueObservingOptionNew context:nil];
self.pipController.delegate = self;
[self.loadingPlayer play];

// 真实直播数据
self.realItem = [AVPlayerItem playerItemWithURL:[NSURL URLWithString:url]];
[self.realItem addObserver:self forKeyPath:@"status" options:NSKeyValueObservingOptionNew context:nil];
self.realPlayer = [[AVPlayer alloc] initWithPlayerItem:self.realItem];
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context
{
if (object == self.pipController && [keyPath isEqualToString:@"pictureInPicturePossible"]){
if (self.pipController.pictureInPicturePossible && !self.pipController.pictureInPictureActive) {
dispatch_async(dispatch_get_main_queue(), ^{
[self startPip];
});
}
} else if (object == self.realItem && [keyPath isEqualToString:@"status"]) {
if (self.realPlayer.status == AVPlayerItemStatusReadyToPlay) {
if (self.loadingPlayer) {
[self.loadingPlayer pause];
[self.loadingPlayer.currentItem.asset cancelLoading];
self.loadingPlayer = nil;
}
self.avLayer.player = self.realPlayer;
[self.realPlayer play];
[self startPip];
}
}
}

效果如下:

其中退出直播间后首先看到的那个下雨的片段是在工程里随便找到的一个视频文件,后续可以让设计侧重新设计一下输出一个 loading 的样式即可。如此改进后,在退出直播间后很快就能弹出画中画弹窗让用户感知到有画中画这回事了。

四、存在问题

目前看起来简单的播放是可以了,但由于 AVPlayer 只能拉一路 HLS 直播流,结合项目本身的话还是存在一些问题,当然也是可以解决的:

  1. 当主播正在连麦,画中画只能看到一方的画面(后台将连麦双方混流后下发可以解决)
  2. AVSDK 的房间不可用(后台对 AVSDK 房间的音视频数据也旁路转码到 CDN,下发 HLS 直播流地址可解决)

五、一些思考

在处理完以上的 demo 后,我重新仔细看了下 WWDC 的 keynote 以及苹果的开发文档。也终于明白了上面为何 delegate没回调 、 也需要通过 KVO+dispatch_async+本地loading视频 这么奇怪的方式才能正常地启动一个画中画了:

因为苹果从没打算让我们这样自作主张地去触发一个画中画。

无论是从苹果开发文档:

https://developer.apple.com/documentation/avkit/adopting_picture_in_picture_in_a_custom_player?language=objc

https://developer.apple.com/documentation/avkit/avpictureinpicturecontroller?language=objc

还是从 WWDC:

苹果一直在强调需要 用户主动去触发,才能去开启画中画 ,否则审核时将会被拒。苹果更是给出了点击按钮然后才去调用 startPictureInPicture 的例子,其中 pictureInPicturePossible 只是用于判断这个启动的按钮是否能展示给用户。

当我再次去翻看一些视频 App 如哔哩哔哩、腾讯视频等,发现他们都是通过提供一个画中画按钮给用户,因此用户点击按钮时就就能顺理成章地弹出 loading toast 并等待(或者说再次确认) pictureInPicturePossible 为 true 并开启画中画了。

因此,上面一开始做的那些操作其实相当于是各种奇技淫巧了,当然真要这样做也不是不行,也就一个提审中屏蔽就能解决的事。