Retain Cycle — суть, причини виникнення та усунення у розробці додатків

Автор: IT Sectr Опубліковано: 2026-03-29 Час читання: 8 хв

Retain Cycle — це ситуація в ARC, коли два або більше об’єктів посилаються один на одного через strong-посилання, утворюючи замкнений цикл. Згідно з Apple Memory Management Guide, 2026, retain cycle блокує звільнення всіх об’єктів у циклі, оскільки кожний має retain count ≥ 1. На відміну від витоку пам’яті в GC, retain cycle гарантує, що об’єкти залишаються живими, поки живий хоча б один зовнішній учасник циклу — і навіть після втрати всіх зовнішніх посилань, якщо цикл ізольований.

Головне

  • Retain Cycle — замкнений ланцюг strong-посилань, при якому об’єкти не можуть бути звільнені ARC
  • Причина — два (або більше) об’єкти тримають strong-посилання один на одного, обнулення retain count неможливе
  • Наслідки — витік пам’яті: об’єкти назавжди залишаються в пам’яті, зростає споживання RAM
  • Рішення — заміна одного з strong-посилань у циклі на weak або unowned
  • Діагностика — Xcode Memory Debugger, Instruments Leaks, Debug Memory Graph

Що таке 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

Розглянемо класичні сценарії retain cycle, з якими стикається кожен iOS-розробник. Розуміння цих патернів — основа для написання безпечного коду з ARC.

Parent-Child з делегатом

Класичний сценарій: батьківський об’єкт (наприклад, UIViewController) створює дочірній об’єкт та стає його делегатом. Якщо обидва використовують strong-посилання, виникає retain cycle. Рішення — делегат повинен бути weak.

swift
// ПОМИЛКА: 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

NSTimer — класичне джерело retain cycle. Таймер утримує ціль (зазвичай self), а ціль утримує таймер через властивість. Навіть якщо таймер одноразовий, він не звільниться, доки не буде викликано invalidate. Рішення: завжди викликайте timer.invalidate() в deinit або viewDidDisappear.

Багатошарові архітектури

У архітектурах з каскадним володінням (координатори, маршрутизатори) часто виникають багатокрокові цикли: Coordinator → ViewController → ViewModel → Coordinator (через зворотний виклик). Кожне strong-посилання в ланцюзі повинно бути свідомо обране — одне weak-посилання в будь-якій ланці розриває цикл.

Retain Cycle в замиканнях Swift

Замикання (closures) в Swift захоплюють зовнішні змінні за strong-посиланням. Якщо замикання зберігається як властивість об’єкта (наприклад, completion handler) та захоплює self, утворюється retain cycle: self → closure → self.

Це найпоширеніше джерело retain cycle у сучасній Swift-розробці. Воно виникає неявно — розробник може не помітити захоплення self в замиканні, особливо при використанні скороченого синтаксису без явного self.

swift
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 в замиканнях

unowned self — альтернатива weak self, коли self гарантовано живе довше за замикання. Приклад: синхронні замикання, що виконуються негайно (sorted, filter). У таких випадках self точно живий, і unowned безпечний. Однак unowned викликає збій при зверненні до звільненого об’єкта — тому weak вважається безпечним вибором за замовчуванням.

Як виявити retain cycle: інструменти діагностики

Виявлення retain cycle на ранньому етапі критично важливе для продуктивності додатка. Розглянемо основні інструменти та методики виявлення циклічних посилань у розробці iOS.

Xcode Memory Debugger

Xcode Memory Debugger (Debug Memory Graph) — візуальний інструмент, що показує граф об’єктів в пам’яті з їхніми посиланнями. Retain cycle відображається як замкнений ланцюг strong-стрілок. Для запуску: натисніть кнопку Debug Memory Graph на панелі Debug area під час виконання додатка. Кожен об’єкт показано з типом, адресою та списком посилань.

Instruments Leaks

Instruments Leaks — профайлер для автоматичного виявлення витоків. Записує алокації та аналізує граф посилань в реальному часі. Виявляє не тільки retain cycle, але й забуті посилання, незвільнені ViewController та інші витоки. Leaks вказує точний об’єкт та ланцюг утримання.

Логування deinit

Найпростіший спосіб — додати 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 та найкращі практики

Запобігти retain cycle легше, ніж виправляти його в продакшні. Кілька правил, що знижують ризик циклічних посилань.

Правило weak delegate

Усі делегати та dataSource повинні бути weak. Це правило вбудовано в UIKit: усі протоколи делегатів в Apple SDK оголошені з weak-властивостями (UITableView.delegate, UICollectionView.dataSource). Для власних протоколів використовуйте weak var delegate: MyDelegate? та успадковуйте протокол від AnyObject.

Capture list в замиканнях

Будь-яке замикання, яке зберігається як властивість (completion handler, callback) та захоплює self, повинно використовувати [weak self] в capture list. Виняток — замикання, що виконуються негайно та не зберігаються (sorted, map, filter). Для них unowned self безпечний.

Перевірка архітектури

У складних архітектурах (VIPER, Coordinators, Redux) відстежуйте напрям strong-посилань. Власник утримує strong-посилання на підлеглого, але підлеглий повинен посилатися на власника тільки через weak або unowned. Однонапрявлений потік даних спрощує керування посиланнями.

swift
// Приклад: перевірка з логуванням 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 відрізняється від витоку пам’яті в GC?

Retain cycle — специфічна проблема ARC, де замкнене коло strong-посилань блокує звільнення. В GC збирач аналізує досяжність з кореневого набору, а не лічильники посилань — тому цикли не є витоками. В ж ARC будь-який ізольований цикл — гарантований витік.

Як weak-посилання розриває retain cycle?

Weak-посилання не збільшує retain count об’єкта. Якщо замінити одне з strong-посилань у циклі на weak, retain count кожного об’єкта зможе обнулитися. Після звільнення об’єкта weak-посилання автоматично встановлюється в nil, запобігаючи зверненню до звільненої пам’яті.

Чи може retain cycle складатися з трьох та більше об’єктів?

Так, retain cycle може включати будь-яку кількість об’єктів: A → B → C → A. Для розриву достатньо розірвати одну ланку в циклі — замініть будь-яке strong-посилання на weak або unowned. Інструменти показують весь граф, а не лише пари об’єктів.

Чому GCD DispatchWorkItem не створює retain cycle?

GCD (Grand Central Dispatch) не зберігає замикання після виконання. DispatchWorkItem виконується та звільняється, навіть якщо замикання захоплює self. Retain cycle виникає лише коли замикання зберігається як властивість (completion handler в класі), а не коли передається в чергу.

Які типи retain cycle не виявляються Instruments?

Instruments Leaks не завжди знаходить тимчасові retain cycle (що тривають секунди) та циклічні посилання в C/C++ об’єктах через бріджинг. Для повної перевірки використовуйте Memory Debugger вручну разом з логуванням deinit всіх ключових об’єктів у сцені.

Підсумки

  • Retain Cycle — замкнений ланцюг strong-посилань, що блокує звільнення об’єктів в ARC
  • Причини — делегати з strong-посиланням, замикання з захопленням self, двосторонні батьківсько-дитячі відносини
  • Рішення — заміна одного strong-посилання на weak або unowned розриває цикл
  • Замикання — зберігані completion handler завжди повинні використовувати [weak self]
  • Делегати — завжди weak; протокол делегата повинен бути успадкований від AnyObject
  • Виявлення — Xcode Memory Debugger, Instruments Leaks, логування deinit
  • Профілактика — однонапрявлений потік даних, weak делегати, capture list, базовий клас з deinit

Ми розробимо мобільний застосунок під ключ

IT Sectr створює застосунки для iOS та Android для стартапів і бізнесу з 2017 року. Ми проконсультуємо вас і запропонуємо найкраще рішення.

Обговорити проект

Читайте також