第25天 复习2
又到了巩固知识的日子。前三个主题我们涵盖了不少内容,若想让这些知识牢记于心,复习环节至关重要。
不过,今天你还需要从头开发一个完整的应用。我知道就在几天前,你已经独立完成过一个完整应用了,但请再坚持一下 —— 反复练习的意义非凡!
有个关于陶艺课的经典故事:一位老师在陶艺课上做了个实验。他让一半学生一整天只做一个陶罐,力求做到最好;而让另一半学生尽可能多地制作陶罐。
一天结束后,老师把所有陶罐摆在一起,让学生们选出最棒的作品。结果或许出人意料 —— 最优秀的陶罐竟来自那些追求 “数量” 而非 “质量” 的学生。
我并非建议你敷衍了事、仓促完成任务,而是希望你思考真正掌握一项技能需要付出什么。如果你只开发过一个应用,就没有尝试新方法的空间,也没有犯错后挽回的余地,更不会去尝试平时不会考虑的思路 —— 最终你只有一个 “陶罐”,而且它未必出色。
但如果有机会开发 “多个” 应用,每个应用设计得都比较简单,无需承受 “做出优秀产品” 的压力,情况就完全不同了:每次开发都是全新的开始,你能反复练习基础操作,还能自由尝试新想法。
毕竟,就算最坏的情况发生,大不了最后舍弃这个应用,因为几天后你还会有新的开发任务。之后还会有更多任务,事实上,在这门课程中你会开发很多应用 —— 这正是课程的核心目的之一!
好了,闲话不多说:你需要复习一些主题,我会针对某些重要知识点展开详细讲解,此外还有一个完整的应用等待你开发。咱们现在就开始吧!
今天你需要完成三个主题的学习,其中一个是挑战任务。
- 你学到的知识
- 重点内容
- 挑战任务
注意:如果当天未能完成挑战任务也无需担心,后续的学习中你会时不时有空闲时间,所以可以在之后再回过头来完成这些挑战。
你学到的知识
到目前为止,你已经完成了两个 SwiftUI 项目的开发,还完成了一个技术实践项目 ——“两个应用 + 一个技术项目” 的节奏会持续到课程结束。这种节奏能帮助你快速提升知识水平,同时有足够时间回顾和巩固所学内容。
虽然我们只学习了三个 SwiftUI 项目,但你已经掌握了一些最重要的概念:视图(views)、修饰符(modifiers)、状态(state)、栈布局(stack layouts)等等。这些技能在 SwiftUI 开发中会反复用到,因此我希望你能尽早熟练掌握。
当然,你还开发了一些实际项目,完成了大量编程挑战来巩固所学,希望你现在对自己掌握的知识有了清晰的认知。
目前我们已经涵盖的内容包括:
- 构建可滚动表单,将文本与
Picker等控件结合使用。SwiftUI 会将其转换为美观的表格布局,选择新选项时会有新界面滑入。 - 创建
NavigationStack并设置标题。它不仅能将新视图推送到屏幕上,还能设置标题,避免内容被时钟遮挡。 - 如何使用
@State存储可变数据,以及为什么需要它。记住,所有 SwiftUI 视图都是结构体(struct),如果没有@State这类属性包装器,视图就无法修改自身数据。 - 为
TextField和Picker等用户界面控件创建双向绑定(two-way bindings),理解使用$变量名如何实现对变量的读写操作。 - 使用
ForEach循环创建视图,从而一次性生成多个视图。 - 使用
VStack、HStack和ZStack构建复杂布局,还能将它们组合起来创建网格布局。 - 如何将颜色和渐变作为视图使用,包括如何为它们设置特定的框架(frame)以控制尺寸。
- 通过提供文本或图像,再搭配一个点击按钮时执行的闭包(closure)来创建按钮。
- 定义弹窗(alert)显示的条件,然后在其他地方切换该条件的状态以触发弹窗。
- SwiftUI 为何广泛使用不透明结果类型(
some View),以及这与修饰符顺序的重要性之间的紧密联系。 - 如何使用三元条件运算符创建条件修饰符,根据程序状态应用不同的修饰效果。
- 通过视图组合(view composition)和自定义视图修饰符将代码拆分为小块,从而在构建复杂程序时避免陷入代码冗余的困境。
我希望你思考一个问题:在 SwiftUI 中,“视图” 究竟意味着什么?在开始这门课程前,你可能认为 Color.red 绝不可能是视图,但实际上它就是。你也已经见过 LinearGradient 作为视图使用的场景,这意味着将它融入布局非常简单。
那 VStack、Group 或 ForEach 呢?它们也是视图吗?
没错!它们在 SwiftUI 中全都是视图,这正是该框架具有极高可组合性的原因 —— 我们可以把 ForEach 嵌套在另一个 ForEach 中,再放入 Group,最后置于 VStack 里,整个结构依然能正常工作。
记住,任何类型只要符合 View 协议,即拥有一个名为 body、返回 some View 的计算属性,就能成为 SwiftUI 中的视图。
之前我们深入学习了 Swift 中的协议(protocols)、协议扩展(protocol extensions)和面向协议编程(protocol-oriented programming),你可能会疑惑这些内容为何如此重要。现在我希望你能明白:View 协议是 SwiftUI 的核心 —— 任何类型只需几行简单代码就能符合该协议,进而参与到布局中。
在其他用户界面框架(包括苹果自家的 UIKit)中,这类工作通常由类(class)完成。这意味着如果已有一些类型,想让它们用于布局,就需要让这些类型继承自 UIView—— 这会附带超过 200 个你可能根本用不到的属性和方法,以及大量幕后使用的其他功能。
而在 SwiftUI 中,这一切都不存在:我们只需让类型符合 View 协议即可。这就是协议和协议扩展的威力,也是面向协议编程如此重要的原因 —— 只要给类型添加一个 body 属性,SwiftUI 就知道如何利用它进行布局和渲染。
如果你足够细心,可能会发现一个有趣的现象:创建任何 SwiftUI 视图时,都需要让它返回 some View—— 我们创建一个视图,它会返回一个或多个其他视图;这些被返回的视图又有自己的 body 属性,进而返回更多视图…… 如此循环下去,似乎没有尽头。
这看起来像是 SwiftUI 陷入了无限循环:如果所有视图都由其他视图构成,那最终的 “根基” 在哪里?
显然,循环必然有终点,否则我们的 SwiftUI 代码根本无法运行。其中的关键在于苹果所说的 “原始视图”(primitive views)—— 它们是 SwiftUI 最基础的构建块,虽然符合 View 协议,但不会渲染其他类型的视图,而是返回一些固定内容。
这类基础构建块有不少,而且并不难理解 —— 例如 Text、Image、Color、Spacer 等等。归根结底,我们构建的所有用户界面都是在这些基础构建块之上创建的,正是它们打破了看似无限的循环。
重点内容
有三个重要知识点值得深入探讨。这部分内容一方面是对所学知识的复习(通过不同示例帮助你加深理解),另一方面我也想借此机会解答一些你可能已经遇到的疑问。
结构体(struct)与类(class)
首先要回顾的是你应该还有印象的内容:结构体和类。它们都是用于创建包含属性和方法的复杂数据类型的方式,但两者的工作原理(更具体地说,是它们的区别)至关重要。
如果你还记得,结构体和类有五个关键区别:
- 类没有默认的成员构造器(memberwise initializer),而结构体默认拥有。
- 类可以通过继承扩展功能,结构体则不能。
- 复制一个类时,两个副本指向同一份数据;而结构体的副本始终是独立的(值类型特性)。
- 类可以有析构器(deinitializer),结构体则没有。
- 可以修改常量类(let 修饰的类实例)中的可变属性(var 修饰的属性);但对于常量结构体(let 修饰的结构体实例),无论其属性是常量还是变量,都无法修改。
在苹果最初的编程语言 Objective-C 中,我们几乎所有场景都使用类 —— 当时没有其他选择,因为类是该语言工作方式的核心。
而在 Swift 中,我们有选择的余地,且选择应基于上述区别。我说 “应基于”,是因为实际开发中,经常有人不关注这些区别,不管场景如何,始终只用 class 或 struct,却没有考虑这种选择可能带来的影响。
选择结构体还是类,取决于你和你要解决的具体问题。但我希望你能考虑:这个选择是否能清晰传达你的意图。唐纳德・克努特(Donald Knuth)曾说:“程序是写给人看的,只是顺便让计算机执行”,这句话恰好点明了我想表达的核心 —— 当别人阅读你的代码时,能清楚理解你的意图吗?
如果你大部分时候使用结构体,只在某个特定场景下改用类,这本身就传递了一种意图:这个类型很特殊,使用方式也不同。但如果你始终使用类,这种区分就会消失 —— 毕竟,绝大多数场景下你其实并不需要类。
提示:SwiftUI 中结构体和类的使用方式与传统框架完全相反,这是一个很有趣的特点。在 UIKit 中,我们用结构体存储数据,用类构建界面;但在 SwiftUI 中,情况完全颠倒。这也提醒我们,即便有些知识看似当下无用,学习它们依然很重要。
与 ForEach 协作
第二个要讨论的是 ForEach,我们之前用过类似这样的代码:
ForEach(0 ..< 100) { number in
Text("第 \(number) 行")
}和 SwiftUI 中的大多数元素一样,ForEach 也是一个视图,但它能在循环中创建其他视图。
现在考虑这样一个字符串数组:
let agents = ["西里尔", "拉娜", "帕姆", "斯特林"]如何循环遍历这个数组并创建文本视图呢?
一种方法是沿用之前的写法:
VStack {
ForEach(0..<agents.count) {
Text(agents[$0])
}
}但 SwiftUI 还提供了第二种方案:直接遍历数组。这种方式需要多思考一步,因为 SwiftUI 需要知道如何唯一标识数组中的每个元素。
试想一下:如果我们遍历一个包含 4 个元素的数组,会创建 4 个视图;但如果 body 重新调用时,数组变成了 5 个元素,SwiftUI 就需要知道哪个视图是新增的,才能将其显示在界面上。SwiftUI 绝不想每次有微小变化时,就丢弃整个布局重新开始,而是希望以最小的代价完成更新 —— 比如保留已有的 4 个视图,只添加第 5 个视图。
这就回到了核心问题:Swift 如何标识数组中的元素?当我们使用 0..<5 或 0..<agents.count 这样的范围时,Swift 能确定每个元素都是唯一的,因为它会使用范围中的数字 —— 循环中每个数字只出现一次,因此必然是唯一的。
但在字符串数组中,这种方式不再适用。不过我们能清楚看到数组中的每个值都是唯一的(比如 ["西里尔", "拉娜", "帕姆", "斯特林"] 中没有重复值)。因此,我们可以告诉 SwiftUI:用字符串本身(如 “西里尔”“拉娜” 等)作为循环中每个视图的唯一标识。
对应的代码如下:
VStack {
ForEach(agents, id: \.self) {
Text($0)
}
}这样一来,我们不再通过遍历整数来读取数组元素,而是直接读取数组中的每个项 —— 就像普通的 for 循环一样。
随着你 SwiftUI 学习的深入,我们还会介绍第三种标识视图的方式:使用 Identifiable 协议,不过这会在后续内容中讲解。
与绑定(binding)协作
当我们使用 Picker 和 TextField 等控件时,会通过 $属性名 的方式,为它们创建与某个 @State 属性的双向绑定。这种方式对于简单属性非常好用,但有时(希望只是偶尔!)你可能需要更复杂的功能:比如根据某些逻辑计算当前值,或者在值被修改时执行额外操作,而不仅仅是存储值。
如果想监听绑定值的变化,你可能会尝试使用 Swift 的 didSet 属性观察器,但结果可能会让你失望。这时,自定义绑定(custom bindings)就能派上用场了:它们的用法和 @State 绑定类似,但我们能完全控制其工作方式。
绑定并非 “魔法”:@State 只是帮我们省去了一些繁琐的模板代码,如果你愿意,完全可以手动创建和管理绑定。再次说明,我展示这些内容并非因为它很常用(实际上它并不常用),而是希望消除你对 “SwiftUI 在幕后施展魔法” 的误解。
SwiftUI 为我们做的所有事情,都可以手动实现。虽然绝大多数情况下,依赖自动解决方案更好,但偶尔 “掀开面纱” 了解背后的原理,能帮助你理解它到底在替你做什么。
首先来看自定义绑定的最简单形式:它只是将值存储到另一个 @State 属性中,并从该属性读取值:
struct ContentView: View {
@State private var selection = 0
var body: some View {
let binding = Binding(
get: { selection }, // 读取值时,返回 selection 的当前值
set: { selection = $0 } // 修改值时,将新值赋给 selection
)
return VStack {
Picker("选择一个数字", selection: binding) {
ForEach(0..<3) {
Text("选项 \($0)")
}
}
.pickerStyle(.segmented) // 设置选择器样式为分段式
}
}
}可以看到,这个绑定本质上只是一个 “中转站”—— 它本身不存储或计算数据,只是在界面控件和被操作的底层状态值之间搭建桥梁。
但请注意,此时选择器的创建方式变成了 selection: binding—— 不再需要美元符号($)。因为这个变量本身就是双向绑定,所以无需再显式请求绑定。
如果需要,我们还可以创建更复杂的绑定,实现比 “传递单一值” 更多的功能。例如,假设我们有一个表单,包含三个开关(toggle):用户是否同意服务条款、是否同意隐私政策、是否同意接收物流邮件。
我们可以用三个布尔类型的 @State 属性来表示这些状态:
@State var agreedToTerms = false // 同意服务条款
@State var agreedToPrivacyPolicy = false // 同意隐私政策
@State var agreedToEmails = false // 同意接收邮件虽然用户可以手动切换每个开关,但我们也可以用一个自定义绑定来实现 “一键全选 / 全不选”。这个绑定的逻辑是:当三个布尔值都为 true 时,绑定值为 true;当绑定值被修改时,同步更新三个布尔值。代码如下:
let agreedToAll = Binding(
get: {
// 读取绑定值:三个条件都满足时返回 true
agreedToTerms && agreedToPrivacyPolicy && agreedToEmails
},
set: {
// 修改绑定值:将新值同步赋给三个属性
agreedToTerms = $0
agreedToPrivacyPolicy = $0
agreedToEmails = $0
}
)这样,我们就能创建四个开关:三个对应单个布尔属性,还有一个 “全选” 开关,用于一次性同意或不同意所有三项:
struct ContentView: View {
@State private var agreedToTerms = false
@State private var agreedToPrivacyPolicy = false
@State private var agreedToEmails = false
var body: some View {
let agreedToAll = Binding<Bool>(
get: {
agreedToTerms && agreedToPrivacyPolicy && agreedToEmails
},
set: {
agreedToTerms = $0
agreedToPrivacyPolicy = $0
agreedToEmails = $0
}
)
return VStack {
Toggle("同意服务条款", isOn: $agreedToTerms)
Toggle("同意隐私政策", isOn: $agreedToPrivacyPolicy)
Toggle("同意接收物流邮件", isOn: $agreedToEmails)
Toggle("全部同意", isOn: agreedToAll) // 无需 $,直接使用自定义绑定
}
}
}再次强调,自定义绑定并非日常开发中的常用功能,但花时间了解其背后的原理非常重要。尽管 SwiftUI 非常智能,但它终究只是一个工具,而非 “魔法”!
挑战任务
你已经掌握了数组、状态、视图、图像、文本等基础知识,现在是时候将它们结合起来了:你的挑战任务是开发一个 “石头剪刀布” 脑力训练游戏,玩家需要根据提示判断该赢还是该输。
游戏规则大致如下:
- 每一轮游戏,应用会随机选择 “石头”“剪刀” 或 “布” 中的一种。
- 每一轮,应用会交替提示玩家 “要赢” 或 “要输”。
- 玩家需要点击正确的选项,以达成 “赢” 或 “输” 的目标。
- 如果选择正确,玩家得 1 分;否则扣 1 分。
- 游戏共 10 轮,结束后显示玩家最终得分。
例如:如果应用选择 “石头” 且提示 “要赢”,玩家需要选择 “布”;如果应用选择 “石头” 且提示 “要输”,玩家则需要选择 “剪刀”。
要完成这个挑战,你需要运用在教程 1 和教程 2 中学到的技能:
- 从 “App” 模板开始,创建一个属性来存储 “石头”“剪刀”“布” 这三个可能的选项。
- 需要创建两个
@State属性:一个存储应用当前的选择,另一个存储玩家当前需要 “赢” 还是 “输”。 - 可以使用
Int.random(in:)来随机选择选项;对于 “是否要赢”,也可以用这个方法,但有个更简单的选择 ——Bool.random()会随机返回true或false。初始值设置好后,每轮游戏用toggle()切换状态即可。 - 创建一个
VStack,显示玩家的得分、应用的选择,以及玩家需要 “赢” 还是 “输” 的提示。可以用if shouldWin(假设 “是否要赢” 的属性名为shouldWin)来返回两种不同的文本提示。 - 核心部分是创建三个按钮,分别对应玩家的选择:“石头”“剪刀”“布”。
- 使用
font()修饰符调整文本大小。如果你用表情符号表示三个选项,表情符号的大小也会随之缩放。提示:可以用.font(.system(size: 200))设置超大号系统字体 —— 虽然字体大小固定,但能确保显示效果清晰醒目!
下面我会提供一些提示,但建议你先尽量独立完成挑战,再查看提示。
提示:
- 先从最简单的逻辑入手:创建三个按钮,每个按钮的逻辑类似 “玩家点击了‘石头’,当前需要‘赢’,而应用选择了‘剪刀’,所以得 1 分”。
- 实现基础逻辑后,尝试简化代码,比如用一个数组存储 “克制关系”。例如,如果选项数组是
["石头", "剪刀", "布"],那么对应的 “制胜选项” 数组可以是["布", "石头", "剪刀"](即 “布” 克 “石头”,“石头” 克 “剪刀”,“剪刀” 克 “布”)。 - 不一定要添加图片,只用文本视图和按钮就足够了。不妨试试用表情符号(比如 “🪨”“✂️”“🖐️”)来表示选项!
这个挑战会很有趣:它融合了 Swift 语法、SwiftUI 界面开发和简单逻辑,而且你完全可以根据自己的喜好,为游戏设计独特的主题风格。