避免 NSTimer定时器 未调用 invalidate 而导致的内存泄露

问题的起因

前段时间在维护项目时,遇到了一个内存泄露,后面查出是使用了NSTimer定时器而导致的问题(因为没有在UI界面关闭时调动invalidate停止定时器,导致UI类对象不能被释放)。
定时器NSTimer在使用的时候,很多新人都会忽略 invalidate 这个方法(也有一些人本来是打算在功能写完之后再添加,结果忘了…),而因为NSTimer的方法参数里面,target参数都是强引用的方式,所以会造成target对象引用计数+1(这里往往是UIViewController对象),如果在后面忘记了释放NSTimer对象,那么target对象(ViewController)就不会被释放,于是就导致内存泄露啦。SafeTimer也可以适用于不确定在哪里调用 invalidate 的情况,因为只要定时器所在的对象被释放了,那么定时器也就会停止释放了。

+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti invocation:(NSInvocation *)invocation repeats:(BOOL)yesOrNo;
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti invocation:(NSInvocation *)invocation repeats:(BOOL)yesOrNo;

+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo;
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo;

官方文档关于 target:(id)aTarget 的注释:

target
The object to which to send the message specified by aSelector when the timer fires. The timer maintains a strong reference to target until it (the timer) is invalidated.

解决问题的方法

因为工程代码是之前遗留下来的,让我一个一个地方去找出NSTimer,并在适当时候调用invalidate,想想都懒……

只好想一个方法,一劳永逸了!

于是我就干脆自己写一个类SafeTimer,思路就是在初始化定时器的时候,添加一个自定义类的对象代替原本的Target,使得NSTimer定时器只会保留自定义的类对象,而不影响原先Target的引用计数。
额,讲的烦躁,直接上图吧

图1:NSTimer原先的调用方式
图2:SafeTimer的调用方式

这样,我只需要在 SafeObj 里面处理定时器NSTimer的回调,然后在回调里面判断viewController对象是否已经被释放;如果viewController已经被释放了,那么就停止计时器;否则就把消息发送给 viewController ,让viewController处理回调事件。

简单来说,就是在NSTimer的回调中 增加了一个中间层判断。


这是项目的github地址:https://github.com/WalkingToTheDistant/SafeTimer/tree/master/Timer/SafeTimer,如果觉得我写的代码对你有帮助的话,给我一个星呗^ _ ^
可能项目还存在问题(自己测试能力有限),如果你们发现了问题,告诉我一声,我一定尽快修改过来的哈。


关键代码说明

首先在SafeTimer里面增加NSTimer的函数方法,然后在SafeTimer里面增加SafeObj对象初始化NSTimer。
(PS:也许有人会问,为什么SafeTimer不是继承NSTimer为父类,那么就不用在头文件手动添加NSTimer的方法啦?这个问题我在最后解释吧)

// =======================================================================
@implementation SafeTimer

+ (NSTimer*)scheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo
{
    SafeObj *obj = [SafeObj new];
    SEL newSel = [obj replaceSafeTarget:aTarget withSelector:aSelector]; // 保存原先的aTarget和aSelector,并返回替换的aSelector
   
    NSTimer *timer = nil;
    if([[self class] isUseBlock] == YES){ // 判断iOS版本>=10.0时,则使用NSTimer的新方法block
        timer = [NSTimer scheduledTimerWithTimeInterval:ti repeats:yesOrNo block:^(NSTimer * _Nonnull timer) {
            
            if(obj != nil) {
                [obj handleTimer:timer];
            }
        }];
    } else { // 使用自定义类初始化定时器NSTimer
        timer = [NSTimer scheduledTimerWithTimeInterval:ti target:obj selector:newSel userInfo:userInfo repeats:yesOrNo]; 
    }
    obj.safeTimer = timer; // 弱引用指向该定时器,是为了在SafeObj里面能处理定时器NSTimer
    
    return timer;
}

上面代码就完成了NSTimer的初始化,那么再来看看SafeObj里面如何处理定时器的回调处理

@interface SafeObj : NSObject

@property(nonatomic, weak) NSTimer *safeTimer; // 弱引用

@property(nonatomic, weak) id target; // 弱引用

@property(nonatomic) SEL selector;

@property(nonatomic, assign) IMP impOfSelector;

@end

@implementation SafeObj

- (SEL) replaceSafeTarget:(id)aTarget withSelector:(SEL)aSelector
{
    _target = aTarget; 
    _selector = aSelector;
    if(_target != nil
       && _selector != nil){ // 为了加快调用函数的速度
        _impOfSelector = [_target methodForSelector:_selector];
    } else {
        _impOfSelector = nil;
    }
    
    return @selector(handleTimer:); // 返回了SafeObj自定义的函数
}

我们在 replaceSafeTarget 里面,先把原本的 aTarget 和 aSelector 保留下来,然后把SafeObj自定义的函数handleTimer作为返回值,给定时器NSTimer进行初始化。那么NSTimer就会定时回调 SafeObj 的 handleTimer,接下来就在看看 handleTimer 中如何把消息传给原本的 aSelector 了

- (void) handleTimer:(NSTimer*)timer
{
    if(_target != nil // 因为 _target 是弱引用,所以当 _target 指向的对象已经被释放的时候,那么_target就会被置nil
       && _selector != nil
       && [_target respondsToSelector:_selector] == YES){ // 调用原先的Selector方法

        if(_impOfSelector != nil){ // 考虑到消息发送会有一定的查找时间,所以这里直接调用IMP,节省消息发送的时间
            ((void(*)(id, SEL, id))_impOfSelector)(_target, _selector, timer);
        } else {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
            [_target performSelector:_selector withObject:timer];
#pragma clang diagnostic pop
        }
        
    } else { // 关闭定时器
        if(_safeTimer != nil){
            [_safeTimer invalidate];
        }
        _safeTimer = nil;
        _invocation = nil;
        _impOfSelector = nil;
    }
}

SafeTimer的主要功能都介绍完了


NSTimer类簇

最后,再来解释SafeTimer为什么不继承NSTimer
首先需要了解一下iOS的 类簇https://www.jianshu.com/p/3ae2f9589fae 我这篇文章有说明类簇的一部分知识,如果想要了解更多的话,那就去网上查查呗。

类簇,简单来说,就是虚类和继承类的关系,平时我们使用的类,如NSTimer和NSString,都是虚类,只是声明了方法,而具体实现都是在其子类里面。在编译的时候,系统会根据对象使用环境而将虚类(NSTimer)替换成对应的子类,代码的功能都是在子类对象里面实现。

接下来我们看下平时代码中NSTimer的具体实现子类是什么:

NSTimer *timer3 = [NSTimer scheduledTimerWithTimeInterval:5 invocation:invocation repeats:YES];

-> po  timer3

-> <__NSCFTimer: 0x60c000170740>

__NSCFTimer 这个就是实现定时器功能的子类的名称。

接下来,如果我们的SafeTimer 继承了 NSTimer,那么关系就会变成这样

NSTimer类继承关系图

那么如果我们在 SafeTimer 里面调用super 的方法的话,那么就是调用父类NSTimer的方法,可是实际上NSTimer并没有任何方法实现,那么代码就会报错:

Timer[40419:14279075] *** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '*** initialization method -initWithFireDate:interval:target:selector:userInfo:repeats: cannot be sent to an abstract object of class SafeTimer: Create a concrete instance!'

错误信息很明显:不能用虚类对象调用方法。

而系统在编译的时候,遇到SafeTimer时,不确定是干嘛的,所以不会自动转换成NSTimer类簇里面具体的子类,而SafeTimer里面如果也没有去实现这个方法,那么代码也会报无法找到具体实现的错误(因为iOS查找方法时,都是往父类上查找的,所以无法查找到NSTimer其他同级的子类是否实现)。


附录:更准确的定时器

NSTimer定时器在一定程度上是无法准确按时回调的(受CPU影响),如果有需求需要准备的时间显示,比如毫秒级的倒计时。那么可以使用CADisplayLink 或者 dispatch_source_t。

CADisplayLink 跟屏幕刷新频率一致(60帧/秒),适用于UI刷新,而且回调都是在主线程中执行;而dispatch_source_t 适用于回调处理逻辑计算,可指定在主线程或者子线程上执行。
CADisplayLink示例代码:

CADisplayLink  displayLick = [CADisplayLink displayLinkWithTarget:self selector:@selector(handleDisplayLink)];
[displayLick addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode];

dispatch_source_t示例代码:

@property(nonatomic, retain) dispatch_source_t timer; // 注意这里,dispatch_source_t对象在函数作用域之后必须保留对象不被释放,不然定时回调不会执行

_timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0,
dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0));
    // dispatch_source_set_timer 第二个参数标识定时器什么时候开始(DISPATCH_TIME_NOW-现在),第三个参数表示1秒循环一次,NSEC_PER_SEC-秒,NSEC_PER_MSEC-毫秒
    dispatch_source_set_timer(_timer, DISPATCH_TIME_NOW, 1 * NSEC_PER_SEC, 0); 
    dispatch_source_set_event_handler(_timer, ^{ 
        // 定时器处理代码
        NSLog(@"1");
});
dispatch_resume(_timer);

使用 dispatch_source_t 需要注意的时候,创建 dispatch_source_create 返回的 dispatch_source_t 的对象一定要保存下来,不然如果出了作用域之后 dispatch_source_t 对象被释放的话,那么定时器也不会执行了。

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

推荐阅读更多精彩内容

  • 1.ios高性能编程 (1).内层 最小的内层平均值和峰值(2).耗电量 高效的算法和数据结构(3).初始化时...
    欧辰_OSR阅读 29,242评论 8 265
  • Swift1> Swift和OC的区别1.1> Swift没有地址/指针的概念1.2> 泛型1.3> 类型严谨 对...
    cosWriter阅读 11,065评论 1 32
  • 零、说在前面的 最近趁着悠闲,所以总是想写点什么,主要是为了总结。不总结、恐怕以后就被遗忘了,总结一下、也能很好的...
    CoderHG阅读 1,875评论 1 18
  • 如果我们在一起,会不会是最幸福的一对。 可偏偏,我们那么相爱,却没有在一起。 我们知道彼此好多好多小秘密,在一起时...
    秋山2407阅读 181评论 0 0
  • 婚后这几年,我们很少在大街上手拉手,不是害羞,而是中间多了一个让我们共同牵手的人物--卡卡,他毫无目的跑动,让我们...
    思想聚焦的原创阅读 185评论 0 7