咱们在开发应用的过程当中,每每在不少地方须要倒计时,好比说轮播图,验证码,活动倒计时等等。而在实现这些功能的时候,咱们每每会遇到不少坑须要咱们当心的规避掉。 由于文章内容的关系,要求你们都有一些runloop的基础知识,固然若是没有,也没什么特别大的问题。这里推荐一下 ibireme的这篇文章。html
话很少说,直接上正题:ios
在开发过程当中,咱们基本上只用了这几种方式来实现倒计时git
1.PerformSelecter 2.NSTimer 3.CADisplayLink 4.GCDgithub
咱们使用下面的代码能够实现指定延迟以后执行:macos
- (void)performSelector:(SEL)aSelector withObject:(id)anArgument afterDelay:(NSTimeInterval)delay;
复制代码
它的方法描述以下编程
Invokes a method of the receiver on the current thread using the default mode after a delay. This method sets up a timer to perform the aSelector message on the current thread’s run loop. The timer is configured to run in the default mode (NSDefaultRunLoopMode). When the timer fires, the thread attempts to dequeue the message from the run loop and perform the selector. It succeeds if the run loop is running and in the default mode; otherwise, the timer waits until the run loop is in the default mode. If you want the message to be dequeued when the run loop is in a mode other than the default mode, use the performSelector:withObject:afterDelay:inModes: method instead. If you are not sure whether the current thread is the main thread, you can use the performSelectorOnMainThread:withObject:waitUntilDone: or performSelectorOnMainThread:withObject:waitUntilDone:modes:method to guarantee that your selector executes on the main thread. To cancel a queued message, use the cancelPreviousPerformRequestsWithTarget: or cancelPreviousPerformRequestsWithTarget:selector:object:method.api
这个方法在Foundation框架下的NSRunLoop.h文件下。当咱们调用NSObject 这个方法的时候,在runloop的内部是会建立一个Timer并添加到当前线程的 RunLoop 中。因此若是当前线程没有 RunLoop,则这个方法会失效。并且还有几个很大的缺陷:数组
- 这个方法必须在NSDefaultRunLoopMode下才能运行
- 由于它基于RunLoop实现,因此可能会形成精确度上的问题。 这个问题在其余两个方法上也会出现,因此咱们下面细说
- 内存管理上很是容易出问题。 当咱们执行 [self performSelector: afterDelay:]的时候,系统会将self的引用计数加1,执行完这个方法时,还会将self的引用计数减1,当方法尚未执行的时候,要返回父视图释放当前视图的时候,self的计数没有减小到0,而致使没法调用dealloc方法,出现了内存泄露。
由于它有如此之多的缺陷,因此咱们不该该使用它,或者说,不该该在倒计时这方法使用它。xcode
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti invocation:(NSInvocation *)invocation repeats:(BOOL)yesOrNo;
+ (NSTimer *)scheduledTimerWithTimeInterval:(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 *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo;
+ (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));
复制代码
方法描述以下安全
A timer that fires after a certain time interval has elapsed, sending a specified message to a target object. 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. To use a timer effectively, you should be aware of how run loops operate. See Threading Programming Guide for more information. A timer is not a real-time mechanism. If a timer’s firing time occurs during a long run loop callout or while the run loop is in a mode that isn't monitoring the timer, the timer doesn't fire until the next time the run loop checks the timer. Therefore, the actual time at which a timer fires can be significantly later. See also Timer Tolerance. NSTimer is toll-free bridged with its Core Foundation counterpart, CFRunLoopTimerRef. See Toll-Free Bridging for more information.
这个方法在Foundation框架下的NSTimer.h文件下。一个NSTimer的对象只能注册在一个RunLoop当中,可是能够添加到多个RunLoop Mode当中。 NSTimer 其实就是 CFRunLoopTimerRef,他们之间是 Toll-Free Bridging 的。它的底层是由XNU 内核的 mk_timer来驱动的。一个 NSTimer 注册到 RunLoop 后,RunLoop 会为其重复的时间点注册好事件。例如 10:00, 10:10, 10:20 这几个时间点。RunLoop为了节省资源,并不会在很是准确的时间点回调这个Timer。Timer 有个属性叫作Tolerance (宽容度),标示了当时间点到后,允许有多少最大偏差。 在文件中,系统提供了一共8个方法,其中三个方法是直接将timer添加到了当前runloop 的DefaultMode,而不须要咱们本身操做,固然这样的代价是runloop只能是当前runloop,模式是DefaultMode:
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti invocation:(NSInvocation *)invocation repeats:(BOOL)yesOrNo;
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(id)userInfo repeats:(BOOL)yesOrNo;
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block;
复制代码
其余五个方法,是不会自动添加到RunLoop的,还须要调用addTimer:forMode:
:
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block;
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti invocation:(NSInvocation *)invocation repeats:(BOOL)yesOrNo;
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(id)userInfo repeats:(BOOL)yesOrNo;
- (instancetype)initWithFireDate:(NSDate *)date interval:(NSTimeInterval)ti target:(id)t selector:(SEL)s userInfo:(id)ui repeats:(BOOL)rep;
- (instancetype)initWithFireDate:(NSDate *)date interval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block;
复制代码
假如咱们开启了NSTimer,可是却没有运行,咱们能够检查RunLoop是否运行,以及运行的Mode是否正确
NSTimer和PerformSelecter有不少相似的地方,好比说二者的建立和撤销都必需要在同一个线程上,内存管理上都有泄露的风险,精度上都有问题。下面让咱们讲一下后两个问题。
当咱们使用了NSTimer的时候,RunLoop会强持有一个NSTimer,而NSTimer内部持有一个self的target,而控制器又持有NSTimer对象,这样就形成了一个循环引用。虽然系统提供了一个invalidate方法来把NSTimer从RunLoop中释放掉并取消强引用,可是每每找不到应有的位置来放置。 咱们解决这个问题的思路很简单,初始化NSTimer时把触发事件的target替换成一个单独的对象,而后这个对象中NSTimer的SEL方法触发时让这个方法在当前的视图self中实现。 利用RunTime在target对象中动态的建立SEL方法,而后target对象关联当前的视图self,当target对象执行SEL方法时,取出关联对象self,而后让self执行该方法。 实现代码以下:
.h
#import <Foundation/Foundation.h>
@interface NSTimer (Brex)
/**
* 建立一个不会形成循环引用的循环执行的Timer
*/
+ (instancetype)brexScheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(id)userInfo;
@end
.m
#import "NSTimer+Brex.h"
@interface BrexTimerTarget : NSObject
@property (nonatomic, weak) id target;
@property (nonatomic, assign) SEL selector;
@property (nonatomic, weak) NSTimer *timer;
@end
@implementation PltTimerTarget
- (void)brexTimerTargetAction:(NSTimer *)timer
{
if (self.target) {
[self.target performSelector:self.selector withObject:timer afterDelay:0.0];
} else {
[self.timer invalidate];
self.timer = nil;
}
}
@end
@implementation NSTimer (Brex)
+ (instancetype)brexScheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(id)userInfo
{
BrexTimerTarget *timerTarget = [[BrexTimerTarget alloc] init];
timerTarget.target = aTarget;
timerTarget.selector = aSelector;
NSTimer *timer = [NSTimer timerWithTimeInterval:ti target:timerTarget selector:@selector(brexTimerTargetAction:) userInfo:userInfo repeats:YES];
[[NSRunLoop mainRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
timerTarget.timer = timer;
return timerTarget.timer;
}
@end
复制代码
固然,真正在使用的时候,仍是须要经过测试再来验证。
上面咱们也提到了,其实NSTimer并非很是准确的。 NSTimer其实算不上一个真正的时间机制。它只有在被加入到RunLoop的时候才能触发。 假如在一个RunLoop下没能检测到定时器,那么它会在下一个RunLoop中检查,并不会延后执行。换个说法,咱们能够理解为:“这趟火车没遇上,等下一班吧”。 另外,有时候RunLoop正在处理一个很费事的操做,好比说遍历一个很是很是大的数组,那么也可能会“忘记”查看定时器了。这么咱们能够理解为“火车晚点了”。 固然,这两种状况表现起来其实都是NSTimer不许确。 因此,真正的定时器触发时间不是本身设定的那个时间,而是可能加入了一个RunLoop的触发时间。而且,NSRunLoop算不上真正的线程安全,假如NSTimer没有在一个线程中操做,那么可能会触发不可意料的后果。
Warning The NSRunLoop class is generally not considered to be thread-safe and its methods should only be called within the context of the current thread. You should never try to call the methods of an NSRunLoop object running in a different thread, as doing so might cause unexpected results. NSRunLoop类一般不被认为是线程安全的,它的方法应该只在当前线程中调用。您不该尝试调用在不一样线程中运行的NSRunLoop对象的方法,由于这样作可能会致使意外的结果。
建立方法
self.displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(handleDisplayLink:)];
[self.displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode];
中止方法
[self.displayLink invalidate];
self.displayLink = nil;
复制代码
CADisplayLink是一个能让咱们以和屏幕刷新率同步的频率将特定的内容画到屏幕上的定时器类。它和NSTimer在实现上有些相似。不过区别在于每当屏幕显示内容刷新结束的时候,runloop就会向CADisplayLink指定的target发送一次指定的selector消息, 而NSTimer以指定的模式注册到runloop后,每当设定的周期时间到达后,runloop会向指定的target发送一次指定的selector消息。 固然,和NSTimer相似,CADisplayLink也会由于一样的缘由出现精问题,不过单就精度而言,CADisplayLink会更高一点。这里的表现就就是画面掉帧了。 咱们一般状况下,会把它使用在界面的不停重绘,好比视频播放的时候须要不停地获取下一帧用于界面渲染,还有动画的绘制等地方。
终于,咱们讲到重点了:GCD倒计时
dispatch_source_t timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue);
dispatch_source_set_timer(timer, dispatch_walltime(NULL, 0), 10*NSEC_PER_SEC, 1*NSEC_PER_SEC); //每10秒触发timer,偏差1秒
dispatch_source_set_event_handler(timer, ^{
// 定时器触发时执行的 block
});
dispatch_resume(timer);
复制代码
了解GCD倒计时的原理,须要咱们最好阅读一下libdispatch源码。固然,若是你不想阅读,直接往下看也能够。 dispatch_source_create
这个API为一个dispatch_source_t
类型的结构体ds作了分配内存和初始化操做,而后将其返回。
下面从底层源码的角度来研究这几行代码的做用。首先是 dispatch_source_create
函数,它和以前见到的 create 函数都差很少,对 dispatchsourcet 对象作了一些初始化工做:
dispatch_source_t ds = NULL;
ds = _dispatch_alloc(DISPATCH_VTABLE(source), sizeof(struct dispatch_source_s));
_dispatch_queue_init((dispatch_queue_t)ds);
ds->do_suspend_cnt = DISPATCH_OBJECT_SUSPEND_INTERVAL;
ds->do_targetq = &_dispatch_mgr_q;
dispatch_set_target_queue(ds, q);
return ds;
复制代码
这里涉及到两个队列,其中 q 是用户指定的队列,表示事件触发的回调在哪一个队列执行。而 _dispatch_mgr_q 则表示由哪一个队列来管理这个 source,mgr 是 manager 的缩写.
其次是 dispatch_source_set_timer,
void
dispatch_source_set_timer(dispatch_source_t ds,
dispatch_time_t start,
uint64_t interval,
uint64_t leeway)
{
......
struct dispatch_set_timer_params *params;
......
dispatch_barrier_async_f((dispatch_queue_t)ds, params,
_dispatch_source_set_timer2);
}
复制代码
这段代码中,首先会对参数进行一个过滤和从新设置,而后建立一个dispatch_set_timer_params
的指针:
//这个 params 负责绑定定时器对象与他的参数
struct dispatch_set_timer_params {
dispatch_source_t ds;
uintptr_t ident;
struct dispatch_timer_source_s values;
};
复制代码
最后调用
dispatch_barrier_async_f((dispatch_queue_t)ds, params, _dispatch_source_set_timer2);
复制代码
随后调用_dispatch_source_set_timer2
方法:
static void _dispatch_source_set_timer2(void *context) {
// Called on the source queue
struct dispatch_set_timer_params *params = context;
dispatch_suspend(params->ds);
dispatch_barrier_async_f(&_dispatch_mgr_q, params,
_dispatch_source_set_timer3);
}
复制代码
而后接着调用_dispatch_source_set_timer3
方法:
static void _dispatch_source_set_timer3(void *context)
{
// Called on the _dispatch_mgr_q
struct dispatch_set_timer_params *params = context;
......
_dispatch_timer_list_update(ds);
......
}
复制代码
_dispatch_timer_list_update
函数的做用是根据下一次触发时间将 timer 排序。
接下来,当初分发到 manager 队列的 block 将要被执行,走到 _dispatch_mgr_invoke
函数,其中有以下代码:
r = select(FD_SETSIZE, &tmp_rfds, &tmp_wfds, NULL, sel_timeoutp);
复制代码
可见,GCD定时器的底层是由XNU内核中的select方法实现的。熟悉socket编程的朋友可能对这个方法很熟悉。这个方法能够用来处理阻塞,粘包等问题。
由于方法来自于最底层,GCD倒计时算得上最精确的。
那么有没有可能出现不精确的问题呢?
答案是也有可能!
这里咱们看一张图
假如你对时间的精确的没有特别高的要求,好比说轮播图什么的,能够选择使用NSTimer;建立动画什么的,可使用CADisplayLink;想要追求高精度,可使用GCD倒计时;至于PerformSelecter,仍是算了吧。
我当初曾经将一个轮播图做为一个tableview的headerView。测试的时候发现一个你们可能都会遇到的问题,滑动tableview的时候轮播图不滑了。这个问题很好解决,
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
复制代码
更换RunLoop的mode,就能够了。
这个我是受到了FPSLabel的启发,在它的基础上扩展了一下,只作了一个在页面最上层滑动的View。它主要是用来在debug模式下进行测试,上面展现了页面自己的FPS,App版本号,iOS版本号,手机型号等等数据。咱们通常状况下认为,FPS在55-60之间,算的上流畅,低于55就要找问题,解决问题了。固然,这个view自己添加的自己也会影响到当前页面的FPS。“观察者效应”嘛。
当初曾经接触到一个需求,要在一个tableview上实现多个带倒计时cell。最开始的时候我是使用NSTimer一个一个来实现的,可是后来发现,当cell多起来的时候,页面会变得很是卡顿。为了解决这个,我本身想出了一个办法:我实现了一个倒计时的单例,每过1秒就会发出一个对应页面的block(当时有好几个页面须要),以及一个总的通知,里面只包含一个当前的时间戳,而且公开开启倒计时以及关闭倒计时的方法。这样,一个页面就能够只使用一个倒计时来实现了。每一个cell只须要持有一个倒计时的终点时间就能够了。
我就是在当时开始研究倒计时的问题,甚至本身用select函数实现了一个倒计时单例。
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
[[NSThread currentThread] setName:@"custom Timer"];
......
fd_set read_fd_set;
FD_ZERO(&read_fd_set);
FD_SET(self.fdCustomTimerModifyWaitTimeReadPipe, &read_fd_set);
struct timeval tv;
tv.tv_sec = self.customTimerWaitTimeInterval;
tv.tv_usec = 0;
......
long ret = select(self.fdCustomTimerModifyWaitTimeReadPipe + 1, &read_fd_set, NULL, NULL, &tv);//核心
self.customTimerSelectTime = [[NSDate date] timeIntervalSince1970];
......
if(ret == 0){
NSLog(@"select 超时!\n");
NSLog(@"self.customTimerWaitTimeInterval:%lld", self.customTimerWaitTimeInterval);
if(self.customTimerNeedNotification)
{
dispatch_sync(dispatch_get_main_queue(), ^{
[[NSNotificationCenter defaultCenter] postNotificationName:customTimerIntervalNotification object:nil];
});
}
if(self.auctionHouseDetailViewControllerTimerCallBack)
{
dispatch_sync(dispatch_get_main_queue(), ^{
self.auctionHouseDetailViewControllerTimerCallBack();
});
}
}
复制代码
后来思考了一下,为了项目的稳定,仍是返回去用GCD来从新实现了。
后来测试的时候又发现了一个可能出现的问题,用户手机的时间可能不是准确的,或者通过的修改,跟服务器时间有很大的差距。这样就出现了一个很好笑的情况:8点开始的活动,由于手机自己时间的不许确,原本应该还有一个小时的时间,可是显示出来就只有40分钟了。这就很尴尬了。
为了解决这个问题,咱们将方法修改了一下:
在进入页面的时候,咱们要返回一个服务器时间,同时获取一个本地时间,计算出二者的差值,在计算倒计时的时候,把这个差值计算进去,以便保持时间的相对准确。同时,假如用户在本页面进入了后台模式又返回到前台模式,咱们经过一个接口接收当前的服务器时间,在进行以前的计算,假如两次的获得的时间差大体相等,咱们就不作处理;假如发现时间差发生了很大的变化(主要是为了防止用户修改系统时间),就强制刷新页面。
我阅读MrPeak的这篇文章,学习了另一个办法:
首先仍是会依赖于接口和服务器时间作同步,每次同步记录一个serverTime(Unix time),同时记录当前客户端的时间值lastSyncLocalTime,到以后算本地时间的时候先取curLocalTime,算出偏移量,再加上serverTime就得出时间了:
uint64_t realLocalTime = 0;
if (serverTime != 0 && lastSyncLocalTime != 0) {
realLocalTime = serverTime + (curLocalTime - lastSyncLocalTime);
}
else {
realLocalTime = [[NSDate date] timeIntervalSince1970]*1000;
}
复制代码
若是历来没和服务器时间同步过,就只能取本地的系统时间了,这种状况几乎也没什么影响,说明客户端还没开始用过。
关键在于若是获取本地的时间,能够用一个小技巧来获取系统当前运行了多长时间,用系统的运行时间来记录当前客户端的时间:
//get system uptime since last boot
- (NSTimeInterval)uptime
{
struct timeval boottime;
int mib[2] = {CTL_KERN, KERN_BOOTTIME};
size_t size = sizeof(boottime);
struct timeval now;
struct timezone tz;
gettimeofday(&now, &tz);
double uptime = -1;
if (sysctl(mib, 2, &boottime, &size, NULL, 0) != -1 && boottime.tv_sec != 0)
{
uptime = now.tv_sec - boottime.tv_sec;
uptime += (double)(now.tv_usec - boottime.tv_usec) / 1000000.0;
}
return uptime;
}
复制代码
gettimeofday和sysctl都会受系统时间影响,但他们两者作一个减法所得的值,就和系统时间无关了。这样就能够避免用户修改时间了。固然用户若是关机,过段时间再开机,会致使咱们获取到的时间慢与服务器时间,真实场景中,慢于服务器时间每每影响较小,咱们通常担忧的是客户端时间快于服务器时间。
这种方法原理上和个人差很少,可是请求次数会比个人少一些,可是缺点上文也说了:有可能会致使咱们获取到的时间慢与服务器时间。
用户在发送完验证码,而后误触退出页面再从新进入,不少app都是会从新刷新发送验证码的按钮,固然,出于保护机制,每每第二个验证码不会很快的发送过来。由于以前已经实现了一个倒计时的单例,我把这个页面的倒计时的终点时间,设置为倒计时的一个单例属性,在进入下一步。在从新进入这个页面的时候,进行上一条中作出的操做,进行判断。
深刻理解RunLoop
从NSTimer的失效性谈起(二):关于GCD Timer和libdispatch
iOS关于时间的处理