iOS定时器NSTimer内存泄露原理分析+解决方案

  • 一、NSTimer简介
  • 二、NSTimer与RunLoop
  • 三、NSTimer内存泄露分析
    • 1.NSTimer引用分析
    • 2.NSTimer内存泄漏解决方案
  • 四、NSTimer使用建议
    • 1.初始化分析
    • 2.延迟定时任务VS重复定时任务

一、NSTimer简介

  • NSTimer是iOS开发执行定时任务时常用的类,它支持定制定时任务的开始执行时间、任务时间间隔、重复执行、RunLoopMode等。
  • NSTimer必须与RunLoop搭配使用,因为其定时任务的触发基于RunLoop,NSTimer使用常见的Target-Action模式。由于RunLoop会强引用timer,timer会强引用Target,容易造成循环引用、内存泄露等问题。本文主要分析内存泄露原因,提供笔者所了解到的解决方案。代码详见DEMO,欢迎留言或者邮件(mailtolinbing@163.com)勘误、交流。

二、NSTimer与RunLoop

  • NSTimer需要与RunLoop搭配使用。创建完定时器后,需要把定时器添加到指定RunLoopMode,添加完毕定时器就自动触发(fire)或者在设定时间fireDate后自动触发。
  • NSTimer并非真正的机械定时器,可能会出现延迟情况。当timer注册的RunLoop正在执行耗时任务、或者当前RunLoopMode并非注册是指定的mode,定时任务可能会延迟执行。

三、NSTimer内存泄露分析

1.NSTimer引用分析

  • 1.1 NSTimer使用步骤

    • 1.创建NSTimer对象,传入Target、Action等;
    • 2.根据需求把NSTimer加入到特定RunLoop的特定Mode。此时timer会自动fire,若此前指定了启动时间fireDate,则会在指定时间自动fire
    • 3.当定时器使用完毕,调用invalidate使之失效。
  • 1.2 NSTimer引用分析

把NSTimer当做普通对象使用,如下实现定时任务,会出现内存泄露

@interface FYLeakView()
@property (nonatomic, strong) NSTimer *timer;
@end

@implementation FYLeakView

// 不会调用
- (void)dealloc {
    NSLog(@"%s", __func__);
    [_timer invalidate];
}

- (instancetype)initWithFrame:(CGRect)frame {
    if (self = [super initWithFrame:frame]) {
        self.backgroundColor = [UIColor yellowColor];
        
        NSLog(@"%@", self.timer); // 触发定时器创建
    }
    return self;
}

- (void)p_timerAction {
    NSLog(@"%s", __func__);
}

- (NSTimer *)timer {
    if (_timer == nil) {
        _timer = [NSTimer timerWithTimeInterval:1 target:self selector:@selector(p_timerAction) userInfo:nil repeats:YES];
        [[NSRunLoop mainRunLoop] addTimer:_timer forMode:NSRunLoopCommonModes];
    }
    return _timer;
}
@end
  • 此时的内存中对象的引用关系图如下
对象引用关系.png
  • 对象间引用关系分析

    • 1.创建定时器,调用timerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo;,传入Target对象,Timer会在强引用Target直到Timer失效(调用invalidate)
    • 2.注册定时器,调用RunLoop的addTimer:(NSTimer *)timer forMode:(NSRunLoopMode)mode把Timer注册到RunLoop对应mode。RunLoop会强引用Timer。当调用Timer的invalidate使之失效时,RunLoop才会把Timer从RunLoop中移除并清除Timer对Target的强引用,invalidate是把Timer从RunLoop中移除的唯一方法。
    • 3.Target要使用Timer做定时任务,通常会强引用Timer。
  • 内存泄露分析

    • 创建Timer必须传入Target,引用1不可避免。Timer事件触发基于RunLoop运行循环,必须把Timer添加到RunLoop中,引用2也不可避免。
    • 把Timer注册到RunLoop后,Timer会被其强引用,保证Timer存活,定时任务能触发。因此Target对象使用Timer实际上可以使用weak弱引用,只要你能保证创建完Timer将其加入RunLoop中。(注意:Timer通常用懒加载,且Timer一加入RunLoop就自动fire,如果想在特定时间点才fire定时任务,那必须到特定时间点才加入RunLoop,或者初始化后马上加入RunLoop并暂停定时器)
    • Target强引用or弱引用Timer并不是问题的关键,问题的关键是:一定要在Timer使用完毕调用invalidate使之失效(手动调用or系统自动调用),Timer从RunLoop中被移除并清除强引用,这个操作可打破引用1、2,而引用3是强弱引用已经不重要了。
    • 但是哪个时间点才适合invalidate,上述例子在dealloc中invalidate并不奏效。原因是:dealloc在Target对象析构时由系统调用,根据上图RunLoop强引用Timer、Timer强引用Target。Target必须等待Timer执行invalidate后清除其对Target的强引用,dealloc才会执行。Timer则在等待Target析构由系统调用dealloc而执行invalidate,两者陷入互相等待的死循环。
    • 此时造成的主要问题有两个:Target、Timer内存泄露; 定时任务会持续执行,如果定时任务中存在耗性能操作,或者操作公共数据结构等,结果相当糟糕。
  • 1.3 解决途径:在合适时间点invalidate定时器???

    • 上文提示只要invalidate定时器即可解决问题,然而不能在dealloc中invalidate,哪个时间点才是合适时间点?
    • 如下例子:上述带定时任务的自定义View提供一个invalidate接口给使用该类的客户执行invalidate操作,可解决问题。若是带有定时任务ViewController,如果在viewWillDisappear中执行invalidate可解决问题,但是当要求只要控制器对象存活就必须执行定时任务,就无法满足需求,灵活性差。如果控制器也提供invalidate接口,使用控制器类的客户很难找到合适时间点调用,因为通常控制器都是在pop出栈时释放,就算找到合适的时间点,也不是好的处理方案。原因是:该方案会把内部定时任务暴露出去,破坏封装性;且你无法保证使用者都记得invalidate,定时任务应该由内部管理。
@interface FYNormalView : UIView
- (void)invalidate;
@end

@implementation FYNormalView
- (void)invalidate {
    [self.timer invalidate];
}

......
@end

@interface FYNormalViewController ()
@property (nonatomic, strong) FYNormalView *normalView;
@property (nonatomic, strong) NSTimer *timer;
@end

@implementation FYNormalViewController
- (void)dealloc {
    NSLog(@"%s", __func__);
    [_normalView invalidate]; // invalidate
}

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.view.backgroundColor = [UIColor whiteColor];
    self.title = @"定时器 避免内存泄漏";
    [self.view addSubview:self.normalView];
    self.normalView.frame = CGRectMake(100, 100, 100, 100);
    
    NSLog(@"%f", self.timer.timeInterval);
}

- (void)viewWillDisappear:(BOOL)animated {
    [super viewWillDisappear:animated];
    [self.timer invalidate]; // invalidate
}

- (void)p_timerAction {
    NSLog(@"%s", __func__);
}

- (NSTimer *)timer {
    if (_timer == nil) {
        _timer = [NSTimer timerWithTimeInterval:1 target:self selector:@selector(p_timerAction) userInfo:nil repeats:YES];
        [[NSRunLoop mainRunLoop] addTimer:_timer forMode:NSRunLoopCommonModes];        
    }
    return _timer;
}

- (FYNormalView *)normalView {
    if (_normalView == nil) {
        _normalView = [[FYNormalView alloc] init];
    }
    return _normalView;
}
@end

2.NSTimer内存泄漏解决方案

  • 内存泄漏主要原因是RunLoop强引用Timer、Timer强引用Target,导致Target不执行析构。下面提供两种解决方案,本质是是从Target入手,把Target替换为另一个对象,而不是使用Timer的客户对象。客户对象不在作为Target,即可像使用普通对象一样,在dealloc中invalidate Timer。

  • 方案一:使用Block代替Target-Action

@implementation NSTimer (Block)
+ (instancetype)fy_scheduledTimerWithTimeInterval:(NSTimeInterval)inTimeInterval
                                     actionBlock:(FYTimerActionBlock)block
                                          repeats:(BOOL)yesOrNo
{
    NSTimer *timer = [self scheduledTimerWithTimeInterval:inTimeInterval target:self selector:@selector(p_timerAction:) userInfo:block repeats:yesOrNo];
    return timer;
}

+ (instancetype)fy_timerWithTimeInterval:(NSTimeInterval)inTimeInterval
                            actionBlock:(FYTimerActionBlock)block
                            runLoopMode:(NSRunLoopMode)mode
                                 repeats:(BOOL)yesOrNo
{
    NSTimer *timer = [self timerWithTimeInterval:inTimeInterval target:self selector:@selector(p_timerAction:) userInfo:block repeats:yesOrNo];
    [[NSRunLoop mainRunLoop] addTimer:timer forMode:mode];
    return timer;
}

+ (void)p_timerAction:(NSTimer *)timer {
    
    if([timer userInfo]) {
        FYTimerActionBlock actionBlock = (FYTimerActionBlock)[timer userInfo];
        actionBlock(timer);
    }
}
@end

/// 客户对象使用Timer
@interface FYSolutionView()
@property (nonatomic, strong) NSTimer *timer;
@end

@implementation FYSolutionView

- (void)dealloc {
    NSLog(@"%s", __func__);
    [_timer invalidate];  // View析构时,由内部invalid
}

- (instancetype)initWithFrame:(CGRect)frame {
    if (self = [super initWithFrame:frame]) {
        self.backgroundColor = [UIColor blueColor];
        [self.timer fy_resumeTimer];
    }
    return self;
}

- (void)p_timerAction {
    NSLog(@"%s", __func__);
}

- (NSTimer *)timer {
    if (_timer == nil) {
        __weak typeof(self) weakSelf = self;
        _timer = [NSTimer fy_timerWithTimeInterval:1 actionBlock:^(NSTimer *timer) {
            [weakSelf p_timerAction];
        } runLoopMode:NSRunLoopCommonModes repeats:YES];
    }
    return _timer;
}
@end
Block方案 对象引用关系.png
  • iOS10开始,系统新增了block形式的初始化方式(NSTimer *)timerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block。目前大多数应用会兼容iOS8,因此必须自己构建block初始化方案。
  • 上述代码使用block封装定时任务,并对block把执行copy操作拷贝到堆上,由Timer的userInfo持有。创建Timer时传入NSTimer类对象作为Target,类对象也是对象,但是它类似单例,由系统管理什么周期。
  • 使用Timer的客户对象Client(FYSolutionView)不再被Timer强引用,当它执行dealloc时,调用Timer invalidate,所有引用关系正常打破。注意在定时任务block使用self时需要注意转为弱指针,否则还是会有循环引用,详见引用关系图。
  • 方案二:直接替换Target
  • 代码与对象间引用关系图如下
@interface FYTimerTarget : NSObject
@property (nonatomic, weak) id target;
@property (nonatomic, assign) SEL selector;
@property (nonatomic, weak) NSTimer *timer; // weak
@end

@implementation FYTimerTarget
- (void)timerTargetAction:(NSTimer *)timer {
    
    if (self.target) {
        [self.target performSelectorOnMainThread:self.selector withObject:timer waitUntilDone:NO];
    } else {
        [self.timer invalidate];
        self.timer = nil;
    }
}
@end

@implementation NSTimer (NoCycleReference)
+ (instancetype)fy_timerWithTimeInterval:(NSTimeInterval)interval
                                  target:(id)target
                                selector:(SEL)selector
                                userInfo:(id)userInfo
                             runLoopMode:(NSRunLoopMode)mode
                                 repeats:(BOOL)yesOrNo
{
    if (!target || !selector) { return nil; }
    
    // FYTimerTarget作为替代target,避免循环引用
    FYTimerTarget *timerTarget = [[FYTimerTarget alloc] init];
    timerTarget.target = target;
    timerTarget.selector = selector;
    
    NSTimer *timer = [NSTimer timerWithTimeInterval:interval target:timerTarget selector:@selector(timerTargetAction:) userInfo:userInfo repeats:yesOrNo];
    [[NSRunLoop mainRunLoop] addTimer:timer forMode:mode];
    timerTarget.timer = timer;
    return timerTarget.timer;
}
@end
替换Target方案 对象引用关系.png

四、NSTimer使用建议

1.初始化分析

  • NSTimer一共有三种初始化方案:init开头的普通创建方法、timer开头的类工厂方法、scheduled开头的类工厂方法。前两者需要手动加入RunLoop中,后者会自动加入当前RunLoop的DefaultMode中。

2.延迟定时任务VS重复定时任务

  • 上文仅讨论的都是NSTimer重复执行定时任务的情况。当创建定时任务仅需执行一次(repeats=NO,也就是延迟定时任务),则执行完定时任务,会自动执行invalidate操作。也就是说,如果能保证延迟任务一定会执行,实际上无需理会上文那些破事。但需注意:Timer会强引用Target直到延迟任务执行完毕。如果使用场景要求:Target控制Timer的声明周期,Target对象析构时延迟任务无需执行,则还是必须如上处理。
  • 笔者建议这种场景使用CGD延迟任务dispatch_after即可,简单安全且高效。
  • 执行重复执行定时任务也可使用GCD定时器。GCD定时器无需考虑引用问题,且支持更精确的定时任务。不过GCD定时器是纯C形式,非面向对象形式,执行暂停、取消操作不是很方便。还是需要根据使用场景选择合适的方案。

References

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

推荐阅读更多精彩内容