iOS端一次視頻全屏需求的實現

MelLinn 8年前發布 | 9K 次閱讀 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

 

來自:https://techblog.toutiao.com/2017/03/28/fullscreen/

 

 本文由用戶 MelLinn 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!