iOS定時器NSTimer內存泄露原理分析+解決方案

  • 一、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。
  • 內存泄露分析

    • 創建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

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

推薦閱讀更多精彩內容