需求背景
因为公司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
之后会将这个NSTimer
以NSDefaultRunLoopMode
模式放入当前线程的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
并不能马上使用,还需要我们自己调用RunLoop
的addTimer: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
有两种预设的模式,RunLoopDefaultMode
和TrackingRunLoopMode
。
当定时器被添加到主线程中且无指定模式时,会被默认添加到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定时器
RunLoop
是dispatch_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
方法也就可以正常调用NSTimer
的invalidate
方法
那具体该怎么做呢?
- 方案2.1
创建一个WeakProxy
代理对象,NSTimer
的target
设置为WeakProxy
代理对象,WeakProxy
是UIViewController
的代理对象,通过代理方法把所有发送到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
写一个NSTimer
的category
把NSTimer
的target
设置为NSTimer
自身, 把触发事件通过block
传入给NSTimer
,在NSTimer
的category
里面触发事件。其实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