iOS 面向bug开发之UIWindow出现的“穿透”问题

莫名的穿透

之前版本中出现一个bug,个别的QA同事反映APP中的部分弹框(UIView)弹出来之后就没法点击了,然而这个弹框蒙层背后的界面依然可以正常交互,好像出现了一种“穿透”效果:

穿透.gif

因为复现的次数很少,规律也没找到,听得我也是一头雾水,我在想这是什么高级效果,既然不能复现那只能从源头,也就是代码层面排查了,因为不管所谓的“高级特效”是不是自己写出来的,但代码可是永远不会对你说谎的。
既然是弹出来以后就无法交互了,我第一反应是想看看这几个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的基础上做的,会不会是这个组件产生了影响,这个线索很关键,于是我顺着这条线,在这个第三方组件的源码里找到了蛛丝马迹:

EBBanerWindow

源码中自定义了两个UIWindow用来作为推送消息弹框的载体,分别是EBBanerWindow类型和EBEmptyWindow类型,至于这两个UIWindow到底是怎样分工的这里就不分析了,我也没细看。图中我圈出来的地方引起了我的注意,因为他给这两个自定义的UIWindow设置的frame都是CGectZero,并且这两个UIWindowwindowLevel都是UIWindowLevelAlert,这两个属性便让我和“穿透”这一特性挂上了钩,我在想项目的弹框是不是被无意间add到这两个自定义UIWindow中的其中一个上面了,因为这两个UIWindowframe都是CGectZero,如果父viewframeCGectZero的话那子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.使用UIAlertViewshow时,系统使用了一个临时的并且层级最高的UIWindow来展现UIAlertView,所以当show弹窗时,keyWindow已经被替换为_UIAlertControllerShimPresenterWindow,打印了一下也的确是这样。

UIAlertView替换keyWindow.png

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的原因):

通常情况下的windows数组

所以在这种情况下不管我们用keyWidowappdelegate.window还是windows.firstobject去取出来的window都应该是同一个,这也是我们没有加入这个推送组件之前为什么不会发生“穿透”问题的原因。

收到推送之后的windows数组

当用户收到过一次推送后可以看到这个时候windows数组中已经有3个window了,在图中最上面的UIWindow也是windows数组的第一个,后面两个是EBBannerWindowEBEmptyWindow,那为什么这两个自定义的windowwindows数组中会排在UIWindow的后面呢?第一个决定因素是UIWindowLevel,它代表window的一个级别,共有三个类型,分别是:
windowLevel的不同级别

这是三个CGFloat类型的常量,通过打印得到这个三个值分别是0.000000 2000.000000 1000.000000。因为EBBannerWindowEBEmptyWindowwindowLevel都是UIWindowLevelAlert的,而UIWindowwindowLevelUIWindowLevelNormal,由此可以推断影响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属性设为NOmakeKeyAndVisible虽然是一个“convenience method”,也能让window展现,但是因为它会改变keyWindow,所以我个人是不建议使用。

回到windows数组的排列顺序,前面说了,第一个是取决于windowLevel的大小,大的排后面,小的排前面,那么如果数组中两个window的level相同,那么谁排在前面,谁排在后面呢?我在网上看到的一个答案说是最后一次调用makeKeyAndVisible或者setHidden:YES方法的那个window排在后面,因为这两个方法都会将一个windowhidden属性改为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];

demoOne

可以发现windows数组的顺序也跟着变化了,bannerWindow是排在了emptyWindow的后面,这么看来上面的观点好像有点道理,别急,我们接着往下看。

demoTwo

为了进一步证明这个观点我将源码中给emptyWindowbannerWindow设置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];

demoTwo

可以看到windows数组并没有发生变化,那就证明上面的观点是不正确的,那为毛这个UIWindow始终会排在第一个呢?突然一个想法冒了出来,决定它崇高地位的只有它创建的时间了,因为它是一启动就被创建的,所以才会排在第一个,由此我假设决定windows数组中元素顺序的是window们被创建的先后顺序。

demoThree

根据这个假设我先改变了emptyWindowbannerWindow的创建顺序,将bannerWindow放在了emptyWindow之前,但是他们调用setHidden:的顺序还是不变,bannerWindowemptyWindow之后:

        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后面,刚好形成了一个互斥,也就是说决定因素只有一个,我们来看看结果:

demoThree

结果是bannerWindow排在了windows前面,那就证明在level相同的前提下,决定数组中前后顺序的是window创建的顺序,再来个demoFour佐证一下:

demoFour

demoFour

我在AppDelegateself.window之前先创建了这两个自定义的window,这里有个点要注意下就是创建一个window的同时就要为它设置一个rootViewController,不然会crash的,运行的结果在左边。

demoFive

那么windowlevel的高低和创建的顺序这两者哪一个优先级更高呢?我将bannerWindowemptyWindow的优先级都改为了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;

demoFive

可以看出来因为self.window因为level最高,所以它还是排在了数组最后一个,而上面两个window因为level相同,则根据创建的先后顺序排列,bannerWindow是先创建的,所以排在前面,代码和运行结果基本可以证实我这个观点了。到这里搞清楚了windows数组的排列顺序,再来看看这个bug产生的原因吧。

穿透的原因

前面说了当UIAlertView弹出来后keyWindow被替换成一个临时的window,当UIAlertViewdismiss之后这个临时的window也随即被销毁,那么系统会去寻找一个新的keyWindow人选,这个候选人自然是来自windows数组,至于竞选规则就是上面几个demo总结出来的先优先级后创建顺序规则,按我们项目中最初的写法,这个新的keyWindow人选是第三方组件的中的emptyWindow,因为它优先级最高并且是最后一个被创建的,同时这个emptyWindowframeCGRectZero,所以加上去的弹框的所有点击事件都无法响应了,这个地方其实我仍然有点疑惑的是,为什么这个window的frameCGRectZero添加到这个window上的view依然可以展示出来,只是不能响应点击事件而已,但也正因为它的frameCGRectZero,弹出来的被添加到emptyWindow上的这些UIView,并没有阻断其他window上控件的事件传递,盖在它们下面的控件依然可以正常点击,这也就是所谓的“穿透”效果。

解决方案

找到问题的原因之后我们对这个推送第三方组件进行了修改,因为他是一个单例,没法在没有推送的情况下去销毁这两个自定义的UIWindow,只能在弹出和消失的时候加以控制。讨论出来的方案是首先将bannerWindowemptyWindowwindowLevel降为了默认值UIWindowLevelNormal,因为一个推送弹框只要让用户看见即可,它不存在交互,所以不需要那么高的级别,而且windowLevel会影响一个windowwindows数组中的顺序,而数组中的顺序是影响keyWindow的关键因素。其次将这两个自定义window的展现方式从makeKeyAndVisible改为setHidden:NO,我猜想到原作者写这句[originKeyWindow makeKeyAndVisible];代码原因就只是想让他的自定义window展现出来而不影响到keyWindow,所以调用setHidden:方法足矣,改了这两个地方后,emptyWindowbannerWindowappdelegate.window都是UIWindowLevelNormal级别的,但因为emptyWindow是最后被创建的,所以它还是会排在数组的最后面,只要UIAlertView弹出并消失后,它依然是keyWindow的最佳人选,所以我们需要在推送框的的hide方法里,在hide动画完成之后,手动的调用一次[appdelegate.window makeKeyAndVisible];方法,将appdelegate.window置回keyWindow,也就是让emptyWindowkeyWindow的席位交出去,我们虽然不能改变它最佳候选人的位置,但却可以让它自己让位,这样就避免了keyWindow的影响,另外我们也把所有addkeyWindow上的这类弹框的展现方式统一做了替换,分别添加到对应的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属性

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

推荐阅读更多精彩内容