App里總會有很多的彈窗,為了美觀,大多數彈窗都需要蓋住導航欄;這時彈窗會添加到window上以滿足需求。但添加到window上的彈窗卻不方便管理,也與頁面脫離關系,如果有異步的情況,彈窗會更加復雜難以處理,如何才能對window彈窗統一進行管理,解決這些問題?
window彈窗面臨的問題:
- 為了統一管理彈框,首先需要對window彈窗可能會出現的問題進行梳理,避免以后在維護彈窗時出現問題,到時候再進行兼容和修改會比較麻煩。這里列舉了一些常見的問題,如果有未考慮到的情況,可以在評論里補充。
1、多個彈窗可能會產生重疊:如app啟動的時候有2個彈窗,正巧2個彈窗都觸發展示,這時候這2個彈窗就會重疊在一起。
2、彈窗無法與頁面關聯:如登錄后有個彈框需要在tab2頁顯示,但是啟動后首屏頁為tab1,這時候彈框就不能顯示,當tab2出現時才顯示。
3、彈窗無法設置優先級:一個比較簡單的例子:有多個彈層的新手引導,用戶關閉引導頁1時,按順序呈現引導頁2、引導頁3, 如果中間有其他彈窗出現的邏輯,應該等待引導頁結束再展示。
4、彈窗無法留活:一個簡單的例子:一個活動彈窗含有2個活動,點擊活動A進入詳情頁,此時window彈窗應該消失,當從詳情頁返回時,活動彈窗應該繼續展示,才能點擊進入活動B查看詳情。為了避免重新觸發彈窗的邏輯,應該對彈窗進行緩存。
5、異步彈窗處理復雜:例如網絡請求彈窗的數據,彈窗的展示因此延時,用戶在此期間跳轉其他頁面,或者當前頁面已經返回,因為是window彈窗,彈窗則不應該顯示出來。
6、彈窗不能自動關閉:例如用戶被迫下線,此時app的所有彈窗都應該自動移除,或者彈窗展示情況下app發生頁面跳轉,避免彈窗忘記關閉的情況,也應該自動移除現有的彈窗。
問題4效果對比-前:
問題4效果對比-后:
問題分析:
一、多個彈框重疊沖突: 這個問題比較好解決,簡單的做法是使用信號量來限制當前彈窗的數量,讓彈窗一個一個的出現。創建一個彈窗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];
});
});
}
??但是使用信號量來處理彈窗展示的數量,這種方式只能滿足讓彈窗一個個出現,沒辦法刪除或者變更未展示的彈框,是不方便對彈窗進行管理的。
??這時候使用隊列是一個比較好的選擇,在show的時候把彈窗添加進隊列中, dismiss的時候從隊列里移除,當上一個彈窗dimiss,從隊列里選出下一個要展示的,這樣也能做到彈窗始終只會有一個正在展示,而未展示的彈窗則在隊列中等待展示。
+ (void)showView:(UIView *)view {
if ([self shareInstance].currentView == nil) {
// 當前無彈窗展示直接展示
UIWindow *window = [UIApplication sharedApplication].delegate.window;
[window addSubview:view];
} else {
// 當前有彈窗則加入隊列中
LYWindowScreenModel *model = [LYWindowScreenModel new];
model.view = view;
[[self shareInstance].arrayWaitViews addObject:model];
}
}
+ (void)dismiss:(UIView *)view {
if ([self shareInstance].currentView == view) {
// 刪除當前彈窗
[view removeFromSuperview];
[[self shareInstance] setCurrentView:nil];
} else {
// 刪除隊列中的彈窗
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];
}
}
}
// 展示下一個彈窗
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];
}
}
}
}
二、彈窗無法與頁面關聯: 彈窗要在指定的頁面顯示, 因為之前已經有了彈窗隊列,此時應該把彈窗添加到隊列中去等待展示,但是此時隊列里的彈窗并沒有頁面限制,即使放進隊列里也會在其他頁面出現。 所以需要對每個彈窗指定一個展示的頁面, 當從隊列里推出彈窗進行展示時,判斷當前頁面是否為可展示的頁面,如果不是則繼續在隊列里等待。
+ (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];
}
}
??給彈窗指定頁面后,這時候需要對當前頁面的變化進行監聽,當指定頁面出現時彈窗應該及時呈現出來。這里的做法是hook UIViewController的viewWillAppear方法,在頁面變化時發送通知,告訴manager頁面發生變化,檢索隊列里是否有此頁面等待展示的彈窗。
??考慮到重寫viewWillAppear方法后,每次頁面變化都會發送通知,可能會帶來一定的性能問題, 所以manager只有在隊列里有等待的彈窗時才注冊通知,無等待的彈窗則不需要知道頁面的變化,這時候可以移除通知。(如果有更好的監聽頁面變化的方法望告之)
+ (void)viewWillAppearNotification:(NSNotification *)notification {
id identifier = notification.object[LYViewControllerClassIdentifier];
[self viewNeedShowFromQueueWithPage:identifier];
}
+ (void)viewNeedShowFromQueueWithPage:(UIViewController *)page {
// 當前屏幕有彈框,則不顯示
if ([self shareInstance].currentView) {
return;
}
// 隊列里無等待顯示的視圖
if (![self shareInstance].arrayWaitViews.count) {
return;
}
// 推出隊列中需要展示的視圖進行展示
__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];
}
}
三、設置彈窗優先級:因為現在有了彈窗等待隊列,彈窗的優先級也就可以很好的解決,在添加進隊列時,給彈窗設置一個level值,根據level值排序后從隊列里推出展示的彈窗自然是優先級比較高的彈窗。
??因為有時候無法確認其他彈框的level值,level的設定建議以場景來設置level,因為同一場景的多個彈窗大部分情況下無需按優先級展示,同等level能按先后順序展示即可。
如:
typedef enum : NSUInteger {
LYLevelHigh = -100, // 優先級最高, 場景如開屏動畫
LYLevelMedium = -1, // 優先級高, 場景如啟動完成廣告彈窗
LYLevelDefault = 0, // 優先級一般,場景如新手引導
LYLevelLow = 100, // 優先級低,場景如常用彈窗
} LYWindowScreenLevel;
如:開屏動畫 > 廣告 > 引導 > 業務彈窗,這樣可以滿足app內絕大多數的彈窗展示順序,如果有變動可再改動自己修改值。
四、彈窗無法留活:還是之前拋出的問題,點擊活動A進入詳情頁,此時window彈窗應該消失,當從詳情頁返回時,活動彈窗應該繼續展示。為了避免再一次執行彈窗的展示邏輯,所以需要對當前的彈窗進行緩存,等待頁面重新回來時展示。 這種情況只是頁面暫時離開,頁面并未從頁面路徑棧里消失,如果頁面已經不存在,那么緩存里的彈窗也應該移除。
??新建一個彈框的緩存數組,這里并沒有放入之前等待隊列里, 是因為等待隊列里的彈窗都是仍未展示的,無論頁面是否新建(根據class),當這個頁面是彈窗指定的歸屬類時都可以展示出來。而緩存的彈窗是與具體的頁面關聯的(根據obj),如果頁面返回再重新進入,頁面已經重新構造,上次緩存的彈窗是不應該再展示的,因為頁面重新構造后可能會重新觸發彈窗的邏輯,這時候可能就會2個相同的彈窗,所以這里用了2個隊列存儲彈框。
具體實現是:
- hook UIViewController的viewWillDisappear方法,當有需要緩存的彈窗時添加監聽,當頁面離開時移除當前展示的彈框,并且當前彈窗添加進緩存隊列里。
+ (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;
// 添加進緩存數組
[[self shareInstance].arrayAliveViews addObject:model];
[self addWaitShowNotification];
}
[[self shareInstance].currentView removeFromSuperview];
[[self shareInstance] setCurrentView:nil];
[self removeNotification];
}
}
-
如果這個頁面已經銷毀,那么這個彈框也沒有意義再緩存,當頁面已經離開時,判斷緩存隊列里的彈窗所指定的緩存頁面是否存在,如果已經銷毀則刪除彈窗。
如何判斷頁面已經不存在?如果只是判斷Controller是否為空,這顯然是不行的,因為即使頁面返回,Controller可能仍被其他類持有。所以仍然需要判斷頁面是否還在某個頁面路徑棧里, 而OC中帶有頁面容器的情況有,UINavigationController、presentedViewController、UITabBarController、UISplitViewController、Sub-Controller。tabBar 和 Split 基本上都會包含一次navigation,而subController也會根據父級的路徑棧一同消失。 所以頁面可以通過Controller是否還有navigationController或者presentingViewController來判斷當前頁面是否已經從頁面棧里移除。
// 如果存活彈框的歸屬頁面已移除,則移除該頁面的所有彈框
+ (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];
}
}
}
}
- 當監聽到頁面返回到原來頁面時彈窗應該重新出現,彈窗從緩存隊列里刪除,并且添加進等待隊列里,等待顯示。
+ (void)viewNeedShowFromQueueWithPage:(UIViewController *)page {
// 判斷當前頁是否有存活的彈框,有則加入隊列中。
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];
}
}
}
五、異步彈窗情況:異步彈框的情況稍微復雜,基本上都會跟網絡請求扯上聯系,如果網絡請求未完成的情況下,頻繁的“進入-返回”,這可能會出現多個彈窗的網絡請求同時在請求,這時候會產生3個問題:
- 將會有多個相同彈窗出現(如果未對彈窗限制次數)
- 彈窗可能不是最后一次請求想要的彈窗。
- 彈窗可能無法響應點擊事件。
想要統一支持各種業務場景的彈框,異步的問題就需要解決,為了更清晰的理解各種異步場景彈窗的展示邏輯,這里列出了所有異步場景的情況:
- A—B, B—A ,A—B2, 彈窗延遲加載時在本類, 但是實例發生變換 【不緩存】
- A—B, B—A ,A—B, 彈窗延遲加載時在本類, 實例未發生變換。 【緩存】
- A—B, B—C ,C—B, 彈窗延遲加載時不在本類,B還在頁面棧。【緩存】
- A—B, B—C ,C—A, 彈窗延遲加載時不在本類,B不在頁面棧。【移除緩存】
- A—B, B—A , 彈窗延遲加載時不在本類。 B未知是否釋放 【不確定】
六、自動刪除彈窗:有些app需要登錄之后才能展示彈窗,如果用戶下線或者被踢,這個用戶的彈窗都應該移除。 因為有了隊列,當用戶下線時移除當前展示的彈窗和隊列里等待彈窗就可以統一移除manager管理的所有彈窗。
+ (void)removeAllQueueViews {
[[self shareInstance].arrayAliveViews removeAllObjects];
[[self shareInstance].arrayWaitViews removeAllObjects];
[[self shareInstance].currentView removeFromSuperview];
[[self shareInstance] setCurrentView:nil];
[self removeNotification];
}
當頁面離開時,為避免忘記手動刪除彈窗,window展示在其他地方,此頁面的彈窗也應該自動刪除,這里在問題四里面已經得到解決,在頁面離開時自動移除彈窗。
父控制器問題:
因為這里采用的是 -viewWillAppear 和 -viewWillDisappear 的方式來觀察頁面的變化(如果有其他方法望告之),如果有彈窗是在父控制器里觸發的,那么頁面的變化可能是在子控制器里進行頁面切換變化的,這時候父控制器里的彈框可能就不會準確。這里父控制器只是一個容器,真正呈現頁面元素的是子控制器,所以對于父控制器里的彈框,他的頁面歸屬,應該是呈現頁面的子控制器。 對父控制器里的彈框指定多個歸屬頁面,這樣就能實現父控制里的彈框可以精準的在部分子控制器顯示,或者不顯示,讓彈框可以指定多個頁面。
page = @[class1, class2, class3];
iOS13問題:
iOS13的present默認是非全屏的展示,present之后頁面并不會走viewWillDisappear方法,導致彈窗不會自動移除。 這種情況需要手動去移除彈窗或者走iOS13以前的present方式。
動畫:
因為最終彈窗添加到window上或者移除都是在manager里處理的,有些情況可能彈窗的出現和移除需要動畫進行修飾,而等待隊列里的彈窗就無法知道具體的動畫。這種情況,可以添加一個block來告訴外界該彈窗剛剛被添加到window上,你可自行處理自己的動畫操作
[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);
}];
}];
總結
到此,一開始提出的6個window彈窗問題都已得到解決,實現思路比較簡單,主要通過隊列和監聽頁面變化來處理指定頁面和順序的問題。由于keywindow的不確定性,這里的彈框都是統一添加到appdelegate.window上。
大致效果: