[iOS] Timer と DispatchSourceTimer の選び方と安全な使い方は?
有限状態機とデザインパターンを使って DispatchSourceTimer をラップし、安全で使いやすくする。

写真提供:Ralph Hutter
Timer について
iOS 開発では必ず遭遇するニーズの一つに「Timer タイマー」があります。UI 層でのカウントダウン表示やバナーのスライドショーから、データロジック層での定期的なイベント送信やデータの定期解放まで、目標達成のために Timer が必要です。
Foundation — Timer (NSTimer)
Timer は誰もが直感的に最初に思い浮かべる API ですが、Timer を選択・使用する際には以下の点に注意する必要があります。
長所と短所
Timer の利点:
-
デフォルトで UI 処理と統合されており、特にメインスレッドで実行する必要はありません。
-
トリガータイミングの自動調整による電力使用の最適化
-
複雑さは低く、最大で Retain Cycle が発生するか Timer の停止忘れが起こるが、直接的なクラッシュは発生しません
Timer の欠点:
-
精度は RunLoop の状態に影響され、UI の高いインタラクションやモード切り替え時にトリガーが遅れる可能性があります
-
suspend、resume、activateなどの高度な操作はサポートしていません
適したシーン
UIレベルの要件、例えばバナーの自動スクロール(Auto Scroll ScrollView)やクーポン取得のカウントダウンなど、これらはユーザーが前景の現在の画面で内容に反応できれば十分な場合、私は直接 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 Mode の影響
-
.default:デフォルトで追加されるモードで、主にUI表示を処理します。
.trackingモードに切り替わると一時的に停止します -
.tracking:ScrollViewのスクロールやジェスチャーの処理。 -
.common:.defaultと.trackingの両方を処理します。
⭐️️️⭐️️️⭐️️️そのため、デフォルトでは Timer は
.defaultモードに追加されており、ユーザーが ScrollView をスクロールしたりジェスチャー操作を行うと自動的に一時停止 し、操作が終了してから再開されます。そのため、Timer の発火が遅れたり、回数が予想より少なくなる可能性があります。
これに対して、Timer を .common モードに追加することで上記の問題を解決できます:
RunLoop.main.add(timer, forMode: .common)
// メインのRunLoopにタイマーを.commonモードで追加する
Grand Central Dispatch — DispatchSourceTimer
Timer のほかに、GCD はもう一つの DispatchSourceTimer という方法も提供しています。
長所と短所
DispatchSourceTimer の利点:
-
操作の柔軟性(
suspend、resumeのサポート)が優れている -
高い精度と信頼性:GCDキューに依存
-
leeway を設定して消費電力を制御可能
-
安定して常駐するタスク(GCD キュー)
DispatchSourceTimer の欠点:
-
UI操作は自分でメインスレッドに切り替える必要があります
-
APIの使用は複雑で順序があり、誤用するとクラッシュします
-
安全に使用するにはラップが必要です
適したシーン
Timer が UI 層のシナリオに適しているのに対し、DispatchSourceTimer は UI やユーザーの現在の画面に関係ないタスクに適しています。最も一般的なのはトラッキングイベントの送信で、ユーザー操作によって生成されたイベントを定期的にサーバーに送信したり、不要な CoreData のデータを定期的にクリーンアップしたりする場合です。これらには DispatchSourceTimer が非常に適しています。
ライフサイクル

DispatchSourceTimer のライフサイクルは外部オブジェクトによって保持されているかどうかに依存します。GCD キュー自体はタイマーの所有者を強く保持せず、イベントのスケジューリングと実行のみを担当します。
クラッシュ問題
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 {
// オーナーオブジェクトが消える時にタイマーを同期的にキャンセル
// しなくても問題ない(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 Pattern: 各状態内の振る舞いロジックに注目する。
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 }
// タイマーを作成しactivate()
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()
}
}
// トラッキングを一時停止する(例:アプリがバックグラウンドに入るとき)
func pauseTracking() {
timerMachine.suspend()
}
// トラッキングを再開する(例:アプリがフォアグラウンドに戻るとき)
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を使って変換しました。




コメント