Skip to content

第35天 复习3

又是一个巩固知识的日子,和往常一样,我们有很多内容要回顾、很多细节要深入探索,还有一个全新的挑战要应对。

正如你所知,这些挑战的设计初衷是让你在不需要我过多帮助的情况下独立完成。当然会有提示,但仅此而已——如何解决问题完全取决于你,这也是一个让你可以按照自己的方式处理问题的机会。

这里的目标不仅仅是让你编写更多代码,尽管代码编写也很重要。真正的目标是让你能够熟练应对全新的 Xcode 项目——当面对新问题时,你能有信心设计解决方案,并且知道如何将方案转化为可运行的代码。

我最喜欢的玛雅·安吉洛的一句名言是:“人生不应该双手握棒球手套度过一生——你需要有能力回馈些什么。”

而今天正是“回馈”的日子。基础项目的基础部分对你对你应该不会太难,但其中有很大的定制和改进空间——希望你能抓住这个机会去探索、尝试,享受其中的乐趣!

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

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

注意: 如果当天当天没有在当天完成挑战任务也不用担心——在之后的日子里,你会发现时不时会有空闲时间,所以挑战可以在之后再回过头来完成这些挑战。

你学到的内容

到目前为止,你应该已经开始对 SwiftUI 的工作方式感到熟悉了。我知道对有些人来说,这可能是一个巨大的思维障碍,因为我们无法再精确控制程序的执行流程,而是需要先构建“完整的状态”,然后再让事情按流程发展。不过,你已经完成了四个完整的项目,还深入学习了两个技术项目,所以希望你已经开始理解 SwiftUI 的运作逻辑。

虽然你现在可以继续深入开发另外两个应用,但在开发过程中,你已经掌握了多项宝贵技能:

  • 如何使用 Stepper 让用户输入数字,包括当标签是简单文本视图时使用其简化形式。
  • 如何使用 DatePicker 让用户选择日期,包括使用 displayedComponents 参数控制显示日期还是时间。
  • 在 Swift 中处理日期,使用 DateDateComponentsDateFormatter
  • 如何引入机器学习,以充分利用现代 iOS 设备的全部性能。
  • 使用 List 构建可滚动的数据表格,尤其是如何直接从数据数组创建表格行。
  • 使用 onAppear() 在视图显示时执行代码。
  • 通过 Bundle 类查找文件路径,从应用程序包中读取文件,包括从中加载字符串。
  • 使用 fatalError() 使代码崩溃,以及为什么这在某些情况下实际上是件好事。
  • 如何使用 UITextChecker 检查字符串的拼写是否正确。
  • 使用 animation() 修饰符隐式创建动画。
  • 通过延迟和重复自定义动画自定义动画,以及在缓入缓出(ease-in-ease-out)动画和弹性(spring)动画之间进行选择。
  • animation() 修饰符附加到绑定(binding)上,以便直接通过 UI 控件为变化添加动画。
  • 使用 withAnimation() 创建创建显式动画。
  • 为单个单个视图视图附加多个 animation() 修饰符,以控制动画栈。
  • 使用 DragGesture() 让用户可以移动视图,然后将视图弹回原始位置。
  • 使用 SwiftUI 内置的转场效果,以及创建自定义转场效果。

没错,仅仅三个项目就包含了这么多新知识,但因为每个主题都是先单独讲解(比如“列表如何工作”),然后在实际项目中应用(比如“现在我们实际使用列表”),所以希望这些知识都已经被你吸收。如果没有,也不用害怕回头复习之前的章节——这些内容会一直存在,而且对你掌握 SwiftUI 大有帮助。

在继续学习之前,我想补充一点重要内容:即使你开始理解 SwiftUI 的工作方式,你仍然会(而且可能经常会)发现,很难准确实现自己想要的效果。

动画就是一个典型例子。对于动画,我们想表达的是“让那个按钮——就是右边那个——现在旋转起来”。但 SwiftUI 的设计理念并不支持这种命令式的思维方式:我们不能直接说“让按钮旋转”。

这不仅仅是我之前提到的思维障碍那么简单。你可能理解 SwiftUI 的工作原理,但仍然不知道如何实现某个效果。对于有编程经验的人来说,这个问题尤为突出,因为他们习惯了另一种思维方式——他们有几个月、几年甚至几十年的“肌肉记忆”,能轻松解决问题,但前提是他们可以精确控制所有事物的行为。

记住,在 SwiftUI 中,所有视图以及所有动画都必须是“状态的函数”。这意味着我们不是“命令按钮旋转”,而是将按钮的旋转角度与某个状态绑定,然后通过修改该状态来实现旋转效果。这种方式常常让人感到沮丧,因为我们知道最终要达到的效果,却不知道该如何实现。

如果在课程学习过程中遇到这种情况,放轻松就好——这很正常,你并不孤单。如果某个问题你研究了一两个小时仍无法解决,不妨先放一放,继续学习下一个项目,一周后再回头看看。那时你会掌握更多知识、有更多实践经验,而且清醒的头脑总是有帮助的。

重点要点

在继续学习之前,我想详细讨论三个内容。同样,这只是为了确保你在继续学习前完全理解某些关键概念,希望能帮助你理解 Swift 和 SwiftUI 在底层的实际工作方式。

ForEach 和 List 中的范围(Range)

正如我多次提到的,当我们在循环中创建视图时,SwiftUI 需要知道如何唯一每个项目进行唯一标识,以便对数据的增减进行动画处理。这本身本身本身并不复杂,但有一种用法经常让人困惑,那就是“范围”的使用。

首先,我们来看一段代码:

swift
ForEach(0..<5) {
    Text("Row \($0)")
}

这段代码从 0 循环到 5(不包含 5),每次次循环输出一段文本。SwiftUI 可以确定每个项目都是唯一的,因为它是对范围进行计数,而范围中没有重复的值。

事实上,如果你查看 ForEach 背后的 SwiftUI 代码,会发现它的定义其实是这样的:

swift
public init(_ data: Range<Int>, @ViewBuilder content: @escaping (Int) -> Content)

视图构建器(即实际组装视图的部分)会从范围中获取一个整数,并返回一段可渲染的视图内容。

现在尝试编写以下代码:

swift
ForEach(0...5) {
   Text("Row \($0)")
}

这段代码从 0 循环到 5(包含 5),理论上会创建 6 个视图。但实际上这段代码无法编译,所以根本创建不了视图。

再仔细看看 ForEach 所需的数据类型:Range<Int>。这是一种整数范围,但它是特定类型的范围——还有另一种非常相似的类型叫做 ClosedRange<Int>,这就是问题所在。

当我们编写 0..<5 时,得到的是 Range<Int> 类型;而当我们编写 0...5 时,得到的是 ClosedRange<Int> 类型。尽管在我们看来这两种范围很相似,但 Swift 认为它们是不同的类型,因此我们不能在 ForEach 中使用闭合范围(ClosedRange)——目前这是不可能的,不过我希望未来这一点会有所改变。

字符串是什么?

在我们看来,字符串似乎是很简单的东西:一个字母接着一个字母,可能还夹杂着一些标点符号。但实际上,字符串是 Swift 中最复杂的特性之一,值得花点时间理解其工作原理。

首先,你可能已经注意到,以下代码是不被允许的:

swift
let name = "Paul"
let firstLetter = name[0]

这段代码试图读取字符串“Paul”的第一个字符。如果让一个人“执行”这段代码,他会说是“P”,这很合理,因为“P”就是第一个字母。

但实际上,字符串比单个字符复杂得多:许多表情符号(emoji)是由多个字符组合而成,以表达特定含义。例如,一个简单的“点赞”表情符号有多种肤色版本,实现方式是先有一个基础表情符号(点赞手势),再加上一个肤色修饰符(从浅色到深色)。这两个字符组合在一起,最终呈现为一个带有特定肤色的点赞表情符号,但在底层其实是两个独立的字符。

如果 Swift 单独处理这些字符,那么读取第一个字符会得到没有肤色的点赞表情符号,读取第二个字符会得到没有手势的肤色修饰符——前者虽然能显示,但不符合发送者的预期,后者则会显得很怪异。

再看看这段代码:

swift
print(name.count)

它会输出测试字符串的字符数量,这看起来也很简单。但正如我们刚才所说,有些独立字符本应组合在一起表达完整含义,这意味着 count 不能简单地返回字符串中字符的总数。相反,它需要从第一个字符开始,逐个统计所有“独立语义字符”(同时考虑所有组合在一起的修饰符),最终得出总数。

这个过程并不快,但能保证结果的准确性。至少你需要明白一点:字符串有时可能会很复杂,但 Swift 正在为我们做大量工作,以避免我们无意中犯错误。这意味着在处理简单字符串时,我们可能需要编写更多代码,但同时也意味着我们的代码能自动支持复杂字符串——包括所有你能想到的表情符号——在未来的使用场景中。

扁平化的应用程序包(Flat App Bundles)

在“单词拼写游戏”(Word Scramble)项目中,我们在应用程序包中查找 start.txt 文件,然后加载该文件供游戏使用。当时我解释过,所有 iOS、macOS、tvOS 和 watchOS 应用都以“包”(bundle)的形式发布,包中包含二进制文件(编译后的 Swift 程序)、Info.plist 文件、资源目录(asset catalog)等内容。

有一点我当时没有提到,那就是这些包的构建方式,特别是资源目录和零散文件(loose files)的处理方式。

首先,资源目录是我们存储应用中使用的图片的地方,它不仅仅是一种整理图片的便捷方式。实际上,当 Xcode 构建资源目录时,会对所有图片进行优化,以适配 iOS 设备,然后将优化后的结果放入编译后的资源目录中,以便高效加载。随着你对资源目录的深入了解,你会发现它还能处理矢量资源、颜色、纹理等更多内容——它的功能非常多样!

其次,零散文件指的是应用中所有其他类型的媒体文件——文本文件、JSON 文件、XML 文件、视频文件等。如果这类文件较多,你可以在 Xcode 中创建分组(group)来整理它们,但在构建项目时,这些分组会被移除:所有文件都会被放入一个名为“资源目录”(resource directory)的单一目录中。这样做的好处是,当我们让应用程序包查找“start.txt”的 URL 时,不需要在包的所有目录中搜索,只需在这一个目录中查找即可,因为所有文件都在这里。

这会带来一个有趣的问题,而且你迟早会遇到:由于 Xcode 项目中所有地方的零散文件最终都会被放入同一个资源目录,所以你不能在项目的任何地方使用相同的文件名。无论文件位于哪个分组,无论它们在 Xcode 项目中看起来相距多远:如果你的项目中有两个名为 start.txt 的文件,构建过程都会失败,因为 Xcode 无法将它们同时放入同一个目录中。

挑战任务

在进入更复杂的项目之前,重要的是你有足够的时间停下来,运用已经掌握的知识。所以今天,你需要完全独立地完成一个新项目,我除了下面的一些提示外,不会提供其他帮助。你准备好了吗?

你的目标是构建一个面向儿童的“教育娱乐”(edutainment)应用,帮助他们练习乘法表——比如“7 乘 8 等于多少?”这类问题。教育娱乐类应用的核心是教育,但最好能带有足够的趣味性,让孩子们愿意“玩”。

具体分解如下:

  • 玩家需要选择想要练习的乘法表范围。可以通过点击按钮选择,也可以通过“最多到…”的步进器(Stepper)选择,范围从 2 到 12。
  • 玩家需要选择想要回答的问题数量:5 道、10 道或 20 道。
  • 根据玩家选择的难度范围和问题数量,随机生成相应数量的题目。

如果你想更偏向“教育”方向,那么应用中会需要一些步进器、一个文本框和几个按钮。建议你从这个基础版本开始,确保核心功能正常工作。

完成基础版本后,你可以根据自己的想法,将应用向“娱乐”方向拓展——你甚至可以完全舍弃 Stepper 这类固定控件,转而使用色彩鲜艳的按钮来实现相同的功能。

这个挑战最好分步骤完成:先让某个功能正常工作,然后再逐步改进。也许你对简单的应用就很满意,也许你想花些时间打造一个有趣的设计——这完全取决于你!

重要提示: 很容易陷入这些挑战中,花几个小时解决某个只因想要实现特定效果而出现的 bug。不要给自己太多压力,否则会 burnout(过度劳累)!相反,先编写最简单的可运行代码,然后再慢慢完善。

如果你有足够的时间,可以使用类似 Kenney 的动物资源包(顺便说一句,这个资源包是公有领域的!)为应用添加有趣的主题,把它打造成一个真正的游戏。也可以添加一些动画——应用需要吸引 9 岁及以下的孩子,所以色彩鲜艳是个不错的选择!

要完成这个挑战,你需要运用到目前为止所有项目中学到的技能,但如果从简单的部分开始,逐步推进,成功的概率会更大。这个应用的核心逻辑并不复杂,所以先确保基础功能正确,然后再根据时间情况进行拓展。

至少,你应该完成以下内容:

  1. 从“应用”(App)模板开始,添加一些状态来判断游戏是否正在进行,或者是否在让用户选择设置。
  2. 根据用户的设置生成一系列题目。
  3. 在游戏结束时,告诉玩家答对了多少道题,然后提供“再玩一次”的选项。

当代码能正常工作后,尝试将布局拆分成新的 SwiftUI 视图,而不是把所有内容都放在 ContentView 中。这需要在视图之间传递数据,目前我们还没有详细讲解过这部分内容,所以现阶段可以使用闭包(closure)传递数据——例如,设置视图中的按钮动作可以调用父视图传入的函数,该函数会根据用户的设置启动游戏。

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

提示:

  • 游戏开始时,就应该生成所有题目,并将它们存储在一个题目数组中。
  • 这些题目最好用单独的 Swift 结构体 Question 来表示,存储题目的文本和答案。
  • 展示题目时,可以使用另一个名为 questionNumber 之类的状态属性(整数类型),该属性指向题目数组中的某个位置,以确定当前显示哪道题。
  • 可以通过屏幕上的按钮(类似计算器的按钮)或数字键盘文本框获取用户输入——选择你喜欢的方式即可。
  • 如果你打算将闭包传入视图的初始化器中,供后续使用,Xcode 会要求你将该闭包标记为 @escaping。这表示“该闭包会在当前方法之外使用”。

这个应用的最简版本并不难构建。先确保核心功能正确——明确你要实现的基本逻辑——然后再考虑如何让它更生动有趣。我知道有趣的部分很吸引人,但归根结底,这个应用需要“有用”,与其试图一次性实现所有功能,结果中途失去兴趣,不如先把核心功能做好。

本站使用 VitePress 制作