iOS 工程

NSTimer 内存泄漏:retain cycle 的 3 个原因与解决方案

重复 NSTimer 会强引用 target,因此在调用 invalidate() 之前,视图控制器都不会释放。本文总结循环引用的形成结构,以及基于代码块的 API、按生命周期 invalidate、weak proxy 三种解决方案。

3 分钟阅读
NSTimer 内存泄漏:retain cycle 的 3 个原因与解决方案 封面图

如果关闭页面后没有调用 deinit,请把 NSTimer(Swift 中为 Timer)列为首要嫌疑对象。

先说结论。

重复计时器会强引用 target。

在调用 invalidate() 之前,该视图控制器绝不会释放。

Effective Objective-C 2.0 的最后第 52 项完整讨论了这个经典陷阱,即使到了 Swift 时代仍然完全适用。


先看核心总结(3 点)

  1. Timer.scheduledTimer(target:) 会通过 强引用 持有 target。
  2. 如果视图控制器把计时器保存为属性,循环引用 就形成了。
  3. 解决方案有三个 — 基于代码块的 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,造成内存泄漏和电量损耗。

将计时器与视图控制器之间的循环引用改为 weak proxy 结构的对比图
关键是把左侧的循环替换为右侧的结构

解决方案 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 方式,这种模式现在依然适用。

Xcode 内存图调试器显示泄漏警告图标的笔记本电脑屏幕
关闭页面后,先检查 deinit 日志

总结

  • 重复计时器 + target: self + 保存为属性 = 循环引用三件套
  • 计划在 deinit 中调用 invalidate 在结构上不可行
  • 默认使用基于代码块的 API + [weak self];对于 CADisplayLink 等强制要求 target 的 API,请使用 weak proxy
  • 养成关闭页面并确认是否打印 deinit 日志的习惯,这是成本最低的排查方式

一本超过 10 年历史的书,其最后一项至今仍会在最新代码审查中被指出,这让我觉得框架会变化,但引用关系的原理始终不变。


参考资料