iOS-NSTimer真的没有想象中的简单:与其相关的NSInvocation,NSProxy,NSRunloop都会用吗?

在iOS开发当中,无可避免的会涉及到定时任务,比如在发送验证码时的倒计时:

验证码倒计时demo
验证码倒计时demo

小编相信每个人都遇到过这样的需求,都很熟练的写出代码来了,如下:

NSTimer *timer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(timerFire:) userInfo:nil repeats:YES];

简单是简单,但是,这个样子写出来的代码却有两个很大的缺陷:

1.会导致内存泄漏(在@selector(timerFire:)这个方法里打印一个log,会发现这个log在pop之后还会打印)

2.如果滑动ScrollView的时候,定时器却不会走,只有松开ScrollView之后,定时器才重新走,如此会导致体验不佳

内存泄漏不是个小事,这个样子会导致很多程序上的bug。而至于滑动ScrollView时定时器不走的缺陷可以暂时稍后。

为了解决这个bug,我们先来分析NSTimer内存泄漏的原因:首先在Demo中,NSTimer在初始化的时候是放在对象(其实是一个ViewController的对象)方法中的,而当前对象self又是作为NSTimer对象的一个参数存在的,为此就导致了一个死循环,即:

NSTimer闭环

解决这个bug,必须打破self->timer->self(其中->代表强引用)这种循环引用,其中self->timer这一步没法避免,只能从timer->self这里着手,让其变成timer -- self(其中--代表弱引用)。

为此,我们需要查看NSTimer的官方文档,同时也发现了如下两个方法:

+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti invocation:(NSInvocation *)invocation repeats:(BOOL)yesOrNo;
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti invocation:(NSInvocation *)invocation repeats:(BOOL)yesOrNo;

NSInvocation是什么?用过Target-Action的人一定不陌生。现在我们去翻看官方文档,第一句就已经解释清楚了:

`NSInvocation` objects are used to store and forward messages between objects and between applications, primarily by [`NSTimer`](dash-apple-api://load?topic_id=1412393&language=occ) objects and the distributed objects system

简而言之就是:NSInvocation对象会保存并转发一些信息,而且完全可以适用于NSTimer对象。而从其暴露的方法来看,只有"invocationWithMethodSignature:"这一个方法,不解释了,想了解的去看官方文档,这里直接上代码:

    NSMethodSignature *methodSignature = [[self class] instanceMethodSignatureForSelector:@selector(timerFire:)];
    NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:methodSignature];
    invocation.target = self;
    invocation.selector = @selector(timerFire:);
    NSTimer *timer = [NSTimer scheduledTimerWithTimeInterval:1.0 invocation:invocation repeats:YES];

然而经过测试,内存泄漏的bug依旧没有解决,@selector(timerFire:)这里面的log在pop的时候依旧在打印。仔细分析一下会发现NSTimer导致的闭环依旧没有解决,只不过是从self->timer->self演变成了self->timer->invocation->self罢了。

虽然NSInvocation并未解决,但是却提供了一个思路:假设有一个对象objectA,其对self进行一个弱引用,那么就会变成self->timer->objectA--self(其中->代表强引用,而--代表弱引用)就可以了。

在翻看大量资料之后,小编得知iOS提供了这样一个类:NSProxy。对于NSProxy的解释,官方文档是这样解释的:

An abstract superclass defining an API for objects that act as stand-ins for other objects or for objects that don’t exist yet.

(自己翻译吧!小编倒是认为"stand-in"是关键词。)

那么,我们根据NSInvocation的思想(主要有两点:1.store messages 2.forward messages)去查看NSProxy的官方文档,发现有两个方法十分类似:

- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel;      // 类似store messages
- (void)forwardInvocation:(NSInvocation *)invocation;           // 类似forward messages,而且这里也涉及到了NSInvocation

除此之外,还要解决一个对self弱引用的问题,为此只需要给NSProxy进行一个拓展,增加一个对对象的弱引用,继承是最好的办法。

继承自NSProxy声明一个叫NSProxyInprovement的类,并在.h当中声明一个weak修饰的属性,如下面代码:

@interface NSProxyInprovement : NSProxy

@property (nonatomic, weak) id aTarget;      // 此对象要从外部传过来

@end

同时在NSProxyInprovement的.m中,实现类似NSInvocation中"store and forward messages"的两个方法,如下面代码:

@implementation NSProxyInprovement

- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel {
    return [self.aTarget methodSignatureForSelector:sel];
}

- (void)forwardInvocation:(NSInvocation *)invocation {
    [invocation invokeWithTarget:self.aTarget];
}

@end

使用起来很简单但是却比较繁琐,如下面代码:

self.proxy = [NSProxyInprovement alloc];
self.proxy.aTarget = self;
self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:self.proxy selector:@selector(timerFire:) userInfo:userInfo repeats:YES];

经过测试,当pop的时候,调用了dealloc的方法,为此内存泄漏的bug算是解决了。

接下来解决上面遗留的那个滑动ScrollView的时候,定时器不走的缺陷。为此,我们看看官方文档对于@selector(scheduledTimerWithTimeInterval:target:userInfo:repeats:)的解释

Creates a timer and schedules it on the current run loop in the default mode.

从上面的解释当中可以看到NSTimer还结合了NSRunloop的知识,并且mode类型是NSDefaultRunLoopMode,这就是问题所在:当滑动ScrollView的时候,NSRunloop的mode并不是NSDefaultRunLoopMode,而是UITrackingRunLoopMode,为此,我们需要设置一个包含既包含NSDefaultRunLoopMode又包含UITrackingRunLoopMode的mode,那就是NSRunLoopCommonModes。

完整代码如下:

self.proxy = [NSProxyInprovement alloc];
self.proxy.aTarget = self;
self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:self.proxy selector:@selector(timerFire:) userInfo:userInfo repeats:YES];
[[NSRunLoop currentRunLoop] addTimer:self.timer forMode:NSRunLoopCommonModes];

但是这个样子的话还有一个缺陷,那就是NSRunloop使用了两次,为了改善这个,我们使用NSTimer的另一个方法,完整代码如下:

self.proxy = [NSProxyInprovement alloc];
self.proxy.aTarget = self;
self.timer = [NSTimer timerWithTimeInterval:1.0 target:self.proxy selector:@selector(timerFire:) userInfo:userInfo repeats:YES];
[[NSRunLoop currentRunLoop] addTimer:self.timer forMode:NSRunLoopCommonModes];

详情只需要查看NSTimer的官方文档就可以了,官方文档写的很清楚。

经过测试,当滑动ScrollView的时候,定时器不走的那个缺陷也修复了,完美。

不过,在小编看来,bug与缺陷虽然都修复了,但是代码写起来十分的繁琐,毕竟还要引入NSProxyInprovement这个类,还要创建,传值,十分的繁琐,一点都不符合组件化开发的需求。

为此小编写了一个十分简单的组件放到了Github上,并且可支持Cocoapods(不要吝啬你手里的Star)。

猜你喜欢

转载自blog.csdn.net/LiuChongFaye/article/details/81509985