iOS window彈窗管理

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效果對比-前:


t6znx-4jxkk.gif

問題4效果對比-后:


tugcl-llxv1.gif

問題分析:

一、多個(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未知是否釋放 【不確定】
1.png
2.png



六、自動(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上。

大致效果:


tu690-6ylp0.gif

github:
https://github.com/LYKit/LYWindowScreenView.git

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

友情鏈接更多精彩內(nèi)容