NSTimer所隱藏的內存泄漏問題

日常的編碼工作中,經常會碰到某些需要定期或延時執行的操作,這個時候會有多種計時方法供我們使用,比如NSTimer、CADisplayLink、GCD等。前兩種方式在使用中都可能會出現循環引用而導致內存泄漏問題。

what

我們先來看一下,NSTimer所隱藏的內存泄漏問題是什么樣子的,通常,在push出來的viewController中我們是這樣做的:

@interface MainViewController ()
@property (nonatomic, strong) UIButton *fireButton;
@property (nonatomic, strong) UIButton *invalidateButton;
@property (nonatomic, strong) NSTimer *timer;
@end

@implementation MainViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
    ...
}

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

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

- (void)timeStart {
    NSLog(@"timeStart");
}

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

就這樣,一個坑被我們在不知不覺中挖好了。我們希望當viewController將要釋放的時候,在dealloc方法中去將NSTimer對象給停掉釋放。結果往往是不盡人意的,控制臺在viewController彈棧后仍然不停的打印“timeStart”。

why

知道了這種隱藏的內存問題是什么,那我么接下來看一下為什么這種情況下會產生循環引用。

[NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(timeStart) userInfo:nil repeats:YES];

在這個方法中,如果repeats傳入的參數是YES,那么NSTimer對象會保留target傳入的對象,也就是強持有它,這種情形會保持到NSTimer對象失效為止。如果是一次性的定時器,不需要手動去invalidate就會失效,但是重復性的定時器,需要主動去調用invalidate方法才會失效。

綜上看來,viewController強持有了NSTimer對象,而NSTimer對象在其[NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(timeStart) userInfo:nil repeats:YES];方法中對傳入的target參數也進行了強引用,這也保證了NSTimer對象調用時的正確性。此時如果傳入target的是self,那么就會形成viewController和NSTimer對象的循環引用環,如果循環引用環沒有被打破,那么viewController在彈棧的時候就不會被釋放,也就不會走dealloc方法,也就不會將NSTimer對象invalidate,這個時候viewController和NSTimer對象都得不到釋放,也就產生了內存泄漏。

how

如何才能解決這種內存泄漏問題呢,關鍵在于破掉viewController和NSTimer對象的循環引用環。此時可能會有兩種思路:
  1、viewController弱引用NSTimer對象
  2、NSTimer對象弱引用傳入target的self對象

第一種方案

我們首先采用第一種方法@property (nonatomic, weak) NSTimer *timer;,運行程序后發現在viewController彈棧后依然在打印“timeStart”,這是因為NSTimer對象被創建后會被加入到RunLoop中,會被RunLoop強持有,這種思路是不能解決問題的。

第二種方案

在第二種方法中,我們做如下處理

__weak typeof(self) weakSelf = self;
[NSTimer scheduledTimerWithTimeInterval:1.0 target: weakSelf selector:@selector(timeStart) userInfo:nil repeats:YES];

運行程序,viewController彈棧后我們依然發現控制臺在打印“timeStart”,是不是要瘋了?冷靜下來,仔細想想,其實__weak typeof(self) weakSelf = self只是幫我們創建了一個新的變量weakSelf,而weakSelf這個變量所指向的對象和self是一致的,那么我們所做的這些事沒有任何意義的,NSTimer對象持有的仍然是viewController的當前對象,不論你傳入的是self或者weakSelf!
上面在分析原因的時候說到,NSTimer對象會保留target傳入的對象,也就是強持有它,這種情形會保持到NSTimer對象失效為止。那么我們主動去將NSTimer對象調用invalidate方法不就可以了嗎,現在問題是這個調用invalidate的時機在哪最好呢,我們怎么知道什么時候才是NSTimer的最佳釋放時機呢?其實不一定非得是最佳時機,利用viewController的生命周期方法也可以解決循環引用問題。在的viewController將要消失的時候,我們去invalidate定時器,在viewController創建后的恰當時機再次啟動定時器不就可以解決這個問題了嗎,代碼如下:

- (void)viewWillDisappear:(BOOL)animated {
    [super viewWillDisappear:animated];
    if (self.timer.isValid) {
        [self.timer invalidate];
    }
}

問題又來了,生命周期方法并不是誰都有的,這并不是一個通用的解決辦法。有沒有一種方法能夠一勞永逸的解決這個問題呢,我創建NSTimer后,不必去關心它什么時候需要釋放,但又不會引起循環引用。有,上面用過的dealloc方法,任何一個對象被銷毀之前都會調用這個對象的dealloc方法,但是我們要保證已經強引用了NSTimer對象的這個對象不被NSTimer對象強引用即可。
對了,我們似乎想到了一個類似的問題,為什么UIBUtton的target傳入self,而沒有被強引用導致出現循環引用問題呢?是不是其內部做了某些處理呢,那么又是如何處理的呢?不得不說Github是個好地方,在上面扒到一個第三方庫Chameleon,它包含蘋果用于iOS的UIKit以及一些用于MAC OS X的一些框架的接口實現,雖然已經在2014年停止更新維護了,但是仍然不失是一個學習利器。在里面看到了如下代碼

- (void)addTarget:(id)target action:(SEL)action forControlEvents:(UIControlEvents)controlEvents {
    UIControlAction *controlAction = [[UIControlAction alloc] init];
    controlAction.target = target;
    controlAction.action = action;
    controlAction.controlEvents = controlEvents;
    [_registeredActions addObject:controlAction];
}

并且

@interface UIAction : NSObject
@property (nonatomic, weak) id target;
@property (nonatomic, assign) SEL action;
@end

也就是說,UIButton沒有直接對target進行強引用,而是創建了一個的UIControlAction對象controlAction,并對它做了強引用。UIControlAction繼承于UIAction,而UIAction也有一個target屬性,并且是weak修飾的,這么看來,UIButton通過UIControlAction對象弱引用target對象,這樣就避免了UIButton和target對象的循環引用。這對我們來說是一種啟示,我們當然也可以用這種方法來解決NSTimer帶來的循環引用問題。代碼如下:

#import "DWTimer.h"

@interface DWTimerTarget : NSObject
@property (nonatomic, weak) id target;
@property (nonatomic, assign) SEL selector;
@property (nonatomic, weak) NSTimer* timer;
@end

@implementation DWTimerTarget

- (void) fire:(NSTimer *)timer {
    if(self.target) {
        [self.target performSelector:self.selector withObject:timer.userInfo afterDelay:0.0f];
    } else {
        [self.timer invalidate];
    }
}

@end

@implementation DWTimer

+ (NSTimer *) scheduledTimerWithTimeInterval:(NSTimeInterval)interval
                                      target:(id)aTarget
                                    selector:(SEL)aSelector
                                    userInfo:(id)userInfo
                                     repeats:(BOOL)repeats {
    DWTimerTarget* timerTarget = [[DWTimerTarget alloc] init];
    timerTarget.target = aTarget;
    timerTarget.selector = aSelector;
    timerTarget.timer = [NSTimer scheduledTimerWithTimeInterval:interval
                                                         target:timerTarget
                                                       selector:@selector(fire:)
                                                       userInfo:userInfo
                                                        repeats:repeats];
    return timerTarget.timer;
}

@end

如此,基本實現了我們的目標:通過弱引用傳入的target對象,徹底解決了NSTImer所隱藏的內存泄漏問題。CADisplayLink和NSTimer的問題基本一致,就不再贅述,也可以利用上述方法解決問題。


后續優化方案

文章寫了這么長時間后回頭再看,發現還有一種更好的方案,記錄如下。

給NSTimer增加一個分類,提供一個Block方法,這樣我們在使用Block時就可以像使用普通block那樣去處理循環引用問題了。

// NSTimer+Block.h
@interface NSTimer (Block)
+ (NSTimer *)dw_scheduledTimerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block;
@end
    
// NSTimer+Block.m
+ (NSTimer *)dw_scheduledTimerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer * _Nonnull))block {
    return [NSTimer scheduledTimerWithTimeInterval:interval target:self selector:@selector(callback:) userInfo:[block copy] repeats:YES];
}

+ (void)callback:(NSTimer *)timer {
    void(^block)(NSTimer *) = timer.userInfo;
    block(timer);
}

這樣在合適的時機(比如dealloc方法中執行invalidate方法)關閉timer即可。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。