NSTimer的坑

之前要做一個發送短信驗證碼的倒計時功能,打算用NSTimer來實現,做的過程中發現坑還是有不少的。

  • 基本使用
  • NSTimer的強引用問題
  • 不準時
  • iOS10中的改動
    其中會涉及到一些runloop的知識,這里不會另外去講,在我之前寫的一篇runloop的文章中已經提及過,有需要的可以看看。

1、基本使用

創建timer的方法:

//把創建timer并把它添加到當前線程runloop中,模式是默認的default mode
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo;
//和上面的方法作用差不多,但不會把timer自動添加到runloop中,需要人手動加
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo;

參數說明:

  • ti:定時器觸發間隔時間,單位為秒,可以是小數。
  • aTarget:發送消息的目標,timer會強引用aTarget,直到調用invalidate方法。
  • aSelector:將要發送給aTarget的消息,可以不帶參,如果帶有參數則應把timer作為參數傳遞過去:- (void)timerFireMethod:(NSTimer *)timer
  • userInfo:傳遞的用戶信息,timer對此進行強引用。
  • yesOrNo:是否重復。如果是YES則重復觸發,直到調用invalidate方法;如果是NO,則只觸發一次就自動調用invalidate方法。

比如:

- (void)viewDidLoad {
    [super viewDidLoad];
    self.timer = [NSTimer scheduledTimerWithTimeInterval:1 target:self selector:@selector(timerMethod) userInfo:nil repeats:YES];
}

- (void)timerMethod{
    NSLog(@"timer2 run");
}

timer要添加到runloop才有效,因此運行要滿足幾個條件:1.當前線程的runloop存在,2.timer添加到runloop,3.runloop mode要適配。
比如在子線程中使用NSTimer:

- (void)viewDidLoad {
    [super viewDidLoad];
    UIButton *btn = [[UIButton alloc]initWithFrame:CGRectMake(0, 80, 50, 50)];
    btn.backgroundColor = [UIColor redColor];
    [self.view addSubview:btn];
    [btn addTarget:self action:@selector(clicked) forControlEvents:UIControlEventTouchUpInside];
    
    [NSThread detachNewThreadSelector:@selector(threadMethod) toTarget:self withObject:nil];
}

- (void)threadMethod{
    self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(timerMethod) userInfo:nil repeats:YES];
    CFRunLoopRun();
}

- (void)clicked{
    [self.timer invalidate];
    [self.navigationController popViewControllerAnimated:YES];
}

如果要runloop修改模式,調用一次addTimer:forMode:方法就可以了:

[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];

其余的NSTimer初始化方法大同小異就不展開了。

2、NSTimer 不準確

這篇文章中有這么一個觀點:

很多講述定時器的技術文中都有這么一個觀點,如果一個定時器錯過了本次可以觸發的時間點,那么定時器將跳過這個時間點,等待下一個時間點的到來。但這個觀點跟定時器在RunLoop中的工作原理并不符。定時消息從內核發出,消息在消息中心等待被處理,RunLoop每次Loop都會去消息中心查找相應的端口消息,若找到相應的端口消息就會進行處理,所以,即使當前RunLoop正在執行一個耗時很長的任務,當任務執行完進入下一次Loop時,那些未被處理的消息仍然會被處理。經過大量測試表明,定時消息并不會因延遲而掉失。

驗證代碼:

- (void)viewDidLoad {
    [super viewDidLoad];
    self.view.backgroundColor = [UIColor whiteColor];
    // 創建observer
    CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(CFAllocatorGetDefault(), kCFRunLoopAllActivities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
        NSLog(@"----監聽到RunLoop狀態發生改變---%zd", activity);
    });
    // 添加觀察者:監聽RunLoop的狀態
    CFRunLoopAddObserver(CFRunLoopGetCurrent(), observer, kCFRunLoopDefaultMode);

    self.timer = [NSTimer scheduledTimerWithTimeInterval:1 target:self selector:@selector(timerMethod) userInfo:nil repeats:YES];
    self.timer.fireDate = [NSDate dateWithTimeIntervalSinceNow:3];
    [self performSelector:@selector(busyOperation) withObject:nil afterDelay:0.5];
    
    // 釋放Observer
    CFRelease(observer);
}

- (void)timerMethod{
    NSLog(@"timer2 run");
}

- (void)busyOperation{
    NSLog(@"線程繁忙開始");
    long count = 0xffffffff;
    CGFloat calculateValue = 0;
    for (long i = 0; i < count; i++) {
        calculateValue = i/2;
    }
    NSLog(@"線程繁忙結束");
}
32:runloop即將進入休眠;64:runloop喚醒

對照runloop狀態代碼,32表示runloop即將休眠,64表示runloop喚醒,128表示runloop退出

typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
    kCFRunLoopEntry = (1UL << 0),
    kCFRunLoopBeforeTimers = (1UL << 1),
    kCFRunLoopBeforeSources = (1UL << 2),
    kCFRunLoopBeforeWaiting = (1UL << 5),
    kCFRunLoopAfterWaiting = (1UL << 6),
    kCFRunLoopExit = (1UL << 7),
    kCFRunLoopAllActivities = 0x0FFFFFFFU
};

定時消息不會因為延時而消失。如果這段代碼有寫得不合理的地方請告訴我。但不管怎樣有一點是可以肯定的,NSTimer定時器不是十分精確。

3、NSTimer強引用引起的內存問題。

@property (nonatomic ,strong)NSTimer *timer;
- (void)viewDidLoad {
    [super viewDidLoad];
    self.timer = [NSTimer scheduledTimerWithTimeInterval:1 target:self selector:@selector(timerMethod) userInfo:nil repeats:YES];
}

- (void)timerMethod{
    NSLog(@"timer2 run");
}

運行上面這段代碼,如果從這一級VC pop回上一級VC,timer still running!!


強引用示意圖

runloop強引用timer,timer強引用target對象。要解除這兩種強引用就必須要調用invalidate方法。

關于invalidate方法
invalidate方法有2個功能:
1、將timer從runloop中移除
2、timer本身也會釋放它持有資源,比如target、userinfo、block。
之后的timer也就永遠無效了,要再次使用timer就要重新創建。
timer只有這一個方法可以完成此操作,所以我們取消一個timer必須要調用此方法。(在添加到runloop前,可以使用它的getter方法isValid來判斷,一個是防止為nil,另一個是防止為無效)

NSTimer 在哪個線程創建就要在哪個線程停止,否則會導致資源不能被正確的釋放。因此invalidate方法必須在timer添加到的runloop所在的線程中調用。
ps:在網上看很多技術文,[timer invalidate]timer = nil;放在一起使用,我覺得僅僅調用invalidate方法就足夠解決問題了。

在vc 的dealloc方法中調用invalidate

- (void)dealloc{
    NSLog(@"銷毀了");
    [self.timer invalidate];
}

結果還是一樣的!無法走到dealloc方法。
因為timer對view controller的強引用,導致vc無法釋放,也就無法走到dealloc方法了。(即使timer屬性是weak,結果是走不到dealloc,只不過vc(self)和timer之間不再有保留環)

那么加個按鈕方法:

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

恩!先點擊按鈕,然后再pop回上一級VC,這時就可以走到dealloc方法了。但是這樣并不雅觀。

問題的關鍵是self(vc)被timer強引用,那么target不是self(vc)不就可以了嗎?

#import "NSTimer+Addition.h"
@implementation NSTimer (Addition)
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)interval block:(void(^)())block repeats:(BOOL)repeats{
    return [self scheduledTimerWithTimeInterval:interval
                                         target:self
                                       selector:@selector(blockInvoke:)
                                       userInfo:[block copy]
                                        repeats:repeats];
}

+ (void)blockInvoke:(NSTimer *)timer {
    void (^block)() = timer.userInfo;
    if(block) {
        block();
    }
}
@end

vc:
- (void)viewDidLoad {
    [super viewDidLoad];
    self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0 block:^{
        NSLog(@"timer2 run");
    } repeats:YES];
}

- (void)dealloc{
    NSLog(@"銷毀了");
    [self.timer invalidate];
}

返回上級VC,可以走到dealloc


這里利用的是NSTimer分類作為target,還使用了block(也要注意block造成的循環引用問題,如果block捕獲了self,而timer又通過userInfo持有block,最后self本身又持有timer就會形成保留環)。這里真正創建timer實例的地方是在NSTimerCategory中,而且target也是NSTimerNSTimer持有timer實例,timer實例持有NSTimer,還是有循環引用的。要想打破上述循環引用,需要在創建timer的類(非NSTimer)中對timer進行invalidate

另一種制造假target的寫法,本質上還是相同的

4、子線程中使用NSTimer的坑

情形一:

A界面 push進入B界面,在B中創建子線程,子線程中創建timer、開啟runloop;B上的按鈕用來釋放timer,點擊B導航欄返回按鈕返回A。

@property (nonatomic ,weak)NSTimer *timer;
@property (nonatomic )CFRunLoopRef runloop;
@property (nonatomic ,weak)NSThread *thread;
@property (nonatomic )CFRunLoopObserverRef observer;

- (void)viewDidLoad {
    [super viewDidLoad];
    self.view.backgroundColor = [UIColor whiteColor];
    UIButton *btn = [[UIButton alloc]initWithFrame:CGRectMake(0, 80, 50, 50)];
    btn.backgroundColor = [UIColor redColor];
    [self.view addSubview:btn];
    [btn addTarget:self action:@selector(clicked) forControlEvents:UIControlEventTouchUpInside];
    
    NSThread *thread = [[NSThread alloc]initWithTarget:self selector:@selector(threadMethod) object:nil];
    self.thread = thread;
    [self.thread start];
}

- (void)timerMethod{
    NSLog(@"timer2 run");
}
- (void)dealloc{
    NSLog(@"銷毀了");
// CFRelease(self.observer);
}

- (void)clicked{
  [self.timer invalidate];
}

- (void)threadMethod{
    self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(timerMethod) userInfo:nil repeats:YES];
    self.runloop = CFRunLoopGetCurrent();
 
    self.observer = CFRunLoopObserverCreateWithHandler(CFAllocatorGetDefault(), kCFRunLoopAllActivities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
        NSLog(@"----監聽到RunLoop狀態發生改變---%zd", activity);
    });
    CFRunLoopAddObserver(self.runloop, self.observer, kCFRunLoopDefaultMode);
    
    CFRunLoopRun();
    CFRelease(self.observer);
}

這段代碼在iOS10、iOS9環境下運行結果不太一樣。


iOS9
iOS10

iOS10環境下,從B返回A,B不會被釋放(無法走到dealloc)。從運行結果看來,iOS10中子線程runloop最后一直處于休眠狀態。

分析:
在B中創建了一個子線程,通過NSThread *thread = [[NSThread alloc]initWithTarget:self selector:@selector(threadMethod) object:nil];,子線程會對target也就是self(B控制器)進行強引用,這是B無法釋放的原因。要釋放B就要退出子線程,也就是要退出子線程的runloop。所以問題可能就是iOS9、iOS10在處理子線程runloop上有不同。

參考文章第一篇講到:

若目標RunLoop當前沒有定時源需要處理(像上面的例子那樣,子線程RunLoop只有一個定時器,該定時器移除后,則子線程RunLoop沒有定時源需要處理),則通知內核不需要再向當前Timer Port發送定時消息并移除該Timer Port。在iOS10環境下,當移除Timer Port后,內核會把消息列表中與該Timer Port相應的定時消息移除,而iOS10以前的環境下,當移除Timer Port后,內核不會把消息列表中與該Timer Port相應的定時消息移除。iOS10的處理是更為合理的,iOS10以前的處理可能是歷史遺留問題吧。

例子中涉及到線程異步的問題,定時器是在子線程RunLoop中注冊的,但定時器的移除操作卻是在主線程,由于子線程RunLoop處理完一次定時信號后,就會進入休眠狀態。在iOS10以前的環境下,定時器被移除后,內核仍然會向對應的Timer Port發送一次信號,所以子線程RunLoop接收到信號后會被喚醒,由于沒有定時源需要處理,所以RunLoop會直接跳轉到判斷階段,判斷階段會檢測當前RunLoopMode是否有事件源需要處理,若沒有事件源需要處理,則會退出RunLoop。由于例子中子線程RunLoop的當前RunLoopMode只有一個定時器,而定時器被移除后,RunLoopMode就沒有了需要處理的事件源,所以會退出RunLoop,子線程的主函數也因此返回,頁面B對象被釋放。

但在iOS10環境下,當定時器被移除后,內核不再向對應的Timer Port發送任何信號,所以子線程RunLoop一直處于休眠狀態并沒有退出,而我們只需要手動喚醒RunLoop即可。

從上面iOS9運行結果圖來看,紅框的兩處時間差正好在一秒左右。(我點擊按鈕的時間在最后一次休眠和最后一次喚醒之間,在這期間timer被移除)


iOS9

對比iOS10運行結果(點擊按鈕的事件也是在最后一次休眠之后),確實可以得出結論:iOS9環境下,timer移除后,內核確實向timer port再次發送了信號使得子線程runloop喚醒,最后runloop由于沒有mode item而退出。

所以也即:

- (void)clicked{
    [self.timer invalidate];
    CFRunLoopWakeUp(self.runloop);
}

手動喚醒runloop,這樣改動以后的運行結果:

iOS10

又或者是,不使用CFRunLoopWakeUp而直接用CFRunLoopStop( )來退出runloop。因為使用CFRunLoopWakeUp,相當于是讓runloop依賴當前runloop mode有沒有事件源來決定是否退出。而這種方法本身就不是十分靠譜,因為系統也有可能給runloop添加一些事件源,導致runloop不一定會退出。

ps:一些題外話。是一些自我思路糾正,寫出來是為了給自己日后看的。各位看官可以跳過這部分~
在最開始寫完這筆記之后的幾天又翻出這段代碼來看。大概是頭腦短路吧..曾經認為上面代碼中的按鈕點擊是一個子線程runloop source0。。。還做了下面一張圖分析。。。(大概犯蠢沒看清按鈕事件的時機)


不過很快就意識到這哪里是什么子線程source0....子線程runloop沒有source0只有timer和observer(明明之前自己還nslog出來過)!這個按鈕事件是主線程的嘛!
然后在誤打誤撞的情況下...我在主線程runloop又添加了一個observer對主線程runloop狀態進行監聽,代碼很簡單我就不貼了。從A進入到B,什么都不要做,等待main runloop穩定下來(一開始main runloop很活躍,最后穩定下來就是休眠了,只剩下子線程runloop狀態在控制臺有輸出,如下圖)。在我點擊按鈕之后,main runloop喚醒,iOS10中子線程同上最后一直處于休眠狀態。


iOS10測試

要喚醒runloop休眠有這么幾種情況:基于端口的輸入源到達(source1)、timer喚醒、runloop超時時間到、人為手動喚醒runloop。

因為一直認為點擊按鈕時這一行為是一個source0,所以對主線程runloop喚醒感到意外。然后重新看回深入理解runloop這篇文章,發現有這么一個Q&A:

Q:還有一個問題哈,就是UIButton點擊事件打印堆棧看的話是從source0調出的,文中說的是source1事件,不知道哪個是正確的呢?
A:首先是由那個Source1 接收IOHIDEvent,之后在回調 __IOHIDEventSystemClientQueueCallback() 內觸發的 Source0,Source0 再觸發的_UIApplicationHandleEventQueue()。所以UIButton事件看到是在 Source0 內的。你可以在 __IOHIDEventSystemClientQueueCallback 處下一個 Symbolic Breakpoint 看一下。

按照作者的回答,做了測試,發現的確是那樣的。所以主線程的喚醒是由于source1事件。

情形二

但上面這種寫法是在子線程創建timer,在主線程中銷毀timer。根據invalidate方法api文檔中提到的,NSTimer 在哪個線程創建就要在哪個線程停止,否則會導致資源不能被正確的釋放。所以如果要修改一下:

- (void)clicked{
    if (self.timer && self.thread) {
        [self performSelector:@selector(cancel) onThread:self.thread withObject:nil waitUntilDone:YES];
    }
}

- (void)cancel{
    if (self.thread) {
        [self.timer invalidate];
//        CFRunLoopWakeUp(self.runloop);//不能dealloc
        CFRunLoopStop(self.runloop);//可以dealloc
    }
}

這里調用perform..,是會給runloop添加源的,所以要退出runloop就不能使用CFRunLoopWakeUp了。
ps:本來想著要讓子線程退出,那就使用[NSThread exit],但貌似是行不通。。

情形三

讓子線程timer計數幾次就停止

- (void)viewDidLoad {
    [super viewDidLoad];
    self.view.backgroundColor = [UIColor whiteColor];
    count = 0;

    NSThread *thread = [[NSThread alloc]initWithTarget:self selector:@selector(threadMethod) object:nil];
    self.thread = thread;
    [self.thread start];
}
- (void)threadMethod{
    @autoreleasepool {
        self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(timerMethod:) userInfo:nil repeats:YES];
//            self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0 block:^{
//                NSLog(@"timer2 run");
//            } repeats:YES];
        self.runloop = CFRunLoopGetCurrent();
        self.observer = CFRunLoopObserverCreateWithHandler(CFAllocatorGetDefault(), kCFRunLoopAllActivities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
            NSLog(@"----監聽到RunLoop狀態發生改變---%zd", activity);
        });
        CFRunLoopAddObserver(self.runloop, self.observer, kCFRunLoopDefaultMode);
        CFRunLoopRun();
        CFRelease(self.observer);
        NSLog(@"thread end");
    }
}

- (void)timerMethod:(NSTimer *)timer{
    count++;
    NSLog(@"timer2 run");
    if (count == 2) {
        [timer invalidate];
        NSLog(@"timer invalidate");
    }
}
- (void)dealloc{
    NSLog(@"銷毀了");
}
iOS10

和情形一不同,這里移除timer的操作是放在子線程中做的(在timer call out中)。從控制臺輸出中可以看到,這是在子線程runloop喚醒之后才移除timer,接著就進行是否退出runloop的判斷。由于子線程runloop中已經沒有事件源了,因此runloop就退出了。
在情形一,子線程創建timer,主線程移除timer,點擊按鈕的時機是由人來把控的,因此會發生在子線程runloop休眠后移除timer導致runloop無法喚醒的問題。而情形三則沒有這樣的問題,資源可以得到安全釋放。vc返回上一級也能得到銷毀。

5、其他

  • NSTimer不支持暫停和繼續
  • NSTimer不支持后臺運行(真機),但是模擬器上App進入后臺的時候,NSTimer還會持續觸發。真機進入后臺timer會停。

參考文章:
http://www.lxweimin.com/p/7045813769fd

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,488評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,034評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,327評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,554評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,337評論 6 404
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,883評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,975評論 3 439
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,114評論 0 286
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,625評論 1 332
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,555評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,737評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,244評論 5 355
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 43,973評論 3 345
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,362評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,615評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,343評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,699評論 2 370

推薦閱讀更多精彩內容