7-12.【OC】【RunLoop】NSTimer 放在非主线程或非默认 Mode 下会发生什么?

5 阅读3分钟

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. 放在非主线程(子线程)下

这是新手最容易遇到的“定时器不跑”的问题。

  • 会发生什么:

    1. RunLoop 默认不启动: 主线程的 RunLoop 是由系统启动的,但子线程的 RunLoop 虽然存在,默认却是休眠/未开启状态。
    2. 线程立即结束: 子线程在执行完创建 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,直接由内核调度,天生支持子线程。