第七章 Combine的时间操作符

2,301 阅读7分钟

响应式编程背后的核心思想是随着时间的推移对异步事件流进行建模。在这方面,Combine 框架提供了一系列可以让您处理时间的运算符。特别是,序列如何随着时间的推移对值做出反应和转换。

delay

Combine中有一些关于时间的操作符。最基本的时间操作运算符delay可以延迟来自发布者的值,以便您看到它们比它们实际发生的时间晚。

delay(for:tolerance:scheduler:options) 操作符对整个值序列进行时移:

每次上游发布者发出一个值时,delay 将它保留一段时间,然后在你要求的延迟之后在你指定的调度程序上发出它

原书中这里的示例代码都略微复杂一些,有些花哨。我们这里只用最简单的demo来展现这些时间操作符的功能。

在playground中加入如下代码:

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


timerPublisher
    // 2
    .handleEvents(receiveOutput: { date in
           print ("Sending \(date) to delay()")
       })
	 // 3
    .delay(for: .seconds(2), scheduler: DispatchQueue.main)
    .sink { value in
        // 4
        print("Receive \(value)")
    }
    .store(in: &cancellable)
  1. 创建一个定时器的Publisher
  2. 订阅定时器的Publisher,同时用调试方式打印出定时器发布的数据,此处发布的是Date数据
  3. 延迟2秒后,将数据发布给下游
  4. 打印接收到的日期数据

运行playground,得到结果:

Sending 2021-10-14 09:23:04 +0000 to delay()
Receive 2021-10-14 09:23:02 +0000
Sending 2021-10-14 09:23:05 +0000 to delay()
Receive 2021-10-14 09:23:03 +0000
Sending 2021-10-14 09:23:06 +0000 to delay()
Receive 2021-10-14 09:23:04 +0000

可以看到,每次发布的日期和接受的日期,中间相差了2秒。

Collecting values

在某些情况下,您可能需要以指定的时间间隔从发布者那里收集数据。这是一种很有用的缓冲形式。例如,当您想在短时间内对一组值求平均值并输出平均值时。

collect(_:options:)

通过给定的时间分组策略收集元素,并发出集合的单个数组。

注意这个操作符和我们在第三章中提到的collect不同,第三章传输操作符中的collect的参数声明为collect(_:)

使用 collect(_:options:) 按照您提供的 SchedulerStride 指定的时间表发出元素数组。在每个预定间隔结束时,发布者发送一个包含它收集的项目的数组。

如果上游发布者在填充缓冲区之前完成,发布者会发送一个包含它接收到的项目的数组。这可能少于请求的步幅中指定的元素数量。 如果上游发布者因错误而失败,则此发布者会将错误转发给下游接收者,而不是发送其输出。

上面的示例以五个为一组 (Stride) 收集在一秒计时器上生成的时间戳。

这里我们使用Apple官方文档中的示例做以说明:

let sub = Timer.publish(every: 1, on: .main, in: .default)
    .autoconnect()
    .collect(.byTime(RunLoop.main, .seconds(5)))
    .sink { print("\($0)", terminator: "\n\n") }

// Prints: "[2020-01-24 00:54:46 +0000, 2020-01-24 00:54:47 +0000,
//          2020-01-24 00:54:48 +0000, 2020-01-24 00:54:49 +0000,
//          2020-01-24 00:54:50 +0000]"
  • 在这个例子中,我们用定时器Publisher每秒发布数据。
  • Scheduler设置为RunLoop.main主线程,Stride设定为5秒。
  • collect(_:options:) 操作符在5秒内收集Publisher发布的数据,因为Publisher每秒发布一次,所以共收集了5次,并存储到一个数组中
  • 5秒后,sink开始接收到数据,数据就是collect收集到的数组内容

Debounce

仅在事件之间经过指定的时间间隔后才发布元素,即使在时间间隔内Publisher发布了很多元素,这个操作符让我们可以过滤掉其他元素,而发布时间间隔内最后发布的那一个元素。

debounce和后面的throttle都是为了“去抖动”而设计。“去抖动”在我们的程序中,可以想象为假设我们点击一个button,会发送一次网络请求。但如果我们在短时间内快速点击多次Button,如何有效的处理这些请求呢?常用的设计方式就是只处理最后一次点击事件,丢弃之前的“无效”点击事件。这个时候,就是这两个操作符发挥作用的时候

debounce_break-2.png

在上图中我们看到:

  • 0.1秒和0.2秒时,Publisher分别发布了1和2两个Int数据
  • 1.1秒和1.2秒时,Publisher分别发布了5和6两个Int数据
  • Publisher用debounce操作符进行处理,处理间隔为0.5秒

这里我们改写Apple官方文档来实现这个操作符的示意图,因为原书中的讲解过于复杂。

使用 debounce(for:scheduler:options:) 运算符来控制值的数量和从上游发布者传递值之间的时间。此运算符对于处理突发或高容量事件流很有用,您需要将传递到下游的值数量减少到您指定的速率。

我们看下代码:

let bounces:[(Int,TimeInterval)] = [
    (1, 0.1),   // 0.1秒 发布 1
    (2, 0.2),   // 0.2秒 发布 2
    (5, 1.1),   // 1.1秒 发布 5
    (6, 1.2)    // 1.2秒 发布 6
]

let subject = PassthroughSubject<Int, Never>()
var cancellable = subject
    .debounce(for: .seconds(0.5), scheduler: RunLoop.main)
    .sink { index in
        print ("Received index \(index)")
    }

for bounce in bounces {
    DispatchQueue.main.asyncAfter(deadline: .now() + bounce.1) {
        subject.send(bounce.0)
    }
}

// Prints:
// Received index 2
// Received index 6

在for循环中,循环用PassthroughSubject发布数据,发布的时机按照设定的元组的第二个元素进行延迟。同时,设定debounce的延迟时间为0.5秒。 第一个0.5秒内,发布了1和2,debounce发挥作用,只保留最后一个发布值2,使其进入到sink 第二个0.5秒内,发布了5和6,debounce发挥作用,只保留最后一个发布值6,使其进入到sink

demo中我们debounce设置的延迟时间是0.5秒,也就是说 debounce只接收0.5秒内的最后一个发布的数据。,也就是文档中提到的将传递至下游的数据降低至你指定的频率。

我们再介绍这个操作符实际应用的一种用法,比如我们的搜索页面。 在搜索页面我们有一个搜索框,还有搜索结果列表。当我们在搜索框中输入搜索关键字时,根据关键字的变化来启动Combine流程,检索本地数据符合搜索关键字的项,显示到搜索结果列表上。 当用户输入过于快速时,可能每个输入元素都会产生一个搜索请求,这浪费了CPU的处理(如果是网络请求搜索则会浪费网络资源),此时,我们可以用debounce来延迟用户输入到开始搜索这段时间的延迟,同时,过滤掉多余的数据,而只是处理延迟过后最后一次发布的数据。

代码片段:

$searchText
	.debounce(for: .sencond(0.5), scheduler: RunLoop.main)

我们再次比较一下delaydebounce

  • delay只是单纯的延迟将上游数据发布给下游
  • debounce除了延迟发布上游数据外,还会过滤掉一些数据,只发布延迟后最后发布的一个数据。

Throttle

在指定的时间间隔内发布上游发布者发布的最新元素或第一个元素。

throttle (latest=false)

throttle_false_13_2-1.png

throttle (latest=true)

throttle_true_13_2.png

从图中可以看出,throttledebounce类似,不同之处在于throttle额外有一个参数latest,设置为true时,得到的值与debounce一样,为最后发布的值,设置为false时,与debounce相反,为最先发布的值。

这个操作符我们同样使用Apple的官方文档说明和示例代码。

使用throttle(for:scheduler:latest:) 在您指定的时间间隔内有选择地从上游发布者重新发布元素。在限制间隔内从上游接收的其他元素不会重新发布。 在下面的示例中, Timer.TimerPublisher 以一秒的间隔生成元素; Throttle(for:scheduler:latest:) 操作符传递第一个事件,然后在接下来的十秒间隔内仅重新发布最新的事件:

cancellable = Timer.publish(every: 3.0, on: .main, in: .default)
    .autoconnect()
    .print("\(Date().description)")
    .throttle(for: 10.0, scheduler: RunLoop.main, latest: true)
    .sink(
        receiveCompletion: { print ("Completion: \($0).") },
        receiveValue: { print("Received Timestamp \($0).") }
     )

// Prints:
 //    Publish at: 2020-03-19 18:26:54 +0000: receive value: (2020-03-19 18:26:57 +0000)
 //    Received Timestamp 2020-03-19 18:26:57 +0000.
 //    Publish at: 2020-03-19 18:26:54 +0000: receive value: (2020-03-19 18:27:00 +0000)
 //    Publish at: 2020-03-19 18:26:54 +0000: receive value: (2020-03-19 18:27:03 +0000)
 //    Publish at: 2020-03-19 18:26:54 +0000: receive value: (2020-03-19 18:27:06 +0000)
 //    Publish at: 2020-03-19 18:26:54 +0000: receive value: (2020-03-19 18:27:09 +0000)
 //    Received Timestamp 2020-03-19 18:27:09 +0000.

timeout

func timeout<S>(_ interval: S.SchedulerTimeType.Stride, scheduler: S, options: S.SchedulerOptions? = nil, customError: (() -> Self.Failure)? = nil) -> Publishers.Timeout<Self, S> where S : Scheduler

超时运算符,当timeout触发时,它要么完成发布者,要么发出您指定的错误。在这两种情况下,Publisher都将被中止。

我们用Apple官方文档中的代码来演示这一功能:

var WAIT_TIME : Int = 2
var TIMEOUT_TIME : Int = 5

let subject = PassthroughSubject<String, Never>()
let cancellable = subject
	// 1
    .timeout(.seconds(TIMEOUT_TIME), scheduler: DispatchQueue.main, options: nil, customError:nil)
    .sink(
          receiveCompletion: { print ("completion: \($0) at \(Date())") },
          receiveValue: { print ("value: \($0) at \(Date())") }
     )

// 2
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(WAIT_TIME),
                              execute: { subject.send("Some data - sent after a delay of \(WAIT_TIME) seconds") } )

// Prints: value: Some data - sent after a delay of 2 seconds at 2020-03-10 23:47:59 +0000
//         completion: finished at 2020-03-10 23:48:04 +0000
  1. PassthroughSubject 发布 String 元素并配置为如果在 5 秒(TIMEOUT_TIME)内没有收到新元素则超时。
  2. 在指定的 2 秒(WAIT_TIME) 之后发布单个值(仅发布一次)

最后看到打印结果为收到第一次发布的值,5秒后打印出超时

代码中我们并没有使用timeout的customError参数,也就是默认其为nil,所以最后打印的结果是completion: finished。如果为 customError 参数提供闭包,则上游发布者会在超时时终止,并会显示错误信息。

measureInterval(using:options:)

测量并发出从上游发布者接收到的事件之间的时间间隔。

我们用Apple官方文档中的代码演示这一功能:

cancellable = Timer.publish(every: 1, on: .main, in: .default)
    .autoconnect()
    .measureInterval(using: RunLoop.main)
    .sink { print("\($0)", terminator: "\n") }

// Prints:
//      Stride(magnitude: 1.0013610124588013)
//      Stride(magnitude: 0.9992760419845581)

在上面的示例中,一个 1 秒计时器用作事件发布者的数据源; measureInterval(using:options:) 运算符报告在主运行循环上接收事件之间经过的时间

本章Key Points

  • Combine 对异步事件的处理扩展到操作时间本身。
  • 时间系列操作符可让您在很长一段时间内抽象工作,而不仅仅是处理离散事件。
  • 可以使用delay运算符来延迟处理Combine流程。
  • collect可以像大坝一样,“收集”水流,然后再一次性的释放他们。
  • debouncethrottle易于随着时间的推移选择单个值。
  • timeout提供超时处理功能。
  • 时间可以用 measureInterval 来测量