Ghostboard pixel

NSTimer - Repeat

NSTimer - Repeat

The NSTimer class is a workhorse of iOS applications, but it is also complex, fraught with hidden gotchas and cumbersome to use. For example, when a timer expires, its callback mechanism consists of performing a selector on a target object, which doesn’t work with a pure Swift class (or struct, etc.) and forces us to use NSObject-derived classes. Also, the run loop maintains a strong reference to a timer’s target object, which can be unexpected and problematic. To break the strong reference, we must stop the timer (by calling invalidate()), but only from the same thread on which the timer was installed. And once invalidated, timers cannot be reused.

by a guest blogger

The core Swift team often asks, “if we didn’t already have it, would we add it to Swift 3?” If the NSTimer API didn’t already exist, what kind of API might we like to have? It would probably:

  •  Be simple to use for simple cases, with a clean, Swifty syntax.
  •  Be closure based.
  • Allow us to hold weak references to objects.
  • Be thread-safe for subscribing and cancelling timers.

Let’s write some hypothetical snippets of how we might like to use an API like this in our program. Once we’re happy with the way it looks, we can work backwards and figure out the implementation details.

For a timer that’s going to run one time, we would need to provide the time interval and the code that’s going to execute:

Repeat.once(after: 1) {
      print("running once after 1 second")
}

Of course we should support a repeating timer as well:

Repeat.every(seconds: 5) {
      print("another 5 seconds has gone by..")
}

What if we might need to cancel our timer? There should be a way to keep track of a specific closure that we have scheduled, that doesn’t get in our way when we don’t need it, but is there when we do:

let id = Repeat.once(after: 10) {
      print("this is never going to run")
}
id.invalidate()

It might also be convenient to alter the timer from within the closure itself, perhaps in response to some condition. We might want to stop the timer, repeat it with the same interval, or with a different interval (not shown here):

Repeat.after(seconds: 5) {
      print("still here...")

      if shouldStop() {
            return .Stop
      } else {
            return .Repeat
      }
}

A closure-based API allows us to easily nest timers as well! The below example counts to 10 (after an initial delay), increasing its speed half-way through:

// after 3 seconds,
Repeat.once(after: 3) {
      var count = 0

      // start off refreshing every 1 second
      Repeat.after(seconds: 1) {
            print("count = \(count)")
            count += 1

            // if we get to 10, stop
            if count == 10 {
                  return .Stop
            }
            // at 5, go faster
            else if count == 5 {
                  return .RepeatAfter(0.5)
            }
            // otherwise repeat at the current rate
            else {
                  return .Repeat
                 }
            }
      }
}

Let’s recap where we are so far. Though the specific details of the above API could be altered to suit one’s preferences, we have created an API that:

  • Is clean and simple to use for simple cases, with increased sophistication available when needed.
  • Keeps the scheduling of the timer close to the code that will execute when the timer fires.
  • Uses closures in a way that feels at home in Swift code and allows for the use of capture lists to capture references weakly.

The following public functions are exposed (the source file contains more-detailed comments):

// Execute closure once after given delay (in seconds)
Repeat.once(after: NSTimeInterval, closure: () -> ()) -> RepeatSubscriberId

// Execute closure indefinitely with given delay
Repeat.every(seconds: NSTimeInterval, closure: () -> ()) -> RepeatSubscriberId

// Execute closure after given delay. Further executions/delays controlled by 
// return value from closure, which can be .Stop, .Repeat or .RepeatAfter(NSTimeInterval)
Repeat.after(seconds: NSTimeInterval, closure: () -> Repeat.Result) -> RepeatSubscriberId

// Invalidates closure execution for the given subscriber(s)
Repeat.invalidate(id: RepeatSubscriberId) -> Bool
Repeat.invalidate(ids: [RepeatSubscriberId]) -> [Bool]

A reference implementation, which uses GCD as the underlying timer mechanism, is available to download. A few implementation notes:

  • The public API is thread-safe via a lock on the singleton Repeat instance (equivalent to wrapping the body of each function in Objective-C’s @synchronized {}). Consequently, a reasonable number of timers is expected to exist at any one time – i.e. not hundreds/thousands/etc.
  • The provided closures are executed on the main queue.
  • Like NSTimer and GCD, this is not suitable for realtime needs (i.e. don’t drive your game’s run loop or latency-sensitive audio processing with this). According to Apple’s documentation, “the effective resolution of the time interval for a timer is limited to on the order of 50-100 milliseconds.”
import Foundation

typealias RepeatClosure        = () -> ()
typealias RepeatClosureWithRet = () -> Repeat.Result

typealias RepeatSubscriberId   = UInt


class Repeat {
    enum Result {
        case Stop
        case Repeat
        case RepeatAfter(NSTimeInterval)
    }
    
    private struct SubscriberInfo {
        let closure: RepeatClosureWithRet
        var timeInterval: NSTimeInterval
    }
    
    private static let sharedInstance = Repeat()
    
    private static var subscribers: [RepeatSubscriberId: SubscriberInfo] = [:]
    
    private static var _nextSubscriberId: RepeatSubscriberId = 0
    private static var nextSubscriberId: RepeatSubscriberId {
        let id = _nextSubscriberId
        _nextSubscriberId += 1
        return id
    }
    
    
    // Execute a closure once
     
    // - Parameters:
    // - after: The timeInterval in seconds after which the closure is executed
    // - closure: The closure to execute
     
    // - Returns: Id which can be used to invalidate execution of the closure
    
    static func once(after timeInterval: NSTimeInterval, closure: RepeatClosure) -> RepeatSubscriberId {
        let closureWithRet: RepeatClosureWithRet = {
            closure()
            return .Stop
        }
        return Repeat.dispatch(timeInterval, closure: closureWithRet)
    }
    
    
    // Execute a closure repeatedly
     
    // - Parameters:
    // - seconds: The timeInterval in seconds after which the closure is executed
    // - closure: The closure to execute
     
    // - Returns: Id which can be used to invalidate execution of the closure

    static func every(seconds timeInterval: NSTimeInterval, closure: RepeatClosure) -> RepeatSubscriberId {
        let closureWithRet: RepeatClosureWithRet = {
            closure()
            return .Repeat
        }
        return Repeat.dispatch(timeInterval, closure: closureWithRet)
    }
    
    
    // Execute a closure after a desired delay. The closure's return param - to be provided by the client - will control whether the closure repeats (with the same or a different delay) or stops.
     
    // - Parameters:
    // - seconds: The timeInterval in seconds after which the closure is executed
    // - closure: The closure to execute
     
    // - Returns: Id which can be used to invalidate execution of the closure

    static func after(seconds timeInterval: NSTimeInterval, closure: RepeatClosureWithRet) -> RepeatSubscriberId {
        return Repeat.dispatch(timeInterval, closure: closure)
    }
    
    
    // Internal function which does the subscription and scheduling of the closures
     
    // - Parameters:
    // - timeInterval: TimeInterval (in seconds) until execution of closure
    // - closure: Closure to execute, should return RepeatResult
     
    // - Returns: Id which can be used to invalidate execution of the closure
    
    static private func dispatch(timeInterval: NSTimeInterval, closure: RepeatClosureWithRet) -> RepeatSubscriberId {
        assert(timeInterval > 0, "Expecting intervalSecs to be > 0, not \(timeInterval)")
        
        // thread safety
        objc_sync_enter(Repeat.sharedInstance)
        defer { objc_sync_exit(Repeat.sharedInstance) }
        
        // setup info for the repeat request
        let id = Repeat.nextSubscriberId
        Repeat.subscribers[id] = SubscriberInfo(closure: closure, timeInterval: timeInterval)
        
        // call the actual dispatch
        Repeat.dispatch(subscriberId: id, timeInterval: timeInterval)
        
        return id
    }
    
    
    // Internal function which does the scheduling of the closures
     
    // - Parameters:
    // - subscriberId: SubscribedId to dispatch for
    // - timeInterval: time until the next desired callback. Could be looked up via `subscriberId`, but 'unrolled' to avoid the unnecessary dictionary lookup, as both call sites (of this function) have it readily available.
    
    static private func dispatch(subscriberId subscriberId: RepeatSubscriberId, timeInterval: NSTimeInterval) {
        assert(Repeat.subscribers.keys.contains(subscriberId), "Invalid subscriberId \(subscriberId)")
        assert(timeInterval > 0, "Expecting intervalSecs to be > 0, not \(timeInterval)")
        
        dispatch_after(dispatch_time(DISPATCH_TIME_NOW, Int64(NSTimeInterval(NSEC_PER_SEC) * timeInterval)), dispatch_get_main_queue()) {
            Repeat.sharedInstance.timerCallback(subscriberId)
        }
    }
    
    
    // Invalidates closure execution for the given subscriberId
     
    // - Parameters:
    // - id: SubscriberId to cancel execution for
     
    // - Returns: Whether a subscriber was found and invalidated
    
    static func invalidate(id: RepeatSubscriberId) -> Bool {
        objc_sync_enter(Repeat.sharedInstance)
        defer { objc_sync_exit(Repeat.sharedInstance) }
        
        return Repeat.subscribers.removeValueForKey(id) != nil
    }
    
    
    // Invalidates closure execution for the given subscribers
     
    // - Parameters:
    // - ids: SubscriberIds to invalidate
     
    // - Returns: List of booleans which indicate whether each given subscriber was found and invalidated
    
    static func invalidate(ids: [RepeatSubscriberId]) -> [Bool] {
        guard !ids.isEmpty else { return [] }
        
        objc_sync_enter(Repeat.sharedInstance)
        defer { objc_sync_exit(Repeat.sharedInstance) }
        
        return ids.map { Repeat.subscribers.removeValueForKey($0) != nil }
    }
    
    
    // Internal function which processes the timer callbacks
     
    // - Parameters:
    // - timer: Timer which triggered
    
    private func timerCallback(subscriberId: RepeatSubscriberId) {
        objc_sync_enter(Repeat.sharedInstance)
        defer { objc_sync_exit(Repeat.sharedInstance) }
        
        // if we no longer have a record of the subscriber, assume it was invalidated and return (without scheduling any further callbacks for that subscriber)
        guard let info = Repeat.subscribers[subscriberId] else { return }
        
        let result = info.closure()
        
        // the client may have just invalidated us in the above closure - if so, don't attempt to dispatch another callback
        guard Repeat.subscribers.keys.contains(subscriberId) else { return }
        
        switch result {
        case .Stop:
            Repeat.subscribers.removeValueForKey(subscriberId)
        case .Repeat:
            Repeat.dispatch(subscriberId: subscriberId, timeInterval: info.timeInterval)
        case .RepeatAfter(let interval):
            assert(interval > 0, "Expecting interval to be > 0, not \(interval)")
            Repeat.subscribers[subscriberId] = SubscriberInfo(closure: info.closure, timeInterval: interval)
            
            Repeat.dispatch(subscriberId: subscriberId, timeInterval: interval)
        }
    }
}

extension RepeatSubscriberId {
    
    // Invalidates closure execution for this subscriber.
     
    // Instead of calling `Repeat.invalidate(subscriberId)`, this convenience extension lets us call `subscriberId.invalidate()`. 
    // Note that due to the typealias of `RepeatSubscriberId` to `UInt`, this pollutes UInt's 'namespace' so that you can do `UInt(0).invalidate()` 
    // and it compiles (though it is obviously nonsensical).
     
    // - Returns: Whether the subscriber was found and invalidated successfully
    
    func invalidate() -> Bool {
        return Repeat.invalidate(self)
    }
}

References

Class Reference
Title Image: @ sergign / shutterstock.com