Ingeniería iOS

Fugas de memoria de NSTimer: 3 causas y soluciones para los retain cycles

Un NSTimer repetitivo retiene fuertemente su target, por lo que el controlador de vista no se libera hasta llamar a invalidate(). Aquí se resumen la estructura del ciclo y tres soluciones: API basada en bloques, invalidate durante el ciclo de vida y weak proxy.

3 min de lectura
Imagen de portada de Fugas de memoria de NSTimer: 3 causas y soluciones para los retain cycles

Si deinit no se llama al cerrar una pantalla, hay un sospechoso que debe encabezar la lista: NSTimer (Timer en Swift).

Empecemos por la conclusión.

Un temporizador repetitivo retiene fuertemente su target.

El controlador de vista no se libera hasta llamar a invalidate().

El último, el punto 52, de Effective Objective-C 2.0 trata por completo esta trampa clásica, que sigue siendo igual de válida en la era de Swift.


Resumen clave (3 puntos)

  1. Timer.scheduledTimer(target:) mantiene el target mediante una referencia fuerte.
  2. Si el controlador de vista guarda el temporizador como propiedad, se completa la referencia cíclica.
  3. Hay tres soluciones: API basada en bloques + weak, llamar a invalidate fuera de deinit y el patrón weak proxy.

Cómo se forma la referencia cíclica

El código problemático tiene este aspecto.

class PollingViewController: UIViewController {
    var timer: Timer?

    override func viewDidLoad() {
        super.viewDidLoad()
        timer = Timer.scheduledTimer(
            timeInterval: 5.0,
            target: self,          // El temporizador self lo retiene fuertemente
            selector: #selector(refresh),
            userInfo: nil,
            repeats: true
        )
    }

    deinit {
        timer?.invalidate()        // Nunca se llama
    }
}

Si seguimos las referencias, queda así:

  • Controlador de vista → retiene fuertemente el temporizador mediante la propiedad timer
  • Temporizador → retiene fuertemente self, el controlador de vista que actúa como target
  • Además, RunLoop → retiene fuertemente el temporizador

«¿No se puede llamar a invalidate en deinit?». Ese es el núcleo de la trampa. Como el temporizador retiene el controlador de vista, su contador de referencias nunca llega a 0 y deinit nunca se ejecuta. Aunque se haga pop de la pantalla, refresh sigue ejecutándose cada 5 segundos en segundo plano, provocando una fuga de memoria y un mayor consumo de batería.

Diagrama comparativo que transforma la referencia cíclica entre el temporizador y el controlador de vista en una estructura con weak proxy
La clave es sustituir el ciclo de la izquierda por la estructura de la derecha

Solución 1: API basada en bloques (iOS 10+)

Es la solución moderna más limpia. En lugar del enfoque basado en target, recibe un cierre y usa weak para las capturas.

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

RunLoop sigue reteniendo el temporizador, pero ya no existe un ciclo que retenga fuertemente el controlador de vista. Cuando este se libera, deinit puede llamar a invalidate correctamente.

La solución basada en categorías que propone Effective Objective-C tiene la misma esencia. Antes de iOS 10 no existía una API basada en bloques, así que recomendaba crear una categoría que pasara un bloque a NSTimer mediante userInfo.

Solución 2: llamar a invalidate según el ciclo de vida

Consiste en limpiar en el momento en que desaparece la pantalla, no en deinit.

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

Al llamar a invalidate, el temporizador suelta la referencia fuerte al target y rompe el ciclo. Sin embargo, hay que recrearlo en viewWillAppear y considerar también los casos en que otra pantalla cubre brevemente la actual y luego desaparece, lo que aumenta los puntos de mantenimiento.

Solución 3: patrón weak proxy

Es un patrón clásico para situaciones que exigen pasar un target, como la compatibilidad con iOS 9 o CADisplayLink. Inserta entre el temporizador y el controlador de vista un delegado con solo una referencia débil.

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),   // El temporizador retiene fuertemente solo el proxy
    selector: #selector(refresh),
    userInfo: nil,
    repeats: true
)

El temporizador solo retiene fuertemente el proxy, y este observa el controlador de vista únicamente mediante weak. Cuando el controlador se libera, los mensajes dirigidos al proxy pasan a nil mediante forwardingTarget y desaparecen silenciosamente. Es un caso de uso del reenvío de mensajes del runtime de Objective-C para resolver referencias cíclicas.

Como CADisplayLink todavía solo ofrece el enfoque basado en target, este patrón sigue vigente.

Pantalla de un portátil con el icono de advertencia de fuga en el depurador Memory Graph de Xcode
Cierra la pantalla y comprueba primero el registro de deinit

Resumen

  • Temporizador repetitivo + target: self + guardado como propiedad = el trío de la referencia cíclica
  • El plan de llamar a invalidate en deinit no es viable estructuralmente
  • Como base, usa API basada en bloques + [weak self]; para APIs que obligan a usar target, como CADisplayLink, usa weak proxy
  • Acostúmbrate a cerrar la pantalla y comprobar si aparece el registro de deinit: es la forma más barata de detectar este problema

Que el último punto de un libro de hace más de 10 años siga apareciendo en revisiones de código actuales demuestra que los frameworks cambian, pero los principios de las relaciones entre referencias permanecen.


Referencias