Retain cycle в Objective-C: причины циклических ссылок и способы их устранения

Когда мобильное приложение для риэлтора начинает «подтормаживать» после десятка переходов между карточками объектов, а в отладчике видно, что память только растёт, — почти наверняка вы имеете дело с retain cycle. Даже при полностью включённом ARC объекты могут намертво удерживать друг друга сильными ссылками, и счётчики никогда не опустятся до нуля. dealloc не вызывается, контроллеры остаются в памяти, асинхронные загрузки каталогов недвижимости накладываются друг на друга, и пользователь в какой-то момент просто видит вылетевшее по OOM приложение.

В реальных проектах — от простого интерфейса для просмотра квартир до сложных систем с IoT-датчиками в умных зданиях — такие ошибки чаще всего прячутся в блоках, делегатах, таймерах, NSNotificationCenter и асинхронных completion-обработчиках. Именно там удобная архитектура незаметно превращается в цепочку взаимных удержаний, которую ARC сам не разрулит.

Что такое retain cycle простыми словами

Retain cycle — это замкнутый круг из сильных ссылок: объект A удерживает объект B, а объект B удерживает объект A (или цепочка длиннее, но суть та же). ARC честно вставляет retain и release везде, где нужно, однако логику цикла он не анализирует. Если на группу объектов ссылаются только они сами, система считает, что они всё ещё нужны, и не освобождает их. Проблема особенно коварна тем, что внешне приложение работает, но память утекает с каждым новым экраном или повторной операцией.

Как это выглядит на практике

Классический сценарий в приложении для девелопера: UIViewController каталога новостроек хранит блок как свойство, чтобы обновлять интерфейс после загрузки данных с сервера. Блок внутри захватывает self для обращения к таблице или коллекции. self владеет блоком, блок владеет self. После ухода с экрана контроллер не освобождается, а при следующем заходе создаётся новый экземпляр, и расход памяти растёт. Через пару десятков таких переходов приложение может начать получать memory warnings или просто упасть на глазах у риэлтора, показывающего клиенту планировки.

Где retain cycle возникает чаще всего

1. Блоки и completion handler’ы

Самый массовый источник проблем. Любой блок по умолчанию захватывает переменные (включая self) сильной ссылкой. Если этот блок сохраняется в свойстве того же объекта или живёт достаточно долго, образуется цикл. В мире недвижимости это постоянно встречается при загрузке каталогов: контроллер инициирует запрос, передаёт completion handler, а сам handler хранится внутри менеджера сетевых операций или в очереди задач. Если контроллер уходит с экрана до ответа сервера, а блок продолжает его удерживать, — утечка гарантирована.

2. Делегаты, назначенные как strong

Ошибка, которую часто допускают начинающие разработчики (а в крупных проектах — по недосмотру при рефакторинге): объявить свойство делегата как strong. Объект-владелец удерживает делегата, а делегат, в свою очередь, почти всегда ссылается на владельца через собственные свойства. В архитектуре риэлторского приложения делегатом может выступать координатор навигации или презентер, и если связь двусторонняя и сильная, экраны не выгружаются. Для делегатов практически всегда нужен weak — это не владение, а лишь канал для уведомлений.

3. NSTimer и CADisplayLink

Таймеры удерживают свою цель (target) и сами удерживаются run loop’ом. Если view controller создаёт таймер для периодического обновления данных, например, обратного отсчёта до окончания бронирования квартиры, и не останавливает его явно, цикл замыкается. Контроллер не может освободиться, потому что таймер его держит, а таймер продолжает слать события уже несуществующему экрану. Особенно болезненно это проявляется в экранах с анимациями переходов между планировками или при опросе IoT-датчиков в реальном времени.

4. NSNotificationCenter

При использовании block-based API подписки на уведомления блок легко захватывает self. Если наблюдатель не удалён вовремя, объект остаётся в памяти до тех пор, пока центр уведомлений держит этот блок. В системах умного здания, где множество датчиков шлют события о температуре, влажности или открытии дверей, такая утечка может накапливаться при каждом входе в раздел мониторинга. Проблема усугубляется, если разработчик забывает снять observer при уходе с экрана или делает это слишком поздно.

5. Сильные связи между моделями и менеджерами

Цикл не всегда лежит на поверхности UI. Часто он прячется в архитектуре: менеджер сетевого слоя хранит модель объекта недвижимости, модель хранит callback для обновления, а callback удерживает менеджера. Такие цепочки возникают в кэшах, обработчиках событий, координаторах синхронизации каталогов. В офлайн-режиме, когда данные кэшируются и периодически обновляются, подобные взаимные удержания приводят к тому, что кэш разрастается, а старые версии объектов никогда не выгружаются.

Как распознать retain cycle

Типичные симптомы

  • dealloc не вызывается после закрытия экрана — классический маркер.
  • Экран продолжает «жить» в памяти, даже если пользователь ушёл из него несколько переходов назад.
  • При повторном заходе на экран расход памяти увеличивается — это хорошо видно в отладчике Xcode.
  • Таймер или callback продолжает срабатывать, хотя экран уже закрыт (например, в консоли появляются логи от старого контроллера).
  • В Instruments (Leaks, Allocations) видно, что объекты не освобождаются, а их количество растёт.

Что проверять в первую очередь

  1. Наличие и вызов dealloc у подозреваемого класса — просто поставьте точку останова.
  2. Свойства, объявленные как strong, которые могут участвовать в цикле: блоки, таймеры, менеджеры, массивы зависимостей.
  3. Все блоки, захватывающие self, особенно те, что сохраняются в свойствах.
  4. Таймеры, observer’ы и делегатные поля — проверьте, не объявлен ли delegate как strong.
  5. Жизненный цикл объекта: кто его создал, кто должен его отпустить, и не появилась ли лишняя сильная ссылка по пути.

Основные способы устранения retain cycle

Использовать weak там, где нет владения

Если ссылка нужна только для доступа, а не для управления временем жизни объекта, — это кандидат на weak. Классический пример: делегат. В приложении для риэлтора экран карточки объекта может иметь делегата, которому сообщает о действиях пользователя (добавить в избранное, позвонить агенту). Если делегат объявлен как weak, цикл разрывается: экран не удерживает делегата, а делегат может спокойно существовать дольше экрана.

@interface PropertyCardViewController : UIViewController
@property (nonatomic, weak) id<PropertyCardDelegate> delegate;
@end

Применять weak-strong dance в блоках

Стандартный и наиболее надёжный приём для блоков, которые могут пережить породивший их объект. Сначала создаётся слабая ссылка на self, а внутри блока она временно усиливается до strong. Это гарантирует, что блок не удерживает объект бесконечно, но если объект ещё жив на момент выполнения блока, он не будет деаллоцирован прямо посреди полезной работы.

__weak typeof(self) weakSelf = self;
[self.networkManager fetchPropertyListWithCompletion:^(NSArray *properties) {
    __strong typeof(weakSelf) strongSelf = weakSelf;
    if (!strongSelf) { return; }
    [strongSelf updateCatalogWithProperties:properties];
}];

Этот шаблон отлично работает для сетевых запросов каталога объектов, загрузки детальной информации о квартире, обновления статусов бронирования — везде, где ответ может прийти уже после того, как пользователь покинул экран.

Своевременно инвалидировать таймеры

NSTimer и CADisplayLink необходимо останавливать до того, как объект начнёт освобождаться. Наивно полагаться на dealloc — таймер удерживает цель, поэтому dealloc никогда не вызовется. В экране с обратным отсчётом до конца бронирования или с периодическим опросом датчиков умного дома таймер нужно явно инвалидировать, например, в viewWillDisappear или при завершении операции.

- (void)stopCountdown {
    [self.countdownTimer invalidate];
    self.countdownTimer = nil;
}

Удалять наблюдателей

Если используется block-based observer в NSNotificationCenter, важно снять подписку в подходящий момент. Для iOS 9 и новее это не так критично благодаря автоматическому удалению, но в старых кодовых базах и при ручном управлении лучше явно вызывать удаление. В проектах, где данные с IoT-устройств приходят через уведомления, забытый observer может держать в памяти целый граф объектов мониторинга.

- (void)dealloc {
    [[NSNotificationCenter defaultCenter] removeObserver:self];
}

Разрывать связи после завершения работы

Если объект хранит блок, callback или completion handler только на время операции, после её завершения свойство нужно явно обнулить. Это особенно полезно в сетевых запросах, отложенных задачах синхронизации каталогов и временных координаторах. Например, после успешной отправки заявки на просмотр квартиры можно обнулить completion, чтобы разорвать возможный цикл с менеджером заявок.

Таблица: частые источники retain cycle и решения

Источник проблемы Почему возникает цикл Как исправить
Блок в свойстве Блок удерживает self, а self удерживает блок weak self, обнуление блока после использования
Delegate как strong Владение идёт в обе стороны weak delegate
NSTimer Таймер удерживает target и живёт в run loop invalidate, weak-подход через proxy
NSNotificationCenter Блок может удерживать self, observer забывают снять Снимать observer, использовать слабые ссылки
Completion handler Долгоживущий блок сохраняет владельца weak-strong dance
Замкнутые графы объектов Несколько strong-ссылок образуют цикл Пересмотреть ownership-модель

Пошаговый алгоритм поиска retain cycle

Шаг 1. Найти объект, который не освобождается

Поставьте точку останова в dealloc и пройдите по сценарию: открыть экран, закрыть, повторить. Если dealloc не срабатывает, объект где-то удерживается.

Шаг 2. Посмотреть его strong-ссылки

Изучите все свойства, объявленные как strong: блоки, таймеры, менеджеры, массивы. В контексте недвижимости это может быть кэш объектов, менеджер синхронизации или ссылка на презентер.

Шаг 3. Проверить блоки

Если блок использует self, выясните, хранится ли он у этого же объекта. Часто блок сохраняется в свойстве для повторного использования или как обработчик завершения.

Шаг 4. Проверить делегаты и callback’и

Убедитесь, что делегаты объявлены как weak, а callback-свойства не образуют цепочку владения. В архитектуре экрана карточки объекта делегат должен быть слабым, иначе экран никогда не выгрузится.

Шаг 5. Проверить таймеры и observer’ы

Если объект использует таймер или подписку на события, убедитесь, что они снимаются вовремя — в viewWillDisappear, dealloc или по завершении операции.

Шаг 6. Подтвердить исправление инструментами

После правки повторите сценарий и убедитесь, что dealloc вызывается, а память стабилизируется. Instruments (Allocations) покажет, что количество живых экземпляров контроллера не растёт.

Типовые ошибки разработчиков

  • Писать self прямо внутри долгоживущего блока без слабой ссылки — самая частая причина утечек в обработчиках завершения сетевых запросов.
  • Делать delegate strong «чтобы точно не потерялся» — опасное заблуждение, приводящее к циклам между координаторами и экранами.
  • Откладывать invalidate таймера до dealloc — таймер не даст вызваться dealloc, и объект зависнет навсегда.
  • Не удалять observer’ы при закрытии экрана — особенно критично в приложениях с фоновым мониторингом датчиков.
  • Считать, что ARC автоматически устраняет все проблемы с памятью — ARC управляет только одиночными ссылками, но не разрывает логические циклы.
  • Оборачивать weak self без проверки strongSelf — если объект уже деаллоцирован, обращение к strongSelf без проверки приведёт к выполнению кода на нулевом указателе или к странным падениям.

Практический чек-лист перед релизом

  • delegate объявлен как weak.
  • Все долгоживущие блоки не удерживают self напрямую — используется weak-strong dance.
  • Таймеры инвалидируются вручную до освобождения объекта.
  • Подписки на уведомления снимаются (особенно в старом коде).
  • После завершения сетевых операций callback обнуляется, чтобы разорвать возможную связь.
  • dealloc вызывается у всех экранов и вспомогательных объектов при стандартных сценариях навигации.
  • Память проверена на сценариях многократного перехода туда-обратно (например, каталог → карточка → назад) с помощью Instruments.

Когда weak не подходит

weak — не панацея. Если объект должен гарантированно существовать до конца асинхронной операции (например, чтобы сохранить данные формы заявки на ипотеку), слабая ссылка может обнулиться в самый неподходящий момент. В таких случаях применяют weak-strong dance: сначала не создают цикл, а затем внутри блока временно усиливают ссылку до strong, чтобы объект не исчез во время выполнения критичного кода. Это даёт контролируемое продление жизни ровно на период работы блока.

Пример безопасного шаблона для блока

__weak typeof(self) weakSelf = self;
[self.propertyLoader loadDetailsForID:propertyID completion:^(PropertyDetails *details) {
    __strong typeof(weakSelf) strongSelf = weakSelf;
    if (!strongSelf) {
        // Пользователь уже ушёл с экрана, можно ничего не делать
        return;
    }
    [strongSelf displayDetails:details];
    strongSelf.loadingCompletion = nil; // разрываем связь после использования
}];

Этот шаблон отлично зарекомендовал себя в сетевых запросах каталога недвижимости, загрузке карточек квартир, обновлении статусов бронирования и любых других операциях, где ответ сервера может прийти позже, чем пользователь покинул экран.

Вывод

Retain cycle в Objective-C — не экзотический баг, а регулярная инженерная проблема, особенно в блоках, делегатах и таймерах. Если держать в голове простое правило «сильная ссылка должна объясняться владением», а всё остальное переводить на weak или явное освобождение, большинство циклов исчезнет ещё на этапе проектирования. В контексте приложений для рынка недвижимости, где асинхронные загрузки, таймеры бронирований и делегаты повсюду, дисциплина управления памятью напрямую влияет на стабильность и впечатление риэлтора от инструмента.

FAQ

Почему ARC не спасает от retain cycle?

ARC автоматизирует вставку retain и release, но не анализирует логические циклы владения. Если два объекта ссылаются друг на друга сильными ссылками, ARC считает, что они оба нужны, и не освобождает их.

Где чаще всего искать циклическую ссылку?

В блоках, таймерах, делегатных связях и подписках на уведомления. Именно эти механизмы чаще всего используются для асинхронной работы и обратных вызовов в приложениях для недвижимости.

Всегда ли нужно писать weak self?

Нет. Это необходимо только там, где блок может пережить объект или хранится самим объектом. Для краткоживущих локальных блоков (например, внутри UIView.animate) это обычно не требуется.

Как понять, что проблема исправлена?

После закрытия экрана должен вызываться dealloc, а память не должна расти при повторении сценария. Простейший тест: открыть экран, закрыть, открыть снова — количество живых экземпляров в памяти должно оставаться стабильным.