ZhgChg.Li

iOS Timer 與 DispatchSourceTimer 選擇與安全封裝技巧|有限狀態機防止閃退

iOS 開發必備 Timer 使用指南,解析 Timer 與 DispatchSourceTimer 優缺點,並提供有限狀態機封裝 DispatchSourceTimer,避免閃退及 Race Condition,實現高精度且安全的定時任務管理。

iOS Timer 與 DispatchSourceTimer 選擇與安全封裝技巧|有限狀態機防止閃退

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

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

Photo by Ralph Hutter

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 :

的確快半年沒寫什麼了,新工作剛到職,持續找尋靈感中!💪

下一篇可能分享 Fastlane Match 憑證管理跟 Self-hosted Runner 的建置過程. .或是 Bitbucket Pipeline. .或是 AppStoreConnect API…

延伸閱讀

在 GitHub 上補充修正
編輯這篇文章
本文首次發表於 Medium
點此查看原文
分享這篇文章
複製連結 · 分享到社群
ZhgChgLi
作者

ZhgChgLi

An iOS, web, and automation developer from Taiwan 🇹🇼 who also loves sharing, traveling, and writing.

留言 · Comments