iOS Timer 與 DispatchSourceTimer 選擇與安全封裝技巧|有限狀態機防止閃退
iOS 開發必備 Timer 使用指南,解析 Timer 與 DispatchSourceTimer 優缺點,並提供有限狀態機封裝 DispatchSourceTimer,避免閃退及 Race Condition,實現高精度且安全的定時任務管理。
Click here to view the English version of this article.
点击这里查看本文章简体中文版本。
基於 SEO 考量,本文標題與描述經 AI 調整,原始版本請參考內文。
文章目錄
[iOS] Timer 與 DispatchSourceTimer 如何選擇與安全的使用?
使用有限狀態機與 Design Patterns 封裝 DispatchSourceTimer,使其更安全易用。
Photo by Ralph Hutter
關於 Timer
在 iOS 開發中一定會遇到的需求場景「Timer 定時觸發器」;從 UI 層面上顯示倒數計時、Banner 輪播到資料邏輯層面上的定時發送 Events、定時清除釋放資料;我們都需要 Timer 來幫助我們達成目標。
Foundation — Timer (NSTimer)
Timer 應該是大家最直覺會先想到的 API,但是在選擇及使用 Timer 上我們需要注意以下幾個點。
優缺點
Timer 的優點:
- 默認與 UI 工作整合,不需要特別切 Main Thread 執行
- 自動調整觸發時機優化使用電量
- 使用複雜度較低,最多只會發生 Retain Cycle 或忘記停止 Timer,但不會直接造成 Crash
Timer 的缺點:
- 精確度受 RunLoop 狀態影響,在 UI 高互動或 Mode 切換時可能延後觸發
- 不支援
suspend,resume,activate…等進階操作
適合場景
UI 層面需求,例如輪播 Banners (Auto Scroll ScrollView)或是優惠券領取倒數計時;這些只要求使用者在前景當前畫面能響應內容的場景,我會選擇直接用 Timer,方便、快速、安全的達成目的。
生命週期
在 UI Main Thread 上建立 Timer,Timer 會被 Main Thread 的 RunLoop 強持有、並透過 RunLoop 輪詢機制定期觸發,直到 Timer invalidate( ) 才會被釋放;因此我們需要在 ViewController 上強持有 Timer 並在 deinit 時呼叫 Timer invalidate( ),才能在畫面退出後正確終止釋放 Timer。
- ⭐️️️View Controller 強持有 Timer, Timer 的 Execution Block (handler / closure)務必為 Weak Self;否則會 Retain Cycle。
- ⭐️️️務必在 View Controller 生命週期結束時呼叫 Timer invalidate( ),否則 RunLoop 仍會持有 Timer 繼續執行。
RunLoop 是 Thread 內的事件處理迴圈,會輪詢接收處理事件; Main Thread 系統會自動建立 RunLoop (RunLoop.main) ,除此之外其他 Thread 不一定會有 RunLoop。
使用
我們可以直接使用 Timer.scheduledTimer 宣告一個 Timer (會自動加入 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!")
}
}
也可以自行宣告 Timer 物件加入到 RunLoop:
1
2
3
4
5
6
let timer= Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
// do something..
}
self.timer = timer
// 加入 RunLoop 後才會開始執行
RunLoop.main.add(timer, forMode: .default)
Timer 的操作方法
invalidate()終止 Timerfire()立即觸發一次
RunLoop Mode 的影響
.default:預設加入的 Mode,主要是處理 UI 顯示。 會在切換到.trackingMode 時先暫停.tracking:處理 ScrollView 滾動、Gesture 手勢。.common:.default+.tracking都會處理。
⭐️️️⭐️️️⭐️️️因此在默認情況下,我們的 Timer 是加到
.defaultMode, 會在使用者滾動 ScrollView 或是手勢操作時自動暫停 ,等到操作結束後才會繼續,可能造成 Timer 延後觸發或是次數低於預期。
對此,我們可以把 Timer 改加入到 .common Mode 就能解決以上問題:
1
RunLoop.main.add(timer, forMode: .common)
Grand Central Dispatch — DispatchSourceTimer
除了 Timer 之外,GCD 也提供了另一個 DispatchSourceTimer 方法可供選擇。
優缺點
DispatchSourceTimer 的優點:
- 操作彈性(支援
suspend,resume) 較好 - 精確度與可靠程度較高:依賴 GCD Queue
- 可自行設定 leeway 控制耗電量
- 可穩定常駐任務 (GCD Queue)
DispatchSourceTimer 的缺點:
- UI 操作需自行切換回 Main Thread
- API 使用複雜且有順序, 用錯會 Crash
- 需要封裝才能安全使用
適合場景
相較 Timer 適合 UI 層面的場景,DispatchSourceTimer 比較適合做那些跟 UI 或使用者當前畫面無關的任務場景;最常見的就是發送 Tracking 事件,我們會定時把使用者操作產生的事件發送到伺服器,或是定時清理無用的 CoreData 資料;這些就很適合使用 DispatchSourceTimer。
生命週期
DispatchSourceTimer 的生命週期取決於是否仍被外部物件持有;GCD queue 本身不會強持有 timer 的 owner,只負責調度與執行事件。
閃退問題
DispatchSourceTimer 雖然提供更多可操作方法: activate , suspend , resume , cancel ;但是它極其敏感,只要呼叫的順序不對就會直閃退 (EXC_BREAKPOINT/DispatchSourceTimer) 非常危險。
以下情況均會直接閃退:
- ❌ suspend( ) 與 resume( ) 沒有成對使用 suspend( ) 後又呼叫一次 suspend( ) resume( ) 後又呼叫一次 resume( )
- ❌ suspend( ) 後呼叫 cancel( ) 需要先 resume( ) 才能 cancel( )
- ❌ suspend( ) 狀態下 Timer 被釋放 (nil)
- ❌ cancel( ) 後再呼叫其他操作
使用 Finite-State Machine 有限狀態機封裝操作
進入本篇文章的另一個重點,該如何安全的使用 DispatchSourceTimer?
如上圖所示,我們使用有限狀態機封裝 DispatchSourceTimer 的操作,使其可以更安全、更容易的使用:
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 {
// 有限狀態機有哪些狀態
private enum TimerState {
// 初始狀態
case idle
// 執行中
case running
// 暫停中
case suspended
// 終止中
case cancelled
}
private var timer: DispatchSourceTimer?
private lazy var timerQueue: DispatchQueue = {
DispatchQueue(label: "li.zhgchg.DispatchSourceTimerMachine", qos: .background)
}()
private var _state: TimerState = .idle
deinit {
// Owner 物件消失時,同步 cancel timer
// 雖不做也不影響(handler 是 weak),但是可以確保流程符合預期
if _state == .suspended {
timer?.resume()
_state = .running
}
if _state == .running {
timer?.cancel()
timer = nil
_state = .cancelled
}
}
// 啟動 Timer
func activate(repeatTimeInterval: DispatchTimeInterval, handler: DispatchSource.DispatchSourceHandler?) {
// 只有 idle, cancelled 狀態可以啟用 Timer
guard [.idle, .cancelled].contains(_state) else { return }
// 建立 Timer and activate()
let timer = makeTimer(repeatTimeInterval: repeatTimeInterval, handler: handler)
self.timer = timer
timer.activate()
// 切換到 running 狀態
_state = .running
}
// 暫停 Timer
func suspend() {
// 只有在 running 狀態可以暫停 Timer
guard [.running].contains(_state) else { return }
// 暫停 Timer
timer?.suspend()
// 切換到 suspended 狀態
_state = .suspended
}
// 恢復 Timer
func resume() {
// 只有在 suspended 狀態可以恢復 Timer
guard [.suspended].contains(_state) else { return }
// 恢復 Timer
timer?.resume()
// 切換到 running 狀態
_state = .running
}
// 終止 Timer
func cancel() {
// 只有在 suspended, running 狀態可以終止 Timer
guard [.suspended, .running].contains(_state) else { return }
// 如果當前是 suspended 狀態,先 resume() 再終止
// 此為 DispatchSourceTimer 的限制,只能在 running 才能 cancel()
if _state == .suspended {
self.resume()
}
// 終止 Timer
timer?.cancel()
timer = nil
// 切換到 cancelled 狀態
_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
}
}
我們簡單使用有限狀態機「封裝了狀態可以轉換成什麼狀態」與「狀態需要做什麼」的邏輯,如果在錯誤的狀態下呼叫會被忽略(不會閃退),我們還多做了一些優化,例如 suspended 狀態也能 cancel、cancelled 狀態能重新 activate。
延伸閱讀:
之前寫過另一篇文章「 Design Patterns 實戰應用|封裝 Socket.IO 即時通訊架構 」中也有使用到有限狀態機,另外還多使用了 State Pattern。
Finite-State Machine 有限狀態機: 關注的是狀態之間的轉換控制與該做什麼。
State Pattern: 關注的是每個狀態內的行為邏輯。
使用 Serial Queue 操作有限狀態機狀態轉換
有了狀態機確保 DispatchSourceTimer 能安全使用之後還沒結束,我們無法保證在外部呼叫使用 DispatchSourceTimerMachine 的地方是在同個 Thread,如果不同 Thread 都操作了這個物件就會造成 Race Condition 一樣會引發閃退。
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 {
// 有限狀態機有哪些狀態
private enum TimerState {
// 初始狀態
case idle
// 執行中
case running
// 暫停中
case suspended
// 終止中
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 {
// Owner 物件消失時,同步 cancel timer
// 雖不做也不影響(handler 是 weak),但是可以確保流程符合預期
// 確保 sync 執行完畢
operation(async: false) { [self] in
if _state == .suspended {
timer?.resume()
_state = .running
}
if _state == .running {
timer?.cancel()
timer = nil
_state = .cancelled
}
}
}
// 啟動 Timer
func activate(repeatTimeInterval: DispatchTimeInterval, handler: DispatchSource.DispatchSourceHandler?) {
operation { [weak self] in
guard let self = self else { return }
// 只有 idle, cancelled 狀態可以啟用 Timer
guard [.idle, .cancelled].contains(_state) else { return }
// 建立 Timer and activate()
let timer = makeTimer(repeatTimeInterval: repeatTimeInterval, handler: handler)
self.timer = timer
timer.activate()
// 切換到 running 狀態
_state = .running
}
}
// 暫停 Timer
func suspend() {
operation { [weak self] in
guard let self = self else { return }
// 只有在 running 狀態可以暫停 Timer
guard [.running].contains(_state) else { return }
// 暫停 Timer
timer?.suspend()
// 切換到 suspended 狀態
_state = .suspended
}
}
// 恢復 Timer
func resume() {
operation { [weak self] in
guard let self = self else { return }
// 只有在 suspended 狀態可以恢復 Timer
guard [.suspended].contains(_state) else { return }
// 恢復 Timer
timer?.resume()
// 切換到 running 狀態
_state = .running
}
}
// 終止 Timer
func cancel() {
operation { [weak self] in
guard let self = self else { return }
// 只有在 suspended, running 狀態可以終止 Timer
guard [.suspended, .running].contains(_state) else { return }
// 如果當前是 suspended 狀態,先 resume() 再終止
// 此為 DispatchSourceTimer 的限制,只能在 running 才能 cancel()
if _state == .suspended {
self.resume()
}
// 終止 Timer
timer?.cancel()
timer = nil
// 切換到 cancelled 狀態
_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
}
}
現在,我們可以安全無憂的使用 DispatchSourceTimerMachine 物件作為 Timer 了:
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] = []
// 啟動定期 tracking
func startTracking() {
timerMachine.activate(repeatTimeInterval: .seconds(30)) { [weak self] in
self?.sendTrackingEvent()
}
}
// 暫停 tracking(例如 App 進背景)
func pauseTracking() {
timerMachine.suspend()
}
// 恢復 tracking(例如 App 回前景)
func resumeTracking() {
timerMachine.resume()
}
// 停止 tracking(例如頁面離開)
func stopTracking() {
timerMachine.cancel()
}
private func sendTrackingEvent() {
// send events to server...
}
}
到此如何安全的使用 DispatchSourceTimer 環節已結束,再來延伸幾個 Design Patterns 的使用,方便我們抽象物件進行測試跟 DispatchSourceHandler 執行邏輯抽象。
延伸 — 使用 Adapter Pattern + Factory Pattern 產生 DispatchSourceTimer (利於抽象測試)
DispatchSourceTimer 是 GCD 的 Objective-C 物件,在測試環節我們很難對其 Mock (無 Protocol);因此我們需要自己定義一層 Protocol + Factory Pattern 產生,讓 TimerStateMachine 是能寫測試的。
Adapter Pattern— 封裝 DispatchSourceTimer 操作:
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()
}
// DispatchSourceTimer 的 Adapter 實現
final class DispatchSourceTimerAdapter: TimerAdapter {
// 原始的 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 — 抽象產生 TimerAdapter 的方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
protocol DispatchSourceTimerAdapterFactorySpec {
func makeTimer(repeatTimeInterval: DispatchTimeInterval, handler: DispatchSourceProtocol.DispatchSourceHandler?) -> TimerAdapter
}
// 封裝 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
}
}
組合使用:
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 {
// 略..
private var timer: TimerAdapter?
private let timerFactory: DispatchSourceTimerAdapterFactorySpec
public init(timerFactory: DispatchSourceTimerAdapterFactorySpec) {
self.timerFactory = timerFactory
}
// 略..
func activate(repeatTimeInterval: DispatchTimeInterval, handler: DispatchSource.DispatchSourceHandler?) {
onQueue { [weak self] in
guard let self else { return }
guard [.idle, .cancelled].contains(_state) else { return }
// 使用 Factory MakeTimer
let timer = timerFactory.makeTimer(repeatTimeInterval: repeatTimeInterval, handler: handler)
self.timer = timer
timer.activate()
_state = .running
}
}
// 略..
}
這樣我們就能對 TimerAdapter / DispatchSourceTimerAdapterFactorySpec 在測試環節撰寫 Mock Object 跑單元測試。
延伸 — 使用 Strategy Pattern 封裝 DispatchSourceHandler 工作
假設我們的 DispatchSourceHandler 希望執行的事能動態改變,可以使用 Strategy Pattern 來封裝工作內容。
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
}
}
組合使用:
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] = [:]
// 註冊需要的 Event 策略
func register(event: TrackingHandlerStrategy) {
events[type(of: event).target] = event
}
func retrive<T: TrackingHandlerStrategy>(event: T.Type) -> T? {
return events[event.target] as? T
}
// 啟動定期 tracking
func startTracking() {
timerMachine.activate(repeatTimeInterval: .seconds(30)) { [weak self] in
self?.events.values.forEach { event in
event.execute()
}
}
}
// 暫停 tracking(例如 App 進背景)
func pauseTracking() {
timerMachine.suspend()
}
// 恢復 tracking(例如 App 回前景)
func resumeTracking() {
timerMachine.resume()
}
// 停止 tracking(例如頁面離開)
func stopTracking() {
timerMachine.cancel()
}
}
鳴謝
感謝 Ethan Huang 大大 Donate 的 5 Beers :
的確快半年沒寫什麼了,新工作剛到職,持續找尋靈感中!💪
下一篇可能分享 Fastlane Match 憑證管理跟 Self-hosted Runner 的建置過程. .或是 Bitbucket Pipeline. .或是 AppStoreConnect API…
延伸閱讀
- Design Patterns 的實戰應用紀錄 (封裝 Sockiet.io)
- Design Patterns 的實戰應用紀錄 (封裝 WKWebView)
- Visitor Pattern in Swift
- Visitor Pattern in TableView
有任何問題及指教歡迎 與我聯絡 。
本文首次發表於 Medium (點此查看原始版本),由 ZMediumToMarkdown 提供自動轉換與同步技術。





