NSTimer精准度及影响因素分析

项目中经常会遇到需要定时循环执行某些方法的场景,例如发送短信倒计时需求,此时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长时间调出期间

该情况如何理解呢?

扫描二维码关注公众号,回复: 8642215 查看本文章

其实run loops是在循环不断的处理不同类型的事件,其中NSTimer属于CFRunLoopTimerRef类型的事件,如图所示:

run loops

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/ObserverNSTimer触发需要指定一个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 loopsNSTimer,同时应当注意到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触发事件中进行耗时操作,如果不能避免,将耗时操作移至其余线程进行。
发布了71 篇原创文章 · 获赞 34 · 访问量 9万+

猜你喜欢

转载自blog.csdn.net/TuGeLe/article/details/88138648