NSTimer使用的注意事项

需求背景

因为公司App中的即时性要求比较高,像位置、状态等一些数据要时刻跟服务器进行同步,所以不可避免的项目中用到的NSTimer比较多,但由于对NSTimer的不熟悉而造成卡顿(计时不准)、内存泄漏(循环引用)。

预期目的

  • 了解NSTimer的基本使用
  • 导致NSTimer卡顿(计时不准)的原因
  • 避免NSTimer卡顿(计时不准)
  • 导致NSTimer内存泄漏(循环引用)的原因
  • 避免NSTimer内存泄漏(循环引用)

具体实现

  • 什么是NSTimer

A timer provides a way to perform a delayed action or a periodic action. The timer waits until a certain time interval has elapsed and then fires, sending a specified message to a specified object.

说的是timer就是一个能在从现在开始的,在后面的某一个时刻或者周期性的执行我们指定的方法的对象。

  • NSTimer 的创建

NSTimer的创建通常有两种方式,一种是以scheduledTimerWithTimeInterval为开头的类方法 。这些方法在创建了NSTimer之后会将这个NSTimerNSDefaultRunLoopMode模式放入当前线程的RunLoop

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

另一种是以timerWithTimeInterval为开头的类方法。这些方法创建的NSTimer并不能马上使用,还需要我们自己调用RunLoopaddTimer:forMode:方法将NSTimer放入RunLoop,这样NSTimer才能正常工作。

+ (NSTimer *)timerWithTimeInterval:(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的创建的两种方式,每种都有一个需要添加target的方法。

+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo;
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo;
  • 导致NSTimer卡顿(计时不准)的原因

NSTimer会是准时触发事件么?答案是否定的。
1. RunLoop模式的影响
在做界面滑动等操作时,计时器会不准。主线程的RunLoop有两种预设的模式,RunLoopDefaultModeTrackingRunLoopMode
当定时器被添加到主线程中且无指定模式时,会被默认添加到DefaultMode中,一般情况下定时器会正常触发定时任务。但是当用户进行UI交互操作时(比如滑动tableview),主线程会切换到TrackingRunLoopMode,在此模式下定时器并不会被触发。

2. RunLoop的影响
有时候你会发现实际的触发时间跟你想象的差距还比较大。NSTimer不是一个实时系统,因此不管是一次性的还是周期性的timer的实际触发事件的时间可能都会跟我们预想的会有出入。差距的大小跟当前我们程序的执行情况有关系,比如可能程序是多线程的,而你的timer只是添加在某一个线程的runloop的某一种指定的runloopmode中,由于多线程通常都是分时执行的,而且每次执行的mode也可能随着实际情况发生变化。

A repeating timer reschedules itself based on the scheduled firing time, not the actual firing time. For example, if a timer is scheduled to fire at a particular time and every 5 seconds after that, the scheduled firing time will always fall on the original 5 second time intervals, even if the actual firing time gets delayed. If the firing time is delayed so far that it passes one or more of the scheduled firing times, the timer is fired only once for that time period; the timer is then rescheduled, after firing, for the next scheduled firing time in the future

大概意思是假设你添加了一个timer指定5秒后触发某一个事件,但是恰好那个时候当前线程在执行一个连续复杂的运算,这个时候timer就会延迟到该连续复杂的运算执行完以后才会执行。重复性的timer遇到这种情况,如果延迟超过了一个周期,则会和后面的触发进行合并,即在一个周期内只会触发一次。但是不管该timer的触发时间延迟的有多离谱,他后面的timer的触发时间总是倍数于第一次添加timer的间隙。

  • 避免NSTimer造成的卡顿(计时不准)

1. RunLoop模式的影响解决

添加定时器到主线程的CommonMode或者在子线程中创建timer,在主线程进行定时任务的操作。

[[NSRunLoop mainRunLoop]addTimer:timer forMode:NSRunLoopCommonModes];

2. RunLoop的影响解决
使用mach_absolute_time()来实现更高精度的定时器。iPhone上有这么一个均匀变化的东西来提供给我们作为时间参考,就是CPU的时钟周期数ticks。 通过mach_absolute_time()获取CPU已运行的tick数量。将tick数经过转换变成秒或者纳秒,从而实现时间的计算。
不过这个tick数,在每次手机重启之后,会重新开始计数,而且iPhone锁屏进入休眠之后tick也会暂停计数。
mach_absolute_time()不会受系统时间影响,只受设备重启和休眠行为影响。

#include 
#include 
 
static const uint64_t NANOS_PER_USEC = 1000ULL;
static const uint64_t NANOS_PER_MILLISEC = 1000ULL * NANOS_PER_USEC;
static const uint64_t NANOS_PER_SEC = 1000ULL * NANOS_PER_MILLISEC;
 
static mach_timebase_info_data_t timebase_info;

static uint64_t nanos_to_abs(uint64_t nanos) {
    return nanos * timebase_info.denom / timebase_info.numer;
}

void waitSeconds(int seconds) {
    mach_timebase_info(&timebase_info);
    uint64_t time_to_wait = nanos_to_abs(seconds * NANOS_PER_SEC);
    uint64_t now = mach_absolute_time();
    mach_wait_until(now + time_to_wait);
}

理论上这是iPhone上最精准的定时器,可以达到纳秒级别的精度。

3. 其他定时器
CADisplayLink是一个频率能达到屏幕刷新率的定时器类。iPhone屏幕刷新频率为60帧/秒,也就是说最小间隔可以达到1/60s。

CADisplayLink * displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(clicked)];
[displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode: NSRunLoopCommonModes];

GCD定时器 RunLoopdispatch_source_t实现的timer,所以理论上来说,GCD定时器的精度比NSTimer只高不低。

NSTimeInterval interval = 1.0;
_timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0));
dispatch_source_set_timer(_timer, dispatch_walltime(NULL, 0), interval * NSEC_PER_SEC, 0);
dispatch_source_set_event_handler(_timer, ^{
    NSLog(@"GCD timer test");
});
dispatch_resume(_timer);

4. 小结
其实,NSTimer一般场景下足够准了,对于“不准”更多是集中在对其错误的使用方式上,只要我们足够深入了解,正确地使用,就能让它足够“准”。
实际上,苹果也不推荐使用太高精度的定时器,对于NSTimer,精度在50-100ms都是正常的,如果我们需要足够高精度地进行计时,比如统计APP启动时间、一段任务代码的运行时间等等,NSTimer或许稍显不足,mach_absolute_time()应该可以帮到你,苹果开发工具也带有更专业的API或者插件提供给开发者。

  • 导致NSTimer内存泄漏(循环引用)的原因

NSTimer造成内存泄漏的根本原因其实就是循环引用,要知道如何造成循环引用就需要知道NSTimer和它调用的函数对象间到底发生了什么?或者直接点说,它们是怎么相互持有?为什么要相互持有呢?
从前面官方给出的NSTimer定义解释可以看出timer会在未来的某个时刻执行一次或者多次我们指定的方法,这也就牵扯出一个问题,如何保证timer在未来的某个时刻触发指定事件的时候,我们指定的方法是有效的呢?

Timers work in conjunction with run loops. Run loops maintain strong references to their timers, so you don’t have to maintain your own strong reference to a timer after you have added it to a run loop.

其实很简单,只要将指定给timer的方法的接收者retain一份就搞定了,实际上系统也是这样做的。

不管是重复性的timer还是一次性的timer都会对它的方法的接收者进行retain,这两种timer的区别在于“一次性的timer在完成调用以后会自动将自己invalidate,而重复的timer则将一直存在,直到开发者自己调用的invalidate它为止”。

  • 避免NSTimer内存泄漏(循环引用)

1.在UIViewController的注销事件中手动调用invalidate方法。

2.NSTimer的角度来看解决方案,如果NSTimer不持有UIViewController,那么UIViewController就可以正常销毁,在dealloc方法也就可以正常调用NSTimerinvalidate方法

那具体该怎么做呢?

  • 方案2.1
    创建一个WeakProxy代理对象,NSTimertarget设置为WeakProxy代理对象,WeakProxyUIViewController的代理对象,通过代理方法把所有发送到WeakProxy的消息都会被转发到UIViewController对象。
#import "TestViewController.h"
#import "YYWeakProxy.h" // YYKit 的一个代理类的实现

@interface TestViewController ()
@property (nonatomic,weak) NSTimer *timer;
@end

@implementation TestViewController
- (void)viewDidLoad {
    [super viewDidLoad];
    self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:[YYWeakProxy proxyWithTarget:self] selector:@selector(outputLog:) userInfo:nil repeats:YES];
}

- (void)outputLog:(NSTimer *)timer {
    NSLog(@"计时方法");
}

- (void)dealloc {
    [self.timer invalidate];
    NSLog(@"TestViewController销毁");
}
@end
  • 方案2.2
    写一个NSTimercategoryNSTimertarget设置为NSTimer自身, 把触发事件通过block传入给NSTimer,在NSTimercategory里面触发事件。其实iOS10之后苹果已经提供了block的方法:
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0));
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0));

自己实现category:

// NSTimer+Blocks.h
#import 
@interface NSTimer (Blocks)
+ (NSTimer *)block_scheduledTimerWithTimeInterval:(NSTimeInterval)interval
                                       repeats:(BOOL)repeats
                                         block:(void(^)())block;
@end
// NSTimer+Blocks.m
#import "NSTimer+Blocks.h"

@implementation NSTimer (Blocks)
+ (NSTimer *) block_scheduledTimerWithTimeInterval:(NSTimeInterval)interval
                                       repeats:(BOOL)repeats
                                         block:(void(^)())block;
{
    return [self scheduledTimerWithTimeInterval:interval
                                         target:self
                                       selector:@selector(xx_timerSelector:)
                                       userInfo:[block copy]
                                        repeats:repeats];
}
+ (void) xx_timerSelector:(NSTimer *)timer {
    void (^block)() = timer.userInfo;
    if(block) {
        block();
    }
}
@end

调用:

// TestViewController.m
#import "TestViewController.h"
#import "NSTimer+Blocks.h"

@interface TestViewController ()
@property (nonatomic,weak) NSTimer *timer;
@end

@implementation TestViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    self.timer =[NSTimer block_scheduledTimerWithTimeInterval:1.0 repeats:YES block:^{
            NSLog(@"计时方法");
    }];
}

- (void)dealloc {
    [self.timer invalidate];
    NSLog(@"TestViewController销毁");
}
@end

你可能感兴趣的:(NSTimer使用的注意事项)