倒计时设计

原文地址html

计算机是不存在倒计时这个概念的,全部的倒计时设计来源于对定时器的运用:给予一个deadline,以秒为时间间隔,在每次回调时刷新屏幕上的数字。倒计时的实现几乎没有门槛,不管NSTimer也好,GCD也罢,甚至使用CADisplayLink都能用来制做一个倒计时方案。但一样的,低门槛也意味着方案的上下限差也很大,本文打算谈谈如何设计一个倒计时方案node

为何要写这篇文章

事实上,倒计时和我目前开发的方向八竿子打不着,我也确实没有必要和想过写这么一套方案。只是这几天有朋友分享了别人设计的倒计时功能:git

采用一个全局计时管理对象针对每个倒计时按钮分配计时器,计时器会生成一个NSOperation对象来执行回调,完成倒计时功能github

在抛开代码不谈的状况下,这套设计思路我也是存疑的。若是倒计时要使用operation,那就须要使用queue来完成任务。根据queue的串行并行属性,要考虑这两点:算法

  • 若是queue是并行的,一个界面上存在多个倒计时按钮时,可能会新建线程来处理同一个queue的任务,这部分的开销并非必需的安全

  • operation须要投放到queue里面启动执行。假如每秒的回调包装成operation处理,那么须要一个定时机制来投放这些operation。若是是这么,为何不直接使用定时器,而要用operation数据结构

但在看完设计者的文章和代码以后,我发现对方根本没有考虑过上面的问题。他operation的任务思路很奇怪:多线程

在每个operation里面,采用while + sleep的方式,每次回调后让线程睡眠一秒,直至倒计时结束app

- (void)main {
    ......
    do {
        callback(self.leftTime);
        [NSThread sleepForTimeInterval: 1];
    } while (--self.leftTime > 0);
    ......
}
复制代码

这种实现有三个坑爹的地方:异步

  1. while循环结束以前,内部的临时变量不会被释放,存在内存占用过大的风险

  2. 若是queue是串行属性,多个operation将没法保证回调时间的正确

  3. 不该该采用sleep方式计时,这很浪费线程的执行效率

另外,应用进入后台时,全部的子线程会被中止执行任务,这个会致使了应用切换先后台后,倒计时剩余时间不许。对于这种状况通常也有三种方式来作时间校订:

  1. 保存一个倒计时deadline,在进入active后从新计算剩余倒计时

  2. 注册通知,在切换先后台时计算时长,减去这个时间更新剩余时间

  3. 建立应用后台任务,继续进行倒计时

而上面的设计采用了3的解决方案,鉴于应用在后台时,用户对应用毫无感知的特色,这几乎是最差的一种方案。因而基于这一个个槽点,我决定按照本身的想法,作一个相对高效的倒计时方案

存储结构

一套功能方案设计的目的是为了简化逻辑流程,隐藏实现细节,尽量少的暴露接口。普通的倒计时是调用方直接使用定时器实现规律性回调,定时器须要持有调用方的信息。而倒计时设计隐藏了定时器的实现细节,只需调用方提供回调,其他的耦合关联由管理类来完成,相似的设计有NotificationObserver

即使是系统使用的两种相似的监听设计,在内部实现时,所用到的存储结构也是不一样的。Notification不持有对象自己,采用保存对象地址的实现,但这样存在野指针风险。Observer会持有对象,但会形成循环引用的风险。能够说:

不一样的考量标准和业务场景决定了不一样的结构设计

回调设计

iOS中经常使用的回调方式包括delegateblock两种,这两种方式都能很好的处理倒计时的需求。但出于如下理由我选择了block

  • delegate的耦合关系强于block

    delegate在委托方和代理方中间添加了一层中间层,解除了双方的直接耦合关系,但一样的委托方和代理方须要依赖于protocol,这是这种模式必然存在的耦合关系。相比之下,block并不存在这种烦恼

  • 更少的代码

    一样的回调处理下,delegate的代码量要多于block。并非说更多的代码必定很差,只是一样符合实现需求,逻辑清晰一样清晰的状况下,后者优于前者:

    #pragma mark - delegate type
      - (void)doSomething {
          /// do something
          if ([_delegate respondsToSelector: @selector(countDownWithLeftTime:)]) {
              [_delegate countDownWithLeftTime: leftTime];
          }
      }
      
      #pragma mark - block type
      - (void)doSomething: (void(^)(NSInteger leftTime))countDown {
          /// do something
          if (countDown) { countDown(leftTime); }
      }
    复制代码

任务停止

倒计时设计应当知足最基本的中止条件:倒计时归零,但除了正常停止以外,老是有提早结束任务的可能性。对外界提供removeXXX格式的接口是一种好的思路,这让代码成对匹配,赏心悦目。但在实际开发中,咱们应当重视一个设计原则:

接口隔离原则:一个类与另外一个类之间的依赖性,应该依赖于尽量小的接口

对外暴露接口须要再三思考,由于一旦提供了额外的接口,这意味着实现方要多处理一个逻辑,调用方须要多调用一次代码。决定是否提供remove接口应该取决于对象依赖,NotificationObserver都会由于保存对象的关键信息致使额外的风险,所以必须提供移除策略

因为咱们已经采用block设计,无需直接依赖对象的某些信息,能够忽略remove接口的选项。而实际上,想要提供停止任务的功能,系统的enum为咱们提供了很好的思路:在回调中提供标记位,一旦标记位被修改,回调完成后停止任务。结合回调设计采用的方案,获得block的结构:

typedef void(^LXDReceiverCallback)(long leftTime, bool *isStop);
复制代码

重复任务

通知中心是去重实现中很典型的bad case:同一次通知的回调次数取决于注册该通知的次数。若是将这种设计放到倒计时中,具体表现为剩余时长以正常倒计时数倍的速率减小。正常场景下很难出现这种问题,但若是在限时抢购的列表界面,多个cell被复用的状况下,很容易出现这种状况

typedef struct hash_entry_t {
    void *entry;
} hash_entry_t;

class LXDBaseHashmap {
public:
    LXDBaseHashmap();
    virtual ~LXDBaseHashmap();
    unsigned int entries_count;
    hash_entry_t *hash_entries;
    
protected:
    unsigned int obj_hash_code(void *obj);
};
复制代码

hash表是一种经常使用的去重算法:一个输入只对应一个输出。为了不对象重复建立倒计时任务,咱们须要找到对象所拥有的一个惟一值做为输入——对象地址,而且以此做为key检测是否存在冲突:

#define hash_bucket_count 7

unsigned int LXDBaseHashmap::obj_hash_code(void *obj) {
    uint64_t *val1 = (uint64_t *)obj;
    uint64_t *val2 = val1 + 1;
    return (unsigned int)(*val1 + *val2) % hash_bucket_count;
}
复制代码

hash表须要考量的另外一个因素是表的长度。一样状况下,表长度越大,越难发生计算冲突,但一样也占用了更多的内存。出于如下缘由,最终桶的长度设定为7

  1. 倒计时场景下出现大量倒计时的可能性低
  2. 因为地址都是双数,桶的个数必须为单数,不然没法计算出单数结果

另外一方面,因为hash表的长度仅为7,出现计算冲突的并不小,咱们还要考虑出现冲突时的处理方案,能够借鉴@synchronized的实现方式来解决这个问题。@synchronized一样基于对象地址进行hash计算,每一个桶存储一张可用锁链表,链表的节点保存对象,用于执行冲突后的再匹配工做:

typedef struct SyncData {
    id object;
    recursive_mutex_t mutex;
    struct SyncData* nextData;
    int threadCount;
} SyncData;

typedef struct SyncList {
    SyncData *data;
    spinlock_t lock;
} SyncList;

#define COUNT 16
#define HASH(obj) ((((uintptr_t)(obj)) >> 5) & (COUNT - 1))
#define LOCK_FOR_OBJ(obj) sDataLists[HASH(obj)].lock
#define LIST_FOR_OBJ(obj) sDataLists[HASH(obj)].data
static SyncList sDataLists[COUNT];
复制代码

所以,仿照这种设计,咱们存储的链表节点除了保存回调block和倒计时长以外,还须要保存调用对象的地址信息,获得结构体:

typedef struct LXDReceiver {
    long lefttime;
    uintptr_t objaddr;
    LXDReceiverCallback callback;
} LXDReceiver;
复制代码

链表性能

因为链表的查找须要遍历链表,效率较低。在倒计时完毕或者任务停止时,移除任务会有没必要要的损耗。采用双向链表的设计,能够获取到删除任务的先后节点,快速的完成删除。另外预留一个链表长度变量,每一个链表提供一个表头来提供长度属性,方便后续扩充:

typedef struct LXDReceiverNode {
    unsigned int count;
    LXDReceiver *receiver;
    LXDReceiverNode *next;
    LXDReceiverNode *previous;
    
    LXDReceiverNode(LXDReceiver *receiver = NULL, LXDReceiverNode *previous = NULL) {
        this->count = 0;
        this->next = NULL;
        this->previous = previous;
        this->receiver = receiver;
    }
} LXDReceiverNode;
复制代码

至此为止,方案的数据结构已经肯定,剩下的就是对倒计时逻辑的封装设计

逻辑封装

定时器方案

经常使用于实现倒计时的定时器有NSTimerGCD两种,处于两点考虑,我选择了后者:

  1. NSTimer须要启动子线程的runloop,另外iOS10+系统必须手动启用一次runloop才能完成定时器的移除

  2. GCD具备更高的效率和精确度

除此以外,定时器的设计通常被分为多定时器设计单定时器设计,两种各有优劣

  • 多定时器设计

    多定时器的设计下,每一个倒计时任务拥有本身的计时器。优势在于能够单独控制每一个任务的回调间隔。缺点是因为多个定时器的屏幕刷新不必定会同步,致使UI更新不一样步等

  • 单定时器设计

    单定时器的设计下,全部倒计时任务使用同一个计时器。优势在于减小了额外的性能损耗,设计结构更清晰。缺点在定时器已经启动的状况下,新任务的首次倒计时可能会有明显的提早以及多个倒计时任务强制使用同一种计时间隔。

考虑到倒计时的的UI同步效果以及更好的性能,我选择单定时器设计方案。另外若是确实存在多个不一样计时间隔的需求,单定时器设计也能够很好的扩充接口提供支持

注册任务

对象在注册倒计时任务时,取对象的地址进行hash计算,根据结果找到存储的对象链表。链表节点存储的objcaddr用来匹配对象,若是匹配失败,那么新建一个任务回调节点。完成插入后,启动定时器:

- (void)registerCountDown: (LXDTimerCallback)countDown
               forSeconds: (NSUInteger)seconds
             withReceiver: (id)receiver {
    if (countDown == nil || seconds <= 0 || receiver == nil) { return; }
    
    lxd_wait(self.lock);
    self.receives->insertReceiver((__bridge void *)receiver, countDown, seconds);
    [self _startupTimer];
    lxd_signal(self.lock);
}

bool LXDReceiverHashmap::insertReceiver(void *obj, LXDReceiverCallback callback, unsigned long lefttime) {
    unsigned int offset = obj_hash_code(obj);
    hash_entry_t *entry = hash_entries + offset;
    LXDReceiverNode *header = (LXDReceiverNode *)entry->entry;
    LXDReceiverNode *node = header->next;
    
    if (node == NULL) {
        LXDReceiver *receiver = create_receiver(obj, callback, lefttime);
        node = new LXDReceiverNode(receiver, header);
        header->next = node;
        header->count++;
        return true;
    }
    
    do {
        if (compare(node, obj) == true) {
            node->receiver->callback = callback;
            node->receiver->lefttime = lefttime;
            return false;
        }
    } while (node->next != NULL && (node = node->next));
    
    if (compare(node, obj) == true) {
        node->receiver->callback = callback;
        node->receiver->lefttime = lefttime;
        return false;
    }
    
    LXDReceiver *receiver = create_receiver(obj, callback, lefttime);
    node->next = new LXDReceiverNode(receiver, node);
    header->count++;
    return true;
}
复制代码

倒计时回调

定时器启动后,会遍历全部的回调链表,而且调起回调处理。若是在本次遍历中发生已经不存在任何倒计时任务,那么定时器将被释放:

- (void)_countDown {
    NSMutableArray *removeNodes = @[].mutableCopy;
    
    for (unsigned int offset = 0; offset < _receives->entries_count; offset++) {
        hash_entry_t *entry = _receives->hash_entries + offset;
        LXDReceiverNode *header = (LXDReceiverNode *)entry->entry;
        LXDReceiverNode *node = header->next;
        
        while (node != NULL) {
            [removeNodes addObject: [NSValue valueWithPointer: (void *)node]];
            node = node->next;
        }
    }
    
    if (removeNodes == 0 && self.timer != nil) {
        lxd_wait(self.lock);
        dispatch_cancel(self.timer);
        self.timer = nil;
        lxd_signal(self.lock);
    }
    
    dispatch_async(dispatch_get_main_queue(), ^{
        [removeNodes enumerateObjectsWithOptions: NSEnumerationReverse usingBlock: ^(NSValue *obj, NSUInteger idx, BOOL * _Nonnull stop) {
            LXDReceiverNode *node = (LXDReceiverNode *)[obj pointerValue];
            node->receiver->lefttime--;
            BOOL isStop = NO;
            
            node->receiver->callback(node->receiver->lefttime, &isStop);
            if (node->receiver->lefttime > 0 && !isStop) {
                [removeNodes removeObjectAtIndex: idx];
            }
        }];
        
        dispatch_async(_timerQueue, ^{
            lxd_wait(self.lock);
            for (id obj in removeNodes) {
                LXDReceiverNode *node = (LXDReceiverNode *)[obj pointerValue];
                _receives->destoryNode(node);
            }
            lxd_signal(self.lock);
        });
    });
}
复制代码

倒计时任务会由于倒计时归零或者标记位被修改这两个缘由结束,假如本次回调中正好全部的倒计时任务都处理完毕了,全部的注册者都被清除。此时并不会马上中止定时器,而是等待到下次回调再中止。主要出于两个条件考虑:

  1. 回调属于异步执行,若是要本次处理完成后检测注册队列状态,须要额外的同步机制开销
  2. 假如在下次回调前又注册了新的倒计时任务,能够避免销毁重建定时器的开销

先后台切换

应用在先后台切换的过程当中,会在非后台线程执行完当前任务后挂起线程。通常来讲,咱们的倒计时会由于先后台切换而停止,除非咱们将倒计时放在主线程建立后台线程继续执行。此外,应用从新回到ative状态时,只要在后台停留的时长超出了定时器的回调间隔,那么倒计时会马上被回调,破坏了原有的回调时间和倒计时长

文章开头提到有三种方案解决这种先后台切换对定时器的方案。后台线程倒计时能够最大程度的保证倒计时的回调时间依旧正确,可是基于应用后台无感知的特性,这种消耗资源的方案不在咱们的考虑范围。因为在设计上,我已经采用了保留lefttime的方式,所以保存deadline从新计算剩余时长也不是最佳选择。采用方案2计算后台停留时间而且更新剩余时间是最合适的作法:

- (void)applicationDidBecameActive: (NSNotification *)notif {
    if (self.enterBackgroundTime && self.timer) {
        long delay = [[NSDate date] timeIntervalSinceDate: self.enterBackgroundTime];
        
        dispatch_suspend(self.timer);
        for (unsigned int offset = 0; offset < _receives->entries_count; offset++) {
            hash_entry_t *entry = _receives->hash_entries + offset;
            LXDReceiverNode *header = (LXDReceiverNode *)entry->entry;
            LXDReceiverNode *node = header->next;
            
            while (node != NULL) {
                if (node->receiver->lefttime < delay) {
                    node->receiver->lefttime = 0;
                } else {
                    node->receiver->lefttime -= delay;
                }
                
                bool isStop = false;
                node->receiver->callback(node->receiver->lefttime, &isStop);
                if (node->receiver->lefttime <= 0 || isStop) {
                    lxd_wait(self.lock);
                    _receives->destoryNode(node);
                    lxd_signal(self.lock);
                }
            }
        }
        dispatch_resume(self.timer);
    }
}

- (void)applicationDidEnterBackground: (NSNotification *)notif {
    self.enterBackgroundTime = [NSDate date];
}
复制代码

因为通知的回调线程和定时器的处理线程可能存在多线程的竞争,为了排除这一干扰,我采用了sema加锁,以及在遍历期间挂起定时器,减小没必要要的麻烦

对外接口

前文说过,在不影响功能性的状况下,应当尽可能减小对外接口的数量。所以,倒计时管理类只须要提供一个接口便可:

/*!
 *  @class  LXDTimerManager
 *  定时器管理
 */
@interface LXDTimerManager : NSObject

/*!
 *  @method timerManager
 *  获取定时器管理对象
 */
+ (instancetype)timerManager;

/*!
 *  @method registerCountDown:forSeconds:withReceiver:
 *  注册倒计时回调
 *
 *  @params countDown   回调block
 *  @params seconds     倒计时长
 *  @params receiver    注册的对象
 */
- (void)registerCountDown: (LXDTimerCallback)countDown
               forSeconds: (NSUInteger)seconds
             withReceiver: (id)receiver;

@end
复制代码

操做安全

为了倒计时任务的可靠性,咱们应该在子线程启动定时器,一方面提升了精准度,另外一方面避免形成主线程的卡顿。但因为涉及到UI更新先后台切换两个状况,必需要考虑到多线程可能对数据的破坏力。从设计上来讲,底层设计只提供实现接口,不考虑任何业务场景。所以应该在上层调用处作安全处理

管理类使用DISPATCH_QUEUE_SERIAL属性建立的任务队列,确保定时器的回调之间是互不干扰的。对外提供的register接口没法保证调用方所处的线程环境,所以应当对操做进行加锁。此外涉及到hashmap的改动的代码都应当加锁保护:

- (instancetype)init {
    if (self = [super init]) {
        self.receives = new LXDReceiverHashmap();
        self.lock = dispatch_semaphore_create(1);
        self.timerQueue = dispatch_queue_create("com.sindrilin.timer.queue", DISPATCH_QUEUE_SERIAL);
        
        [[NSNotificationCenter defaultCenter] addObserver: self selector: @selector(applicationDidBecameActive:) name: UIApplicationDidBecomeActiveNotification object: nil];
        [[NSNotificationCenter defaultCenter] addObserver: self selector: @selector(applicationDidEnterBackground:) name: UIApplicationDidEnterBackgroundNotification object: nil];
    }
    return self;
}

- (void)registerCountDown: (LXDTimerCallback)countDown
           forSeconds: (NSUInteger)seconds
         withReceiver: (id)receiver {
    if (countDown == nil || seconds <= 0 || receiver == nil) { return; }
    
    lxd_wait(self.lock);
    self.receives->insertReceiver((__bridge void *)receiver, countDown, seconds);
    [self _startupTimer];
    lxd_signal(self.lock);
}
复制代码

除了加锁,避免线程竞争的产生环境也是可行的。一个明显的竞争时机在于应用切换先后台倒计时回调可能会同时被执行,所以在通知回调的遍历操做过程先后,将定时器suspend,避免刚好发生冲突的可能。

循环引用

不一样于大多数的倒计时方案,本方案经过扩充NSObject的方法来保证全部的类对象均可以注册倒计时任务。在iOS中,block是最容易引发循环引用的机制之一。为了尽可能减小可能存在的引用问题,在接口的设计上,我让block接受一个id类型的调用对象,在接口层内部进行了一次__weak声明,而且在对象被释放后停止定时器任务:

@implementation NSObject (PerformTimer)

__weak typeof(self) weakself = self;
[[LXDTimerManager timerManager] registerCountDown: ^(long leftTime, bool *isStop) {
    if (weakself) {
        countDown(weakself, leftTime, (BOOL *)isStop);
    } else {
        *isStop = true;
    }
} forSeconds: seconds withReceiver: self];

@end
复制代码

固然,我也作好了调用者彻底不用receiver的准备了~

其余

最后再次声明这个观点:

倒计时方案几乎没有门槛,但也不只限于倒计时方案

设计一个功能须要通过仔细考虑多个因素,包括逻辑性能质量多个方面。洋洋洒洒写完以后,发现设计倒计时也不是那么的容易,并且hash + linked list的设计上我采用了struct + C++的数据结构实现。虽然这套设计直接采用NSDictionary + NSArray来实现也是彻底没有问题的,可是看了那么多源码,那么多算法,不去实践下实在太惋惜了。基于吐槽 + 实践两层缘由,最终完成了这么一个东西

本篇文章基于这段时间学习的收获总结而成,若是您以为有不足之处,还万请指出。项目已同步至cocoapods,可经过pod 'LXDTimerManager'导入

demo

关注个人公众号获取更新信息
相关文章
相关标签/搜索