Skip to content

第25天 复习2

又到了巩固知识的日子。前三个主题我们涵盖了不少内容,若想让这些知识牢记于心,复习环节至关重要。

不过,今天你还需要从头开发一个完整的应用。我知道就在几天前,你已经独立完成过一个完整应用了,但请再坚持一下 —— 反复练习的意义非凡!

有个关于陶艺课的经典故事:一位老师在陶艺课上做了个实验。他让一半学生一整天只做一个陶罐,力求做到最好;而让另一半学生尽可能多地制作陶罐。

一天结束后,老师把所有陶罐摆在一起,让学生们选出最棒的作品。结果或许出人意料 —— 最优秀的陶罐竟来自那些追求 “数量” 而非 “质量” 的学生。

我并非建议你敷衍了事、仓促完成任务,而是希望你思考真正掌握一项技能需要付出什么。如果你只开发过一个应用,就没有尝试新方法的空间,也没有犯错后挽回的余地,更不会去尝试平时不会考虑的思路 —— 最终你只有一个 “陶罐”,而且它未必出色。

但如果有机会开发 “多个” 应用,每个应用设计得都比较简单,无需承受 “做出优秀产品” 的压力,情况就完全不同了:每次开发都是全新的开始,你能反复练习基础操作,还能自由尝试新想法。

毕竟,就算最坏的情况发生,大不了最后舍弃这个应用,因为几天后你还会有新的开发任务。之后还会有更多任务,事实上,在这门课程中你会开发很多应用 —— 这正是课程的核心目的之一!

好了,闲话不多说:你需要复习一些主题,我会针对某些重要知识点展开详细讲解,此外还有一个完整的应用等待你开发。咱们现在就开始吧!

今天你需要完成三个主题的学习,其中一个是挑战任务。

  • 你学到的知识
  • 重点内容
  • 挑战任务

注意:如果当天未能完成挑战任务也无需担心,后续的学习中你会时不时有空闲时间,所以可以在之后再回过头来完成这些挑战。

你学到的知识

到目前为止,你已经完成了两个 SwiftUI 项目的开发,还完成了一个技术实践项目 ——“两个应用 + 一个技术项目” 的节奏会持续到课程结束。这种节奏能帮助你快速提升知识水平,同时有足够时间回顾和巩固所学内容。

虽然我们只学习了三个 SwiftUI 项目,但你已经掌握了一些最重要的概念:视图(views)、修饰符(modifiers)、状态(state)、栈布局(stack layouts)等等。这些技能在 SwiftUI 开发中会反复用到,因此我希望你能尽早熟练掌握。

当然,你还开发了一些实际项目,完成了大量编程挑战来巩固所学,希望你现在对自己掌握的知识有了清晰的认知。

目前我们已经涵盖的内容包括:

  • 构建可滚动表单,将文本与 Picker 等控件结合使用。SwiftUI 会将其转换为美观的表格布局,选择新选项时会有新界面滑入。
  • 创建 NavigationStack 并设置标题。它不仅能将新视图推送到屏幕上,还能设置标题,避免内容被时钟遮挡。
  • 如何使用 @State 存储可变数据,以及为什么需要它。记住,所有 SwiftUI 视图都是结构体(struct),如果没有 @State 这类属性包装器,视图就无法修改自身数据。
  • TextFieldPicker 等用户界面控件创建双向绑定(two-way bindings),理解使用 $变量名 如何实现对变量的读写操作。
  • 使用 ForEach 循环创建视图,从而一次性生成多个视图。
  • 使用 VStackHStackZStack 构建复杂布局,还能将它们组合起来创建网格布局。
  • 如何将颜色和渐变作为视图使用,包括如何为它们设置特定的框架(frame)以控制尺寸。
  • 通过提供文本或图像,再搭配一个点击按钮时执行的闭包(closure)来创建按钮。
  • 定义弹窗(alert)显示的条件,然后在其他地方切换该条件的状态以触发弹窗。
  • SwiftUI 为何广泛使用不透明结果类型(some View),以及这与修饰符顺序的重要性之间的紧密联系。
  • 如何使用三元条件运算符创建条件修饰符,根据程序状态应用不同的修饰效果。
  • 通过视图组合(view composition)和自定义视图修饰符将代码拆分为小块,从而在构建复杂程序时避免陷入代码冗余的困境。

我希望你思考一个问题:在 SwiftUI 中,“视图” 究竟意味着什么?在开始这门课程前,你可能认为 Color.red 绝不可能是视图,但实际上它就是。你也已经见过 LinearGradient 作为视图使用的场景,这意味着将它融入布局非常简单。

VStackGroupForEach 呢?它们也是视图吗?

没错!它们在 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 协议,但不会渲染其他类型的视图,而是返回一些固定内容。

这类基础构建块有不少,而且并不难理解 —— 例如 TextImageColorSpacer 等等。归根结底,我们构建的所有用户界面都是在这些基础构建块之上创建的,正是它们打破了看似无限的循环。

重点内容

有三个重要知识点值得深入探讨。这部分内容一方面是对所学知识的复习(通过不同示例帮助你加深理解),另一方面我也想借此机会解答一些你可能已经遇到的疑问。

结构体(struct)与类(class)

首先要回顾的是你应该还有印象的内容:结构体和类。它们都是用于创建包含属性和方法的复杂数据类型的方式,但两者的工作原理(更具体地说,是它们的区别)至关重要。

如果你还记得,结构体和类有五个关键区别:

  1. 类没有默认的成员构造器(memberwise initializer),而结构体默认拥有。
  2. 类可以通过继承扩展功能,结构体则不能。
  3. 复制一个类时,两个副本指向同一份数据;而结构体的副本始终是独立的(值类型特性)。
  4. 类可以有析构器(deinitializer),结构体则没有。
  5. 可以修改常量类(let 修饰的类实例)中的可变属性(var 修饰的属性);但对于常量结构体(let 修饰的结构体实例),无论其属性是常量还是变量,都无法修改。

在苹果最初的编程语言 Objective-C 中,我们几乎所有场景都使用类 —— 当时没有其他选择,因为类是该语言工作方式的核心。

而在 Swift 中,我们有选择的余地,且选择应基于上述区别。我说 “应基于”,是因为实际开发中,经常有人不关注这些区别,不管场景如何,始终只用 classstruct,却没有考虑这种选择可能带来的影响。

选择结构体还是类,取决于你和你要解决的具体问题。但我希望你能考虑:这个选择是否能清晰传达你的意图。唐纳德・克努特(Donald Knuth)曾说:“程序是写给人看的,只是顺便让计算机执行”,这句话恰好点明了我想表达的核心 —— 当别人阅读你的代码时,能清楚理解你的意图吗?

如果你大部分时候使用结构体,只在某个特定场景下改用类,这本身就传递了一种意图:这个类型很特殊,使用方式也不同。但如果你始终使用类,这种区分就会消失 —— 毕竟,绝大多数场景下你其实并不需要类。

提示:SwiftUI 中结构体和类的使用方式与传统框架完全相反,这是一个很有趣的特点。在 UIKit 中,我们用结构体存储数据,用类构建界面;但在 SwiftUI 中,情况完全颠倒。这也提醒我们,即便有些知识看似当下无用,学习它们依然很重要。

与 ForEach 协作

第二个要讨论的是 ForEach,我们之前用过类似这样的代码:

swift
ForEach(0 ..< 100) { number in
    Text("第 \(number) 行")
}

和 SwiftUI 中的大多数元素一样,ForEach 也是一个视图,但它能在循环中创建其他视图。

现在考虑这样一个字符串数组:

swift
let agents = ["西里尔", "拉娜", "帕姆", "斯特林"]

如何循环遍历这个数组并创建文本视图呢?

一种方法是沿用之前的写法:

swift
VStack {
    ForEach(0..<agents.count) {
        Text(agents[$0])
    }
}

但 SwiftUI 还提供了第二种方案:直接遍历数组。这种方式需要多思考一步,因为 SwiftUI 需要知道如何唯一标识数组中的每个元素。

试想一下:如果我们遍历一个包含 4 个元素的数组,会创建 4 个视图;但如果 body 重新调用时,数组变成了 5 个元素,SwiftUI 就需要知道哪个视图是新增的,才能将其显示在界面上。SwiftUI 绝不想每次有微小变化时,就丢弃整个布局重新开始,而是希望以最小的代价完成更新 —— 比如保留已有的 4 个视图,只添加第 5 个视图。

这就回到了核心问题:Swift 如何标识数组中的元素?当我们使用 0..<50..<agents.count 这样的范围时,Swift 能确定每个元素都是唯一的,因为它会使用范围中的数字 —— 循环中每个数字只出现一次,因此必然是唯一的。

但在字符串数组中,这种方式不再适用。不过我们能清楚看到数组中的每个值都是唯一的(比如 ["西里尔", "拉娜", "帕姆", "斯特林"] 中没有重复值)。因此,我们可以告诉 SwiftUI:用字符串本身(如 “西里尔”“拉娜” 等)作为循环中每个视图的唯一标识。

对应的代码如下:

swift
VStack {
    ForEach(agents, id: \.self) {
        Text($0)
    }
}

这样一来,我们不再通过遍历整数来读取数组元素,而是直接读取数组中的每个项 —— 就像普通的 for 循环一样。

随着你 SwiftUI 学习的深入,我们还会介绍第三种标识视图的方式:使用 Identifiable 协议,不过这会在后续内容中讲解。

与绑定(binding)协作

当我们使用 PickerTextField 等控件时,会通过 $属性名 的方式,为它们创建与某个 @State 属性的双向绑定。这种方式对于简单属性非常好用,但有时(希望只是偶尔!)你可能需要更复杂的功能:比如根据某些逻辑计算当前值,或者在值被修改时执行额外操作,而不仅仅是存储值。

如果想监听绑定值的变化,你可能会尝试使用 Swift 的 didSet 属性观察器,但结果可能会让你失望。这时,自定义绑定(custom bindings)就能派上用场了:它们的用法和 @State 绑定类似,但我们能完全控制其工作方式。

绑定并非 “魔法”:@State 只是帮我们省去了一些繁琐的模板代码,如果你愿意,完全可以手动创建和管理绑定。再次说明,我展示这些内容并非因为它很常用(实际上它并不常用),而是希望消除你对 “SwiftUI 在幕后施展魔法” 的误解。

SwiftUI 为我们做的所有事情,都可以手动实现。虽然绝大多数情况下,依赖自动解决方案更好,但偶尔 “掀开面纱” 了解背后的原理,能帮助你理解它到底在替你做什么。

首先来看自定义绑定的最简单形式:它只是将值存储到另一个 @State 属性中,并从该属性读取值:

swift
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 属性来表示这些状态:

swift
@State var agreedToTerms = false  // 同意服务条款
@State var agreedToPrivacyPolicy = false  // 同意隐私政策
@State var agreedToEmails = false  // 同意接收邮件

虽然用户可以手动切换每个开关,但我们也可以用一个自定义绑定来实现 “一键全选 / 全不选”。这个绑定的逻辑是:当三个布尔值都为 true 时,绑定值为 true;当绑定值被修改时,同步更新三个布尔值。代码如下:

swift
let agreedToAll = Binding(
    get: {
        // 读取绑定值:三个条件都满足时返回 true
        agreedToTerms && agreedToPrivacyPolicy && agreedToEmails
    },
    set: {
        // 修改绑定值:将新值同步赋给三个属性
        agreedToTerms = $0
        agreedToPrivacyPolicy = $0
        agreedToEmails = $0
    }
)

这样,我们就能创建四个开关:三个对应单个布尔属性,还有一个 “全选” 开关,用于一次性同意或不同意所有三项:

swift
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 中学到的技能:

  1. 从 “App” 模板开始,创建一个属性来存储 “石头”“剪刀”“布” 这三个可能的选项。
  2. 需要创建两个 @State 属性:一个存储应用当前的选择,另一个存储玩家当前需要 “赢” 还是 “输”。
  3. 可以使用 Int.random(in:) 来随机选择选项;对于 “是否要赢”,也可以用这个方法,但有个更简单的选择 ——Bool.random() 会随机返回 truefalse。初始值设置好后,每轮游戏用 toggle() 切换状态即可。
  4. 创建一个 VStack,显示玩家的得分、应用的选择,以及玩家需要 “赢” 还是 “输” 的提示。可以用 if shouldWin(假设 “是否要赢” 的属性名为 shouldWin)来返回两种不同的文本提示。
  5. 核心部分是创建三个按钮,分别对应玩家的选择:“石头”“剪刀”“布”。
  6. 使用 font() 修饰符调整文本大小。如果你用表情符号表示三个选项,表情符号的大小也会随之缩放。提示:可以用 .font(.system(size: 200)) 设置超大号系统字体 —— 虽然字体大小固定,但能确保显示效果清晰醒目!

下面我会提供一些提示,但建议你先尽量独立完成挑战,再查看提示。

提示:

  • 先从最简单的逻辑入手:创建三个按钮,每个按钮的逻辑类似 “玩家点击了‘石头’,当前需要‘赢’,而应用选择了‘剪刀’,所以得 1 分”。
  • 实现基础逻辑后,尝试简化代码,比如用一个数组存储 “克制关系”。例如,如果选项数组是 ["石头", "剪刀", "布"],那么对应的 “制胜选项” 数组可以是 ["布", "石头", "剪刀"](即 “布” 克 “石头”,“石头” 克 “剪刀”,“剪刀” 克 “布”)。
  • 不一定要添加图片,只用文本视图和按钮就足够了。不妨试试用表情符号(比如 “🪨”“✂️”“🖐️”)来表示选项!

这个挑战会很有趣:它融合了 Swift 语法、SwiftUI 界面开发和简单逻辑,而且你完全可以根据自己的喜好,为游戏设计独特的主题风格。

本站使用 VitePress 制作