- 一、NSTimer簡介
- 二、NSTimer與RunLoop
- 三、NSTimer內存泄露分析
- 1.NSTimer引用分析
- 2.NSTimer內存泄漏解決方案
- 四、NSTimer使用建議
- 1.初始化分析
- 2.延遲定時任務VS重復定時任務
一、NSTimer簡介
- NSTimer是iOS開發執行定時任務時常用的類,它支持定制定時任務的開始執行時間、任務時間間隔、重復執行、RunLoopMode等。
- NSTimer必須與RunLoop搭配使用,因為其定時任務的觸發基于RunLoop,NSTimer使用常見的Target-Action模式。由于RunLoop會強引用timer,timer會強引用Target,容易造成循環引用、內存泄露等問題。本文主要分析內存泄露原因,提供筆者所了解到的解決方案。代碼詳見DEMO,歡迎留言或者郵件(mailtolinbing@163.com)勘誤、交流。
二、NSTimer與RunLoop
- NSTimer需要與RunLoop搭配使用。創建完定時器后,需要把定時器添加到指定RunLoopMode,添加完畢定時器就自動觸發(fire)或者在設定時間fireDate后自動觸發。
- NSTimer并非真正的機械定時器,可能會出現延遲情況。當timer注冊的RunLoop正在執行耗時任務、或者當前RunLoopMode并非注冊是指定的mode,定時任務可能會延遲執行。
三、NSTimer內存泄露分析
1.NSTimer引用分析
-
1.1 NSTimer使用步驟
- 1.創建NSTimer對象,傳入Target、Action等;
- 2.根據需求把NSTimer加入到特定RunLoop的特定Mode。此時timer會自動fire,若此前指定了啟動時間fireDate,則會在指定時間自動fire
- 3.當定時器使用完畢,調用invalidate使之失效。
1.2 NSTimer引用分析
把NSTimer當做普通對象使用,如下實現定時任務,會出現內存泄露
@interface FYLeakView()
@property (nonatomic, strong) NSTimer *timer;
@end
@implementation FYLeakView
// 不會調用
- (void)dealloc {
NSLog(@"%s", __func__);
[_timer invalidate];
}
- (instancetype)initWithFrame:(CGRect)frame {
if (self = [super initWithFrame:frame]) {
self.backgroundColor = [UIColor yellowColor];
NSLog(@"%@", self.timer); // 觸發定時器創建
}
return self;
}
- (void)p_timerAction {
NSLog(@"%s", __func__);
}
- (NSTimer *)timer {
if (_timer == nil) {
_timer = [NSTimer timerWithTimeInterval:1 target:self selector:@selector(p_timerAction) userInfo:nil repeats:YES];
[[NSRunLoop mainRunLoop] addTimer:_timer forMode:NSRunLoopCommonModes];
}
return _timer;
}
@end
- 此時的內存中對象的引用關系圖如下
對象引用關系.png
-
對象間引用關系分析
- 1.創建定時器,調用
timerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo;
,傳入Target對象,Timer會在強引用Target直到Timer失效(調用invalidate) - 2.注冊定時器,調用RunLoop的
addTimer:(NSTimer *)timer forMode:(NSRunLoopMode)mode
把Timer注冊到RunLoop對應mode。RunLoop會強引用Timer。當調用Timer的invalidate使之失效時,RunLoop才會把Timer從RunLoop中移除并清除Timer對Target的強引用
,invalidate是把Timer從RunLoop中移除的唯一方法。 - 3.Target要使用Timer做定時任務,通常會強引用Timer。
- 1.創建定時器,調用
-
內存泄露分析
- 創建Timer必須傳入Target,引用1不可避免。Timer事件觸發基于RunLoop運行循環,必須把Timer添加到RunLoop中,引用2也不可避免。
- 把Timer注冊到RunLoop后,Timer會被其強引用,保證Timer存活,定時任務能觸發。因此Target對象使用Timer實際上可以使用weak弱引用,只要你能保證創建完Timer將其加入RunLoop中。(注意:Timer通常用懶加載,且Timer一加入RunLoop就自動fire,如果想在特定時間點才fire定時任務,那必須到特定時間點才加入RunLoop,或者初始化后馬上加入RunLoop并暫停定時器)
- Target強引用or弱引用Timer并不是問題的關鍵,問題的關鍵是:一定要在Timer使用完畢調用invalidate使之失效(手動調用or系統自動調用),Timer從RunLoop中被移除并清除強引用,這個操作可打破引用1、2,而引用3是強弱引用已經不重要了。
- 但是哪個時間點才適合invalidate,上述例子在dealloc中invalidate并不奏效。原因是:dealloc在Target對象析構時由系統調用,根據上圖RunLoop強引用Timer、Timer強引用Target。Target必須等待Timer執行invalidate后清除其對Target的強引用,dealloc才會執行。Timer則在等待Target析構由系統調用dealloc而執行invalidate,兩者陷入互相等待的死循環。
- 此時造成的主要問題有兩個:Target、Timer內存泄露; 定時任務會持續執行,如果定時任務中存在耗性能操作,或者操作公共數據結構等,結果相當糟糕。
-
1.3 解決途徑:在合適時間點invalidate定時器???
- 上文提示只要invalidate定時器即可解決問題,然而不能在dealloc中invalidate,哪個時間點才是合適時間點?
- 如下例子:上述帶定時任務的自定義View提供一個invalidate接口給使用該類的客戶執行invalidate操作,可解決問題。若是帶有定時任務ViewController,如果在viewWillDisappear中執行invalidate可解決問題,但是當要求只要控制器對象存活就必須執行定時任務,就無法滿足需求,靈活性差。如果控制器也提供invalidate接口,使用控制器類的客戶很難找到合適時間點調用,因為通常控制器都是在pop出棧時釋放,就算找到合適的時間點,也不是好的處理方案。原因是:該方案會把內部定時任務暴露出去,破壞封裝性;且你無法保證使用者都記得invalidate,定時任務應該由內部管理。
@interface FYNormalView : UIView
- (void)invalidate;
@end
@implementation FYNormalView
- (void)invalidate {
[self.timer invalidate];
}
......
@end
@interface FYNormalViewController ()
@property (nonatomic, strong) FYNormalView *normalView;
@property (nonatomic, strong) NSTimer *timer;
@end
@implementation FYNormalViewController
- (void)dealloc {
NSLog(@"%s", __func__);
[_normalView invalidate]; // invalidate
}
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = [UIColor whiteColor];
self.title = @"定時器 避免內存泄漏";
[self.view addSubview:self.normalView];
self.normalView.frame = CGRectMake(100, 100, 100, 100);
NSLog(@"%f", self.timer.timeInterval);
}
- (void)viewWillDisappear:(BOOL)animated {
[super viewWillDisappear:animated];
[self.timer invalidate]; // invalidate
}
- (void)p_timerAction {
NSLog(@"%s", __func__);
}
- (NSTimer *)timer {
if (_timer == nil) {
_timer = [NSTimer timerWithTimeInterval:1 target:self selector:@selector(p_timerAction) userInfo:nil repeats:YES];
[[NSRunLoop mainRunLoop] addTimer:_timer forMode:NSRunLoopCommonModes];
}
return _timer;
}
- (FYNormalView *)normalView {
if (_normalView == nil) {
_normalView = [[FYNormalView alloc] init];
}
return _normalView;
}
@end
2.NSTimer內存泄漏解決方案
內存泄漏主要原因是RunLoop強引用Timer、Timer強引用Target,導致Target不執行析構。下面提供兩種解決方案,本質是是從Target入手,把Target替換為另一個對象,而不是使用Timer的客戶對象。客戶對象不在作為Target,即可像使用普通對象一樣,在dealloc中invalidate Timer。
方案一:使用Block代替Target-Action
@implementation NSTimer (Block)
+ (instancetype)fy_scheduledTimerWithTimeInterval:(NSTimeInterval)inTimeInterval
actionBlock:(FYTimerActionBlock)block
repeats:(BOOL)yesOrNo
{
NSTimer *timer = [self scheduledTimerWithTimeInterval:inTimeInterval target:self selector:@selector(p_timerAction:) userInfo:block repeats:yesOrNo];
return timer;
}
+ (instancetype)fy_timerWithTimeInterval:(NSTimeInterval)inTimeInterval
actionBlock:(FYTimerActionBlock)block
runLoopMode:(NSRunLoopMode)mode
repeats:(BOOL)yesOrNo
{
NSTimer *timer = [self timerWithTimeInterval:inTimeInterval target:self selector:@selector(p_timerAction:) userInfo:block repeats:yesOrNo];
[[NSRunLoop mainRunLoop] addTimer:timer forMode:mode];
return timer;
}
+ (void)p_timerAction:(NSTimer *)timer {
if([timer userInfo]) {
FYTimerActionBlock actionBlock = (FYTimerActionBlock)[timer userInfo];
actionBlock(timer);
}
}
@end
/// 客戶對象使用Timer
@interface FYSolutionView()
@property (nonatomic, strong) NSTimer *timer;
@end
@implementation FYSolutionView
- (void)dealloc {
NSLog(@"%s", __func__);
[_timer invalidate]; // View析構時,由內部invalid
}
- (instancetype)initWithFrame:(CGRect)frame {
if (self = [super initWithFrame:frame]) {
self.backgroundColor = [UIColor blueColor];
[self.timer fy_resumeTimer];
}
return self;
}
- (void)p_timerAction {
NSLog(@"%s", __func__);
}
- (NSTimer *)timer {
if (_timer == nil) {
__weak typeof(self) weakSelf = self;
_timer = [NSTimer fy_timerWithTimeInterval:1 actionBlock:^(NSTimer *timer) {
[weakSelf p_timerAction];
} runLoopMode:NSRunLoopCommonModes repeats:YES];
}
return _timer;
}
@end
Block方案 對象引用關系.png
- iOS10開始,系統新增了block形式的初始化方式
(NSTimer *)timerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block
。目前大多數應用會兼容iOS8,因此必須自己構建block初始化方案。 - 上述代碼使用block封裝定時任務,并對block把執行copy操作拷貝到堆上,由Timer的userInfo持有。創建Timer時傳入
NSTimer類對象
作為Target,類對象也是對象,但是它類似單例,由系統管理什么周期。 - 使用Timer的客戶對象Client(FYSolutionView)不再被Timer強引用,當它執行dealloc時,調用Timer invalidate,所有引用關系正常打破。
注意在定時任務block使用self時需要注意轉為弱指針,否則還是會有循環引用,詳見引用關系圖。
- 方案二:直接替換Target
- 代碼與對象間引用關系圖如下
@interface FYTimerTarget : NSObject
@property (nonatomic, weak) id target;
@property (nonatomic, assign) SEL selector;
@property (nonatomic, weak) NSTimer *timer; // weak
@end
@implementation FYTimerTarget
- (void)timerTargetAction:(NSTimer *)timer {
if (self.target) {
[self.target performSelectorOnMainThread:self.selector withObject:timer waitUntilDone:NO];
} else {
[self.timer invalidate];
self.timer = nil;
}
}
@end
@implementation NSTimer (NoCycleReference)
+ (instancetype)fy_timerWithTimeInterval:(NSTimeInterval)interval
target:(id)target
selector:(SEL)selector
userInfo:(id)userInfo
runLoopMode:(NSRunLoopMode)mode
repeats:(BOOL)yesOrNo
{
if (!target || !selector) { return nil; }
// FYTimerTarget作為替代target,避免循環引用
FYTimerTarget *timerTarget = [[FYTimerTarget alloc] init];
timerTarget.target = target;
timerTarget.selector = selector;
NSTimer *timer = [NSTimer timerWithTimeInterval:interval target:timerTarget selector:@selector(timerTargetAction:) userInfo:userInfo repeats:yesOrNo];
[[NSRunLoop mainRunLoop] addTimer:timer forMode:mode];
timerTarget.timer = timer;
return timerTarget.timer;
}
@end
替換Target方案 對象引用關系.png
四、NSTimer使用建議
1.初始化分析
- NSTimer一共有三種初始化方案:init開頭的普通創建方法、timer開頭的類工廠方法、scheduled開頭的類工廠方法。前兩者需要手動加入RunLoop中,后者會自動加入當前RunLoop的DefaultMode中。
2.延遲定時任務VS重復定時任務
- 上文僅討論的都是NSTimer重復執行定時任務的情況。當創建定時任務僅需執行一次(repeats=NO,也就是延遲定時任務),則執行完定時任務,會
自動執行invalidate操作
。也就是說,如果能保證延遲任務一定會執行,實際上無需理會上文那些破事。但需注意:Timer會強引用Target直到延遲任務執行完畢
。如果使用場景要求:Target控制Timer的聲明周期,Target對象析構時延遲任務無需執行,則還是必須如上處理。 - 筆者建議這種場景使用CGD延遲任務dispatch_after即可,簡單安全且高效。
- 執行重復執行定時任務也可使用GCD定時器。GCD定時器無需考慮引用問題,且支持更精確的定時任務。不過GCD定時器是純C形式,非面向對象形式,執行暫停、取消操作不是很方便。還是需要根據使用場景選擇合適的方案。
References
- 蘋果官方文檔 NSTimer
- 《Effective Objective-C 2.0 編寫高質量iOS與OS X代碼的52個有效方法》