UIWindow实践

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

Debug View Hierarchy.png

Debug Session.png

图中的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,销毁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设置成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 -> UIViewControllerUIWindow能够自己处理自己的旋转问题,所以不需要我们再做额外的操作。

  • 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的原理总结出的一些经验,自己留一篇记录加深印象,同时也希望对大家有所帮助!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,319评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,801评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,567评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,156评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,019评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,090评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,500评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,192评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,474评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,566评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,338评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,212评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,572评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,890评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,169评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,478评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,661评论 2 335

推荐阅读更多精彩内容

  • 重点参考链接: View Programming Guide for iOS https://developer....
    Kevin_Junbaozi阅读 4,377评论 0 15
  • 7、不使用IB是,下面这样做有什么问题? 6、请说说Layer和View的关系,以及你是如何使用它们的。 1.首先...
    AlanGe阅读 633评论 0 1
  • UIView的功能 负责渲染区域的内容,并且响应该区域内发生的触摸事件 UIWindow 在iOS App中,UI...
    小蘑菇2阅读 758评论 4 5
  • 一、简介 <<UIWindow类定义,管理和协调的Windows应用程序显示在屏幕上的对象(如Windows)。一...
    无邪8阅读 1,339评论 2 3
  • 四月九号,《欢乐喜剧人》第三季收官,辽宁民间艺术团的文松团队获得了第三季冠军。 其实这样的节目谁拿冠军都无所谓,重...
    酒言醉语阅读 989评论 14 8