NSTimer内存泄漏、解决及MSWeakTimer

泄漏原因

NSTimer对象会强引用它的target对象。具体造成引用循环的原因,可以先看下以下代码:

#import "ViewController.h"

@interface ViewController (){
    NSTimer *_timer;
}

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    [self startPolling];
}

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

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

- (void)doPoll {
    //Do Something
}

- (void)dealloc {
    [_timer invalidate];
}
@end

我们的ViewController对象强引用一个实例变量_timer,与此同时_timer的target又是self(当前ViewController对象),前文提到过NSTimer会强引用它的target,此时就产生了一个引用循环。

引用循环示例图

目前打破这个循环的方式就是要么手动置空viewController,要么调用stopPolling方法置空_timer。
虽然看上去打破这个循环不难,但是如果需要手动去调用一个方法来避免内存泄漏其实是有点不太合理的。
如果想用过在dealloc方法中调用stopPolling方法去打破循环会带来一个鸡生蛋的问题:该视图控制器是无法被释放的,它的引用计数器因为_timer的原因永远不会降到0,也就不会触发dealloc方法。

解决

Block法

思路就是使用block的形式替换掉原先的“target-selector”方式,打断_timer对于其他对象的引用。
官方已经在iOS10之后加入了新的api,从而支持了block形式创建timer:

NSTimer新api

根据翻译,加入block形式就是为了避免引用循环。
但是其实在项目中,为了向下兼容,这个api估计也是暂时用不到了。

根据《Effective Objective-C 2.0》一书的做法其实也是类似于官方的,不过基于更低版本的api,适配起来会方便很多,可以参考一下:

#import <Foundation/Foundation.h>

@interface NSTimer (EOCBlockSupport)

+ (NSTimer *)eoc_scheduledTimerWithTimeInterval:(NSTimeInterval)interval
                                        repeats:(BOOL)repeats
                                          block:(void (^)(NSTimer *timer))block;
@end
#import "NSTimer+EOCBlockSupport.h"

@implementation NSTimer (EOCBlockSupport)

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

#pragma mark - Private Method
- (void)eoc_blockInvoke:(NSTimer *)timer {
    void(^block)(NSTimer *timer) = timer.userInfo;
    if (block) {
        block(timer);
    }
}
@end

简单来说就是使用userInfo这个参数去传递block给selector去进行执行,target是timer自己,不会造成引用循环。还有一个需要注意的地方就是规避block的引用循环,为什么之类的详细解释不在这说了。

构造第三方target法

@GGGHub对于该方法比较有研究:

利用RunTime解决由NSTimer导致的内存泄 漏
利用NSProxy解决NSTimer内存泄漏问题

以下内容也是基于他给出的方法进行展开。
首先讲一下runtime的方法,关键思路还是打破viewController的引用计数不能降为0,从而使它可以调用dealloc方法,从而再打断viewController和timer的强引用,代码如下,需要复制的去原博:

runtime法

画张图方便理解:

原理图

虽然图中_targetObject和_timer之间好像有循环引用,但是由于self的干预可以直接置空_timer从而打破循环。

至于NSPorxy方法其实原理也是一样的,也是运用runtime,不过使用了消息转发的机制,使用NSProxy的原因如下(引用):

实际上本篇用了消息转发的机制来避免NSTimer内存泄漏的问题,无论NSProxy
与NSObject的派生类在Objective-C
运行时找不到消息都会执行消息转发。所以这个解决方案用NSProxy与NSObject
的子类都能实现,不过NSProxy从类名来看是代理类专门负责代理对象转发消息的。相比NSObject类来说NSProxy更轻量级,通过NSProxy可以帮助Objective-C
间接的实现多重继承的功能。

截一段代码:

使用NSProxy
MSWeakTimer

描述
MSWeakTimer是由mindsnacks写的一个轻量级的定时器库,使用GCD来实现,没有引用循环的问题并且线程安全。

先来解决一个问题,线程安全是什么鬼?
苹果在NSTimer文档的invalidate方法中写到:

Special Considerations
You must send this message from the thread on which the timer was installed. If you send this message from another thread, the input source associated with the timer may not be removed from its run loop, which could prevent the thread from exiting properly.

大概就是NSTimer的启动和失效必须都是在同一个线程调用,否则可能没用。

所以对于匿名的GCD线程,我们最好不要在里面用NSTimer了,而使用GCD自带的定时线程,于是MSWeakTimer诞生了。值得一提的是这个库是苹果工程师认证过的。

初始化

- (id)initWithTimeInterval:(NSTimeInterval)timeInterval
                    target:(id)target
                  selector:(SEL)selector
                  userInfo:(id)userInfo
                   repeats:(BOOL)repeats
             dispatchQueue:(dispatch_queue_t)dispatchQueue
{
    NSParameterAssert(target);
    NSParameterAssert(selector);
    NSParameterAssert(dispatchQueue);

    if ((self = [super init]))
    {
        self.timeInterval = timeInterval;
        self.target = target;
        self.selector = selector;
        self.userInfo = userInfo;
        self.repeats = repeats;

        NSString *privateQueueName = [NSString stringWithFormat:@"com.mindsnacks.msweaktimer.%p", self];
        //创建一个私有的串行队列
        self.privateSerialQueue = dispatch_queue_create([privateQueueName cStringUsingEncoding:NSASCIIStringEncoding], DISPATCH_QUEUE_SERIAL);
        //保证私有的串行队列任务在目标队列上串行执行(先进先执行)。
        dispatch_set_target_queue(self.privateSerialQueue, dispatchQueue);
        //创建timer事件
        self.timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER,
                                            0,
                                            0,
                                            self.privateSerialQueue);
    }

    return self;
}

tolerance
由于系统底层的调度优化关系,当我们使用定时器调用fired的时候并不能立马就能运行的。可能马上运行,也可能需要等一段时间(如果当前CPU忙着做别的事情)。当时我们可以设置一个最大等待时间。
看设置时间时候的源代码:

- (void)resetTimerProperties
{
    int64_t intervalInNanoseconds = (int64_t)(self.timeInterval * NSEC_PER_SEC);
    int64_t toleranceInNanoseconds = (int64_t)(self.tolerance * NSEC_PER_SEC);

    dispatch_source_set_timer(self.timer,
                              dispatch_time(DISPATCH_TIME_NOW, intervalInNanoseconds),
                              (uint64_t)intervalInNanoseconds,
                              //这里设置了等待时间
                              toleranceInNanoseconds
                              );
}

再看看官方对于这个参数的详细解释吧:

Any fire of the timer may be delayed by the system in order to improve power consumption and system performance. The upper limit to the allowable delay
may be configured with the 'leeway' argument, the lower limit is under the
control of the system.
For the initial timer fire at 'start', the upper limit to the allowable delay is set to 'leeway' nanoseconds. For the subsequent timer fires at 'start' + N * 'interval', the upper limit is MIN('leeway','interval'/2).
The lower limit to the allowable delay may vary with process state such as visibility of application UI. If the specified timer source was created with a mask of DISPATCH_TIMER_STRICT, the system will make a best effort to strictly observe the provided 'leeway' value even if it is smaller than the current lower limit. Note that a minimal amount of delay is to be expected even if this flag is specified.

对于刚创建的timer第一次在start时间点fire,那么这个fire的时间上限为'leeway',即第一次fire不会晚于'start' + 'leeway' 。
对于重复了N次的fire,那么这个时间上限就是 MIN('leeway','interval'/2)。
如果我们使用了参数DISPATCH_TIMER_STRICT,那么系统将尽最大可能去"尽早
"启动定时器,即使DISPATCH_TIMER_STRICT比当前的发射延迟下限还低。注意就算这样,还是会有微量的延迟。

MSWeakTimer中对于这个参数就是重新包装一下,名字叫tolerance,更好理解一点。

OSAtomicTestAndSetBarrier

先看代码:

- (void)invalidate
{
    // We check with an atomic operation if it has already been invalidated. Ideally we would synchronize this on the private queue,
    // but since we can't know the context from which this method will be called, dispatch_sync might cause a deadlock.
    if (!OSAtomicTestAndSetBarrier(7, &_timerFlags.timerIsInvalidated))
    {
        dispatch_source_t timer = self.timer;
        dispatch_async(self.privateSerialQueue, ^{
            dispatch_source_cancel(timer);
            ms_release_gcd_object(timer);
        });
    }
}

- (void)timerFired
{
    // Checking attomatically if the timer has already been invalidated.
    if (OSAtomicAnd32OrigBarrier(1, &_timerFlags.timerIsInvalidated))
    {
        return;
    }

    // We're not worried about this warning because the selector we're calling doesn't return a +1 object.
    #pragma clang diagnostic push
    #pragma clang diagnostic ignored "-Warc-performSelector-leaks"
        [self.target performSelector:self.selector withObject:self];
    #pragma clang diagnostic pop

    if (!self.repeats)
    {
        [self invalidate];
    }
}

在invalidate方法中使用了异步方法去取消定时器,因为用同步的话可能带来线程死锁。
于是这里引入了一个比较优雅的OSAtomicTestAndSetBarrier方法去判断和更改timer的invalidate状态。
这个函数的作用就是原子性得去检测并设置屏障

  • 好处一:原子操作
  • 好处二:检测和改变变量一步到位
  • 好处三:高大上
    后面的OSAtomicAnd32OrigBarrier也是差不多意思。(水平不高,就不敢乱说话了)。

这一块还是需要专门花时间去研读一下:Threading Programming Guide

推荐阅读更多精彩内容

  • 我们常用NSTimer的方式 如下代码所示,是我们最常见的使用timer的方式 当使用NSTimer的schedu...
    yohunl阅读 1,509评论 1 17
  • 首先介绍NSTimer的几种创建方式 常用方法 三种方法的区别是: scheduledTimerWithTimeI...
    不吃鸡爪阅读 681评论 0 3
  • 偶得前言 NSRunLoop与定时器 [- invalidate的作用](#- invalidate的作用) 我们...
    tingxins阅读 790评论 0 11
  • CSS的样式1.内置样式,就是html文件在标签上的默认的样式2.外部样式引入 3.内部样式表 各种选择器 4.内...
    种谔阅读 216评论 0 0
  • 同学选修成功挂掉,在班群抱怨说老师平时还总点名,分给的少,选修而已,居然还挂了他,十分气愤,奉劝我们别选他的课。看...
    oO0啦啦阅读 124评论 0 0