关于 axiom-timer-patterns
面向 iOS 开发者的计时器安全技能,涵盖 RunLoop 行为、DispatchSourceTimer 状态转换及计时器 API 选择,并提供预防崩溃、滚动时暂停和生命周期问题的模式。
主要功能
- 01计时器 API 选择指南
- 02RunLoop 模式与滚动问题修复
- 03DispatchSourceTimer 状态安全模式
- 04串行队列与 UI 更新建议
- 05计时器生命周期反模式检查
适用场景
- 01使 UI 计时器在滚动时继续运行
- 02诊断 DispatchSourceTimer 崩溃
- 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 模式陷阱
滚动时计时器会停止触发。这是 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