You use the NSTimer class to create timer objects or, more simply, timers. A timer waits until a certain time interval has elapsed and then fires, sending a specified message to a target object. For example, you could create an NSTimer object that sends a message to a window, telling it to update itself after a certain time interval.
这段话的意思简单的来说就是NSTimer是一个定时器,能够在每个确定时间间隔里发送信息给对象。
NSRunLoop
谈到定时器,首先需要了解的一个概念是 NSRunLoop。NSRunLoop 是消息处理的一种机制,类似于 Windows 中的消息循环、Node.js 的事件处理,有个更通用的叫法是 Event Loop。
其原理很简单,启动一个循环,无限地重复接受消息->等待消息->处理消息这个过程,直到退出。伪代码如下:
|
|
每个线程内部都会有一个 RunLoop,启动 RunLoop 之后,就能够让线程在没有消息时休眠,在有消息时被唤醒并处理消息,避免资源长期被占用。
在 iOS 中,NSThead 和 NSRunLoop 是一一对应的,但创建线程的时候不会默认创建 NSRunLoop,实际上也不允许自己创建 NSRunLoop,在线程内第一次调用[NSRunLoop currentRunLoop]的时候才会自动创建。
苹果不允许自己创建RunLoop,但是提供了两个方法来获取线程的RunLoop:CFRunLoopGetMain() 和 CFRunLoopGetCurrent()。
在cocoaTouch框架中只有主线程的RunLoop是默认打开的.
RunLoop有几个Mode,他们分别是:
1:kCFRunLoopDefaultMode: App的默认 Mode,通常主线程是在这个 Mode 下运行的。默认模式中几乎包含了所有输入源(NSConnection除外),一般情况下应使用此模式。
2:UITrackingRunLoopMode: 界面跟踪 Mode,用于 ScrollView 追踪触摸滑动,保证界面滑动时不受其他 Mode 影响。在拖动loop或其他user interface tracking loops时处于此种模式下,在此模式下会限制输入事件的处理。例如,当手指按住UITableView拖动时就会处于此模式。
3:UIInitializationRunLoopMode: 在刚启动 App 时第进入的第一个 Mode,启动完成后就不再使用。
4:GSEventReceiveRunLoopMode: 接受系统事件的内部 Mode,通常用不到。
5:kCFRunLoopCommonModes: 这是一个占位的 Mode,没有实际作用。这是一个伪模式,其为一组run loop mode的集合,将输入源加入此模式意味着在Common Modes中包含的所有模式下都可以处理。(是一个模式集合,当绑定一个事件源到这个模式集合的时候就相当于绑定到了集合内的每一个模式。)在Cocoa应用程序中,默认情况下Common Modes包含default modes,modal modes,event Tracking modes.可使用CFRunLoopAddCommonMode方法向Common Modes中添加自定义modes。
苹果公开提供的 Mode 有两个:kCFRunLoopDefaultMode (NSDefaultRunLoopMode) 和 UITrackingRunLoopMode,你可以用这两个 Mode Name 来操作其对应的 Mode。
同时苹果还提供了一个操作 Common 标记的字符串:kCFRunLoopCommonModes (NSRunLoopCommonModes),你可以用这个字符串来操作 Common Items,或标记一个 Mode 为 “Common”。使用时注意区分这个字符串和其他 mode name。
创建timer
NSTimer的几种创建方式:
|
|
|
|
常用方法
|
|
三种方法的区别是:
scheduledTimerWithTimeInterval方法不仅创建了NSTimer对象,还把该NSTimer对象加入到了当前线程的RunLoop(默认NSDefaultRunLoopModel模式)中。
前两个方法需要使用addTimer:forMode:方法将NSTimer加入到RunLoop中。
|
|
具体代码如下:
|
|
|
|
以上三种方式都是在 RunLoop 的 Mode为 NSDefaultRunLoopMode 时触发, 在滚动scrollView的时候NSTimer会停止工作.
NSTimer和RunLoop
滑动 ScrollView 时 NSTimer 不工作的问题
应用场景举例:主线程的 RunLoop 里有两个预置的 Mode:kCFRunLoopDefaultMode 和 UITrackingRunLoopMode。这两个 Mode 都已经被标记为”Common”属性。DefaultMode 是 App 平时所处的状态,TrackingRunLoopMode 是追踪 ScrollView 滑动时的状态。当你创建一个 Timer 并加到 DefaultMode 时,Timer 会得到重复回调,但此时滑动一个TableView时,RunLoop 会将 mode 切换为 TrackingRunLoopMode,这时 Timer 就不会被回调,并且也不会影响到滑动操作。
有时你需要一个 Timer,在两个 Mode 中都能得到回调,一种办法就是将这个 Timer 分别加入这两个 Mode。还有一种方式,就是将 Timer 加入到顶层的 RunLoop 的 “commonModeItems” 中。”commonModeItems” 被 RunLoop 自动更新到所有具有”Common”属性的 Mode 里去。
|
|
forMode的参数有两种类型可供选择: NSDefaultRunLoopMode
, NSRunLoopCommonModes
,第一个参数为默认参数,
当前线程就是主线程,也就是UI线程时,某些UI事件,比如UIScrollView的拖动操作,会将 RunLoop 切换成 NSEventTrackingRunLoopMode
模式,在这个过程中,默认的 NSDefaultRunLoopMode
模式中注册的事件是不会被执行的。NSRunLoopCommonModes
能够在多线程中起作用,这个模式等效于NSDefaultRunLoopMode
和 NSEventTrackingRunLoopMode
的结合,这也是将modes换为 NSRunLoopCommonModes
便可解决的原因。
|
|
NSTimer的触发
当定时器创建完(不用scheduled的),添加到runloop中后,该定时器将在初始化时指定的timeInterval秒后自动触发。
可以使用-(void)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.
在重复执行的定时器中调用此方法后立即触发该定时器,但不会中断其之前的执行计划;
在不重复执行的定时器中调用此方法,立即触发后,就会使这个定时器失效。
调用fire的时候,立即触发timer的方法,该方法触发不影响计时器原本的计时,只是新增一次触发
当NSTimer进入后台的时,NSTimer计时暂停,进入前台继续
子线程中NSTimer的坑
1.必须保证有一个活跃的runloop。
NSObject 的 (void)performSelector:(SEL)aSelector withObject:(id)anArgument afterDelay:(NSTimeInterval)delay;
也可以用于延迟一段时间执行特定的代码.该方法内部是启用一个Timer并添加到当前线程的runloop,原理与NSTimer一样.
performSelector
和 scheduledTimerWithTimeInterval
方法都是基于runloop的。我们知道,当一个应用启动时,系统会开启一个主线程,并且把主线程的runloop激活,也就是run起来,并且主线程的runloop是不会停止的。所以,当这两个方法在主线程可以被正常调用。但情况往往不是这样的。实际编码中,我们更多的逻辑是放在子线程中执行的。而子线程的runloop是默认关闭的。这时如果不手动激活runloop,performSelector
和 scheduledTimerWithTimeInterval
的调用将是无效的。
实例:
|
|
当我们用performSelector的方式把timer放到子线程中的runloop里时发现timer不好使了。
子线程默认不会创建 runloop ,currentRunloop的方式是可以获得 runloop 的,在线程内第一次调用 [NSRunLoop currentRunLoop]
的时候才会自动创建。但是此时的这个runloop并没有run(激活),我们要在 [[NSRunLoop currentRunLoop] addTimer:_testTimer forMode:NSDefaultRunLoopMode];
后调用 [[NSRunLoop currentRunLoop] run];
才可以使得这个timer正常工作。
总结:
|
|
这行代码的作用就是打开当前线程的runLoop,在cocoaTouch框架中只有主线程的RunLoop是默认打开的,而其他线程的RunLoop如果需要使用就必须手动打开,所以如果我们是想要添加到主线程的RunLoop的话,是不需要手动打开RunLoop的。
停止timer
|
|
这个是唯一一个可以将计时器从runloop中移出的方法,并删除了runloop对计时器的强引用,也是唯一去除对target强引用的方法
苹果文档:
Stops the timer from ever firing again and requests its removal from its run loop.
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中将其移除。
- invalidate方法是将NSTimer对象从RunLoop中移除的唯一方法。
- 调用invalidate方法会删除RunLoop对NSTimer的强引用,以及NSTimer对target和userInfo的强引用
那为什么RunLoop会对NSTimer强引用呢?
Timers work in conjunction with run loops. Run loops maintain strong references to their timers
( 计时器与运行循环一起工作。Run loops维护对计时器的强引用)
在 invalidate 方法的文档里还有这这样一段话:
You must send this message from the thread on which the timer was installed. If you send this message from another thread, the input source associated with the timer may not be removed from its run loop, which could prevent the thread from exiting properly.
NSTimer的创建与撤销必须在同一个线程操作、performSelector的创建与撤销必须在同一个线程操作。
循环引用导致的内存泄露的问题
NSTimer 添加到 Runloop 的时候,会被 Runloop 强引用:
Note in particular that 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触发后,在调用invalidated之前会一直保持对target的强引用也就是 self
Target is the object to which to send the message specified by aSelector when the timer fires. The timer maintains a strong reference to target until it (the timer) is invalidated.
也就是说 NSTimer 强引用了 self ,导致 self 一直不能被释放掉,所以走不到 self 的 dealloc 里。
由上可见:NSTimer强引用了self,self也强引用了NSTimer,由此造成了循环引用,同时Runloop也强引用NSTimer。
|
|
我们可能写过类似上面的代码,一般情况下它是可以正常执行的,我们并没有过多的去想timer的问题,但是实际上这样写是有问题的。如果创建timer时repeats:YES,再运行的话,我们发现dealloc函数就永远不会调用了(我们这里SecondController是被另一个vc push进来的)。
引起这个问题的原因就是:timer会强引用自己的target,在上面的例子中,我们的vc是强引用_testTimer对象的,但是创建这个timer的时候我们的target传入的是self,此时就导致了tiemr也强引用了self,导致循环引用的产生。
准确的说,timer在isValid为YES的时候(当timer触发后,在调用invalidated之前)是强引用自己的target的,所以一般我们都把invalidate的时机放在viewWillDisappear:或viewDidDisappear:的时候,这样vc就会正常释放了。
|
|
上面这样就可以正常释放了,
但这里还要说一点:
循环引用和内存泄露还是稍有不同的
就拿我们的timer举例,如果我们在创建timer的时候repeats:NO,但是触发的时机是30s,但是我们在这个vc中停留小于30s,就会造成暂时的循环引用,但是这种情况也不能说是内存泄露,从我们退出vc开始到timer触发时的这一段时间内,vc和timer造成了循环引用,但是当timer触发后,你会发现,vc的dealloc也被调用了,此时vc和timer的内存都能够得到释放,循环引用是有暂时性的,所以要理解循环引用和内存泄露是稍有不同的。
当定时器是不重复的(repeat=NO),在执行完触发函数后,会自动调用invalidate解除runloop的注册和解除对target的强引用
几种成熟的解决循环引用方案
一. 使用自定义Category用Block解决
|
|
|
|
定义一个NSTimer的类别,在类别中定义一个类方法。类方法有一个类型为块的参数(定义的块位于栈上,为了防止块被释放,需要调用copy方法,将块移到堆上)。
使用这个类别的方式如下:
|
|
使用这种方案就可以防止NSTimer对类的保留,从而打破了循环引用的产生。__strong ViewController *strongSelf = weakSelf
主要是为了防止执行块的代码时,类被释放了。
在类的dealloc方法中,记得调用[_timer invalidate]。
定时器对象指定的target是NSTimer类对象是个单例,因此计时器是否会保留它都无所谓。这么做,循环引用依然存在,但是因为类对象无需回收,所以能解决问题。
优点:代码简洁,逻辑清晰
缺点:
- 需要使用weakSelf避免block循环引用
- 不再使用原生API
- 同时要为NSTimer何CADisplayLink分别引进一个Category
二. GCD自己实现Timer
直接用GCD自己实现一个定时器,YYKit直接有一个现成的类YYTimer这里不再赘述。
缺点:代价有点大,需要自己重新造一个定时器。
三. 代理NSProxy
使用工具类YYWeakProxy解决NSTimer/CADisplayLink循环引用问题!
|
|
|
|
该方法引入一个YYWeakProxy对象,在这个对象中弱引用真正的目标对象。通过YYWeakProxy对象,将NSTimer/CADisplayLink对象弱引用目标对象。
使用方法:
|
|
|
|
|
|
为什么NSProxy的子类YYWeakProxy可以解决呢?
- NSProxy本身是一个抽象类,它遵循NSObject协议,提供了消息转发的通用接口,NSProxy通常用来实现消息转发机制和惰性初始化资源。不能直接使用NSProxy。需要创建NSProxy的子类,并实现init以及消息转发的相关方法,才可以用。
- YYWeakProxy继承了NSProxy,定义了一个弱引用的target对象,通过重写消息转发等关键方法,让target对象去处理接收到的消息。在整个引用链中,Controller对象强引用NSTimer/CADisplayLink对象,NSTimer/CADisplayLink对象强引用YYWeakProxy对象,而YYWeakProxy对象弱引用Controller对象,所以在YYWeakProxy对象的作用下,Controller对象和NSTimer/CADisplayLink对象之间并没有相互持有,完美解决循环引用的问题。
NSTimer 不精确
可靠性
不可靠.
1.其所在的 RunLoop 会定时检测是否可以触发 NSTimer 的事件,但由于 iOS 有多个 RunLoop 的运行模式,如果被切到另一个 run loop mode,在别的模式中注册的NSTimer的事件就不会被触发。
2.每个 RunLoop 的循环间隔也无法保证,当某个任务耗时比较久,RunLoop 的下一个消息处理就只能顺延,导致 NSTimer 的时间已经到达,但 Runloop 却无法及时触发 NSTimer,导致该时间点的回调被错过。NSTimer不会延后执行,而是会等下一次触发,相当于等公交错过了,只能等下一趟车,tolerance属性可以设置误差范围
苹果官方文档:
A timer is not a real-time mechanism; it fires only when one of the run loop modes to which the timer has been added is running and able to check if the timer’s firing time has passed. If a timer’s firing time occurs during a long callout or while the run loop is in a mode that is not monitoring the timer, the timer does not fire until the next time the run loop checks the timer.
最小精度
理论上最小精度为 0.1 毫秒。不过由于受 Runloop 的影响,会有 50 ~ 100 毫秒的误差,所以,实际精度可以认为是 0.1 秒。
苹果官方文档:
Because of the various input sources a typical run loop manages, the effective resolution of the time interval for a timer is limited to on the order of 50-100 milliseconds.
实测结果
间隔 0.1 秒,调用12次。其中倒数第二次调用前会执行一个比较耗时的运算任务。
代码:
|
|
结果:
|
|
可以看到偏差在 1 ~ 2 毫秒左右。在第 10 次之后执行了一个较耗时的任务,导致第 11 次比预期延迟了 0.5 秒执行。后面的回调仍然按照预设的延时执行。
NSTimer当前所处的线程正在进行耗时操作,这期间有可能会错过很多次NSTimer的循环周期,但是NSTimer并不会将前面错过的执行次数在后面都执行一遍,而是继续执行后面的循环,也就是在一个循环周期内只会执行一次循环。
无论循环延迟的多离谱,循环间隔都不会发生变化,在进行完耗时之后,有可能会立即执行一次NSTimer循环,但是后面的循环间隔始终和第一次添加循环时的间隔相同。
后台运行
NSTimer不支持后台运行(真机),但是模拟器上App进入后台的时候,NSTimer还会持续触发
如果需要后台运行可以通过下面两种方式支持
让App支持后台运行(运行音频)(在后台可以触发)
记录离开和进入App的时间,手动控制计时器(在后台不能触发)
第一种控制起来比较麻烦,通常建议手动控制,不在后台触发计时
NSTimer注意点
- NSTimer只有被注册到runloop才能起作用,fire不是开启定时器的方法,只是触发一次定时器的方法
- NSTimer会强引用target
- invalidate取消runloop的注册和target的强引用,如果是非重复的定时器,则在触发时会自动调用invalidate
和其他定时器的对比
NSTimer本质
CFRunLoopTimerRef 是基于时间的触发器,它和 NSTimer 是toll-free bridged 的,可以混用。其包含一个时间长度和一个回调(函数指针)。当其加入到 RunLoop 时,RunLoop会注册对应的时间点,当时间点到时,RunLoop会被唤醒以执行那个回调。
NSTimer 其实就是 CFRunLoopTimerRef,他们之间是 toll-free bridged 的。一个 NSTimer 注册到 RunLoop 后,RunLoop 会为其重复的时间点注册好事件。例如 10:00, 10:10, 10:20 这几个时间点。RunLoop为了节省资源,并不会在非常准确的时间点回调这个Timer。Timer 有个属性叫做 Tolerance (宽容度),标示了当时间点到后,容许有多少最大误差。
如果某个时间点被错过了,例如执行了一个很长的任务,则那个时间点的回调也会跳过去,不会延后执行。就比如等公交,如果 10:10 时我忙着玩手机错过了那个点的公交,那我只能等 10:20 这一趟了。
定时器一般用于延迟一段时间执行特定的代码,必要的话按照指定的频率重复执行。iOS 中延时执行有多种方式,常用的有:
- NSTimer
- NSObject 的 (void)performSelector:(SEL)aSelector withObject:(id)anArgument afterDelay:(NSTimeInterval)delay;
- CADisplayLink
- GCD 的 dispatch_after
- GCD 的 dispatch_source_t
每种方法创建的定时器,其可靠性与最小精度都有不同。可靠性指是否严格按照设定的时间间隔按时执行,最小精度指支持的最小时间间隔是多少。
NSObject 的 (void)performSelector:(SEL)aSelector withObject:(id)anArgument afterDelay:(NSTimeInterval)delay;
当调用 NSObject 的 performSelecter:afterDelay: 后,实际上其内部会创建一个 Timer 并添加到当前线程的 RunLoop 中。所以如果当前线程没有 RunLoop,则这个方法会失效。
当调用 performSelector:onThread: 时,实际上其会创建一个 Timer 加到对应的线程去,同样的,如果对应线程没有 RunLoop 该方法也会失效。
performSelector:withObject:afterDelay:
这是 NSObject 对 NSTimer 封装后提供的一个比较简单的延时方法,内部用的也是 NSTimer.
CADisplayLink
CADisplayLink 是一个和屏幕刷新率(每秒 60 帧,间隔 16.67 ms)一致的定时器(但实际实现原理更复杂,和 NSTimer 并不一样,其内部实际是操作了一个 Source)。如果在两次屏幕刷新之间执行了一个长任务,那其中就会有一帧被跳过去(和 NSTimer 相似),造成界面卡顿的感觉。在快速滑动TableView时,即使一帧的卡顿也会让用户有所察觉。Facebook 开源的 AsyncDisplayLink 就是为了解决界面卡顿的问题,其内部也用到了 RunLoop。
使用方法:
|
|
可靠性:
如果执行的任务很耗时,也会导致回调被错过,所以并不十分可靠。但是,假如调用者能够确保任务能够在最小时间间隔内执行完成,CADisplayLink 就比较可靠,因为屏幕的刷新频率是固定的。
最小精度:
受限于每秒 60 帧的屏幕刷新频率,注定 CADisplayLink 的最小精度为 16.67 毫秒。误差在 1 毫秒左右。
另外需要注意的是,虽然 CADisplayLink 有一个属性 frameInterval 是用于设置定时器的调用间隔,但是这个属性会在第一次回调之后才生效,对于第一次回调,总是会以 1/60 的间隔来执行的。这样会导致的结果是,比如你设置了每 1 秒执行一次某个方法,但是第一次执行的时候,却是在 16.7 毫秒之后,远远小于预设值。
实测结果:
参考: 更可靠和高精度的 iOS 定时器
误差在 0.1 ~ 0.5 毫秒之间,精度比 NSTimer 要高。CADisplayLink 在第一次回调以及在耗时任务之后的回调,精度不可控。
GCD 的 dispatch_after
dispatch_after 用起来十分简单,代码紧凑易读,而且可以很轻松地在别的线程分配延时任务,所以使用范围很广泛。
|
|
可靠性
Any fire of the timer may be delayed by the system in order to improve power consumption and system performance. The upper limit to the allowable delay may be configured with the ‘leeway’ argument, the lower limit is under the control of the system.
最小精度
延时参数的单位是纳秒。如果有延时,则无法保证。
实测结果
参考: 更可靠和高精度的 iOS 定时器
平均误差 9 毫秒。
GCD 的 dispatch_source_t
经测试,dispatch_source_t 的最小精度和可靠性都与 diapatch_after 差不多。
参考: 更可靠和高精度的 iOS 定时器
更高精度的定时器
上述的各种定时器,都受限于苹果为了保护电池和提高性能采用的策略,导致无法实时地执行回调。如果你的确需要使用更高精度的定时器,官方也提供了方法,见 High Precision Timers in iOS / OS X
前面所述的定时器,使用方法各有不同,但其核心代码实际上是一样的。
|
|
而有别于普通定时器的高精度定时器,则是基于高优先级的线程调度类创建的定时器,在没有多线程冲突的情况下,这类定时器的请求会被优先处理。
实现方法
- 把定时器所在的线程,移到高优先级的线程调度类。
- 使用更精确的计时器API,换言之,你想要 10 秒后执行,就绝对在 10 秒后执行,绝不提前,也不延迟。
如何使用
How do I get put into the real time scheduling class?
Which timing API(s) should I use?
提高调度优先级:
|
|
精确延时:
|
|
最小精度
小于 0.5 毫秒。这里有一份实现的代码以及与普通定时器的对比。
参考
苹果官方文档 https://developer.apple.com/documentation/foundation/timer
深入理解RunLoop https://blog.ibireme.com/2015/05/18/runloop/
更可靠和高精度的 iOS 定时器 http://blog.lessfun.com/blog/2016/08/05/reliable-timer-in-ios/
iOS实录8:解决NSTimer/CADisplayLink的循环引用 https://www.jianshu.com/p/5068b6f02238
第52条:别忘了NSTimer会保留其目标对象 https://www.jianshu.com/p/bfaad0dee84b
NSTimer用法与循环引用 https://www.jianshu.com/p/63d1391d7bb8
选择 GCD 还是 NSTimer ? http://www.jianshu.com/p/0c050af6c5ee
iOS开发 之 不要告诉我你会用NSTimer! http://www.jianshu.com/p/330d7310339d
timer中的那些坑 http://www.jianshu.com/p/544e2e24eda2
深入NSTimer(iOS) http://www.jianshu.com/p/583ca675065a
iOS笔记之NSTimer http://www.jianshu.com/p/d17430f4fc0f
NSTimer学习笔记 https://www.jianshu.com/p/07b28a8479ea
iOS 中的 NSTimer https://blog.callmewhy.com/2015/07/06/weak-timer-in-ios/