iOS 中精確定時的常用方法

定時器用于延遲一段時間或在指定時間點執(zhí)行特定的代碼,之前我們介紹過iOS中處理定時任務常用方法,通過不同方法創(chuàng)建的定時器,其可靠性與精度都有不同。

  1. 定時器與runLoop:定時器NSTimer、CADisplayLink,底層基本都是由 runLoop 支持的。iOS中每個線程內部都會有一個NSRunLoop ,可以通過[NSRunLoop currentRunLoop]獲取當前線程中的runLoop ,二者是一一對應關系。runLoop 啟動之后,就能夠讓線程在沒有消息時休眠,在有消息時被喚醒并處理消息,避免資源長期被占用。定時器可以作為資源被 add 到 runLoop 中,受runLoop循環(huán)的控制及影響。
  2. 可靠性指是否嚴格按照設定的時間間隔按時執(zhí)行selector;精度指支持的最小時間間隔是多少,對程序中的定時器而言,由于線程的切換,處理任務的耗時程度不同,可靠性和精度只是參考值。

1. NSTimer的精度

影響NSTimer的執(zhí)行selector的因素:NSTimer被添加到特定mode的runLoop中;該mode型的runloop正在運行;到達激發(fā)時間。 runLoop 切換模式時,NSTimer 如果處于default模式下可能不會被觸發(fā)。每個 runLoop 的循環(huán)間隔也無法保證,一般時間間隔限制為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打印了兩次執(zhí)行onTimeout的時間差,我們通過對比ts - _lastTS與QiNSTimerInterval的值、1s內執(zhí)行次數(shù),來確定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左側時間戳及l(fā)og數(shù)量。當QiNSTimerInterval為0.001時,1秒鐘內打印了1000條log,兩條log的時間間隔可控,也即NSTimer允許1ms的時間精度。當QiNSTimerInterval為0.0001時,進行以上對比,數(shù)據(jù)出現(xiàn)偏差。因此,我們得出,理想狀態(tài)下NSTimer的精度為1ms。

注意:

  1. NSTimer的時間精度雖然為1ms,但是只是理想狀態(tài)下,任何操作都可能會使onTimeout延時執(zhí)行。例如,現(xiàn)實中,我們在界面輸出一個倒計時,如果設置QiNSTimerInterval為0.001,界面中秒位的變化明顯變慢,正常使用NSTimer進行毫秒刷新時,一般只精確到100ms才不會感到異常。
  2. 在一定程度上保證timer“準時”的方法:在子線程中創(chuàng)建timer,在子線程中進行定時任務的操作,需要UI操作時切換回主線程進行操作;或者在子線程中創(chuàng)建timer,在主線程進行定時任務的操作。

2. GCDTimer 的精度

回顧一下 GCDTimer 的基本實現(xiàn)過程:

// 1. 創(chuàng)建 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. 啟動定時器(剛創(chuàng)建的source處于被掛起狀態(tài))
dispatch_resume(timer);
// 5. 暫停定時器
dispatch_suspend(timer);
// 6. 取消定時器
dispatch_source_cancel(timer);
timer = nil;

GCDTimer相較于NSTimer的代碼處理過程優(yōu)點很明顯,NSTimer必須保證有一個活躍的runloop、創(chuàng)建與撤銷必須在同一個線程操作、內存管理有潛在泄露的風險等,從上面的實現(xiàn)過程就可以看出使用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。
    現(xiàn)存的iOS設備屏幕的刷新頻率為60Hz,這一點可以從CADisplayLink的duration屬性看出來。duration的值為1/60,即0.166666...
  • timestamp
    表示屏幕顯示的上一幀的時間戳,只讀屬性,CFTimeInterval類型,該屬性通常被target用來計算下一幀中應該顯示的內容。
  • preferredFramesPerSecond
    可以通過該屬性來設置CADisplayLink每秒刷新次數(shù),默認值為屏幕最大幀率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. 理想狀態(tài)下,1s內執(zhí)行60次,最小精度為16.7ms左右,精度誤差一般在 0.1 ~ 0.5 毫秒之間,精度比 NSTimer 要高。CADisplayLink運行在主線程中在耗時任務之后,精度也不可控,需要借助多線程處理。
  2. 如果想保證精度,需要先確保任務能夠在最小時間間隔內執(zhí)行完成,CADisplayLink 就比較可靠(例如毫秒級倒計時,這種比較簡單非耗時任務可以保證質量,但是每次倒計時應以16.7ms為單位累加)。

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

上述的幾種定時器雖然形式與用法不一,但核心邏輯實際是一樣的,都受限于蘋果為提高性能采用的各種策略,可能導致下一次無法實時地執(zhí)行selector。如果你確有需求要使用更高精度的定時器(一般視頻/音頻、精確幀速率的游戲等相關數(shù)據(jù)流操作中會需要),蘋果也提供了相應方法 iOS/OS X 中的高精度定時器。這里說的高精度定時器與之前介紹的幾個定時器處理邏輯不一樣,它是基于高優(yōu)先級的線程調度類創(chuàng)建的定時器,在沒有多線程沖突的情況下,這類定時器的請求會被優(yōu)先處理。

iOS/OS X 中的高精度定時器邏輯:把定時器所在的線程,移到高優(yōu)先級的線程調度類;使用底層更精確的計時器API(以CPU時鐘為參照的計時API)。

4.1 使用過程
  • 將計時線程,調度為實時線程
    把定時器所在的線程,移到高優(yōu)先級的線程調度類,即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() 用于獲取機器時間(單位是納秒),測試代碼來源于網(wǎng)絡,其功能展示了高精度定時器與NSTimer的對比。

5. 總結

  1. NSTimer 最常用,需要注意的就是加入的 runLoop 的 Mode ,若是子線程,需要手動 run 這個 RunLoop ;同時注意使用 invalidate 手動停止定時,否則引起內存泄漏;NSTimer的創(chuàng)建與撤銷必須在同一個線程操作,不能跨越線程操作;
  2. GCD Timer 較 NSTimer 精度高,一般用于對文件資源等定期讀寫操作很方便,使用時需要注意 dispatch_resume 與 dispatch_suspend 配套,并且要給 dispatch source 設置新值或者置nil,需先 dispatch_source_cancel(timer) ,否則會導致崩潰;
  3. 需與顯示更新同步的定時,建議 CADisplayLink ,可以省去多余計算;
  4. 高精度定時,一般視頻/音頻、精確幀速率的游戲等相關數(shù)據(jù)流操作中會需要;
  5. iOS中任何定時器的精度,都只是個參考值。

工程源碼GitHub地址

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,578評論 6 544
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 99,701評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,691評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,974評論 1 318
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,694評論 6 413
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 56,026評論 1 329
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,015評論 3 450
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,193評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經...
    沈念sama閱讀 49,719評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,442評論 3 360
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,668評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,151評論 5 365
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,846評論 3 351
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,255評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,592評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,394評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,635評論 2 380