ZhgChg.Li

iOS Timer|DispatchSourceTimerの選び方と安全な使い方を徹底解説

有限状態機とデザインパターンでDispatchSourceTimerを安全に封装し、iOS Timerの使い分けと問題解決を実現。効率的で安全なタイマー管理を求める開発者必見。

iOS Timer|DispatchSourceTimerの選び方と安全な使い方を徹底解説
本記事は AI による翻訳です。お気づきの点があればお知らせください。

[iOS] Timer と DispatchSourceTimer の選び方と安全な使い方は?

有限状態機とデザインパターンを使って DispatchSourceTimer をラップし、安全で使いやすくする。

Photo by Ralph Hutter

Photo by Ralph Hutter

Timer について

iOS 開発では必ず直面する要件の一つに「Timer タイマー」があります。UI 層でのカウントダウン表示やバナーのスライドショーから、データロジック層でのイベントの定期送信や定期的なデータのクリア・解放まで、目標達成のために Timer が必要です。

Foundation — Timer (NSTimer)

Timer は誰もが直感的に最初に思い浮かべる API ですが、Timer を選択・使用する際には以下の点に注意する必要があります。

利点と欠点

Timer の利点:

  • デフォルトでUI操作と統合されており、特にメインスレッドで実行する必要はありません。

  • トリガータイミングを自動調整して電力消費を最適化します。

  • 複雑さが低く、発生する問題は最大で Retain Cycle や Timer の停止忘れのみで、直接クラッシュすることはありません。

Timer の欠点:

  • 精度は RunLoop の状態に影響され、UI の高いインタラクションやモード切替時にトリガーが遅れる可能性があります

  • suspendresumeactivate などの高度な操作をサポートしていません

適したシーン

UI 層面の要件、例えばバナーの自動スクロールやクーポン取得のカウントダウンなど、これらはユーザーが前景の画面で内容に反応できれば十分な場合、私は直接 Timer を使います。簡単で迅速かつ安全に目的を達成できます。

ライフサイクル

UIメインスレッド上で Timer を作成すると、Timer はメインスレッドの RunLoop に強く保持され、RunLoop のポーリング機構によって定期的にトリガーされます。Timer は invalidate() が呼ばれるまで解放されません。そのため、ViewController で Timer を強く保持し、deinit 時に Timer の invalidate() を呼び出して、画面が終了した後に正しく Timer を停止・解放する必要があります。

  • ⭐️️️View Controller は Timer を強く保持し、Timer の実行ブロック(handler / closure)は必ず Weak Self にすること;さもなければ Retain Cycle が発生します。

  • ⭐️️️ View Controller のライフサイクル終了時に必ず Timer の invalidate() を呼び出してください。さもないと RunLoop が Timer を保持し続けて実行されます。

RunLoop はスレッド内のイベント処理ループで、イベントの受信と処理を繰り返します;メインスレッドではシステムが自動的に RunLoop (RunLoop.main) を作成しますが、それ以外のスレッドには必ずしも 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
  // 何かを実行する..
}
self.timer = timer
// RunLoopに追加してから実行が始まる
RunLoop.main.add(timer, forMode: .default)

Timer の操作方法

  • invalidate() は Timer を終了する

  • fire() は即座に一度トリガーします

RunLoop モードの影響

  • .default :デフォルトで追加されるモードで、主にUI表示を処理します。
    .tracking モードに切り替えるときに一時停止します

  • .tracking :ScrollView のスクロールやジェスチャー操作を処理します。

  • .common.default.tracking の両方が処理されます。

⭐️️️⭐️️️⭐️️️そのため、デフォルトでは Timer は .default モードに追加されており、ユーザーが ScrollView をスクロールしたりジェスチャー操作を行うと自動的に一時停止し、操作が終了してから再開されます。これにより Timer の発火が遅れたり、回数が予想より少なくなる可能性があります。

これに対して、Timer を .common モードに追加すれば上記の問題を解決できます:

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

Grand Central Dispatch — DispatchSourceTimer

Timer 以外に、GCD はもう一つの選択肢として DispatchSourceTimer を提供しています。

長所と短所

DispatchSourceTimer の利点:

  • 操作の柔軟性(suspendresume のサポート)が優れている

  • 高い精度と信頼性:GCDキューに依存

  • leeway を設定して消費電力を制御可能

  • 安定して常駐するタスク(GCD キュー)

DispatchSourceTimer の欠点:

  • UI操作は自分でMain Threadに切り替える必要があります

  • APIの使用は複雑で順序があり、誤用するとクラッシュします

  • 安全に使用するにはラップが必要です

適したシーン

Timer が UI 層のシーンに適しているのに対し、DispatchSourceTimer は UI やユーザーの現在の画面に関係ないタスクに適しています。最も一般的なのはトラッキングイベントの送信で、ユーザー操作で発生したイベントを定期的にサーバーに送信したり、不要な CoreData のデータを定期的にクリーンアップしたりする場合です。これらは DispatchSourceTimer を使うのに非常に適しています。

ライフサイクル

DispatchSourceTimer のライフサイクルは外部オブジェクトがまだ保持しているかどうかに依存します。GCD キュー自体はタイマーのオーナーを強く保持せず、イベントのスケジューリングと実行のみを担当します。

クラッシュ問題

DispatchSourceTimer は activatesuspendresumecancel といった多くの操作メソッドを提供していますが、非常に繊細で、呼び出し順序を間違えるとすぐにクラッシュ(EXC_BREAKPOINT/DispatchSourceTimer)するため非常に危険です。

以下の状況では直接クラッシュします:

  • ❌ suspend( ) と resume( ) がペアになっていない
    suspend( ) の後に再度 suspend( ) を呼び出す
    resume( ) の後に再度 resume( ) を呼び出す

  • ❌ suspend( ) の後に cancel( ) を呼ぶ場合は、先に resume( ) を呼ぶ必要があります。

  • ❌ 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 {
        // 所有者オブジェクトが消滅するとき、同期的にタイマーをキャンセル
        // 実行しなくても問題ない(handlerはweak)が、処理の期待通りを保証するため
        if _state == .suspended {
            timer?.resume()
            _state = .running
        }
        if _state == .running {
            timer?.cancel()
            timer = nil
            _state = .cancelled
        }
    }

    // タイマーを起動
    func activate(repeatTimeInterval: DispatchTimeInterval, handler: DispatchSource.DispatchSourceHandler?) {
        // idle, cancelled 状態のみタイマーを起動可能
        guard [.idle, .cancelled].contains(_state) else { return }
        
        // タイマーを作成し activate()
        let timer = makeTimer(repeatTimeInterval: repeatTimeInterval, handler: handler)
        self.timer = timer
        timer.activate()
        
        // running 状態へ切り替え
        _state = .running
    }

    // タイマーを一時停止
    func suspend() {
        // running 状態のみ一時停止可能
        guard [.running].contains(_state) else { return }
        
        // タイマーを一時停止
        timer?.suspend()
        
        // suspended 状態へ切り替え
        _state = .suspended
    }

    // タイマーを再開
    func resume() {
        // suspended 状態のみ再開可能
        guard [.suspended].contains(_state) else { return }
        
        // タイマーを再開
        timer?.resume()
        
        // running 状態へ切り替え
        _state = .running
    }

    // タイマーを終了
    func cancel() {
        // suspended, running 状態のみ終了可能
        guard [.suspended, .running].contains(_state) else { return }
        
        // 現在 suspended 状態の場合は先に resume() してから終了
        // これは DispatchSourceTimer の制約で、running 状態でのみ cancel() 可能
        if _state == .suspended {
            self.resume()
        }

        // タイマーを終了
        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パターン:各状態内の振る舞いロジックに注目する。

Serial Queue を使った有限状態機の状態遷移操作

状態機で DispatchSourceTimer の安全な使用を確保した後も、まだ問題は解決していません。外部から DispatchSourceTimerMachine を呼び出す場所が同じスレッドであるとは限らず、異なるスレッドから同じオブジェクトを操作するとレースコンディションが発生し、クラッシュの原因となります。

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 {
        // オーナーオブジェクトが消える時、同期的にタイマーをキャンセル
        // しなくても問題ない(handlerはweak)が、処理の整合性を保つために行う
        // syncで完了するまで待つ
        operation(async: false) { [self] in
            if _state == .suspended {
                timer?.resume()
                _state = .running
            }
            if _state == .running {
                timer?.cancel()
                timer = nil
                _state = .cancelled
            }
        }
    }

    // タイマーを起動
    func activate(repeatTimeInterval: DispatchTimeInterval, handler: DispatchSource.DispatchSourceHandler?) {
        operation { [weak self] in
            guard let self = self else { return }
            // idleまたはcancelled状態のみタイマーを起動可能
            guard [.idle, .cancelled].contains(_state) else { return }
            
            // タイマーを作成して起動
            let timer = makeTimer(repeatTimeInterval: repeatTimeInterval, handler: handler)
            self.timer = timer
            timer.activate()
            
            // running状態に変更
            _state = .running
        }
    }

    // タイマーを一時停止
    func suspend() {
        operation { [weak self] in
            guard let self = self else { return }
            // running状態のみ一時停止可能
            guard [.running].contains(_state) else { return }
            
            // タイマーを一時停止
            timer?.suspend()
            
            // suspended状態に変更
            _state = .suspended
        }
    }

    // タイマーを再開
    func resume() {
        operation { [weak self] in
            guard let self = self else { return }
            // suspended状態のみ再開可能
            guard [.suspended].contains(_state) else { return }
            
            // タイマーを再開
            timer?.resume()
            
            // running状態に変更
            _state = .running
        }
    }

    // タイマーを終了
    func cancel() {
        operation { [weak self] in
            guard let self = self else { return }
            // suspendedまたはrunning状態のみ終了可能
            guard [.suspended, .running].contains(_state) else { return }
            
            // suspended状態の場合は先にresumeしてから終了
            // DispatchSourceTimerの制約で、running状態でのみcancel可能
            if _state == .suspended {
                self.resume()
            }
            
            // タイマーを終了
            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] = []

    // 定期トラッキングを開始
    func startTracking() {
        timerMachine.activate(repeatTimeInterval: .seconds(30)) { [weak self] in
            self?.sendTrackingEvent()
        }
    }

    // トラッキングを一時停止(例:Appがバックグラウンドに入るとき)
    func pauseTracking() {
        timerMachine.suspend()
    }

    // トラッキングを再開(例:Appがフォアグラウンドに戻るとき)
    func resumeTracking() {
        timerMachine.resume()
    }

    // トラッキングを停止(例:画面を離れるとき)
    func stopTracking() {
        timerMachine.cancel()
    }

    private func sendTrackingEvent() {
        // サーバーにイベントを送信...
    }
}

ここまでで DispatchSourceTimer を安全に使用する方法の説明は終了です。次に、いくつかのデザインパターンの活用について述べます。これにより、オブジェクトの抽象化によるテストや DispatchSourceHandler の実行ロジックの抽象化が容易になります。

拡張 — Adapterパターン + Factoryパターンを使って DispatchSourceTimer を生成(抽象化テストに便利)

DispatchSourceTimer は GCD の Objective-C オブジェクトであり、テスト時に Mock が難しい(Protocol がない)ため、自分で Protocol と Factory Pattern を定義して生成し、TimerStateMachine をテスト可能にする必要があります。

Adapterパターン— 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でタイマーを作成
            let timer = timerFactory.makeTimer(repeatTimeInterval: repeatTimeInterval, handler: handler)
            self.timer = timer

            timer.activate()
            _state = .running
        }
    }

    // 略..
}

これで TimerAdapter / DispatchSourceTimerAdapterFactorySpec のテスト段階で Mock Object を作成して単体テストを実行できます。

拡張 — Strategy パターンで DispatchSourceHandler の処理をカプセル化する

DispatchSourceHandler が実行する内容を動的に変更したい場合、Strategy パターンを使って処理内容をカプセル化できます。

TrackingHandlerStrategy:

protocol TrackingHandlerStrategy {
    static var target: String { get }
    func execute()
}

// ホームイベント
final class HomeTrackingHandlerStrategy: TrackingHandlerStrategy {
    static var target: String = "home"
    func execute() {
       // ホームイベントログを取得して送信
    }
}

// プロダクトイベント
final class ProductTrackingHandlerStrategy: TrackingHandlerStrategy {
    static var target: String = "product"
    func execute() {
       // プロダクトイベントログを取得して送信
    }
}

組み合わせて使用:

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] = [:]

    // 必要なイベント戦略を登録する
    func register(event: TrackingHandlerStrategy) {
        events[type(of: event).target] = event
    }

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

    // 定期トラッキングを開始する
    func startTracking() {
        timerMachine.activate(repeatTimeInterval: .seconds(30)) { [weak self] in
            self?.events.values.forEach { event in
                event.execute()
            }
        }
    }

    // トラッキングを一時停止する(例:アプリがバックグラウンドに入るとき)
    func pauseTracking() {
        timerMachine.suspend()
    }

    // トラッキングを再開する(例:アプリがフォアグラウンドに戻るとき)
    func resumeTracking() {
        timerMachine.resume()
    }

    // トラッキングを停止する(例:画面を離れるとき)
    func stopTracking() {
        timerMachine.cancel()
    }
}

謝辞

感謝 Ethan Huang さんの 5本のビール のご寄付:

確かに半年ほど何も書いていませんでした。新しい仕事に就いたばかりで、引き続きインスピレーションを探しています!💪

次回は Fastlane Match の証明書管理や Self-hosted Runner の構築手順、または Bitbucket Pipeline や AppStoreConnect API について共有するかもしれません。

関連記事

Post MediumからZMediumToMarkdownを使って変換しました。

GitHub で編集
この記事を改善
本記事は Medium で初公開
オリジナルを読む
この記事をシェア
リンクをコピー · SNS でシェア
ZhgChgLi
著者

ZhgChgLi

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

コメント