NSTimer學習筆記

bomo發表於2017-05-13

NSTimer是iOS最常用的定時器工具之一,在使用的時候常常會遇到各種各樣的問題,最常見的是記憶體洩漏,通常我們使用NSTimer的一般流程是這樣的

  1. 在ViewController初始化或載入的地方建立NSTimer,並且通過屬性持有(為了關閉)

  2. 在ViewController的dealloc方法關閉定時器(invalidate),並且把NSTimer置為nil

上面做法可能會造成記憶體洩漏,invalidate方法通常不能放在NStimer.target.dealloc裡面,因為NSTimer會對target強引用,而如果target對NSTimer強引用就會造成迴圈引用

1. 建構函式

NSTimer只有被新增的Runloop才能生效,NSTimer有下面兩種型別的建構函式

  • initWithFireDate

  • timerWithTimeInterval

  • scheduledTimerWithTimeInterval

scheduledTimerWithTimeInterval除了構造timer,還會把timer新增到當前執行緒的runloop,所以我們通常使用scheduledTimerWithTimeInterval構造NSTimer而不是timerWithTimeInterval

  1. 沒有新增到runloop的timer,呼叫fire的時候會直接觸發,並且只觸發一次(如果repeat:YES)

- (void)viewDidLoad {
    [super viewDidLoad];

    [self timer1];
    //[self timer2];
    //[self timer3];
    //[self timer4];
}

- (void)timer1 {
    self.timer = [NSTimer timerWithTimeInterval:2 target:self selector:@selector(timerTest:) userInfo:nil repeats:YES];
    // 不會觸發
}

- (void)timer2 {
    self.timer = [NSTimer timerWithTimeInterval:2 target:self selector:@selector(timerTest:) userInfo:nil repeats:YES];
    // 正常觸發
    [[NSRunLoop currentRunLoop] addTimer:self.timer forMode:NSDefaultRunLoopMode];
}

- (IBAction)invalidate:(id)sender {
    [self.timer invalidate];
    self.timer = nil;
}

- (void)timerTest:(NSObject *)obj {
    NSLog(@"time fire");
}
  1. 如果使用timerWithTimeIntervalinitWithFireDate構造,需要手動新增到runloop上,使用scheduledTimerWithTimeInterval則不需要

- (void)timer3 {
    self.timer = [[NSTimer alloc] initWithFireDate:[NSDate dateWithTimeIntervalSinceNow:3] interval:3 target:self selector:@selector(timerTest:) userInfo:nil repeats:YES];
    // 需要新增到runloop才能觸發
    [[NSRunLoop currentRunLoop] addTimer:self.timer forMode:NSDefaultRunLoopMode];
}

- (void)timer4 {
    // 自動新增到runloop
    self.timer = [NSTimer scheduledTimerWithTimeInterval:2 target:self selector:@selector(timerTest:) userInfo:nil repeats:YES];
}

2. NSTimer的觸發

  • NSTimer在新增到runloop時,timer開始計時,即使runloop沒有開啟(run),在構造NSTimer的時候,如果不是馬上開始計時,可以先使用timerWithTimeInterval再手動加入runloop上

  • 呼叫fire的時候,立即觸發timer的方法,該方法觸發不影響計時器原本的計時,只是新增一次觸發

  • 當NSTimer進入後臺的時,NSTimer計時暫停,進入前臺繼續

3. NSTimer和Runloop

上面建構函式我們可以看到,當我們把timer新增到runloop的時候會指定NSRunLoopMode(scheduledTimerWithTimeInterval預設使用NSDefaultRunLoopMode),iOS支援的有下面兩種模式

  • NSDefaultRunLoopMode:預設的執行模式,用於大部分操作,除了NSConnection物件事件。

  • NSRunLoopCommonModes:是一個模式集合,當繫結一個事件源到這個模式集合的時候就相當於繫結到了集合內的每一個模式。

下面三種是內部框架支援(AppKit)

  • NSConnectionReplyMode:用來監控NSConnection物件的回覆的,很少能夠用到。

  • NSModalPanelRunLoopMode:用於標明和Mode Panel相關的事件。

  • NSEventTrackingRunLoopMode:用於跟蹤觸控事件觸發的模式(例如UIScrollView上下滾動)。

當timer新增到主執行緒的runloop時,某些UI事件(如:UIScrollView的拖動操作)會將runloop切換到NSEventTrackingRunLoopMode模式下,在這個模式下,NSDefaultRunLoopMode模式註冊的事件是不會被執行的,也就是通過scheduledTimerWithTimeInterval方法新增到runloop的NSTimer這時候是不會被執行的

為了讓NSTimer不被UI事件干擾,我們需要將註冊到runloop的timer的mode設為NSRunLoopCommonModes,這個模式等效於NSDefaultRunLoopMode和NSEventTrackingRunLoopMode的結合

// 主執行緒
self.timer = [NSTimer timerWithTimeInterval:2 target:self selector:@selector(timerTest:) userInfo:nil repeats:YES];
[[NSRunLoop currentRunLoop] addTimer:self.timer forMode:NSRunLoopCommonModes];

4. 迴圈引用

迴圈引用是最經常遇到的問題之一

  • NSTimer在建構函式會對target強引用,在呼叫invalidate時,會移除去target的強引用

    NSLog(@"Retain count is %ld", CFGetRetainCount((__bridge CFTypeRef)self));
    
    NSTimer *timer = [NSTimer timerWithTimeInterval:2 target:self selector:@selector(timerTest:) userInfo:@"ghi" repeats:YES];
    
    NSLog(@"Retain count is %ld", CFGetRetainCount((__bridge CFTypeRef)self));
    
    [timer invalidate];
    
    NSLog(@"Retain count is %ld", CFGetRetainCount((__bridge CFTypeRef)self));
    輸出如下
    2017-05-09 10:41:45.071 NSTimerTest[6861:914021] Retain count is 6
    2017-05-09 10:41:46.056 NSTimerTest[6861:914021] Retain count is 7
    2017-05-09 10:41:47.848 NSTimerTest[6861:914021] Retain count is 6
  • NSTimer被加到Runloop的時候,會被runloop強引用持有,在呼叫invalidate的時候,會從runloop刪除

    NSTimer *timer = [NSTimer timerWithTimeInterval:2 target:self selector:@selector(timerTest:) userInfo:@"ghi" repeats:YES];
    
    NSLog(@"Retain count is %ld", CFGetRetainCount((__bridge CFTypeRef)timer));
    
    [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];
    
    NSLog(@"Retain count is %ld", CFGetRetainCount((__bridge CFTypeRef)timer));
    
    [timer invalidate];
    
    NSLog(@"Retain count is %ld", CFGetRetainCount((__bridge CFTypeRef)timer));
    輸出如下
    2017-05-09 09:37:30.573 NSTimerTest[6505:883666] Retain count is 1
    2017-05-09 09:37:33.177 NSTimerTest[6505:883666] Retain count is 2
    2017-05-09 09:38:19.111 NSTimerTest[6505:883666] Retain count is 1
  • 當定時器是不重複的(repeat=NO),在執行完觸發函式後,會自動呼叫invalidate解除runloop的註冊和接觸對target的強引用

由於NSTimer被加到runloop的時候會被runloop強引用,故如果使用scheduledTimerWithTimeInterval建構函式時,我們可以在viewcontroller使用weak引用NSTimer

@property (nonatomic, weak) NSTimer *timer;
...

- (void)viewDidLoad {
    [super viewDidLoad];

    // 由於timer會被當前執行緒的runloop持有,故可以使用weak引用,而當呼叫invalidate時,self.timer會被自動置為nil
    self.timer = [NSTimer scheduledTimerWithTimeInterval:2 target:self selector:@selector(timerTest:) userInfo:nil repeats:YES];

    // 或者
    NSTimer *timer2 = [NSTimer timerWithTimeInterval:2 target:self selector:@selector(timerTest:) userInfo:nil repeats:YES];
    [[NSRunLoop currentRunLoop] addTimer:timer2 forMode:NSDefaultRunLoopMode];
    self.timer = timer;

}

所以通常我們不能在dealloc方法讓[timer invalidate], 因為timer在invalidate之前,會引用self(通常是ViewController),導致self無法釋放,可以在viewDidDisappear或顯式呼叫timer的invalidate方法

invalidate是唯一讓timer從runloop刪除的方法,也是唯一去除對target強引用的方法

5. 多執行緒

如果我們不在主執行緒使用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]
}

6. NSTimer準確性

通常我們使用NSTimer的時候都是在主執行緒使用的,主執行緒負責很多複雜的操作,例如UI處理,UI時間響應,並且iOS上的主執行緒是優先響應UI事件的,而NSTimer的優先順序較低,有時候NSTimer的觸發並不準確,例如當我們在滑動UIScrollView的時候,NSTimer就會延遲觸發,主線優先響應UI的操作,只有UIScrollView停止了才觸發NSTimer的事件
解決方案
NSTimer加入到runloop預設的Mode為NSDefaultRunLoopMode, 我們需要手動設定Mode為NSRunLoopCommonModes
這時候,NSTimer即使在UI持續操作過程中也能得到觸發,當然,會降低流暢度

NSTimer觸發是不精確的,如果由於某些原因錯過了觸發時間,例如執行了一個長時間的任務,那麼NSTimer不會延後執行,而是會等下一次觸發,相當於等公交錯過了,只能等下一趟車,tolerance屬性可以設定誤差範圍

NSTimer *timer = [NSTimer scheduledTimerWithTimeInterval:2 target:self selector:@selector(timerTest:) userInfo:nil repeats:YES];
// 誤差範圍1s內
timer.tolerance = 1;

如果對精度有要求,可以使用GCD的定時器

7 NSTimer暫停/繼續

NSTimer不支援暫停和繼續,如果需要可以使用GCD的定時器

8. 後臺執行

NSTimer不支援後臺執行(真機),但是模擬器上App進入後臺的時候,NSTimer還會持續觸發

如果需要後臺執行可以通過下面兩種方式支援

  1. 讓App支援後臺執行(執行音訊)(在後臺可以觸發)

  2. 記錄離開和進入App的時間,手動控制計時器(在後臺不能觸發)

第一種控制起來比較麻煩,通常建議手動控制,不在後臺觸發計時

9. performSelector

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. 總結

總的來說使用NSTimer有兩點需要注意

  1. NSTimer只有被註冊到runloop才能起作用,fire不是開啟定時器的方法,只是觸發一次定時器的方法

  2. NSTimer會強引用target

  3. invalidate取消runloop的註冊和target的強引用,如果是非重複的定時器,則在觸發時會自動呼叫invalidate

通常我們自己封裝GCD定時器使用起來更為方便,不會有這些問題