从命令式到声明式:思路的转变
UIKit 时代写界面是「拿到控件再改属性」:改文案、改颜色、手动触发布局。SwiftUI 换了一种问法——不要描述怎么改,而是描述在某个状态下界面长什么样。状态变了,框架负责算出最小差异并重绘。这个转变带来的最大收益是,界面与数据不再可能不一致,因为界面本身就是数据的函数。
声明式 UI 的价值不是少写代码,而是消灭「界面没跟上数据」这一类 bug,让你把精力从同步状态挪到设计状态上。
- body 是纯函数读状态、产出视图描述,不产生副作用。
- 状态驱动变化只能由状态引起,不要直接去改视图。
- 值语义View 是 struct,重算生成的是轻量描述,绘制由框架合并。
- 单一数据源同一份状态只放在一个层级,其余层级用绑定引用。
View 是值类型,界面是状态的函数
一个 View 只需要实现 body 属性,返回另一个 View。它没有生命周期方法,也不持有父视图的强引用。框架在需要时调用 body,比较新旧描述。这意味着两件事:不要在 body 里做耗时计算或网络请求;也不要依赖「body 只会执行一次」这种假设,它可能被执行很多次。
组件的拆分原则是按状态边界拆,而不是按像素区域拆。卡片、列表行、工具栏各自持有自己需要的最小状态,再用 @Binding 向上暴露修改能力,就能避免把整个页面的状态堆进一个巨型 View 里,也避免了无关状态变化引发的整片重绘。
- 不发请求放到
.task或 ViewModel 中执行。 - 不写全局状态副作用应该发生在事件回调里。
- 不做重计算排序过滤交给计算属性或模型,必要时缓存。
状态管理:@State、@Binding 与 @Observable
状态包装器各有明确职责:@State 存视图私有的值类型状态;@Binding 把读写能力传给子视图;@StateObject 让视图拥有引用类型模型;@ObservedObject 接收外部传入的模型;@Environment 读取跨层级注入的环境值。iOS 17 之后可以用 @Observable 宏替代 ObservableObject,少写一堆样板代码。
import SwiftUI
@Observable
final class CartModel {
var items: [String] = []
var total: Int { items.count * 10 }
func add(_ name: String) { items.append(name) }
}
struct CartView: View {
@State private var cart = CartModel()
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("已选 \(cart.items.count) 件").font(.footnote)
Button("加入一件商品") { cart.add("SwiftUI 实战") }
Text("合计 \(cart.total) 元")
}
.padding()
}
}
一个经验规则:能放在子视图里的状态就不要放父视图,能推导出来的值就不要单独存成状态。上面把 total 写成计算属性而不是存储属性,就从根上避免了「加了商品忘了更新总价」这类不一致。
布局系统:从堆栈到 Grid
SwiftUI 的布局模型是「父视图提议尺寸、子视图返回需求尺寸」。理解这条规则,很多「为什么它跑到屏幕外面去了」的问题就不再是玄学:frame 是提议,Spacer 是可伸缩的占位,layoutPriority 用来解决同级视图争抢空间的冲突。二维卡片墙用 LazyVGrid,列表用 List,滚动容器一定要配懒加载系列,否则一次性构建全部子视图会明显拖慢首屏。
struct ProductGrid: View {
private let items = (1...24).map { "商品 \($0)" }
private let columns = [GridItem(.adaptive(minimum: 140), spacing: 12)]
var body: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 12) {
ForEach(items, id: \.self) { name in
Text(name).frame(maxWidth: .infinity, minHeight: 88)
}
}
.padding()
}
}
}
.adaptive(minimum: 140) 的意思是尽量多塞、每列不小于 140 点,于是同一份代码在 iPhone 上是两列、在 iPad 上是四列,不需要写任何断点判断,也不需要为不同尺寸维护两套布局。
预览与调试:改动即时可见
SwiftUI 的开发体验有一半来自预览。用 #Preview 可以同时预览多个状态与多个设备尺寸,把组件在不同状态下的样子并排放在一起,是发现布局问题最快的方式。深色模式、窄屏、空数据、超长文案这四种状态,建议每个可复用组件都各留一份预览。
调试时优先用 View Debugger 看层级,其次临时给容器加 .border(.red) 确认尺寸来源。更进阶的做法是给自定义容器实现 Layout 协议,自己决定子视图的摆放位置,这比层层套 GeometryReader 干净得多,性能也更好,因为它是纯粹的确定性计算。
如果预览迟迟不刷新,先确认代码能编译通过,再检查是否有循环引用导致预览进程卡死。预览用的是独立的渲染进程,它的行为与真机并非完全一致,涉及传感器、后台任务的功能仍然要上真机验证。
性能优化与三个误区
第一个误区是把大对象塞进 @State,它适合视图私有的小状态,引用类型模型应该交给 @Observable。第二个是给 ForEach 用数组下标当 id,插入或删除元素后框架会认错行,动画错乱、输入框内容串位都是这么来的;正确做法是让数据模型实现 Identifiable,用稳定的业务主键。第三个是为了「只更新一部分」而滥用 AnyView,它会擦除类型信息,让框架难以做结构比较,结果反而更慢。
还有两个容易被忽略的细节:把格式化器(如日期、数字格式)写在 body 里会反复创建对象,应该提到外部或做缓存;给列表行加过重的阴影与模糊效果,会显著增加离屏渲染成本,长列表滚动时能明显感觉到掉帧。
用 Instruments 的 SwiftUI 模板观察视图重绘次数,是判断优化是否奏效的可靠手段;凭感觉「加了缓存就快了」往往会踩空,因为真正的瓶颈可能在图片解码或布局计算上。
小结与延伸阅读
SwiftUI 的难点在于换脑子:先把状态设计清楚,界面几乎自动就对了。把状态放错位置,才是一切奇怪问题的根源。
- 回看《Swift 语言入门:iOS 开发第一步》,确认值类型与可选值已经吃透。
- 读《iOS 应用开发全流程:从 Xcode 到上架》,把练习项目真正发布出去。
- 需要桌面端时读《macOS 桌面应用开发入门》,同一套知识能复用大半。
练习目标:用 SwiftUI 做一个本地待办清单,支持分组、搜索与深色模式。做完这一个,状态管理、布局与预览三块就都练到了。