一、問題背景
最近需求量放緩,想起了以前曾經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 的窗口用來顯示鍵盤按鈕。目前對這個研究還不是很多,以后有了新發現再與大家分享。