重新认识了NSTimer以及他与RunLoop关系

已经将近两年没有写过文章了,之前记录的知识点都在有道笔记上,看到网上那么多人分享知识,突然也想重新写了,分享知识能够使自己学到更多.
最近在查阅iOS中RunLoop资料时无意间看到了NSTimer与RunLoop的关系,于是开始去了解NSTimer,发现之前对NSTimer的运用只是把代码写上了,并没有深入去了解他里面存在的问题.通过查看资料,以及自己写代码测试,现将学到的知识总结一下,里面有认识理解不正确的欢迎指正

一.NSTimer创建方法
我个人认为NSTimer的创建可以分为三种
1.scheduledTimer创建

1),scheduledTimerWithTimeInterval: invocation: repeats:
2),scheduledTimerWithTimeInterval: target: selector: userInfo: repeats:

2.timerWithTimeInterval类方法

1),timerWithTimeInterval: target: selector: userInfo: repeats:
2),timerWithTimeInterval: invocation: repeats:

3.init创建

initWithFireDate: interval: target: selector: userInfo: repeats:

那么他们有什么区别呢?
大部分人习惯使用方法1,简单直接,有的人习惯性的设置一个全局变量,在viewWillDisappear:或者viewDidDisappear:方法中写上

if(self.testTimer.isValid) {
[self.testTimerinvalidate];
}
self.testTimer=nil;

并没有想过为什么,而大多数情况,我们设置Timer也是在主线程中,也并未出现过Timer设置完后无效的情况,所以都没有去深入研究过.
接下来我们一个问题一个问题的说:
其实NSTimer和Runloop有着密不可分的关系(这里不是讲Runloop的,而我也并没有对runloop了解特别深入,所以不多说),大部分人直接使用scheduledTimerWithTimeInterval: target: selector: userInfo: repeats:方法创建Timer,只要创建好,就可以直接执行Timer的触发事件,因为这个方法系统会默认为我们添加到Runloop的NSDefaultRunLoopMode中,通过代码用各种方法创建Timer测试

- (void)viewDidLoad {
[superviewDidLoad];
self.view.backgroundColor= [UIColorwhiteColor];
[selfcreateView];
[self initTestTimerWithMethod:0 repeats:YES];
//[self createCustomTimer];
//[self createThread];
}
#pragma mark - NSTimer
//创建NSTimer
- (void)initTestTimerWithMethod:(int)method repeats:(BOOL)repeat {
switch(method) {
case0://scheduledTimerWithTimeInterval:方法创建
{
//会自动执行,并且自动加入当前线程的Run Loop中其mode为:NSDefaultRunLoopMode
self.testTimer= [NSTimerscheduledTimerWithTimeInterval:2target:selfselector:@selector(timerAction1)userInfo:nilrepeats:repeat];
}
break;
default:
break;
}
}
- (void)timerAction1 {
NSLog(@"scheduledTimer方法%@",@"执行Timer事件");
}

点击按钮,我们会发现控制台输出如下

1.png

接下来用另外两种方法创建,以上代码不再重复,直接写case: 内容

case1://timerWithTimeInterval:方法创建
{
//需要手动加入主循环池中
self.testTimer= [NSTimertimerWithTimeInterval:2target:selfselector:@selector(timerAction2)userInfo:nilrepeats:repeat];
}
break;
case2://initWithFireDate:方法创建
{
//init方法需要手动加入循环池,它会在设定的启动时间启动
self.testTimer= [[NSTimeralloc]initWithFireDate:[NSDatedateWithTimeIntervalSinceNow:5]interval:1target:selfselector:@selector(timerAction3)userInfo:nilrepeats:repeat];
}
break;

//Timer执行方法
- (void)timerAction2 {
NSLog(@"timerWithTimeInterval:方法%@",@"执行Timer事件");
}
- (void)timerAction3 {
NSLog(@"initWithFireDate:方法%@",@"执行Timer事件");
}

当我们把viewDidLoad方法中调用的initTestTimerWithMethod: repeats:方法参数改为1时,点击按钮,会发现控制台没有任何输出,将参数改为2同样控制台没有任何输出,查阅官方文档发现,原来这两种方法创建的Timer,不会自动添加到Runloop中,需要我们手动添加到当前的Runloop中才会执行,也就是说明Timer与Runloop有着密不可分的关系,于是修改代码

case1://timerWithTimeInterval:方法创建
{
//需要手动加入主循环池中
self.testTimer= [NSTimertimerWithTimeInterval:2target:selfselector:@selector(timerAction2)userInfo:nilrepeats:repeat];
[[NSRunLoop currentRunLoop]addTimer:self.testTimerforMode:NSDefaultRunLoopMode];
}
break;
case2://initWithFireDate:方法创建
{
//init方法需要手动加入循环池,它会在设定的启动时间启动
self.testTimer= [[NSTimeralloc]initWithFireDate:[NSDatedateWithTimeIntervalSinceNow:5]interval:1target:selfselector:@selector(timerAction3)userInfo:nilrepeats:repeat];
[[NSRunLoopcurrentRunLoop]addTimer:self.testTimerforMode:NSDefaultRunLoopMode];
}
break;

修改后把viewDidLoad方法中调用的initTestTimerWithMethod: repeats:方法参数分别改为1和2依次运行代码,会发现控制台有输出

每个线程都对应一个Runloop,而主线程的Runloop默认是开启的,子线程的Runloop默认不是开启的.通常情况我们的Timer是在主线程中创建的,但是也不乏有的时候是在子线程中创建的,前段时间我就遇到了问题,我们公司是做软硬件的,产品智能音箱需要联网,其中一种联网方法是热点联网,用到了TCP,UDP,通常我们是要另开线程创建Socket的,Socket连接以及数据发送等在任何一个过程中都有可能失败,这里不详细说明,我们的需求是在建立TCP,UDP连接,发送数据以及联网过程加入定时器设置总超时时间,测试测出bug,在一个特定条件下,APP界面的联网提示一直不消失,当时花了很长时间解决这个bug,因为联网过程分了很多步,在任何一步都可能失败,最终发现只要在那个特定的一步出错导致联网失败都会出现这个bug,找了很久才发现是因为Timer设置的执行方法没有执行,但是也想不明白为什么没有执行,在网上查阅资料才知道,原来在子线程创建Timer需要加入Runloop中并开启Runloop,不妨测试一下

case3:
{
[self createThread];
}
break;

//多线程创建Timer
- (void)createThread {
//NSLog(@"主线程%@", [NSThread currentThread]);
//创建并执行新的线程
NSThread*thread = [[NSThreadalloc]initWithTarget:selfselector:@selector(createTimerWithThread)object:nil];
[threadstart];
}
- (void)createTimerWithThread {
//在当前Run Loop中添加timer,模式是默认的NSDefaultRunLoopMode
self.threadTimer= [NSTimerscheduledTimerWithTimeInterval:2.0target:selfselector:@selector(threadTimerAction)userInfo:nilrepeats:YES];
//开始执行子线程的Run Loop
[[NSRunLoopcurrentRunLoop]run];
}
//子线程中timer的回调方法
- (void)threadTimerAction {
NSLog(@"子线程中创建Timer %@",@"执行Timer事件");
}

我们先把[[NSRunLoopcurrentRunLoop]run];这行代码注释掉,会发现控制台没有任何输出,但是添加上这行代码,Timer的执行事件会正常触发.所以要注意在子线程中创建Timer,一定要开始当前线程的Runloop.
二,Timer正常执行后也会遇到的问题
1.循环引用,内存泄露
前面我们已经提到,通常我们会将Timer设置为全局变量,在界面将要消失或者消失的时候将Timer invalidate掉,这是为什么呢?下面我们就来探讨一下
其实Timer会强引用自己的target对象的,而target对象也会对Timer强引用,不妨我们测试一下,还是上面的代码,我们在dealloc方法中打印

- (void)dealloc {
NSLog(@"dealloc");
}

这里补充一下,当前TestTimerViewController是由ViewController presen过来的第二个VC,点击关闭按钮,返回ViewController,presen进来的时候Timer触发.这时候会发现控制台输出了,点击关闭按钮返回主界面,dealloc方法并没有调用,而且无论是调用哪种方法创建的Timer都是没有调用dealloc方法,认真观察我们发现,以上调用的Timer都是重复执行的,即repeats的值为YES,那我们改为NO结果会怎么样呢?

- (void)viewDidLoad {
[superviewDidLoad];
self.view.backgroundColor= [UIColorwhiteColor];
[selfcreateView];
[selfinitTestTimerWithMethod:2repeats:NO];
}

我们看控制台输出,结果是无论调用哪种方法,返回上一界面的时候都会发现调用dealloc方法了,但是重复执行的时候dealloc始终没有调用,这个时候怎么办呢?
我们只需要在界面消失的时候将Timer invalidate

- (void)viewWillDisappear:(BOOL)animated {
[superviewWillDisappear:animated];
//在invalidate之前最好先用isValid先判断是否还在线程中
//将定时器从循环池中移除。
if(self.testTimer.isValid) {
[self.testTimerinvalidate];
}
self.testTimer=nil;
}

这时候再将repeates值修改为YES会看到返回界面的时候控制台输出了dealloc,即调用了dealloc方法, 但是还有一种情况,我们这里是TestTimerViewController强引用了_testTimer,那如果只是单单的创建一个临时变量的Timer的时候上面的现象还会发生吗? 不妨试一试

- (void)viewDidLoad {
[superviewDidLoad];
self.view.backgroundColor= [UIColorwhiteColor];
[selfcreateView];
[selfcreateCustomTimer];
}
//无全局变量创建Timer
- (void)createCustomTimer {
[NSTimerscheduledTimerWithTimeInterval:1target:selfselector:@selector(customTimerAction)userInfo:nilrepeats:YES];
}

我们会看到答案是YES,当repeats:参数为YES的时候,返回时dealloc仍然不会调用,当repeats参数为NO时候,返回上一界面dealloc会调用
总结: 我们在使用Timer的时候,只要创建了Timer,持有Timer的对象都会对Timer强引用,而Timer的target对象也会被Timer强引用,其实根本原因是Timer在isValid为YES的时候是强引用自己的target的对象,当界面回收的时候Timer持有VC,回收Timer时候要回收发现VC持有Timer,这样就造成循环引用. 但是当Timer的target触发事件是只有一次即repeats参数为NO时候,Timer会invalidate自身,这样VC也会回收,当Timer的target触发事件是重复的即repeats参数为YES的时候,Timer不会invalidate自身,需要我们自己手动invalidate,所以在使用NSTimer的时候最好用全局变量定义,界面消失的时候要将Timer invalidate掉,这样才会避免由于循环引用造成的内存泄露

2,Timer中Runloop的mode
我们有时在使用Timer的时候会发现他触发事件的时机不对,这就与Runloop相关了,一个RunLoop包含若干个Mode,每个Mode又包含若干个Source/Timer/Observer.每次调用RunLoop的主函数时,只能指定其中一个Mode,这个Mode被称为CurrentMode,Runloop的模式也分为几种:常见的是default和common modes模式以及event tracking模式(组件拖动输入源 UITrackingRunLoopModes 不处理定时事件),而connection模式(处理NSConnection事件,属于系统内部)用户基本不用.这里需要强调common modes模式:NSRunLoopCommonModes 这是一组可配置的通用模式。将input sources与该模式关联则同时也将input sources与该组中的其它模式进行了关联.每次运行一个run loop,你指定run loop的运行模式。当相应的模式传递给run loop时,只有与该模式对应的 input sources才被监控并允许run loop对事件进行处理(与此类似,也只有与该模式对应的observers才会被通知),针对不同的Mode系统有不同的处理策略和优先级,而default Mode是优先级比较低的,例如当我们在滑动屏幕的时候,其Runloop的mode会切换到event tracking模式,event tracking模式是不处理定时事件的,所以此时当我们的Timer添加的Runloop的模式是default的时候,Timer的事件是不执行的,只有滑动结束了,又重新切换到default模式时候Timer才会执行,而此时他会把之前这段时间的Timer的事件都一次性执行,因为为了避免这种情况发生,我们通常把他添加到Runloop中,设置模式为common modes.话不多说,看代码

case0://scheduledTimerWithTimeInterval:方法创建
{
//会自动执行,并且自动加入当前线程的Run Loop中其mode为:NSDefaultRunLoopMode
self.testTimer= [NSTimerscheduledTimerWithTimeInterval:2target:selfselector:@selector(timerAction1)userInfo:nilrepeats:repeat];
[[NSRunLoopcurrentRunLoop]addTimer:self.testTimerforMode:NSRunLoopCommonModes];
}
break;

//Timer执行方法
- (void)timerAction1 {
NSLog(@"scheduledTimer方法%@",@"执行Timer事件");
NSLog(@"timerAction1 %@", [[NSRunLoopcurrentRunLoop]currentMode]);
}
- (void)scrollViewDidScroll:(UIScrollView*)scrollView {
NSLog(@"滑动屏幕时%@", [[NSRunLoopcurrentRunLoop]currentMode]);
}

这里我创建的TestTimerViewController直接继承自UITableViewController,我设置了100行数据,可以自由滑动,将Timer添加的Runloop的mode设置为NSRunLoopCommonModes,滑动过程看控制台输出情况会发现Timer的触发事件仍然是每隔两秒执行一次

但是若将其模式更改为defaultMode,则控制台输出如下,
[图片上传中。。。(5)]

我们会发现事件触发时间与我们设置的不同,
同时你会发现在子线程创建的Timer默认添加到当前的的Runloop,其mode是default,但是当我们滑动屏幕的时候,并不会影响Timer的执行时间,因为他是在子线程中的Runloop中,而滑动事件是在主线程中的,这里就不再上代码了
三,GCD定时
相信用GCD定时器的人不太多,我也是之前在一个demo上看到这些代码后,才去搜索查看的,GCD定时不需要我们的管理内存释放,我们只需要写出想要执行的事件.
1.只执行一次

- (void)createGCDTimerSourceActionOnce {
delayInSeconds=2.0;
//参数1:开始执行的时间,参数2:延迟时间(单位是纳秒)
dispatch_after(dispatch_time(DISPATCH_TIME_NOW,delayInSeconds*NSEC_PER_SEC),dispatch_get_main_queue(), ^(void){
//执行事件
NSLog(@"GCD定时器只执行一次");
});
}

2.重复执行

- (void)createGCDTimerSourceActionRepeat {
delayInSeconds=2.0;
//创建Dispatch Source
GCDTimerSource=dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER,0,0,dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT,0));
//设置Timer的参数
//参数1: dispatch_source_t,参数2:开始执行的时间,参数3:执行时间间隔(单位是纳秒),参数4:时间精度(系统可以延时的时间间隔)
//系统已预订了宏NSEC_PER_SEC,设置时间:间隔时间(单位秒)*NSEC_PER_SEC
dispatch_source_set_timer(GCDTimerSource,DISPATCH_TIME_NOW,delayInSeconds*NSEC_PER_SEC,0.0);
//设置Dispatch Source的事件回调
dispatch_source_set_event_handler(GCDTimerSource, ^{
//重复执行的事件
NSLog(@"GCD定时器重复执行");
});
//dispatch_source默认是挂起的状态,通过dispatch_resume函数开启
dispatch_resume(GCDTimerSource);
}

总结
1.使用Timer的时候最好使用全局变量,在页面消失的时候将Timer invalidate掉,防止循环引用造成的内存泄露(当然了,是在repeats值为YES的时候)
2.子线程中创建Timer要将其Runloop开启[[NSRunLoopcurrentRunLoop]run];否则会不执行Timer事件
3.最好将Timer添加到Runloop的Mode设置为CommonModes

最后,对于Runloop,我还了解的不够好,希望再多查资料,多运用,大家也可以多研究研究,上面有不对的地方还请提出宝贵意见

推荐阅读更多精彩内容