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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,772评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,458评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,610评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,640评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,657评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,590评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,962评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,631评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,870评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,611评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,704评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,386评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,969评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,944评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,179评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,742评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,440评论 2 342

推荐阅读更多精彩内容

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