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 不就好了嗎?」正是這個陷阱的核心。由於計時器持有視圖控制器,參考計數永遠不會變成 0,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),   // 計時器只會強烈參考 proxy
    selector: #selector(refresh),
    userInfo: nil,
    repeats: true
)

計時器強烈持有的只有 proxy,而 proxy 只以 weak 參考視圖控制器。視圖控制器釋放後,傳給 proxy 的訊息會透過 forwardingTarget 流向 nil,安靜地消失。這是將 Objective-C 執行環境的訊息轉送應用於解決循環參考的案例。

CADisplayLink 目前仍只提供 target 方式,因此這個模式現在依然實用。

Xcode 記憶體圖形除錯器顯示洩漏警告圖示的筆電畫面
關閉畫面後,先確認 deinit 記錄

總結

  • 重複計時器 + target: self + 儲存為屬性 = 循環參考三件套
  • 打算在 deinit 中 invalidate,在結構上並不可行
  • 預設使用區塊式 API + [weak self];對 CADisplayLink 等強制要求 target 的 API,請使用 weak proxy
  • 養成關閉畫面並確認是否出現 deinit 記錄的習慣,就能以最低成本抓出這個問題

一本超過 10 年歷史的書,其最後一項至今仍會在最新的程式碼審查中被指出,讓我覺得框架雖然會改變,但參考關係的原理始終不變。


參考資料