相信做 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,这个模式其实就是NSDefaultRunLoopMode 与 NSEventTrackingRunLoopMode 的结合。
[[NSRunLoop currentRunLoop] addTimer:self.timer forMode:NSRunLoopCommonModes];