问题的起因
前段时间在维护项目时,遇到了一个内存泄露,后面查出是使用了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的引用计数。
额,讲的烦躁,直接上图吧
这样,我只需要在 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,那么关系就会变成这样
那么如果我们在 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 对象被释放的话,那么定时器也不会执行了。