上一节末尾,我们提出了一个问题,为什么必须要保持 multicastB.connect()
返回值的引用才可以让 sinkC
和 sinkD
订阅到事件呢?这似乎又是个有悖于我们直觉的行为。在 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
外部,我们只创建了两个弱引用,而创建真正的 Sink
和 PassthroughSubject
对象,以及订阅,都是在 do
作用域内部发生的。按照我们的想象,离开作用域后,subject
应该被销毁,sink
的订阅也应该被取消。
但事实并不如此,从:
XCTAssertNotNil(weakSubject)
XCTAssertNotNil(weakSink)
这两个判断就可以看到,subject
和 sink
引用的对象依旧存活,并且,subject
也可以订阅到在 do
作用域之外发生的事件 1 和 .finished
。直到 .finished
之后,subject
和 sink
才会自然销毁。
这可是个我们应该绝对提高警惕的场景,甚至绝大多数情况下,这都不是我们预期的行为。至于为什么如此,以及未来这种行为会不会改变,由于 Combine 并不开源,我们也暂时无法得知了。通过这些试验,我们唯一能确定的就是:始终都应该在你的订阅模型中,保持一个 AnyCancellable
角色,它可以像我们预期的一样,管理 Combine 中订阅者和发布者的生存周期。
What's next?
以上,就是我们对 Combine 中内存管理方式的一些探索。回顾一下之前的各种研究,我们都是围绕着一个 Publisher
存在着多个订阅者的情况。下一节,我们来研究反过来的情况。可以让一个订阅者同时订阅多个 Publisher
么?此时的订阅者会收到来自这些 Publisher
的事件么?