第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,拿新旧两个结果做对比,只把变化的部分应用到渲染层。
这套思维有三条推论,后面所有内容都围绕它:
- 界面是状态的函数。你不再持有 label 引用,也不会有"忘了刷新某个控件"。
- 状态改变是唯一的驱动力。想动界面就改状态,改完界面自动跟上。
- 不要在 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"完全相反。它分三步:
- 父提议(proposal):父容器向子视图提出一个可选的尺寸范围,比如"你可以用 300×100,或者更矮"。父容器不替子视图做决定。
- 子决定(size that fits):子视图根据提议,算出自己想要的尺寸。Text 会按文字长度和字体算出行高;图片会按自己的长宽比;VStack 会把所有子视图的宽度和高度汇总。
- 容器对齐(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 里仍然是不必要的负担。
本章小结
- SwiftUI 里界面是状态的纯函数:你声明"状态→界面",框架负责 diff 与渲染,不持有视图引用。
- View 是值类型结构体,body 返回的是描述性值树;真正的渲染对象由框架在内部维护,body 可以被反复求值,里面不能放副作用和耗时计算。
- 新旧值树做 diff 后只更新变化的节点——这就是为什么"改状态就刷新 UI"自动且高效。
- 修饰符不是改属性,而是层层返回新的包装视图;顺序从外到内生效,换顺序结果不同。
@ViewBuilder把 body 里的多个表达式和if收集成子树;iOS 27 起统一为 ContentBuilder,复杂 body 类型检查更快。- 布局三步:父容器提议尺寸、子视图自定尺寸、容器按对齐摆放;几乎不需要手动算 frame。
- Liquid Glass 对标准控件自动生效,优先用标准结构,不要手搓毛玻璃;自定义玻璃用
.glassEffect()、.buttonStyle(.glass/.glassProminent),多元素用GlassEffectContainer分组。 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 行,各子属性职责单一。)