Retain Cycle — istota, przyczyny powstawania i eliminacja w tworzeniu aplikacji

Autor: IT Sectr Opublikowano: 2026-03-29 Czas czytania: 8 min

Retain Cycle (cykliczna referencja) — sytuacja w ARC, gdy dwa lub więcej obiektów odwołuje się do siebie nawzajem poprzez strong referencje, tworząc zamknięty cykl. Według Apple Memory Management Guide, 2026, retain cycle blokuje zwalnianie wszystkich obiektów w cyklu, ponieważ każdy ma retain count ≥ 1. W przeciwieństwie do wycieku pamięci w GC, retain cycle gwarantuje utrzymanie obiektów przy życiu, dopóki żyje przynajmniej jeden zewnętrzny uczestnik cyklu — i nawet po utracie wszystkich zewnętrznych referencji, jeśli cykl jest izolowany.

Najważniejsze

  • Retain Cycle — zamknięty łańcuch strong referencji, przez który obiekty nie mogą zostać zwolnione przez ARC
  • Przyczyna — dwa (lub więcej) obiekty utrzymują strong referencję na siebie nawzajem, zerowanie retain count jest niemożliwe
  • Konsekwencje — wyciek pamięci: obiekty pozostają w pamięci na zawsze, wzrasta zużycie RAM
  • Rozwiązanie — zastąpienie jednej z strong referencji w cyklu na weak lub unowned
  • Diagnostyka — Xcode Memory Debugger, Instruments Leaks, Debug Memory Graph

Czym jest Retain Cycle?

Retain Cycle — to sytuacja, w której dwa lub więcej obiektów posiada się nawzajem poprzez strong referencje, tworząc zamknięty graf zależności. ARC nie może zwolnić żadnego z tych obiektów, ponieważ retain count każdego z nich wynosi zawsze ≥ 1: obiekt A trzyma B, B trzyma A, a ich liczniki nigdy się nie zerują.

Problem występuje wyłącznie w systemach z zliczaniem referencji (ARC, MRR). W Garbage Collection mechanizm zbiera nieosiągalne obiekty na podstawie grafu referencji z korzenia — cykle nie stanowią przeszkody. W ARC natomiast cykl jest równoznaczny z wyciekiem, ponieważ deterministyczne zwalnianie oparte na liczniku nie może rozwiązać zależności cyklicznej.

Według WWDC 2012 Session 406, retain cycle jest najczęstszą przyczyną wycieków pamięci w aplikacjach Objective-C i Swift. Typowe scenariusze: relacje parent-child z delegatami, domknięcia przechwytujące self oraz architektury warstwowe z dwukierunkowymi powiązaniami.

Przykłady retain cycle w iOS development

Rozważmy klasyczne scenariusze retain cycle, z którymi spotyka się każdy iOS developer. Zrozumienie tych wzorców to podstawa pisania bezpiecznego kodu z ARC.

Parent-Child z delegatem

Klasyczny scenariusz: obiekt nadrzędny (np. UIViewController) tworzy podrzędny i staje się jego delegatem. Jeśli oba używają strong referencji, powstaje retain cycle. Rozwiązanie — delegat powinien być weak.

swift
// BŁĄD: retain cycle przez 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 przez delegate)
}                                     // ⚠️ Retain cycle!

class ChildVC: UIViewController {
    var delegate: ChildDelegate?    // ❌ strong domyślnie
}

// POPRAWKA: weak delegate
class ChildVC: UIViewController {
    weak var delegate: ChildDelegate? // ✅ weak — nie przechowuje
}

W przykładzie ParentVC trzyma strong referencję na ChildVC poprzez właściwość child. ChildVC trzyma strong referencję na ParentVC przez delegate. Cykl jest zamknięty. Poprawka: weak var delegate — referencja nie zwiększa retain count i ParentVC może zostać zwolniony.

NSTimer i retain cycle

NSTimer — klasyczne źródło retain cycle. Timer utrzymuje target (zwykle self), a target utrzymuje timer poprzez właściwość. Nawet jeśli timer jest jednorazowy, nie zostanie zwolniony do czasu invalidate. Rozwiązanie: zawsze wywołuj timer.invalidate() w deinit lub viewDidDisappear.

Architektury warstwowe

W architekturach z kaskadowym posiadaniem (koordynatory, routery) często powstają wieloetapowe cykle: Coordinator → ViewController → ViewModel → Coordinator (poprzez callback). Każda strong referencja w łańcuchu musi być świadomie wybrana — jedna weak referencja w dowolnym ogniwie przerywa cykl.

Retain Cycle w domknięciach Swift

Domknięcia (closures) w Swift przechwytują zmienne zewnętrzne przez strong referencję. Jeśli domknięcie jest przechowywane jako właściwość obiektu (np. completion handler) i przechwytuje self, powstaje retain cycle: self → closure → self.

To najczęstsze źródło retain cycle we współczesnym Swift development. Powstaje niejawnie — programista może nie zauważyć przechwycenia self w domknięciu, szczególnie przy użyciu skróconej składni bez jawnego 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()
        }

        // ✅ Poprawka: capture list z weak self
        onComplete = { [weak self] data in
            guard let self else { return }
            self.result = data
            self.notifyUI()
        }
    }

    func notifyUI() { }
}

Capture list [weak self] tworzy słabą referencję na self wewnątrz domknięcia. Jeśli DownloadService zostanie zwolniony przed wykonaniem domknięcia, self staje się nil i kod bezpiecznie wychodzi przez guard. To standardowy wzorzec dla asynchronicznych domknięć w Swift — należy go stosować zawsze, gdy domknięcie jest przechowywane jako właściwość.

Unowned self w domknięciach

unowned self — alternatywa dla weak self, gdy self gwarantowanie żyje dłużej niż domknięcie. Przykład: synchroniczne domknięcie wykonujące się natychmiast (sorted, filter). W takich przypadkach self na pewno żyje i unowned jest bezpieczny. Jednak unowned powoduje crash przy odwołaniu do zwolnionego obiektu — dlatego weak jest bezpiecznym wyborem domyślnie.

Jak wykryć retain cycle: narzędzia diagnostyczne

Wykrywanie retain cycle na wczesnym etapie jest kluczowe dla wydajności aplikacji. Rozważmy główne narzędzia i metody identyfikacji cyklicznych referencji w iOS development.

Xcode Memory Debugger

Xcode Memory Debugger (Debug Memory Graph) — wizualne narzędzie pokazujące graf obiektów w pamięci z ich referencjami. Retain cycle wyświetla się jako zamknięty łańcuch strong strzałek. Aby uruchomić: kliknij przycisk Debug Memory Graph w panelu Debug area podczas działania aplikacji. Każdy obiekt jest pokazany z typem, adresem i listą referencji.

Instruments Leaks

Instruments Leaks — profiler do automatycznego wykrywania wycieków. Rejestruje alokacje i analizuje graf referencji w czasie rzeczywistym. Wykrywa nie tylko retain cycle, ale także zapomniane referencje, niezwolnione ViewController i inne wycieki. Leaks wskazuje dokładny obiekt i łańcuch utrzymania.

Logowanie deinit

Najprostszy sposób — dodaj print w deinit każdej kluczowej klasy. Jeśli deinit nie jest wywoływany przy oczekiwanym zniszczeniu obiektu — występuje retain cycle. Ta metoda nie wymaga narzędzi i jest skuteczna do wstępnej diagnostyki.

NarzędzieTypKiedy stosować
Memory DebuggerWizualny grafRęczne sprawdzenie po nawigacji
Instruments LeaksAutomatyczna analizaTesty regresyjne, CI
deinit printRęczne logowanieDevelopment, code review
Malloc ScribbleRuntime-flagDebugowanie use-after-free

Zalecane podejście: używaj deinit logowania na etapie developmentu, Memory Debugger — przy ręcznym testowaniu, Instruments Leaks — w CI/CD pipeline do automatycznej kontroli regresyjnej wycieków.

Profilaktyka retain cycle i best practices

Zapobieganie retain cycle jest łatwiejsze niż naprawianie go w produkcji. Kilka zasad, które zmniejszają ryzyko cyklicznych referencji do minimum.

Zasada weak delegate

Wszyscy delegaci i dataSource powinni być weak. Ta zasada jest wbudowana w UIKit: wszystkie protokoły delegatów w Apple SDK są zadeklarowane z weak właściwościami (UITableView.delegate, UICollectionView.dataSource). Dla własnych protokołów używaj weak var delegate: MyDelegate? i dziedzicz protokół po AnyObject.

Capture list w domknięciach

Każde domknięcie przechowywane jako właściwość (completion handler, callback) i przechwytujące self, powinno używać [weak self] w capture list. Wyjątek — domknięcia wykonujące się natychmiast i nieprzechowywane (sorted, map, filter). Dla nich unowned self jest bezpieczny.

Sprawdzenie architektury

W złożonych architekturach (VIPER, Coordinators, Redux) śledź kierunek strong referencji. Właściciel trzyma strong referencję na podwładnego, ale podwładny powinien odwoływać się do właściciela tylko przez weak lub unowned. Jednokierunkowy przepływ danych (unidirectional data flow) ułatwia kontrolę referencji.

swift
// Przykład: sprawdzenie z logowaniem deinit
class BaseViewController: UIViewController {
    deinit {
        print("✅ \(type(of: self)) deallocated")
    }
}

// Użycie: wszystkie ViewController dziedziczą po BaseViewController
class ProfileVC: BaseViewController {
    var viewModel: ProfileViewModel?
    var onLogout: (() -> Void)?

    override func viewDidLoad() {
        super.viewDidLoad()
        onLogout = { [weak self] in
            self?.dismiss(animated: true)
        }
    }
}
// Po zamknięciu ProfileVC oczekujemy „✅ ProfileVC deallocated” w konsoli

Klasa bazowa z logowaniem deinit daje natychmiastową informację zwrotną. Jeśli komunikat nie pojawił się przy oczekiwanym zamknięciu ekranu — w tej klasie występuje retain cycle. Dodaj tę praktykę do szablonu projektu dla wszystkich ViewController.

Często zadawane pytania

Czym retain cycle różni się od wycieku pamięci w GC?

Retain cycle — specyficzny problem ARC, gdzie zamknięty krąg strong referencji blokuje zwalnianie. W GC mechanizm analizuje osiągalność z korzenia (root set), a nie licznik referencji — dlatego cykle nie są wyciekiem. W ARC natomiast każdy izolowany cykl to gwarantowany wyciek.

Jak weak referencja przerywa retain cycle?

Weak referencja nie zwiększa retain count obiektu. Jeśli zastąpisz jedną z strong referencji w cyklu na weak, retain count każdego obiektu będzie mógł się wyzerować. Po zwolnieniu obiektu weak referencja automatycznie ustawia się na nil, zapobiegając odwołaniu do martwej pamięci.

Czy retain cycle może składać się z trzech lub więcej obiektów?

Tak, retain cycle może obejmować dowolną liczbę obiektów: A → B → C → A. Aby zwolnić, wystarczy przerwać jedno ogniwo w cyklu — zastąpić dowolną strong referencję na weak lub unowned. Narzędzia pokazują cały graf, nie tylko pary obiektów.

Dlaczego GCD DispatchWorkItem nie tworzy retain cycle?

GCD (Grand Central Dispatch) nie przechowuje domknięcia po wykonaniu. DispatchWorkItem wykonuje się i jest zwalniany, nawet jeśli domknięcie przechwytuje self. Retain cycle powstaje tylko gdy domknięcie jest przechowywane jako właściwość (completion handler w klasie), a nie gdy jest przekazywane do kolejki.

Jakie typy retain cycle nie są wykrywane przez Instruments?

Instruments Leaks nie zawsze znajduje tymczasowe retain cycle (istniejące sekundy) i cykliczne referencje w obiektach C/C++ przez bridge. Aby dokładnie sprawdzić, używaj Memory Debugger ręcznie + logowanie deinit wszystkich kluczowych obiektów w scenie.

Podsumowanie

  • Retain Cycle — zamknięty łańcuch strong referencji blokujący zwalnianie obiektów w ARC
  • Przyczyny — delegaci z strong referencją, domknięcia z przechwytywaniem self, dwukierunkowe relacje parent-child
  • Rozwiązanie — zastąpienie jednej strong referencji na weak lub unowned przerywa cykl
  • Domknięcia — przechowywane completion handler zawsze powinny używać [weak self]
  • Delegaci — zawsze weak; protokół delegata powinien dziedziczyć po AnyObject
  • Diagnostyka — Xcode Memory Debugger, Instruments Leaks, logowanie deinit
  • Profilaktyka — jednokierunkowy przepływ danych, weak delegate, capture list, klasa bazowa z deinit

Opracujemy aplikację mobilną pod klucz

IT Sectr tworzy aplikacje na iOS i Androida dla startupów i firm od 2017 roku. Doradzimy Ci i zaproponujemy najlepsze rozwiązanie.

Omów projekt

Przeczytaj również