NSTimer 使用注意事项

NSTimer 是系统提供的定时器,系统提供的api也比较简单,使用很方便,项目开发中会经常用到。然而,在使用NSTimer时,如果不注意,非常容易引起内存泄露的问题。本文总结了下NSTimer 引起内存泄露问题的原因,以及解决方案。

NSTimer的使用

通常情况下,NSTimer 是作为controller或者view的一个属性来使用:

/**
 gif播放的定时器
 */
@property (nonatomic, strong) NSTimer *gifPlayTimer;

timer初始化:

NSTimer * timer = [NSTimer scheduledTimerWithTimeInterval:0.01 target:self selector:@selector(refreshPlayTime) userInfo:nil repeats:YES];
    self.gifPlayTimer = timer;
    [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];

该timer的作用是每隔0.01秒会执行一次self 的 refreshPlayTime 方法。

这样使用是没有问题的,每0.01秒确实会执行一次 refreshPlayTime方法。

然而当该控制器退出之后,会发现timer仍旧在执行,每隔0.01秒还是会调用 refreshPlayTime方法,而且,控制器退出了,但是该控制器的 dealloc 方法并没有被调用,也就是该控制器没有被释放,有内存泄露的问题。

既然timer没有停止,那么手动调用timer的 invalidate方法试一下。

通常情况下,我们希望在控制器释放的时候结束timer,也就是在 dealloc 方法中将timer给停掉。代码如下:

- (void)dealloc
{
    [self.gifPlayTimer invalidate];
}

然而,并没有什么用,在退出控制器之后,timer仍旧生效。原因上面其实也说了,因为控制器的 dealloc方法根本没有被调用。为什么控制器不会被释放?以及如何解决?

NSTimer对target的强引用

首先看一下NSTimer初始化方法的官方文档介绍:

+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo;

注意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.

注意:文档中写的很清楚,timer对target会有一个强引用,直到timer is invalidated。也就是说,在timer调用 invalidate方法之前,timer对target一直都有一个强引用。这也是为什么控制器的dealloc 方法不会被调用的原因。

由于timer对target强引用的特性,如果要避免控制器不释放的问题,需要在特定的时机调用timer 的 invalidate方法,也就是提前结束timer。在通常情况下,这种方式是可以解决问题的,虽然需要警惕页面退出之前有没有结束timer,但毕竟解决了问题不是。但是,日常项目中通常是多人协作,如果该timer是一个view的属性,而这个view又需要让别人使用,那timer什么时候结束呢?让调用者来管理timer的结束显然是不合理的。更好的方式还是应该在dealloc 方法中结束timer,这样调用者根本无须关注timer。

那么如何解决呢?

timer修饰符改为weak

上述代码中,self强引用了timer,timer又强引用了self,导致timer不能释放,self也一直不能释放,那么如果timer的修饰符是weak,能解决这个问题嘛?

/**
 gif播放的定时器
 */
@property (nonatomic, weak) NSTimer *gifPlayTimer;

经过验证,使用weak修饰timer并不能解决问题。Why?

看一下

- (void)addTimer:(NSTimer *)timer forMode:(NSRunLoopMode)mode;

方法的文档介绍:

The receiver retains aTimer. To remove a timer from all run loop modes on which it is installed, send an invalidate message to the timer.

也就是说,runLoop会对timer有强引用,因此,timer修饰符是weak,timer还是不能释放,timer的target也就不能释放。

target用weak来修饰

既然timer强引用了target,导致target一直不能释放,如果target用weak来修饰,能解决这个问题嘛?

__weak typeof(self) weakSelf = self;
NSTimer * timer = [NSTimer scheduledTimerWithTimeInterval:0.01 target:weakSelf selector:@selector(refreshPlayTime) userInfo:nil repeats:YES];
self.gifPlayTimer = timer;
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];

经过验证,并没有解决问题。Why?

实际上,上面的写法和直接使用self的并没有太大的区别,唯一的区别是这种写法timer的target有可能是nil,不过这种可能性太低了。

使用中间target的方式

这种方法的思路是:新建一个中间对象Object,该中间对象对timer真正的target有一个弱引用,写代码时,timer的target 是Object。timer触发的方法仍旧是真正target中的方法。

部分代码如下:

@interface _YYImageWeakProxy : NSProxy
// 对target有一个弱引用
@property (nonatomic, weak, readonly) id target;
- (instancetype)initWithTarget:(id)target;
+ (instancetype)proxyWithTarget:(id)target;
@end

timer的初始化方法:

NSTimer * timer = [NSTimer scheduledTimerWithTimeInterval:0.01 target:[_YYImageWeakProxy proxyWithTarget:self] selector:@selector(refreshPlayTime) userInfo:nil repeats:YES];
self.gifPlayTimer = timer;
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];

使用这种方式验证一下,是可以解决问题的,target的dealloc方法会被调用

上述的引用关系如下:
引用关系
这种方式避免了timer直接引用target,因此self的dealloc方法会调用,在dealloc方法中移除timer即可。

使用这种方式timer的方法如何执行呢?其实通过上面的代码也可以看出,借助了NSProxy类。NSProxy类是和NSObject平级的类,该类的作用可以简单的理解为一个代理,将消息转发给另一个对象。
看一下_YYImageWeakProxy中的代码:

@implementation _YYImageWeakProxy

- (id)forwardingTargetForSelector:(SEL)selector {
    return _target;
}
- (void)forwardInvocation:(NSInvocation *)invocation {
    void *null = NULL;
    [invocation setReturnValue:&null];
}
- (NSMethodSignature *)methodSignatureForSelector:(SEL)selector {
    return [NSObject instanceMethodSignatureForSelector:@selector(init)];
}
- (BOOL)respondsToSelector:(SEL)aSelector {
    return [_target respondsToSelector:aSelector];
}

- (Class)class {
    return [_target class];
}
- (BOOL)isKindOfClass:(Class)aClass {
    return [_target isKindOfClass:aClass];
}
- (BOOL)isMemberOfClass:(Class)aClass {
    return [_target isMemberOfClass:aClass];
}
- (BOOL)conformsToProtocol:(Protocol *)aProtocol {
    return [_target conformsToProtocol:aProtocol];
}
- (BOOL)isProxy {
    return YES;
}

@end

主要完成了消息转发的功能,将其接收到的消息,转发给target,这样timer就能正确触发对应的方法。

NSTimer+YYAdd

YYKit框架中提供了NSTimer的一个分类,NSTimer+YYAdd,使用该分类中的方法,能够解决问题,target的dealloc方法会被调用。看一下使用方法:

NSTimer * timer = [NSTimer timerWithTimeInterval:3.0f block:^(NSTimer * _Nonnull timer) {
            [weakSelf hideControlViewWithAnimation];
        } repeats:YES];
_hiddenTimer = timer;
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)seconds block:(void (^)(NSTimer *timer))block repeats:(BOOL)repeats;

该方法是NSTimer+YYAdd 提供的。那么该Category是如何解决timer强引用target的问题呢?看一下其内部实现:

+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)seconds block:(void (^)(NSTimer *timer))block repeats:(BOOL)repeats {
    return [NSTimer timerWithTimeInterval:seconds target:self selector:@selector(_yy_ExecBlock:) userInfo:[block copy] repeats:repeats];
}

+ (void)_yy_ExecBlock:(NSTimer *)timer {
    if ([timer userInfo]) {
        void (^block)(NSTimer *timer) = (void (^)(NSTimer *timer))[timer userInfo];
        block(timer);
    }
}

注意:timer的target变成了self,也就是timer,而_yy_ExecBlock方法实际上就是执行timer的回调。

也就是说,NSTimer+YYAdd中的解决方式也是使用中间类的方式,只不过这里的中间类正好是NSTimer对象,写起来更简单一些。具体引用关系如下:
引用关系

invalidate方法注意事项

看一下invalidate方法的介绍:

This method is the only way to remove a timer from an NSRunLoop object. The NSRunLoop object removes its strong reference to the timer, either just before the invalidate method returns or at some later point.

You must send this message from the thread on which the timer was installed. If you send this message from another thread, the input source associated with the timer may not be removed from its run loop, which could prevent the thread from exiting properly.

两点:

(1)invalidate方法是唯一能从runloop中移除timer的方式,调用invalidate方法后,runloop会移除对timer的强引用。

(2)timer的添加和timer的移除(invalidate)需要在同一个线程中,否则timer可能不能正确的移除,线程不能正确退出。

完。

猜你喜欢

转载自blog.csdn.net/tugele/article/details/80209702