1. Introduction

在过去几周中,我们终于了解了如何考虑我们正在开发的体系结构中的副作用(第1部分、第2部分、第3部分、第4部分)。
这可能是我们最需要的一集,我们发现,如果你想在reducers方面建模你的应用程序的架构,那么一个副作用就是返回一个值,这个值封装了一个工作单元,然后由store执行。这使得我们的reducers很好且易于理解,并将混乱的effects执行委托给store,在那里它在运行时解释它们。

reducer返回的值叫做Effect,它实际上是对我们在Point-Free上遇到过很多次的一种类型的重命名,以前叫做Parallel。它只是一个简单的结构,它包装了一个函数,该函数接受一个函数作为其第一个参数,有时称为“callback”,然后返回void。这允许我们将一个异步工作单元表示为一个值,例如,一个网络请求可以表示为一个Effect值,当URLSession数据任务完成时调用回调。我们还看到,这种Effect类型支持map操作,这为我们提供了一种转换效果的轻量级方法,我们还看到,这允许我们在应用程序中极大地清理有效的代码。

然而,我们在这一系列剧集的结尾有一点奇怪。 在iOS社区中,甚至在苹果生态系统中,有一些东西看起来很像Effect类型。这种类型有很多名字,但其根思想有时被称为“reactive streams”,并且在许多开源库中都有这种思想的实现,比如ReactiveSwiftRxSwift,最近苹果通过他们的Combine框架加入了竞争。

所以在本集中,我们想要利用这些社区的所有伟大工作来展示我们如何不必为我们的架构维护我们自己的反应效果库。 我们真的可以用这些库来替换Effect类型,事情应该会进展得很顺利。 但是,为了本集的目的,我们需要选择一个,为了简单起见,我们将选择Combine,因为我们不需要引入依赖项。我想强调的是,在本集中发生的所有事情对于ReactiveSwift和RxSwift都同样有效,我们强烈建议您将自己选择的响应式库移植到架构中,以证明这一点。

2. The Effect type: a quick recap

让我们先来研究一下Combine API,以便了解它与之前设计的Effect类型的比较。

我们已经在Point-Free上多次讨论了Effect类型的形状,首先是为了理解map函数,然后是试图理解逆变性,然后是试图理解zip和flatMap的属性,然后是当我们需要重构快照测试库以使用异步值时。最近,我们给这种形状起了个名字“Effect”,下面就是它的所有荣耀:

public struct Effect<A> {
  public let run: (@escaping (A) -> Void) -> Void

  public func map<B>(_ f: @escaping (A) -> B) -> Effect<B> {
    return Effect<B> { callback in self.run { a in callback(f(a)) } }
  }
}

这是一种非常简单的类型。它表达了一个类型有能力随时向您交付值的想法。这对于异步来说是完美的。例如:

import Dispatch

let anIntInTwoSeconds = Effect<Int> { callback in
  DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
    callback(42)
  }
}

该值表示一个整数,可以在以后需要传递该值时进行传递。代码块是没有立即完成的。它只在我们决定运行这个值时起作用:

anIntInTwoSeconds.run { int in print(int) }
// 42

这个会在2秒后打印出来。

这种不立即工作的特性被称为“laziness”。只有在被要求时才会完成运作。 与此相反的是“eager”,我们可以对Effect类型做一些小的改变,以便它在创建时就开始工作。这将是我们很快要理解的一个重要区别。

Effect还支持map操作,它为我们提供了一种非常简单的方法来转换保存在里面的值:

let squared = anIntInTwoSeconds.map { $0 * $0 }
// Effect<Int>

这是Effect类型的基础,但我们还可以说更多。例如,这种类型肯定支持zip操作,可以并行运行多个effects,然后将它们的值收集到一个值中,它还支持flatMap操作,它允许您将异步值排序在一起。我们还可以考虑更复杂的“higher-order effects””,即将effects作为输入,将返回effects作为输出的函数。你可以用这些东西实现很多东西,比如 cancellation and debouncing

但是,从本质上来说,Effect类型是非常简单的。所以,如果你对这种材料感到舒服,那么不需要太多的工作来获得对Combine的基本理解。Combine就像一个增压的,强化的Effect。它表达了Effect类型所能表达的一切,但也有很多别的东西。

3. The Combine-Effect Correspondence

从根本上说,Combine框架有两个概念:publishers and subscriberspublishers是能够向任何感兴趣的人传递价值的类型。这正是Effect的特点,但Combinepublishers提供了更多的附加功能。subscribers是可以接收值的类型。在我们的Effect类型中,这个概念没有名字,但最接近的概念是当我们调用一个Effectrun方法来让这个Effect工作时。Combine为概念subscriber提供了一种类型,因为它们支持更多的功能,包括cancellation and demandcancellation允许您停止subscriber获取任何未来的值,而demand允许subscriberspublishers沟通他们想要接收多少值。

这就是CombineEffect类型的基本对应关系。 当我们说到“publisher”时,只考虑我们的Effect类型,当我们说到“subscriber”时,只考虑我们在effect上的run

4. Publishers

但现在让我们动手做得更深入一点,展示如何实际创建publishers and subscribers,并看看API如何与我们的Effect类型相关。

让我们简单地开始。在effect世界中,我们很容易创造出一个值,并在一小段延迟后交付:

let anIntInTwoSeconds = Effect<Int> { callback in
  DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
    callback(42)
  }
}

我们如何通过Combine来实现这一点? 有一些高级的运算符可以很快地帮我们完成这个,但是让我们从基本原理开始。我们该如何构建publisher?

import Combine

Publisher.init

🛑 Protocol ‘Publisher’ can only be used as a generic constraint because it has Self or associated type requirements

好了,这就引出了我们的第一课,当谈到Combine:大多数概念都表示为协议而不是具体的类型。Publisher类型实际上是一个协议,它甚至有关联的类型,因此我们不会经常直接处理Publisher类型。

由于具有关联类型的协议的这种缺陷,Combine为我们提供了一个Publisher协议的具体实现,称为AnyPublisher。为协议提供“any”包装器(也称为“类型擦除”包装器)是非常流行的,这样您就可以轻松地实例化协议的实例,而不必自己进行自定义一致性。那么,让我们看看如何创建AnyPublisher:

AnyPublisher.init(<#publisher: Publisher#>)

它只有一个初始化器,它只接受一个publisher。所以这个现在帮不了我们。我们正在寻找创建发布者的方法,而不需要遵循发布者协议的全新类型。

有时,当提供了这些“any”包装器时,有一种方法可以用底层协议的所有功能实例化它们。

例如,Iterator协议的AnyIterator包装器提供了一种简单的方法来创建迭代器,通过提供一个闭包来表示计算迭代中的下一个值:

var count = 0
let iterator = AnyIterator<Int>.init {
  count += 1
  return count
}
// AnyIterator<Int>

这表示一个迭代器从1到无穷进行计数,但我们可以取前10个值:

Array(iterator.prefix(10))
// [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

不幸的是,AnyPublisher并没有提供给我们这样好的内容。

那么,我们还有什么可以利用的? Combine为我们提供了另一个名为FuturePublisher的具体实现,它带有一个基于回调的初始化器,就像Effect类型一样:

Future.init(<#attemptToFulfill: (@escaping (Result<_, _>) -> Void) -> Void#>)

这个初始化器给你一个回调,你可以用一个result值来调用它。这里使用结果是因为future可以通过一个值成功,也可以失败。这意味着我们需要在使用这个初始化式之前指定这些类型。现在,让我们用Never作为失败通用型来代表永不失败的publisher:

Future<Int, Never>.init { callback in
  <#code#>
}

现在一旦我们有了一些数据我们可以调用这个回调。例如:

Future<Int, Never> { callback in
  callback(.success(42))
}

我们也可以在我们的future值上增加一个延迟,以便以后交付:

let aFutureInt = Future<Int, Never> { callback in
  DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
    callback(.success(42))
  }
}

所以现在创造这种future值的方式就像我们创造Effect类型的值一样。我们只需要打开一个闭包,然后得到一个回调函数,然后我们可以在任何时候用我们的数据调用那个回调函数。

5. Subscribers

为了获得future的值,我们必须订阅。这类似于我们run我们的effect值,但我们可以subscribe。 我们在订阅的时候有很多选择。

我们真正感兴趣的是一个subscriber:

.subscribe(<#subscriber: Subscriber#>)

其他的则是关于发布者订阅的dispatch queue or run loop

回想一下,在其核心,Combine主要关注的是发布者和订阅者。publisher,就像我们这里的Future值一样,是一种可以向任何感兴趣的人传递值的类型,而subscriber是一种可以接收值的类型。 因此,在这里提供一个Subscriber以某种方式允许我们从未来接收值,然后使用该值做一些事情,比如打印它。那么,我们如何创建订阅者呢?

Subscriber.init

🛑 Protocol ‘Subscriber’ can only be used as a generic constraint because it has Self or associated type requirements

Welp,再一次将这个概念抽象到协议背后。它有相关的类型。所以我们不能直接处理订阅者的问题。但是,幸运的是,Combine提供了AnySubscriber包装器类型,与AnyPublisher不同,它对我们的情况实际上很有用。它有4个初始化器。

这里列出的第一个对我们来说特别有趣:

AnySubscriber.init(
  receiveSubscription: <#((Subscription) -> Void)?#>,
  receiveValue: <#((_) -> Subscribers.Demand)?#>,
  receiveCompletion: <#((Subscribers.Completion<_>) -> Void)?#>
)

这让我们可以进入3个定义事件的订阅:

  • subscriber附加订阅到publisher时,它表示为,我们得到了一个Subscription对象。它就像被连接的用户的凭证。我们可以使用subscription 对象来表示我们希望从发布者获得多少值。

  • publisher提供值时,我们就可以利用这些值做一些事情,比如打印出来。它需要返回一个Demand值,这允许我们告诉publisher我们还想从他们那里得到多少值。这是一个强大的功能,特别是对于那些可以发送大量数据的publisher来说,但是我们现在并不需要这个功能。

  • 最后,当publisher完成时,它会传递一个完成值,这表明它要么成功完成,要么失败完成。

所以,让我们填充这些闭包,以便我们可以创建我们的订阅:

aFutureInt.subscribe(
  AnySubscriber<Int, Never>(
    receiveSubscription: { subscription in
      print("subscription")
      subscription.request(.unlimited)
  },
    receiveValue: { value in
      print("value", value)
      return .unlimited
  },
    receiveCompletion: { completion in
      print("completion", completion)
  })
)

And we can now run it.

// subscription
// value 42
// completion finished

但同时,它看起来,肯定比仅仅在effectrun要多得多。但这也带来了更大的冲击。首先,它内置了demand的概念,这个概念很强大,但目前也不需要。它还具有取消功能,这可以通过订阅的cancel方法来完成:

subscription.cancel()

它可能很强大,但我们现在并不需要它。

幸运的是,有一种更方便的方式来订阅publisher,当你不需要要求subscribers的全部力量。在publishers上有两个方法,称为sink

它们允许您只通过调用receiveValue和receivcomplete事件来订阅发布者。你不能访问实际的订阅,你不能控制需求。它假设需求是无限的。

这个方法非常容易使用,它基本上看起来就像run for effects:

aFutureInt.sink { int in
  print(int)
}

然而,当我们这样做时,什么也打印不出来。这是因为sink实际上返回了一些东西,而subscribe则没有,并且返回值允许我们取消向我们的接收器交付的future值。因为我们没有保留那个值,它会立即被释放,然后取消订阅。

返回值的类型被称为AnyCancellable,这是另一个“any”包装器,但这次是针对Cancellable协议,如果我们保留它,我们最终会在2秒后得到我们的值:

let cancellable = aFutureInt.sink { int
  in print(int)
}

我们甚至可以取消这个cancellable值,以防止该值被发送到我们的接收器(sink):

cancellable.cancel()

现在这看起来更像我们运行effects时所做的。我们只需调用一个方法,就可以利用publisher提供的任何值。值得注意的是,游乐场有一些隐式行为,让这种cancellable值长期存在,这是我们的值得以传递的原因。在实际应用中,你需要自己保存这个值,比如存储在视图控制器的实例变量中。

6. Eagerness vs. laziness

我们开始看到Effect类型和Combine框架之间的对应关系,这可能会让我们相信,我们可以将我们的老朋友Effect从我们架构中的职责中解脱出来,而开始更多地依赖于Combine框架。也许我们只是用Future替换Effect的所有实例,用sink替换run的所有实例。

不幸的是,现在情况并非如此。我们的代码现在有一个微妙的问题,所以让我们来解决它。

为了查看第一个问题,让我们在future中添加一个print语句:

let aFutureInt = Future<Int, Never> { callback in
  DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
    print("Hello from inside the future!")
    callback(.success(42))
  }
}

如果我们运行这段代码,我们将得到一个print语句,即使future被取消了。

我们甚至可以注释掉整个sink

//let cancellable = aFutureInt.sink { int in
//  print(int)
//}
//cancellable.cancel()

我们仍然得到print语句,即使没有人再引用future

这是因为Future类型是eager,这意味着它在创建时就开始工作,而不是在订阅时。

这是一个相当大的陷阱,当然不是我们的reducer想要的。我们的简化程序的美妙之处在于,它们是纯粹的函数,用于在给定某些用户操作的情况下改变应用程序的当前状态,然后它们返回一个effects数组,这些effects稍后将由store运行。如果我们使用这些Future类型,那么我们将在reducer被调用的那一刻开始执行它。这将是特别令人惊讶的测试,如果我们只想测试reducer如何改变一些状态,但在幕后,effects却在暗中触发!

幸运的是,在Combine中有一种非常简单的方法可以将一个eagerpublisher变成一个lazypublisher。我们可以简单地把它包装在一个Deferred publisher中,它有一个初始化式,接受一个返回publisher的闭包:

let aFutureInt = Deferred {
  Future<Int, Never> { callback in
    DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
      print("Hello from inside the future!")
      callback(.success(42))
    }
  }
}

这使得future不会立即运行,但如果我们创建一个接收器,它将启动:

let cancellable = aFutureInt.sink { int in
  print(int)
}

好了,这就解决了急切问题,而且在处理Combine时也给我们上了重要的一课:有时候,Combine中的东西是eager,但我们从不希望我们的架构中有eager(热切的)的东西。幸运的是,有一种很好的方法可以把eager的发布者变成lazy的发布者,但如果Combine的架构清楚地指出eagerpublishers ,那就更好了。

7. Subjects

我们使用Future的下一个问题是,它实际上只表示一个稍后可以交付的值。它不能提供多种价值:

let aFutureInt = Deferred {
  Future<Int, Never> { callback in
    DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
      print("Hello from inside the future!")
      callback(.success(42))
      callback(.success(1729))
    }
  }
}

当它运行时,我们只有42个被送到sink。一旦Future接收到一个值,它将立即完成,不再发出其他值。

这就是Future的设计。我们肯定能产生需要传递多种值的effects。 例如,如果我们有一个表示套接字连接的effects。我们希望将来自该套接字连接的所有值传递给我们的reducer。我们还可以有一个表示reachabilityeffect,每当应用的reachability状态发生变化时,我们可以发出一个值,这样reducer就可以对这些事件做出反应。

我们的effect类型没有这个限制:

let twoIntsInTwoSeconds = Effect<Int> { callback in
  DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
    callback(42)
    callback(1729)
  }
}

twoIntsInTwoSeconds.run { print($0) }
// 42
// 1729

所以,尽管Future的初始化器看起来很像我们的Effect类型,但它并不完全相同。似乎在Combine框架中没有publisher允许你用一个接受回调的闭包来初始化它,这样你就可以在任何时候给它提供你想要的任意多的值。

幸运的是,当我们需要向publisher发送多个值时,Combine还有另一个概念,publisher可以反过来通知subscribers。它被称为“subject”,这不是我们在简单的Effect类型中需要处理的概念,但它对于连接非Combine世界和Combine世界非常有用。

主题由Subject类型表示,如果尝试初始化该类型:

Subject.init

Subject,好比发布者和订阅者,也是协议,所以它本身不是很有用。

幸运的是,Combine提供了Subject协议的两个具体实现,称为passthrough和current value subjects:

let passthrough = PassthroughSubject<Int, Never>()
let currentValue = CurrentValueSubject<Int, Never>(value: 1)

它们之间的主要区别是,通过currentvaluessubject,您可以访问最近发出的值(这就是为什么在创建它时必须提供显式的值),而passthrough subjects中的值只能通过subscribing来访问。

我们可以像publisher一样订阅一个主题,可以使用subscriber方法,也可以使用sink方法,为了获得后面的值,我们需要保留一个可取消的值,这样我们的订阅才会继续:

let c1 = passthrough.sink { x in print("passthrough", x) }
let c2 = currentValue.sink { x in print("currentValue", x) }

当我们运行它时,我们立即得到一个当前值,但是passthrough subject仍然是空的。

// currentValue 2

然后我们可以直接将值发送给subject,对*publisher**来说这通常是不可能的:

passthrough.send(42)
currentValue.send(1729)
// passthrough 42
// currentValue 1729

Future类型不同,我们可以自由发送我们想要的任何值。

passthrough.send(42)
currentValue.send(1729)
passthrough.send(42)
currentValue.send(1729)
// passthrough 42
// currentValue 1729
// passthrough 42
// currentValue 1729

这当然不像Effect类型那么容易,但尽管如此,Combine确实让我们能够创建一个publisher,我们可以发送许多值,以便它将这些值发布给它的订阅者。

8. Next time: refactoring the architecture

这就是Combine框架的基础。还有很多话要说,但我们学到的足够重武器了。我们也了解了Combine和Effect之间的对应关系。

总而言之:在Combine的世界里,我们有publishers;在Effect的世界里,我们有Effect;在Combine的世界里,我们有subscribers;在Effect的世界里,我们有run。幸运的是,Combine附带了一些附加功能,比如sink,就像在Effect上的run一样。并进一步Combine comes with Future,创造了一个Effects的相似物,但需要说明的是,它们非常迫切,需要包装在Deferred publisher中,而且它们只能接收单个值,这意味着我们必须使用另一个Combine概念,即subjects,来简单地设置更长寿的事件流。

这是基本的对应关系,所以问题是,我们是否可以重构我们已经构建的可组合架构,以利用Combine的功能,而不是自己从头构建它?

让我们重构掉Effect类型,这样我们就可以利用Combine来避免重新发明轮子……!