Как избежать retain cycles с delegates, closures и Combine/async callbacks?
Делегаты объявляйте weak (протокол: AnyObject). В замыканиях используйте [weak self]. В Combine — sink с [weak self] вместо assign(to:on:). Сохранённые Task захватывают self — тоже нужен [weak self].
Retain cycles с delegates, closures, Combine и async/await
Управление памятью в UIKit требует явного контроля над ссылками в каждом из этих механизмов. Общий принцип: объект не должен косвенно удерживать сам себя через замкнутый граф сильных ссылок.
Delegates: всегда weak
Делегат почти всегда является родителем или владельцем объекта, который назначает делегата. Чтобы не возникал цикл, делегат объявляется как weak:
// Протокол ОБЯЗАН наследоваться от AnyObject
protocol ImageLoaderDelegate: AnyObject {
func imageLoader(_ loader: ImageLoader, didFinishWith image: UIImage)
}
class ImageLoader {
weak var delegate: ImageLoaderDelegate? // не держим делегата
}
class GalleryViewController: UIViewController, ImageLoaderDelegate {
let loader = ImageLoader()
override func viewDidLoad() {
super.viewDidLoad()
loader.delegate = self // цикл невозможен: loader.delegate — weak
}
func imageLoader(_ loader: ImageLoader, didFinishWith image: UIImage) {
imageView.image = image
}
}
Если протокол не наследуется от AnyObject, компилятор запретит weak var — добавьте ограничение.
Closures: capture list [weak self]
Каждый раз, когда замыкание сохраняется как свойство объекта и обращается к self, нужен capture list:
class ProfileViewController: UIViewController {
var onSave: (() -> Void)?
private let uploadService = UploadService()
func setup() {
// Без [weak self] — retain cycle: vc -> uploadService -> completion -> vc
uploadService.completion = { [weak self] result in
guard let self else { return }
switch result {
case .success(let url): self.updateAvatar(url)
case .failure(let error): self.showError(error)
}
}
}
}
Для эскейп-замыканий (@escaping), которые хранятся асинхронно, всегда используйте [weak self]. Для не-эскейп замыканий (синхронных) retain cycle технически невозможен, но [weak self] не повредит.
unowned — альтернатива когда объект точно будет жить дольше замыкания. Crash при ошибочном использовании, поэтому безопаснее weak.
Combine: sink и store
Combine-цепочки с sink захватывают self сильно по умолчанию:
class SearchViewController: UIViewController {
private var cancellables = Set<AnyCancellable>()
private let viewModel = SearchViewModel()
override func viewDidLoad() {
super.viewDidLoad()
// Цикл: vc -> cancellables -> sink-closure -> vc
// Разрыв: [weak self] в closure
viewModel.$results
.receive(on: DispatchQueue.main)
.sink { [weak self] results in
self?.applySnapshot(results)
}
.store(in: &cancellables)
}
}
Набор cancellables уничтожается вместе с контроллером в deinit, что отменяет все подписки. При [weak self] даже если что-то держит cancellable снаружи — контроллер освободится.
Опасный антипаттерн с assign(to:on:):
// ЦИКЛ: assign удерживает self сильно
viewModel.$title.assign(to: \.titleLabel.text, on: self).store(in: &cancellables)
// Безопасная альтернатива через sink:
viewModel.$title
.sink { [weak self] title in self?.titleLabel.text = title }
.store(in: &cancellables)
async/await и Task
Task, созданный внутри метода класса, неявно захватывает actor (или self в классе). Если Task сохраняется как свойство:
class VideoPlayerViewController: UIViewController {
private var loadTask: Task<Void, Never>?
func startLoading() {
// Task захватывает self; если свойство держит Task — цикл
loadTask = Task { [weak self] in
guard let self else { return }
let url = await self.fetchVideoURL()
await MainActor.run {
self.player.load(url)
}
}
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
loadTask?.cancel() // отменяем при уходе с экрана
loadTask = nil
}
}
Если Task не сохраняется (fire-and-forget), cycle не возникает даже без [weak self] — Task завершается и освобождает захваченное.
Быстрая проверка через deinit
deinit {
print("[DEINIT] \(type(of: self))")
}
Если строка не появляется после pop/dismiss — есть утечка. Откройте Memory Graph Debugger (Xcode: Debug → Memory Graph) и найдите остаток объекта с красной иконкой.
Подводные камни
- Протокол делегата без
AnyObject(илиclass) constraint не принимаетweak— компилятор выдаст ошибку "'weak' cannot be applied". assign(to:on:)в Combine удерживает объект сильно — предпочитайтеsinkс[weak self].- Вложенные замыкания требуют отдельного capture list — внешний
[weak self]не распространяется на внутреннее замыкание автоматически. - Timer с
target: selfудерживает объект; нужно вызватьtimer.invalidate()вdeinitили использовать block-basedTimer.scheduledTimer(withTimeInterval:repeats:block:)с[weak self]. - NotificationCenter-наблюдатель, добавленный через
addObserver(_:selector:name:object:), тоже удерживает объект; удаляйте вdeinitили используйте block-based вариант с[weak self]. - В async Task внутри actor
selfзахватывается как isolated actor — изолированный захват не создаёт retain cycle, но Task сохранённый как свойство класса (не actor) — создаёт. - Memory Debugger показывает только живые объекты — запускайте проверку именно после pop/dismiss, когда утечка должна была освободиться.
Common mistakes
- Отвечать определением без production-сценария.
- Не называть runtime boundary, security boundary или failure mode.
- Игнорировать версию API, observability и тестовую проверку.
What the interviewer is testing
- Объясняет механизм своими словами и без выдуманных API.
- Называет реальные риски, диагностику и критерий корректности.
- Связывает ответ с текущей документацией и миграционными ограничениями.