本节摘要:视图树上的每个页面节点都由一个 UIViewController 照料,它有从出生到回收的固定节律:init → loadView → viewDidLoad → viewWillAppear → viewDidAppear → viewWillDisappear → viewDidDisappear → deinit。本节给每个回调标注"该做什么、忌做什么",用带日志的实验观察真实触发顺序,并讲清 Will 与 Did 的语义差、离开页面时机的选择——第 4 章列表刷新与第 6 章内存排错都直接依赖这套节律。
上一节种好了静态的树,这一节看它随时间怎么活。控制器是页面的园丁:它造视图、喂数据、听事件、退场清理。系统在固定的时机回调它的方法,这套顺序就是生命周期的节律:
final class LifecycleViewController: UIViewController { // 1. 出生:属性在此初始化,别碰视图(view 还没造) init() { super.init(nibName: nil, bundle: nil) print("① init:控制器对象出生,view 尚不存在") } required init?(coder: NSCoder) { fatalError("纯代码路线不走这里") } // 2. 造根视图:只有不用系统默认视图时才重写,普通页面跳过 override func loadView() { super.loadView() print("② loadView:根视图被造出(首次访问 view 时懒加载触发)") } // 3. 视图就绪:一次性配置全放这——建层级、钉约束、绑事件 override func viewDidLoad() { super.viewDidLoad() print("③ viewDidLoad:视图加载完成,一生只走一次") view.backgroundColor = .systemMint } // 4/5. 即将出现与已出现:前者做每次都要刷新的准备,后者做昂贵布局 override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) print("④ viewWillAppear:即将上屏——每次回来都会走") } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) print("⑤ viewDidAppear:已经上屏——开始动画、开始计时的安全时机") } // 6/7. 即将消失与已消失:暂停与清理的最佳窗口 override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) print("⑥ viewWillDisappear:即将离屏——收键盘、暂停计时器") } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) print("⑦ viewDidDisappear:已经离屏——停掉无需继续的后台任务") } // 8. 回收:2.8 的 ARC 探针;没打印 = 没释放 = 大概率循环引用 deinit { print("⑧ deinit:控制器回收") } }
把这段代码设为窗口根控制器并运行,随后推入下一页再返回,控制台就是一部完整的节律纪录片:
首次启动: ① init:控制器对象出生,view 尚不存在 ② loadView:根视图被造出(首次访问 view 时懒加载触发) ③ viewDidLoad:视图加载完成,一生只走一次 ④ viewWillAppear:即将上屏——每次回来都会走 ⑤ viewDidAppear:已经上屏——开始动画、开始计时的安全时机 推入下一页(本页被盖住): ⑥ viewWillDisappear:即将离屏——收键盘、暂停计时器 ⑦ viewDidDisappear:已经离屏——停掉无需继续的后台任务 返回本页: ④ viewWillAppear:即将上屏——每次回来都会走 ⑤ viewDidAppear:已经上屏——开始动画、开始计时的安全时机 退出页面(UINavigationController 弹栈): ⑥ viewWillDisappear:即将离屏——收键盘、暂停计时器 ⑦ viewDidDisappear:已经离屏——停掉无需继续的后台任务 ⑧ deinit:控制器回收

背景:详情页要显示"距下次浇水还有几秒"的倒计时,第一版把计时器启动放在 viewDidLoad、销毁放在 deinit。操作与结果:
// 事故版节选 final class TimerBadViewController: UIViewController { private var timer: Timer? override func viewDidLoad() { super.viewDidLoad() timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in print("tick") // 每秒都在跑 } } deinit { timer?.invalidate() } // 问题:deinit 不来,这里永远不执行 }
症状:从详情页返回列表,控制台仍每秒打印 tick;内存图里页面控制器一直活着。解读:计时器的闭包强捕获了控制器(2.8 的环),deinit 永不触发,invalidate 永不执行——清理代码放在一个"被泄漏导致永远到不了"的位置,等于没写。修复分两步:闭包改 [weak self] 拆环;启动挪到 viewDidAppear、停止挪到 viewDidDisappear,让节律本身管住计时器的寿命:
// 修复版节选 final class TimerGoodViewController: UIViewController { private var timer: Timer? override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in self?.renderCountdown() // weak 拆环 + 页面不在屏就不该跑 } } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) timer?.invalidate(); timer = nil // 离屏即停,资源与节律对齐 } private func renderCountdown() { /* 刷新倒计时标签 */ } }
变式:后台播放类需求(背景音乐)恰恰不能在 viewDidDisappear 停,那是"页面生命周期"与"应用生命周期"两条线的区别——后者在 AppDelegate 或 SceneDelegate 的回调里,本册用到时再展开。判断口诀:资源的寿命跟谁走,清理就放在谁的对应回调里。
⚠️ 常见坑:在 init 里访问 self.view 会触发 loadView 提前执行,把"还没配置完的控制器"推上屏幕,出现一闪而过的空界面。规则就一条:init 里只碰自己的属性,视图相关的一切从 viewDidLoad 开始。
节律有了,下一节给这根枝条长出第一批叶片:常用控件与事件接线。