莫名的穿透
之前版本中出现一个bug,个别的QA同事反映APP中的部分弹框(UIView
)弹出来之后就没法点击了,然而这个弹框蒙层背后的界面依然可以正常交互,好像出现了一种“穿透”效果:
因为复现的次数很少,规律也没找到,听得我也是一头雾水,我在想这是什么高级效果,既然不能复现那只能从源头,也就是代码层面排查了,因为不管所谓的“高级特效”是不是自己写出来的,但代码可是永远不会对你说谎的。
既然是弹出来以后就无法交互了,我第一反应是想看看这几个
UIView
是以什么样的方式弹出的,结果在他们中间都找到了同一段代码:
- (void)show {
[[UIApplication sharedApplication].keyWindow addSubview:self];
}
这几个UIView
都是被加到了keyWindow
上,当时觉得问题也就出在这里了,因为加在UIWindow
上的东西始终是展示在最上面的,可能某种情况下导致这些UIView
无法被销毁了,但至于是什么情况我也猜不到,最后将他们的展示方式都改为了:
[self.tabBarController.view addSubview:self];
后来就没有人反馈过类似的问题了,我的心里在还为自己又解决了一个“灵异”问题而自嗨了短暂的几秒。
后来无意中被我找到了复现的规律,我发现只要我在某一个页面弹出过一次UIAlertView
以后,其他只要是add到keyWindow
上的弹框,100%会出现这种“穿透的效果”,这也是我上面的demo中有一个按钮是alert的原因,后面会讲到。
我跟旁边的dj_rose同学提到了这个现象,他捕捉到了window这个关键字,告诉我在上个版本我们接入过一个第三方的推送组件,这个组件就是在UIWindow
的基础上做的,会不会是这个组件产生了影响,这个线索很关键,于是我顺着这条线,在这个第三方组件的源码里找到了蛛丝马迹:
源码中自定义了两个
UIWindow
用来作为推送消息弹框的载体,分别是EBBanerWindow
类型和EBEmptyWindow
类型,至于这两个UIWindow
到底是怎样分工的这里就不分析了,我也没细看。图中我圈出来的地方引起了我的注意,因为他给这两个自定义的UIWindow
设置的frame
都是CGectZero
,并且这两个UIWindow
的windowLevel
都是UIWindowLevelAlert
,这两个属性便让我和“穿透”这一特性挂上了钩,我在想项目的弹框是不是被无意间add到这两个自定义UIWindow
中的其中一个上面了,因为这两个UIWindow
的frame
都是CGectZero
,如果父view
的frame
是CGectZero
的话那子view
肯定是不会响应到各种点击事件的,而且这两个UIWindow
如果出现的话肯定是在层级的最上面,所以会出现一直浮在上面无法销毁的现象,但我又一想,不对啊,我都是add在keyWindow
上的啊,同时我看到了下面这几句代码:
sharedWindow = [[self alloc] initWithFrame:CGRectZero];
sharedWindow.windowLevel = UIWindowLevelAlert;
sharedWindow.layer.masksToBounds = NO;
UIWindow *originKeyWindow = UIApplication.sharedApplication.keyWindow;
[sharedWindow makeKeyAndVisible];
[originKeyWindow makeKeyAndVisible];
他会将原来的keyWindow
保存到originKeyWindow
这个临时变量中,最后再向它发送一遍makeKeyAndVisible
消息,这样理应keyWindow
是不会被影响到的,作者之所以加上这步操作我猜他也是为了防止keyWindow
被替换而带来的不必要的麻烦,这就奇怪了,也就是说在某种情况下我的keyWindow
还是被偷偷替换了。
keyWindow和UIAlertView
那到底是谁替换了keyWindow呢?答案我在上面已经留下过伏笔了,我复现的规律是只要在APP中任何一个页面弹出来过一次UIAlertView
之后,这个“穿透”就会发生,那肯定就是UIAlertView
影响到了keyWindow
,之前对UIAlertView
的了解也就停留在“其实它也是一个Window”的级别上,至于它为什么会改变keyWindow
并没有研究过,随即百度一番(百度虽然有点low但却能解决现阶段我的大部分问题,原因可能是我太low了吧),找到了几篇有用的文章,将他们的观点总结一下:
1.使用UIAlertView
的show
时,系统使用了一个临时的并且层级最高的UIWindow
来展现UIAlertView
,所以当show
弹窗时,keyWindow
已经被替换为_UIAlertControllerShimPresenterWindow
,打印了一下也的确是这样。
2.当
UIAlertView
消失后keyWindow
将会转向另一个UIWindow
,至于这个UIWindow
是哪一个,取决于在[UIApplication sharedApplication].windows
数组中的位置的先后。
windows数组的排列顺序
一般的,我们项目中在AppDelegate
里都会有这样几句代码:
self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
[self.window makeKeyAndVisible];
其实写了这么多遍也没细细分析过这句代码,它其实就是给APP设置一个keyWindow
,用来作为之后呈现的一切UIView
的载体,这个window会排在windows
数组里的第一个,一般项目中如果没有特殊需求去自定义UIWindow
的话,这个windows
数组里的firstObject
通常情况下始终会是我们在AppDelegate
中创建的这个window(当然也不排除有firstObject
被系统偷偷替换的情况,这也是我们另一个线上反馈很多次无法复现的bug的原因):
所以在这种情况下不管我们用
keyWidow
,appdelegate.window
还是windows.firstobject
去取出来的window都应该是同一个,这也是我们没有加入这个推送组件之前为什么不会发生“穿透”问题的原因。
当用户收到过一次推送后可以看到这个时候
windows
数组中已经有3个window了,在图中最上面的UIWindow
也是windows
数组的第一个,后面两个是EBBannerWindow
和EBEmptyWindow
,那为什么这两个自定义的window在windows
数组中会排在UIWindow
的后面呢?第一个决定因素是UIWindowLevel
,它代表window的一个级别,共有三个类型,分别是:这是三个
CGFloat
类型的常量,通过打印得到这个三个值分别是0.000000 2000.000000 1000.000000
。因为EBBannerWindow
和EBEmptyWindow
的windowLevel
都是UIWindowLevelAlert
的,而UIWindow
的windowLevel
是UIWindowLevelNormal
,由此可以推断影响windows
数组排列的顺序的第一个因素是level低的在前面,level高的在后面。那么当level相同的时候呢,这就和
window
的展现方式有关系了,首先来看看这个方法。
makeKeyAndVisible和setHidden:
讨论windows
数组中元素的排列顺序之前我们先来看一下苹果官方文档对于makeKeyAndVisible
这个方法的解释:
This is a convenience method to show the current window and position it in front of all other windows at the same level or lower. If you only want to show the window, change its hidden property to
NO
.
意思也就是这个方法可以让一个window在跟它级别相等或者级别比它低的window中凸现出来,让这个window中的view展示在其他window的上面,如果你只是想让一个window展现出来,将它的hidden
属性设为NO
就可以了,因为一个UIWindow
被创建出来的时候hidden
属性是默认为YES
的。
其实在开发中我们也只需要将那个在AppDelegate
中创建的window设为keyWindow
,其他的自定义window需要展现,只需要将它们的hidden
属性设为NO
,makeKeyAndVisible
虽然是一个“convenience method”,也能让window展现,但是因为它会改变keyWindow
,所以我个人是不建议使用。
回到windows
数组的排列顺序,前面说了,第一个是取决于windowLevel
的大小,大的排后面,小的排前面,那么如果数组中两个window的level相同,那么谁排在前面,谁排在后面呢?我在网上看到的一个答案说是最后一次调用makeKeyAndVisible
或者setHidden:YES
方法的那个window排在后面,因为这两个方法都会将一个window的hidden
属性改为YES
,所以会影响这个window在数组中的顺序,最后一次调用这两个方法中任何一个的window它也就类似于“最后一个被影响过”,所以它排在别人后面。
demoOne
我将原来EBBanerWindow.m
中两个自定义window的展现方式都改为了通过setHidden:
的方式,并且代码的顺序改为emptyWindow
在前,bannerWindow
在后,因为这两个window的level是相同的,按上面的观点那bannerWindow
肯定会在windows
数组中排在emptyWindow
的后面,我们来看看运行结果:
UIWindow *originKeyWindow = UIApplication.sharedApplication.keyWindow;
emptyWindow = [[EBEmptyWindow alloc] initWithFrame:CGRectZero];
emptyWindow.windowLevel = UIWindowLevelAlert;
[emptyWindow setHidden:NO];
[originKeyWindow makeKeyAndVisible];
bannerWindow = [[self alloc] initWithFrame:CGRectZero];
bannerWindow.windowLevel = UIWindowLevelAlert;
[bannerWindow setHidden:NO];
[originKeyWindow makeKeyAndVisible];
可以发现
windows
数组的顺序也跟着变化了,bannerWindow
是排在了emptyWindow
的后面,这么看来上面的观点好像有点道理,别急,我们接着往下看。
demoTwo
为了进一步证明这个观点我将源码中给emptyWindow
和bannerWindow
设置windowLevel
的代码注释掉,这样他们两的level也变成了默认值UIWindowLevelNormal
,这样[originKeyWindow makeKeyAndVisible]
是最后调用的,那么按照最后调用排后面的原则是不是windows
数组中UIWindow
会跑到数组的最后一个去呢,let us see see:
UIWindow *originKeyWindow = UIApplication.sharedApplication.keyWindow;
emptyWindow = [[EBEmptyWindow alloc] initWithFrame:CGRectZero];
// emptyWindow.windowLevel = UIWindowLevelAlert;
[emptyWindow setHidden:NO];
[originKeyWindow makeKeyAndVisible];
bannerWindow = [[self alloc] initWithFrame:CGRectZero];
// bannerWindow.windowLevel = UIWindowLevelAlert;
[bannerWindow setHidden:NO];
[originKeyWindow makeKeyAndVisible];
可以看到
windows
数组并没有发生变化,那就证明上面的观点是不正确的,那为毛这个UIWindow
始终会排在第一个呢?突然一个想法冒了出来,决定它崇高地位的只有它创建的时间了,因为它是一启动就被创建的,所以才会排在第一个,由此我假设决定windows
数组中元素顺序的是window们被创建的先后顺序。
demoThree
根据这个假设我先改变了emptyWindow
和bannerWindow
的创建顺序,将bannerWindow
放在了emptyWindow
之前,但是他们调用setHidden:
的顺序还是不变,bannerWindow
在emptyWindow
之后:
UIWindow *originKeyWindow = UIApplication.sharedApplication.keyWindow;
bannerWindow = [[self alloc] initWithFrame:CGRectZero];
emptyWindow = [[EBEmptyWindow alloc] initWithFrame:CGRectZero];
emptyWindow.windowLevel = UIWindowLevelAlert;
[emptyWindow setHidden:NO];
[originKeyWindow makeKeyAndVisible];
bannerWindow.windowLevel = UIWindowLevelAlert;
[bannerWindow setHidden:NO];
[originKeyWindow makeKeyAndVisible];
这样如果按先创建的顺序bannerWindow
应该排在emptyWindow
前面,如果按照后调用setHidden:
的顺序bannerWindow
应该排在emptyWindow
后面,刚好形成了一个互斥,也就是说决定因素只有一个,我们来看看结果:
结果是
bannerWindow
排在了windows
前面,那就证明在level相同的前提下,决定数组中前后顺序的是window创建的顺序,再来个demoFour佐证一下:
demoFour
我在
AppDelegate
中self.window
之前先创建了这两个自定义的window,这里有个点要注意下就是创建一个window的同时就要为它设置一个rootViewController
,不然会crash的,运行的结果在左边。
demoFive
那么windowlevel的高低和创建的顺序这两者哪一个优先级更高呢?我将bannerWindow
和emptyWindow
的优先级都改为了UIWindowLevelNormal
,然后将AppDelegate
中的self.window
的level提高到了UIWindowLevelAlert
,但创建顺序self.window
还是在两个自定义window的后面,看一下运行结果:
UIViewController *vc = [UIViewController new];
self.bannerWindow = [[EBBannerWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
self.bannerWindow.rootViewController = vc;
[self.bannerWindow setHidden:NO];
self.emptyWindow = [[EBEmptyWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
self.emptyWindow.rootViewController = vc;
[self.emptyWindow setHidden:NO];
self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
self.window.windowLevel = UIWindowLevelAlert;
可以看出来因为
self.window
因为level最高,所以它还是排在了数组最后一个,而上面两个window因为level相同,则根据创建的先后顺序排列,bannerWindow
是先创建的,所以排在前面,代码和运行结果基本可以证实我这个观点了。到这里搞清楚了windows
数组的排列顺序,再来看看这个bug产生的原因吧。
穿透的原因
前面说了当UIAlertView
弹出来后keyWindow
被替换成一个临时的window,当UIAlertView
dismiss之后这个临时的window也随即被销毁,那么系统会去寻找一个新的keyWindow
人选,这个候选人自然是来自windows
数组,至于竞选规则就是上面几个demo总结出来的先优先级后创建顺序规则,按我们项目中最初的写法,这个新的keyWindow
人选是第三方组件的中的emptyWindow
,因为它优先级最高并且是最后一个被创建的,同时这个emptyWindow
的frame
是CGRectZero
,所以加上去的弹框的所有点击事件都无法响应了,这个地方其实我仍然有点疑惑的是,为什么这个window的frame
是CGRectZero
添加到这个window上的view依然可以展示出来,只是不能响应点击事件而已,但也正因为它的frame
是CGRectZero
,弹出来的被添加到emptyWindow
上的这些UIView
,并没有阻断其他window上控件的事件传递,盖在它们下面的控件依然可以正常点击,这也就是所谓的“穿透”效果。
解决方案
找到问题的原因之后我们对这个推送第三方组件进行了修改,因为他是一个单例,没法在没有推送的情况下去销毁这两个自定义的UIWindow
,只能在弹出和消失的时候加以控制。讨论出来的方案是首先将bannerWindow
和emptyWindow
的windowLevel
降为了默认值UIWindowLevelNormal
,因为一个推送弹框只要让用户看见即可,它不存在交互,所以不需要那么高的级别,而且windowLevel
会影响一个window在windows
数组中的顺序,而数组中的顺序是影响keyWindow
的关键因素。其次将这两个自定义window的展现方式从makeKeyAndVisible
改为setHidden:NO
,我猜想到原作者写这句[originKeyWindow makeKeyAndVisible];
代码原因就只是想让他的自定义window展现出来而不影响到keyWindow
,所以调用setHidden:
方法足矣,改了这两个地方后,emptyWindow
、bannerWindow
和appdelegate.window
都是UIWindowLevelNormal
级别的,但因为emptyWindow
是最后被创建的,所以它还是会排在数组的最后面,只要UIAlertView
弹出并消失后,它依然是keyWindow
的最佳人选,所以我们需要在推送框的的hide
方法里,在hide
动画完成之后,手动的调用一次[appdelegate.window makeKeyAndVisible];
方法,将appdelegate.window
置回keyWindow
,也就是让emptyWindow
把keyWindow
的席位交出去,我们虽然不能改变它最佳候选人的位置,但却可以让它自己让位,这样就避免了keyWindow
的影响,另外我们也把所有add
到keyWindow
上的这类弹框的展现方式统一做了替换,分别添加到对应的VC.view
或者tabbarVC.view
上,对于UIAlertView
,因为在iOS 9.0
以后也被苹果废弃了,我猜苹果可能也是发现了这种用UIWindow
承载UIAlertView
这种方式会对keyWindow
产生影响,另一方面UIAlertView
的代理方法对代码逻辑也是一种拆散,所以我们也将项目中使用UIAlertView
的地方逐步的替换为更好的UIAlertController
,毕竟UIAlertController
是不会去改变keyWindow
的。
面向bug开发
踩过这一个坑后我想说在使用国内非大神写的第三方组件上时一定要“三思”,其实这个组件确实写的蛮厉害,替我们节省了很多开发时间,不过抛开这个组件其他地方不谈,我能在初始化window这段源码中看到作者的防御机制,但感觉并没有吃透UIWindow
的一些用法和原理,才导致在和UIAlertView
一起的时候出现了问题。其实也是因为这个bug才迫使我去一点一点了解UIWindow
的东西,可能bug才是推动技术进步的第一要素,以上是我自己总结出来的一些心得,看到这些观点的同学也务必要“三思”而后行。
参考文章:
EBBannerView只需一行代码:展示跟 iOS 系统一样的推送通知横幅
添加多个UIWindow时,使用keyWindow要注意一点
iOS 关于UIAlertController、UIAlertView弹窗问题
UIWindow的windowLevel属性