一、问题背景
最近需求量放缓,想起了以前曾经later的小需求,也就是弹出来的AlertView中间的文本框输入一些信息,如果输入的信息为空,则把确定按钮置灰。而UIKit里没有开放修改AlertView中subview的API,且在iOS8以后也没办法通过subViews属性拿到AlertView的子view。所以现在想写一个AlerView,可以开放出一些项目中可能用的到的接口,并且最大程度的保留原UIAlertView的接口不变。
开始写没多久,在自测show方法的时候出现了一点问题(调用show函数可以直接将AlertView显示出来,而不用传入当前的view参数,即不加入当前的View hierarchy)。因为当时的实现方案跟在项目中写图片预览控件的思路一样,通过UIApplication拿到window列表,然后取第0个window,直接添加AlertView上去,结果发现显示不出来,被当前ViewController给盖住了(棕色的一块就是盖在AlertView上方的ViewController的rootview)。
</img>
那之前一直使用的方案为什么在这里行不通了呢?再加上前不久出现的测试包上点击crash问题(问题描述:从聊天管理或者卡片设置背面的+号进入邀请人界面,选择分享到发现。然后选择取消发布,回到聊天管理或者卡片设置页,此时再点击+号,发生crash)。都跟UIWindow这个玩意儿有关。于是最近便围绕了以下三个问题进行了研究。
(1)UIWindow究竟是什么?
(2)为什么在项目里使用的window直接添加View的方案在这里行不通了?
(3)为什么只有选择分享到发现,并取消发布之后会出现crash,而在其他的页面跳转不会出现UIWindow问题?
二、UIWindow概念
UIWindw定义了一个负责管理,协调一个App的View是如何显示在设备屏幕上的窗口类,除非一个App可以显示在一个外部的设备屏幕上,那么一个App只拥有一个窗口。UIWindow本身没有标题栏,关闭操作栏等任何的装饰物,用户不会看见,移动或者是关闭它,这跟Mac OS上的window有很大的差别。
UIWindow的两大主要功能是提供了一块给View的显示区域,并且负责分发各种事件给View,比如传递触摸事件给各项View或者其它对象。而改变App的显示内容,可以改变UIWindow的rootView,而不需要去创建一个新的UIWindow。同时,它还负责与ViewController协同去处理设备旋转时的情况。
讲到Window还必须要提的两个概念是UIWindowLevel以及KeyWindow。UIWindowLevel是一个CGFloat值,现在UIKit定义了三种Level:
UIKIT_EXTERN const UIWindowLevel UIWindowLevelNormal;
UIKIT_EXTERN const UIWindowLevel UIWindowLevelAlert;
UIKIT_EXTERN const UIWindowLevel UIWindowLevelStatusBar
UIWindowLevel为2D的iOS世界引入了Z轴的概念,它相当于以屏幕为原地,以使用者为正方向的一根轴。值越小代表离使用者越远,越大代表越靠近使用者。高Level的Window会盖住低Level的window,若是两者Level一样则根据添加顺序来决定,这类似于我们添加子View(UIWindow本来也就是UIView的子类)。而上面三个值分别是0.0,2000.0,1000.0,而大部分在App上使用的都是UIWindowLevelNormal,这也是每个Window被创建出来时的默认值。
我们在创建一个新的window的时候,要让它显示出来必须要调用makeKeyAndVisible方法,让window显示出来,并让它成为一个KeyWindow。KeyWindow是UIApplication的一个开放属性,它是当前App的主window,用来接收键盘输入以及非触摸事件(触摸事件是传递给触摸事件发生的window,不一定是keyWindow),或者是跟坐标值无关的事件都会被传递给keyWindow。并且在同一时刻,只有一个window会成为keyWindow。但是需要注意一件事情,成为keywindow与windowLevel无关,并不是windowLevel最高的window会成为keywindow。
三、UIWindow在App启动时扮演的角色
人这一辈子主要要回答三个问题,一是从哪里来,二是到哪里去,三是你是谁。那上面介绍了UIWindow是谁,那么现在就要介绍UIWindow从哪里来,并且它要到哪里去了。那我这儿就从Application启动开始讲起。
(1)The Main Function
所有以C语言为基础的程序的入口都是main函数,iOS App也不例外。以下程序就是iOS App的main函数:
#import <UIKit/UIKit.h>
#import "AppDelegate.h"
int main(int argc, char * argv[])
{
@autoreleasepool {
return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
}
}
唯一不同的是你不用去写main函数,这是Xcode自动创建的。这段代码也很简单,它唯一做的工作就是把控制权移交给UIKit framework,根据第三个参数principalClassName创建一个UIApplication对象,根据第四个参数创建一个AppDelegate对象。
(2) UIApplication
UIApplication是一个App的核心,它主要的职能是负责方便系统和App的交互,管理Event Loop进行各项事件的处理,以及向自己的Delegate,即AppDelegate进行一些关键事件的传递。
一个App只有一个UIApplication单例对象,可以通过[UIApplication sharedApplication]来获得单例。它还能做一些应用级别的事,比如设置桌面上App图标右上角的红点数字,或者是使用openURL直接拨电话,发短信等。在此不做延伸。
(3)UIWindow
UIWindow是iOS启动之后,被创建的第一个视图控件。它有可能是通过Interface Builder被创建出来的,也有可能是我们在AppDelegate中自定义创建出来的。当它被创建,添加了rootView之后,一个App的界面最终被展示在用户面前。而如果是自定义创建window时,我们通常会使用window.rootViewController来为它添加rootView,值得注意的是,这句代码仅仅是给UIWindow添加了rootViewController的view,或者说这是一种更加便利的方式来为UIWindow添加rootView,而这个rootViewController属性并不是用来让controller与UIWindow之间进行通信的。
除此之外,UIWindow还负责与UIApplication一起负责传递Event给View以及ViewController。
四、问题解决
(1)AlertView的show方法问题
当我第一次出现这个问题的时候,代码如下:
- (void)viewDidLoad {
[super viewDidLoad];
// Do any additional setup after loading the view, typically from a nib.
self.view.backgroundColor = [UIColor brownColor];
TDAlertView *alertView = [[TDAlertView alloc]initWithTitle:@"TDAlertView" message:@"test" delegate:self cancelButtonTitle:@"取消" textFieldPlaceHolders:@[@"账号",@"密码"] otherButtonTitles:@"圣诞",@"快乐",nil];
[alertView show];
}
而TDAlertView的show方法代码如下:
- (void)show{
if (!self){
return;
}
UIWindow *showWindow = [[[UIApplication sharedApplication]windows] objectAtIndex:0];
[showWindow addSubview:self];
}
当时的第一个猜测,认为是不是不应该放在viewDidLoad里,因为此时viewController的rootView尚未生成,当rootview生成之后,自然而然的就会盖在AlertView上。于是后面把显示代码放在viewDidAppear里面,果然AlertView就正常显示了。这样问题算是初步解决。
但是我很快发现问题解决的并不彻底,因为我又试了一下,让刚刚那个controller一出现就跳转到controllerB,然后在controllerB里面的viewDidLoad里面添加显示AlertView的代码,发现AlertView竟然能显示出来。除此之外,我还发现如果把当前Window的rootViewController改为UINavgationController的话,那么在第一个ViewController的ViewDidLoad里面显示AlertView也可以正常显示了。
这说明问题viewDidLoad只是出现这问题的一个因素,还有一些因素影响着最终的显示效果。而通过分析两个UIViewController的不同显示行为,可以分析得出很有可能是rootViewController的原因让显示出错。
而经过上面第三节UIWindow中的描述,rootViewController是提供了UIWindow的rootView,后面显示内容的更改都是在rootView在这个层级的更改。而show方法则是在UIWindow上直接添加,则不属于rootView这个层级,它与rootView属于平级关系。所以当在rootViewController的viewDidLoad里直接添加View时,rootView是后面添加到UIWindow上的,所以它的层级高于AlertView。而在ViewControllerB的viewDidLoad里显示AlertView时,尽管ViewControllerB里面的view是后面加上去的,但是它属于本来就低于AlertView的rootView hierarchy,所以AlertView会按预想的显示出来。
上面两张图展示了View的层级关系,前面是没有正常显示的,后面的是正常显示的。这也验证了上面的想法。而对于UINavgationController,它属于container viewController。它会自己向UIWindow提供rootView,所以这保证了它的rootView肯定在最底层,故也可以正常显示。
总结:要分清楚UIWindow的rootView hierarchy与UIWindow作为UIView子类本身的view hierarchy,以免出现显示的层级错误。
(2)测试包点击按钮crash问题
这个问题很早就被发现,并定位到是使用了topViewController。但是当时在测试的时候发现很奇怪,只要一跳到发现的发布页,再回来就会出问题,但是只要不跳到这个页面,随便跳哪也不会出这个问题。下面用一个GIF来演示一下这个bug。
看错误信息发现是UIViewController没有goToPage方法所导致的crash。而取topViewController的方法摘要如下:
if ([UIApplication sharedApplication].windows.count == 0) {
return nil;
}
// find root view contoller from window
UIWindow *window = [UIApplication sharedApplication].keyWindow;
UIViewController *rootViewController = window.rootViewController;
if (rootViewController == nil) {
window = [UIApplication sharedApplication].windows[0];
rootViewController = window.rootViewController;
}
能看出来它是通过UIApplication拿到当前的keyWindow,然后通过拿到keyWindow上的rootViewController,如果为空,再去拿windows列表底部的window,获得它的rootViewController。然后通过打断点发现,在crash的时候,获得的keyWindow是AlitripMonitorStatusBar,而这个东西就是那个左上角负责显示上传,下载数据的黑框,而这时再拿到的rootViewController就会出现问题。(顺便提一句,通过[UIApplication sharedApplication]获得的windows列表包含了所有可见或者不可见的非系统UIWindow,系统window包括最上面的statusBar等等。而windows列表的排序是按照windowLevel升序排列。)
那到底keyWindow是为什么会被变更呢?我最开始的想法是可能因为跨工程所以让最上面的浮层又重新贴了一层,并变成了keyWindow,但是整个工程里发现AlitripMonitorStatusBar只有在初始化的时候会调用makeKeyAndVisible,而且通过实验,跳到其他工程并没有出现这种crash,于是放弃了这种想法。然后我又想会不会是发布页在退出时做了一些特殊的事情,可是通过看代码也没发现什么特殊的地方。
最终我采用跟踪window变化的方法来确定问题所在,即在每个节点,比如viewController的进入,退出时,而且由于第一次踩的坑,还在viewDidLoad和viewDidAppear里做了区分。最终发现在退出到邀请列表页时,viewDidLoad里的keyWindow变成了_UIAlertControllerShimPresenterWindow。根据名字很明显能发现这是警告框所在的window,也就是发现发布页唯一特殊的地方在于因为要二次确认,它唤起了UIActionSheet,而此时这个View出现在了_UIAlertControllerShimPresenterWindow,并让它变成了keyWindow。然后由于这个window的消失,这个keyWindow被AlitripMonitorStatusBar给“继承”了。从此之后打印出来的keyWindow都变成了AlitripMonitorStatusBar。
但是由于我们看不到UIActionSheet消失时的具体代码,于是只能通过一些对比实验来确定它的keyWindow继承顺序。通过对AlitripMonitorStatusBar与AtomEntryView两个window进行windowLevel变更进行实验,最终确定当AlertWindow被移除时,它的keyWindow的继承顺序是按照windowLevel降序继承,当windowLevel相同时,则按照添加顺序降序继承。
最后我修改了一下获得topViewController的代码,将获取window的顺序改为先拿UIWindow列表底部的window,再去获取keyWindow。因为App上的ViewController都是在最底部的UIWindow,这样就不会出现keyWindow继承的问题导致拿UIViewController错误的问题,然后进行试验,发现crash问题消失,问题解决。
总结:当使用的警告框等需要显示在另外一个window上的控件时,要保证接下来的keyWindow的继承正确,否则会出现拿不到正确的rootViewController的问题。
五、拾遗
在发现的问题的过程中,还发现了一些特殊的UIWindow,也一并分享出来。
(1)UITextEffectsWindow
这是iOS8引入的一个新window,是键盘所在的window。它的windowLevel是10,高于UIWindowLevelNormal。
(2)UIRemoteKeyboardWindow
iOS9之后,新增了一个类型为 UIRemoteKeyboardWindow 的窗口用来显示键盘按钮。目前对这个研究还不是很多,以后有了新发现再与大家分享。