Жизненный цикл 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()
}
Что где делать: памятка
| Метод | Сколько раз | Что класть |
| loadView | 1 | Подмена корневого view при вёрстке кодом |
| viewDidLoad | 1 | Иерархия, констрейнты, делегаты, лёгкая настройка |
| 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 там задавать бессмысленно.