从命令式到声明式:思路的转变

UIKit 时代写界面是「拿到控件再改属性」:改文案、改颜色、手动触发布局。SwiftUI 换了一种问法——不要描述怎么改,而是描述在某个状态下界面长什么样。状态变了,框架负责算出最小差异并重绘。这个转变带来的最大收益是,界面与数据不再可能不一致,因为界面本身就是数据的函数。

声明式 UI 的价值不是少写代码,而是消灭「界面没跟上数据」这一类 bug,让你把精力从同步状态挪到设计状态上。
先记住这四点
  • body 是纯函数读状态、产出视图描述,不产生副作用。
  • 状态驱动变化只能由状态引起,不要直接去改视图。
  • 值语义View 是 struct,重算生成的是轻量描述,绘制由框架合并。
  • 单一数据源同一份状态只放在一个层级,其余层级用绑定引用。
SwiftUI 声明式界面配图:圆角 UI 组件卡片与状态连接线
组件是圆角卡片,连接线是状态流转:数据往哪流,界面就往哪变。

View 是值类型,界面是状态的函数

一个 View 只需要实现 body 属性,返回另一个 View。它没有生命周期方法,也不持有父视图的强引用。框架在需要时调用 body,比较新旧描述。这意味着两件事:不要在 body 里做耗时计算或网络请求;也不要依赖「body 只会执行一次」这种假设,它可能被执行很多次。

组件的拆分原则是按状态边界拆,而不是按像素区域拆。卡片、列表行、工具栏各自持有自己需要的最小状态,再用 @Binding 向上暴露修改能力,就能避免把整个页面的状态堆进一个巨型 View 里,也避免了无关状态变化引发的整片重绘。

body 里不能做的事
  • 不发请求放到 .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 干净得多,性能也更好,因为它是纯粹的确定性计算。

如果预览迟迟不刷新,先确认代码能编译通过,再检查是否有循环引用导致预览进程卡死。预览用的是独立的渲染进程,它的行为与真机并非完全一致,涉及传感器、后台任务的功能仍然要上真机验证。