美文网首页
iOS 定时器(NSTimer、dispatch_source_

iOS 定时器(NSTimer、dispatch_source_

作者: 宇宙无敌大强子 | 来源:发表于2021-09-16 21:26 被阅读0次

    本文约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 <QuartzCore/QuartzCore.h>
    
    typedef void(^GQExecuteDisplayLinkBlock) (CADisplayLink *displayLink);
    
    @interface CADisplayLink (GQTool)
    
    @property (nonatomic,copy) GQExecuteDisplayLinkBlock executeBlock;
    
    + (CADisplayLink *)displayLinkWithExecuteBlock:(GQExecuteDisplayLinkBlock)block;
    
    @end
    
    // .m
    #import "CADisplayLink+GQTool.h"
    #import <objc/runtime.h>
    
    @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_

          本文链接:https://www.haomeiwen.com/subject/ikedgltx.html