После подписки контроллера на уведомления его deinit не вызывается после закрытия экрана; контроллер хранит токен подписки. Какой граф владения нужно проверить первым?
Сначала проверьте цикл: контроллер сильно владеет токеном подписки, запись подписки удерживается центром уведомлений, а замыкание в этой записи сильно захватывает контроллер. В результате контроллер удерживает токен, а токен через замыкание удерживает контроллер, поэтому его deinit не вызывается.
Обычно цикл разрывают слабым захватом self и явно удаляют подписку в момент завершения жизненного цикла экрана.
ARC автоматизирует добавление и удаление операций управления сильными ссылками, но не анализирует смысл графа владения. Если цепочка сильных ссылок замыкается в цикл, каждая ссылка продолжает считать объект нужным, даже когда из внешнего кода объект уже недостижим.
Особенно часто это возникает в callback-механизмах: система хранит замыкание дольше текущего метода, а замыкание может хранить объект-владелец. Автоматическое управление памятью не может безопасно решить, какая из этих ссылок должна быть слабой.
У подписки через замыкание есть долгоживущий владелец — например, NotificationCenter. Если контроллер сохраняет возвращённый токен, а замыкание захватывает self сильно, образуется такой граф:
Обнуление внешней ссылки на контроллер не уменьшает его счётчик сильных ссылок до нуля. Следствие — не вызывается deinit, а подписка продолжает существовать и может выполнять код для уже закрытого экрана.
Минимальный вариант с разрывом цикла:
[weak self] означает, что замыкание не становится владельцем контроллера. Тогда при исчезновении остальных сильных ссылок контроллер уничтожается, а его deinit удаляет регистрацию.
Слабый захват сам по себе не прекращает регистрацию: центр уведомлений всё ещё может хранить запись и вызывать замыкание. После уничтожения контроллера такой callback обычно просто ничего не делает, поэтому для освобождения ресурсов и прекращения ненужных уведомлений подписку следует отменять явно при завершении жизненного цикла.
[unowned self] также разрывает цикл, но не обнуляется автоматически. Если уведомление придёт после уничтожения контроллера, обращение к self приведёт к аварийному завершению. Поэтому unowned допустим только при реально гарантированном порядке жизни, а для внешнего источника уведомлений обычно безопаснее weak.
Экран создаёт подписку в viewDidAppear, сохраняет токен в свойстве и удаляется из навигационного стека. При сильном захвате self экран не освобождается: центр уведомлений продолжает владеть callback, callback — экраном, а экран — токеном.
Вариант с сильным захватом прост, но создаёт цикл. Вариант с unowned не создаёт цикла и не имеет поведения optional-ссылки, однако может аварийно завершить приложение при позднем уведомлении. Вариант с weak self безопасен для неопределённого времени доставки уведомления, но требует явного снятия подписки.
Практическое решение — использовать [weak self], отменять подписку в симметричной точке жизненного цикла, например в viewDidDisappear или отдельном методе stop, и не полагаться только на deinit. Это делает владение явным и предотвращает как утечку контроллера, так и накопление ненужных регистраций.
1. Достаточно ли заменить сильный захват на weak, если токен подписки никогда не удаляется?
Нет. Цикл между контроллером и callback будет разорван, поэтому контроллер сможет уничтожиться, но центр уведомлений продолжит хранить регистрацию и замыкание. Такая регистрация может навсегда оставаться в центре, а callback будет регулярно вызываться и бездействовать после обнуления self. Подписку всё равно нужно отменять, когда она больше не нужна.
2. Что изменится, если контроллер не хранит токен подписки?
Отсутствие сохранённого токена не гарантирует отсутствие утечки. Центр уведомлений хранит регистрацию и замыкание; если замыкание сильно захватывает контроллер, может остаться цепочка центр уведомлений → регистрация → замыкание → контроллер. Сохранение токена в контроллере делает цикл более очевидным, но не является обязательным условием для удержания контроллера callback-механизмом.
3. Почему вызов removeObserver в deinit не всегда спасает от цикла?
deinit вызывается только после того, как объект уже стал доступен для уничтожения. При цикле сильных ссылок этого не происходит, поэтому код удаления подписки в deinit не будет достигнут. Удаление нужно выполнять из внешней точки жизненного цикла, либо сначала разорвать цикл слабым захватом, чтобы deinit вообще смог запуститься.