当前位置: 首页 > 工具软件 > Timer > 使用案例 >

iOS开发之三大计时器(Timer、DispatchSourceTimer、CADisplayLink)

柴凌
2023-12-01

1. 概述

说起计时器,很多开发人员第一时间就会想起Timer,但是随着使用的深入,慢慢就发现Timer不是很精确,随后就有想到GCD Timer,专业点就是DispatchSourceTimer,除了这两个还有一个,那就是CADisplayLink,没错,这些都可以用于定时器使用。

本篇文章就对这三种定时器进行阐述,讲解其用法、注意事项,以及利弊。

首先强调一下,本篇文章的所有API以及代码全部基于Swift语言。

文中如果不对的地方,还请指正!

2. Timer

Timer,我们先看看官方的定义:

A timer that fires after a certain time interval has elapsed, sending a specified message to a target object.

一个计时器,在经过一定的时间间隔后触发,向目标对象发送指定的消息。

之前的文章(iOS Runloop探索(快速了解Runloop))中讲到过,Timer与Runloop是息息相关的,没有Runloop,Timer根本无法运行,创建一个Timer对象后,需要显式或隐式的将其加入到指定Runloop的mode里面,然后等待Runloop循环调用。

另外,Timer不是采用实时机制,

下面列表中的方法都是Timer的初始化方法,

方法名称描述
class func scheduledTimer(withTimeInterval: TimeInterval, repeats: Bool, block: (Timer) -> Void) -> Timer创建一个Timer,并自动将其加入到当前Runloop的默认模式下。
class func scheduledTimer(timeInterval: TimeInterval, target: Any, selector: Selector, userInfo: Any?, repeats: Bool) -> Timer创建一个Timer,并自动将其加入到当前Runloop的默认模式下。
class func scheduledTimer(timeInterval: TimeInterval, invocation: NSInvocation, repeats: Bool) -> Timer创建一个Timer,并自动将其加入到当前Runloop的默认模式下。
init(timeInterval: TimeInterval, repeats: Bool, block: (Timer) -> Void)初始化一个Timer,需要手动调用add(_:forMode:) 方法将其加入到指定的Runloop的模式下。
init(timeInterval: TimeInterval, invocation: NSInvocation, repeats: Bool)初始化一个Timer,需要手动调用add(_:forMode:) 方法将其加入到指定的Runloop的模式下。
init(timeInterval: TimeInterval, target: Any, selector: Selector, userInfo: Any?, repeats: Bool)初始化一个Timer,需要手动调用add(_:forMode:) 方法将其加入到指定的Runloop的模式下。
init(fire: Date, interval: TimeInterval, repeats: Bool, block: (Timer) -> Void)初始化一个在指定时间触发的Timer,需要手动调用add(_:forMode:) 方法将其加入到指定的Runloop的模式下。
init(fireAt: Date, interval: TimeInterval, target: Any, selector: Selector, userInfo: Any?, repeats: Bool)初始化一个在指定时间触发的Timer,需要手动调用add(_:forMode:) 方法将其加入到指定的Runloop的模式下。

上表中,前三个类方法在创建完Timer对象后,会将其添加到当前的Runloop的默认Mode里面,而后面的几个init方法则需要手动调用add(_:forMode:) 方法将其加入到指定的Runloop的Mode里面。

Timer的运行离不开线程,下面将就主线程和子线程中的Timer进行讲解。

2.1 主线程中的Timer

主线程在程序启动的时候就已经开启了,与主线程关联的Runloop也已经开启了,所以说在主线程中用Timer还是比较容易的,但是主线程毕竟任务繁多,可能会影响到Timer的执行效率。

override func viewDidLoad() {
        super.viewDidLoad()

        Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { (timer) in
            print("我默认已经加入到Runloop中了")
        }
        
        let timer2 = Timer(timeInterval: 1, repeats: true) { (timer) in
            print("我需要手动加入到Runloop中")
        }
        RunLoop.current.add(timer2, forMode: .default)
}

上面的两种初始化方法是没问题的,timer都能正常的运行。如果将这段代码放到某个二级界面(由一级界面push或者present过来的),那么就有问题了,有什么问题呢?看一下输出结果:

我默认已经加入到Runloop中了
我需要手动加入到Runloop中
我默认已经加入到Runloop中了
我需要手动加入到Runloop中
deinit
我默认已经加入到Runloop中了
我需要手动加入到Runloop中
我默认已经加入到Runloop中了
我需要手动加入到Runloop中

二级界面都deinit了,timer还在运行,是谁持有了这两个timer呢?苹果官方有这样一段话:

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.

意思就是Runloop会强引用加入到其中的Timer,所以我们需要在二级界面销毁的时候干掉Runloop中的timer,代码改为:

    var timer1: Timer?
    var timer2: Timer?
    
    deinit {
        print("deinit")
        timer1?.invalidate()
        timer2?.invalidate()
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        timer1 = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { (timer) in
            print("我默认已经加入到Runloop中了")
            
        }
        timer2 = Timer(timeInterval: 1, repeats: true) { (timer) in
            print("我需要手动加入到Runloop中")
        }
        RunLoop.current.add(timer2!, forMode: .default)
    }

invalidate()方法将会从Runloop中移除Timer,并停止timer的运行。

2.2 子线程中的Timer

子线程中的Runloop默认是不开启的,而且在子线程中Runloop是懒加载的,只有第一次获取的时候才会创建。

override func viewDidLoad() {
        super.viewDidLoad()
        DispatchQueue.global().async {
            Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { (timer) in
                print("我默认已经加入到Runloop中了")
            }
            
            let timer2 = Timer(timeInterval: 1, repeats: true) { (timer) in
                print("我需要手动加入到Runloop中")
            }
            RunLoop.current.add(timer2, forMode: .default)
            
            // 子线程中的Runloop默认是没有开启的,需要调用run()方法开启。
            RunLoop.current.run()
        }
}

同样的,如果在二级界面执行上面代码,同样会出现问题的,界面销毁了,而Timer还在运行,原理同上。

2.3 Timer与Runloop Mode

在讲Timer加入到Runloop的时候,需要额外注意指定的Mode,有的界面设计到滚动或者一些控件的拖动,此时有可能会导致Timer停止运行。

名称描述
static let common: RunLoop.Mode使用该值作为模式被添加到Runloop的对象,将会被Runloop的所有的模式监控,也就是该模式包含了default和tracking两个模式。
static let `default`: RunLoop.Mode处理输入源的模式,Runloop常用的模式,App的默认Mode,通常主线程是在这个Mode下运行。
static let tracking: RunLoop.Mode在跟踪控件的时候使用该模式。界面跟踪 Mode,用于 ScrollView 追踪触摸滑动,保证界面滑动时不受其他 Mode 影响。

比如界面有ScrollView,如果将Timer添加到default模式下,那么在界面滚动的时候,Timer将会停止。

如果将Timer添加到tracking模式下,那么只有在界面滚动的时候,Timer才会运行,滚动停止,Timer停止。

开发中,除了特殊的需求,一般都将Timer添加到common模式下,以保证Timer正常运行。

2.4 Timer的循环引用

循环引用,你持有我,我持有你,谁也不放了谁,谁也都释放不了。

Timer如果用的不小心,很可能就导致了循环引用。

    var timer1: Timer?
    var timer2: Timer?
    
    deinit {
        print("deinit")
        timer1?.invalidate()
        timer2?.invalidate()
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        timer1 = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] (timer) in
            self?.saySomething1()
            
        }
        timer2 = Timer(timeInterval: 1, repeats: true) {  [weak self]  (timer) in
            self?.saySomething2()
        }
        RunLoop.current.add(timer2!, forMode: .default)
    }
    
    func saySomething1() {
        print("我默认已经加入到Runloop中了")
    }
    
    func saySomething2() {
        print("我需要手动加入到Runloop中")
    }

上面代码中,如果没有在Timer的闭包中加上[weak self],那么肯定会发生循环引用的。

3. GCD Timer(DispatchSourceTimer)

DispatchSourceTimer,也就是大家通常叫的GCD Timer,是依赖于GCD的一种Timer,Runloop的底层代码中也用到这种Timer,可见GCD Timer并不依赖与Runloop。

先看一下苹果的定义:

A dispatch source that submits the event handler block based on a timer.

3.1 GCD Timer 创建

使用下面的方法即可创建一个DispatchSourceTimer对象。

    class func makeTimerSource(flags: DispatchSource.TimerFlags = [], queue: DispatchQueue? = nil) -> DispatchSourceTimer
    // 默认在主队列中调度使用
    let timer = DispatchSource.makeTimerSource()
    // 指定在主队列中调度使用
    let timer = DispatchSource.makeTimerSource(flags: [], queue: DispatchQueue.main)
    // 指定在全局队列中调度使用
    let timer = DispatchSource.makeTimerSource(flags: [], queue: DispatchQueue.global())
    // 指定在自定义队列中调度使用
    let customQueue = DispatchQueue(label: "customQueue")
    let timer = DispatchSource.makeTimerSource(flags: [], queue: customQueue)

3.2 GCD Timer 配置

配置Timer参数,需要使用DispatchSourceTimer协议的方法。可以安排一次或多次触发的Timer。Timer每次触发的时候,都会调用已部署的任务。

MethodsDescription
func schedule(deadline: DispatchTime, repeating: DispatchTimeInterval, leeway: DispatchTimeInterval)Schedules a timer with the specified deadline, repeat interval, and leeway values.
func schedule(deadline: DispatchTime, repeating: Double, leeway: DispatchTimeInterval)Schedules a timer with the specified deadline, repeat interval, and leeway values.
func schedule(wallDeadline: DispatchWallTime, repeating: DispatchTimeInterval, leeway: DispatchTimeInterval)Schedules a timer with the specified time, repeat interval, and leeway values.
func schedule(wallDeadline: DispatchWallTime, repeating: Double, leeway: DispatchTimeInterval)Schedules a timer with the specified time, repeat interval, and leeway values.

调用上面的方法可配置Timer的开始时间,重复执行时间,以及允许的偏差量。

        // 从现在开始,每秒执行一次。
        timer?.schedule(deadline: DispatchTime.now(), repeating: .seconds(1), leeway: .nanoseconds(1))
        // 5秒之后执行任务,不重复。
        timer?.schedule(deadline: DispatchTime.now() + 5, repeating: .never, leeway: .nanoseconds(1))

3.3 GCD Timer 部署任务

当Timer配置完参数后,使用DispatchSourceProtocol协议的方法来部署要执行的任务。

MethodsDescription
func setEventHandler(handler: DispatchWorkItem)Sets the event handler work item for the dispatch source.
func setEventHandler(qos: DispatchQoS, flags: DispatchWorkItemFlags, handler: Self.DispatchSourceHandler?)Sets the event handler work item for the dispatch source.
func setRegistrationHandler(handler: DispatchWorkItem)Sets the registration handler work item for the dispatch source.
func setRegistrationHandler(qos: DispatchQoS, flags: DispatchWorkItemFlags, handler: Self.DispatchSourceHandler?)Sets the registration handler work item for the dispatch source.
func setCancelHandler(handler: DispatchWorkItem)Sets the cancellation handler block for the dispatch source.
func setCancelHandler(qos: DispatchQoS, flags: DispatchWorkItemFlags, handler: Self.DispatchSourceHandler?)Sets the cancellation handler block for the dispatch source with the specified quality-of-service class and work item options.

上表是苹果官方的描述,下面看一下setEventHandlersetRegistrationHandler的区别:

  • setEventHandler:给Timer设置要执行的任务,包括一次性任务和定时重复的任务。回调方法在子线程中执行。
  • setRegistrationHandler:这个方法设置的任务只会执行一次,也就是在Timer就绪后开始运行的时候执行,类似于Timer开始的一个通知回调。回调方法在子线程中执行。

例如下面的代码:

    var timer: DispatchSourceTimer?

    func initTimer() {
        // 默认在主队列中调度使用
        timer = DispatchSource.makeTimerSource()
        
        // 从现在开始,每秒执行一次。
        timer?.schedule(deadline: DispatchTime.now(), repeating: .seconds(1), leeway: .nanoseconds(1))
        // 5秒之后执行任务,不重复。
//        timer?.schedule(deadline: DispatchTime.now() + 5, repeating: .never, leeway: .nanoseconds(1))
        
        timer?.setEventHandler {
            DispatchQueue.main.async {
                print("执行任务")
            }
        }
        
        timer?.setRegistrationHandler(handler: {
            DispatchQueue.main.async {
                print("Timer开始工作了")
            }
        })
        timer?.activate()
    }

执行结果如下:

2020-11-28 02:20:00 +0000 Timer开始工作了
2020-11-28 02:20:00 +0000 执行任务
2020-11-28 02:20:01 +0000 执行任务
2020-11-28 02:20:02 +0000 执行任务

3.4 GCD Timer控制方法

下面看一下Timer的一下控制方法及状态:

MethodsDescription
func activate()

Activates the dispatch source.

func suspend()Suspends the dispatch source.
func resume()Resumes the dispatch source
func cancel()Asynchronously cancels the dispatch source, preventing any further invocation of its event handler block.
var isCancelled: BoolReturns a Boolean indicating whether the given dispatch source has been canceled.

方法详解:

  • activate() : 当创建完一个Timer之后,其处于未激活的状态,所以要执行Timer,需要调用该方法。
  • suspend() : 当Timer开始运行后,调用该方法便会将Timer挂起,即暂停。
  • resume() : 当Timer被挂起后,调用该方法便会将Timer继续运行。
  • cancel() : 调用该方法后,Timer将会被取消,被取消的Timer如果想再执行任务,则需要重新创建。

上面的这些方法如果使用不当,很容易造成APP崩溃,下面来看一下具体注意事项及建议:

  1. 当Timer创建完后,建议调用activate()方法开始运行。如果直接调用resume()也可以开始运行。
  2. suspend()的时候,并不会停止当前正在执行的event事件,而是会停止下一次event事件。
  3. 当Timer处于suspend的状态时,如果销毁Timer或其所属的控制器,会导致APP奔溃。
  4. suspend()和resume()需要成对出现,挂起一次,恢复一次,如果Timer开始运行后,在没有suspend的时候,直接调用resume(),会导致APP崩溃。
  5. 使用cancel()的时候,如果Timer处于suspend状态,APP崩溃。
  6. 另外需要注意block的循环引用问题。

4. CADisplayLink

A timer object that allows your application to synchronize its drawing to the refresh rate of the display.

CADisplayLink是一个定时器,允许你的应用以和屏幕刷新率相同的频率将内容显示到屏幕上。

在应用中创建一个新的 CADisplayLink 对象,并给它提供一个 targetselector,当屏幕刷新的时候会调用。为了同步显示内容,需要使用add(to:forMode:) 方法把它添加到runloop中。

一但 CADisplayLink 以指定的模式添加到Runloop之后,每当屏幕需要刷新的时候,Runloop就会调用CADisplayLink绑定的target上的selector,这时target可以访问到 CADisplayLink 的每次调用的时间戳,该时间戳是上一帧显示的时间,这样可以用来准备下一帧显示需要的数据。

4.1 CADisplayLink常用方法

MethodsDescription
init(target: Any, selector: Selector)

display link的构造方法,需要指定target和selector。selector方法中可带displaylink参数,方法示例如下:

func selector(displaylink: CADisplayLink)

func add(to: RunLoop, forMode: RunLoop.Mode)将display link加入指定的Runloop的Mode里。
func remove(from: RunLoop, forMode: RunLoop.Mode)

将display link从指定的Runloop的Mode里移除。

func invalidate()

将display link从Runloop的所有Mode中移除,同时释放所持有的target。invalidate()方法是线程安全的,因此允许在其他线程停止正在运行的display link。

简单的创建方法如下:

    func createDisplayLink() {
        let displaylink = CADisplayLink(target: self, selector: #selector(updateAction))
        displaylink.add(to: .current, forMode: .default)
    }
    
    @objc func updateAction(displaylink: CADisplayLink) {
        print(displaylink.timestamp)
    }

4.2 CADisplayLink常用属性

PropertiesDescription
var duration: CFTimeInterval屏幕刷新时间间隔,只读属性。只有当Selector方法执行过一次才会有值。如果iOS设备的刷新频率是60HZ,那么屏幕16.7ms刷新一次,即duration为16.7ms。这个值取决于设备的刷新频率,会有波动。
var preferredFramesPerSecond: Int指定的Selector的调用频率。在iOS上,每秒的最大帧数通常是60,所以其最大值为60。默认值是0,如果设置为0,意味着Selector的调用频率和屏幕的最大刷新频率相同。
var isPaused: Bool控制计时器暂停与恢复。
var timestamp: CFTimeInterval屏幕显示的上一帧的时间戳,这个属性通常被target用来计算下一帧中应该显示的内容。
var targetTimestamp: CFTimeInterval屏幕下一帧显示时的时间戳,可以使用targetTimestamp取消或暂停可能超出帧间可用时间的长时间运算等,以保持一致的帧速率。

针对targetTimestamp的用法,苹果给出了一个示例,如下:

下面的的step方法中,做了一个耗时的运算,每次循环的时候,判断一下当前的时间是否超过了下一帧显示的时间,如果超过了,退出循环。

func createDisplayLink() {
    let displayLink = CADisplayLink(target: self, selector: #selector(step))
    displayLink.add(to: .main, forMode: .defaultRunLoopMode)
}
    
func step(displayLink: CADisplayLink) {
    var sqrtSum = 0.0
    for i in 0 ..< Int.max {
        sqrtSum += sqrt(Double(i))
        
        if (CACurrentMediaTime() >= displayLink.targetTimestamp) {
            print("break at i =", i)
            break
        }
    }
}

 4.3 CADisplayLink优缺点

优点:

  • iOS设备的屏幕刷新频率是固定的,所以在正常情况下CADisplayLink指定的Selector在每次刷新结束都被调用,精确度非常高。
  • CADisplayLink 使用场合相对专一, 适合做界面的不停重绘,比如视频播放的时候需要不停地获取下一帧用于界面渲染。

缺点:

  • 如果CPU不堪重负的话,那么屏幕的刷新频率就会受到影响,因此CADisplayLink指定的Selector的调动频率也会受到影响。
  • 如果Selector的运行时间大于重绘每帧的间隔时间,就会导致跳过若干次调用Selector的机会,这取决于执行时间长短。
  • 容易造成循环引用。

5. 总结

本篇文章主要讲解了iOS开发常用的三个计时器,Timer、DispatchSourceTimer、CADisplayLink,每个计时器都有各自的原理以及优缺点,使用的时候应该根据具体情况而选择。

三者不同之处:

  • 原理不同:
  1. CADisplayLink指定的回调方法的调用频率和依赖于屏幕的刷新频率,每次刷新完调用一次回调方法
  2. Timer以指定的模式注册到runloop后,每当设定的周期时间到达后,Runloop会向指定的target发送一次指定的selector消息。
  3. Timer和CADisplayLink的执行依赖Runloop,DispatchSourceTimer基于GCD,不依赖Runloop。
  • 周期设置不同:
  1. CADisplayLink指定的回调方法在每次屏幕刷新完调用一次,也可以通过preferredFramesPerSecond属性设置每次调用回调方法的次数。
  2. Timer和DispatchSourceTimer可以简单直接设置单词执行和多次重复执行,CADisplayLink不可设置单次执行。
  • 精度不同:
  1. iOS设备的刷新频率很固定,因此CADisplayLink回调方法调用频率也很固定。
  2. Timer受Runloop影响,如果Timer到了触发时间,而这时候Runloop正在处理其他耗时的任务,那么本次Timer的调用会被漏掉。
  3. DispatchSourceTimer计时精度也比较高,同时也可以设置一个偏差值leeway,系统可以使用leeway值来提前或延迟触发定时器。
  • 使用场景不同:
  1. Timer的使用范围比较广泛,各种需要单次或者循环定时处理的任务都可以使用,适用于对计时准确度要求不高的场景。
  2. DispatchSourceTimer相比Timer精度要高,更适合对计时准确度较高的场景。
  3. CADisplayLink 使用场合相对专一, 适合做界面的不停重绘,比如视频播放的时候需要不停地获取下一帧用于界面渲染。

本篇文章出自https://blog.csdn.net/guoyongming925的博客,如需转载,请标明出处。

 

 类似资料: