第52条:别忘了NSTimer会保留其目标对象

  本条要点:(作者总结)

  •  NSTimer 对象会保留其目标,直到计时器本身失效为止,调用 invalidate 方法可令计时器失效,另外,一次性的计时器在触发完任务之后也会失效。
  • 反复执行任务的计时器(repeating timer),很容易引入保留环,如果这种计时器的目标对象又保留了计时器本身,那肯定会导致保留环。这种环状保留关系,可能是直接发生的,也可能是通过对象图里的其他对象间接发生的。
  • 可以扩充 NSTimer 的功能,用 “块”来打破保留环。不过,除非 NSTimer 将来在公共接口里提供此功能,否则必须创建分类,将相关实现代码加入其中。

  计时器是一种很方便也很有用的对象。Foundation 框架中有个类叫做 NSTimer,开发者可以指定绝对的日期与时间,以便到时执行任务,也可以指定执行任务的相对延时时间。计时器还可以重复运行任务,有个与之相关联的 “间隔值”(interval)可用来指定任务的触发频率。比方说,可以每 5 秒轮询某个资源。

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

  + (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)seconds target:(id)target selector:(SEL)selector userInfo:(id)userInfo repeats:(BOOL)repeats

  用此方法创建出来的计时器,会在指定的间隔时间之后执行任务。也可以令其反复执行任务,直到开发者稍后将其手动关闭为止。target 与 selector 参数表示计时器将在哪个对象上调用哪个方法。计时器会保留其目标对象,等到自身“失效”时再释放此对象。调用 invalidate 方法可令计时器失效;执行完相关任务之后,一次性的计时器也会失效。开发者若将计时器设置成重复执行模式,那么必须自己调用 invalidate 方法,才能令其停止。

  由于计时器会保留其目标对象,所以反复执行任务通常会导致应用程序出问题。也就是说,设置成重复执行模式的那种计时器,很容易引入 “保留环”。要想知道其中缘由,请看下列代码:

  #import <Foundation/Foundation.h>

  @interface EOCClass : NSObject 

  - (void)startPolling;

  - (void)stopPolling;

  @end

  @implementation EOCClass {

    NSTimer *_pollTimer;

  }

  - (id)init {

    return [super init];

  }

  - (void)dealloc {

    [_pollTimer invalidate];

  }

  - (void)stopPolling {

    [_pollTimer invalidate];

    _pollTimer = nil;

  }

  - (void)startPolling {

    _pollTimer = [NSTimer scheduledTimerWithTimeInterval: 5.0 target: self selector:@selector(p_doPoll) userInfo: nil repeats: YES];

  }

  - (void)p_doPoll {

    // Poll the resource

  }

  @end

 

  能看出问题吗?如果创建了本类的实例,并调用其 startPolling 方法,那会如何呢?创建计时器的时候,由于目标对象是 self ,所以要保留此实例。然而,因为计时器是用实例变量存放的,所以实例也保留了计时器。(回想一下,第 30 条说过,在 ARC 环境中,这种情况将执行保留操作。)于是,就产生了 “保留环”,如果此环能在某一时刻打破,那就不会出什么问题。然而要想打破保留环,只能改变实例变量或令计时器无效。所以说,要么调用 stopPolling,要么令系统将此实例回收,只有这样才能打破保留环。除非使用该类的所有代码均在你的掌控之中,否则无法确保 stopPolling 一定会调用。而且即便能满足此条件,这种通过调用某方法来避免内存泄漏的做法,也不是个好主意。另外,如果想在系统回收本类实例的过程中令计时器无效,从而打破保留环,那又会陷入死结。因为在计时器对象尚且有效时,EOCClass 实例的保留计数绝不会降为 0 ,因此系统也绝不会将其回收。而现在又没人来调用 invalidate 方法,所以计时器将一直处于有效状态。

  当指向 EOCClass 实例的最后一个外部引用移走之后,该实例仍然会继续存活,因为计时器还保留着它。而计时器对象也不可能为系统所释放,因为实例中还有个强引用正在指向它。更糟糕的是:除了计时器之外,已经没有别的引用再指向这个实例了,于是该实例就永远“丢失”了。而除了该实例之外,又没有其他引用指向计时器。于是,内存就泄漏了。这种内存泄漏问题尤为严重,因为计时器还将继续反复的执行轮询任务。要是每次轮询时都得联网下载数据的话,那么程序就会一直下载数据,这又更容易导致其他内存泄漏问题。

  单从计时器本身入手,很难解决这个问题。可以要求外界对象在释放最后一个指向本实例的引用之前,必须先调用 stopPolling 方法。然而这种情况无法通过代码检测出来。此外,假如该类随着某套公开的 API 对外发布给其他开发者,那么无法保证他们一定会调用此方法。

  这个问题可通过“块”来解决。虽然计时器当前并不直接支持块,但是可以用下面这段代码为其添加此功能:

  #import <Foundation/Foundation.h>

  @interface NSTimer (EOCBlockSupport)

  + (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();

  }

  }

  @end

  这个办法为何能解决“保留环”问题呢?大家马上就会明白。这段代码将计时器所应执行的任务封装成“块”,在调用计时器函数时,把它作为 userInfo 参数传进去。该参数可用来存放“不透明值”(opaque value)(不指明具体用途的值,可以理解为“万能值”),只要计时器还有效,就会一直保留着它。传入参数时要通过 copy 方法将 block 拷贝到 “堆”上,否则等到稍后要执行它的时候,该块可能已经无效了。计时器现在的 target 是 NSTimer 类对象,这是个单例,因此计时器是否会保留它,其实都无所谓。此处依然有保留环,然而因为类对象(class object)无须回收,所以不用担心。

  这套方案本身并不能解决问题,但它提供了解决问题所需的工具。修改刚才那段有问题的范例代码,使用新分类中的 eoc_scheduledTimerWithTimeInterval 方法来创建计时器:

  - (void)startPolling {

    _pollTimer = [NSTimer eoc_scheduledTimerWithTimeInterval: 5.0 block:^{

    [self p_doPoll];

    } repeats:YES];

  }

  仔细看看代码,就会发现还是有保留环。因为块捕获了 self 变量,所以块要保留实例。而计时器又通过 userInfo 参数保留了块。最后,实例本身还要保留计时器。不过,只要改用 weak 引用,即可打破保留环:

  - (void)startPolling {

    __weak EOCClass *weakSelf = self;

    _pollTimer = [NSTimer eoc_scheduledTimerWithTimeInterval: 5.0 block:^{

          EOCClass *strongSelf = weakSelf;

          [strongSelf p_doPoll];

          } repeats:YES];

  }

  这段代码采用了一种很有效的写法,它先定义了一个弱引用,令其指向 self,然后使块捕获这个引用,而不直接去捕获普通的 self 变量,也就是说,self 不会为计时器所保留。当块开始执行时,立刻生成 strong 引用,以保证实例在执行期间持续存活。

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

  END 

posted @ 2017-09-05 19:29  鳄鱼不怕牙医不怕  阅读(226)  评论(0编辑  收藏  举报