莫名的穿透
之前版本中出現(xiàn)一個bug,個別的QA同事反映APP中的部分彈框(UIView)彈出來之后就沒法點擊了,然而這個彈框蒙層背后的界面依然可以正常交互,好像出現(xiàn)了一種“穿透”效果:

因為復(fù)現(xiàn)的次數(shù)很少,規(guī)律也沒找到,聽得我也是一頭霧水,我在想這是什么高級效果,既然不能復(fù)現(xiàn)那只能從源頭,也就是代碼層面排查了,因為不管所謂的“高級特效”是不是自己寫出來的,但代碼可是永遠(yuǎn)不會對你說謊的。
既然是彈出來以后就無法交互了,我第一反應(yīng)是想看看這幾個
UIView是以什么樣的方式彈出的,結(jié)果在他們中間都找到了同一段代碼:
- (void)show {
[[UIApplication sharedApplication].keyWindow addSubview:self];
}
這幾個UIView都是被加到了keyWindow上,當(dāng)時覺得問題也就出在這里了,因為加在UIWindow上的東西始終是展示在最上面的,可能某種情況下導(dǎo)致這些UIView無法被銷毀了,但至于是什么情況我也猜不到,最后將他們的展示方式都改為了:
[self.tabBarController.view addSubview:self];
后來就沒有人反饋過類似的問題了,我的心里在還為自己又解決了一個“靈異”問題而自嗨了短暫的幾秒。
后來無意中被我找到了復(fù)現(xiàn)的規(guī)律,我發(fā)現(xiàn)只要我在某一個頁面彈出過一次UIAlertView以后,其他只要是add到keyWindow上的彈框,100%會出現(xiàn)這種“穿透的效果”,這也是我上面的demo中有一個按鈕是alert的原因,后面會講到。
我跟旁邊的dj_rose同學(xué)提到了這個現(xiàn)象,他捕捉到了window這個關(guān)鍵字,告訴我在上個版本我們接入過一個第三方的推送組件,這個組件就是在UIWindow的基礎(chǔ)上做的,會不會是這個組件產(chǎn)生了影響,這個線索很關(guān)鍵,于是我順著這條線,在這個第三方組件的源碼里找到了蛛絲馬跡:

源碼中自定義了兩個
UIWindow用來作為推送消息彈框的載體,分別是EBBanerWindow類型和EBEmptyWindow類型,至于這兩個UIWindow到底是怎樣分工的這里就不分析了,我也沒細(xì)看。圖中我圈出來的地方引起了我的注意,因為他給這兩個自定義的UIWindow設(shè)置的frame都是CGectZero,并且這兩個UIWindow的windowLevel都是UIWindowLevelAlert,這兩個屬性便讓我和“穿透”這一特性掛上了鉤,我在想項目的彈框是不是被無意間add到這兩個自定義UIWindow中的其中一個上面了,因為這兩個UIWindow的frame都是CGectZero,如果父view的frame是CGectZero的話那子view肯定是不會響應(yīng)到各種點擊事件的,而且這兩個UIWindow如果出現(xiàn)的話肯定是在層級的最上面,所以會出現(xiàn)一直浮在上面無法銷毀的現(xiàn)象,但我又一想,不對啊,我都是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這個臨時變量中,最后再向它發(fā)送一遍makeKeyAndVisible消息,這樣理應(yīng)keyWindow是不會被影響到的,作者之所以加上這步操作我猜他也是為了防止keyWindow被替換而帶來的不必要的麻煩,這就奇怪了,也就是說在某種情況下我的keyWindow還是被偷偷替換了。
keyWindow和UIAlertView
那到底是誰替換了keyWindow呢?答案我在上面已經(jīng)留下過伏筆了,我復(fù)現(xiàn)的規(guī)律是只要在APP中任何一個頁面彈出來過一次UIAlertView之后,這個“穿透”就會發(fā)生,那肯定就是UIAlertView影響到了keyWindow,之前對UIAlertView的了解也就停留在“其實它也是一個Window”的級別上,至于它為什么會改變keyWindow并沒有研究過,隨即百度一番(百度雖然有點low但卻能解決現(xiàn)階段我的大部分問題,原因可能是我太low了吧),找到了幾篇有用的文章,將他們的觀點總結(jié)一下:
1.使用UIAlertView的show時,系統(tǒng)使用了一個臨時的并且層級最高的UIWindow來展現(xiàn)UIAlertView,所以當(dāng)show彈窗時,keyWindow已經(jīng)被替換為_UIAlertControllerShimPresenterWindow,打印了一下也的確是這樣。

2.當(dāng)
UIAlertView消失后keyWindow將會轉(zhuǎn)向另一個UIWindow,至于這個UIWindow是哪一個,取決于在[UIApplication sharedApplication].windows數(shù)組中的位置的先后。
windows數(shù)組的排列順序
一般的,我們項目中在AppDelegate里都會有這樣幾句代碼:
self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
[self.window makeKeyAndVisible];
其實寫了這么多遍也沒細(xì)細(xì)分析過這句代碼,它其實就是給APP設(shè)置一個keyWindow,用來作為之后呈現(xiàn)的一切UIView的載體,這個window會排在windows數(shù)組里的第一個,一般項目中如果沒有特殊需求去自定義UIWindow的話,這個windows數(shù)組里的firstObject通常情況下始終會是我們在AppDelegate中創(chuàng)建的這個window(當(dāng)然也不排除有firstObject被系統(tǒng)偷偷替換的情況,這也是我們另一個線上反饋很多次無法復(fù)現(xiàn)的bug的原因):

所以在這種情況下不管我們用
keyWidow,appdelegate.window還是windows.firstobject去取出來的window都應(yīng)該是同一個,這也是我們沒有加入這個推送組件之前為什么不會發(fā)生“穿透”問題的原因。

當(dāng)用戶收到過一次推送后可以看到這個時候
windows數(shù)組中已經(jīng)有3個window了,在圖中最上面的UIWindow也是windows數(shù)組的第一個,后面兩個是EBBannerWindow和EBEmptyWindow,那為什么這兩個自定義的window在windows數(shù)組中會排在UIWindow的后面呢?第一個決定因素是UIWindowLevel,它代表window的一個級別,共有三個類型,分別是:
這是三個
CGFloat類型的常量,通過打印得到這個三個值分別是0.000000 2000.000000 1000.000000。因為EBBannerWindow和EBEmptyWindow的windowLevel都是UIWindowLevelAlert的,而UIWindow的windowLevel是UIWindowLevelNormal,由此可以推斷影響windows數(shù)組排列的順序的第一個因素是level低的在前面,level高的在后面。那么當(dāng)level相同的時候呢,這就和
window的展現(xiàn)方式有關(guān)系了,首先來看看這個方法。
makeKeyAndVisible和setHidden:
討論windows數(shù)組中元素的排列順序之前我們先來看一下蘋果官方文檔對于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中凸現(xiàn)出來,讓這個window中的view展示在其他window的上面,如果你只是想讓一個window展現(xiàn)出來,將它的hidden屬性設(shè)為NO就可以了,因為一個UIWindow被創(chuàng)建出來的時候hidden屬性是默認(rèn)為YES的。
其實在開發(fā)中我們也只需要將那個在AppDelegate中創(chuàng)建的window設(shè)為keyWindow,其他的自定義window需要展現(xiàn),只需要將它們的hidden屬性設(shè)為NO,makeKeyAndVisible雖然是一個“convenience method”,也能讓window展現(xiàn),但是因為它會改變keyWindow,所以我個人是不建議使用。
回到windows數(shù)組的排列順序,前面說了,第一個是取決于windowLevel的大小,大的排后面,小的排前面,那么如果數(shù)組中兩個window的level相同,那么誰排在前面,誰排在后面呢?我在網(wǎng)上看到的一個答案說是最后一次調(diào)用makeKeyAndVisible或者setHidden:YES方法的那個window排在后面,因為這兩個方法都會將一個window的hidden屬性改為YES,所以會影響這個window在數(shù)組中的順序,最后一次調(diào)用這兩個方法中任何一個的window它也就類似于“最后一個被影響過”,所以它排在別人后面。
demoOne
我將原來EBBanerWindow.m中兩個自定義window的展現(xiàn)方式都改為了通過setHidden:的方式,并且代碼的順序改為emptyWindow在前,bannerWindow在后,因為這兩個window的level是相同的,按上面的觀點那bannerWindow肯定會在windows數(shù)組中排在emptyWindow的后面,我們來看看運(yùn)行結(jié)果:
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];

可以發(fā)現(xiàn)
windows數(shù)組的順序也跟著變化了,bannerWindow是排在了emptyWindow的后面,這么看來上面的觀點好像有點道理,別急,我們接著往下看。
demoTwo
為了進(jìn)一步證明這個觀點我將源碼中給emptyWindow和bannerWindow設(shè)置windowLevel的代碼注釋掉,這樣他們兩的level也變成了默認(rèn)值UIWindowLevelNormal,這樣[originKeyWindow makeKeyAndVisible]是最后調(diào)用的,那么按照最后調(diào)用排后面的原則是不是windows數(shù)組中UIWindow會跑到數(shù)組的最后一個去呢,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數(shù)組并沒有發(fā)生變化,那就證明上面的觀點是不正確的,那為毛這個UIWindow始終會排在第一個呢?突然一個想法冒了出來,決定它崇高地位的只有它創(chuàng)建的時間了,因為它是一啟動就被創(chuàng)建的,所以才會排在第一個,由此我假設(shè)決定windows數(shù)組中元素順序的是window們被創(chuàng)建的先后順序。
demoThree
根據(jù)這個假設(shè)我先改變了emptyWindow和bannerWindow的創(chuàng)建順序,將bannerWindow放在了emptyWindow之前,但是他們調(diào)用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];
這樣如果按先創(chuàng)建的順序bannerWindow應(yīng)該排在emptyWindow前面,如果按照后調(diào)用setHidden:的順序bannerWindow應(yīng)該排在emptyWindow后面,剛好形成了一個互斥,也就是說決定因素只有一個,我們來看看結(jié)果:

結(jié)果是
bannerWindow排在了windows前面,那就證明在level相同的前提下,決定數(shù)組中前后順序的是window創(chuàng)建的順序,再來個demoFour佐證一下:
demoFour

我在
AppDelegate中self.window之前先創(chuàng)建了這兩個自定義的window,這里有個點要注意下就是創(chuàng)建一個window的同時就要為它設(shè)置一個rootViewController,不然會crash的,運(yùn)行的結(jié)果在左邊。
demoFive
那么windowlevel的高低和創(chuàng)建的順序這兩者哪一個優(yōu)先級更高呢?我將bannerWindow和emptyWindow的優(yōu)先級都改為了UIWindowLevelNormal,然后將AppDelegate中的self.window的level提高到了UIWindowLevelAlert,但創(chuàng)建順序self.window還是在兩個自定義window的后面,看一下運(yùn)行結(jié)果:
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最高,所以它還是排在了數(shù)組最后一個,而上面兩個window因為level相同,則根據(jù)創(chuàng)建的先后順序排列,bannerWindow是先創(chuàng)建的,所以排在前面,代碼和運(yùn)行結(jié)果基本可以證實我這個觀點了。到這里搞清楚了windows數(shù)組的排列順序,再來看看這個bug產(chǎn)生的原因吧。
穿透的原因
前面說了當(dāng)UIAlertView彈出來后keyWindow被替換成一個臨時的window,當(dāng)UIAlertViewdismiss之后這個臨時的window也隨即被銷毀,那么系統(tǒng)會去尋找一個新的keyWindow人選,這個候選人自然是來自windows數(shù)組,至于競選規(guī)則就是上面幾個demo總結(jié)出來的先優(yōu)先級后創(chuàng)建順序規(guī)則,按我們項目中最初的寫法,這個新的keyWindow人選是第三方組件的中的emptyWindow,因為它優(yōu)先級最高并且是最后一個被創(chuàng)建的,同時這個emptyWindow的frame是CGRectZero,所以加上去的彈框的所有點擊事件都無法響應(yīng)了,這個地方其實我仍然有點疑惑的是,為什么這個window的frame是CGRectZero添加到這個window上的view依然可以展示出來,只是不能響應(yīng)點擊事件而已,但也正因為它的frame是CGRectZero,彈出來的被添加到emptyWindow上的這些UIView,并沒有阻斷其他window上控件的事件傳遞,蓋在它們下面的控件依然可以正常點擊,這也就是所謂的“穿透”效果。
解決方案
找到問題的原因之后我們對這個推送第三方組件進(jìn)行了修改,因為他是一個單例,沒法在沒有推送的情況下去銷毀這兩個自定義的UIWindow,只能在彈出和消失的時候加以控制。討論出來的方案是首先將bannerWindow和emptyWindow的windowLevel降為了默認(rèn)值UIWindowLevelNormal,因為一個推送彈框只要讓用戶看見即可,它不存在交互,所以不需要那么高的級別,而且windowLevel會影響一個window在windows數(shù)組中的順序,而數(shù)組中的順序是影響keyWindow的關(guān)鍵因素。其次將這兩個自定義window的展現(xiàn)方式從makeKeyAndVisible改為setHidden:NO,我猜想到原作者寫這句[originKeyWindow makeKeyAndVisible];代碼原因就只是想讓他的自定義window展現(xiàn)出來而不影響到keyWindow,所以調(diào)用setHidden:方法足矣,改了這兩個地方后,emptyWindow、bannerWindow和appdelegate.window都是UIWindowLevelNormal級別的,但因為emptyWindow是最后被創(chuàng)建的,所以它還是會排在數(shù)組的最后面,只要UIAlertView彈出并消失后,它依然是keyWindow的最佳人選,所以我們需要在推送框的的hide方法里,在hide動畫完成之后,手動的調(diào)用一次[appdelegate.window makeKeyAndVisible];方法,將appdelegate.window置回keyWindow,也就是讓emptyWindow把keyWindow的席位交出去,我們雖然不能改變它最佳候選人的位置,但卻可以讓它自己讓位,這樣就避免了keyWindow的影響,另外我們也把所有add到keyWindow上的這類彈框的展現(xiàn)方式統(tǒng)一做了替換,分別添加到對應(yīng)的VC.view或者tabbarVC.view上,對于UIAlertView,因為在iOS 9.0以后也被蘋果廢棄了,我猜蘋果可能也是發(fā)現(xiàn)了這種用UIWindow承載UIAlertView這種方式會對keyWindow產(chǎn)生影響,另一方面UIAlertView的代理方法對代碼邏輯也是一種拆散,所以我們也將項目中使用UIAlertView的地方逐步的替換為更好的UIAlertController,畢竟UIAlertController是不會去改變keyWindow的。
面向bug開發(fā)
踩過這一個坑后我想說在使用國內(nèi)非大神寫的第三方組件上時一定要“三思”,其實這個組件確實寫的蠻厲害,替我們節(jié)省了很多開發(fā)時間,不過拋開這個組件其他地方不談,我能在初始化window這段源碼中看到作者的防御機(jī)制,但感覺并沒有吃透UIWindow的一些用法和原理,才導(dǎo)致在和UIAlertView一起的時候出現(xiàn)了問題。其實也是因為這個bug才迫使我去一點一點了解UIWindow的東西,可能bug才是推動技術(shù)進(jìn)步的第一要素,以上是我自己總結(jié)出來的一些心得,看到這些觀點的同學(xué)也務(wù)必要“三思”而后行。
參考文章:
EBBannerView只需一行代碼:展示跟 iOS 系統(tǒng)一樣的推送通知橫幅
添加多個UIWindow時,使用keyWindow要注意一點
iOS 關(guān)于UIAlertController、UIAlertView彈窗問題
UIWindow的windowLevel屬性