iOS的几种定时器及区别

在开发中我们经常用到定时器,iOS为我们提供了多种定时器,包括NSTimer、CADisplayLink、GCD、NSThread(performSelector:afterDelay:),其本质都是通过RunLoop来实现,但GCD通过其调度机制大大提高了性能。定时器的使用中容易存在一些误区,故写本文总结。

本文将介绍iOS的几种定时器、定时器的立即执行方法、内存泄露问题、不准时问题

NSTimer

iOS中最基本的定时器,在Swift中称为Timer。其通过RunLoop来实现,一般情况下较为准确,但当当前循环耗时操作较多时,会出现延迟问题。同时,也受所加入的RunLoop的RunLoopMode影响,具体可以参考RunLoop的特性。

创建

构造方法主要分为自动启动和手动启动,手动启动的构造方法需要我们在创建NSTimer后手动启动它:

1
2
3
4
5
6
7
/// 构造并开启(启动NSTimer本质上是将其加入RunLoop中)
// "scheduledTimer"前缀的为自动启动NSTimer的,如:
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block

/// 构造但不开启
// "timer"前缀的为只构造不启用的,如:
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block

前面说到,定时器的本质是加入到了RunLoop的Timer列表中,从而随着运行循环来实现定时器的功能。所以NSTimer除了构造,还需要加入RunLoop。关于RunLoop简单实用可以见文末。

释放

定时器的释放一定要先将其终止,而后才能销毁对象。具体原因下文会说。

1
- (void)invalidate;

立即执行(fire)

我们对定时器设置了延时之后,有时需要让它立刻执行,可以使用fire方法:

1
- (void)fire;

但是该方法的使用需要注意: fire方法不会改变预定周期性调度。什么意思呢?就是说,如果我们把Timer设置为循环调用,那么我们任何时候调用fire方法,下一次调度的时间仍旧是按照预定时间,而非基于本次执行的时间计算而得。这里需要特别注意,我们可以参考下面的🌰:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
self.timer1 = [NSTimer timerWithTimeInterval:5.0 target:self selector:@selector(timerMethod1) userInfo:nil repeats:NO];
[[NSRunLoop currentRunLoop] addTimer:self.timer forMode:NSRunLoopCommonModes];

self.timer2 = [NSTimer timerWithTimeInterval:5.0 target:self selector:@selector(timerMethod2) userInfo:nil repeats:NO];
[[NSRunLoop currentRunLoop] addTimer:self.timer forMode:NSRunLoopCommonModes];

/* ......... */

- (void)timerMethod1 {
static int timerIdx1 = 0;
NSLog(@"Timer Method1: %d", timerIdx1++);
}
- (void)timerMethod2 {
static int timerIdx2 = 0;
NSLog(@"Timer Method2: %d", timerIdx2++);
}

我们定义了两个NSTimer并加入到RUnLoop中,其目标方法和其他属性均相同,唯一区别是前者只运行一次。

我们在第8秒时调用fire方法,结果如何呢? timer1立即执行,并且由于仅执行一次,其任务结束。而timer2在第8秒执行后,仍旧在第10秒执行,这样的结果说明了fire方法不会改变预定周期性调度

CADisplayLink是基于屏幕刷新的周期,所以其一般很准时,每秒刷新60次。其本质也是通过RunLoop,所以不难看出,当RunLoop选择其他模式或被耗时操作过多时,仍旧会造成延迟。

其使用步骤为 创建CADisplayLink->添加至RunLoop中->终止->销毁。代码如下:

1
2
3
4
5
6
7
8
// 创建CADisplayLink
CADisplayLink *disLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(linkMethod)];
// 添加至RunLoop中
[disLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes];
// 终止定时器
[disLink invalidate];
// 销毁对象
disLink = nil;

由于其并非NSTimer的子类,直接使用NSRunLoop的添加Timer方法无法加入,应使用CADisplayLink自己的addToRunLoop:forMode:方法。

同时,由于其是基于屏幕刷新的,所以也度量单位是每帧,其提供了根据屏幕刷新来设置间隔的frameInterval属性,其决定于屏幕刷新多少帧时调用一次该方法,默认为1,即1/60秒调用一次。

如果我们想要计算出每次调用的时间间隔,可以通过frameInterval * duration求出,后者为屏幕每帧间隔的只读属性。

在日常开发中,适当使用CADisplayLink甚至有优化作用。比如对于需要动态计算进度的进度条,由于起进度反馈主要是为了UI更新,那么当计算进度的频率超过帧数时,就造成了很多无谓的计算。如果将计算进度的方法绑定到CADisplayLink上来调用,则只在每次屏幕刷新时计算进度,优化了性能。MBProcessHUB则是利用了这一特性。

GCD

GCD定时器是dispatch_source_t类型的变量,其可以实现更加精准的定时效果。我们来看看如何使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/** 创建定时器对象
* para1: DISPATCH_SOURCE_TYPE_TIMER 为定时器类型
* para2-3: 中间两个参数对定时器无用
* para4: 最后为在什么调度队列中使用
*/
_gcdTimer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_global_queue(0, 0));
/** 设置定时器
* para2: 任务开始时间
* para3: 任务的间隔
* para4: 可接受的误差时间,设置0即不允许出现误差
* Tips: 单位均为纳秒
*/
dispatch_source_set_timer(_gcdTimer, DISPATCH_TIME_NOW, 2.0 * NSEC_PER_SEC, 0.0 * NSEC_PER_SEC);
/** 设置定时器任务
* 可以通过block方式
* 也可以通过C函数方式
*/
dispatch_source_set_event_handler(_gcdTimer, ^{
static int gcdIdx = 0;
NSLog(@"GCD Method: %d", gcdIdx++);
NSLog(@"%@", [NSThread currentThread]);

if(gcdIdx == 5) {
// 终止定时器
dispatch_suspend(_gcdTimer);
}
});
// 启动任务,GCD计时器创建后需要手动启动
dispatch_resume(_gcdTimer);

GCD更准时的原因

通过观察代码,我们可以发现GCD定时器实际上是使用了dispatch源(dispatch source),dispatch源监听系统内核对象并处理。dispatch类似生产者消费者模式,通过监听系统内核对象,在生产者生产数据后自动通知相应的dispatch队列执行,后者充当消费者。通过系统级调用,更加精准。

定时器不准时的问题及解决

通过上文的叙述,我们大致了解了定时器不准时的原因,总结一下主要是

  • 当前RunLoop过于繁忙
  • RunLoop模式与定时器所在模式不同

上面解释了GCD更加准时的原因,所以解决方案也不难得出:

  • 避免过多耗时操作并发
  • 采用GCD定时器
  • 创建新线程并开启RunLoop,将定时器加入其中(适度使用)
  • 将定时器添加到NSRunLoopCommonModes(使用不当会阻塞UI响应)

其中后两者在使用前应确保合理使用,否则会产生负面影响。

定时器的内存泄露问题

定时器在使用时应格外注意内存管理,常见情况时定时器对象无法释放造成内存泄露,而严重情况会造成控制器也无法释放,危害更大。其内存泄露有两部分问题,我们先来看第一部分:

问题1: NSTimer无法释放

我们知道,NSTimer实际上是加入到RunLoop中的,那么在其启动时其被RunLoop强引用,那么即使我们在后面将定时器设为nil,也只是引用计数减少了1,其仍因为被RunLoop引用而无法释放,造成内存泄露。

问题2: 控制器无法释放

这是NSTimer无法释放所造成的更严重问题,由于为定时器设置了target,控制器就会得到一个来自定时器的引用。我们来分析一下这个情况,首先定时器必须被强引用,否则将在autoreleasepool之后被释放掉造成野指针。而定时器的target又对控制器有一个强引用,这就是典型的强引用循环(循环引用)。

那么如何解决这两个问题呢?答案就是使用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.
If it was configured with target and user info objects, the receiver removes its strong references to those objects as well.

即,invalidate方法会将定时器从RunLoop中移除,同时解除对target等对象的强引用。

CADisplayLink同理,而GCD定时器则使用dispatch_suspend()

更多技术文章,欢迎访问
本人博客 - Minecode’s Blog
Github - Minecodecraft