Timer类型以及使用注意事项

1. NSTimer

NSTimer 是一个基于运行循环(RunLoop)的计时器。它可以在一个指定的时间间隔内重复触发一个方法。你可以使用 scheduledTimerWithTimeInterval:target:selector:userInfo:repeats:timerWithTimeInterval:target:selector:userInfo:repeats: 方法创建一个 NSTimer 实例。注意,NSTimer 对象对其目标对象(target)有一个强引用(即使target是weak的),这可能导致循环引用的问题,另外还要注意计时器本身不释放的问题。要避免这些情况,一方面避免Timer对象对其目标对象(target)的强引用以及在适当的时候手动使计时器失效(invalidate)。

为了解决这个问题,您可以采用以下策略:

1.1 手动断开引用

在一些情况下,您可能需要在特定的时间点手动断开对象之间的引用。例如,您可能希望在任务完成时停止计时器并将其设置为 nil,从而断开对当前对象的引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class NSTimerDemo {
var timer: Timer?

func startTimer() {
timer = Timer.scheduledTimer(timeInterval: 1.0, target: self, selector: #selector(timerFired), userInfo: nil, repeats: true)
}

@objc func timerFired() {
print("Timer fired!")
// 在某个条件下停止计时器并断开引用
if someCondition {
timer?.invalidate()
timer = nil
}
}
}

请注意,这种方法需要您在适当的时候手动管理引用,否则可能会导致内存泄漏。而且,不要忘记在适当的时候调用 invalidate() 来停止计时器并释放资源。

1.2 使用 WeakProxy

WeakProxy 可以帮助解决循环引用问题,但它不能完全替代 invalidate()WeakProxy 是一种代理模式,它的主要目的是在不创建循环引用的情况下,将消息转发给实际目标对象。在使用 NSTimerTimer 时,可以使用 WeakProxy 来避免循环引用,但即使使用了 WeakProxy,当不需要计时器时,你仍然需要调用 invalidate() 来停止计时器并释放资源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class WeakProxy: NSObject {
weak var target: NSObjectProtocol?

init(target: NSObjectProtocol) {
self.target = target
super.init()
}

override func responds(to aSelector: Selector!) -> Bool {
return (target?.responds(to: aSelector) ?? false) || super.responds(to: aSelector)
}

override func forwardingTarget(for aSelector: Selector!) -> Any? {
return target
}
}

class NSTimerProxyDemo: NSObject {
var timer: Timer?

func startTimer() {
let proxy = WeakProxy(target: self)
timer = Timer.scheduledTimer(timeInterval: 1.0, target: proxy, selector: #selector(timerFired), userInfo: nil, repeats: true)
}

@objc func timerFired() {
print("Timer fired!")
}

deinit {
timer?.invalidate()
}
}

在这个示例中,我们创建了一个 WeakProxy 类,它可以将消息转发给实际的目标对象(在本例中是 NSTimerProxyDemo 的实例)。然后,在创建计时器时,我们使用 WeakProxy 实例作为其目标。这样可以避免计时器对目标对象的强引用,从而避免循环引用。

1.3 使用 block-based API来创建一个带有 block 的计时器:

使用 [weak self] 来捕获一个对当前对象的弱引用,从而避免循环引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class BlockTimer {
var timer: Timer?

func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
self?.timerFired()
}
}

func timerFired() {
print("Timer fired!")
}

deinit {
timer?.invalidate()
}
}

1.4 给NSTimer写个扩展,target指向类对象本身(相当于自己实现block-based API):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class BlockSupportTimer: NSObject {
typealias TimerBlock = () -> Void

@objc
private class func qb_blockTimerInvoke(_ timer: Timer) {
if let block = timer.userInfo as? TimerBlock {
block()
}
}

class func qb_scheduledTimer(withTimeInterval interval: TimeInterval, block: @escaping TimerBlock, repeats: Bool) -> Timer {
return Timer.scheduledTimer(timeInterval: interval, target: self, selector: #selector(qb_blockTimerInvoke(_:)), userInfo: block, repeats: repeats)
}
}

这段代码中target是类对象,类对象本身不会受到引用计数(retain/release)机制的影响。其生命周期是由 Objective-C 运行时系统管理的,它们在应用程序启动时创建,一直存在于应用程序的整个生命周期中,直到应用程序结束,因此不需要担心引用计数问题。

1.5 子线程中使用 NSTimer

需要将计时器添加到子线程的 RunLoop 中,以确保子线程保持活动状态,从而使计时器能够正常工作。以下是在子线程中使用 NSTimer 并保持子线程活动的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class TimerViewController: UIViewController {

private var timerThread: Thread?

override func viewDidLoad() {
super.viewDidLoad()
startTimerInChildThread()
}

// 开始子线程
private func startTimerInChildThread() {
timerThread = Thread(target: self, selector: #selector(startTimerInThread), object: nil)
timerThread?.start()
}

// 在子线程中启动计时器并将其添加到 RunLoop
@objc private func startTimerInThread() {
autoreleasepool {
let timer = Timer(timeInterval: 1, target: self, selector: #selector(timerFired), userInfo: nil, repeats: true)
RunLoop.current.add(timer, forMode: .common)
RunLoop.current.run()
}
}

// 计时器触发的方法
@objc private func timerFired() {
print("Timer fired in child thread")
}

// 停止计时器并结束子线程的 RunLoop
private func stopTimerAndChildThread() {
timerThread?.cancel()
timerThread = nil
}

// 当视图控制器被销毁时,确保停止计时器并结束子线程的 RunLoop
deinit {
stopTimerAndChildThread()
}
}

使用这种方法,子线程的 RunLoop 会保持活动状态,从而使 NSTimer 能够在子线程中正常运行。请注意,在适当的时候,您需要手动停止计时器并结束子线程的 RunLoop,以避免资源浪费和潜在的内存泄漏问题。

1.5 用NSTimer实现暂停和恢复功能:

NSTimer 本身没有直接的暂停和恢复功能。但是,可以通过一种间接的方式实现暂停和重新开始计时器的效果。以下是一种实现暂停和恢复 NSTimer 的方法,但是建议还是用GCD的Timer:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class PausableTimerWrapper {
var timer: Timer?
var startTime: Date?
var timeInterval: TimeInterval?
var block: (() -> Void)?

init(timeInterval: TimeInterval, block: @escaping () -> Void) {
self.timeInterval = timeInterval
self.block = block
self.startTimer()
}

@objc func timerFired() {
block?()
}

func startTimer() {
if timer == nil {
startTime = Date()
timer = Timer.scheduledTimer(timeInterval: timeInterval!, target: self, selector: #selector(timerFired), userInfo: nil, repeats: true)
}
}

func pauseTimer() {
timer?.invalidate()
timer = nil
}

func resumeTimer() {
if let startTime = startTime, let block = block, let timeInterval = timeInterval {
if timer == nil {
timer = Timer.scheduledTimer(timeInterval: timeInterval, target: self, selector: #selector(timerFired), userInfo: nil, repeats: true)
}
}
}

deinit {
timer?.invalidate()
timer = nil
}
}

这种方法并不是真正地暂停和恢复同一个计时器实例,而是通过创建新的计时器实例来实现暂停和恢复的效果。在使用此方法时,请确保正确处理计时器的释放和内存管理。

这是一个用于同步屏幕刷新率的计时器,适用于需要进行高精度动画的场景。它可以确保在屏幕每次刷新时都会调用一个指定的方法。使用 CADisplayLink(target:selector:) 方法创建一个 CADisplayLink 实例,并将其添加到运行循环中。

3. DispatchSourceTimer

这是一个基于 GCD(Grand Central Dispatch)的计时器。你可以使用 DispatchSource.makeTimerSource(queue:) 方法创建一个 DispatchSourceTimer 实例,并使用 schedule(deadline:repeating:leeway:) 方法设置计时器的触发时间和重复间隔。这种计时器可以在多个线程之间安全地使用,无需担心循环引用问题。

和NSTimer的不同:

  1. 线程模型:NSTimer 与一个具体的 RunLoop 关联,运行在创建计时器的线程中。这意味着如果 RunLoop 被阻塞,计时器将不会触发。而 DispatchSourceTimer 不受线程限制,它使用 Grand Central Dispatch(GCD)在指定队列上同步(串行队列)或者(并行队列)异步执行。

  2. 精度:DispatchSourceTimer 提供了更高的精度,因为它不受 RunLoop 的影响。相比之下,NSTimer 的精度可能受到 RunLoop 阻塞的影响。

  3. 暂停和恢复:DispatchSourceTimer 提供了方便的 suspend()resume() 方法用于暂停和恢复计时器。而对于 NSTimer,您需要手动使计时器失效(invalidate())并在需要时重新创建。

  4. 内存管理:NSTimer 对其目标对象(通常是 self)有一个强引用(非block的api),可能导致循环引用,因此需要额外关注内存管理。而 DispatchSourceTimer 可以避免此问题,因为它不直接引用目标对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class GCDTimerDemo {
private var timer: DispatchSourceTimer?

func startTimer() {
guard timer == nil else { return }

timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global())
timer?.schedule(deadline: .now(), repeating: .seconds(1))
timer?.setEventHandler { [weak self] in
print("Timer fired!")
DispatchQueue.main.async {
// 在这里执行任务,例如更新 UI 或执行其他操作
}
}
timer?.resume()
}

func pauseTimer() {
timer?.suspend()
}

func resumeTimer() {
timer?.resume()
}

func cancelTimer() {
timer?.cancel()
timer = nil
}

deinit {
cancelTimer()
}
}

使用RxSwift实现 timer 的功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

/// 倒计时
private func startNextLectureTimer() {
guard let nextLectureInfo = courseInfoModel?.nextLectureInfo,
nextLectureInfo.isHasNextLecture == true
else { return }

let timer = Observable<Int>.interval(.seconds(1), scheduler: MainScheduler.instance)
timer.map { [weak self] timeElapsed -> Int in
guard let _ = self
else { return 0 }

return nextLectureInfo.countDown - timeElapsed
}
.take(nextLectureInfo.countDown + 1)
.map { remainingSeconds -> String in
let minutes = remainingSeconds / 60
let seconds = remainingSeconds % 60
return String(format: "下节课 %02d:%02d", minutes, seconds)
}
.bind(to: nextTimeLabel.rx.text)
.disposed(by: disposeBag)
}

  1. 创建一个 Observable 计时器 timer,使用 Observable<Int>.interval(.seconds(1), scheduler: MainScheduler.instance)。这个计时器每秒发出一个事件,事件值为自计时器启动以来经过的秒数。这里的 scheduler 参数表示计时器运行在主线程上,这对于更新 UI 元素很重要。
  2. 使用 map 操作符将计时器发出的事件值(经过的秒数)转换为剩余秒数。这是通过将 nextLectureInfo.countDown(下一堂课的倒计时总时长)减去 timeElapsed(已经过去的秒数)来实现的。注意 weak self 的使用,以避免循环引用。
  3. 使用 take 操作符限制计时器发出的事件数量。这里使用 nextLectureInfo.countDown + 1 作为事件数量,确保计时器在倒计时结束时停止。
  4. 再次使用 map 操作符,将剩余秒数转换为 mm:ss 格式的字符串。这里将剩余秒数转换为分钟和秒数,然后使用 String(format:) 方法将其格式化为 “下节课 %02d:%02d” 的字符串。
  5. 使用 bind(to:) 操作符将格式化后的字符串绑定到 nextTimeLabel.rx.text。这意味着每次计时器发出事件时,nextTimeLabel 的文本都会更新为当前显示的倒计时。

ps:可以在源码ConcurrentDispatchQueueScheduler中查看到,RX 内部是基于DispatchSourceTimer 实现的。利用rx的监听模式,可以创建一个shared单例的timer observable挂载多种任务。可以优雅的实现app的定时任务,比如数据上报定时器,心跳检测等;

DispatchSourceTimer设计一个页面时长统计器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
class PointManager {
private var timer: DispatchSourceTimer?
private var timerTask: (()->Void)?
private var interval: Int = 5
private var deadline: DispatchTime = .now()

static let shareInstance = PointManager()
override private init() {
super.init()
}

private func setupTimer() {
timer = DispatchSource.makeTimerSource(queue: DispatchQueue.main())
timer?.schedule(deadline: .now(), repeating: .seconds(6))
timer?.setEventHandler { [weak self] in
print("Timer fired!")
DispatchQueue.main.async {
self.timerTask()
}
}
timer?.resume()
}
public func configTimer(interval: Int, deadline: DispatchTime, task: (()->Void)?) {
timerTask = task
self.interval = interval
self.deadline = deadline
setupTimer()
}

// 到了新页面立即上报,重新配置timer
public func relayTimer(_ afterSecond: Double) {
if let timer = timer {
let now = DispatchTime.now()
let nextStartTime = now + afterSecond // 设置下一个任务的启动时间为当前时间 + afterSecond秒
let leeway = DispatchTimeInterval.milliseconds(100) // 设置允许的误差为 100 毫秒
timer.schedule(deadline: nextStartTime, repeating: .seconds(interval), leeway: leeway)
}
}

public func reported(_ point: String, params: JSON) {

}
}

外部使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func configGlobalTask() {
let pageAppearTask = {
if let curVC = UIViewController.current as? ViewController,
let logName = curVC.logName {
PointManager.reported("all_page_t", params: ["source": logName])
debugPrint("PointManager === \(logName)")
}
}
// 5s之后开始,间隔5s执行一次,viewappear的时候延迟1s打印一次
PointManager.shared.configTimer(interval: 5, deadline: DispatchTime.now() + 5, task: pageAppearTask)
ViewController.gloablAppearTask = {
PointManager.shared.relayTimer(1)
}
}