iOS 中精确定时的常用方法

avatar
奇舞团移动端团队 @奇舞团

级别: ★☆☆☆☆
标签:「iOS」「定时 」
作者: dac_1033
审校: QiShare团队


定时器用于延迟一段时间或在指定时间点执行特定的代码,之前我们介绍过iOS中处理定时任务常用方法,通过不同方法创建的定时器,其可靠性与精度都有不同。

  1. 定时器与runLoop:定时器NSTimer、CADisplayLink,底层基本都是由 runLoop 支持的。iOS中每个线程内部都会有一个NSRunLoop ,可以通过[NSRunLoop currentRunLoop]获取当前线程中的runLoop ,二者是一一对应关系。runLoop 启动之后,就能够让线程在没有消息时休眠,在有消息时被唤醒并处理消息,避免资源长期被占用。定时器可以作为资源被 add 到 runLoop 中,受runLoop循环的控制及影响。
  2. 可靠性指是否严格按照设定的时间间隔按时执行selector;精度指支持的最小时间间隔是多少,对程序中的定时器而言,由于线程的切换,处理任务的耗时程度不同,可靠性和精度只是参考值。

1. NSTimer的精度

影响NSTimer的执行selector的因素:NSTimer被添加到特定mode的runLoop中;该mode型的runloop正在运行;到达激发时间。 runLoop 切换模式时,NSTimer 如果处于default模式下可能不会被触发。每个 runLoop 的循环间隔也无法保证,一般时间间隔限制为50-100毫秒比较合理,如果某个任务比较耗时,runLoop 的处理下一个就会被顺延,也就是说NSTimer但并不可靠。

测试代码:

#import "QiNSTimer.h"

#define QiNSTimerInterval    0.0001

@interface QiNSTimer ()

@property (nonatomic, strong) NSTimer *timer;
@property (nonatomic, strong) NSLock *lock;

@property (nonatomic, assign) NSInteger count;
@property (nonatomic, assign) NSTimeInterval lastTS;

@end

@implementation QiNSTimer


#pragma mark - NSTimer Methods

- (void)resumeTimer {
    
    if (_timer) {
        [self pauseTimer];
    }
    _timer = [NSTimer scheduledTimerWithTimeInterval:QiNSTimerInterval target:self selector:@selector(onTimeout:) userInfo:nil repeats:YES];
    [[NSRunLoop currentRunLoop] addTimer:_timer forMode:NSRunLoopCommonModes];
    [[NSRunLoop currentRunLoop] run];
    [_timer fire];
}

- (void)pauseTimer {

    [_timer invalidate];
    _timer = nil;
}

- (void)onTimeout:(NSTimer *)sender {
    
    NSTimeInterval ts = [[NSDate date] timeIntervalSince1970];
    NSLog(@"---QiNSTimer--->>%ld  %.5f", (long)_count++, ts - _lastTS);
    _lastTS = ts;
}

@end

实验设置:在代码中我们只通过NSLog打印了两次执行onTimeout的时间差,我们通过对比ts - lastTS与QiNSTimerInterval的值、1s内执行次数,来确定NSTimer可否满足QiNSTimerInterval这个精度。
注意:我们避免了onTimeout任何耗时操作,从而尽量保证NSLog打印出的定时的精确性。

//// 实验结果:

// QiNSTimerInterval为0.01时
2019-07-22 18:42:50.516502+0800 QiTimer[1063:226400] ---QiNSTimer--->>1  0.01002
2019-07-22 18:42:50.526461+0800 QiTimer[1063:226400] ---QiNSTimer--->>2  0.00996
2019-07-22 18:42:50.536480+0800 QiTimer[1063:226400] ---QiNSTimer--->>3  0.01002
.
.
.
2019-07-22 18:42:51.506502+0800 QiTimer[1063:226400] ---QiNSTimer--->>100  0.01055
2019-07-22 18:42:51.516437+0800 QiTimer[1063:226400] ---QiNSTimer--->>101  0.00998
2019-07-22 18:42:51.526183+0800 QiTimer[1063:226400] ---QiNSTimer--->>102  0.00974

// QiNSTimerInterval为0.001时
2019-07-22 18:45:59.655696+0800 QiTimer[1075:227871] ---QiNSTimer--->>1  0.00095
2019-07-22 18:45:59.656705+0800 QiTimer[1075:227871] ---QiNSTimer--->>2  0.00101
2019-07-22 18:45:59.657709+0800 QiTimer[1075:227871] ---QiNSTimer--->>3  0.00100
.
.
.
2019-07-22 18:46:00.654778+0800 QiTimer[1075:227871] ---QiNSTimer--->>1000  0.00104
2019-07-22 18:46:00.655737+0800 QiTimer[1075:227871] ---QiNSTimer--->>1001  0.00096
2019-07-22 18:46:00.656741+0800 QiTimer[1075:227871] ---QiNSTimer--->>1002  0.00100

// QiNSTimerInterval为0.0001时
2019-07-22 18:48:07.960160+0800 QiTimer[1085:228783] ---QiNSTimer--->>1  0.00040
2019-07-22 18:48:07.960422+0800 QiTimer[1085:228783] ---QiNSTimer--->>2  0.00027
2019-07-22 18:48:07.960646+0800 QiTimer[1085:228783] ---QiNSTimer--->>3  0.00022
.
.
.
2019-07-22 18:48:09.316050+0800 QiTimer[1085:228783] ---QiNSTimer--->>10001  0.00012
2019-07-22 18:48:09.316157+0800 QiTimer[1085:228783] ---QiNSTimer--->>10002  0.00011
2019-07-22 18:48:09.316253+0800 QiTimer[1085:228783] ---QiNSTimer--->>10003  0.00009

说明:
在设置不同timeInterval值实验时,对比log左侧时间戳及log数量。当QiNSTimerInterval为0.001时,1秒钟内打印了1000条log,两条log的时间间隔可控,也即NSTimer允许1ms的时间精度。当QiNSTimerInterval为0.0001时,进行以上对比,数据出现偏差。因此,我们得出,理想状态下NSTimer的精度为1ms。

注意:

  1. NSTimer的时间精度虽然为1ms,但是只是理想状态下,任何操作都可能会使onTimeout延时执行。例如,现实中,我们在界面输出一个倒计时,如果设置QiNSTimerInterval为0.001,界面中秒位的变化明显变慢,正常使用NSTimer进行毫秒刷新时,一般只精确到100ms才不会感到异常。
  2. 在一定程度上保证timer“准时”的方法:在子线程中创建timer,在子线程中进行定时任务的操作,需要UI操作时切换回主线程进行操作;或者在子线程中创建timer,在主线程进行定时任务的操作。

2. GCDTimer 的精度

回顾一下 GCDTimer 的基本实现过程:

// 1. 创建 dispatch source,指定检测事件为定时
dispatch_source_t timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_global_queue("Timer_Queue", 0));
// 2. 设置定时器启动时间、间隔
dispatch_source_set_timer(timer, DISPATCH_TIME_NOW, 0.5 * NSEC_PER_SEC,  0 * NSEC_PER_SEC); 
// 3. 设置callback
dispatch_source_set_event_handler(timer, ^{
        NSLog(@"timer fired");
    });
dispatch_source_set_event_handler(timer, ^{
       //取消定时器时一些操作
    });
// 4. 启动定时器(刚创建的source处于被挂起状态)
dispatch_resume(timer);
// 5. 暂停定时器
dispatch_suspend(timer);
// 6. 取消定时器
dispatch_source_cancel(timer);
timer = nil;

GCDTimer相较于NSTimer的代码处理过程优点很明显,NSTimer必须保证有一个活跃的runloop、创建与撤销必须在同一个线程操作、内存管理有潜在泄露的风险等,从上面的实现过程就可以看出使用GCDTimer基本没有这些顾虑。按照NSTimer的测试逻辑对GCDTimer也进行相应测试,代码如下:

#import "QiGCDTimer.h"

@interface QiGCDTimer ()

@property (strong, nonatomic) dispatch_source_t timer;

@property (nonatomic, assign) NSInteger count;
@property (nonatomic, assign) NSTimeInterval lastTS;

@end

@implementation QiGCDTimer

+ (QiGCDTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats queue:(dispatch_queue_t)queue block:(void (^)(void))block {
    
    QiGCDTimer *timer = [[QiGCDTimer alloc] initWithInterval:interval repeats:repeats queue:queue block:block];
    return timer;
}

- (instancetype)initWithInterval:(NSTimeInterval)interval repeats:(BOOL)repeats queue:(dispatch_queue_t)queue block:(void (^)(void))block {
    
    self = [super init];
    if (self) {
        _timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue);
        dispatch_source_set_timer(self.timer, dispatch_time(DISPATCH_TIME_NOW, interval * NSEC_PER_SEC), interval * NSEC_PER_SEC, 0);
        dispatch_source_set_event_handler(self.timer, ^{
            if (!repeats) {
                dispatch_source_cancel(self.timer);
            }
            block();
            
            
            //// 测试
            [self onTimeout];
        });
        dispatch_resume(self.timer);
    }
    return self;
}

- (void)dealloc {
    
    [self invalidate];
}

- (void)invalidate {
    
    if (self.timer) {
        dispatch_source_cancel(self.timer);
    }
}

- (void)onTimeout {
    
    NSTimeInterval ts = [[NSDate date] timeIntervalSince1970];
    NSLog(@"---QiGCDTimer--->>%ld  %.5f", (long)_count++, ts - _lastTS);
    _lastTS = ts;
}

@end

测试结果及应说明的事项基本与NSTimer一致。

3. CADisplayLink

CADisplayLink 属于 QuartzCore框架,它调用间隔与屏幕刷新频率一致,每秒 60 帧,间隔 16.67ms。 当需与显示更新同步的定时时(如刷新界面动画等),建议CADisplayLink,可以省去一些多余的计算。我们之前没有介绍过CADisplayLink,下面我们看一下CADisplayLink的用法和精度:

3.1 调用形式
- (void)resumeCADisplayLink {

        _displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(rotate)];
        _displayLink.frameInterval = 1;
        [_displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes];
}

- (void) pauseCADisplayLink {

    [_displayLink invalidate];
    _displayLink = nil;
}

3.2 几个属性
  • frameInterval
    表示间隔多少帧调用一次selector,默认为1,即每帧都调用一次。官方文档中强调,当该值被设定小于1时,结果是不可预知的。
  • duration
    表示两次屏幕刷新之间的时间间隔,只读属性,该属性在target的selector被首次调用以后才会被赋值,我们可以计算出selector的调用间隔时间为duration * frameInterval。 现存的iOS设备屏幕的刷新频率为60Hz,这一点可以从CADisplayLink的duration属性看出来。duration的值为1/60,即0.166666...
  • timestamp
    表示屏幕显示的上一帧的时间戳,只读属性,CFTimeInterval类型,该属性通常被target用来计算下一帧中应该显示的内容。
  • preferredFramesPerSecond
    可以通过该属性来设置CADisplayLink每秒刷新次数,默认值为屏幕最大帧率60Hz,如果在特定帧率内无法提供对象的操作,可以通过降低帧率解决,实际的屏幕帧率会和手动设置的preferredFramesPerSecond值有一定的出入。
3.3 CADisplayLink的精度

iOS设备的屏幕刷新频率(FPS)是60Hz,CADisplayLink调用间隔与屏幕刷新频率一致,即最小精度为 16.67 ms。

同样按照NSTimer的测试逻辑对CADisplayLink也进行相应测试,代码如下:

#import "QiCADisplayLink.h"
#import <QuartzCore/QuartzCore.h>

@interface QiCADisplayLink ()

@property (nonatomic, strong) CADisplayLink *displayLink;

@property (nonatomic, assign) NSInteger count;
@property (nonatomic, assign) NSTimeInterval lastTS;

@end

@implementation QiCADisplayLink


#pragma mark - NSTimer Methods

- (void)resumeDisplayLink {

    _displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(onTimeout)];
    [_displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes];
}

- (void)pauseDisplayLink {

    [_displayLink invalidate];
    _displayLink = nil;
}


- (void)onTimeout {
    
    NSTimeInterval ts = [[NSDate date] timeIntervalSince1970];
    NSLog(@"---QiCADisplayLink--->>%ld  %.5f", (long)_count++, ts - _lastTS);
    _lastTS = ts;
}

@end
//// 测试结果
2019-07-23 10:10:49.027269+0800 QiTimer[659:82685] ---QiCADisplayLink--->>1  0.01681
2019-07-23 10:10:49.043827+0800 QiTimer[659:82685] ---QiCADisplayLink--->>2  0.01659
2019-07-23 10:10:49.060542+0800 QiTimer[659:82685] ---QiCADisplayLink--->>3  0.01671
.
.
.
2019-07-23 10:10:50.010421+0800 QiTimer[659:82685] ---QiCADisplayLink--->>60  0.01664
2019-07-23 10:10:50.027155+0800 QiTimer[659:82685] ---QiCADisplayLink--->>61  0.01673
2019-07-23 10:10:50.043830+0800 QiTimer[659:82685] ---QiCADisplayLink--->>62  0.01669

注意:

  1. 理想状态下,1s内执行60次,最小精度为16.7ms左右,精度误差一般在 0.1 ~ 0.5 毫秒之间,精度比 NSTimer 要高。CADisplayLink运行在主线程中在耗时任务之后,精度也不可控,需要借助多线程处理。
  2. 如果想保证精度,需要先确保任务能够在最小时间间隔内执行完成,CADisplayLink 就比较可靠(例如毫秒级倒计时,这种比较简单非耗时任务可以保证质量,但是每次倒计时应以16.7ms为单位累加)。

4. iOS/OS X 中的高精度定时器

上述的几种定时器虽然形式与用法不一,但核心逻辑实际是一样的,都受限于苹果为提高性能采用的各种策略,可能导致下一次无法实时地执行selector。如果你确有需求要使用更高精度的定时器(一般视频/音频、精确帧速率的游戏等相关数据流操作中会需要),苹果也提供了相应方法 iOS/OS X 中的高精度定时器。这里说的高精度定时器与之前介绍的几个定时器处理逻辑不一样,它是基于高优先级的线程调度类创建的定时器,在没有多线程冲突的情况下,这类定时器的请求会被优先处理。

iOS/OS X 中的高精度定时器逻辑:把定时器所在的线程,移到高优先级的线程调度类;使用底层更精确的计时器API(以CPU时钟为参照的计时API)。

4.1 使用过程
  • 将计时线程,调度为实时线程 把定时器所在的线程,移到高优先级的线程调度类,即the real time scheduling class中:
#include <mach/mach.h>
#include <mach/mach_time.h>
#include <pthread.h>
 
void move_pthread_to_realtime_scheduling_class(pthread_t pthread)
{
    mach_timebase_info_data_t timebase_info;
    mach_timebase_info(&timebase_info);
 
    const uint64_t NANOS_PER_MSEC = 1000000ULL;
    double clock2abs = ((double)timebase_info.denom / (double)timebase_info.numer) * NANOS_PER_MSEC;
 
    thread_time_constraint_policy_data_t policy;
    policy.period      = 0;
    policy.computation = (uint32_t)(5 * clock2abs); // 5 ms of work
    policy.constraint  = (uint32_t)(10 * clock2abs);
    policy.preemptible = FALSE;
 
    int kr = thread_policy_set(pthread_mach_thread_np(pthread_self()),
                   THREAD_TIME_CONSTRAINT_POLICY,
                   (thread_policy_t)&policy,
                   THREAD_TIME_CONSTRAINT_POLICY_COUNT);
    if (kr != KERN_SUCCESS) {
        mach_error("thread_policy_set:", kr);
        exit(1);
    }
}
  • 会用到的计时API
    使用更精确的计时API
    mach_wait_until(),如下代码使用mach_wait_until()等待10秒:
#include <mach/mach.h>
#include <mach/mach_time.h>
 
static const uint64_t NANOS_PER_USEC = 1000ULL;
static const uint64_t NANOS_PER_MILLISEC = 1000ULL * NANOS_PER_USEC;
static const uint64_t NANOS_PER_SEC = 1000ULL * NANOS_PER_MILLISEC;
 
static mach_timebase_info_data_t timebase_info;
 
static uint64_t abs_to_nanos(uint64_t abs) {
    return abs * timebase_info.numer  / timebase_info.denom;
}
 
static uint64_t nanos_to_abs(uint64_t nanos) {
    return nanos * timebase_info.denom / timebase_info.numer;
}
 
void example_mach_wait_until(int argc, const char * argv[])
{
    mach_timebase_info(&timebase_info);
    uint64_t time_to_wait = nanos_to_abs(10ULL * NANOS_PER_SEC);
    uint64_t now = mach_absolute_time();
    mach_wait_until(now + time_to_wait);
}
4.2 该定时器的精度

mach_absolute_time() 用于获取机器时间(单位是纳秒),测试代码来源于网络,其功能展示了高精度定时器与NSTimer的对比。

5. 总结

  1. NSTimer 最常用,需要注意的就是加入的 runLoop 的 Mode ,若是子线程,需要手动 run 这个 RunLoop ;同时注意使用 invalidate 手动停止定时,否则引起内存泄漏;NSTimer的创建与撤销必须在同一个线程操作,不能跨越线程操作;
  2. GCD Timer 较 NSTimer 精度高,一般用于对文件资源等定期读写操作很方便,使用时需要注意 dispatch_resume 与 dispatch_suspend 配套,并且要给 dispatch source 设置新值或者置nil,需先 dispatch_source_cancel(timer) ,否则会导致崩溃;
  3. 需与显示更新同步的定时,建议 CADisplayLink ,可以省去多余计算;
  4. 高精度定时,一般视频/音频、精确帧速率的游戏等相关数据流操作中会需要;
  5. iOS中任何定时器的精度,都只是个参考值。

工程源码GitHub地址


推荐文章:
Sign In With Apple(一)
算法小专栏:动态规划(一)
Dart基础(一)
Dart基础(二)
Dart基础(三)
Dart基础(四)
iOS 短信验证码倒计时按钮
iOS 环境变量配置
iOS 中处理定时任务的常用方法
奇舞周刊