SwiftUI 复习知识点
本文是一份「单文件可通读」的 SwiftUI 深度复习材料,面向已经熟悉 Swift 语言、有一定 UIKit 经验、准备面试或系统性复盘的 iOS 工程师。
阅读建议:
- 第 0~2 章是心智模型与状态机制,决定你能否写出"对的"SwiftUI;
- 第 3~7 章是UI 与交互子系统(导航/动画/手势/绘图/互操作),对应面试高频考点;
- 第 8~10 章是工程化(Preview/测试/性能);
- 第 11~12 章是项目实战(关键模块 + 完整架构示例);
- 附录是面试速记与版本/API 对照表。
本文所有 API 行为以 iOS 17 / Xcode 15 为基线,并对 iOS 18 的新增做了必要标注。所有代码示例均经过整理,可直接放入 Xcode 工程编译(少量简写为了排版美观)。
0. 心智模型:理解 SwiftUI 的"内核"
0.1 声明式 ≠ 简化语法,而是"声明依赖 + 系统调度"
UIKit 是命令式 + 保留对象树:你手动创建视图对象、加入视图层级、手动改属性、手动刷新。 SwiftUI 是声明式 + 重建视图树:你用一个纯函数 body -> some View,根据当前状态描述 UI 长什么样;框架负责 diff、调度、刷新。
// UIKit 思路(命令式)
label.text = "Hello"
label.textColor = .red
view.addSubview(label)
// SwiftUI 思路(声明式)
Text(name)
.foregroundStyle(.red)
// name 变了 -> body 重新求值 -> 框架 diff -> 视图刷新这个差异决定了 SwiftUI 的所有特性:不可变视图、单向数据流、状态驱动、属性包装器。
0.2 View 协议的真相
public protocol View {
associatedtype Body: View
@ViewBuilder var body: Self.Body { get }
}要点:
View只有一个要求body,它是只读计算属性;associatedtype Body: View配合some View(不透明返回类型,Swift 5.1+),让body的具体类型在编译期就被推导出来,但对外隐藏。编译期已知类型意味着运行时没有类型擦除开销,diff 也更快;- 不要用
AnyView来"统一类型"。AnyView会破坏 diff 性能(强制走类型擦除路径),只在确实需要动态分发时使用。
0.3 @ViewBuilder 与视图组合
@ViewBuilder 是一个 result builder(Swift 5.4 起),它把闭包内的多条表达式编译成 TupleView / _ConditionalContent 的嵌套结构,从而支持 if/else、ForEach、switch:
VStack {
if condition {
Text("A")
} else {
Text("B")
Image(systemName: "star")
}
}
// 编译后等价于:
// VStack<TupleView<_ConditionalContent<Text, TupleView<(Text, Image)>>>>由此带来的后果:
if/else会改变视图树的具体类型,因此会被 SwiftUI 视为结构变化,触发整棵子树的销毁与重建。如果只是切换内容,优先用if/else改成.opacity()、.offset()、transaction控制,或用Group { if ... };if let同理,慎用。
0.4 视图树与 Attribute Graph
SwiftUI 内部维护两棵树:
- 视图树(View Tree):你的
body求值得到的不可变值树。每次状态变化都生成一棵新的(值拷贝,开销很小)。 - 属性图(Attribute Graph, AG):持久化的依赖追踪图。视图、状态、属性之间形成
node → dependency的有向图,状态变化时 AG 只重算依赖路径上的节点。
经典误解:"SwiftUI 每次都把整个
body跑一遍,是不是很慢?" 实际上:body求值本身很快(值类型拷贝),AG 决定的是哪些节点真正需要重渲染。优化 SwiftUI 性能的关键是减少不必要的依赖(详见第 10 章)。
0.5 Identity:视图身份决定 diff
SwiftUI 判断两棵视图树的差异,靠两层:
- 结构身份(Structural Identity):视图在树中的"位置"。
if/else分支不同 = 结构不同 = 不同节点; - 显式身份(Explicit Identity):
id()、Identifiable、ForEach(id:)。
ForEach(items) { item in // items: [Item] where Item: Identifiable
Row(item: item)
}
// 或显式提供 id
ForEach(0..<n, id: \.self) { i in Cell(i: i) }陷阱:id(\.self) 当数组顺序变化时(如排序、插入到中间),会因为 id 变化触发整列重建。对于可重排的列表,必须用稳定的业务 id(如 uuid),否则会看到动画错乱、状态丢失。
0.6 生命周期
Text("hi")
.onAppear { /* 视图首次进入屏幕 */ }
.onDisappear { /* 视图被移除 */ }
.onChange(of: value) { oldValue, newValue in /* iOS 17+ 二参数闭包 */ }
.onChange(of: value) { /* 旧版单参数,iOS 14+ */ }
.onReceive(timer) { date in /* Combine 订阅 */ }
.task { /* 视图出现时启动 async 任务,离开自动 cancel */ }
.task(id: someKey) { /* someKey 变化时重启任务 */ }onAppear 在 lazy 容器(List、LazyVStack)的子项上才会按需触发,但在普通 VStack 的子项上会跟随父视图一起出现。
1. 视图与 Modifier
1.1 modifier 顺序很重要
Text("Hello")
.padding()
.background(.red)
// vs
Text("Hello")
.background(.red)
.padding()每个 modifier 实际上返回一个包装原视图的新类型(如 _Padding<Text>、_BackgroundModifier<Text>),所以顺序不同得到不同视图结构、不同视觉效果。
1.2 内置 modifier vs 环境值
很多 modifier 是"读取环境 + 写入环境"的语法糖:
.font(.title)→ 写入\.environment(\.font, .title).foregroundStyle(.red)→ 写入\.foregroundStyle.tint(.blue)→ 影响 Button 等控件强调色
如果想让某个子视图"屏蔽"父级环境,用 .environment(\.xxx, .default) 重置。
1.3 自定义 ViewModifier
struct CardStyle: ViewModifier {
var padding: CGFloat = 16
func body(content: Content) -> some View {
content
.padding(padding)
.background(.white, in: RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.1), radius: 8, y: 4)
}
}
extension View {
func cardStyle(padding: CGFloat = 16) -> some View {
modifier(CardStyle(padding: padding))
}
}
// 使用
VStack { /*...*/ }.cardStyle()要点:
ViewModifier适合"无状态或少量状态"的封装;带状态时优先抽出独立View;- 把自定义 modifier 写到
extension View上,调用更自然。
1.4 ButtonStyle / LabelStyle / ToggleStyle
控件的外观定制走专用协议:
struct ScaleButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.96 : 1.0)
.animation(.easeOut(duration: 0.15), value: configuration.isPressed)
}
}
Button("Tap") { /*...*/ }.buttonStyle(ScaleButtonStyle())PrimitiveButtonStyle 比 ButtonStyle 更底层,可完全自定义手势。
2. 布局系统
2.1 SwiftUI 布局协商机制
容器(HStack/VStack/…)会向子视图提一个问题:"在 (宽度, 高度) 范围内,你想多大?" 子视图返回一个尺寸 + 对齐方式。SwiftUI 的布局是双向协商:
- 容器给子视图一个"建议尺寸"(proposal);
- 子视图根据自己的内容返回"实际尺寸";
- 容器根据子视图的返回值和
spacing/alignment摆放它们。
理解这一点,就能解释:
Image默认贪婪 → 总是用原始尺寸;Text在宽度受限时会自动换行;Color/Spacer/Rectangle占满剩余空间;fixedSize()让视图"忽略父级提案,用理想尺寸"。
2.2 主要容器
| 容器 | 用途 | 关键参数 |
|---|---|---|
HStack / VStack / ZStack | 线性/层叠 | alignment、spacing |
LazyVStack / LazyHStack | 懒加载(配合 ScrollView) | 同上 |
List | 列表(自带 Cell、分隔线、编辑模式) | style、editActions |
ScrollView | 可滚动容器 | axes、showsIndicators |
Grid / LazyVGrid / LazyHGrid | 网格 | columns、spacing |
ViewThatFits | 自适应选择最合适子视图 | axes |
GroupBox / ControlGroup | 分组容器 | — |
2.3 frame 与尺寸约束
.frame(width: 200, height: 80) // 固定
.frame(maxWidth: .infinity) // 占满宽度
.frame(maxWidth: .infinity, alignment: .leading) // 占满 + 左对齐
.frame(minWidth: 100, idealWidth: 200, maxWidth: 300) // 弹性
.fixedSize(horizontal: false, vertical: true) // 在某个轴上忽略父约束
.ignoresSafeArea(edges: .bottom) // 忽略安全区2.4 GeometryReader:能用、但慎用
GeometryReader { geo in
Text("\(geo.size.width)")
.frame(width: geo.size.width)
}特点:
- 贪婪占满父容器提供的全部空间(默认
.topLeading对齐); - 不会反向给父容器报告自己的尺寸 → 因此它不能"自适应内容";
- 性能开销中等,但滥用会让布局退化为"手动算坐标",失去 SwiftUI 的优势。
替代方案:
- 用
background(GeometryReader { … })或.onGeometryChange(iOS 18+)"偷偷测量"而不影响主布局; - 用
Layout协议(iOS 16+)自定义容器; - 用
PreferenceKey上报尺寸(旧方案)。
2.5 iOS 18+: onGeometryChange
Text("Measure me")
.onGeometryChange(for: CGSize.self) { proxy in
proxy.size
} action: { newSize in
self.measuredSize = newSize
}比 GeometryReader + PreferenceKey 更轻量,也不会触发自身重新布局。
2.6 自定义 Layout 协议(iOS 16+)
struct FlowLayout: Layout {
var spacing: CGFloat = 8
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let maxWidth = proposal.width ?? .infinity
var totalWidth: CGFloat = 0
var totalHeight: CGFloat = 0
var lineWidth: CGFloat = 0
var lineHeight: CGFloat = 0
for view in subviews {
let size = view.sizeThatFits(.unspecified)
if lineWidth + size.width > maxWidth {
totalWidth = max(totalWidth, lineWidth)
totalHeight += lineHeight + spacing
lineWidth = size.width
lineHeight = size.height
} else {
lineWidth += size.width + spacing
lineHeight = max(lineHeight, size.height)
}
}
totalWidth = max(totalWidth, lineWidth)
totalHeight += lineHeight
return CGSize(width: totalWidth, height: totalHeight)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
var x = bounds.minX
var y = bounds.minY
var lineHeight: CGFloat = 0
for view in subviews {
let size = view.sizeThatFits(.unspecified)
if x + size.width > bounds.maxX {
x = bounds.minX
y += lineHeight + spacing
lineHeight = 0
}
view.place(at: CGPoint(x: x, y: y), anchor: .topLeading, proposal: ProposedViewSize(size))
x += size.width + spacing
lineHeight = max(lineHeight, size.height)
}
}
}
FlowLayout(spacing: 6) {
ForEach(tags, id: \.self) { Text($0).padding(6).background(.blue.opacity(0.2), in: Capsule()) }
}Layout 是真正"参与 SwiftUI 布局协商"的自定义容器,不像 GeometryReader 那样脱离系统。
3. 状态与数据流(重点章节)
这一章决定你能不能写出正确的 SwiftUI。90% 的 bug 来自对状态包装器的误用。
3.1 数据流总览
┌────────────┐ @Binding ┌──────────────┐
│ @State │ ─────────────────▶ │ Child View │
│ (值/拥有) │ ◀───────────────── │ │
└────────────┘ └──────────────┘
│
│ .environment(model)
▼
┌────────────────────┐ @Environment(Model.self)
│ @Observable Model │ ──────────────▶ 任意后代视图
│ (引用/共享) │
└────────────────────┘核心三问(每次选包装器时都问自己):
- 谁拥有这个数据?(owner)
- 数据是值类型还是引用类型?
- 谁有权修改它?
3.2 @State:视图私有、值类型、拥有权归当前视图
struct CounterView: View {
@State private var count = 0 // 真正存储在 SwiftUI 的内部存储里,不在 struct 实例上
var body: some View {
Button("count: \(count)") { count += 1 }
}
}要点:
@State实际上不存储在 View struct 上。View 是值类型且会被反复重建,状态必须存在 SwiftUI 的"长期存储"中,View 只持有StateObject的 key;- 因此
private是惯例,不要把@State传给其他视图作为初始值——父视图重建时子视图会拿不到更新(因为父视图的@State值是上一次快照); - 不要用
@State持有class。引用类型用@StateObject(旧)或@State+@Observable(新)。
3.3 @Binding:引用上层状态
struct VolumeSlider: View {
@Binding var volume: Double // 不是 owner,只是 borrower
var body: some View {
Slider(value: $volume, in: 0...100)
}
}
// 父视图:
@State private var volume: Double = 50
VolumeSlider(volume: $volume)$volume 是 @State 投影出的 Binding<Double>,它是一个双向通道:子视图改了它,父视图的 volume 同步变化。
3.4 @Observable 宏(iOS 17+ Observation 框架)
import Observation
@Observable
final class UserModel {
var name: String = ""
var avatarURL: URL?
var isPremium: Bool = false
@ObservationIgnored // 不参与依赖追踪
var cache: [String: Data] = [:]
}行为:
- 宏在编译期为每个
var注入access(key:)/withMutation(key:)钩子; - SwiftUI 在
body中读取user.name时,自动记录"当前视图依赖name"; - 当
name变化时,只有真正依赖name的视图才会重新求值——比旧的ObservableObject + @Published(整个对象都触发objectWillChange)精确得多。
与旧 ObservableObject 对比
| 维度 | ObservableObject + @Published | @Observable |
|---|---|---|
| 粒度 | 整个对象(@Published 属性粒度但触发对象级 refresh) | 单个属性 |
| 代码 | 需 class XYZ: ObservableObject + @Published | 只需 @Observable class |
| 持有 | @StateObject / @ObservedObject / @EnvironmentObject | 普通属性 / @State / @Environment |
| 绑定 | $model.foo 需 @ObservedObject | $model.foo 需 @Bindable |
| 性能 | 中 | 更好(精确追踪) |
3.5 @StateObject / @ObservedObject(iOS 13+ 旧 API)
class OldModel: ObservableObject {
@Published var count = 0
}
struct Owner: View {
@StateObject var model = OldModel() // 当前视图拥有 + 创建
}
struct Borrower: View {
@ObservedObject var model: OldModel // 外部传入,不拥有
}经典陷阱:在子视图里用 @StateObject 接收外部传入的对象 = 每次重新创建 → 数据丢失。规则:只有"自己创建并拥有"的对象才用 @StateObject,外部传入的用 @ObservedObject。
iOS 17+ 新代码统一用 @Observable + 普通属性 / @State / @Environment,旧 API 仍可用但建议迁移。
3.6 @Environment:环境注入(iOS 17+ 推荐)
@Observable
final class AppModel { var theme: Theme = .light }
@main
struct MyApp: App {
@State private var appModel = AppModel()
var body: some Scene {
WindowGroup {
RootView().environment(appModel) // 注入
}
}
}
struct RootView: View {
@Environment(AppModel.self) private var appModel // 读取
var body: some View { Text(appModel.theme.rawValue) }
}注意:
.environment(appModel)是 iOS 17+ 的新方法,等价于旧的.environmentObject(appModel);- 读取用
@Environment(AppModel.self),类型安全; - 环境值会沿视图树向下传递,任意层级都能取到;
- 测试/预览时方便"注入不同实例"。
3.7 @Bindable:让 @Observable 对象支持 $ 绑定
struct EditProfileView: View {
@Bindable var user: UserModel // 注意是 var,不是 let
var body: some View {
TextField("Name", text: $user.name) // 现在可以用 $ 了
Toggle("Premium", isOn: $user.isPremium)
}
}@Bindable 在 iOS 17+ 取代了旧 @ObservedObject 的 $ 绑定能力,但它不持有、不管理生命周期——它只是给一个 @Observable 对象"加上绑定语法糖"。
3.8 @AppStorage / @SceneStorage:轻量持久化
// UserDefaults 的语法糖
@AppStorage("username") private var username: String = "guest"
@AppStorage("volume") private var volume: Double = 0.5
// 场景级(多窗口隔离,系统恢复时还原)
@SceneStorage("draft.text") private var draft: String = ""适合:小型配置、首选项。不适合:复杂模型、大量数据。复杂场景用 SwiftData / Core Data / 文件。
3.9 EnvironmentKey 自定义
struct ThemeKey: EnvironmentKey {
static let defaultValue: Theme = .light
}
extension EnvironmentValues {
var theme: Theme {
get { self[ThemeKey.self] }
set { self[ThemeKey.self] = newValue }
}
}
// 注入与读取
ContentView().environment(\.theme, .dark)
@Environment(\.theme) var themeiOS 17+ 推荐直接注入对象(@Environment(AppModel.self)),但 EnvironmentKey 仍然适合"值类型配置"(如颜色、字号、特性开关)。
3.10 数据流决策树
1) 是值类型吗?
├─ 是 → 当前视图拥有吗?
│ ├─ 是 → @State
│ └─ 否 → @Binding
└─ 否(class)
2) 整个 App 共享吗?
├─ 是 → @Observable + .environment() + @Environment(T.self)
└─ 否 → 当前视图拥有吗?
├─ 是 → @Observable + @State
└─ 否 → @Observable + @Bindable / 普通属性3.11 常见反模式
- ❌ 在子视图里
@State private var external = external:父视图刷新后值不同步; - ❌ 用
@StateObject var model: Model:丢失外部传入; - ❌ 用
@ObservedObject var model = Model():每次父刷新都会重建; - ❌ 把整个大对象塞进
@Environment,让所有视图都依赖整个对象(应拆分); - ❌ 在
body里创建let model = Model():每次求值都新建实例。
4. 导航与列表
4.1 NavigationStack(iOS 16+)取代 NavigationView
NavigationView 在 iOS 16 被弃用,统一用 NavigationStack(栈式)或 NavigationSplitView(分栏)。
NavigationStack {
List(items) { item in
NavigationLink(value: item) { // 用 value 关联目标
Row(item: item)
}
}
.navigationDestination(for: Item.self) { item in
DetailView(item: item)
}
.navigationDestination(for: String.self) { path in
Text("Path: \(path)")
}
}Value-based Routing
// 路径可以用 @State 完全控制
struct RootView: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
List { /* … */ }
.navigationDestination(for: Item.self) { DetailView(item: $0) }
}
.onChange(of: pushTrigger) { _, _ in
path.append(selectedItem) // 编程式 push
}
}
}NavigationPath 是类型擦除的路径栈,可以混装多种类型;如果类型固定,用 [Item] / [Route]。
4.2 NavigationSplitView(分栏,iPad/Mac/大屏必备)
NavigationSplitView {
Sidebar()
} content: {
MiddleColumn()
} detail: {
DetailColumn()
}
.navigationSplitViewStyle(.balanced) // .automatic / .balanced / .prominentDetailiOS 上自动降级为 NavigationStack;iPad 横屏 / Mac 上呈现三栏。这是写"通用 App"的标准结构。
4.3 TabView
TabView {
HomeView().tabItem { Label("首页", systemImage: "house") }
SearchView().tabItem { Label("搜索", systemImage: "magnifyingglass") }
ProfileView().tabItem { Label("我的", systemImage: "person") }
}
// 选中索引可绑定
@State private var selection = 0
TabView(selection: $selection) { /* … */ }
// 翻页式(引导页、轮播)
TabView { ForEach(0..<3) { PageView(idx: $0) } }
.tabViewStyle(.page)
.indexViewStyle(.page(backgroundDisplayMode: .always))iOS 18+ 新增 TabView 的可定制样式与 .tabViewStyle(.sidebarAdaptable)。
4.4 List 详解
// 普通
List(items) { item in Row(item: item) }
// 分节
List {
Section("设置") {
Toggle("通知", isOn: $notify)
Toggle("深色模式", isOn: $dark)
}
Section("关于") {
LabeledContent("版本", value: "1.0")
}
}
.listStyle(.insetGrouped) // .automatic / .plain / .grouped / .insetGrouped / .sidebar
// 编辑模式(滑动删除、拖拽排序)
List {
ForEach(items) { item in Row(item: item) }
.onMove { source, dest in items.move(fromOffsets: source, toOffset: dest) }
.onDelete { idx in items.remove(atOffsets: idx) }
}
.environment(\.editMode, .constant(.active))性能要点:
List默认是懒加载的,类似UITableView;LazyVStack/LazyHStack也是懒的,但没有复用机制(一次性创建视图结构,只是延迟实例化)。对于超长列表,List仍然更省内存;ForEach必须保证id稳定且唯一。
4.5 sheet / fullScreenCover / popover / confirmationDialog
.sheet(isPresented: $showSheet) {
SheetView().presentationDetents([.medium, .large]) // iOS 16+ 半屏
}
.sheet(item: $selectedItem) { item in // item: Identifiable? → 非 nil 时弹出
DetailView(item: item)
}
.fullScreenCover(isPresented: $showFull) { FullScreenView() }
.popover(isPresented: $showPopover) { PopView() } // iPad 上浮窗,iPhone 退化成 sheet
.confirmationDialog("确认删除?", isPresented: $show) {
Button("删除", role: .destructive) { delete() }
Button("取消", role: .cancel) {}
}
.alert("错误", isPresented: $showError) { } // alert 也用 isPresented4.6 deep link / URL 导航
.navigationDestination(item: $route) { route in
switch route {
case .detail(let id): DetailView(id: id)
case .settings: SettingsView()
}
}
.onOpenURL { url in
// 解析 url → 更新 path 或 route
}5. 表单与输入
5.1 Form
Form {
Section("账户") {
TextField("邮箱", text: $email)
.textInputAutocapitalization(.never)
.keyboardType(.emailAddress)
SecureField("密码", text: $password)
}
Section("偏好") {
Toggle("通知", isOn: $notify)
Picker("主题", selection: $theme) {
ForEach(Theme.allCases) { Text($0.rawValue).tag($0) }
}
Stepper("数量:\(count)", value: $count, in: 0...10)
Slider(value: $volume)
}
}
.formStyle(.grouped) // iOS 16+5.2 焦点管理
enum Field: Hashable { case email, password }
struct LoginView: View {
@FocusState private var focus: Field?
var body: some View {
Form {
TextField("邮箱", text: $email).focused($focus, equals: .email)
SecureField("密码", text: $password).focused($focus, equals: .password)
Button("登录") { focus = .email } // 编程式切焦点
}
.onSubmit { focus = (focus == .email ? .password : nil) }
}
}5.3 键盘
.keyboardType(...)决定键盘类型(.numberPad/.emailAddress/.default);.submitLabel(.next)决定回车键文案;.onSubmit { … }回车触发;- 键盘避让:iOS 14+ 默认启用
Form/List自动避让;自定义视图用.scrollDismissesKeyboard(.interactively)。
6. 动画与过渡
6.1 隐式 vs 显式
// 隐式:modifier 给所有可动画属性加默认动画
Text("\(count)").animation(.easeInOut, value: count)
// 显式:在闭包里改变状态,状态变化会自动用动画过渡
withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) {
expanded.toggle()
}6.2 Animation
.easeInOut(duration: 0.3)
.linear(duration: 0.3)
.spring(response: 0.4, dampingFraction: 0.7, blendDuration: 0.2)
.bouncy(duration: 0.5, extraBounce: 0.2) // iOS 17+ 简化 API
.smooth(duration: 0.4) // iOS 17+
.interpolatingSpring(stiffness: 100, damping: 10)
.timingCurve(0.4, 0.0, 0.2, 1.0, duration: 0.4)
.animation(.easeInOut.speed(2))
.repeatForever(autoreverses: true)6.3 transition:插入/移除时的过渡
if show {
Text("Hello")
.transition(.move(edge: .top).combined(with: .opacity))
// .scale.combined(with: .opacity)
// .push(from: .leading) // iOS 17+
}
Button("toggle") {
withAnimation { show.toggle() } // 必须在 withAnimation 内变化
}.asymmetric(insertion:removal:) 可让出现和消失用不同动画。
6.4 matchedGeometryEffect:共享元素动画
@Namespace private var ns
if !expanded {
Text("Hello").matchedGeometryEffect(id: "title", in: ns)
} else {
Text("Hello").font(.largeTitle).matchedGeometryEffect(id: "title", in: ns)
}
// 切换时框架自动做"形变过渡"经典用途:列表 → 详情的 hero animation、tab 切换。
6.5 PhaseAnimator(iOS 17+)
PhaseAnimator([false, true]) { content, phase in
content.scaleEffect(phase ? 1.2 : 1.0)
} animation: { phase in
phase ? .bouncy : .linear
}无需手动驱动状态,按顺序循环动画。
6.6 KeyframeAnimator(iOS 17+)
KeyframeAnimator(initialValue: CGFloat(0)) { value in
Circle().offset(y: value)
} keyframes: { _ in
KeyframeTrack(\.self) {
CubicKeyframe(0, duration: 0.2)
CubicKeyframe(50, duration: 0.3)
SpringKeyframe(0, duration: 0.5)
}
}适合做"按时间轴精确控制"的复杂动画(如曲线运动、形变)。
6.7 自定义动画(iOS 17+)
struct BounceAnimation: CustomAnimation {
var amplitude: Double = 20
func animate<V>(value: V, time: Double, context: AnimationContext) -> V? where V: VectorArithmetic {
// 自定义曲线函数
let decay = exp(-time * 3)
let offset = sin(time * 10) * amplitude * decay
return value.scaled(by: 1.0 - offset / 100)
}
}7. 手势
7.1 高级手势修饰符
.onTapGesture(count: 2) { /* 双击 */ }
.onTapGesture { /* 单击 */ } // 顺序:先注册更具体的(双击)
.onLongPressGesture(minimumDuration: 1.0) { /* 长按 */ }
.onLongPressGesture(minimumDuration: 1.0, onPressingChanged: { isPressing in })
DragGesture(minimumDistance: 10)
.onChanged { value in offset = value.translation }
.onEnded { value in /* 拖拽结束 */ }
MagnificationGesture() // 捏合缩放
RotationGesture() // 双指旋转7.2 组合手势
let drag = DragGesture()
let tap = TapGesture()
// 串行:先完成 A,再开始 B
let seq = tap.sequenced(before: drag)
// 同时
let both = drag.simultaneously(with: MagnificationGesture())
// 互斥:谁先识别就用谁
let one = tap.exclusively(before: longPress)7.3 与 UIKit 手势协调
嵌入 UIKit 视图(UIScrollView、MKMapView)后,SwiftUI 手势可能与 UIKit 手势冲突。处理方式:
- 在
UIViewRepresentable内用gestureRecognizerShouldBegin/shouldRecognizeSimultaneouslyWith; - 用
.simultaneousGesture(drag)让 SwiftUI 不"独占"手势; - 用
.highPriorityGesture(...)优先某个手势。
8. 绘图与图形
8.1 Path / Shape
Path { path in
path.move(to: CGPoint(x: 0, y: 0))
path.addLine(to: CGPoint(x: 100, y: 50))
path.addCurve(to: CGPoint(x: 200, y: 0), control1: ..., control2: ...)
}
.stroke(.red, lineWidth: 2)
// .fill(.red)
struct Triangle: Shape {
func path(in rect: CGRect) -> Path {
var p = Path()
p.move(to: CGPoint(x: rect.midX, y: rect.minY))
p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
p.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
p.closeSubpath()
return p
}
}
Triangle().fill(.orange)8.2 Canvas(iOS 15+)
Canvas { context, size in
for i in 0..<100 {
let rect = CGRect(x: i * 5, y: 0, width: 3, height: size.height)
context.fill(Path(rect), with: .color(.blue.opacity(Double(i)/100)))
}
}
.frame(width: 500, height: 200)特点:命令式绘制、极高性能、不参与 SwiftUI 视图树(不能给 Canvas 内的元素加 .onTapGesture)。适合图表、特效、粒子。
8.3 渐变与材质
LinearGradient(colors: [.red, .blue], startPoint: .top, endPoint: .bottom)
RadialGradient(colors: [.white, .black], center: .center, startRadius: 0, endRadius: 100)
AngularGradient(colors: [.red, .yellow, .green, .blue, .red], center: .center)
// 毛玻璃材质
.background(.ultraThinMaterial)
.background(.regularMaterial)
.background(.thinMaterial)9. SwiftUI ↔ UIKit 互操作
9.1 UIViewRepresentable(SwiftUI 用 UIKit)
struct MapView: UIViewRepresentable {
@Binding var region: MKCoordinateRegion
func makeUIView(context: Context) -> MKMapView {
let map = MKMapView()
map.delegate = context.coordinator
return map
}
func updateUIView(_ uiView: MKMapView, context: Context) {
uiView.setRegion(region, animated: true)
}
func makeCoordinator() -> Coordinator { Coordinator(self) }
class Coordinator: NSObject, MKMapViewDelegate {
let parent: MapView
init(_ parent: MapView) { self.parent = parent }
func mapView(_ mapView: MKMapView, regionDidChangeAnimated: Bool) {
parent.region = mapView.region
}
}
}模式记忆口诀:make → 创建一次,update → 每次 SwiftUI 状态变化调用,Coordinator → 处理 delegate 回调。
9.2 UIViewControllerRepresentable
struct ImagePicker: UIViewControllerRepresentable {
@Binding var image: UIImage?
@Environment(\.dismiss) var dismiss
func makeUIViewController(context: Context) -> UIImagePickerController {
let p = UIImagePickerController()
p.delegate = context.coordinator
return p
}
func updateUIViewController(_ vc: UIImagePickerController, context: Context) {}
func makeCoordinator() -> Coordinator { Coordinator(self) }
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
let parent: ImagePicker
init(_ parent: ImagePicker) { self.parent = parent }
func imagePickerController(_ p: UIImagePickerController, didFinishPickingMediaWithInfo info: ...) {
parent.image = info[.originalImage] as? UIImage
parent.dismiss()
}
}
}9.3 UIHostingController(UIKit 用 SwiftUI)
let hosting = UIHostingController(rootView: SwiftUIView())
navigationController?.pushViewController(hosting, animated: true)
// UITableViewCell / UICollectionViewCell 里嵌入 SwiftUI(iOS 16+ 推荐)
let registration = UICollectionView.CellRegistration<UICollectionViewCell, Item> { cell, _, item in
cell.contentConfiguration = UIHostingConfiguration {
RowView(item: item)
}
.margins(.horizontal, 16)
.margins(.vertical, 8)
}UIHostingConfiguration 是 Apple 在 iOS 16 引入、用于把 SwiftUI 视图作为 UIKit Cell 内容的官方方式,性能比"包一层 UIHostingController 当 cell"好得多。
10. Preview 与测试
10.1 #Preview 宏(iOS 17+)
#Preview("默认") {
CounterView()
}
#Preview("深色") {
CounterView()
.preferredColorScheme(.dark)
.environment(AppModel(theme: .dark))
}
#Preview("不同尺寸") {
CounterView()
.previewLayout(.sizeThatFits)
}支持多语言、多色盲模式、多尺寸、多 device:
#Preview {
ContentView()
}
.environment(\.locale, .init(identifier: "zh-Hans"))
.previewDevice("iPhone 15 Pro")10.2 单元测试
视图层单元测试在 SwiftUI 里相对难(视图是声明式的、不可直接断言内部)。三种主流做法:
- 抽取纯逻辑:把业务逻辑(reducer、parser、calculator)抽到非 View 类型,用 XCTest 测试;
- ViewInspector:第三方库,可"打开"视图树做断言;
- 快照测试:用
swift-snapshot-testing渲染视图截图比对。
import ViewInspector
import XCTest
final class CounterViewTests: XCTestCase {
func testInitialState() throws {
let sut = CounterView()
let text = try sut.inspect().find(text: "count: 0")
XCTAssertNotNil(text)
}
}10.3 UI 测试(XCUITest)
final class AppUITests: XCTestCase {
func testTapIncrements() {
let app = XCUIApplication()
app.launch()
let button = app.buttons["Increment"]
button.tap()
XCTAssertEqual(button.label, "count: 1")
}
}11. 性能优化与调试
这是面试加分题,也是中大型 SwiftUI 项目的关键。
11.1 Attribute Graph 与 Self._printChanges()
struct SomeView: View {
var body: some View {
let _ = Self._printChanges() // 打印"是谁导致这次重算"
Text("...")
}
}控制台会输出类似:SomeView: @self changed. 或 SomeView: @dependency "user.name" changed.。这是定位"为什么我的视图不停刷新"的最快方法。
11.2 主要性能反模式
- 过度依赖
GeometryReader→ 引发整页重算; AnyView滥用 → 类型擦除、diff 退化;ForEach(id: \.self)配合可变数组 → 频繁重建;- 大对象作为
@EnvironmentObject→ 所有读取者都依赖整对象; body内创建大对象(let formatter = DateFormatter()/let url = URL(...)) → 每次重新求值都重建;if/else切换复杂子树 → 结构变化导致重建;- 嵌套
VStack中大量非 lazy 子视图 → 启动慢; task中重复发起网络请求 → 应在task(id:)中按依赖变化重启。
11.3 优化手段
Equatable视图 +.equatable()modifier:
struct Row: View, Equatable {
let item: Item
static func == (lhs: Row, rhs: Row) -> Bool { lhs.item == rhs.item }
var body: some View { /* ... */ }
}
ForEach(items) { item in
Row(item: item).equatable()
}- 拆分视图:把"很少变"和"经常变"的拆开,让 SwiftUI 精确刷新;
@Observable自带精确追踪,天然优化;- Instruments → SwiftUI template:可直接看每帧的 body 求值次数与耗时;
- 避免在
body里读文件、解析 JSON、做 IO; drawingGroup():把视图树合成成 Metal 渲染(复杂图形、动画场景的优化利器)。
11.4 Accessibility
Text("购买").accessibilityLabel("购买按钮") // 读屏朗读文案
Image(systemName: "star").accessibilityHidden(true) // 装饰性元素对 VoiceOver 隐藏
VStack {
Text("$9.99")
Text("月费")
}.accessibilityElement(children: .combine) // 合并为单个可访问性元素
.accessibilityAddTraits(.isButton)
.accessibilityHint("点击进入订阅页")可访问性不仅是规范要求,对自动化测试(XCUITest 的 label 定位)也至关重要。
12. 项目关键模块(实战代码)
这一章给出生产可用的模块代码骨架,覆盖中大型 App 的常见子系统。
12.1 网络层:APIClient + Repository + DTO
// 1) API 客户端:统一发请求、错误处理、解耦
final class APIClient {
let baseURL: URL
let session: URLSession
let decoder: JSONDecoder
init(baseURL: URL, session: URLSession = .shared) {
self.baseURL = baseURL
self.session = session
self.decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
}
func send<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
let request = try endpoint.urlRequest(baseURL: baseURL)
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}
guard (200..<300).contains(http.statusCode) else {
throw APIError.statusCode(http.statusCode, data)
}
do {
return try decoder.decode(T.self, from: data)
} catch {
throw APIError.decoding(error)
}
}
}
struct Endpoint {
enum Method: String { case GET, POST, PUT, DELETE }
var path: String
var method: Method = .GET
var query: [URLQueryItem] = []
var body: Data?
var headers: [String: String] = [:]
func urlRequest(baseURL: URL) throws -> URLRequest {
var components = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false)
if !query.isEmpty { components?.queryItems = query }
guard let url = components?.url else { throw APIError.invalidURL }
var request = URLRequest(url: url)
request.httpMethod = method.rawValue
request.httpBody = body
for (k, v) in headers { request.setValue(v, forHTTPHeaderField: k) }
return request
}
}
enum APIError: Error, LocalizedError {
case invalidURL, invalidResponse
case statusCode(Int, Data)
case decoding(Error)
}
// 2) Repository:业务侧使用,对外暴露领域模型(不是 DTO)
final class FeedRepository {
let client: APIClient
init(client: APIClient) { self.client = client }
struct FeedDTO: Decodable { let items: [ItemDTO]; let nextCursor: String? }
struct ItemDTO: Decodable { let id: String; let title: String; let imageURL: URL? }
func fetch(cursor: String?, pageSize: Int = 20) async throws -> ([FeedItem], String?) {
let endpoint = Endpoint(
path: "/feed",
query: [
.init(name: "limit", value: String(pageSize)),
.init(name: "cursor", value: cursor)
].filter { $0.value != nil }
)
let dto: FeedDTO = try await client.send(endpoint)
let items = dto.items.map { FeedItem(id: $0.id, title: $0.title, imageURL: $0.imageURL) }
return (items, dto.nextCursor)
}
}
// 3) 领域模型
struct FeedItem: Identifiable, Hashable {
let id: String
let title: String
let imageURL: URL?
}12.2 状态管理:Store + Reducer(单向流)
类似 TCA(The Composable Architecture)但更轻量。优点:状态变化可预测、易测试。
@Observable
final class Store<State: Equatable, Action> {
private(set) var state: State
private let reducer: (inout State, Action) -> Effect<Action>
private var effectTask: Task<Void, Never>?
init(initial: State, reducer: @escaping (inout State, Action) -> Effect<Action>) {
self.state = initial
self.reducer = reducer
}
func send(_ action: Action) {
let effect = reducer(&state, action)
switch effect {
case .none: break
case .run(let work):
effectTask?.cancel()
effectTask = Task {
await work(send)
}
}
}
}
enum Effect<Action> {
case none
case run((@Sendable (Action) -> Void) async -> Void)
}12.3 路由模块:Router + Value-based Routing
// 路由枚举(App 级)
enum Route: Hashable {
case feedDetail(FeedItem)
case profile(String) // userId
case settings
case web(URL)
}
// 各 Tab 内的 Router
@Observable
final class AppRouter {
var homePath = NavigationPath()
var profilePath = NavigationPath()
var selectedTab: Tab = .home
func push(_ route: Route, on tab: Tab? = nil) {
let target = tab ?? selectedTab
switch target {
case .home: homePath.append(route)
case .profile: profilePath.append(route)
}
}
func handle(url: URL) { // deep link
guard let route = Route(url: url) else { return }
push(route)
}
}
extension Route {
init?(url: URL) {
// 解析自定义 scheme / universal link
...
}
}
// View 层
struct RootView: View {
@Environment(AppRouter.self) var router
var body: some View {
TabView(selection: Binding(get: { router.selectedTab },
set: { router.selectedTab = $0 })) {
NavigationStack(path: Binding(get: { router.homePath },
set: { router.homePath = $0 })) {
HomeView()
.navigationDestination(for: Route.self) { route in
RouteView(route: route)
}
}
.tabItem { Label("首页", systemImage: "house") }
.tag(Tab.home)
}
.onOpenURL { router.handle(url: $0) }
}
}12.4 持久化层:SwiftData(iOS 17+)
import SwiftData
@Model
final class Article {
@Attribute(.unique) var id: String
var title: String
var bodyText: String
var savedAt: Date
init(id: String, title: String, bodyText: String, savedAt: Date = .now) {
self.id = id; self.title = title; self.bodyText = bodyText; self.savedAt = savedAt
}
}
// App 注入
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
RootView()
}
.modelContainer(for: Article.self)
}
}
// 任意视图读取
struct SavedView: View {
@Query(sort: \Article.savedAt, order: .reverse) var articles: [Article]
@Environment(\.modelContext) var ctx
var body: some View {
List(articles) { a in
VStack(alignment: .leading) {
Text(a.title).bold()
Text(a.savedAt.formatted()).foregroundStyle(.secondary)
}
.swipeActions {
Button("删除", role: .destructive) { ctx.delete(a) }
}
}
}
}SwiftData 是 Core Data 的 Swift 原生封装,配合 @Query/@Model/@Environment(\.modelContext) 与 SwiftUI 天然集成。旧项目用 Core Data 时,仍可用 NSPersistentContainer + @FetchRequest。
12.5 主题系统:Theme + Environment
struct Theme {
var primary: Color
var background: Color
var secondaryText: Color
var cornerRadius: CGFloat
static let light = Theme(primary: .blue, background: .white,
secondaryText: .gray, cornerRadius: 12)
static let dark = Theme(primary: .orange, background: .black,
secondaryText: .lightGray, cornerRadius: 12)
}
private struct ThemeKey: EnvironmentKey { static let defaultValue = Theme.light }
extension EnvironmentValues {
var theme: Theme {
get { self[ThemeKey.self] } set { self[ThemeKey.self] = newValue }
}
}
extension View {
func theme(_ theme: Theme) -> some View { environment(\.theme, theme) }
}
// 使用
struct Card<Content: View>: View {
@Environment(\.theme) var theme
@ViewBuilder var content: Content
var body: some View {
content
.padding()
.background(theme.background)
.clipShape(RoundedRectangle(cornerRadius: theme.cornerRadius))
}
}12.6 加载状态与错误处理:泛型 Loadable
enum Loadable<T> {
case idle, loading, loaded(T), failed(Error)
var value: T? { if case .loaded(let v) = self { return v }; return nil }
}
struct AsyncContentView<Source: LoadableSource, Content: View>: View where Source.Value: Equatable {
@State var source: Source
@ViewBuilder var content: (Source.Value) -> Content
var body: some View {
switch source.state {
case .idle, .loading:
ProgressView().task { await source.load() }
case .loaded(let value):
content(value)
case .failed(let error):
ErrorView(error: error) { Task { await source.load() } }
}
}
}
protocol LoadableSource {
associatedtype Value
var state: Loadable<Value> { get set }
mutating func load() async
}把"加载/错误/重试"封装成通用容器,UI 层只关心"成功内容"。
12.7 可访问性、本地化、动态字号
Text("Hello")
.font(.headline)
.accessibilityLabel("问候") // VoiceOver 朗读
.dynamicTypeSize(...(.accessibility3)) // 限制最大动态字号(极端情况下不破坏布局)13. 完整项目架构示例
给一个"内容流 App"的完整架构:Feed 列表 → 详情 → 收藏,包含网络、缓存、SwiftData、路由、错误处理。
13.1 整体分层
┌─────────────────────────────────────────────────┐
│ App / Scene │
│ (依赖注入:APIClient, Repository, ModelContainer)│
└─────────────────────────────────────────────────┘
│
┌──────────────┼──────────────┐
▼ ▼ ▼
Router AppState Theme/Env
(导航) (全局状态) (外观)
│ │
▼ ▼
┌─────────────────────────────────────────────────┐
│ Feature Layer │
│ ┌─────────┐ ┌──────────┐ ┌─────────┐ │
│ │ Feed │ │ Detail │ │ Profile │ ... │
│ │ Feature │ │ Feature │ │ Feature │ │
│ └─────────┘ └──────────┘ └─────────┘ │
└─────────────────────────────────────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────┐
│ Domain / Data │
│ Repository · APIClient · SwiftData Models │
└─────────────────────────────────────────────────┘设计原则:
- 单向数据流:View → Action → Store/Reducer → State → View;
- 依赖注入:所有外部依赖(API、Database、Analytics)通过
@Environment注入; - Feature 模块化:每个 Feature 自包含 View / Store / Repository;Feature 之间通过 Router 与 AppState 解耦;
- 可测试性:Repository、Store 都是纯 Swift 类型,可脱离 UI 单测。
13.2 App 入口与依赖注入
@main
struct MyApp: App {
@State private var appState = AppState()
@State private var router = AppRouter()
private let apiClient = APIClient(baseURL: URL(string: "https://api.example.com")!)
private let feedRepo: FeedRepository
init() {
let api = APIClient(baseURL: URL(string: "https://api.example.com")!)
self.apiClient = api
self.feedRepo = FeedRepository(client: api)
}
var body: some Scene {
WindowGroup {
RootView()
.environment(appState)
.environment(router)
.environment(feedRepo)
}
.modelContainer(for: [Article.self])
}
}
@Observable
final class AppState {
var session: Session? // 登录态
var settings: Settings = .init()
}13.3 Feature:Feed 列表
// MARK: - State & Actions
@Observable
final class FeedStore {
enum State: Equatable {
case idle, loading, loaded([FeedItem], nextCursor: String?), failed(String)
}
var state: State = .idle
var savedArticleIDs: Set<String> = []
private let repo: FeedRepository
private var bag: Task<Void, Never>?
init(repo: FeedRepository) { self.repo = repo }
func load() async {
bag?.cancel()
state = .loading
do {
let (items, cursor) = try await repo.fetch(cursor: nil)
state = .loaded(items, nextCursor: cursor)
} catch {
state = .failed(error.localizedDescription)
}
}
func loadMore() async {
guard case .loaded(let items, let cursor?) = state else { return }
do {
let (newItems, nextCursor) = try await repo.fetch(cursor: cursor)
state = .loaded(items + newItems, nextCursor: nextCursor)
} catch {
// 加载更多失败:保持原数据,提示 toast
}
}
}
// MARK: - View
struct FeedView: View {
@Environment(FeedRepository.self) var repo
@Environment(AppRouter.self) var router
@State private var store: FeedStore?
var body: some View {
NavigationStack(path: Binding(
get: { router.homePath },
set: { router.homePath = $0 }
)) {
Group {
if let store {
content(store: store)
}
}
.navigationTitle("动态")
.navigationDestination(for: Route.self) { route in
RouteView(route: route)
}
}
.task {
if store == nil {
let s = FeedStore(repo: repo)
store = s
await s.load()
}
}
}
@ViewBuilder
private func content(store: FeedStore) -> some View {
switch store.state {
case .idle, .loading:
ProgressView()
case .failed(let msg):
ErrorView(message: msg) { Task { await store.load() } }
case .loaded(let items, _):
List(items) { item in
Button { router.push(.feedDetail(item)) } label: {
FeedRow(item: item)
}
.buttonStyle(.plain)
}
.listStyle(.plain)
.refreshable { await store.load() }
}
}
}13.4 Feature:详情 + 收藏
struct DetailView: View {
let item: FeedItem
@Environment(\.modelContext) var ctx
@Query var allArticles: [Article]
private var isSaved: Bool { allArticles.contains { $0.id == item.id } }
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
Text(item.title).font(.title2).bold()
if let url = item.imageURL {
AsyncImage(url: url) { img in img.resizable().aspectRatio(contentMode: .fill) }
placeholder: { Color.gray.opacity(0.2) }
.frame(height: 220)
.clipped()
}
}
.padding()
}
.navigationTitle("详情")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
toggleSaved()
} label: {
Image(systemName: isSaved ? "star.fill" : "star")
}
}
}
}
private func toggleSaved() {
if let exist = allArticles.first(where: { $0.id == item.id }) {
ctx.delete(exist)
} else {
ctx.insert(Article(id: item.id, title: item.title, bodyText: ""))
}
}
}13.5 RouteView(集中分发)
struct RouteView: View {
let route: Route
var body: some View {
switch route {
case .feedDetail(let item): DetailView(item: item)
case .profile(let id): ProfileView(userId: id)
case .settings: SettingsView()
case .web(let url): WebView(url: url)
}
}
}13.6 数据流回顾
用户下拉刷新
│
▼
FeedView.refreshable
│
▼
FeedStore.load() (async)
│
▼
FeedRepository.fetch()
│
▼
APIClient.send(...) → URLSession
│
▼
DTO → FeedItem(领域模型)
│
▼
store.state = .loaded(...) ← @Observable 触发
│
▼
FeedView.body 自动重新求值
│
▼
List 刷新(AG 精确定位到 state 变化)每一步都可以单独替换或测试:
- 换 Mock
APIClient→ 测试 Repository; - 换 Mock
FeedRepository→ 测试 Store; - 用 ViewInspector → 测试 View。
13.7 架构选型对比(用作面试回答模板)
| 架构 | 状态共享 | 状态可预测性 | 学习曲线 | 适用场景 |
|---|---|---|---|---|
| MVVM + Combine | ViewModel @Bindable | 中(双向绑定复杂时易乱) | 低 | 中小项目、迁移自 UIKit |
| MVI / Store + Reducer(本文方案) | Store + Action | 高(单向流) | 中 | 中大型项目 |
| TCA | 全局 Store | 极高(含副作用管理) | 高 | 大型 App、可测试性优先 |
| Architectures | 各 Feature 独立 | 中 | 中 | 模块化团队 |
| Coordinator | 协调器层级 | 中(解决导航) | 中 | 复杂导航、多入口 |
选型要点:团队规模 → 业务复杂度 → 状态可预测性诉求 → 测试覆盖率诉求。SwiftUI 的声明式特性天然适合单向流,MVI/Store 模式与 SwiftUI 的契合度最高。
附录 A:高频面试题精选(含要点回答)
Q1. @State / @StateObject / @ObservedObject / @EnvironmentObject / @Observable / @Bindable 的区别?
@State:值类型本地状态,当前视图拥有,存在 SwiftUI 内部存储;@StateObject:引用类型 + 拥有 + 创建(旧);@ObservedObject:引用类型 + 外部传入,不管理生命周期(旧);@EnvironmentObject:跨视图树共享的引用类型(旧);@Observable(iOS 17+):宏,给 class 自动加属性级依赖追踪;@Bindable(iOS 17+):让@Observable对象支持$绑定语法,不持有不创建。
Q2. SwiftUI 的 body 是怎么被触发的?
- 任何被 SwiftUI 在
body内读取的@State/@Observable属性会注册依赖; - 属性变化时,Attribute Graph 沿依赖路径只重算受影响节点;
Self._printChanges()可以打印"是谁变了"。
Q3. 为什么 modifier 顺序会影响效果?
每个 modifier 实际上是"包装原视图的新类型"。.padding().background(.red) 等价于"先 padding 再给 padding 后的视图铺红底";.background(.red).padding() 则是"先铺红底,再在外部加 padding"——视觉边界不同。
Q4. some View 与 AnyView 区别?
some View是不透明返回类型,编译期已知具体类型,性能最优;AnyView类型擦除,运行时动态分发,破坏 diff 性能;- 仅在需要"动态切换不同具体类型的视图"时使用
AnyView,否则用Group/@ViewBuilder解决。
Q5. if/else 在 body 里和 .opacity() 切换有什么区别?
if/else 改变视图树结构(结构身份),SwiftUI 会销毁旧分支、创建新分支;.opacity() 切换不改变结构,只是绘制属性变化。频繁切换的"内容显隐"用 opacity/transaction 更省;条件性"完全不同的内容"用 if/else。
Q6. 如何优化 SwiftUI 列表性能?
List而不是LazyVStack(List 有视图复用,Lazy 系列没有);ForEach必须有稳定id;- 行视图加
.equatable(); - 用
@Observable替代ObservableObject精确追踪; - 避免在行视图内做 IO、解析;
- 用 Instruments → SwiftUI template 定位热点。
Q7. NavigationStack 与 NavigationView 区别?
NavigationView(弃用):API 模糊、iPad 行为不一致;NavigationStack:明确栈语义、支持path编程式控制、支持navigationDestination(for:)类型路由;NavigationSplitView:用于 iPad/Mac 多栏布局。
Q8. @AppStorage、@SceneStorage、UserDefaults、SwiftData 各适用什么?
@AppStorage:UserDefaults 语法糖,少量配置;@SceneStorage:场景级临时数据(每个窗口隔离),系统恢复时还原;- UserDefaults:直接访问、跨进程;
- SwiftData / Core Data:结构化、关联关系、大量数据。
Q9. SwiftUI 中如何做 hero animation?
@Namespace var ns
// 列表
Thumbnail().matchedGeometryEffect(id: item.id, in: ns)
// 详情(push 后)
Hero().matchedGeometryEffect(id: item.id, in: ns)需在 withAnimation 触发的状态变化下生效(push 通常需要在自定义 transition 中协调,或用 matchedGeometryEffect 配合 sheet/overlay)。
Q10. 如何处理 SwiftUI 与 UIKit 手势冲突?
.simultaneousGesture(...):让两个手势并行识别;.highPriorityGesture(...):优先某个;.gesture(..., including: .all):限定手势类别;- 在
UIViewRepresentable的Coordinator内实现UIGestureRecognizerDelegate。
附录 B:iOS 版本 API 速查
| iOS 版本 | 关键新增 |
|---|---|
| 13 | SwiftUI 首发,基础视图、NavigationView、@State/@Binding/@ObservedObject |
| 14 | App/Scene、@StateObject、@AppStorage、LazyVStack/LazyHStack、TextEditor、Widget |
| 15 | AsyncImage、Canvas、.refreshable、AttributedString、Material、.task |
| 16 | NavigationStack、NavigationSplitView、Layout 协议、Grid、ViewThatFits、AnyLayout、UIHostingConfiguration |
| 17 | @Observable 宏、#Preview、@Bindable、ScrollView 改进、PhaseAnimator/KeyframeAnimator、SwiftData、CustomAnimation、Symbol effects |
| 18 | TabView 重做、onGeometryChange、MeshGradient、改进的 ScrollView、新的 widget 能力 |
附录 C:常用 modifier 速查
// 尺寸与布局
.frame(width:height:alignment:)
.frame(maxWidth:maxHeight:)
.padding(.horizontal, 16)
.offset(x:y:)
.position(x:y:)
.fixedSize()
.ignoresSafeArea()
.layoutPriority(1)
// 文字
.font(.title)
.foregroundStyle(.red)
.bold().italic().underline()
.lineLimit(2).truncationMode(.tail)
.multilineTextAlignment(.center)
.minimumScaleFactor(0.8)
// 形状
.clipShape(RoundedRectangle(cornerRadius: 12))
.background(.white, in: RoundedRectangle(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).stroke(.gray, lineWidth: 1))
.shadow(color:radius:x:y:)
// 交互
.onTapGesture {}.onLongPressGesture {}
.draggable("text").dropDestination(for: String.self) { }
.hoverEffect(.lift) // iPadOS
// 列表
.listStyle(.insetGrouped)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets())
.refreshable { }
.swipeActions { }
// 导航
.navigationTitle("标题")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .topBarTrailing) { } }
.navigationBarBackButtonHidden()
// 弹窗
.sheet(isPresented:) { }
.fullScreenCover(isPresented:) { }
.confirmationDialog(_,isPresented:) { }
.alert(_,isPresented:) { }
// 状态依赖
.task { }
.task(id: someValue) { }
.onChange(of: initialValue) { oldValue, newValue in }
.onReceive(publisher) { }附录 D:反模式速查(面试加分项)
| 反模式 | 后果 | 正确做法 |
|---|---|---|
@StateObject var model: T 接收外部传入 | 数据被重置 | @ObservedObject 或 @Observable + 普通属性 |
let _ = print(...) 在 body 中做重活 | 每次重算都执行 | 抽出到 onAppear |
大量 AnyView | diff 性能下降 | 用 @ViewBuilder / Group |
ForEach(0..<n, id: \.self) 用于动态数组 | 顺序变化触发重建 | 用业务 id |
在 body 内创建 DateFormatter | 重复分配 | 改为常量 / 静态属性 |
用 GeometryReader 包裹整个布局 | 性能下降、布局退化 | 仅在必要时使用 |
在 body 中调用网络请求 | 每次重算都触发 | 用 .task |
全局 @StateObject 当作"全局变量" | 数据来源不明 | 用 @Environment(AppState.self) |
if/else 切换大量子树 | 结构变化导致重建 | 用 opacity / transaction |
延伸阅读
- Apple SwiftUI 官方文档
- WWDC22: The SwiftUI cookbook for navigation
- WWDC23: Discover Observation in SwiftUI
- WWDC23: Dive into Core Animations & Motion Effects
- SwiftData Documentation
- 100 Days of SwiftUI — 入门到进阶
- Point-Free: The Composable Architecture
- Swift by Sundell — SwiftUI
结语:SwiftUI 的核心是状态驱动 + 单向数据流 + 编译期类型。掌握第 0、3、11 章的"心智模型 + 状态 + 性能",剩下的 API 都是查文档的事。架构选型没有银弹——团队规模、业务复杂度、可测试性诉求决定你该选 MVVM 还是 MVI;最重要的是把"状态变化的来源"写得清晰可追溯。