说 NSTimer “必然”导致循环引用其实有些绝对,但在 iOS 10 / macOS 10.12 之前,如果你使用经典的 scheduledTimerWithTimeInterval:target:selector:... 方法,它确实是一个几乎无法避开的内存陷阱。
其根本原因不在于 delegate 模式,而在于 NSTimer 特有的强引用设计和 RunLoop 机制。
1. 核心矛盾:RunLoop 才是真正的“幕后黑手”
要理解这个循环引用,我们需要看清这条强引用链:
- RunLoop 持有 Timer:当你把 Timer 注册到 RunLoop 时,RunLoop 会强引用这个 Timer。
- Timer 持有 Target:为了能准时调用你的方法,
NSTimer会强引用它的target(通常是你的 ViewController)。 - ViewController 持有 Timer:为了能手动停止计时器,你通常会用一个
strong属性来保存这个 Timer。
结果: 形成了一个 ViewController Timer 的双向强引用。即使你关掉界面,RunLoop 依然抓着 Timer,Timer 依然抓着 ViewController,谁都走不掉。
2. 为什么 weakSelf 救不了它?
很多开发者尝试用处理 Block 的套路来对付 Timer,结果发现无效:
Objective-C
__weak typeof(self) weakSelf = self;
// 错误尝试:Timer 内部依然会尝试将 weakSelf 重新 retain 为强引用
self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:weakSelf selector:...];
原因: NSTimer 的内部实现里,它不管你传进来的是强指针还是弱指针,它都会对 target 执行一次 retain。这是它的 ABI 契约决定的——它要确保在触发方法时,目标对象一定还在内存里。
3. “自杀”逻辑的失效
通常我们会在 dealloc 里销毁资源:
Objective-C
- (void)dealloc {
[self.timer invalidate];
}
死锁: dealloc 永远不会被调用,因为 Timer 强引用着 self。而 invalidate(停止并从 RunLoop 移除 Timer 的唯一方法)写在 dealloc 里。这就成了一个“如果不执行 A 就不能执行 B,但不执行 B 就永远到不了 A”的死局。
4. 解决方案:如何优雅地打破循环?
方案 A:iOS 10+ 的 Block API(推荐)
苹果终于意识到这个设计太坑,在 iOS 10 引入了不带 target 的 Block 版本:
Objective-C
__weak typeof(self) weakSelf = self;
self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0 repeats:YES block:^(NSTimer * _Nonnull timer) {
[weakSelf doSomething];
}];
这里的 weakSelf 在 Block 里是生效的,因为 Timer 此时持有的是 Block,而 Block 弱引用了 self。
方案 B:中介代理(NSProxy)
如果你必须支持老版本,可以创建一个“假目标”:
- 创建一个继承自
NSProxy的类WeakProxy。 WeakProxy内部 弱引用 真正的target(ViewController)。- 把 Timer 的
target指向WeakProxy。
逻辑: RunLoop Timer WeakProxy ViewController。此时 ViewController 就可以顺利进入 dealloc,并在那里执行 [timer invalidate]。
总结
NSTimer 的循环引用是由其**“必须强持有 Target 才能保证回调安全”**的设计理念导致的。在没有 Block API 的年代,它迫使开发者必须在 viewWillDisappear: 这种生命周期方法里手动 invalidate,这违背了“高内聚”的编程原则。