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


圖中的
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,銷毀viewController和newWindow。由于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),自己留一篇記錄加深印象,同時也希望對大家有所幫助!