ReactNative实现APP应用内部画中画(iOS端)

1,050 阅读6分钟

近期由于项目需求安排,需要给视频播放添加画中画这个功能,在实现这个功能的时候遇到了些问题,在此例举出来并附上解决方案。(由于大家集成的版本不同,可能坑也不一样,我只列出项目中版本的坑)

简单描述一下我们本次的需求:视屏播放页添加一个画中画的按钮,点击画中画按钮的时候,返回到首页并显示画中画的小视频窗口,该窗口如果不手动关闭,则一直悬浮在界面。如果再次进入刚才的视频播放页,则该画中画窗口消失并正常播放视频,具体效果可参考哔哩哔哩的iOS端。

iOS画中画这个功能实际上是之前开放在iPad端的,然后再iOS14系统上支持了移动端。具体用原生的实现可以参照 # iOS开发-iOS14画中画(OC)这篇文章。原生要实现这个功能比较简单,要把这个功能做好各个点做好做优化不在本篇介绍范围之内,可参照别的文章,接下来进入正文。

我们项目中用到的组件是react-native-video,不过是用的以前的老版本,如果要集成的话,建议采用最新版本,之前的老版本里面有不少坑。下面是这个组件的代码截图

05208525-A3EF-4698-A7AB-5B922454DE08.png 这个组件自带了画中画的功能,配置一下就能用。简单比较核心的几个属性

  • (1)pictureInPicture:这个属性传bool值,开启或者关闭画中画
  • (2)onPictureInPictureStatusChanged:这个是画中画状态改变的回调函数,传 e => void的函数,e.isActive用来判断当前画中画的状态。
  • (3)onRestoreUserInterfaceForPictureInPictureStop:这个是画中画模式下点击x这个关闭按钮回调的方法,传()=>void。这个函数调用之后,还会调用(2),所以整个组件的画中画开关逻辑需要结合(2)(3)两个来实现

坑1:onRestoreUserInterfaceForPictureInPictureStop这个方法在集成的时候竟然没有被调用,最后是修改原生文件RCTVideo.m

- (void)pictureInPictureController:(AVPictureInPictureController *)pictureInPictureController restoreUserInterfaceForPictureInPictureStopWithCompletionHandler:(void (^)(BOOL))completionHandler {
    NSAssert(_restoreUserInterfaceForPIPStopCompletionHandler == NULL, @"restoreUserInterfaceForPIPStopCompletionHandler was not called after picture in picture was exited.");
    if (self.onRestoreUserInterfaceForPictureInPictureStop) {
        self.onRestoreUserInterfaceForPictureInPictureStop(@{});
    }
    _restoreUserInterfaceForPIPStopCompletionHandler = completionHandler;
    [self setRestoreUserInterfaceForPIPStopCompletionHandler:YES];
}

坑2:完成该组件集成之后满心欢喜的以为完成了这个小需求,点击画中画,返回到首页,然后画中画开启然后瞬间又消失了,当时一度有点懵,上网搜了一下,好像也没有搜到rn集成实现应用内画中画的功能。

我仔细分析了一下,应该是视频播放页在返回的过程中被释放了,所以导致画中画窗口消失,验证了一下,从视频播放页开启画中画并push下一个页面,画中画窗口正常运行,也验证了我的猜想。接下来的难点就是如何在navigation调用pop的过程中保持页面存活。首先想到的是在js创建静态变量用来存video的ref,不过这个可以不用试直接排除。毕竟rn跳转是通过路由跳转,就算ref和视频页能被保存不被释放,但是再次进入该视频的视屏播放页的时候,也没有办法保证跳转进入能保持之前离开该页面的各种状态。

解决:于是,我想到了第二个方案,毕竟rn最终也是通过原生来实现的,那么我只要保证视屏播放页对应的controller不被释放,那么对应的rn的component页面也会存在。所以我点击画中画的时候,调用原生方法,偷个懒,用appdelegate存下来,有了引用,该对象就不会被释放了。

RCT_EXPORT_METHOD(pictureInPicture:(NSString *)resId) {
  dispatch_async(dispatch_get_main_queue(), ^{
  // 获取当前页面对应的viewcontroller
    UIViewController * tvc = [[[UIApplication sharedApplication] keyWindow]yt_currentViewController];
    ((AppDelegate *)[UIApplication sharedApplication].delegate).lastVideoController = tvc;
    //[[[UIApplication sharedApplication] keyWindow] yt_currentNavigationController]是获取当前页面对应navigationcontroller
    [[[[UIApplication sharedApplication] keyWindow] yt_currentNavigationController]popToRootViewControllerAnimated:YES];
    ((AppDelegate *)[UIApplication sharedApplication].delegate).resId = resId;
    NSLog(@"lastVideoController=%@,id=%@",((AppDelegate *)[UIApplication sharedApplication].delegate).lastVideoController,resId);

  });
}

// 画中画窗口恢复全屏播放
RCT_EXPORT_METHOD(pushToVideoScreen) {
  dispatch_async(dispatch_get_main_queue(), ^{
    UINavigationController * nav = [[[UIApplication sharedApplication] keyWindow] yt_currentNavigationController];
    UIViewController * vc = ((AppDelegate *)[UIApplication sharedApplication].delegate).lastVideoController;
    if (![nav.viewControllers containsObject:vc]) {
      [nav pushViewController:vc animated:YES];
      [self releaseLastVideoController];
    }
  });
}

RCT_EXPORT_METHOD(supportPictureInPicture:(RCTResponseSenderBlock)callback) {
  dispatch_async(dispatch_get_main_queue(), ^{
    BOOL value = [AVPictureInPictureController isPictureInPictureSupported];
    callback(@[[NSNull null],value?@"true":@"false"]);
  });
}

RCT_EXPORT_METHOD(releaseLastVideoController) {
  dispatch_async(dispatch_get_main_queue(), ^{
    AppDelegate * delegate = (AppDelegate *)[UIApplication sharedApplication].delegate;
    delegate.lastVideoController = nil;
    delegate.resId = nil;
  });
}

坑3:完成上述步骤之后,点击画中画确实返回到了首页,并且画中画窗口正常。似乎完美解决了,再次窃喜。然而,我发现暂停按钮点击之后又会被恢复成播放状态,也就是在应用内无法点击暂停,回到手机桌面时则能正常暂停。想来也是,rn的这个video组件设计应该就不支持应用内画中画,我这么用原生来达到效果无异于横穿马路,最终达到终点但是横穿势必带来不可预估的问题。

解决:这个经过阅读RCTVideo.m源码排查出,是kvo监听别的属性的时候,将rate置为1。(至于为啥到手机桌面的时候点暂停是正常,这个我没搞清楚,源码比较多我也没完完整整理一遍,只是找到了可以解决这个问题的方案,至于调试排查过程,可以说是很艰辛了)。在该源码文件中搜setSrc函数,找到设置kvo playbackRate的地方,修改为[self->_player addObserver:self forKeyPath:playbackRate options:NSKeyValueObservingOptionNew context:nil];,再在observeValueForKeyPath函数中,红色框中的代码:

45E97532-125E-4232-B3FC-42CDFB111A31.png

坑4:完成上面整个过程,画中画似乎没有问题了,已不敢在窃喜。简单试了一下,流程正常就打包交给测试了。然而不久,噩耗再次传来。点击画中画回到首页,点击画中画窗口还原按钮回到视屏播放页,再次点击画中画,但是画中画窗口不显示,但是音频播放没有停止(那个时候我大概是崩溃的,当时这个需求很急,眼看着要上线了)。打开xcode调试,所有数据都是正常的,controller没有释放,[_pipController startPictureInPicture];这一句也成功调用,奇怪的是画中的代理方法一个都没被调用,打印日志,没有成功也没有失败,可以说是很无解。网上查了很久也没找到相似的问题,只找到一篇文章提了几个可能会出现这个问题的原因(# iOS14画中画实践踩坑)

解决:看到有一篇文章在说集成原生画中画,进入直播播放页立刻返回的时候主动开启画中画,然后画中画会先有一段时间黑屏。作者后来解释到画中画应该由用户主动开启而不是我们代码去拉起,各大平台的画中画也是延迟再开启,防止画中画视频或者直播还没加载好出现黑屏。关键字“延迟”,虽然跟我的情况不一样,但是或许可试。最终我改成下面代码,运行可用(至于为啥,我也不是很清楚,如果有清楚原因的大佬,麻烦留言讲解一下,感激不尽)。原本以为集成画中画很简单,没想到就这些坑花了三四天才处理完。

this.setState({ pictureInPicture: true }, () => {
      this.popTimer = setTimeout(() => {
        native.pictureInPicture(`video-${resDTO.id}`);
      }, 300);
    });

最后总算改完了,功能也正常了。期间还有原生与js端交互的优化这里没有提及,但是那些都是小问题,可以根据需求添加简单的原生代码就能处理。