iOS土味兒講義(二)–彈窗的前世今生

Mr_WEI發表於2019-02-28

彈窗的前世今生

iOS土味兒講義(二)–彈窗的前世今生

Here`s a tip…and a spear behind it.–趙信

這是我的土味iOS講義的第二篇,完整專案的github地址:
土味iOS講義
整個系列龜速更新中,覺得有意思的請點下 Star,有疑問或者任何想法和建議歡迎提 Issues
另外,上一篇的作業有人做嗎?

開始之前先對上一篇《一個Button引發的血案》的一些疑問做一些總結說明。

  • 這整個一系列的文章裡面,所有的需求都是我編的,可能有人在自己過往的專案中遇到過類似的,也有可能沒遇到過甚至永遠不會用到。你可能覺得需求很荒謬,但請不要在意這些細節,因為原本文章的目的就不是來講解如何完成某一個需求的。
  • 很多人都知道KVO是基於runtime實現的,甚至有人能夠自己動手實現。但是這個知識點你學完了之後除了能用他來實現KVO之外還能做什麼嗎?恐怕大部分的人都一臉問號。如果你看完上一篇文章,能把問號變成“臥槽!還有這種操作???”,那我的目的就達到了。
  • 實名反對runtime什麼的,在你把runtime的原始碼都看一遍之後再說會更有說服力。

開始說說彈窗

當我們每次接到一個不知道怎麼去實現的需求的時候,仿照系統原生的寫法是最好的解決方案,所以聊起彈窗的話,UIAlertview這個控制元件是怎麼都避不開的話題,因為沒有哪一個彈窗的設計比UIAlertView更經典了,想寫好一個優秀的自定義彈窗,那麼抄他準沒錯。
但是任何設計的優秀性都是具有時效的,蘋果對於Alert的實現方案也不是一成不變的。

UIAlertView的前世今生

在遠古到上古時代(iOS 7以前),UIAlertView是通過在螢幕上加了一層Alertwindow, 然後將AlertView的檢視加在了這個Window上,在很多頁面跳轉的總結文章或者第三方框架中都是使用這樣的方法。類似於以下的程式碼你肯定見過:

- (void)showAlertView:(UIView*)view{
    _window = [[UIWindow alloc]initWithFrame:[UIScreen mainScreen].bounds];
    _window.windowLevel = UIWindowLevelAlert;
    _window.backgroundColor = [UIColor colorWithRed:0 green:0 blue:0 alpha:0.6];
    [_window addSubview:view];
    _window.hidden = NO;
    [_window makeKeyAndVisible];
}
複製程式碼

當然還有變種的ViewController版本:

- (void)showAlertViewController:(UIViewController*)viewController{
    _window = [[UIWindow alloc]initWithFrame:[UIScreen mainScreen].bounds];
    _window.windowLevel = UIWindowLevelAlert;
    _window.backgroundColor = [UIColor colorWithRed:0 green:0 blue:0 alpha:0.6];
    _window.rootViewController = viewController;
    _window.hidden = NO;
    [_window makeKeyAndVisible];
}
複製程式碼

很好很強大的思路,在大家做個頁面跳轉都一臉懵逼無腦Push的上古時代,這絕對是頁面彈窗的最佳實現無疑了,因為蘋果自己就是這麼寫的。
上古時代的騰訊QQ第三方登入SDK,在你手機內沒有安裝QQ的時候,會從左邊滑出一個網頁的QQ登入頁面,其實也是這種建立window的做法。(如果不是,請指正。)

到了中古時代(iOS 8 以前),蘋果已經不推薦我們自己建立新的window來實現彈窗功能了,或許是因為濫用window導致了一些他不想看到的後果?誰知道呢!反正在整個中古時代,UIAlertView是直接貼在當前的keywindow上的,不再建立新的window來放置彈窗,從這個時候開始,window的隱藏和顯示,還有關於windowLevel的一切,我們可以不用再過於關心了。

及至近古時代(iOS 8之後),UIAlertview以及他的小兄弟UIActionSheet在經歷了漫長的歲月之後,終於壽終正寢。UIAlertController橫空出世,取而代之。從這個時候開始,蘋果對彈窗的定義終於從一個view層級上升到了controller層級,在減少對window層級的暴力操作的同時,增強了對彈窗整個生命週期的把控。

為什麼要說歷史?

可能很多人都不是很在意這方面的問題,因為他看起來好像沒有什麼用處,但是我還是覺得了解這些東西是有必要的,至少如果說一個比較晚入門的iOS開發者,可能沒有見過那些老式的寫法,也從來沒有直接操作過window層級的東西,在見到相關的程式碼的時候,能夠有一個正確的判斷,知道他是已經過時的東西。

最起碼我覺得不要以後有一天,會有人發一篇文章說:臥槽現在的iOS面試者,讓他寫一個自定義彈窗他還要建立一個Window,這種寫法現在還有人在用我也是服了!阮一峰老師已經被黑的夠慘了,所以最好不要留給別人以後鞭屍我們的機會。

做一個普通的彈窗

現在已經很少有公司還去寫相容iOS 7系統的程式碼了,在蘋果官方的統計資料是這樣的:

iOS土味兒講義(二)–彈窗的前世今生

使用iOS 9以及更低版本的使用者只有5%,這裡面如果再去除iOS 8 和iOS 9 的使用率,留給 iOS 7的份額還有多少我不太好預測,但是趨勢明眼人都能看得出來,如果你的公司還要一意孤行的讓你去相容iOS 7的話,甩出這張圖去跟他撕吧。

當然其實我更傾向於直接基於iOS 9系統去做開發,那麼蘋方字型就不用相容了,但是目前還沒能成功的說服某些人。

所以那麼顯而易見的,既然至少基於iOS 8來開發,那麼彈窗就應該使用UIAlertController的思路來實現咯,根本就不需要採用第二種辦法,跟著蘋果的方向走肯定是沒有錯的,那麼我們開始吧!

  1. 首先當然還是要來看看,如果我們想在iOS 8以上的系統裡面使用UIAlertController是怎麼樣的,一般的寫法像下面這樣:
UIAlertController *alertController = [UIAlertController alertControllerWithTitle:@"彈窗" message:@"訊息" preferredStyle:UIAlertControllerStyleAlert];
    UIAlertAction *action1 = [UIAlertAction actionWithTitle:@"はい" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
    }];
    [alertController addAction:action1];
    UIAlertAction *action2 = [UIAlertAction actionWithTitle:@"いいえ" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
    }];
    [alertController addAction:action2];
    [self presentViewController:alertController animated:YES completion:nil];
複製程式碼

我們拋開UIAlertAction相關的程式碼看一看,emmm這TM不就是建立了一個ViewController,然後present出來了嗎?我也可以啊!
所以先寫一個ViewController,長什麼樣子無所謂,看起來像個彈窗就行,彈窗周邊應該是透明的,很自然的把self.view的背景色設定為透明,然後隨便寫個觸發事件把他present出來就行了。但是當我們做完這一步的時候,好像發生了什麼奇奇怪怪的事情:

iOS土味兒講義(二)–彈窗的前世今生

彈出來的視窗整個黑掉了,說明我們之前某些地方是想錯了的,present出來的viewController表面上看起來是蓋在了原有介面的上面,但那其實只是個動畫,最終實際上是替換掉了,所以設定透明以後會導致下面沒有東西所以就變成黑色了。

所以我們需要重寫viewController的初始化方法,
新增self.modalPresentationStyle = UIModalPresentationCustom;
改變一下present方式,就可以用覆蓋的方式來present新的介面了。

至於說彈出視窗的方式,那就需要自定義轉場動畫了,無論你想要左彈,右彈,飛入還是溶解都不是問題,這些地方我都不想說太多,具體可以參考一下我很久之前寫過的仿UIAlertController實現

這些內容其實幾年前就有人寫過了,所以不太想再貼一堆系統屬性,列舉等等一系列程式碼,講解每一個屬性有什麼效果,感覺意義不大,一筆帶過就好,最後總結一下自己的看法:

  • 就像前面所說的一樣,蘋果選擇推出UIAlertController除了避免直接操作UIWindow之外,更加強了對整個彈窗的生命週期的把控,這句話可以琢磨琢磨。
  • 彈窗是全屏的,雖然看起來好像有用的只有中間那一點點,但這種實現方式本身就已經把整個螢幕的操作空間都預留出來了,你可以盡情發揮。
  • 那種左滑半個螢幕,側彈一個選項卡的操作其實殊途同歸,一切的不同只不過就在於轉場動畫和彈窗的UI設計罷了,本質上是一樣的。
  • 彈窗繼承自UIViewController但是並不僅限於此,可以帶Navgation可以帶Tabbar,一切任你想象。比方說要做一個登入視窗,視窗還要支援跳轉輸入驗證碼?如果使用UIView來做的話,是不是想一想都覺得很恐怖?
  • 個人不太推薦現在仍然還在自行建立Window甚至在某個單例下面常年掛著一個Window的寫法,但是向當前KeyWindow上addSubView這種寫法其實在某些情況下也是可以小而美的,可以根據情況選擇大可不必矯枉過正。

普通的彈窗講完了,那麼如何讓彈窗也能實現自己的窗生價值,變得不普通呢?

如果彈窗也有夢想

很顯然,能夠讓使用者看到是彈窗實現自己窗生價值的唯一途徑,但是很遺憾,一個APP裡面彈窗有很多,位置卻只有一個,所以排除所有其他彈窗所造成的干擾,將自己顯示在使用者的面前,我稱之為彈窗的夢想。

在正常的情況下,一個優秀的設計是能夠讓所有的彈窗在互不影響的情況下,實現各自的價值的。但是理想與現實總是有差距的,在我們的APP裡面除了像輸入框,登入窗那樣的彈窗之外還有一種比較特別的,我稱之為觸發式彈窗。這些彈窗往往是不可控的,我們不知道他什麼時候會彈出來,這種彈窗彈出的時機往往取決於外部:後臺伺服器訊息傳送,手機訊號強弱變化,電量變化等等。

總之生活中就是有這麼多的巧合,然而不論是巧合也好,單純的設計缺陷也罷,都不是我們置之不理的理由,終究還是需要一一應對,那麼現在來看看造成這種現象的原因吧!

首先我要把鍋甩給UIAlertController了,因為自從他出現開始,才有這種問題的,或者說不是UIAlertController的問題,是presentViewController這種實現方式的侷限性,而這種侷限性在UIAlertView時代是被規避了的。

有興趣的可以去做一個實驗,寫一個方法連續show兩個不同的UIAlertView,其中一個會在另一個dissmiss之後自動顯示出來,而如果你連續present兩個UIAlertController,那麼第二次的present其實是無效的,控制檯會輸出類似於這樣的Warning: Attempt to present <UIAlertController: 0x7fb07582ce00> on <ViewController: 0x7fb074608450> which is already presenting <UIAlertController: 0x7fb075803000>,那麼這個彈窗就相當於被吃掉了。

不同風格的夢想導師

如果說彈窗是有夢想的,那麼我們程式設計師自然就是他們的夢想導師,為他們在追尋夢想的道路上保駕護航。導師有兩種,一種比較保守,我稱之為“右派”;一種比較激進,我稱之為“左派”。簡單的說來,“右派”導師喜歡排隊,”左派”導師喜歡插隊,兩種風格需要根據不同的設計需求來選擇。

所以,首先你要有一個隊。

“右派”

既然說到需要有一個隊,那麼我們實際操作起來第一件事情肯定是要先建立一個佇列,而且這個佇列應該是全域性的,單例的,整個APP所有的彈窗都需要通過這個佇列來管理,那麼既然presentViewController是ViewController的工作,我選擇建立一個ViewController的類別,新增這樣的方法:

- (NSOperationQueue *)getOperationQueue {
    
    static NSOperationQueue *operationQueue = nil;
    
    static dispatch_once_t onceToken;
    
    dispatch_once(&onceToken, ^{
        
        operationQueue = [NSOperationQueue new];
        
    });
    
    return operationQueue;
}

複製程式碼

以上程式碼新增一個方法獲取一個單例的佇列,用來存放所有的彈窗操作,想要使用佇列管理所有彈窗行為,就要使用operation將彈窗操作包裹起來,並設定操作依賴,讓其中一個彈窗完成之後,才允許新的彈窗彈出,建立一個自定義的presentViewController方法來實現以上需求:

- (void)xxx_presentViewController:(UIViewController *)controller completion:(void (^)(void))completion{
    NSBlockOperation *operation = [NSBlockOperation blockOperationWithBlock:^{
        dispatch_async(dispatch_get_main_queue(), ^{ 
            [self presentViewController:controller animated:YES completion:completion];
        });
    }];
    if ([self getOperationQueue].operations.lastObject) {
        [operation addDependency:[self getOperationQueue].operations.lastObject];
    }
    [[self getOperationQueue] addOperation:operation];  
}

複製程式碼

這樣我們就將彈窗操作排列了起來,但彈窗並不是一個子執行緒的耗時操作,真正的彈窗動作最終還是要轉到主執行緒來做,整個operation其實在彈窗彈出來的那個瞬間就已經結束了,所以我們應該想一個方法將佇列執行緒阻塞住,就好像將彈窗的位置當做一個公共資源來訪問,只有在當前彈窗位置沒有視窗的時候才允許彈窗,這裡我選擇使用dispatch_semaphore_t(訊號量)。

將上面的程式碼變化一下:

    dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
    
    NSBlockOperation *operation = [NSBlockOperation blockOperationWithBlock:^{
        
        dispatch_async(dispatch_get_main_queue(), ^{
            
            [self presentViewController:controller animated:YES completion:completion];
            
        });
        
        dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
        
    }];
    if ([self getOperationQueue].operations.lastObject) {
        
        [operation addDependency:[self getOperationQueue].operations.lastObject];
        
    }
    
    [[self getOperationQueue] addOperation:operation];

複製程式碼

建立一個初始值為0的訊號量semaphore,在彈窗彈出之後呼叫dispatch_semaphore_wait會一直阻塞執行緒使得下一個彈窗操作不會被執行,剩下的只需要在彈窗消失的時候呼叫dispatch_semaphore_signal將訊號量+1就好了。

所以我又要使用runtime了:

- (void)setDisappearCompletion:(void (^)(void))completion {
    objc_setAssociatedObject(self, @selector(getDisappearCompletion), completion, OBJC_ASSOCIATION_COPY_NONATOMIC);
    
}
- (void (^)(void))getDisappearCompletion {
    
    return objc_getAssociatedObject(self, _cmd);
    
}
+ (void)load {
    
    SEL oldSel = @selector(viewDidDisappear:);
    
    SEL newSel = @selector(xxx_viewDidDisappear:);
    
    Method oldMethod = class_getInstanceMethod([self class], oldSel);
    
    Method newMethod = class_getInstanceMethod([self class], newSel);
    
    
    
    BOOL didAddMethod = class_addMethod(self, oldSel, method_getImplementation(newMethod), method_getTypeEncoding(newMethod));
    
    if (didAddMethod) {
        
        class_replaceMethod(self, newSel, method_getImplementation(oldMethod), method_getTypeEncoding(oldMethod));
        
    } else {
        
        method_exchangeImplementations(oldMethod, newMethod);
        
    }
    
}
- (void)xxx_viewDidDisappear:(BOOL)animated {
    
    [self xxx_viewDidDisappear:animated];

    if ([self getDisappearCompletion]) {
        [self getDisappearCompletion]();
        
    }
    
}

複製程式碼

為UIViewController建立一個block屬性,然後hook一下viewDidDisappear方法,讓ViewController在消失的時候執行一下回撥,最後我們在彈窗操作的operation中設定一下block的內容:

[controller setDisappearCompletion:^{
            dispatch_semaphore_signal(semaphore);
        }];

複製程式碼

在程式碼中將訊號量+1,使得整個執行緒由阻塞態變為就緒態,迎接下一個彈窗操作的到來。

這樣,“右派”導師所有的特點都被我們確定完畢了,裡面使用到了GCD和runtime中
的Method Swizzling,和上一篇文章中的不同。當然
Method Swizzling我也是隨手就寫了一個,不要太過於在意這是不是最安全的寫法,不過我對於在訊號量中是否使用DISPATCH_TIME_FOREVER尚存疑慮,如果你們誰有比較完整的觀點,告訴我好嗎?

結語

還是我的一貫作風,“右派”的寫法我講完了,“左派”的風格其實大同小異,有人有興趣實現一下嗎?還是像上一篇文章一樣,當做一個作業吧,如果有任何想法,歡迎隨時來交流喲。

相關文章