category + 替换target + 模仿新api = 循环引用远离NSTimer
前言
这篇文章的由来是当初去滴滴面试的时候,面试官小哥问的一个问题:如何避免NSTimer循环引用呢?第一反应就是在viewDidDisappear中invalidate掉timer;又追问:如果业务很复杂到你不能在viewDidDisappear中invalidate呢?当时心里还在暗暗疑问:什么业务能在VC都消失了还需要timer,但临时想了一下,还是大致说了一下利用中间对象解耦的方式。面试小哥表示赞同,并点了我一句:代理。
离开的路上稍微想了一下,发现确实不能完全依赖于控制器啊的viewDidDisappear之类的只有部分类才有的方法来执行timer的释放,因为timer不一定就放在VC中啊!所以需要创造一种能够通用的NSTimer的解耦方式,所以就有了这篇文章。
(PS:滴滴的面试小哥真的好腼腆好害羞啊~ 但态度什么的都超好~ 哈哈哈)
基于代理的实现方式
- NSTimer的需求对象创建协议,使用中间对象服从该协议;
- NSTimer的target为中间对象,selector为代理方法;
- 中间对象实现的代理方法中,需求对象执行NSTimer实际需要调用的方法。
详细来说下
NSTimer的需求对象创建协议:
@protocol PresentVCWeakTimerDelegate<NSObject>
- (void)useTimer:(NSTimer *)timer;
@end
需求对象生成timer,这里我们利用timer的userInfo来进行方法和参数的传递:
//此处可以将TimerManager生成一个单例模式,全局所有的timer的处理都可以由他来进行,但此处为了演示manager的释放,故如此实现。
//传入需求对象(一般就是当前self),注意此处被manager全局持有时,要使用weak修饰,不然manager和self相互持有,仍然无法释放。
TimerManager *manager = [[TimerManager alloc] initWithObj:self];
//千万不要讲self封入userInfo之中,因为timer和userInfo是强引用的关系,会破坏解耦的目的。
NSDictionary *userInfo = @{
@"SEL":NSStringFromSelector(@selector(dosth:)),
@"para":@{
@"name":@"XTShow",
@"age":@"18"
}
};
//此处的target是代理对象,selector是代理协议中的方法
self.timer = [NSTimer scheduledTimerWithTimeInterval:1 target:self.delegate selector:@selector(useTimer:) userInfo:userInfo repeats:YES];
中间对象要将自身设置为需求对象的代理对象,并实现代理方法:
- (instancetype)initWithObj:(NSObject *)obj
{
self = [super init];
if (self) {
self.delegateObj = obj;
if ([obj isKindOfClass:[PresentVC class]]) {
PresentVC *realobj = (PresentVC *)obj;
realobj.delegate = self;
}
}
return self;
}
代理方法中实际上是在让需求对象执行真正需要用timer里调用的方法:
-(void)useTimer:(NSTimer *)timer{
NSString *selStr = timer.userInfo[@"SEL"];
NSDictionary *para = timer.userInfo[@"para"];
SEL selector = NSSelectorFromString(selStr);
[self.delegateObj performSelector:selector withObject:para];
//performSelector会报黄色警告,如有介意,替代方法如下
//IMP imp = [self.delegateObj methodForSelector:selector];
//void (*func)(id,SEL,NSDictionary *) = (void *)imp;
//func(self.delegateObj,selector,para);
}
缺点:
需要使用NSTimer的类都要专门实现一个协议,稍微有点麻烦。
基于category的实现方式
期间我又拜读了學徒杨小胖的文章,发现了一种更简便易用的方式。但在一些小点上,还是有一些个人的看法,稍作修改后,总结出如下方案。
使用NSTimer的category,将timer的target从NSTimer需求对象替换成NSTimer类。
通过category新增的方法:
static NSString * const BlockKey = @"BlockKey";
typedef void(^SelectorBlock)(NSTimer *timer);
@interface NSTimer()
@property (nonatomic,copy) SelectorBlock block;
@end
@implementation NSTimer (CycleRetainGetOut)
+ (NSTimer *)XT_scheduledTimerWithTimeInterval:(NSTimeInterval)ti block:(void(^)(NSTimer *timer))block userInfo:(id)userInfo repeats:(BOOL)yesOrNo {
NSTimer *timer = [self scheduledTimerWithTimeInterval:ti target:self selector:@selector(performBlock:) userInfo:userInfo repeats:yesOrNo];
timer.block = block;
return timer;
}
@end
其中的block就是需求对象需要执行的方法,而且此处我没有使用userInfo来传递block,而是在category中新增了一个block属性:
#import <objc/runtime.h>
- (void)setBlock:(SelectorBlock)block {
objc_setAssociatedObject(self, &BlockKey, block, OBJC_ASSOCIATION_COPY_NONATOMIC);
}
- (SelectorBlock)block {
return objc_getAssociatedObject(self, &BlockKey);
}
用他来传递block,保证需求对象的方法的执行:
+ (void)performBlock:(NSTimer *)timer {
if (timer.block) {
timer.block(timer);
}
}
同时保证userInfo这个参数不会被废掉。
在iOS10中,新增了3个含block的NSTimer初始化方法,而且自带防循环引用的“特效”!通过观察这三个api发现,他们会将timer作为block的参数提供给需求对象,因此,我也进一步将当前的timer放入了block,传递给需求对象,毕竟timer中还是有一些属性可能会使用到的。
总结以上,实际使用时,已经与官方api很相似了:
#import "NSTimer+CycleRetainGetOut.h"
__weak __typeof__(self)weakSelf = self;
self.timer = [NSTimer XT_scheduledTimerWithTimeInterval:1 block:^(NSTimer *timer) {
[weakSelf dosthWithTimer:timer];
} userInfo:@"useTimerInWeak" repeats:YES];
还有一点,就是
-(void)dealloc{
[self.timer invalidate];
}
的问题。在上面的两种方式中,都已经可以保证需求对象的正常释放和timer停止调用对象。但是仍然建议在dealloc方法中对timer进行invalidate处理。
从invalidate的官方注释中就可以发现:
This method is the only way to remove a timer from an NSRunLoop object.
...
If it was configured with target and user info objects, the receiver removes its strong references to those objects as well.
这个方法是唯一的能够将timer从runloop中移除的方式,而且还能解决userInfo的强引用问题。
我们只是解决了循环引用问题,而并没有处理timer与runloop的关系,因此invalidate方法还是建议调用的。
还有就是我看到很多教程中在invalidate后还会将timer=nil,个人认为这步其实是不需要的,因为通过log可以发现,self.timer在invalidate之后,已经置为null了,再nil一下,效果重复,不会起到其他作用。
以上就是让NSTimer远离循环引用的解决方案,
但是!
在测试的过程中,我还是遇到了一些问题,想与大家分享一下,也希望能有大神不吝赐教,为我指点迷津。
在常见的NSTimer解耦的文章中,都是在强调需求对象的释放,但是timer的释放呢?只是需求对象被释放和停止方法调用就能说明timer被释放了吗?
判断一个对象是否被释放,最直观的就是观察其dealloc方法是否被调用。
我通过两种方式来尝试获取NSTimer的dealloc方法:
1.创建NSTimer的子类
实践中会发现NSTimer的子类只能通过new来初始化,常规的scheduledTimerWithTimeInterval之类的初始化方法,使用后会直接崩溃。通过查阅资料1、资料2发现,NSTimer是一个“class cluster”,类簇,并不能创建子类;苹果的官方文档更加直白
Subclassing Notes
Do not subclass NSTimer
直接就不允许创建子类。
2.在category中通过method_exchangeImplementations交换dealloc
在NSTimer的category中,重写load方法,在其中交换dealloc和自定义方法。然后发现还是不会调用,个人认为,在category中被替换的dealloc方法并不是NSTimer类真正的dealloc方法,而是我们新增的一个方法,所以即使timer真的调用了dealloc方法,也不会走此处我们新增的这个dealloc。(还是category用得少啊。。。)
既然如此,那么我就采用微小问题巨大化的方式来看一下吧:创建10000个timer!
- (void)checkTimerRelease {
self.timerArray = [NSMutableArray array];
__weak __typeof__(self)weakSelf = self;
for (int i = 0; i < 10000; i++) {
NSTimer *timer = [NSTimer XT_scheduledTimerWithTimeInterval:1 block:^(NSTimer *timer) {
[weakSelf dosth];
} userInfo:@"asd" repeats:YES];
[self.timerArray addObject:timer];
}
}
-(void)dealloc{
for (NSTimer *timer in self.timerArray) {
[timer invalidate];
}
}
果不其然!需求对象(此处为VC)能够正常释放,方法调用也停止了,但是!因为生产了10000个timer而增加的6MB左右的内存并没有释放掉。此处我又在猜想,是否timer = nil真的有释放内存的效果,还有承载timer的timerArray的大小在timer释放后是否还是那么大?所以又进行了修改:
-(void)dealloc{
for (NSTimer __strong *timer in self.timerArray) {
[timer invalidate];
timer = nil;
}
self.timerArray = nil;(或self.timerArray = [NSMutableArray array];)
}
事实证明,并木有用~
难道是基于category的方法有问题?用基于代理的方式来试验下。。。并不能将10000个都解耦,因为代理是一对一的。。。
那么就用最传统的公认的方式来尝试下:
- (void)checkTimerReleaseInTradition {
self.timerArray = [NSMutableArray array];
for (int i = 0; i < 10000; i++) {
NSTimer *timer = [NSTimer scheduledTimerWithTimeInterval:1 target:self selector:@selector(dosth) userInfo:nil repeats:YES];//即使此时repeats设置为NO,也不会释放内存
[self.timerArray addObject:timer];
}
}
-(void)viewDidDisappear:(BOOL)animated{
[super viewDidDisappear:animated];
for (NSTimer *timer in self.timerArray) {
[timer invalidate];
}
}
哎呦!内存还是没释放掉!
那么再用iOS10中的block系列api尝试下:
- (void)creatTimerInNewApi {
__weak __typeof__(self)weakSelf = self;
for (int i = 0; i < 10000; i++) {
if (@available(iOS 10.0, *)) {
[NSTimer scheduledTimerWithTimeInterval:1 repeats:YES block:^(NSTimer * _Nonnull timer) {
[weakSelf dosth];
}];
}
}
}
不行不行还是不行啊~内存仍没有释放掉。
难道这仍旧是引用的原因吗?不应该啊,需求对象已经释放掉了,谁还在引用着timer呢?难道是timer的特性?那么我们继续往表层走,不使用NSTimer了,直接使用最基本的NSObject。
- (void)newOBj {
self.timerArray = [NSMutableArray array];
for (int i = 0; i < 10000; i++) {
NSObject *obj = [NSObject new];
[self.timerArray addObject:obj];
}
}
内存还是没完全释放掉!但应该是因为NSObject实例对象所需的内存空间很小,所以未被释放掉的内存空间也很小。
最后我又尝试了用@autoreleasepool将创建的对象还有数组都暴露进去,还是没有用。
那么此时问题就不应该局限于NSTimer或者循环引用的问题了,而是有一些我并不了解的内存管理机制:因为这里的对象的retain计数已经是0了,但并没有像当初学习的时候所说的,立即被释放掉,而是仍占用着内存;亦或是因为,我们在每次创建对象的时候,会有类似于记录的附加信息写入内存?个人认为不太可能,如果真是那样的话,那么记录的大小几乎与创建的对象一样大了,太夸张了。
总结
那么现在来看,上面的两种针对NSTimer的解耦方式是没有问题的,各位可以放心使用。
问题在于,对于大量对象的内存占用,系统内部到底是如何处理的呢?希望有大神能够指点迷津啊!万分感谢!