axiom-timer-patterns について
iOS 開発者向けのタイマー安全スキルです。RunLoop の動作、DispatchSourceTimer の状態遷移、API の選び方を扱い、クラッシュやスクロール中の停止、ライフサイクルの問題を防ぐ方法を示します。
主な機能
- 01タイマー API の選択ガイド
- 02RunLoop モードとスクロール問題の対策
- 03DispatchSourceTimer の状態安全パターン
- 04シリアルキューと UI 更新の指針
- 05タイマーのライフサイクルに関するアンチパターン確認
活用シーン
- 01スクロール中も UI タイマーを動作させる
- 02DispatchSourceTimer のクラッシュを診断する
- 03非同期コードに使うタイマーを選ぶ
- 04ビューを閉じる際にタイマーを安全に停止する
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ charleswiltgen/ axiom --skill axiom-timer-patternsインストール後、Claude Code で/axiom-timer-patternsを入力して読み込まれたか確認します。
詳細
タイマーの安全なパターン
概要
タイマー関連のクラッシュは、発生が断続的で、クラッシュログがコードではなく GCD の内部を示すことが多いため、診断が最も難しい問題のひとつです。基本原則:DispatchSourceTimer には状態マシンがあり、それに違反すると、一見ランダムに見える EXC_BAD_INSTRUCTION クラッシュが確実に発生します。Timer(NSTimer)には RunLoop モードの落とし穴があり、スクロール中にタイマーがひそかに停止します。どちらも、このスキルのパターンで防止できます。
プロンプトの例
- 「ユーザーがスクロールするとタイマーが止まります」
- 「タイマーのコードで EXC_BAD_INSTRUCTION クラッシュが発生します」
- 「Timer と DispatchSourceTimer のどちらを使うべきですか?」
- 「DispatchSourceTimer を安全にキャンセルするにはどうすればよいですか?」
- 「DispatchSourceTimer が dealloc 時にクラッシュします」
- 「ビューコントローラーを閉じた後もタイマーが動き続けます」
パート 1:Timer と DispatchSourceTimer の選択ツリー
| 特徴 | Timer | DispatchSourceTimer | AsyncTimerSequence |
|---|---|---|---|
| スレッド安全性 | メインスレッドのみ(RunLoop に依存) | 任意のキュー(選択可能) | Task に依存(構造化並行処理) |
| スクロール中の動作 | .common モードのみ | 常に動作(RunLoop に依存しない) | 常に動作(RunLoop に依存しない) |
| 精度 | 低(RunLoop による集約) | 高(GCD スケジューリング) | 中(クロックに依存) |
| ライフサイクルの複雑さ | 低(invalidate + nil) | 高(状態マシン、4 つのクラッシュパターン) | 低(Task のキャンセル) |
| iOS バージョン | 2.0+ | 8.0+(GCD) | 16.0+ |
| 用途 | メインスレッド上の UI 更新 | バックグラウンド処理、正確なタイミング、カスタムキュー | 最新の非同期コード、構造化並行処理 |
簡単な選択方法
Need a simple UI update timer?
├─ Yes → Timer (with .common RunLoop mode)
│
Need precise timing or background queue?
├─ Yes → DispatchSourceTimer (with SafeDispatchTimer wrapper)
│
Writing modern async/await code on iOS 16+?
├─ Yes → AsyncTimerSequence (ContinuousClock.timer)
│
Need Combine integration?
└─ Yes → Timer.publish
パート 2:RunLoop モードの落とし穴
スクロール中に Timer が発火しなくなります。これは iOS 開発で最もよくあるタイマーバグです。
発生する理由
Timer.scheduledTimer は、タイマーを現在の RunLoop の .default モードに追加します。ユーザーがスクロールすると(UIScrollView、SwiftUI ScrollView、List)、RunLoop は .tracking モードに切り替わります。タイマーは .default にしか登録されていないため、.tracking モードでは発火しません。
時間的コスト:RunLoop モードを知らないと、スクロール中にタイマーが不可解に停止する問題のデバッグに 30 分以上かかります。
❌ 修正前 — スクロール中に Timer が停止
// BAD: Timer added to .default mode (implicit)
let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
self?.updateProgress()
}
// Timer STOPS when user scrolls any UIScrollView or SwiftUI List
✅ 修正後 — スクロール中も Timer が動作
// GOOD: Explicitly add to .common mode (includes both .default and .tracking)
let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
self?.updateProgress()
}
RunLoop.current.add(timer, forMode: .common)
✅ 修正後 — Combine Timer もスクロール中に動作
// GOOD: Timer.publish with .common mode — survives scrolling in SwiftUI
Timer.publish(every: 1.0, tolerance: 0.1, on: .main, in: .common)
.autoconnect()
.sink { [weak self] _ in
self?.updateProgress()
}
.store(in: &cancellables)
ポイント:in: パラメーターを省略すると、デフォルトで .default になります。常に明示的に .common を指定してください。
RunLoop モード
| モード | 有効になるタイミング | タイマーは発火するか |
|---|---|---|
.default | 通常の操作中 | はい |
.tracking | スクロール中 | .common に追加されている場合のみ |
.common | 疑似モード:.default と .tracking を含む | はい(常に) |
パート 3:DispatchSourceTimer の 4 つのクラッシュパターン
これらはいずれも EXC_BAD_INSTRUCTION を引き起こします。クラッシュログが GCD の内部を示すため、タイマーのコードに原因をたどるのが困難なクラッシュです。
クラッシュフレームとパターンの対応
クラッシュログで EXC_BAD_INSTRUCTION を確認したら、最上位のフレームを照合してください。
| クラッシュの最上位フレーム | クラッシュパターン | 修正方法 |
|---|---|---|
dispatch_source_cancel | クラッシュ 2:サスペンド中のキャンセル | cancel() の前に resume() |
_dispatch_source_dispose | クラッシュ 3:サスペンド中の解放 | 解放前に resume + cancel |
dispatch_resume | クラッシュ 4:キャンセル後の resume | 操作前に isCancelled を確認 |
_dispatch_source_refs_t / suspend count | クラッシュ 1:サスペンドの不均衡 | 状態を追跡し、実行中の場合のみ suspend |
DispatchSourceTimer の状態マシン
activate()
idle ──────────────► running
│ ▲
suspend() │ │ resume()
▼ │
suspended
│
resume() + cancel()
│
▼
cancelled (terminal)
CRASH ZONES:
suspended → cancel() = EXC_BAD_INSTRUCTION
suspended → dealloc = EXC_BAD_INSTRUCTION
suspended → suspend() = suspend count underflow on dealloc
cancelled → resume() = EXC_BAD_INSTRUCTION
クラッシュ 1:すでにサスペンド中のタイマーを suspend する
対応する resume() 呼び出しをせずに suspend() を複数回呼び出すパターンです。suspend() を呼ぶたびに、内部カウンターが増加します。解放時に suspend カウントがゼロでないと、GCD がクラッシュします。
❌ クラッシュ
let timer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(deadline: .now(), repeating: 1.0)
timer.setEventHandler { doWork() }
timer.activate()
// User triggers pause twice rapidly
timer.suspend() // suspend count = 1
timer.suspend() // suspend count = 2
timer.resume() // suspend count = 1
// Timer deallocated with suspend count = 1 → EXC_BAD_INSTRUCTION
✅ 安全な方法
// Track state — only suspend if running
var isRunning = true
func pause() {
guard isRunning else { return }
timer.suspend()
isRunning = false
}
func unpause() {
guard !isRunning else { return }
timer.resume()
isRunning = true
}
クラッシュ 2:サスペンド中のキャンセル
GCD では、キャンセル前にディスパッチソースがサスペンドされていない状態である必要があります。サスペンド中のタイマーをキャンセルすると、直ちにクラッシュします。
❌ クラッシュ
let timer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(deadline: .now(), repeating: 1.0)
timer.setEventHandler { doWork() }
timer.activate()
timer.suspend()
timer.cancel() // EXC_BAD_INSTRUCTION — can't cancel while suspended
✅ 安全
// ALWAYS resume before cancelling
timer.resume() // Move out of suspended state
timer.cancel() // Now safe to cancel
クラッシュ 3:サスペンド中の解放
サスペンド中にタイマーを nil に設定する(またはスコープ外にする)と、発生します。解放時に内部でクリーンアップが試みられますが、サスペンド中のソースでは失敗します。
❌ クラッシュ
var timer: DispatchSourceTimer?
func startTimer() {
timer = DispatchSource.makeTimerSource(queue: queue)
timer?.schedule(deadline: .now(), repeating: 1.0)
timer?.setEventHandler { [weak self] in self?.doWork() }
timer?.activate()
}
func pauseTimer() {
timer?.suspend()
}
func cleanup() {
timer = nil // Dealloc while suspended → EXC_BAD_INSTRUCTION
}
✅ 安全
func cleanup() {
// Resume before releasing
timer?.resume()
timer?.cancel()
timer = nil // Now safe — timer is in cancelled state
}
クラッシュ 4:キャンセル後の操作
キャンセル済みのタイマーに対して resume() または suspend() を呼び出すと、発生します。キャンセルは終端状態です。タイマーは再利用できません。
❌ クラッシュ
timer.cancel()
timer.resume() // EXC_BAD_INSTRUCTION — can't resume a cancelled source
✅ 安全
// Track cancellation state
var isCancelled = false
func cancel() {
guard !isCancelled else { return }
timer.cancel()
isCancelled = true
}
func resume() {
guard !isCancelled else { return } // Check before operating
timer.resume()
}
パート 4:SafeDispatchTimer ラッパー
このクラスをコピーして貼り付けることで、4 つすべてのクラッシュパターンを防げます。ステートマシンによって、有効な状態遷移が保証されます。
final class SafeDispatchTimer {
enum State { case idle, running, suspended, cancelled }
private(set) var state: State = .idle
private let timer: DispatchSourceTimer
init(queue: DispatchQueue = DispatchQueue(label: "safe-dispatch-timer")) {
timer = DispatchSource.makeTimerSource(queue: queue)
}
func schedule(interval: TimeInterval, handler: @escaping () -> Void) {
guard state == .idle else { return }
timer.schedule(deadline: .now() + interval, repeating: interval)
timer.setEventHandler(handler: handler)
timer.activate()
state = .running
}
func suspend() {
guard state == .running else { return }
timer.suspend()
state = .suspended
}
func resume() {
guard state == .suspended else { return }
timer.resume()
state = .running
}
func cancel() {
switch state {
case .suspended:
timer.resume() // Must resume before cancel
timer.cancel()
case .running:
timer.cancel()
case .idle, .cancelled:
return
}
state = .cancelled
}
deinit {
cancel() // Safe cleanup regardless of current state
}
}
使用方法
class BackgroundPoller {
private var timer: SafeDispatchTimer?
func start() {
timer = SafeDispatchTimer()
timer?.schedule(interval: 5.0) { [weak self] in
self?.fetchData()
}
}
func pause() {
timer?.suspend() // Safe — no-op if not running
}
func unpause() {
timer?.resume() // Safe — no-op if not suspended
}
func stop() {
timer?.cancel() // Safe — handles any state
timer = nil
}
}
パート 5:スレッドセーフティ
専用のシリアルキューを必ず使用する
DispatchSourceTimer は、作成時に指定したキュー上でイベントハンドラーを実行します。並行キューを使うと、複数のイベントが重なったときや、ハンドラーから共有状態を変更するときに競合状態が発生します。
❌ 競合状態
// BAD: Concurrent queue — handler can fire while previous invocation is still running
let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global())
timer.setEventHandler {
self.count += 1 // Race condition
self.processItem(count) // Overlapping invocations
}
✅ シリアルキュー
// GOOD: Dedicated serial queue — handler invocations are serialized
let timerQueue = DispatchQueue(label: "com.app.timer-queue")
let timer = DispatchSource.makeTimerSource(queue: timerQueue)
timer.setEventHandler { [weak self] in
self?.count += 1 // Safe — serial queue
self?.processItem(count) // No overlap
}
UI 更新にはメインキューを使用する
タイマーのハンドラーで UI を更新する場合は、メインキューにディスパッチします。
let timer = DispatchSource.makeTimerSource(queue: timerQueue)
timer.setEventHandler { [weak self] in
let result = self?.computeResult()
DispatchQueue.main.async {
self?.updateUI(with: result)
}
}
パート 6:アンチパターン
| アンチパターン | 時間コスト | 対処法 |
|---|---|---|
.default RunLoop モードのタイマー | スクロールのフリーズをデバッグするのに 30 分以上 | .common モードを使用する |
| DispatchSourceTimer の状態を追跡しない | EXC_BAD_INSTRUCTION クラッシュ。原因の特定に何時間もかかる | SafeDispatchTimer ラッパーを使用する |
サスペンド中に timer.cancel() を呼び出す | 本番環境でのクラッシュ | resume() の後に cancel() を呼び出す |
.global() キュー上のタイマー | 競合状態、不定期なクラッシュ | 専用のシリアルキューを使用する |
| タイマーを強制アンラップする | タイマーがすでにキャンセルされている場合にクラッシュ | オプショナルチェックまたは状態 enum を使用する |
| キャンセル前にイベントハンドラーをクリアしない | retain cycle が発生する可能性 | timer.setEventHandler(handler: nil) の後にキャンセルする |
| タイマーがターゲットを保持する(セレクター API) | メモリーリーク — deinit が呼び出されない | [weak self] を使ったブロック API を使用する |
| 前のタイマーを無効化せずに新しいタイマーを作成する | タイマーの増加、CPU の浪費 | 新しいタイマーを作成する前に、必ず無効化またはキャンセルする |
| RunLoop のないバックグラウンドスレッド上のタイマー | タイマーが何も通知せず、発火しない | タイマーには RunLoop が必要です。バックグラウンド処理には DispatchSourceTimer または AsyncTimerSequence を使用する |
パート 7:プレッシャーのかかる状況
シナリオ 1:「とにかく Timer.scheduledTimer を使って先に進む」
状況:期限が迫っており、毎秒更新を繰り返す必要があります。
プレッシャー:Timer は DispatchSourceTimer よりシンプルです。「単なる UI 更新用タイマーだから、GCD の複雑さは必要ありません。」
このスキルを使った場合に期待される対応:シンプルな UI 更新には Timer を選びます。ただし、スクロール中もタイマーが動作するよう、.common RunLoop モードに追加します。精度、バックグラウンド実行、またはカスタムキューが必要な場合にのみ、DispatchSourceTimer を選びます。
このスキルを使わない場合のアンチパターン:デフォルトの .default モードで Timer.scheduledTimer を使用する → スクロール中にタイマーが停止する → ユーザーから「スクロールするとプログレスバーが止まる」と報告される → デバッグに 30 分以上かかる。
反論テンプレート:「UI 更新には Timer が適切ですが、.common RunLoop モードに追加する必要があります。そうしないと、ユーザーがスクロールするたびにタイマーが止まります。2 行変更するだけで、確実に発生するバグ報告を防げます。」
シナリオ 2:「クラッシュはたまにしか発生しないので、リリースして後で修正しましょう」
状況:本番環境のクラッシュログに EXC_BAD_INSTRUCTION が記録されています。開発環境では安定して再現できません。
プレッシャー:「まれなケースです。ユーザーはアプリを再起動できます。次のリリースで修正しましょう。」
このスキルで期待される対応:クラッシュのシグネチャを DispatchSourceTimer のステートマシン違反として認識します。4 つのクラッシュパターンはすべて決定的に発生します。特定の状態遷移が起きるたびに発生します。「断続的」に見えるのは、クラッシュ自体ではなく、状態遷移のタイミングに依存するためです。SafeDispatchTimer ラッパーを適用します。
スキルがない場合のアンチパターン:修正せずにリリースする → ユーザー数の増加に伴ってクラッシュ率も上昇する → App Store の審査指標にクラッシュが表示される → リジェクトのリスクが高まる。
反論テンプレート:「このクラッシュは決定的に発生します。タイマーが特定の状態にあるたびに起きます。「断続的」なのは、その状態になるタイミングだけです。SafeDispatchTimer は、4 つすべてのクラッシュパターンを解消するドロップイン置換です。15 分で対応でき、本番環境でのクラッシュを防げます。」
シナリオ 3:「Timer.invalidate() でクリーンアップできます」
状況:ビューコントローラーで Timer を使用し、deinit で invalidate() を呼び出しています。
プレッシャー:「invalidate() は標準的なクリーンアップ方法です。どのチュートリアルにも載っています。」
このスキルで期待される対応:循環参照を認識します。Timer.scheduledTimer(timeInterval:target:selector:) はターゲットを保持します。ターゲットがビューコントローラーで、そのビューコントローラーがタイマーへの強参照を保持している場合、循環参照が発生します。タイマーが self を存続させるため、deinit は呼び出されません。解決策は、ブロック API で [weak self] を使い、deinit ではなく viewWillDisappear でタイマーを無効化することです。
スキルがない場合のアンチパターン:タイマーが self を保持する → deinit が呼び出されない → invalidate が呼び出されない → タイマーが動き続ける → メモリーリークとタイマーの蓄積 → 最終的にクラッシュまたはバッテリー消耗が発生する。
反論テンプレート:「ブロックベースの Timer API で [weak self] を使うのが解決策です。セレクターベースの API はターゲットを保持するため、deinit が呼び出されず、invalidate() も呼び出されません。安全策として、invalidate() を viewWillDisappear に移す必要もあります。」
関連スキル
axiom-timer-patterns-ref— Timer、DispatchSourceTimer、Combine Timer.publish、AsyncTimerSequence のライフサイクル図とプラットフォーム対応状況を含む API リファレンスaxiom-memory-debugging— メモリーリークパターン 1 としての Timer(Timer がターゲットを保持し、RunLoop が Timer を保持)axiom-energy— エネルギー消費パターンとしての Timer(許容時間、コアレッシング、イベント駆動型の代替手段)
リソース
WWDC:2017-706
スキル:timer-patterns-ref、memory-debugging、energy、energy-ref