UIWindow
是Cocoa框架的重要组件之一,所有的UIView
都要通过UIWindow
来进行展现,没有UIWindow
就没有我们的界面。关于UIWindow
的介绍和与其他组件如UIViewController
UIView
之间的关系,有很多文章已经说得很清楚了,相信作为一个有经验的iOS开发者都应该了解的比较清楚。如果有一些不清楚的地方,这里给一个传送门:UIWindow简单介绍。
很多时候我们的App只有一个用来展示我们界面的UIWindow
,而且这个UIWindow
通常是在你创建工程的时候自动生成的,这就让我们就算了解UIWindow
的基本原理,也会比较少的接触到UIWindow
的实际使用。当App复杂度逐渐提高的时候,一些特定的场景使用UIWindow
会得到很好的解决。所以,这篇文章就介绍UIWindow
的使用场景和方法,还包括一些实践中潜在的一些坑。Let’s Go!
一、使用场景
-
可能在任何界面弹出的视图
这种场景是
UIWindow
最主要的使用场景之一,也是Cocoa本身对UIWindow
的使用场景之一,比如Alert提醒框、自定义键盘、Loading框等。UIKit中的UIAlertView
和弹出键盘都是新建了一个UIWindow
,这一点可以在Debug模式下设置断点,再点击Debug View Hierarchey查看UIView的层次结构,清晰的看到当前应用有几个UIWindow
图中的UITextEffectsWindow
指的就是键盘的window
。
接下来就简单说明一下如何使用UIWindow,一般我们不会去直接继承UIWindow
,因为我们不需要改变拓展它的用途而是仅仅使用它。通常把新建的window
作为viewController
的强持有属性,代码如下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设置成keyWindow
,在退出界面时恢复原来的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
设置成keyWIndow
,简单调用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 } }
-
独立的、跨界面使用的服务
这类使用场景也比较常见,比如录音、仿Assistive Touch、悬浮窗等。以录音为例,录音进入后台后,可能会在statusBar
上显示一个红色的正在录音的提示框,这种效果用UIWindow来
实现就非常自然,因为它不会影响到其他界面,始终在自己的newWindow
里做相应的操作,不用关心主窗口的页面切换。实现方式其实和第一种使用场景类似,也是强持有newWindow
的实例,不过有一个需要注意的地方:
- 设置
window.frame
为比UIScreen.mainScreen().bounds
小的值,比如录音的提示框,可能很自然会设置成statusBar.frame
,但这可能会影响UIWindow
的旋转。为了避免这个问题,我们一般来说都会把window.frame
设置成UIScreen.mainScreen().bounds
。
但是这样带来一个问题就是我们的newWindow
把下层window
的触摸事件都屏蔽了。这一点在自定义AlertView
时可能是我们想要的结果,毕竟我们不想在alert
弹框时用户还能做其他的操作,但是在录音的时候,我们希望用户还能做其他的操作,这个时候正确的做法就是继承UIWindow
,重载hitTest
方法。class ThroughView:UIView { // 在你的xib/storyboard/代码里,设置需要穿透的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 } }
-
复杂的页面切换中作为遮罩层防止闪屏。
这类用法不需要我们创建新的UIWindow,而是当我们遇到复杂的页面切换时,调用当前视图的截屏方法snapshotViewAfterScreenUpdates
获取屏幕截图snapView
,再调用window.addSubview
方法添加snapView
,达到覆盖下层的页面切换效果,防止闪屏,页面切换结束后removeFromSuperview
。这种方法在下面介绍的UIWindow切换rootViewController导致无法释放中有使用到,具体的使用可以往后继续看。snapView = self.view.snapshotViewAfterScreenUpdates(false) snapView.frame = self.view.frame self.window?.addSubview(snapView)
二、潜在的坑
虽然UIWindow
非常适用于上述提到的几种场景,但总体来说,我们使用到常规组件的频率还是要比UIWindow
高出不少,这也就意味着当你在使用UIWindow
过程中遇到了坑时,可能比较难找到相应的解决办法。这里我也记录一下我在使用UIWindow
的过程中踩的一些坑,都是属于比较隐蔽而且资料较少的,希望能帮助到大家。
-
UIWindow旋转问题
一般来说,我们创建一个UIWindow的时候都会把它的
bounds
设置成主屏幕大小UIScreen.mainScreen().bounds
但我们知道,在iOS7上,UIScreen.mainScreen().bounds
不会随着设备旋转方向而改变,iOS8以上则会随设备旋转方向改变,即横屏和竖屏状态下,宽和高会互换。所以,一般为了兼容iOS7获取主屏幕的bounds
的正确大小,我们会给UIScreen加一个extension/category
extension UIScreen { var compatibleBounds:CGRect {//iOS7 mainScreen bounds 不随设备旋转 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 } }
所以在这里,你可能以为,如果应用要兼容iOS7,那么应该把
window.bounds
设置为UIScreen.mainScreen().compatibleBounds
。但其实这样做是错误的,正确的做法是恰恰是最原始的做法,即设置成UIScreen.mainScreen().bounds
。
我没有找到合理的解释,但是我觉得可能的原因是,旋转事件的派发流程是:UIApplication
->UIWindow
->UIViewController
,UIWindow
能够自己处理自己的旋转问题,所以不需要我们再做额外的操作。 -
UIWindow切换rootViewController导致无法释放
当我们需要从任何界面跳转到一个viewController时,并且释放原来的viewController,如果使用通常的presentViewController,原来的viewController并没有释放。这时候可以通过简单的改变window.rootViewController达到这个效果。
let newVC= SomeViewController() if let window = UIApplication.sharedApplication().keyWindow { var oldVC = window.rootViewController window.rootViewController = vc oldVC = nil }
那么,此时oldVC有没有被释放呢?
- 正常的情况下,oldVC如果是一个简单的viewController,oldVC当然是被释放了,因为已经没有引用指向oldVC。
- 如果oldVC是
UINavigationController
或者UITabbarController
之内的容器类,oldVC和上述一样,也会被释放。但是这时候如果oldVC已经push进了几个viewController,这些viewController会被释放么?答案是肯定的。因为当viewController被容器类管理时,只有容器类会持有viewController的引用,当容器类被销毁时失去引用也会被释放了。 -
当oldVC调用了presentViewController模态弹出了viewController的时候,oldVC在iOS7上会被释放,但是在iOS8以上的设备并没有被释放。这个是我实践得出的结果,我觉得可能的原因是由于每个viewController都有这两个只读属性presentedViewController和presentingViewController,代表弹出它和它弹出的viewController,可能是这两个属性导致了循环引用,所以得不到释放。所以,当我们要切换一个已经present了的控制器,我们需要把该控制器逐层dismissViewController(因为present之后还可以继续present),直到dismiss到根视图。为了达到这个效果,我写了一个简单的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,已经dismiss到最底层了 complete() } } dismissToRootVC(topMostVC) { self.rootViewController = newValue // 延迟执行,等待UI更新界面 self.performSelector(#selector(UIWindow.delay(_:)), withObject: snapView, afterDelay: 0) } } else { self.rootViewController = newValue } } } func delay(snapView:AnyObject?) { (snapView as? UIView)?.removeFromSuperview() } }
增加snapView的目的是防止闪屏,因为我们要在逐层dismiss到根视图后,再切换viewController,这就会造成顶层视图到根视图之间的视图一闪而过。具体的实现代码注释部分已经说明的比较详细了,就不再赘述。
-
UIWindow不显示View
这个问题严格上说不是UIWindow本身的问题,而是出现在使用转场动画
UIViewControllerContextTransitioning
时,在动画结束之后,待显示的viewController.view
没有被自动添加到UIWindow
上,导致显示为黑屏或空白,这是Cocoa本身的bug。解决的方法就是在转场动画结束之后手动把view
添加到keyWindow
上。transitionContext.completeTransition(true) UIApplication.sharedApplication().keyWindow!.addSubview(toViewController.view)
对应stack overflow上的这个问题:“From View Controller” disappears using UIViewControllerContextTransitioning
三、小结
回顾一下,写这篇文章的一个原因就是我发现介绍UIWindow
的实际使用的文章非常少,所以在此总结一下常见的使用场景和方法,还有就是使用UIWindow遇到的坑。这篇文章的内容大部分是我在实践中结合UIWindow
的原理总结出的一些经验,自己留一篇记录加深印象,同时也希望对大家有所帮助!