Using Publishers to Prevent Hanging Timers
One of the venerable Foundation
APIs to get a Combine
extension in Catalina/iOS 13 is the Timer
class:
static func publish(
every interval: TimeInterval,
tolerance: TimeInterval? = nil,
on runLoop: RunLoop,
in mode: RunLoop.Mode,
options: RunLoop.SchedulerOptions? = nil)
-> Timer.TimerPublisher
In practice we’d probably use it something like:
import Foundation
import Combine
let subscription =
Timer.publish(every: 1.0, on: .main, in: .default)
.autoconnect()
.sink { _ in
print("timer fired")
}
// Time passes...
subscription.cancel()
Note that Timer.publish
returns a ConnectablePublisher
meaning we need to call connect
or use autoconnect
to start it publishing. Otherwise this feels like a pretty straight-forward definition of a timer.
So straight-forward, in fact, that it looks pretty familiar. Here’s how we might have written a timer before Combine
:
let timer =
Timer(timeInterval: 1.0, repeats: true) { _ in
print("timer fired")
}
RunLoop.main.add(timer, forMode: .default)
// Time passes...
timer.invalidate()
We explicitly add the timer to the run loop instead of using autoconnect
to start it. And to stop it we call invalidate
on the returned timer
ref rather than cancel
on an AnyCancellable
. But all the same pieces are there. The interval, run loop, closure to execute, etc.
So it’s tempting to say that, if we’re targeting Catalina/iOS 13 but not interested in filtering timer events through a bunch of Combine operators, there’s really no benefit to using Timer
’s publisher API.
But this overlooks a major yet somewhat invisible benefit, which is life cycle management.
Let’s say we have a class that uses an old-school timer:
class OldTimer {
let timer: Timer
init() {
timer =
Timer(timeInterval: 1.0, repeats: true) { _ in
print("Old Timer, go!")
}
RunLoop.main.add(timer, forMode: .default)
}
}
var old: OldTimer? = OldTimer()
// ⏱ Wait two seconds ⏱
old = nil
What do we we see in our output?
Old Timer, go!
Old Timer, go!
Old Timer, go!
Old Timer, go!
Old Timer, go!
Old Timer, go!
Old Timer, go!
Old Timer, go!
Old Timer, go!
…
Uh oh! We forgot some crucial cleanup in our deinit
:
class OldTimer {
let timer: Timer
// ...
deinit {
timer.invalidate()
}
}
Now we get the expected results:
Old Timer, go!
Old Timer, go!
But how easy is it to forget that deinit
? I do it practically every time I use a Timer
, and often in less obvious circumstances than what’s presented here.
Let’s look at the publisher case:
class NewSchool {
let subscription: AnyCancellable
init() {
subscription =
Timer.publish(every:1.0, on:.main, in:.default)
.autoconnect()
.sink { _ in
print("New School, I choose you!")
}
}
deinit {
subscription.cancel()
}
}
var new: NewSchool? = NewSchool()
// ⏱ Two seconds later ⏱
new = nil
We see:
New School, I choose you!
New School, I choose you!
Not a big difference, right? Here’s the trick, though: unlike a Timer
which lives on irrespective of our reference to it, the life cycle of a Subscription
is completeley tied up in its value. If it gets set to nil
and/or released, it automatically cancels its connection to its publisher.
So in our example, because the default behavior of properties like subscription
is to be retained while their object exists and automatically nil
’d out when it’s deallocated, we don’t need an explicit dealloc
statement at all! In other words:
class NewSchool {
let subscription: AnyCancellable
init() {
subscription =
Timer.publish(every:1.0, on:.main, in:.default)
.autoconnect()
.sink { _ in
print("New School, I choose you!")
}
}
}
var new: NewSchool? = NewSchool()
// ⏱ Two seconds later ⏱
new = nil
still does exactly what we want it to:
New School, I choose you!
New School, I choose you!
So publishers and subscriptions present a much more intuitive way to think about cancellation, invalidation, and life cycles. And that’s great! But it’s also worth pointing out they make the default, naïve implementation the correct one. This makes them effective protection against bugs of omission — the value of which shouldn’t be underestimated.