Retain Cycle — це ситуація в ARC, коли два або більше об’єктів посилаються один на одного через strong-посилання, утворюючи замкнений цикл. Згідно з Apple Memory Management Guide, 2026, retain cycle блокує звільнення всіх об’єктів у циклі, оскільки кожний має retain count ≥ 1. На відміну від витоку пам’яті в GC, retain cycle гарантує, що об’єкти залишаються живими, поки живий хоча б один зовнішній учасник циклу — і навіть після втрати всіх зовнішніх посилань, якщо цикл ізольований.
Головне
Retain Cycle — це ситуація, в якій два або більше об’єктів володіють один одним через strong-посилання, створюючи замкнений граф залежностей. ARC не може звільнити жодний з цих об’єктів, оскільки retain count кожного завжди ≥ 1: об’єкт A утримує B, B утримує A, і їхні лічильники ніколи не досягають нуля.
Проблема виникає виключно в системах з підрахунком посилань (ARC, MRR). В збиранні сміття (Garbage Collection) збирач визначає недосяжність через граф посилань з кореневого набору — цикли не є перешкодою. В ARC, однак, цикл еквівалентний витоку, оскільки детерміноване звільнення за лічильником не може вирішити циклічні залежності.
Згідно з WWDC 2012 Session 406, retain cycle — найпоширеніша причина витоків пам’яті в додатках Objective-C та Swift. Типові сценарії: батьківсько-дитячі відносини з делегатами, замикання, що захоплюють self, та багатошарові архітектури з двосторонніми зв’язками.
Розглянемо класичні сценарії retain cycle, з якими стикається кожен iOS-розробник. Розуміння цих патернів — основа для написання безпечного коду з ARC.
Класичний сценарій: батьківський об’єкт (наприклад, UIViewController) створює дочірній об’єкт та стає його делегатом. Якщо обидва використовують strong-посилання, виникає retain cycle. Рішення — делегат повинен бути weak.
// ПОМИЛКА: retain cycle через strong delegate
protocol ChildDelegate: AnyObject { }
class ParentVC: UIViewController, ChildDelegate {
var child: ChildVC?
func showChild() {
child = ChildVC()
child?.delegate = self // Parent → Child (strong)
} // Child → Parent (strong через delegate)
} // ⚠️ Retain cycle!
class ChildVC: UIViewController {
var delegate: ChildDelegate? // ❌ strong за замовчуванням
}
// ВИПРАВЛЕННЯ: weak delegate
class ChildVC: UIViewController {
weak var delegate: ChildDelegate? // ✅ weak — не утримує
}
У прикладі ParentVC утримує strong-посилання на ChildVC через властивість child. ChildVC утримує strong-посилання на ParentVC через delegate. Цикл замкнуто. Виправлення: weak var delegate — посилання не збільшує retain count, і ParentVC може бути звільнений.
NSTimer — класичне джерело retain cycle. Таймер утримує ціль (зазвичай self), а ціль утримує таймер через властивість. Навіть якщо таймер одноразовий, він не звільниться, доки не буде викликано invalidate. Рішення: завжди викликайте timer.invalidate() в deinit або viewDidDisappear.
У архітектурах з каскадним володінням (координатори, маршрутизатори) часто виникають багатокрокові цикли: Coordinator → ViewController → ViewModel → Coordinator (через зворотний виклик). Кожне strong-посилання в ланцюзі повинно бути свідомо обране — одне weak-посилання в будь-якій ланці розриває цикл.
Замикання (closures) в Swift захоплюють зовнішні змінні за strong-посиланням. Якщо замикання зберігається як властивість об’єкта (наприклад, completion handler) та захоплює self, утворюється retain cycle: self → closure → self.
Це найпоширеніше джерело retain cycle у сучасній Swift-розробці. Воно виникає неявно — розробник може не помітити захоплення self в замиканні, особливо при використанні скороченого синтаксису без явного self.
class DownloadService {
var onComplete: ((Data) -> Void)?
var result: Data?
func startDownload() {
// ❌ Retain cycle: self → onComplete → self
onComplete = { data in
self.result = data
self.notifyUI()
}
// ✅ Виправлення: capture list з weak self
onComplete = { [weak self] data in
guard let self else { return }
self.result = data
self.notifyUI()
}
}
func notifyUI() { }
}
Capture list [weak self] створює слабке посилання на self всередині замикання. Якщо DownloadService звільняється до виконання замикання, self стає nil, і код безпечно виходить через guard. Це стандартний патерн для асинхронних замикань в Swift — його слід застосовувати завжди, коли замикання зберігається як властивість.
unowned self — альтернатива weak self, коли self гарантовано живе довше за замикання. Приклад: синхронні замикання, що виконуються негайно (sorted, filter). У таких випадках self точно живий, і unowned безпечний. Однак unowned викликає збій при зверненні до звільненого об’єкта — тому weak вважається безпечним вибором за замовчуванням.
Виявлення retain cycle на ранньому етапі критично важливе для продуктивності додатка. Розглянемо основні інструменти та методики виявлення циклічних посилань у розробці iOS.
Xcode Memory Debugger (Debug Memory Graph) — візуальний інструмент, що показує граф об’єктів в пам’яті з їхніми посиланнями. Retain cycle відображається як замкнений ланцюг strong-стрілок. Для запуску: натисніть кнопку Debug Memory Graph на панелі Debug area під час виконання додатка. Кожен об’єкт показано з типом, адресою та списком посилань.
Instruments Leaks — профайлер для автоматичного виявлення витоків. Записує алокації та аналізує граф посилань в реальному часі. Виявляє не тільки retain cycle, але й забуті посилання, незвільнені ViewController та інші витоки. Leaks вказує точний об’єкт та ланцюг утримання.
Найпростіший спосіб — додати print в deinit кожного ключового класу. Якщо deinit не викликається при очікуваному знищенні об’єкта — є retain cycle. Цей метод не потребує інструментів і ефективний для початкової діагностики.
| Інструмент | Тип | Коли застосовувати |
|---|---|---|
| Memory Debugger | Візуальний граф | Ручна перевірка після навігації |
| Instruments Leaks | Автоматичний аналіз | Регресійне тестування, CI |
| deinit print | Ручне логування | Розробка, code review |
| Malloc Scribble | Флаг часу виконання | Налагодження use-after-free |
Рекомендований підхід: використовуйте логування deinit на етапі розробки, Memory Debugger під час ручного тестування та Instruments Leaks у конвієрі CI/CD для автоматичного контролю регресій витоків.
Запобігти retain cycle легше, ніж виправляти його в продакшні. Кілька правил, що знижують ризик циклічних посилань.
Усі делегати та dataSource повинні бути weak. Це правило вбудовано в UIKit: усі протоколи делегатів в Apple SDK оголошені з weak-властивостями (UITableView.delegate, UICollectionView.dataSource). Для власних протоколів використовуйте weak var delegate: MyDelegate? та успадковуйте протокол від AnyObject.
Будь-яке замикання, яке зберігається як властивість (completion handler, callback) та захоплює self, повинно використовувати [weak self] в capture list. Виняток — замикання, що виконуються негайно та не зберігаються (sorted, map, filter). Для них unowned self безпечний.
У складних архітектурах (VIPER, Coordinators, Redux) відстежуйте напрям strong-посилань. Власник утримує strong-посилання на підлеглого, але підлеглий повинен посилатися на власника тільки через weak або unowned. Однонапрявлений потік даних спрощує керування посиланнями.
// Приклад: перевірка з логуванням deinit
class BaseViewController: UIViewController {
deinit {
print("✅ \(type(of: self)) deallocated")
}
}
// Використання: всі ViewController успадковуються від BaseViewController
class ProfileVC: BaseViewController {
var viewModel: ProfileViewModel?
var onLogout: (() -> Void)?
override func viewDidLoad() {
super.viewDidLoad()
onLogout = { [weak self] in
self?.dismiss(animated: true)
}
}
}
// При закритті ProfileVC очікуємо "✅ ProfileVC deallocated" в консолі
Базовий клас з логуванням deinit надає миттівий зворотний зв’язок. Якщо повідомлення не з'явилося при очікуваному закритті екрану — в цьому класі є retain cycle. Додайте цю практику в шаблон проєкту для всіх ViewController.
Часто задавані питання
Retain cycle — специфічна проблема ARC, де замкнене коло strong-посилань блокує звільнення. В GC збирач аналізує досяжність з кореневого набору, а не лічильники посилань — тому цикли не є витоками. В ж ARC будь-який ізольований цикл — гарантований витік.
Weak-посилання не збільшує retain count об’єкта. Якщо замінити одне з strong-посилань у циклі на weak, retain count кожного об’єкта зможе обнулитися. Після звільнення об’єкта weak-посилання автоматично встановлюється в nil, запобігаючи зверненню до звільненої пам’яті.
Так, retain cycle може включати будь-яку кількість об’єктів: A → B → C → A. Для розриву достатньо розірвати одну ланку в циклі — замініть будь-яке strong-посилання на weak або unowned. Інструменти показують весь граф, а не лише пари об’єктів.
GCD (Grand Central Dispatch) не зберігає замикання після виконання. DispatchWorkItem виконується та звільняється, навіть якщо замикання захоплює self. Retain cycle виникає лише коли замикання зберігається як властивість (completion handler в класі), а не коли передається в чергу.
Instruments Leaks не завжди знаходить тимчасові retain cycle (що тривають секунди) та циклічні посилання в C/C++ об’єктах через бріджинг. Для повної перевірки використовуйте Memory Debugger вручну разом з логуванням deinit всіх ключових об’єктів у сцені.
Підсумки
Ми розробимо мобільний застосунок під ключ
IT Sectr створює застосунки для iOS та Android для стартапів і бізнесу з 2017 року. Ми проконсультуємо вас і запропонуємо найкраще рішення.
Читайте також