对于一个带有视频播放功能的 app 产品来说,视频全屏是一个基本且重要的需求。虽然这个需求看起来很简单,但是在实现上,我们前后迭代了三套技术方案。这篇文章将介绍这三种实现方案中的利弊和坑点,以及实现过程中积累的经验。
需求要点:
对于这三种实现方案,我写了个 分别示意。三个方案分别在 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 的转场动画,形成一个符合产品需求的动画效果。
在方案二的实现过程中,我们遇到了不少问题。
用默认方式 present 一个 viewController,会导致 presentingViewController 的 view 被从视图层次中移除,同时 presentingViewController 的 viewWillDisappear 方法被调用,这对原有业务逻辑有较大影响。
调研后发现使用 UIModalPresentationOverFullScreen 的方式来 present,presentingViewController 的生命周期将不受影响。
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 两个类。
在 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。
上一个问题中讲到,在 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,比如存入了错误的高度缓存。
如果说业务上的坑点都能通过修改代码逻辑来依次解决,但系统级的坑点却很难有有效的解决方案。
在开发过程中发现,这种全屏方式会偶现手机半边黑屏的问题。在主线程忙碌时这个问题有较大的复现概率。
比如在这张图中,系统 statusBar 的宽度明显是横屏时的宽度,但是在渲染时整个界面都被旋转了 90 度,造成下方出现了半边黑屏。 但是在这种情形下,如果读取 UIWindow,UIScreen 以及各个层次的 view 的 frame,得到的数值都符合预期,唯独屏幕上渲染出来的结果是 bug 的。
写了几个 demo 表明,这个即便没有转场动画,只要 present 一个只支持横屏方向的 ViewController,半边黑屏的问题就有概率复现。 尝试了在全屏动画完成后再设置 UIDevice 的 orientation,设置 StatusBarOrientation 等方法,但均没能解决这个问题。
当 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: 方法控制。
虽然方案三看起来有些出乎意料的简单,但使用这个方案,我们比较顺利的完成了视频全屏的需求。
来源: http://www.tuicool.com/articles/qiEj6vz