上一节末尾,我们提出了一个问题,为什么必须要保持 multicastB.connect() 返回值的引用才可以让 sinkCsinkD 订阅到事件呢?这似乎又是个有悖于我们直觉的行为。在 Combine 里,我们必须要小心翼翼地保持 Publisher 的引用才可以让订阅者正常订阅到事件么?

从一个最自然的模型开始

为了研究这个问题,我们先创建一个新的测试用例:

func testAnyCancellable() {
  let subject = PassthroughSubject<Int, Never>()
  var received = [Subscribers.Event<Int, Never>]()

  weak var weakCancellable: AnyCancellable?

  do {
    let anyCancellable = subject.sink(event: { received.append($0) })
    weakCancellable = anyCancellable

    subject.send(1)
  }

  XCTAssertNil(weakCancellable)

  subject.send(2)
  XCTAssertEqual(received, [1].asEvents(completion: nil))
}

这个测试用例的重点,就是中间我们用 do 手工创建的作用域。之所以要这样做,是为了尽可能避免不同编译设置下,编译器对代码作用域进行的优化。在这个作用域里,我们自己实现的 sink(event:) 返回一个 AnyCancellable 对象。在 Apple 官方文档里明确说明了,AnyCancellable 对象在离开作用域的时候,会自动调用 cancel() 方法取消订阅。

于是,received 中,应该有在 do 作用域内订阅到的事件 1。离开作用域后,anyCancellable 就被释放了,它会自动取消订阅,进而,weakCancellable 就会变成 nil,这也是我们要测试的第一个条件。之后,我们又通过 subject 生成了事件 2,此时 anyCancellable 已经不存在了,received 中自然也不会有新事件进账,这是我们要测试的第二个条件。

直接执行这个测试,结果应该和我们描述的一模一样,这也是最符合我们预期的行为。

不会自动取消的订阅者

但如果我们换一种订阅方式,直接使用 Subscribers.Sink,结果就有些迷惑了。把刚才那个测试用例改成这样:

func testSinkCancellation() {
  let subject = PassthroughSubject<Int, Never>()
  var received = [Subscribers.Event<Int, Never>]()

  weak var weakSink: Subscribers.Sink<Int, Never>?

  do {
    let sink = Subscribers.Sink<Int, Never>(
      receiveCompletion: { received.append(.complete($0))},
      receiveValue: { received.append(.value($0))})

    weakSink = sink
    subject.subscribe(sink)

    subject.send(1)
  }

  XCTAssertNotNil(weakSink)

  subject.send(2)
  weakSink?.cancel()
  subject.send(3)

  XCTAssertEqual(received, [1, 2].asEvents(completion: nil))
}

do 作用域里,我们只是把 anyCancellable 换成了 sink。从测试的条件可以看到,这次,weakSink 不再是 nil 了,这就意味着,尽管离开了作用域,sink 引用的对象仍旧是存活着的!直到我们手工调用了 weakSink?.cancel(),它才会释放自己,进而不会订阅到接下来的事件 3。按照我们之前的分析,之所以 sink 能存活下来,很有可能是 subject 创建的 Subscription 对象持有了 sink 所代表的订阅者的引用。

如果你第一次看到这个场景,想必会对此感到困惑。

一个不需要任何强引用的场景

但是,故事到此还没结束。实际上,不仅我们不需要保持订阅者的引用,就连 subject 的强引用也可以没有,整个订阅还是有机会正常工作。来看下面这个测试用例:

func testOwnership() {
  var received = [Subscribers.Event<Int, Never>]()

  weak var weakSubject: PassthroughSubject<Int, Never>?
  weak var weakSink: Subscribers.Sink<Int, Never>?

  do {
    let subject = PassthroughSubject<Int, Never>()
    weakSubject = subject

    let sink = Subscribers.Sink<Int, Never>(
      receiveCompletion: { received.append(.complete($0))},
      receiveValue: { received.append(.value($0))})
    weakSink = sink

    subject.subscribe(sink)
  }

  XCTAssertNotNil(weakSubject)
  XCTAssertNotNil(weakSink)

  weakSubject?.send(1)
  weakSubject?.send(completion: .finished)

  XCTAssertNil(weakSubject)
  XCTAssertNil(weakSink)

  XCTAssertEqual(received, [1].asEvents(completion: .finished))
}

这次,在 do 外部,我们只创建了两个弱引用,而创建真正的 SinkPassthroughSubject 对象,以及订阅,都是在 do 作用域内部发生的。按照我们的想象,离开作用域后,subject 应该被销毁,sink 的订阅也应该被取消。

但事实并不如此,从:

XCTAssertNotNil(weakSubject)
XCTAssertNotNil(weakSink)

这两个判断就可以看到,subjectsink 引用的对象依旧存活,并且,subject 也可以订阅到在 do 作用域之外发生的事件 1 和 .finished。直到 .finished 之后,subjectsink 才会自然销毁。

这可是个我们应该绝对提高警惕的场景,甚至绝大多数情况下,这都不是我们预期的行为。至于为什么如此,以及未来这种行为会不会改变,由于 Combine 并不开源,我们也暂时无法得知了。通过这些试验,我们唯一能确定的就是:始终都应该在你的订阅模型中,保持一个 AnyCancellable 角色,它可以像我们预期的一样,管理 Combine 中订阅者和发布者的生存周期

What's next?

以上,就是我们对 Combine 中内存管理方式的一些探索。回顾一下之前的各种研究,我们都是围绕着一个 Publisher 存在着多个订阅者的情况。下一节,我们来研究反过来的情况。可以让一个订阅者同时订阅多个 Publisher 么?此时的订阅者会收到来自这些 Publisher 的事件么?

所有订阅均支持 12 期免息分期

¥ 59

按月订阅

一个月,观看并下载所有视频内容。初来泊学,这可能是个最好的开始。

开始订阅

¥ 512

按年订阅

一年的时间,让我们一起疯狂地狩猎知识吧。比按月订阅优惠 28%

开始订阅

¥ 1280

泊学终身会员

永久观看和下载所有泊学网站视频,并赠送 100 元商店优惠券。

我要加入
如需帮助,欢迎通过以下方式联系我们