macOS 开发之定时器的使用

前段时间开发了一款状态栏小工具 时光(Time Go) ,它用到了定时器,近期十里在想大家会不会也有用定时器的需求呢!所以呢,写本文意在分享 macOS 开发中关于定时器的的收获及其使用方法,我们一起进步。

实现平台

  • macOS 10.14.3
  • swift 4.2.1
  • xcode 10.1

准备环境

为了更好地展示定时器的使用,这里我们使用 playground 测试我们的定时器,可以使用快捷键 Option + Shift + Command + n 新建 playgroud 文件。在文件中我们准备一个 TimerDemo 类用于测试定时器:

import Foundation


class TimerDemo {
    
    private var timeCount = 5
    
    static let share = TimerDemo()
    
    // 添加不同定时器使用方法的示例代码
    func run() {
        print("TimerDemo")
    }
    
}

TimerDemo.share.run()

上面定义了 TimerDemo 类,同时实现了一个 TimerDemo 的单例,方便调用方法展示效果!

定时器的使用

网上能搜到有三种实现定时器的方式,因为苹果官方 API 的变化导致部分搜到的信息过时,本文整理的实现方法适用于上述描述的实现平台,近期应该不会过期,哈哈:

本人觉得第三种方式不是什么好的方式,所以本文只讲前两种方式。

使用 Timer 类

Timer 类的使用依赖 RunLoop(线程的事件循环,app 运行一定会有一个名为 main 的 RunLoop,主要负责界面控件的显示和交互) ,只有添加到激活状态的 RunLoop 中定时器才能正常工作。

定时器的运行根据次数分两种:单次和循环:

  • 单次的定时器,会在指定的时间过后,自动销毁并从 RunLoop 中弹出,而不再影响 RunLoop 的运行
  • 循环的定时器,会根据指定时间间隔触发执行处理任务,需要开发者手动执行 Timer 对象的 invalidate 方法才能销毁并从 RunLoop 中弹出。一般使用循环定时器,不会让其一直执行,满足某种条件或执行指定次数之后,手动调用 invlaidate 方法停止定时器

根据定时器的使用方式,可以分三种:

  • 使用 Timer(timeInterval:repeats:block:)Timer(timeInterval:target:selector:userInfo:repeats:) 类方法初始化 Timer 对象,将对象手动添加到指定 RunLoop 中执行
  • 使用 Timer(fire:interval:repeats:block:)Timer(fireAt:interval:target:selector:userInfo:repeats:) 类方法初始化 Timer 对象,将对象手动添加到指定 RunLoop 中执行
  • 使用 Timer.scheduledTimer(withTimeInterval:repeats:block:)Timer.scheduledTimer(timeInterval:target:selector:userInfo:repeats:) 类方法,这两个方法都会创建 Timer 对象,并且以默认的运行模式添加到当前的 RunLoop 中

下面我们开始尝试上面的三种方式。先在上面定义的 TimerDemo 类中声明一个 timer 属性:

private var timer: Timer?

第一种方式

Timer(timeInterval:repeats:block:) 类方法

  • timeInterval 是定时器的触发时间间隔,单位为秒
  • repeats 布尔类型,代表是否重复,如果为 true ,定时器是循环定时器,如果为 false ,定时器是单次的
  • block 为 ((timer: Timer?) -> Void) 类型的闭包,其中 timer 是定时器自身

我们为 TimerDemo 类定义一个 oneshot1 方法,使用 Timer(timeInterval:repeats:block:) 类方法实现一个单次的定时器,如下:

// 使用 Timer(timeInterval:repeats:block:) 初始化对象,并将其添加到 main 线程的 RunLoop 中,单次
func oneshot1() {
    print("\(Date()): Timer(timeInterval:repeats:block:) 初始化,单次")
    timer = Timer(timeInterval: 1, repeats: false, block: { timer in
        self.oneFireHandler(timer)
    })
    RunLoop.main.add(timer!, forMode: .common)
}

可以看到定时器时间间隔是 1 秒,单次,并且闭包中调用定义的 oneFireHandler 方法,此方法是用来处理我们需要做的定时任务的,比如我们打印一个字符串:

@objc private func oneFireHandler(_ timer: Timer?) -> Void {
    print("\(Date()): 单次倒计时结束!")
}

还需要注意的是,oneshot1 中初始化得到 Timer 对象后,执行 RunLoop.main.add(timer:forMode:) 方法将定时器加入到 RunLoop 中,这一步是必须的,其中 forMode 参数我们使用默认模式(common),可以防止其它高优先级 mode 的事件影响定时器的运行。打印信息时同时打印了当前时间,主要作为参考看倒计时对不对。下面我们测试一下我们定义的 oneshot1 方法:

TimerDemo.share.oneshot1()

可以看到打印结果,确实是 1 秒的时间间隔,且执行了定时器任务。

2019-03-05 13:51:07 +0000: Timer(timeInterval:repeats:block:) 初始化,单次
2019-03-05 13:51:08 +0000: 单次倒计时结束!

上面 oneshot1oneFirehandler(timer:) 的实现可以简化为下面的形式,看您的使用习惯和需求决定使用哪种形式了:

func oneshot1() {
    print("\(Date()): Timer(timeInterval:repeats:block:) 初始化,单次")
    timer = Timer(timeInterval: 1, repeats: false) { timer in
        print("\(Date()): 单次倒计时结束!")
    }
    RunLoop.main.add(timer!, forMode: .common)
}

我们继续试一下循环定时器,先定义一个循环定时器要处理的任务:

@objc private func loopFireHandler(_ timer: Timer?) -> Void {
    if self.timeCount <= 0 {
        timer!.invalidate()
        print("\(Date()): 循环倒计时结束!")
        self.timeCount = 5
        return
    }
    self.timeCount -= 1
    print("\(Date()): 倒计时 \(self.timeCount) 秒")
}

loopFireHandler 方法中进行倒计时,到 0 时,调用定时器对象的 invalidate 方法释放定时器。另外定时器的实现如下 loop1 方法:

func loop1() {
    print("\(Date()): Timer(timeInterval:repeats:block:) 初始化,循环")
    timer = Timer(timeInterval: 1, repeats: true, block: { timer in
        self.loopFireHandler(timer)
    })
    RunLoop.main.add(timer!, forMode: .common)
}

可以看到只是 repeats 参数改为 true,block 中调用 loopFireHandler 方法,下面测试一下 loop1:

TimerDemo.share.loop1()

运行结果如我们所想,实现了 5 秒倒计时:

2019-03-05 14:10:22 +0000: Timer(timeInterval:repeats:block:) 初始化,循环
2019-03-05 14:10:23 +0000: 倒计时 42019-03-05 14:10:24 +0000: 倒计时 32019-03-05 14:10:25 +0000: 倒计时 22019-03-05 14:10:26 +0000: 倒计时 12019-03-05 14:10:27 +0000: 倒计时 02019-03-05 14:10:28 +0000: 循环倒计时结束!

Timer(timeInterval:target:selector:userInfo:repeats:) 类方法

  • timeInterval 定时器时间间隔,单位为秒
  • target 定时器每次循环结束后发送消息的目标对象
  • selector 指定定时器每次计时结束要执行的方法,必须为 @objc 的函数对象,所以上面定义的 oneFireHandlerloopFireHandler 最前面要加 @objc 关键词
  • userinfo 指定用户信息,可以指定为 nil
  • repeats 决定要不要循环执行,true 为循环,false 为单次

单次和循环的定时器处理,只有方法中的 repeats 参数和指定的处理任务函数不一样,其它都一致,所以后面只展示循环定时器的实现。

使用方法如下 loop2 方法:

// 使用 Timer(timeInterval:target:selector:userInfo:repeats:) 初始化对象,并将其添加到 main 线程的 RunLoop 中,循环
func loop2() {
    print("\(Date()): Timer(timeInterval:target:selector:userInfo:repeats:) 初始化,循环")
    timer = Timer(timeInterval: 1, target: self, selector: #selector(self.loopFireHandler(_:)), userInfo: nil, repeats: true)
    RunLoop.main.add(timer!, forMode: .common)
}

同样需要注意的是,需要将定时器 timer 添加到 RunLoop 中去。调用这个方法 TimerDemo.share.loop2() 即可看到打印结果:

2019-03-05 14:21:07 +0000: Timer(timeInterval:target:selector:userInfo:repeats:) 初始化,循环
2019-03-05 14:21:08 +0000: 倒计时 42019-03-05 14:21:09 +0000: 倒计时 32019-03-05 14:21:10 +0000: 倒计时 22019-03-05 14:21:11 +0000: 倒计时 12019-03-05 14:21:12 +0000: 倒计时 02019-03-05 14:21:13 +0000: 循环倒计时结束!

第二种方式

Timer(fire:interval:repeats:block:) 类方法

这个方法相较于 Timer(timeInterval:repeats:block:) 只多了一个 fire 参数,这个参数代表的意思是什么时候触发第一次计时结束,类型是 Date 类,可以使用方法 Date(timeIntervalSinceNow:) 方法指定相对于现在的时刻,这个方法参数单位是秒,最终 Timer(fire:interval:repeats:block:) 类方法实现循环定时器的过程如下 loop3:

//  使用 Timer(fire:interval:repeats:block:) 初始化对象,并将其添加到 main 线程的 RunLoop 中,循环
func loop3() {
    print("\(Date()): Timer(fire:interval:repeats:block:) 初始化,添加到 RunLoop,循环")
    timer = Timer(fire: Date(timeIntervalSinceNow: 0), interval: 1, repeats: true, block: { timer in
        self.loopFireHandler(timer)
    })
    RunLoop.main.add(timer!, forMode: .common)
}

注意 fire 参数,这里我们设置的是 Date(timeIntervalSinceNow: 0),也就是现在就触发一次计时结束,此时执行 TimerDemo.share.loop3() 可以看到结果:

2019-03-05 14:30:35 +0000: Timer(fire:interval:repeats:block:) 初始化,添加到 RunLoop,循环
2019-03-05 14:30:35 +0000: 倒计时 4 
2019-03-05 14:30:36 +0000: 倒计时 3 
2019-03-05 14:30:37 +0000: 倒计时 2 
2019-03-05 14:30:38 +0000: 倒计时 1 
2019-03-05 14:30:39 +0000: 倒计时 0 
2019-03-05 14:30:40 +0000: 循环倒计时结束!

可以看到第一行和第二行的打印时间都是 2019-03-05 14:30:35 +0000 这正匹配我们设置的 fire 参数,我们改一下 fire 参数为 Date(timeIntervalSinceNow: 3),也就是相对于现在延后 3 秒触发第一次计时结束,执行 TimerDemo.share.loop3() 看一下结果:

2019-03-05 14:33:00 +0000: Timer(fire:interval:repeats:block:) 初始化,添加到 RunLoop,循环
2019-03-05 14:33:03 +0000: 倒计时 4 
2019-03-05 14:33:04 +0000: 倒计时 3 
2019-03-05 14:33:05 +0000: 倒计时 2 
2019-03-05 14:33:06 +0000: 倒计时 1 
2019-03-05 14:33:07 +0000: 倒计时 0 
2019-03-05 14:33:08 +0000: 循环倒计时结束!

第二行与第一行的打印时间果然差了 3 秒,这下您应该明白这个 fire 参数的含义了吧!

Timer(fireAt:interval:target:selector:userInfo:repeats:) 类方法

这个方法相对于 Timer(timeInterval:target:selector:userInfo:repeats:) 方法多了一个 fireAt 参数,这个参数与上一个类方法的 fire 含义一致,使用就很简单了,定义 loop4 方法:

//  使用 Timer(fireAt:interval:target:selector:userInfo:repeats:) 初始化对象,并将其添加到 main 线程的 RunLoop 中,循环
func loop4() {
    print("\(Date()): Timer(fireAt:interval:target:selector:userInfo:repeats:) 初始化,添加到 RunLoop,循环")
    timer = Timer(fireAt: Date(timeIntervalSinceNow: 0), interval: 1, target: self, selector: #selector(self.loopFireHandler(_:)), userInfo: nil, repeats: true)
    RunLoop.main.add(timer!, forMode: .common)
}

这里就不展示执行结果了。

第三种方式

Timer.scheduledTimer(withTimeInterval:repeats:block:) 类方法

此方法参数与 Timer(timeInterval:repeats:block:) 参数一致,所以实现定时器一定难不倒我们了,但是这里需要注意的是,使用此方法得到的 timer 对象在执行方法的时候已经添加到 RunLoop 中了,所以不需要我们手动添加到 RunLoop 了:

//  使用 Timer.scheduledTimer(withTimeInterval:repeats:block:) 方法注册运行,循环
func loop5() {
    print("\(Date()): Timer.scheduledTimer(withTimeInterval:repeats:block:) 方法,循环")
    timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { timer in
        self.loopFireHandler(timer)
    })
}

Timer.scheduledTimer(timeInterval:target:selector:userInfo:repeats:) 类方法

此方法如同 Timer.scheduledTimer(withTimeInterval:repeats:block:) 方法,也不需要将方法返回的对象手动添加到 RunLoop 了:

//  使用 Timer.scheduledTimer(timeInterval:target:selector:userInfo:repeats:) 方法注册运行,循环
func loop6() {
    print("\(Date()): Timer.scheduledTimer(withTimeInterval:repeats:block:) 方法,循环")
    timer = Timer.scheduledTimer(timeInterval: 1, target: self, selector: #selector(self.loopFireHandler(_:)), userInfo: nil, repeats: true)
}

Timer 类定时器总结

综上,我们算是了解了 6 中类方法实现定时器,但是这种方式有个问题:因为是运行在 main 线程的的 RunLoop 中,所以可能出现时间延迟的问题,主线程主要处理 UI 控件的交互,如果在其中再有一些运算量大的操作,必定会影响定时器的执行(阻塞),也就是说 Timer 不论是在哪个 RunLoop 中,终究会是受 RunLoop 的影响。下面我们就再了解一个不受 RunLoop 影响的 GCD 方式的定时器。

GCD 方式的 Timer

通过 GCD 方式创建的定时器不会受 RunLoop 的影响,是一种比较底层的实现,所以很高效而且不受窗口控件频繁操作的影响。GCD 方式的定时器类型为 DispatchSourceTimer,其实例对象可以使用 DispatchSourceMakeTimerSource 方法分配资源而得到,这个对象有以下常用方法:

  • resume() 开启运行或恢复运行
  • cancel() 取消定时器
  • suspend() 挂起定时器,可以使用 resumne() 恢复运行
  • setEventHandler(handler: (() -> Void)?) 设置定时器任务的处理
  • setCancelHandler(handler: (() -> Void)?) 设置定时器取消时的处理
  • schedule(deadline:repeating:leeway:) 配置定时器的时间参数

schedule(deadline:repeating:leeway:) 方法

这个是 DispatchSourceTimer 对象用来配置时间参数的方法,本节主要介绍一下其参数。

  • deadline 类型为 DispatchTime ,这个时间精度可以达到纳秒级,含义与上面 Timer 类的 Timer(fire:interval:repeats:block:) 方法的 fire 一致,不过类型不一样。DispatchTime 有 now() 方法用来获取现在的时间,可以与 DispatchTimeInterval 对象实现 + 操作,得到一个相对于时刻,DispatchTimeInterval 对象可以通过调用 seconds(_:Int) 方法得到秒,milliseconds(_:Int) 方法得到毫秒,microseconds(_:Int) 方法得到微秒,nanoseconds(_:Int) 方法得到纳秒
  • repeating 与 Timer 类方法中的 repeat 不是一个意思,这里代表的是定时器时间间隔也就是 timeInterval,类型为 DispatchTimeInterval,比如指定一秒可以 DispatchTimeInterval.seconds(1)。这个参数具有默认值,当不指定这个参数的时候,这个参数就会设置为 DispatchTimeInterval.never ,那么定时器只执行一次
  • leeway 时间容差,是一个定时时间的宽容度,具有一个默认值,所以调用此方法的时候可以不用指定这个参数,如果要指定的话,也是 DispatchTimeInterval 类型

使用方法

讲了上面这么多,还是用实际的例子说明,首先在 TimerDemo 类中添加如下属性:

private var gcdTimer: DispatchSourceTimer?

单次定时器,如方法 oneshot7:

// GCD 方式的定时器,单次
func oneshot7() {
    print("\(Date()): GCD 方式的定时器,单次")
    gcdTimer = DispatchSource.makeTimerSource()
    gcdTimer?.setEventHandler() {
        print("\(Date()): 单次倒计时结束!")
    }
    gcdTimer?.schedule(deadline: .now() + .seconds(1))
    gcdTimer?.resume()
}

循环定时器,如方法 loop7:

// GCD 方式的定时器,循环
func loop7() {
    print("\(Date()): GCD 方式的定时器,循环")
    gcdTimer = DispatchSource.makeTimerSource()
    gcdTimer?.setEventHandler() {
        if self.timeCount <= 0 {
            self.gcdTimer?.cancel()
            return
        }
        self.timeCount -= 1
        print("\(Date()): 倒计时 \(self.timeCount) 秒")
    }
    gcdTimer?.setCancelHandler() {
        print("\(Date()): 倒计时结束!")
    }
    gcdTimer?.schedule(deadline: .now() + .seconds(1), repeating: .seconds(1))
    gcdTimer?.resume()
}

注意

如果定时器任务中需要对 UI 控件进行操作,要将那部分操作放在主线程进行也就是用下面的代码包裹:

DispatchQueue.main.async {
    // UI控件操作
}

总结

上述定时器演示的 playgroud 可以通过下面链接查看:

cocoaTimer.playground

两种方式示例对比

为了凸显 GCD 方式定时器不受 UI 的操作的影响,本小节新建一个名为 TimerDemo 的工程,工程的 Main.storyboard 中添加一个按钮,两个 label 和一个滑块,合理放置位置,比如:

分别将两个 label 的按钮绑定属性到 ViewController 类,同时为按钮添加一个 action 用来启动两种定时器。两个 label 分别显示 Timer 定时器的倒计时时间和 GCD 方式的定时器的倒计时时间,当启动定时器后,我们频繁滑动滑杆,观察两个定时器的情况,最终实现的 ViewController 类如下:

class ViewController: NSViewController {

    @IBOutlet weak var timerLabel1: NSTextField!
    @IBOutlet weak var timerLabel2: NSTextField!
    @IBOutlet weak var timerButton: NSButton!
    
    var count1 = 60
    var count2 = 60
    var timer: Timer?
    var gcdTimer: DispatchSourceTimer?
    
    override func viewDidLoad() {
        super.viewDidLoad()

        // Do any additional setup after loading the view.
    }

    override var representedObject: Any? {
        didSet {
        // Update the view, if already loaded.
        }
    }

    @IBAction func startTimer(_ sender: Any) {
        timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { timer in
            if self.count1 <= 0 {
                timer.invalidate()
                return
            }
            self.count1 -= 1
            self.timerLabel1.stringValue = "timer: \(self.count1) 秒"
        }
        
        gcdTimer = DispatchSource.makeTimerSource()
        gcdTimer?.setEventHandler() {
            if self.count2 <= 0 {
                self.gcdTimer?.cancel()
                return
            }
            self.count2 -= 1
            DispatchQueue.main.async {
                self.timerLabel2.stringValue = "gcdTimer: \(self.count2) 秒"
            }
        }
        gcdTimer?.schedule(deadline: .now() + .seconds(1), repeating: .seconds(1), leeway: .milliseconds(1))
        gcdTimer?.resume()
        
        self.timerButton.isEnabled = false
    }
}

最后运行程序,疯狂滑动滑杆可以看到 GCD 方式的定时器丝毫不受影响,而 Timer 类的定时器受到了阻塞:

示例工程下载:TimerDemo