美文网首页iOS专题@IT·互联网
iOS定时器NSTimer内存泄露原理分析+解决方案

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

作者: 浮游lb | 来源:发表于2018-08-13 21:50 被阅读471次

    一、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 scheduledTimerWithTimeInterval: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

    相关文章

      网友评论

        本文标题:iOS定时器NSTimer内存泄露原理分析+解决方案

        本文链接:https://www.haomeiwen.com/subject/fxjpbftx.html