[iOS] Timer 與 DispatchSourceTimer 如何選擇與安全的使用?

AI Summary11 min read

TL;DR

本文比較 iOS 中 Timer 與 DispatchSourceTimer 的優缺點及適用場景,並提出使用有限狀態機封裝 DispatchSourceTimer 以確保安全使用,避免因操作順序錯誤導致的閃退問題。

Key Takeaways

  • Timer 適合 UI 層面需求(如輪播、倒數計時),但精確度受 RunLoop 影響;DispatchSourceTimer 適合後台任務(如發送事件、清理資料),精確度較高但操作複雜易閃退。
  • 使用有限狀態機封裝 DispatchSourceTimer,透過狀態轉換控制(idle、running、suspended、cancelled)確保操作順序正確,避免閃退。
  • 結合 Serial Queue 操作狀態機,防止多執行緒競爭條件(Race Condition),進一步提升安全性。
  • 透過 Adapter Pattern 和 Factory Pattern 抽象化 DispatchSourceTimer,便於單元測試;使用 Strategy Pattern 封裝事件處理邏輯,增加靈活性。
  • Timer 需注意生命週期管理(如使用 weak self 避免循環引用、在 deinit 呼叫 invalidate),而 DispatchSourceTimer 需嚴格遵循操作順序(如 suspend/resume 成對使用)。

使用有限狀態機與 Design Patterns 封裝 DispatchSourceTimer,使其更安全易用。

https://medium.com/media/488577f1a34898594b6da98d4e710915/href
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):

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:

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() 終止 Timer
  • fire() 立即觸發一次

RunLoop Mode 的影響

  • .default:預設加入的 Mode,主要是處理 UI 顯示。
    會在切換到 .tracking Mode 時先暫停
  • .tracking:處理 ScrollView 滾動、Gesture 手勢。
  • .common:.default + .tracking 都會處理。
⭐️️️⭐️️️⭐️️️因此在默認情況下,我們的 Timer 是加到 .default Mode,會在使用者滾動 ScrollView 或是手勢操作時自動暫停,等到操作結束後才會繼續,可能造成 Timer 延後觸發或是次數低於預期。

對此,我們可以把 Timer 改加入到 .common Mode 就能解決以上問題:

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 的操作,使其可以更安全、更容易的使用:

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 一樣會引發閃退。

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 了:

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 操作:

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 的方法:

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
}
}

組合使用:

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:

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
}
}

組合使用:

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:

13+ | Patreon

的確快半年沒寫什麼了,新工作剛到職,持續找尋靈感中!💪
下一篇可能分享 Fastlane Match 憑證管理跟 Self-hosted Runner 的建置過程..或是 Bitbucket Pipeline..或是 AppStoreConnect API…

延伸閱讀

有任何問題及指教歡迎與我聯絡

https://medium.com/media/8652ffa596b15d4e2a3dc94591c00522/href

[iOS] Timer 與 DispatchSourceTimer 如何選擇與安全的使用? was originally published in ZRealm Dev. on Medium, where people are continuing the conversation by highlighting and responding to this story.

Visit Website