Циклы сильных ссылок, захват self и поиск утечек
Retain cycle — это когда объекты держат друг друга за руки и вместе идут ко дну: счётчик ни у одного не дойдёт до нуля.
Цикл сильных ссылок возникает, когда по сильным ссылкам можно пройти от объекта к самому себе. ARC не обходит граф, поэтому такой узел он не заметит никогда — разорвать цикл обязан разработчик.
«Что такое retain cycle и как его разорвать?»
Что на самом деле проверяют
Может ли кандидат нарисовать цикл на доске и назвать место, где ставится weak. Дальше почти всегда идёт продолжение про замыкания — именно там циклы встречаются в 90% реальных случаев.
Развёрнутый ответ
Простейший случай — два класса, ссылающиеся друг на друга.
final class Chat {
var lastMessage: Message?
deinit { print("deinit Chat") }
}
final class Message {
var chat: Chat? // сильная ссылка обратно -> цикл
deinit { print("deinit Message") }
}
var chat: Chat? = Chat()
var message: Message? = Message()
chat?.lastMessage = message
message?.chat = chat
chat = nil
message = nil
print("обнулили обе переменные")
Вывод:
обнулили обе переменные
Ни одного deinit — оба объекта живы и недостижимы. Лечение: одна ссылка в кольце должна перестать быть сильной. Здесь это weak var chat: Chat? у Message: сообщение принадлежит чату, а не наоборот. Правило — «ребёнок ссылается на родителя слабо».
Замыкания: самый частый цикл
Замыкание — это объект на куче, и оно сильно захватывает всё, что использует. Если объект хранит замыкание, а замыкание обращается к self, цикл замкнулся.
final class FeedViewModel {
private var onUpdate: (() -> Void)?
private var items: [String] = []
func bind() {
// ЦИКЛ: self -> onUpdate -> self
onUpdate = {
print(self.items.count)
}
}
func bindFixed() {
onUpdate = { [weak self] in
guard let self else { return }
print(self.items.count)
}
}
deinit { print("deinit FeedViewModel") }
}
[weak self] — это capture list, он задаёт способ захвата. guard let self else { return } поднимает сильную ссылку на время выполнения блока, чтобы объект не умер посередине. [unowned self] цикл тоже разрывает, но при выполнении блока после смерти объекта даёт крэш.
Те же грабли раскиданы по фреймворкам: подписка Combine или RxSwift, хранимая в self; Timer.scheduledTimer, который RunLoop держит сильно; наблюдатель NotificationCenter с блоком.
final class Ticker {
private var timer: Timer?
private var seconds = 0
func start() {
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
self?.seconds += 1
}
}
deinit {
timer?.invalidate() // без invalidate RunLoop держит таймер вечно
print("deinit Ticker")
}
}
«Всегда ли нужен [weak self] в замыкании?»
Что на самом деле проверяют
Ловят cargo cult. Многие пишут [weak self] вообще везде, потому что «так безопаснее», и это признак того, что механику человек не понимает.
Развёрнутый ответ
Цикл возможен, только если замыкание переживает вызов: оно @escaping и где-то сохранено. Non-escaping замыкание отрабатывает синхронно и умирает на выходе из функции — держать self ему негде.
final class Cart {
var prices: [Int] = [100, 200, 300]
var total = 0
func recalc() {
// non-escaping: map и filter отработают синхронно, weak self не нужен
total = prices.filter { $0 > 100 }.map { $0 * 2 }.reduce(0, +)
// non-escaping: UIView.animate выполняется и отпускает замыкание
UIView.animate(withDuration: 0.2) {
self.updateLabel()
}
// escaping и НЕ сохраняется в self: цикла нет, но self проживёт до вызова блока
DispatchQueue.main.async { [weak self] in
self?.updateLabel()
}
}
func updateLabel() { print("total = \(total)") }
}
Отдельно про DispatchQueue.main.async: цикла тут нет — очередь держит блок, блок держит self, но очередь не принадлежит self. Сильный захват лишь отложит освобождение максимум до выполнения блока, и иногда это даже нужно. [weak self] здесь ставят не от утечки, а чтобы не делать работу для закрытого экрана.
«Как вы искали утечку памяти?»
Что на самом деле проверяют
Работали ли вы с инструментами руками или только читали про них.
Развёрнутый ответ
deinitсprint— самый дешёвый способ. Открыли экран, закрыли, в консоли нет строки — объект жив. Ноль настройки, ловит большинство циклов.- Memory Graph Debugger в Xcode: пауза, снимок графа, слева список экземпляров. Ищем «лишние» копии контроллера и смотрим, кто держит: у стрелок подписано имя свойства или
closure context. - Instruments: шаблон Leaks для недостижимых блоков и Allocations с Mark Generation — открыл экран, закрыл, поставил метку, смотришь, что осталось между поколениями.
И важное различие для финала ответа: утечка и рост памяти — не одно и то же. Утечка — объект зациклен или недостижим и не будет освобождён никогда. Рост может быть законным: кэш изображений, буфер, коллекция, которую вы сами набиваете. Leaks покажет только первое.
Типичные ошибки кандидатов
- Ставить
[weak self]вmap,filter,sortиUIView.animate— там замыкание non-escaping, цикла быть не может. - Писать
[unowned self]в сетевом колбэке: экран закрыли раньше ответа — крэш. - Забывать
invalidate()уTimerи считать, что[weak self]его убьёт. - Путать утечку с ростом памяти и называть утечкой любой график, идущий вверх.
- Считать, что
[weak self]вDispatchQueue.main.asyncспасает от цикла — цикла там и не было. - Не уметь назвать ни одного инструмента, кроме «смотрел в Xcode на график памяти».
Как ответить кратко
Retain cycle — это когда объекты держат друг друга сильными ссылками и счётчик ни у одного не обнуляется. ARC граф не обходит, поэтому цикл разрываем мы: одна ссылка в кольце становится weak или unowned. Чаще всего цикл получается через замыкание: self хранит блок, блок захватывает self — лечится capture list [weak self] и guard let self. В non-escaping замыканиях вроде map или UIView.animate weak self не нужен вообще, это лишний ритуал. Ищу утечки так: сначала print в deinit при открытии и закрытии экрана, если не видно — Memory Graph Debugger, кто держит объект, и Instruments Leaks с Allocations. И отделяю утечку от честного роста памяти вроде кэша.