实现NSTimer解耦及衍生的思考

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的解耦方式是没有问题的,各位可以放心使用。
问题在于,对于大量对象的内存占用,系统内部到底是如何处理的呢?希望有大神能够指点迷津啊!万分感谢!


Demo

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

推荐阅读更多精彩内容

  • 1. 单例写法 单例模式确保某一个类只有一个实例,而且自行实例化并向整个系统提供这个实例。 一般情况下, 如果一个...
    sellse阅读 1,003评论 0 1
  • 之前要做一个发送短信验证码的倒计时功能,打算用NSTimer来实现,做的过程中发现坑还是有不少的。 基本使用 NS...
    WeiHing阅读 4,378评论 1 8
  • iOS中计时器常用的有两种方式 使用NSTimer类(Swift 中更名为 Timer) NSTimer 常用的初...
    superDg阅读 1,836评论 0 1
  • NSTimer 计时器是一种很方便的对象。Foundation 框架中有个类叫做NSTimer,开发者可以指定绝对...
    KKLinJJ阅读 657评论 0 1
  • 俗话说:自信的女人最美丽。 我一直都很羡慕那些自信满满的女人,在她们的眼中,世界上没有什么事是她们做不到的。反观自...
    潘秋宇之秋雨夜眠阅读 226评论 3 1