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