App里總會(huì)有很多的彈窗,為了美觀,大多數(shù)彈窗都需要蓋住導(dǎo)航欄;這時(shí)彈窗會(huì)添加到window上以滿足需求。但添加到window上的彈窗卻不方便管理,也與頁面脫離關(guān)系,如果有異步的情況,彈窗會(huì)更加復(fù)雜難以處理,如何才能對window彈窗統(tǒng)一進(jìn)行管理,解決這些問題?
window彈窗面臨的問題:
- 為了統(tǒng)一管理彈框,首先需要對window彈窗可能會(huì)出現(xiàn)的問題進(jìn)行梳理,避免以后在維護(hù)彈窗時(shí)出現(xiàn)問題,到時(shí)候再進(jìn)行兼容和修改會(huì)比較麻煩。這里列舉了一些常見的問題,如果有未考慮到的情況,可以在評論里補(bǔ)充。
1、多個(gè)彈窗可能會(huì)產(chǎn)生重疊:如app啟動(dòng)的時(shí)候有2個(gè)彈窗,正巧2個(gè)彈窗都觸發(fā)展示,這時(shí)候這2個(gè)彈窗就會(huì)重疊在一起。
2、彈窗無法與頁面關(guān)聯(lián):如登錄后有個(gè)彈框需要在tab2頁顯示,但是啟動(dòng)后首屏頁為tab1,這時(shí)候彈框就不能顯示,當(dāng)tab2出現(xiàn)時(shí)才顯示。
3、彈窗無法設(shè)置優(yōu)先級:一個(gè)比較簡單的例子:有多個(gè)彈層的新手引導(dǎo),用戶關(guān)閉引導(dǎo)頁1時(shí),按順序呈現(xiàn)引導(dǎo)頁2、引導(dǎo)頁3, 如果中間有其他彈窗出現(xiàn)的邏輯,應(yīng)該等待引導(dǎo)頁結(jié)束再展示。
4、彈窗無法留活:一個(gè)簡單的例子:一個(gè)活動(dòng)彈窗含有2個(gè)活動(dòng),點(diǎn)擊活動(dòng)A進(jìn)入詳情頁,此時(shí)window彈窗應(yīng)該消失,當(dāng)從詳情頁返回時(shí),活動(dòng)彈窗應(yīng)該繼續(xù)展示,才能點(diǎn)擊進(jìn)入活動(dòng)B查看詳情。為了避免重新觸發(fā)彈窗的邏輯,應(yīng)該對彈窗進(jìn)行緩存。
5、異步彈窗處理復(fù)雜:例如網(wǎng)絡(luò)請求彈窗的數(shù)據(jù),彈窗的展示因此延時(shí),用戶在此期間跳轉(zhuǎn)其他頁面,或者當(dāng)前頁面已經(jīng)返回,因?yàn)槭莣indow彈窗,彈窗則不應(yīng)該顯示出來。
6、彈窗不能自動(dòng)關(guān)閉:例如用戶被迫下線,此時(shí)app的所有彈窗都應(yīng)該自動(dòng)移除,或者彈窗展示情況下app發(fā)生頁面跳轉(zhuǎn),避免彈窗忘記關(guān)閉的情況,也應(yīng)該自動(dòng)移除現(xiàn)有的彈窗。
問題4效果對比-前:

問題4效果對比-后:

問題分析:
一、多個(gè)彈框重疊沖突: 這個(gè)問題比較好解決,簡單的做法是使用信號(hào)量來限制當(dāng)前彈窗的數(shù)量,讓彈窗一個(gè)一個(gè)的出現(xiàn)。創(chuàng)建一個(gè)彈窗manager,添加show和dismiss方法, show方法lock, dismiss方法 Release Lock。
- (void)show
{
//位于非主線程 不阻塞
dispatch_async(dispatch_queue_create(QUEUE_NAME, DISPATCH_QUEUE_SERIAL), ^{
//Lock
dispatch_semaphore_wait(_globalInstancesLock, DISPATCH_TIME_FOREVER);
//保證主線程UI操作
dispatch_async(dispatch_get_main_queue(), ^{
[[[UIApplication sharedApplication] keyWindow] addSubview:self];
});
});
}
- (void)dismiss
{
dispatch_async(dispatch_queue_create(QUEUE_NAME, DISPATCH_QUEUE_SERIAL), ^{
//Release Lock
dispatch_semaphore_signal(_globalInstancesLock);
dispatch_async(dispatch_get_main_queue(), ^{
[self removeFromSuperview];
});
});
}
??但是使用信號(hào)量來處理彈窗展示的數(shù)量,這種方式只能滿足讓彈窗一個(gè)個(gè)出現(xiàn),沒辦法刪除或者變更未展示的彈框,是不方便對彈窗進(jìn)行管理的。
??這時(shí)候使用隊(duì)列是一個(gè)比較好的選擇,在show的時(shí)候把彈窗添加進(jìn)隊(duì)列中, dismiss的時(shí)候從隊(duì)列里移除,當(dāng)上一個(gè)彈窗dimiss,從隊(duì)列里選出下一個(gè)要展示的,這樣也能做到彈窗始終只會(huì)有一個(gè)正在展示,而未展示的彈窗則在隊(duì)列中等待展示。
+ (void)showView:(UIView *)view {
if ([self shareInstance].currentView == nil) {
// 當(dāng)前無彈窗展示直接展示
UIWindow *window = [UIApplication sharedApplication].delegate.window;
[window addSubview:view];
} else {
// 當(dāng)前有彈窗則加入隊(duì)列中
LYWindowScreenModel *model = [LYWindowScreenModel new];
model.view = view;
[[self shareInstance].arrayWaitViews addObject:model];
}
}
+ (void)dismiss:(UIView *)view {
if ([self shareInstance].currentView == view) {
// 刪除當(dāng)前彈窗
[view removeFromSuperview];
[[self shareInstance] setCurrentView:nil];
} else {
// 刪除隊(duì)列中的彈窗
for (int i = 0; i < [self shareInstance].arrayWaitViews.count; i++) {
LYWindowScreenModel *model = [self shareInstance].arrayWaitViews[i];
if (model.view == view) {
[[self shareInstance].arrayWaitViews removeObject:model];
}
}
}
// 展示下一個(gè)彈窗
if ([self shareInstance].arrayWaitViews.count > 0) {
for (int i = 0; i < [self shareInstance].arrayWaitViews.count; i++) {
LYWindowScreenModel *model = [self shareInstance].arrayWaitViews[i];
if (model.view) {
UIWindow *window = [UIApplication sharedApplication].delegate.window;
[window addSubview:view];
}
}
}
}
二、彈窗無法與頁面關(guān)聯(lián): 彈窗要在指定的頁面顯示, 因?yàn)橹耙呀?jīng)有了彈窗隊(duì)列,此時(shí)應(yīng)該把彈窗添加到隊(duì)列中去等待展示,但是此時(shí)隊(duì)列里的彈窗并沒有頁面限制,即使放進(jìn)隊(duì)列里也會(huì)在其他頁面出現(xiàn)。 所以需要對每個(gè)彈窗指定一個(gè)展示的頁面, 當(dāng)從隊(duì)列里推出彈窗進(jìn)行展示時(shí),判斷當(dāng)前頁面是否為可展示的頁面,如果不是則繼續(xù)在隊(duì)列里等待。
+ (void)showView:(UIView *)view
page:(Class)page
{
UIViewController *currentController = [UIViewController currentViewController];
if ([self shareInstance].currentView == nil &&
[currentController isMemberOfClass:page]) {
UIWindow *window = [UIApplication sharedApplication].delegate.window;
[window addSubview:view];
} else {
LYWindowScreenModel *model = [LYWindowScreenModel new];
model.view = view;
model.pageClass = page;
[[self shareInstance].arrayWaitViews addObject:model];
}
}
??給彈窗指定頁面后,這時(shí)候需要對當(dāng)前頁面的變化進(jìn)行監(jiān)聽,當(dāng)指定頁面出現(xiàn)時(shí)彈窗應(yīng)該及時(shí)呈現(xiàn)出來。這里的做法是hook UIViewController的viewWillAppear方法,在頁面變化時(shí)發(fā)送通知,告訴manager頁面發(fā)生變化,檢索隊(duì)列里是否有此頁面等待展示的彈窗。
??考慮到重寫viewWillAppear方法后,每次頁面變化都會(huì)發(fā)送通知,可能會(huì)帶來一定的性能問題, 所以manager只有在隊(duì)列里有等待的彈窗時(shí)才注冊通知,無等待的彈窗則不需要知道頁面的變化,這時(shí)候可以移除通知。(如果有更好的監(jiān)聽頁面變化的方法望告之)
+ (void)viewWillAppearNotification:(NSNotification *)notification {
id identifier = notification.object[LYViewControllerClassIdentifier];
[self viewNeedShowFromQueueWithPage:identifier];
}
+ (void)viewNeedShowFromQueueWithPage:(UIViewController *)page {
// 當(dāng)前屏幕有彈框,則不顯示
if ([self shareInstance].currentView) {
return;
}
// 隊(duì)列里無等待顯示的視圖
if (![self shareInstance].arrayWaitViews.count) {
return;
}
// 推出隊(duì)列中需要展示的視圖進(jìn)行展示
__block LYWindowScreenModel *model = nil;
[[self shareInstance].arrayWaitViews enumerateObjectsUsingBlock:^(LYWindowScreenModel *obj, NSUInteger idx, BOOL * _Nonnull stop) {
if (obj.pageClass && obj.view) {
if ([page isKindOfClass:obj.pageClass]) {
UIWindow *window = [UIApplication sharedApplication].delegate.window;
[window addSubview:obj.view];
model = obj;
*stop = YES;
}
}
}];
if (model) {
[[self shareInstance].arrayWaitViews removeObject:model];
}
}
三、設(shè)置彈窗優(yōu)先級:因?yàn)楝F(xiàn)在有了彈窗等待隊(duì)列,彈窗的優(yōu)先級也就可以很好的解決,在添加進(jìn)隊(duì)列時(shí),給彈窗設(shè)置一個(gè)level值,根據(jù)level值排序后從隊(duì)列里推出展示的彈窗自然是優(yōu)先級比較高的彈窗。
??因?yàn)橛袝r(shí)候無法確認(rèn)其他彈框的level值,level的設(shè)定建議以場景來設(shè)置level,因?yàn)橥粓鼍暗亩鄠€(gè)彈窗大部分情況下無需按優(yōu)先級展示,同等level能按先后順序展示即可。
如:
typedef enum : NSUInteger {
LYLevelHigh = -100, // 優(yōu)先級最高, 場景如開屏動(dòng)畫
LYLevelMedium = -1, // 優(yōu)先級高, 場景如啟動(dòng)完成廣告彈窗
LYLevelDefault = 0, // 優(yōu)先級一般,場景如新手引導(dǎo)
LYLevelLow = 100, // 優(yōu)先級低,場景如常用彈窗
} LYWindowScreenLevel;
如:開屏動(dòng)畫 > 廣告 > 引導(dǎo) > 業(yè)務(wù)彈窗,這樣可以滿足app內(nèi)絕大多數(shù)的彈窗展示順序,如果有變動(dòng)可再改動(dòng)自己修改值。
四、彈窗無法留活:還是之前拋出的問題,點(diǎn)擊活動(dòng)A進(jìn)入詳情頁,此時(shí)window彈窗應(yīng)該消失,當(dāng)從詳情頁返回時(shí),活動(dòng)彈窗應(yīng)該繼續(xù)展示。為了避免再一次執(zhí)行彈窗的展示邏輯,所以需要對當(dāng)前的彈窗進(jìn)行緩存,等待頁面重新回來時(shí)展示。 這種情況只是頁面暫時(shí)離開,頁面并未從頁面路徑棧里消失,如果頁面已經(jīng)不存在,那么緩存里的彈窗也應(yīng)該移除。
??新建一個(gè)彈框的緩存數(shù)組,這里并沒有放入之前等待隊(duì)列里, 是因?yàn)榈却?duì)列里的彈窗都是仍未展示的,無論頁面是否新建(根據(jù)class),當(dāng)這個(gè)頁面是彈窗指定的歸屬類時(shí)都可以展示出來。而緩存的彈窗是與具體的頁面關(guān)聯(lián)的(根據(jù)obj),如果頁面返回再重新進(jìn)入,頁面已經(jīng)重新構(gòu)造,上次緩存的彈窗是不應(yīng)該再展示的,因?yàn)轫撁嬷匦聵?gòu)造后可能會(huì)重新觸發(fā)彈窗的邏輯,這時(shí)候可能就會(huì)2個(gè)相同的彈窗,所以這里用了2個(gè)隊(duì)列存儲(chǔ)彈框。
具體實(shí)現(xiàn)是:
- hook UIViewController的viewWillDisappear方法,當(dāng)有需要緩存的彈窗時(shí)添加監(jiān)聽,當(dāng)頁面離開時(shí)移除當(dāng)前展示的彈框,并且當(dāng)前彈窗添加進(jìn)緩存隊(duì)列里。
+ (void)viewWillDisAppearNotification:(NSNotification *)notification {
NSString *strClass = notification.object[LYViewControllerClassName];
id identifier = notification.object[LYViewControllerClassIdentifier];
if ([self shareInstance].currentView && [strClass isEqualToString:NSStringFromClass([self shareInstance].pageClass)]) {
if ([self shareInstance].keepAlive) {
LYWindowScreenModel *model = [LYWindowScreenModel new];
model.view = [self shareInstance].currentView;
model.pageClass = [self shareInstance].pageClass;
model.level = LYLevelHigh;
model.keepAlive = [self shareInstance].keepAlive;
model.identifier = identifier;
model.addCompleted = [self shareInstance].addCompleted;
// 添加進(jìn)緩存數(shù)組
[[self shareInstance].arrayAliveViews addObject:model];
[self addWaitShowNotification];
}
[[self shareInstance].currentView removeFromSuperview];
[[self shareInstance] setCurrentView:nil];
[self removeNotification];
}
}
-
如果這個(gè)頁面已經(jīng)銷毀,那么這個(gè)彈框也沒有意義再緩存,當(dāng)頁面已經(jīng)離開時(shí),判斷緩存隊(duì)列里的彈窗所指定的緩存頁面是否存在,如果已經(jīng)銷毀則刪除彈窗。
如何判斷頁面已經(jīng)不存在?如果只是判斷Controller是否為空,這顯然是不行的,因?yàn)榧词鬼撁娣祷?,Controller可能仍被其他類持有。所以仍然需要判斷頁面是否還在某個(gè)頁面路徑棧里, 而OC中帶有頁面容器的情況有,UINavigationController、presentedViewController、UITabBarController、UISplitViewController、Sub-Controller。tabBar 和 Split 基本上都會(huì)包含一次navigation,而subController也會(huì)根據(jù)父級的路徑棧一同消失。 所以頁面可以通過Controller是否還有navigationController或者presentingViewController來判斷當(dāng)前頁面是否已經(jīng)從頁面棧里移除。
// 如果存活彈框的歸屬頁面已移除,則移除該頁面的所有彈框
+ (void)viewDidDisAppearNotification:(NSNotification *)notification {
if ([self shareInstance].arrayAliveViews.count) {
for (int i = 0; i < [self shareInstance].arrayAliveViews.count; i++) {
LYWindowScreenModel *model = [self shareInstance].arrayAliveViews[i];
BOOL exist = model.identifier.navigationController || model.identifier.presentingViewController;
if (!exist) {
[[self shareInstance].arrayAliveViews removeObject:model];
[self removeNotification];
}
}
}
}
- 當(dāng)監(jiān)聽到頁面返回到原來頁面時(shí)彈窗應(yīng)該重新出現(xiàn),彈窗從緩存隊(duì)列里刪除,并且添加進(jìn)等待隊(duì)列里,等待顯示。
+ (void)viewNeedShowFromQueueWithPage:(UIViewController *)page {
// 判斷當(dāng)前頁是否有存活的彈框,有則加入隊(duì)列中。
if ([self shareInstance].arrayAliveViews.count) {
for (int i = 0; i < [self shareInstance].arrayAliveViews.count; i++) {
LYWindowScreenModel *model = [self shareInstance].arrayAliveViews[i];
if (page == model.identifier) {
[[self shareInstance].arrayWaitViews addObject:model];
[[self shareInstance].arrayAliveViews removeObject:model];
}
}
}
五、異步彈窗情況:異步彈框的情況稍微復(fù)雜,基本上都會(huì)跟網(wǎng)絡(luò)請求扯上聯(lián)系,如果網(wǎng)絡(luò)請求未完成的情況下,頻繁的“進(jìn)入-返回”,這可能會(huì)出現(xiàn)多個(gè)彈窗的網(wǎng)絡(luò)請求同時(shí)在請求,這時(shí)候會(huì)產(chǎn)生3個(gè)問題:
- 將會(huì)有多個(gè)相同彈窗出現(xiàn)(如果未對彈窗限制次數(shù))
- 彈窗可能不是最后一次請求想要的彈窗。
- 彈窗可能無法響應(yīng)點(diǎn)擊事件。
想要統(tǒng)一支持各種業(yè)務(wù)場景的彈框,異步的問題就需要解決,為了更清晰的理解各種異步場景彈窗的展示邏輯,這里列出了所有異步場景的情況:
- A—B, B—A ,A—B2, 彈窗延遲加載時(shí)在本類, 但是實(shí)例發(fā)生變換 【不緩存】
- A—B, B—A ,A—B, 彈窗延遲加載時(shí)在本類, 實(shí)例未發(fā)生變換。 【緩存】
- A—B, B—C ,C—B, 彈窗延遲加載時(shí)不在本類,B還在頁面棧?!揪彺妗?/li>
- A—B, B—C ,C—A, 彈窗延遲加載時(shí)不在本類,B不在頁面棧?!疽瞥彺妗?/li>
- A—B, B—A , 彈窗延遲加載時(shí)不在本類。 B未知是否釋放 【不確定】


六、自動(dòng)刪除彈窗:有些app需要登錄之后才能展示彈窗,如果用戶下線或者被踢,這個(gè)用戶的彈窗都應(yīng)該移除。 因?yàn)橛辛岁?duì)列,當(dāng)用戶下線時(shí)移除當(dāng)前展示的彈窗和隊(duì)列里等待彈窗就可以統(tǒng)一移除manager管理的所有彈窗。
+ (void)removeAllQueueViews {
[[self shareInstance].arrayAliveViews removeAllObjects];
[[self shareInstance].arrayWaitViews removeAllObjects];
[[self shareInstance].currentView removeFromSuperview];
[[self shareInstance] setCurrentView:nil];
[self removeNotification];
}
當(dāng)頁面離開時(shí),為避免忘記手動(dòng)刪除彈窗,window展示在其他地方,此頁面的彈窗也應(yīng)該自動(dòng)刪除,這里在問題四里面已經(jīng)得到解決,在頁面離開時(shí)自動(dòng)移除彈窗。
父控制器問題:
因?yàn)檫@里采用的是 -viewWillAppear 和 -viewWillDisappear 的方式來觀察頁面的變化(如果有其他方法望告之),如果有彈窗是在父控制器里觸發(fā)的,那么頁面的變化可能是在子控制器里進(jìn)行頁面切換變化的,這時(shí)候父控制器里的彈框可能就不會(huì)準(zhǔn)確。這里父控制器只是一個(gè)容器,真正呈現(xiàn)頁面元素的是子控制器,所以對于父控制器里的彈框,他的頁面歸屬,應(yīng)該是呈現(xiàn)頁面的子控制器。 對父控制器里的彈框指定多個(gè)歸屬頁面,這樣就能實(shí)現(xiàn)父控制里的彈框可以精準(zhǔn)的在部分子控制器顯示,或者不顯示,讓彈框可以指定多個(gè)頁面。
page = @[class1, class2, class3];
iOS13問題:
iOS13的present默認(rèn)是非全屏的展示,present之后頁面并不會(huì)走viewWillDisappear方法,導(dǎo)致彈窗不會(huì)自動(dòng)移除。 這種情況需要手動(dòng)去移除彈窗或者走iOS13以前的present方式。
動(dòng)畫:
因?yàn)樽罱K彈窗添加到window上或者移除都是在manager里處理的,有些情況可能彈窗的出現(xiàn)和移除需要?jiǎng)赢嬤M(jìn)行修飾,而等待隊(duì)列里的彈窗就無法知道具體的動(dòng)畫。這種情況,可以添加一個(gè)block來告訴外界該彈窗剛剛被添加到window上,你可自行處理自己的動(dòng)畫操作
[LYWindowScreenView addWindowScreenView:self.label2 page:self.class level:LYLevelLow keepAlive:YES addCompleted:^{
self.label2.frame = CGRectMake(50, 700, CGRectGetWidth(self.view.frame)-100, CGRectGetHeight(self.view.frame)-270);
[UIView animateWithDuration:0.3 animations:^{
self.label2.frame = CGRectMake(50, 250, CGRectGetWidth(self.view.frame)-100, CGRectGetHeight(self.view.frame)-270);
}];
}];
總結(jié)
到此,一開始提出的6個(gè)window彈窗問題都已得到解決,實(shí)現(xiàn)思路比較簡單,主要通過隊(duì)列和監(jiān)聽頁面變化來處理指定頁面和順序的問題。由于keywindow的不確定性,這里的彈框都是統(tǒng)一添加到appdelegate.window上。
大致效果:
