背景

在平时的开发中,会有很多弹窗的需求,尤其是在首页(版本升级,运营活动)等等

但是很多时候都是由不同的童鞋开发,而且基本都是依赖于网络接口,所以先后顺序都是无法保证

这就导致一个后果,弹窗泛滥成灾

另外我们可以发现,系统自带的AlertView是有队列管理的,总是消失了一个弹窗以后,再显示下一个弹窗

于是便有了这个Popup队列管理中心

雏形

在开发前,我们可以大概的设想一下这个的设计雏形

有一个Popup管理类,由这个类维护一个数组,外部调用该类的API将弹窗添加到这个数组中

外部在消失弹窗的时候,调用该类的API将该弹窗从这个数组中移除

最后从队列中取出下一次弹窗进行展示

以上方式有几个弊端

  1. 接入方式过于复杂

  2. 使用者需要关心的太多了,如果哪个童鞋在消失自己的弹窗的时候,忘记了从队列中移除,并且没有从队列中取出下一个弹窗进行展示,那队列中的其他弹窗都将永不见天日

原理

为了解决以上问题,该项目运用了几个Objective-C底层的原理,下面将一一剖析

为了不让使用者在消失自己的弹窗后,需要手动从队列中移除,并且唤醒下一个弹窗

最开始我想到的是定时任务,但是后面觉得定时任务不是很优雅,后面选择了监听Runloop运行状态变化

Runloop

- (instancetype)init {
    self = [super init];
    if (self) {
				//创建一个以和屏幕刷新率相同的频率的定时器
        _displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(wakeup)];
        [_displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
        dispatch_async(dispatch_get_main_queue(), ^{
          //添加Runloop监听者
            [self addRunloopObserver];
        });
    }
    return self;
}

- (void)addRunloopObserver {
  //获取当前runloop
    CFRunLoopRef runloop = CFRunLoopGetCurrent();
  //创建context
    CFRunLoopObserverContext context = {0, (__bridge void *)self, &CFRetain, &CFRelease, NULL};
  //创建runloop监听者
    static CFRunLoopObserverRef defaultModeObserver;
  /*其中runloop的状态有如下几种,这里监听的是kCFRunLoopBeforeWaiting
  	kCFRunLoopEntry = (1UL << 0),
    kCFRunLoopBeforeTimers = (1UL << 1),
    kCFRunLoopBeforeSources = (1UL << 2),
    kCFRunLoopBeforeWaiting = (1UL << 5),
    kCFRunLoopAfterWaiting = (1UL << 6),
    kCFRunLoopExit = (1UL << 7),
    kCFRunLoopAllActivities = 0x0FFFFFFFU
  */
    defaultModeObserver = CFRunLoopObserverCreate(kCFAllocatorDefault, kCFRunLoopBeforeWaiting, YES, 0, &callback, &context);
  //为当前runloop添加runloop监听者
    CFRunLoopAddObserver(runloop, defaultModeObserver, kCFRunLoopCommonModes);
    CFRelease(defaultModeObserver);
}

void callback(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) {
    //Runloop 回调,在这里面可以管理Popup队列
}

截止,就完成了利用Runloop取代定时任务,对Popup队列进行定时管理

KVO

为了简化使用者调用,不需要使用者自己将类添加到队列中

一开始想着定义一个show的接口方法,然后添加NSObject的分类,在load里面通过hook的方式,动态替换show成swizzle方法,在swizzle方法里面将popup添加进队列

想法是挺美好的,但是现实是残酷的,因为hook是不能影响到子类的

后面苦思冥想,想有什么办法通过监听方法是否有被调用

最终醍醐灌顶,参照了系统的KVO原理,通过创建派生类方式,再利用runtime,hook原有的show方法

- (void)ssp_registerToPopupCenter {
    BOOL flag = [self conformsToProtocol:@protocol(SSPPopupProtocol)];
    NSString *msg = [NSString stringWithFormat:@"%@ should conform SSPPopupProtocol", NSStringFromClass([self class])];
    NSAssert(flag, msg);
    
  //创建派生类
    NSString * className = [NSString stringWithFormat:@"SSPPopupCenter_%@",NSStringFromClass(self.class)];
    const char *cla = className.UTF8String;
    Class subP = objc_allocateClassPair([self class], cla, 0);
    
    if (nil != subP && ![subP isKindOfClass:[self class]]) {
      //替换原有的show的方法,该方法是通过协议约束的
        [self swizzleClass:subP method:@selector(ssp_show) swizzledSelector:@selector(ssp_swizzle_show)];
        
      //如果实现了hide的方法,同样也替换
        if (nil != class_getInstanceMethod([self class], @selector(ssp_hide))) {
            [self swizzleClass:subP method:@selector(ssp_hide) swizzledSelector:@selector(ssp_swizzle_hide)];
        }
        
        [self addClass:[self class] method:@selector(ssp_level)];
        [self addClass:[self class] method:@selector(setSsp_level:)];
        
      //注册派生类
        objc_registerClassPair(subP);
    } else {
      //获取派生类
        subP = objc_getClass(cla);
    }
  //修改对象的isa指针为派生类
    object_setClass(self, subP);
}

Runtime

以下是hook的普遍方式,但大多数人都是直接拷贝,拷贝的版本也是多样化,因为他们没有真正的看过里面的实现

下面将大概讲解一下

- (void)swizzleClass:(Class)clazz method:(SEL)originalSelector swizzledSelector:(SEL)swizzledSelector {
  //通过selector的名字,搜索该类的方法列表中是否有该方法
    Method originalMethod = class_getInstanceMethod(clazz, originalSelector);
    Method swizzledMethod = class_getInstanceMethod(clazz, swizzledSelector);
    
  //如果两个方法都有,则直接替换两个方法的实现
    if (originalMethod && swizzledMethod) {
        method_exchangeImplementations(originalMethod, swizzledMethod);
        return;
    }
   
  //如果swizzle方法没有,则动态添加,一般如果是利用分类hook,那么一般swizzle方法也是有的
    if (nil == swizzledMethod) {
        BOOL didAddMethod = class_addMethod(clazz,
                                            swizzledSelector,
                                            method_getImplementation(originalMethod),
                                            method_getTypeEncoding(originalMethod));
        if (didAddMethod) {
            swizzledMethod = class_getInstanceMethod(clazz, swizzledSelector);
        }
    }

  //最终将原来的方法具体实现替换成swizzle方法的实现
    if (swizzledMethod) {
        class_replaceMethod(clazz,
                            originalSelector,
                            method_getImplementation(swizzledMethod),
                            method_getTypeEncoding(swizzledMethod));
    }
}

Category

弹窗有时候是有优先级的,该项目里在协议中定义了一个属性ssp_level, 定义如下:

typedef enum : NSUInteger {
    SSPPopupActivityLevelNormal,	//默认优先级
    SSPPopupActivityLevelHigh,		//高优先级,会优先插到前面
} SSPPopupActivityLevel;

大家都知道Category正常是不能添加属性的,要添加属性需要用到Runtime,下面就是具体实现

- (void)ssp_registerToPopupCenter {
    ...
      //添加ssp_level对应的getter,setter方法
        [self addClass:[self class] method:@selector(ssp_level)];
        [self addClass:[self class] method:@selector(setSsp_level:)];
    ...
}

- (void)addClass:(Class)clazz method:(SEL)selector {
    Method method = class_getInstanceMethod(clazz, selector);
    BOOL didAddMethod = class_addMethod(clazz,
                                        selector,
                                        method_getImplementation(method),
                                        method_getTypeEncoding(method));
    NSAssert(didAddMethod, @"add method failure");
}

- (SSPPopupActivityLevel)ssp_level {
    return [objc_getAssociatedObject(self, &kSSPopupCenterLevel) integerValue];
}

- (void)setSsp_level:(SSPPopupActivityLevel)level {
    objc_setAssociatedObject(self, &kSSPopupCenterLevel, @(level), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

至此,就完成了所有的核心功能,剩下的就是对队列的管理,这个无非就是操作一个数组,就不在此展开了

后记

后面可以将系统的AlertView也管理起来,不过现在还没有想好一个比较优雅的方案

源码

具体源码请前往:SSPPopupCenter