计算机是不存在倒计时这个概念的,所有的倒计时设计来源于对定时器的运用:给予一个deadline,以秒为时间间隔,在每次回调时刷新屏幕上的数字。倒计时的实现几乎没有门槛,无论NSTimer也好,GCD也罢,甚至使用CADisplayLink都能用来制作一个倒计时方案。但同样的,低门槛也意味着方案的上下限差也很大,本文打算谈谈如何设计一个倒计时方案
为什么要写这篇文章
事实上,倒计时和我目前开发的方向八竿子打不着,我也确实没有必要和想过写这么一套方案。只是这几天有朋友分享了别人设计的倒计时功能:
采用一个全局计时管理对象针对每一个倒计时按钮分配计时器,计时器会生成一个NSOperation对象来执行回调,完成倒计时功能
在抛开代码不谈的情况下,这套设计思路我也是存疑的。如果倒计时要使用operation,那就需要使用queue来完成任务。根据queue的串行并行属性,要考虑这两点:
如果queue是并行的,一个界面上存在多个倒计时按钮时,可能会新建线程来处理同一个queue的任务,这部分的开销并不是必需的
operation需要投放到queue里面启动执行。假如每秒的回调包装成operation处理,那么需要一个定时机制来投放这些operation。如果是这么,为什么不直接使用定时器,而要用operation
但在看完设计者的文章和代码之后,我发现对方根本没有考虑过上面的问题。他operation的任务思路很奇怪:
在每一个operation里面,采用while + sleep的方式,每次回调后让线程睡眠一秒,直至倒计时结束
- (void)main { ...... do { callback(self.leftTime); [NSThread sleepForTimeInterval: 1]; } while (--self.leftTime > 0); ...... }
这种实现有三个坑爹的地方:
while循环结束之前,内部的临时变量不会被释放,存在内存占用过大的风险
如果queue是串行属性,多个operation将无法保证回调时间的正确
不应该采用sleep方式计时,这很浪费线程的执行效率
另外,应用进入后台时,所有的子线程会被停止执行任务,这个会导致了应用切换前后台后,倒计时剩余时间不准。对于这种情况一般也有三种方式来做时间校正:
保存一个倒计时deadline,在进入active后重新计算剩余倒计时
注册通知,在切换前后台时计算时长,减去这个时间更新剩余时间
创建应用后台任务,继续进行倒计时
而上面的设计采用了3的解决方案,鉴于应用在后台时,用户对应用毫无感知的特点,这几乎是最差的一种方案。于是基于这一个个槽点,我决定按照自己的想法,做一个相对高效的倒计时方案
定时器选择
常用于实现倒计时的定时器有NSTimer和GCD两种,处于两点考虑,我选择了后者:
NSTimer需要启动子线程的runloop,另外iOS10+系统必须手动启用一次runloop才能完成定时器的移除
GCD具有更高的效率和精确度
除此之外,定时器的设计一般被分为多定时器设计和单定时器设计,两种各有优劣
多定时器设计
多定时器的设计下,每个倒计时任务拥有自己的计时器。优点在于可以单独控制每个任务的回调间隔。缺点是由于多个定时器的屏幕刷新不一定会同步,导致UI更新不同步等
单定时器设计
单定时器的设计下,所有倒计时任务使用同一个计时器。优点在于减少了额外的性能损耗,设计结构更清晰。缺点在定时器已经启动的情况下,新任务的首次倒计时可能会有明显的提前以及多个倒计时任务强制使用同一种计时间隔。
考虑到倒计时的的UI同步效果以及更好的性能,我选择单定时器设计方案。另外如果确实存在多个不同计时间隔的需求,单定时器设计也可以很好的扩充接口提供支持
设计思路
我们应当将接口设计的足够简单,并且减少需要操作的次数。简单点来说,调用者只要关心如何注册一个倒计时任务就行了。成双成对的接口设计确实看着很美,但是用起来却没有看上去舒服。没错,我说的就是notification和observer
或许我们需要中途结束这个倒计时任务,但是提供remove接口并不是一个很好的选择。实际上iOS早就提供给我们更好的选择:enum传入一个标记变量来帮助我们中止遍历,而这不会增加我们额外的成本。结合接口简单、中止任务这两个需求,可以很轻松的设计出定时器管理类的头文件:
/*! * @block LXDTimerCallback * 回调block * * @params leftTime 倒计时剩余秒数 * @params isStop 等同于enum的isStop,修改为YES后定时任务结束 */ typedef void(^LXDTimerCallback)(long leftTime, bool *isStop); /*! * @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
去重
虽然常用的倒计时几乎是获取验证码场景下使用的,按钮的倒计时也基本不存在多次倒计时的需求,但是商品抢购的cell或许会肆意的注册倒计时任务,教你好好做人。同一对象存在多次回调显然是不合理的,因此引入去重方案是很有必要的。
一般选择hash表来解决重复问题,但由于倒计时的特性,同时执行的倒计时任务不会太多,因此hash表不宜过长占用过多的内存。而一旦hash表长度不够,就容易发生碰撞问题。和中止任务一样,系统也有类似的做法提供我们参考。
@synchronized同步锁使用hashmap + linked list的设计实现去重
@synchronized加锁会以对象地址进行hash运算,每一个对象允许缓存一个对应的NSLock以链表的方式存储起来,并且链表的节点结构保存了一个对象的地址用来解决碰撞。仿照这种设计,存储的回调数据只需保留时长、回调block以及对象地址这三个信息,为了更好的性能,缓存节点同样采用结构体设计:
/*! * @structure LXDReceiver * 存储回调结构体 * * @var lefttime 倒计时时长 * @var objaddr 保留的对象地址 * @var callback 回调block */ typedef struct LXDReceiver { long lefttime; uintptr_t objaddr; LXDReceiverCallback callback; } LXDReceiver; /// 根据对象地址hash化 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+linked list实现去重 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; }
操作效率
由于链表的查找效率比较低,在任务执行完毕或者中止时,删除任务也需要进行一次遍历才能完成。而在倒计时回调时由于需要访问每一个节点的原因,采用双向链表的设计,可以获取到当前节点的前后节点,快速的完成删除工作。另外采用链表头存储链表有效回调长度,可以方便遍历过程中快速统计注册者的个数,决定是否停止定时器:
/*! * @structure LXDReceiverNode * 存储结构双向链表节点 * * @var count 链表有效长度 * @var receiver 回调结构 * @var next 下个节点 * @var previous 上个节点 */ 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; - (void)_countDown { unsigned int receiversCount = 0; for (unsigned int offset = 0; offset < _receives->entries_count; offset++) { hash_entry_t *entry = _receives->hash_entries + offset; LXDReceiverNode *header = (LXDReceiverNode *)entry->entry; __block LXDReceiverNode *node = header->next; dispatch_async(dispatch_get_main_queue(), ^{ lxd_wait(self.lock); while (node != NULL) { LXDReceiver *receiver = node->receiver; LXDReceiverNode *next = node->next; receiver->lefttime--; bool isStop = false; receiver->callback(receiver->lefttime, &isStop); if (receiver->lefttime <= 0 || isStop) { _receives->destoryNode(node); header->count--; } node = next; } lxd_signal(self.lock); }); receiversCount += header->count; } if (receiversCount == 0 && self.timer != nil) { lxd_wait(self.lock); dispatch_cancel(self.timer); self.timer = nil; lxd_signal(self.lock); } }
倒计时任务会因为lefttime归零或者标记位被修改这两个原因结束,假如本次回调中正好所有的倒计时任务都处理完毕了,所有的注册者都被清除。此时并不会立刻停止定时器,而是等待到下次回调再停止。主要出于两个条件考虑:
回调属于异步执行,如果要本次处理完成后检测注册队列状态,需要额外的同步机制开销
假如在下次回调前又注册了新的倒计时任务,可以避免销毁重建定时器的开销
前后台切换
应用在前后台切换的过程中,会结束所有的非后台线程的继续执行。一般来说,我们的倒计时也会被中止,除非我们将任务放在主线程中执行。此外,应用重新回到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; __block LXDReceiverNode *node = header->next; dispatch_async(dispatch_get_main_queue(), ^{ lxd_wait(self.lock); while (node != NULL) { LXDReceiver *receiver = node->receiver; LXDReceiverNode *next = node->next; receiver->lefttime -= delay; bool isStop = false; receiver->callback(receiver->lefttime < 0 ? 0 : receiver->lefttime, &isStop); if (receiver->lefttime <= 0 || isStop) { _receives->destoryNode(node); header->count--; } node = next; } lxd_signal(self.lock); }); } dispatch_resume(self.timer); } } - (void)applicationDidEnterBackground: (NSNotification *)notif { self.enterBackgroundTime = [NSDate date]; }
由于通知的回调线程和定时器的处理线程可能存在多线程的竞争,为了排除这一干扰,我采用了sema加锁,以及在遍历期间挂起定时器,减少不必要的麻烦
操作安全
为了倒计时任务的可靠性,我们应该在子线程启动定时器,一方面提高了精准度,另一方面避免造成主线程的卡顿。但由于涉及到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) - (void)beginCountDown: (LXDObjectCountDown)countDown forSeconds: (NSInteger)seconds { if (countDown == nil || seconds <= 0) { return; } __weak typeof(self) weakself = self; [[LXDTimerManager timerManager] registerCountDown: ^(long leftTime, bool *isStop) { countDown(weakself, leftTime, (BOOL *)isStop); } forSeconds: seconds withReceiver: self]; } @end
当然,我也做好了调用者完全不用receiver的准备了~
其他
最后再次声明这个观点:
倒计时方案几乎没有门槛,但也不仅限于倒计时方案
设计一个功能需要经过仔细考虑多个因素,包括逻辑、性能、质量多个方面。洋洋洒洒写完之后,发现设计倒计时也不是那么的容易,而且hash + linked list的设计上我采用了struct + C++的数据结构实现。虽然这套设计直接采用NSDictionary + NSArray来实现也是完全没有问题的,但是看了那么多源码,那么多算法,不去实践下实在太可惜了。基于吐槽 + 实践两层原因,最终完成了这么一个东西
本篇文章基于这段时间学习的收获总结而成,如果您觉得有不足之处,还万请指出。项目已同步至cocoapods,可通过pod 'LXDTimerManager'导入
作者:骑着jm的hi
链接:https://juejin.im/post/5a50657d518825733060be8b