objc-retain.com

Управление памятью в UIKit: жизненный цикл view и контроллеров

Мобильные решения
Управление памятью в UIKit: жизненный цикл view и контроллеров

Когда в UIKit «течет» память, проблема почти всегда не в самом UIViewController, а в том, как он удерживает view, дочерние контроллеры, замыкания, таймеры и внешние ресурсы. Понимание жизненного цикла view и контроллеров помогает не только избегать утечек, но и правильно освобождать тяжелые объекты в нужный момент. На практике это особенно заметно в приложениях для рынка недвижимости, где экран карточки объекта может одновременно держать галерею из 15 полноразмерных фото, карту с кластеризацией, видеообзор и подписку на обновление статуса сделки — и все это на устройстве риэлтора, который за день просматривает десятки таких карточек.

Почему жизненный цикл в UIKit связан с памятью

UIKit управляет интерфейсом через контроллеры и их представления. Контроллер владеет своим view и связанными с ним подвиджетами, а при загрузке view может происходить автоматическое создание и удержание объекта в памяти. Apple прямо указывает, что доступ к свойству view может вызвать его загрузку, а проверить наличие уже загруженного view можно через isViewLoaded. Также документация подчеркивает, что UIViewController сам помогает снижать потребление памяти в критические моменты и может освобождать ненужные ресурсы при нехватке памяти.

Для iOS-разработчика это означает простую вещь: жизненный цикл UI — это не только про порядок вызовов методов, но и про то, когда объект реально находится в памяти, что его удерживает и когда можно безопасно отпускать ресурсы. В контексте PropTech-решений это знание напрямую влияет на стабильность приложения: если экран с панорамным просмотром квартиры не освобождает текстуры после ухода пользователя, три-четыре таких экрана — и система убивает приложение по OOM, а риэлтор теряет прогресс показа.

Главные этапы жизненного цикла UIViewController

Жизненный цикл удобно делить на четыре зоны: создание, загрузка view, отображение и уничтожение. Каждая зона диктует свои правила работы с памятью, и путаница между ними — источник большинства проблем.

1. Создание контроллера

Контроллер появляется в памяти через init(...), init(coder:) или фабричные методы. В этот момент сам объект уже существует, но его view может еще не быть создано. Это разделение принципиально: объект контроллера в памяти — не значит, что его view-иерархия уже загружена.

Что важно:

  • не делать здесь тяжелую инициализацию интерфейса — это задача viewDidLoad();
  • не обращаться без необходимости к view, если она еще не нужна — это спровоцирует преждевременную загрузку;
  • не путать жизнь объекта контроллера и жизнь его view — они могут расходиться по времени.

2. Загрузка view

Если view нужна, UIKit вызывает loadView() или загружает ее из storyboard/xib, после чего срабатывает viewDidLoad(). На этом этапе view уже в памяти и можно настраивать UI, подключать данные, добавлять наблюдателей и готовить привязки. Здесь мы впервые получаем гарантию, что вся view-иерархия готова к работе.

Практическое правило:

  • viewDidLoad() подходит для однократной настройки;
  • здесь удобно инициализировать UI-компоненты, но не стоит привязывать то, что должно пересоздаваться при каждом появлении экрана. Например, в приложении для девелопера подписку на обновление статуса бронирования логичнее размещать в viewWillAppear, потому что статус мог измениться, пока пользователь смотрел другой экран.

3. Появление и исчезновение

Когда экран показывается, UIKit вызывает:

  • viewWillAppear(_:)
  • viewDidAppear(_:)

Когда экран скрывается:

  • viewWillDisappear(_:)
  • viewDidDisappear(_:)

Эти методы происходят много раз, если контроллер остается в стеке навигации или используется в контейнере. Наличие на экране и наличие в памяти — не одно и то же: контроллер может быть скрыт, но продолжать жить, если его удерживает navigation stack или другой контейнер. Это особенно важно помнить, потому что невидимый контроллер все еще может получать уведомления и оставаться активным. В риэлторском приложении это классическая ситуация: пользователь уходит с карточки объекта в список, но контроллер карточки продолжает висеть в стеке и обрабатывать push-уведомления об изменении цены, накапливая дублирующиеся обновления.

4. Уничтожение

Когда на контроллер больше нет сильных ссылок, вызывается deinit. Это последняя точка для проверки, что объект действительно освобождается. Если deinit не срабатывает после ухода экрана, почти наверняка есть retain cycle или лишняя сильная ссылка. Я всегда рекомендую на этапе разработки добавлять хотя бы временный print в deinit каждого экрана — это простейший детектор утечек, который экономит часы отладки.

Как view и контроллеры удерживаются в памяти

Отношения владения между контроллером и его view — центральная тема для понимания утечек. Если выстроить правильную ментальную модель, большинство проблем с памятью становятся предсказуемыми.

Контроллер владеет своим view

UIKit рассматривает view как собственность контроллера. Документация Apple прямо говорит, что контроллер — единственный владелец своего view и создаваемых им подвидов. Это значит, что:

  • view обычно живет не дольше контроллера;
  • от view нельзя ожидать отдельной «самостоятельной жизни»;
  • если контроллер уничтожен, view тоже должна освободиться.

Из этого следует важный архитектурный вывод: бизнес-логику и состояние нельзя хранить во view. View — это презентационный слой, который может быть пересоздан или выгружен. В приложениях для недвижимости это критично: данные о планировке, цене и статусе объекта должны жить в модели или контроллере, а не в UILabel или UIImageView.

Контейнеры могут удерживать контроллеры дольше, чем кажется

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

View может быть выгружена, даже если контроллер еще жив

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

Что именно нужно освобождать вручную

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

Что чаще всего вызывает утечки

  • Timer, который продолжает удерживать цель — классика, с которой я сталкивался еще во времена ручного управления памятью в Objective-C, и она никуда не делась с приходом ARC.
  • CADisplayLink — по сути, тот же таймер, привязанный к частоте обновления экрана.
  • Блоки и замыкания, захватывающие self strongly — самая частая причина retain cycle в современных iOS-приложениях.
  • Подписки на NotificationCenter без удаления — контроллер продолжает получать уведомления, даже когда его уже не должно быть в памяти.
  • Делегаты, если они объявлены как strong вместо weak — создают обратную сильную ссылку.
  • Асинхронные задачи, completion handlers и callback-цепочки — запрос к API девелопера может вернуться через 30 секунд, когда пользователь уже давно закрыл экран.
  • Дочерние контроллеры, добавленные не по правилам containment — если не вызвать willMove(toParent:) и removeFromParent(), дочерний контроллер может остаться в иерархии с сильной ссылкой от родителя.

Где использовать weak

Для захвата self в escaping-замыканиях почти всегда нужен [weak self], если замыкание живет дольше самого контроллера. Это базовая практика для избежания retain cycle. Но важно понимать нюанс: [weak self] не нужен в не-escaping замыканиях, которые выполняются синхронно и гарантированно завершаются до выхода из области видимости контроллера.

Пример из практики: в экране каталога объектов недвижимости у вас есть замыкание для загрузки изображения с сервера. Оно escaping — запрос уходит в сеть и возвращается асинхронно. Без [weak self] контроллер будет удерживаться замыканием до завершения запроса, даже если пользователь уже ушел на другой экран. С [weak self] контроллер может освободиться, а замыкание просто не выполнит обновление UI для уже несуществующего экрана.

Ошибки, которые чаще всего ломают память в UIKit

За годы работы с iOS-приложениями — от простых инди-проектов на Objective-C до крупных PropTech-решений с IoT-интеграциями — я вывел для себя список самых частых проблем. Они универсальны и не зависят от домена, но в приложениях для недвижимости проявляются особенно остро из-за тяжелого медиаконтента и множества асинхронных сценариев.

Ошибка Что происходит Как исправить
Сильный захват self в блоке Контроллер не освобождается Использовать [weak self]
Невызванный invalidate() у таймера Таймер удерживает объект Отменять таймер в viewWillDisappear или deinit
Неправильный delegate Объекты образуют цикл Делегат должен быть weak
Подписка без отписки Накопление наблюдателей Удалять observer в нужный момент
Тяжелые данные в viewDidLoad() Рост памяти при каждом экране Переносить тяжелую загрузку в ленивый сценарий
Хранение состояния только в view Потеря данных после выгрузки view Хранить состояние в контроллере или модели

Отдельно отмечу проблему с таймерами в риэлторских приложениях: автопрокрутка галереи изображений или таймер обновления статуса объекта должны быть жестко привязаны к видимости экрана. Если таймер продолжает тикать после ухода с экрана, он не только удерживает контроллер в памяти, но и выполняет бесполезную работу — например, обновляет UI, который никто не видит.

Как выстраивать управление памятью по жизненному циклу

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

В viewDidLoad()

  • настраивать интерфейс;
  • назначать делегаты;
  • конфигурировать таблицы, коллекции, жесты;
  • создавать легкие UI-объекты;
  • подключать подписки, если они управляются жизненным циклом контроллера.

В viewWillAppear(_:)

  • обновлять данные, которые могли измениться;
  • синхронизировать состояние интерфейса;
  • запускать обновление экрана перед показом.

В viewDidDisappear(_:)

  • останавливать анимации;
  • выключать таймеры;
  • отменять временные подписки;
  • завершать UI-активность, которая больше не нужна на фоне.

В deinit

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

Когда освобождать ресурсы: на исчезновении или в deinit

Здесь есть важный нюанс. Не все нужно освобождать в deinit, потому что он может наступить слишком поздно. Если ресурс дорогой и не нужен, когда экран скрыт, лучше отпускать его в viewDidDisappear(_:). Это особенно критично для ресурсов, которые потребляют не только память, но и энергию: камера, GPS, потоковая передача данных с IoT-датчиков умного здания.

Подходит для viewDidDisappear(_:):

  • анимации;
  • активные таймеры;
  • камеры;
  • подписки, которые не должны жить в фоне;
  • потоковые обновления UI.

Подходит для deinit:

  • финальная очистка;
  • подтверждение, что контроллер действительно ушел;
  • освобождение объектов, которые живут столько же, сколько сам контроллер.

В контексте PropTech это различие хорошо иллюстрирует экран мониторинга умного здания: когда риэлтор или управляющий уходит с экрана, поток данных с датчиков температуры и влажности нужно остановить в viewDidDisappear, чтобы не гонять сетевой трафик и не нагружать процессор. А вот финальную очистку контекста аналитики можно оставить на deinit.

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

  • deinit вызывается после закрытия экрана.
  • Нет сильных ссылок из замыканий на self.
  • Все таймеры и display link отменяются.
  • Делегаты объявлены как weak, если это возможно.
  • Подписки снимаются своевременно.
  • Тяжелые объекты не создаются раньше, чем нужно.
  • View не используется как единственное хранилище состояния.
  • Дочерние контроллеры добавляются и удаляются по правилам containment.

Этот чек-лист я рекомендую проходить для каждого нового экрана до код-ревью. В приложениях для рынка недвижимости, где экраны часто насыщены медиа и асинхронными операциями, он окупается многократно: одна утечка на экране карточки объекта, умноженная на сотню просмотров в день активным риэлтором, — это гарантированный crash к вечеру.

Как быстро отловить retain cycle

Самый надежный практический метод — добавить временный лог в deinit и проверить, исчезает ли экран без задержек и утечек. Если deinit не вызывается:

  • сначала проверьте блоки и замыкания;
  • затем таймеры;
  • потом делегаты и наблюдателей;
  • после этого — иерархию контейнеров и ссылки на child view controllers.

Если экран сложный, полезно использовать Memory Graph Debugger в Xcode: он показывает цепочку удержания объекта и помогает увидеть, кто именно не отпускает контроллер. В моей практике был случай с экраном виртуального шоу-рума: контроллер не освобождался из-за циклической ссылки через SCNSceneRendererDelegate, и только Memory Graph помог быстро найти проблему — визуальный граф зависимостей показал цикл за секунду, тогда как ручной аудит кода занял бы часы.

Типовой сценарий: экран каталога объектов

В приложении для недвижимости это особенно заметно. Например, экран карточки объекта может содержать:

  • изображения высокого разрешения;
  • карту;
  • видеообзор;
  • подписку на обновление статуса;
  • таймер автопрокрутки;
  • вложенные контроллеры для фото, характеристик и похожих объектов.

Если все это держать до deinit, приложение быстро раздувается по памяти. Правильнее:

  • загружать тяжелые ресурсы лениво;
  • освобождать медиаконтент, когда экран уходит;
  • пересоздавать то, что легко восстановить;
  • держать бизнес-состояние отдельно от view.

Это особенно полезно в мобильных решениях для риэлторов и девелоперов, где пользователь часто открывает десятки карточек подряд, а память на устройстве ограничена. Типичный сценарий: риэлтор на показе листает каталог, открывая одну карточку за другой. Если каждая карточка удерживает в памяти распакованные изображения на 50-100 МБ, после пятого-шестого объекта приложение упадет. Решение — выгружать изображения в viewDidDisappear и подгружать их заново при возврате, полагаясь на кэш сетевого слоя для скорости.

Коротко: что запомнить

  • viewDidLoad() — один раз для настройки.
  • viewWillAppear / viewDidAppear — для обновления и показа.
  • viewWillDisappear / viewDidDisappear — для остановки временной активности.
  • deinit — индикатор, что контроллер реально освободился.
  • Контроллер может жить дольше, чем кажется, если его удерживает контейнер.
  • Большинство проблем памяти в UIKit связаны не с ARC, а с сильными ссылками и неверным временем очистки.

FAQ

Почему viewDidLoad() вызывается не всегда?

Потому что view создается только при необходимости. Если экран еще не показывался и к view не обращались, загрузка может не произойти. Это ленивая инициализация на уровне UIKit: система не тратит ресурсы на создание view-иерархии, пока она реально не понадобится. В приложениях с глубокой навигацией это дает ощутимую экономию памяти на старте.

Если экран закрылся, почему deinit не вызвался?

Чаще всего контроллер все еще удерживается чем-то: navigation stack, tab bar, таймером, closure, observer или сильной ссылкой из другого объекта. Реже проблема в том, что экран не закрылся, а просто скрылся — например, в tab bar контроллеры остаются в памяти после переключения вкладок. В риэлторских приложениях частая причина — подписка на обновления через WebSocket, которая удерживает контроллер сильной ссылкой в обработчике сообщений.

Можно ли хранить состояние в view?

Лучше не делать этого. view — это UI-слой, а не надежное хранилище бизнес-состояния. Если view будет пересоздана, данные могут исчезнуть. Представьте: пользователь заполнил форму заявки на просмотр объекта, свернул приложение, система выгрузила view из-за нехватки памяти — и все введенные данные потеряны, потому что хранились в полях ввода, а не в модели.

Нужно ли использовать [weak self] в каждом замыкании?

Нет, только там, где замыкание может пережить контроллер. В краткоживущих синхронных вызовах это обычно не требуется. Например, в UIView.animate или в forEach по массиву [weak self] избыточен. А вот в completion handler сетевого запроса или в обработчике таймера — обязателен.

Что важнее для памяти: viewDidDisappear или deinit?

Для освобождения временных ресурсов чаще важнее viewDidDisappear, а deinit нужен как финальная проверка корректной деаллокации. Первый отвечает за своевременность, второй — за подтверждение. В идеале тяжелые ресурсы освобождаются в viewDidDisappear, а в deinit мы просто убеждаемся, что ничего не осталось.

Вывод

Грамотное управление памятью в UIKit строится не вокруг одного метода, а вокруг понимания того, кто кого удерживает и в какой момент экран действительно перестает быть нужен. Если разделять жизненный цикл view и жизненный цикл контроллера, вовремя отпускать тяжелые ресурсы и проверять deinit, большинство утечек исчезает еще до попадания в продакшен. В PropTech-разработке, где приложения работают с тяжелым медиаконтентом, потоковыми данными и интенсивной навигацией, это не просто best practice — это условие выживания приложения на реальных устройствах риэлторов и клиентов.