画面を閉じてもdeinitが呼ばれないなら、最有力の容疑者としてまずNSTimer(SwiftではTimer)を疑いましょう。
結論から説明します。
繰り返しタイマーはtargetを強く参照します。
invalidate()を呼ぶまで、ビューコントローラーは決して解放されません。
Effective Objective-C 2.0の最後の第52項目がこの古典的な落とし穴を丸ごと扱うほど有名な問題で、Swiftの時代にもそのまま当てはまります。
まずは要点(3つ)
Timer.scheduledTimer(target:)はtargetを強い参照で保持します。- ビューコントローラーがタイマーをプロパティに保持すると、循環参照が完成します。
- 解決策は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が動き続け、メモリーリークに加えてバッテリーも消費します。
解決策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が提案するカテゴリによる解決策も本質は同じです。iOS 10より前はブロックベースAPIがなかったため、NSTimerにブロックをuserInfoで渡すカテゴリを自作して使うよう推奨していました。
解決策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 + プロパティ保持 = 循環参照の3点セットです
- deinitでinvalidateする計画は構造上成立しません
- 基本はブロックベースAPI +
[weak self]、CADisplayLinkのようにtargetが必須のAPIにはweak proxyを使いましょう - 画面を閉じてdeinitログが出るか確認する習慣が、この問題を最も低コストで見つける方法です
10年以上前の本の最後の項目が今も最新のコードレビューで指摘されるのを見ると、フレームワークが変わっても参照関係の原理は変わらないのだと感じます。

