NSTimer計時器對象,就是一個能在從現在開始的后面的某一個時刻或者周期性的執行我們指定的方法的對象。
CADisplayLink也是一個計時器,它的timeInterval和屏幕刷新頻率一致(60幀/秒)。
CADisplayLink和NSTimer類似,接下來只著重講解NSTimer;
創建NSTimer實例
創建NSTimer實例的方法共有8種,其中實例方法2個,類方法6個。這8種方法基本都大同小異,大致可以分為兩類,現分別以兩個常用方法說明兩者區別:
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo;
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo;
我們分別用這兩個方法創建實例,看下輪詢結果:
- (void)test {
NSTimer *timer = [NSTimer scheduledTimerWithTimeInterval:1 target:self selector:@selector(testTimer) userInfo:nil repeats:NO];
}
- (void)testTimer {
NSLog(@"%s",__func__);
}
輸出結果:[ViewController testTimer]
- (void)test {
NSTimer *timer = [NSTimer timerWithTimeInterval:1 target:self selector:@selector(testTimer) userInfo:nil repeats:NO];
}
- (void)testTimer {
NSLog(@"%s",__func__);
}
無結果,testTimer未調用
區別很明顯了(其他scheduled,timerWith開頭的方法也是一樣):scheduledTimerWithTimeInterval方法創建實例后執行了輪詢的方法,這是因為這個方法創建的timer會被自動添加到runloop中,由runloop處理輪詢事件。而timerWithTimeInterval方法創建timer后,需我們手動添加至runloop中;
CADisplayLink只有一個實例化方法,同樣道理,也需要手動添加至runloop中:
+ (CADisplayLink *)displayLinkWithTarget:(id)target selector:(SEL)sel;
- (void)addToRunLoop:(NSRunLoop *)runloop forMode:(NSRunLoopMode)mode;
scheduledTimerWithTimeInterval方法雖能自動添加至runloop中,但這個runloop只會是當前runloop,runloop的模式也只是NSDefaultRunLoopMode;而手動添加至runloop,則可以自己設置指定的runloop及runloopMode;我們改寫第二段代碼看下:
- (void)test {
NSTimer *timer = [NSTimer timerWithTimeInterval:1 target:self selector:@selector(testTimer) userInfo:nil repeats:NO];
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
}
[ViewController testTimer]
那timer為什么一定要添加至runloop中才會執行呢?
NSTimer與NSRunLoop
在iOS中,所有消息都會被添加到NSRunloop中,在NSRunloop中分為‘input source’跟'timer source',并在循環中檢查是不是有事件需要發生,如果需要那么就調用相應的函數處理。NSTimer這種'timer source'資源要想起作用,那肯定需要加到runloop中才會執行了。
將NSTimer添加至runloop中,也需指定runloop mode;runloop mode有很多種,接下來我們主要講下以下3種:
- NSDefaultRunLoopMode
- UITrackingRunLoopMode
- NSRunLoopCommonModes
NSDefaultRunLoopMode:
這個是默認的運行模式,也是最常用的運行模式,NSTimer與NSURLConnection默認運行該模式下。默認模式中幾乎包含了所有輸入源(NSConnection除外),一般情況下應使用此模式。
UITrackingRunLoopMode:
當滑動屏幕的時候,比如UIScrollView或者它的子類UITableView、UICollectionView等滑動時runloop處于這個模式下。在此模式下會限制輸入事件的處理。
NSRunLoopCommonModes:
這是一組可配置的常用模式,是一個偽模式,其為一組runloop mode的集合。將輸入源與此模式相關聯還將其與組中的每種模式相關聯,當輸入源加入此模式意味著在CommonModes中包含的所有模式下都可以處理。 對于iOS應用程序,默認情況下此設置包括NSDefaultRunLoopMode和UITrackingRunLoopMode。可使用CFRunLoopAddCommonMode
方法在CommonModes中添加自定義modes。
了解這幾種runloop modes后,我們就知道平時開發中timer在屏幕滑動的時候停止執行的原因了,也能輕易的解決這個問題:
一個timer可以被添加到runloop的多個模式,因此如果想讓timer在UIScrollView等滑動的時候也能夠觸發,就可以分別添加到UITrackingRunLoopMode,UITrackingRunLoopMode這兩個模式下。或者直接添加到NSRunLoopCommonModes這一個模式集,它包含了上面的兩種模式。
另外,一個線程有且只有一個runloop。主線程的runloop默認是開啟的,其他線程runloop并不是默認開啟的。所以當在子線程中添加timer時,還需手動開啟這個runloop:
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
NSTimer *timer = [NSTimer timerWithTimeInterval:1 target:self selector:@selector(testTimer) userInfo:nil repeats:NO];
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
[[NSRunLoop currentRunLoop] run];
});
NSTimer觸發事件的準時性
NSTimer不是一個實時系統,不管是一次性的還是周期性的timer的實際觸發事件的時間可能都會跟我們預想的會有出入。NSTimer并不是準時的,造成不準時的原因如下:
-
觸發點可能被跳過去
- 線程處理比較耗時的事情時會發生這種情況,當線程處理的事情比較耗時時,timer就會延遲到該事件執行完以后才會執行。
- timer添加到的runloop mode不是runloop當前運行的mode,也會發生這種情況。
雖然跳過去,但是接下來的執行不會依據被延遲的時間加上間隔時間,而是根據之前的時間來執行。比如:
定時時間間隔為2秒,t1秒添加成功,那么會在t2、t4、t6、t8、t10秒注冊好事件,并在這些時間觸發。假設第3秒時,執行了一個超時操作耗費了5.5秒,則觸發時間是:t2、t8.5、t10,第4和第6秒就被跳過去了,雖然在t8.5秒觸發了一次,但是下一次觸發時間是t10,而不是t10.5。 -
不準點
比如上面說的t2、t4、t6、t8、t10,并不會在準確的時間觸發,而是會延遲個很小的時間。也有兩個因素:- RunLoop為了節省資源,并不會在非常準確的時間點觸發。
- 線程有耗時操作,或者其它線程有耗時操作也會影響
好在,這種不準點對我們開發影響并不大(基本是毫秒級別以下的延遲)
NSTimer內存問題
重點來了!!!
還是先看段代碼
- (void)test {
self.timer = [NSTimer timerWithTimeInterval:1 target:self selector:@selector(testTimer) userInfo:nil repeats:NO];
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
}
- (void)dealloc {
NSLog(@"%s",__func__);
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"%s",__func__);
[self dismissViewControllerAnimated:YES completion:nil];
}
點擊屏幕,輸出結果:
[TestViewController touchesBegan:withEvent:]
[TestViewController dealloc]
接下來,我們將創建timer時的repeats改為YES再看下:
self.timer = [NSTimer timerWithTimeInterval:1 target:self selector:@selector(testTimer) userInfo:nil repeats:YES];
點擊屏幕,輸出結果:
[TestViewController touchesBegan:withEvent:]
可以看出,repeats改為YES后TestViewController沒有被釋放,發生了內存泄露。為什么會這樣呢?
前面說了,NSTimer是要添加至runloop中的,runloop會對timer有強引用。而timer會對目標對象target進行強引用(類似UIButton等并不會強引用target)。如果viewController也強引用了timer,那viewController和timer循環引用了。而就算viewController沒有強引用timer,由于runloop對timer的強引用一直存在,timer又強引用著viewController,viewController也不能釋放。它們的引用關系如下:
對于單次事件的timer來說,在事件執行完后,timer會把本身從NSRunLoop中移除,然后就是釋放對‘target’對象的強引用,所以不會有內存泄露問題。NSTimer的- (void)invalidate方法就是這個作用,所以對于重復性的timer在不需要的時候調用invalidate方法也能解決內存問題:
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"%s",__func__);
[_timer invalidate];
[self dismissViewControllerAnimated:YES completion:nil];
}
點擊屏幕,輸出結果:
[TestViewController touchesBegan:withEvent:]
[TestViewController dealloc]
But,
調用invalidate方法,這是在我們能明確timer銷毀的時機的基礎上調用的(就像上面的代碼,我們能明確的知道touchesBegan事件時停止timer)。但大部分時候的需求并沒有明確的時機,而是讓timer一直執行直到所在VC銷毀時才停止timer,也就是說我們必須在dealloc方法中調用timer的invalidate方法以確保萬無一失。但timer未調用invalidate方法VC并不會銷毀,不會調用dealloc方法;dealloc沒調用,那timer的invalidate調用不了。這就尷尬了,陷入了死結。這該如何是好?
這種情況,其實有多種解決方案。最簡單的方案就是:在viewWillAppear方法中創建、執行timer,在viewWillDisappear中銷毀timer。這種方式雖然能解決內存問題,但實現過程比較low會有潛在的bug。其實也算不上解決方案,不推薦使用。
下面,介紹比較高大上比較有技術含量,并且能完美解決內存泄漏問題的方案;
首先我們分析下解決timer造成內存泄漏的突破點:
我們再看下上面的內存引用圖。runloop對timer的強引用是一直存在的,我們無法改變這種狀態(除非timer調用invalidate方法);前面也分析了:當runloop強引用timer時,viewController對timer是否是強引用viewController都釋放不了。那么唯一的突破點就是打破timer對target(即viewController)的強引用,讓viewController能正常釋放,然后調用dealloc方法進而銷毀timer將timer本身從NSRunLoop中移除。如何能做到呢?
這有以下方案:
- 創建NSTimer分類,將target設置為本身的NSTimer類對象,將selector的方式改為block回調方式;
@interface NSTimer (JSBlockSupport)
+ (NSTimer *)js_scheduledTimerWithTimeInterval:(NSTimeInterval)ti block:(void(^)(void))block repeats:(BOOL)yesOrNo;
@end
@implementation NSTimer (JSBlockSupport)
+ (NSTimer *)js_scheduledTimerWithTimeInterval:(NSTimeInterval)ti block:(void (^)(void))block repeats:(BOOL)yesOrNo {
return [self scheduledTimerWithTimeInterval:ti target:self selector:@selector(js_blockInvoke:) userInfo:[block copy] repeats:yesOrNo];
}
+ (void)js_blockInvoke:(NSTimer *)timer {
void (^block)(void) = timer.userInfo;
if (block) {
block();
}
}
@end
- (void)test {
__weak typeof (self) weakSelf = self;
self.timer = [NSTimer js_scheduledTimerWithTimeInterval:1 block:^{
__strong typeof (weakSelf) strongSelf = weakSelf;
[strongSelf testTimer];
} repeats:YES];
}
- (void)dealloc {
NSLog(@"%s",__func__);
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"%s",__func__);
// [_timer invalidate];
[self dismissViewControllerAnimated:YES completion:nil];
}
點擊屏幕,輸出結果:
[TestViewController touchesBegan:withEvent:]
[TestViewController dealloc]
這里很巧妙的利用了timer的userInfo屬性,將執行的操作作為block賦值給userInfo(id類型),在分類中的selector取出userInfo并執行這個block。
在調用分類方法時,有個注意點:block里需使用weak對象,再用strong對象調用方法。這是因為viewController強引用timer,timer強引用block,block會捕獲里面的viewController,這同樣循環引用了,本來我們是為了處理內存問題的,結果一不小心就全白忙了。用strong對象調用方法,是為了保證對象一直存活執行方法。
我們再回過頭來看下CADisplayLink如何處理,由于CADisplayLink沒有類似userInfo這樣的屬性,所以我們需要為CADisplayLink分類添加屬性,為分類添加屬性就要用到runtime關聯屬性了:
@interface CADisplayLink (JSBlockSupport)
+ (CADisplayLink *)js_displayLinkWithBlock:(void(^)(void))block;
@end
static void *JSDisplayLinkKey = "JSDisplayLinkKey";
@implementation CADisplayLink (JSBlockSupport)
+ (CADisplayLink *)js_displayLinkWithBlock:(void (^)(void))block {
CADisplayLink *displayLink = [self displayLinkWithTarget:self selector:@selector(js_blockInvoke:)];
// 直接關聯了一個block對象
objc_setAssociatedObject(displayLink, JSDisplayLinkKey, block, OBJC_ASSOCIATION_COPY);
return displayLink;
}
+ (void)js_blockInvoke:(CADisplayLink *)displayLink {
void (^block)(void) = objc_getAssociatedObject(displayLink, JSDisplayLinkKey);
if (block) {
block();
}
}
@end
- (void)test {
__weak typeof (self) weakSelf = self;
self.displayLink = [CADisplayLink js_displayLinkWithBlock:^{
__strong typeof (weakSelf) strongSelf = weakSelf;
[strongSelf testTimer];
}];
[self.displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes];
}
- 創建NSTimer分類,創建一個中間對象,這個對象弱引用實際的target,它本身作為timer的target;這樣timer沒有強引用原本target對象了,而代理對象收到的消息轉由中間對象保存調用。
// 中間對象
@interface TimerWeakObject : NSObject
@property (nonatomic, weak) id target;
@property (nonatomic, assign) SEL selector;
@property (nonatomic, weak) NSTimer *timer;
- (void)fire:(NSTimer *)timer;
@end
@implementation TimerWeakObject
- (void)fire:(NSTimer *)timer {
if ([self.target respondsToSelector:self.selector]) {
[self.target performSelector:self.selector withObject:timer.userInfo];
}
}
@end
@interface NSTimer (JSBlockSupport)
+ (NSTimer *)js_scheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget block:(void (^)(void))block selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo ;
@end
@implementation NSTimer (JSBlockSupport)
+ (NSTimer *)js_scheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget block:(void (^)(void))block selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo {
TimerWeakObject *temp = [[TimerWeakObject alloc] init];
temp.target = aTarget;
temp.selector = aSelector;
temp.timer = [self scheduledTimerWithTimeInterval:ti target:temp selector:@selector(fire:) userInfo:userInfo repeats:yesOrNo];
return temp.timer;
}
@end
- 為timer原來的target設置NSProxy代理對象,將這個代理設置為timer的target。這樣timer沒有強引用原本target對象了,而代理對象收到的消息通過消息轉發機制又轉發給原本target處理。(這個和上面手動實現的一樣,只是使用NSProxy更簡便)
- (void)test {
self.timer = [NSTimer timerWithTimeInterval:1 target:[YYWeakProxy proxyWithTarget:self] selector:@selector(testTimer) userInfo:nil repeats:YES];
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
}
點擊屏幕,輸出結果:
[TestViewController touchesBegan:withEvent:]
[TestViewController dealloc]
這里創建NSProxy代理對象,使用了大神寫的YYWeakProxy類,具體實現可以下載看下其源碼。
- iOS10添加了block方式的初始化方法,同樣也解決了內存泄漏問題(但一般項目都要適配iOS10之前的系統,所以也并沒有什么用處):
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0));