Жизненный цикл UIViewController

Порядок методов жизненного цикла — вопрос-фильтр: по ответу сразу видно, писал ли кандидат экраны руками или только правил чужие.

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

Вопрос 1: «Расскажите порядок методов жизненного цикла»

Что на самом деле проверяют

Не память на названия, а понимание, в какой момент какие данные уже доступны. Кандидат, который знает порядок, не будет считать ширину экрана там, где Auto Layout ещё не отработал, и не подпишется на нотификации в методе, который вызывается один раз за жизнь контроллера.

Развёрнутый ответ

Полный порядок при первом показе экрана выглядит так:

loadView
viewDidLoad
viewWillAppear(_:)
viewWillLayoutSubviews
viewDidLayoutSubviews
viewDidAppear(_:)
   ... экран живёт ...
viewWillDisappear(_:)
viewDidDisappear(_:)

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

final class ProfileViewController: UIViewController {
    private let tokenStore: TokenStore

    override func viewDidLoad() {
        super.viewDidLoad()
        // Один раз: иерархия вьюх, констрейнты, делегаты, статичная настройка
        view.addSubview(tableView)
        tableView.dataSource = self
        setupConstraints()
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        // Каждый показ: свежие данные и подписки
        NotificationCenter.default.addObserver(
            self, selector: #selector(balanceChanged),
            name: .balanceDidChange, object: nil
        )
        reloadBalance()
    }

    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        NotificationCenter.default.removeObserver(self)
    }
}

Вопрос 2: «Почему нельзя настраивать frame во viewDidLoad?»

Что на самом деле проверяют

Понимание, что загрузка view и расчёт её геометрии — два разных этапа. Это тот же навык, что отличает «работает на моём симуляторе» от «работает на всех размерах экрана».

Развёрнутый ответ

Во viewDidLoad view.bounds уже не нулевой, но и не финальный: он взят из storyboard/xib (обычно размер того устройства, на котором рисовали макет) либо из значения по умолчанию при вёрстке кодом. Auto Layout ещё не проходил, safe area может быть не учтена, контейнер ещё не сообщил реальный размер.

Настоящие размеры доступны в viewDidLayoutSubviews. Но у него есть цена: он вызывается много раз — при повороте, при появлении клавиатуры, при смене размера контейнера, при каждом проходе layout-цикла. Поэтому создавать там подвьюхи, добавлять подписки или запускать сетевые запросы нельзя: получите десять слоёв и десять запросов.

override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    // Только вещи, зависящие от финальных размеров, и идемпотентные
    gradientLayer.frame = headerView.bounds
    avatarView.layer.cornerRadius = avatarView.bounds.width / 2
}

loadView переопределяют, когда верстают кодом и хотят подсунуть собственный корневой view. Важно: не вызывать в нём super.loadView() и не трогать self.view внутри — обращение к нему запустит загрузку рекурсивно.

override func loadView() {
    view = PlayerRootView()   // без super.loadView()
}

Что где делать: памятка

МетодСколько разЧто класть
loadView1Подмена корневого view при вёрстке кодом
viewDidLoad1Иерархия, констрейнты, делегаты, лёгкая настройка
viewWillAppearкаждый показОбновление данных, подписки, скрытие navigation bar
viewDidLayoutSubviewsмного разРабота с финальными размерами: frame слоёв, скругления
viewDidAppearкаждый показАналитика показа, старт анимаций и таймеров, фокус в поле
viewWillDisappearкаждый уходСохранение черновика, скрытие клавиатуры
viewDidDisappearкаждый уходОстановка таймеров, отписки, освобождение ресурсов

Тяжёлую работу — парсинг большого JSON, разбор базы, синхронную работу с диском — не кладут во viewDidLoad стартового экрана: этот код выполняется на главном потоке и напрямую растягивает время до первого кадра.

Про deinit, утечки и контейнеры

didReceiveMemoryWarning сегодня почти не переопределяют. А вот deinit — рабочий инструмент диагностики: если после pop из навигации deinit не печатается, у вас цикл сильных ссылок (замыкание без [weak self], сильный делегат, таймер с target).

deinit { print("deinit \(type(of: self))") }

// Частая причина утечки:
service.onUpdate = { [weak self] items in   // без weak контроллер не умрёт
    self?.apply(items)
}

При работе с child view controller нужна полная пара вызовов — иначе ребёнок не получает события жизненного цикла и вращения:

addChild(child)
containerView.addSubview(child.view)
child.view.frame = containerView.bounds
child.didMove(toParent: self)

// Удаление — в зеркальном порядке
child.willMove(toParent: nil)
child.view.removeFromSuperview()
child.removeFromParent()

Типичные ошибки кандидатов

  • Путают viewDidLoad и viewWillAppear: обновляют данные один раз и удивляются устаревшему экрану после возврата назад.
  • Подписываются на нотификации во viewWillAppear, но не отписываются в viewDidDisappear — получают дубли обработчиков.
  • Считают view.bounds во viewDidLoad финальным и жёстко задают frame.
  • Создают подвьюхи во viewDidLayoutSubviews, не понимая, что метод вызывается многократно.
  • Забывают didMove(toParent:) при встраивании child-контроллера.
  • Не вызывают super в переопределённых методах.

Как ответить кратко

loadView создаёт view, viewDidLoad срабатывает один раз после загрузки — там иерархия и констрейнты. viewWillAppear вызывается при каждом показе, туда идут свежие данные и подписки. Геометрия финальна только в viewDidLayoutSubviews, и он вызывается много раз, поэтому там нельзя ничего создавать — только правка frame слоёв и скруглений. viewDidAppear — аналитика и анимации, viewDidDisappear — отписки. Во viewDidLoad bounds взят из nib и Auto Layout ещё не отработал, поэтому frame там задавать бессмысленно.

Проверьте себя
1. Пользователь ушёл с экрана по push и вернулся назад по кнопке «Назад». Какие методы вызовутся при возврате?
AviewDidLoad, затем viewWillAppear и viewDidAppear
BviewWillAppear, viewDidLayoutSubviews и viewDidAppear (viewDidLoad не вызовется)
CТолько viewDidAppear
DloadView и viewDidLoad заново, так как контроллер пересоздаётся
2. Почему задавать frame подвьюхи во viewDidLoad — плохая идея?
AFrame вообще нельзя менять программно, только через Auto Layout
BviewDidLoad выполняется в фоновом потоке, а UI можно трогать только на главном
CНа этот момент bounds взят из nib или значения по умолчанию, Auto Layout ещё не отработал, и размеры не финальные
DviewDidLoad вызывается многократно, и frame будет перезаписан
3. После pop контроллера из UINavigationController его deinit не печатается. О чём это говорит?
AЭто нормально: навигация кеширует контроллеры на случай возврата
BЕсть цикл сильных ссылок — например, замыкание, захватившее self без weak
CНужно вызвать didReceiveMemoryWarning вручную
DКонтроллер не вызвал super.viewDidDisappear