将 NSTimer 放在非主线程或非默认 Mode 下,会直接触及 RunLoop 的底层调度机制。这通常是“定时器失效”或“线程内存泄露”的主要诱因。
以下是两种场景下的具体表现与底层原因:
1. 放在非默认 Mode 下(如 UITrackingRunLoopMode)
如果你在创建 Timer 时没有显式指定 Mode(例如使用 scheduledTimer),它会被自动放入 NSDefaultRunLoopMode。
- 会发生什么: 当用户滑动屏幕(如滚动 TableView)时,主线程 RunLoop 会立即切换到
UITrackingRunLoopMode。此时,处于 Default Mode 的 Timer 会被挂起(暂停) 。 - 后果: 定时器回调停止触发。直到手指离开屏幕,RunLoop 切换回 Default Mode,Timer 才会“补发”之前的回调(如果它是重复的,通常会合并触发)。
- 解决: 必须将其手动加入
NSRunLoopCommonModes。这并不是一个真正的模式,而是一个“标签”,告诉 RunLoop 在 Default 和 Tracking 模式下都去检查这个 Timer。
2. 放在非主线程(子线程)下
这是新手最容易遇到的“定时器不跑”的问题。
-
会发生什么:
- RunLoop 默认不启动: 主线程的 RunLoop 是由系统启动的,但子线程的 RunLoop 虽然存在,默认却是休眠/未开启状态。
- 线程立即结束: 子线程在执行完创建 Timer 的代码后,如果没有其他任务,线程会直接销毁。
-
后果: Timer 虽然被注册到了子线程的 RunLoop 中,但因为循环没跑起来,它就像一个没人去读的闹钟,永远不会响。
-
正确姿势:
Swift
Thread.detachNewThread { let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in print("子线程定时器触发") } // 必须手动开启当前线程的 RunLoop RunLoop.current.run() }
3. 潜在的危险:内存与销毁
在子线程使用 Timer 会引入更复杂的生命周期问题:
A. 内存泄露(AutoreleasePool)
子线程的 RunLoop 开启后,通常是一个死循环(RunLoop.current.run())。如果你的 Timer 产生了很多临时对象,而你没有在该线程手动添加 autoreleasepool,这些对象会堆积在内存中,直到 RunLoop 停止。
B. 无法在子线程销毁主线程的 Timer
NSTimer 不是线程安全的。
- 场景: 你在主线程创建了 Timer,试图在后台线程调用
[timer invalidate]。 - 风险: 虽然有时能成功,但官方文档明确指出:你必须在创建 Timer 的同一个线程调用
invalidate。否则,该 Timer 所在的 RunLoop 可能无法正确移除对它的引用,导致 Timer 持续消耗资源或 Crash。
总结:对比表
| 维度 | 主线程 Default Mode | 主线程 Common Modes | 子线程 (不启动 Loop) | 子线程 (启动 Loop) |
|---|---|---|---|---|
| 滑动时运行 | ❌ 暂停 | ✅ 正常 | ❌ 不运行 | ✅ 正常 |
| 执行精度 | 受 UI 压力影响 | 较高 | ❌ 不执行 | 最高(不受 UI 干扰) |
| 内存风险 | 低(系统管理) | 低 | 无 | 高(需手动加 Pool) |
| 线程安全 | 是 | 是 | ❌ 严禁跨线程操作 | 需同线程操作 |
💡 进阶建议
如果你需要在后台执行一个高精度的定时任务,且不希望被 UI 模式干扰,也不想去折腾复杂的子线程 RunLoop,GCD Timer (dispatch_source_set_timer) 是更好的选择。它不依赖 RunLoop,直接由内核调度,天生支持子线程。