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.