之前要做一個發送短信驗證碼的倒計時功能,打算用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(@"線程繁忙結束");
}
對照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實例的地方是在NSTimer
的Category
中,而且target
也是NSTimer
,NSTimer
持有timer
實例,timer
實例持有NSTimer
,還是有循環引用的。要想打破上述循環引用,需要在創建timer
的類(非NSTimer)中對timer
進行invalidate
。
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環境下運行結果不太一樣。
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被移除)
對比iOS10運行結果(點擊按鈕的事件也是在最后一次休眠之后),確實可以得出結論:iOS9環境下,timer移除后,內核確實向timer port再次發送了信號使得子線程runloop喚醒,最后runloop由于沒有mode item而退出。
所以也即:
- (void)clicked{
[self.timer invalidate];
CFRunLoopWakeUp(self.runloop);
}
手動喚醒runloop,這樣改動以后的運行結果:
又或者是,不使用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中子線程同上最后一直處于休眠狀態。
要喚醒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(@"銷毀了");
}
和情形一不同,這里移除timer的操作是放在子線程中做的(在timer call out中)。從控制臺輸出中可以看到,這是在子線程runloop喚醒之后才移除timer,接著就進行是否退出runloop的判斷。由于子線程runloop中已經沒有事件源了,因此runloop就退出了。
在情形一,子線程創建timer,主線程移除timer,點擊按鈕的時機是由人來把控的,因此會發生在子線程runloop休眠后移除timer導致runloop無法喚醒的問題。而情形三則沒有這樣的問題,資源可以得到安全釋放。vc返回上一級也能得到銷毀。
5、其他
- NSTimer不支持暫停和繼續
- NSTimer不支持后臺運行(真機),但是模擬器上App進入后臺的時候,NSTimer還會持續觸發。真機進入后臺timer會停。