UIWindow實(shí)踐

UIWindow是Cocoa框架的重要組件之一,所有的UIView都要通過UIWindow來進(jìn)行展現(xiàn),沒有UIWindow就沒有我們的界面。關(guān)于UIWindow的介紹和與其他組件如UIViewController UIView之間的關(guān)系,有很多文章已經(jīng)說得很清楚了,相信作為一個有經(jīng)驗(yàn)的iOS開發(fā)者都應(yīng)該了解的比較清楚。如果有一些不清楚的地方,這里給一個傳送門:UIWindow簡單介紹。

很多時候我們的App只有一個用來展示我們界面的UIWindow,而且這個UIWindow通常是在你創(chuàng)建工程的時候自動生成的,這就讓我們就算了解UIWindow的基本原理,也會比較少的接觸到UIWindow的實(shí)際使用。當(dāng)App復(fù)雜度逐漸提高的時候,一些特定的場景使用UIWindow會得到很好的解決。所以,這篇文章就介紹UIWindow的使用場景和方法,還包括一些實(shí)踐中潛在的一些坑。Let's Go!


一、使用場景

  • 可能在任何界面彈出的視圖

這種場景是UIWindow最主要的使用場景之一,也是Cocoa本身對UIWindow的使用場景之一,比如Alert提醒框、自定義鍵盤、Loading框等。UIKit中的UIAlertView和彈出鍵盤都是新建了一個UIWindow,這一點(diǎn)可以在Debug模式下設(shè)置斷點(diǎn),再點(diǎn)擊Debug View Hierarchey查看UIView的層次結(jié)構(gòu),清晰的看到當(dāng)前應(yīng)用有幾個UIWindow

Debug View Hierarchy.png

Debug Session.png

圖中的UITextEffectsWindow指的就是鍵盤的window。
接下來就簡單說明一下如何使用UIWindow,一般我們不會去直接繼承UIWindow,因?yàn)槲覀儾恍枰淖兺卣顾挠猛径莾H僅使用它。通常把新建的window作為viewController的強(qiáng)持有屬性,代碼如下

class ModalViewController: UIViewController {
    
    var newWindow:UIWindow?
    var prevWindow:UIWindow?
    
    func show(){//顯示界面
        if newWindow == nil {
            //暫存原來的keyWindow
            self.prevWindow = UIApplication.sharedApplication().keyWindow
            //新建UIWIndow
            let uiwindow = UIWindow(frame: UIScreen.mainScreen().bounds)
            uiwindow.rootViewController = self
            uiwindow.makeKeyAndVisible()
            self.newWindow = uiwindow
        }
    }
    func dismiss(){//退出界面
        self.prevWindow?.makeKeyAndVisible()
        self.newWindow?.rootViewController = nil
        self.newWindow = nil
    }
}

上述代碼把原來的keyWindow暫存,把我們新建的window設(shè)置成keyWindow,在退出界面時恢復(fù)原來的keyWindow,銷毀viewControllernewWindow。由于keyWindow的官方定義是

The key window is the one that is designated to receive keyboard and other non-touch related events. Only one window at a time may be the key window.
keyWindow是指定的用來接收鍵盤以及非觸摸類的消息,而且程序中每一個時刻只能有一個window是keyWindow

如果你希望展示的viewController只需要接受觸摸事件,不需要接受彈出鍵盤等非觸摸事件,你完全可以不把newWindow設(shè)置成keyWIndow,簡單調(diào)用window.hidden = false就可以把界面顯示出來,如下所示

class ModalViewController: UIViewController {
  
  var newWindow:UIWindow!
  
  func show(){//顯示界面
      if newWindow == nil {
          //新建UIWIndow
          let uiwindow = UIWindow(frame: UIScreen.mainScreen().bounds)
          uiwindow.rootViewController = self
          uiwindow.hidden = false
          uiwindow.backgroundColor = UIColor.clearColor()
          self.newWindow = uiwindow
      }
  }
  func dismiss(){//退出界面
      self.newWindow.rootViewController = nil
      self.newWindow = nil
  }
}
  • 獨(dú)立的、跨界面使用的服務(wù)

這類使用場景也比較常見,比如錄音、仿Assistive Touch、懸浮窗等。以錄音為例,錄音進(jìn)入后臺后,可能會在statusBar上顯示一個紅色的正在錄音的提示框,這種效果用UIWindow來實(shí)現(xiàn)就非常自然,因?yàn)樗粫绊懙狡渌缑?,始終在自己的newWindow里做相應(yīng)的操作,不用關(guān)心主窗口的頁面切換。實(shí)現(xiàn)方式其實(shí)和第一種使用場景類似,也是強(qiáng)持有newWindow的實(shí)例,不過有一個需要注意的地方:

  • 設(shè)置window.frame為比UIScreen.mainScreen().bounds小的值,比如錄音的提示框,可能很自然會設(shè)置成statusBar.frame,但這可能會影響UIWindow的旋轉(zhuǎn)。為了避免這個問題,我們一般來說都會把window.frame設(shè)置成UIScreen.mainScreen().bounds。
    但是這樣帶來一個問題就是我們的newWindow把下層window的觸摸事件都屏蔽了。這一點(diǎn)在自定義AlertView時可能是我們想要的結(jié)果,畢竟我們不想在alert彈框時用戶還能做其他的操作,但是在錄音的時候,我們希望用戶還能做其他的操作,這個時候正確的做法就是繼承UIWindow,重載hitTest方法。
class ThroughView:UIView {
    // 在你的xib/storyboard/代碼里,設(shè)置需要穿透的View為ThroughView
}
class ProgressWindow:UIWindow {
    override func hitTest(point: CGPoint, withEvent event: UIEvent?) -> UIView? {
        let hitView = super.hitTest(point, withEvent: event)
        //如果hitView是需要事件穿透的ThoughView則返回nil
        if let _ = hitView as? ThroughView {
            return nil
        } else {
            return hitView
        }
        return hitView
    }
}
  • 復(fù)雜的頁面切換中作為遮罩層防止閃屏。

這類用法不需要我們創(chuàng)建新的UIWindow,而是當(dāng)我們遇到復(fù)雜的頁面切換時,調(diào)用當(dāng)前視圖的截屏方法snapshotViewAfterScreenUpdates獲取屏幕截圖snapView,再調(diào)用window.addSubview方法添加snapView,達(dá)到覆蓋下層的頁面切換效果,防止閃屏,頁面切換結(jié)束后removeFromSuperview。這種方法在下面介紹的UIWindow切換rootViewController導(dǎo)致無法釋放中有使用到,具體的使用可以往后繼續(xù)看。

snapView = self.view.snapshotViewAfterScreenUpdates(false)
snapView.frame = self.view.frame
self.window?.addSubview(snapView)

二、潛在的坑

雖然UIWindow非常適用于上述提到的幾種場景,但總體來說,我們使用到常規(guī)組件的頻率還是要比UIWindow高出不少,這也就意味著當(dāng)你在使用UIWindow過程中遇到了坑時,可能比較難找到相應(yīng)的解決辦法。這里我也記錄一下我在使用UIWindow的過程中踩的一些坑,都是屬于比較隱蔽而且資料較少的,希望能幫助到大家。

  • UIWindow旋轉(zhuǎn)問題

一般來說,我們創(chuàng)建一個UIWindow的時候都會把它的bounds設(shè)置成主屏幕大小UIScreen.mainScreen().bounds
但我們知道,在iOS7上,UIScreen.mainScreen().bounds不會隨著設(shè)備旋轉(zhuǎn)方向而改變,iOS8以上則會隨設(shè)備旋轉(zhuǎn)方向改變,即橫屏和豎屏狀態(tài)下,寬和高會互換。所以,一般為了兼容iOS7獲取主屏幕的bounds的正確大小,我們會給UIScreen加一個extension/category

extension UIScreen {
    var compatibleBounds:CGRect {//iOS7 mainScreen bounds 不隨設(shè)備旋轉(zhuǎn)
        var rect = self.bounds
        if NSFoundationVersionNumber < NSFoundationVersionNumber_iOS_8_0 {
            let orientation = UIApplication.sharedApplication().statusBarOrientation
            if orientation.isLandscape{
                rect.size.width = self.bounds.height
                rect.size.height = self.bounds.width
            }
        }
    return rect
    }
}

所以在這里,你可能以為,如果應(yīng)用要兼容iOS7,那么應(yīng)該把window.bounds設(shè)置為UIScreen.mainScreen().compatibleBounds。但其實(shí)這樣做是錯誤的,正確的做法是恰恰是最原始的做法,即設(shè)置成UIScreen.mainScreen().bounds
我沒有找到合理的解釋,但是我覺得可能的原因是,旋轉(zhuǎn)事件的派發(fā)流程是:UIApplication -> UIWindow -> UIViewController,UIWindow能夠自己處理自己的旋轉(zhuǎn)問題,所以不需要我們再做額外的操作。

  • UIWindow切換rootViewController導(dǎo)致無法釋放

當(dāng)我們需要從任何界面跳轉(zhuǎn)到一個viewController時,并且釋放原來的viewController,如果使用通常的presentViewController,原來的viewController并沒有釋放。這時候可以通過簡單的改變window.rootViewController達(dá)到這個效果。

let newVC= SomeViewController()
if let window = UIApplication.sharedApplication().keyWindow {
    var oldVC = window.rootViewController
    window.rootViewController = vc
    oldVC = nil
}

那么,此時oldVC有沒有被釋放呢?

  • 正常的情況下,oldVC如果是一個簡單的viewController,oldVC當(dāng)然是被釋放了,因?yàn)橐呀?jīng)沒有引用指向oldVC。
  • 如果oldVC是UINavigationController或者UITabbarController之內(nèi)的容器類,oldVC和上述一樣,也會被釋放。但是這時候如果oldVC已經(jīng)push進(jìn)了幾個viewController,這些viewController會被釋放么?答案是肯定的。因?yàn)楫?dāng)viewController被容器類管理時,只有容器類會持有viewController的引用,當(dāng)容器類被銷毀時失去引用也會被釋放了。
  • 當(dāng)oldVC調(diào)用了presentViewController模態(tài)彈出了viewController的時候,oldVC在iOS7上會被釋放,但是在iOS8以上的設(shè)備并沒有被釋放。這個是我實(shí)踐得出的結(jié)果,我覺得可能的原因是由于每個viewController都有這兩個只讀屬性presentedViewController和presentingViewController,代表彈出它和它彈出的viewController,可能是這兩個屬性導(dǎo)致了循環(huán)引用,所以得不到釋放。所以,當(dāng)我們要切換一個已經(jīng)present了的控制器,我們需要把該控制器逐層dismissViewController(因?yàn)閜resent之后還可以繼續(xù)present),直到dismiss到根視圖。為了達(dá)到這個效果,我寫了一個簡單的extension。
extension UIWindow {
    var safeRootViewController:UIViewController? {
        get {
            return self.rootViewController
        }
        
        set {
            if let prevRootVC = self.rootViewController {
                // Get TopMost VC
                var topMostVC:UIViewController! = prevRootVC
                while(topMostVC.presentedViewController != nil) {
                    topMostVC = topMostVC.presentedViewController
                }
                var window:UIWindow?
                // 增加snapView防止閃屏
                var snapView:UIView?
                if topMostVC != prevRootVC {
                    snapView = topMostVC.view.snapshotViewAfterScreenUpdates(false)
                    snapView?.frame = UIScreen.mainScreen().compatibleBounds
                    self.addSubview(snapView!)
                }

                func dismissToRootVC(topMostVC:UIViewController,complete:() -> Void) {
                    // 獲取present topMostVC的VC
                    if let presentingVC = topMostVC.presentingViewController {
                        topMostVC.dismissViewControllerAnimated(false) {
                            dismissToRootVC(presentingVC,complete: complete)
                        }
                    } else {// 說明topMostVC沒有被present,已經(jīng)dismiss到最底層了
                        complete()
                    }
                }
                dismissToRootVC(topMostVC) {
                    self.rootViewController = newValue
                    // 延遲執(zhí)行,等待UI更新界面
                    self.performSelector(#selector(UIWindow.delay(_:)), withObject: snapView, afterDelay: 0)
                }
            } else {
                self.rootViewController = newValue
            }
        }
    }
    func delay(snapView:AnyObject?) {
        (snapView as? UIView)?.removeFromSuperview()
    }
}

增加snapView的目的是防止閃屏,因?yàn)槲覀円谥饘觗ismiss到根視圖后,再切換viewController,這就會造成頂層視圖到根視圖之間的視圖一閃而過。具體的實(shí)現(xiàn)代碼注釋部分已經(jīng)說明的比較詳細(xì)了,就不再贅述。

  • UIWindow不顯示View

這個問題嚴(yán)格上說不是UIWindow本身的問題,而是出現(xiàn)在使用轉(zhuǎn)場動畫UIViewControllerContextTransitioning時,在動畫結(jié)束之后,待顯示的viewController.view沒有被自動添加到UIWindow上,導(dǎo)致顯示為黑屏或空白,這是Cocoa本身的bug。解決的方法就是在轉(zhuǎn)場動畫結(jié)束之后手動把view添加到keyWindow上。

transitionContext.completeTransition(true)
UIApplication.sharedApplication().keyWindow!.addSubview(toViewController.view)

對應(yīng)stack overflow上的這個問題:“From View Controller” disappears using UIViewControllerContextTransitioning


三、小結(jié)

回顧一下,寫這篇文章的一個原因就是我發(fā)現(xiàn)介紹UIWindow的實(shí)際使用的文章非常少,所以在此總結(jié)一下常見的使用場景和方法,還有就是使用UIWindow遇到的坑。這篇文章的內(nèi)容大部分是我在實(shí)踐中結(jié)合UIWindow的原理總結(jié)出的一些經(jīng)驗(yàn),自己留一篇記錄加深印象,同時也希望對大家有所幫助!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 重點(diǎn)參考鏈接: View Programming Guide for iOS https://developer....
    Kevin_Junbaozi閱讀 4,721評論 0 15
  • 7、不使用IB是,下面這樣做有什么問題? 6、請說說Layer和View的關(guān)系,以及你是如何使用它們的。 1.首先...
    AlanGe閱讀 995評論 0 1
  • UIView的功能 負(fù)責(zé)渲染區(qū)域的內(nèi)容,并且響應(yīng)該區(qū)域內(nèi)發(fā)生的觸摸事件 UIWindow 在iOS App中,UI...
    小蘑菇2閱讀 832評論 4 5
  • 一、簡介 <<UIWindow類定義,管理和協(xié)調(diào)的Windows應(yīng)用程序顯示在屏幕上的對象(如Windows)。一...
    無邪8閱讀 1,553評論 2 3
  • 四月九號,《歡樂喜劇人》第三季收官,遼寧民間藝術(shù)團(tuán)的文松團(tuán)隊(duì)獲得了第三季冠軍。 其實(shí)這樣的節(jié)目誰拿冠軍都無所謂,重...
    酒言醉語閱讀 1,120評論 14 8

友情鏈接更多精彩內(nèi)容