前言
在开发的过程中,我们经常会用到定时器,比如登录时的验证码重新发送的倒计时,商城类应用中支付倒计时等等,iOS为我们提供了多种定时器,包括NSTimer, CADisplayLink, GCD, NSThread(performSelector:afterDelay:),其本质都是通过RunLoop来实现的,本文是根据平常使用和其他的文档中内容,整理总结的,如果有错误和缺失的地方,欢迎大家批评指正。
NSTimer
NSTimer是最基本也是最常用的定时器,基本上在初学是都会有介绍,也是坑最多的,所以面试时也基本是必问的问题,所以介绍它的相关文章也比较多,我在这里抛砖引玉,欢迎大家补充。
创建
构造方法基本分成两种,自启动和手动启动的,手动启动的构造方法需要我们在创建后手动启动它
自动启动有两个方法:
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti invocation:(NSInvocation *)invocation repeats:(BOOL)yesOrNo;
和
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo;
相对应的手动启动的方法也有个:
+ (NSTimer *)timerWithTimeInterval:(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;
注意:上面这两个方法中有两个方法需要使用invocation对象,invocation对象的创建方式是:
NSMethodSignature *signature = [[self class] instanceMethodSignatureForSelector:@selector(timerSelector:)]; NSInvocation* invocation = [NSInvocation invocationWithMethodSignature:signature]; invocation.target = self; invocation.selector = @selector(timerSelector:); self.timer = [NSTimer scheduledTimerWithTimeInterval:1.f invocation:invocation repeats:YES];
此外,iOS10之后还提供了两种通过block方式调用方法的构造方法,同样是自动启动和手动启动两种各一个方法:
+ (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));
再有,有时我们需要使定时器延迟执行,针对这种需求,系统也提供了两个方法,一个是block方式调用,一个是@selector方式调用
- (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));
- (instancetype)initWithFireDate:(NSDate *)date interval:(NSTimeInterval)ti target:(id)t selector:(SEL)s userInfo:(nullable id)ui repeats:(BOOL)rep NS_DESIGNATED_INITIALIZER;
有时我们需要使有延时的定时器立即执行,则可是调用fire方法:
- (void)fire;
注意:fire方法不会改变预定周期性调度
什么意思呢,就是说如果我们把timer设置为循环调用,那么我们什么时候调用fire方法,下一次调度的时间仍旧是按照我们预定的时间,而非给予本次执行的时间计算而得,例如:我们设置延迟10秒执行,循环周期为5秒,在第8秒的时候我们调用了fire方法,则定时器仍然会在第10秒的时候执行调度,然后是15秒、20秒。。
还有一个需要注意的地方是,手动启动的定时器需要手动将timer add到runloop中,否则定时器不会启动:
self.timer = [NSTimer timerWithTimeInterval:1.f target:self selector:@selector(timerSelector:) userInfo:@{@"key":@"value"} repeats:YES];
// 将timer添加到NSRunLoopCommonModes中,可以防止页面滚动时定时器失效
[[NSRunLoop mainRunLoop] addTimer:self.timer forMode:NSRunLoopCommonModes];
释放
定时器的释放一定要先将其终止,而后才能销毁对象,否则可能会出现内存泄露。
- (void)invalidate;
下面是一堆坑及解决办法
一、子线程启动定时器的问题
我们都知道iOS是通过runloop作为消息循环机制,主线程默认启动了tunloop,但是子线程并没有默认的runloop,所以在子线程中启动定时器是不生效的。
解决的方式很简单,在子线程启动一下runloop就可以了
dispatch_async(dispatch_get_global_queue(0, 0), ^{
NSTimer* timer = [NSTimer timerWithTimeInterval:1 target:self selector:@selector(Timered:) userInfo:nil repeats:YES];
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
[[NSRunLoop currentRunLoop] run];
});
二、runloop的mode问题
有些朋友发现,将timer添加到runloop的NSDefaultRunLoopMode之后,在拖动UIScrollView的时候,定时器会暂停执行,等拖动完成才会继续执行,出现这种问题的原因是UIScrollView滚动时执行的是UITrackingRunLoopMode。解决方法是将timer add到NSRunLoopCommonModes,因为UITrackingRunLoopMode和kCFRunLoopDefaultMode都被标记为了common模式,所以只需要将timer的模式设置为NSRunLoopCommonModes,就可以在默认模式和追踪模式都能够运行。
三、循环引用问题
前两个问题一般情况下是不会碰到的,但是循环引用问题,确是每个使用者都会碰到的。
如果想要测试一下自己代码中的NSTimer是否有循环引用问题,那就在自己的timer调用的方法中打印一下字符串,然后看一下控制台信息,如果在页面退出之后仍然在打印,也就是仍然在循环调用,就说明在页面退出之后timer并没有销毁,这就是出现了循环引用的标志性特点。
究其原因是在页面退出时,timer强引用了target,导致target无法自动销毁,而timer是target的属性,如果把销毁timer的方法写在- (void)dealloc 方法中,此时是不会调用的。
在这里首先声明一下:不是所有的NSTimer都会造成循环引用。就像不是所有的block都会造成循环引用一样。以下两种timer不会有循环引用:
- 非repeat类型的。非repeat类型的定时器只执行一次,而且不会强引用target,因此不会出现循环引用。
- block类型的。iOS10之后才出现的新的API,老版本app无法使用。当然,block内部的循环引用需要注意避免
解决办法:
至于解决办法,我在网上看到了很多方法,有封装timer的,有使用NSProxy方式的,都比较复杂,也比较难以理解,我自己使用的另一种方法,我自己觉得比较易用且容易理解,下方是代码:
- (void)viewWillDisappear:(BOOL)animated
{
[super viewWillDisappear:animated];
[self.timer invalidate];
self.timer = nil;
}
- (void)viewWillAppear:(BOOL)animated
{
[super viewWillAppear:animated];
if (!self.timer || !self.timer.valid) {
self.timer = [NSTimer timerWithTimeInterval:1.f target:self selector:@selector(timerSelector:) userInfo:@{@"key":@"value"} repeats:YES];
[[NSRunLoop mainRunLoop] addTimer:self.timer forMode:NSRunLoopCommonModes];
}
}
其实很简单,就是在页面即将退出时,调用timer的销毁方法,然后设置为nil,然后在viewWillAppear中判断当前页面的timer如果不存在或者已被销毁,就重新创建。根据官方文档上说的,invalidate方法会将定时器从RunLoop中移除,同时解除对target等对象的强引用,所以在调用invalidate方法的时候,循环引用就已经被打破了,controller即target也就可以自动销毁了,
GCD
GCD定时器是dispatch_source_t
类型的变量,其可以实现更加精准的定时效果。我们来看看如何使用:
/** 创建定时器对象
* @param: DISPATCH_SOURCE_TYPE_TIMER 为定时器类型
* @param: 中间两个参数对定时器无用
* @param: 最后是在那个线程中使用
*/
self.GCDTimer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_main_queue());
/** 设置定时器
* @param: 第一个参数为定时器对象
* @param: 第二个参数是调用开始时间
* @param: 第三个参数为调用间隔时间
* @param: 第四个参数为允许的误差,设置为0即不允许出现误差
*/
dispatch_source_set_timer(self.GCDTimer, dispatch_walltime(NULL, 0), 1 * NSEC_PER_SEC, 0);
/** 设置定时器调用
* @param: 第一个参数为定时器对象
* @param: 第二个参数是调用方法,可以使用block方式,也可以使用C函数方式
*/
dispatch_source_set_event_handler(self.GCDTimer, ^{
[weakSelf timerSelector];
});
// 启动任务,GCD计时器创建后需要手动启动
dispatch_resume(self.GCDTimer);
// 定时器挂起
dispatch_suspend(self.GCDTimer);
// 定时器销毁
dispatch_source_cancel(self.GCDTimer);
需要注意的地方:
- dispatch_source_t 一定要被设置为成员变量,否则将会立即被释放。
dispatch_source_set_timer()
方法中的第二个参数,如果使用dispatch_time或者DISPATCH_TIME_NOW,系统会使用默认时钟来计时,然而当系统休眠的时候,默认时钟会停止,这就会导致计时器停止,而使用dispatch_walltime()
方法可以让计时器按照真实时间间隔进行计时dispatch_source_set_event_handler()
中的任务实在子线程中执行的,若要更新UI,要调用dispatch_async(dispatch_get_main_queue(), ^{})
回到主线程。- 暂停(dispatch_suspend)的timer,不能被释放的,会引起崩溃。
- dispatch_suspend 和 dispatch_resume 应该是成对出现的。两者分别会减少和增加 dispatch 对象的暂停计数,但是没有 API 获取当前是暂停还是执行状态,所以需要自己记录。你调用了suspend(暂停)几次,你想resume(恢复)的话,就必须要remuse(恢复)几次,才能继续运行。所以建议控制器添加一个标识符,记录源是否处于暂停状态,在dealloc事件中判断当前源是否被暂停,如果被暂停,则resume,即可解决内存泄漏问题。
- 如果暂停的代码加到 dispatch_source_set_event_handler 的 block 中,并不会发生崩溃,但是这个时候页面会无法释放造成内存泄漏。
- GCD的计时效应仍不是百分之百准确的。另外,他的触发事件也有可能被阻塞,当GCD内部管理的所有线程都被占用时,其触发事件将被延迟。
可能会引起崩溃的操作
- 在remuse(恢复)的状态下,如果再进行一次resume(恢复)就会crash,所以要注册一个BOOL值的状态进行记录,防止多次suspend和resume引起闪退。
- 在suspend(暂停)的状态下,如果你设置timer = nil就会crash。
- 在suspend(暂停)的状态下,即使调用dispatch_source_cancel也没用,会造成内存泄漏,甚至崩溃。
CADisplayLink
CADisplayLink是基于屏幕刷新的周期,所以其一般很准时,每秒刷新60次。其本质也是通过RunLoop,所以不难看出,当RunLoop选择其他模式或被耗时操作过多时,仍旧会造成延迟。
其使用步骤为 创建CADisplayLink->添加至RunLoop中->终止->销毁。代码如下:
// 创建CADisplayLink
CADisplayLink *disLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(linkMethod)];
// 添加至RunLoop中
[disLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes];
// 终止定时器
[disLink invalidate];
// 销毁对象
disLink = nil;
由于其并非NSTimer的子类,直接使用NSRunLoop的添加Timer方法无法加入,应使用CADisplayLink自己的addToRunLoop:forMode:方法。
同时,由于其是基于屏幕刷新的,所以也度量单位是每帧,其提供了根据屏幕刷新来设置间隔的frameInterval
属性,其决定于屏幕刷新多少帧时调用一次该方法,默认为1,即1/60秒调用一次。
如果我们想要计算出每次调用的时间间隔,可以通过frameInterval * duration
求出,后者为屏幕每帧间隔的只读属性。
在日常开发中,适当使用CADisplayLink甚至有优化作用。比如对于需要动态计算进度的进度条,由于起进度反馈主要是为了UI更新,那么当计算进度的频率超过帧数时,就造成了很多无谓的计算。如果将计算进度的方法绑定到CADisplayLink上来调用,则只在每次屏幕刷新时计算进度,优化了性能。MBProcessHUB则是利用了这一特性。
performSelector:afterDelay
这种方式通常是用于在延时后去处理一些操作,其内部也是基于将timer加到runloop中实现的。因此也存在NSTimer的关于子线程runloop的问题。
[self performSelector:@selector(timerSelector:) withObject:nil afterDelay:2.f];
这种调用方式的好处是可以取消。
[NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(timerSelector:) object:nil];
注意以上是没有参数的。当有参数的时候必须保证两个方法的参数一样。否则无法取消。
[NSObject cancelPreviousPerformRequestsWithTarget:self];//取消所有的performSelector:方法
[[self class] cancelPreviousPerformRequestsWithTarget:self];//取消本类中的performSelector:方法