本节摘要:第 2 章备好了养分,现在把界面种进 UIKit 的土壤。动工前先看图纸:MVC 把代码分成模型(数据与规则)、视图(屏幕呈现)、控制器(居中调度)三层。本节讲三层的职责边界、UIKit 里的现实形态(控制器拥有视图、模型被双方共享),并用一次真实重构演示"巨型控制器"怎么瘦身为三层各司其职——这是第 4 章列表、第 6 章排错全部依赖的组织方式。
养分配齐,从本节起界面成为主角。第一个要立的规矩不是写代码,而是分层:不画图纸就动工,小工程能糊弄,长到列表页那种规模必然失控。MVC 把一株界面植物分成三个器官:
三层的通信纪律是本节的灵魂:控制器单向指挥视图、单向读写模型;视图与模型互不认识。视图有事(用户点了按钮)通过 Target-Action 或委托告诉控制器;模型有变化(数据更新)通过通知或回调告诉控制器。所有箭头都经过控制器——它是唯一知道双方存在的器官。

UIKit 的现实是:控制器自带一个根视图(它的 view 属性),界面控件大多直接挂在控制器名下。于是新手很自然地把所有代码写进一个文件——查数据、拼文案、造视图、处理点击、甚至算倒计时。两个月后这个文件上千行,改一处、崩三处。
看一段反面教材的浓缩版(真实项目里它能膨胀十倍):
// 反面教材:一个方法里干了三层的活 @objc func waterTapped() { // 模型的活:业务规则 let interval = plantKind == "多肉" ? 10 : 3 let shouldWater = passedDays >= interval // 控制器的活:判断与调度 if shouldWater { passedDays = 0 // 视图的活:拼文案、改界面 statusLabel.text = "\(plantName) 已浇水,间隔 \(interval) 天" statusLabel.textColor = .systemGreen waterButton.isEnabled = false } else { statusLabel.text = "还剩 \(interval - passedDays) 天" } }
三层的活挤在一个函数里,问题是结构性 的:规则改了要读界面代码才知道改哪、换一套界面(第 5 章 SwiftUI 版)时规则没法搬走、想给规则写测试得先把界面跑起来。图纸存在的意义就是此刻的判断依据。
背景:上面的水龙头方法要支撑 UIKit 与 SwiftUI 两套界面,还要能写单元测试。操作:按图纸分三次搬家。
// 第一次搬家:规则进模型(2.4 的 Plant 顺势扩充) struct Plant { var name: String var kind: PlantKind var passedDays: Int var intervalDays: Int { kind == .succulent ? 10 : 3 } // 规则收进模型 var shouldWater: Bool { passedDays >= intervalDays } var statusText: String { // 展示文案也算模型的"数据形状" shouldWater ? "\(name) 该浇水了" : "\(name) 还剩 \(intervalDays - passedDays) 天" } mutating func water() { passedDays = 0 } } // 第二次搬家:视图配置进视图层(按钮样式收进工厂函数,控制器只调用) func makeWaterButton() -> UIButton { let b = UIButton(type: .system) b.setTitle("浇水", for: .normal) b.titleLabel?.font = .boldSystemFont(ofSize: 18) return b } // 第三次搬家:控制器只剩翻译 final class PlantViewController: UIViewController { private var plant: Plant private let statusLabel = UILabel() private lazy var waterButton = makeWaterButton() init(plant: Plant) { self.plant = plant; super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError("纯代码初始化") } override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .systemMint statusLabel.translatesAutoresizingMaskIntoConstraints = false waterButton.translatesAutoresizingMaskIntoConstraints = false waterButton.addTarget(self, action: #selector(waterTapped), for: .touchUpInside) view.addSubview(statusLabel); view.addSubview(waterButton) NSLayoutConstraint.activate([ statusLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), statusLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), waterButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), waterButton.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 24) ]) render() } @objc private func waterTapped() { // 控制器:事件 → 模型操作 → 刷新视图 plant.water() render() } private func render() { // 唯一的"模型到视图"翻译口 statusLabel.text = plant.statusText statusLabel.textColor = plant.shouldWater ? .systemRed : .darkGray waterButton.isEnabled = true } }
结果:控制器不到五十行,规则在模型、样式在工厂、翻译集中在一个 render 函数。解读:任何界面变化都经过 render 这一个口子,排错时只要问"render 被调了吗、模型值对吗"两个问题;而 SwiftUI 版界面将来直接消费同一个 Plant——搬家搬对了,两套界面共享一套模型。变式:更复杂的页面把 render 拆成 renderHeader、renderList 等小函数,纪律不变:视图不反向写模型,模型不知道视图。
| 职责 | 归属 | 判断口诀 |
|---|---|---|
| 业务规则、数据形状 | Model | 换界面它不变吗?变就不该在这 |
| 控件样式、布局 | View 层 | 不看数据能写出来吗?能就属于视图 |
| 事件响应、数据到界面翻译 | Controller | 两边都要懂?那就是信使的活 |
💡 关键直觉:MVC 不是三个文件夹,是一句问答——"这段代码换一套界面还要不要?"要,它属于模型或视图;不要,它才是控制器。
图纸有了,下一节挖开土壤看真正的根系:UIWindow 与 UIView 的父子层级。