1. 什么是NSTimer
官方的解释“A timer provides a way to perform a delayed action or a periodic action. The timer waits until a certain time interval has elapsed and then fires, sending a specified message to a specified object. ” 翻译过来就是NSTimer 提供了一种执行延迟动作或周期动作的方法。可以指定时间间隔,向一个对象发送消息。
NSTimer是iOS最常用的定时器工具之一,比如用来定时更新界面,定时发送请求等等。但是在使用过程中,有很多需要注意的地方,稍微不注意就会产生 bug、crash、内存泄漏。
2. NSTimer的头文件
// Use the timerWithTimeInterval:invocation:repeats: or timerWithTimeInterval:target:selector:userInfo:repeats: class method to create the timer object without scheduling it on a run loop. (After creating it, you must add the timer to a run loop manually by calling the addTimer:forMode: method of the corresponding NSRunLoop object.)
// 创建一个定时器,但是么有添加到运行循环,我们需要在创建定时器后手动的调用 NSRunLoop 对象的 addTimer:forMode: 方法。
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti invocation:(NSInvocation *)invocation repeats:(BOOL)yesOrNo;
// Creates and returns a new NSTimer object and schedules it on the current run loop in the default mode.
// 创建一个timer并把它指定到一个默认的runloop模式中,并且在 TimeInterval时间后 启动定时器
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti invocation:(NSInvocation *)invocation repeats:(BOOL)yesOrNo;
// Use the timerWithTimeInterval:invocation:repeats: or timerWithTimeInterval:target:selector:userInfo:repeats: class method to create the timer object without scheduling it on a run loop. (After creating it, you must add the timer to a run loop manually by calling the addTimer:forMode: method of the corresponding NSRunLoop object.)
// 创建一个定时器,但是么有添加到运行循环,我们需要在创建定时器后手动的调用 NSRunLoop 对象的 addTimer:forMode: 方法。
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo;
// Creates and returns a new NSTimer object and schedules it on the current run loop in the default mode.
// 创建一个timer并把它指定到一个默认的runloop模式中,并且在 TimeInterval时间后 启动定时器
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo;
// 默认的初始化方法,(创建定时器后,手动添加到 运行循环,并且手动触发才会启动定时器)
- (instancetype)initWithFireDate:(NSDate *)date interval:(NSTimeInterval)ti target:(id)t selector:(SEL)s userInfo:(nullable id)ui repeats:(BOOL)rep NS_DESIGNATED_INITIALIZER;
iOS10之后新加入的方法,使用block避免循环引用引起的内存泄漏问题
/// 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.
+ (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));
/// 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.
+ (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));
/// Initializes a new NSTimer object using the block as the main body of execution for the timer. This timer needs to be scheduled on a run loop (via -[NSRunLoop addTimer:]) before it will fire.
- (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));
// You can use this method to fire a repeating timer without interrupting its regular firing schedule. If the timer is non-repeating, it is automatically invalidated after firing, even if its scheduled fire date has not arrived.
// 启动 Timer 触发Target的方法调用但是并不会改变Timer的时间设置。 即 time没有到达到,Timer会立即启动调用方法且没有改变时间设置,当时间 time 到了的时候,Timer还是会调用方法。
- (void)fire;
// 这是设置定时器的启动时间,常用来管理定时器的启动与停止
@property (copy) NSDate *fireDate;
// 启动定时器
timer.fireDate = [NSDate distantPast];
//停止定时器
timer.fireDate = [NSDate distantFuture];
// 开启
[time setFireDate:[NSDate distanPast]]
// NSTimer 关闭
[time setFireDate:[NSDate distantFunture]]
//继续。
[timer setFireDate:[NSDate date]];
// 这个是一个只读属性,获取定时器调用间隔时间
@property (readonly) NSTimeInterval timeInterval;
// Setting a tolerance for a timer allows it to fire later than the scheduled fire date, improving the ability of the system to optimize for increased power savings and responsiveness. The timer may fire at any time between its scheduled fire date and the scheduled fire date plus the tolerance. The timer will not fire before the scheduled fire date. For repeating timers, the next fire date is calculated from the original fire date regardless of tolerance applied at individual fire times, to avoid drift. The default value is zero, which means no additional tolerance is applied. The system reserves the right to apply a small amount of tolerance to certain timers regardless of the value of this property.
// As the user of the timer, you will have the best idea of what an appropriate tolerance for a timer may be. A general rule of thumb, though, is to set the tolerance to at least 10% of the interval, for a repeating timer. Even a small amount of tolerance will have a significant positive impact on the power usage of your application. The system may put a maximum value of the tolerance.
// 这是7.0之后新增的一个属性,因为NSTimer并不完全精准,通过这个值设置误差范围
@property NSTimeInterval tolerance NS_AVAILABLE(10_9, 7_0);
// 停止 Timer ---> 唯一的方法将定时器从循环池中移除
- (void)invalidate;
// 获取定时器是否有效
@property (readonly, getter=isValid) BOOL valid;
// 获取参数信息---> 通常传入的是 nil
@property (nullable, readonly, retain) id userInfo;
3. NSTimer的一般用法
3.1 初始化方法
系统提供了8个创建方法,6个类方法,2个实例初始化方法。
- 有三个方法直接将timer添加到了当前runloop,而不需要我们自己操作,当然这样的代价是runloop只能是当前runloop,模式是NSDefaultRunLoopMode
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti invocation:(NSInvocation *)invocation repeats:(BOOL)yesOrNo;
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(id)userInfo repeats:(BOOL)yesOrNo;
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block;
- 下面五种创建,不会自动添加到runloop,还需调用addTimer:forMode:方法添加到指定的mode中
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block;
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti invocation:(NSInvocation *)invocation repeats:(BOOL)yesOrNo;
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(id)userInfo repeats:(BOOL)yesOrNo;
- (instancetype)initWithFireDate:(NSDate *)date interval:(NSTimeInterval)ti target:(id)t selector:(SEL)s userInfo:(id)ui repeats:(BOOL)rep;
- (instancetype)initWithFireDate:(NSDate *)date interval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block;
初始化方法的参数说明
ti(NSTimeInterval):定时器触发间隔时间,单位为秒,可以是小数。如果值小于等于0.0的话,系统会默认赋值0.1毫秒
invocation(NSInvocation):这种形式用的比较少,大部分都是block和aSelector的形式
yesOrNo(BOOL):是否重复,如果是YES则重复触发,直到调用invalidate方法;如果是NO,则只触发一次就自动调用invalidate方法
aTarget(id):发送消息的目标,timer会强引用aTarget,直到调用invalidate方法
aSelector(SEL):将要发送给aTarget的消息,如果带有参数则参数为:(NSTimer *)timer
userInfo(id):传递的用户信息。使用的话,首先aSelector须带有参数的声明,然后可以通过[timer userInfo]获取,也可以为nil,那么[timer userInfo]就为空
date(NSDate):触发的时间,一般情况下我们都写[NSDate date],这样的话定时器会立马触发一次,并且以此时间为基准。如果没有此参数的方法,则都是以当前时间为基准,第一次触发时间是当前时间加上时间间隔ti
block(void (^)(NSTimer *timer)):timer触发的时候会执行这个操作,带有一个参数,无返回值
3.2 NSTimer的触发
-
-(void)fire方法说明:
调用fire的时候,立即触发timer的方法,该方法触发不影响计时器原本的计时,只是新增一次触发
1. 对于重复定时器,它不会影响正常的定时触发。 启动timer触发target的方法调用但是并不会改变timer的时间设置interval。 即 interval没有到达到,timer会立即启动调用方法且没有改变时间设置,当时间interval到了的时候,timer还是会调用selector。
2. 对于非重复定时器,触发后就调用了invalidate方法。既使interval的时间周期内还没有触发
- NSTimer在添加到runloop时,timer开始计时,即使runloop没有开启(run)。在构造NSTimer的时候,如果不是马上开始计时,可以先使用timerWithTimeInterval,随后再手动加入runloop上
- 当NSTimer进入后台的时,NSTimer计时暂停,进入前台继续
4. NSTimer和Runloop
上面构造函数我们可以看到,当我们把timer添加到runloop的时候会指定NSRunLoopMode(scheduledTimerWithTimeInterval默认使用NSDefaultRunLoopMode),iOS支持的有下面两种模式
NSDefaultRunLoopMode:默认的运行模式,用于大部分操作,除了NSConnection对象事件。
NSRunLoopCommonModes:是一个模式集合,当绑定一个事件源到这个模式集合的时候就相当于绑定到了集合内的每一个模式。
下面三种是内部框架支持(AppKit)
NSConnectionReplyMode:用来监控NSConnection对象的回复的,很少能够用到。
NSModalPanelRunLoopMode:用于标明和Mode Panel相关的事件。
NSEventTrackingRunLoopMode:用于跟踪触摸事件触发的模式(例如UIScrollView上下滚动)。
当timer添加到主线程的runloop时,某些UI事件(如:UIScrollView或者它的子类UITableView、UICollectionView等滑动时)会将runloop切换到NSEventTrackingRunLoopMode模式下。在这个模式下,NSDefaultRunLoopMode模式注册的事件是不会被执行的,也就是通过scheduledTimerWithTimeInterval方法添加到runloop的NSTimer这时候是不会被执行的。为了让NSTimer不被UI事件干扰,我们需要将注册到runloop的timer的mode设为NSRunLoopCommonModes,这个模式等效于NSDefaultRunLoopMode和NSEventTrackingRunLoopMode的结合
5. NSTimer循环引用的问题
5.1 NSTimer的造成循环引用的原因分析
如下,创建一个计时器
NSTimer *timer = [[NSTimer alloc] timerWithTimeInterval:1 target:self selector:@selector(timerFire) userInfo:nil repeats:YES];
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];
上述代码,将创建一个无限循环的 timer,并在当前线程的 runloop 中开始执行。
如下图所示:
Timer 添加到 Runloop 的时候,会被 Runloop 强引用;Timer 又会有一个对 Target 的强引用(也就是 self);也就是说 NSTimer 强引用了 self ,导致 self 一直不能被释放掉,所以也就走不到 self 的 dealloc 里。
主要是 NSTimer 对 target 是强引用的。如果在target调用dealloc之前没有释放timer就会造成内存的泄漏,或者生命周期超出开发者的预期。
那么,[timer invalidate] 要什么时候调用?
有些人会在 self 的 dealloc 里面调用,这几乎可以确定是错误的。因为 timer 会引用住 self,在 timer 停止之前,是不会释放 self 的,self 的 dealloc 也不可能会被调用。
5.2 NSTimer的造成循环引用的解决方案
- NSTimer在构造函数会对target强引用,在调用invalidate时,会移除去target的强引用
- NSTimer被加到Runloop的时候,会被runloop强引用持有,在调用invalidate的时候,会从runloop删除
- 当定时器是不重复的(repeat=NO),在执行完触发函数后,会自动调用invalidate解除runloop的注册和接触对target的强引用
5.2.1 根据业务需要,在适当的地方启动 timer 和 停止 timer。比如 timer 是页面用来更新页面内部的 view 的,那可以选择在页面显示的时候启动 timer,页面不可见的时候停止 timer。比如:
- (void)viewWillAppear
{
[super viewWillAppear];
self.timer =
[NSTimer scheduledTimerWithTimeInterval:1 target:self selector:@selector(update) userInfo:nil repeats:YES];
}
- (void)viewDidDisappear
{
[super viewDidDisappear];
[self.timer invalidate];
}
5.2.2 weakSelf:
问题的关键就在于 self 被 NSTimer 强引用了,如果我们能打破这个强引用问题自然而然就解决了。所以一个很简单的想法就是:weakSelf
_weak typeof(self) weakSelf = self;
_timer = [NSTimer scheduledTimerWithTimeInterval:3.0f
target:weakSelf
selector:@selector(timerFire:)
userInfo:nil
repeats:YES];
然而这并没有什么卵用,这里的 __weak 和 __strong 唯一的区别就是:如果在这两行代码执行的期间 self 被释放了, NSTimer 的 target 会变成 nil 。
5.2.3 target:既然没办法通过 __weak 把 self 抽离出来,我们可以造个假的 target 给 NSTimer 。这个假的 target 类似于一个中间的代理人,它做的唯一的工作就是挺身而出接下了 NSTimer 的强引用。实现方式如下:
@interface WeakTimer : NSObject
@property (nonatomic, weak) id target;
@property (nonatomic, assign) SEL selector;
@property (nonatomic, weak) NSTimer* timer;
@end
@implementation WeakTimer
- (void) fire:(NSTimer *)timer {
if(self.target) {
[self.target performSelector:self.selector withObject:timer.userInfo];
} else {
[self.timer invalidate];
}
}
@end
然后我们再封装个假的 scheduledTimerWithTimeInterval 方法,但是在调用的时候已经偷梁换柱了:
+ (NSTimer *) scheduledTimerWithTimeInterval:(NSTimeInterval)interval
target:(id)aTarget
selector:(SEL)aSelector
userInfo:(id)userInfo
repeats:(BOOL)repeats {
WeakTimer* timerTarget = [[WeakTimer alloc] init];
timerTarget.target = aTarget;
timerTarget.selector = aSelector;
timerTarget.timer = [NSTimer scheduledTimerWithTimeInterval:interval
target:timerTarget
selector:@selector(fire:)
userInfo:userInfo
repeats:repeats];
return timerTarget.timer;
}
5.2.4 block:如果能用 block 来调用 NSTimer 那岂不是更好了。我们可以这样来实现:
@interface NSTimer (XP)
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)seconds block:(void (^)(NSTimer *timer))block repeats:(BOOL)repeats;
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)seconds block:(void (^)(NSTimer *timer))block repeats:(BOOL)repeats;
@end
@implementation NSTimer (XP)
+ (void)_xp_timerBlock:(NSTimer *)timer {
if ([timer userInfo]) {
void (^block)(NSTimer *timer) = (void (^)(NSTimer *timer))[timer userInfo];
block(timer);
}
}
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)seconds block:(void (^)(NSTimer *timer))block repeats:(BOOL)repeats {
return [NSTimer scheduledTimerWithTimeInterval:seconds target:self selector:@selector(_xp_timerBlock:) userInfo:[block copy] repeats:repeats];
}
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)seconds block:(void (^)(NSTimer *timer))block repeats:(BOOL)repeats {
return [NSTimer timerWithTimeInterval:seconds target:self selector:@selector(_xp_timerBlock:) userInfo:[block copy] repeats:repeats];
}
@end
使用,以scheduledTimerWithTimeInterval方法为例:
__weak typeof(self) weakSelf = self;
[NSTimer scheduledTimerWithTimeInterval:1.F block:^(NSTimer * _Nonnull timer) {
NSLog(@"%@", weakSelf);
} repeats:YES];
这4种解决方法中个人比较偏向与使用block方法,并且在iOS10中系统已经新增了block方法,可见这也是Apple力推的方法。
5.3 停止 timer 可能会导致 self 对象销毁
值得注意的是,调用 [timer invalidate] 停止 timer,此时 timer 会释放 target,如果 timer 是最后一个持有 target 的对象,那么此次释放会直接触发 target 的 。比如:
- (void)onEnterBackground:(id)sender
{
[self.timer invalidate];
[self.view stopAnimation]; // dangerous!
}
以上代码,加入第一行的 invalidate 之后,self 被销毁了,那么第二行访问 self.view 时候,就会触发野指针 crash。因为 Objective-C 的方法里面,self 是没有被 retain 的。这种情况,有个临时的解决方案如下:
- (void)onEnterBackground:(id)sender
{
__weak id weakSelf = self;
[self.timer invalidate];
[weakSelf.view stopAnimation]; // dangerous!
}
将 self 改为弱引用。但是也是一个临时解决方案。正确解决方法是,查出其它对象没有引用 self 的时候,为什么 timer 还没停止。这个案例告诉大家,当见到 invalidate 被调用之后很神奇地出现了 self 野指针 crash 的时候,不要惊讶,就是 timer 没处理好。
6. 多线程
如果我们不在主线程使用Timer的时候,即使我们把timer添加到runloop,也不能被触发,因为主线程的runloop默认是开启的,而其他线程的runloop默认没有实现runloop,并且在后台线程使用NSTimer不能通过fire启动定时器,只能通过runloop不断的运行下去
- (void)viewDidLoad {
[super viewDidLoad];
// 使用新线程
[NSThread detachNewThreadSelector:@selector(startNewThread) toTarget:self withObject:nil];
}
- (void)startNewThread {
self.timer = [NSTimer timerWithTimeInterval:2 target:self selector:@selector(timerTest:) userInfo:nil repeats:YES];
// 添加到runloop
NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
[runLoop addTimer:self.timer forMode:NSDefaultRunLoopMode];
// 非主线程需要手动运行runloop,run方法会阻塞,直到没有输入源的时候返回(例如:timer从runloop中移除,invalidate)
[runLoop run]
}
7. NSTimer准确性
NSTimer触发是不精确的,如果由于某些原因错过了触发时间,例如执行了一个长时间的任务,那么NSTimer不会延后执行,而是会等下一次触发,相当于等公交错过了,只能等下一趟车,tolerance属性可以设置误差范围
NSTimer *timer = [NSTimer scheduledTimerWithTimeInterval:2 target:self selector:@selector(timerTest:) userInfo:nil repeats:YES];
// 误差范围1s内
timer.tolerance = 1;
7.1 第一种不准时:有可能跳过去
7.1.1 线程处理比耗时的事情时会发生
7.1.2 还有就是timer添加到的runloop模式不是runloop当前运行的模式,这种情况经常发生。
对于第一种情况我们不应该在timer上下功夫,而是应该避免这个耗时的工作。那么第二种情况,作为开发者这也是最应该去关注的地方,要留意,然后视情况而定是否将timer添加到runloop多个模式
虽然跳过去,但是,接下来的执行不会依据被延迟的时间加上间隔时间,而是根据之前的时间来执行。比如:
定时时间间隔为2秒,t1秒添加成功,那么会在t2、t4、t6、t8、t10秒注册好事件,并在这些时间触发。假设第3秒时,执行了一个超时操作耗费了5.5秒,则触发时间是:t2、t8.5、t10,第4和第6秒就被跳过去了,虽然在t8.5秒触发了一次,但是下一次触发时间是t10,而不是t10.5。
7.2 第二种不准时:不准点
比如上面说的t2、t4、t6、t8、t10,并不会在准确的时间触发,而是会延迟个很小的时间,原因也可以归结为2点:
1. RunLoop为了节省资源,并不会在非常准确的时间点触发
2. 线程有耗时操作,或者其它线程有耗时操作也会影响
以我来讲,从来没有特别准的时间,
iOS7以后,Timer 有个属性叫做 Tolerance (时间宽容度,默认是0),标示了当时间点到后,容许有多少最大误差。
它只会在准确的触发时间到加上Tolerance时间内触发,而不会提前触发(是不是有点像我们的火车,只会晚点。。。)。另外可重复定时器的触发时间点不受Tolerance影响,即类似上面说的t8.5触发后,下一个点不会是t10.5,而是t10 + Tolerance,不让timer因为Tolerance而产生漂移(突然想起嵌入式令人头疼的温漂)。
其实对于这种不准点,对我们开发影响并不大(基本是毫秒妙级别以下的延迟),很少会用到非常准点的情况。
如果对精度有要求,可以使用GCD定时器
8. 后台运行
NSTimer不支持后台运行(真机),但是模拟器上App进入后台的时候,NSTimer还会持续触发
如果需要后台运行可以通过下面两种方式支持
让App支持后台运行(运行音频)(在后台可以触发)
记录离开和进入App的时间,手动控制计时器(在后台不能触发)
第一种控制起来比较麻烦,通常建议手动控制,不在后台触发计时
9. Perform Delay
[NSObject performSelector:withObject:afterDelay:] 和 [NSObject performSelector:withObject:afterDelay:inMode:] 我们简称为 Perform Delay,他们的实现原理就是一个不循环(repeat 为 NO)的 timer。所以使用这两个接口的注意事项跟使用 timer 类似。需要在适当的地方调用 [NSObject cancelPreviousPerformRequestsWithTarget:selector:object:]
-
NSObject对象有一个performSelector可以用于延迟执行一个方法,其实该方法内部是启用一个Timer并添加到当前线程的runloop,原理与NSTimer一样,所以在非主线程使用的时候,需要保证线程的runloop是运行的,否则不会得到执行
如下:
- (void)viewDidLoad { [super viewDidLoad]; [NSThread detachNewThreadSelector:@selector(startNewThread) toTarget:self withObject:nil]; } - (void)startNewThread { // test方法不会触发,因为runloop默认不开启 [self performSelector:@selector(test) withObject:nil afterDelay:1]; } - (void)test { NSLog(@"test trigger"); }
10. 暂停、重新开启定时器
10.1 暂停、重新开启
//暂停
[_timer setFireDate:[NSDate distantFuture]];
//重新开启
[_timer setFireDate:[NSDate distantPast]];
比如,在页面消失的时候关闭定时器,然后等页面再次打开的时候,又开启定时器。(主要是为了防止它在后台运行,暂用CPU)可以使用下面的代码实现:
//页面将要进入前台,开启定时器
-(void)viewWillAppear:(BOOL)animated {
//开启定时器
[_timer setFireDate:[NSDate distantPast]];
}
//页面消失,进入后台不显示该页面,关闭定时器
-(void)viewDidDisappear:(BOOL)animated {
//关闭定时器
[_timer setFireDate:[NSDate distantFuture]];
}
10.2 销毁
if(_timer){
[_timer invalidate];
_timer = nil;
}
总结
总的来说使用NSTimer有两点需要注意
- NSTimer只有被注册到runloop才能起作用,fire不是开启定时器的方法,只是触发一次定时器的方法
- NSTimer会强引用target。invalidate取消runloop的注册和target的强引用,如果是非重复的定时器,则在触发时会自动调用invalidate
通常我们自己封装GCD定时器使用起来更为方便,不会有这些问题