Swift’s Scheduled Timer — Defusing a time bomb
Modernizing Swift’s Timer API
We have a leak…
The following View
and ViewModel
implementations for displaying a simple Timer
can leak memory:
struct TimerView: View {
@StateObject
private var vm = TimerViewModel()
var body: some View {
VStack {
Button(
vm.isInactive ? "start" : "stop",
action: vm.isInactive ? vm.startTimer : vm.stopTimer
)
Text(String(vm.elapsed))
}
}
}
class TimerViewModel: ObservableObject {
@Published
private(set) var elapsed: Int = 0
var isInactive: Bool {
timer == nil
}
private var timer: Timer?
func startTimer() {
timer?.invalidate()
timer = Timer.scheduledTimer(
withTimeInterval: 1,
repeats: true
) { [weak self] timer in
self?.elapsed += 1
}
}
func stopTimer() {
timer?.invalidate()
timer = nil
objectWillChange.send()
}
}
On 9 out of 10 days I can stare at that code and would not be able to spot an issue. If that is you and you’d like to try your hands in debugging see the example project here. (For a UIKit
version go here)
Since SwiftUI
View
s are value-types TimerViewModel
, which is a reference-type, must be the culprit. So under what circumstances does TimerViewModel
leak memory and why might it be difficult to spot? As the title suggests: the issue lies within the Timer
API. Command-clicking on scheduledTimer(withTimeInterval:repeats:block)
yields a subtle hint:
- parameter:
repeats
IfYES
, the timer will repeatedly reschedule itself until invalidated.
We do call invalidate()
when the Timer
is stopped. And when we create a Timer
we call invalidate()
on any potentially pre-existing instances… What else is there?
If all our app did was display TimerView
we don’t have an issue. But when presented by another View,
TimerViewModel
has the potential to leak memory. Let’s take a look at the example app. Here we have ContentView
displaying TimerView
. Additionally a Button
can hide TimerView
:
struct ContentView: View {
@State
private var timerIsShowing = true
var body: some View {
VStack {
if timerIsShowing {
TimerView()
}
Button(timerIsShowing ? "Hide timer" : "Show timer") {
timerIsShowing = !timerIsShowing
}
}
}
}
This is where we get into trouble. Starting and then hiding a Timer
will cause our TimerViewModel
to get deinitialized. Without a call to invalidate()
. If we show TimerView
again we will get a fresh instance of TimerViewModel
with its timer
property initialized to nil
. The Timer
we started previously will continue to re-schedule itself on RunLoop
, which has a strong reference to our Timer
instance but we no longer have any references to it. Adding a bit of logging inside Timer
’s block
closure reveals the issue:
Note: Had we captured self
not weak
ly but with unowned
our app would have crashed the first time our Timer
-instance fired after the TimerViewModel
instance that created it was deinitialized.
Manually invalidating on deinit
To stop leaking Timer
-instances (and potentially perform work during each re-scheduled RunLoop
-cycle) we could add a call to invalidate()
in TimerViewModel
’s deinitializer:
class TimerViewModel: ObservableObject {
deinit {
timer?.invalidate()
}
// ...
}
While the above works, it has a significant drawback: We are adding “cleanup code” far away from where we create our Timer
instance, which makes it difficult to maintain.
Introducing AnyCancellable
Swift’s Combine
framework uses AnyCancellable
to tie “cleanup code” to the deinitialization of a parent object. Let’s create our own version:
final class TypeErasedCancellable {
private var onDeinit: () -> Void
init(_ cancel: @escaping () -> Void) {
self.onDeinit = cancel
}
deinit {
onDeinit()
}
}
Note: If your your project already uses Combine
it makes sense to use AnyCancellable
instead of TypeErasedCancellable
.
Just like in TimerViewModel
we perform “cleanup code” when the instance gets released from memory. The difference is that now we get to pass in the “cleanup code” in form of a closure when we create a TypeErasedCancellable
instance.
Here is a first pass at refactoring TimerViewModel
:
class TimerViewModel: ObservableObject {
@Published
private(set) var elapsed: Int = 0
var isInactive: Bool {
cancellable == nil
}
private var cancellable: TypeErasedCancellable?
func startTimer() {
let timer = Timer.scheduledTimer(
withTimeInterval: 1,
repeats: false
) { [weak self] _ in
self?.elapsed += 1
}
cancellable = TypeErasedCancellable({
timer.invalidate()
})
}
func stopTimer() {
cancellable = nil
}
}
Instead of holding onto our Timer
with a property, we create a TypeErasedCancellable
property that contains our cleanup code. Now all the work to invalidate Timer
is physically close to its instantiation, which allows us to see the entire lifecycle of our Timer
-instance in one place. To stop Timer
we now just have to release our cancellable
from memory by setting it to nil
. As long as we didn’t create a retain cycle by directly or indirectly creating a strong reference to self
the cancellable
will get deinitialized, during which invalidate()
gets called on Timer
, which in turn will not reschedule itself on the RunLoop
; releasing all references to our Timer
.
The same will happen when our TimerViewModel
gets deinitialized, which is exactly what we were after! Logging confirms this:
Note: We are not guaranteed that the TimerViewModel
instance gets deallocated immediately when its View
is no longer visible. If Timer
’s block
closure had side-effects we would also have to manually invalidate Timer
in onDisappear(perform:)
.
There are a couple of improvements we can still make.
Making dependencies explicit
I personally don’t like that the creation of a Timer
has side effects on another object (the RunLoop
) that can hold a strong reference to our Timer
. This hides coupling and to an extend contributed to the initial difficulty of spotting the memory leak. Let’s improve Timer
’s API and remove hidden dependencies and potential retain-cycles:
extension Timer {
func schedule(
on runLoop: RunLoop = .main,
forMode mode: RunLoop.Mode = .default,
onCanceled: (() -> Void)? = nil
) -> TypeErasedCancellable {
runLoop.add(self, forMode: mode)
return TypeErasedCancellable { [weak self] in
self?.invalidate()
onCanceled?()
}
}
}
Timer
now has a schedule()
method that returns a TypeErasedCancellable
and makes it explicit we are interacting with RunLoop
. We also have the option to pass in some work we want to perform when a timer is cancelled. Starting a Timer
now looks like this:
class TimerViewModel: ObservableObject {
// ...
func startTimer() {
cancellable = Timer(
timeInterval: 1,
repeats: true
) { [weak self] _ in
self?.elapsed += 1
}.schedule()
}
}
Multiple TypeErasedCancellable
s
We can learn one last lesson from AnyCancellable
; if we want multiple cancellables released during deinitialization it would be convenient to be able to maintain them in a Collection
. Combine
uses a Set
, so let’s copy that approach.
TypeErasedCancellable
will have to conform to Hashable
:
extension TypeErasedCancellable: Hashable {
private var identifier: ObjectIdentifier {
ObjectIdentifier(self)
}
static func == (lhs: TypeErasedCancellable, rhs: TypeErasedCancellable) -> Bool {
lhs.identifier == rhs.identifier
}
func hash(into hasher: inout Hasher) {
hasher.combine(identifier)
}
}
We can now extend TypeErasedCancellable
with a method for inserting into a Set
of TypeErasedCancellable
s:
extension TypeErasedCancellable {
func store(in cancellables: inout Set<TypeErasedCancellable>) {
cancellables.insert(self)
}
}
or we could implement a custom +=
operator for a Set
of TypeErasedCancellable
s:
extension Set where Element == TypeErasedCancellable {
static func += (lhs: inout Self, rhs: Element) {
lhs.insert(rhs)
}
}
ViewModels and @MainActor
I consider it best practice to use the @MainActor
attribute for view-models. Timer
’s run block is a sendable closure. Under Swift 6 properties that are @MainActor
-isolated cannot be referenced from sendable closures. I use these convenience methods on Timer
for that purpose:
extension Timer {
@MainActor
convenience init(
every timeInterval: Double,
repeat block: @escaping @MainActor () -> Void
) {
self.init(timeInterval: timeInterval, repeats: true) { _ in
Task { @MainActor in block() }
}
}
@MainActor
convenience init(
once timeInterval: Double,
repeat block: @escaping @MainActor () -> Void
) {
self.init(timeInterval: timeInterval, repeats: false) { _ in
Task { @MainActor in block() }
}
}
}
The completed project can be found here.
A final poll:
Thanks so much for reading. If you enjoyed this article consider following me on threads @DudeOnSwift.