mcpField
A
axiom-timer-patterns

axiom-timer-patterns

github.com/charleswiltgen開発1.2k84 回インストール今日更新
リポジトリ

axiom-timer-patterns について

iOS 開発者向けのタイマー安全スキルです。RunLoop の動作、DispatchSourceTimer の状態遷移、API の選び方を扱い、クラッシュやスクロール中の停止、ライフサイクルの問題を防ぐ方法を示します。

主な機能

  1. 01タイマー API の選択ガイド
  2. 02RunLoop モードとスクロール問題の対策
  3. 03DispatchSourceTimer の状態安全パターン
  4. 04シリアルキューと UI 更新の指針
  5. 05タイマーのライフサイクルに関するアンチパターン確認

活用シーン

  1. 01スクロール中も UI タイマーを動作させる
  2. 02DispatchSourceTimer のクラッシュを診断する
  3. 03非同期コードに使うタイマーを選ぶ
  4. 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 の選択ツリー

特徴TimerDispatchSourceTimerAsyncTimerSequence
スレッド安全性メインスレッドのみ(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

コメント