Post

iOS Timer vs DispatchSourceTimer|Safe Usage with Finite State Machine & Design Patterns

Discover how to enhance iOS timer safety by encapsulating DispatchSourceTimer using finite state machines and design patterns, solving common concurrency issues for reliable app performance.

iOS Timer vs DispatchSourceTimer|Safe Usage with Finite State Machine & Design Patterns

点击这里查看本文章简体中文版本。

點擊這裡查看本文章正體中文版本。

This post was translated with AI assistance — let me know if anything sounds off!

Table of Contents


[iOS] How to Choose and Safely Use Timer and DispatchSourceTimer?

Encapsulating DispatchSourceTimer with Finite State Machine and Design Patterns for Safer and Easier Use.

Photo by [Ralph Hutter](https://unsplash.com/@pixelfreund?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

Photo by Ralph Hutter

About Timer

In iOS development, the “Timer trigger” is a common requirement; from UI aspects like showing countdowns and banner carousels to data logic tasks like scheduled event sending and periodic data cleanup, we rely on timers to help achieve these goals.

Foundation — Timer (NSTimer)

Timer is probably the most intuitive API that comes to mind first, but there are several points to consider when choosing and using Timer.

Advantages and Disadvantages

Advantages of Timer:

  • By default, it integrates with UI work and does not require explicitly switching to the Main Thread.

  • Automatically adjusts trigger timing to optimize power usage

  • Lower complexity, at most causing retain cycles or forgetting to stop the Timer, but will not directly cause a crash

Disadvantages of Timer:

  • Accuracy is affected by the RunLoop state and may be delayed during high UI interaction or mode switching.

  • Does not support advanced operations like suspend, resume, activate, etc.

Suitable Scenarios

UI-level needs, such as carousel banners (auto-scrolling ScrollView) or coupon countdown timers; these scenarios only require the user to respond to content in the current foreground screen. I choose to use Timer directly for a convenient, fast, and safe solution.

Lifecycle

Creating a Timer on the UI Main Thread means the Timer is strongly held by the Main Thread’s RunLoop and is periodically triggered by the RunLoop’s polling mechanism. It will only be released when Timer.invalidate() is called. Therefore, we need to strongly hold the Timer in the ViewController and call Timer.invalidate() in deinit to properly stop and release the Timer after the view is dismissed.

  • ⭐️️️View Controller strongly retains Timer, the Timer’s Execution Block (handler/closure) must use Weak Self; otherwise, it will cause a Retain Cycle.

  • ⭐️️️ Be sure to call Timer invalidate() at the end of the View Controller lifecycle; otherwise, the RunLoop will still hold the Timer and keep it running.

RunLoop is an event processing loop within a Thread that polls and handles events; the system automatically creates a RunLoop for the Main Thread (RunLoop.main), but other Threads may not have a RunLoop.

Usage

We can directly use Timer.scheduledTimer to declare a Timer (it automatically adds to RunLoop.main & Mode: .default):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
final class HomeViewController: UIViewController {
    
    private var timer: Timer?
    
    deinit {
        self.timer?.invalidate()
        self.timer = nil
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        startCarousel()
    }
    
    private func startCarousel() {
        self.timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { [weak self] _ in
            self?.doSomething()
        })
    }

    private func doSomething() {
        print("Hello World!")
    }
}

You can also declare a Timer object yourself and add it to the RunLoop:

1
2
3
4
5
6
let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
  // do something..
}
self.timer = timer
// Adding to RunLoop starts the timer
RunLoop.main.add(timer, forMode: .default)

How to Use Timer

  • invalidate() stops the Timer

  • fire() triggers immediately once

The Impact of RunLoop Mode

  • .default: The default added mode, mainly handles UI display.
    Will pause first when switching to .tracking mode

  • .tracking: Handles ScrollView scrolling and Gesture recognition.

  • .common: Handles both .default and .tracking.

⭐️️️⭐️️️⭐️️️Therefore, by default, our Timer is added to the .default mode, which will automatically pause when the user scrolls a ScrollView or performs gestures, and will only resume after the interaction ends. This may cause the Timer to trigger late or fire fewer times than expected.

For this, we can add Timer to the .common Mode to solve the above issue:

1
RunLoop.main.add(timer, forMode: .common)

Grand Central Dispatch — DispatchSourceTimer

Besides Timer, GCD also provides another option called DispatchSourceTimer.

Advantages and Disadvantages

Advantages of DispatchSourceTimer:

  • Better operational flexibility (supports suspend and resume)

  • Higher accuracy and reliability: depends on GCD queue

  • Leeway can be set manually to control power consumption

  • Stable resident task (GCD queue)

Disadvantages of DispatchSourceTimer:

  • UI operations require manually switching back to the Main Thread

  • The API is complex and order-dependent; using it incorrectly will cause a crash

  • Encapsulation Is Needed for Safe Usage

Suitable Scenarios

Compared to Timer, which is suitable for UI-related scenarios, DispatchSourceTimer is better suited for tasks unrelated to the UI or the current user screen. The most common use cases are sending tracking events, where user-generated events are periodically sent to the server, or regularly cleaning up unused CoreData data. These tasks are ideal for using DispatchSourceTimer.

Life Cycle

The lifecycle of DispatchSourceTimer depends on whether it is still retained by an external object; the GCD queue itself does not strongly retain the timer’s owner and only handles scheduling and executing events.

Crash Issues

Although DispatchSourceTimer offers more control methods: activate, suspend, resume, cancel; it is extremely sensitive, and calling them in the wrong order will cause immediate crashes (EXC_BREAKPOINT/DispatchSourceTimer), which is very dangerous.

The following situations will cause an immediate crash:

  • ❌ suspend() and resume() are not used in pairs
    Calling suspend() again after suspend()
    Calling resume() again after resume()

  • ❌ Calling cancel() after suspend()
    You need to call resume() before cancel()

  • ❌ Timer is released (nil) while in suspend() state

  • ❌ Calling other operations after cancel()

Using Finite-State Machine to Encapsulate Operations

Moving on to another key point of this article: How to safely use DispatchSourceTimer?

As shown in the above image, we use a finite state machine to encapsulate DispatchSourceTimer operations, making it safer and easier to use:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
final class DispatchSourceTimerMachine {
    // States of the finite state machine
    private enum TimerState {
        // Initial state
        case idle
        // Running
        case running
        // Suspended
        case suspended
        // Cancelled
        case cancelled
    }

    private var timer: DispatchSourceTimer?
    private lazy var timerQueue: DispatchQueue = {
        DispatchQueue(label: "li.zhgchg.DispatchSourceTimerMachine", qos: .background)
    }()

    private var _state: TimerState = .idle
    
    deinit {
        // When the owner object is deallocated, cancel the timer synchronously
        // Not mandatory (handler is weak), but ensures the flow is as expected
        if _state == .suspended {
            timer?.resume()
            _state = .running
        }
        if _state == .running {
            timer?.cancel()
            timer = nil
            _state = .cancelled
        }
    }

    // Start the Timer
    func activate(repeatTimeInterval: DispatchTimeInterval, handler: DispatchSource.DispatchSourceHandler?) {
        // Timer can only be activated in idle or cancelled state
        guard [.idle, .cancelled].contains(_state) else { return }
        
        // Create Timer and activate()
        let timer = makeTimer(repeatTimeInterval: repeatTimeInterval, handler: handler)
        self.timer = timer
        timer.activate()
        
        // Switch to running state
        _state = .running
    }

    // Suspend the Timer
    func suspend() {
        // Timer can only be suspended when running
        guard [.running].contains(_state) else { return }
        
        // Suspend Timer
        timer?.suspend()
        
        // Switch to suspended state
        _state = .suspended
    }

    // Resume the Timer
    func resume() {
        // Timer can only be resumed when suspended
        guard [.suspended].contains(_state) else { return }
        
        // Resume Timer
        timer?.resume()
        
        // Switch to running state
        _state = .running
    }

    // Cancel the Timer
    func cancel() {
        // Timer can only be cancelled when suspended or running
        guard [.suspended, .running].contains(_state) else { return }
        
        // If currently suspended, resume first before cancelling
        // This is a DispatchSourceTimer limitation; can only cancel when running
        if _state == .suspended {
            self.resume()
        }

        // Cancel Timer
        timer?.cancel()
        timer = nil
        
        // Switch to cancelled state
        _state = .cancelled
    }
    
    private func makeTimer(repeatTimeInterval: DispatchTimeInterval, handler: DispatchSourceProtocol.DispatchSourceHandler?) -> DispatchSourceTimer {
        let timer = DispatchSource.makeTimerSource(queue: timerQueue)
        timer.schedule(deadline: .now(), repeating: repeatTimeInterval)
        timer.setEventHandler(qos: .background, handler: handler)
        return timer
    }
}

We simply use a finite state machine to encapsulate the logic of “which states can transition to which” and “what actions each state requires.” Calls made in the wrong state are ignored (no crashes). We also added some optimizations, such as allowing cancel in the suspended state and reactivating from the cancelled state.

Further Reading:

Previously, I wrote another article “Design Patterns Practical Application|Encapsulating Socket.IO Real-time Communication Architecture”, where I also used a finite state machine and additionally applied the State Pattern.

Finite-State Machine: Focuses on controlling state transitions and the actions to perform.

State Pattern: Focuses on the behavior logic within each state.

Using Serial Queue to Manage Finite State Machine Transitions

Even after ensuring safe use of DispatchSourceTimer with a state machine, the issue is not fully resolved. We cannot guarantee that calls to DispatchSourceTimerMachine from outside occur on the same thread. If multiple threads operate on this object, race conditions may still cause crashes.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
final class DispatchSourceTimerMachine {
    // States of the finite state machine
    private enum TimerState {
        // Initial state
        case idle
        // Running
        case running
        // Suspended
        case suspended
        // Cancelled
        case cancelled
    }

    private var timer: DispatchSourceTimer?
    private lazy var timerQueue: DispatchQueue = {
        DispatchQueue(label: "li.zhgchg.DispatchSourceTimerMachine", qos: .background)
    }()

    private var _state: TimerState = .idle

    private static let operationQueueSpecificKey = DispatchSpecificKey<ObjectIdentifier>()
    private lazy var operationQueueSpecificValue: ObjectIdentifier = ObjectIdentifier(self)
    private lazy var operationQueue: DispatchQueue = {
        let queue = DispatchQueue(label: "li.zhgchg.DispatchSourceTimerMachine.operationQueue")
        queue.setSpecific(key: Self.operationQueueSpecificKey, value: operationQueueSpecificValue)
        return queue
    }()
    private func operation(async: Bool = true, _ work: @escaping () -> Void) {
        if DispatchQueue.getSpecific(key: Self.operationQueueSpecificKey) == operationQueueSpecificValue {
            work()
        } else {
            if async {
                operationQueue.async(execute: work)
            } else {
                operationQueue.sync(execute: work)
            }
        }
    }
    
    deinit {
        // When the owner object disappears, synchronously cancel the timer
        // Not required (handler is weak), but ensures the flow is as expected
        // Ensure sync execution completes
        operation(async: false) { [self] in
            if _state == .suspended {
                timer?.resume()
                _state = .running
            }
            if _state == .running {
                timer?.cancel()
                timer = nil
                _state = .cancelled
            }
        }
    }

    // Start the Timer
    func activate(repeatTimeInterval: DispatchTimeInterval, handler: DispatchSource.DispatchSourceHandler?) {
        operation { [weak self] in
            guard let self = self else { return }
            // Only idle or cancelled states can activate the Timer
            guard [.idle, .cancelled].contains(_state) else { return }
            
            // Create Timer and activate()
            let timer = makeTimer(repeatTimeInterval: repeatTimeInterval, handler: handler)
            self.timer = timer
            timer.activate()
            
            // Switch to running state
            _state = .running
        }
    }

    // Suspend the Timer
    func suspend() {
        operation { [weak self] in
            guard let self = self else { return }
            // Only running state can suspend the Timer
            guard [.running].contains(_state) else { return }
            
            // Suspend the Timer
            timer?.suspend()
            
            // Switch to suspended state
            _state = .suspended
        }
    }

    // Resume the Timer
    func resume() {
        operation { [weak self] in
            guard let self = self else { return }
            // Only suspended state can resume the Timer
            guard [.suspended].contains(_state) else { return }
            
            // Resume the Timer
            timer?.resume()
            
            // Switch to running state
            _state = .running
        }
    }

    // Cancel the Timer
    func cancel() {
        operation { [weak self] in
            guard let self = self else { return }
            // Only suspended or running states can cancel the Timer
            guard [.suspended, .running].contains(_state) else { return }
            
            // If currently suspended, resume first then cancel
            // This is a DispatchSourceTimer limitation: can only cancel when running
            if _state == .suspended {
                self.resume()
            }
            
            // Cancel the Timer
            timer?.cancel()
            timer = nil
            
            // Switch to cancelled state
            _state = .cancelled
        }
    }
    
    private func makeTimer(repeatTimeInterval: DispatchTimeInterval, handler: DispatchSourceProtocol.DispatchSourceHandler?) -> DispatchSourceTimer {
        let timer = DispatchSource.makeTimerSource(queue: timerQueue)
        timer.schedule(deadline: .now(), repeating: repeatTimeInterval)
        timer.setEventHandler(qos: .background, handler: handler)
        return timer
    }
}

Now, we can safely use the DispatchSourceTimerMachine object as a Timer without worries:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
final class TrackingEventSender {

    private let timerMachine = DispatchSourceTimerMachine()
    public var events: [String: String] = []

    // Start periodic tracking
    func startTracking() {
        timerMachine.activate(repeatTimeInterval: .seconds(30)) { [weak self] in
            self?.sendTrackingEvent()
        }
    }

    // Pause tracking (e.g., when App goes to background)
    func pauseTracking() {
        timerMachine.suspend()
    }

    // Resume tracking (e.g., when App returns to foreground)
    func resumeTracking() {
        timerMachine.resume()
    }

    // Stop tracking (e.g., when leaving the page)
    func stopTracking() {
        timerMachine.cancel()
    }

    private func sendTrackingEvent() {
        // send events to server...
    }
}

The section on how to safely use DispatchSourceTimer has ended. Next, we will extend to several Design Patterns to help us abstract objects for testing and to abstract the execution logic of DispatchSourceHandler.

Extension — Using Adapter Pattern + Factory Pattern to Create DispatchSourceTimer (Facilitates Abstract Testing)

DispatchSourceTimer is a GCD Objective-C object, making it difficult to mock in tests (no Protocol available); therefore, we need to define our own Protocol + Factory Pattern to generate it, allowing TimerStateMachine to be testable.

Adapter Pattern— Encapsulating DispatchSourceTimer Operations:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public protocol TimerAdapter {
    func schedule(repeating: DispatchTimeInterval)
    func setEventHandler(handler: DispatchSourceProtocol.DispatchSourceHandler?)
    func activate()
    func suspend()
    func resume()
    func cancel()
}

// Adapter implementation for DispatchSourceTimer
final class DispatchSourceTimerAdapter: TimerAdapter {
    // Original DispatchSourceTimer
    private let timer: DispatchSourceTimer

    init(label: String = "li.zhgchg.DispatchSourceTimerAdapter") {
        let queue = DispatchQueue(label: label, qos: .background)
        let timer = DispatchSource.makeTimerSource(queue: queue)
        self.timer = timer
    }

    func schedule(repeating: DispatchTimeInterval) {
        timer.schedule(deadline: .now(), repeating: repeating)
    }

    func setEventHandler(handler: DispatchSourceProtocol.DispatchSourceHandler?) {
        timer.setEventHandler(qos: .background, handler: handler)
    }

    func activate() {
        timer.activate()
    }

    func suspend() {
        timer.suspend()
    }

    func resume() {
        timer.resume()
    }

    func cancel() {
        timer.cancel()
    }
}

Factory Pattern — Abstract method to create TimerAdapter:

1
2
3
4
5
6
7
8
9
10
11
12
13
protocol DispatchSourceTimerAdapterFactorySpec {
    func makeTimer(repeatTimeInterval: DispatchTimeInterval, handler: DispatchSourceProtocol.DispatchSourceHandler?) -> TimerAdapter
}

// Encapsulates the creation steps of DispatchSourceTimerAdapter
final class DispatchSourceTimerAdapterFactory: DispatchSourceTimerAdapterFactorySpec {
    public func makeTimer(repeatTimeInterval: DispatchTimeInterval, handler: DispatchSourceProtocol.DispatchSourceHandler?) -> TimerAdapter {
        let timer = DispatchSourceTimerAdapter()
        timer.schedule(repeating: repeatTimeInterval)
        timer.setEventHandler(handler: handler)
        return timer
    }
}

Combined Usage:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
var stateMachine = DispatchSourceTimerMachine(timerFactory: DispatchSourceTimerAdapterFactory())

//
final class DispatchSourceTimerMachine {
    // Omitted..
    private var timer: TimerAdapter?
    private let timerFactory: DispatchSourceTimerAdapterFactorySpec
    public init(timerFactory: DispatchSourceTimerAdapterFactorySpec) {
        self.timerFactory = timerFactory
    }
    // Omitted..

    func activate(repeatTimeInterval: DispatchTimeInterval, handler: DispatchSource.DispatchSourceHandler?) {
        onQueue { [weak self] in
            guard let self else { return }
            guard [.idle, .cancelled].contains(_state) else { return }
            // Use Factory to make Timer
            let timer = timerFactory.makeTimer(repeatTimeInterval: repeatTimeInterval, handler: handler)
            self.timer = timer

            timer.activate()
            _state = .running
        }
    }

    // Omitted..
}

This allows us to write Mock Objects for TimerAdapter / DispatchSourceTimerAdapterFactorySpec during testing to run unit tests.

Extension — Using the Strategy Pattern to Encapsulate DispatchSourceHandler Tasks

Assuming we want the DispatchSourceHandler to execute tasks that can change dynamically, we can use the Strategy Pattern to encapsulate the work content.

TrackingHandlerStrategy:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
protocol TrackingHandlerStrategy {
    static var target: String { get }
    func execute()
}

// Home Event
final class HomeTrackingHandlerStrategy: TrackingHandlerStrategy {
    static var target: String = "home"
    func execute() {
       // fetch home event logs..and send
    }
}

// Product Event
final class ProductTrackingHandlerStrategy: TrackingHandlerStrategy {
    static var target: String = "product"
    func execute() {
       // fetch product event logs..and send
    }
}

Combined Usage:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
var sender = TrackingEventSender()
sender.register(event: HomeTrackingHandlerStrategy())
sender.register(event: ProductTrackingHandlerStrategy())
sender.startTracking()

// ...

//

final class TrackingEventSender {

    private let timerMachine = DispatchSourceTimerMachine()
    private var events: [String: TrackingHandlerStrategy] = [:]

    // Register required event strategies
    func register(event: TrackingHandlerStrategy) {
        events[type(of: event).target] = event
    }

    func retrive<T: TrackingHandlerStrategy>(event: T.Type) -> T? {
        return events[event.target] as? T
    }

    // Start periodic tracking
    func startTracking() {
        timerMachine.activate(repeatTimeInterval: .seconds(30)) { [weak self] in
            self?.events.values.forEach { event in
                event.execute()
            }
        }
    }

    // Pause tracking (e.g., app enters background)
    func pauseTracking() {
        timerMachine.suspend()
    }

    // Resume tracking (e.g., app enters foreground)
    func resumeTracking() {
        timerMachine.resume()
    }

    // Stop tracking (e.g., page leaves)
    func stopTracking() {
        timerMachine.cancel()
    }
}

Acknowledgments

Thanks to Ethan Huang for donating 5 Beers:

Indeed, I haven’t written much for almost half a year. Just started a new job and am continuously seeking inspiration! 💪

The next article might share the process of managing certificates with Fastlane Match and setting up a Self-hosted Runner, or it could cover Bitbucket Pipeline, or the AppStoreConnect API…

Further Reading

If you have any questions or feedback, feel free to contact me.


Buy me a beer

This post was originally published on Medium (View original post), and automatically converted and synced by ZMediumToMarkdown.

Improve this page on Github.

This post is licensed under CC BY 4.0 by the author.