iOS 如何正确使用 NSTimer

106 阅读4分钟

相信做 iOS 开发的童靴对 NSTimer 应该不会陌生,但要想正确使用它,可能会遇到不少的坑。下面我就结合自己项目中遇到的问题,讨论一下 NSTimer 在使用的中我们要避开的那些坑:

坑1:创建方式

Apple API 为我们提供了以下几种创建 NSTimer 的方式:

+ (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;

- (instancetype)initWithFireDate:(NSDate *)date interval:(NSTimeInterval)ti target:(id)t selector:(SEL)s userInfo:(nullable id)ui repeats:(BOOL)rep;
  • 以 timerWithTimeInterval 开头的构造方法,我们可以创建一个定时器,但是默认没有添加到 runloop 中,因此我们在创建定时器后,需要手动将其添加到 NSRunLoop 中,否则将不会循环执行。
  • 以 scheduledTimerWithTimeInterval 开头的构造方法,从此构造方法创建的定时器,它会默认将其指定到一个默认的 runloop 中,并且 timerInterval 时候后,定时器会自启动。
  • init 是默认的初始化方法,需要我们手动添加到 runloop 中,并且还需要手动触发 fire,才能启动定时器。

NSTimer 的创建和释放必须放在同一个线程中,所以我们的创建实例的时候,一定要特别留意这几个创建方式的区别,我更喜欢使用第 4 个创建方法。

坑2:循环引用

提出问题:我们使用 scheduledTimerWithTimeInterval 创建一个 NSTimer 实例后,timer 会自动添加到runloop 中,此时会被 runloop 强引用,而 timer 又会对 target 强引用,这样就形成强引用循环了。如果不手动停止 timer,那么 self 这个 VC 将不能被释放,尤其是当我们这个 VC 是 push 进来的,pop 将不会被释放。

解决办法:问题的关键在于 self 被 timer 强引用了,如果我们能打破这个强引用,那问题就解决了。

方案1:在 VC 的 dealloc 中释放 timer?

在提出问题中,我们已经知道形成了循环引用了,那 VC 就不能得到释放,dealloc 方法也不会执行,那在 dealloc 中释放 timer 是解决不了问题的。

方案2:在 VC 的 viewWillDisappear 中释放timer?

这样的确能在一定程度上解决问题,如果当我们 VC 再push 一个新的界面时,VC 没有释放,那么 timer 也就不能释放。所以这种方案不是最理想的。

方案3:直接弱引用 self(VC)

__weak typeof(self) weakSelf = self;
self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:weakSelf selector:@selector(countDownHandler) userInfo:nil repeats:YES];

然并卵,在 block 中,block 是对变量进行拷贝,注意拷贝的是变量本身而不是对象。以上面的代码为例,block 只是对变量 weakSelf 拷贝了一份,相当于在 block 的内存中,定义了一个 __weak blockWeak 对象,然后执行了blockWeak = weakSelf,并没有引起对象持有权的变化。回过头来看看 timer,虽然我们将 weakSelf 传入 timer 构造方法中,虽然我们看似弱引用的 self 对象,但 target 的说明中明确提到是强引用了这个 target,也就是说timer 强引用了一个弱引用的变量,结果还是强引用,这和你直接传 self 进来效果是一样的,并不能解除强引用循环。这样的做唯一作用是如果在 timer 运行期间 self 被释放了,timer 的 target 也就置为 nil,仅此而已。

方案4:我们可以创建一个临时的 target,让 timer 强引用这个临时变量对象,在这个临时对象中弱引用 self。这个 target 类似于一个代理,它的工作就是背锅,接下 timer 的强引用工作。

直接上代码:

#import <Foundation/Foundation.h>

typedef void(^SFWeakTimerBlock)(id userInfo);

@interface SFWeakTimer : NSObject

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

+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)interval
                                      block:(SFWeakTimerBlock)block
                                   userInfo:(id)userInfo
                                    repeats:(BOOL)repeats;
@end
#import "SFWeakTimer.h"

@interface SFWeakTimerTarget : NSObject

@property (weak, nonatomic) id target;
@property (assign, nonatomic) SEL selector;
@property (weak, nonatomic) NSTimer *timer;

- (void)fire:(NSTimer *)timer;
@end

@implementation SFWeakTimerTarget

- (void)fire:(NSTimer *)timer {
    
    if (self.target && [self.target respondsToSelector:self.selector]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
        [self.target performSelector:self.selector withObject:timer.userInfo afterDelay:0.0f];
#pragma clang diagnostic pop
    } else {
        [self.timer invalidate];
    }
}
@end

@implementation SFWeakTimer

+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)interval
                                     target:(id)aTarget
                                   selector:(SEL)aSelector
                                   userInfo:(id)userInfo
                                    repeats:(BOOL)repeats {
    
    SFWeakTimerTarget *timerTarget = [[SFWeakTimerTarget alloc] init];
    timerTarget.target = aTarget;
    timerTarget.selector = aSelector;
    timerTarget.timer = [NSTimer scheduledTimerWithTimeInterval:interval target:timerTarget selector:@selector(fire:) userInfo:userInfo repeats:repeats];
    
    return timerTarget.timer;
}

+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)interval
                                      block:(SFWeakTimerBlock)block
                                   userInfo:(id)userInfo
                                    repeats:(BOOL)repeats {
    return [self scheduledTimerWithTimeInterval:interval target:self selector:@selector(sf_timerUsingBlockWithObjects:) userInfo:@[[block copy], userInfo] repeats:repeats];
}

+ (void)sf_timerUsingBlockWithObjects:(NSArray *)objects {
    SFWeakTimerBlock block = [objects firstObject];
    id userInfo = [objects lastObject];
    
    if (block) {
        block(userInfo);
    }
}
@end

当前也可以参考 YYKit/YYWeakProxy 中的例子,github 中有 YYKit 的使用教程。

问题解决,破费!

坑3:NSDefaultRunLoopMode 搞怪

提出问题:当使用 NSTimer 的 scheduledTimerWithTimeInterval 的方法时,事实上此时的 timer 会被加入到当前线程的 runloop 中,默认为 NSDefaultRunLoopMode。如果当前线程是主线程,某些事件如 UIScrollView 的拖动时,会将 runloop 切换到 NSEventTrackingRunLoopMode 模式,在拖动的过程中,默认的 NSDefaultRunLoopMode 模式中注册的事件是不会被执行的。从而此时的 timer 也就不会触发。

解决办法:把创建好的 timer 手动添加到指定模式中,此处为 NSRunLoopCommonModes,这个模式其实就是NSDefaultRunLoopModeNSEventTrackingRunLoopMode 的结合。

[[NSRunLoop currentRunLoop] addTimer:self.timer forMode:NSRunLoopCommonModes];