苹果应用开发实战六:SwiftUI 思维与视图体系

0 阅读12分钟

第6章 SwiftUI 思维与视图体系

本章导读:SwiftUI 不是"更好用的 UIKit",而是一套反过来的编程模型。UIKit 时代你创建视图、持有引用、在回调里亲手改 frame 和颜色;SwiftUI 里你只声明"当前状态下界面应该长什么样",剩下的 diff、布局、渲染全由框架做。这套模型的底层只有几件事:View 是值类型、body 会被反复求值、修饰符是一层层的包装、布局走"父提议—子决定—容器对齐"三步。本章把这几件事讲透,并交代 iOS 26/27 起的设计语言 Liquid Glass 该怎么用(正确 API 是 .glassEffect(),不是 visionOS 专属的 glassBackgroundEffect)。理解了本章,后面第 7–12 章的所有控件、布局、动画才不会变成死记 API。

6.1 声明式 UI:描述"是什么"而非"怎么变"

先看一段 UIKit 风格的思维方式:按钮点击后,你要找到 label 的引用、把它的文字改成新值、把颜色从灰改成蓝、如果数字为 0 还要把它隐藏。状态散落在各个视图的属性里,"现在界面长什么样"是一连串 mutate 的结果。

SwiftUI 的写法是另一个极端:

struct RatingView: View {
    @State private var score = 0

    var body: some View {
        VStack {
            Text("当前评分:\(score)")
                .foregroundStyle(score == 0 ? .secondary : .primary)
            Button("加一分") { score += 1 }
        }
    }
}

你写的是一个纯函数:给定 score,界面是什么样。按钮闭包只改状态(score += 1),不直接碰任何界面元素。框架发现 score 变了,就重新调用 body,拿新旧两个结果做对比,只把变化的部分应用到渲染层。

这套思维有三条推论,后面所有内容都围绕它:

  1. 界面是状态的函数。你不再持有 label 引用,也不会有"忘了刷新某个控件"。
  2. 状态改变是唯一的驱动力。想动界面就改状态,改完界面自动跟上。
  3. 不要在 body 里写副作用(网络请求、打印、改全局变量),因为 body 会被求值很多次,副作用会被执行很多次。副作用走 .task、.onChange、按钮 action 这些明确的入口。

提示:新手最常见的心智残留是还想"拿到某个视图的指针去改它"。在 SwiftUI 里,凡是 body 里出现的东西,你都不该去"找"它——它是状态的投影。第 9 章讲完状态管理后,你会对这句话有更深的体会。

6.2 View 是值类型:body 求值、视图 diff、渲染更新

6.2.1 View 是一个协议,body 返回一棵值树

View 是个协议,几乎所有视图都是 struct(值类型):

protocol View {
    associatedtype Body: View
    var body: SomeView { get }
}

关键在于:你的 body 返回的不是一个"活的" UI 对象,而是一棵描述性的值树。 当 score 从 0 变成 1,SwiftUI 重新执行 body,得到一棵新的值树。然后它做 diff:

  • 新旧值树在结构上是同一个 RatingView、同一个 VStack、同一个 Text;
  • Text 的输入变了("当前评分:0" → "当前评分:1"),颜色也变了(.secondary → .primary);
  • 于是渲染层只更新这个文本节点的内容和颜色,别的一概不动。

这套机制意味着:你写的结构体本身很轻量,反复 new 出来也无所谓——它只是数据,不是昂贵的视图对象。真正的 UI 渲染对象在框架内部,由 diff 结果驱动。

6.2.2 body 什么时候被调用

  • 被这个视图观察的状态(@State、@Observable 对象的属性等)变了;
  • 父视图重新求值,把新的值传进来;
  • 环境值(.environment)变了。

不要假设 body 只调用一次。滚动列表里的每一行滑出再滑入、动态字体设置变化、深浅色切换,都可能触发重新求值。所以 body 里不要放耗时计算——把派生数据提前算好缓存起来。

注意:iOS 27 起 @State 从 DynamicProperty 变成了宏,存入 @State 的引用类型改为懒初始化,视图重建时只创建一次实例。这对你是透明的升级(行为回移植到了 iOS 17),但它解释了为什么以前有人发现"每次 body 求值 new 了一个 ViewModel",现在不再发生了。

6.3 修饰符的本质:顺序与包装、ViewBuilder

6.3.1 修饰符不是"改属性",是返回一个新视图

这是 SwiftUI 里最反直觉、也最值得花时间理解的一点:

Text("你好")
    .padding()
    .background(.blue)

.padding() 不是"给 Text 加了内边距",它返回了一个新的视图(ModifiedContent),把 Text 包在里面;.background(.blue) 又返回一个新视图,把上一步的结果包在里面。最终的视图树从外到内是:背景视图 → 填充视图 → Text。

所以顺序是刚性的,换个位置结果完全不同:

// 写法 A:蓝底把 padding 也包住,整块都是蓝色
Text("你好").padding().background(.blue)

// 写法 B:背景只包住文字,padding 区域是透明的
Text("你好").background(.blue).padding()

在模拟器上两种效果一眼可辨。记住口诀:修饰符由外向内生效,写在最外面的先包裹。

6.3.2 ViewBuilder:为什么 body 里能写多个视图

你可能注意到 body 里直接放了 VStack、Text、Button 好几条语句,甚至能写 if。这是 @ViewBuilder 这个结果构建器(result builder)在起作用:它把闭包里一连串视图表达式收集起来,合成一个元组视图容器。if score > 0 { ... } 则会被编译成"条件存在或不存在某个子树"。

iOS 27 / Xcode 27 把 ViewBuilder 等多种构建器统一成了 ContentBuilder,附带的好处是复杂 body 的类型检查时间显著缩短——以前超长 body 编译转圈几十秒的问题,现在改善很多。

实际写代码时,当一个 body 超过大概 50 行,就该把它拆成子视图:

struct RatingView: View {
    @State private var score = 0

    var body: some View {
        VStack {
            scoreText
            incrementButton
        }
    }

    private var scoreText: some View {
        Text("当前评分:\(score)")
            .foregroundStyle(score == 0 ? .secondary : .primary)
    }

    private var incrementButton: some View {
        Button("加一分") { score += 1 }
    }
}

拆成计算属性既缩短了主 body,也让每一块的意图更清晰。

6.4 布局流程:父提议、子决定、容器对齐

SwiftUI 的布局和 UIKit 的"子视图自己定 frame"完全相反。它分三步:

  1. 父提议(proposal):父容器向子视图提出一个可选的尺寸范围,比如"你可以用 300×100,或者更矮"。父容器不替子视图做决定。
  2. 子决定(size that fits):子视图根据提议,算出自己想要的尺寸。Text 会按文字长度和字体算出行高;图片会按自己的长宽比;VStack 会把所有子视图的宽度和高度汇总。
  3. 容器对齐(placement):父容器拿到所有子视图算好的尺寸,按对齐方式把它们摆好,最后向自己的父容器汇报自己的总尺寸。

举个具体的例子:

VStack(alignment: .leading, spacing: 12) {
    Text("标题")
    Image(systemName: "star")
}

VStack 先向 Text 提议宽度(屏幕宽减去边距),Text 算好自己的高度;再向 Image 提议同样的宽度,Image 算好自己的高度;VStack 总高 = 两个高度 + spacing,总宽 = 子视图里最宽的那个。最后它把两个子视图按 .leading(左对齐)摆放。

这解释了几个长期困惑新手的现象:

  • 为什么 Text("x") 后面加 .frame(width: 200) 和前面加效果不同:frame 本身是一个"决定尺寸"的修饰符,它先给内容提议一个固定尺寸,再把内容包成 200 宽。顺序决定谁提议给谁。
  • 为什么 Spacer() 能撑开:Spacer 没有固有尺寸,它会吃掉父容器给的所有剩余空间;Stack 里有 Spacer,就说明空间分配策略变了。
  • 为什么在 SwiftUI 里几乎用不到手动算 frame:每一层只负责"我想要多大、怎么摆我的孩子",尺寸是自底向上算出来的。

警告:GeometryReader 是这套规则的例外——它会贪婪地吃掉父容器给的所有空间。很多人用它做"居中一个小视图",结果整个区域被撑满。需要基于尺寸做布局时,优先用 .frame、.padding、Layout 协议(第 7、12 章),而不是 GeometryReader。

6.5 Liquid Glass 设计语言与材质

6.5.1 它是什么,为什么你几乎不用做什么

Liquid Glass 是 Apple 在 iOS 26(WWDC25)推出的全新设计语言,iOS 27 做了精修。它带来一种自适应的液态玻璃材质:控件背后有光学折射感、随内容滚动自动在明暗间变化、开关和滑块交互时会"液化"变形。对开发者最重要的事实是:你用 Xcode 26/27 SDK 编译,标准控件(按钮、标签栏、导航栏、sheet、工具栏)自动就有 Liquid Glass 外观,一行代码都不用改。

所以第一原则是:优先用标准结构,不要手搓毛玻璃去模拟它。 旧代码里 .background(.ultraThinMaterial)、UIVisualEffectView 那一套,在 iOS 26+ 下通常应该直接删掉,让系统给你标准玻璃。

6.5.2 自定义视图要加玻璃,用这些 API(拼写必须照抄)

当你要给自己的视图加玻璃质感时,正确 API 是 glassEffect 家族(iOS 26.0+):

// 给一个自定义徽标加玻璃,默认胶囊形
Label("沙漠模式", systemImage: "sun.max.fill")
    .padding()
    .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 16))
    .tint(.green)

// 标准玻璃按钮
Button("开始使用") { }
    .buttonStyle(.glassProminent)
Button("了解更多") { }
    .buttonStyle(.glass)

几个配套能力:

  • GlassEffectContainer { ... }:把多个玻璃元素分组。玻璃不能采样玻璃——两个玻璃视图叠在一起必须放在同一个容器里,材质采样才一致。
  • .glassEffectID(_:in:) 配 @Namespace:让玻璃元素之间做流体形变转场,比如一个小徽标展开成一组。
  • .tint 给玻璃着色:只用于传达语义(这是主要行动点),不要为了好看给每个按钮都染颜色。

警告:glassBackgroundEffect(_:displayMode:) 是 visionOS 2.4+ 专属的窗口背板 API,在 iOS 示例里禁止使用。iOS/iPadOS/macOS 上自定义玻璃一律用 glassEffect()。本书后续所有章节(含第 29、30 章实战项目)的玻璃代码都遵循这条。

6.5.3 设计上的分寸

  • 按钮默认胶囊形,偏高度列表里可以用 .controlSize(.small) 保持密度。
  • 不要给每个卡片都加玻璃——玻璃用于"悬浮在内容之上"的元素(标签栏、工具栏、弹出层、关键操作按钮),内容卡片用普通不透明背景就好。
  • 深浅色、滚动到边缘时玻璃的观感由系统自动处理,你不需要写任何适配代码。

6.6 常见问题与排错

1. body 里写了网络请求/打印,结果被执行了好几次 body 是纯函数,会被反复求值。把副作用挪到 .task { }、按钮 action 或 .onChange 里。

2. 加了 .padding() 和 .background(),位置换一下样子就变了 修饰符顺序问题(见 6.3.1)。记住从外到内包裹:想让背景包住边距,就把 background 写在 padding 外面。

3. 想用毛玻璃做背景,发现毛玻璃上叠加玻璃元素时材质"糊"成一片 玻璃不能采样玻璃。用 GlassEffectContainer 把多个玻璃元素包起来;内容层不要叠玻璃。

4. 在 iOS 26+ 上手写 .background(.ultraThinMaterial),看起来跟系统风格不一致 删掉它。让标准 sheet、工具栏、按钮自动获得 Liquid Glass;只有自定义悬浮元素才用 .glassEffect()。

5. 一个超长 body 编译要等几十秒 拆子视图(6.3.2)。Xcode 27 的 ContentBuilder 已经缩短了类型检查时间,但把 100 行挤在一个 body 里仍然是不必要的负担。

本章小结

  1. SwiftUI 里界面是状态的纯函数:你声明"状态→界面",框架负责 diff 与渲染,不持有视图引用。
  2. View 是值类型结构体,body 返回的是描述性值树;真正的渲染对象由框架在内部维护,body 可以被反复求值,里面不能放副作用和耗时计算。
  3. 新旧值树做 diff 后只更新变化的节点——这就是为什么"改状态就刷新 UI"自动且高效。
  4. 修饰符不是改属性,而是层层返回新的包装视图;顺序从外到内生效,换顺序结果不同。
  5. @ViewBuilder 把 body 里的多个表达式和 if 收集成子树;iOS 27 起统一为 ContentBuilder,复杂 body 类型检查更快。
  6. 布局三步:父容器提议尺寸、子视图自定尺寸、容器按对齐摆放;几乎不需要手动算 frame。
  7. Liquid Glass 对标准控件自动生效,优先用标准结构,不要手搓毛玻璃;自定义玻璃用 .glassEffect()、.buttonStyle(.glass/.glassProminent),多元素用 GlassEffectContainer 分组。
  8. glassBackgroundEffect 仅 visionOS,iOS 代码一律不碰。

练习

1.(动手)写一个计数器视图:@State 存一个整数,两个按钮分别加、减,Text 显示当前值;当值小于 0 时文字变红,大于 10 时显示"满了"。分别验证:改值时只有 Text 在变(可以在 body 里加一行打印,观察按钮点击时 body 被调用几次)。(完成标准:打印日志证明每次状态变化 body 恰好重新求值一次;颜色逻辑正确。) 2.(动手)把同一个 Text 分别用 .padding().background(.blue) 和 .background(.blue).padding() 写两遍并排放在 HStack 里,用模拟器截图对比两块蓝底的范围差异,并在注释里解释原因。(完成标准:能口述"修饰符从外到内包裹",且两图差异正确。) 3.(思考)用 VStack 放三个不同长度的 Text,加上 alignment: .leading;再换成 .center、.trailing,观察三行的对齐边如何变化,对应布局三步里的哪一步。(完成标准:能用"父提议—子决定—容器对齐"解释对齐参数作用在哪一步。) 4.(动手)给你的一个自定义徽标(HStack 包一个 SF Symbol 和一段文字)加 .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 16)),在 iOS 27 模拟器上按压观察缩放回弹效果;故意把 tint 染成系统绿,对比默认无色的观感差异。(完成标准:玻璃效果真实可见,并能说出 tint 应只用于语义强调。) 5.(选做)把一个 100 行的假想 body(你可以乱写一些 Text、Image、VStack 组合)拆成至少 3 个命名子视图计算属性,测量拆分前后的编译时间差异(Xcode 27)。(完成标准:拆分后主 body 少于 30 行,各子属性职责单一。)