iOSエンジニアリング

NSTimerのメモリーリーク、retain cycleの原因と解決策3選

繰り返しNSTimerはtargetを強く参照するため、invalidate()するまでビューコントローラーは解放されません。循環参照が生じる構造と、ブロックベースAPI、ライフサイクルでのinvalidate、weak proxyという3つの解決策をまとめます。

読了 4 分
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. 解決策は3つ — ブロックベースAPI + weakdeinit以外でinvalidateweak 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が提案するカテゴリによる解決策も本質は同じです。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方式しか提供しないため、このパターンは現在も有効です。

Xcodeのメモリーグラフデバッガーにリーク警告アイコンが表示されたノートパソコンの画面
画面を閉じたら、まずdeinitログを確認しましょう

まとめ

  • 繰り返しタイマー + target: self + プロパティ保持 = 循環参照の3点セットです
  • deinitでinvalidateする計画は構造上成立しません
  • 基本はブロックベースAPI + [weak self]、CADisplayLinkのようにtargetが必須のAPIにはweak proxyを使いましょう
  • 画面を閉じてdeinitログが出るか確認する習慣が、この問題を最も低コストで見つける方法です

10年以上前の本の最後の項目が今も最新のコードレビューで指摘されるのを見ると、フレームワークが変わっても参照関係の原理は変わらないのだと感じます。


参考資料