iOS土味儿讲义(二)--弹窗的前世今生

4,889 阅读13分钟

弹窗的前世今生

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 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出来就行了。但是当我们做完这一步的时候,好像发生了什么奇奇怪怪的事情:

弹出来的窗口整个黑掉了,说明我们之前某些地方是想错了的,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尚存疑虑,如果你们谁有比较完整的观点,告诉我好吗?

结语

还是我的一贯作风,“右派”的写法我讲完了,“左派”的风格其实大同小异,有人有兴趣实现一下吗?还是像上一篇文章一样,当做一个作业吧,如果有任何想法,欢迎随时来交流哟。