I needed to cancel subscriptions (AnyCancellable
) made using sink inside the sink
closure without causing any leakage. It didn’t turn out to be straightforward. We’ll explore how I solved that here.
For this example, we’ll use a dummy Timer
Publisher
:
struct SingletonTimerProvider {
static let timer =
Timer.publish(every: 2, on: .main, in: .default).autoconnect()
}
We have a UIViewController
that recursively subscribes to the Timer
. Every time the subscription’s sink
closure gets executed, it creates another subscription. They stop creating more subscriptions when they reach a certain threshold. The starting code looks like this:
final class ViewController: UIViewController {
private var cancellables = Set<AnyCancellable>()
private let startingTimeInterval = Date().timeIntervalSince1970
private let maxThreshold = 10.0
override func viewDidLoad() {
super.viewDidLoad()
subscribeToTimer(threshold: 3.0)
}
private func subscribeToTimer(threshold: TimeInterval) {
SingletonTimerProvider.timer.sink { [weak self] value in
print("Received timer value: \(value)")
guard let self = self else {
return
}
print("Subscriptions = \(self.cancellables.count)")
if value.timeIntervalSince1970 - self.startingTimeInterval
< min(threshold, self.maxThreshold) {
print("Recursively subscribing to timer")
self.subscribeToTimer(threshold: threshold + 3.0)
}
}.store(in: &cancellables)
}
}
The immediately noticeable problem with this is that as the subscriptions reach the threshold, they stop creating child subscriptions, but they are still active. The log looks like this:
Received timer value: 2021-03-14 23:53:37 +0000
Subscriptions = 4
Received timer value: 2021-03-14 23:53:39 +0000
Subscriptions = 4
Recursively subscribing to timer
Received timer value: 2021-03-14 23:53:39 +0000
Subscriptions = 5
Recursively subscribing to timer
Received timer value: 2021-03-14 23:53:39 +0000
Subscriptions = 6
Received timer value: 2021-03-14 23:53:39 +0000
Subscriptions = 6
Received timer value: 2021-03-14 23:53:41 +0000
Subscriptions = 6
Received timer value: 2021-03-14 23:53:41 +0000
Subscriptions = 6
... and so on
The "Receive timer value"
logs go on forever, and the number of subscriptions stays at 6
. The Timer still executes the closures because we never cancel the subscriptions. To fix this, I tried to manually remove the subscription from the self.cancellables
property:
var cancellable: AnyCancellable?
cancellable = SingletonTimerProvider.timer.sink { [weak self] value in
// ...
if value.timeIntervalSince1970 - self.startingTimeInterval
< min(threshold, self.maxThreshold) {
// ...
} else {
if let cancellable = cancellable {
self.cancellables.remove(cancellable)
}
}
}
cancellables.insert(cancellable!)
This didn’t work out too well. The number of subscriptions reached zero, but the closures still got executed.
Received timer value: 2021-03-15 00:18:55 +0000
Subscriptions = 2
Received timer value: 2021-03-15 00:18:55 +0000
Subscriptions = 1
Received timer value: 2021-03-15 00:18:57 +0000
Subscriptions = 0
Received timer value: 2021-03-15 00:18:57 +0000
Subscriptions = 0
Received timer value: 2021-03-15 00:18:57 +0000
Subscriptions = 0
Received timer value: 2021-03-15 00:18:57 +0000
Subscriptions = 0
... and so on
It turns out that using the cancellable
has made the closure strongly capture that variable. And so the subscription still did not get cancelled. I was able to stop capturing cancellable
by setting it to nil
right after its removal from self.cancellables
.
if value.timeIntervalSince1970 - self.startingTimeInterval
< min(threshold, self.maxThreshold) {
// ...
} else {
if let cancellable = cancellable {
self.cancellables.remove(cancellable)
}
cancellable = nil
}
This worked well. It correctly cancelled the subscriptions. But it has one problem. If the view controller is dismissed manually by the user while the recursive subscriptions are still ongoing, there will be no chance for the cancellable = nil
to be called. And thus, the remaining subscriptions will continue to be active. It’s not as harmful as before because the self
is captured weakly, and there is a guard
. But it’s still a leak that I needed to solve. Here’s what the logs look like now:
Received timer value: 2021-03-15 00:26:10 +0000
Subscriptions = 1
Recursively subscribing to timer
Received timer value: 2021-03-15 00:26:12 +0000
Subscriptions = 2
Received timer value: 2021-03-15 00:26:12 +0000
Subscriptions = 1
Recursively subscribing to timer
Manually closed the view controller!
Received timer value: 2021-03-15 00:26:14 +0000
Received timer value: 2021-03-15 00:26:14 +0000
Received timer value: 2021-03-15 00:26:16 +0000
Received timer value: 2021-03-15 00:26:16 +0000
Received timer value: 2021-03-15 00:26:18 +0000
Received timer value: 2021-03-15 00:26:18 +0000
... and so on
The "Received timer value"
logs indicate that the subscriptions were still active if the user manually dismissed the view controller.
To fix this problem, we should avoid capturing the AnyCancellable
inside the closure. We still need to retrieve that AnyCancellable
so that we can cancel it. To get around that, I used an indirect reference.
- Use a
UUID
as a token that points to theAnyCancellable
. - Store the
AnyCancellable
along with theUUID
. - Use the
UUID
within the closure instead of theAnyCancellable
.
The last change looks like this:
private var cancellables = [UUID: AnyCancellable]()
private func subscribeToTimer(threshold: TimeInterval) {
let token = UUID()
let cancellable = SingletonTimerProvider.timer.sink { [weak self] value in
// ...
if value.timeIntervalSince1970 - self.startingTimeInterval
< min(threshold, self.maxThreshold) {
// ...
} else {
self.cancellables.removeValue(forKey: token)
}
}
cancellables[token] = cancellable
}
Now, the closure only references the token
, which is just a UUID
. Removing the AnyCancellable
via self.cancellables.removeValue(forKey: )
permanently cancels the subscription. And the subscriptions immediately get cancelled when the view controller is manually dismissed (deallocated).
You can find the full source code here: AnotherViewController.swift
.