跳转至

第11章:计时器

重复和非重复计时器在编码时总是有用的。 除了异步执行代码之外,您通常还需要控制任务应该重复的何时频率

Dispatch框架可用之前,开发人员依靠RunLoop异步执行任务并实现并发。您可以使用Timer创建重复和非重复计时器。然后,苹果发布了Dispatch框架,包括DispatchSourceTimer

虽然上述所有内容都能够创建计时器,但并非所有计时器在Combine中都是相等的。继续阅读!

使用RunLoop

主线程和您创建的任何线程,最好使用Thread类,都可以有自己的RunLoop。只需从当前线程中调用RunLoop.current:如果需要,Foundation将为您创建一个。请注意,除非您了解运行循环的操作方式——特别是您需要运行运行循环的循环——否则您最好使用运行应用程序主线程的主RunLoop

Note

苹果文档中的一个重要注意事项和红灯警告是,RunLoop类不是线程安全的。您只应该为当前线程的运行循环调用RunLoop方法。

RunLoop实现您将在第17章“调度程序”中了解的Scheduler协议。它定义了几种相对低级的方法,也是唯一允许您创建可取消计时器的方法:

let runLoop = RunLoop.main

let subscription = runLoop.schedule(
  after: runLoop.now,
  interval: .seconds(1),
  tolerance: .milliseconds(100)
) {
  print("Timer fired")
}

此计时器不会传递任何值,也不会创建发布者。它从after:带有指定intervaltolerance的参数中指定的日期开始,仅此而已。它与Combine的唯一有用之处是,它返回的Cancellable允许您在一段时间后停止计时器。

这方面的一个例子可能是:

runLoop.schedule(after: .init(Date(timeIntervalSinceNow: 3.0))) {
  subscription.cancel()
}

但考虑到所有因素,RunLoop并不是创建计时器的最佳方式。使用Timer类会更好!

使用计时器类

Timer早在苹果将其重命名为“macOS”之前,是原始Mac OS X中可用的最古老的计时器。由于其委托模式和与RunLoop的密切关系,它一直很棘手。Combine带来了一个现代变体,您可以直接用作发布者,而无需所有设置样板。

您可以通过以下方式创建重复计时器发布者:

let publisher = Timer.publish(every: 1.0, on: .main, in: .common)

确定的两个参数:

  • 您的计时器连接到哪个RunLoop。在这里,主线程的RunLoop
  • 计时器在哪个运行循环模式下运行。在这里,默认运行循环模式。

除非您了解运行循环是如何运行的,否则您应该坚持使用这些默认值。 运行循环是macOS中异步事件源处理的基本机制,但它们的API有点繁琐。 您可以通过调用RunLoop.current为您自己创建或从Foundation获取的任何线程获取RunLoop,因此您也可以编写以下代码:

let publisher = Timer.publish(every: 1.0, on: .current, in: .common)

Note

DispatchQueue.main以外的派单队列上运行此代码可能会导致不可预测的结果。Dispatch框架在不使用运行循环的情况下管理其线程。由于运行循环需要调用其run方法之一来处理事件,因此除了主队列外,您永远不会在任何队列上看到计时器触发。保持安全,为您的计时器锁定RunLoop.main

计时器返回的发布者是ConnectablePublisher。这是Publisher的一个特殊变体,在您显式调用其connect()方法之前,它不会在订阅时开始启动。您还可以使用autoconnect()操作符,该操作符在第一个订阅者订阅时自动连接。

Note

您将在第13章“资源管理”中了解有关可连接发布者的更多信息。

因此,创建订阅时启动计时器的发布者的最佳方法是编写:

let publisher = Timer
  .publish(every: 1.0, on: .main, in: .common)
  .autoconnect()

计时器反复发出当前日期,其Publisher.Output类型为Date。您可以使用scan操作符制作一个发送增加值的计时器:

let subscription = Timer
  .publish(every: 1.0, on: .main, in: .common)
  .autoconnect()
  .scan(0) { counter, _ in counter + 1 }
  .sink { counter in
    print("Counter is \(counter)")
  }

还有一个您在这里没有看到的Timer.publish()参数:tolerance。它指定了与您要求的持续时间的可接受的偏差,作为TimeInterval。但请注意,使用低于RunLoop minimumTolerance值的值可能不会产生预期结果。

使用DispatchQueue

您可以使用派单队列生成计时器事件。虽然调度框架有一个DispatchTimerSource事件源,但令人惊讶的是,Combine没有为它提供计时器接口。相反,您将使用另一种方法在队列中生成计时器事件。然而,这可能会有点复杂:

let queue = DispatchQueue.main

// 1
let source = PassthroughSubject<Int, Never>()

// 2
var counter = 0

// 3
let cancellable = queue.schedule(
  after: queue.now,
  interval: .seconds(1)
) {
  source.send(counter)
  counter += 1
}

// 4
let subscription = source.sink {
  print("Timer emitted \($0)")
}

在之前的代码中,您:

  1. 创建一个subject,您将向其发送计时器值。
  2. 准备一个计数器。每次计时器工作时,你都会增加它。
  3. 每秒对所选队列安排重复操作。操作立即开始。
  4. 订阅subject以获取计时器值。

正如你所看到的,这并不漂亮。这有助于将此代码移动到函数,并传递间隔和开始时间。

关键点

  • 如果您怀念Objective-C代码,请使用良好的旧RunLoop类创建计时器。
  • 使用Timer.publish获取在指定的RunLoop上以给定间隔生成值的发布者。
  • 使用DispatchQueue.schedule在调度队列上发出事件的现代计时器。

接下来去哪?

在第18章“自定义发布者和处理背压”中,您将学习如何编写自己的发布者,并将使用DispatchSourceTimer创建替代计时发布者。

但别着急!在此之前,有很多东西需要学习,从下一章的键值观察开始。