iOS端一次视频全屏需求的实现

对于一个带有视频播放功能的app产品来说,视频全屏是一个基本且重要的需求。虽然这个需求看起来很简单,但是在实现上,我们前后迭代了三套技术方案。这篇文章将介绍这三种实现方案中的利弊和坑点,以及实现过程中积累的经验。

需求要点:

  • 在屏幕旋转的动画中,需要保持播放器之外的界面布局(比如“First View”等几行字的布局不应该发生变化)
  • 全屏切换到小屏,小屏需要回到原先位置

对于这三种实现方案,我写了个demo分别示意。三个方案分别在demo的三个tab中。

原始方案:方案一

从小屏进入全屏时,将播放器所在的view放置到window上,用transform的方式做一个旋转动画,最终让view完全覆盖window。 从全屏回到小屏时,用transform的方式做旋转动画,最终让播放器所在的view回到原先的parentView上

核心代码示例:

- (void)enterFullscreen {

    if (self.movieView.state != MovieViewStateSmall) {
        return;
    }

    self.movieView.state = MovieViewStateAnimating;

    /*
     * 记录进入全屏前的parentView和frame
     */
    self.movieView.movieViewParentView = self.movieView.superview;
    self.movieView.movieViewFrame = self.movieView.frame;

    /*
     * movieView移到window上
     */
    CGRect rectInWindow = [self.movieView convertRect:self.movieView.bounds toView:[UIApplication sharedApplication].keyWindow];
    [self.movieView removeFromSuperview];
    self.movieView.frame = rectInWindow;
    [[UIApplication sharedApplication].keyWindow addSubview:self.movieView];

    /*
     * 执行动画
     */
    [UIView animateWithDuration:0.5 animations:^{
        self.movieView.transform = CGAffineTransformMakeRotation(M_PI_2);
        self.movieView.bounds = CGRectMake(0, 0, CGRectGetHeight(self.movieView.superview.bounds), CGRectGetWidth(self.movieView.superview.bounds));
        self.movieView.center = CGPointMake(CGRectGetMidX(self.movieView.superview.bounds), CGRectGetMidY(self.movieView.superview.bounds));
    } completion:^(BOOL finished) {
        self.movieView.state = MovieViewStateFullscreen;
    }];
}

- (void)exitFullscreen {

    if (self.movieView.state != MovieViewStateFullscreen) {
        return;
    }

    self.movieView.state = MovieViewStateAnimating;

    CGRect frame = [self.movieView.movieViewParentView convertRect:self.movieView.movieViewFrame toView:[UIApplication sharedApplication].keyWindow];
    [UIView animateWithDuration:0.5 animations:^{
        self.movieView.transform = CGAffineTransformIdentity;
        self.movieView.frame = frame;
    } completion:^(BOOL finished) {
        /*
         * movieView回到小屏位置
         */
        [self.movieView removeFromSuperview];
        self.movieView.frame = self.movieView.movieViewFrame;
        [self.movieView.movieViewParentView addSubview:self.movieView];
        self.movieView.state = MovieViewStateSmall;
    }];
}

这种方式在实现上相对简单,因为仅仅旋转了播放器所在的view,view controller和device的方向均始终为竖直(portrait)。但最大的问题就是全屏时status bar的方向依然是竖直的,虽然之前通过全屏时隐藏statusBar来掩盖了这个问题,但这同时导致了用户无法在视频全屏时看到时间、网络情况等,体验有待改善。

方案二设想

为了解决status bar不能转至横向的问题,我们决定替换视频全屏的实现方式。

业界比较流行的转屏方式应该是通过私有接口设置UIDevice的orientation属性。但直接设置这一属性的实现出来的转屏动画效果有些欠缺。比如旋转过程中会漏出黑色。

由于setStatusBarOrientation等方法已经被标记为depreciated了,使用它可能会带来风险,于是我们暂时也没有考虑这种方式

一个顺理成章的技术方案是:

在一个只支持Portrait的ViewController上,present一个只支持Landscape的ViewController,通过改写ViewController之间的转场动画,既能高度自定义全屏动画,也能让StatusBar在视频全屏时横向显示。

这个方案没有用任何私有接口或hack的方式,完全符合苹果的要求,理想中它应该会是一个稳定可靠的方案。

于是我们选用了present一个ViewController的方式作为方案二进行了下去。

核心设计为:

新增一个ViewController的子类,demo中为FullscreenViewController,重写这个类的supportedInterfaceOrientations方法,返回UIInterfaceOrientationMaskLandscape。

全屏时,present这个FullscreenViewController,系统会自动将statusBar转至Landscape方向。 同时自定义这个FullscreenViewController的转场动画,形成一个符合产品需求的动画效果。

方案二坑点&解决

在方案二的实现过程中,我们遇到了不少问题。

业务上的坑点

  • 兼容viewWillDisppear等生命周期方法

用默认方式present一个viewController,会导致presentingViewController的view被从视图层次中移除,同时presentingViewController的viewWillDisappear方法被调用,这对原有业务逻辑有较大影响。

调研后发现使用UIModalPresentationOverFullScreen的方式来present,presentingViewController的生命周期将不受影响。

  • 对iOS7的兼容

UIModalPresentationOverFullScreen只支持iOS8以上系统,对于iOS7系统,我们使用UIModalPresentationCustom的present方式。然而iOS7和iOS8中,view的层次结构有所不同,导致iOS7下需要进行特殊兼容:

在iOS8及以上,present一个viewController时,view的层次结构是

UIWindow frame = (0 0; 667 375)  
    | presentingViewController.view frame = (0 0; 667 375); transform = [0, 1, -1, 0, 0, 0]
    | UITransitionView frame = (0 0; 667 375)
        | presentedViewController.view frame = (0 0; 375 667)

在iOS7中,present一个viewController时,view的层次结构是

UIWindow frame = (0 0; 320 480)  
    | UITransitionView frame = (0 0; 320 480)
        | presentingViewController.view frame = (0 0; 320 480)
        | presentedViewController.view frame = (0 0; 320 480) transform = [0, -1, 1, 0, 0, 0]

所以在iOS7中,需要自行将presentedViewController.view应用transform变形,让它旋转90度达到横屏的效果。 在demo中,进入全屏的动画对iOS7和iOS8及以上系统做了分别处理:

iOS7:进入全屏的动画开始前,设置presentedViewController.view.transform = CGAffineTransformIdentity,为的是让presentedViewController.view覆盖在播放器view的位置上,形成动画起始的布局;在全屏动画的过程中,设置presentedViewController.view应用transform变形,让它旋转90度达到横屏的效果;

iOS8及以上:进去全屏的动画开始前,由于presentedViewController.view已经被系统旋转了90度,所以我们也让presentedViewController.view旋转90度,才能覆盖在播放器view的位置上;在全屏动画的过程中,设置presentedViewController.view.transform = CGAffineTransformIdentity,由于它的父视图已经是横向状态,所以此时presentedViewController.view看起来也称为了横屏状态。

具体代码可以参考demo中的EnterFullscreenTransition和ExitFullscreenTransition两个类。

  • 部分控件依靠window尺寸布局,导致全屏动画过程中布局错乱

在iOS8及以上系统中,present的动画过程中,iOS对presentingViewController的view的frame经过了两次变化:

第一次变化:由于window的bounds从竖直(height > width)的状态变化为了横向(width > height)的状态,由于autoresizing的作用,presentingViewController.view的frame也变成了横向状态

第二次变化:系统给presentingViewController.view增加了transform使其旋转了90度,让presentingViewController.view看起来还是竖直方向的

如果一个presentingViewController.view的一个子视图通过读取window的宽高来布局,那么在第一次变化的时候,window的宽高已经对调,导致第二次变化时这个子视图的布局错乱。

demo中,方案二内的红色小字展示了这个bug。

  • Window横竖屏的切换导致tableView被reloadData

上一个问题中讲到,在present的过程中,iOS对presentingViewController的view的frame经过了两次变化,这很可能会导致presentingViewController中的tableView被触发reloadData。

原本,为了让一个视频在退出全屏时回到原来的位置上,我们只需要记录movieView的superView以及movieView小屏状态下的frame,退出全屏时将movieView重新添加到superView上即可(如demo中的实现方式)。但是如果这个superView是一个tableViewCell的话,reloadData会导致cell的重用。退出全屏时将movieView添加到superView上,反而会导致视频视图回到了错误的位置。在这种情况下,我们只能改为记录movieView所在cell的index来弥补这个问题。

另外,由于我们的app对tableView做了高度缓存等优化,在一些极端情况下,这两次出乎意料的reloadData导致了一些业务上的bug,比如存入了错误的高度缓存。

系统级的坑点

如果说业务上的坑点都能通过修改代码逻辑来依次解决,但系统级的坑点却很难有有效的解决方案。

  • 屏幕渲染bug导致半边黑屏问题(iOS10)

在开发过程中发现,这种全屏方式会偶现手机半边黑屏的问题。在主线程忙碌时这个问题有较大的复现概率。

比如在这张图中,系统statusBar的宽度明显是横屏时的宽度,但是在渲染时整个界面都被旋转了90度,造成下方出现了半边黑屏。 但是在这种情形下,如果读取UIWindow,UIScreen以及各个层次的view的frame,得到的数值都符合预期,唯独屏幕上渲染出来的结果是bug的。

写了几个demo表明,这个即便没有转场动画,只要present一个只支持横屏方向的ViewController,半边黑屏的问题就有概率复现。 尝试了在全屏动画完成后再设置UIDevice的orientation,设置StatusBarOrientation等方法,但均没能解决这个问题。

  • UIScreen长宽互换bug(iOS10)

当app在后台时,触发了present操作,再返回前台,会导致读取UIScreen时长宽被互换了,但此时UIWindow的长宽却是符合预期的。

如果其他业务中,有界面是通过读取UIScreen的长宽来布局的话,这时就会出现布局异常的bug,比如某一段时间的详情页:

对于这个问题,我们采用了两个walkaround的方案:

(1)当app在后台时,禁止触发全屏相关的代码; (2)各业务不依赖UIScreen布局,比较好的做法是仅依赖superView进行布局;

方案二放弃

屏幕渲染bug导致半边黑屏问题一直得不到解决,并且在腾讯视频、爱奇艺等app上也发现了类似的bug。

这是爱奇艺

这是腾讯视频

针对这个问题,我们尝试了苹果的Apple Developer Technical Support,通过这个渠道可以接触到苹果的工程师,也许能给我们提供一些绕过这个bug的方法或者其他意见。在回信中,苹果承认这是他们的一个bug,但暂时没有给出解决方案。

无奈之下,我们只能放弃了方案二,开始寻求其他的方案。

方案三尝试

方案三尝试了一个看起来不太合理的方案:

在方案一的基础上,调用UIApplication的setStatusBarOrientation:animated:方法来改变statusBar的方向 同时重写当前的ViewController的shouldAutorotate方法,返回NO

官方文档对setStatusBarOrientation:animated:方法的描述是这样的:

Sets the app's status bar to the specified orientation, optionally animating the transition. Calling this method changes the value of the statusBarOrientation property and rotates the status bar, animating the transition if animated is YES . If your app has rotatable window content, however, you should not arbitrarily set status-bar orientation using this method. The status-bar orientation set by this method does not change if the device changes orientation.

这个方法已经被depreciate了,并且文档中也透露出不希望开发者调用的意思,然而神奇的是,使用这个方法并配合shouldAutorotate返回NO,竟然能旋转statusBar,并且让动画效果符合产品需求。

在supportedInterfaceOrientations的文档中,有这样的说明:

When the user changes the device orientation, the system calls this method on the root view controller or the topmost presented view controller that fills the window. If the view controller supports the new orientation, the window and view controller are rotated to the new orientation. This method is only called if the view controller'€™s shouldAutorotate method returns true.

也就是说,当shouldAutorotate为NO的时候,supportedInterfaceOrientations方法将不再被调用。由于无法窥探UIKit的内部实现,我们只能猜测,当shouldAutorotate为NO的时候,界面的方向将不受supportedInterfaceOrientations控制,转而被setStatusBarOrientation:animated:方法控制。

虽然方案三看起来有些出乎意料的简单,但使用这个方案,我们比较顺利的完成了视频全屏的需求。

参考资料

supportedInterfaceOrientations

setStatusBarOrientation:animated:

shouldAutorotate

更新日期: