iOS定时器NSTimer内存泄露原理分析+解决方案
2018-08-13 本文已影响471人
浮游lb
一、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
- 此时的内存中对象的引用关系图如下
-
对象间引用关系分析
- 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。
- 1.创建定时器,调用
-
内存泄露分析
- 创建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
- 苹果官方文档 NSTimer
- 《Effective Objective-C 2.0 编写高质量iOS与OS X代码的52个有效方法》