最近在替公司 app 做健康檢查,找到一些 memory leaks 的問題,其中一個就是由 NSTimer 所引起的 retain cycle。

NSTimer 是個很容易造成 retain cycle 的物件,無論是新手或是老手都很可能一個不留意就踩到這個坑。舉個很常見的例子,這樣寫就產生 retain cycle 了:

@interface MyViewController()
@property (nonatomic, strong) NSTimer *timer;
@end
  
@implementation MyViewController
- (void)viewDidLoad {
  [super viewDidLoad];
  self.timer = [NSTimer scheduledTimerWithTimeInterval:0.5 target:self selector:@selector(timerFired:) userInfo:nil repeats:YES];
}
- (void)dealloc {
  [_timer invalidate];
  _timer = nil;
}
- (void)timerFired:(NSTimer *timer) {
  // Do something...
}
@end

發生什麼事

根據文件說明

The timer maintains a strong reference to targetuntil it (the timer) is invalidated.

也就是說,view controller 擁有這個 timer,而 timer 也擁有 view controller。你可能會想說「我不是在 dealloc 把 timer invalidate 了嗎?」但問題在於因為 retain cycle 已經造成 view controller 無法被釋放,所以 dealloc 不會被呼叫,timer 也就不會被 invalidate。

有人可能會想說「那我傳 weakSelf 給 target 不行嗎?」,答案是不行的,timer 依然會抓住 self 喔!

那如果我把 timer 改成 weak property 呢?這是蘋果文件裡頭建議的寫法,也的確可以打破 retain cycle,但「self 會被 timer 抓住,timer 會被 runloop 抓住」,所以還是無法被釋放。

Note in particular that run loops maintain strong references to their timers, so you don’t have to maintain your own strong reference to a timer after you have added it to a run loop.

解法

常見的解法有兩種,第一種是透過 proxy (middleman) 連結 target 跟 timer,我覺得比較麻煩所以不採用,有興趣的人可以看看參考資料。第二種是利用 block,寫起來簡單許多,蘋果也在 iOS 10 開始提供相關的兩支 API:

  • + scheduledTimerWithTimeInterval:repeats:block:
  • + timerWithTimeInterval:repeats:block:

因為還要支援舊版的 iOS,所以我透過 category 幫 NSTimer 加上兩支名稱故意雷同的 API,檔案放在我的 GitHub。廢話不多說,直接看程式碼:

@interface NSTimer (Block)
+ (NSTimer *)cht_scheduledTimerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block;
@end

@implementation NSTimer (Block)
+ (NSTimer *)cht_scheduledTimerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block {
    NSTimer *t = [self scheduledTimerWithTimeInterval:interval
                                               target:self
                                             selector:@selector(cht_invokeBlock:)
                                             userInfo:[block copy]
                                              repeats:repeats];
    return t;
}

+ (void)cht_invokeBlock:(NSTimer *)timer {
    void (^block)(NSTimer *) = timer.userInfo;
    if (block) {
        block(timer);
    }
}
@end

為什麼 block 要回傳 timer?

因為有可能你沒有用一個 property 儲存 timer,但你有需要用到它,這時你就可以在 block 裡頭使用了。就算你有用 property 存起來好了,這樣的設計也可以讓你在 block 裡頭不必 weak-strong dance 就能使用 timer。

為什麼 API 不提供 userInfo 設定

因為你現在可以使用 block 而不是舊有的 target-action 了,何必還要在 userInfo 塞資料呢。而且假設提供 userInfo 設定的話,使用者還得知道 timer.userInfo 的某個部份是 block,另一個部份才是他設定的資料,這樣很難用很容易出錯。

用法

程式碼還蠻好懂的,就不多做解釋了。有了 block 版本的 NSTimer 之後,開頭的例子就可以改寫成:

@interface MyViewController()
@property (nonatomic, weak) NSTimer *timer;  // 這裡用 weak 就可以了
@end

@implementation MyViewController
- (void)viewDidLoad {
  [super viewDidLoad];
  __weak typeof(self)weakSelf = self;
  self.timer = [NSTimer cht_scheduledTimerWithTimeInterval:0.5 repeats:YES block:^(NSTimer *timer) {
    __strong typeof(weakSelf)self = weakSelf;
    [self timerFired:timer];
  }];
}
- (void)dealloc {
  [_timer invalidate];
  _timer = nil;
}
- (void)timerFired:(NSTimer *timer) {
  // Do something...
}
@end

參考資料