La creazione di un timer è un’operazione che, prima o dopo, qualsiasi sviluppatore iOS si trova a fare.
C’è una piccola insidia, tuttavia, nella gestione della memoria che può portare ad avere dei retain cycle all’interno della classe/controller in cui vi trovate a definire il timer.
La documentazione di Apple definisce così la classe Timer
(NSTimer
per i nostalgici):
Timers work in conjunction with run loops. Run loops maintain strong references to their timers, so you don’t have to maintain your own strong reference to a timer after you have added it to a run loop.
Un timer in automatico inserito nel run loop in cui viene istanziato (generalmente il main thread), questo però comporta un’insidia non da poco. L’ownership non è (solo) della classe in cui andate a definirlo, ma bisogna agire manualmente per invalidarlo e annullare il suo riferimento.
Proviamo a spiegare il tutto con un semplice esempio.
Creiamo un timer ripetuto
Supponiamo di avere un semplicissimo UIViewController
che istanzia un timer:
import UIKit
class SampleViewController: UIViewController {
private var timer: Timer?
// MARK: - Init
convenience init() {
self.init(nibName: nil, bundle: nil)
view.backgroundColor = .white
}
deinit {
print("[SampleViewController] deinit called")
}
// MARK: - View Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
timer = Timer.scheduledTimer(timeInterval: 1.0, target: self, selector: #selector(timerFired(_:)), userInfo: nil, repeats: true)
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
timer = nil
}
// MARK: - Private
@objc private func timerFired(_ sender: Timer) {
print("[SampleViewController] timer fired")
}
}
Da un altro controller istanziamo SampleViewController
e lo dismettiamo con un semplice drag verso il basso.
let controller = SampleViewController()
present(controller, animated: true)
Retain cycle in agguato
Ci sembrerebbe di aver fatto tutto in maniera corretta, ma guardando la console notiamo che il deinit
non viene chiamato dismettendo il controller:
[SampleViewController] timer fired
[SampleViewController] timer fired
[SampleViewController] timer fired
Analizzando il memory graph, notiamo effettivamente che il controller è ancora presente in menoria, mantenuto in vita da _NSCFTimer
:
La chiave per risolvere il problema sta nel chiamare il metodo invalidate
del timer:
This method is the only way to remove a timer from an RunLoop object. The
RunLoop
object removes its strong reference to the timer, either just before the invalidate()method returns or at some later point.
If it was configured with target and user info objects, the receiver removes its strong references to those objects as well.
In questo caso la documentazione è abbastanza chiara: il timer viene rimosso dal relativo RunLoop e, cosa importante, viene rimosso il riferimento strong
all’oggetto target del timer.
Andiamo quindi a modificare il nostro viewWillDisappear:
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
timer?.invalidate()
timer = nil
}
Boom! Il nostro controller viene ora deallocato in maniera corretta:
[SampleViewController] timer fired
[SampleViewController] timer fired
[SampleViewController] timer fired
[SampleViewController] deinit called
Conclusioni
Nonostante la documentazione si chiara a riguardo, è un problema che trovo spesso nei progetti su cui mi trovo a lavorare e che, ammetto, mi capita di fare.
Piccola nota, il tutto è valido per i timer ripetitivi, ovvero che hanno parametro repeat: true
. Per i timer che vengono eseguiti una sola volta, l’ownership viene rilasciata automaticamente.
Spero di non scordarmelo più dopo averci fatto anche un post a riguardo 😎