跳转至

第3章:转换操作符

完成了第1节,你已经学到了很多东西。你应该对这一成就感到非常满意 你已经为Combine的基础知识打下了坚实的基础,现在你准备在此基础上再接再厉。

在本章中,你将学习Combine中的一个基本操作符类别。变换操作符。你会经常使用转换操作符,把来自发布者的值处理成对你的订阅者可用的格式。正如你所看到的,Combine中的转换操作符与Swift标准库中的常规操作符有相似之处,比如mapflatMap

在本章结束时,你将会对所有的东西进行转换!

开始工作

打开本章的启动Playground,里面已经导入了Combine,准备让你开始编码。

操作符是发布者

Combine中,我们把对来自发布者的值进行操作的方法称为"操作符"。

每个Combine操作符都会返回一个发布者。一般来说,发布者接收上游事件,对其进行操作,然后将操作后的事件发送到下游的订阅者。

为了简化这个概念,在本章中,你将专注于使用操作符和处理其输出。除非操作符的目的是处理上游的错误,否则它将只是在下游重新发布上述错误。

Note

在本章中,你将专注于变换操作符,所以错误处理不会出现在每个操作符的例子中。你将在第16章"错误处理"中学习所有关于错误处理的知识。

收集值

发布者可以发出单个的值或值的集合。你会经常使用集合,例如,当你想填充列表或网格视图时。在本书的后面,你会学到如何做到这一点。

collect()

collect操作符提供了一种方便的方法,可以将发布者的单个值流转化为一个单一的数组。为了帮助理解这个操作符和你在本书中要学习的所有其他操作符,你将使用时序图。

时序图有助于直观地了解操作符的工作原理。最上面的线是上游的发布者。盒子代表操作符。底线是用户,或者更确切地说,在操作符操作来自上游发布者的值之后,用户将收到什么。

底线也可以是另一个操作符,接收来自上游发布者的输出,执行其操作,并将这些值发送到下游。

img

这个时序图描述了collect如何缓冲单个数值的流,直到上游发布者完成。然后,它向下游发送该数组。

把这个新的例子添加到你的Playground

example(of: "collect") {
  ["A", "B", "C", "D", "E"].publisher
    .sink(receiveCompletion: { print($0) },
          receiveValue: { print($0) })
    .store(in: &subscriptions)
}

这段代码还没有使用collect操作符。运行这个Playground,你会看到每个值都出现在一个单独的行中,后面有一个完成事件:

——— Example of: collect ———
A
B
C
D
E
finished

现在在调用sink之前使用collect。你的代码现在应该是这样的:

["A", "B", "C", "D", "E"].publisher
  .collect()
  .sink(receiveCompletion: { print($0) },
        receiveValue: { print($0) })
  .store(in: &subscriptions)

再次运行playground,你现在会看到sink收到一个数组值,然后是完成事件:

——— Example of: collect ———
["A", "B", "C", "D", "E"]
finished

Note

在使用collect()和其他不需要指定计数或限制的缓冲操作符时要小心。它们会使用无限制的内存来存储接收到的值,因为它们不会在上游完成之前发射。

collect操作符有一些变化。例如,你可以指定你只想接收一定数量的值,有效地将上游切成"批次"。

替换下面一行:

.collect()

为:

.collect(2)

运行Playground,你会看到以下输出:

——— Example of: collect ———
["A", "B"]
["C", "D"]
["E"]
finished

最后一个值,E,也是一个数组。这是因为上游发布者在collect填满其规定的缓冲区之前就完成了,所以它把剩下的东西作为一个数组发送。

映射值

除了收集数值之外,你还经常想以某种方式转换这些数值。Combine为此提供了几个映射操作符。

map(_:)

你将学习的第一个是map,它的工作方式与Swift的标准map一样,只是它对从发布者发出的值进行操作。在时序图中,map需要一个闭包,将每个值乘以2

img

请注意,与collect不同的是,这个操作符在上游发布数值后会立即重新发布。

把这个新的例子添加到你的Playground

example(of: "map") {
  // 1
  let formatter = NumberFormatter()
  formatter.numberStyle = .spellOut

  // 2
  [123, 4, 56].publisher
    // 3
    .map {
      formatter.string(for: NSNumber(integerLiteral: $0)) ?? ""
    }
    .sink(receiveValue: { print($0) })
    .store(in: &subscriptions)
}

以下是代码的过程:

  1. 创建一个数字格式化器来拼出每个数字。
  2. 创建一个整数的发布者。
  3. 使用map,传递一个闭包,获得上游值并返回使用格式化器的结果,以返回数字拼出的字符串。

运行Playground,你会看到这样的输出:

——— Example of: map ———
one hundred twenty-three
four
fifty-six

映射关键路径

map系列的操作符还包括三个版本, 可以使用关键路径映射到一个, 两个, 或三个属性的值. 它们的签名如下:

  • map<T>(_:)
  • map<T0, T1>(_:_:)
  • map<T0, T1, T2>(_:_:_:)

T代表在给定的关键路径上发现的值的类型。

在下一个例子中,你将使用Source/SupportCode.swift中定义的Coordinate类型和quadrantOf(x:y:)方法。Coordinate有两个属性。xyquadrantOf(x:y:)xy的值作为参数,并返回一个字符串,表示xy值的象限。

Note

象限是坐标几何的一部分。欲了解更多信息,你可以访问mathworld.wolfram.com/Quadrant.html

如果你有兴趣,可以随时查看这些定义,否则只需使用map(_:_:)与下面的例子到你的Playground

example(of: "mapping key paths") {
  // 1
  let publisher = PassthroughSubject<Coordinate, Never>()

  // 2
  publisher
    // 3
    .map(\.x, \.y)
    .sink(receiveValue: { x, y in
      // 4
      print(
        "The coordinate at (\(x), \(y)) is in quadrant",
        quadrantOf(x: x, y: y)
      )
    })
    .store(in: &subscriptions)

  // 5
  publisher.send(Coordinate(x: 10, y: -8))
  publisher.send(Coordinate(x: 0, y: 5))
}

在这个例子中,你使用的是map的版本,通过关键路径映射到两个属性。

按照上面的步骤,你:

  1. 创建一个永远不会发出错误的Coordinate的发布者。
  2. 开始订阅该发布者。
  3. 使用关键路径映射到Coordinatexy属性。
  4. 打印一个说明提供xy值的象限的语句。
  5. 通过发布者发送一些坐标。

运行Playground,这个订阅的输出将是以下内容:

——— Example of: map key paths ———
The coordinate at (10, -8) is in quadrant 4
The coordinate at (0, 5) is in quadrant boundary

tryMap(_:)

包括map在内的几个操作符都有一个对应的try前缀,需要一个抛出的闭包。如果你抛出一个错误,该操作符将向下游发出该错误。

要尝试tryMap,请在Playground上添加这个例子:

example(of: "tryMap") {
  // 1
  Just("Directory name that does not exist")
    // 2
    .tryMap { try FileManager.default.contentsOfDirectory(atPath: $0) }
    // 3
    .sink(receiveCompletion: { print($0) },
          receiveValue: { print($0) })
    .store(in: &subscriptions)
}

以下是你刚刚做的事,或者至少是试图做的事!

  1. 创建一个代表不存在的目录名称的字符串的发布者。
  2. 使用tryMap试图获取不存在的目录的内容。
  3. 接收并打印出任何值或完成事件。

Note

在调用抛出方法时,你仍然需要使用try关键字。

运行Playground,观察tryMap输出一个失败的完成事件,并有适当的"folder doesn’t exist"错误(输出缩写):

——— Example of: tryMap ———
failure(..."The folder “Directory name that does not exist” doesn't exist."...)

扁平化的发布者

虽然一开始有些神秘,但扁平化的概念并不复杂,难以理解。通过几个精选的例子,你会了解到它的一切。

flatMap(maxPublishers:_:)

flatMap操作符将多个上游发布者平移为一个下游发布者--或者更具体地说,平移来自这些发布者的发送。

flatMap返回的发布者与它接收的上游发布者的类型不一样,而且通常也不会一样。

CombineflatMap的一个常见用例是当你想把一个发布者发出的元素传递给一个本身返回一个发布者的方法,并最终订阅该第二个发布者发出的元素。

是时候实现一个例子来看看这个动作了。添加这个新的例子:

example(of: "flatMap") {
  // 1
  func decode(_ codes: [Int]) -> AnyPublisher<String, Never> {
    // 2
    Just(
      codes
        .compactMap { code in
          guard (32...255).contains(code) else { return nil }
          return String(UnicodeScalar(code) ?? " ")
        }
        // 3
        .joined()
    )
    // 4
    .eraseToAnyPublisher()
  }
}

从上面看,你:

  1. 定义一个函数,该函数接收一个整数数组,每个数组代表一个ASCII码,并返回一个类型清除的字符串发布者,该发布者从不发出错误。
  2. 创建一个Just发布者,如果字符代码在0.255的范围内,就将其转换为字符串,这包括标准和扩展的可打印ASCII字符。
  3. 将字符串连接在一起。
  4. 输入擦除发布者,以匹配该函数的返回类型。

Note

关于ASCII字符编码的更多信息,你可以访问www.asciitable.com

完成这些工作后,将这段代码添加到你当前的例子中,使该函数和flatMap操作符发挥作用:

// 5
[72, 101, 108, 108, 111, 44, 32, 87, 111, 114, 108, 100, 33]
  .publisher
  .collect()
  // 6
  .flatMap(decode)
  // 7
  .sink(receiveValue: { print($0) })
  .store(in: &subscriptions)

有了这个代码,你就可以:

  1. 将秘密信息创建为ASCII字符代码数组,将其转换为一个发布者,并将其发射的元素收集到一个单一的数组中。
  2. 使用flatMap将数组元素传递给你的解码器函数。
  3. 订阅由decode(_:)返回的pubisher发出的元素,并打印出这些值。

运行Playground,你会看到以下情况:

——— Example of: flatMap ———
Hello, World!

回顾一下前面的定义:flatMap将所有收到的发布者的输出平铺到一个单一的发布者中。这可能会造成内存问题,因为它将缓冲你发送给它的许多发布者,以更新它向下游发送的单一发布者。

为了了解如何管理这个问题,请看这个flatMap的时序图:

img

在图中,flatMap接收三个发布者。P1, P2, 和P3. 每个发布者都有一个value属性,它也是一个发布者。flatMapP1P2发射value发布者的值,但是忽略P3,因为maxPublishers被设置为2。你将在第19章"测试"中获得更多关于flatMap和其maxPublishers参数的练习。

现在你已经掌握了Combine中最强大的操作符之一。然而,flatMap并不是用不同的输出来交换输入的唯一方法。因此,在结束本章之前,你将学习一些更有用的操作来进行老式的切换。

替换上游的输出

map例子的前面,你使用了FoundationFormatter.string(for:)方法。它产生了一个可选的字符串,你使用了nil-coalescing操作符(??)将一个nil值替换为一个非nil值。Combine还包括一个操作符,当你想总是传递一个值时,你可以使用它。

replaceNil(with:)

正如下面的时序图所描述的,replaceNil将接收可选的值,并用你指定的值替换nil

img

把这个新的例子添加到你的Playground

example(of: "replaceNil") {
  // 1
  ["A", nil, "C"].publisher
    .eraseToAnyPublisher()
    .replaceNil(with: "-") // 2
    .sink(receiveValue: { print($0) }) // 3
    .store(in: &subscriptions)
}

你刚才做了什么:

  1. 从一个可选字符串的数组中创建一个发布者。
  2. 使用replaceNil(with:)将从上游发布者收到的nil值替换为新的非nil值。
  3. 打印出该值。

Note

replaceNil(with:)有一些重载,可能会使Swift混淆,为你的用例选择错误的重载。这将导致类型保持为Optional<String>,而不是被完全解包。上面的代码使用eraseToAnyPublisher()来解决这个错误。你可以在Swift论坛上了解更多关于这个问题的信息:https://bit.ly/30M5Qv7

运行Playground,你会看到下面的情况:

——— Example of: replaceNil ———
A
-
C

使用nil-coalescing操作符??replaceNil之间有一个微妙但重要的区别。??操作符仍然可以产生nil结果,而replaceNil不能。将replaceNil的用法改为如下,你将看到错误的操作符重载所引起的错误:

.replaceNil(with: "-" as String?)

在继续前行之前,请恢复这一变化。这个例子还展示了你如何以一种组合方式将多个操作符连锁起来。这允许你以多种方式操纵从原点发布者到订阅者的值。

replaceEmpty(with:)

你可以使用replaceEmpty(with:)操作符来替换--或者说,插入--一个值,如果一个发布者完成后没有发出一个值。

在下面的时序图中,发布者完成后没有发出任何东西,这时replaceEmpty(with:)操作符插入了一个值,并将其发布到下游:

img

添加这个新的例子,看看它的运作情况:

example(of: "replaceEmpty(with:)") {
  // 1
  let empty = Empty<Int, Never>()

  // 2
  empty
    .sink(receiveCompletion: { print($0) },
          receiveValue: { print($0) })
    .store(in: &subscriptions)
}

你在这里做什么:

  1. 创建一个空的发布者,立即发出一个完成事件。
  2. 订阅它,并打印收到的事件。

使用Empty发布者类型来创建一个立即发出.finished完成事件的发布者。你也可以通过给它的completeImmediately参数传递false来配置它,使其永远不发射任何东西,默认是true。这个发布者对于演示或测试目的很有用,或者当你想做的只是向订阅者发出一些任务完成的信号。运行Playground,你会看到它成功地完成了:

——— Example of: replaceEmpty ———
finished

现在,在调用sink之前插入这行代码:

.replaceEmpty(with: 1)

Run the playground again, and this time you get a 1 before the completion:

1
finished

递增地转换输出

你已经看到了Combine是如何包括诸如map这样的操作符的,这些操作符与Swift标准库中的高阶函数相似。然而,Combine还有一些小技巧,让你可以操作从上游发布者那里收到的值。

scan(_:_:)

在转换类别中,一个很好的例子是scan。它将向一个闭包提供由上游发布者发出的当前值,以及由该闭包返回的最后一个值。

在下面的时序图中,scan开始时存储了一个起始值0。当它从发布者那里收到每个值时,它将其添加到之前存储的值中,然后存储并发布结果:

img

Note

如果你使用完整的项目来输入和运行这段代码,就没有直接的方法来绘制输出--就像在Playground上一样。相反,你可以将下面例子中的sink代码改为.sink(receiveValue: { print($0) })

关于如何使用scan的实际例子,请将这个新的例子添加到你的Playground

example(of: "scan") {
  // 1
  var dailyGainLoss: Int { .random(in: -10...10) }

  // 2
  let august2019 = (0..<22)
    .map { _ in dailyGainLoss }
    .publisher

  // 3
  august2019
    .scan(50) { latest, current in
      max(0, latest + current)
    }
    .sink(receiveValue: { _ in })
    .store(in: &subscriptions)
}

在这个例子中,你:

  1. 创建一个计算的属性,生成一个-1010之间的随机整数。
  2. 使用该生成器从一个随机整数数组中创建一个发布者,该数组代表一个月内虚构的每日股票价格变化。
  3. 使用scan,起始值为50,然后将每天的变化添加到运行的股票价格中。使用max可以保持价格不为负值--幸好股票价格不能低于零!

这一次,你没有在订阅中打印任何东西。运行Playground,然后点击右边结果侧边栏的方形显示结果按钮:

img

谈论牛市运行! 你的股票表现如何?

还有一个抛出错误的tryScan操作符,其工作原理与此类似。如果闭包抛出一个错误,tryScan就会因为这个错误而失败。

挑战

熟能生巧。完成这项挑战,以确保你在继续前进之前能够很好地使用转换操作符。

挑战:使用转换操作符创建一个电话号码查询工具

本挑战的目标是创建一个能做两件事的发布者。

  1. 接收一个由十个数字或字母组成的字符串。
  2. 在联系人数据结构中查找该号码。

挑战文件夹中的启动程序包括一个contacts字典和三个函数。你需要使用转换操作符和这些函数来创建一个订阅input发布者。在Add your code here占位符下面,在测试你的实现的forEach块之前,插入你的代码。

Tip

如果函数签名匹配,你可以直接将一个函数或闭包作为参数传递给操作符。例如,`map(convert)'。

分解这个挑战,你需要:

  1. 将输入转化为数字 - 使用convert函数,如果不能将输入转化为整数,将返回nil
  2. 如果前面的操作符返回nil,用0代替。
  3. 每次收集10个值,这些值对应于美国使用的3位区号和7位电话号码的格式。
  4. 对收集到的字符串值进行格式化,以符合联系人字典中电话号码的格式--使用提供的format函数。
  5. "拨号"从前一个操作员那里收到的输入--使用提供的dial功能。

解决方案

你的代码是否产生了预期的结果?从订阅input开始,首先你需要把输入的字符串一个一个地转换为整数:

input
  .map(convert)

接下来你需要用0来替换convert返回的nil值:

.replaceNil(with: 0)

为了查询前面操作的结果,你需要收集这些值,然后将其格式化,以符合contacts字典中使用的电话号码格式:

.collect(10)
.map(format)

最后,你需要使用dial函数来查找格式化的字符串输入,然后订阅:

.map(dial)
.sink(receiveValue: { print($0) })

运行该Playground将产生以下结果:

——— Example of: Create a phone number lookup ———
Contact not found for 000-123-4567
Dialing Marin (408-555-4321)...
Dialing Shai (212-555-3434)...

如果你把它与VoIP服务连接起来,则可获得额外加分!

关键点

  • 你把对发布者的输出进行操作的方法称为"操作符"。
  • 操作符也是发布者。
  • 转化操作符将来自上游发布者的输入转换成适合下游使用的输出。
  • 时序图是可视化每个组合操作符如何工作的好方法。
  • 在使用任何缓冲值的操作符(如collectflatMap)时要小心,以避免内存问题。
  • 在应用Swift标准库中已有的函数知识时要注意。一些名字相似的组合操作符的工作原理是一样的,而其他操作符的工作原理则完全不同。
  • 在订阅中把多个操作符串联起来,对发布者发出的事件进行复杂的复合转换,这很常见。

接下来去哪?

要走的路! 你刚刚把自己改造成了一个改造的巨人。

现在是时候学习如何使用另一个基本的操作符集合来过滤你从上游发布者那里得到的东西了。