iOS:计时器

Page content

1S的误差对计算机来说都是天文数字

前言

公司的项目有一个秒杀模块,真是加了又删,删了又加,改了又改,多人开发的时候代码封装与控制没有做好,导致项目多处计时器,秒杀时间计算等逻辑,代码冗余而又难改,最关键的是大量的NSTimer的不当使用.

鉴于NSTimer不当使用导致计时不精准,并且影响迭代效率,我们来做一次iOS中计时器功能的整理迭代。

NSTimer与RunLoop

NSTimer

NSTimer第一种初始化方式:

~~~objective-c
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo;
~~~

使用这种方式初始化时,Timer会被加入到当前线程的RunLoop中,模式是默认的NSDefaultRunLoopMode;

而在当前线程为主线程的情况下,当主线程中进行复杂的运算,或者进行UI界面操作时,会出现问题——

由于在main runloop中NSTimer是同步交付的被“阻塞”,而模式也有可能会改变:

(会将RunLoop切换成NSEventTrackingRunLoopMode模式,在这个过程中,默认的NSDefaultRunLoopMode模式中注册的事件是不会被执行的);

也就是说,此时使用scheduledTimerWithTimeInterval添加到RunLoop中的Timer就不会执行,因此,就会导致NSTimer计时出现延误。


另一种初始化方式:

~~~objective-c
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo;
~~~

使用这种方式初始化时,我们需要调用NSRunLoop.currentRunLoop,把timer加入到runloop中并且指定Mode:

~~~objective-c
FOUNDATION_EXPORT NSRunLoopMode const NSDefaultRunLoopMode;
FOUNDATION_EXPORT NSRunLoopMode const NSRunLoopCommonModes NS_AVAILABLE(10_5, 2_0);
- (void)addTimer:(NSTimer *)timer forMode:(NSRunLoopMode)mode;
~~~

根据Apple文档,NSRunLoopCommonModes等效于NSDefaultRunLoopMode和NSEventTrackingRunLoopMode的结合;

当我们使用NSRunLoopCommonModes模式启动时,Timer的优先级将和UI控件一样高,也即不会受到主线程UI操作的影响,从而正常执行。

RunLoop

Runloop即运行循环,每个线程都有一个实际已经存在的runloop,他不停地运行;

从程序开始到程序退出,Runloop在不断地监听各种事件,使得程序可以检测到用户的各种触摸交互、网络返回的数据、定时器在预定的时间触发等一系列操作;

Runloop只接受两种任务:输入源和定时源。

计时器就是其中的定时源。默认状态下,子线程的runloop中没有加入我们自己的源,同样也是没有启动的(只有主线程的Runloop是自动开启的),在子线程中使用定时器时,就需要自己加到runloop中,并启动该线程的runloop,从而使定时器正常运行。

关于Runloop,我将会单独写一篇文章做详细记录。

第一种初始化方式的弊端我们可以通过初始化方式二来进行解决,除此之外,在子线程中进行逻辑运算,在主线程中进行刷新UI的操作,也可以解决主线程阻塞导致模式转变而计时不准的问题。

NSTimer与NSThread

在实际项目中,有很多计时器关联的操作是比较费时的或计算量较大的,如某些秒杀逻辑的运算,这种情况下,为避免阻塞主线程或导致UI卡顿,我们需要将计时器关联的操作放到子线程中执行,再在主线程回调刷新UI,这种情况下,就要结合线程操作。

~~~objective-c
// 创建并执行新的线程
- (void)StartTimerThread {   
    NSThread *thread = [[NSThread alloc] initWithTarget:self selector:@selector(timerThread) object:nil];
    [thread start];
}
- (void)timerThread {
    @autoreleasepool {
        // 在当前Run Loop中添加timer,模式是默认的NSDefaultRunLoopMode
        _timer = [NSTimer scheduledTimerWithTimeInterval:2.0 target:self selector:@selector(timerCallback) userInfo:nil repeats:YES];
        // 开始执行新线程的Run Loop
        [[NSRunLoop currentRunLoop] run];
    }
}

// timer的关联方法
- (void)timerCallback {
    // 逻辑运算
  	// 执行完毕后主线程回调刷新UI
    [self performSelectorOnMainThread:@selector(refreshUI) withObject:nil waitUntilDone:false];
}
// 回调方法
- (void)refreshUI {
    /// 刷新UI
}

// timer的取消方法
- (void)cancelTimer {
    NSThread *thread = [[NSThread alloc] initWithTarget:self selector:@selector(timerCancelThread) object:nil];
    [thread start];
}
- (void)timerCancelThread {
    if (_timer) {
        [_timer invalidate];
        _timer = nil;
    }
}
~~~

一定记住,要及时销毁timer,否则会有内存泄漏的风险

我们看timer的invalidate方法官方介绍:

This method is the only way to remove a timer from an NSRunLoop object. The NSRunLoop object removes its strong reference to the timer, either just before the invalidate method returns or at some later point. If it was configured with target and user info objects, the receiver removes its strong references to those objects as well. You must send this message from the thread on which the timer was installed. If you send this message from another thread, the input source associated with the timer may not be removed from its run loop, which could prevent the thread from exiting properly.

NSTimer再初始化时,会持有target对象,而Runloop对象会持有timer对象;

当invalidate被调用时,NSRunLoop对象会释放对timer的持有,timer会释放对target的持有;

除此之外,我们没有途径可以释放timer对target的持有。所以解决内存泄露就必须撤销timer,若不撤销,target对象将永远无法释放。

并且:NSTimer的创建与撤销必须在同一个线程操作。

对于timerThread方法中的@autoreleasepool{},我们都知道主线程的RunLoop是默认启动的,而在main函数中:

~~~objective-c
int main(int argc, char * argv[]) {
    @autoreleasepool {
        return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
    }
}
~~~

也存在自动释放池,这种做法,我将在之后的RunLoop及多线程相关文章中做出说明和讲解。

GCD

NSTimer使用起来着实有太多不便,需要注意的地方太多。我们的另一个选择是使用GCD的Dispatch Sources 来创建一个timer。

简介

什么是dispatch source呢?

dispatch source是一个监视某些类型事件的对象。当这些事件发生时,它自动将一个block放入一个dispatch queue的执行例程中。

简要来说,它是一个“输入源”来起到监听作用,在事件发生时自动执行block中的任务。

对于dispatch source,将开单独的文章进行记录和说明。

使用

我们在Xcode中打dispatch source 会出现dispatch source timer的提示,敲下回车后,代码是这个样子的:

~~~objective-c
dispatch_source_t timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_main_queue());
    dispatch_source_set_timer(timer, DISPATCH_TIME_NOW, 1 * NSEC_PER_SEC, 1 * NSEC_PER_SEC);
    dispatch_source_set_event_handler(timer, ^{
        //
    });
    dispatch_resume(timer);
~~~

便于观察,我们可以把参数拆分一下,使用GCD的dispatch source来创建计时器的主要步骤如下:

~~~objective-c
// 创建一个队列
dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
// 根据队列创建timer
dispatch_source_t timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue);
// 首次执行时间
dispatch_time_t start = dispatch_time(DISPATCH_TIME_NOW, 0);
// 任务执行时间间隔
uint64_t interval = timeInterval * NSEC_PER_SEC;
// 允许的延迟(精确度)
uint64_t leeway = 0 * NSEC_PER_SEC;
// 设置timer的 首次执行时间, 任务执行时间间隔, 精确度
dispatch_source_set_timer(timer, start, interval, leeway);
// 设置timer的 执行任务事件
__weak typeof (self) weakSelf = self;
dispatch_source_set_event_handler(timer, ^{
    // action handle
    [weakSelf do];
});
// 启动timer
dispatch_resume(timer);
// 取消timer
dispatch_source_cancel(timer);
~~~

我们知道,任何计时器都不可能做到100%精确,leeway这个参数很有意思,它表明了我们希望系统为达到的精确度做出努力的程度,既然这样,也就表明,精确度越高,系统CPU资源的占用也就越多,相反,精确度越低,CPU占用就越低。

这样做的意义就在于降低资源消耗。如果系统可以让cpu休息足够长的时间,并在每次醒来的时候执行一个任务集合,而不是不断的醒来睡去以执行任务,那么系统会更高效。

如果传入一个比较大的leeway给你的计时器,意味着你允许系统拖延你的计时器来将计时器任务与其他任务联合起来一起执行。

这样使用dispatch timer 已经可以满足大部分的需求了,但是每次启动都要写这么多代码,而且也不够全面,那我们对dispatch timer做一次封装。

封装

这个封装的目的在于,我们需要像NSTimer那样,是计时器的创建和管理更简单方便,而且需要更全面的功能,那么封装的这个计时器类,我们把它设定为单例类:SeanTimer。

SeanTimer的主体功能:   1. 提供创建计时器的接口;   2. 提供取消计时器的接口;   3. 计时器的需求有可能在很多模块都存在,我们需要提供计时器的缓存,以优化和查找;   4. 对于特定的需求,我们需要新开启的计时器能够同时执行已取消的计时器的方法和新添加的逻辑;

基于以上设计,我们定义接口文件如下:   

~~~objective-c
#import <Foundation/Foundation.h>
typedef NS_ENUM(NSUInteger, ActionOption) {
    PreviousActionCancel,//废除旧任务
    PreviousActionMerge,//合并新旧任务
};
@interface SeanTimer : NSObject
/**
 * method:sharedInstance.
 */
+ (instancetype)sharedInstance;
/**
 * method:create&start a timer based GCD.
 * Name:         名字标识;
 * TimeInterval: 计时器执行任务间隔;
 * Queue:        任务执行的线程(默认子线程);
 * Repeats:      是否重复;
 * Option:       相同标识timer任务执行模式,0废除旧任务(默认),1合并新旧任务;
 * Action:       timer关联SEL.
 * 合并新任务模式下暂不支持传参
 */
- (void)TimerWithName:(NSString *)name TimeInterval:(double)timeInterval Queue:(dispatch_queue_t)queue Repeats:(BOOL)repeats  Option:(ActionOption)option Target:(id)target Action:(SEL)action Object:(id)obj;
/**
 * method:cancelTimerWithName.
 */
- (void)cancelTimerWithName:(NSString *)name;
/**
 * method:cancel all timer.
 */
- (void)cancelAllTimer;
@end
~~~

我们将缓存放入匿名类别中:

~~~objective-c
#import "SeanTimer.h"
@interface SeanTimer ()
@property (strong , nonatomic) NSMutableDictionary * timerCache;
@property (strong , nonatomic) NSMutableDictionary * actionCache;
@end
~~~

一个是timer缓存,避免同样的timer重复创建;

另一个是action缓存,这里记录了timer的关联方法,存储类型为SEL类型;

timer的创建与action等的缓存,均是根据“name”别名来作Key存储。

我们来实现单例方法和缓存对象的懒加载:

~~~objective-c
#pragma mark - sharedInstance.
static SeanTimer * _instance = nil;
/**
 * method:sharedInstance.
 */
+ (instancetype)sharedInstance {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        _instance = [[SeanTimer alloc] init];
    });
    return _instance;
}

#pragma mark - lazy loading.
/**
 * timer cache, timer缓存.
 */
- (NSMutableDictionary *)timerCache {
    if (!_timerCache) {
        _timerCache = [NSMutableDictionary dictionary];
    }
    return _timerCache;
}
/**
 * action cache, action缓存.
 */
- (NSMutableDictionary *)actionCache {
    if (!_actionCache) {
        _actionCache = [NSMutableDictionary dictionary];
    }
    return _actionCache;
}
~~~

最后是主体功能的实现:

~~~objective-c
#pragma mark - methods:public
/**
 * method:create&start a timer based GCD.
 * Name:         名字标识;
 * TimeInterval: 计时器执行任务间隔;
 * Queue:        任务执行的线程(默认子线程);
 * Repeats:      是否重复;
 * Option:       相同标识timer任务执行模式,0废除旧任务(默认),1合并新旧任务;
 * Action:       timer关联SEL.
 */
- (void)TimerWithName:(NSString *)name TimeInterval:(double)timeInterval Queue:(dispatch_queue_t)queue Repeats:(BOOL)repeats  Option:(ActionOption)option Target:(id)target Action:(SEL)action Object:(id)obj {
    if (!name)  return;
    if (!action) return;
    if (!target)  return;
    if (!queue) queue = dispatch_get_global_queue(0, 0);
  
    dispatch_source_t timer = self.timerCache[name];  
    if (!timer) {
        timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue);
        // timer cache
        [self.timerCache setObject:timer forKey:name];
    }
    dispatch_time_t start = dispatch_time(DISPATCH_TIME_NOW, 0);
    uint64_t interval = timeInterval * NSEC_PER_SEC;
    uint64_t leeway = 0 * NSEC_PER_SEC;
    dispatch_source_set_timer(timer, start, interval, leeway);
    
    __weak typeof (self) weakSelf = self;
    switch (option) {
        case PreviousActionMerge: // 合并之前的任务,下一轮一起执行
        {
            [self saveAction:action WithName:name];
            dispatch_source_set_event_handler(timer, ^{
                [weakSelf performActionsWithName:name Target:target];
                if (!repeats) {
                    [self cancelTimerWithName:name];
                }
            });
            break;
        }
        case PreviousActionCancel: // 取消之前的任务,下一次仅执行新任务
        {
            [self removeActionCacheWithName:name];
            dispatch_source_set_event_handler(timer, ^{
                [weakSelf performActionWithTarget:target Action:action Object:obj];
                if (!repeats) {
                    [self cancelTimerWithName:name];
                }
            });
            break;
        }
    }
    dispatch_resume(timer);
}
/**
 * method:cancelTimerWithName.
 */
- (void)cancelTimerWithName:(NSString *)name {
    dispatch_source_t timer = self.timerCache[name];
    if (!timer) return;
    dispatch_source_cancel(timer);
    [self.timerCache removeObjectForKey:name];
//    [self.actionCache removeObjectForKey:name];
}
/**
 * method:cancel all timer.
 */
- (void)cancelAllTimer {
    [self.timerCache enumerateKeysAndObjectsUsingBlock:^(NSString * name, dispatch_source_t timer, BOOL * _Nonnull stop) {
        [self.timerCache removeObjectForKey:name];
        dispatch_source_cancel(timer);
    }];
}

#pragma mark - methods:private.
/**
 * 根据timer名,将新的action加入缓存
 */
- (void)saveAction:(SEL)action WithName:(NSString *)name {
    NSString * actionString = NSStringFromSelector(action);
    id actionArray = self.actionCache[name];
    if (actionArray&&[actionArray isKindOfClass:[NSMutableArray class]]) {
        [(NSMutableArray *)actionArray addObject:actionString];
        return;
    }
    NSMutableArray * actionArrayNew = [[NSMutableArray alloc] initWithObjects:actionString, nil];
    [self.actionCache setObject:actionArrayNew forKey:name];
}
/**
 * 根据timer名,将之前的action移除缓存
 */
- (void)removeActionCacheWithName:(NSString *)name {
    if (!self.actionCache[name]) return;
    [self.actionCache removeObjectForKey:name];
}
- (void)performActionsWithName:(NSString *)name Target:(id)target {
//    [self removeActionCacheWithName:name];
    NSMutableArray * actionArray = self.actionCache[name];
    [actionArray enumerateObjectsUsingBlock:^(NSString * actionString, NSUInteger idx, BOOL * _Nonnull stop) {
        SEL aAction = NSSelectorFromString(actionString);
        IMP imp = [target methodForSelector:aAction];
        void (*func)(id, SEL , id) = (void *)imp;
        func(target, aAction , nil);
    }];
}
- (void)performActionWithTarget:(id)target Action:(SEL)action Object:(id)obj {
    IMP imp = [target methodForSelector:action];
    void (*func)(id, SEL , id) = (void *)imp;
    func(target, action , obj);
}
~~~

对于public方法:

  1. TimerWithName:Timer的创建方法,首先从缓存中根据name查找并获取timer,没有获取到,则创建新的timer;其次对timer进行相关设置;下一步,则根据ActionOption这个执行策略的枚举,来判断是否合并之前的任务一起执行,从而设置相应的dispatch_source_set_event_handler。Repeats字段是仿照NSTimer的Repeats,不需要重复执行的任务,将在一次执行后调用dispatch_source_cancel取消掉。
  2. cancelTimerWithName:Timer的取消方法:根据name,先从缓存中找到timer,然后dispatch_source_cancel取消掉这个timer,最后从缓存删除。
  3. cancelAllTimer:取消所有timer的方法,遍历timer缓存,逐个的将timer取消,并从缓存删除。

对于private方法: 1. saveAction: 将action加入缓存,对于action缓存,由于有合并执行策略的存在,存储结构为:{“name”:[action1, action2]},此方法以name为键的形式将多个同name的action一一缓存。由于OC语言的特性,这里缓存的并非真正的SEL,而是缓存的SEL对应的名称,调用时再根据名称获取SEL进行调用。

  1. removeActionCacheWithName: 将action从缓存中移除,直接从缓存数组中删除。

  2. SEL aAction = NSSelectorFromString(actionString) : 根据SEL名称获取SEL;

IMP imp = [target methodForSelector:aAction]根据SEL获取方法指针;

void (*func)(id, SEL , id) = (void *)imp;

func(target, aAction , nil); 最后,根据方法指针进行函数调用。

在合并任务,也即多方法调用里,并未实现传参的功能,如有需要,可以根据SEL名称进行参数Object的缓存。

  1. performActionWithTarget:单action的调用方法;

单方法调用则更为简单:

~~~objective-c
IMP imp = [target methodForSelector:action];
void (*func)(id, SEL , id) = (void *)imp;
func(target, action , obj);
~~~

对于这种底层的直接调用函数的方式,可以查看Runtime的文章,通过函数调动,相比SEL选择器调用速度更快,但失去了Runtime的灵活性,孰优孰略,根据需求自行考量。   

使用封装

我们定义一个宏作为Timer名字,声明一个整型index,创建一个label:

~~~objective-c

#define TIMER_TEST @"Timer_Test"
@interface ViewController ()
{
    int index;
}
@property (weak, nonatomic) IBOutlet UILabel *label;
@end
~~~

在viewDidLoad方法中进行测试调用:

~~~objective-c
- (void)viewDidLoad  {
    [super viewDidLoad];
//    [self timerTestWithActionOption:PreviousActionMerge];
    [self timerTestWithActionOption:PreviousActionCancel];
}

- (void)timerTestWithActionOption:(ActionOption)option {
    index = 0;
    [[SeanTimer sharedInstance] TimerWithName:TIMER_TEST TimeInterval:1.f Queue:dispatch_get_global_queue(0, 0) Repeats:true Option:option Target:self Action:@selector(action1) Object:nil];
  
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        [[SeanTimer sharedInstance] cancelTimerWithName:TIMER_TEST];
    });
    
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        [[SeanTimer sharedInstance] TimerWithName:TIMER_TEST TimeInterval:1.f Queue:dispatch_get_global_queue(0, 0) Repeats:true Option:option Target:self Action:@selector(action2) Object:nil];
    });
}
- (void)action1 {
    index += 1;
    dispatch_async(dispatch_get_main_queue(), ^{
        _label.text = [NSString stringWithFormat:@"%i", index];
    });
}
- (void)action2 
    dispatch_async(dispatch_get_main_queue(), ^{
        NSLog(@"%i", index);
    });
}
~~~

可以看到,我们的计时器在两种模式下都可以正常工作,但在同命名的Timer的任务执行模式转换时要多加注意。

最后,不要忘记销毁:

~~~objective-c
- (void)dealloc {
    [[SeanTimer sharedInstance] cancelTimerWithName:TIMER_TEST];
}
~~~