项目中经常会遇到需要定时循环执行某些方法的场景,例如发送短信倒计时需求,此时NSTimer
就派上用场了,关于NSTimer
的用法在此就不做多的解释,这里要讨论的是NSTimer
真的可以准确完成定时触发的功能吗?
一、NSTimer是否是精准的?
首先我们先看一下苹果官方对于NSTimer
的定义:
Timers work in conjunction with run loops. Run loops maintain strong references to their timers, so you don’t have to maintain your own strong reference to a timer after you have added it to a run loop.
该段概述提到了NSTimer
是基于run loops
运行的,同时NSTimer
必须添加至run loops
后才能够正常触发。
关于run loops
的运行原理,可以参考该篇文章:《Runloop机制解析及应用》。
另外在官方介绍中,提到了下面相关内容:
A timer is not a real-time mechanism. If a timer’s firing time occurs during a long run loop callout or while the run loop is in a mode that isn't monitoring the timer, the timer doesn't fire until the next time the run loop checks the timer. Therefore, the actual time at which a timer fires can be significantly later. See also Timer Tolerance.
该段介绍提到了如果timer
触发的时间点发生在run loops
长时间调出时间或者run loops
所处的模式不对timer
进行监控,此时timer
就不会在指定时间点触发。
由官方文档可知,NSTimer
在某些情况下并不是准确触发的,下面我们模拟一下这些情况:
首先模拟一下NSTimer
正常触发的情况:
- (void)viewDidLoad {
[super viewDidLoad];
[NSTimer scheduledTimerWithTimeInterval:1 target:self selector:@selector(timerAction) userInfo:nil repeats:YES];
}
- (void)timerAction {
NSLog(@"--->timer action");
}
此时的输出如下:
2019-03-04 15:35:18.307775+0800 TempDemo[73807:20248724] --->timer action
2019-03-04 15:35:19.306643+0800 TempDemo[73807:20248724] --->timer action
2019-03-04 15:35:20.307151+0800 TempDemo[73807:20248724] --->timer action
2019-03-04 15:35:21.307057+0800 TempDemo[73807:20248724] --->timer action
2019-03-04 15:35:22.307683+0800 TempDemo[73807:20248724] --->timer action
2019-03-04 15:35:23.307638+0800 TempDemo[73807:20248724] --->timer action
由输出可知,在正常情况下,NSTimer
可以保证精确触发,触发时机的误差保持在1ms之内。
1. NSTimer触发时间点在run loops长时间调出期间
该情况如何理解呢?
其实run loops
是在循环不断的处理不同类型的事件,其中NSTimer
属于CFRunLoopTimerRef
类型的事件,如图所示:
当run loops
处于正常状态,那么在不停循环检测过程中,能够保证在合适的某次循环中触发NSTimer
,同时这也是NSTimer
在正常状态下有部分时间偏差的原因。
但是run loops
在处理不同类型的事件时,可能会发生阻塞情况,在这些情况下,就有可能出现问题。
(1) run loops
在处理timer
事件时阻塞
当run loops
在处理timer
事件时耗时严重,导致在对应时间间隔内没有循环执行到处理下一个timer
触发事件,就会造成NSTimer
的不准确。
要模拟该类情况,我们只需要在timer
触发的事件中执行一段耗时操作即可(需要保证该耗时操作时间大于一次循环间隔才能看出效果)。
- (void)viewDidLoad {
[super viewDidLoad];
[NSTimer scheduledTimerWithTimeInterval:1 target:self selector:@selector(timerAction) userInfo:nil repeats:YES];
}
- (void)timerAction {
NSLog(@"--->begin timer action");
//模拟timer中耗时操作
int count = 0;
for (int i = 0; i < 1000000000; i++) {
count += i;
}
NSLog(@"--->end timer action");
}
在该模拟下的输出结果如下:
2019-03-04 17:03:00.598161+0800 TempDemo[74774:20287875] --->begin timer action
2019-03-04 17:03:03.249786+0800 TempDemo[74774:20287875] --->end timer action
2019-03-04 17:03:03.598479+0800 TempDemo[74774:20287875] --->begin timer action
2019-03-04 17:03:06.254242+0800 TempDemo[74774:20287875] --->end timer action
2019-03-04 17:03:06.598461+0800 TempDemo[74774:20287875] --->begin timer action
2019-03-04 17:03:09.249965+0800 TempDemo[74774:20287875] --->end timer action
2019-03-04 17:03:09.598343+0800 TempDemo[74774:20287875] --->begin timer action
由输出可知,在这种情况下,NSTimer
在进行一次触发时,会丢失之后的部分触发,当该次触发结束后,之后的触发又能够正常进行。
(2) run loops
在处理非timer
事件时阻塞
当run loops
在处理某一类非timer
事件时耗时严重,导致在对应时间间隔内没有循环执行到处理CFRunLoopTimerRef
类型事件时,就会造成NSTimer
的不精确。
要模拟该类情况,我们只需要在NSTimer
循环调用期间执行一段耗时操作代码即可(需保证该耗时操作时间大于一次循环间隔才能看出效果)。
- (void)viewDidLoad {
[super viewDidLoad];
[NSTimer scheduledTimerWithTimeInterval:1 target:self selector:@selector(timerAction) userInfo:nil repeats:YES];
// 模拟耗时操作
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
int count = 0;
for (int i = 0; i < 1000000000; i++) {
count += i;
}
});
}
- (void)timerAction {
NSLog(@"--->timer action");
}
在该模拟下的输出结果如下:
2019-03-04 15:53:49.880060+0800 TempDemo[74017:20256998] --->timer action
2019-03-04 15:53:50.879945+0800 TempDemo[74017:20256998] --->timer action
2019-03-04 15:53:53.734708+0800 TempDemo[74017:20256998] --->timer action
2019-03-04 15:53:53.880392+0800 TempDemo[74017:20256998] --->timer action
2019-03-04 15:53:54.879653+0800 TempDemo[74017:20256998] --->timer action
2019-03-04 15:53:55.879377+0800 TempDemo[74017:20256998] --->timer action
由输出结果可见,在这种情况下,NSTimer
的触发已经不准确了(本应在15:53:51.880左右触发的事件最终并未触发,本应在15:53:52.880左右触发的事件被延迟到15:53:53.734左右触发),但是在耗时操作结束之后,NSTimer
的触发又恢复了正常。
2. run loops
所处mode不会对NSTimer
监控
这类情况该如何理解呢?
run loops
在循环时,每次循环都会有一个对应的mode
,该次循环会处理对应mode
下的事件,比如Source/Timer/Observer
,NSTimer
触发需要指定一个mode
,当run loops
处于其余模式的情况下,NSTimer
就不会触发,从而造成NSTimer
的不准确。
要模拟这种情况,我们可以正常创建NSTimer
(该timer处于NSDefaultRunLoopMode
模式下),然后我们可以创建一个ScrollView,通过滚动ScrollView来把主线程的run loops
切换至UITrackingRunLoopMode
模式(UITrackingRunLoopMode
模式优先级高于NSDefaultRunLoopMode
模式)。
- (void)viewDidLoad {
[super viewDidLoad];
//通过该方法创建的Timer默认处于NSDefaultRunLoopMode模式下
[NSTimer scheduledTimerWithTimeInterval:1 target:self selector:@selector(timerAction) userInfo:nil repeats:YES];
UIScrollView *scrollView = [[UIScrollView alloc] initWithFrame:self.view.bounds];
scrollView.backgroundColor = [UIColor redColor];
scrollView.contentSize = CGSizeMake(self.view.bounds.size.width, self.view.bounds.size.height * 3);
[self.view addSubview:scrollView];
}
- (void)timerAction {
NSLog(@"--->timer action");
}
我们在NSTimer
触发后,通过滚动ScrollView来切换主线程的run loops
的模式,得到如下输出:
2019-03-04 16:10:41.452006+0800 TempDemo[74248:20264903] --->timer action
2019-03-04 16:10:42.451895+0800 TempDemo[74248:20264903] --->timer action
2019-03-04 16:10:47.258628+0800 TempDemo[74248:20264903] --->timer action
2019-03-04 16:10:47.451128+0800 TempDemo[74248:20264903] --->timer action
2019-03-04 16:10:48.451959+0800 TempDemo[74248:20264903] --->timer action
2019-03-04 16:10:49.450980+0800 TempDemo[74248:20264903] --->timer action
由输出可见,在滚动ScrollView的这段时间内(16:10:43到16:10:47),丢失了NSTimer
的部分触发,同时也有部分触发时间点发生了偏移。
二、分析解决NSTimer
不精确问题
为解决NSTimer
不精确的问题,我们可以考虑一下上述两种情况造成触发不准确的原因。
第一种情况原因为在run loops
循环过程中,被NSTimer
触发事件阻塞了,导致循环不能及时进行下去,进而贻误之后NSTimer
触发时间。
第二种情况原因为在run loops
循环过程中,在某一时刻发生了阻塞情况,导致循环不能及时进行下去,进而贻误NSTimer
触发时间。
第三种情况原因为在run loops
循环过程中,发生了模式的转换,导致原有模式下的NSTimer
不会正常触发。
以上情况都是由于NSTimer
所依赖的run loops
会被多种原因干扰正常循环,所以要想解决NSTimer
精度问题,就要避免所依赖的run loops
被外界干扰。
- 注意:虽然第三种情况可以指定
NSTimer
所处模式为NSRunLoopCommonModes
,但是这种解决方法并不能改变run loops
在特定模式下不能处理其余模式事件的本质。
那么,如何避免run loops
被外界干扰呢?
前面在官方文档中提到了NSTimer
必须被添加至run loops
中才能正常进行,其中每一个线程都会有自己对应的run loops
。
在前面几个例子中,我们都是将NSTimer
放入主线程的run loops
中,而主线程的run loops
容易被多种因素影响(例如视图的点击滚动事件等)。
我们可以将NSTimer
放入一个专门负责timer
的线程中,由该线程的run loops
负责触发。
首先我们实现一个正常的由子线程run loops
触发的NSTimer
:
- (void)viewDidLoad {
[super viewDidLoad];
dispatch_queue_t queue = dispatch_queue_create("com.mademao.timer", DISPATCH_QUEUE_CONCURRENT);
dispatch_async(queue, ^{
//NSTimer会被添加到当前线程的run loops中
[NSTimer scheduledTimerWithTimeInterval:1 target:self selector:@selector(timerAction) userInfo:nil repeats:YES];
});
}
- (void)timerAction {
NSLog(@"--->timer action");
}
编译运行之后发现NSTimer
并没有被触发,这是由于子线程的run loops
虽然会在需要的时候被自动创建,但是需要手动启动run loops
循环:
- (void)viewDidLoad {
[super viewDidLoad];
dispatch_queue_t queue = dispatch_queue_create("com.mademao.timer", DISPATCH_QUEUE_CONCURRENT);
dispatch_async(queue, ^{
//NSTimer会被添加到当前线程的run loops中
[NSTimer scheduledTimerWithTimeInterval:1 target:self selector:@selector(timerAction) userInfo:nil repeats:YES];
[[NSRunLoop currentRunLoop] run];
});
}
- (void)timerAction {
NSLog(@"--->timer action");
}
至此,我们就正常启动了一个基于子线程run loops
的NSTimer
,同时应当注意到NSTimer
触发事件是在子线程中触发的。
那么我们该如何避免上述第一种情况造成的NSTimer
不精确问题呢?
第一种情况是由于耗时操作阻塞了当前run loops
处理timer
事件,我们可以将阻塞操作交于其余线程去处理,避开对本线程run loops
循环的阻塞,如下:
- (void)viewDidLoad {
[super viewDidLoad];
dispatch_queue_t queue = dispatch_queue_create("com.mademao.timer", DISPATCH_QUEUE_CONCURRENT);
dispatch_async(queue, ^{
//NSTimer会被添加到当前线程的run loops中
[NSTimer scheduledTimerWithTimeInterval:1 target:self selector:@selector(timerAction) userInfo:nil repeats:YES];
[[NSRunLoop currentRunLoop] run];
});
}
- (void)timerAction {
NSLog(@"--->begin timer action");
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
int count = 0;
for (int i = 0; i < 1000000000; i++) {
count += i;
}
});
NSLog(@"--->end timer action");
}
执行后发现NSTimer
正常触发。
接下来我们解决第二种造成NSTimer
不精准的情况
其实第二种情况造成NSTimer
不精准的原因在于耗时操作阻塞了NSTimer
所依赖run loops
处理非timer
类型事件方法。所以要解决该情况,避免耗时操作进入NSTimer
所在子线程即可:
- (void)viewDidLoad {
[super viewDidLoad];
dispatch_queue_t queue = dispatch_queue_create("com.mademao.timer", DISPATCH_QUEUE_CONCURRENT);
dispatch_async(queue, ^{
//NSTimer会被添加到当前线程的run loops中
[NSTimer scheduledTimerWithTimeInterval:1 target:self selector:@selector(timerAction) userInfo:nil repeats:YES];
[[NSRunLoop currentRunLoop] run];
});
// 模拟耗时操作
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
int count = 0;
for (int i = 0; i < 1000000000; i++) {
count += i;
}
});
}
- (void)timerAction {
NSLog(@"--->timer action");
}
在上述代码中,耗时操作阻塞的是主线程中的run loops
循环,而并不会阻塞NSTimer
所在线程的run loops
循环,所以NSTimer
不精准的问题迎刃而解。
最后解决由于run loos
模式改变引起的NSTimer
不精准问题
其实到这里,这个问题如同第二种情况一样已经被解决了。
只要我们不手动去改变子线程run loops
所处模式,那么视图点击滚动等操作只会改变主线程run loops
所处模式,并不会改变子线程run loops
所处模式。只要run loops
所处模式没有发生变化,那么NSTimer
就可以正常循环触发。
三、总结
本篇文章我们了解了能影响NSTimer
的操作,同时结合run loops
相关知识分析了这些操作影响NSTimer
的底层原因,之后我们便可以最大程度的避免NSTimer
触发的不精确,当然我们以后在开发中应当注意以下几点:
- 尽量避免将
NSTimer
放入容易受到影响的主线程run loops
中。 - 尽量避免将耗时操作放入
NSTimer
依赖的线程中。 - 尽量避免在
NSTimer
触发事件中进行耗时操作,如果不能避免,将耗时操作移至其余线程进行。