Engenharia iOS

Vazamentos de memória do NSTimer: 3 causas e soluções para retain cycles

Um NSTimer repetitivo retém o target fortemente, então o view controller não é liberado até invalidate() ser chamado. Este resumo explica a estrutura do ciclo e três soluções: API baseada em blocos, invalidate no ciclo de vida e weak proxy.

3 min de leitura
Imagem de capa de Vazamentos de memória do NSTimer: 3 causas e soluções para retain cycles

Se deinit não for chamado depois de fechar uma tela, coloque um suspeito no topo da lista: NSTimer (Timer no Swift).

Vamos começar pela conclusão.

Um timer repetitivo retém o target fortemente.

O view controller nunca é liberado até invalidate() ser chamado.

O último item, o de número 52, de Effective Objective-C 2.0 trata completamente dessa armadilha clássica, que continua igualmente válida na era do Swift.


Resumo principal (3 pontos)

  1. Timer.scheduledTimer(target:) mantém o target com uma referência forte.
  2. Quando o view controller mantém o timer em uma propriedade, o ciclo de retenção é concluído.
  3. Há três soluções — API baseada em blocos + weak, chamar invalidate fora de deinit e o padrão weak proxy.

Como o ciclo de retenção é formado

O código problemático é assim.

class PollingViewController: UIViewController {
    var timer: Timer?

    override func viewDidLoad() {
        super.viewDidLoad()
        timer = Timer.scheduledTimer(
            timeInterval: 5.0,
            target: self,          // O timer self o retém fortemente
            selector: #selector(refresh),
            userInfo: nil,
            repeats: true
        )
    }

    deinit {
        timer?.invalidate()        // Nunca é chamado
    }
}

Seguindo as referências, temos isto:

  • View controller → retém o timer fortemente pela propriedade timer
  • Timer → retém fortemente self, o view controller que é o target
  • Além disso, RunLoop → retém o timer fortemente

“Não dá para chamar invalidate no deinit?” Esse é o ponto central da armadilha. Como o timer retém o view controller, a contagem de referências nunca chega a 0 e deinit nunca é executado. Mesmo depois de dar pop na tela, o refresh continua rodando a cada 5 segundos em segundo plano, causando vazamento de memória e de bateria.

Diagrama comparativo que substitui o ciclo de retenção entre timer e view controller por uma estrutura com weak proxy
O ponto central é substituir o ciclo à esquerda pela estrutura à direita

Solução 1: API baseada em blocos (iOS 10+)

É a solução moderna mais limpa. Em vez do modo baseado em target, receba uma closure e faça as capturas usando weak.

timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
    self?.refresh()
}

O timer continua retido pelo RunLoop, mas não há um ciclo que retenha o view controller fortemente. Quando ele é liberado, deinit chama invalidate normalmente.

A solução com category proposta por Effective Objective-C tem a mesma essência. Antes do iOS 10, não havia API baseada em blocos, então a recomendação era criar uma category que passasse um bloco ao NSTimer por meio de userInfo.

Solução 2: chamar invalidate conforme o ciclo de vida

A ideia é fazer a limpeza em o momento em que a tela desaparece, e não no deinit.

override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)
    timer?.invalidate()
    timer = nil
}

Ao chamar invalidate, o timer solta a referência forte ao target e o ciclo é quebrado. Porém, é preciso recriá-lo no viewWillAppear e considerar também quando outra tela cobre a atual por instantes e depois sai, aumentando os pontos de manutenção.

Solução 3: padrão weak proxy

Este é um padrão clássico para situações que exigem passar um target, como suporte ao iOS 9 e CADisplayLink. Insira entre o timer e o view controller um delegado com apenas uma referência fraca.

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),   // O timer retém fortemente apenas o proxy
    selector: #selector(refresh),
    userInfo: nil,
    repeats: true
)

O timer retém fortemente apenas o proxy, e o proxy observa o view controller somente com weak. Quando o view controller é liberado, as mensagens enviadas ao proxy passam por forwardingTarget até nil e desaparecem silenciosamente. É um caso de aplicação do encaminhamento de mensagens do runtime do Objective-C para resolver ciclos de retenção.

Como o CADisplayLink ainda oferece apenas o modo baseado em target, esse padrão continua atual.

Tela de notebook com o ícone de alerta de vazamento no Memory Graph Debugger do Xcode
Feche a tela e verifique primeiro o log de deinit

Resumo

  • Timer repetitivo + target: self + armazenado em propriedade = o trio do ciclo de retenção
  • O plano de chamar invalidate no deinit não é viável estruturalmente
  • Por padrão, use API baseada em blocos + [weak self]; em APIs que exigem target, como CADisplayLink, use weak proxy
  • Crie o hábito de fechar a tela e verificar se o log de deinit aparece; é a forma mais barata de detectar esse problema

Ver o último item de um livro com mais de 10 anos ainda ser apontado em code reviews atuais mostra que os frameworks mudam, mas os princípios das relações entre referências permanecem os mesmos.


Referências