——写给三年前写 Auto Layout 约束写到手抽筋的我

【短笺一|从“摆控件”到“叙事”】

那晚我在 storyboard 里拖了第六条约束,按钮还是偏一像素。我把屏幕一锁,换成 SwiftUI 起手的三行:

VStack(spacing: 12) {
    TitleView(text: title)
    Toggle("提醒我", isOn: $isOn)
    PrimaryButton("保存", action: save)
}
.padding(.horizontal, 16)

UIKit 的工作像在摆家具:先放沙发,再推茶几,手里拿着卷尺;SwiftUI 像写分镜:镜头一、镜头二、镜头三,布局来自层次。真正的转折不是“写法更少”,而是把“界面==状态的函数”这件事当真

UI = f(state)

  • 你把“按下保存键 → 置灰按钮 → 显示 HUD → 跳转下一页”的一堆 imperative 操作,换成了状态流

    enum ScreenState { idle, saving, saved }

    视图只描述各状态的样子过渡交给 SwiftUI 的 diff 和动画系统。

  • 你第一次发现,删掉一半的“隐藏/显示”布尔开关,界面反而更稳。

给过去的你:别先问“这个控件在哪儿”,先问“这个状态有哪些形态”。形态写对了,控件自己会排好队。

【短笺二|生命周期不再是“回调清单”】

UIKit 的脑图里有 viewDidLoad / viewWillAppear / viewDidAppear / …。SwiftUI 里,这张清单被撕碎,换成了三个钩子:

  • onAppear:第一次渲染或再次进入视图时触发(注意:List 里的 cell 也会反复 appear)。
  • task:与视图绑定的异步任务;当视图消失时自动取消。
  • onChange(of:):状态观察器,告诉你“变化发生了,你要不要跟一下动作”。
struct DetailScreen: View {
    @State private var model: Model?
    @State private var isSaving = false

    var body: some View {
        content
        .task { model = try? await api.fetch() }
        .onChange(of: isSaving) { saving in
            if saving { Analytics.log("begin_save") }
        }
    }
}
  • 你不再“记着”何时解绑通知、何时取消网络请求;视图的可见性=任务的生命
  • 你第一次把“防内存泄漏”的 checklist 扔进了垃圾桶一半。

给过去的你:把副作用丢进 task,把监听丢进 onChange,把一次性的初始化丢进 onAppear。剩下的交给系统回收。

【短笺三|布局:从 Auto Layout 的“力学”到 Stack 的“文法”】

Auto Layout 是“力平衡学”:约束求解器在解联立方程;SwiftUI 是“语法树”:VStack/HStack/ZStack 先定了句法,对齐和优先级再调语义

三条我常用的“句法”记忆法:

  1. “先骨架,后装饰”:先堆 Stack,后上 .padding/.background/.overlay。
  2. “让谁长,就给谁权”:Spacer() 是生长点,LayoutPriority 像优先生长权。
  3. “几何别太早知道”:GeometryReader 是最后手段,别拿它当万能 rAF(容易把父子布局搞反)。

常见坑两个:

  • GeometryReader 把孩子拉成无限大:外包一层 VStack { child } 或者加 fixedSize(),别让它吞掉所有空间。
  • List 的 ForEach 乱跳:id 不稳定导致 diff 误判,务必提供稳定主键
ForEach(items, id: \.id) { row in
    RowView(row)
}
// 千万不要 id: \.self(除非 self 是稳定的值类型主键)

给过去的你:Auto Layout 是“求解”,SwiftUI 是“表达”。表达清楚,求解自然发生。

【短笺四|导航:从 push/pop 到“路径”】

过去我在 UINavigationController 上 push/pop;现在我给 NavigationStack 一个路径数组

struct AppNav: View {
    @State private var path: [Route] = []

    var body: some View {
        NavigationStack(path: $path) {
            List(items) { item in
                NavigationLink(value: Route.detail(item.id)) {
                    Text(item.title)
                }
            }
            .navigationDestination(for: Route.self) { route in
                switch route {
                case .detail(let id): DetailScreen(id: id)
                case .editor(let id): EditorScreen(id: id)
                }
            }
        }
    }
}
  • Deep Link = 直接改 path;
  • 返回上一级 = path.removeLast();
  • 回到根 = path.removeAll()。

SwiftUI 的导航不是命令式的“去那里”,而是声明现在在哪儿

踩坑备忘

  • 在 iOS 16 的早期版本里,NavigationStack 与 sheet/alert 叠加时,dismiss 顺序可能乱。我的做法是:把所有呈现状态集中到一个 PresentationState,用 enum 表示哪种浮层在场,避免多个布尔开关互相打架。

【短笺五|状态源:@State、@Binding、@ObservedObject、@EnvironmentObject——别混】

我给自己画过一张小地图:

  • @State:视图内部的真源头(值类型);
  • @Binding:把源头的一根“水管”借给子视图
  • @ObservedObject:外部对象的观察(一次性订阅);
  • @EnvironmentObject:全局供给站(跨层级注入)。

错误的做法:到处塞 @EnvironmentObject,结果视图哪里都能改全局状态,调试像找幽灵。

正确的做法:单一数据流,能局部就局部,顶层才注入环境。

final class Session: ObservableObject {
    @Published var user: User?
    @Published var premium: Bool = false
}

@main
struct AppMain: App {
    @StateObject private var session = Session()

    var body: some Scene {
        WindowGroup {
            RootView()
                .environmentObject(session) // 只注入一次
        }
    }
}

给过去的你:任何能 @State 解决的,不要上 ObservableObject;任何能传 @Binding 的,不要上 EnvironmentObject。

【短笺六|动画:不是“加个 options”,而是“状态过渡”】

UIKit 的动画像“事务”:UIView.animate(withDuration:…) { // 改属性 };SwiftUI 的动画像“声明”:这个状态的变化应该被动画化

两种常见写法:

// 方式 A:在状态改变时包裹
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
    isExpanded.toggle()
}

// 方式 B:让某个值的变化总是带动画
Text(value.description)
    .animation(.easeInOut, value: value)
  • 用 .matchedGeometryEffect 做跨视图过渡时,id 必须稳定
  • 复杂序列动画别硬顶在一处,用 phase 枚举把阶段拆开,状态驱动顺序更稳。

【短笺七|性能与“误会”】

SwiftUI 性能差?多半是身份误会重建过度

  • 误会一:把大模型塞进 @State,每次变动都触发全量 diff。解法:拆粒度,或把大对象放进 ObservableObject 的小 @Published。
  • 误会二:body 里做重活(日期格式化、图片解码)。解法:搬到 ViewModel 或用 @MainActor + 缓存。
  • 误会三:ForEach 没稳定 id,List 频繁重用导致滚动丢焦点。
  • 误会四:在 onAppear 写单例初始化,cell 往返时反复执行。加一个 once 标记或搬到更高层。

诊断工具箱

  • Instruments 里的 SwiftUI 模板 + Time Profiler
  • Xcode → Debug View Hierarchy 看 diff;
  • Transactions 插件可视化动画事务;
  • OSLog + Logger 给关键状态变化打点(不要 print 洪水)。

【短笺八|桥接:不是叛逃,是请老伙计来帮忙】

SwiftUI 不会让你当场造轮子。真遇到“UIKit 更合适”的地方,拉一座桥:

struct BlurView: UIViewRepresentable {
    func makeUIView(context: Context) -> UIVisualEffectView {
        UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
    }
    func updateUIView(_ uiView: UIVisualEffectView, context: Context) { }
}

或把一个复杂的 UIViewController 收进来:

struct ScannerSheet: UIViewControllerRepresentable {
    func makeUIViewController(context: Context) -> ScannerVC { ScannerVC() }
    func updateUIViewController(_ vc: ScannerVC, context: Context) { }
}

要诀:桥接层只做桥接,不要把业务逻辑塞进 Representable;让 SwiftUI 仍然持有状态的源头

【尾注|我给自己的三条铁律】

  1. 视图是结果,不是过程:别在视图里讲故事,用状态让故事自己发生。
  2. 一个事实只存一处:任何需要“同步两份”的状态,早晚出 bug。
  3. 可撤销优先:重构前先把测试和日志补齐;动画、导航、全局状态的改动都要能回退。

——

后来我回看那堆 Auto Layout 代码,心里并没有不耐烦。它们像旧城里的巷子,拐来拐去,却有味道。SwiftUI 像新修的环线,一圈绕下来,清楚利落。

真正的革命不是“把巷子全拆了”,而是学会什么时候走巷子,什么时候上环线。当你第一次把“界面==状态的函数”放在脑子最前面,SwiftUI 这条路就不再陌生。你会发现,手上写的,已经不是控件,而是场景。