關閉畫面後若沒有呼叫 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 不就好了嗎?」正是這個陷阱的核心。由於計時器持有視圖控制器,參考計數永遠不會變成 0,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), // 計時器只會強烈參考 proxy
selector: #selector(refresh),
userInfo: nil,
repeats: true
)
計時器強烈持有的只有 proxy,而 proxy 只以 weak 參考視圖控制器。視圖控制器釋放後,傳給 proxy 的訊息會透過 forwardingTarget 流向 nil,安靜地消失。這是將 Objective-C 執行環境的訊息轉送應用於解決循環參考的案例。
CADisplayLink 目前仍只提供 target 方式,因此這個模式現在依然實用。
總結
- 重複計時器 + target: self + 儲存為屬性 = 循環參考三件套
- 打算在 deinit 中 invalidate,在結構上並不可行
- 預設使用區塊式 API +
[weak self];對 CADisplayLink 等強制要求 target 的 API,請使用 weak proxy - 養成關閉畫面並確認是否出現 deinit 記錄的習慣,就能以最低成本抓出這個問題
一本超過 10 年歷史的書,其最後一項至今仍會在最新的程式碼審查中被指出,讓我覺得框架雖然會改變,但參考關係的原理始終不變。

