如果关闭页面后没有调用 deinit,请把 NSTimer(Swift 中为 Timer)列为首要嫌疑对象。
先说结论。
重复计时器会强引用 target。
在调用
invalidate()之前,该视图控制器绝不会释放。
Effective Objective-C 2.0 的最后第 52 项完整讨论了这个经典陷阱,即使到了 Swift 时代仍然完全适用。
先看核心总结(3 点)
Timer.scheduledTimer(target:)会通过 强引用 持有 target。- 如果视图控制器把计时器保存为属性,循环引用 就形成了。
- 解决方案有三个 — 基于代码块的 API + weak、在 deinit 之外调用 invalidate,以及 weak proxy 模式。
循环引用是如何形成的
问题代码如下。
class PollingViewController: UIViewController {
var timer: Timer?
override func viewDidLoad() {
super.viewDidLoad()
timer = Timer.scheduledTimer(
timeInterval: 5.0,
target: self, // 计时器会 self强引用它
selector: #selector(refresh),
userInfo: nil,
repeats: true
)
}
deinit {
timer?.invalidate() // 永远不会调用
}
}
沿着引用关系看,结构如下:
- 视图控制器 → 通过 timer 属性强引用计时器
- 计时器 → 强引用作为 target 的 self(视图控制器)
- 此外,RunLoop → 强引用计时器
“在 deinit 中调用 invalidate 不就行了吗?”这正是陷阱的核心。由于计时器持有视图控制器,引用计数永远无法归零,因此 deinit 永远不会执行。即使页面已经 pop,后台仍会每 5 秒持续执行 refresh,造成内存泄漏和电量损耗。
解决方案 1:基于代码块的 API(iOS 10+)
这是最简洁的现代解决方案。用接收闭包的方式替代 target,并让捕获使用 weak。
timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
self?.refresh()
}
计时器仍然会被 RunLoop 持有,但不再存在强引用视图控制器的循环。视图控制器释放后,deinit 就能正常调用 invalidate。
Effective Objective-C 提出的 category 方案本质相同。iOS 10 之前没有基于代码块的 API,因此建议自行创建 category,通过 userInfo 将代码块传给 NSTimer。
解决方案 2:根据生命周期调用 invalidate
不是在 deinit 中,而是在 页面消失时进行清理。
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
timer?.invalidate()
timer = nil
}
调用 invalidate 的瞬间,计时器会释放对 target 的强引用,从而打破循环。不过还需要在 viewWillAppear 中重新创建计时器,并考虑其他页面短暂覆盖当前页面后返回的情况,因此维护点会增加。
解决方案 3:weak proxy 模式
这是必须传入 target 的场景(支持 iOS 9、CADisplayLink 等)所使用的经典模式。在计时器和视图控制器之间插入一个只持有弱引用的代理。
final class WeakProxy: NSObject {
weak var target: NSObjectProtocol?
init(target: NSObjectProtocol) {
self.target = target
super.init()
}
override func forwardingTarget(for aSelector: Selector!) -> Any? {
target
}
}
timer = Timer.scheduledTimer(
timeInterval: 5.0,
target: WeakProxy(target: self), // 计时器只强引用代理
selector: #selector(refresh),
userInfo: nil,
repeats: true
)
计时器只强引用代理,而代理只通过 weak 观察视图控制器。视图控制器释放后,发往代理的消息会通过 forwardingTarget 流向 nil,并静默消失。这是将 Objective-C 运行时的消息转发应用于解决循环引用的案例。
由于 CADisplayLink 目前仍只提供 target 方式,这种模式现在依然适用。
总结
- 重复计时器 + target: self + 保存为属性 = 循环引用三件套
- 计划在 deinit 中调用 invalidate 在结构上不可行
- 默认使用基于代码块的 API +
[weak self];对于 CADisplayLink 等强制要求 target 的 API,请使用 weak proxy - 养成关闭页面并确认是否打印 deinit 日志的习惯,这是成本最低的排查方式
一本超过 10 年历史的书,其最后一项至今仍会在最新代码审查中被指出,这让我觉得框架会变化,但引用关系的原理始终不变。

