iOS 定时器(NSTimer、dispatch_source_t和CADisplayLink)

本文约100行代码,读完大概用时5-10分钟,理解的话看个人知识掌握程度。

App在开发的过程中,经常会遇到倒计时等等与时间计算有关的需求,这时就需要我们去使用定时器了,本篇我们就来盘点盘点iOS中的三大定时器:NSTimer、dispatch_source_t和CADisplayLink。

一、NSTimer

1.NSTimer的介绍

NSTimer应该是新手最耳熟能详的定时器了,通过Apple开发文档的描述 A timer that fires after a certain time interval has elapsed, sending a specified message to a target object. 我们可以看到它是通过间隔一定的时间,向目标对象发送指定的消息(OC中调用方法在底层就是发送消息)来实现定时器的功能的。NSTimer在使用的过程中其实是有很多小细节需要注意的,下面都会讲到。

2.NSTimer的方法

NSTimer有3个timerWith类方法(初始化):

/// @param ti 定时器的时间间隔
/// @param invocation 方法调用
/// @param yesOrNo 是否重复执行
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti invocation:(NSInvocation *)invocation repeats:(BOOL)yesOrNo;
/// @param ti 定时器的时间间隔
/// @param aTarget 目标对象(一般是self)
/// @param aSelector 方法调用
/// @param yesOrNo 是否重复执行
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo;
/// @param interval 定时器的时间间隔
/// @param repeats 是否重复执行
/// @param block 方法调用(代码块的形式)
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block;

这3种类方法需要你手动将timer对象添加到runloop中;

// 在主runloop上添加
[[NSRunLoop currentRunLoop] addTimer:_timer forMode:NSDefaultRunLoopMode];

和3个scheduledTimer类方法(初始化):

/// @param ti 定时器的时间间隔
/// @param invocation 方法调用
/// @param yesOrNo 是否重复执行
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti invocation:(NSInvocation *)invocation repeats:(BOOL)yesOrNo;
/// @param ti 定时器的时间间隔
/// @param aTarget 目标对象(一般是self)
/// @param aSelector 方法调用
/// @param yesOrNo 是否重复执行
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo;
/// @param interval 定时器的时间间隔
/// @param repeats 是否重复执行
/// @param block 方法调用(代码块的形式)
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block;

这3种类方法需要会自动将timer对象添加到当前runloop(默认是主runloop)中,并且mode为NSDefaultRunLoopMode;

以及2个initWith实例方法(初始化):

/// 需要手动将timer对象添加到runloop中
/// @param date 开始执行的日期
/// @param interval 定时器的时间间隔
/// @param repeats 是否重复执行
/// @param block 方法调用(代码块的形式)
- (instancetype)initWithFireDate:(NSDate *)date interval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block;
/// 需要手动将timer对象添加到runloop中
/// @param date 开始执行的日期
/// @param ti 定时器的时间间隔
/// @param t 目标对象(一般是self)
/// @param s 方法调用
/// @param ui 可自定义的参数
/// @param rep 是否重复执行
- (instancetype)initWithFireDate:(NSDate *)date interval:(NSTimeInterval)ti target:(id)t selector:(SEL)s userInfo:(nullable id)ui repeats:(BOOL)rep;

以上8个初始化方法,scheduledTimer方法会自动添加timer到当前runloop,其他则不会

8个初始化方法的区别图示

使用timerWithinitWith 时需要手动添加timer到runloop

2个常用的实例方法:

// 开始执行
- (void)fire;
// 销毁
- (void)invalidate;

fire会立即调用方法,在执行完后,如果不是重复的timer,会立即 invalidate ;
invalidate会停止重复的timer(不重复的执行一次后会自动invalidate),并将其从runloop中remove。

3.NSTimer的使用示例

3.1 在一个普通的VC中使用:

@property (nonatomic, strong) NSTimer *timer; //作为属性一般使用strong修饰,因为timer是一个对象,需要被持有者强引用以防提前释放

// 这里使用weakSelf来避免循环引用从而导致内存泄漏
__weak typeof(self) weakSelf = self;
_timer = [NSTimer scheduledTimerWithTimeInterval:1 repeats:YES block:^(NSTimer * _Nonnull timer) {
   [weakSelf changeLabelText];
}];

值得注意的是,当我们使用 target: selector: 的方式时,target后面使用weakSelf 并不能避免循环引用,此时timer依然会对self进行强引用,会导致内存泄漏,下面的代码是错误的:

_timer = [NSTimer scheduledTimerWithTimeInterval:1 target:weakSelf selector:@selector(changeLabelText) userInfo:nil repeats:YES];

如果此时页面上有scrollView或者tableView等在滑动时,需要手动更改timer的mode:

/* 当scrollView滚动的时候,当前的 MainRunLoop 会处于 UITrackingRunLoopMode 的模式下,
在这个模式下,是不会处理 NSDefaultRunLoopMode 的任务的 */
[[NSRunLoop mainRunLoop] addTimer:_timer forMode:NSRunLoopCommonModes];

3.2 立即执行timer的方法调用:

// 执行fire方法后,会立即执行本来需要时间间隔后才执行的指定方法调用
// 对于重复的timer,它是一次额外的操作,并且不会打破正常的schedule
// 对于不重复的timer,它一触发完后,timer就被invalidate,就不管原来设定的时间间隔了
[_timer fire];

3.3 timer的释放与销毁:

if ([_timer isValid]) {
    [_timer invalidate]; //对于不重复的timer可以不写,因为不重复的timer在执行完后就自动invalidate了
    _timer = nil;
}

PS:看到很多文章说timer的销毁不能放在dealloc中,要放在- (void)viewDidDisappear:(BOOL)animated中,因为在dealloc中并不会去执行。这种说法一般是不对的,首先,dealloc中不会去执行大概率是出现了循环引用,此时VC仍然被timer强引用,导致VC没法dealloc,那么timer当然不会去执行销毁;其次,viewDidDisappear时去销毁那么你在跳往下一级页面而不是返回上一级页面的时候,此时当前页面一般是要继续存在的,这么做就将当前页面的timer销毁了,肯定是不对的,正确的做法是使用上面timer的block API + weakSelf来避免循环使用。

4.NSTimer的更多使用技巧

4.1 暂停和启动:

// 让timer的fire时间为“遥远的未来”,那么它就“暂停”了
[_timer setFireDate:[NSDate distantFuture]];

// 让timer的fire时间为“马上”或“远古”,那么它就启动了
[self.timer setFireDate:[NSDate date]];
[self.timer setFireDate:[NSDate distantPast]];

4.2 非固定时间间隔执行timer

- (void)randomTimeTimer {
    // 此处先将timer的timeInterval设置为无穷大,这样它便不会执行
    _timer =  [NSTimer timerWithTimeInterval:MAXFLOAT target:self selector:@selector(randomTimeFireMethod) userInfo:nil repeats:YES];
    [[NSRunLoop mainRunLoop] addTimer: _timer forMode:NSDefaultRunLoopMode];
}

- (void)randomTimeFireMethod {
    static int timeExecute = 0;
    
    // 这里的4是你想要timer执行的次数
    if (timeExecute < 4) {
        // 不定长执行
        NSTimeInterval timeInterval = [self.randomTime[timeExecute] doubleValue];
        timeExecute++;
        // 使用fireDate来控制timer以达到不定长执行
        _timer.fireDate = [NSDate dateWithTimeIntervalSinceNow:timeInterval];
    }
}

OC中并没有NSTimer的暂停、启动和非固定时间间隔的方法,我们可以使用这种奇思妙想来达到这个目的。

4.3 在子线程中使用NSTimer

5.NSTimer的注意事项

5.1 为什么NSTimer的销毁需要invalidate + =nil
在OC中,一般我们销毁强引用,会直接使用 =nil ,但是NSTimer不可以。我们来看看ARC中的NSTimer创建到销毁的过程中具体的引用关系变化:

  • VC创建NSTimer后,此时VC对timer强引用,再之后timer加入到runloop后,系统也会强引用timer


    NSTimer的创建过程
  • =nil 后,VC解除了对timer的强引用,但此时系统依然对timer有强引用
    =nil
  • 调用 invalidate 方法后,系统解除对timer的强引用
    invalidate后

    综合以上,我们需要对timer invalidate + =nil ,才能真正的销毁NSTimer。

5.2 NSTimer不是实时的 / NSTimer可以设置Tolerance(容忍度)。

  • NSTimer加入的runloop正好处在一个耗时的周期内;
  • NSTimer添加的runloopMode不是当前runloop所处的mode时,如NSDefaultRunLoopMode的NSTimer在页面滑动时暂停;
  • Tolerance大概是避免NSTimer在runloop中的不实时带来的时间偏移的(实际开发中使用较少,暂时没怎么研究)。

二、dispatch_source_t

1. dispatch_source_t的介绍

dispatch_source_t 是众多DISPATCH_SOURCE种类的一种
针对NSTimer受runloop的影响而不精准的问题,dispatch_source_t是一种相对精准的计时器,并且它天生就可以使用GCD在子线程中执行,解决NSTimer在主线程中导致卡顿的问题,但是它的缺点也比较明显,就是代码量相对比较多一点。

2. dispatch_source_t的使用

定义属性:

// 此处也用strong修饰,虽然没有 * ,但是dispatch_source_t也是对象,和普通的对象一样,strong防止提前释放
@property (nonatomic, strong) dispatch_source_t timer;

初始化和设定:

- (void)initTimer {
    if (!_gcdTimer) {
        // 创建队列
        dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
        // 初始化timer(设定source_type,以及队列)
        _gcdTimer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue);
        // 设定timer的开始时间
        dispatch_time_t start = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0 * NSEC_PER_SEC));
        // 如果timer的间隔时间比较大,那么可以使用dispatch_walltime来创建start,可以避免误差
        dispatch_time_t start_0 = dispatch_walltime(0, 0);
        // 设定timer的固定时间间隔
        uint64_t interval = (uint64_t)(1 * NSEC_PER_SEC);
        // 设置timer,最后一个参数为leeway,是用来设置定时器的“期望精度值”,系统会根据这个值延迟或提前触发定时器
        dispatch_source_set_timer(_gcdTimer, start, interval, 0);
        // 设定timer的方法调用
        dispatch_source_set_event_handler(_gcdTimer, ^{
            // 如果timer的方法调用是UI方面相关的操作,需要在主线程中执行(线程间通信)
            dispatch_async(dispatch_get_main_queue(), ^{
                [self changeLabelText];
            });
        });
        // 开启定时器
        dispatch_resume(_gcdTimer);
    }
}

dispatch_source_create方法参数详细说明

  • 第一个参数:dispatch_source_type_t type为设置GCD源方法的类型,前面已经列举过了。
  • 第二个参数:uintptr_t handle Apple的API介绍说,暂时没有使用,传0即可。
  • 第三个参数:unsigned long mask Apple的API介绍说,使用DISPATCH_TIMER_STRICT,会引起电量消耗加剧,毕竟要求精确时间,所以一般传0即可,视业务情况而定。
  • 第四个参数:dispatch_queue_t _Nullable queue 队列,将定时器事件处理的Block提交到哪个队列,可以传Null,默认为全局队列。

开启定时器:

dispatch_resume(_gcdTimer);

暂停定时器:

// 暂停
- (void)pauseTimer {
    if (_gcdTimer) {
        dispatch_suspend(_gcdTimer);
    }
}

// 暂停后的重新开启
dispatch_resume(_gcdTimer);

销毁定时器:

dispatch_source_cancel(_gcdTimer);
_gcdTimer = nil;
3. dispatch_source_t的注意事项

timer被dispatch_suspend后是不能释放的,否则会引起崩溃。因为OC中并没有dispatch_source_t的暂停和开启状态的记录,所以如果我们用到了它的暂停和开启,则我们必须手动记录,有dispatch_suspend则必有dispatch_resume

4. dispatch_source_t的优缺点

优点:

  • 性能更好,相对更精确;
  • 自带暂停、继续;
  • 天生适合在子线程中使用;
  • 不需要加入到runloop中,也不需要管runloop的mode。

缺点:

  • 每次dispatch_resume都会先执行一次;
  • 本质上也不是完全精确;
  • 代码量较多。

三、CADisplayLink

1. CADisplayLink的介绍

CADisplayLink是OC中精度最高的定时器,它是根据设备的屏幕刷新频率来执行操作,因此它的使用场景也相对当一,比较适合用来做UI的绘制、自定义的动画引擎以及视频播放的渲染。

2. CADisplayLink的方法和相关属性

1个初始化类方法:

/// @param target 目标对象(一般是self)
/// @param sel 方法调用
+ (CADisplayLink *)displayLinkWithTarget:(id)target selector:(SEL)sel;

3个实例方法

/// 将CADisplayLink对象添加到runloop中并指定mode
/// @param runloop 加入的runloop
/// @param mode 指定runloopMode
- (void)addToRunLoop:(NSRunLoop *)runloop forMode:(NSRunLoopMode)mode;
/// 将CADisplayLink对象从runloop指定的mode中移除
/// @param runloop 被移除CADisplayLink对象的runloop
/// @param mode 指定的runloopMode
- (void)removeFromRunLoop:(NSRunLoop *)runloop forMode:(NSRunLoopMode)mode;
// 将CADisplayLink对象从runloop所有mode中移除
- (void)invalidate;

两个移除方法的区别
removeFromRunLoop会将其从指定的runloop的指定mode中移除,此方法在runloop或者mode任一不匹配的情况下都无效,而且remove时需要进行判断,如果指定的mode中不存在,那么将会引起crash,原因是重复over-release
invalidate是从runloop的所有模式中移除,并取消和target的关联关系,此方法可以多次调用,不会引起crash。

3个只读属性:

// 当前屏幕上显示帧率的时间戳
@property(readonly, nonatomic) CFTimeInterval timestamp;
// 定时器的时间间隔
@property(readonly, nonatomic) CFTimeInterval duration;
// 客户端针对其渲染的下一个时间戳
@property(readonly, nonatomic) CFTimeInterval targetTimestamp;

3个读写属性:

// 是否暂停,设置了暂停后定时器将暂停,直到设置为false的时候再执行
@property(getter=isPaused, nonatomic) BOOL paused;
// 从iOS10开始已废弃,不要去使用
@property(nonatomic) NSInteger frameInterval;
// 每秒刷新次数(帧率)
@property(nonatomic) NSInteger preferredFramesPerSecond;
3. CADisplayLink使用示例
// 创建
- (void)initDisplaylink {
    _displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(changeLabelText)];
    _displayLink.preferredFramesPerSecond = 0; //每秒刷新次数,设置为0时就是默认屏幕的最大刷新帧率
    [_displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSDefaultRunLoopMode];
}
// 销毁
[_displayLink invalidate];
_displayLink = nil;
4. CADisplayLink的特性
  • CADisplayLink不能够继承
  • 修改帧率
    CADisplayLink的实际帧率是由屏幕最大帧率(maximumFramesPerSecond)和参数preferredFramesPerSecond一起决定的,规则为:如果屏幕最大帧率是60,实际帧率只能是15、20、30、60中的一种;如果设置大于60的值,屏幕实际帧率为60。如果设置的是26~35之间的值,实际帧率是30;如果设置为0,会使用最高帧率。
  • 在添加进runloop时应当选择高优先级的,以保证动画的流畅
5. CADisplayLink防止循环引用

上面NSTimer在防止循环引用时使用了NSTimer本身提供的block方法而非传入target的方式,但是CADisplayLink本身没有提供block方法,只有传入target的方式,那么我们怎么避免循环引用呢?
首先我们来看一种错误的做法:

__weak typeof(self) weakSelf = self;
_displayLink = [CADisplayLink displayLinkWithTarget:weakSelf selector:@selector(changeLabelText:)];
_displayLink.preferredFramesPerSecond = 10;
[_displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSDefaultRunLoopMode];

然后再看另一种错误的做法:

// 将displayLink属性声明为weak
@property (nonatomic, weak) CADisplayLink *displayLink;

// 初始化
CADisplayLink *temp = [CADisplayLink displayLinkWithTarget:self selector:@selector(changeLabelText:)];
temp.preferredFramesPerSecond = 10;
[temp addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSDefaultRunLoopMode];
_displayLink = temp;

以上两种方法都是错误的,在页面销毁时,它们无一例外的定时器都没有被销毁,依然在工作,原因在于此时runloop对定时器依然有强引用。

此时正确的做法有两种:
1.使用NSProxy
创建一个继承自NSProxy的新类GQProxy

// .h
@interface GQProxy : NSProxy

@property (weak, nonatomic) id target;

+ (instancetype)proxyWithTarget:(id)target;

@end
// .m
#import "GQProxy.h"

@implementation GQProxy

+ (instancetype)proxyWithTarget:(id)target {
    GQProxy *proxy = [GQProxy alloc];
    proxy.target = target;
    return proxy;
}

//返回方法签名
-(NSMethodSignature*)methodSignatureForSelector:(SEL)sel{
    
    return [self.target methodSignatureForSelector:sel];
}

-(void)forwardInvocation:(NSInvocation *)invocation{
    
    [invocation invokeWithTarget:self.target];
}

@end

在初始化定时器时

_displayLink = [CADisplayLink displayLinkWithTarget:[GQProxy proxyWithTarget:self] selector:@selector(changeLabelText:)];
_displayLink.preferredFramesPerSecond = 10; 
[_displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSDefaultRunLoopMode];

这样就可以在避免循环引用了,推荐NSTimer也使用这种方法

2.使用category扩展block方法
新建一个分类 CADisplayLink+GQTool

// .h
#import 

typedef void(^GQExecuteDisplayLinkBlock) (CADisplayLink *displayLink);

@interface CADisplayLink (GQTool)

@property (nonatomic,copy) GQExecuteDisplayLinkBlock executeBlock;

+ (CADisplayLink *)displayLinkWithExecuteBlock:(GQExecuteDisplayLinkBlock)block;

@end
// .m
#import "CADisplayLink+GQTool.h"
#import 

@implementation CADisplayLink (GQTool)

- (void)setExecuteBlock:(GQExecuteDisplayLinkBlock)executeBlock{

    objc_setAssociatedObject(self, @selector(executeBlock), [executeBlock copy], OBJC_ASSOCIATION_COPY_NONATOMIC);
}

- (GQExecuteDisplayLinkBlock)executeBlock{

    return objc_getAssociatedObject(self, @selector(executeBlock));
}

+ (CADisplayLink *)displayLinkWithExecuteBlock:(GQExecuteDisplayLinkBlock)block{

    CADisplayLink *displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(gq_executeDisplayLink:)];
    displayLink.executeBlock = [block copy];
    return displayLink;
}

+ (void)gq_executeDisplayLink:(CADisplayLink *)displayLink{

    if (displayLink.executeBlock) {
        displayLink.executeBlock(displayLink);
    }
}

@end

在初始化定时器时

__weak typeof(self) weakSelf = self;
_displayLink = [CADisplayLink displayLinkWithExecuteBlock:^(CADisplayLink * _Nonnull displayLink) {
    [weakSelf changeLabelText];
}];
[_displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSDefaultRunLoopMode];

这样也可以避免定时器对VC的强引用,但本质上只是将定时器的target从控制器换成了定时器本身的类,还是存在循环引用,只不过对我们的系统没有影响了。所以推荐使用NSProxy这种方法

以上就是关于iOS中三种定时器的详细介绍,原创不易,如果您觉得这篇文章对您有用的话,就顺手点个赞+关注吧。

你可能感兴趣的:(iOS 定时器(NSTimer、dispatch_source_t和CADisplayLink))