iOS定时器

1.NSTimer

iOS中最基本的定时器。其通过RunLoop来实现,一般情况下较为准确,但当当前循环耗时操作较多时,会出现延迟问题。同时,也受所加入的RunLoop的RunLoopMode影响。

NSTimer的类方法

+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti invocation:(NSInvocation *)invocation repeats:(BOOL)yesOrNo;
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti invocation:(NSInvocation *)invocation repeats:(BOOL)yesOrNo;

+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo;
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo;
//iOS10.0之后可以用的方法
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0));
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0));

NSTimer的对象方法
这两个方法多了一个参数date,这个参数可以指定定时器什么时候开启,创建完之后需要手动加入Runloop

- (instancetype)initWithFireDate:(NSDate *)date interval:(NSTimeInterval)ti target:(id)t selector:(SEL)s userInfo:(nullable id)ui repeats:(BOOL)rep NS_DESIGNATED_INITIALIZER;
- (void)fire;
//iOS10.0之后可以使用
- (instancetype)initWithFireDate:(NSDate *)date interval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0));

可以看到定时器创建的类方法都分为invocation和selector两种方式。

1.1Timer定时器的创建

1.1.1传NSInvocation方法创建定时器

NSMethodSignature *signature = [[self class] instanceMethodSignatureForSelector:@selector(timered)];
    NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:signature];
    invocation.target = self;
    invocation.selector = @selector(timered);
    NSTimer *timer = [NSTimer scheduledTimerWithTimeInterval:1 invocation:invocation repeats:YES];

- (void)timered{
    NSLog(@"定时器被调用");
}

1.1.2传SEL方式创建定时器

NSTimer *timer = [NSTimer scheduledTimerWithTimeInterval:1 target:self selector:@selector(timered) userInfo:nil repeats:YES];

1.1.3block方式创建定时器

NSTimer *timer = [NSTimer scheduledTimerWithTimeInterval:1 repeats:YES block:^(NSTimer * _Nonnull timer) {
        NSLog(@"定时器被调用");
    }];

1.1.4NSTimer的fire方法

调用了fire方法之后会立即执行定时器的方法,fire方法不会改变预定周期性调度。即调用完fire方法之后不会从当前时间重新开始计算时间间隔,还是会从上一次计算时间间隔。
定时器如果不循环调用,提前调用了fire方法,不会在时间到了之后在调用一次,因为执行一次之后任务就结束了。

NSLog(@"当前时间");
    NSTimer *timer = [NSTimer scheduledTimerWithTimeInterval:10 target:self selector:@selector(timered) userInfo:nil repeats:YES];
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        [timer fire];
    });

运行结果:

2020-03-02 17:54:02.436750+0800 Test[46183:3046157] 当前时间
2020-03-02 17:54:07.438237+0800 Test[46183:3046157] 定时器被调用
2020-03-02 17:54:12.439445+0800 Test[46183:3046157] 定时器被调用

定时器时间间隔设置了10秒,第五秒的时候调用了一次fire方法,定时器第二次调用是在第10秒的时候,而不是15秒的时候。

定时器如果不循环调用,提前调用了fire方法,不会在时间到了之后在调用一次,因为执行一次之后任务就结束了。

NSLog(@"当前时间");
    NSTimer *timer = [NSTimer scheduledTimerWithTimeInterval:10 target:self selector:@selector(timered) userInfo:nil repeats:NO];
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        [timer fire];
    });

运行结果:

2020-03-02 17:56:39.859278+0800 Test[46250:3050494] 当前时间
2020-03-02 17:56:44.860940+0800 Test[46250:3050494] 定时器被调用

定时器在5秒之后调用一次就不再调用。
1.1.5timerWithTimeInterval和scheduledTimerWithTimeInterval的区别
scheduledTimerWithTimeInterval创建的时候就已经添加到runloop,
通过timerWithTimeInterval创建定时器之后需要手动添加到runloop才能开始运行,因为定时器的运行是依赖runloop的,xcode中对方法的解释
scheduledTimerWithTimeInterval

Creates and returns a new NSTimer object initialized with the specified block object and schedules it on the current run loop in the default mode.

timerWithTimeInterval

Creates and returns a new NSTimer object initialized with the specified block object. This timer needs to be scheduled on a run loop (via -[NSRunLoop addTimer:]) before it will fire.

1.2定时器循环引用出现的原因

把定时器在一个控制器中创建,在控制器的dealloc方法中销毁定时器。
通过NSInvocation、selector和block三种方式创建定时器,分别在控制器的dealloc中销毁定时器

- (void)dealloc{
    NSLog(@"控制器被销毁");
    [self.timer invalidate];
    self.timer = nil;
}

运行发现,当控制器被pop时,只有通过block方式创建的定时器会调用“控制器被销毁”。说明其他两种方式都会造成控制器无法释放,造成内存泄漏。
如果把是否循环的参数改成NO,表示定时器只执行一次,则控制器也能被销毁。

1.2.1定时器造成循环引用的原因

因为定时器要被加到runloop中才能运行,所以定时器被runloop强引用,因为定时器在运行时需要调用传入的target的方法,target一般都是控制器,所以定时器强引用了控制器,定时器的销毁又放在控制器的dealloc方法中,所以一直无法释放。

1.2.2 不会造成循环引用的情况

  • 1.非重复计时器,即repeat参数传NO的,因为执行完一次之后会直接失效。相当于调用了invalidate方法,runloop会把定时器移除。所以控制器就可以被销毁了。苹果文档描述如下
//非重复计时器在触发后立即使自身失效。
 A nonrepeating timer invalidates itself immediately after it fires. 
  • 2.通过iOS10.0之后的新方法,定时器调用block,方法的介绍中描述如下
//阻塞定时器执行体;在执行时,计时器本身作为参数传递给这个块,以帮助避免循环引用
- parameter:  block  The execution body of the timer; the timer itself is passed as the parameter to this block when executed to aid in avoiding cyclical references

1.3循环引用的解决

由于造成循环引用的原因是runloop强引用NSTimer,NSTimer强引用控制器,所以我们可以让NSTimer不再强引用控制器,即在创建NSTimer的时候传入的target为另一个对象,用来相应定时器。
具体步骤:
1.创建类YYTimerResponse类,在类中时间NSTimer的调用方法"- (void)timered"

#import "YYTimerResponse.h"

@implementation YYTimerResponse
- (void)timered{
    NSLog(@"在YYTimerResponse中定时器被调用");
}
- (void)dealloc{
    NSLog(@"YYTimerResponse被销毁");
}
@end

2.在控制器中添加定时器,并且在dealloc中销毁定时器

- (void)viewDidLoad {
    [super viewDidLoad];

    YYTimerResponse *timeResponse = [[YYTimerResponse alloc] init];
    self.timer = [NSTimer scheduledTimerWithTimeInterval:1 target:timeResponse selector:@selector(timered) userInfo:nil repeats:YES];
}
- (void)dealloc{
    NSLog(@"控制器被销毁");
    [self.timer invalidate];
    self.timer = nil;
}

运行之后定时器开始调用,控制器返回之后打印如下

2020-03-02 18:47:46.879168+0800 Test[47229:3134132] 在YYTimerResponse中定时器被调用
2020-03-02 18:47:47.879918+0800 Test[47229:3134132] 在YYTimerResponse中定时器被调用
2020-03-02 18:47:48.116563+0800 Test[47229:3134132] 控制器被销毁
2020-03-02 18:47:48.116796+0800 Test[47229:3134132] YYTimerResponse被销毁

因为此时不再强引用控制器,所以当控制器返回时,控制器的dealloc方法被调用,在dealloc方法中调用了[self.timer invalidate];,所以NSTimer被移除runloop,控制器被销毁所以控制器也不强引用NSTimer了,所以NSTimer也要被释放,所以YYTimerResponse也被销毁。

1.4在子线程启动定时器

dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        NSLog(@"当前线程%@", [NSThread currentThread]);
        self.timer = [NSTimer scheduledTimerWithTimeInterval:1 target:self selector:@selector(timered) userInfo:nil repeats:YES];
    });
- (void)timered{
    NSLog(@"定时器被调用,当前线程:%@", [NSThread currentThread]);
}

运行结果:

2020-03-02 19:04:50.483133+0800 Test[47650:3162147] 当前线程{number = 3, name = (null)}

运行之后发现定时器没有执行
因为子线程的runloop默认没有开启
所以需要在创建完定时器之后调用“[[NSRunLoop currentRunLoop] run];”,开启定时器。
因为子线程也可以强引用NSTimer,所以此时控制器也不会被销毁,所以还是需要像1.3中一样解决循环引用的问题
所以代码修改如下:

dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        NSLog(@"当前线程%@", [NSThread currentThread]);
        YYTimerResponse *timeResponse = [[YYTimerResponse alloc] init];
        self.timer = [NSTimer scheduledTimerWithTimeInterval:1 target:timeResponse selector:@selector(timered) userInfo:nil repeats:YES];
        [[NSRunLoop currentRunLoop] run];
    });

运行结果:发现控制器pop之后,定时器还是无法停止
把target设置为其他对象后,控制器的dealloc方法仍然无法调用的原因:因为在开启异步操作的block中强引用了self,即子线程的runloop强引用了控制器,所以控制器无法被销毁,把self弱引用。
所以最终代码如下:

-(void)viewDidLoad{
[super viewDidLoad];
__weak __typeof__(self) weakSelf = self;
    self.timeResponse = [[YYTimerResponse alloc] init];
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        
        NSLog(@"当前线程%@", [NSThread currentThread]);
        weakSelf.timer = [NSTimer scheduledTimerWithTimeInterval:1 target:weakSelf.timeResponse selector:@selector(timered) userInfo:nil repeats:YES];
        [[NSRunLoop currentRunLoop] run];
        weakSelf.thread = [NSThread currentThread];
    });
}
- (void)dealloc{
    [self.timer invalidate];
    self.timer = nil;
    self.timeResponse = nil;
    NSLog(@"控制器被销毁");
}

打印结果:

2020-03-03 16:03:48.477618+0800 Test[55881:3549309] 当前线程{number = 3, name = (null)}
2020-03-03 16:03:49.479146+0800 Test[55881:3549309] 在YYTimerResponse中定时器被调用,当前线程{number = 3, name = (null)}
2020-03-03 16:03:50.480857+0800 Test[55881:3549309] 在YYTimerResponse中定时器被调用,当前线程{number = 3, name = (null)}
2020-03-03 16:03:51.483406+0800 Test[55881:3549309] 在YYTimerResponse中定时器被调用,当前线程{number = 3, name = (null)}
2020-03-03 16:03:53.939806+0800 Test[55881:3549256] YYTimerResponse被销毁
2020-03-03 16:03:53.939990+0800 Test[55881:3549256] 控制器被销毁

在上面的代码中,除了把self弱引用之外,还手动把YYTimerResponse对象设置为nil,因为YYTimerResponse对象也被强引用了,无法销毁。

1.5定时器加入Runloop的模式(有时无法响应)

在进行UI交互的时候(如tableView的滑动时),runloop所在的模式是UITrackingRunLoopMode,而在把定时器默认加入runloop的时候会加入"NSDefaultRunLoopMode"
runloop执行任务时会在Mode间切换,所以在UI交互时无法响应定时器。

1.5.1解决方法1: 再加入runloop时把模式设置为NSRunLoopCommonModes

把定时器添加到runloop时模式设置为"NSRunLoopCommonModes",这个模式并不是一种Mode,而是一种特殊的标记,关联的有一个set(默认包含NSDefaultRunLoopMode、NSModalPanelRunLoopMode、NSEventTrackingRunLoopMode) 。
代码:

YYTimerResponse *timeResponse = [[YYTimerResponse alloc] init];
    self.timer = [NSTimer timerWithTimeInterval:1 target:timeResponse selector:@selector(timered) userInfo:nil repeats:YES];
    [[NSRunLoop currentRunLoop] addTimer:self.timer forMode:NSRunLoopCommonModes];

1.5.3解决方法2:把定时器加入子线程

由于UI交互是在主线程,所以把定时器加入子线程的Runloop,就不用管加入的模式,因为是两个runloop,没有关联。

2.GCD中的定时器

GCD中的Dispatch Source其中的一种类型是DISPATCH_SOURCE_TYPE_TIMER,可以实现定时器的功能。
dispatch源监听系统内核对象并处理,其可以实现更加精准的定时效果。
GCD的定时器不是通过runloop实现的,所以不会被runloop强引用,需要当前对象强引用,否则会直接被释放。因此GCD的定时器也没有循环引用的问题。
使用步骤:

NSLog(@"当前时间");
    //设置定时器回调执行所在的队列
    dispatch_queue_t queue = dispatch_get_main_queue();
    //创建dispatch_source_t类型的对象gcdTimer
    self.gcdTimer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue);
    //设置定时器开始时间,2秒后
    dispatch_time_t startTime = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC));
    //时间间隔
    uint64_t intervalTime = (int64_t)(1 * NSEC_PER_SEC);
    //允许误差时间,设置为0即不允许有误差
    uint64_t errorTime = 0;
    //按照上面的参数设置定时器
    dispatch_source_set_timer(self.gcdTimer, startTime, intervalTime, errorTime);
    //设置定时器的回调
    dispatch_source_set_event_handler(self.gcdTimer, ^{
        NSLog(@"GCD定时器调用");
    });
    //启动定时器
    dispatch_resume(self.gcdTimer);

启动定时器,然后返回控制器,打印结果如下

2020-03-03 17:24:33.348304+0800 Test[57422:3669949] 当前时间
2020-03-03 17:24:35.348971+0800 Test[57422:3669949] GCD定时器调用,当前线程{number = 1, name = main}
2020-03-03 17:24:36.348850+0800 Test[57422:3669949] GCD定时器调用,当前线程{number = 1, name = main}
2020-03-03 17:24:37.303353+0800 Test[57422:3669949] 控制器被销毁

由打印结果可以知道GCD的定时器不会引起循环引用。
暂停GCD定时器:

dispatch_suspend(self.gcdTimer);

取消GCD定时器:

dispatch_cancel(self.gcdTimer);

GCD定时器暂停之后仍然可以继续执行,NSTimer则不可以,NSTimer只能直接销毁,需要再次启动则需要重建创建NSTimer定时器。
GCD定时器调用了"dispatch_cancel"之后则无法继续执行,通过打印调用“dispatch_cancel”之前和之后gcdTimer对象,可以发现之后对象中会标识出“cancelled”。
注意点:
dispatch_suspend 状态下无法释放
如果调用 dispatch_suspend 后 timer 是无法被释放的。一般情况下会发生崩溃并报“EXC_BAD_INSTRUCTION”错误,看下 GCD 源码dispatch source release 的时候判断了当前是否是在暂停状态。
所以,dispatch_suspend 状态下直接释放当前控制器或者释放定时器,会导致定时器崩溃。
并且初始状态(未调用dispatch_resume)、挂起状态,都不能直接调用dispatch_source_cancel(timer),调用就会导致app闪退。
建议一:尽量不使用dispatch_suspend,在dealloc方法中,在dispatch_resume状态下直接使用dispatch_source_cancel来取消定时器。
建议二:使用懒加载创建定时器,并且记录当timer 处于dispatch_suspend的状态。这些时候,只要在 调用dealloc 时判断下,已经调用过 dispatch_suspend 则再调用下 dispatch_resume后再cancel,然后再释放timer。
参考:iOS中如何正确释放GCD定时器(dispatch_source_t)以及防止Crash?

3. CADisplayLink定时器

CADisplayLink是基于屏幕刷新的周期,所以其一般很准时,每秒刷新60次。其本质也是通过RunLoop,所以当RunLoop选择其他模式或被耗时操作过多时,仍旧会造成延迟。NSTimer中的循环引用问题他也存在。
使用步骤:

//创建接受定时器回调的对象
    YYTimerResponse *timeresponse = [[YYTimerResponse alloc] init];
    // 创建CADisplayLink
    self.displayLink = [CADisplayLink displayLinkWithTarget:timeresponse selector:@selector(timered)];
    //设置定时器周期,这个属性即将被废弃, 在iOS10新增了“preferredFramesPerSecond”代替他
//    self.displayLink.frameInterval = 60;
    self.displayLink.preferredFramesPerSecond = 1;
    // 添加至RunLoop中
    [self.displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes];

使用“frameInterval”属性设置定时器间隔时,因为屏幕一秒钟刷新60次,所以设置为60,表示一秒钟调用一次。
使用“preferredFramesPerSecond”时,设置的就是几秒钟刷新一次,该属性iOS10.0后才能用。

你可能感兴趣的:(iOS定时器)