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. 02诊断 DispatchSourceTimer 崩溃
  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 模式陷阱

滚动时计时器会停止触发。这是 iOS 开发中最常见的计时器问题。

原因

Timer.scheduledTimer 会将计时器添加到当前 RunLoop 的 .default 模式中。用户滚动时(UIScrollView、SwiftUI ScrollView、List),RunLoop 会切换到 .tracking 模式。由于计时器仅注册在 .default 模式中,因此不会在 .tracking 模式下触发。

时间成本:滚动时计时器莫名停止 → 如果不了解 RunLoop 模式,调试可能要花 30+ 分钟。

❌ 错误 — 滚动时计时器会停止
// 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
✅ 修复 — 滚动时计时器仍会运行
// 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:挂起时释放释放前先恢复并取消
dispatch_resume崩溃 4:取消后恢复操作前检查 isCancelled
_dispatch_source_refs_t / suspend count崩溃 1:挂起次数不平衡跟踪状态,仅在运行时挂起
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() 都会使内部计数器递增。释放时,如果挂起计数不为零,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() 队列上使用计时器竞态条件、间歇性崩溃使用专用串行队列
强制解包计时器计时器已取消时会崩溃使用可选值检查或状态枚举
取消前未清除事件处理器可能导致循环引用先调用 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(视图控制器),且视图控制器强引用该计时器,就会形成循环引用。由于计时器让 self 保持存活,deinit 永远不会被调用。解决方案:在闭包 API 中使用 [weak self],并在 viewWillDisappear 中(而不是 deinit 中)使计时器失效。

未使用技能时的反模式:计时器保留 self → deinit 永远不会被调用 → invalidate 永远不会被调用 → 计时器持续触发 → 内存泄漏 + 计时器不断累积 → 最终崩溃或耗尽电量。

反驳模板:“修复方法是使用带有 [weak self] 的闭包式 Timer API。基于选择器的 API 会保留其目标,这意味着我们的 deinit 永远不会触发,invalidate() 也永远不会被调用。我们还需要将 invalidate() 移到 viewWillDisappear 中,作为安全保障。”


相关技能

  • axiom-timer-patterns-ref — Timer、DispatchSourceTimer、Combine Timer.publish、AsyncTimerSequence 的 API 参考,包含生命周期图和平台可用性说明
  • axiom-memory-debugging — Timer 作为模式 1 内存泄漏(Timer 保留目标,RunLoop 保留 Timer)
  • axiom-energy — Timer 作为耗电模式(容差、合并、事件驱动替代方案)

资源

WWDC:2017-706

技能:timer-patterns-ref、memory-debugging、energy、energy-ref

评论

支持 Markdown