iOS常用的定时器NSTimer, CADisplayLink, GCD, NSThread(performSelector:afterDelay:)

本文详细介绍了iOS中四种常用的定时器:NSTimer的基本使用、创建与释放,NSTimer的常见问题及其解决办法;GCD定时器的使用与注意事项;CADisplayLink的工作原理及优势;以及performSelector:afterDelay:的使用场景与取消机制。通过实例分析,帮助开发者深入理解并合理选择定时器。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

前言

在开发的过程中,我们经常会用到定时器,比如登录时的验证码重新发送的倒计时,商城类应用中支付倒计时等等,iOS为我们提供了多种定时器,包括NSTimer, CADisplayLink, GCD, NSThread(performSelector:afterDelay:),其本质都是通过RunLoop来实现的,本文是根据平常使用和其他的文档中内容,整理总结的,如果有错误和缺失的地方,欢迎大家批评指正。

NSTimer

NSTimer是最基本也是最常用的定时器,基本上在初学是都会有介绍,也是坑最多的,所以面试时也基本是必问的问题,所以介绍它的相关文章也比较多,我在这里抛砖引玉,欢迎大家补充。

创建

构造方法基本分成两种,自启动和手动启动的,手动启动的构造方法需要我们在创建后手动启动它

自动启动有两个方法:

+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti invocation:(NSInvocation *)invocation repeats:(BOOL)yesOrNo;
和
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo;

相对应的手动启动的方法也有个:

+ (NSTimer *)timerWithTimeInterval:(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;

注意:上面这两个方法中有两个方法需要使用invocation对象,invocation对象的创建方式是:

NSMethodSignature  *signature = [[self class] instanceMethodSignatureForSelector:@selector(timerSelector:)];
NSInvocation* invocation = [NSInvocation invocationWithMethodSignature:signature];
invocation.target = self;
invocation.selector = @selector(timerSelector:);
self.timer = [NSTimer scheduledTimerWithTimeInterval:1.f invocation:invocation repeats:YES];

此外,iOS10之后还提供了两种通过block方式调用方法的构造方法,同样是自动启动和手动启动两种各一个方法:

+ (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));

再有,有时我们需要使定时器延迟执行,针对这种需求,系统也提供了两个方法,一个是block方式调用,一个是@selector方式调用

- (instancetype)initWithFireDate:(NSDate *)date interval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0));
- (instancetype)initWithFireDate:(NSDate *)date interval:(NSTimeInterval)ti target:(id)t selector:(SEL)s userInfo:(nullable id)ui repeats:(BOOL)rep NS_DESIGNATED_INITIALIZER;

有时我们需要使有延时的定时器立即执行,则可是调用fire方法:

- (void)fire;

注意:fire方法不会改变预定周期性调度

什么意思呢,就是说如果我们把timer设置为循环调用,那么我们什么时候调用fire方法,下一次调度的时间仍旧是按照我们预定的时间,而非给予本次执行的时间计算而得,例如:我们设置延迟10秒执行,循环周期为5秒,在第8秒的时候我们调用了fire方法,则定时器仍然会在第10秒的时候执行调度,然后是15秒、20秒。。

还有一个需要注意的地方是,手动启动的定时器需要手动将timer add到runloop中,否则定时器不会启动:

self.timer = [NSTimer timerWithTimeInterval:1.f target:self selector:@selector(timerSelector:) userInfo:@{@"key":@"value"} repeats:YES];
// 将timer添加到NSRunLoopCommonModes中,可以防止页面滚动时定时器失效
[[NSRunLoop mainRunLoop] addTimer:self.timer forMode:NSRunLoopCommonModes];
释放

定时器的释放一定要先将其终止,而后才能销毁对象,否则可能会出现内存泄露

- (void)invalidate;
下面是一堆坑及解决办法
一、子线程启动定时器的问题

我们都知道iOS是通过runloop作为消息循环机制,主线程默认启动了tunloop,但是子线程并没有默认的runloop,所以在子线程中启动定时器是不生效的。

解决的方式很简单,在子线程启动一下runloop就可以了

dispatch_async(dispatch_get_global_queue(0, 0), ^{
    NSTimer* timer = [NSTimer timerWithTimeInterval:1 target:self selector:@selector(Timered:) userInfo:nil repeats:YES];
    [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
    [[NSRunLoop currentRunLoop] run];
});
二、runloop的mode问题

有些朋友发现,将timer添加到runloop的NSDefaultRunLoopMode之后,在拖动UIScrollView的时候,定时器会暂停执行,等拖动完成才会继续执行,出现这种问题的原因是UIScrollView滚动时执行的是UITrackingRunLoopMode。解决方法是将timer add到NSRunLoopCommonModes,因为UITrackingRunLoopMode和kCFRunLoopDefaultMode都被标记为了common模式,所以只需要将timer的模式设置为NSRunLoopCommonModes,就可以在默认模式和追踪模式都能够运行。

三、循环引用问题

前两个问题一般情况下是不会碰到的,但是循环引用问题,确是每个使用者都会碰到的。

如果想要测试一下自己代码中的NSTimer是否有循环引用问题,那就在自己的timer调用的方法中打印一下字符串,然后看一下控制台信息,如果在页面退出之后仍然在打印,也就是仍然在循环调用,就说明在页面退出之后timer并没有销毁,这就是出现了循环引用的标志性特点。

究其原因是在页面退出时,timer强引用了target,导致target无法自动销毁,而timer是target的属性,如果把销毁timer的方法写在- (void)dealloc 方法中,此时是不会调用的。

在这里首先声明一下:不是所有的NSTimer都会造成循环引用。就像不是所有的block都会造成循环引用一样。以下两种timer不会有循环引用:

  • 非repeat类型的。非repeat类型的定时器只执行一次,而且不会强引用target,因此不会出现循环引用。
  • block类型的。iOS10之后才出现的新的API,老版本app无法使用。当然,block内部的循环引用需要注意避免

解决办法:

至于解决办法,我在网上看到了很多方法,有封装timer的,有使用NSProxy方式的,都比较复杂,也比较难以理解,我自己使用的另一种方法,我自己觉得比较易用且容易理解,下方是代码:

- (void)viewWillDisappear:(BOOL)animated
{
    [super viewWillDisappear:animated];
    [self.timer invalidate];
    self.timer = nil;
}

- (void)viewWillAppear:(BOOL)animated
{
  	[super viewWillAppear:animated];
    if (!self.timer || !self.timer.valid) {
        self.timer = [NSTimer timerWithTimeInterval:1.f target:self selector:@selector(timerSelector:) userInfo:@{@"key":@"value"} repeats:YES];
        [[NSRunLoop mainRunLoop] addTimer:self.timer forMode:NSRunLoopCommonModes];
    }
}

其实很简单,就是在页面即将退出时,调用timer的销毁方法,然后设置为nil,然后在viewWillAppear中判断当前页面的timer如果不存在或者已被销毁,就重新创建。根据官方文档上说的,invalidate方法会将定时器从RunLoop中移除,同时解除对target等对象的强引用,所以在调用invalidate方法的时候,循环引用就已经被打破了,controller即target也就可以自动销毁了,

GCD

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

/** 创建定时器对象
 * @param: DISPATCH_SOURCE_TYPE_TIMER 为定时器类型
 * @param: 中间两个参数对定时器无用
 * @param: 最后是在那个线程中使用
 */
self.GCDTimer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_main_queue());
/** 设置定时器
 * @param: 第一个参数为定时器对象
 * @param: 第二个参数是调用开始时间
 * @param: 第三个参数为调用间隔时间
 * @param: 第四个参数为允许的误差,设置为0即不允许出现误差
 */
dispatch_source_set_timer(self.GCDTimer, dispatch_walltime(NULL, 0), 1 * NSEC_PER_SEC, 0);
/** 设置定时器调用
 * @param: 第一个参数为定时器对象
 * @param: 第二个参数是调用方法,可以使用block方式,也可以使用C函数方式
 */
dispatch_source_set_event_handler(self.GCDTimer, ^{
    [weakSelf timerSelector];
});
// 启动任务,GCD计时器创建后需要手动启动
dispatch_resume(self.GCDTimer);

// 定时器挂起
dispatch_suspend(self.GCDTimer);

// 定时器销毁
dispatch_source_cancel(self.GCDTimer);

需要注意的地方:

  • dispatch_source_t 一定要被设置为成员变量,否则将会立即被释放。
  • dispatch_source_set_timer()方法中的第二个参数,如果使用dispatch_time或者DISPATCH_TIME_NOW,系统会使用默认时钟来计时,然而当系统休眠的时候,默认时钟会停止,这就会导致计时器停止,而使用dispatch_walltime()方法可以让计时器按照真实时间间隔进行计时
  • dispatch_source_set_event_handler()中的任务实在子线程中执行的,若要更新UI,要调用dispatch_async(dispatch_get_main_queue(), ^{})回到主线程。
  • 暂停(dispatch_suspend)的timer,不能被释放的,会引起崩溃。
  • dispatch_suspend 和 dispatch_resume 应该是成对出现的。两者分别会减少和增加 dispatch 对象的暂停计数,但是没有 API 获取当前是暂停还是执行状态,所以需要自己记录。你调用了suspend(暂停)几次,你想resume(恢复)的话,就必须要remuse(恢复)几次,才能继续运行。所以建议控制器添加一个标识符,记录源是否处于暂停状态,在dealloc事件中判断当前源是否被暂停,如果被暂停,则resume,即可解决内存泄漏问题。
  • 如果暂停的代码加到 dispatch_source_set_event_handler 的 block 中,并不会发生崩溃,但是这个时候页面会无法释放造成内存泄漏。
  • GCD的计时效应仍不是百分之百准确的。另外,他的触发事件也有可能被阻塞,当GCD内部管理的所有线程都被占用时,其触发事件将被延迟。

可能会引起崩溃的操作

  • 在remuse(恢复)的状态下,如果再进行一次resume(恢复)就会crash,所以要注册一个BOOL值的状态进行记录,防止多次suspend和resume引起闪退。
  • 在suspend(暂停)的状态下,如果你设置timer = nil就会crash。
  • 在suspend(暂停)的状态下,即使调用dispatch_source_cancel也没用,会造成内存泄漏,甚至崩溃。

CADisplayLink

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

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

// 创建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则是利用了这一特性。

performSelector:afterDelay

这种方式通常是用于在延时后去处理一些操作,其内部也是基于将timer加到runloop中实现的。因此也存在NSTimer的关于子线程runloop的问题。

[self performSelector:@selector(timerSelector:) withObject:nil afterDelay:2.f];

这种调用方式的好处是可以取消。

[NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(timerSelector:) object:nil];

注意以上是没有参数的。当有参数的时候必须保证两个方法的参数一样。否则无法取消。

[NSObject cancelPreviousPerformRequestsWithTarget:self];//取消所有的performSelector:方法
[[self class] cancelPreviousPerformRequestsWithTarget:self];//取消本类中的performSelector:方法
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值