52个有效方法(52) - 别忘了NSTimer会保留其目标对象

计时器要和“运行循环”(run loop)相关联,运行循环到时会触发任务。创建NSTimer时,可以将其“预先安排”在当前的运行循环中,也可以先创建好,然后由开发者来调度。无论采用哪种方式,只有把计时器放在运行循环里,它才能正常触发任务。

NSTimer常用的创建方法有以下两种

  • 第一种创建方式
    + (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL) repeats;
    
    • scheduledTimerWithTimeInterval 在主线程创建的定时器会在创建后自动将 timer 添加到主线程的 runloop 并启动.
    • 主线程的 runloopModeNSDefaultRunLoopMode,但是在 ScrollView 滑动时执行的是 UITrackingRunLoopMode.NSDefaultRunLoopMode被挂起,定时器失效,等到停止滑动才恢复.
    • 因此需要将 timer 分别加入 UITrackingRunLoopModeNSDefaultRunLoopMode
    [[NSRunLoop mainRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];
    [[NSRunLoop mainRunLoop] addTimer:timer forMode: UITrackingRunLoopMode];
    
    或者直接添加到 NSRunLoopCommonModes
    [[NSRunLoop mainRunLoop] addTimer:timer forMode: NSRunLoopCommonModes];
    
  • 第二种创建方式
    + (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL) repeats;
    
    • timerWithTimeInterval 创建的定时器不会直接启动,而需要手动添加到 runloop 中;为防止出现滑动视图时定时器被挂起,可直接添加到 NSRunLoopCommonModes.

保留环问题

重复执行模式的计时器,很容易引入保留环

@interface EOCClass : NSObject
- (void)startPolling;
- (void)stopPolling;
@end

@implementation EOCClass{
    NSTimer *_poliTimer;
}

- (id) init{
    return [super init];
}

- (void)dealloc{
    [_poliTimer invalidate];
}

- (void)stopPolling{
    [_poliTimer invalidate];
    _poliTimer = nil;
}

- (void)startPolling{
    _poliTimer = [NSTimer scheduledTimerWithTimeInterval:5.0 target:self selector:@selector(p_doPoll) userInfo:nil repeats:YES];
}

- (void)p_doPoll{
    // Poll the resource
}

如果创建了本类实例,并调用了startPolling方法。创建计时器的时候,由于目标对象是self,所以要保留此实例。然而,因为计时器是用实例变量存放的,所以实例也保留了计数器,于是就产生了保留环。
调用stopPolling方法或令系统将实例回收(会自动调用dealloc方法)可以使计时器失效,从而打破循环,但无法确保startPolling方法一定调用,而由于计时器保存着实例,实例永远不会被系统回收。当EOCClass实例的最后一个外部引用移走之后,实例仍然存活,而计时器对象也就不可能被系统回收,除了计时器外没有别的引用再指向这个实例,实例就永远丢失了,造成内存泄漏。

由于计时器保留其目标对象,而对象又保留计时器,所以导致保留环

解决方案:采用块为计时器添加新功能,为NSTimer添加分类

@interface NSTimer (EOCBlocksSupport)
+ (NSTimer*)eoc_scheduledTimerWithTimeInterval:(NSTimeInterval)interval block:(void(^)())block repeats:(BOOL)repeats;
@end

@implementation NSTimer( EOCBlocksSupport)

+ (NSTimer*)eoc_scheduledTimerWithTimeInterval:(NSTimeInterval)interval block:(void (^)())block repeats:(BOOL)repeats{
    return [self scheduledTimerWithTimeInterval:interval target:self selector:@selector(eoc_blockInvoke:) userInfo:[block copy] repeats:repeats];
}

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

在外部调用时范例


- (void)startPolling{
    __weak EOCClass *weakSelf = self;
    _poliTimer = [NSTimer eoc_scheduledTimerWithTimeInterval:5.0 block:^{
        EOCClass *strongSelf = weakSelf;
        [strongSelf p_doPoll];
    } repeats:YES];
}

这段代码先定义了一个弱引用指向self,然后用块捕获这个引用,这样self就不会被计时器所保留,当块开始执行时,立刻生成strong引用,保证实例在执行器继续存活。

采用这种写法之后,如果外界指向 EOCClass 实例的最后一个引用将其释放,则该实例就可为系统所回收了。回收过程中还会调用计时器的 invalidate 方法,这样的话,计时器就不会再执行任务了。此处使用 weak 引用还能令程序更加安全,因为有时开发者可能在编写 dealloc 时忘了调用计时器的 invalidate 方法,从而导致计时器再次运行,若发生此类情况,则块里的 weakSelf 会变成 nil。

要点
  1. NSTimer对象会保留其目标,直到计时器本身失效为止,调用invalidate方法可令计时器失效,另外,一次性的计时器在触发任务之后也会失效。

  2. 反复执行任务的计时器,很容易引入保环,如果这种计时器的目标对象又保留了计时器本身,那肯定会导致保留环。这种环状保留关系,可能是直接发生的,也可能是通过其他对象间接发生的。

  3. 可以扩充NSTimer的功能,用“块”来打破保留环。不过,除非NSTimer将来在公共接口里提供此功能,否则必须创建分类,将相关实现代码加入其中。

推荐阅读更多精彩内容