学习原生 iOS 开发,入门 Swift 语言,使用 SwiftUI 控件,掌握类似 React 的声明式语法、响应式数据、组件拆分与导航等核心要素。结合 Swift + SwiftUI + SwiftData,可以开发苹果生态的跨端应用,体验原生 App 的开发流程。
对于拥有 JavaScript / TypeScript 等语言开发经验的开发者,学习 Swift 语法只需完成快速的心智转换即可。以下是高效的原生起步资源:
Unwrap),包含 Optionals(可选型)、Structs(结构体)等核心概念的随堂微练习。对于拥有前端开发经验(React / Vue / TypeScript)的开发者,SwiftUI 的上手较为顺畅。为了在编写原生代码时保持清晰逻辑,可以在头脑中建立以下对应关系:
| Web 前端生态 | SwiftUI 原生生态 | 说明 |
|---|---|---|
| HTML / JSX | 声明式 DSL | 均为声明式,SwiftUI 使用尾随闭包语法来嵌套视图。 |
| ReactDOM.render() | @main / WindowGroup |
App 入口与生命周期托管,负责初始化根路由挂载。 |
| CSS / Tailwind CSS | 链式修饰符 (Modifiers) | 链式修饰符(如 .padding().background())顺序敏感,每一个都会返回一个包裹了原始视图的变体视图。 |
useState() |
@State |
组件内部状态。一旦被修改,SwiftUI 会重新绘制当前组件。 |
value + onChange 绑定 |
$ 双向绑定指针 |
使用 $ 前缀(如 $username)直接向 TextField 等输入组件传入引用,免去手动书写回调。 |
useMemo() / 计算属性 |
计算属性 (Computed Property) | Swift 结构体计算属性在依赖的 @State 改变后自动求值。 |
useEffect(..., []) |
.onAppear / .onDisappear |
原生页面/组件生命周期修饰符,负责组件的挂载与卸载副作用处理。 |
| 组件 Props / Children | 属性 / @ViewBuilder |
传递属性作为入参;若需包裹子视图,使用 @ViewBuilder 语法糖注入闭包。 |
在 Web CSS 中,line-height 通常是字号的倍数(如 1.5)或具体像素(如 24px)。而在 SwiftUI 中,.lineSpacing(spacing) 传入的是行与行之间的绝对像素差值。
这里有一个明确的换算公式:
$$\text{SwiftUI lineSpacing} = \text{CSS lineHeight} - \text{fontSize}$$
例如,若 font-size 是 14px,在 CSS 中期望的 line-height 是 20px,则在 SwiftUI 中应设置 .lineSpacing(6)。
在编写 UI 前,需要掌握 Swift 的重要语言特性:Optionals。在 Web/JS 开发中,变量未赋值时通常是 undefined 或 null,访问它们可能导致运行时崩溃。
在 Swift 中,可能缺失值的变量必须被显式声明为可选型(Optional):
var name: String? = nil // "?" 表示这是一个可能为 nil 的 String
// print(name!) // 避免使用 "!" 强制解包,若 name 为 nil 会引发运行时错误
if let 绑定:成功解包则进入代码块,适用于局部使用:if let safeName = name {
print("Hello, \(safeName)")
} else {
print("未提供名字")
}
guard let 语句:常用于函数开头,绑定失败则提前退出,避免深度嵌套:guard let safeName = name else {
return // 提前结束函数
}
print("Hello, safeName = \(safeName)") // safeName 此时已安全且可在同级作用域直接使用
?? 空合运算符:类似于 JS 的 ?? 降级兜底:let displayName = name ?? "匿名用户"
通过 Xcode 创建一个 SwiftUI App,左侧项目导航栏会创建以下骨架:
WeSplitApp.swift:应用程序的入口点。拥有 @main 属性,声明整个应用的启动 Scene 和挂载的根视图:import SwiftUI
@main
struct WeSplitApp: App {
var body: some Scene {
WindowGroup {
ContentView() // 挂载应用的第一个根界面
}
}
}
ContentView.swift:主视图文件。编写声明式用户界面、布局容器与状态绑定的核心文件。Assets.xcassets:媒体资产目录。托管应用图标(AppIcon)、系统主题强调色(AccentColor)以及静态图片、色彩 Token。Image(.avatar) 和 Color(.cardBackground),避免硬编码字符串与已废弃的 #colorLiteral 语法。Assets.xcassets 属性面板中为 Color Set 设置 Appearance -> Any, Dark 即可实现夜间模式色彩自动转换。Resizing -> Single Scale 并勾选 Preserve Vector Data,系统会在运行时按设备密度矢量渲染。Preview Content/Preview Assets.xcassets:预览专用资产槽。仅在 Xcode Previews 画布渲染时生效,用于存放 Mock 图片或测试 JSON,不会打包进生产包中。.gitignore 过滤规范保存项目时,建议在根目录添加 .gitignore 文件,过滤掉 Xcode 编译产生的临时缓存与用户个性化数据:
.DS_Store
build/
*.xcodeproj/project.xcworkspace/xcuserdata/
*.xcodeproj/xcuserdata/
DerivedData/
在开启 iOS 项目时,需要在最底层做出一系列决定工程结构与迭代效率的配置决策。
对于拥有 Web 端全栈开发经验的团队,一个核心的技术决策是:需要支持到哪个 iOS 最低版本?
N,以及前两个大版本 N-1 和 N-2)。例如在 2026 年,最新版本为 iOS 26,应用最低兼容至 iOS 17。@Observable 宏,利用编译期对依赖属性进行细粒度监听,避免整页重绘)、新一代数据持久化框架 SwiftData、全新预览宏 #Preview 以及 NavigationStack 路由等底层技术,同时无需在代码中频繁编写 @available 降级兜底逻辑。17.0;或者在 Targets 列表中选择主 App Target,进入 General 标签页,修改 Minimum Deployments 区域的值。Package.swift 文件中显式声明平台支持进行约束:let package = Package(
name: "SaharaUI",
platforms: [
.iOS(.v17) // 强制约束此 Package 仅能在 iOS 17 及以上版本编译运行
],
...
)
在现代 iOS 开发中,转向使用官方原生的 Swift Package Manager (SPM)。
File -> Add Packages...。https://github.com/airbnb/lottie-spm.git)。4.0.0 到 < 5.0.0),点击 Add Package 即可使用。真机调试是访问陀螺仪、线性马达、真机沙盒等原生物理特性的常用方式。Xcode 提供了无线联调体验:
设置 -> 隐私与安全性 -> 开发者模式,开启后根据提示重启手机)。Signing & Capabilities 中,配置个人 Apple ID 开发者账号完成自动签名(Automatically manage signing)。设置 -> 通用 -> VPN 与设备管理 中信任自己的开发者证书。Window -> Devices and Simulators。Connect via network。Cmd + R,项目即可通过 WiFi 无线部署并自动挂载 LLDB 调试器。Debug -> View Hierarchy -> Capture View Hierarchy),即可在 3D 空间中横向展开当前界面的控件层级结构,直观进行物理层级排查。SwiftUI 中,所有的 UI(文本、图片、容器)都必须遵循 View 协议:
struct ContentView: View {
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
}
.padding() // 链式调用修饰符
}
}
some View 关键字:这是一个不透明返回类型(Opaque Return Type)。它告诉 Swift 编译器:“这个属性会返回某种符合 View 协议的具体强类型结构,但不必在代码中显式写出复杂的嵌套泛型”。VStack { ... } 内部包裹视图,本质上都是闭包函数参数,Swift 允许将函数的最后一个闭包参数移到括号外部。每一个修饰符都会返回一个包裹了原始视图的全新变体视图。顺序不同,效果会有所不同:
// 场景 A:先加 Padding,再涂背景色
Text("A").padding().background(.red) // 留白空间被染红
// 场景 B:先涂背景色,再加 Padding
Text("B").background(.red).padding() // 紧贴文本染色,外围留白透明
Text("Button")
.cornerRadius(12) // 旧写法,且先圆角再加背景会导致圆角被背景直角覆盖
.background(.blue)
先设置背景,再进行裁剪,并使用 .clipShape 修饰符:
Text("Button")
.padding()
.background(Color.blue)
.clipShape(RoundedRectangle(cornerRadius: 12)) // 圆角施加于背景复合视图,正确应用圆角
.clipShape 允许灵活裁剪出各种形状(如圆形 .clipShape(Circle()) 等)。
在 Web 开发中,Flexbox 是常用的弹性容器,配合 CSS 属性(如 justify-content 和 align-items)控制对齐;而定位(position: absolute / fixed)则是脱离文档流布局的方式。
在 SwiftUI 中,这一切通过 Stack(VStack/HStack/ZStack)、Spacer 和 修饰符(Modifiers) 统一:
flex-direction: column。flex-direction: row。| 布局属性 / 行为 | Web CSS (Flexbox / Absolute) | SwiftUI (Stacks / ZStack) |
|---|---|---|
| 容器声明 | display: flex; |
VStack { ... } 或 HStack { ... } |
| 主轴方向 | flex-direction: row / column; |
选用不同的 Stack 组件(HStack / VStack) |
| 沿主轴填充拉伸 | flex-grow: 1; 或 width: 100%; |
使用 Spacer()。Spacer 会填满 Stack 主轴的剩余空间 |
| 交叉轴对齐方式 | align-items: center / flex-start; |
实例化 Stacks 时指定参数:VStack(alignment: .leading) |
| 悬浮/多层重叠 | position: absolute; z-index: 10; |
采用 ZStack { ... } 容器,或直接使用修饰符 .overlay(...) |
例如,当 FloatingActionButton (FAB) 需要悬浮在主页面的右下角时:
.fab {
position: fixed;
bottom: 16px;
right: 16px;
z-index: 50;
}
ZStack 搭配内部的 Spacer() 控制方向,或者直接使用 .overlay(alignment: .bottomTrailing)。ZStack {
ScrollView { ... } // 底层主内容
VStack {
Spacer() // 顶上用 Spacer 压实
HStack {
Spacer() // 左边用 Spacer 压实
FloatingActionButton()
.padding(.trailing, 16)
.padding(.bottom, 16)
}
}
}
说明:SwiftUI 没有“脱离文档流”的概念。所有视图均遵循声明式的排版,悬浮本质上是通过在 ZStack 中使用 Spacer 将元素推至屏幕角落。@State@State 是用于托管组件内部私有状态的属性包装器。一旦变量被修改,SwiftUI 会重新计算当前组件的 body 属性进行视图重绘。
struct CounterView: View {
@State private var tapCount = 0 // 声明为 private,保证状态内聚
var body: some View {
Button("点击次数: \(tapCount)") {
tapCount += 1 // 触发 body 重新计算
}
}
}
$ 符号当需要将状态同步给系统表单输入组件(如 TextField)时,引入 $ 前缀,获取该状态的双向绑定指针(Binding Pointer)。这是 SwiftUI 中声明式双向映射的机制。
@State private var name = ""
var body: some View {
Form {
// TextField 接受双向绑定指针
TextField("Enter your name", text: $name)
// 仅仅用于只读显示时,直接读取变量,不需要 $ 符号
Text("Your name is \(name)")
}
}
$name 编译机制:projectedValue (投影属性)在 React 开发中,input 属于受控组件,通常需要手动声明 value 并监听 onChange:
// React 做法
<input value={name} onChange={(e) => setName(e.target.value)} />
在 Swift 中,编译器处理了这一过程:
@State private var name 声明了组件的状态,类型是 String。$name 调用了该状态的 projectedValue(投影属性),类型是 Binding<String>(即指向该状态的强类型指针)。TextField 接收到这个 Binding 指针后,当用户在屏幕上输入文字时,可以直接修改父组件的状态,并触发 UI 更新。在 SwiftUI 中,无需像 React 那样显式调用 useMemo 或处理副作用。只要参与计算的 @State 属性发生改变,Swift 的计算属性就会自动被重新求值并回显:
@State private var checkAmount = 100.0
@State private var peopleCount = 2
// 当 checkAmount 或 peopleCount 改变时自动求值
var amountPerPerson: Double {
return checkAmount / Double(peopleCount)
}
在渲染选项卡或分类列表时,使用遵循 CaseIterable 和 Identifiable 协议的 enum 替代硬编码字符串数组,可以在编译期保障类型安全:
enum TaskCategory: String, CaseIterable, Identifiable {
case all = "全部"
case dev = "开发"
case leetcode = "LeetCode"
case other = "其他"
var id: String { self.rawValue }
}
// 在 ForEach 中迭代,编译器自动保障零拼写错误风险
ForEach(TaskCategory.allCases) { category in
Text(category.rawValue)
}
@Binding 与回调闭包 (Callback)当需要将子组件的交互同步到父组件时,在 SwiftUI 中有 @Binding 和回调闭包两种方式:
@Binding(状态双向绑定指针):// 子组件:声明双向实参引用,自身保持“无状态”
struct CustomToggleView: View {
@Binding var isOn: Bool
var body: some View {
Button(action: { isOn.toggle() }) {
Image(systemName: isOn ? "checkmark.square" : "square")
}
}
}
// 子组件:通过回调抛出事件,维持组件职责单一
struct TaskRowView: View {
let task: TaskItem
let onToggleComplete: () -> Void // 状态提升回调
var body: some View {
Button(action: onToggleComplete) {
Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
}
}
}
.onChange若要在状态发生改变时执行特定的业务逻辑,可以使用 .onChange 修饰符:
Toggle("开启每日提醒", isOn: $isReminderEnabled)
.onChange(of: isReminderEnabled) { oldValue, newValue in
if newValue {
notificationManager.scheduleDailyReminder(at: reminderTime)
} else {
notificationManager.cancelAllNotifications()
}
}
在开发输入框时,通过 @FocusState 与 .focused(),可以实现焦点控制:
struct InputFocusedView: View {
@State private var amountText = ""
@FocusState private var isAmountFocused: Bool // 控制键盘焦点的属性
var body: some View {
NavigationStack {
TextField("输入数字", text: $amountText)
.keyboardType(.decimalPad)
.focused($isAmountFocused) // 绑定焦点
.toolbar {
ToolbarItem(placement: .keyboard) {
Button("Done") {
isAmountFocused = false // 主动失去焦点,收起键盘
}
}
}
}
}
}
由于 SwiftUI 的 body 遵循 @ViewBuilder 结果构造器,可以直接在视图树内使用 if-else 条件分支。
if-else 与结构 Diff_ConditionalContent 强类型:if-else 条件分支转换为强类型容器:_ConditionalContent<A, B>。这意味着无论当前显示哪个分支,SwiftUI 都在内存中为另一个分支保留了结构占位。当状态切换时,系统能计算出节点差异并触发过渡动画,避免视图切换顿挫。结合物理弹簧动画,可以让条件渲染的分支在挂载/卸载时平滑滑动:
if viewModel.filteredTasks.isEmpty {
EmptyStateView()
// 声明组件挂载/卸载时的物理过渡(淡入结合 95% 缩放)
.transition(.opacity.combined(with: .scale(scale: 0.95)))
} else {
List { ... }
}
通过在触发状态修改的代码上包裹 withAnimation(.spring(response: 0.35, dampingFraction: 0.75)),SwiftUI 会自动渲染出过渡动画。
在 React 中,使用空依赖数组的 useEffect 来处理组件的 Mount 和 Unmount。在 SwiftUI 中,这对应着原生的生命周期修饰符:.onAppear 和 .onDisappear。
struct LifecycleDemoView: View {
@State private var systemStatus = "初始化..."
@State private var timer: Timer? = nil // 用于定时器的私有变量
var body: some View {
Text("状态: \(systemStatus)")
// 对应组件挂载阶段
.onAppear {
systemStatus = "已连接到本地数据底座"
print("页面挂载成功,触发初始刷新并启动轮询...")
// 初始化并启动定时器
timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { _ in
print("定时同步本地 SwiftData 与 SQLite 磁盘缓存...")
}
}
// 对应组件卸载与清理阶段
.onDisappear {
print("页面已从视图树中卸载,释放后台资源...")
// 注销定时器并置空,防止内存泄漏
timer?.invalidate()
timer = nil
}
}
}
这两者是在组件挂载时进行数据加载、同步状态,以及在离开页面时释放硬件资源的入口。
将复杂的视图拆分为独立的组件,支持在 Preview 中单独渲染和调试:
struct SaharaButton: View {
let title: String
var action: () -> Void
var body: some View {
Button(action: action) {
Text(title)
.font(.headline)
.foregroundColor(.white)
.padding()
.frame(maxWidth: .infinity) // 撑满父容器宽度
.background(Color.blue)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
}
// 预览专用 Mock 渲染
#Preview("Sahara 按钮预览") {
SaharaButton(title: "预览专用按钮") {}
.padding()
}
当需要在多处复用相同的修饰符组合(如卡片边框)时,通过 ViewModifier 协议和 extension View 可以实现语义化调用:
// 1. 定义修饰符结构体
struct PrimaryCardStyle: ViewModifier {
func body(content: Content) -> some View {
content
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.accentColor.opacity(0.2), lineWidth: 1)
)
}
}
// 2. 使用扩展简化调用方式
extension View {
func primaryCardStyle() -> some View {
modifier(PrimaryCardStyle())
}
}
// 3. 业务层应用
Text("示例卡片")
.primaryCardStyle() // 语义化调用
@ViewBuilder 自定义容器组件在编写可包裹任意子视图的卡片或插槽组件时,可以通过 @ViewBuilder 构造器接收闭包:
struct SaharaCard<Content: View>: View {
let content: () -> Content
init(@ViewBuilder content: @escaping () -> Content) {
self.content = content
}
var body: some View {
VStack {
content()
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 16))
}
}
// 使用自定义插槽卡片容器
SaharaCard {
Text("卡片标题").font(.headline)
Text("卡片详细描述信息")
}
在 iOS 开发中,页面间的导航(Navigation)是核心机制。SwiftUI 的导航以 Push/Pop 堆栈 为核心,遵循 Apple 人机交互指南(HIG)的层级结构。
在 iOS 16+ 中,使用 NavigationStack 容器进行页面栈管理:
NavigationStack {
List(tasks) { task in
// 1. 声明导航触发源与关联数据值 (强类型绑定)
NavigationLink(value: task) {
TaskRowView(task: task)
}
}
// 2. 声明强类型对应目标页 (编译期类型安全检查)
.navigationDestination(for: TaskItem.self) { task in
TaskDetailView(task: task)
}
.navigationTitle("控制台") // 导航大标题
.navigationBarTitleDisplayMode(.inline) // 导航标题模式(大标题 .large / 小标题 .inline)
.toolbarBackground(Color.Sahara.surfaceContainerLow, for: .navigationBar) // 自定义导航背景色
.toolbarColorScheme(.light, for: .navigationBar) // 状态栏与导航栏图标风格
}
Web 路由对比:
在 Web 开发中,路由定义与导航触发是分离的,依赖 URL 匹配(如 <Link to="/tasks/123">)。如果 URL 拼写错误,会引发 404。而在 SwiftUI 中,.navigationDestination(for:) 依靠强类型绑定,在编译期确保跳转的目标数据类型匹配。
用于应用主界面的平级顶层导航切换,类似于 React Navigation 中的 Tab.Navigator:
TabView(selection: $activeTab) {
DashboardView()
.tabItem {
Label("看板", systemImage: "square.grid.2x2")
}
.tag(Tab.dashboard)
TaskListView()
.tabItem {
Label("任务", systemImage: "checkmark.circle")
}
.tag(Tab.tasks)
}
.tint(Color.Sahara.primary)
在 iOS 架构中,两者的职责如下:
TabView:平级页面切换(仪表盘 / 任务 / 设置)。NavigationStack:层级推入(列表 → 详情),右滑入场,左滑手势返回。NavigationStack,确保各 Tab 的导航堆栈互不干扰:TabView(selection: $activeTab) {
NavigationStack { // Tab 1 独立堆栈
DashboardView()
}
.tabItem { Label("看板", systemImage: "square.grid.2x2") }
.tag(Tab.dashboard)
NavigationStack { // Tab 2 独立堆栈
TaskListView()
}
.tabItem { Label("任务", systemImage: "checkmark.circle") }
.tag(Tab.tasks)
}
当需要在代码中根据逻辑手动触发跳转、或从多层详情页返回根页面时,可以通过绑定 @State 数组(堆栈路径)来实现:
struct ProgrammaticNavigationView: View {
@State private var path: [TaskItem] = [] // 强类型路径堆栈
var body: some View {
NavigationStack(path: $path) {
VStack {
Button("去特定任务详情") {
path.append(someTask) // 手动 Push 压入堆栈
}
Button("一键返回主页") {
path.removeAll() // 清空堆栈,Pop 到根视图
}
}
.navigationDestination(for: TaskItem.self) { task in
TaskDetailView(task: task)
}
}
}
}