NSTimer与NSRunloop深入解析

前言

以前在项目里面或多或少会接触一些 NSTimer ,比较常见的功能实现,像是点击按钮发送手机的验证码,为了不让用户重复点击发送短信,一般点击之后会用 NSTimer 来实现一个60s的倒计时,这就是 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的简单使用

初始化方法

自动加入currentRunLoop
1
2
+ scheduledTimerWithTimeInterval: (NSTimeInterval) invocation: (nonnull NSInvocation *) repeats: (BOOL)
+ scheduledTimerWithTimeInterval:(NSTimeInterval) target:(nonnull id) selector:(nonnull SEL) userInfo:(nullable id) repeats:(BOOL)

这两个是创建一个定时器,并加入到当前运行循环中,即我们可以这样去理解,这样初始化一个定时器时,在(NSTimeInterval)seconds 时间之后,自动启动定时器。

手动加入currentRunLoop

而下面这两个方法则是需要自己手动加入循环:

1
2
+ timerWithTimeInterval:(NSTimeInterval) invocation:(nonnull NSInvocation *) repeats:(BOOL)
+ timerWithTimeInterval:(NSTimeInterval) target:(nonnull id) selector:(nonnull SEL) userInfo:(nullable id) repeats:(BOOL)

官方文档是这么说滴:

class method to create the timer object without scheduling it on a run loop.after creating it, you must add the timer to a run loop manually by calling the addTimer:forMode: method of the corresponding NSRunLoop object.

所以我们要执行下面的方法:

1
2
NSRunLoop *runloop=[NSRunLoop currentRunLoop];
[runloop addTimer: _timer forMode: NSDefaultRunLoopMode];

手动加入 currentRunLoop 与自动加入 currentRunLoop 的效果是一模一样的!

而关于什么是 NSRunLoop 什么是 NSDefaultRunLoopMode,下面我会详细写,这里先留一个坑。

其他方法

1
2
3
4
5
6
- invalidate
- fire
- isValid //是否在运行
- timeInterval //定时器延时时间
- userInfo //其他信息
- setFireDate: //重新设置定时器开始运行的时间

其中必须注意的两方法是 invalidatefire

我们的惯用代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@property (nonatomic,strong) NSTimer *timer;
//开始按钮,响应的action
- (IBAction)startTime:(id)sender;
//暂停按钮响应的action
- (IBAction)stopTime:(id)sender;

- (IBAction)startTime:(id)sender {
if(!_timer) {
_timer=[NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(timerAction:) userInfo:nil repeats:YES];
[_timer fire];
}
}

- (IBAction)stopTime:(id)sender {
if (_timer) {
if ([self.timer isValid]) {
[self.timer invalidate];
_timer=nil;
}
}

所以有人会以为 fireNSTimer 的启动方法,而 invalidateNSTimer 的暂停方法。

其实不然。

还是看看官方文档是怎么说的吧:

  • fire:You can use this method to fire a repeating timer without interrupting its regular firing schedule. If the timer is non-repeating, it is automatically invalidated after firing, even if its scheduled fire date has not arrived.
  • invalidate:This method is the only way to remove a timer from an NSRunLoop object. The NSRunLoop object removes and releases the timer, either just before the invalidate method returns or at some later point.

所以。

fire 不是启动一个 NSTimer,它只是提前触发定时器,而不影响之前的那个定时器设置的时间。如果我们设置的 NSTimerrepeats: YES,则时间到时,还是会重复执行方法。当我们把它改为 NO 之后,fire 触发一次之后,该 NSTimer 就被自动销毁了,以后再 fire 也不会触发了.

invalidate 这是唯一一个把一个定时器从一个运行循环中移除的方法。NSRunLoop object 这个对象移除,并且release掉这个的定时器,或者是在这个invalidate方法返回的之前或是在之后的某个时间段,再进行移除并release操作。并不是说暂停 NSTimer

所以必须重视一点:

如果使用重复的 NSTimer 一定要有对应的 invalidate,否则Timer会一直存在,不会被销毁。

NSTimer 是怎么管理对象生命周期的

一开始使用 NSTimer 时,不知道大家是不是会像我一样纳闷,它是如如何保证在未来的某个时刻触发指定的对象事件时,该对象事件是有效的?而不被提前销毁。

自己写个例子其实就能知道,当一个对象被 NSTimer 使用时,只有等到 NSTimer 执行结束,才会调用对象的 dealloc 方法。也就是说,对象只有在在 NSTimer 被销毁是才释放。

所以,NSTimner 是对对象做了 retain 操作,这是运用了 Objective—C 的内存管理。不管是重复性的 timer 还是一次性的 time r都会对它的方法的接收者进行retain。

区别在于:一次性的timer在完成调用以后会自动将自己invalidate,而重复的timer则将永生,直到你显示的invalidate它为止

NSTime 的实时性

我们写一个小例子:

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
30
31
32
33
#import "ViewController.h"

@interface ViewController ()

@property (nonatomic, strong) NSTimer *timer;

@end

@implementation ViewController

- (void)viewDidLoad {
[super viewDidLoad];

self.timer = [NSTimer scheduledTimerWithTimeInterval:2.0 target:self selector:@selector(timerAction:) userInfo:nil repeats:YES];
//3秒后假装主线程很忙
[self performSelector:@selector(simulateBusy) withObject:nil afterDelay:3];
}

- (void)timerAction:(NSTimer*)timer {
NSLog(@"Hi, Timer Action for instance %@", self);
}

- (void)simulateBusy {
NSLog(@"start simulate busy!");
sleep(5);
NSLog(@"finish simulate busy!");
}

- (void)didReceiveMemoryWarning {
[super didReceiveMemoryWarning];
}

@end

看一下运行结果:

从结果来分析:

  我添加了一个timer指定2秒后触发某一个事件,但是正好那个时候当前线程在执行一个耗时处理,这个时候 timer 就会延迟到该连续运算执行完以后才会执行。重复性的 timer 遇到这种情况,如果延迟超过了一个周期,则会和后面的触发进行合并,即在一个周期内只会触发一次。但是不管该timer的触发时间延迟的有多离谱,他后面的timer的触发时间总是倍数于第一次添加timer的间隙。
  所以会我们这样的结果,在执行耗时操作的5秒中,本来每两秒执行一次的方法至少要执行两次,但是在耗时操作结束之后并没有立刻执行两次,而是继续按照原来的“节奏”,验证了我们上面说的。
    
So,

  NSTimer 不是一个实时系统,因此不管是一次性的还是周期性的 timer 的实际触发事件的时间可能都会跟我们预想的会有出入。

差距的大小跟当前我们程序的执行情况有关系,比如可能程序是多线程的,而你的 timer 只是添加在某一个线程的 runloop 的某一种指定的 runloopmode 中,由于多线程通常都是分时执行的,而且每次执行的mode也可能随着实际情况发生变化。

NSRunLoop

上面我们已经提起了 NSRunloop 了,它与 NSTimer 息息相关。

什么是NSRunLoop

runLoop是一个与线程相关的机制。

在应用程序层面,无论在哪个操作系统,所有线程的运行方式基本是一样的。在线程开始运行后,都在 running, ready, 或是 blocked状态中切换,直至终止。在创建一个新的线程的时候,我们必须指定入口函数(entry-point function)。当入口函数执行完毕或是我们主动终止线程,线程就会停止运行然后被系统回收。

如果任务执行完毕,线程就被回收,那么下一个新的任务来,我们还需要重新创建和配置一个线程。非常地消耗性能,这个时候就引出了我们的 RunLoop 机制。用 RunLoop 来实现线程的常驻。

Runloop可以简单理解为一个循环。

1
2
3
4
5
6
func loop() {
repeat {
var event = nextEvent();
process(event);
} while (event != quit);
}

在这个循环里面等待事件,然后处理事件。而这个循环是基于线程的。 通过 RunLoop 这样的机制,线程能够在没有事件需要处理的时候休息,有事情的时候运行。减轻CPU压力。

为什么说NSTimer与NSRunLoop息息相关

因为!NSTimer 必须添加到 NSRunLoop 才能生效

我们上面在使用 NSTimner 时的手动添加就是实现这么一个过程。

自动添加,它其实是做了两件事:首先创建一个 timer,然后将该 timer 添加到当前runloop 的 default mode 中。也就是这个便利方法给我们造成了只要创建了timer就可以生效的错觉。

  NSTimer 其实也是一种资源,如果看过多线程变成指引文档的话,我们会发现所有的 source 如果要起作用,就得加到 runloop 中去。同理 timer 这种资源要想起作用,那肯定也需要加到runloop 中才会生效。如果一个 runloop 里面不包含任何资源的话,运行该 runloop 时会立马退出。你可能会说那我们APP的主线程的 runloop 我们没有往其中添加任何资源,为什么它还好好的运行。我们不添加,不代表框架没有添加,如果有兴趣的话你可以打印一下 main threadrunloop,你会发现有很多资源。   

生活处处有“惊喜”

你有没有遇到:

你初始化一个 NSTimner 并添加到 runloop,但是呢,它很调皮,就是怎么样都不去执行?

反正我是遇到过。

以前的项目里就有一个类似前言我所说的需求:

我自己测试的时候一点问题,都没有,但是由于它的本质是一个 UIScrollView,当它滑动时,计时器会停止。当看到计时器骤停,我TM的心脏也快要骤停了好伐?

究其原因:

能让 NSTimer 停下的原因无非两个:

runloop 没有运行

每一个线程都有它自己的 runloop,程序的主线程会自动的使runloop 生效,这也是我们前面那么多的例子都没有运行 runloop 的原因,因为我们默认当前线程是主线程;但对于我们自己新建的线程,它的 runloop 是不会自己运行起来,当我们需要使用它的 runloop 时,就得自己启动。

参考以下代码:

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
30
31
32
33
34
35
36
37
 #import "ViewController.h"

@interface ViewController ()

@end


@implementation ViewController

- (void)viewDidLoad {
[super viewDidLoad];

[NSThread detachNewThreadSelector:@selector(testTimerSheduleToRunloop1) toTarget:self withObject:nil];
}

- (void)timerAction:(NSTimer*)timer {
NSLog(@"Hi, Timer Action for instance %@", self);
}

- (void)testTimerSheduleToRunloop1
{
NSLog(@"Test timer shedult to a non-running runloop");
NSTimer *timer = [[NSTimer alloc] initWithFireDate:[NSDate dateWithTimeIntervalSinceNow:1] interval:1 target:self selector:@selector(timerAction:) userInfo:nil repeats:NO];
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];

NSLog(@"the thread's runloop: %@", [NSRunLoop currentRunLoop]);

// 打开下面一行, 该线程的runloop就会运行起来,timer才会起作用
//[[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:3]];
}

- (void)didReceiveMemoryWarning {
[super didReceiveMemoryWarning];

}

@end

运行后我们可以从控制台看出,timer 已经添加进去了:

但是由于没有运行这一句代码:

1
[[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:3]]

而导致 NSTimer 没有生效。

mode 的问题

mode 的问题也正是我滑动 scrollerViewtimer 停止的原因。

提到mode,就需要谈谈 RunLoop Modes

简单的说,runLoop 有多个 Mode,RunLoop 只能运行一个 Mode,RunLoop 只会处理它当前 Mode 的事件。

所以就会导致一些地方我们需要去注意。

一般 timer 是运行在 RunLoopdefault mode 上,而ScrollView 在用户滑动时(主线程需要去刷新UI),主线程 RunLoop 会转到 UITrackingRunLoopMode。而这个时候,Timer 就不会运行,方法得不到 fire

主要有两个方法解决:

方法一:我们在另外一个线程执行和处理 Timer 事件,然后在主线程更新UI。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
- (void)viewDidLoad
{
[super viewDidLoad];
//创建并执行新的线程
NSThread *thread = [[NSThread alloc] initWithTarget:self selector:@selector(newThread) object:nil];
[thread start];
}

- (void)newThread
{
@autoreleasepool
{
//在当前Run Loop中添加timer,模式是默认的NSDefaultRunLoopMode
[NSTimer scheduledTimerWithTimeInterval:2.0 target:self selector:@selector(timer_callback) userInfo:nil repeats:YES];
//开始执行新线程的Run Loop
[[NSRunLoop currentRunLoop] run];
}
}

//timer的回调方法
- (void)timer_callback
{
NSLog(@"Timer %@", [NSThread currentThread]);
}

我个人还是更为推荐方法二,不需要再去新建线程,给CPU无谓的压力。

方法二:

设置 RunLoop Mode,例如 NSTimer,我们指定它运行于NSRunLoopCommonModes,这是一个 Mode 的集合。注册到这个 Mode 下后,无论当前 RunLoop 运行哪个 mode,事件都能得到执行。

1
2
3
NSTimer *timer = [NSTimer timerWithTimeInterval:2.0 target:self selector:@selector(timerAction:) userInfo:nil repeats:YES];
//使用NSRunLoopCommonModes模式,把timer加入到当前Run Loop中。
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];

最后的话

写这篇博客的时候,还是有一点点感悟的,去年我在写登录页面的时候,不知道为什么会计时骤停,直接把滑动给禁止了。虽然也解决了这个BUG,但是现在想想还是觉得当时自己的这种学习态度不是特别好啊。怎么样也要做一个比较有追求的coder嘛!

祭上今天看到的一张图:

希望大家膝盖没有很疼!