Swift-秘籍第二版-全-
Swift 秘籍第二版(全)
原文:
zh.annas-archive.org/md5/9857d57cb8d5d81cfd9d4f486cd6d7d9译者:飞龙
前言
自从苹果在 2014 年 WWDC 上宣布 Swift 编程语言以来,它已经成为增长最快的编程语言之一。Swift 是现代的、开源的、易于使用的,因此它的实用性可以扩展到苹果生态系统之外,使其有可能在所有平台上以及任何场景中使用。
Swift 5.3 是这款令人兴奋的新编程语言的最新版本,为您提供构建性能卓越、响应迅速的应用程序的工具,同时代码安全且整洁。
本书将引导您了解 Swift 的特性,逐步构建您的知识和工具集,以便您可以使用 Swift 构建下一个伟大的应用或服务。
您将获得使用 Swift 完成实际任务的有用、易于遵循的食谱。每个食谱只使用本书中之前覆盖过的概念,因此您永远不会感到迷茫。
了解是什么让 Swift 成为当今增长最快、最激动人心的编程语言之一。
第一章:本书面向对象
如果您正在寻找一本书来帮助您了解 Swift 5 提供的各种特性,以及高效编码和构建应用的技巧和窍门,那么这本书就是为您准备的。了解 Swift 或一般编程概念将有所帮助。
本书涵盖内容
第一章, Swift 构建块,向您介绍 Swift 5 的基本构建块、其语法以及基本 Swift 构造的功能。本章还将向您介绍苹果的 Xcode IDE 和 Swift Playgrounds,这提供了一个理想的方式来创建、执行、调试和理解本书中包含的食谱,从而为您启动开发过程做好准备。在本章中,您将学习如何编写您的第一个 Swift 程序,并理解 Swift 语言的各个基本元素。
第二章, 掌握构建块,教您如何在第一章学习的基础构建块和 Swift 标准库提供的功能的基础上创建更复杂的结构。您将了解如何将变量打包成元组,借助数组对数据进行排序,以及使用字典存储键值对。您还将学习如何使用属性观察器来控制对代码的访问和可见性。然后,您将学习如何使用扩展来扩展代码的功能。
第三章,使用 Swift 控制流进行数据处理,将向您展示编程的全部内容都是关于做决策,因此在本章中,我们将探讨如何基于获得的信息做出决策,以及如何改变代码的控制流。您将学习如何使用if/else语句有条件地执行代码。您还将学习如何使用switch语句控制代码的执行流程,并通过理解如何使用for和while循环来循环此代码。然后您将了解如何使用try、throw、do和catch语句处理 Swift 错误。此外,我们还将介绍defer语句如何在函数执行完成后改变状态或清理不再需要的值时非常有用。
第四章,泛型、操作符和嵌套类型,为您提供了对 Swift 两个高级特性的理解,即泛型和操作符。使用这些特性,您将学习如何构建灵活且定义良好的功能。此外,您还将了解嵌套类型如何允许对您的构造进行逻辑分组、访问和命名空间。
第五章,超越标准库,带您探索 Foundation 和 UIKit 等框架提供的标准库之外的功能。学习如何使用这些功能将帮助您充分利用 Swift 语言。
第六章,使用 Swift 构建 iOS 应用,我们将开始构建我们自己的 iOS 应用的旅程。结合前几章的食谱中学到的所有内容,本章将教授您如何使用 Swift 构建 iOS 应用。
第七章,Swift Playgrounds,您将全面了解使用 Swift Playgrounds,并探索前几章未涉及的高级功能,以创建完全交互式的体验。
第八章,服务器端 Swift*,向您介绍 Swift 编程的全新方面:使用 Swift 进行服务器端编程。此外,您还将了解如何通过安装 Swift 工具链在 Linux 上运行 Swift。您将学习如何使用 Web 服务器框架构建 REST API,以及如何通过托管服务托管您的 API。您还将了解如何通过理解如何使用 Vapor(Swift 5 中最受欢迎的框架之一)来轻松完成任务。
第九章,Swift 中的性能和响应性,探讨了 Swift 编程的更高级概念,以了解某些 Swift 类型是如何实现及其性能特性的。此外,您还将学习如何使用 Grand Central Dispatch API 执行异步任务。然后,我们将探讨所有苹果平台上可用的多线程环境以及如何增强 Swift 结构的性能配置文件,以构建快速响应的应用程序。
第十章,SwiftUI 和 Combine 框架,直接深入苹果的新 UI 框架 SwiftUI。使用 SwiftUI,我们将通过探索用于实现此目的的声明性语法来学习如何创建 iOS 应用程序。我们还将探讨 SwiftUI 和 UIKit 如何协同工作。除了 SwiftUI,我们还将探索新 Combine 框架的基础知识以及它是如何与 SwiftUI 一起工作的。
第十一章,在 Swift 中使用 CoreML 和 Vision,将深入探讨苹果提供的框架以及我们如何使用 Swift 代码来利用机器学习技术,因为机器学习正变得越来越热门。在这一章中,我们将介绍 CoreML 和 Vision 框架,使用预训练模型来实时识别对象。
要充分利用本书
要跟随本书中的示例,您需要一个运行 macOS Catalina(特别是版本 10.15.4)或更高版本的计算机。您还需要一个 Apple ID,以便从 Mac App Store 下载并安装 Xcode 12。关于服务器端 Swift 的章节(第八章, 服务器端 Swift)还需要一个运行 Ubuntu 20.04 LTS 的系统。
本书中的代码已在 Swift 5.3 上进行了测试,但应与任何更新的 Swift 版本兼容。
| 本书涵盖的软件/硬件 | 操作系统要求 |
|---|---|
| macOS | 10.15.4+ (Catalina) |
| Xcode | 12+ |
| Ubuntu(用于第八章, 服务器端 Swift) | 20.04 LTS |
如果您使用的是本书的数字版,我们建议您亲自输入代码或通过 GitHub 仓库(下一节中提供链接)访问代码。这样做将帮助您避免与代码复制粘贴相关的任何潜在错误。
下载示例代码文件
您可以从 GitHub 下载本书的示例代码文件,网址为github.com/PacktPublishing/Swift-Cookbook-Second-Edition。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!
代码实战
本书的相关代码在行动视频可以在bit.ly/3shdTeQ查看。
下载彩色图像
我们还提供了一个包含本书中使用的截图/图表的彩色图像的 PDF 文件。您可以从这里下载:static.packt-cdn.com/downloads/9781839211195_ColorImages.pdf。
使用的约定
本书使用了多种文本约定。
CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“我们可以在任何使用[Pug]或Array<Pug>的地方替换Grumble。”
代码块设置如下:
let fraction = rating / total
let ratingOutOf5 = fraction * 5
let roundedRating = round(ratingOutOf5) // Rounds to the nearest
// integer.
当我们希望将你的注意力引到代码块的一个特定部分时,相关的行或项目将以粗体显示:
class ProgrammeFetcher {
typealias FetchResultHandler = (String?, Error?) -> Void
func fetchCurrentProgrammeName(forChannel channel: Channel,
resultHandler: FetchResultHandler) {
// Get next programme
let programmeName = "Sherlock"
resultHandler(programmeName, nil)
}
任何命令行输入或输出都按照以下方式编写:
$ mkdir css
$ cd css
粗体:表示新术语、重要单词或你在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中看起来像这样。以下是一个示例:“从管理面板中选择系统信息。”
警告或重要提示看起来像这样。
小技巧和窍门看起来像这样。
章节
在这本书中,你会找到一些经常出现的标题(如准备就绪、如何操作...、工作原理...、还有更多...和另请参阅)。
为了清楚地说明如何完成食谱,请按照以下方式使用这些部分:
准备就绪
本节告诉你可以期待在食谱中看到什么,并描述如何设置任何软件或任何为食谱所需的初步设置。
如何操作…
本节包含遵循食谱所需的步骤。
如何操作…
本节通常包含对上一节发生情况的详细解释。
还有更多…
本节包含有关食谱的附加信息,以便你更了解食谱。
另请参阅
本节提供了对其他有用信息的链接,这些信息对食谱很有帮助。
联系我们
我们欢迎读者的反馈。
一般反馈:如果你对本书的任何方面有疑问,请在邮件主题中提及书名,并通过customercare@packtpub.com将邮件发送给我们。
勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果你在本书中发现错误,我们将不胜感激,如果你能向我们报告这一点。请访问www.packtpub.com/support/errata,选择你的书,点击勘误提交表单链接,并输入详细信息。
盗版:如果你在互联网上以任何形式遇到我们作品的非法副本,如果你能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packt.com与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com.
评论
请留下评论。一旦您阅读并使用了这本书,为何不在您购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,我们 Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!
关于 Packt 的更多信息,请访问 packt.com.
Swift 构建块
自从 Apple 在 2014 年的 WWDC 上宣布 Swift 编程语言以来,它已经迅速成为增长最快的编程语言之一。TIOBE 是一家衡量软件质量并发布编程语言使用排名指数的公司。在撰写本文时,Swift 在这个指数上排名第 11 位。这比本书第一版撰写时上升了两位(访问 www.tiobe.com/tiobe_index)。
Swift 是一种现代的通用编程语言,它侧重于类型安全和表达性以及简洁的语法。作为 Objective-C 的现代替代品,它已经取代了那种较老的语言,成为 Apple 平台开发的未来。
仅凭这个细分市场就能确保 Swift 作为一种有用且重要的编程语言的地位。然而,Apple 决定开源 Swift,使其影响力超越了 Apple 的生态系统,使其有可能在所有平台上以及任何场景中使用。
自从开源 Swift 以来,Apple 提供了在 Linux 上运行您的 Swift 代码的支持。在后面的章节中,我们将探讨使用 Swift 服务器来执行您的代码。此外,Swift Playgrounds iPad 应用将您的平板电脑变成一个轻量级的 集成开发环境(IDE)。尽管有这些使用和编写 Swift 代码的替代方法,但最简单的方法还是在 Mac 上使用 Apple 的 Xcode IDE。本书的开头,我们将介绍如何设置它,然后除非另有说明,否则将假设读者使用这个开发环境。Xcode 还提供了探索 Swift 标准库、基础库以及任何其他适用于 iOS 或 Mac 开发的框架的结构和语法的完美方式。
Swift Playground 是一个用于执行 Swift 代码的简化环境。就我们的目的而言,Playgrounds 提供了一种创建、运行和理解本书中包含的食谱的理想方式。因此,也将假设读者正在使用 Xcode Playground 来实现本书中包含的食谱,除非另有说明。
Swift 5.3 是一个重要的版本,它添加了许多对 Apple 的 SwiftUI 框架至关重要的语言特性,我们将在第十章 SwiftUI 和 Combine 框架中进行介绍。Swift 5.3 还将与未来的 Swift 版本有更好的兼容性,这意味着现在用 Swift 5.3 编写的代码可以与用 Swift 6 及更高版本编写的代码并行运行。本书将假设读者正在使用 Swift 5.3;所有代码都将与这个版本兼容。
在本章中,我们将探讨 Swift 语言的构建块,检查构成 Swift 语言基础的基本 Swift 组件的语法和功能。
本章将涵盖以下食谱:
-
创建您的第一个 Swift 程序
-
使用 Strings、Ints、Floats 和 Bools
-
解包可选值,以及强制解包
-
在函数中重用代码
-
在对象类中封装功能
-
将值捆绑到 structs 中
-
使用 enums 列举值
-
使用闭包传递功能
-
使用协议定义接口
第二章:技术要求
本章的所有代码都可以在书籍的 GitHub 仓库中找到,地址为 github.com/PacktPublishing/Swift-Cookbook-Second-Edition/tree/master/Chapter01
查看以下视频以查看代码的实际操作:bit.ly/3rp0DnJ
创建你的第一个 Swift 程序
在第一个菜谱中,我们将设置我们的开发环境,并使用 Swift Playgrounds 创建我们的第一个 Swift 代码片段。
准备工作
首先,我们必须从 Mac App Store 下载并安装 Apple 的 IDE,Xcode:
- 从 Dock 或通过 Spotlight 打开 Mac App Store:


图 1.1 – App Store
- 搜索
xcode:

图 1.2 – 搜索 xcode
- 点击 GET,然后安装:

图 1.3 – App Store 中的 Xcode
Xcode 是一个大型下载(近 8 GB),所以这可能会花费一些时间,具体取决于你的网络连接。
- 下载完成后,从 App Store 或 Dock 中打开 Xcode:

图 1.4 – 打开 Xcode
如何操作...
下载了 Xcode 后,让我们创建第一个 Swift playground:
-
从 Dock 中的图标启动 Xcode。
-
从欢迎屏幕中选择“使用 playground 开始”:

图 1.5 – Xcode,新项目
- 从模板的 iOS 选项卡中选择空白,然后按下一步:

图 1.6 – Xcode,选择项目类型
- 为你的 playground 选择一个名称和位置,然后按创建:

图 1.7 – Xcode,保存项目
Xcode playgrounds 可以基于三个不同的 Apple 平台之一:iOS、tvOS 和 macOS。根据你的选择,playgrounds 提供对 iOS、tvOS 或 macOS 可用框架的完全访问权限。本书将假设使用 iOS playground,主要是因为这是作者的首选平台。除非另有说明,否则在代码具有 UI 组件的情况下,将使用 iOS 平台。
你现在会看到一个看起来像这样的视图:

图 1.8 – Playground – 代码模板
- 让我们将单词
playground替换为Swift!。点击窗口左下角的播放图标以在 playground 中执行代码:

图 1.9 – 演示场运行/代码执行
恭喜!你已经运行了你的第一行 Swift 代码。
在窗口的右侧,你可以看到演示场中每一行代码的输出。我们可以看到我们的代码行输出了 Hello Swift!

图 1.10 – "Hello, Swift!" 演示场
还有更多...
如果你将光标移至右侧的输出上,你会看到两个按钮,一个看起来像一只眼睛,另一个是一个圆形的方块:

图 1.11 – 演示场,输出
点击眼睛按钮以获取输出结果的快速查看框。这对于文本字符串来说并不特别有用,但对于更直观的输出,如颜色和视图,可能很有用:

图 1.12 – 演示场输出快速查看
点击方块按钮,会在你的代码下方添加一个框,显示代码的输出。如果你想要看到输出如何随着代码的改变而变化,这会非常有用:

图 1.13 – 演示场,内联输出
参见
我们将在第七章 Swift 演示场中了解更多关于演示场以及如何进一步使用它们。
使用字符串、整数、浮点数和布尔值
任何编程语言中的许多核心操作都涉及操作文本、数字以及确定真伪。让我们通过查看 Swift 的基本类型以及学习如何分配常量和变量来了解如何在 Swift 中完成这些操作。在这个过程中,我们将接触到 Swift 的静态类型和可变性系统。
准备中
在 Xcode 中打开一个新的 Swift 演示场。前面的食谱解释了如何做到这一点。
如何做到...
让我们运行一些探索基本类型的 Swift 代码,然后我们可以一步一步地分析它:
- 将以下内容输入到新的演示场文件中:
let phrase: String = "The quick brown fox jumps over the lazy dog"
let numberOfFoxes: Int = 1
let numberOfAnimals: Int = 2
let averageCharactersPerWord: Float = (3+5+5+3+5+4+3+4+3) / 9
print(averageCharactersPerWord) // 3.8888888
/*
phrase = "The quick brown ? jumps over the lazy ?" // Doesn't compile
*/
var anotherPhrase = phrase
anotherPhrase = "The quick brown jumps over the lazy "
print(phrase) // "The quick brown fox jumps over the lazy dog"
print(anotherPhrase) // "The quick brown jumps over the lazy "
var phraseInfo = "The phrase" + " has: "
print(phraseInfo) // "The phrase has: "
phraseInfo = phraseInfo + "\(numberOfFoxes) fox and \
(numberOfAnimals) animals"
print(phraseInfo) // "The phrase has: 1 fox and 2 animals"
print("Number of characters in phrase: \(phrase.count)")
let multilineExplanation = """
Why is the following phrase often used?
"The quick brown fox jumps over the lazy dog"
This phrase contains every letter in the alphabet.
"""
let phrasesAreEqual = phrase == anotherPhrase
print(phrasesAreEqual) // false
let phraseHas43Characters = phrase.count == 40 + 3
print(phraseHas43Characters) // true
- 按下窗口底部的播放按钮以运行演示场并验证 Xcode 没有显示任何错误。你的演示场应该看起来像下面的截图,其中右侧的时间线上有每一行的输出,底部控制台中有打印的值:

图 1.14 – 演示场控制台输出
它是如何工作的...
让我们逐行分析前面的代码,以理解它。
在这一行代码中,我们将一些文本分配给一个常量值:
let phrase: String = "The quick brown fox jumps over the lazy dog"
我们通过使用let关键字定义一个新的常量值,并给这个常量起一个名字,phrase。冒号:表示我们想要定义要存储在常量中的信息类型,并且类型定义在冒号之后。在这种情况下,我们想要分配一个字符串(String是大多数编程语言对文本的称呼)。等号=表示我们正在将值赋给已定义的常量,而"The quick brown fox jumps over the lazy dog"是一个String字面量,这意味着它是一种构建字符串的简单方法。Swift 将任何包含在""标记内的文本视为String字面量。
我们将等号右侧的String字面量赋值给等号左侧的常量。
接下来,我们再赋值两个常量,但这次它们是Int类型,即整数:
let numberOfFoxes: Int = 1
let numberOfAnimals: Int = 2
而不是直接赋值,我们可以将数学表达式的结果赋给常量。这个常量是一个Float,即浮点数:
let averageCharactersPerWord: Float = (3+5+5+3+5+4+3+4+3) / 9
换句话说,它可以存储分数而不是整数。注意,在这个行的右侧时序中,值显示为3.88889。
print函数允许我们查看打印到控制台或显示在游乐场中的任何表达式的输出:
print(averageCharactersPerWord)
我们将在后面的菜谱中介绍函数,但到目前为止,你需要知道的是,为了使用一个函数,你输入它的名字(在这个例子中是print),然后在括号()内包含任何函数所需的输入。
当我们的代码调用这个函数时,代码右侧的时间线显示了该语句的输出为3.88888,这与它上面的行不同。我们执行的这个数学表达式的实际值是3.88888888...,有无限多个 8。然而,print函数将其四舍五入到只有五位小数,并且与上面行的时序不同。在处理浮点数时,记住这个真实值与 Swift 语言表示方式之间的潜在差异是很重要的。
接下来,你会看到一些行被涂成灰色:
/*
phrase = "The quick brown ? jumps over the lazy ?" // Doesn't compile
*/
游乐场不会为这些行生成输出,因为它们是注释。代码行前的/*语法和代码行后的*/语法表示这是一个注释块,因此 Swift 应该忽略这个块中输入的任何内容。
移除/*和*/,你会看到// 不编译仍然是灰色。这是因为//也代表注释。同一行上此注释之后的内容也将被忽略。
如果你现在尝试运行这段代码,Xcode 会告诉你这一行存在问题,所以让我们看看这一行以确定问题所在。
在等号=的左侧,我们有phrase,这是我们之前声明的,现在我们正在尝试给它分配一个新值。我们无法这样做,因为我们使用let关键字将phrase定义为常量。我们只应该使用let来定义那些我们知道不会改变的东西。将某物定义为不可变,或不可变,是 Swift 中的一个重要概念,我们将在后面的章节中再次讨论它。
如果我们想要定义可以改变的东西,我们可以使用var关键字声明它:
var anotherPhrase = phrase
由于anotherPhrase是一个变量,我们可以给它分配一个新值:
anotherPhrase = "The quick brown jumps over the lazy "
Swift 中的字符串是完全Unicode 兼容的,因此我们可以使用表情符号而不是文字。现在,让我们打印出我们字符串的值,看看它们包含什么值:
print(phrase) // "The quick brown fox jumps over the lazy dog"
print(anotherPhrase) // "The quick brown jumps over the lazy "
在前面的行中,我们做了以下操作:
-
定义了一个名为
phrase的字符串 -
将一个名为
anotherPhrase的字符串定义为与phrase相同的值 -
改变了
anotherPhrase的值 -
打印了
phrase和anotherPhrase的值
当我们这样做时,我们会发现只有anotherPhrase打印出了被分配的新值,尽管phrase和anotherPhrase的初始值是相同的。尽管phrase和anotherPhrase具有相同的值,但它们之间没有内在的联系;因此,当anotherPhrase被分配一个新值时,这不会影响phrase。
字符串可以很容易地使用+运算符组合:
var phraseInfo = "The phrase" + " has: "
print(phraseInfo) // "The phrase has: "
这给出了你预期的结果;字符串被连接起来。
你经常会想通过包含从其他表达式派生的值来创建字符串。我们可以使用String插值来完成这个操作:
phraseInfo = phraseInfo + "\(numberOfFoxes) fox and \(numberOfAnimals)
animals"
print(phraseInfo) // "The phrase has: 1 fox and 2 animals"
在\(和)之间插入的值可以是任何可以表示为字符串的东西,包括其他字符串、整数、浮点数或表达式。
我们还可以使用字符串插值来使用表达式,例如显示字符串中的字符数:
print("Number of characters in phrase: \(phrase.count)")
Swift 中的字符串是集合,是元素的容器;在这种情况下,字符串是一组字符的集合。我们将在下一章更深入地介绍集合,但就目前而言,了解你的集合可以通过它们的count属性告诉你它们包含多少元素就足够了。我们使用这个属性来输出短语中的字符数。
可以使用"""在字符串的开始和结束处定义多行字符串字面量:
let multilineExplanation = """
Why is the following phrase often used?
"The quick brown fox jumps over the lazy dog"
This phrase contains every letter in the alphabet.
"""
多行字符串的内容必须从开始和结束标记的单独一行开始。在多行字符串字面量中,你可以使用单引号字符"而不需要使用额外的转义字符,就像在单行字符串字面量中那样。
布尔值,或Bool,表示真或假。在下一行,我们评估布尔表达式的值,并将结果赋给phrasesAreEqual常量:
let phrasesAreEqual: Bool = phrase == anotherPhrase
print(phrasesAreEqual) // false
在这里,等号==比较其左右两侧的值,如果两个值相等则返回true,否则返回false。
如我们之前讨论的,尽管我们最初将 anotherPhrase 赋值为 phrase,但我们后来将 anotherPhrase 赋值为一个新值;因此,phrase 和 anotherPhrase 不相等,表达式将值赋为 false。
== 运算符的每一侧都可以是任何评估为匹配另一侧类型的表达式,就像我们在以下代码中所做的那样:
let phraseHas43Characters: Bool = phrase.characters.count == 40 + 3
print(phraseHas43Characters) // true
在这种情况下,phrase 的字符计数为 43。由于 40 + 3 也等于 43,因此常量被赋予了 true 的值。
更多...
在这个配方中,我们定义了许多常量和变量,当我们这样做时,我们也明确地定义了它们的类型。例如,考虑以下 Swift 代码行:
let clearlyAString: String = "This is a string literal"
Swift 是一种静态类型语言。这意味着我们定义的任何常量或变量都必须具有特定的类型,一旦定义,就不能更改为其类型。然而,在前面的一行代码中,clearlyAString 常量显然是一个字符串!表达式的右侧是一个字符串字面量,因此我们知道左侧将是一个字符串。更重要的是,Swift 编译器也知道这一点(编译器是将 Swift 代码转换为机器代码的程序)。
Swift 的核心在于简洁性,因此,由于编译器可以推断类型,我们不需要显式地声明它。与前面的代码相比,我们可以使用以下代码,它仍然可以运行,尽管我们没有指定类型:
let clearlyAString = "This is a string literal"
事实上,我们迄今为止所做的所有类型声明都可以删除!所以,回到我们已编写的代码中,删除所有类型声明(:String、:Int、:Float 和 :Bool),因为它们都可以被推断。运行游乐场以确认这仍然是有效的 Swift 代码。
参见
有关 Swift 中这些基本类型的更多信息,请参阅 Apple 对 Swift 语言的文档:
-
整数、浮点数和布尔值:
swiftbook.link/docs/the-basics -
字符串和字符:
swiftbook.link/docs/strings
解包可选值和强制解包
在现实世界中,我们并不总是知道问题的答案,如果我们假设我们总是知道答案,可能会出现问题。在编程语言中也是如此,尤其是在处理我们可能无法控制的系统时。在许多语言中,没有方法可以指出我们可能在任何给定时间不知道一个值。这可能导致代码脆弱或在使用值之前需要进行大量检查以确保值存在。
编程语言使用术语 nil 或 null 来表示值的缺失。请注意,这与数字 0 或空(长度为零)的字符串 "" 不同。Swift 使用 nil 来表示值的缺失。因此,将 nil 分配给值将移除当前分配的任何值。
由于 Swift 是类型安全的,并且使其编写安全代码更容易,这种歧义必须得到解决,Swift 语言通过一种称为可选性的东西来解决这个问题。在这个菜谱中,我们将探讨 Swift 中的可选性是什么,以及如何安全地处理和使用它们。
开始使用
在一个新的 playground 中输入以下内容:
var dayOfTheWeek: String = "Monday"
dayOfTheWeek = "Tuesday"
dayOfTheWeek = "Wednesday"
dayOfTheWeek = nil
当你尝试运行代码时,你会看到编译器抛出了一个错误,并且不允许你将nil赋值给dayOfTheWeek变量。这是完全正确的!一周中的某一天可能会改变,但永远不会没有当前的一周。
由于我们声明了类型为String,编译器期望的就是这个,而nil不是字符串,所以它不能被分配给这个变量。
即使你移除了类型声明,让编译器推断它,就像我们在前面的菜谱中所做的那样,这也同样适用。这是因为类型是在变量声明时推断的,并且由于它被分配了一个字符串值,所以推断出的类型是String。所有其他对这个变量的使用都将与这个推断出的String类型进行校验。
删除最后一行,因为编译器问题将阻止我们在 playground 中运行进一步的代码。
如何做到...
我们将探讨一个适合使用可选变量变量的不同场景。Nick 和 Finn 正在玩游戏。在每一轮中,Finn 会把手放在背后并选择要举起的手指数量,Nick 会猜测这是多少个手指,然后 Finn 会向他展示他选择举起的手指数量。
为了帮助跟踪游戏,Nick 将 Finn 举起的手指数量存储在一个变量中。当 Finn 展示他的手时,Nick 可以输入手指的数量,但当 Finn 的手在背后时,Nick 不知道 Finn 举起的手指数量,因此不能存储一个值。
让我们输入以下代码:
// Start of the game
var numberOfFingersHeldUpByFinn: Int?
// Finn's hand behind his back
numberOfFingersHeldUpByFinn = nil
// Finn shows his hand
numberOfFingersHeldUpByFinn = 3
// Finn puts hand back behind his back
numberOfFingersHeldUpByFinn = nil
// Finn shows his hand
numberOfFingersHeldUpByFinn = 1
print(numberOfFingersHeldUpByFinn)
// End of the game
let lastNumberOfFingersHeldUpByFinn: Int = numberOfFingersHeldUpByFinn!
与一周中的某一天示例不同,这段代码没有问题地编译,尽管我们给变量赋值了nil。让我们看看为什么我们能这样做。
它是如何工作的...
我们知道在游戏中会有时候我们不知道举起的手指数量,所以这个变量是可选的;它可能是一个Int,或者它可能是nil。你可能会记得,nil并不代表0。完全有可能 Finn 举起的是零个手指(即紧握的拳头),这是一个有效的答案。在这种情况下,nil代表对手指数量的未知。为了将这个变量声明为可选的,我们定义了期望的类型,但额外添加了一个?:
var numberOfFingersHeldUpByFinn: Int?
在 Swift 中,这被称为可选包裹的Int。我们将Int类型包裹在可选的概念中。我在强调这个术语包裹是因为我们稍后需要解包这个可选类型。在游戏开始时,我们不知道有多少手指被举起,所以我们给这个变量赋值nil,这是可选变量的允许操作:
numberOfFingersHeldUpByFinn = nil
一旦芬恩的手被展示出来,我们知道他举起了多少根手指,我们就可以将那个Int值赋给变量:
numberOfFingersHeldUpByFinn = 3
由于变量类型是可选的Int,有效的值要么是Int,要么是nil;如果我们尝试其他类型的值,我们将得到编译器错误:
numberOfFingersHeldUpByFinn = "three" // Doesn't compile because "three" isn't an Int or nil
如我们之前讨论的,Swift 有一个静态类型系统,因此一旦声明了变量类型,就不能更改。因此,尽管我们已经将Int类型的值赋给了变量,但这并没有改变变量的类型为非可选的Int;它的类型仍然是Int?。由于类型仍然是可选的,当芬恩再次将手放在背后时,我们可以将其赋值为nil:
numberOfFingersHeldUpByFinn = nil
当我们打印一个可选变量时,输出会告诉我们它是可选的,例如,Optional(1):
numberOfFingersHeldUpByFinn = 1
print(numberOfFingersHeldUpByFinn)
你会注意到编译器在打印行上高亮显示了一个问题,说表达式隐式转换为Int?到Any。我们之所以看到这一点,是因为我们向打印注释传递了一个可选值,而该注释期望一个非可选值。为了解决这个问题,我们可以提供一个值,如果我们的可选值恰好是nil,就会使用这个值。这被称为空值合并运算符:
print(numberOfFingersHeldUpByFinn ?? "Unknown")
在游戏结束时,我们希望存储芬恩在游戏最后一轮举起的指数字数。由于我们知道我们将至少玩一局游戏,我们知道必须有一个值代表最后举起的指数字数。因此,我们将lastNumberOfFingersHeldUpByFinn变量声明为非可选的Int:
let lastNumberOfFingersHeldUpByFinn: Int = numberOfFingersHeldUpByFinn!
然而,我们的numberOfFingersHeldUpByFinn变量是一个可选的Int,将可选值赋给非可选变量会导致编译器出现问题,因为它不能确定它将分配一个非空值。为了解决这个问题,我们需要声明这个变量现在是非可选的,尽管我们将其声明为可选的。我们通过在值后添加!来实现这一点。
如果你从前面的语句中移除!,编译器将抱怨:
let lastNumberOfFingersHeldUpByFinn: Int = numberOfFingersHeldUpByFinn
// Does not compile
在 Swift 术语中,通过添加!,我们正在解包可选变量。一旦它被解包,因此不再是可选的,我们就可以将其赋给lastNumberOfFingersHeldUpByFinn变量,这个变量也不是可选的。
警告!使用这种强制解包可能很危险。当你强制解包一个可选变量时,你是在声明你确信在该代码执行的该点变量中有一个值。然而,如果变量是nil,你的代码运行时将出现错误,并且执行将终止。如果这段代码在一个应用程序中运行,那么应用程序将崩溃。我们将在后面的章节中看到更安全地解包可选值的方法。
让我们看看当我们强制解包一个设置为nil的变量时会发生什么。想象一下,我们在第一轮游戏开始之前就结束了游戏:
// Start of the game
var numberOfFingersHeldUpByFinn: Int?
// Hand behind his back
numberOfFingersHeldUpByFinn = nil
print(numberOfFingersHeldUpByFinn) // nil
// End of the game
let lastNumberOfFingersHeldUpByFinn: Int = numberOfFingersHeldUpByFinn!
此代码可以编译并运行,但在运行时将崩溃,因为当numberOfFingersHeldUpByFinn被分配给lastNumberOfFingersHeldUpByFinn时,numberOfFingersHeldUpByFinn的值是nil。
还有更多...
到目前为止,我们已经看到了非可选变量,其中必须提供正确类型的值,以及可选变量,其中值可以是基础类型或nil。在一个完美的世界中,这将是我们所需要的全部。然而,我们可能需要声明一个变量,即使我们不知道它在声明时的值,也应该被视为非可选。对于这些情况,我们可以在 Swift 中将变量声明为隐式解包可选(IUO)。
在 playground 中运行以下代码:
var legalName: String!
// At birth
legalName = nil
// At birth registration
legalName = "Alissa Jones"
// At enrolling in school
print(legalName)
// At enrolling in college
print(legalName)
// Registering Marriage
legalName = "Alissa Moon"
// When meeting new people
print(legalName)
在前面的例子中,我们声明了一个人的法定姓名,这在他们的一生中会在许多地方使用,例如在教育机构注册时。它可以被法律请求或通过婚姻而改变,但你永远不会期望某人的法定姓名不存在。然而,当一个人出生时,这正是发生的事情!当一个人出生时,他们直到出生被注册之前都没有法定姓名。因此,如果我们试图在代码中模拟这种情况,一个人的法定姓名可以表示为IUO。
在代码中,我们通过在类型后放置一个!符号来声明一个变量为 IUO(不可确定值):
let legalName: String!
IUOs 与强制解包具有相同的风险。你承诺变量有一个值,即使它可能是nil。尽管变量可能是nil,但当尝试访问它时,将有一个值存在。如果变量被访问,但其中不包含值,执行将终止,你的应用将崩溃。
当 IUOs 被分配给其他变量并且类型被推断时,它们的行为有一些微妙之处。用代码来说明这一点最容易,所以请在 playground 中输入以下内容:
var input: Int! = 5 // Int!
print(input) // 5
var output1 = input // Int?
print(output1 as Any) // Optional(5)
var output2 = input + 1 // Int
print(output2) // 5
当一个 IUO 被分配给一个新的变量时,编译器无法确定是否已经分配了一个非 nil 值。因此,如果一个 IUO 被分配给一个新的变量,就像这里的output1一样,编译器会采取安全措施,并推断这个新变量的类型是可选的。然而,如果 IUO 的值已经被解包,那么编译器就知道它有一个非 nil 值,并将推断出一个非可选类型。当分配output2时,输入的值被解包以便将其加1。因此,output2的类型被推断为非可选的Int。
另请参阅
有关可选的更多信息,请参阅swiftbook.link/docs/the-basics。
在函数中重用代码
函数是几乎所有编程语言的基础构建块,允许定义和重用功能。Swift 的语法提供了一种表达性强的方式来定义你的函数,创建简洁且易于阅读的代码。在本食谱中,我们将遍历我们可以创建的不同类型的函数,并了解如何定义和使用它们。
如何操作...
让我们看看如何在 Swift 中定义函数:
func nameOfFunction(parameterLabel1 parameter1: ParameterType1, parameterLabel2 parameter2: ParameterType2,...) -> OutputType {
// Function's implementation
// If the function has an output type,
// the function must return a valid value
return output
}
让我们更详细地看看函数是如何定义的:
-
func: 这表示你正在声明一个函数。 -
nameOfFunction: 这将是你的函数名称,并且按照惯例,使用驼峰式命名法(这意味着除了第一个单词外,每个单词的首字母都大写,并且所有空格都被删除)。这应该描述函数的功能,并提供一些上下文,以说明函数返回的值(如果有的话)。这将是你从代码的其他部分调用方法的方式,因此在命名时请记住这一点。 -
parameterLabel1 parameter1: ParameterType1: 这是函数的第一个输入,或参数。你可以指定任意多的参数,用逗号分隔。每个参数都有一个参数名(parameter1)和类型(ParameterType1)。参数名是参数值将如何提供给函数实现的方式。你可以选择性地在参数名前提供参数标签(parameterLabel1),当你的函数被使用时(在调用点),它将用于标记参数。 -
-> OutputType: 这表示该函数返回一个值,并指示该值的类型。如果没有返回值,则可以省略。 -
{ }: 大括号表示函数实现的开始和结束;当函数被调用时,其中的任何内容都将被执行。 -
return output: 如果函数返回一个值,你输入return然后是返回的值。这结束了函数的执行;任何在返回语句之后编写的代码都不会被执行。
现在,让我们将其付诸实践。
想象我们正在构建一个联系人应用程序来保存你家人和朋友的详细信息,我们想要创建一个联系人的全名字符串。让我们探索一些函数可以使用的不同方式:
// Input parameters and output
func fullName(givenName: String,
middleName: String,
familyName: String) -> String {
return "\(givenName) \(middleName) \(familyName)"
}
前面的函数接受三个字符串参数,并输出一个字符串,将这些参数组合在一起,并在它们之间添加空格。这个函数唯一做的事情就是接受输入并产生输出,而不产生任何副作用;这种类型的函数通常被称为纯函数。要调用此函数,我们输入函数名称,然后在括号()内输入输入参数,其中每个参数值前面都跟着它的标签:
let myFullName = fullName(givenName: "Keith",
middleName: "David",
familyName: "Moon")
print(myFullName) // Keith David Moon
具有多个参数的函数可能会相当长,因此为了提高可读性,参数可以放在不同的行上,就像前面的例子一样。这适用于函数的定义和调用。惯例是将参数名称的起始部分与第一个参数对齐。
由于函数返回一个值,我们可以将这个函数的输出赋给一个常量或变量,就像任何其他表达式一样。
下一个函数接受相同的输入参数,但它的目的不是返回一个值。相反,它将参数打印出来,作为由空格分隔的一个字符串:
// Input parameters, with a side effect and no output
func printFullName(givenName: String,
middleName: String,
familyName: String) {
print("\(givenName) \(middleName) \(familyName)")
}
我们可以像调用前面的函数一样调用这个函数,尽管它不能被赋值给任何东西,因为它没有返回值:
printFullName(givenName: "Keith", middleName: "David", familyName:
"Moon")
以下函数不接受任何参数,因为它执行任务所需的一切都包含在其中,尽管它确实会输出一个字符串。这个函数调用了我们之前定义的fullName函数,利用了它能够根据组成部分名称生成全名的能力。重用功能是函数提供的最有用的特性:
// No inputs, with an output
func authorsFullName() -> String {
return fullName(givenName: "Keith",
middleName: "David",
familyName: "Moon")
}
由于authorsFullName不接受任何参数,我们可以通过输入函数名后跟空括号()来执行它,因为它返回一个值,所以我们可以将authorsFullName的结果赋值给一个变量:
let authorOfThisBook = authorsFullName()
我们的最后一个例子不接受任何参数也不返回任何值:
// No inputs, no output
func printAuthorsFullName() {
let author = authorsFullName()
print(author)
}
您可以像调用前面的无参数函数一样调用这个函数,并且没有返回值可以赋值:
printAuthorsFullName()
从前面的例子中可以看出,在定义函数时,不需要输入参数和提供输出值。
还有更多...
现在,让我们看看几种使您对函数的使用更加表达性和简洁的方法。
默认参数值
Swift 的一个便利之处在于能够为参数指定默认值。这些允许你在调用时省略参数,因为将提供默认值。让我们使用这个配方中早些时候的相同示例,其中我们正在创建一个联系人应用程序来保存有关我们家人和朋友的信息。您的许多家庭成员可能和您有相同的姓氏,因此我们可以将姓氏设置为该参数的默认值。因此,只有当姓氏与默认值不同时才需要提供姓氏。
将以下代码输入到游乐场中:
func fullName(givenName: String,
middleName: String,
familyName: String = "Moon") -> String {
return "\(givenName) \(middleName) \(familyName)"
}
定义默认值看起来与将值赋给familyName: String = "Moon"参数相似。在调用函数时,具有默认值的参数不需要提供:
let keith = fullName(givenName: "Keith", middleName: "David")
let alissa = fullName(givenName: "Alissa", middleName: "May")
let laura = fullName(givenName: "Laura",
middleName: "May",
familyName: "Jones")
print(keith) // Keith David Moon
print(alissa) // Alissa May Moon
print(laura) // Laura May Jones
参数重载
Swift 支持参数重载,这允许函数具有相同的名称,但仅通过它们接受的参数来区分。
让我们通过在游乐场中输入以下代码来了解更多关于参数重载的信息:
func combine(_ givenName: String, _ familyName: String) -> String {
return "\(givenName) \(familyName)"
}
func combine(_ integer1: Int, _ integer2: Int) -> Int {
return integer1+integer2
}
let combinedString = combine("Finnley", "Moon")
let combinedInt = combine(5, 10)
print(combinedString) // Finnley Moon
print(combinedInt) // 15
上述两个函数都名为 combine,但一个接受两个字符串作为参数,另一个接受两个 Int。因此,当我们调用函数时,Swift 通过我们传递的参数值知道我们想要哪个实现。
在前面的函数声明中,我们引入了新的内容,匿名参数标签:_ givenName: String。
当我们声明参数时,我们使用下划线 _ 作为参数标签。这表示我们不想在调用函数时显示参数名称。这应该只在参数的目的无需标签即可清楚时使用。
参见
关于函数的更多信息可以在 swiftbook.link/docs/functions 找到。
在对象类中封装功能
面向对象编程是一种常见且强大的编程范式。其核心是对象类。对象允许我们封装数据和功能,然后可以存储和传递。
在这个菜谱中,我们将构建一些类对象,分解它们的组件,并了解它们是如何定义和使用的。
如何做到...
让我们编写一些代码来创建和使用类对象,然后我们将逐步分析代码在做什么:
- 首先,创建一个
Person类对象:
class Person {
}
- 在花括号
{和}内,添加三个代表人名的常量,以及一个代表他们居住国的变量:
let givenName: String
let middleName: String
let familyName: String
var countryOfResidence: String = "UK"
- 在属性下方,但仍在花括号内,为我们的
Person对象添加一个初始化方法:
init(givenName: String, middleName: String, familyName: String) {
self.givenName = givenName
self.middleName = middleName
self.familyName = familyName
}
- 接下来,添加一个变量作为类的属性,具有计算值:
var displayString: String {
return "\(self.fullName()) - Location: \
(self.countryOfResidence)"
}
- 在
Person对象内添加一个函数,该函数返回人的全名:
func fullName() -> String {
return "\(givenName) \(middleName) \(familyName)"
}
- 接下来,创建一个扩展
Person对象功能的Friend对象:
final class Friend: Person {
}
- 在
Friend类对象内部,添加一个变量属性来保存用户遇见朋友的位置详情,并重写显示字符串属性以自定义Friend对象的行为:
var whereWeMet: String?
override var displayString: String {
let meetingPlace = whereWeMet ?? "Don't know where we met"
return "\(super.displayString) - \(meetingPlace)"
}
- 除了
Friend对象外,创建一个扩展Person对象功能的Family对象:
final class Family: Person {
}
- 向我们的
Family对象添加一个关系属性,并创建一个初始化方法来填充它,除了来自Person的其他属性:
final class Family: Person {
let relationship: String
init(givenName: String,
middleName: String,
familyName: String = "Moon",
relationship: String) {
self.relationship = relationship
super.init(givenName: givenName,
middleName: middleName,
familyName: familyName)
}
}
- 给
Family对象一个自定义的displayString方法,通过在Family对象定义内(在花括号内)添加此代码来包含relationship属性的值:
override var displayString: String {
return "\(super.displayString) - \(relationship)"
}
- 最后,创建我们新对象的实例,并打印显示字符串以查看其值如何不同:
let steve = Person(givenName: "Steven",
middleName: "Paul",
familyName: "Jobs")
let richard = Friend(givenName: "Richard",
middleName: "Adrian",
familyName: "Das")
richard.whereWeMet = "Worked together at Travel Supermarket"
let finnley = Family(givenName: "Finnley",
middleName: "David",
relationship: "Son")
let dave = Family(givenName: "Dave",
middleName: "deRidder",
familyName: "Jones",
relationship: "Father-In-Law")
dave.countryOfResidence = "US"
print(steve.displayString)
// Steven Paul Jobs
print(richard.displayString)
// Richard Adrian Das - Worked together at Travel Supermarket
print(finnley.displayString)
// Finnley David Moon - Son
print(dave.displayString)
// Dave deRidder Jones - Father-In-Law
它是如何工作的...
类使用 class 关键字定义。按照惯例,类名以大写字母开头,类的实现包含在或“范围”在花括号内:
class Person {
//...
}
对象可以具有属性值,这些值包含在对象内部。这些属性可以有初始值,就像以下代码中的 countryOfResidence 一样,尽管请注意,一旦设置了初始值,常量(使用 let 定义)就不能更改:
class Person {
let givenName: String
let middleName: String
let familyName: String
var countryOfResidence: String = "UK"
//...
}
如果你的类只包含前面的属性定义,编译器会发出警告,因为 givenName、middleName 和 familyName 被定义为非可选字符串,但我们没有提供任何填充这些值的方法。
编译器需要知道对象如何初始化,这样我们就可以确保所有非可选属性确实有值:
class Person {
let givenName: String
let middleName: String
let familyName: String
var countryOfResidence: String = "UK"
init(givenName: String, middleName: String, familyName: String) {
self.givenName = givenName
self.middleName = middleName
self.familyName = familyName
}
//...
}
init 是一个特殊的方法(在对象内部定义的函数称为方法),当对象被初始化时会被调用。在前面的 Person 对象中,初始化对象时必须传入 givenName、middleName 和 familyName,我们将这些提供的值赋给对象的属性。使用 self. 前缀来区分属性和传入的值,因为它们具有相同的名称。
由于 countryOfResidence 有一个初始值,我们不需要为它传入一个值。但这并不是最佳做法,因为当我们创建一个 Person 对象时,它总是会将 countryOfResidence 变量设置为 "UK",然后我们可能需要在初始化后更改该值。另一种方法是使用默认参数值,如前一个食谱中所示。将 Person 对象的初始化修改如下:
class Person {
let givenName: String
let middleName: String
let familyName: String
var countryOfResidence: String
init(givenName: String,
middleName: String,
familyName: String,
countryOfResidence: String = "UK") {
self.givenName = givenName
self.middleName = middleName
self.familyName = familyName
self.countryOfResidence = countryOfResidence
}
//...
}
现在,你可以在初始化时提供一个居住国家,或者省略它以使用默认值。
接下来,让我们看看 Person 类的 displayString 属性:
class Person {
//...
var displayString: String {
return "\(self.fullName()) - Location: \
(self.countryOfResidence)"
}
//...
}
这个属性声明与其他的不同。它不是直接分配一个值,而是后面跟着一个包含在大括号内的表达式。这是一个计算属性;它的值不是静态的,而是在每次访问属性时由给定的表达式确定。任何有效的表达式都可以用来计算属性,但必须返回一个与属性声明的类型匹配的值。编译器将强制执行这一点,你不能省略计算属性的变量类型。
在构建上面的返回值时,我们使用了 self.fullName() 和 self.countryOfResidence。正如我们在前面的 init 方法中所做的那样,我们使用 self. 来表明我们正在访问 Person 对象当前实例的方法和属性。然而,由于 displayString 已经是当前实例上的一个属性,Swift 编译器已经知道这个上下文,因此可以省略这些 self 引用:
var displayString: String {
return "\(fullName()) - Location: \(countryOfResidence)"
}
对象可以根据它们包含的信息执行工作,并且这项工作可以在方法中定义。方法只是包含在类中的函数,并且可以访问对象的所有属性。Person 对象的 fullName 方法就是这样一个例子:
class Person {
//...
func fullName() -> String {
return "\(givenName) \(middleName) \(familyName))"
}
//...
}
函数的所有能力都是可用的,我们在上一个菜谱中已经探索过,包括可选输入和输出、默认参数值和参数重载。
定义了一个Person对象后,我们希望扩展Person的概念来定义一个朋友。朋友也是一个人物,所以可以合理地推断,任何Person对象能做的事情,Friend对象也能做。我们通过将Friend定义为Person的子类来模拟这种继承行为。我们在类名后面,通过冒号:来定义我们的Friend类继承的类(称为“超类”):
final class Friend: Person {
var whereWeMet: String?
//...
}
通过从Person继承,我们的Friend对象继承了其超类中所有的属性和方法。然后我们可以添加任何我们需要的额外功能。在这种情况下,我们添加了一个属性来记录我们是如何遇到这个朋友的。
final前缀告诉编译器我们不想让这个类被继承;它是继承层次结构中的最终类。这允许编译器进行一些优化,因为它知道它不会被扩展。
除了实现新的功能外,我们还可以使用override关键字覆盖超类的功能:
final class Friend: Person {
//...
override var displayString: String {
let meetingPlace = whereWeMet ?? "Don't know where we met"
return "\(super.displayString) - \(meetingPlace)"
}
}
在前面的代码中,我们覆盖了从Person继承来的displayString计算属性,因为我们想添加“在哪里遇到”的信息。在计算属性中,我们可以通过调用super.来访问超类的实现,然后引用属性或方法。
接下来,让我们看看我们如何自定义子类的初始化方式:
final class Family: Person {
let relationship: String
init(givenName: String,
middleName: String,
familyName: String = "Moon",
relationship: String) {
self.relationship = relationship
super.init(givenName: givenName,
middleName: middleName,
familyName: familyName)
}
//...
}
我们的Family类也继承自Person,但我们想添加一个relationship属性,这个属性应该作为初始化的一部分,因此我们可以声明一个新的init,它也接受一个关系字符串值。然后传递的值被分配给relationship属性,因为调用了超类的初始化器。
子类必须在调用超类的init方法之前为其所有非可选属性分配一个值。如果我们忘记为relationship分配值,或者在我们调用super.init之后分配它,那么我们的代码将无法编译。
在定义了所有类对象之后,我们可以创建这些对象的实例,并调用这些对象的方法和访问它们的属性:
let steve = Person(givenName: "Steven",
middleName: "Paul",
familyName: "Jobs")
let richard = Friend(givenName: "Richard",
middleName: "Adrian",
familyName: "Das")
richard.whereWeMet = "Worked together at Travel Supermarket"
let finnley = Family(givenName: "Finnley",
middleName: "David",
relationship: "Son")
let dave = Family(givenName: "Dave",
middleName: "deRidder",
familyName: "Jones",
relationship: "Father-In-Law")
dave.countryOfResidence = "US"
print(steve.displayString)
// Steven Paul Jobs
print(richard.displayString)
// Richard Adrian Das - Worked together at Travel Supermarket
print(finnley.displayString)
// Finnley David Moon - Son
print(dave.displayString)
// Dave deRidder Jones - Father-In-Law
要创建一个对象的实例,我们使用对象名就像一个函数一样,传递任何所需的参数。这会返回一个对象实例,然后我们可以将其分配给一个常量或变量。
在创建实例时,我们实际上是在调用对象的init方法,你可以像下面这样显式地做:
let steve = Person.init(givenName: "Steven",
middleName: "Paul",
familyName: "Jobs")
然而,为了简洁,这通常会被省略。
还有更多...
类对象是引用类型,这是一个指代它们在内部存储和引用方式的术语。为了了解这些引用类型语义是如何工作的,让我们看看当一个对象被修改时它的行为:
class MovieReview {
let movieTitle: String
var starRating: Int // Rating out of 5
init(movieTitle: String, starRating: Int) {
self.movieTitle = movieTitle
self.starRating = starRating
}
}
// Write a review
let shawshankReviewOnYourWebsite = MovieReview(movieTitle: "Shawshank
Redemption", starRating: 3)
// Post it to social media
let referenceToReviewOnTwitter = shawshankReviewOnYourWebsite
let referenceToReviewOnFacebook = shawshankReviewOnYourWebsite
print(referenceToReviewOnTwitter.starRating) // 3
print(referenceToReviewOnFacebook.starRating) // 3
// Reconsider the review
shawshankReviewOnYourWebsite.starRating = 5
// The change is visible from anywhere with a reference to the object
print(referenceToReviewOnTwitter.starRating) // 5
print(referenceToReviewOnFacebook.starRating) // 5
我们定义了一个MovieReview类对象,创建了这个MovieReview对象的实例,然后将这个评论分配给了两个不同的常量。由于类对象是引用类型,它是对存储在常量中的对象的引用,而不是对象的新副本。因此,当我们重新考虑我们的评论,给《肖申克的救赎》五星(并且理应如此!)时,我们是在更改底层对象。当访问starRating属性时,所有访问该底层对象的引用都将接收到更新后的值。
参见
-
更多关于类和结构体的信息可以在
swiftbook.link/docs/classes-and-structures找到。 -
在第九章,“Swift 中的性能和响应性”,我们将更详细地探讨引用语义,并看看它如何影响性能。
将值捆绑到结构体中
类对象非常适合封装与统一概念(如人)相关的数据和功能,因为它们允许引用单个实例。然而,并非所有事物都是对象。我们可能需要表示逻辑上分组在一起的数据,但除此之外没有更多。它不是其部分的简单总和;它是其部分的总和。
对于这一点,有结构体。简称为结构体,结构体可以在许多编程语言中找到。与类(引用类型)相对,结构体是值类型,当传递时行为不同。在这个食谱中,我们将学习 Swift 中的结构体是如何工作的,以及何时以及如何使用它们。
准备工作
这个食谱将在上一个食谱的基础上构建,所以请打开你之前用于上一个食谱的游乐场。如果你没有完成上一个食谱,不要担心,因为这个食谱将包含你需要的所有代码。
如何做到这一点...
我们已经定义了一个Person对象,它有三个与人的名字相关的独立字符串属性。然而,这三个独立的字符串并不是孤立存在的,因为它们共同定义了一个人的名字。目前,如果你想检索一个人的名字,你必须访问三个独立的属性并将它们组合起来。让我们通过定义一个人的名字为其自己的结构体来整理一下:
- 创建一个名为
PersonName的结构体:
struct PersonName {
}
- 向
PersonName添加三个属性:givenName、middleName和familyName。将前两个设置为常量,最后一个设置为变量,因为姓氏可能会改变:
struct PersonName {
let givenName: String
let middleName: String
var familyName: String
}
- 添加一个方法将三个属性组合成一个
fullName字符串:
func fullName() -> String {
return "\(givenName) \(middleName) \(familyName)"
}
- 提供一个方法来更改姓氏属性,并在该方法前加上
mutating关键字:
mutating func change(familyName: String) {
self.familyName = familyName
}
- 创建一个包含属性值的个人名称:
var alissasName = PersonName(givenName: "Alissa",
middleName: "May",
familyName: "Jones")
它是如何工作的...
定义结构体与定义对象类非常相似,这是故意的。类的大部分功能也对结构体可用。因此,你会注意到,除了使用 struct 关键字而不是 class 之外,类和结构体的定义几乎相同。
在 PersonName 结构体内部,我们有用于名字三个组成部分的属性和之前看到的 fullName 方法,用于将三个名字组成部分组合成一个完整的名字字符串。
我们创建的用于更改姓氏属性的方法有一个我们之前没有见过的关键字,mutating:
mutating func change(familyName: String) {
self.familyName = familyName
}
这个关键字必须添加到任何会更改结构体属性的 struct 中的方法。这个关键字是为了通知任何使用该方法的人,它将改变,或者说“变异”,结构体。与类对象不同,当你变异一个结构体时,你会创建一个具有更改属性的结构的副本。这种行为被称为值类型语义。
为了看到这个行为的效果,让我们首先创建一个结构体,然后检查当我们将其分配给不同的值时,它是否表现如我们所期望:
let alissasBirthName = PersonName(givenName: "Alissa",
middleName: "May",
familyName: "Jones")
print(alissasBirthName.fullName()) // Alissa May Jones
var alissasCurrentName = alissasBirthName
print(alissasCurrentName.fullName()) // Alissa May Jones
到目前为止,一切顺利。我们已经创建了一个名为 PersonName 的结构体,将其分配给一个名为 alissasBirthName 的常量,然后将该常量分配给一个名为 alissasCurrentName 的变量。
现在,让我们看看当我们变异 alissasCurrentName 时会发生什么:
alissasCurrentName.change(familyName: "Moon")
print(alissasBirthName.fullName()) // Alissa May Jones
print(alissasCurrentName.fullName()) // Alissa May Moon
当我们在 alissasCurrentName 变量上调用可变方法时,只有该变量会发生变化。这种变化不会反映在 alissasBirthName 上,即使这些结构体曾经是相同的。如果 PersonName 是一个对象类,这种行为将会不同,我们已经在之前的菜谱中探讨了这种行为。
还有更多...
我们可以使用这种值类型行为与常量和变量如何交互来限制意外的更改。
为了看到这个行为的效果,首先,让我们将我们的 Person 类修改为新的 PersonName 结构体:
class Person {
let birthName: PersonName
var currentName: PersonName
var countryOfResidence: String
init(name: PersonName, countryOfResidence: String = "UK") {
birthName = name
currentName = name
self.countryOfResidence = countryOfResidence
}
var displayString: String {
return "\(currentName.fullName()) - Location: \
(countryOfResidence)"
}
}
我们为我们的新 PersonName 结构体类型添加了 birthName 和 currentName 属性,并在创建 Person 对象时用相同的值初始化它们。由于一个人的出生名字不会改变,我们将其定义为常量,但他们的当前名字可以改变,因此它被定义为变量。
现在,让我们创建一个新的 Person 对象:
var name = PersonName(givenName: "Alissa", middleName: "May", familyName: "Jones")
let alissa = Person(name: name)
print(alissa.currentName.fullName()) // Alissa May Jones
由于我们的 PersonName 结构体具有值语义,我们可以利用这一点来强制执行我们期望我们的模型具有的行为。我们预计无法更改一个人的出生名字,如果你尝试这样做,你会发现编译器不会允许你这样做。
正如我们之前讨论的,更改姓氏会变异结构体,因此会创建一个新的副本。然而,我们将 birthName 定义为常量,它不能被更改,因此我们能够更改姓氏的唯一方法是将 birthName 的定义从 let 更改为 var:
alissa.birthName.change(familyName: "Moon") // Does not compile.
// Compiler tells you to change let to var
当我们将currentName更改为具有新的姓氏时,我们可以这样做,因为我们将其定义为var,它将更改currentName属性,但不会更改birthName属性,即使这些属性被赋予了相同的值:
print(alissa.birthName.fullName()) // Alissa May Jones
print(alissa.currentName.fullName()) // Alissa May Jones
alissa.currentName.change(familyName: "Moon")
print(alissa.birthName.fullName()) // Alissa May Jones
print(alissa.currentName.fullName()) // Alissa May Moon
我们已经使用对象和结构体的组合创建了一个强制执行我们预期行为的模型。这种技术可以帮助减少我们代码中的潜在错误。
参见
-
关于结构体的更多信息可以在
swiftbook.link/docs/classes-and-structures找到。 -
在第九章,Swift 中的性能和响应性中,我们将更详细地探讨值语义,并了解它如何影响性能。
使用枚举枚举值
枚举是一种编程结构,允许您定义一个具有有限选项的值类型。大多数编程语言都有枚举(通常缩写为枚举),尽管 Swift 语言将这一概念推向了比大多数语言更远的地步。
iOS/macOS SDK 中的一个枚举示例是ComparisonResult,您会在排序项时使用它。在排序目的的比较中,比较的结果只有三种可能:
-
ascending:项目按升序排列。 -
descending:项目按降序排列。 -
same:项目相同。
比较结果的可能选项有限;因此,它是一个完美的候选者,可以用枚举表示:
enum ComparisonResult : Int {
case orderedAscending
case orderedSame
case orderedDescending
}
Swift 将枚举概念提升为第一类类型。正如我们将看到的,这使得枚举成为建模信息的非常强大的工具。
本菜谱将探讨如何在 Swift 中使用枚举以及何时使用。
准备工作
本菜谱将在之前的菜谱之上构建,因此请打开您用于之前菜谱的游乐场。如果您还没有尝试之前的菜谱,不要担心,因为这个菜谱将包含您需要的所有代码。
如何做...
在在对象类中封装功能的菜谱中,我们创建了一个Person对象来表示模型中的人,在将值打包到结构体中的菜谱中,我们创建了一个PersonName结构体来存储有关人名的信息。现在,让我们将注意力转向一个人的头衔(例如,先生,夫人),它位于某人的全名之前。一个人可能拥有的常见头衔数量有限且有限,因此枚举是表示此类信息的绝佳方式。
- 创建一个枚举来表示一个人的头衔:
enum Title {
case mr
case mrs
case mister
case miss
case dr
case prof
case other
}
我们使用enum关键字定义我们的枚举并提供枚举的名称。与类和结构体一样,惯例是它以大写字母开头,实现定义在大括号内。我们使用case关键字定义每个枚举选项,并且按照惯例,这些选项以小写字母开头。
- 将我们的
Title枚举的mr情况分配给一个值:
let title1 = Title.mr
枚举可以通过指定枚举类型,然后一个点,然后是情况来分配。然而,如果编译器可以推断枚举类型,我们可以省略类型,只提供情况,前面有一个点。
- 定义一个
Title类型的常量值,然后使用类型推断将其分配给一个情况:
let title2: Title
title2 = .mr
它是如何工作的...
在包括 C 和 Objective-C 在内的许多编程语言中,枚举被定义为整数之上的类型定义,每个情况都被赋予一个定义的整数值。在 Swift 中,枚举不需要在底层表示整数。实际上,它们不需要由任何类型支持,可以独立存在作为自己的抽象概念。考虑以下示例:
enum CompassPoint {
case North, South, East, West
}
将罗盘方向映射为整数没有意义,在 Swift 中我们也不必这样做。
注意,我们可以通过逗号分隔在相同行上定义多个情况。
对于 Title,基于整数的枚举似乎不太合适;然而,基于字符串的一个可能更好。所以,让我们将我们的枚举声明为基于字符串的:
enum Title: String {
case mr = "Mr"
case mrs = "Mrs"
case mister = "Master"
case miss = "Miss"
case dr = "Dr"
case prof = "Prof"
case other // Inferred as "other"
}
枚举的原始底层类型在名称和冒号分隔符之后声明。可以用于支持枚举的原始类型仅限于可以表示为字面量的类型。这包括以下 Swift 基本类型:
-
String -
Int -
Float -
Bool
这些类型可以用作枚举的底层类型,因为它们符合名为 RawRepresentable 的协议。我们将在本章后面介绍协议。
情况可以分配原始类型的值;然而,某些类型可以推断,因此不需要显式声明。对于基于整数的枚举,推断的值从 0 开始按顺序分配:
enum Rating: Int {
case worst // Infered as 0
case bad // Infered as 1
case average // Infered as 2
case good // Infered as 3
case best // Infered as 4
}
对于基于字符串的枚举,推断的值是情况的名称,因此我们 Title 枚举中的 other 情况被推断为 other。
我们可以通过访问其 rawValue 属性来获取枚举的底层值:
let title1 = Title.mr
print(title1.rawValue) // "Mr"
还有更多...
如本食谱介绍中所述,Swift 将枚举视为一等类型,因此它们可以具有在大多数编程语言中不可用的功能。这包括具有计算属性和方法。
方法和计算属性
让我们设想,对我们来说知道一个人的头衔是否与该人持有的专业资格相关是很重要的。让我们向我们的枚举添加一个方法来提供这个信息:
enum Title: String {
case mr = "Mr"
case mrs = "Mrs"
case mister = "Master"
case miss = "Miss"
case dr = "Dr"
case prof = "Prof"
case other // Inferred as "other"
func isProfessional() -> Bool {
return self == Title.dr || self == Title.prof
}
}
对于我们定义的头衔列表,Dr 和 Prof 与专业资格相关,因此我们的方法在 self(调用此方法枚举类型的实例)等于 dr 情况或等于 prof 情况时返回 true。
在定义此方法时,我们使用了 ||,这是 OR 逻辑运算符。使用此运算符返回一个 true Bool 值,如果左侧的表达式评估为 true 或右侧的表达式评估为 true。另一个有用的常见运算符是 AND 运算符 &&,但这个运算符不适用于此方法。
这种功能感觉更适合作为计算属性,因为isProfessional是否是枚举本身的内在属性,我们不需要做太多工作来确定答案。所以,让我们将其改为属性:
enum Title: String {
case mr = "Mr"
case mrs = "Mrs"
case mister = "Master"
case miss = "Miss"
case dr = "Dr"
case prof = "Prof"
case other // Inferred as "other"
var isProfessional: Bool {
return self == Title.dr || self == Title.prof
}
}
现在,我们可以通过访问其上的计算属性来确定标题是否是专业标题:
let loganTitle = Title.mr
let xavierTitle = Title.prof
print(loganTitle.isProfessional) // false
print(xavierTitle.isProfessional) // true
我们不能在枚举上存储任何额外的信息,除了枚举值本身之外,但能够定义提供额外枚举信息的方法和计算属性是一个真正强大的选项。
关联值
我们基于字符串的枚举似乎非常适合我们的标题信息,除了我们有一个名为other的情况。如果某人的标题在我们定义枚举时没有考虑,我们可以选择other,但这并没有捕捉到其他标题是什么。在我们的模型中,我们需要定义另一个属性来保存为other提供的值,但这将我们的标题定义拆分成了两个单独的属性,这可能会导致意外的值组合。
Swift 枚举针对这种情况有一个解决方案,即关联值。我们可以选择为每个枚举情况关联一个值,这样我们就可以将非可选字符串绑定到other情况。
让我们重写我们的Title枚举以使用关联值:
enum Title {
case mr
case mrs
case mister
case miss
case dr
case prof
case other(String)
}
我们通过在情况声明后用括号括起值的类型来定义other情况具有关联值。我们不需要为每个情况添加关联值。每个情况声明可以有不同的关联值类型,或者根本不添加。
包含关联值的枚举不能有原始类型,因为它们现在太复杂,无法由这些基本类型之一表示,所以我们的Title枚举不再是基于字符串的。
现在,让我们看看如何分配具有关联类型的枚举情况:
let mister: Title = .mr
let dame: Title = .other("Dame")
关联值在情况之后用括号声明,编译器强制类型与我们在枚举定义中声明的类型匹配。因为我们声明other情况具有非可选字符串,所以我们确保不能选择other标题而不提供其他标题的详细信息,并且我们不需要另一个属性来完全在我们的模型中代表Title。
参见
关于枚举的更多信息可以在swiftbook.link/docs/enums找到。
使用闭包传递功能
闭包也被称为匿名函数,这是解释它们的最佳方式。闭包是没有名称的函数,就像其他函数一样,它们可以接受一组输入参数,并可以返回输出。闭包的行为类似于其他基本类型。它们可以被分配、存储、传递,并用作函数和其他闭包的输入和输出。
在这个菜谱中,我们将探讨如何在代码中使用闭包以及何时使用。
准备工作
我们将继续构建本章前面提到的联系人应用示例,所以你应该使用与之前食谱相同的 playground。
如果你在新的 playground 中实现这个功能,首先添加之前食谱中的相关代码:
struct PersonName {
let givenName: String
let middleName: String
var familyName: String
func fullName() -> String {
return "\(givenName) \(middleName) \(familyName)"
}
mutating func change(familyName: String) {
self.familyName = familyName
}
}
class Person {
let birthName: PersonName
var currentName: PersonName
var countryOfResidence: String
init(name: PersonName, countryOfResidence: String = "UK") {
birthName = name
currentName = name
self.countryOfResidence = countryOfResidence
}
var displayString: String {
return "\(currentName.fullName()) - Location: \
(countryOfResidence)"
}
}
对于代码如何工作的解释,请参阅本章之前的食谱。
如何做...
现在,让我们定义多种类型的闭包,然后我们将逐步处理它们:
- 定义一个打印这位作者详情的闭包,它不接受任何输入也不返回任何输出:
// No input, no output
let printAuthorsDetails: () -> Void = {
let name = PersonName(givenName: "Keith",
middleName: "David",
familyName: "Moon")
let author = Person(name: name)
print(author.displayString)
}
printAuthorsDetails() // "Keith David Moon - Location: UK"
- 定义一个创建
Person对象的闭包。闭包不接受任何输入,但返回一个Person对象作为输出:
// No input, Person output
let createAuthor: () -> Person = {
let name = PersonName(givenName: "Keith",
middleName: "David",
familyName: "Moon")
let author = Person(name: name)
return author
}
let author = createAuthor()
print(author.displayString) // "Keith David Moon - Location: UK"
- 定义一个打印个人详情的闭包,以他们的名字的三个组成部分作为
String输入,但不返回任何输出:
// String inputs, no output
let printPersonsDetails: (String, String, String) -> Void = { given,
middle, family in
let name = PersonName(givenName: given,
middleName: middle,
familyName: family)
let author = Person(name: name)
print(author.displayString)
}
printPersonsDetails("Kathleen", "Mary", "Moon")
// "Kathleen Mary Moon - Location: UK"
- 最后,定义一个闭包来创建一个人,将三个名字组成部分作为字符串输入,并返回一个
Person对象作为输出:
// String inputs, Person output
let createPerson: (String, String, String) -> Person = { given,
middle, family in
let name = PersonName(givenName: given,
middleName: middle,
familyName: family)
let person = Person(name: name)
return person
}
let felix = createPerson("Felix", "Robert", "Moon")
print(felix.displayString) // "Felix Robert Moon - Location: UK"
它是如何工作的...
让我们看看我们刚刚实现的不同类型的闭包:
// No input, no output
let printAuthorsDetails: () -> Void = {
let name = PersonName(givenName: "Keith", middleName: "David",
familyName: "Moon")
let author = Person(name: name)
print(author.displayString)
}
在 Swift 中,闭包作为一等类型,可以被分配给常量或变量,而常量和变量需要一个类型。为了定义闭包的类型,我们需要指定输入参数类型和输出类型,对于前面代码中的闭包,其类型是() -> Void。Void类型是“无”的另一种说法,所以这个闭包不接受任何输入并返回无,闭包的功能定义在大括号内,就像其他函数一样。
现在我们已经定义了这个闭包并将其分配给printAuthorsDetails常量,我们可以像其他函数一样执行它,但使用变量名,而不是函数名。使用这个闭包,将会打印出这位作者的详情:
printAuthorsDetails() // "Keith David Moon - Location: UK"
下一个闭包类型不接受任何输入参数,但返回一个Person对象,正如你可以从() -> Person类型定义中看到的那样:
// No input, Person output
let createAuthor: () -> Person = {
let name = PersonName(givenName: "Keith", middleName: "David",
familyName: "Moon")
let author = Person(name: name)
return author
}
let author: Person = createAuthor()
print(author.displayString) // "Keith David Moon - Location: UK"
由于它有一个输出,闭包的执行返回一个可以分配给变量或常量的值。在前面代码中,我们执行了createAuthor闭包,并将输出分配给author常量。由于我们定义了闭包类型为() -> Person,编译器知道输出类型是Person,因此可以推断出常量的类型。由于我们不需要显式声明它,让我们删除类型声明:
let author = createAuthor()
print(author.displayString) // "Keith David Moon - Location: UK"
接下来,让我们看看一个接受输入参数的闭包:
// String inputs, no output
let printPersonsDetails: (String, String, String) -> Void = { given,
middle, family in
let name = PersonName(givenName: given, middleName: middle,
familyName: family)
let author = Person(name: name)
print(author.displayString)
}
printPersonsDetails("Kathleen", "Mary", "Moon") // "Kathleen Mary Moon
- Location: UK"
你会记得,从函数的食谱中,我们可以定义参数标签,这决定了函数使用时如何引用参数,以及参数名称,这定义了函数内部如何引用参数。在闭包中,这些定义略有不同:
- 闭包不能定义参数标签,所以在调用闭包时,必须使用顺序和参数类型来确定应该提供哪些值作为参数:
(String, String, String) -> Void
- 参数名称定义在大括号内,后面跟着
in关键字:
given, middle, family in
将所有这些放在一起,我们可以定义并执行一个带有输入和输出的闭包,如下所示:
// String inputs, Person output
let createPerson: (String, String, String) -> Person = { given, middle,
family in
let name = PersonName(givenName: given,
middleName: middle,
familyName: family)
let person = Person(name: name)
return person
}
let felix = createPerson("Felix", "Robert", "Moon")
print(felix.displayString) // "Felix Robert Moon - Location: UK"
还有更多...
我们已经看到了如何存储闭包,但我们也可以将它们用作方法参数。当我们想要在长时间运行的任务完成后得到通知时,这种模式非常有用。
让我们假设我们想要将我们的Person对象的详细信息保存到远程数据库中,可能是为了备份或在其他设备上使用。我们可能希望在过程完成后得到通知,因此执行一些额外的代码,比如打印一个完成消息,或者更新一些用户界面。虽然实际的保存实现超出了本食谱的范围,但我们可以修改我们的Person类,以允许调用这个保存功能,传递一个在完成时执行的闭包。
添加一个保存到远程数据库的方法,接受一个完成“处理器”,并将其存储以供后续执行:
class Person {
//....
var saveHandler: ((Bool) -> Void)?
func saveToRemoteDatabase(handler: @escaping (Bool) -> Void) {
saveHandler = handler
// Send person information to remove database
// Once remote save is complete, it calls saveComplete(Bool)
// We'll fake it for the moment, and assume the save is
// complete.
saveComplete(success: true)
}
func saveComplete(success: Bool) {
saveHandler?(success)
}
}
我们定义了一个可选变量,在长时间运行的保存操作期间保持保存处理器的状态。我们的闭包将接受一个Bool来指示保存是否成功:
var saveHandler: ((Bool) -> Void)?
现在我们定义一个方法来保存我们的Person对象,该方法接受一个作为参数的闭包:
func saveToRemoteDatabase(handler: @escaping (Bool) -> Void) {
saveHandler = handler
// Send person information to remove database
// Once remote save is complete, it calls saveComplete(Bool)
// We'll fake it for the moment, and assume the save is complete.
saveComplete(success: true)
}
我们的函数将给定的闭包存储在变量中,然后开始将数据保存到远程数据库的过程(这个过程的实际实现超出了本食谱的范围)。这个保存过程将在完成后调用saveComplete方法。
我们在闭包类型定义之前添加了一个修饰符@escaping。这告诉编译器,我们打算将闭包存储起来并在以后使用,而不是在这个方法中使用闭包。闭包将“逃离”这个方法的范围。这个修饰符是必要的,以防止编译器执行某些优化,如果闭包是“非逃离”的,这些优化是可能的。它还有助于让这个方法的使用者理解他们提供的闭包是立即执行,还是稍后执行。
保存操作完成后,我们可以执行saveHandler变量,传入success布尔值:
func saveComplete(success: Bool) {
saveHandler?(success)
}
由于我们将闭包存储为可选的,我们需要通过在变量名后添加一个?来解包它。如果saveHandler有值,闭包将被执行;如果它是nil,表达式将被忽略。
现在我们有一个接受闭包的函数,让我们看看如何调用它:
let fox = createPerson("Fox", "Richard", "Moon")
fox.saveToRemoteDatabase(handler: { success in
print("Saved finished. Successful: \(success)")
})
Swift 提供了一种更简洁的方式来向函数提供闭包。当一个闭包是最后一个(或唯一的)参数时,Swift 允许它作为尾随闭包提供。这意味着可以省略参数名,闭包可以指定在参数括号之后。因此,我们可以用以下更简洁的语法重写前面的代码:
let fox = createPerson("Fox", "Richard", "Moon")
fox.saveToRemoteDatabase() { success in
print("Saved finished. Successful: \(success)")
}
参见
关于闭包的更多信息可以在swiftbook.link/docs/closures找到。
使用协议定义接口
协议是描述类型提供的接口的一种方式。它们可以被视为一份合同,定义了你可以如何与该类型的实例交互。协议是抽象“做什么”与“如何做”的一个很好的方式。正如我们将在后续章节中看到的,Swift 向协议添加了功能,使它们比许多其他编程语言中的协议更有用和强大。
准备工作
我们将继续构建前一个菜谱中的示例,但如果你还没有遵循这些菜谱,不要担心,因为所有需要的代码都列在即将到来的部分中。
如何做到这一点...
在上一个菜谱中,我们向我们的Person类添加了一个方法,该方法(给定完整的实现)可以将它保存到远程数据库中。这是一个非常有用的功能,随着我们向我们的应用程序添加更多功能,我们可能还需要将更多类型保存到远程数据库中:
- 创建一个协议来定义我们将如何与可以通过这种方式保存的任何东西进行接口:
protocol Saveable {
var saveNeeded: Bool { get set }
func saveToRemoteDatabase(handler: @escaping (Bool) -> Void)
}
- 更新我们的
Person类,使其符合Saveable协议:
class Person: Saveable {
//....
var saveHandler: ((Bool) -> Void)?
var saveNeeded: Bool = true
func saveToRemoteDatabase(handler: @escaping (Bool) -> Void) {
saveHandler = handler
// Send person information to remove database
// Once remote save is complete, it calls
// saveComplete(Bool)
// We'll fake it for the moment, and assume the save is
// complete.
saveComplete(success: true)
}
func saveComplete(success: Bool) {
saveHandler?(success)
}
}
它是如何工作的...
协议是用protocol关键字定义的,实现包含在大括号内。正如我们看到的其他类型定义一样,通常以大写字母开始协议名称。将协议命名为类型是或做的事情也是惯例。在这个协议中,我们声明任何类型的实现都是可保存的。
符合此协议的类型需要实现接口的两个部分。让我们看看第一个:
var saveNeeded: Bool { get set }
Saveable协议声明,任何实现它的类型都需要一个名为saveNeeded的变量,它是一个Bool。这个属性将指示远程数据库中保存的信息已过时,需要保存。除了通常的属性声明外,协议还要求我们定义属性是否可以访问(get)和更改(set),这是在类型声明之后的大括号中添加的。移除set关键字使其成为只读变量。
将协议属性定义为只读并不能阻止实现类型允许设置该属性,只是该属性的设置没有在接口中定义。
我们协议定义的第二部分是描述我们可以调用的方法,将信息保存到远程数据库中:
func saveToRemoteDatabase(handler: @escaping (Bool) -> Void)
这个func声明与我们见过的其他函数声明完全相同。然而,这个函数的实现,本应包含在大括号内,被省略了。任何符合此协议的类型都必须提供这个函数及其实现。
现在我们已经定义了我们的协议,我们需要在我们的Person类上实现Saveable协议,这是我们在这个章节中一直在使用的:
class Person: Saveable {
//....
var saveHandler: ((Bool) -> Void)?
func saveToRemoteDatabase(handler: @escaping (Bool) -> Void) {
saveHandler = handler
// Send person information to remove database
// Once remote save is complete, it calls saveComplete(Bool)
// We'll fake it for the moment, and assume the save is
// complete.
saveComplete(success: true)
}
func saveComplete(success: Bool) {
saveHandler?(success)
}
}
遵守协议看起来就像我们在本章前面看到的类继承自另一个类一样。协议名称添加在类型名称之后,用冒号:分隔。通过添加这个遵守,编译器会抱怨我们的Person对象没有实现协议的一部分,因为我们没有声明saveNeeded属性。所以让我们添加它:
class Person: Saveable {
//....
var saveHandler: ((Bool) -> Void)?
var saveNeeded: Bool = true
func saveToRemoteDatabase(handler: @escaping (Bool) -> Void) {
saveHandler = handler
// Send person information to remove database
// Once remote save is complete, it calls saveComplete(Bool)
// We'll fake it for the moment, and assume the save is
// complete.
saveComplete(success: true)
}
func saveComplete(success: Bool) {
saveHandler?(success)
}
}
我们将添加一个默认值true,因为当创建这个对象的实例时,它不会在远程数据库中,因此需要被保存。
还有更多...
协议遵守可以应用于类、结构体、枚举,甚至其他协议。协议的好处是它允许实例被存储和传递,而不需要知道其底层的实现方式。这提供了许多好处,包括使用模拟对象进行测试和在不改变实现方式和位置的情况下更改实现。
让我们在我们的应用中添加一个功能,允许我们为联系人的生日设置提醒,我们还将希望将其保存到我们的远程数据库中。
我们可以使用协议遵守来为我们的提醒提供相同的、一致的保存功能接口,即使提醒在保存方面可能有非常不同的实现。
让我们创建我们的Reminder对象,并让它遵守Saveable协议:
class Reminder: Saveable {
var dateOfReminder: String // There is a better way to store dates,
// but this suffice currently.
var reminderDetail: String // eg. Ali's birthday
init(date: String, detail: String) {
dateOfReminder = date
reminderDetail = detail
}
var saveHandler: ((Bool) -> Void)?
var saveNeeded: Bool = true
func saveToRemoteDatabase(handler: @escaping (Bool) -> Void) {
saveHandler = handler
// Send reminder information to remove database
// Once remote save is complete, it calls
// saveComplete(success: Bool)
// We'll fake it for the moment, and assume the save is
// complete.
saveComplete(success: true)
}
func saveComplete(success: Bool) {
saveHandler?(success)
}
}
我们的Reminder对象遵守Saveable并实现了所有要求。
现在我们有两个代表非常不同事物且具有不同功能的对象,但它们都实现了Saveable,因此我们可以以相同的方式处理它们。
为了看到这个功能在实际中的应用,让我们创建一个对象来管理我们应用中的信息保存:
class SaveManager {
func save(_ thingToSave: Saveable) {
thingToSave.saveToRemoteDatabase { success in
print("Saved! Success: \(success)")
}
}
}
let nick = createPerson("Nick", "Edward", "Moon") // This closure was
// covered in the previous recipe
let birthdayReminder = Reminder(date: "12/06/2008", detail: "Nick's
Birthday")
let saveManager = SaveManager()
saveManager.save(nick)
saveManager.save(birthdayReminder)
在前面的例子中,SaveManager不知道它被传递的底层类型,但它不需要知道。它接收遵守Saveable协议的实例,因此可以使用该接口保存每个实例。
参见
关于协议的更多信息可以在swiftbook.link/docs/protocols找到。
掌握构建块
上一章解释了构成 Swift 语言基石的基本类型。在本章中,我们将在此基础上创建更复杂的数据结构,如数组和字典,然后再探讨 Swift 提供的一些小宝藏,如元组和 typealias。最后,我们将通过探讨扩展和访问控制来结束本章,这两者都是对构建稳健且高效的代码库至关重要的组件。
在本章中,我们将介绍以下食谱:
-
将变量打包成元组
-
使用数组对数据进行排序
-
使用集合来存储数据
-
使用字典存储键值对
-
自定义类型的下标
-
使用 typealias 更改你的名字
-
使用属性观察器获取属性更改通知
-
使用扩展扩展功能
-
使用访问控制来控制访问
让我们开始吧!
第三章:技术要求
本章的所有代码都可以在这本书的 GitHub 仓库中找到:github.com/PacktPublishing/Swift-Cookbook-Second-Edition/tree/master/Chapter02
查看以下视频,了解代码的实际应用:bit.ly/2YGayJh
将变量打包成元组
元组是由两个或更多值组成的组合,可以被视为一个整体。如果你曾经希望从一个函数或方法中返回多个值,你应该会发现元组非常有趣。
准备工作
创建一个新的游乐场并添加以下语句:
import Foundation
此示例使用 Foundation 中的一个函数。我们将在第五章“超越标准库”中更详细地探讨 Foundation,但就目前而言,我们只需要导入它。
如何做到这一点...
让我们想象我们正在构建一个应用程序,该应用程序从多个来源获取电影评分并将它们一起展示,以帮助用户决定看哪部电影。这些来源可能使用不同的评分系统,如下所示:
-
5 星中的星级数量
-
10 分中的得分
-
百分比得分
我们希望将这些评分标准化,以便可以直接比较并并排显示。我们希望所有评分都表示为 5 星中的星级数量,因此我们将编写一个函数,该函数将返回 5 星中的整星级数。然后我们将使用这个函数来显示用户界面(UI)中正确的星级数量。
我们的 UI 还包括一个标签,它将读取 x 星电影,其中 x 是星级数。如果我们的函数返回星级数和可以放入 UI 的字符串将非常有用。我们可以使用元组来完成这个任务。让我们开始吧:
- 创建一个函数来标准化星级评分。以下函数接受一个评分和一个可能的总评分,然后返回一个包含标准化评分和用于 UI 显示的字符串的元组:
func normalizedStarRating(forRating rating: Float,
ofPossibleTotal total: Float) -> (Int, String) {
}
- 在函数内部,计算总分数的分数。然后,将这个分数乘以我们的标准化总分数 5,并四舍五入到最接近的整数:
let fraction = rating / total
let ratingOutOf5 = fraction * 5
let roundedRating = round(ratingOutOf5) // Rounds to the nearest
// integer.
- 仍然在函数内部,将四舍五入的分数从
Float转换为Int。然后,创建显示字符串,并将Int和String作为元组返回:
let numberOfStars = Int(roundedRating) // Turns a Float into an Int
let ratingString = "\(numberOfStars) Star Movie"
return (numberOfStars, ratingString)
- 调用我们新的函数,并将结果存储在一个常量中:
let ratingAndDisplayString = normalisedStarRating(forRating: 5,
ofPossibleTotal: 10)
- 从元组中检索星级评分并打印结果:
let ratingNumber = ratingAndDisplayString.0
print(ratingNumber) // 3 - Use to show the right number of stars
- 从元组中检索显示字符串并打印结果:
let ratingString = ratingAndDisplayString.1
print(ratingString) // "3 Star Movie" - Use to put in the label
有了这个,我们就创建并使用了元组。
它是如何工作的...
元组被声明为一个逗号分隔的类型列表,用括号括起来。在上面的代码中,你可以看到一个被声明为(Int, String)的元组。函数normalizedStarRating标准化评分,并将numberOfStars作为最接近的星级数,ratingString作为显示字符串。然后,将这些值通过在括号内用逗号分隔组合成一个元组;即(numberOfStars, ratingString)。这个元组值随后由函数返回。
接下来,让我们看看我们可以用返回的元组值做什么:
let ratingAndDisplayString = normalizedStarRating(forRating: 5,
ofPossibleTotal: 10)
调用我们的函数返回一个元组,我们将其存储在一个名为ratingAndDisplayString的常量中。我们可以通过访问元组的编号成员来访问元组的组件:
let ratingNumber = ratingAndDisplayString.0
print(ratingNumber) // 3 - Use to show the right number of stars
let ratingString = ratingAndDisplayString.1
print(ratingString) // "3 Star Movie" - Use to put in the label
与大多数编程语言中的编号系统一样,成员编号系统从0开始。用于识别编号集合中某个位置的数字称为索引。
有另一种方法可以检索元组的组件,这可能比使用编号索引更容易记住。通过指定一个变量名元组,元组的每个值将被分配给相应的变量名。因此,我们可以简化访问元组值和打印结果:
let (nextNumber, nextString) = normalizedStarRating(forRating: 8,
ofPossibleTotal: 10)
print(nextNumber) // 4
print(nextString) // "4 Star Movie"
由于数值是返回元组中的第一个值,因此它被分配给nextNumber常量,而第二个值,即字符串,被分配给nextString。然后,它们可以像任何其他常量一样使用,从而消除了记住哪个索引对应哪个值的需求。
还有更多...
正如我们之前提到的,通过数字访问元组的组件并不理想,因为我们必须记住它们在元组中的顺序以确保我们访问的是正确的组件。为了提供一些上下文,我们可以在元组组件上添加标签,这样在访问它们时就可以用来识别。元组标签的定义方式与参数标签类似,位于类型之前,并用冒号分隔。让我们给这个菜谱中创建的函数添加标签,然后使用这些标签来访问元组值:
func normalizedStarRating(forRating rating: Float,
ofPossibleTotal total: Float)
-> (starRating: Int, displayString: String) {
let fraction = rating / total
let ratingOutOf5 = fraction * 5
let roundedRating = round(ratingOutOf5) // Rounds to the nearest
// integer.
let numberOfStars = Int(roundedRating) // Turns a Float into an Int
let ratingString = "\(numberOfStars) Star Movie"
return (starRating: numberOfStars, displayString: ratingString)
}
let ratingAndDisplayString = normalizedStarRating(forRating: 5,
ofPossibleTotal: 10)
let ratingInt = ratingAndDisplayString.starRating
print(ratingInt) // 3 - Use to show the right number of stars
let ratingString = ratingAndDisplayString.displayString
print(ratingString) // "3 Stars" - Use to put in the label
作为函数声明的一部分,我们可以看到元组是如何被声明的:
(starRating: Int, displayString: String)
当创建这种类型的元组时,提供的值前面有一个标签:
return (starRating: numberOfStars, displayString: ratingString)
要访问元组的组件,我们可以使用这些标签(尽管索引的数量仍然有效):
let ratingValue = ratingAndDisplayString.starRating
print(ratingValue) // 3 - Use to show the right number of stars
let ratingString = ratingAndDisplayString.displayString
print(ratingString) // "3 Stars" - Use to put in the label
元组是捆绑值的一种方便且轻量级的方式。
在这个例子中,我们创建了一个包含两个组件的元组。然而,元组可以包含任意数量的组件。
参见
关于元组的更多信息可以在 Apple 的 Swift 语言文档中找到,请参阅docs.swift.org/swift-book/ReferenceManual/Types.html。
使用数组对数据进行排序
到目前为止,在这本书中,我们已经学习了多种不同的 Swift 构造:类、结构体、枚举、闭包、协议和元组。然而,单独处理这些构造中的一个实例的情况很少见。通常,我们会拥有许多这样的构造,我们需要一种方法来收集多个实例并将它们放置在有用的数据结构中。在接下来的几个菜谱中,我们将检查 Swift 提供的三个集合数据结构;即,数组、集合和字典(在其他编程语言中,字典通常被称为哈希表):

图 2.1 – 数据结构集合
在做这件事的时候,我们将看看如何使用它们来存储和访问信息,然后检查它们的相对特性。
开始
首先,让我们研究数组,它们是有序元素列表。我们不会使用之前菜谱中的任何组件,因此你可以为这个菜谱创建一个新的游乐场。
如何做到这一点...
让我们使用数组来组织要观看的电影列表:
- 创建一个名为
moviesToWatch的数组。这将存储我们的字符串:
var moviesToWatch: Array<String> = Array()
- 将三部电影追加到我们的电影列表数组末尾:
moviesToWatch.append("The Shawshank Redemption")
moviesToWatch.append("Ghostbusters")
moviesToWatch.append("Terminator 2")
- 按顺序打印列表中每部电影的名称:
print(moviesToWatch[0]) // "The Shawshank Redemption"
print(moviesToWatch[1]) // "Ghostbusters"
print(moviesToWatch[2]) // "Terminator 2"
- 打印到目前为止列表中电影的数量:
print(moviesToWatch.count) // 3
- 在列表中插入一部新电影,使其成为第三个。由于数组是基于 0 的,这是在索引 2 处完成的:
moviesToWatch.insert("The Matrix", at: 2)
- 打印列表计数以检查它是否增加了 1,并打印更新后的新列表:
print(moviesToWatch.count) // 4
print(moviesToWatch)
// The Shawshank Redemption
// Ghostbusters
// The Matrix
// Terminator 2
- 使用
first和last数组属性访问它们各自的值并打印它们:
let firstMovieToWatch = moviesToWatch.first
print(firstMovieToWatch as Any) // Optional("The Shawshank
Redemption")
let lastMovieToWatch = moviesToWatch.last
print(lastMovieToWatch as Any) // Optional("Terminator 2")
- 使用索引下标访问列表中的第二部电影并打印它。然后,将新值设置到相同的下标。一旦完成,打印列表计数以检查未更改的电影数量,并打印列表以检查第二个数组元素是否已更改:
let secondMovieToWatch = moviesToWatch[1]
print(secondMovieToWatch) // "Ghostbusters"
moviesToWatch[1] = "Ghostbusters (1984)"
print(moviesToWatch.count) // 4
print(moviesToWatch)
// The Shawshank Redemption
// Ghostbusters (1984)
// The Matrix
// Terminator 2
- 使用数组字面量语法初始化一个新的间谍电影数组:
let spyMovieSuggestions: [String] = ["The Bourne Identity",
"Casino Royale",
"Mission Impossible"]
- 使用加法运算符(
+)将我们创建的两个数组合并,并将它们赋值回moviesToWatch变量。然后,打印数组计数以反映合并的两个列表,并打印新列表:
moviesToWatch = moviesToWatch + spyMovieSuggestions
print(moviesToWatch.count) // 7
print(moviesToWatch)
// The Shawshank Redemption
// Ghostbusters (1984)
// The Matrix
// Terminator 2
// The Bourne Identity
// Casino Royale
// Mission Impossible
- 接下来,使用数组便利初始化器创建一个包含三个相同条目的数组。然后,更新每个数组元素,以便显示其余的电影标题:
var starWarsTrilogy = Array<String>(repeating: "Star Wars: ",
count: 3)
starWarsTrilogy[0] = starWarsTrilogy[0] + "A New Hope"
starWarsTrilogy[1] = starWarsTrilogy[1] + "Empire Strikes Back"
starWarsTrilogy[2] = starWarsTrilogy[2] + "Return of the Jedi"
print(starWarsTrilogy)
// Star Wars: A New Hope
// Star Wars: Empire Strikes Back
// Star Wars: Return of the Jedi
- 让我们将我们现有的电影列表的一部分替换为我们的
starWarsTrilogy列表,然后打印计数和列表:
moviesToWatch.replaceSubrange(2...4, with: starWarsTrilogy)
print(moviesToWatch.count) // 7
print(moviesToWatch)
// The Shawshank Redemption
// Ghostbusters (1984)
// Star Wars: A New Hope
// Star Wars: Empire Strikes Back
// Star Wars: Return of the Jedi
// Casino Royale
// Mission Impossible
- 最后,删除列表中的最后一部电影,并检查数组计数是否减少了一个:
moviesToWatch.remove(at: 6)
print(moviesToWatch.count) // 6
print(moviesToWatch)
// The Shawshank Redemption
// Ghostbusters (1984)
// Star Wars: A New Hope
// Star Wars: Empire Strikes Back
// Star Wars: Return of the Jedi
// Casino Royale
这样,我们就探讨了多种创建和操作数组的方法。
它是如何工作的...
在创建数组时,我们需要指定将存储在数组中的元素类型。数组元素类型在数组的类型声明中用尖括号声明。在我们的例子中,我们存储字符串:
var moviesToWatch: Array<String> = Array()
moviesToWatch.append("The Shawshank Redemption")
moviesToWatch.append("Ghostbusters")
moviesToWatch.append("Terminator 2")
上一段代码使用了 Swift 语言的一个特性,称为 泛型,这在许多编程语言中都可以找到,将在第四章中详细介绍,泛型、运算符和嵌套类型。
Array 的 append 方法将在数组的末尾添加一个新元素。现在我们已经将一些元素放入数组中,我们可以检索并打印这些元素:
print(moviesToWatch[0]) // "The Shawshank Redemption"
print(moviesToWatch[1]) // "Ghostbusters"
print(moviesToWatch[2]) // "Terminator 2"
数组中的元素使用基于零的索引编号,因此数组中的第一个元素在索引 0,第二个在索引 1,第三个在索引 2,依此类推。我们可以使用下标访问数组中的元素,其中我们提供要访问的元素的索引。下标在数组实例名称后用方括号指定。
当使用索引下标访问元素时,不会进行检查以确保你提供了有效的索引。实际上,如果提供了一个数组不包含的索引,这将导致崩溃。相反,我们可以使用 Array 上的某些索引辅助方法来确保我们有一个适用于此数组的有效索引。让我们使用这些辅助方法之一来检查我们已知适用于我们数组的索引,然后使用另一个我们知道不是有效索引的索引:
let index5 = moviesToWatch.index(moviesToWatch.startIndex,
offsetBy: 5,
limitedBy: moviesToWatch.endIndex)
print(index5 as Any) // Optional(5)
let index10 = moviesToWatch.index(moviesToWatch.startIndex,
offsetBy: 10,
limitedBy: moviesToWatch.endIndex)
print(index10 as Any) // nil
index 方法允许我们指定我们想要的索引作为第一个索引参数的偏移量,但作为受最后一个索引参数限制的偏移量。如果它在范围内,这将返回有效索引,如果不在范围内,则返回 nil。到游乐场结束时,moviesToWatch 数组包含六个元素,在这种情况下,检索索引 5 是成功的,但索引 10 返回 nil。
在下一章中,我们将介绍如何根据该索引是否存在来做出决策,但到目前为止,了解这种方法可用就足够了。
数组有一个 count 属性,它告诉我们它们存储了多少个元素。因此,当我们添加一个元素时,这个值将改变:
print(moviesToWatch.count) // 3
可以使用与前面代码中相同的基于零的索引在任何位置插入数组中的元素:
moviesToWatch.insert("The Matrix", at: 2)
因此,通过在索引 2 处插入 "The Matrix",它将被放置在我们数组中的第三个位置,并且索引 2 或更大的所有元素都将向下移动 1 位。
这增加了数组的计数:
print(moviesToWatch.count) // 4
数组还提供了一些有用的计算属性,用于访问数组的两端元素:
let firstMovieToWatch = moviesToWatch.first
print(firstMovieToWatch as Any) // Optional("The Shawshank Redemption")
let lastMovieToWatch = moviesToWatch.last
print(firstMovieToWatch as Any) // Optional("Terminator 2")
let secondMovieToWatch = moviesToWatch[1]
print(secondMovieToWatch) // "Ghostbusters"
这些属性是可选值,因为数组可能为空,如果是这样,这些值将是 nil。然而,通过索引下标访问数组元素返回的是一个非可选值。
除了通过下标检索值之外,我们还可以将值赋给数组下标:
moviesToWatch[1] = "Ghostbusters (1984)"
这将用新值替换给定索引处的元素。
当我们创建第一个数组时,我们创建了一个空数组,然后向其中添加了值。此外,还可以使用数组字面量来创建一个已经包含值的数组:
let spyMovieSuggestions: [String] = ["The Bourne Identity",
"Casino Royale",
"Mission Impossible"]
可以使用方括号内的元素类型来指定数组类型,并且可以通过方括号内用逗号分隔的元素来定义数组字面量。因此,我们可以这样定义一个整数数组:
let fibonacci: [Int] = [1, 1, 2, 3, 5, 8, 13, 21, 34, 55]
正如我们在上一章中学到的,编译器通常可以从我们分配的值中推断类型,并且当类型被推断时,我们不需要指定它。在前面的两个数组中,spyMovieSuggestions 和 fibonacci,数组中的所有元素都是同一类型;即 String 和 Int,分别。由于这些类型可以推断,我们不需要定义它们:
let spyMovieSuggestions = ["The Bourne Identity", "Casino Royale",
"Mission Impossible"]
let fibonacci = [1, 1, 2, 3, 5, 8, 13, 21, 34, 55]
可以使用 + 运算符组合数组:
moviesToWatch = moviesToWatch + spyMovieSuggestions
这将通过将第二个数组中的元素追加到第一个数组中来创建一个新的数组。
数组提供了一个方便的初始化器,该初始化器将重复元素填充到数组中。我们可以使用这个初始化器来创建一个包含著名电影三部曲名称的数组:
var starWarsTrilogy = Array<String>(repeating: "Star Wars: ", count: 3)
然后,我们可以结合下标访问、字符串连接和下标赋值,将完整的电影名称添加到我们的三部曲数组中:
starWarsTrilogy[0] = starWarsTrilogy[0] + "A New Hope"
starWarsTrilogy[1] = starWarsTrilogy[1] + "Empire Strikes Back"
starWarsTrilogy[2] = starWarsTrilogy[2] + "Return of the Jedi"
数组还提供了一个辅助函数,用于将一组值替换为另一个数组中的值:
moviesToWatch.replaceSubrange(2...4, with: starWarsTrilogy)
在这里,我们使用 ... 来指定两个整数值之间的范围,包括这些值。因此,这个范围包含整数 2、3 和 4。
在后续章节中,我们将以这种方式指定范围。或者,您可以指定一个范围,该范围向上到但不包括范围的顶部。这被称为半开区间:
moviesToWatch.replaceSubrange(2..<5, with: starWarsTrilogy)
对于我们的数组,我们已经添加了元素、访问了它们,并替换了它们,因此我们需要知道如何从数组中删除元素:
moviesToWatch.remove(at: 6)
将元素的索引提供给 remove 方法。通过这样做,该索引处的元素将从数组中移除,并且所有后续元素将向上移动一个位置以填充空位。这将使数组的计数减少 1:
print(moviesToWatch.count) // 6
还有更多...
如果你熟悉 Objective-C,你将使用 NSArray,它提供了与 Swift 数组类似的功能。你可能也记得 NSArray 是不可变的,这意味着一旦创建,其内容就不能更改。如果你需要更改其内容,则应使用 NSMutableArray。因此,你可能想知道 Swift 是否有类似可变和不可变数组的概念。它确实有,但与使用单独的可变和不可变类型不同,你通过将其声明为变量来创建可变数组,通过将其声明为常量来创建不可变数组:
let evenNumbersTo10 = [2, 4, 6, 8, 10]
evenNumbersTo10.append(12) // Doesn't compile
var evenNumbersTo12 = evenNumbersTo10
evenNumbersTo12.append(12) // Does compile
要理解为什么是这样,重要的是要知道数组是一个值类型,Swift 中的其他集合类型也是值类型。
正如我们在上一章中看到的,值类型本质上是不可变的,并且每次它被修改时都会创建一个更改后的副本。因此,通过使用 let 将数组分配给常量,我们防止了任何新值被分配,这使得修改数组成为不可能。
参见
更多关于数组的信息可以在 Apple 的 Swift 语言文档中找到,链接为 developer.apple.com/documentation/swift/array。
数组使用泛型来定义它们包含的元素类型。泛型将在第四章 Generics, Operators, and Nested Types 中详细讨论。
在集合中保存你的数据
我们将要查看的下一个集合类型是 集合。集合与数组在两个重要方面有所不同。集合中的元素是 无序 存储的,并且每个唯一的元素只保留一次。在这个菜谱中,我们将学习如何创建和操作集合。
如何做到这一点...
首先,让我们探索一些我们可以创建集合并在其上执行集合代数的方法:
- 创建一个包含前九个斐波那契数的数组,以及一个包含相同元素的集合:
let fibonacciArray: Array<Int> = [1, 1, 2, 3, 5, 8, 13, 21, 34]
let fibonacciSet: Set<Int> = [1, 1, 2, 3, 5, 8, 13, 21, 34]
print(fibonacciArray.count) // 9
print(fibonacciSet.count) // 8
- 使用
count属性打印出每个集合中的元素数量。尽管它们是由相同的元素创建的,但计数值是不同的:
print(fibonacciArray.count) // 9
print(fibonacciSet.count) // 8
- 向一个动物集合中插入一个元素,删除一个元素,并检查集合是否包含一个给定的元素:
var animals: Set<String> = ["cat", "dog", "mouse", "elephant"]
animals.insert("rabbit")
print(animals.contains("dog")) // true
animals.remove("dog")
print(animals.contains("dog")) // false
- 创建包含常见数学数字组的集合。我们将使用这些集合来探索一些集合代数的方法:
let evenNumbers = Set<Int>(arrayLiteral: 2, 4, 6, 8, 10)
let oddNumbers: Set<Int> = [1, 3, 5, 7, 9]
let squareNumbers: Set<Int> = [1, 4, 9]
let triangularNumbers: Set<Int> = [1, 3, 6, 10]
- 获取两个集合的并集并打印结果:
let evenOrTriangularNumbers = evenNumbers.union(triangularNumbers)
// 2, 4, 6, 8, 10, 1, 3, unordered
print(evenOrTriangularNumbers.count) // 7
- 获取两个集合的交集并打印结果:
let oddAndSquareNumbers = oddNumbers.intersection(squareNumbers)
// 1, 9, unordered
print(oddAndSquareNumbers.count) // 2
- 获取两个集合的对称差并打印结果:
let squareOrTriangularNotBoth =
squareNumbers.symmetricDifference(triangularNumbers)
// 4, 9, 3, 6, 10, unordered
print(squareOrTriangularNotBoth.count) // 5
- 获取从另一个集合中减去一个集合的结果并打印结果:
let squareNotOdd = squareNumbers.subtracting(oddNumbers) // 4
print(squareNotOdd.count) // 1
接下来,我们将检查可用的集合成员比较方法:
- 创建一些具有重叠成员的集合:
let animalKingdom: Set<String> = ["dog", "cat", "pidgeon",
"chimpanzee", "snake", "kangaroo",
"giraffe", "elephant", "tiger",
"lion", "panther"]
let vertebrates: Set<String> = ["dog", "cat", "pidgeon",
"chimpanzee", "snake", "kangaroo",
"giraffe", "elephant", "tiger",
"lion", "panther"]
let reptile: Set<String> = ["snake"]
let mammals: Set<String> = ["dog", "cat", "chimpanzee",
"kangaroo", "giraffe", "elephant",
"tiger", "lion", "panther"]
let catFamily: Set<String> = ["cat", "tiger", "lion", "panther"]
let domesticAnimals: Set<String> = ["cat", "dog"]
- 使用
isSubset方法确定一个集合是否是另一个集合的子集。然后,打印结果:
print(mammals.isSubset(of: animalKingdom)) // true
- 使用
isSuperset方法确定一个集合是否是另一个集合的超集。然后,打印结果:
print(mammals.isSuperset(of: catFamily)) // true
- 使用
isStrictSubset方法确定一个集合是否是另一个集合的严格子集。然后,打印结果:
print(vertebrates.isStrictSubset(of: animalKingdom)) // false
print(mammals.isStrictSubset(of: animalKingdom)) // true
- 使用
isStrictSuperset方法确定一个集合是否是另一个集合的严格超集。然后,打印结果:
print(animalKingdom.isStrictSuperset(of: vertebrates)) // false
print(animalKingdom.isStrictSuperset(of: domesticAnimals)) // true
- 使用
isDisjoint方法确定一个集合是否与另一个集合不交集。然后,打印结果:
print(catFamily.isDisjoint(with: reptile)) // true
它是如何工作的...
集合的创建几乎与数组相同,并且像数组一样,我们必须指定将要存储在其中的元素类型:
let fibonacciArray: Array<Int> = [1, 1, 2, 3, 5, 8, 13, 21, 34]
let fibonacciSet: Set<Int> = [1, 1, 2, 3, 5, 8, 13, 21, 34]
数组和集合存储元素的方式不同。如果你向数组提供多个相同值的元素,它将会多次存储它们。集合的工作方式不同;它只会存储每个唯一元素的单一版本。因此,在先前的斐波那契数列中,数组存储了前两个值1, 1的两个元素,但集合只会存储这个1元素。这导致集合具有不同的计数,尽管它们是用相同的值创建的:
print(fibonacciArray.count) // 9
print(fibonacciSet.count) // 8
这种唯一存储元素的能力是由于集合对其可以持有的元素类型的要求。集合的元素必须符合Hashable协议。该协议要求提供一个hashValue属性作为Int,集合使用这个hashValue来进行唯一性比较。Int和String类型都符合Hashable,但任何将存储在集合中的自定义类型也需要符合Hashable。
集合的insert、remove和contains方法按预期工作,编译器强制执行提供正确的类型。这种编译器类型检查是通过所有集合类型都具有的泛型约束来完成的。我们将在第四章中更详细地介绍泛型,泛型、运算符和嵌套类型。
并集
union方法返回一个包含调用该方法时集合的所有唯一元素以及作为参数提供的集合的集合:
let evenOrTriangularNumbers = evenNumbers.union(triangularNumbers)
// 2,4,6,8,10,1,3,unordered
以下图表描述了集合 A 和集合 B 的并集:

图 2.2 – 集合并集
交集
intersection方法返回一个集合,其中包含调用该方法时集合和作为参数提供的集合中共同包含的唯一元素:
let oddAndSquareNumbers = oddNumbers.intersection(squareNumbers)
// 1, 9, unordered
以下图表描述了集合 A 和集合 B 的交集:

图 2.3 – 集合交集
对称差集
symmetricDifference方法返回一个集合,其中包含调用该方法时集合或提供的参数集合中的唯一元素,但不包括两个集合都有的元素:
let squareOrTriangularNotBoth =
squareNumbers.symmetricDifference(triangularNumbers)
// 4, 9, 3, 6, 10, unordered
这种set操作有时被称为方法,因为在其他编程语言中,包括 Swift 的早期版本,都称为exclusiveOr。
以下图表描述了集合 A 和集合 B 的对称差集:

图 2.4 – 对称差集
减法
subtracting 方法返回一个唯一的元素集合,这些元素可以在调用该方法所在的集合中找到,但不在传递作为参数的集合中。与我们所提到的其他集合操作方法不同,如果您交换调用该方法所在的集合与提供的参数集合,则此方法不一定返回相同的值:
let squareNotOdd = squareNumbers.subtracting(oddNumbers) // 4
以下图示展示了从集合 A 中减去集合 B 所创建的集合:

图 2.5– 减去一个集合
成员比较
除了集合操作方法之外,还有一些我们可以用来确定集合成员信息的方法。
isSubset 方法将在调用该方法所在的集合中的所有元素都包含在传递作为参数的集合中时返回 true:
print(mammals.isSubset(of: animalKingdom)) // true
以下图示展示了集合 B 是集合 A 的子集:

图 2.6 – 子集
如果两个集合相等(它们包含相同的元素),这也会返回 true。如果您只想在调用该方法所在的集合是子集且不相等时返回 true,则可以使用 isStrictSubset:
print(vertebrates.isStrictSubset(of: animalKingdom)) // false
print(mammals.isStrictSubset(of: animalKingdom)) // true
isSuperset 方法将在所有作为参数传递到集合中的元素都包含在调用该方法所在的集合中时返回 true:
print(mammals.isSuperset(of: catFamily)) // true
以下图示展示了集合 A 是集合 B 的超集:

图 2.7 – 超集
如果两个集合相等(它们包含相同的元素),这也会返回 true。如果您只想在调用该方法所在的集合是超集且不相等时返回 true,则可以使用 isStrictSuperset:
print(animalKingdom.isStrictSuperset(of: vertebrates)) // false
print(animalKingdom.isStrictSuperset(of: domesticAnimals)) // true
isDisjoint 方法将在调用该方法所在的集合和传递作为参数的集合之间没有共同元素时返回 true:
print(catFamily.isDisjoint(with: reptile)) // true
以下图示表明集合 A 和集合 B 是互斥的:

图 2.8 – 互斥
与数组一样,可以通过将其分配给 let 常量而不是 var 变量来声明集合为不可变:
let planets: Set<String> = ["Mercury", "Venus", "Earth",
"Mars", "Jupiter", "Saturn",
"Uranus", "Neptune", "Pluto"]
planets.remove("Pluto") // Doesn't compile
这是因为集合,就像其他集合类型一样,是一个值类型。移除一个元素将修改集合,这会创建一个新的副本,但 let 常量不能分配新的值,因此编译器会阻止任何修改操作。
参见
关于数组的更多信息可以在苹果公司关于 Swift 语言的文档中找到,请参阅docs.swift.org/swift-book/LanguageGuide/CollectionTypes.html。
集合使用泛型来定义它们包含的元素类型。泛型将在第四章中详细讨论,泛型、运算符和嵌套类型。
使用字典存储键值对
我们将要查看的最后一种集合类型是字典。这是编程语言中的一个熟悉的结构,有时被称为哈希表。字典包含键和值之间的配对集合。键可以是符合Hashable协议的任何元素(就像集合中的元素一样),而值可以是任何类型。字典的内容不是按顺序存储的,与数组不同;相反,键在存储值和检索值时都使用。
准备工作
在这个菜谱中,我们将使用字典来存储工作场所的人员详细信息。我们需要根据人员在组织中的角色(例如公司目录)来存储和检索个人信息。为了保存此人的信息,我们将使用从第一章,“Swift 构建块”中修改过的Person类。
将以下代码输入到一个新的游乐场中:
struct PersonName {
let givenName: String
let familyName: String
}
enum CommunicationMethod {
case phone
case email
case textMessage
case fax
case telepathy
case subSpaceRelay
case tachyons
}
class Person {
let name: PersonName
let preferredCommunicationMethod: CommunicationMethod
convenience init(givenName: String,
familyName: String,
commsMethod: CommunicationMethod) {
let name = PersonName(givenName: givenName, familyName:
familyName)
self.init(name: name, commsMethod: commsMethod)
}
init(name: PersonName, commsMethod: CommunicationMethod) {
self.name = name
preferredCommunicationMethod = commsMethod
}
var displayName: String {
return "\(name.givenName) \(name.familyName)"
}
}
如何操作...
让我们使用之前定义的Person对象,通过字典构建我们的工作场所目录:
- 创建一个员工目录的
Dictionary:
var crew = Dictionary<String, Person>()
- 用员工详细信息填充字典:
crew["Captain"] = Person(givenName: "Jean-Luc",
familyName: "Picard",
commsMethod: .phone)
crew["First Officer"] = Person(givenName: "William",
familyName: "Riker",
commsMethod: .email)
crew["Chief Engineer"] = Person(givenName: "Geordi",
familyName: "LaForge",
commsMethod: .textMessage)
crew["Second Officer"] = Person(givenName: "Data",
familyName: "Soong",
commsMethod: .fax)
crew["Councillor"] = Person(givenName: "Deanna",
familyName: "Troi",
commsMethod: .telepathy)
crew["Security Officer"] = Person(givenName: "Tasha",
familyName: "Yar",
commsMethod: .subSpaceRelay)
crew["Chief Medical Officer"] = Person(givenName: "Beverly",
familyName: "Crusher",
commsMethod: .tachyons)
- 获取字典中所有键的数组。这将给我们一个包含组织中所有角色的数组:
let roles = Array(crew.keys)
print(roles)
- 使用键检索一名员工并打印结果:
let firstRole = roles.first! // Chief Medical Officer
let cmo = crew[firstRole]! // Person: Beverly Crusher
print("\(firstRole): \(cmo.displayName)")
// Chief Medical Officer: Beverly Crusher
- 通过对现有键分配新值来替换字典中的值。当设置新值时,将丢弃该键的旧值:
print(crew["Security Officer"]!.name.givenName) // Tasha
crew["Security Officer"] = Person(givenName: "Worf",
familyName: "Son of Mogh",
commsMethod: .subSpaceRelay)
print(crew["Security Officer"]!.name.givenName) // Worf
通过这样,我们已经学会了如何创建、填充和查找字典中的值。
它是如何工作的...
与其他集合类型一样,当我们创建一个字典时,我们需要提供字典将持有的类型。对于字典,我们需要定义两种类型。第一种是键的类型(必须符合Hashable),第二种是存储在键上的值的类型。对于我们的字典,我们使用String作为键,Person作为存储的值:
var crew = Dictionary<String, Person>()
与数组一样,我们可以使用方括号指定dictionary类型,并使用字典字面量创建一个,其中:分隔键和值:
let intByName: [String: Int] = ["one": 1, "two": 2, "three": 3]
因此,我们可以更改我们的字典定义,使其看起来像这样:
var crew: [String: Person] = [:]
[:]符号表示一个空字典作为字典字面量。
使用下标向字典中添加元素。与数组不同,数组在下标中使用Int索引,而字典使用键,然后将给定的值与给定的键配对。在以下示例中,我们将一个Person对象分配给"Captain"键:
crew["Captain"] = Person(givenName: "Jean-Luc",
familyName: "Picard",
commsMethod: .phone)
如果当前不存在值,则将分配的值添加。如果给定键已存在值,则旧值将被新值替换,并且旧值将被丢弃。
字典上有一些属性提供了所有的键和值。这些属性是自定义集合类型,可以传递给数组初始化器以创建一个数组:
let roles = Array(crew.keys)
print(roles)
要显示字典的所有键,如keys属性所提供的,我们可以创建一个数组或直接迭代集合。我们将在下一章中介绍迭代集合的值,所以现在我们将创建一个数组。
接下来,我们将使用数组中的一个键值,与船员一起检索关联的Person的完整详细信息:
let firstRole = roles.first! // Chief Medical Officer
let cmo = crew[firstRole]! // Person: Beverly Crusher
print("\(firstRole): \(cmo.displayName)")
// Chief Medical Officer: Beverly Crusher
我们使用first属性获取第一个元素,但由于这是一个可选类型,我们需要使用!来强制解包。我们可以将firstRole传递给字典下标,它现在是一个非可选的String,以获取与该键关联的Person对象。通过下标检索值的返回类型也是可选的,因此在我们打印其值之前也需要强制解包。
强制解包通常是不安全的事情,因为如果我们强制解包一个最终是nil的值,我们的代码将会崩溃。我们建议你在解包可选值之前检查该值不是nil。我们将在下一章中介绍如何做到这一点。
还有更多...
在这个菜谱中,我们使用了字符串作为字典的键。然而,我们也可以使用符合Hashable协议的类型。
使用String作为员工目录的键的一个缺点是,很容易误输入员工的职位或查找一个你期望存在但实际上不存在的职位。因此,我们可以通过使用符合Hashable协议并且更适合作为模型中键的东西来改进我们的实现。
在我们的模型中,我们有一组有限的员工职位,枚举是表示有限数量选项的完美选择,所以让我们将我们的角色定义为枚举:
enum Role: String {
case captain = "Captain"
case firstOfficer = "First Officer"
case secondOfficer = "Second Officer"
case chiefEngineer = "Chief Engineer"
case councillor = "Councillor"
case securityOfficer = "Security Officer"
case chiefMedicalOfficer = "Chief Medical Officer"
}
现在,让我们改变我们的字典定义,使其使用这个新的枚举作为键,然后插入我们的员工,使用这些枚举值:
var crew = Dictionary<Role, Person>()
crew[.captain] = Person(givenName: "Jean-Luc",
familyName: "Picard",
commsMethod: .phone)
crew[.firstOfficer] = Person(givenName: "William",
familyName: "Riker",
commsMethod: .email)
crew[.chiefEngineer] = Person(givenName: "Geordi",
familyName: "LaForge",
commsMethod: .textMessage)
crew[.secondOfficer] = Person(givenName: "Data",
familyName: "Soong",
commsMethod: .fax)
crew[.councillor] = Person(givenName: "Deanna",
familyName: "Troi",
commsMethod: .telepathy)
crew[.securityOfficer] = Person(givenName: "Tasha",
familyName: "Yar",
commsMethod: .subSpaceRelay)
crew[.chiefMedicalOfficer] = Person(givenName: "Beverly",
familyName: "Crusher",
commsMethod: .tachyons)
你还需要更改所有其他使用crew的地方,使它们使用新的基于枚举的键。
让我们看看它是如何以及为什么这样工作的。我们创建了一个基于String的Role枚举:
enum Role: String {
//...
}
以这种方式定义有两个好处:
-
我们打算向用户显示这些职位,所以我们需要一个
Role枚举的字符串表示形式,无论我们如何定义它。 -
枚举中包含一点协议和泛型魔法,这意味着如果一个枚举由实现
Hashable协议的类型(如String)支持,那么枚举也会自动实现Hashable协议。因此,将Role定义为基于字符串的枚举,满足了字典对键必须是Hashable的要求,而无需我们做任何额外的工作。
由于我们的crew字典现在定义为基于Role键,所有下标操作都必须使用角色枚举中的值:
crew[.captain] = Person(givenName: "Jean-Luc",
familyName: "Picard",
commsMethod: .phone)
let cmo = crew[.chiefMedicalOfficer]
编译器强制执行此操作,因此在与我们的员工目录交互时不再可能使用不正确的角色。使用 Swift 的构造和类型系统来强制正确使用你的代码是我们应该努力做到的,因为它可以减少错误并防止我们的代码以意外的方式被使用。
参见
关于字典的更多信息可以在苹果公司关于 Swift 语言的文档中找到,请参阅swiftbook.link/docs/collections。
自定义类型的下标
通过使用集合类型,我们已经看到它们的元素是通过下标访问的。然而,不仅仅是集合类型可以有下标;你的自定义类型也可以提供下标功能。
准备工作
在这个菜谱中,我们将创建一个简单的井字棋游戏,也称为“圈叉游戏”。为此,我们需要一个三行三列的位置网格,每个位置由玩家 1 的零、玩家 2 的叉或空位填充。我们可以将这些位置存储在数组数组中。
初始游戏设置代码使用了我们在本书中已经介绍的概念,所以我们将不会深入其实现。将以下代码输入到一个新的游乐场中,以便我们可以看到下标如何改进其使用:
enum GridPosition: String {
case player1 = "o"
case player2 = "x"
case empty = " "
}
struct TicTacToe {
var gridStorage: [[GridPosition]] = []
init() {
gridStorage.append(Array(repeating: .empty, count: 3))
gridStorage.append(Array(repeating: .empty, count: 3))
gridStorage.append(Array(repeating: .empty, count: 3))
}
func gameStateString() -> String {
var stateString = "-------------\n"
stateString += printableString(forRow: gridStorage[0])
stateString += "-------------\n"
stateString += printableString(forRow: gridStorage[1])
stateString += "-------------\n"
stateString += printableString(forRow: gridStorage[2])
stateString += "-------------\n"
return stateString
}
func printableString(forRow row: [GridPosition]) -> String {
var rowString = "| \(row[0].rawValue) "
rowString += "| \(row[1].rawValue) "
rowString += "| \(row[2].rawValue) |\n"
return rowString
}
}
如何做...
让我们回顾一下如何使用之前定义的井字棋游戏,以及如何使用下标改进其使用方式。我们还将检查这是如何工作的:
- 让我们创建一个
TicTacToe网格的实例:
var game = TicTacToe()
- 要让玩家进行移动,我们需要更改分配给数组中相关位置的
GridPosition值。这用于存储网格位置。玩家 1 将在网格中间位置放置一个零,这将位于行位置 1,列位置 1(因为它是基于零的数组):
// Move 1
game.gridStorage[1][1] = .player1
print(game.gameStateString())
/*
-------------
| | | |
-------------
| | o | |
-------------
| | | |
-------------
*/
- 接下来,玩家 2 将他们的叉放在右上角的位置,这是行位置 0,列位置 2:
// Move 2
game.gridStorage[0][2] = .player2
print(game.gameStateString())
/*
-------------
| | | x |
-------------
| | o | |
-------------
| | | |
-------------
*/
我们可以在游戏中进行移动。我们可以通过直接向gridStorage数组添加信息来实现这一点,但这并不理想。玩家不需要知道移动是如何存储的,而且我们应该能够更改存储游戏信息的方式,而无需更改移动方式。为了解决这个问题,让我们创建游戏结构体的下标,以便在游戏中进行移动就像为数组赋值一样。
- 将以下下标方法添加到
TicTacToe结构体中:
struct TicTacToe {
var gridStorage: [[GridPosition]] = []
//...
subscript(row: Int, column: Int) -> GridPosition {
get {
return gridStorage[row][column]
}
set(newValue) {
gridStorage[row][column] = newValue
}
}
//...
}
- 因此,现在我们可以改变每个玩家如何移动并完成游戏:
// Move 1
game[1, 1] = .player1
print(game.gameStateString())
/*
-------------
| | | |
-------------
| | o | |
-------------
| | | |
-------------
*/
// Move 2
game[0, 2] = .player2
print(game.gameStateString())
/*
-------------
| | | x |
-------------
| | o | |
-------------
| | | |
-------------
*/
// Move 3
game[0, 0] = .player1
print(game.gameStateString())
/*
-------------
| o | | x |
-------------
| | o | |
-------------
| | | |
-------------
*/
// Move 4
game[1, 2] = .player2
print(game.gameStateString())
/*
-------------
| o | | x |
-------------
| | o | x |
-------------
| | | |
-------------
*/
// Move 5
game[2, 2] = .player1
print(game.gameStateString())
/*
-------------
| o | | x |
-------------
| | o | x |
-------------
| | | o |
-------------
*/
- 就像使用数组一样,我们可以使用下标来访问值,以及为其赋值:
let topLeft = game[0, 0]
let middle = game[1, 1]
let bottomRight = game[2, 2]
let p1HasWon = (topLeft == .player1)
&& (middle == .player1)
&& (bottomRight == .player1)
它是如何工作的...
下标功能可以在类、结构体或枚举中定义,也可以在协议中声明为要求。为此,我们可以定义subscript(这是一个保留关键字,用于激活所需功能),并带有输入参数和输出类型:
subscript(row: Int, column: Int) -> GridPosition
这个 subscript 定义的工作方式就像是一个计算属性,其中 get 可以定义为允许你通过 subscript 访问值,而 set 可以定义为使用 subscript 分配值:
subscript(row: Int, column: Int) -> GridPosition {
get {
return gridStorage[row][column]
}
set(newValue) {
gridStorage[row][column] = newValue
}
}
可以定义任意数量的输入参数,并且这些参数应以逗号分隔的值在 subscript 中提供:
game[1, 2] = .player2 // Assigning a value
let topLeft = game[0, 0] // Accessing a value
还有更多...
就像在函数中定义的参数一样,subscript 参数可以有额外的标签。如果定义了,这些标签在调用位置是必需的,因此我们添加的 subscript 可以定义为以下形式:
subscript(atRow row: Int, atColumn column: Int) -> GridPosition
在这种情况下,当使用 subscript 时,我们还会在 subscript 中提供标签:
game[atRow: 1, atColumn: 2] = .player2 // Assigning a value
let topLeft = game[atRow: 0, atColumn: 0] // Accessing a value
参见
关于下标的更多信息可以在苹果关于 Swift 语言的文档中找到,请参阅 swiftbook.link/docs/subscripts。
使用 typealias 更改名称
typealias 声明允许你为类型创建别名(因此它的命名非常准确!)。你可以指定一个可以用来替代任何给定类型定义的名称。如果这个类型相当复杂,typealias 可以是一种简化其使用的方法。
如何做到这一点...
我们将使用 typealias 来替换数组定义:
- 首先,让我们创建一些可以存储在数组中的东西。在这个例子中,让我们创建一个
Pug结构体:
struct Pug {
let name: String
}
- 现在,我们可以创建一个数组,它将包含
Pug结构体的实例:
let pugs = [Pug]()
你可能知道也可能不知道,一群哈巴狗的集体名词叫做 grumble。
- 我们可以设置一个
typealias来定义哈巴狗数组为Grumble:
typealias Grumble = [Pug]
- 定义之后,我们可以在任何使用
[Pug]或Array<Pug>的地方替换为Grumble:
var grumble = Grumble()
- 然而,这并不是某种新型别——它只是一个具有所有相同功能的数组:
let marty = Pug(name: "Marty McPug")
let wolfie = Pug(name: "Wolfgang Pug")
let buddy = Pug(name: "Buddy")
grumble.append(marty)
grumble.append(wolfie)
grumble.append(buddy)
还有更多...
上述示例使我们能够以更自然和表达的方式使用类型。此外,我们可以使用 typealias 来简化可能在多个地方使用的更复杂类型。
为了看到这可能有什么用,我们可以部分构建一个对象来获取节目信息:
enum Channel {
case BBC1
case BBC2
case BBCNews
//...
}
class ProgrammeFetcher {
func fetchCurrentProgrammeName(forChannel channel: Channel,
resultHandler: (String?, Error?) -> Void) {
// ...
// Do the work to get the current programme
// ...
let exampleProgramName = "Sherlock"
resultHandler(exampleProgramName, nil)
}
func fetchNextProgrammeName(forChannel channel: Channel,
resultHandler: (String?, Error?) -> Void) {
// ...
// Do the work to get the next programme
// ...
let exampleProgramName = "Luther"
resultHandler(exampleProgramName, nil)
}
}
在 ProgrammeFetcher 对象中,我们有两个方法,它们接受一个频道和一个结果处理闭包。结果处理闭包具有以下定义。我们必须为每个方法定义两次:
(String?, Error?) -> Void
相反,我们可以使用名为 FetchResultHandler 的 typealias 来定义这个闭包定义,并用这个 typealias 的引用替换每个方法定义:
class ProgrammeFetcher {
typealias FetchResultHandler = (String?, Error?) -> Void
func fetchCurrentProgrammeName(forChannel channel: Channel,
resultHandler: FetchResultHandler) {
// Get next programme
let programmeName = "Sherlock"
resultHandler(programmeName, nil)
}
func fetchNextProgrammeName(forChannel channel: Channel,
resultHandler: FetchResultHandler) {
// Get next programme
let programmeName = "Luther"
resultHandler(programmeName, nil)
}
}
不仅这使我们免于两次定义闭包类型,而且它也是对闭包执行的功能的更好描述。
使用 typealias 并不影响我们向方法提供闭包的方式:
let fetcher = ProgrammeFetcher()
fetcher.fetchCurrentProgrammeName(forChannel: .BBC1,
resultHandler: { programmeName, error in
print(programmeName as Any)
})
参见
关于 typealias 的更多信息可以在苹果关于 Swift 语言的文档中找到,请参阅 swiftbook.link/docs/declarations。
使用属性观察器获取属性更改通知
想要知道属性值何时发生变化是很常见的。可能你想要更新另一个属性的值或更新一些用户界面元素。在 Objective-C 中,这通常是通过编写自己的 getter 和 setter 或使用 键值观察(KVO)来实现的,但在 Swift 中,我们有了对属性观察器的原生支持。
准备工作
要检查属性观察器,我们应该创建一个具有我们想要观察的属性的物体。让我们创建一个管理用户和包含当前用户名的属性的物体:
class UserManager {
var currentUserName: String = "Emmanuel Goldstein"
}
当当前用户更改时,我们想要显示一些友好的消息。我们将使用属性观察器来完成这个任务。
如何实现...
让我们开始吧:
- 修改
currentUserName属性定义,使其看起来如下:
class UserManager {
var currentUserName: String = "Emmanuel Goldstein" {
willSet (newUserName) {
print("Goodbye to \(currentUserName)")
print("I hear \(newUserName) is on their way!")
}
didSet (oldUserName) {
print("Welcome to \(currentUserName)")
print("I miss \(oldUserName) already!")
}
}
}
- 创建一个
UserManager的实例并更改当前用户名。这将生成友好的消息:
let manager = UserManager()
manager.currentUserName = "Dade Murphy"
// Goodbye to Emmanuel Goldstein
// I hear Dade Murphy is on their way!
// Welcome to Dade Murphy
// I miss Emmanuel Goldstein already!
manager.currentUserName = "Kate Libby"
// Goodbye to Dade Murphy
// I hear Kate Libby is on their way!
// Welcome to Kate Libby
// I miss Dade Murphy already!
它是如何工作的...
属性观察器可以添加在属性声明之后的括号内,并且有两种类型:willSet 和 didSet。
willSet 观察器将在属性设置之前调用,并提供将要设置在属性上的值。这个新值可以在括号内命名;例如,newUserName:
willSet (newUserName) {
//...
}
didSet 观察器将在属性设置后调用,并提供属性设置之前的值。这个旧值可以在括号内命名;例如,oldUserName:
didSet (oldUserName) {
//...
}
还有更多...
传递给属性观察器的新值和旧值具有隐含的名称,因此没有必要显式地命名它们。willSet 观察器传递一个具有隐含名称 newValue 的值,而 didSet 观察器传递一个具有隐含名称 oldValue 的值。
因此,我们可以移除显式名称并使用隐含值名称:
class UserManager {
var currentUserName: String = "Emmanuel Goldstein" {
willSet {
print("Goodbye to \(currentUserName)")
print("I hear \(newValue) is on their way!")
}
didSet {
print("Welcome to \(currentUserName)")
print("I miss \(oldValue) already!")
}
}
}
相关内容
更多关于属性观察器的信息可以在 Apple 的 Swift 语言文档中找到,请参阅swiftbook.link/docs/properties。
使用扩展扩展功能
扩展让我们能够向现有的类、结构体、枚举和协议添加功能。当原始类型由外部框架提供时,这特别有用,这意味着你无法直接添加功能。
准备工作
想象一下,我们经常需要从一个给定的字符串中获取第一个单词。而不是反复编写将字符串拆分为单词并检索第一个单词的代码,我们可以扩展 String 的功能以提供它自己的第一个单词。
如何实现...
让我们开始吧:
- 创建一个
String的扩展:
extension String {
}
- 在扩展的括号内添加一个返回字符串中第一个单词的函数:
extension String {
func firstWord() -> String {
let spaceIndex = firstIndex(of: " ") ?? endIndex
let word = prefix(upTo: spaceIndex)
return String(word)
}
}
- 现在,我们可以使用这个新的方法在
String上获取短语中的第一个单词:
let llap = "Live long, and prosper"
let firstWord = llap.firstWord()
print(firstWord) // Live
它是如何工作的...
我们可以使用 extension 关键字定义一个扩展,然后指定我们想要扩展的类型。这个扩展的实现定义在大括号内:
extension String {
//...
}
方法和计算属性可以在扩展中定义,就像它们可以在类、结构体和枚举中定义一样。在这里,我们将向 String 结构体添加一个 firstWord 函数:
extension String {
func firstWord() -> String {
let spaceIndex = firstIndex(of: " ") ?? endIndex
let word = prefix(upTo: spaceIndex)
return String(word)
}
}
firstWord 方法的实现对于这个食谱来说并不重要,所以我们只是简要地提及它。
在 Swift 中,String 是一个集合,因此我们可以使用集合方法来找到空格的第一个索引。然而,这可能是 nil,因为字符串可能只包含一个单词或没有任何字符,所以如果索引是 nil,我们必须使用 endIndex。nil 合并操作符(??)仅用于在 firstIndex(of: " ") 是 nil 时分配 endIndex。更一般地说,它将评估操作符左侧的值,除非它是 nil,在这种情况下,它将分配操作符右侧的值。
然后,我们使用第一个空格的索引来检索直到该索引的子字符串,它具有 SubString 类型。然后我们使用它来创建并返回一个 String。
扩展可以实现使用现有功能的一切,但不能在新的属性中存储信息。因此,可以添加计算属性,但不能添加存储属性。让我们将 firstWord 方法改为计算属性:
extension String {
var firstWord: String {
let spaceIndex = firstIndex(of: " ") ?? endIndex
let word = prefix(upTo: spaceIndex)
return String(word)
}
}
还有更多...
扩展也可以用来添加协议符合性,所以让我们创建一个我们想要添加符合性的协议:
- 协议声明某物可以表示为
Int:
protocol IntRepresentable {
var intValue: Int { get }
}
- 我们可以将
Int扩展并使其符合IntRepresentable,通过返回其自身:
extension Int: IntRepresentable {
var intValue: Int {
return self
}
}
- 接下来,我们将扩展
String,并使用一个接受String并返回Int的Int构造函数,如果我们的String包含表示整数的数字:
extension String: IntRepresentable {
var intValue: Int {
return Int(self) ?? 0
}
}
- 我们也可以扩展我们自己的自定义类型,并添加对同一协议的符合性,所以让我们创建一个可以
IntRepresentable的enum:
enum CrewComplement: Int {
case enterpriseD = 1014
case voyager = 150
case deepSpaceNine = 2000
}
- 由于我们的枚举基于
Int,我们可以通过提供rawValue来符合IntRepresentable:
extension CrewComplement: IntRepresentable {
var intValue: Int {
return rawValue
}
}
- 现在,
String、Int和CrewComplement都符合IntRepresentable,由于我们没有定义String或Int,我们只能通过使用扩展来添加符合性。这种共同的符合性允许我们将它们视为相同类型:
var intableThings = [IntRepresentable]()
intableThings.append(55)
intableThings.append(1200)
intableThings.append("5")
intableThings.append("1009")
intableThings.append(CrewComplement.enterpriseD)
intableThings.append(CrewComplement.voyager)
intableThings.append(CrewComplement.deepSpaceNine)
let over1000 = intableThings.compactMap { $0.intValue > 1000 ?
$0.intValue: nil }
print(over1000)
上述示例中包含了 compactMap 和三元操作符的使用,这些在本章中尚未介绍。更多信息可以在 参见 部分找到。
参见
关于扩展的更多信息可以在 Apple 的 Swift 语言文档中找到,请参阅 swiftbook.link/docs/extensions。
compactMap 的文档可以在developer.apple.com/documentation/swift/sequence/2950916-compactmap找到。
关于三元运算符的更多信息可以在docs.swift.org/swift-book/LanguageGuide/BasicOperators.html#ID71找到。
使用访问控制控制访问
Swift 提供了细粒度的访问控制,允许你指定你的代码对其他代码区域的可见性。这使你可以有意识地决定提供给系统其他部分的接口,从而封装实现逻辑并帮助分离关注点。
Swift 有五个访问级别:
-
私有:只能在现有作用域(由大括号定义)或同一文件中的扩展内访问。
-
文件私有:同一文件中的任何内容都可以访问,但文件外部的内容则不能。
-
内部:同一模块中的任何内容都可以访问,但模块外的内容则不能。
-
公开:在模块内部和外部都可以访问,但不能在定义模块之外进行子类化或重写。
-
公开:在所有地方都可以访问,使用上没有限制,因此可以被子类化和重写。
这些可以应用于类型、属性和函数。
准备工作
要探索这些访问级别中的每一个,我们需要走出我们的游乐场舒适区并创建一个模块。为了有一个可以包含我们的模块以及可以使用它的游乐场,我们需要创建一个 Xcode 工作区:
- 在 Xcode 中,从菜单中选择文件 | 新 | 工作区...:

图 2.9 – Xcode – 新项目
- 给你的工作区起一个名字,例如
AccessControl,并选择一个保存位置。你现在将看到一个空的工作区:

图 2.10 – Xcode – 新项目结构
在这个工作区中,我们需要创建一个模块。为了说明可用的访问控制,让我们让我们的模块代表一个紧密控制其暴露哪些信息以及隐藏哪些信息的实体。符合这个定义的一个例子是苹果公司;即,这家公司。
- 通过从 Xcode 菜单中选择文件 | 新 | 项目...来创建一个新项目:

图 2.11 – 新项目
- 从模板选择器中选择框架:

图 2.12 – 新项目框架
- 将项目命名为
AppleInc:

图 2.13 – 命名项目
- 选择一个位置。然后,在窗口底部,确保“添加到”已设置为刚刚创建的工作区:

图 2.14 – 新项目工作区组
- 现在我们已经有一个模块了,让我们设置一个游乐场来使用它。从 Xcode 菜单中选择 File | New | Playground...:

图 2.15 – 新的游乐场
- 给游乐场起一个名字并将其保存到位置:

图 2.16 – 新项目
-
此游乐场将不会自动添加到工作区中;您需要找到您刚刚创建的游乐场并将其拖放到工作区左侧的文件资源管理器窗格中。
-
在 Xcode 工具栏上按运行按钮来构建
AppleInc模块:

图 2.17 – Xcode 工具栏
- 从文件导航器中选择游乐场,并将导入语句添加到文件顶部:
import AppleInc
我们现在可以查看可用的不同访问控制。
如何做到这一点...
让我们调查最限制性的访问控制:private。标记为private的结构仅在其定义的类型的作用域内可见,以及位于同一文件中的该类型的任何扩展。我们知道苹果有超级秘密的区域,在那里它正在开发新产品,因此让我们创建一个:
-
在文件导航器中选择
AppleInc组,然后从菜单中选择 File | New | File...来创建一个新文件。让我们称它为SecretProductDepartment。 -
在这个新文件中,使用
private访问控制创建一个SecretProductDepartment类:
class SecretProductDepartment {
private var secretCodeWord = "Titan"
private var secretProducts = ["Apple Glasses",
"Apple Car",
"Apple Brain Implant"]
func nextProduct(codeWord: String) -> String? {
let codeCorrect = codeWord == secretCodeWord
return codeCorrect ? secretProducts.first : nil
}
}
接下来,让我们看看fileprivate访问控制。标记为fileprivate的结构仅在其定义的文件内部可见,因此同一文件中定义的相关结构集合将相互可见,但文件外部的内容将看不到这些结构。
当你在苹果商店购买 iPhone 时,它不是在店内制造的;它是在公众无法访问的工厂制造的。因此,让我们使用fileprivate来模拟这一点。
- 创建一个名为
AppleStore的新文件。然后,使用fileprivate访问控制创建AppleStore和Factory的结构:
public enum DeviceModel {
case iPhone12
case iPhone12Mini
case iPhone12Pro
case iPhone12ProMax
}
public class AppleiPhone {
public let model: DeviceModel
fileprivate init(model: DeviceModel) {
self.model = model
}
}
fileprivate class Factory {
func makeiPhone(ofModel model: DeviceModel) -> AppleiPhone {
return AppleiPhone(model: model)
}
}
public class AppleStore {
private var factory = Factory()
public func selliPhone(ofModel model: DeviceModel)
-> AppleiPhone {
return factory.makeiPhone(ofModel: model)
}
}
要调查公共访问控制,我们将定义一些在定义模块外部可见但不能进行子类化或覆盖的东西。苹果本身是模拟这种行为的完美候选者,因为它的某些部分对公众可见。然而,它非常重视其形象和品牌,因此不允许对苹果进行子类化以进行更改和定制。
- 创建一个名为
Apple的新文件,并为苹果创建一个使用public访问控制的类:
public class Person {
public let name: String
public init(name: String) {
self.name = name
}
}
public class Apple {
public private(set) var ceo: Person
private var employees = [Person]()
public let store = AppleStore()
private let secretDepartment = SecretProductDepartment()
public init() {
ceo = Person(name: "Tim Cook")
employees.append(ceo)
}
public func newEmployee(person: Person) {
employees.append(person)
}
func weeklyProductMeeting() {
var superSecretProduct =
secretDepartment.nextProduct(codeWord: "Not sure...
Abracadabra?") // nil
// Try again
superSecretProduct =
secretDepartment.nextProduct(givenCodeWord: "Titan")
print(superSecretProduct as Any) // "Apple Glasses"
}
}
最后,我们有开放访问控制。被定义为开放的结构可以在模块外部使用,并且可以无限制地进行子类化和覆盖。为了解释这种最后的控制,我们想要模拟苹果领域内存在但完全开放且不受限制的东西。因此,为此,我们可以使用 Swift 语言本身!
Swift 已经被苹果开源,因此尽管他们维护该项目,源代码对其他人来说是完全可用的,他们可以获取、修改和改进。
- 创建一个名为
SwiftLanguage的新文件,并创建一个使用open访问控制的 Swift 语言类:
open class SwiftLanguage {
open func versionNumber() -> Float {
return 5.1
}
open func supportedPlatforms() -> [String] {
return ["iOS", "macOS", "tvOS", "watchOS", "Linux"]
}
}
现在我们有一个模块,它使用 Swift 的访问控制来提供与我们的模型相匹配的接口,并提供适当的可见性。
它是如何工作的...
让我们检查我们的 SecretProductDepartment 类,看看它的可见性是如何与我们的模型相匹配的:
class SecretProductDepartment {
private var secretCodeWord = "Titan"
private var secretProducts = ["Apple Glasses",
"Apple Car",
"Apple Brain Implant"]
func nextProduct(codeWord: String) -> String? {
let codeCorrect = codeWord == secretCodeWord
return codeCorrect ? secretProducts.first : nil
}
}
SecretProductDepartment 类没有声明访问控制关键字,当没有指定访问控制时,默认控制为 internal。由于我们希望秘密产品部门在苹果公司内部可见,但不在苹果公司外部可见,这是正确的访问控制方式。
secretCodeWord 和 secretProducts 类的两个属性被标记为私有,因此隐藏了它们的值和存在性,使其无法从 SecretProductDepartment 类外部访问。要查看此限制的实际效果,请将以下内容添加到同一文件中,但位于类外部:
let insecureCodeWord = SecretProductDepartment().secretCodeWord
当你尝试构建模块时,你会被告知由于 private 保护级别,无法访问 secretCodeWord。
虽然这些属性不能直接访问,但我们可以提供一个接口,以受控的方式提供信息。这正是 nextProduct 方法提供的:
func nextProduct(codeWord: String) -> String? {
let codeCorrect = codeWord == secretCodeWord
return codeCorrect ? secretProducts.first : nil
}
如果传递了正确的密码词,它将提供秘密部门的下一个产品的名称,但所有其他产品的细节以及密码词本身都将被隐藏。由于此方法没有指定访问控制,它被设置为默认的 internal。
结构内的内容不能比结构本身有更宽松的访问控制。例如,我们不能将 nextProduct 方法定义为 public,因为这比定义它的类(仅 internal)更宽松。
想想看,这是显而易见的结果,因为你不能在定义模块之外创建内部类的实例,那么你怎么可能调用一个你甚至无法创建的类的实例上的方法呢?
接下来,让我们看看我们创建的 AppleStore.swift 文件。这里的目的是让苹果公司以外的人能够通过苹果商店购买 iPhone,但将 iPhone 的创建限制在它们被制造的工厂,然后仅将访问权限限制在苹果商店:
public enum DeviceModel {
case iPhone12
case iPhone12Mini
case iPhone12Pro
case iPhone12ProMax
}
public class AppleiPhone {
public let model: DeviceModel
fileprivate init(model: DeviceModel) {
self.model = model
}
}
public class AppleStore {
private var factory = Factory()
public func selliPhone(ofModel model: DeviceModel)
-> AppleiPhone {
return factory.makeiPhone(ofModel: model)
}
}
由于我们希望能够在 AppleInc 模块外部销售 iPhone,因此 DeviceModel 枚举以及 AppleiPhone 和 AppleStore 类都被声明为 public。这有利于使它们在模块外部可用,但防止它们被继承或修改。考虑到苹果对其手机和商店的外观和感觉的保护,这似乎是正确的模型。
苹果商店需要从某处获取他们的 iPhone;也就是说,从工厂:
fileprivate class Factory {
func makeiPhone(ofModel model: DeviceModel) -> AppleiPhone {
return AppleiPhone(model: model)
}
}
通过将 Factory 类声明为 fileprivate,它只在本文件中可见,这是完美的,因为我们只想让苹果商店能够使用工厂来创建 iPhone。
我们还限制了 iPhone 的初始化方法,使其只能从本文件中的结构体访问:
fileprivate init(model: DeviceModel)
结果生成的 iPhone 是 public 的,但只有本文件中的结构体才能首先创建 iPhone 类对象。在这种情况下,这是由工厂完成的。
接下来,让我们看看 Apple.swift 文件:
public class Person {
public let name: String
public init(name: String) {
self.name = name
}
}
public class Apple {
public private(set) var ceo: Person
private var employees = [Person]()
public let store = AppleStore()
private let secretDepartment = SecretProductDepartment()
public init() {
ceo = Person(name: "Tim Cook")
employees.append(ceo)
}
public func newEmployee(person: Person) {
employees.append(person)
}
func weeklyProductMeeting() {
var superSecretProduct =
secretDepartment.nextProduct(givenCodeWord: "Not sure...
Abracadabra?") // nil
// Try again
superSecretProduct =
secretDepartment.nextProduct(givenCodeWord: "Titan")
print(superSecretProduct) // "Apple Glasses"
}
}
上述代码使 Person 和 Apple 类以及 newEmployee 方法都变为 public,这允许新员工加入公司。然而,CEO 被定义为既是 public 也是 private:
public private(set) var ceo: Person
我们可以为设置属性定义一个比获取属性时设置的更严格的单独访问控制。这会使它从定义结构外部成为一个只读属性。这提供了我们所需要的访问权限,因为我们希望 CEO 在 AppleInc 模块外部可见,但我们只想从苹果内部更改 CEO。
最终的访问控制是 open。我们将其应用于 SwiftLanguage 类:
open class SwiftLanguage {
open func versionNumber() -> Float {
return 5.0
}
open func supportedPlatforms() -> [String] {
return ["iOS", "macOSX", "tvOS", "watchOS", "Linux"]
}
}
通过将类和方法声明为 open,我们允许任何人(包括 AppleInc 模块外的人)对其进行子类化、重写和修改。由于 Swift 语言是完全开源的,这符合我们想要实现的目标。
还有更多...
在我们的模块完全定义后,让我们看看模块外部的样子。我们需要构建模块以使其在 playground 中可用。选择 playground;它应该包含一个导入 AppleInc 模块的语句:
import AppleInc
首先,让我们看看我们创建的最易访问的类;那就是 SwiftLanguage。让我们对 SwiftLanguage 类进行子类化并重写其行为:
class WinSwift: SwiftLanguage {
override func versionNumber() -> Float {
return 5.3
}
override func supportedPlatforms() -> [String] {
var supported = super.supportedPlatforms()
supported.append("Windows")
return supported
}
}
由于 SwiftLanguage 是 open 的,我们可以通过它来创建子类,以添加更多支持的平台并增加其版本号。
接下来,让我们创建 Apple 类的一个实例并看看我们如何与之交互:
let apple = Apple()
let keith = Person(name: "Keith Moon")
apple.newEmployee(person: keith)
print("Current CEO: \(apple.ceo.name)")
let craig = Person(name: "Craig Federighi")
apple.ceo = craig // Doesn't compile
由于 Person 类和 newEmployee 方法被声明为 public,我们可以创建 Person 并将其提供给苹果作为新员工。我们可以获取关于 CEO 的信息,但无法设置新的 CEO,因为我们定义的属性为 private (set)。
模块提供的另一个公共接口 selliPhone 允许我们从苹果商店购买 iPhone:
// Buy new iPhone
let boughtiPhone = apple.store.selliPhone(ofModel: .iPhone12Pro)
// This works
// Try and create your own iPhone
let buildAniPhone = AppleiPhone(model: .iPhone12Pro)
// Doesn't compile
我们可以从苹果商店获取一个新的 iPhone,因为我们声明了 selliPhone 方法为 public。然而,我们无法直接创建一个新的 iPhone,因为 iPhone 的 init 方法被声明为 fileprivate。
参考也
更多关于访问控制的信息可以在苹果关于 Swift 语言的文档中找到,请参阅 swiftbook.link/docs/access-control。
使用 Swift 控制流进行数据处理
编程全部关于做决定。大多数代码的目的涉及获取信息,检查它,做出决定,并产生输出。到目前为止,我们已经看到了许多表示信息的方法,但在这个章节中,我们将探索如何使用 Swift 的多个控制流语句根据这些信息做出决定。我们将了解它们的区别以及每种情况适用的场景。
在本章中,我们将介绍以下食谱:
-
使用
if/else做出决定 -
使用
switch处理所有情况 -
使用
for循环进行循环 -
使用
while循环进行循环 -
使用
try、throw、do和catch处理错误 -
使用
guard提前检查 -
使用
defer稍后处理 -
使用
fatalError和precondition退出
第四章:技术要求
本章的所有代码都可以在这个书的 GitHub 仓库中找到:github.com/PacktPublishing/Swift-Cookbook-Second-Edition/tree/master/Chapter03
查看以下视频以查看代码的实际运行情况:bit.ly/3aq66Us
使用 if/else 做出决定
if/else 语句是几乎所有编程语言的基础。它使代码能够根据布尔语句的结果有条件地执行。在这个食谱中,我们将看到 if/else 如何使用,包括一些 Swift 特有的方法。
准备工作
如果你曾经玩过台球,你会知道游戏的目标(当玩标准 8 球台球时)是将一种类型的所有球都入袋,然后入袋黑球。当使用美国台球球时,它们编号为 1-15,并且根据它们的类型有不同的图案。1-7 号球是实心颜色,9-15 号球是带有彩色条纹的白色球,8 号球是黑色的:

图 3.1 – 美国台球球
在这个食谱中,我们将编写一个函数,该函数将接受台球上的数字并返回球的类型。
如何做到...
让我们使用 if/else 控制流语句编写一个函数来返回正确的台球类型:
- 创建一个
enum来描述可能的球类型:
enum PoolBallType {
case solid
case stripe
case black
}
- 创建一个方法,该方法将接受一个
Int并返回PoolBallType:
func poolBallType(forNumber number: Int) -> PoolBallType {
if number < 8 {
return .solid
} else if number > 8 {
return .stripe
} else {
return .black
}
}
- 使用此函数并测试我们是否得到预期的结果:
let two = poolBallType(forNumber: 2) // .solid
let eight = poolBallType(forNumber: 8) // .black
let twelve = poolBallType(forNumber: 12) // .stripe
它是如何工作的...
在函数内部,我们定义了三个代码路径:if、else if和else:
if <#a boolean expression#> {
<#executed if boolean expression above is true#>
} else if <#other boolean expression#> {
<#executed if other boolean expression above is true#>
} else {
<#executed if neither boolean expressions are true#>
}
首先,我们想要确定球是否是实心。由于我们知道 1-7 号球是实心的,我们可以测试球号是否小于 8,使用number < 8。如果是true,我们返回enum的.solid情况。
如果它是false,则评估else if布尔表达式。由于 9-15 号球是条纹球,我们可以测试球号是否大于 8,使用number > 8。如果是true,我们返回enum的.stripe情况。
最后,如果前面的布尔表达式都是false,我们返回枚举的.black情况,因为那只能发生在数字正好是 8 的情况下。
else if和else块是可选的,并且你可以声明多个else if来覆盖额外的条件。让我们通过添加一个额外的else if来扩展前面的示例,以更好地确定台球类型。
如我们之前所述,台球编号介于 1 到 15 之间,但我们在实现中并没有考虑这些上下限。所以如果我们向函数提供球号 0,它将返回.solid,如果我们提供球号 16,它将返回.stripe,这并不准确地反映我们的意图:
let zero = poolBallType(forNumber: 0) // .solid
let sixteen = poolBallType(forNumber: 16) // .stripe
让我们修改我们的函数,使其仅在数字介于 1 到 15 之间时返回台球类型,否则返回nil:
func poolBallType(forNumber number: Int) -> PoolBallType? {
if number > 0 && number < 8 {
return .solid
} else if number > 8 && number < 16 {
return .stripe
} else if number == 8 {
return .black
} else {
return nil
}
}
现在我们有四个代码分支在我们的if语句中,我们可以使用 AND 运算符&&来组合布尔语句(也有可用的 OR 运算符||)。
现在,我们可以为预期范围内的数字以及范围外的数字调用我们的函数:
let two = poolBallType(forNumber: 2) // .solid
let eight = poolBallType(forNumber: 8) // .black
let twelve = poolBallType(forNumber: 12) // .stripe
let zero = poolBallType(forNumber: 0) // nil
let sixteen = poolBallType(forNumber: 16) // nil
我们改进的函数将为预期范围之外的数字产生nil。
还有更多...
我们还可以使用其他一些方式来使用 if/else 语句。
理解条件展开
我们创建的函数返回一个可选值,所以如果我们想对结果值做些有用的事情,我们需要unwrap可选值。到目前为止,我们看到的唯一方法是通过强制展开,如果值是nil,这将导致崩溃。
相反,我们可以使用一个if语句来条件性地展开可选值,将其转换为更有用的非可选值。
让我们创建一个函数,用于打印给定数字的台球信息。如果提供的数字适用于台球,它将打印球号和类型;否则,它将打印一条消息说明这不是一个有效的数字。
由于我们希望打印PoolBallType枚举的值,让我们将其改为String支持的,这将使打印其值更容易:
enum PoolBallType: String {
case solid
case stripe
case black
}
现在,让我们编写一个函数来打印台球详细信息:
func printBallDetails(ofNumber number: Int) {
let possibleBallType = poolBallType(forNumber: number)
if let ballType = possibleBallType {
print("\(number) - \(ballType.rawValue)")
} else {
print("\(number) is not a valid pool ball number")
}
}
在我们的printBallDetails函数中,我们首先获取给定数字的球类型:
let possibleBallType = poolBallType(forNumber: number)
在我们改进的函数版本中,这返回了PoolBallType枚举的可选版本。我们希望在打印球详细信息时包括返回的enum的rawValue。由于返回值是可选的,我们需要首先展开它:
if let ballType = possibleBallType {
print("\(number) - \(ballType.rawValue))")
}
在这个 if 语句中,我们不是定义一个布尔表达式,而是将我们的可选值赋给一个常量;if 语句使用这个常量来 条件性地解包 可选值。可选值被检查以确定它是否为 nil;如果不是 nil,则值被解包并赋给常量作为非可选值。这个常量在 if 语句后面的花括号作用域内可用。我们使用这个 ballType 非可选值来获取 print 语句的原始值。
由于当可选值有值时跟随 if-else 语句的 if 分支,那么当可选值为 nil 时,就跟随 else 分支。
由于这意味着给定的数字对于球桌球来说不是有效的,我们打印一条相关的消息:
else {
print("\(number) is not a valid pool ball number")
}
我们现在可以用之前相同的值来调用我们的新函数,以打印出球桌球类型:
printBallDetails(ofNumber: 2) // 2 - solid
printBallDetails(ofNumber: 8) // 8 - black
printBallDetails(ofNumber: 12) // 12 - stripe
printBallDetails(ofNumber: 0) // 0 is not a valid pool ball number
printBallDetails(ofNumber: 16) // 16 is not a valid pool ball number
我们已经使用条件解包来打印球桌球类型,如果有效,或者解释它为什么无效。
链式可选解包
if 语句能够条件性地解包可选值的能力可以链式组合起来,生成一些有用且简洁的代码。以下示例可能有些牵强,但它说明了我们如何使用单个 if 语句来解包一系列可选值。
当你玩一局斯诺克,称为 frame,你第一个入袋的球类型将成为整个帧中你需要入袋的类型,而你的对手则需要入袋相反类型的球。
让我们定义一个斯诺克帧,并说我们想要跟踪每个玩家将要入袋的球类型:
class PoolFrame {
var player1BallType: PoolBallType?
var player2BallType: PoolBallType?
}
我们还将创建一个具有可选的 currentFrame 属性的 PoolTable 对象,该属性将包含有关当前帧的信息,如果正在进行的话:
class PoolTable {
var currentFrame: PoolFrame?
}
现在我们有一个球桌,它有一个可选的帧,每个玩家都有一个可选的球类型。
现在,让我们编写一个函数来打印当前帧中玩家 1 的球类型。当前帧可能是 nil,因为没有正在进行的帧,或者玩家 1 的球类型是 nil,因为还没有入袋的球。因此,我们需要考虑这两种情况中的任何一种:
func printBallTypeOfPlayer1(forTable table: PoolTable) {
if let frame = table.currentFrame, let ballType =
frame.player1BallType {
print(ballType.rawValue)
} else {
print("Player 1 has no ball type or there is no current frame")
}
}
我们的函数接收一个 PoolTable,要打印玩家 1 的球类型,我们首先需要检查并解包 currentFrame 属性,然后我们需要检查并解包当前帧的 player1BallType 属性。
我们可以通过嵌套我们的 if 语句来实现这一点:
func printBallTypeOfPlayer1(forTable table: PoolTable) {
if let frame = table.currentFrame {
if let ballType = frame.player1BallType {
print(ballType.rawValue)
} //... handle else
} //... handle else
}
相反,我们可以通过按顺序执行解包语句(用逗号分隔)来在一个 if 语句中处理这种链式解包,每个语句都可以访问前一个语句解包的值:
func printBallTypeOfPlayer1(forTable table: PoolTable) {
if let frame = table.currentFrame, let ballType =
frame.player1BallType {
print("\(ballType)")
} //... handle else
}
第一个语句解包了 currentFrame 属性,第二个语句使用这个解包的帧来解包玩家 1 的球类型。
让我们使用我们刚刚创建的函数:
- 首先,我们将创建一个表格,并且在没有当前帧的情况下打印玩家 1 的球类型,这将不可用:
//
// Table with no frame in play
//
let table = PoolTable()
table.currentFrame = nil
printBallTypeOfPlayer1(forTable: table)
// Player 1 has no ball type or there is no current frame
- 接下来,我们可以创建一个当前帧,但由于玩家 1 的球类型仍然是
nil,函数会打印出相同的输出:
//
// Table with frame in play, but no balls potted
//
let frame = PoolFrame()
frame.player1BallType = nil
frame.player2BallType = nil
table.currentFrame = frame
printBallTypeOfPlayer1(forTable: table)
// Player 1 has no ball type or there is no current frame
- 如果我们设置玩家 1 的球类型,现在我们的函数会打印出类型:
//
// Table with frame in play, and a ball potted
//
frame.player1BallType = .solid
frame.player2BallType = .stripe
printBallTypeOfPlayer1(forTable: table)
// solid
我们创建了一种方法,可以链式调用条件展开,只有当链中的所有值都不是 nil 时才打印一个值。
使用具有关联值的枚举
正如我们在第一章的使用枚举枚举值菜谱中看到的,Swift Building Blocks,枚举可以有关联值,我们可以使用if语句在一个表达式中同时检查枚举的 case 并提取关联值。
让我们创建一个枚举来表示台球游戏的结果,每个 case 都有一个关联的消息:
enum FrameResult {
case win(congratulations: String)
case lose(commiserations: String)
}
接下来,我们将创建一个函数,它接受一个Result并打印祝贺消息或慰问消息:
func printMessage(forResult result: FrameResult) {
if case Result.win(congratulations: let winMessage) = result {
print("You won! \(winMessage)")
} else if case Result.lose(commiserations: let loseMessage) =
result {
print("You lost :( \(loseMessage)")
}
}
调用此函数将打印结果,然后是相关的消息:
let result = Result.win(congratulations: "You're simply the best!")
printMessage(forResult: result) // You won! You're simply the best!
如果=右侧的值与左侧的 case 匹配,则将执行if case块。此外,你可以指定一个局部常量来表示关联值(以下示例中的winMessage),然后在后续块中可用:
if case Result.win(congratulationsMessage: let winMessage) = result {
print("You won! \(winMessage)")
}
我们使用了if case语句一次性检查枚举值的 case 并访问其关联值。
相关内容
关于 if/else 的更多信息可以在苹果关于 Swift 语言的文档中找到,请参阅swiftbook.link/docs/statements。
使用 switch 处理所有情况
switch语句允许你通过多种方式测试一个特定的值来控制执行流程。在 Objective-C 和其他语言中,switch语句只能用于可以表示为整数的值,并且最常用于基于枚举案例做出决策。
正如我们所见,枚举在 Swift 中变得更加强大,因为它们可以基于不仅仅是整数,同样switch语句也是如此。
Swift 中的switch语句可以用于任何类型,并具有高级的模式匹配功能。
在这个菜谱中,我们将探索switch控制流语句的简单和高级用法来控制逻辑。
准备工作
如果你足够老,记得家用电脑的早期日子,你可能也会记得基于文本的冒险游戏。这些游戏通常描述一个场景,然后让你通过输入命令来移动北、南、东或西。你可以找到并捡起物品,并且通常可以将它们组合起来解决问题。
我们可以使用switch语句来控制简单文本冒险的逻辑。
如何做到这一点...
让我们创建一个基于文本的冒险游戏的部分,并使用switch语句来做出决定:
- 定义一个
enum来表示我们可以旅行的方向:
enum CompassPoint {
case north
case south
case east
case west
}
- 创建一个函数,描述玩家在朝某个方向看时将看到的内容:
func lookTowards(_ direction: CompassPoint) {
switch direction {
case .north:
print("To the north lies a winding road")
case .south:
print("To the south is the Prancing Pony tavern")
case .east:
print("To the east is a blacksmith")
case .west:
print("The the west is the town square")
}
}
lookTowards(.south) // To the south is the Prancing Pony tavern
在我们的文字冒险游戏中,用户可以捡起物品并尝试将它们组合起来以产生新的物品并解决问题。
- 将我们的可用物品定义为
enum:
enum Item {
case key
case lockedDoor
case openDoor
case bluntKnife
case sharpeningStone
case sharpKnife
}
- 编写一个函数,它接受两个物品并尝试将它们组合成一个新的物品。如果物品无法组合,它将返回
nil:
func combine(_ firstItem: Item, with secondItem: Item) -> Item? {
switch (firstItem, secondItem) {
case (.key, .lockedDoor):
print("You have unlocked the door!")
return .openDoor
case (.bluntKnife, .sharpeningStone):
print("Your knife is now sharp")
return .sharpKnife
default:
print("\(firstItem) and \(secondItem) cannot be combined")
return nil
}
}
let door = combine(.key, with: .lockedDoor) // openDoor
let oilAndWater = combine(.bluntKnife, with: .lockedDoor) // nil
- 在我们的文字冒险游戏中,玩家会遇到不同的角色,并且可以与他们互动。定义玩家可以遇到的角色:
enum Character: String {
case wizard
case bartender
case dragon
}
- 编写一个函数,允许玩家说些什么,并且可以选择性地提供一个角色,对他说。互动将取决于所说的内容以及所说的角色:
func say(_ textToSay: String, to character: Character? = nil) {
switch (textToSay, character) {
case ("abracadabra", .wizard?):
print("The wizard says, \"Hey, that's my line!\"")
case ("Pour me a drink", .bartender?):
print("The bartender pours you a drink")
case ("Can I have some of your gold?", .dragon?):
print("The dragon burns you to death with his fiery breath")
case (let textSaid, nil):
print("You say \"\(textSaid)\", to no-one.")
case (_, let anyCharacter?):
print("The \(anyCharacter) looks at you, blankly")
}
}
say("Is anybody there?")
// You say "Is anybody there?", to no-one.
say("Pour me a drink", to: .bartender)
// The bartender pours you a drink
say("Can I open a tab?", to: .bartender)
// The bartender looks at you, blankly
它是如何工作的...
在 lookTowards 函数中,我们希望为每个可能的 CompassPoint 案例打印不同的消息;为此,我们使用 switch 语句:
func lookTowards(_ direction: CompassPoint) {
switch direction {
case .north:
print("To the north lies a winding road")
case .south:
print("To the south is the Prancing Pony tavern")
case .east:
print("To the east is a blacksmith")
case .west:
print("The the west is the town square")
}
}
在 switch 语句的顶部,我们定义想要切换的值;然后我们定义当该值与定义的每个案例匹配时想要执行的操作,使用 case 关键字和匹配的模式:
switch <#value#> {
case <#pattern#>:
<#code#>
case <#pattern#>:
<#code#>
//...
}
每个 case 语句依次评估,如果模式与值匹配,则执行后续代码。
如果你熟悉 Objective-C 中的 switch 语句,你可能记得你需要在每个 case 语句的末尾添加 break; 来停止执行从下一个 case 语句中掉落。在 Swift 中不需要这样做;执行的断开是由下一个 case 语句的开始隐含的。唯一不是这种情况的时候,是因为你的 case 语句是故意为空的;在这些情况下,你需要添加 break 来告诉编译器它故意为这个案例留空。如果你确实想让执行掉落到下一个 case 语句,你可以在 case 语句的末尾添加 fallthrough。
在我们的 combine 函数中,我们有两个基于其值需要切换的值。我们可以将多个值以元组的形式提供给 switch 语句:
func combine(_ firstItem: Item, with secondItem: Item) -> Item? {
switch (firstItem, secondItem) {
//....
}
}
对于每个 case 语句,我们定义元组每个部分的合法值:
case (.key, .lockedDoor):
print("You have unlocked the door!")
return .openDoor
Swift 中的 switch 语句要求覆盖所有可能的案例;然而,你可以使用 default 案例一次性覆盖所有剩余的可能性:
switch (firstItem, secondItem) {
//...
default:
print("\(firstItem) and \(secondItem) cannot be combined")
return nil
}
对于我们之前的 combine 函数,你会注意到,玩家只有在提供正确的顺序时才能组合物品:
let door1 = combine(.key, with: .lockedDoor) // openDoor
let door2 = combine(.lockedDoor, with: .key) // nil
这不是期望的行为,因为玩家无法知道正确的顺序。为了解决这个问题,我们可以在每个 case 语句中添加多个模式。因此,当玩家提供 key 和 lockedDoor 物品时,我们可以使用相同的 case 语句处理 key,lockedDoor 的顺序和 lockedDoor,key 的顺序,格式如下:
switch <#value#> {
case <#pattern#>, <#pattern#>:
<#code#>
default:
<#code#>
}
因此,我们可以将相反的物品顺序作为另一个模式添加到每个案例中:
func combine(_ firstItem: Item, with secondItem: Item) -> Item? {
switch (firstItem, secondItem) {
case (.key, .lockedDoor), (.lockedDoor, .key):
print("You have unlocked the door!")
return .openDoor
case (.bluntKnife, .sharpeningStone), (.sharpeningStone,
.bluntKnife):
print("Your knife is now sharp")
return .sharpKnife
default:
print("\(firstItem) and \(secondItem) cannot be combined")
return nil
}
}
现在物品可以以任何顺序组合:
let door1 = combine(.key, with: .lockedDoor) // openDoor
let door2 = combine(.lockedDoor, with: .key) // openDoor
对于我们的say方法,我们再次有两个值想要切换:玩家说的文本和所说的角色。由于character值是可选的,我们需要展开值以与非可选值进行比较:
func say(_ textToSay: String, to character: Character? = nil) {
switch (textToSay, character) {
case ("abracadabra", .wizard?):
print("The wizard says, "Hey, that's my line!"")
//...
}
}
在switch语句中,当值是可选的,你可以通过添加?将其包装为可选值来将其与非可选值进行比较,使比较有效。在上一个例子中,我们正在将可选的character值与.wizard?进行比较。
当对于一组特定选项有两个值时,我们可能只关心其中一个值,另一个值可以是任何值,并且情况仍然有效。在我们的例子中,一旦处理了所有特定的textToSay和字符配对,以及处理了没有字符的情况,我们想要展开并检索字符,但我们不关心textToSay的值,因此我们可以使用_来表示任何值都是可接受的:
func say(_ textToSay: String, to character: Character? = nil) {
switch (textToSay, character) {
//...
case (_, let anyCharacter?):
print("The \(anyCharacter) looks at you, blankly)")
}
}
要检索作为此case语句一部分输入的字符值,而不是声明一个要匹配的值,我们定义一个将接收值的常量,并且由于我们正在切换的值是可选的,我们也添加了?,如果值不是nil,它将展开值并将其分配给常量。
参见
更多关于switch的信息可以在苹果关于 Swift 语言的文档中找到,请参阅swiftbook.link/docs/switch。
使用for循环进行循环
for循环允许你对集合或范围中的每个元素执行代码。在本食谱中,我们将探讨如何使用for循环对集合中的每个元素执行操作。
如何做到这一点...
让我们创建一些集合,然后使用for循环对集合中的每个元素进行操作:
- 创建一个元素数组,这样我们就可以对数组中的每个元素进行操作:
let theBeatles = ["John", "Paul", "George", "Ringo"]
- 创建一个循环来遍历我们的
theBeatles数组,并打印出for循环提供的每个字符串元素:
for musician in theBeatles {
print(musician)
}
- 创建一个执行固定次数代码的
for循环,而不是遍历数组。我们可以通过提供一个范围而不是集合来实现这一点:
// 5 times table
for value in 1...12 {
print("5 x \(value) = \(value*5)")
}
- 创建一个
for循环来打印字典的键和值。字典包含键和值的配对,因此当遍历字典时,我们将以元组的形式提供键和值:
let beatlesByInstrument = ["rhythm guitar": "John",
"bass guitar": "Paul",
"lead guitar": "George",
"drums": "Ringo"]
for (key, value) in beatlesByInstrument {
print("\(value) plays \(key)")
}
它是如何工作的...
让我们看看我们是如何遍历我们的theBeatles数组的:
for musician in theBeatles {
print(musician)
}
我们指定for关键字,然后为将用于集合或范围中每个元素的局部变量提供一个名称。然后,提供in关键字,后面跟着将要遍历的集合或范围:
for <#each element#> in <#collection or range#> {
<#code to execute#>
}
对于基于范围的循环,每个循环提供的值是范围内的下一个整数:
for value in 1...12 {
print("5 x \(value) = \(value*5)")
}
范围可以是一个闭区间,其中范围包括起始值和结束值,就像上面指定的那样。或者它可以是半开区间,它向上到但不包括最后一个值,如下面的代码所示:
for value in 1..<13 {
print("5 x \(value) = \(value*5)")
}
当遍历字典时,我们需要同时提供键和值;为此,我们提供一个元组,它将接收字典中的每个键和值:
for (key, value) in beatlesByInstrument {
print("\(value) plays \(key)")
}
我们可以定义一个元组并为每个值命名。这个名称随后可以在执行块中使用。让我们将元组的标签改为更好地描述这些值:
for (instrument, musician) in beatlesByInstrument {
print("\(musician) plays \(instrument)")
}
在前面的例子中给元组赋予有意义的名称使代码更容易阅读。
参考资料还有
更多关于for-in循环的信息可以在 Apple 关于 Swift 语言的文档中找到,请参阅swiftbook.link/docs/for-in。
使用 while 循环进行循环
for循环在您知道要循环多少次时很棒,但如果您想循环直到满足某个条件,则需要使用while循环。
while循环的语法如下:
while <#boolean expression#> {
<#code to execute#>
}
代码块将反复执行,直到布尔表达式返回false。因此,在代码块中更改某些值以使布尔表达式变为false是一种常见的模式。
如果布尔表达式没有变为true的机会,代码将无限循环,这可能会锁定您的应用程序。
在这个菜谱中,我们将探讨while循环可以用于重复操作的情况。
准备工作
这个菜谱将涉及模拟随机抛硬币。为了抛硬币,我们需要随机选择正面或反面,因此我们需要使用来自 Foundation 框架的随机数生成器。我们将在第五章,超越标准库中进一步讨论 Foundation,但到目前为止,我们只需要在 playground 的顶部导入 Foundation 框架:
import Foundation
这将使我们能够生成一个随机数,我们现在将使用它。
如何做到这一点...
让我们来计算一下连续抛硬币得到正面的次数:
- 创建一个表示硬币抛掷的
enum,并使用随机数生成器随机选择正面或反面:
enum CoinFlip: Int {
case heads
case tails
static func flipCoin() -> CoinFlip {
return CoinFlip(rawValue: Int(arc4random_uniform(2)))!
}
}
- 创建一个函数,该函数将返回连续抛硬币得到正面的次数。该函数将在
while循环中抛硬币,并在硬币抛掷结果为正面时继续循环:
func howManyHeadsInARow() -> Int {
var numberOfHeadsInARow = 0
var currentCoinFlip = CoinFlip.flipCoin()
while currentCoinFlip == .heads {
numberOfHeadsInARow = numberOfHeadsInARow + 1
currentCoinFlip = CoinFlip.flipCoin()
}
return numberOfHeadsInARow
}
let noOfHeads = howManyHeadsInARow()
它是如何工作的...
在我们的函数中,我们首先跟踪连续抛硬币得到正面的次数,并保留对当前硬币抛掷的引用,这将形成while循环的条件:
func howManyHeadsInARow() -> Int {
var numberOfHeadsInARow = 0
var currentCoinFlip = CoinFlip.flipCoin()
//...
}
在我们的while循环中,我们将继续循环并执行以下代码块中的代码,只要当前的硬币抛掷结果是正面:
while currentCoinFlip == .heads {
numberOfHeadsInARow = numberOfHeadsInARow + 1
currentCoinFlip = CoinFlip.flipCoin()
}
在代码块中,我们将运行总金额加一,并重新翻转硬币。我们正在翻转硬币并将其分配给currentCoinFlip,这将在下一次循环中重新检查,如果它仍然是正面,则下一次循环将被执行。由于我们正在更改影响while条件的东西,这样它最终可能是false,我们可以确信我们不会永远卡在循环中。
一旦硬币翻转结果为反面,while循环条件将为false,因此执行将继续并返回我们一直在保持的运行总金额:
return numberOfHeadsInARow
现在,每次你调用该函数时,硬币都会随机翻转,并返回连续出现正面次数,所以每次调用时,你可能会得到不同的返回值。试几次看看:
let noOfHeads = howManyHeadsInARow()
还有更多...
实际上,我们可以通过将硬币翻转作为循环延续检查的一部分来简化我们的while循环:
func howManyHeadsInARow() -> Int {
var numberOfHeadsInARow = 0
while CoinFlip.flipCoin() == .heads {
numberOfHeadsInARow = numberOfHeadsInARow + 1
}
return numberOfHeadsInARow
}
每次通过循环时,都会评估while条件,这涉及到重新翻转硬币并检查结果。
这更简洁,并且消除了跟踪currentCoinFlip的需要。
参见
更多关于while循环的信息可以在苹果公司关于 Swift 语言的文档中找到,请参阅swiftbook.link/docs/while。
使用 try、throw、do 和 catch 处理错误
编程过程中会发生错误。这些错误可能是由于你自己的代码以意想不到的方式运行,或者由于来自外部系统的意外信息或行为。当这些错误发生时,正确处理它们非常重要。良好的错误处理可以将一个优秀的应用程序与一个伟大的应用程序区分开来。
Swift 提供了一种故意且灵活的错误处理模式,允许特定的错误通过复杂系统级联。
在这个菜谱中,我们将了解如何定义错误,并在必要时抛出它们。
如何做到这一点...
为了检查错误处理,我们将模拟一个可能会出错的过程,对我来说,那就是烹饪餐点:
- 首先,让我们定义烹饪餐点涉及的步骤,作为餐点将经历的状态:
enum MealState {
case initial
case buyIngredients
case prepareIngredients
case cook
case plateUp
case serve
}
- 创建一个对象来表示我们将要烹饪的餐点。该对象将持有餐点在过程中移动的状态:
class Meal {
var state: MealState = .initial
}
我们希望允许餐点在状态之间转换,但并非所有状态转换都是可能的。例如,你不能从购买食材直接过渡到上菜。餐点应该按顺序从一个状态转换到下一个状态。我们可以通过只允许在对象内部设置状态来提供这些限制,使用我们在上一章中探讨的访问控制。
- 将
state属性定义为只能私有设置:
class Meal {
private(set) var state: MealState = .initial
}
- 为了允许从对象外部更改状态,创建一个函数,如果状态转换不可行,则抛出错误:
class Meal {
private(set) var state: MealState = .initial
func change(to newState: MealState) throws {
switch (state, newState) {
case (.initial, .buyIngredients),
(.buyIngredients, .prepareIngredients),
(.prepareIngredients, .cook),
(.cook, .plateUp),
(.plateUp, .serve):
state = newState
default:
throw MealError.canOnlyMoveToAppropriateState
}
}
}
遵循 Swift 的协议导向方法,Swift 中的错误被定义为协议,即 Error 协议。这种方法允许你创建自己的类型来表示代码中的错误,并且只需让它符合 Error 协议即可。
一种常见的做法是将错误定义为枚举,枚举的案例代表可能发生的不同类型的错误。
- 定义前面
Meal类中抛出的错误:
enum MealError: Error {
case canOnlyMoveToAppropriateState
}
- 尝试在一个
do块中执行我们的错误抛出方法并捕获可能发生的任何错误:
let dinner = Meal()
do {
try dinner.change(to: .buyIngredients)
try dinner.change(to: .prepareIngredients)
try dinner.change(to: .cook)
try dinner.change(to: .plateUp)
try dinner.change(to: .serve)
print("Dinner is served!")
} catch let error {
print(error)
}
它是如何工作的...
Swift 错误处理中使用的隐喻(以及其他语言)是 抛出 和 捕获。如果一个方法在执行过程中遇到问题,它可以 抛出 一个错误,此时方法中的其他代码将不会执行,错误将被传递回方法被调用的地方。
为了接收这个错误(可能为了向用户提供错误的详细信息),你必须在方法被调用的地方 捕获 这个错误。
要抛出一个错误,你必须声明该方法有抛出错误的可能性。声明一个方法 throws 允许编译器期望方法中可能出现的错误,并确保你不会忘记捕获这些错误。
可以使用 throws 关键字声明方法可能抛出错误:
func change(to newState: MealState) throws {
//...
}
在我们的状态转换方法中,我们只有在移动到下一个顺序状态时才会改变状态。其他任何操作都是不允许的,应该抛出一个错误。我们可以使用 throw 关键字,后跟一个符合 Error 协议的值来完成这个操作:
func change(to newState: MealState) throws {
//...
default:
throw MealError.canOnlyMoveToAppropriateState
}
}
当我们创建 Meal 对象并遍历准备餐点的状态时,每个状态的变化都可能抛出一个错误。当我们调用标记为可能抛出错误的函数时,我们必须以某种方式执行。我们定义一个 do 块,在其中我们可以调用可能抛出的方法,然后定义一个 catch 块,如果这些方法中的任何一个抛出错误,它将被执行。每个抛出方法的调用都必须以 try 关键字为前缀:
let dinner = Meal()
do {
try dinner.change(to: .buyIngredients)
try dinner.change(to: .prepareIngredients)
try dinner.change(to: .cook)
try dinner.change(to: .plateUp)
try dinner.change(to: .serve)
print("Dinner is served!")
} catch let error {
print(error)
}
如果这些方法中的任何一个抛出错误,执行将立即转移到 catch 块。因此,通过在 try 方法调用之后放置代码,我们确保只有在方法没有抛出错误的情况下才会执行这些代码。在所有状态转换调用之后打印 Dinner is served!,我们知道这只会打印出来,如果我们已经成功通过了所有状态。尝试改变这些状态转换调用的顺序,你会看到错误会被打印出来,而 Dinner is served! 不会。
在我们的 catch 块中,在 catch 关键字之后,我们可以定义想要将捕获的错误分配到的局部常量。然而,如果我们在这里没有指定局部常量,Swift 会隐式地为我们创建一个名为 error 的常量,因此我们实际上可以在 catch 块中省略常量声明并仍然打印错误的值:
do {
//...
} catch {
print(error)
}
Swift 已经为我们定义了错误,所以我们仍然可以打印其值。
还有更多...
我们已经看到了如何抛出和捕获错误,但在介绍中我们提到我们可以通过系统级联错误,所以让我们看看我们如何做到这一点。
在我们的餐点准备示例中,我们允许通过可以抛出错误的 change 方法从外部更改餐点状态。相反,让我们将其更改为私有方法,这样我们就只能从类内部调用它:
class Meal {
private(set) var state: MealState = .initial
private func change(to newState: MealState) throws {
switch (state, newState) {
case (.initial, .buyIngredients),
(.buyIngredients, .prepareIngredients),
(.prepareIngredients, .cook),
(.cook, .plateUp),
(.plateUp, .serve):
state = newState
default:
throw MealError.canOnlyMoveToAppropriateState
}
}
}
接下来,让我们创建一些具体的方法来移动到每个状态:
class Meal {
//...
func buyIngredients() throws {
try change(to: .buyIngredients)
}
func prepareIngredients() throws {
try change(to: .prepareIngredients)
}
func cook() throws {
try change(to: .cook)
}
func plateUp() throws {
try change(to: .plateUp)
}
func serve() throws {
try change(to: .serve)
}
}
你会注意到,当我们从每个新方法内部调用 change 方法时,我们不需要使用 do 和 catch 块来捕获错误;这是因为我们已经将每个新方法定义为可能抛出错误的,所以如果 change 方法的调用抛出错误,这个错误将作为抛出错误传递给我们的新方法的调用者。
这种机制允许在代码的多个层级中可能发生的错误暴露出来并得到适当的处理。
现在我们需要修改我们的餐点准备代码以使用这些新方法:
let dinner = Meal()
do {
try dinner.buyIngredients()
try dinner.prepareIngredients()
try dinner.cook()
try dinner.plateUp()
try dinner.serve()
print("Dinner is served!")
} catch let error {
print(error)
}
让我们添加实际影响我们的餐点的功能。我们将添加一个向餐点中加盐的方法和一个属性,以便我们可以跟踪添加了多少盐。将这些添加到 Meal 类的末尾:
class Meal {
//...
private(set) var saltAdded = 0
func addSalt() throws {
if saltAdded >= 5 {
throw MealError.tooMuchSalt
} else if case .initial = state, case .buyIngredients = state {
throw MealError.wrongStateToAddSalt
} else {
saltAdded = saltAdded + 1
}
}
}
添加盐分可能导致两种错误,要么是因为我们处于不适合添加盐的状态(我们只能在购买食材之后才能添加盐),要么是因为我们添加了过多的盐。让我们将这些两个新的错误添加到我们的 MealError 枚举中:
enum MealError: Error {
case canOnlyMoveToAppropriateState
case tooMuchSalt
case wrongStateToAddSalt
}
现在我们有三种可能发生在准备餐点过程中的错误,我们可能希望以不同的方式处理这些错误。我们可以使用多个 catch 块来过滤仅特定的错误,这样我们就可以单独处理每个错误:
let dinner = Meal()
do {
try dinner.buyIngredients()
try dinner.prepareIngredients()
try dinner.cook()
try dinner.plateUp()
try dinner.serve()
print("Dinner is served!")
} catch MealError.canOnlyMoveToAppropriateState {
print("It's not possible to move to this state")
} catch MealError.tooMuchSalt {
print("Too much salt!")
} catch MealError.wrongStateToAddSalt {
print("Can't add salt at this stage")
} catch {
print("Some other error: \(error)")
}
确保所有可能的错误都被 catch 块处理非常重要,因为未处理的错误会导致程序崩溃。因此,最安全的方法是在最后添加一个未过滤的 catch 块来捕获之前块未捕获的任何错误。
由于函数可以抛出错误,而闭包是一种可以作为参数传递的函数类型,因此我们可以有一个接受抛出闭包的函数,其中它也可以抛出错误。可能的情况是,我们的函数将抛出的唯一错误是由作为参数传递的抛出闭包产生的错误。
当这是真的时,可以使用 rethrows 关键字定义一个函数作为重新抛出。
这种情况相当令人困惑,所以让我们看看一个例子:
func makeMeal(using preparation: (Meal) throws -> ()) rethrows -> Meal {
let newMeal = Meal()
try preparation(newMeal)
return newMeal
}
这个 makeMeal 函数接受一个闭包作为参数;这个闭包接受一个 Meal 对象作为参数,并且不返回任何内容,但可能会抛出错误。
这个函数的目的是为你处理 meal 对象的创建,只让你在块内进行任何餐点准备;然后它返回创建并准备好的餐点。让我们看看它是如何使用的:
do {
let dinner = try makeMeal { meal in
try meal.buyIngredients()
try meal.prepareIngredients()
try meal.cook()
try meal.addSalt()
try meal.plateUp()
try meal.serve()
}
if dinner.state == .serve {
print("Dinner is served!")
}
} catch MealError.canOnlyMoveToAppropriateState {
print("It's not possible to move to this state")
} catch MealError.tooMuchSalt {
print("Too much salt!")
} catch MealError.wrongStateToAddSalt {
print("Can't add salt at this stage")
}
makeMeal函数只抛出闭包参数抛出的错误,因此它可以声明为重新抛出。使用rethrows关键字声明这种类型的函数不是必需的,可以用throws来声明。然而,编译器可以为重新抛出函数进行额外的优化。
参见
更多关于错误处理的信息可以在 Apple 关于 Swift 语言的文档中找到,请参阅swiftbook.link/docs/error-handling。
使用guard语句提前检查
在之前的菜谱中,我们看到了如何使用if语句来检查布尔表达式和展开可选值。在代码块的开头进行一些检查和条件展开是一个常见的用例,然后只有在一切如预期的情况下才执行后续代码。这通常会导致将整个代码块包裹在一个if语句中:
if <#boolean check and unwrapping#> {
<#a block of code#>
<#that could be quite long#>
}
Swift 有一个专门为此目的的更好解决方案;guard语句。
在这个菜谱中,我们将学习如何使用guard语句从方法中提前返回。
准备工作
让我们假设我们有一些来自外部来源的数据,我们希望将其转换为我们的代码可以理解的模型对象,目的是将其显示给用户。我们可以使用guard语句来确保数据格式正确,如果不正确则提前退出。
如何做到这一点...
我们将取一些关于太阳系行星的信息,这些信息可能来自外部来源,并将其转换为我们可以理解的模型:
- 以字典数组的形式创建行星数据:
// From https://en.wikipedia.org/wiki/Solar_System
let inputData: [[String: Any]] = [
["name": "Mercury",
"positionFromSun": 1,
"fractionOfEarthMass": 0.055,
"distanceFromSunInAUs": 0.4,
"hasRings": false],
["name": "Venus",
"positionFromSun": 2,
"fractionOfEarthMass": 0.815,
"distanceFromSunInAUs": 0.7,
"hasRings": false],
["name": "Earth",
"positionFromSun": 3,
"fractionOfEarthMass": 1.0,
"distanceFromSunInAUs": 1.0,
"hasRings": false],
["name": "Mars",
"positionFromSun": 4,
"fractionOfEarthMass": 0.107,
"distanceFromSunInAUs": 1.5,
"hasRings": false],
["name": "Jupiter",
"positionFromSun": 5,
"fractionOfEarthMass": 318.0,
"distanceFromSunInAUs": 5.2,
"hasRings": false],
["name": "Saturn",
"positionFromSun": 6,
"fractionOfEarthMass": 95.0,
"distanceFromSunInAUs": 9.5,
"hasRings": true],
["name": "Uranus",
"positionFromSun": 7,
"fractionOfEarthMass": 14.0,
"distanceFromSunInAUs": 19.2,
"hasRings": false],
["name": "Neptune",
"positionFromSun": 8,
"fractionOfEarthMass": 17.0,
"distanceFromSunInAUs": 30.1,
"hasRings": false]
]
- 定义一个
Planet结构体,它将根据数据创建:
struct Planet {
let name: String
let positionFromSun: Int
let fractionOfEarthMass: Double
let distanceFromSunInAUs: Double
let hasRings: Bool
}
- 一步一步来,创建一个函数,该函数将接受单个行星字典并创建一个
Planet结构体,如果可能的话。我们将使用guard语句来确保字典包含我们期望的所有值:
func makePlanet(fromInput input: [String: Any]) -> Planet? {
guard
let name = input["name"] as? String,
let positionFromSun = input["positionFromSun"] as? Int,
let fractionOfEarthMass = input["fractionOfEarthMass"] as?
Double,
let distanceFromSunInAUs = input["distanceFromSunInAUs"] as?
Double,
let hasRings = input["hasRings"] as? Bool
else {
return nil
}
return Planet(name: name,
positionFromSun: positionFromSun,
fractionOfEarthMass: fractionOfEarthMass,
distanceFromSunInAUs: distanceFromSunInAUs,
hasRings: hasRings)
}
- 现在我们能够处理单个行星数据,创建一个函数,该函数将接受一个行星字典数组并生成一个
Planet结构体的数组,使用guard语句来确保我们成功创建一个Planet结构体:
func makePlanets(fromInput input: [[String: Any]]) -> [Planet] {
var planets = [Planet]()
for inputItem in input {
guard let planet = makePlanet(fromInput: inputItem) else {
continue }
planets.append(planet)
}
return planets
}
它是如何工作的...
guard语句的工作方式与if语句非常相似,因为可选值可以以相同的方式展开和链接。由于我们的行星数据包含字符串、整数、浮点数和布尔值,字典的类型是[String: Any]。因此,为了创建我们的Planet结构体,我们需要检查给定键的预期值是否存在,并将它们转换为正确的类型。
在我们的makePlanet函数中,我们使用guard关键字,然后从行星数据字典中访问和条件地转换我们所需的所有值。如果这些条件转换中的任何一个失败,那么在guard语句之后定义的else块将被执行。我们定义我们的函数返回一个可选的Planet,所以如果我们没有预期的信息,guard将失败,并返回nil:
func makePlanet(fromInput input: [String: Any]) -> Planet? {
guard
let name = input["name"] as? String,
let positionFromSun = input["positionFromSun"] as? Int,
let fractionOfEarthMass = input["fractionOfEarthMass"] as?
Double,
let distanceFromSunInAUs = input["distanceFromSunInAUs"] as?
Double,
let hasRings = input["hasRings"] as? Bool
else {
return nil
}
return Planet(name: name,
positionFromSun: positionFromSun,
fractionOfEarthMass: fractionOfEarthMass,
distanceFromSunInAUs: distanceFromSunInAUs,
hasRings: hasRings)
}
guard语句解包的任何值都可在同一作用域内guard语句下面的任何代码中使用;这使得guard语句非常适合在继续之前确保输入值符合预期。这消除了在if块内嵌套代码的需要。解包的值随后用于初始化Planet结构体。
正如我们所见,guard语句用于在guard条件失败时中断执行,因此编译器确保在else块中放置一个中断执行的语句;这可以是,例如,return、break或continue。
在makePlanets函数中,我们使用for循环遍历字典,并尝试从每个字典中创建一个Planet结构体。如果我们的makePlanet调用返回nil,我们调用continue来跳过这个for循环的迭代,并跳到下一个迭代:
func makePlanets(fromInput input: [[String: Any]]) -> [Planet] {
//...
for inputItem in input {
guard let planet = makePlanet(fromInput: inputItem) else {
continue }
planets.append(planet)
}
//...
}
还有更多...
makePlanets函数接受一个包含行星数据字典的数组,并返回一个Planet结构体的数组。如果提供的数组为空,我们可能决定这不是我们函数的有效输入,并且我们想要抛出一个错误;guard也可以帮助做到这一点。
我们可以使用guard检查任何条件语句是否为真,如果不是,我们可以抛出一个错误:
enum CreationError: Error {
case noData
}
func makePlanets(fromInput input: [[String: Any]]) throws -> [Planet] {
guard input.count > 0 else { throw CreationError.noData }
//...
}
参见
关于guard语句的更多信息可以在 Apple 关于 Swift 语言的文档中找到,链接为swiftbook.link/docs/guard。
使用 defer 延迟执行
通常,当我们调用一个函数时,控制从调用站点传递到函数,然后函数内的语句按顺序执行,直到函数的末尾或直到出现return语句。然后控制返回到调用站点。在以下图中,print语句按顺序 1、2、然后 3 执行:

图 3.2 – print 语句
有时,在函数返回后但在控制返回到调用站点之前执行一些代码可能很有用。这是 Swift 的defer语句的目的。在以下示例中,步骤 3 在步骤 2 之后执行,即使它定义在步骤 2 之上:

图 3.3 – defer 语句
在这个菜谱中,我们将探讨如何使用defer,以及它在什么情况下可能有用。
准备工作
defer语句在函数执行完成后更改状态或清理不再需要的值时非常有用。让我们看看使用defer语句更新状态的例子。
如何做...
想象一下,我们有一些带有星级评分的电影评论,我们想要根据它们的星级评分对它们进行分类:
- 定义电影评论可能被分类为的选项:
enum MovieReviewClass {
case bad
case average
case good
case brilliant
}
- 创建一个用于分类的对象:
class MovieReviewClassifier {
func classify(forStarsOutOf10 stars: Int) -> MovieReviewClass {
if stars > 8 {
return .brilliant // 9 or 10
} else if stars > 6 {
return .good // 7 or 8
} else if stars > 3 {
return .average // 4, 5 or 6
} else {
return .bad // 1, 2 or 3
}
}
}
- 使用分类器对评论进行分类:
let classifier = MovieReviewClassifier()
let review1 = classifier.classify(forStarsOutOf10: 9)
print(review1) // brilliant
这工作得很好,但为了本例的目的,让我们假设这个分类是一个长时间运行的过程,我们想要跟踪分类器的状态,以便我们可以外部检查分类器是否正在分类过程中或已完成。
- 定义可能的分类状态:
enum ClassificationState {
case initial
case classifying
case complete
}
- 更新我们的分类器类以保存和更新状态,使用
defer语句将状态移动到完成状态:
class MovieReviewClassifier {
var state: ClassificationState = .initial
func classify(forStarsOutOf10 stars: Int) -> MovieReviewClass {
state = .classifying
defer {
state = .complete
}
if stars > 8 {
return .brilliant // 9 or 10
} else if stars > 6 {
return .good // 7 or 8
} else if stars > 3 {
return .average // 4, 5 or 6
} else {
return .bad // 1, 2 or 3
}
}
}
- 使用分类器对评论进行分类并检查状态:
let classifier = MovieReviewClassifier()
let review1 = classifier.classify(forStarsOutOf10: 9)
print(review1) // brilliant
print(classifier.state) // complete
它是如何工作的...
我们上面定义的 classify 方法接受一个输入评分,然后根据这个评分返回 MovieReviewClass:
func classify(forStarsOutOf10 stars: Int) -> MovieReviewClass {
//...
if stars > 8 {
return .brilliant // 9 or 10
} else if stars > 6 {
return .good // 7 or 8
} else if stars > 3 {
return .average // 4, 5 or 6
} else {
return .bad // 1, 2 or 3
}
}
在执行此操作的同时,它还会更新一个 state 值,以指示方法在分类过程中的位置:
state = .classifying
defer {
state = .complete
}
defer 语句允许在方法返回后更新状态。
如果我们不使用 defer 语句来编写这个方法,我们必须在返回值之前的每个 if 语句分支中转换到 complete 状态,因为在此之后将不会执行任何操作。该方法的结尾将如下所示:
if stars > 8 {
state = .complete
return .brilliant // 9 or 10
} else if stars > 6 {
state = .complete
return .good // 7 or 8
} else if stars > 3 {
state = .complete
return .average // 4, 5 or 6
} else {
state = .complete
return .bad // 1, 2 or 3
}
当我们使用 defer 语句时,可以避免这种更新状态的重复:
defer {
state = .complete
}
要延迟代码,只需使用 defer 关键字,并将要延迟的代码定义在大括号内;这段代码将在方法返回后、控制流返回给调用者之前运行。
还有更多...
你可以在方法中定义多个 defer 语句,并且它们将按照它们定义的相反顺序执行,所以最后定义的 defer 语句是在方法返回后首先执行的一个。
为了演示,添加一个新的状态,当完成第一次分类之后的分类时,我们将切换到该状态:
enum ClassificationState {
case initial
case classifying
case complete
case completeAgain
}
现在,让我们修改我们的分类器以跟踪其进行的分类数量,并在完成超过一个分类时将其更改为 completeAgain 状态:
class MovieReviewClassifier {
var state: ClassificationState = .initial
var numberOfClassifications = 0
func classify(forStarsOutOf10 stars: Int) -> MovieReviewClass {
state = .classifying
defer {
numberOfClassifications += 1
}
defer {
if numberOfClassifications > 0 {
state = .completeAgain
} else {
state = .complete
}
}
if stars > 8 {
return .brilliant // 9 or 10
} else if stars > 6 {
return .good // 7 or 8
} else if stars > 3 {
return .average // 4, 5 or 6
} else {
return .bad // 1, 2 or 3
}
}
}
现在更改我们使用分类器的方式;第二次使用它时,它将以不同的状态完成:
let classifier = MovieReviewClassifier()
let review1 = classifier.classify(forStarsOutOf10: 9)
print(review1) // brilliant
print(classifier.state) // complete
print(classifier.numberOfClassifications) // 1
let review2 = classifier.classify(forStarsOutOf10: 2)
print(review2) // bad
print(classifier.state) // completeAgain
print(classifier.numberOfClassifications) // 2
由于我们现在已经定义了两个 defer 语句,让我们再次查看它们的执行顺序:
defer {
numberOfClassifications += 1
}
defer {
if numberOfClassifications > 0 {
state = .completeAgain
} else {
state = .complete
}
}
如前所述,最后定义的 defer 语句首先执行。因此,在第一次分类中,一旦方法返回,最后一个 defer 语句将执行,状态将更改为 complete,因为 numberOfClassifications 将为 0。接下来,第一个 defer 语句执行,将 1 添加到 numberOfClassifications 变量中,这将现在是 1。
在第二次分类中,一旦方法返回,最后一个 defer 语句将执行并将状态更改为 completeAgain,因为 numberOfClassifications 大于 0。最后,第一个 defer 语句将执行,增加 numberOfClassifications 的值,使其变为 2。
如果defer语句的顺序相反,状态将始终变为completeAgain,因为numberOfClassifications会在检查之前增加到1。
参见
关于defer语句的更多信息可以在苹果关于 Swift 语言的文档中找到,请访问swiftbook.link/docs/defer。
使用 fatalError 和 precondition 退出
想到你写的代码中,一切都会如预期发生,并且你的程序可以处理任何事件,这是令人欣慰的。然而,有时事情可能会出错——真的会出错。可能会出现一种你知道是可能的但从未期望发生的情况,如果发生,程序应该终止。在这个菜谱中,我们将探讨这类问题:fatalError和precondition。
准备工作
让我们重用之前的示例;我们有一个可以根据评论中给出的 10 颗星中的多少颗来对电影评论进行分类的对象。然而,让我们简化它的使用,并说我们只打算让分类器对象对一部电影评论进行一次分类。
如何做...
让我们设置我们的电影分类器,使其只能使用一次,并且只能接受 10 分制的评分:
- 定义分类状态和电影评论类:
enum ClassificationState {
case initial
case classifying
case complete
}
enum MovieReviewClass {
case bad
case average
case good
case brilliant
}
- 使用
precondition和fatalError重新定义我们的分类器对象,以指示那些不应该发生且会导致问题的情形:
class MovieReviewClassifier {
var state: ClassificationState = .initial
func classify(forStarsOutOf10 stars: Int) -> MovieReviewClass {
precondition(state == .initial, "Classifier state must be
initial")
state = .classifying
defer {
state = .complete
}
if stars > 8 && stars <= 10 {
return .brilliant // 9 or 10
} else if stars > 6 {
return .good // 7 or 8
} else if stars > 3 {
return .average // 4, 5 or 6
} else if stars > 0 {
return .bad // 1, 2 or 3
} else {
fatalError("Star rating must be between 1 and 10")
}
}
}
let classifier = MovieReviewClassifier()
let review1 = classifier.classify(forStarsOutOf10: 9)
print(review1) // brilliant
print(classifier.state) // complete
它是如何工作的...
我们只想使用分类器一次;因此,当我们开始对电影评论进行分类时,当前状态应该是initial,因为这个对象之前从未进行过分类,不应该处于分类的中间状态。如果不是这种情况,分类器正在被错误地使用,我们应该终止代码的执行:
func classify(forStarsOutOf10 stars: Int) -> MovieReviewClass {
precondition(state == .initial, "Classifier state must be initial")
//...
}
我们使用precondition关键字声明一个前置条件,提供一个我们期望为真的布尔语句和一个可选的消息。如果这个布尔语句不为真,代码的执行将终止,并且消息将在控制台显示。
在我们的示例中,我们使状态必须为initial,当调用此方法时。
当我们的分类器执行分类时,它期望在 1 到 10 之间有一个星号的数量。然而,该方法接受一个Int作为参数;因此,可以提供任何整数值,无论是正数还是负数。如果提供的值不在 1 到 10 之间,并且分类器无法提供有效的MovieReviewClass,那么分类器正在被错误地使用,我们应该终止代码的执行:
func classify(forStarsOutOf10 stars: Int) -> MovieReviewClass {
//...
if stars > 8 && stars <= 10 {
return .brilliant // 9 or 10
} else if stars > 6 {
return .good // 7 or 8
} else if stars > 3 {
return .average // 4, 5 or 6
} else if stars > 0 {
return .bad // 1, 2 or 3
} else {
fatalError("Star rating must be between 1 and 10")
}
}
如果-否则语句涵盖了提供的星星对应的所有有效的MovieReviewClass选项,所以如果没有触发这些选项之一,我们使用致命错误来指示错误的使用。这是通过使用fatalError关键字,并提供一个可选的消息来完成的。
参见
关于fatalError的更多信息可以在苹果公司关于 Swift 语言的文档中找到,请参阅swiftbook.link/docs/fatalerror。
泛型、运算符和嵌套类型
Swift 提供了许多高级功能来构建灵活但定义良好的功能,这样它感觉就像是在扩展语言本身。在本章中,我们将检查其中两个功能:泛型和运算符。我们还将看到嵌套类型如何允许逻辑分组、访问控制和命名空间。
在本章中,我们将涵盖以下食谱:
-
使用泛型与类型
-
使用泛型与函数
-
使用泛型与协议
-
使用高级运算符
-
定义选项集
-
创建自定义运算符
-
嵌套类型和命名空间
第五章:技术要求
本章的所有代码都可以在这本书的 GitHub 仓库中找到:github.com/PacktPublishing/Swift-Cookbook-Second-Edition/tree/master/Chapter04
查看以下视频,了解代码的实际应用:bit.ly/39I7wuy
使用泛型与类型
当我们在 Swift 中构建与其他类型交互的东西时,我们通常会直接指定我们正在与之交互的类型。这很有帮助,因为它意味着我们知道该类型具有哪些功能;我们可以使用这些功能,并确保输出具有正确的类型。然而,我们现在有一个只能与指定类型交互的构造;即使概念相同,它也不能与其他类型重用。
泛型让我们在泛型适用其他类型的同时拥有一个定义的类型。这或许可以通过一个例子来最好地说明。
在这个食谱中,我们将创建一个泛型类,该类存储它最后接收的五个项目,并在请求时返回所有这些项目。
准备工作
我们将创建一个自定义集合对象,该对象将存储用户最后复制的五个字符串,以便他们可以粘贴不仅仅是最后复制的字符串,而是最后五个中的任何一个。你可以向列表中添加字符串,并请求列表中的所有字符串,这些字符串将按最新到最旧的顺序返回:
class RecentList {
var slot1: String?
var slot2: String?
var slot3: String?
var slot4: String?
var slot5: String?
func add(recent: String) {
// Move each slot down 1
slot5 = slot4
slot4 = slot3
slot3 = slot2
slot2 = slot1
slot1 = recent
}
func getAll() -> [String] {
var recent = [String]()
if let slot1 = slot1 {
recent.append(slot1)
}
if let slot2 = slot2 {
recent.append(slot2)
}
if let slot3 = slot3 {
recent.append(slot3)
}
if let slot4 = slot4 {
recent.append(slot4)
}
if let slot5 = slot5 {
recent.append(slot5)
}
return recent
}
}
let recentlyCopiedList = RecentList()
recentlyCopiedList.add(recent: "First")
recentlyCopiedList.add(recent: "Next")
recentlyCopiedList.add(recent: "Last")
var recentlyCopied = recentlyCopiedList.getAll()
print(recentlyCopied) // Last, Next, First
这很棒——它正是我们想要的。现在,假设我们想在联系人应用中添加五个最近联系人的列表。这个概念与复制的字符串列表完全相同,因为我们想做到以下事情:
-
向列表中添加内容。
-
获取列表上的所有内容,以便我们可以向用户展示。
然而,因为我们指定了 RecentList 对象只能与字符串一起工作,所以它不能与我的自定义 Person 对象一起工作。我们可以使用泛型使这更有用。
让我们通过使 RecentList 使用泛型来看看如何做到这一点。
如何实现...
我们将更新我们的 RecentList 代码以使用泛型,使其可以与其他类型一起使用:
- 修改
RecentList对象以定义一个泛型类型ListItemType,我们用它来代替String:
class RecentList<ListItemType> {
var slot1: ListItemType?
var slot2: ListItemType?
var slot3: ListItemType?
var slot4: ListItemType?
var slot5: ListItemType?
func add(recent: ListItemType) {
// Move each slot down 1
slot5 = slot4
slot4 = slot3
slot3 = slot2
slot2 = slot1
slot1 = recent
}
func getAll() -> [ListItemType] {
var recent = [ListItemType]()
if let slot1 = slot1 {
recent.append(slot1)
}
if let slot2 = slot2 {
recent.append(slot2)
}
if let slot3 = slot3 {
recent.append(slot3)
}
if let slot4 = slot4 {
recent.append(slot4)
}
if let slot5 = slot5 {
recent.append(slot5)
}
return recent
}
}
- 在创建
RecentList时提供指定的类型String,这将用于替换此RecentList实例的泛型类型:
let recentlyUsedWordList = RecentList<String>()
recentlyUsedWordList.add(recent: "First")
recentlyUsedWordList.add(recent: "Next")
recentlyUsedWordList.add(recent: "Last")
var recentlyUsedWords = recentlyUsedWordList.getAll()
print(recentlyUsedWords) // Last, Next, First
我们本可以用泛型来替换 RecentlList 中的所有 String 引用为 Any,这样它就可以接受任何类型。然而,这将允许列表由不同类型的事物组成,这并不是我们想要的。它还要求我们对返回的值进行类型转换,以便它们变得有用。
让我们来探讨一下如何将我们新泛化的 RecentList 用于我们之前讨论过的其他例子,即最近联系人的列表。
- 创建一个简单的
Person对象:
class Person {
let name: String
init(name: String) {
self.name = name
}
}
- 创建一些人员以添加到我们的最近联系人列表中:
let rod = Person(name: "Rod")
let jane = Person(name: "Jane")
let freddy = Person(name: "Freddy")
- 创建一个新的
RecentList,提供具体的Person类型:
let lastCalledList = RecentList<Person>()
- 向此列表添加人员对象:
lastCalledList.add(recent: freddy)
lastCalledList.add(recent: jane)
lastCalledList.add(recent: rod)
- 获取列表中所有人,由于这被类型化为
Person对象的数组,因此打印它们的name属性:
let lastCalled = lastCalledList.getAll()
for person in lastCalled {
print(person.name)
}
// Rod
// Jane
// Freddy
我们现在有一个泛型 RecentList 类,我们已用它与字符串和自定义的 Person 类一起使用。
它是如何工作的...
要将泛型添加到 class 或 struct 中,泛型类型在类或结构体名称之后用尖括号定义,并且可以给出任何类型名称,尽管它应该以一个 大写字母 开头,就像其他类型名称一样:
class RecentList<ListItemType> {
//...
}
这个泛型类型现在成为了一个替代品,用于在它被使用时指定的具体类型,我们可以在任何使用具体类型的地方使用它。
它可以用作属性类型:
var slot1: ListItemType?
作为参数值:
func add(recent: ListItemType)
以及作为返回类型:
func getAll() -> [ListItemType]
在许多拥有泛型系统的其他编程语言中,泛型类型通常被赋予一个单字母的类型名称,通常是 T。Swift 旨在简洁,但又不失清晰,所以我建议使用更具描述性的类型名称。
如果你有多个泛型类型,这在尖括号内可以用逗号分隔的列表表示,描述性类型名称就变得尤为重要:
class RecentList<ListItemType, SomeOtherType> {
//...
}
我们现在创建了一个泛型 RecentList 对象,它可以与任何类型一起使用。
还有更多...
虽然极端泛型有其优点,但你可能希望限制可以用于泛型类型的类型,特别是如果你需要使用该受限类型的某些功能。
假设除了从 RecentList 返回一个项目数组外,我们还想能够直接打印出列表。为此,我们需要确保在 RecentList 中使用的项目类型是可以转换为要打印的 字符串 的。已经有一个 CustomStringConvertible 协议定义了这种行为,因此我们想确保任何与 RecentList 一起使用的特定类型都符合 CustomStringConvertible:
class RecentList<ListItemType: CustomStringConvertible> {
//...
}
我们在泛型类型名称之后添加约束,用冒号分隔,类似于我们指定协议符合性和类继承的方式。实际上,虽然这个例子将泛型类型约束为实现一个协议,我们也可以指定一个特定类型必须是或继承自的类。
现在我们有了这个约束,我们可以确信任何给定的特定类型都将符合CustomStringConvertible,因此将有一个可以打印的description字符串,所以让我们创建一个方法来做这件事:
class RecentList<ListItemType: CustomStringConvertible> {
func printRecentList() {
for item in getAll() {
let printableItem = String(describing: item)
print(printableItem)
}
}
//...
剩下的唯一事情就是让我们的Person类符合CustomStringConvertible,以便它可以在RecentList中继续作为特定类型使用:
extension Person: CustomStringConvertible {
public var description: String {
return name
}
}
现在,我们可以使用我们的String类型的RecentlyList和我们的Person类型的RecentList来使用此功能:
// Using String type
let recentlyUsedWordList = RecentList<String>()
recentlyUsedWordList.add(recent: "First")
recentlyUsedWordList.add(recent: "Next")
recentlyUsedWordList.add(recent: "Last")
recentlyUsedWordList.printRecentList()
// Last
// Next
// First
// Using Person type
let rod = Person(name: "Rod")
let jane = Person(name: "Jane")
let freddy = Person(name: "Freddy")
let lastCalledList = RecentList<Person>()
lastCalledList.add(recent: freddy)
lastCalledList.add(recent: jane)
lastCalledList.add(recent: rod)
lastCalledList.printRecentList()
// Rod
// Jane
// Freddy
通过约束泛型类型,我们可以使用我们知道类型将具有的功能,以提供额外的功能。
参见
更多关于泛型类型的信息可以在苹果关于 Swift 语言的文档中找到,请参阅swiftbook.link/docs/generics。
使用泛型与函数
除了可以指定泛型类型外,您还可以使用泛型构建既广泛适用又强类型的函数。在这个菜谱中,我们将使用泛型与函数。
如何做到...
我们将使用泛型创建一个函数来帮助将值放入字典中:
- 创建一个泛型函数,将相同的值插入字典的多个键中:
func makeDuplicates<ItemType>(of item: ItemType,
withKeys keys: Set<String>) -> [String: ItemType] {
var duplicates = [String: ItemType]()
for key in keys {
duplicates[key] = item
}
return duplicates
}
- 使用此函数,传入单个值和多个键,值将存储在每个给定的键中:
let awards: Set<String> = ["Best Director",
"Best Picture",
"Best Original Screenplay",
"Best International Feature"]
let oscars2020 = makeDuplicates(of: "Parasite", withKeys: awards)
print(oscars2020["Best Picture"] ?? "")
// Parasite
print(oscars2020["Best International Feature"] ?? "")
// Parasite
它是如何工作的...
就像类型泛型一样,函数的泛型类型在尖括号内指定:
func makeDuplicates<ItemType>(of item: ItemType,
withKeys keys: Set<String>) -> [String: ItemType] {
//...
}
定义好的泛型类型名称可以在函数定义的其余部分用作类型定义。在我们的例子中,我们希望定义输入项的类型为要复制的类型,并且我们还想让字典中持有的值返回的类型相同。
而不是使用泛型,我们可以用Any类型代替泛型类型:
func makeDuplicates(of item: Any, withKeys keys: Set<String>) -> [String: Any] {
//...
}
然而,这种方法给使用此函数的人带来了一些问题:
-
他们将得到一个包含
Any类型值的字典,这需要被转换为一个更有用的类型。 -
没有看到实现,他们不能确定字典包含相同类型的值。一个键可能存储了一个
String,另一个键可能存储了一个Int。 -
没有看到实现,他们不能确定返回字典中的值与提供的项具有相同的类型。
通过使用泛型类型,我们允许功能广泛适用,同时在编译时强制执行我们的类型逻辑。
你会注意到,与使用泛型实例化类型不同,我们不需要在执行函数时明确指定要使用的特定类型:
let oscars2020 = makeDuplicates(of: "Parasite", withKeys: awards)
这是因为编译器能够从提供的第一个参数的类型推断它。由于Parasite是一个字符串,编译器知道该参数具有ItemType泛型类型,因此编译器推断出,对于此方法的使用,ItemType泛型类型成为String的具体类型。
还有更多...
我们可以通过提供作为第二个参数提供的键集合的泛型类型来提高我们函数的可用性:
func makeDuplicates<ItemType, KeyType>(of item: ItemType, withKeys
keys: Set<KeyType>) -> [KeyType: ItemType] {
var duplicates = [KeyType: ItemType]()
for key in keys {
duplicates[key] = item
}
return duplicates
}
泛型类型定义与先前的食谱中一样,作为尖括号内的逗号分隔列表。
Swift 中的所有集合类型(数组、字典、集合等)都使用泛型,在先前的函数中,我们将泛型类型从我们的函数传递到集合中。因此,KeyType必须符合Hashable协议,因为这是在Set中使用所必需的。
如果我们想要使这个约束更明确,或者出于其他原因对泛型类型进行约束,那么这个约束定义在冒号之后:
func makeDuplicates<ItemType, KeyType: Hashable>(of item: ItemType,
withKeys keys: Set<KeyType>) -> [KeyType: ItemType] {
var duplicates = [KeyType: ItemType]()
for key in keys {
duplicates[key] = item
}
return duplicates
}
正如先前的例子一样,如果我们使用的具体类型可以从输入或输出中推断出来,我们就不需要指定它:
let awards: Set<String> = ["Best Director",
"Best Picture",
"Best Original Screenplay",
"Best International Feature"]
let oscars2020 = makeDuplicates(of: "Parasite", withKeys: awards)
print(oscars2020["Best Picture"]) // Parasite
print(oscars2020["Best International Feature"]) // Parasite
我们使用了两个泛型类型来提高函数的灵活性。
参见
更多关于泛型函数的信息可以在 Apple 关于 Swift 语言的文档中找到,请参阅swiftbook.link/docs/generic-functions。
使用协议与泛型结合
到目前为止,在本章中,我们已经看到了如何在类型和函数中使用泛型。在本食谱中,我们将通过查看它们如何在协议中使用来结束我们在 Swift 中使用泛型的旅程。这将允许我们产生抽象接口,同时保持强类型要求,从而允许更详细的模型。
在这个食谱中,我们将构建一个英国交通应用模型,目标是提供不同运输方式的旅程距离和持续时间。
如何实现...
人们的旅行方式可能非常不同,所以让我们首先以泛型方式定义运输方式,然后指定那些旅行方法:
- 定义一个协议来定义运输方式的功能:
protocol TransportMethod {
associatedtype CollectionPoint
var defaultCollectionPoint: CollectionPoint { get }
var averageSpeedInKPH: Double { get }
}
- 创建一个实现
TransportMethod协议的用于火车旅行的结构体:
struct Train: TransportMethod {
typealias CollectionPoint = TrainStation
// User's home or nearest station
var defaultCollectionPoint: TrainStation {
return TrainStation.BMS
}
var averageSpeedInKPH: Double {
return 100
}
}
- 我们需要定义一个
TrainStation类型,将其作为CollectionPoint使用。让我们以枚举的形式来完成它:
enum TrainStation: String {
case BMS = "Bromley South"
case VIC = "London Victoria"
case RAI = "Rainham (Kent)"
case BTN = "Brighton (East Sussex)"
// Full list of UK train stations codes at
// http://www.nationalrail.co.uk/static/documents/content
// /station_codes.csv
}
- 由于我们计划计算旅程的距离和持续时间,让我们创建一个
Journey对象来表示从起点到终点的旅程:
class Journey<TransportType: TransportMethod> {
let start: TransportType.CollectionPoint
let end: TransportType.CollectionPoint
init(start: TransportType.CollectionPoint,
end: TransportType.CollectionPoint) {
self.start = start
self.end = end
}
}
- 将运输方式作为旅程的一个属性添加,因为这将被用于持续时间计算:
class Journey<TransportType: TransportMethod> {
let start: TransportType.CollectionPoint
let end: TransportType.CollectionPoint
let method: TransportType
init(method: TransportType,
start: TransportType.CollectionPoint,
end: TransportType.CollectionPoint) {
self.start = start
self.end = end
self.method = method
}
}
- 为了计算旅程的距离,我们需要起始点和终点有明确的位置。因此,定义一个协议来提供这个位置:
protocol TransportLocation {
var location: CLLocation { get }
}
- 在游乐场的顶部导入
CoreLocation框架:
import CoreLocation
- 在
TransportMethod上约束CollectionPoint关联类型,使其必须符合我们刚刚创建的TransportLocation协议:
protocol TransportMethod {
associatedtype CollectionPoint: TransportLocation
var defaultCollectionPoint: CollectionPoint { get }
var averageSpeedInKPH: Double { get }
}
- 使用
CollectionPoint的起始和结束位置来计算旅程的距离和持续时间:
class Journey<TransportType: TransportMethod> {
var start: TransportType.CollectionPoint
var end: TransportType.CollectionPoint
let method: TransportType
var distanceInKMs: Double
var durationInHours: Double
init(method: TransportType,
start: TransportType.CollectionPoint,
end: TransportType.CollectionPoint) {
self.start = start
self.end = end
self.method = method
// CoreLocation provides the distance in meters,
// so we divide by 1000 to get kilometers
distanceInKMs = end.location.distance(from: start.location)
/ 1000
durationInHours = distanceInKMs / method.averageSpeedInKPH
}
}
- 确保我们的
TrainStation枚举符合TransportLocation协议,这是现在的要求:
enum TrainStation: String, TransportLocation {
case BMS = "Bromley South"
case VIC = "London Victoria"
case RAI = "Rainham (Kent)"
case BTN = "Brighton (East Sussex)"
// Full list of UK train stations codes can be found at
// http://www.nationalrail.co.uk/static/documents/content
// /station_codes.csv
var location: CLLocation {
switch self {
case .BMS:
return CLLocation(latitude: 51.4000504,
longitude: 0.0174237)
case .VIC:
return CLLocation(latitude: 51.4952103,
longitude: -0.1438979)
case .RAI:
return CLLocation(latitude: 51.3663,
longitude: 0.61137)
case .BTN:
return CLLocation(latitude: 50.829,
longitude: -0.14125)
}
}
}
- 使用我们的
Journey对象来计算火车旅程的距离和持续时间:
let trainJourney = Journey(method: Train(),
start: TrainStation.BMS,
end: TrainStation.VIC)
let distanceByTrain = trainJourney.distanceInKMs
let durationByTrain = trainJourney.durationInHours
print("Journey distance: \(distanceByTrain) km")
print("Journey duration: \(durationByTrain) hours")
工作原理...
在一开始,可能不清楚使用哪种结构来定义传输方法最好,可能存在适用于不同旅行方法的不同结构。因此,我们可以定义一个传输方法为一种协议,适当的类型可以符合:
protocol TransportMethod {
associatedtype CollectionPoint
var defaultCollectionPoint: CollectionPoint { get }
var averageSpeedInKPH: Double { get }
}
我们定义了一个关联的泛型类型,我们将其命名为CollectionPoint,它将代表在使用此传输方法时某人可以被收集的类型。通过使用泛型,我们在如何定义可以作为集合点的任何内容方面具有最大的灵活性。
定义了一个关联类型后,它就可以在协议使用时定义的具体类型中作为占位符使用。我们用它来定义每个传输方法应该提供的一个默认集合点。
每个传输方法还提供了一个平均速度,这将在计算旅行时间时被使用。
让我们看看一个具体的传输方法示例,以帮助进一步定义模型:
struct Train: TransportMethod {
typealias CollectionPoint = TrainStation
// User's home or nearest station
var defaultCollectionPoint: TrainStationPoint {
return TrainStation.BMS
}
var averageSpeedInKPH: Double {
return 100
}
}
为了使Train符合TransportMethod协议,我们必须提供一个协议所需的特定版本的CollectionPoint泛型类型。在乘坐火车旅行的情况下,集合点将是一个火车站,因此我们现在必须定义TrainStation类型:
enum TrainStation: String {
case BMS = "Bromley South"
case VIC = "London Victoria"
case RAI = "Rainham (Kent)"
case BTN = "Brighton (East Sussex)"
// Full list of UK train stations codes at
// http://www.nationalrail.co.uk/static/documents/content
// /station_codes.csv
}
由于火车站的数量是有限的,并且可以离散地定义,所以enum是一个表示它们的良好方式。我上面只列出了少数几个,为了简洁。
我们的目的是模拟一个旅程并计算在特定传输方法上的旅程持续时间,所以让我们创建一个Journey对象:
class Journey<TransportType: TransportMethod> {
let start: TransportType.CollectionPoint
let end: TransportType.CollectionPoint
init(start: TransportType.CollectionPoint,
end: TransportType.CollectionPoint) {
self.start = start
self.end = end
}
}
旅程是从一个点到另一个点,因此我们将旅程的起点和终点作为输入参数。我们需要有灵活性来提供任何类型作为起点和终点,但我们需要它们是与传输方法相关联的类型,起点和终点的值类型相同。为了实现这一点,我们可以有一个泛型类型,它被约束为符合TransportMethod协议;然后我们可以通过引用泛型类型的CollectionPoint关联类型来定义我们的起点和终点属性类型。
我们的目标是计算旅程的持续时间。为此,我们需要旅程中的旅行速度和起点到终点的距离。我们的TransportMethod协议定义了它将提供一个平均速度,所以让我们也将传输方法作为旅程的输入:
class Journey<TransportType: TransportMethod> {
let start: TransportType.CollectionPoint
let end: TransportType.CollectionPoint
let method: TransportType
init(method: TransportType,
start: TransportType.CollectionPoint,
end: TransportType.CollectionPoint) {
self.start = start
self.end = end
self.method = method
}
}
为了获取旅程的距离,我们需要计算起点和终点之间的距离,但旅程的起点和终点的类型都是泛型CollectionPoint类型,这可以是任何类型,因此没有我们可以用来计算距离的位置信息。
为了解决这个问题,让我们约束CollectionPoint,使其必须符合一个新的协议TransportLocation:
protocol TransportLocation {
var location: CLLocation { get }
}
任何符合TransportLocation的都必须以CLLocation对象的形式提供一个位置。CLLocation对象是 iOS 上CoreLocation框架的一部分。对CoreLocation框架的进一步研究超出了本书的范围,但只需知道它提供了计算两个CLLocation对象之间距离的方法,我们只需要在 playground 的顶部包含以下内容来使用它:
import CoreLocation
在定义了TransportLocation协议之后,我们可以在TransportMethod协议上约束CollectionPoint关联类型:
protocol TransportMethod {
associatedtype CollectionPoint: TransportLocation
var defaultCollectionPoint: CollectionPoint { get }
var averageSpeedInKPH: Double { get }
}
由于我们的CollectionPoint现在将符合TransportLocation,因此必须有一个位置属性,我们可以回到我们的Journey对象并使用它来计算行程的距离和持续时间:
class Journey<TransportType: TransportMethod> {
var start: TransportType.CollectionPoint
var end: TransportType.CollectionPoint
let method: TransportType
var distanceInKMs: Double
var durationInHours: Double
init(method: TransportType,
start: TransportType.CollectionPoint,
end: TransportType.CollectionPoint) {
self.start = start
self.end = end
self.method = method
// CoreLocation provides the distance in meters,
// so we divide by 1000 to get kilometers
distanceInKMs = end.location.distance(from: start.location) /
1000
durationInHours = distanceInKMs / method.averageSpeedInKPH
}
}
我们需要做的最后一件事是确保我们的TrainStation枚举符合TransportLocation,因为现在这是一个要求。为此,我们只需要声明符合性并添加一个location属性:
enum TrainStation: String, TransportLocation {
case BMS = "Bromley South"
case VIC = "London Victoria"
case RAI = "Rainham (Kent)"
case BTN = "Brighton (East Sussex)"
// Full list of UK train stations codes can be found at
// http://www.nationalrail.co.uk/static/documents/content
// /station_codes.csv
var location: CLLocation {
switch self {
case .BMS:
return CLLocation(latitude: 51.4000504,
longitude: 0.0174237)
case .VIC:
return CLLocation(latitude: 51.4952103,
longitude: -0.1438979)
case .RAI:
return CLLocation(latitude: 51.3663,
longitude: 0.61137)
case .BTN:
return CLLocation(latitude: 50.829,
longitude: -0.14125)
}
}
}
让我们看看我们如何使用我们的旅行模型来创建具有特定类型的行程:
let trainJourney = Journey(method: Train(),
start: TrainStation.BMS,
end: TrainStation.VIC)
let distanceByTrain = trainJourney.distanceInKMs
let durationByTrain = trainJourney.durationInHours
print("Journey distance: \(distanceByTrain) km")
print("Journey duration: \(durationByTrain) hours")
我们使用泛型和协议创建了一个泛型系统,而没有规定我们需要使用的 Swift 构造类型。
还有更多...
在这个菜谱中,我们使一个类型符合TransportMethod——这是我们Train结构体。让我们看看另一个例子,看看以协议为中心的方式处理事物如何允许实现上的灵活性。
在下一个TransportMethod中,我们将实现Road,但我们可以使用多种不同的车辆类型通过公路旅行,它们可能有不同的平均速度。由于我们有有限的公路旅行选项,让我们使用一个enum来定义它:
enum Road: TransportMethod {
typealias CollectionPoint = CLLocation
case car
case motobike
case van
case hgv
// The users home or current location
var defaultCollectionPoint: CLLocation {
return CLLocation(latitude: 51.1,
longitude: 0.1)
}
var averageSpeedInKPH: Double {
switch self {
case .car: return 60
case .motobike: return 70
case .van: return 55
case .hgv: return 50
}
}
}
火车旅行有一个有限的集合点列表,即火车站,但几乎任何地方在公路旅行时都可以是一个集合点。因此,我们可以将Road的集合点定义为任何CLLocation,但CLLocation不符合TransportLocation。我们可以通过扩展CLLocation来添加符合性来解决此问题:
extension CLLocation: TransportLocation {
var location: CLLocation {
return self
}
}
现在,我们可以通过道路定义一次行程并计算其持续时间:
let start = CLLocation(latitude: 51.3994669,
longitude: 0.0116888)
let end = CLLocation(latitude: 51.2968654,
longitude: 0.5053609)
let roadJourney = Journey(method: Road.car,
start: start,
end: end)
let distanceByRoad = roadJourney.distanceInKMs
let durationByRoad = roadJourney.durationInHours
print("Journey distance: \(distanceByRoad) km")
print("Journey duration: \(durationByRoad) hours")
通过采用面向协议的方法来解决计算行程持续时间的任务,并使用协议泛型,我们能够使用完全不同但适当的实现来处理两种运输方式,同时提供了一个接口,以便它们可以以相同的方式进行处理。
对于火车旅行,我们使用一个enum来模拟火车站,并使用一个struct来模拟运输方式;对于road,我们实现了一个enum来表示运输方式,并使用CLLocation对象作为运输位置。
参见
更多关于关联类型的信息可以在 Apple 的 Swift 语言文档中找到,链接为swiftbook.link/docs/associated-types。
使用高级运算符
Swift 是一种编程语言,它采用相对较少的明确定义的原则,并在此基础上构建出表达性和强大的语言特性。数学运算符的概念,如+、-、*和/分别用于加法、减法、乘法和除法,似乎如此基本,以至于无需提及。然而,在 Swift 中,这种常见的数学功能是建立在可扩展和强大的底层运算符系统之上的。
在这个菜谱中,我们将查看 Swift 标准库提供的某些更高级的运算符,在下一个菜谱中,我们将创建我们自己的自定义运算符。
如何做到这一点...
我们将要探索的运算符被称为位运算符,用于操作数值的位表示。
在 Swift 中,可以通过在整型字面量前加上0b来表示整数的二进制形式:
let zero: Int = 0b000
let one: Int = 0b001
let two: Int = 0b010
let three: Int = 0b011
let four: Int = 0b100
let five: Int = 0b101
let six: Int = 0b110
let seven: Int = 0b111
位是计算机系统中最小的值,由 1 或 0 组成。这里提到的整数可以用三个位来表示,这在二进制形式中非常明显,如前一个片段所示。整数six可以用三个位 1、1 和 0 来表示。
当你需要在一个值中表示多个选项时,这些二进制表示非常有用。例如,假设我们想表示一个应用程序特定功能的受支持设备。可用的设备如下所示:
-
手机
-
平板电脑
-
手表
-
笔记本电脑
-
桌面
-
电视
-
脑植入
某些功能可能适用于所有设备,或者你可能仍在开发一个功能,因此目前不适合任何设备,或者它可能是不同设备的组合。我们可以为每个设备有布尔值来表示该功能是否支持该设备,但这不是最佳解决方案,因为没有内在地将属性相互关联,并且你可能忘记在情况变化时更新一些值。
相反,我们可以用一个整数值来表示所有受支持的设备,并使用整数的每一位来表示不同的设备:
let phone: Int = 0b0000001
let tablet: Int = 0b0000010
let watch: Int = 0b0000100
let laptop: Int = 0b0001000
let desktop: Int = 0b0010000
let tv: Int = 0b0100000
let brainImplant: Int = 0b1000000
var supportedDevices: Int
要看看这如何使我们能够在单个值中存储多个设备,让我们将几个设备值相加:
phone = 0b0000001 +
tablet = 0b0000010 +
tv = 0b0100000
------------------
phone
tablet = 0b0100011
tv
由于每个设备都由不同的位表示,设备值通过相加来组合,它们不会重叠。
要测试特定的设备或设备组合是否受支持,我们可以使用位运算的AND操作。位运算 AND 操作将比较两个不同二进制值对应的位,如果两个位输入值都是 1,则在新二进制值中将该位设置为 1。例如,让我们测试我们之前创建的合并值中是否支持手机:
Supported Devices = 0b 0 1 0 0 0 1 1
Phone = 0b 0 0 0 0 0 0 1
AND Operation Result = 0b 0 0 0 0 0 0 1
结果只对最右边的位有 1 位的值,因为这是在Supported Devices值和Phone值中唯一被设置为 1 的位。
一旦我们得到这个结果,我们可以直接将其与 Phone 的值进行比较,如果它们相等,那么我们知道支持的设备值中包含了 Phone 值:
AND Operation Result = 0b 0 0 0 0 0 0 1
Phone = 0b 0 0 0 0 0 0 1
现在我们有了一种将可能的选项组合成一个值的方法,以及一种通过位运算比较这些值以查看一个是否包含在另一个中的方法。Swift 标准库包含位运算符,允许我们像其他数学运算(如 +, -, ***, 和 /)一样轻松地执行这些操作。
通常,运算符将采用以下形式:
<#left hand side value#> <#operator#> <#right hand side value#>
就像在相加两个数字时一样:
2 + 3
在前面的例子中,我们有这些:
-
2:这是左侧的值。 -
+:这是运算符。 -
3:这是右侧的值。
位移运算符 (<<) 会将左侧的整数值向右移动指定的位数。因此,我们可以在声明设备值时更好地表达我们的意图:
let phone: Int = 1 << 0 // 0b0000001
let tablet: Int = 1 << 1 // 0b0000010
let watch: Int = 1 << 2 // 0b0000100
let laptop: Int = 1 << 3 // 0b0001000
let desktop: Int = 1 << 4 // 0b0010000
let tv: Int = 1 << 5 // 0b0100000
let brainImplant: Int = 1 << 6 // 0b1000000
位运算 AND 运算符 (&) 将执行之前手动描述的相同位比较,我们可以使用这个来创建一个函数,以确定特定设备是否存在于支持的设备值中:
supportedDevices = phone + tablet + tv
func isSupported(device: Int) -> Bool {
let bitWiseANDResult = supportedDevices & device
let containsDevice = bitWiseANDResult == device
return containsDevice
}
let phoneSupported = isSupported(device: phone)
print(phoneSupported) // true
let brainImplantSupported = isSupported(device: brainImplant)
print(brainImplantSupported) // false
Swift 标准库还提供了以下逻辑运算的运算符:
- OR:OR 运算,用
|表示,比较位,如果任一值将位设置为 1,则将相应的位设置为 1。对于我们的设备,这意味着在两个设备组合之间创建一个并集:
let deviceThatSupportUIKit = phone + tablet + tv
let stationaryDevices = desktop + tv
let stationaryOrUIKitDevices = deviceThatSupportUIKit |
stationaryDevices
let orIsUnion = stationaryOrUIKitDevices == (phone + tablet + tv +
desktop)
print(orIsUnion) // true
- XOR(异或):XOR 运算,用
^表示,只有在任一值将位设置为 1 但不是两者都设置时,才会将位设置为 1:
let onlyStationaryOrUIKitDevices = deviceThatSupportUIKit ^
stationaryDevices
let xorIsUnionMinusIntersection = onlyStationaryOrUIKitDevices == (phone +
tablet + desktop)
print(xorIsUnionMinusIntersection) // true
我们已经看到了 Swift 标准库为我们提供的某些高级运算符。
参见
关于高级运算符的更多信息可以在 Apple 的 Swift 语言文档中找到,请参阅 swiftbook.link/docs/advanced-operators。
定义选项集
在一个值中持有多个选项的位运算使用是一个常见的模式,并且在 Cocoa Touch 框架 中被广泛使用,一个例子是 UIDeviceOrientation。在 Swift 中,有一个协议 OptionSet,它正式化了这种模式并提供了一些便利。在这个菜谱中,我们将探讨如何定义自己的选项集。
如何做到...
让我们重写上一个菜谱中的示例,该示例定义了支持的设备值,以使用 OptionSet:
struct Devices: OptionSet {
let rawValue: Int
static let phone = Devices(rawValue: 1 << 0)
static let tablet = Devices(rawValue: 1 << 1)
static let watch = Devices(rawValue: 1 << 2)
static let laptop = Devices(rawValue: 1 << 3)
static let desktop = Devices(rawValue: 1 << 4)
static let tv = Devices(rawValue: 1 << 5)
static let brainImplant = Devices(rawValue: 1 << 6)
static let none: Devices = []
static let all: Devices = [.phone,
.tablet,
.watch,
.laptop,
.desktop,
.tv,
.brainImplant]
static let stationary: Devices = [.desktop, .tv]
static let supportsUIKit: Devices = [.phone,
.tablet,
.tv]
}
let supportedDevices: Devices = [.phone,
.tablet,
.watch,
.tv]
它是如何工作的...
OptionSet 协议需要一个 rawValue 属性,并且惯例是为每个选项定义静态常量。此外,还可以定义选项的方便组合作为静态常量,OptionSet 提供了一个便利的初始化器,允许提供选项数组,然后通过位运算加法将选项组合成一个值。
OptionSet协议提供了类似于集合的操纵和比较方法,这些方法执行与上一菜谱中覆盖的相同的位操作:
// Contains / AND and comparison
let phoneIsSupported = supportedDevices.contains(.phone)
// Union / OR
let stationaryOrUIKitDevices =
Devices.supportsUIKit.union(Devices.stationary)
// Intersection / AND
let stationaryAndUIKitDevices =
Devices.supportsUIKit.intersection(Devices.stationary)
我们在第二章,“掌握构建块”中检查的许多集合方法也提供了。
参见
关于OptionSet协议的更多信息可以在 Apple 的 Swift 语言文档中找到,请参阅swiftbook.link/docs/optionset。
创建自定义操作符
在之前的菜谱中,我们查看了一些 Swift 在常见数学操作符之上提供的先进操作符。在这个菜谱中,我们将探讨我们如何创建自己的操作符,使表达的行为非常简洁,感觉像是语言的一部分。
我们将要创建的自定义操作符将用于将一个值的信息追加到另一个值的信息中,产生一个新的值,该值包含第二个值,后跟第一个值。我们想要实现的功能类似于>> Unix 命令。
准备工作
让我们了解 Unix 命令>>是如何工作的,在这个菜谱中,我们将使用自定义操作符在 Swift 中实现类似的功能。
由于 macOS 是基于 Unix 的,我们可以在终端中提供 Unix 命令。打开你的 Mac 上的终端应用程序:

图 4.1 – 焦点搜索
输入cd ~/Desktop并按Enter移动到包含所有桌面文件和文件夹的文件夹。输入touch Tasks.txt然后按Enter在桌面上创建一个名为Tasks.txt的空白文本文件。
要将任务添加到我们的任务文本文件中,我们可以输入以下命令,然后按Enter:
echo "buy milk" >> Tasks.txt
如果你打开桌面上的文本文件,你会看到我们在第一行添加了购买牛奶。
以相同的方式输入另一个任务:
echo "mow the lawn" >> Tasks.txt
重新打开Tasks.txt文件,你会看到割草已经被添加到第二行:

图 4.2 – 任务结果
以相同的方式添加更多任务,你会看到每个任务都被追加到文本文件的下一行。
我们在终端中发出的命令具有以下形式:
<#What to append#> >> <#Where to append it#>
让我们在 Swift 中创建类似的行为;然而,我们不能使用相同的命令字符串>>,因为这个已经被定义为向右位移位,所以让我们使用>>>。
如何做...
我们将定义并使用一个新的“追加”操作符>>>:
- 声明一个
中缀操作符:
infix operator >>>
- 定义当使用两个字符串时我们的操作符的行为:
func >>> (lhs: String, rhs: String) -> String {
var combined = rhs
combined.append(lhs)
return combined
}
- 定义当将
String追加到字符串数组时我们的操作符的行为:
func >>> (lhs: String, rhs: [String]) -> [String] {
var combined = rhs
combined.append(lhs)
return combined
}
- 定义当将字符串数组追加到另一个字符串数组时我们的操作符的行为:
func >>> (lhs: [String], rhs: [String]) -> [String] {
var combined = rhs
combined.append(contentsOf: lhs)
return combined
}
- 在这些实现到位后,使用我们新的操作符来追加内容:
let appendedString = "Two" >>> "One"
print(appendedString) // OneTwo
let appendedStringToArray = "three" >>> ["one", "two"]
print(appendedStringToArray) // ["one", "two", "three"]
let appendedArray = ["three", "four"] >>> ["one", "two"]
print(appendedArray) // ["one", "two", "three", "four"]
- 将前两个操作符实现重构,以使用数组通用的元素类型:
func >>> <Element>(lhs: Element,
rhs: Array<Element>) -> Array<Element> {
var combined = rhs
combined.append(lhs)
return combined
}
func >>> <Element>(lhs: Array<Element>,
rhs: Array<Element>) -> Array<Element> {
var combined = rhs
combined.append(contentsOf: lhs)
return combined
}
- 使用操作符处理任何类型的数组:
let appendedIntToArray = 3 >>> [1, 2]
print(appendedIntToArray) // [1, 2, 3]
let appendedIntArray = [3, 4] >>> [1, 2]
print(appendedIntArray) // [1, 2, 3, 4]
我们也可以为我们自己的自定义类型实现我们的自定义追加操作符。
- 创建一个
Task和一个TaskList来保存它:
struct Task {
let name: String
}
class TaskList: CustomStringConvertible {
private var tasks: [Task] = []
func append(task: Task) {
tasks.append(task)
}
var description: String {
return tasks.map { $0.name }.joined(separator: "\n")
}
}
- 扩展
TaskList以添加对新的追加操作符的支持:
extension TaskList {
static func >>> (lhs: Task, rhs: TaskList) {
rhs.append(task: lhs)
}
}
- 使用我们的自定义操作符将一个
Task追加到TaskList中:
let shoppingList = TaskList()
Task(name: "get milk") >>> shoppingList
print(shoppingList)
Task(name: "get teabags") >>> shoppingList
print(shoppingList)
它是如何工作的...
首先,我们声明了一个中缀操作符:
infix operator >>>
操作符可以分为三种类型:
前缀– 对一个值进行操作,并将其放置在值之前。一个例子是 NOT 操作符:
let trueValue = !falseValue
后缀– 对一个值进行操作,并将其放置在值之后。一个例子是强制解包操作符:
let unwrapped = optional!
中缀- 对两个值进行操作,并将其放置在它们之间。一个例子是加法操作符:
let five = 2 + 3
一旦我们定义了操作符,我们可以编写顶层函数来实现每种类型对的行为:一个在左侧(LHS)和一个在右侧(RHS)。方法参数重载允许我们为多类型配对指定操作符实现。
当我们的操作符与字符串一起使用时,我们可以定义如何将一个字符串追加到另一个字符串上:
func >>> (lhs: String, rhs: String) -> String {
var combined = rhs
combined.append(lhs)
return combined
}
我们可以实现将一个String追加到字符串数组中:
func >>> (lhs: String, rhs: [String]) -> [String] {
var combined = rhs
combined.append(lhs)
return combined
}
我们还可以实现将字符串数组中的元素追加到另一个字符串数组中:
func >>> (lhs: [String], rhs: [String]) -> [String] {
var combined = rhs
combined.append(contentsOf: lhs)
return combined
}
这允许我们使用操作符与字符串和字符串数组一起使用,因为这些是我们定义的实现:
let appendedString = "Two" >>> "One"
print(appendedString) // OneTwo
let appendedStringToArray = "three" >>> ["one", "two"]
print(appendedStringToArray) // ["one", "two", "three"]
let appendedArray = ["three", "four"] >>> ["one", "two"]
print(appendedArray) // ["one", "two", "three", "four"]
我们可以在我们认为可能有用的每种数组类型上实现我们的追加操作符,但相反,我们可以将其实现为一个泛型函数,使其对所有数组都起作用。
因此,我们可以重构前面的两个数组实现,以使用泛型元素类型:
func >>> <Element>(lhs: Element,
rhs: Array<Element>) -> Array<Element> {
var combined = rhs
combined.append(lhs)
return combined
}
func >>> <Element>(lhs: Array<Element>,
rhs: Array<Element>) -> Array<Element> {
var combined = rhs
combined.append(contentsOf: lhs)
return combined
}
这允许我们使用整数数组,而无需显式地为整数数组定义它们:
let appendedIntToArray = 3 >>> [1, 2]
print(appendedIntToArray) // [1, 2, 3]
let appendedIntArray = [3, 4] >>> [1, 2]
print(appendedIntArray) // [1, 2, 3, 4]
我们还可以为我们自己的自定义类型实现它。让我们创建Task和TaskList,这可能有助于使用操作符:
struct Task {
let name: String
}
class TaskList: CustomStringConvertible {
private var tasks: [Task] = []
func append(task: Task) {
tasks.append(task)
}
var description: String {
return tasks.map { $0.name }.joined(separator: "\n")
}
}
我们添加了CustomStringConvertible一致性,以便我们可以轻松地打印出结果。
实现操作符作为顶层函数的替代方法是将其声明为相关类型中的静态函数。我们将在TaskList对象的扩展中声明它,但我们也可以轻松地在TaskList主类声明中声明它:
extension TaskList {
static func >>> (lhs: Task, rhs: TaskList) {
rhs.append(task: lhs)
}
}
在类型内部实现它有几个优点:实现代码紧挨着类型本身,这使得它更容易找到,并利用任何可能具有私有或受限制的访问控制的值或类型,这将防止它们对顶层函数可见。
现在,我们可以使用我们的>>>操作符将一个Task追加到TaskList中:
let shoppingList = TaskList()
Task(name: "get milk") >>> shoppingList
print(shoppingList)
Task(name: "get teabags") >>> shoppingList
print(shoppingList)
我们已经创建了一个自定义操作符,以允许更简洁和更具表现力的代码。
更多...
操作符不仅单独工作,它们通常在同一个表达式中与其他操作符一起使用;数学操作符是这一点的有用例子:
let result = 6 + 8 / 2 / 4
每个这些操作执行的顺序将影响结果。为了理解操作执行的顺序,我们可以添加括号,它们将执行相同的功能:
let result = 6 + ((8 / 2) / 4)
在 Swift 中,关于如何排序运算的决定是通过两个概念——优先级和结合性——来实现的:
-
优先级:这定义了操作类型的重要性。因此,具有最高优先级的操作首先执行;例如,乘法(*)的优先级高于加法(+),因此总是首先执行。
-
结合性:这定义了当值在两侧具有相同优先级的操作时,它应该将自己与哪一侧关联起来进行评估。这具有定义具有相同优先级的操作应该按何种顺序评估的效果:从左到右或从右到左。
让我们利用这些信息来理解前面数学运算的操作顺序。我们有一个包含一个加法和两个除法运算的表达式。除法运算的优先级高于加法运算;因此,除法运算首先被评估:
let result = 6 + (8 / 2 / 4)
现在我们有两个需要在加法运算之前评估的除法运算。由于它们都是除法运算符,它们具有相同的优先级,因此我们必须查看结合性以确定它们的评估顺序。除法运算的结合性为 left,因此它们应该从左到右进行评估。因此,8 / 2 首先被评估,然后是 4 / 4。这给我们以下结果:
let result = 6 + ((8 / 2) / 4)
我们需要为我们自定义的运算符定义优先级和结合性,因为编译器目前不知道它应该如何在包含多个运算的表达式中排序。因此,以下表达式将无法编译:
let multiOperationArray = [5,6] >>> [3,4] >>> [1,2] + [9,10] >>> [7,8]
print(multiOperationArray)
优先级和结合性是在优先级组内定义的,一个运算符可以符合一个现有的组或一个新定义的组。
让我们为我们的附加运算符定义一个新的优先级组:
precedencegroup AppendingPrecedence {
associativity: left
higherThan: AdditionPrecedence
lowerThan: MultiplicationPrecedence
}
在这里,我们将其命名为 AppendingPrecedence 并在花括号内定义其值。我们将将其结合性设置为左侧,以匹配数学运算,并为了建立优先级,我们定义这个优先级组比另一个优先级组高,但比一些其他优先级组低。对于附加运算符,我们将优先级设置为高于加法,因此它将在加法运算符之前但乘法运算符之后进行评估。AdditionPrecendence 和 MultiplicationPrecedence 组都是由 标准库 定义的。
现在我们已经定义了一个优先级组,我们可以确保我们的自定义运算符符合它:
infix operator >>> : AppendingPrecedence
声明了优先级和结合性后,之前创建的复合表达式现在可以编译:
let multiOperationArray = [5,6] >>> [3,4] >>> [1,2] + [9,10] >>> [7,8]
print(multiOperationArray) // [1,2,3,4,5,6,7,8,9,10]
我们已经定义了我们的自定义操作符如何与其他操作符一起工作,从而允许复杂的组合。
参见
关于自定义操作符的更多信息可以在苹果关于 Swift 语言的文档中找到,请参阅swiftbook.link/docs/custom-operators。
嵌套类型和命名空间
在 Objective-C 中,所有对象都在顶层,并且具有全局作用域。它们可以说是在同一个命名空间中。这是包括苹果在内的 Objective-C 开发者遵循的惯例,即在类名前加上两到三个字母标识符的原因之一。
这些前缀字符允许来自不同框架的类似命名的类区分开来,例如,UIView来自UIKit和SKView来自SpriteKit。Swift 通过允许类型嵌套在其他类型中,通过嵌套类型和模块提供命名空间来解决此问题。
任何类型都可以定义为嵌套在另一个类型中。这允许我们将一个类型紧密地关联到另一个类型上,除了提供命名空间外,还有助于区分具有相同名称的类型。在这个菜谱中,我们将创建一些嵌套类型,看看它们是否会影响它们的引用方式。
如何做到这一点...
让我们构建一个系统来监控物理设备和它所显示的用户界面。设备和用户界面都有方向的概念,尽管每个的定义都不同:
- 定义一个类来表示设备:
class Device {
enum Category {
case watch
case phone
case tablet
}
enum Orientation {
case portrait
case portraitUpsideDown
case landscapeLeft
case landscapeRight
}
let category: Category
var currentOrientation: Orientation = .portrait
init(category: Category) {
self.category = category
}
}
在这个类中,我们定义了两个枚举,这些枚举只有在与Device类相关联时才有值。嵌套类型也允许我们简化这些类型的名称。按照惯例,我们可以将它们命名为DeviceCategory和DeviceOrientation以避免混淆,但由于它们是嵌套的,我们可以去掉Device前缀。
在包含它们的类型内部使用嵌套类型时,可以不使用任何限定符;然而,在包含类型外部使用时并非如此。
- 使用点符号从包含类型外部访问嵌套类型:
let phone = Device(category: .phone)
let desiredOrientation: Device.Orientation = .portrait
let phoneHasDesiredOrientation = phone.currentOrientation ==
desiredOrientation
要引用嵌套类型,我们必须首先指定包含类型,因此Device类中的Orientation枚举变为Device.Orientation。
- 定义一个结构体来表示用户界面:
struct UserInterface {
struct Version {
let major: Int
let minor: Int
let patch: Int
}
enum Orientation {
case portrait
case landscape
}
let version: Version
var orientation: Orientation
}
我们的UserInterface结构体还包括一个嵌套的Orientation枚举,但由于这两个枚举位于不同的命名空间中,因此不存在命名冲突。与之前一样,嵌套类型可以在包含类型中不使用任何限定符使用。
让我们看看这两个嵌套类型如何相互结合使用。
- 创建一个函数将设备方向转换为用户界面方向:
func uiOrientation(for deviceOrientation: Device.Orientation) ->
UserInterface.Orientation {
switch deviceOrientation {
case Device.Orientation.portrait,
Device.Orientation.portraitUpsideDown:
return UserInterface.Orientation.portrait
case Device.Orientation.landscapeLeft,
Device.Orientation.landscapeRight:
return UserInterface.Orientation.landscape
}
}
let phoneUIOrientation = uiOrientation(for:
phone.currentOrientation)
print(phoneUIOrientation) // UserInterface.Orientation.portrait
它是如何工作的...
我们的转换函数指定了switch语句和return语句的完整枚举情况;例如:
Device.Orientation.portrait
UserInterface.Orientation.portrait
然而,正如我们之前所看到的,当编译器知道枚举的类型时,只需要指定情况;可以移除枚举类型。对于我们的函数,输入参数类型是 Device.Orientation,返回类型是 UserInterface.Orientation,因此编译器知道枚举类型,因此我们可以移除类型:
func uiOrientation(for deviceOrientation: Device.Orientation) -> UserInterface.Orientation {
switch deviceOrientation {
case .portrait, .portraitUpsideDown:
return .portrait
case .landscapeLeft, .landscapeRight:
return .landscape
}
}
注意,switch 语句中的 .portrait 情况返回 .portrait,但这些是来自不同枚举的情况,编译器知道它们的区别。
还有更多...
我们已经看到了命名空间如何将嵌套在不同容器类型中的类型分开,但模块内的类型也是命名空间化的。这允许你命名你的类型,而不用担心与其他模块中的类型发生冲突。
让我们想象我们正在为医院开发一个应用程序,以跟踪其事件和资源。作为其中的一部分,我们创建了一个类来表示我们打算跟踪的手术操作:
class Operation {
let doctorsName: String
let patientsName: String
init(doctorsName: String, patientsName: String) {
self.doctorsName = doctorsName
self.patientsName = patientsName
}
}
另有一个名为 Operation 的类,由 Foundation 框架提供,可以用来执行和管理长时间运行的任务。我们可以同时使用这两种类型的 Operation,因为 Foundation 框架被公开为一个模块,因此可以通过引用 Foundation 模块来使用长时间运行的 Operation 类:
import Foundation
let medicalOperation = Operation(doctorsName: "Dr. Crusher",
patientsName: "Commander Riker")
let longRunningOperation = Foundation.Operation()
我们已经看到了如何使用它们所属的模块来区分具有相同名称的两个类型。
参见
关于嵌套类型的更多信息可以在 Apple 的 Swift 语言文档中找到,请参阅 swiftbook.link/docs/nested-types。
超越标准库
苹果开源 Swift 的意图是提供一个跨平台、通用编程语言,它已经准备好使用。Swift 标准库提供了核心语言特性和常见集合类型。然而,这并不提供启动所需的所有内容。
因此,苹果提供了一个名为 Foundation 的框架,以帮助您执行核心 Swift 语言和标准库未涵盖的常见编程任务。
当你在苹果平台上开发时,你将使用的 Foundation 框架是 封闭源代码,这意味着底层代码不可访问,只有 API 是可见的。然而,当苹果开源 Swift 并使其对 Linux 可用时,提供 Foundation 框架变得必要。为此,苹果发布了一个开源的、基于 Swift 的 Foundation 版本,作为一个核心库,可在以下位置找到:github.com/apple/swift-corelibs-foundation。
在本章中,我们将介绍以下食谱:
-
使用 Foundation 比较日期
-
使用
URLSession获取数据 -
处理 JSON
-
处理 XML
第六章:技术要求
本章的所有代码都可以在这本书的 GitHub 仓库中找到:github.com/PacktPublishing/Swift-Cookbook-Second-Edition/tree/master/Chapter05
查看以下视频,了解代码的实际应用:bit.ly/3cIcNUK
使用 Foundation 比较日期
本食谱将专注于 Foundation 中一个非常广泛使用的领域,即日期和时间的操作和格式化。
我们将创建一个函数,该函数确定离圣诞节还有多长时间,并将此信息作为字符串返回,以便向用户显示。
准备工作
在新的 iOS playground 中创建一个新的 playground,并在 playground 的顶部导入 Foundation 框架:
import Foundation
如何操作...
让我们创建一个函数,它会返回一个字符串,告诉我们离圣诞节还有多长时间,然后我们可以打印出来:
- 定义函数:
func howLongUntilChristmas() -> String {
}
- 在函数内部,获取当前的日历和时间区域:
let calendar = Calendar.current
let timeZone = TimeZone.current
- 获取当前日期和时间,并使用日历获取当前年份:
let now = Date()
let yearOfNextChristmas = calendar.component(.year, from: now)
- 定义与圣诞节午夜对应的日期组件:
var components = DateComponents(calendar: calendar,
timeZone: timeZone,
year: yearOfNextChristmas,
month: 12,
day: 25,
hour: 0,
minute: 0,
second: 0)
- 从这些组件获取一个
Date对象:
var christmas = components.date!
- 如果我们已经过了今年的圣诞节,我们需要调整组件以指向下一年的圣诞节:
// If we have already had Christmas this year,
// then we need to use Christmas next year.
if christmas < now {
components.year = yearOfNextChristmas + 1
christmas = components.date!
}
- 创建
DateComponentsFormatter来格式化显示到圣诞节的剩余时间:
let componentFormatter = DateComponentsFormatter()
componentFormatter.unitsStyle = .full
componentFormatter.allowedUnits = [.month, .day, .hour, .minute,
.second]
- 使用
DateComponentFormatter返回从现在到下一个圣诞节的字符串:
return componentFormatter.string(from: now, to: christmas)!
- 在
howLongUntilChristmas函数下方,使用此函数创建一个字符串,并打印结果:
let timeUntilChristmas = howLongUntilChristmas()
print("Time until Christmas: \(timeUntilChristmas)")
工作原理...
在步骤 1中,我们创建了howLongUntilChristmas函数,然后在步骤 2中,我们获取当前设置的日历和时间区域,因为它们将用于后续的日期计算:
let calendar = Calendar.current
let timeZone = TimeZone.current
获取当前时区是显而易见的,但Calendar类型代表什么以及为什么需要检索它并不立即明显。
日期的表示方式并不像你想象的那样具有普遍性。某些时间组件大多是通用的,例如年份和天数的长度,因为它们与天文事件有关,例如地球围绕太阳公转一周所需的时间,以及地球围绕自身轴旋转一周所需的时间。然而,其他时间组件,如月份、周以及年份的编号,根植于创建它们的文明。
欧洲以及世界上大多数地区使用的日历被称为格里高利日历,由教皇格列高利十三世于 1582 年引入,取代了儒略日历。目前全球大约有 40 种不同的日历在使用中,包括格里高利日历、中国日历、希伯来日历、伊斯兰日历、波斯日历、埃塞俄比亚日历和巴厘岛 Pawukon 日历。
我们展示距离圣诞节还有多长时间的方式将取决于与用户相关的日历。这就是为什么我们要求用户提供当前日历,如果他们想要不同的表示方式,他们可以更改它。
我们接下来的任务是获取当前的日期和时间:
let now = Date()
在步骤 3中,Date值类型的默认初始化器使用当前日期和时间作为其值。请注意,这个日期值是在创建时设置的;它不会随着当前日期和时间的更新而持续更新。
在这一步,我们获取下一个圣诞节的日期和时间。我们知道圣诞节的时、日、月,因此要构建圣诞节的日期,我们只需要知道年份。Calendar类中有一个名为component的方法,允许我们从Date值中检索特定组件:
let yearOfNextChristmas = calendar.component(.year, from: now)
现在我们有了用户当前日历中的当前年份;我们可以用它来创建圣诞节的日期。
在步骤 4中,我们创建了一个DateComponents实例,传递了日历、时区和我们将定义当前年份午夜 12 点的 12 月 25 日:
var components = DateComponents(calendar: calendar,
timeZone: timeZone,
year: yearOfNextChristmas,
month: 12,
day: 25,
hour: 0,
minute: 0,
second: 0)
在步骤 5中,我们从DateComponents创建一个Date对象。这是一个可选类型,因为我们可能没有向组件提供足够的信息来生成日期;然而,由于我们知道我们已经提供了,我们可以强制展开这个可选类型:
var christmas = components.date!
接下来,我们需要处理一个边缘情况;如果我们今年已经过了圣诞节怎么办?例如,让我们想象当前日期是 2020 年 12 月 27 日;我们正在尝试找到下一个圣诞节的日期,但如果我们使用当前年份,我们将得到 2020 年 12 月 25 日,这是刚刚过去的圣诞节。因此,在步骤 6中,我们将当前年份加 1,得到下一个圣诞节,即 2021 年 12 月 25 日:
if christmas < now {
components.year = yearOfNextChristmas + 1
christmas = components.date!
}
为了解决这个问题,我们检查今年的圣诞节是否在 now 之前;如果是,我们将年份组件提升到下一年,并从 DateComponent 重新创建圣诞节日期。
我们现在有了当前的 Date 和下一个圣诞节的 Date,Foundation 提供了通过使用 DateComponentsFormatter 来计算两个日期之间时间差并将其格式化为用户显示的功能。
在 步骤 7 中,我们创建 DateComponentsFormatter,并将 unitStyle 设置为 full,这将使用完整的单位名称提供字符串,而不使用缩写。我们配置我们想要如何将日期和时间分割以供显示,使用 allowedUnits:
let componentFormatter = DateComponentsFormatter()
componentFormatter.unitsStyle = .full
componentFormatter.allowedUnits = [.month, .day, .hour, .minute,
.second]
在 步骤 8 中,我们可以从格式化器中检索一个字符串,描述两个给定日期之间的时间,并使用提供给格式化器的设置。由于 DateComponentsFormatter 返回一个可选字符串,我们解包并返回它:
return componentFormatter.string(from: now, to: christmas)!
我们的 howLongUntilChristmas 方法将提供一个描述距离圣诞节还有多久的字符串,然后我们可以将其打印出来。
参见
在 Foundation 中还有更多内容可以探索,因此请查看文档以了解更多功能:
-
Swift 3 对 Foundation 的文档:
swiftbook.link/docs/foundation -
Foundation 的开源仓库:
github.com/apple/swift-corelibs-foundation
使用 URLSession 获取数据
每个值得构建的应用在某个时候都需要从互联网发送或接收信息,因此,网络支持是任何开发平台的关键部分。在 Swift 中,这种网络支持由 Foundation 框架提供。
当我们需要从互联网检索信息时,我们会向互联网上的一个服务器发送请求,然后该服务器发送一个响应,希望其中包含我们请求的信息。
在这个菜谱中,我们将学习如何使用 Foundation 框架发送网络请求并接收响应。
准备工作
了解 Foundation 提供的与网络相关的不同组件以及它们的功能是有帮助的:
-
URL:远程服务器上资源的地址。它包含有关服务器和资源在服务器上位置的信息。 -
URLRequest:表示将要发送到远程服务器的请求。定义了资源的 URL、请求的发送方式、以头信息形式存在的元数据,以及应与之一起发送的数据。 -
URLSession:管理与远程服务器的通信,保存该通信的配置,并创建和优化底层连接。 -
URLSessionDataTask:一个管理请求状态并传递响应的对象。 -
URLResponse:保存远程服务器响应的元数据。
如何做到这一点...
让我们使用这些网络工具从远程服务器检索一个图像:
- 导入
PlaygroundSupport并为这个游乐场设置无限执行:
import PlaygroundSupport
PlaygroundPage.current.needsIndefiniteExecution = true
- 导入 Foundation 并创建一个
URLSession实例:
import Foundation
let config = URLSessionConfiguration.default
let session = URLSession(configuration: config)
- 接下来,我们将构造一个请求远程图像的请求:
let urlString = "https://imgs.xkcd.com/comics/api.png"
let url = URL(string: urlString)!
let request = URLRequest(url: url)
- 现在我们有了
URLRequest,我们可以创建一个数据任务来从远程服务器检索图像:
let task = session.dataTask(with: request, completionHandler: {
(data, response, error) in
})
- 我们将图像数据放入一个
UIImage对象中以显示它。因此,我们需要导入UIKit框架,它提供了UIImage。所以,让我们在 playground 的顶部导入UIKit:
import UIKit
- 在完成处理程序中检查图像数据并创建一个
UIImage对象:
let task = session.dataTask(with: request) { (data, response, error)
in
guard let imageData = data else {
return // No Image, handle error
}
_ = UIImage(data: imageData)
}
- 在任务上调用
resume以启动它:
task.resume()
它是如何工作的...
让我们回顾一下之前提到的步骤,以了解我们在做什么:
import PlaygroundSupport
PlaygroundPage.current.needsIndefiniteExecution = true
Playgrounds 从顶部到底部执行它们包含的代码。当达到 playground 页面的末尾时,playground 停止执行。在我们的例子中,任务被创建并启动,但然后 playground 在图像完全检索之前就到达了页面的末尾,因为这是异步发生的。如果 playground 在这里停止执行,完成处理程序将永远不会执行,我们就看不到图像。在正常应用程序中这不是问题,因为应用程序在使用时持续运行;这仅仅是 Swift playgrounds 的工作方式。
为了解决这个问题,我们需要告诉 playground 我们不想它在到达页面末尾时停止执行,而应该在我们等待响应接收时无限期地运行。这是通过在步骤 1中导入PlaygroundSupport框架并将当前PlaygroundPage的needsIndefiniteExecution设置为true来实现的。
import Foundation
let config = URLSessionConfiguration.default
let session = URLSession(configuration: config)
在步骤 2中,当创建URLSession时,我们传入一个URLSessionConfiguration对象,它允许配置请求超时时间和缓存响应等。对于我们的目的,我们将只使用默认配置。
let urlString = "https://imgs.xkcd.com/comics/api.png"
let url = URL(string: urlString)!
let request = URLRequest(url: url)
在步骤 3中,我们将从优秀的网络漫画XKCD(xkcd.com)请求图像。我们可以从一个字符串创建 URL,然后从 URL 创建一个URLRequest请求。
let task = session.dataTask(with: request, completionHandler: { (data,
response, error) in
})
在步骤 4中,我们不是直接创建数据任务;相反,我们要求我们的URLSession实例创建数据任务,并传入URLRequest和一个完成处理程序。一旦从远程服务器收到响应或发生某些错误,完成处理程序将被触发。
完成处理程序有三个输入,都是可选的:
-
data: 数据: 响应体中返回的数据;如果我们的请求成功,这将包含我们的图像数据。 -
response: URLResponse: 响应元数据,包括响应头。如果请求是通过 HTTP/HTTPS 进行的,那么这将是一个HTTPURLResponse,它将包含 HTTP 状态码。 -
error: 错误: 如果请求由于网络问题等失败,此值将包含错误,数据和响应值将为nil。如果请求成功,此错误值将为nil。
let task = session.dataTask(with: request) { (data, response, error) in
guard let imageData = data else {
return // No Image, handle error
}
_ = UIImage(data: imageData)
}
在步骤 6中,我们检查响应数据并将其转换为图像。为此,我们需要从数据中构建一个UIImage对象。UIImage是一个表示 iOS 上图像的类,可以在UIKit框架中找到。因此,我们也在沙盒的顶部导入了UIKit,就像我们在步骤 5中所做的那样。
由于我们在这个示例中不打算对图像做任何处理,我们只是将其在沙盒预览中查看;如果将其分配给一个永远不会使用的值,编译器将会报错。因此,我们用下划线_替换正常的值赋值,这样就可以生成UIImage对象,而无需将其分配给任何东西。
task.resume()
在步骤 7中,我们已经创建了数据任务来检索图像,但我们需要实际启动任务来发出请求。为此,我们在任务上调用resume方法。
当我们运行沙盒时,你最终会看到图像值已经在沙盒的右侧侧栏中填充,你可以点击预览图标来查看已下载的图像:

图 5.1 – 在沙盒时间轴中显示检索到的图像
相关内容
-
更多关于网络的信息可以在 Apple 的网络概述中找到:
swiftbook.link/docs/networking -
更多信息也可以在 Apple 的 URL Session 编程指南中找到:
swiftbook.link/docs/urlsession-guide
处理 JSON
如上一次教程中所述,几乎每个应用程序在某个时候都需要与互联网交换信息,在上一次教程中,我们从远程服务器检索了一个图像。通常,你的应用程序需要检索更多样化的数据,可能涉及搜索结果或服务器上存储的共享状态信息。
这种信息可以用多种方式表示,但最常见的方式之一是JavaScript 对象表示法(JSON),它是一种基于文本的结构,用于表示信息。一个 JSON 对象包含键值对,其中键是字符串,值可以是字符串、数字、布尔值、null、其他对象或数组。
例如,关于一个人的信息可以用这个 JSON 对象来表示:
{
"name": {
"givenName": "Keith",
"middleName": "David",
"familyName": "Moon"
},
"age": 40,
"heightInMetres": 1.778,
"isBritish": true,
"favouriteFootballTeam": null
}
下面的例子是一个 JSON 对象的数组:
[
{
"name": {
"givenName": "Keith",
"middleName": "David",
"familyName": "Moon"
},
"age": 40,
"heightInMetres": 1.778,
"isBritish": true,
"favouriteFootballTeam": null
},
{
"name": {
"givenName": "Alissa",
"middleName": "May",
"familyName": "Moon"
},
"age": 35,
"heightInMetres": 1.765,
"isBritish": false,
"favouriteFootballTeam": null
}
]
基础库提供了从 JSON 数据中读取信息和将信息写入 JSON 数据的工具。在本教程中,我们将与一个基于 JSON 的应用程序编程接口(API)进行交互,以发送和接收信息。
准备工作
我们的目标是与 GitHub API 交互,并为这本书的仓库创建一个 issue。Git 和 GitHub 的完整解释超出了本书的范围;简单来说,它是一个存储你源代码版本副本的服务。与本书相关的资源存储在 GitHub 上的仓库中,GitHub 用户可以创建 issue,这些 issue 可以作为错误报告或功能请求。
如果你还没有一个账户,那么你需要注册一个 GitHub 账户:
-
访问
github.com。 -
填写你的详细信息并点击“注册 GitHub”。
一旦你创建了 GitHub 账户,你需要创建一个个人访问令牌,我们将使用它来验证对 GitHub API 的一些请求。要创建个人访问令牌,请按照以下步骤操作:
-
前往设置页面 (
github.com/settings/tokens) 并点击“生成新令牌”。 -
给令牌起一个名字,并勾选旁边的 repo:

图 5.2 – 创建个人访问令牌
-
点击页面底部的“生成令牌”。现在你将看到你新创建的个人访问令牌。
-
复制这个令牌并将其粘贴到某个地方,因为我们稍后会需要它:

图 5.3 – 生成的个人访问令牌
如何做到这一点...
要创建我们的 issue,我们首先将检索 Packt Publishing 的所有公共仓库,然后找到这本书的相关仓库。然后我们将在该仓库中创建一个新的 issue。
正如前面的配方中所述,我们需要一个 URLSession 对象来执行我们的请求,并且我们需要告诉游乐场在执行到游乐场的末尾时不要结束执行:
import Foundation
import PlaygroundSupport
PlaygroundPage.current.needsIndefiniteExecution = true
let config = URLSessionConfiguration.default
let session = URLSession(configuration: config)
我们的第一步是获取给定用户的所有公共仓库:
- 让我们创建一个函数来做这件事:
func fetchRepos(forUsername username: String) {
let urlString = "https://api.github.com/users/\(username)/repos"
let url = URL(string: urlString)!
var request = URLRequest(url: url)
request.setValue("application/vnd.github.v3+json",
forHTTPHeaderField: "Accept")
let task = session.dataTask(with: request) { (data, response,
error) in
}
task.resume()
}
你会注意到,在创建 URLRequest 之后,我们设置了一个 HTTP 头部;这个特定的头部确保我们总是能够获取到 GitHub API 的第 3 版。
我们从 GitHub API 文档 (developer.github.com/v3/) 中知道,这个响应数据是 JSON 格式。我们需要解析 JSON 数据,将其转换成我们可以使用的东西;输入 JSONSerialization。JSONSerialization 是 Foundation 框架的一部分,它提供了将 Swift 字典和数组转换为 JSON 数据(称为 序列化)以及将其转换回(称为 反序列化)的方法。
- 让我们使用
JSONSerialization将我们的 JSON 响应数据转换成更有用的东西:
func fetchRepos(forUsername username: String) {
let urlString = "https://api.github.com/users/\(username)/repos"
let url = URL(string: urlString)!
var request = URLRequest(url: url)
request.setValue("application/vnd.github.v3+json",
forHTTPHeaderField: "Accept")
let task = session.dataTask(with: request) { (data, response,
error) in
// Once we have handled this response, the Playground
// can finish executing.
defer {
PlaygroundPage.current.finishExecution()
}
// First unwrap the optional data
guard let jsonData = data else {
// If it is nil, there was probably a network error
print(error ?? "Network Error")
return
}
do {
// Deserialisation can throw an error, so we have to `try` and
// catch errors
let deserialised = try JSONSerialization.jsonObject(with:
jsonData, options: [])
print(deserialised)
} catch {
print(error)
}
}
task.resume()
}
- 现在,让我们通过执行我们的函数并传递
PacktPublishing作为 GitHub 用户名来获取公共 Packt 仓库:
fetchRepos(forUsername: "PacktPublishing")
执行后,打印输出应该看起来像这样:

图 5.4 – 公共 GitHub 仓库 API 响应
JSONSerializer已将我们的 JSON 数据转换为熟悉的数组和字典,我们可以用正常方式检索所需的信息。JSON 数据以Any类型反序列化,因为 JSON 的根可以是 JSON 对象或数组。
由于从前面的输出中我们知道响应的根是一个 JSON 对象的数组,我们需要将值从Any类型转换为[String: Any]类型的字典数组。这被称为从一种类型到另一种类型的类型转换,我们可以通过使用as关键字并指定新类型来实现。此关键字可以用三种不同的方式使用:
-
as将执行平凡的类型转换。如果现有类型与预期类型同义,则这是可能的,例如,从子类到超类的类型转换。 -
as?将条件执行类型转换,返回一个可选值。如果无法将值表示为预期类型,则该值将为nil。 -
as!将执行强制类型转换。如果无法将值表示为预期类型,您将遇到崩溃。
因此,让我们将反序列化数据转换为具有字符串键的字典数组,类型为[[String: Any]]:
func fetchRepos(forUsername username: String) {
//...
let task = session.dataTask(with: request) { (data, response, error)
in
//...
do {
// Deserialisation can throw an error, so we have to `try` and
// catch errors
let deserialised = try JSONSerialization.jsonObject(with:
jsonData, options: [])
print(deserialised)
// As `deserialised` has type `Any` we need to cast
guard let repos = deserialised as? [[String: Any]] else {
print("Unexpected Response")
return
}
print(repos)
} catch {
print(error)
}
}
}
现在,我们有了 API 响应中存储库的字典数组,我们需要将其作为输入提供给此函数。提供异步工作结果的一个常见模式是提供一个完成处理程序作为参数。完成处理程序是一个闭包,可以在异步工作完成后执行。
由于我们想要提供的输出是存储库字典的数组,因此如果请求成功,我们将将其定义为闭包的输入,如果不成功,则为错误:
func fetchRepos(forUsername username: String, completionHandler:
@escaping ([[String: Any]]?, Error?) -> Void) {
let urlString = "https://api.github.com/users/\(username)/repos"
let url = URL(string: urlString)!
var request = URLRequest(url: url)
request.setValue("application/vnd.github.v3+json",
forHTTPHeaderField: "Accept")
let task = session.dataTask(with: request) { (data, response, error)
in
// Once we have handled this response, the Playground
// can finish executing.
defer {
PlaygroundPage.current.finishExecution()
}
// First unwrap the optional data
guard let jsonData = data else {
// If it is nil, there was probably a network error
completionHandler(nil, ResponseError.requestUnsuccessful)
return
}
do {
// Deserialisation can throw an error, so we have to `try` and
// catch errors
let deserialised = try JSONSerialization.jsonObject(with:
jsonData, options: [])
// As `deserialised` has type `Any` we need to cast
guard let repos = deserialised as? [[String: Any]] else {
completionHandler(nil,
ResponseError.unexpectedResponseStructure)
return
}
completionHandler(repos, nil)
} catch {
completionHandler(nil, error)
}
}
task.resume()
}
现在,每当生成错误时,我们执行completionHandler,传入错误和nil作为结果值。此外,当我们有存储库结果时,我们执行完成处理程序,传入解析后的 JSON 和nil作为错误。
在前面的代码中,我们传递了一些新的错误,因此让我们定义这些错误:
enum ResponseError: Error {
case requestUnsuccessful
case unexpectedResponseStructure
}
这改变了我们调用此fetchRepos函数的方式:
fetchRepos(forUsername: "PacktPublishing") { (repos, error) in
if let repos = repos {
print(repos)
} else if let error = error {
print(error)
}
}
现在我们已经检索了公共存储库的详细信息,我们将为此章节的存储库提交一个问题。这个问题可以是您对本书的任何反馈;它可以是评论、对新内容的建议,或者您可以告诉我您目前正在进行的 Swift 项目。
此对 GitHub API 的请求将针对您的用户账户进行身份验证,因此我们需要包括我们在本食谱开头创建的个人访问令牌的详细信息。有几种方式可以验证对 GitHub API 的请求,但最简单的是基本身份验证,这涉及到在请求头中添加一个授权字符串。
让我们创建一个方法来正确格式化个人访问令牌以进行身份验证:
func authHeaderValue(for token: String) -> String {
let authorisationValue = Data("\(token):x-oauth-
basic".utf8).base64EncodedString()
return "Basic \(authorisationValue)"
}
接下来,让我们创建一个提交问题的函数。从developer.github.com/v3/issues/#create-an-issue的 API 文档中,我们可以看到,除非你有推送权限,否则你只能使用以下组件创建一个问题:
-
title(必需) -
body(可选)
因此,我们的函数将接受这些信息作为输入,包括仓库名和用户名:
func createIssue(inRepo repo: String,
forUser user: String,
title: String,
body: String?) {
}
创建问题是通过发送一个POST请求来实现的,问题信息作为 JSON 数据在请求体中提供。为了创建我们的请求,我们可以使用JSONSerialization,但这次我们将我们的预期 JSON 结构序列化为Data:
func createIssue(inRepo repo: String,
forUser user: String,
title: String,
body: String?) {
// Create the URL and Request
let urlString = "https://api.github.com/repos/\(user)/\
(repo)/issues"
let url = URL(string: urlString)!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/vnd.github.v3+json",
forHTTPHeaderField: "Accept")
let authorisationValue = authHeaderValue(for: <#your personal
access token>)
request.setValue(authorisationValue, forHTTPHeaderField:
"Authorization")
// Put the issue information into the JSON structure required
var json = ["title": title]
if let body = body {
json["body"] = body
}
// Serialise the json into Data. We can use try! as we know it is
// valid JSON.
// Just be aware that the this will fail if provided value can't be
// converted into valid JSON.
let jsonData = try! JSONSerialization.data(withJSONObject: json,
options: .prettyPrinted)
request.httpBody = jsonData
session.dataTask(with: request) { (data, response, error) in
// TO FINISH
}
}
与之前的 API 请求一样,我们需要一种方式来提供创建问题的结果,所以让我们提供一个完成处理程序,尝试反序列化响应,并将其提供给完成处理程序:
func createIssue(inRepo repo: String,
forUser user: String,
title: String,
body: String?,
completionHandler: @escaping ([String: Any]?, Error?)
-> Void) {
//...
session.dataTask(with: request) { (data, response, error) in
guard let jsonData = data else {
completionHandler(nil, ResponseError.requestUnsuccessful)
return
}
do {
// Deserialisation can throw an error, so we have to `try`
// and catch errors
let deserialised = try JSONSerialization.jsonObject(with:
jsonData, options: [])
// As `deserialised` has type `Any` we need to cast
guard let createdIssue = deserialised as? [String: Any]
else {
completionHandler(nil,
ResponseError.unexpectedResponseStructure)
return
}
completionHandler(createdIssue, nil)
} catch {
completionHandler(nil, error)
}
}
}
成功创建问题的 API 响应提供了一个该问题的 JSON 表示。如果成功,我们的函数将返回这个表示,如果不成功,则返回一个错误。
现在我们已经有一个在仓库中创建问题的函数,是时候使用它来创建一个问题了:
createIssue(inRepo: "Swift-5-Cookbook-Second-Edition",
forUser: "PacktPublishing",
title: <#The title of your feedback#>,
body: <#Extra detail#>) { (issue, error) in
if let issue = issue {
print(issue)
} else if let error = error {
print(error)
}
}
我将检查这些创建的问题,所以请就这本书提供真实的反馈。你发现内容如何?太详细了吗?不够详细?我遗漏了什么或没有完全解释清楚的地方?你有什么问题吗?这是你让我知道的机会。
还有更多...
当我们创建我们的完成处理程序时,我们给了它们两个输入:成功的结果(要么是仓库信息,要么是创建的问题)或者如果有失败,则是一个错误。这两个值都是可选的;一个将是nil,另一个有值。然而,这种约定并不是由语言强制执行的,使用这个函数的用户必须考虑它可能不是这种情况的可能性。如果fetchRepos函数在仓库和错误都带有非nil值时触发完成处理程序,函数的使用者应该怎么做?如果两者都是nil呢?
使用这个函数的用户,即使没有查看函数的内部代码,也不能确定这种情况不会发生,这意味着他们可能需要编写功能性和测试来处理这种可能性,即使这种情况可能永远不会发生。
如果我们能更准确地表示我们函数的预期行为,为用户提供清晰的可能的输出指示,不留任何歧义,那就更好了。我们知道从调用函数中会有两种可能的输出:它要么成功并返回相关值,要么失败并返回一个错误来指示失败的原因。
而不是使用可选值,我们可以使用枚举来表示这些可能性,Foundation 框架提供了一个通用的枚举用于此目的,称为Result。
Result枚举有一个success情况,它有一个关联类型用于成功的结果,以及一个failure情况,它有一个关联类型用于相关的错误。这两个关联类型都被定义为泛型约束,其中失败类型需要遵守Error协议。
我们现在可以定义成功和失败状态,并使用关联值来保存每个状态相关的值,对于成功状态是仓库信息,对于失败状态是错误。
现在,让我们修改fetchRepos函数,使其在completionHandler中提供Result枚举:
func fetchRepos(forUsername username: String,
completionHandler: @escaping (Result<[[String: Any]], Error?) -
-> Void) {
//...
let task = session.dataTask(with: request) { (data, response,
error) in
//...
// First unwrap the optional data
guard let jsonData = data else {
// If it is nil, there was probably a network error
completionHandler(.failure(ResponseError.
requestUnsuccessful))
return
}
do {
// Deserialisation can throw an error,
// so we have to `try` and catch errors
let deserialised = try JSONSerialization.jsonObject(with:
jsonData, options: [] )
// As `deserialised` has type `Any` we need to cast
guard let repos = deserialised as? [[String: Any]] else {
let error = ResponseError.unexpectedResponseStructure
completionHandler(.failure(error))
return
}
completionHandler(.success(repos))
} catch {
completionHandler(.failure(error))
}
}
task.resume()
}
我们需要更新调用fetchRepos函数的方式:
fetchRepos(forUsername: "PacktPublishing", completionHandler:{ result
in
switch result {
case .success(let repos):
print(repos)
case .failure(let error):
print(error)
}
})
我们现在使用switch语句而不是if/else,并且我们得到一个额外的好处,编译器将确保我们已经覆盖了所有可能的结果。
在对fetchRepos函数进行了这次改进之后,我们可以类似地改进createIssue函数:
func createIssue(inRepo repo: String,
forUser user: String,
title: String,
body: String?,
completionHandler: @escaping (Result<[[String:
Any]], Error?) -> Void) {
//...
let task = session.dataTask(with: request) { (data, response,
error) in
guard let jsonData = data else {
completionHandler(.failure(ResponseError.
requestUnsuccessful))
return
}
do {
// Deserialisation can throw an error,
// so we have to `try` and catch errors
let deserialised = try JSONSerialization.jsonObject(with:
jsonData, options: [])
// As `deserialised` has type `Any` we need to cast
guard let createdIssue = deserialised as? [String: Any]
else {
let error = ResponseError.unexpectedResponseStructure
completionHandler(.failure(error))
return
}
completionHandler(.success(createdIssue))
} catch {
completionHandler(.failure(error))
}
}
task.resume()
}
最后,我们需要更新提供给createIssue函数的完成处理器的内容:
createIssue(inRepo: "Swift-5-Cookbook-Second-Edition",
forUser: "PacktPublishing",
title: <#The title of your feedback#>,
body: <#Extra detail#>) { result in
switch result {
case .success(let issue):
print(issue)
case .failure(let error):
print(error)
}
}
处理 JSON 数据和从中提取相关信息可能会令人沮丧。考虑我们fetchRepos函数的 JSON 响应:
[
{
"id": 68144965,
"name": "JSONNode",
"full_name": "keefmoon/JSONNode",
"owner": {
"login": "keefmoon",
"id": 271298,
"avatar_url":
"https://avatars.githubusercontent.com/u/271298?v=3",
"gravatar_id": "",
"url": "https://api.github.com/users/keefmoon",
"html_url": "https://github.com/keefmoon",
"followers_url":
"https://api.github.com/users/keefmoon/followers",
//... Some more URLs
"received_events_url":
"https://api.github.com/users/keefmoon/received_events",
"type": "User",
"site_admin": false
},
"private": false,
//... more values
}
//... more repositories
]
如果我们想要获取第一个仓库所有者的用户名,我们需要反序列化 JSON,然后有条件地解包多层嵌套以获取用户名字符串:
let jsonData = //... returned from the network
guard
let deserialised = try? JSONSerialization.jsonObject(with:
jsonData, options: []),
let repoArray = deserialised as? [[String: Any]],
let firstRepo = repoArray.first,
let ownerDictionary = firstRepo["owner"] as? [String: Any],
let username = ownerDictionary["login"] as? String
else {
return
}
print(username)
只为了获取一个值,就需要进行大量的可选解包和类型转换!Swift 的强类型特性与 JSON 的松散定义模式不太兼容,这就是为什么你需要做很多工作才能将松散类型的信息转换为强类型值。
为了帮助解决这些问题,有一些开源框架可供使用,这些框架使得在 Swift 中使用 JSON 变得更加容易。SwiftyJSON是一个流行的框架,可以在 GitHub 上找到,网址为github.com/SwiftyJSON/SwiftyJSON。
我还构建了一个轻量级的 JSON 辅助工具,名为JSONNode,也可以在 GitHub 上找到,网址为github.com/keefmoon/JSONnode。
使用JSONNode,你可以用以下代码执行相同的任务,即检索第一个仓库的所有者的用户名:
let jsonData = //... returned from the network
guard
let jsonNode = try? JSONNode(data: jsonData),
let username = jsonNode[0]["owner"]["username"].string
else {
return
}
print(username)
JSON 中的信息,无论深度如何,都可以使用子脚本来在一行中检索。
处理 XML
XML代表可扩展标记语言,是一种在网络上存储和传输数据时表示数据的方式。XML 是一个非常灵活的格式,用于表示许多类型的数据。当前 HTML 的规范,它驱动着大部分的网页,是 XML 的一个实现。
在本食谱中,我们将关注的 XML 版本是RSS,代表Really Simple Syndication。RSS 用于定义一组按时间顺序排列的可消化的内容;然后可以使用这些 RSS 源从多个不同的来源聚合内容。RSS 通常用作新闻文章和播客的发布机制。
在本食谱中,我们将学习如何通过获取和解析 BBC 新闻 RSS 源来读取和写入 XML 数据。
准备工作
处理 XML 数据的功能由 Foundation 框架提供。然而,尽管帮助读取 XML 数据的类在苹果的所有平台上都可用,但帮助写入 XML 数据的类仅在 macOS 平台上可用。
这是一个不幸的疏忽,这意味着如果你需要在 iOS 应用中写入 XML 数据,你可能会需要寻找第三方助手或自己构建。我们将在本食谱的末尾调查第三方助手。
为了使用 Foundation 框架调查读取和写入 XML,我们需要创建一个新的基于 macOS 的游乐场,而不是像本书中迄今为止所使用的那样基于 iOS 的游乐场。
按照常规创建一个新的 Swift 游乐场,但请从 macOS 选项卡中选择空白模板:

图 5.5 – 选择模板
我们将要检索和解析的 RSS 源来自 BBC 新闻网站首页,其网址为feeds.bbci.co.uk/news/rss.xml。
我们的第一步是从这个 URL 检索数据,这样我们就可以开始理解它了。由于我们之前已经介绍了通过网络检索信息,我将添加代码而不做进一步说明;查看本章中的使用 URLSession 获取数据食谱以获取更多信息:
import Foundation
import PlaygroundSupport
PlaygroundPage.current.needsIndefiniteExecution = true
func fetchBBCNewsRSSFeed() {
let session = URLSession.shared
let url = URL(string: "http://feeds.bbci.co.uk/news/rss.xml")!
let dataTask = session.dataTask(with: url) { (data, response,
error) in
guard let data = data, error == nil else {
print(error ?? "Unexpected response")
return
}
let dataAsString = String(data: data, encoding: .utf8)!
print(dataAsString)
}
dataTask.resume()
}
fetchBBCNewsRSSFeed()
当你运行游乐场时,你会得到一个如下所示的输出:
<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet title="XSL_formatting" type="text/xsl" href="/shared/bsp/xsl/rss/nolsol.xsl"?>
<rss version="2.0" >
<channel>
<title><![CDATA[BBC News - Home]]></title>;
<description><![CDATA[BBC News - Home]]></description>
<link>https://www.bbc.co.uk/news/</link>
<image>
<url>https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif</url>
<title>BBC News - Home</title>
<link>https://www.bbc.co.uk/news/</link>
</image>
<generator>RSS for Node</generator>
<lastBuildDate>Sat, 15 Aug 2020 00:41:41 GMT</lastBuildDate>
<copyright><![CDATA[Copyright: (C) British Broadcasting
Corporation, see http://news.bbc.co.uk/2/hi/help/rss/4498287.stm
for terms and conditions of reuse.]]></copyright>
<language><![CDATA[en-gb]]></language>
<ttl>15</ttl>
<item>
<title><![CDATA[Coronavirus: Thousands return to UK to beat
France quarantine]]></title>
<description><![CDATA[Holidaymakers have just hours to return to
the UK to avoid the 14-day self-isolation requirement.]]>
</description>
<link>https://www.bbc.co.uk/news/uk-53782019</link>
<guid isPermaLink="true">https://www.bbc.co.uk/news/uk-
53782019</guid>
<pubDate>Fri, 14 Aug 2020 21:21:54 GMT</pubDate>
</item>
//... More items
</channel>
</rss>
如何做到这一点...
整体结构对于看过 HTML 的人来说应该是熟悉的。除了定义 XML 版本和格式的第一行和第二行之外,信息都是通过开标签和关闭标签来结构的。考虑以下示例:
<link>https://www.bbc.co.uk/news/uk-53782019</link>
开标签的名称定义了该 XML 元素的内容;在这种情况下,它是一个链接。然后是元素的内容,内容结束由一个带有其名称之前/字符的关闭标签定义。
除了这个简单的例子之外,XML 元素还可以有属性,这些属性描述了关于元素内容的额外信息:
<guid isPermaLink="true">https://www.bbc.co.uk/news/uk-53782019</guid>
这些在开标签内定义为键值对。
XML 元素的内容可能是一个字符串,就像前面的例子一样,或者它可以是嵌套的子 XML 元素:
<image>
<url>http://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif</url>
<title>BBC News - Home</title>
<link>http://www.bbc.co.uk/news/</link>
</image>
最后,XML 元素的内容可以是数据。这些数据可能以字符串的形式表示,尤其是如果字符串可能很长,并且可能包括换行符、特殊字符和其他可能被误认为是 XML 格式化部分组成部分的组件:
<title><![CDATA[Coronavirus: Thousands return to UK to beat France
quarantine]]></title>
现在我们已经检索到了 XML,我们希望将其解析成有用的东西。我们将使用的解析器是由 Foundation 框架提供的,在 iOS 和 macOS 上可用。它被称为XMLParser。XMLParser是一个SAX解析器,代表简单 XML API。SAX 解析器的特点如下:
-
事件驱动
-
低内存开销
-
只保留相关信息
-
一次遍历
解析器接受一个代理对象,在解析文档时将事件信息传递给它。代理对象的责任是在 XML 数据解析时从这些代理回调中获取并保留相关信息,因为解析器不会保留解析后的数据。
我们将通过一个简单的示例来逐步了解解析器如何向代理报告事件。以下是我们要解析的简单 XML:
<xml version="1.0" encoding="UTF-8"?>
<quotes>
<quote attribution="Homer Simpson">
Press any key to continue, where's the any key?
<;/quote>
<quote attribution="Unknown">
Why do nerds confuse Halloween and Christmas? Because
OCT31=DEC25
</quote>
</quotes>
解析器将逐字符解析 XML,每当触发一个事件时,代理都会被告知:
- 第一个事件将是文档的开始,此时解析器将调用以下内容:
func parserDidStartDocument(_ parser: XMLParser)
在这里,我们可以进行任何所需的设置或状态重置。
- 然后,解析器将遍历文档,直到它到达这个点:
<?xml version="1.0" encoding="UTF-8"?>
<quotes>
** Parser is here **
<quote attribution="Homer Simpson">
Press any key to continue, where's the any key?
</quote>
<quote attribution="Unknown>
Why do nerds confuse Halloween and Christmas? Because
OCT31=DEC25
</quote>
</quotes>
- 解析器已经完成了第一个元素的开标签的解析,因此它触发了代理回调:
func parser(_ parser: XMLParser,
didStartElement elementName: String,
namespaceURI: String?,
qualifiedName qName: String?,
attributes attributeDict: [String : String] = [:]) {
/*
elementName = quotes
namespaceURI = nil
qName = nil
attributeDict = [:]
*/
}
- 解析器继续进行,直到它到达这个点:
<?xml version="1.0" encoding="UTF-8">
<quotes>
<quote attribution="Homer Simpson">
** Parser is here **
Press any key to continue, where's the any key?
</quote>
<quote attribution="Unknown">
Why do nerds confuse Halloween and Christmas? Because
OCT31=DEC25
</quote>
</quotes>
- 由于解析器已经看到了另一个起始标签,它将触发相同的代理回调,并提供有关这个新元素的信息:
func parser(_ parser: XMLParser,
didStartElement elementName: String,
namespaceURI: String?,
qualifiedName qName: String?,
attributes attributeDict: [String : String] = [:]) {
/*
elementName = quote
namespaceURI = nil
qName = nil
attributeDict = ["attribution": "Homer Simpson"]
*/
}
这次,由于元素具有属性信息,这些信息由代理回调在attributeDict字典中提供。
- 解析器现在开始解析第一个
quote元素的内容。在某个时刻,它将使用收集到的内容触发代理回调:
<?xml version="1.0" encoding="UTF-8"?>
<quotes>
<quote attribution="Homer Simpson">
Press any key to continue, ** Parser is here **where's the
any key?
</quote>
<quote attribution="Unknown>
Why do nerds confuse Halloween and Christmas? Because
OCT31=DEC25
</quote>
</quotes>
然后,它将收集到的内容提供给了代理:
func parser(_ parser: XMLParser, foundCharacters string: String) {
/*
string = "Press any key to continue, "
*/
}
解析器在内容中间停止并触发代理回调的原因是为了最大限度地利用内存。解析器必须将处理的所有数据保留在内存中,直到它可以传递给代理。因此,如果解析器确定内存使用量很高,它将收集到目前为止的内容并将其传递给代理。一旦完成,它就可以释放内存并重新收集进一步的内容。
在这个简单的示例中,解析器不会在单个代理回调中提供元素的所有内容是非常不可能的。然而,看到这样的例子是有用的,因为我们必须考虑到这种可能性,并且它将影响我们稍后实现代理的方式。
- 解析器将触发相同的
foundCharacters代理回调,直到将一个元素的所有内容都传递给代理:
<?xml version="1.0" encoding="UTF-8"?>
<quotes>
<quote attribution="Homer Simpson">
Press any key to continue, where's the any key?
** Parser is here **
</quote>
<quote attribution="Unknown">
Why do nerds confuse Halloween and Christmas? Because
OCT31=DEC25
</quote>
</quotes>
- 然后,它提供自上次调用代理以来的新内容:
func parser(_ parser: XMLParser, foundCharacters string: String) {
/*
string = "where's the any key?"
*/
}
- 解析器现在处理第一个
quote元素的关闭标签:
<?xml version="1.0" encoding="UTF-8"?>
<quotes>
<quote attribution="Homer Simpson">
Press any key to continue, where's the any key?
</quote>
** Parser is here **
<quote attribution="Unknown">
Why do nerds confuse Halloween and Christmas? Because
OCT31=DEC25
</quote>
</quotes>
- 然后,它触发代理回调,表示元素的结束:
func parser(_ parser: XMLParser,
didEndElement elementName: String,
namespaceURI: String?,
qualifiedName qName: String?) {
/*
elementName = "quote"
namespaceURI = nil
qName = nil
*/
}
解析器将继续以相同的方式处理下一个quote元素,触发相同的didStartElement序列,然后是一系列foundCharacters回调,最后以对didEndElement的调用结束。
- 完成处理最后一个
quote元素后,解析器将处理quotes元素的关闭标签:
<?xml version="1.0" encoding="UTF-8"?>
<quotes>
<quote attribution="Homer Simpson">
Press any key to continue, where's the any key?
</quote>
<quote attribution="Unknown">
Why do nerds confuse Halloween and Christmas? Because
OCT31=DEC25
</quote>
</quotes>
** Parser is here **
它将为quotes元素触发另一个didEndElement回调:
func parser(_ parser: XMLParser,
didEndElement elementName: String,
namespaceURI: String?,
qualifiedName qName: String?) {
/*
elementName = "quotes"
namespaceURI = nil
qName = nil
*/
}
- 最后,解析器将触发一个代理回调,以指示文档解析完成:
func parserDidEndDocument(_ parser: XMLParser) {
}
现在你已经了解了解析器如何向代理传递信息,我们可以回到我们的 RSS 示例。
它是如何工作的...
你会记得我们检索到的 XML 数据如下所示:
<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet title="XSL_formatting" type="text/xsl" href="/shared/bsp/xsl/rss/nolsol.xsl"?>
<rss version="2.0" >
<channel>
<title><![CDATA[BBC News - Home]]></title>
<description><![CDATA[BBC News - Home]]></description>
<link>https://www.bbc.co.uk/news/</link>
<image>
<url>https://news.bbcimg.co.uk/nol/shared/img/
bbc_news_120x60.gif</url>
<title>BBC News - Home</title>
<link>https://www.bbc.co.uk/news/</link>
</image>
<generator>RSS for Node</generator>
<lastBuildDate>Sat, 15 Aug 2020 00:41:41 GMT</lastBuildDate>
<copyright><![CDATA[Copyright: (C) British Broadcasting
Corporation, see http://news.bbc.co.uk/2/hi/help/rss/
4498287.stm for terms and conditions of reuse.]]>
</copyright>
<language><![CDATA[en-gb]]></language>
<ttl>15</ttl>
<item>
<title><![CDATA[Coronavirus: Thousands return to UK to beat
France quarantine]]></title>
<description><![CDATA[Holidaymakers have just hours to
return to the UK to avoid the 14-day self-isolation
requirement.]]></description>
<link>https://www.bbc.co.uk/news/uk-53782019</link>
<guid isPermaLink="true">https://www.bbc.co.uk/news/uk-
53782019</guid>
<pubDate>Fri, 14 Aug 2020 21:21:54 GMT</pubDate>
</item>
//... More items
</channel>
</rss>
从这个中,我们想要提取可用的新闻文章,因此让我们定义一个包含一些有用信息的NewsArticle模型,并将其放置在游乐场的顶部:
struct NewsArticle {
let title: String
let url: URL
}
由于所需的信息将分布在多个代理回调中,我们的代理需要跟踪它已接收到的信息,以便可以在适当的时间将其拼接在一起。
让我们创建一个类对象作为解析器的代理,并使其符合XMLParserDelegate:
class RSSNewsArticleBuilder: NSObject, XMLParserDelegate {
}
在前面的 XML 中,每篇新闻文章都包含在一个item元素中,因此我们的代理需要跟踪解析器何时为item元素提供内容,以便它可以忽略来自其他元素的内容:
class RSSNewsArticleBuilder: NSObject, XMLParserDelegate {
var inItem = false
func parser(_ parser: XMLParser,
didStartElement elementName: String,
namespaceURI: String?,
qualifiedName qName: String?,
attributes attributeDict: [String : String] = [:]) {
switch elementName {
case "item":
inItem = true
default:
break
}
}
func parser(_ parser: XMLParser,
didEndElement elementName: String,
namespaceURI: String?,
qualifiedName qName: String?) {
switch elementName {
case "item":
inItem = false
default:
break
}
}
}
我们想要从item元素中提取的两个部分是标题和 URL。正如我们从 XML 中可以看到的,标题包含在title元素内的CDATA包装器中,而 URL 包含在link元素中:
<item>
<title><![CDATA[Coronavirus: Thousands return to UK to beat France
quarantine]]></title>
<description><![CDATA[Holidaymakers have just hours to return to the
UK to avoid the 14-day self-isolation requirement.]]>
</description>
<link>https://www.bbc.co.uk/news/uk-53782019</link>
<guid isPermaLink="true">https://www.bbc.co.uk/news/uk-
53782019</guid>
<pubDate>Fri, 14 Aug 2020 21:21:54 GMT</pubDate>
</item>
因此,我们还需要跟踪解析器何时处于link元素中,并且当它处于链接元素内时,将接收到的内容附加到一个String属性中。同样,我们需要跟踪解析器何时处于title元素中,并且当它处于该元素时,将接收到的内容附加到一个Data属性中。
让我们在RSSNewsArticleBuilder对象中添加所需的额外属性:
class RSSNewsArticleBuilder: NSObject, XMLParserDelegate {
var inItem = false
var inTitle = false
var inLink = false
var titleData: Data?
var linkString: String?
//...
}
在didStartElement方法中,我们可以检查需要跟踪的新元素名称。我们还必须记得在开始相关元素时重置链接和标题属性。这样,我们就不会继续将针对下一个项目元素的文本内容附加到上一个元素的文本内容上:
func parser(_ parser: XMLParser,
didStartElement elementName: String,
namespaceURI: String?,
qualifiedName qName: String?,
attributes attributeDict: [String : String] = [:]) {
switch elementName {
case "item":
inItem = true
case "title":
inTitle = true
titleData = Data()
case "link":
inLink = true
linkString = ""
default:
break
}
}
现在我们知道我们处于正确的元素中,我们可以实现两个XMLParserDelegate方法来接收相关内容并将其存储:
class RSSNewsArticleBuilder: NSObject, XMLParserDelegate {
//...
func parser(_ parser: XMLParser, foundCDATA CDATABlock: Data) {
if inTitle {
titleData?.append(CDATABlock)
}
}
func parser(_ parser: XMLParser, foundCharacters string: String) {
if inLink {
linkString?.append(string)
}
}
}
在didEndElement方法中,我们需要更新我们的新属性,并且我们可以打印出我们从 XML 中检索到的值:
class RSSNewsArticleBuilder: NSObject, XMLParserDelegate {
//...
func parser(_ parser: XMLParser,
didEndElement elementName: String,
namespaceURI: String?,
qualifiedName qName: String?) {
switch elementName {
case "item":
inItem = false
guard
let titleData = titleData,
let titleString = String(data: titleData, encoding:
.utf8),
let linkString = linkString,
let link = URL(string: linkString)
else { break }
print(titleString)
print(link)
case "title":
inTitle = false
case "link":
inLink = false
default:
break
}
}
//...
}
现在我们已经提取了新闻文章的标题和 URL,我们可以使用这些信息来创建一个NewsArticle模型对象。首先,让我们创建一个数组来保存我们将创建的NewsArticle对象:
class RSSNewsArticleBuilder: NSObject, XMLParserDelegate {
var inItem = false
var inTitle = false
var inLink = false
var titleData: Data?
var linkString: String?
var articles = [NewsArticle]()
//...
}
我们可以在item元素的末尾创建NewsArticle对象,因为那时我们将拥有所有相关内容:
class RSSNewsArticleBuilder: NSObject, XMLParserDelegate {
//...
func parser(_ parser: XMLParser,
didEndElement elementName: String,
namespaceURI: String?,
qualifiedName qName: String?) {
switch elementName {
case "item":
inItem = false
guard
let titleData = titleData,
let titleString = String(data: titleData, encoding:
.utf8),
let linkString = linkString,
let link = URL(string: linkString)
else { break }
let article = NewsArticle(title: titleString, url: link)
articles.append(article)
case "title":
inTitle = false
case "link":
inLink = false
default:
break
}
}
//...
}
最后,当文档开始时,我们应该确保所有属性都已重置:
class RSSNewsArticleBuilder: NSObject, XMLParserDelegate {
//...
func parserDidStartDocument(_ parser: XMLParser) {
inItem = false
inTitle = false
inLink = false
titleData = nil
linkString = nil
articles = [NewsArticle]()
}
//...
}
现在我们已经完成了解析器代理,让我们回到我们的fetchBBCNewsRSSFeed函数:
func fetchBBCNewsRSSFeed() {
let session = URLSession.shared
let url = URL(string: "http://feeds.bbci.co.uk/news/rss.xml")!
let dataTask = session.dataTask(with: url) { (data, response,
error) in
guard let data = data, error == nil else {
print(error ?? "Unexpected response")
return
}
let dataAsString = String(data: data, encoding: .utf8)!
print(dataAsString)
}
dataTask.resume()
}
一旦检索到 XML 数据,我们将将其传递给XMLParser,设置代理,并告诉解析器解析数据:
func fetchBBCNewsRSSFeed() {
let session = URLSession.shared
let url = URL(string: "http://feeds.bbci.co.uk/news/rss.xml")!
let dataTask = session.dataTask(with: url) { (data, response,
error) in
guard let data = data, error == nil else {
print(error ?? "Unexpected response")
return
}
let parser = XMLParser(data: data)
let articleBuilder = RSSNewsArticleBuilder()
parser.delegate = articleBuilder
parser.parse()
let articles = articleBuilder.articles
print(articles)
}
dataTask.resume()
}
我们希望从这个函数提供文章作为输出,因此我们可以添加一个完成处理程序来提供新闻文章数组或错误:
func fetchBBCNewsRSSFeed(completion: @escaping ([NewsArticle]?, Error?)
-> Void) {
let session = URLSession.shared
let url = URL(string: "http://feeds.bbci.co.uk/news/rss.xml")!
let dataTask = session.dataTask(with: url) { (data, response,
error) in
guard let data = data, error == nil else {
completion(nil, error)
return
}
let parser = XMLParser(data: data)
let articleBuilder = RSSNewsArticleBuilder()
parser.delegate = articleBuilder
parser.parse()
let articles = articleBuilder.articles
completion(articles, nil)
}
dataTask.resume()
}
最后,我们可以调用此函数,它将检索 RSS 源,解析它,并返回一个新闻文章数组:
fetchBBCNewsRSSFeed() { (articles, error) in
if let articles = articles {
print(articles)
} else if let error = error {
print(error)
}
}
还有更多...
Foundation 还提供了写入 XML 数据的能力,尽管目前此功能仅在 macOS 上可用。
在检索到 RSS 源并创建我们的新闻文章后,让我们将此信息写入 XML 数据结构,并将其保存到磁盘。此 XML 将采用以下形式:
<articles>
<article>
<title>Donald Trump calls Fidel Castro 'brutal dictator'
</title>
<url>http://www.bbc.co.uk/news/world-latin-america-
38118739</url>
</article>
<article>
<title>Fidel Castro: Jeremy Corbyn praises 'huge figure'
</title>
<url>http://www.bbc.co.uk/news/uk-38117068</url>
</article>
</articles>
在 XML 结构的根处是一个articles元素,它包含多个article元素,这些元素又包含一个title元素和一个url元素。
要写入 XML 数据,我们将使用XMLDocument和XMLElement对象重新创建前面的结构。一旦构建完成,XMLDocument对象的xmlData属性提供了文档作为数据。
让我们创建一个函数,从NewsArticle数组生成 XML 数据:
func createXML(representing articles: [NewsArticle]) -> Data {
let root = XMLElement(name: "articles")
let document = XMLDocument(rootElement: root)
for article in articles {
let articleElement = XMLElement(name: "article")
let titleElement = XMLElement(name: "title",
stringValue: article.title)
let urlElement = XMLElement(name: "url",
stringValue: article.url.absoluteString)
articleElement.addChild(titleElement)
articleElement.addChild(urlElement)
root.addChild(articleElement)
}
print(document.xmlString)
return document.xmlData
}
我们创建每个XMLElement并将其作为子元素添加到我们想要嵌套的元素中。
如果你在这个故事板中构建它,请确保将此函数放在RSSNewsArticleBuilder之后,并在调用fetchBBCNewsRSSFeed的代码之前,因为这个函数很快就需要可用。
我们对fetchBBCNewsRSSFeed的调用将提供一个NewsArticle数组,因此我们可以将此传递给我们的新函数,以将此信息写入 XML 数据:
fetchBBCNewsRSSFeed() { (articles, error) in
if let articles = articles {
let articleXMLData = createXML(representing: articles)
print(articleXMLData.length)
} else if let error = error {
print(error)
}
}
现在我们有了数据,我们可以获取documents目录的 URL,附加我们将创建的文件名,并将其写入磁盘:
fetchBBCNewsRSSFeed() { (articles, error) in
if let articles = articles {
let xmlData = createXML(representing: articles)
let documentsURL = FileManager.default.urls(for:
.documentDirectory, in: .userDomainMask).first!
let writeURL = documentsURL.appendingPathComponent(
"articles.xml")
print("Writing data to: \(writeURL)")
try! xmlData.write(to: writeURL)
} else if let error = error {
print(error)
}
}
我们现在已检索到 RSS 源,从中提取了有用的信息,将这些信息写入自定义 XML 格式,并将其保存到磁盘。给自己鼓掌吧!
参见
更多关于XMLParser的信息可以在 Apple 的 Foundation 参考文档中找到,网址为swiftbook.link/docs/xmlparser。
其他 XML 解析器也可用,它们可能比 Apple 的解析器有优势,包括能够在 iOS 上写入 XML。它们如下所示:
-
RaptureXML:
github.com/ZaBlanc/RaptureXML -
TBXML:
github.com/71squared/TBXML
使用 Swift 构建 iOS 应用
在本章中,我们将使用 Swift 和 Xcode IDE 构建我们自己的 iOS 应用。一旦我们构建了我们的应用,我们将探讨如何集成单元测试和 用户界面(UI)测试。最后,我们将探讨 Swift 和 iOS 开发中的向后兼容性。
在本章中,我们将涵盖以下食谱:
-
使用 Cocoa Touch 构建 iOS 应用
-
使用 XCTest 进行单元和集成测试
-
使用 XCUITest 进行用户界面测试
-
向后兼容性
让我们开始吧!
第七章:技术要求
对于本章,您需要从 Mac App Store 获取 Xcode 的最新版本。
本章的所有代码都可以在本书的 GitHub 仓库中找到,地址为 github.com/PacktPublishing/Swift-Cookbook-Second-Edition/tree/master/Chapter06
查看以下视频,了解代码的实际应用:bit.ly/3pMG44r
使用 Cocoa Touch 构建 iOS 应用
本书的核心重点是 Swift 编程语言本身,而不是使用该语言为 Apple 平台生成应用或构建服务器端服务。尽管如此,不能忽视的是,绝大多数正在编写的 Swift 代码是用来构建或基于 iOS 和 iPadOS 应用构建的。
在本食谱中,我们将简要了解如何使用 Swift 与 Apple 的 Cocoa Touch 框架交互,并开始构建和创建我们自己的 iOS 应用。
Cocoa Touch 是指作为 iOS SDK 部分提供的 UI 框架集合的名称。其名称来源于 macOS 上的 Cocoa 框架,它为 macOS 应用提供 UI 元素。虽然 macOS 上的 Cocoa 是一个独立的框架,但 Cocoa Touch 是一组提供 iOS 应用 UI 元素并处理应用生命周期的框架;这些框架的核心是 UIKit。
准备工作
首先,我们需要创建一个新的 iOS 应用项目:
-
从 Xcode 菜单中选择文件,然后新建。
-
在打开的对话框中,从 iOS 选项卡中选择 App:

图 6.1 – 选择模板
- 下一个对话框会要求您输入有关您应用的详细信息,选择产品名称和组织名称,并添加以反向 DNS 风格的组织标识符。
反向 DNS 风格意味着取一个你或你的公司拥有的网站,并反转域名组件的顺序。例如,maps.google.com 变为 com.google.maps:

图 6.2 – 新项目的选项
注意前面的选择,因为并非所有选项都默认选中。对于这个食谱,对我们来说重要的是界面和包含测试,这两个我们将在本章后面讨论,当我们查看使用 XCTest 进行单元测试和 XCUITest 进行用户界面测试时。
- 一旦你在 Mac 上选择了保存位置,你将看到以下 Xcode 布局:

图 6.3 – 新项目模板
在这里,我们开始了我们的项目——它并不多,但这是所有新的 iOS 应用开始的地方。
从这个菜单中,按产品 | 运行。Xcode 现在将在模拟器中编译并运行你的应用。
如何做到这一点...
从之前的食谱继续,我们将基于从 Public GitHub API 返回的数据构建我们的应用:
-
在文件资源管理器中,点击 Main.storyboard;这个视图是应用外观的表示,被称为界面构建器。目前,只有一个空白屏幕可见,这与我们之前运行应用时的外观相匹配。这个屏幕代表一个
View Controller对象;正如其名所示,这是一个控制视图的对象。 -
我们将在表格中显示我们的存储库列表。实际上,我们想要创建一个视图控制器类,它是
UITableViewController的子类。因此,从菜单中选择文件,然后选择新建,并选择 Cocoa Touch 类模板:

图 6.4 – 新文件模板
- 我们将在这个视图控制器中显示存储库,所以让我们称它为
ReposTableViewController。指定它是UITableViewController的子类,并确保语言是 Swift:

图 6.5 – 新的文件名和子类
现在我们已经创建了我们的视图控制器类,让我们切换回Main.storyboard并删除为我们创建的空白视图控制器。
- 从对象库中找到表格视图控制器选项,并将其拖入界面构建器编辑器:

图 6.6 – 对象库
- 现在我们有一个表格视图控制器,我们希望这个控制器成为我们自定义子类的一部分。要做到这一点,选择控制器,进入类检查器,将类类型输入为
ReposTableViewController,然后按Enter:

图 6.7:自定义类检查器
虽然我们有将显示存储库名称的视图控制器,但当用户选择一个存储库时,我们希望展示一个新的视图控制器来显示该特定存储库的详细信息。我们将很快介绍这种类型的视图控制器以及如何展示它,但首先,我们需要一种在视图控制器之间导航的机制。
如果你曾经使用过 iOS 应用,你将熟悉在视图之间导航的标准推送和弹出方式。以下截图显示了应用在过渡过程中的中间状态:

图 6.8 – 推送和弹出视图控制器
这些视图控制器的管理,以及它们的展示和消失转换,由一个导航控制器处理,这是 Cocoa Touch 以UINavigationController的形式提供的。让我们看看:
- 要将我们的视图控制器放入导航控制器中,在 Interface Builder 中选择
ReposTableViewController。然后,从 Xcode 菜单中,转到编辑,然后嵌入,并选择导航控制器。
这将在故事板中添加一个导航控制器,并将选定的视图控制器设置为它的根视图控制器(如果故事板中已经存在一个从最初创建的项目中导入的视图控制器,可以将其突出显示并删除)。
-
接下来,我们需要定义当应用启动时屏幕上最初显示哪个视图控制器。在屏幕左侧选择导航控制器,然后在属性检查器中选择“是否为初始视图控制器”。你会看到一条箭头指向左侧的导航控制器,表示它将最初显示。
-
使用这个设置,我们可以通过从文件导航菜单中选择它来开始工作我们的
ReposTableViewController。
当我们创建视图控制器时,模板给我们提供了一堆代码,其中一些是注释掉的。模板提供的第一个方法是viewDidLoad。这是覆盖视图控制器管理的根视图生命周期的方法集的一部分。关于视图生命周期及其相关方法调用的完整详细信息,可以在swiftbook.link/docs/vc-lifecycle找到。
viewDidLoad在视图控制器生命周期中非常早的时候被触发,但在视图控制器对用户可见之前。由于这个原因,这是一个配置视图和检索任何你想要向用户展示的信息的好地方。
- 让我们给视图控制器一个标题:
class ReposTableViewController: UITableViewController {
override func viewDidLoad() {
super.viewDidLoad()
self.title = "Repos"
}
//...
}
现在,如果你构建并运行应用,你会看到一个带有我们刚刚通过编程添加的标题的导航栏。
- 接下来,我们将获取并显示 GitHub 仓库列表。为了获取特定用户的仓库列表,实现以下代码片段:
@discardableResult
internal func fetchRepos(forUsername username: String,
completionHandler: @escaping (FetchReposResult) -> Void)
-> URLSessionDataTask? {
let urlString = "https://api.github.com/users/\
(username)/repos"
guard let url = URL(string: urlString) else {
return nil
}
var request = URLRequest(url: url)
request.setValue("application/vnd.github.v3+json",
forHTTPHeaderField: "Accept")
let task = session.dataTask(with: request) { (data,
response, error) in
// First unwrap the optional data
guard let data = data else {
completionHandler(.failure(ResponseError.
requestUnsuccessful))
return
}
do {
let decoder = JSONDecoder()
let responseObject = try decoder.
decode([Repo].self, from: data)
completionHandler(.success(responseObject))
} catch {
completionHandler(.failure(error))
}
}
task.resume()
return task
}
- 让我们在文件的顶部添加以下突出显示的代码,在类定义之前。我们还将向视图控制器添加一个会话属性,这是网络请求所需的:
import UIKit
struct Repo: Codable {
let name: String?
let url: URL?
enum CodingKeys: String, CodingKey {
case name = "name"
case url = "html_url"
}
}
enum FetchReposResult {
case success([Repo])
case failure(Error)
}
enum ResponseError: Error {
case requestUnsuccessful
case unexpectedResponseStructure }
class ReposTableViewController: UITableViewController {
internal var session = URLSession.shared
//...
}
你可能会注意到前面的函数有一些不同,因为我们现在正在充分利用 Swift 的Codable协议。使用 Codable,我们可以将 API 的 JSON 响应直接映射到我们的结构模型,而无需将其转换为字典,然后迭代每个键值对到一个属性。
- 接下来,在我们的表格视图中,表格的每一行将显示我们从 GitHub API 检索到的其中一个仓库的名称。我们需要一个地方来存储我们从 API 检索到的仓库:
class ReposTableViewController: UITableViewController {
internal var session = URLSession.shared
internal var repos = [Repo]()
//...
}
repos 数组有一个初始为空的数组值,但我们将使用这个属性来存储从 API 获取的结果。
我们现在不需要获取仓库数据。因此,我们将学习如何提供用于表格视图的数据。让我们开始吧:
- 让我们创建一些假仓库,这样我们就可以暂时填充我们的表格视图:
class ReposTableViewController: UITableViewController {
let session = URLSession.shared
var repos = [Repo]()
override func viewDidLoad() {
super.viewDidLoad()
let repo1 = Repo(name: "Test repo 1",
url: URL(string: "http://example.com/repo1")!)
let repo2 = Repo(name: "Test repo 2",
url: URL(string: "http://example.com/repo2")!)
repos.append(contentsOf: [repo1, repo2])
}
//...
}
表格视图中的信息是从表格视图的数据源中填充的,这个数据源可以是任何符合 UITableViewDataSource 协议的对象。
协议。
当表格视图显示并且用户与之交互时,表格视图将要求数据源提供它需要的信息以填充表格视图。对于简单的表格视图实现,通常控制表格视图的视图控制器充当数据源。实际上,当你创建 UITableViewController 的子类时,正如我们所做的那样,视图控制器已经符合 UITableViewDataSource 协议,并被分配为表格视图的数据源。
- 在
UITableViewDataSource中定义的一些方法是在UITableViewController模板中创建的;我们将查看的三个如下:
// MARK: - Table view data source
override func numberOfSections(in tableView: UITableView) -> Int {
// #warning Incomplete implementation, return the number of
// sections
return 0
}
override func tableView(_ tableView: UITableView,
numberOfRowsInSection section: Int) -> Int {
// #warning Incomplete implementation, return the number of rows
return 0
}
/*
override func tableView(_ tableView: UITableView,
cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier:
"RepoCell", for: indexPath)
// Configure the cell...
return cell
}
*/
表格视图中的数据可以分成多个部分,信息在这些部分中以行形式呈现;信息通过一个由部分整数值和行整数值组成的 IndexPath 来引用。
- 数据源方法首先要求我们提供表格视图将拥有的部分数量。我们的应用将只显示一个简单的仓库列表,因此我们只需要一个部分,所以我们将从这个方法中返回
1:
override func numberOfSections(in tableView: UITableView) -> Int {
1
}
- 我们接下来必须提供的是表格视图在给定部分中应该有的行数。如果我们有多个部分,我们可以检查提供的部分索引并返回正确的行数,但由于我们只有一个部分,我们可以在所有情况下返回相同的数字。
我们正在显示我们检索到的所有仓库,所以行数简单地就是 repos 数组中仓库的数量:
override func tableView(_ tableView: UITableView,
numberOfRowsInSection section: Int) -> Int {
repos.count
}
注意,在前面的两个函数中,我们不再使用 return 关键字。这是因为,从 Swift 5.1 开始,你现在可以在函数中使用 隐式返回。只要你的函数没有关于应该返回什么或不应该返回什么的歧义,编译器就可以为你解决这个问题。这允许有更简洁的语法。
现在我们已经告诉了表格视图要显示多少条信息,我们必须能够显示这些信息。表格视图通过一种称为 UITableViewCell 的视图类型来显示信息,而这个单元格就是我们接下来需要提供的。
对于我们提供的每个索引路径,包括节和行范围,我们都会被要求提供一个表格视图将要显示的单元格。表格视图可能非常大,因为它可能需要表示大量数据。然而,在任何时候只能向用户显示少量单元格。这是因为表格视图的只有一部分在任何时候是可见的:

图 6.9 – 表格视图单元格概述
为了提高效率并防止用户滚动时应用变慢,表格视图可以重用已经创建但随后移出屏幕的单元格。实现单元格重用分为两个阶段:
-
使用重用标识符将单元格的类型注册到表格视图中。
-
为给定的重用标识符出列单元格。这将返回一个已移出屏幕的单元格,如果没有可重用的单元格,则创建一个新的单元格。
单元格的注册方式取决于其创建方式。如果单元格已创建,并且其子视图也在代码中布局,则通过 UITableView 上的此方法将单元格的类注册到表格视图中:
func register(_ cellClass: AnyClass?,
forCellReuseIdentifier identifier: String)
如果单元格已在 .xib 中布局(通常由于历史原因称为“nib”),这是一个类似于故事板的视图视觉布局文件,则通过 UITableView 上的此方法将单元格的 nib 注册到表格视图中:
func register(_ nib: UINib?, forCellReuseIdentifier identifier: String)
最后,可以在表格视图中通过故事板定义和布局单元格。这种方法的优点是不需要像前两种方法那样手动注册单元格;与表格视图注册是免费的。然而,这种方法的缺点是单元格布局与表格视图绑定,因此不能像前两种实现那样在其他表格视图中重用。
让我们在故事板中布局我们的单元格,因为我们只会用它与一个表格视图一起使用:
-
切换到我们的
Main.storyboard文件,并选择ReposTableViewController中的表格视图。 -
在属性检查器中,将原型单元格的数量更改为
1;这将向主窗口中的表格视图添加一个单元格。这个单元格将定义将在我们的表格视图中显示的所有单元格的布局。你应该为每种类型的单元格布局创建一个原型单元格;在我们的表格视图中,我们只显示一条信息,所以所有单元格都将具有相同的类型。 -
在故事板中选择一个单元格。属性检查器将切换到显示单元格的属性。单元格样式将被设置为自定义,通常这正是你想要的。当你想在单元格中显示多个信息时,你通常会创建
UITableViewCell的子类,在类检查器中将此设置为单元格的类,然后在自定义单元格类型中布局子视图。然而,对于这个例子,我们只想显示仓库的名称。因此,我们可以使用一个基本的单元格样式,它只有一个文本标签,没有自定义子类,所以从样式下拉菜单中选择基本样式。 -
我们需要设置我们将用于稍后从队列中提取单元格的复用标识符,所以将适当的字符串,例如
RepoCell,输入到属性检查器的复用标识符框中:

图 6.10 – 表格视图单元格标识符
-
现在我们有一个已注册用于与表格视图重用的单元格,我们可以回到我们的视图控制器,并完成对
UITableViewDataSource的遵守。 -
我们的
ReposTableViewController包含一些作为模板一部分创建的注释代码:
/*
override func tableView(_ tableView: UITableView,
cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier:
"RepoCell", for: indexPath)
// Configure the cell... return cell
}
*/
- 到目前为止,你可以移除
/* */注释标记,因为我们已经准备好实现这个方法。
每次表格视图需要将单元格放置在屏幕上时,都会调用此数据源方法;当表格第一次显示时,这就会发生,因为它需要单元格来填充表格视图的可见部分。当用户以某种方式滚动表格视图,从而揭示一个新单元格使其可见时,也会调用此方法。
- 关于方法定义,我们可以看到我们提供了相关的表格视图和所需单元格的索引路径,我们预计将返回一个
UITableViewCell。模板提供的代码实际上为我们做了大部分工作;我们只需要提供在故事板中设置的复用标识符,并设置单元格的标题标签,以便我们得到正确的仓库名称:
override func tableView(_ tableView: UITableView,
cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier:
"RepoCell", for: indexPath)
// Configure the cell...
let repo = repos[indexPath.row]
cell.textLabel?.text = repo.name
return cell
}
单元的textLabel属性是可选的,因为它仅在单元格的样式不是自定义时存在。
- 由于我们现在已经提供了表格视图显示我们的仓库信息所需的一切,让我们点击构建和运行,看看效果:

图 6.11 – 我们应用的第一次运行
太好了!现在我们已经在我们的表格视图中显示了两个测试仓库,让我们用 GitHub API 的实际仓库替换我们的测试数据。
我们之前添加了fetchRepos方法,所以我们只需要调用这个方法,将结果设置到我们的repos属性中,并告诉表格视图由于数据已更改,它需要重新加载:
class ReposTableViewController: UITableViewController {
internal var session = URLSession.shared
internal var repos = [Repo]()
override func viewDidLoad() {
super.viewDidLoad()
title = "Repos"
fetchRepos(forUsername:"SwiftProgrammingCookbook"){ [weak self]
result in
switch result {
case .success(let repos):
self?.repos = repos
case .failure(let error):
self?.repos = []
print("There was an error: \(error)")
}
self?.tableView.reloadData()
}
}
//...
}
正如我们在前面的食谱中所做的那样,我们从 GitHub API 获取了仓库,并收到了一个枚举结果,告诉我们这是成功还是失败。如果是成功的,我们将结果仓库数组存储在我们的repos属性中。一旦我们处理了响应,我们就在UITableView上调用reloadData方法,这指示表格视图重新查询其源以获取要显示的单元格。
我们还在闭包的捕获列表中提供了一个对self的弱引用,以防止保留周期。你可以在第一章 Swift 构建块的闭包食谱中了解更多为什么这很重要。
在这一点上,有一个重要的考虑因素需要解决。iOS 平台是一个多线程环境,这意味着它可以同时做很多事情。这对于保持响应式用户界面,同时能够处理数据和执行长时间运行的任务至关重要。iOS 系统使用队列来管理这项工作,并为涉及用户界面的任何工作保留“主”队列。因此,每次你需要与用户界面交互时,确保这项工作是在主队列中完成的是非常重要的。
我们的fetchRepos方法展示了这种情况可能并不总是成立。我们的fetchRepos方法执行网络操作,我们在创建URLSessionDataTask时向URLSession提供了一个闭包,但无法保证这个闭包将在主线程上执行。因此,当我们从fetchRepos收到响应时,我们需要将处理该响应的工作“调度”到主队列,以确保我们的 UI 更新发生在主队列上。我们可以使用Dispatch框架来做这件事,所以我们需要在文件顶部导入它:
class ReposTableViewController: UITableViewController {
let session = URLSession.shared
var repos = [Repo]()
override func viewDidLoad() {
super.viewDidLoad()
title = "Repos"
fetchRepos(forUsername:"SwiftProgrammingCookbook"){ [weak self]
result in
DispatchQueue.main.async {
switch result {
case .success(let repos):
self?.repos = repos
case .failure(let error):
self?.repos = []
print("There was an error: \(error)")
}
self?.tableView.reloadData()
}
}
}
}
我们将在第九章性能和响应性中更深入地讨论多线程和Dispatch框架。
- 点击构建和运行。几秒钟后,表格视图将填充来自 GitHub API 的各种仓库的名称。
现在我们已经向用户展示了仓库,我们将在我们的应用中实现的功能的下一部分是能够点击一个单元格,并在 WebView 中显示仓库的 GitHub 页面。
由表格视图触发的操作,例如当用户点击一个单元格时,会提供给表格视图的代理,这可以是任何符合UITableViewDelegate的任何东西。正如表格视图的数据源一样,我们的ReposTableViewController已经符合UITableViewDelegate,因为它是一个UITableViewController的子类。
- 如果你查看
UITableViewDelegate协议的文档,你会看到很多可选方法;该文档可以在developer.apple.com/reference/uikit/uitableviewdelegate找到。与我们目的相关的一个如下:
func tableView(_ tableView: UITableView, didSelectRowAt indexPath:
IndexPath)
- 这将在用户选择表格视图中的单元格时在表格视图的代理上被调用,所以让我们在我们的视图控制器中实现这个功能:
override func tableView(_ tableView: UITableView, didSelectRowAt
indexPath: IndexPath) {
let repo = repos[indexPath.row]
let repoURL = repo.url
// TODO: Present the repo's URL in a webview
}
- 对于它提供的功能,我们将使用
SFSafariViewController,传递给它存储库的 URL。然后,我们将该视图控制器传递给show方法,该方法将以最合适的方式显示视图控制器:
override func tableView(_ tableView: UITableView,
didSelectRowAt indexPath: IndexPath) {
let repo = repos[indexPath.row]
guard let repoURL = repo.url else { return }
let webViewController = SFSafariViewController(url: repoURL)
show(webViewController, sender: nil)
}
-
不要忘记在文件顶部导入
SafariServices。 -
点击构建和运行,一旦加载了存储库,点击其中一个单元格。一个新的视图控制器将被推送到屏幕上,并加载相关的存储库网页。
恭喜你——你刚刚构建了你的第一个应用程序,它看起来很棒!
它是如何工作的...
目前,我们的应用程序从特定的硬编码 GitHub 用户名获取存储库。如果用户能够输入将要检索存储库的 GitHub 用户名而不是硬编码用户名,那就太好了。所以,让我们添加这个功能:
-
首先,我们需要一种方式让用户输入他们的 GitHub 用户名;允许用户输入少量文本的最合适方式是通过使用
UITextField。 -
在主故事板中,在对象库中找到文本字段,将其拖动到主窗口,并将其放在我们的
ReposTableViewController的导航栏上。现在,你需要增加文本字段的宽度。目前,只需通过突出显示相应的文本字段并选择大小检查器选项将其硬编码为大约 300px:

图 6.12 – 添加UITextField
与表格视图一样,UITextField通过代理与用户事件通信,该代理需要遵守UITextFieldDelegate。
- 让我们切换回
ReposTableViewController并添加对UITextFieldDelegate的遵守;将协议遵守添加到扩展中是一种常见做法,所以在ReposTableViewController的底部添加以下内容:
extension ReposTableViewController: UITextFieldDelegate {
}
- 在此一致性设置完成后,我们需要将我们的视图控制器设置为
UITextField的代理。回到主故事板,选择文本字段,然后打开连接检查器。你会看到文本字段有一个用于其代理属性的输出。现在,点击、按住并从代表我们的ReposTableViewController的代理旁边的圆圈拖动到符号:

图 6.13 – 带有 IBOutlet 的UITextField
现在代理输出应该有一个值:

图 6.14 – UITextField代理输出
通过查看UITextFieldDelegate的文档,我们可以看到当用户在输入文本后按下键盘上的Return按钮时,会调用textFieldShouldReturn方法,因此这是我们将会实现的方法。
- 让我们回到我们的
ReposViewController,并在我们的扩展中实现这个方法:
extension ReposTableViewController: UITextFieldDelegate {
public func textFieldShouldReturn(_ textField: UITextField)
-> Bool {
// TODO: Fetch repositories from username entered into text
// field
// TODO: Dismiss keyboard
// Returning true as we want the system to have the default
// behaviour
return true
}
}
- 由于仓库将在这里而不是在视图加载时获取,所以让我们将代码从
viewDidLoad移动到这个方法中:
extension ReposTableViewController: UITextFieldDelegate {
public func textFieldShouldReturn(_ textField: UITextField)
-> Bool {
// If no username, clear the data
guard let enteredUsername = textField.text else {
repos.removeAll()
tableView.reloadData()
return true
}
// Fetch repositories from username entered into text field
fetchRepos(forUsername: enteredUsername) { [weak self]
result in
switch result {
case .success(let repos):
self?.repos = repos
case .failure(let error):
self?.repos = []
print("There was an error: \(error)")
}
DispatchQueue.main.async {
self?.tableView.reloadData()
}
}
// TODO: Dismiss keyboard
// Returning true as we want the system to have the default
// behaviour
return true
}
}
Cocoa Touch 实现了编程设计模式MVC,代表模型-视图-控制器;这是一种结构化代码的方式,以保持其元素的复用性,并具有明确的职责。在 MVC 模式中,所有与显示信息相关的代码大致分为三个责任区域:
-
模型对象持有最终将在屏幕上显示的数据;这可能是从网络或设备检索的数据,或者是在应用运行时生成的数据。这些对象可以在应用中的多个地方使用,可能需要不同的视图表示同一数据。
-
视图对象代表屏幕上显示的 UI 元素;这些元素可能只是显示提供的信息,或者从用户那里捕获输入。视图对象可以在需要相同视觉元素的多处使用,即使它们显示的是不同的数据。
-
控制器对象在模型和视图之间充当桥梁;它们负责获取相关的模型对象,并在正确的时间将数据提供给正确的视图对象。控制器对象还负责处理来自视图的用户输入,并根据需要更新模型对象:

图 6.15 – MVC 概述
关于显示网页内容,我们的应用为我们提供了许多显示网页内容的选择:
-
由 WebKit 框架提供的
WKWebView是一个使用最新渲染和 JavaScript 引擎来加载和显示网页内容的视图。虽然它较新,但在某些方面还不够成熟,并且存在缓存内容的问题。 -
由
SafariServices框架提供的SFSafariViewController是一个显示网页内容的视图控制器,它还提供了许多在 Mobile Safari 中可用的功能,包括分享、添加到阅读列表和书签。它还提供了一个方便的按钮,用于在 Mobile Safari 中打开当前网站。
还有更多...
我们需要做的最后一件事是关闭键盘。Cocoa Touch 将当前接收用户事件的对象称为第一响应者。目前,这个对象是文本框。
文本框变为第一响应者这一行为导致了屏幕上键盘的出现。因此,要关闭键盘,文本框只需要放弃其第一响应者的位置:
extension ReposTableViewController: UITextFieldDelegate {
public func textFieldShouldReturn(_ textField: UITextField)
-> Bool {
//...
// Dismiss keyboard
textField.resignFirstResponder()
// Returning true as we want the system to have the default
// behaviour
return true
}
}
现在,点击“构建和运行”。在这个阶段,你可以在文本框中输入任何 GitHub 账户名称以检索其公共仓库列表。请注意,如果你的 Xcode 模拟器没有启用“软键盘”,你只需在物理键盘上按Enter键即可搜索仓库。
相关内容
有关本菜谱中涵盖内容的更多信息,请参阅以下链接:
-
Apple Documentation for GCD:
developer.apple.com/documentation/dispatch -
Apple Documentation UIKit:
developer.apple.com/documentation/uikit
使用 XCTest 进行单元和集成测试
不言而喻,测试在软件开发生命周期中扮演着重要角色。主要来说,很多关注点都集中在物理用户测试上——将你的代码放入那些日复一日使用它的人手中。在某种程度上,这应该是我们的主要关注点之一,但作为软件开发者,我们如何测试和检查我们的代码库的完整性呢?
这就是单元和集成测试发挥作用的地方。在这个菜谱中,我们将为之前编写的 Cocoa Touch 应用编写单元和集成测试。这将完全使用 Swift 语言和 Xcode IDE 编写。
准备工作
回到我们现有的 CocoaTouch 项目中,在文件检查器中查找名为CocoaTouchTest的文件夹。展开此文件夹并选择CocoaTouchTests.swift文件。
在此文件中,你会注意到一个名为CocoaTouchTests的类,它继承自XCTestCase类。XCTestCase提供了一系列函数,我们可以在编写单元测试时使用。
那么,单元测试究竟是什么呢?嗯,它是一个测试(或者在我们的情况下,只是一个函数),用于检查另一个函数是否正在执行其应有的操作。使用XCTestCase编写的测试或函数不仅允许我们使用之前提到的辅助工具套件,还允许 Xcode 可视化和报告测试覆盖率等指标。
有了这些,让我们开始编写我们的第一个单元测试!在CocoaTouchTests.swift文件中,你会看到一些已经被 Xcode 生成的覆盖函数。现在先忽略这些;当我们需要时再处理它们。
如何操作...
让我们先创建以下函数:
func testThatRepoIsNotNil() {
XCTAssertNotNil(viewControllerUnderTest?.repos)
}
让我们一步一步地来分析这个问题。我们将从testThatRepoIsNotNil函数开始。在命名单元测试时,通常要求名称尽可能描述性。根据你的编码标准,你可以选择使用驼峰式命名法或蛇形命名法(我更偏爱驼峰式命名法),但当你使用 Xcode 编写测试时,你总是必须在这些名称前加上“test”这个词。
那么,我们在测试什么呢?在这里,我们正在检查我们的仓库数组是否不为空。
回顾我们的 ReposTableViewController,你会记得我们在变量声明的地方实例化了我们的 "repo" 模型,所以这是一个很好的测试开始。假设有人试图将其更改为可选的,就像这样:
internal var repos: [Repo]?
如果发生这种情况,我们的 CocoaTouch App 中的代码将编译,但测试将失败。
让我们再次看看我们的测试。注意,我们用来检查我们的仓库模型的函数是 viewControllerUnderTest。这是我们访问我们的 RepoTableViewContoller 的方式。我们可以通过在我们的文件中添加以下类级别变量来实现这一点:
var viewControllerUnderTest: ReposTableViewController?
现在,我们需要实例化这个。将以下来自 XCTestCase 的覆盖方法添加到你的类中:
override func setUp() {
viewControllerUnderTest = ReposTableViewController()
}
当运行这个特定类的单元测试时,setUp() 将在运行任何测试用例之前执行,这允许你准备你可能需要的东西,比如实例化一个类。一旦测试完成,你想要释放任何东西或关闭任何东西,你只需使用 tearDown() 函数来做这件事。
这是一个非常基础的测试,但在这里的主要目的并不是看测试实践,而是看我们如何在 Swift 中做到这一点。然而,在我们继续之前,让我们看看我们可用的 Assert 选项。
之前,我们使用了 XCTAssertNotNil,这对于我们的场景来说效果完美。然而,以下选项也是可用的:
XCTAssert
XCTAssertEqual
XCTAssertTrue
XCTAssertGreaterThan
XCTAssertGreaterThanOrEqual
XCTAssertLessThan
XCTAssertLessThanOrEqual
XCTAssertNil
这些只是常见的一些,它们相当直观——一个额外的优点是,每个都有一个可选的 "message" 参数,这允许你添加一个自定义字符串。这允许你更具体地说明发生了什么断言(在 CI/CD 世界中报告时理想)。
现在我们已经了解了如何在 Swift 中编写测试的基础知识,我们需要学习如何运行它们。有两种方法可以实现这一点:
- 首先,我们可以一次性运行我们班级中的所有测试。我们可以通过简单地点击类声明左侧的菱形来实现这一点:

图 6.16 – 类测试用例
- 如果我们想单独运行测试,我们只需选择我们单个测试用例旁边的图标,就像这样:

图 6.17 – 方法测试用例
如果一切按计划进行并且测试通过,我们会看到图标变成绿色:

图 6.18 – 方法通过测试用例
然而,如果我们的班级中有一个或多个测试失败,我们会看到图标变成红色:

图 6.19 – 方法失败测试用例
或者,CMD + U 的快捷键也会让 Xcode 运行与主项目关联的任何测试。记住,只有以单词 test 开头的函数才会被视为测试用例(不包括类名),所以如果你需要,可以在测试用例中添加一个私有函数。
接下来,让我们看看如何使用模拟数据在 Swift 中测试网络逻辑,以帮助我们:
- 我们将开始创建以下测试函数:
func testThatFetchRepoParsesSuccessfulData() { }
- 让我们先弄清楚我们将如何调用它。再一次,我们将利用我们的
viewControllerUnderTest变量:
func testThatFetchRepoParsesSuccessfulData() {
viewControllerUnderTest?.fetchRepos(forUsername: "", completionHandler: { (response) in
print("\(response)")
})
}
这按预期工作,但不幸的是,这并不简单——这会像我们的应用一样调用 API。如果我们想在代码中添加任何 XCAsserts,它们将不会被执行,因为我们的测试和函数已经完成并被拆解,而 API 没有机会响应。
- 要做到这一点,我们需要在我们的
viewControllerUnderTest中模拟一些对象,从URLSession和URLSessionDataTask开始。那么,为什么我们需要模拟这两个呢?让我们先看看我们在 CocoaTouch 应用中使用它们的地方:
let task = session.dataTask(with: request) { (data, response, error) in
在这里,我们通过模拟 URLSession 并使用其一个函数 URLSessionDataTask 来使用 URLSession。我们在这里创建了一个本地会话,然后可以使用它来调用我们的 MockURLSessionDataTask。所以,这里真正的疑问是,我们的 MockURLSessionDataTask 在做什么?我们使用这个来传递一些模拟数据——我们期望从 API 获得的数据——然后通过我们的逻辑运行这些数据。这保证了每次测试的完整性!
- 我们可以在自己的文件中创建以下输入,但为了现在,我们只需将其追加到我们的
CocoaTouchTests.swift文件底部。首先,让我们看看我们的MockURLSession:
class MockURLSession: URLSession {
override func dataTask(with request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask {
return MockURLSessionDataTask(completionHandler: completionHandler, request: request)
}
}
前面的函数相当直观——我们只是用以下 MockURLSessionDataTask 覆盖了 dataTask() 函数:
class MockURLSessionDataTask: URLSessionDataTask {
var completionHandler: (Data?, URLResponse?, Error?) -> Void
var request: URLRequest
init(completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void, request: URLRequest) {
self.completionHandler = completionHandler
self.request = request
super.init()
}
var calledResume = false
override func resume() {
calledResume = true
}
}
乍一看,这似乎有点复杂,但我们在这里真正做的只是添加我们自己的 completionHandler。这将允许它从我们的测试中同步调用(阻止我们的测试失控)。
让我们把所有这些都放在一起,然后回到我们的新测试中:
- 让我们先为我们的
viewControllerUnderTest设置MockURLSession。这是很棒且简单。现在,逐行添加以下内容:
func testThatFetchRepoParsesSuccessfulData() {
viewControllerUnderTest?.session = MockURLSession()
// ...
}
- 首先,让我们添加我们的主要
responseObject。这是我们将要对其执行 XCAsserts 的内容。将其声明为一个可选变量:
var responseObject: FetchReposResult?
- 现在,我们可以调用我们的函数,就像我们在本节前面尝试做的那样。然而,这次,我们将结果赋给一个变量,并将其转换为
MockURLSessionDataTask:
let result = viewControllerUnderTest?.fetchRepos(forUsername: "", completionHandler: { (response) in
responseObject = response
}) as? MockURLSessionDataTask
- 记住,我们可以为
userName变量传递任何我们想要的内容,因为我们不会调用 API。现在,让我们触发我们创建的完成处理程序,并强制通过我们的mockData:
result?.completionHandler(mockData, nil, nil)
我在前面代码中突出显示了 mockData 变量,因为我们需要将其添加到我们想要测试的 JSON 响应中。你可以通过简单地访问 GitHub URL 并将其复制到项目中一个新的、空文件中来实现这一点。我为我的用户名做了这件事,创建了一个名为 mock_Data.json 的文件:

图 6.20 – 添加空文件
记住,当你将文件保存到磁盘时,请选择 CocoaTouch 目标;否则,以下步骤将无法工作。
- 现在,在我们的
Test类中创建一个计算属性,它简单地读取文件并输出Data()对象:
var mockData: Data {
if let path = Bundle.main.path(forResource: "mock_Data", ofType: "json"), let contents = FileManager.default.contents(atPath: path){
return contents
}
return Data()
}
- 到目前为止,我们可以成功地将模拟数据通过我们的
fetchRepos函数传递,而不需要调用 API。我们现在需要做的就是编写一些断言:
switch responseObject {
case .success(let repos):
// Our test data had 3 repos, lets check that parsed okay
XCTAssertEqual(repos.count, 9)
// We know the first repo has a specific name... let's check that
XCTAssertEqual(repos.first?.name, "aerogear-ios-oauth2")
default:
// Anything other than success - failure...
XCTFail()
}
你在这里要测试的内容完全取决于你——它完全基于你选择的测试用例。有时,当你已经编写了一个函数时,思考要测试什么可能是一项艰巨的任务。作为一个开发者,你很容易对项目“过于亲近”。这就是测试驱动开发(TDD)发挥作用的地方,这是一种在编写任何代码之前先编写测试的方法。让我们看看这个,以及我们可以用它实现什么。
它是如何工作的...
测试网络逻辑可能会很麻烦。我发现问题总是出现,比如,你应该测试什么?到底在测试什么?然而,如果你能理解这一点,那么你就已经走上了理解单元测试核心基础的良好道路。
让我们尝试将其分解。我们想要测试的逻辑是我们的fetchRepos()函数。这很简单——我们只需用我们知道的一个仓库用户名调用它,并对返回的仓库列表编写一些 XCAsserts。
虽然这现在会工作,但当用户删除一个仓库时会发生什么?你的测试将失败。这不是好事,因为你的逻辑实际上并没有错误——只是数据是错误的,就像如果 API 因为内部服务器错误而返回一些格式不正确的 JSON 一样。这不是你代码的错——这是 API 的错,API 有责任确保它能够正常工作。
你所想要做的只是检查如果服务器给你一个特定的响应,包含特定的数据,你的逻辑会按照它所说的那样去做。那么,我们如何保证从 API 返回的数据的完整性?我们不能——这就是为什么我们模拟数据,并且实际上根本不调用服务。
还有更多...
TDD 是一种方法,包括在实际上编写你想要的函数之前先编写单元测试。有些人认为这是编写代码的唯一方法,而有些人则宣传在何时以及仅在必要时使用它。据记录,我属于后者,但我们不是来讨论理论的——我们在这里是为了学习如何使用 XCTest 在 Swift 中实现这一点。
回到我们的 CocoaTouch 应用,假设我们想要编写一个验证 UITextField 中空白字符的函数。执行以下步骤以实现这一点:
- 我们首先编写一个存根函数,它看起来可能像这样:
func isUserInputValid(withText text: String) -> Bool {
return false
}
通常,在这里,我会在我的函数中添加关于我想实现什么的注释,但为了 TDD,我们将采取相反的方式。
- 在
CocoaTouchTests.swift文件内部,添加以下测试:
func testThatTextInputValidatesWithSingleWhitespace() {
}
再次强调,以测试名称的字面描述为依据,我们将检查我们的函数是否正确地检测到 String() 中间的空白字符。
- 那么,让我们为当前函数编写一个测试:
func testThatTextInputValidatesWithSingleWhitespaces() {
let result = viewControllerUnderTest?.isUserInputValid(withText: "multiple white spaces")
XCTAssertFalse(result!)
}
- 有了这些,我们很高兴地确认我们在测试用例中设定的所有内容都已得到断言。现在,我们可以继续运行我们的测试。
如预期,我们的测试失败了,这有两个明显的原因。首先,我们并没有真正编写我们的函数,其次,我们将返回类型硬编码为 false。
我们实际上故意将返回类型硬编码为 false,我们这样做是因为 TDD 方法论分为三个阶段:
-
失败测试:完成了,我们做到了。
-
通过测试:可以像你喜欢的样子一样混乱。
-
重构代码:我们可以非常有信心地做到这一点。
策略是在编写单元测试的同时,涵盖所有可能需要的场景和断言,并使其失败(就像我们做的那样)。
在基础设置完成后,我们现在可以自信地转向我们的函数并编写代码,放心地知道我们能够运行测试来检查我们的函数是否损坏:
func isUserInputValid(withText text: String) -> Bool {
return !text.contains(" ")
}
这没有什么特别的,但为了本节的目的,它不需要特别处理。使用 Swift 进行 TDD 不必令人畏惧。毕竟,它只是与 XCTest 完美结合的方法论。
另请参阅
你可以在 developer.apple.com/documentation/xctest 找到更多关于单元测试的信息。
使用 XCUITest 进行用户界面测试
用户界面(UI)测试已经存在了一段时间。从理论上讲,任何使用、测试或检查应用的人每天都在进行,但在自动化方面,它也受到了不少批评。
然而,使用 Swift 和 XCTest,这从未如此简单,而且关于我们将如何实现这一点有一个惊人的隐藏好处。
准备工作
与单元测试不同,当我们测试一个函数、一段逻辑或算法时,用户界面测试正是其字面意义。这是我们测试应用 UI 和 UX 的方式——这些可能并不一定是由程序生成的。
直接前往在创建项目时自动生成的 CocoaTouchUITests.swift 文件。同样,就像单元测试一样,你会在其中注意到一些占位符函数。我们将首先查看一个名为 testExample() 的函数。
如何做到这一点...
考虑到我们在“准备”部分中提到的内容,让我们看看我们的应用并看看我们想测试什么。首先出现在我脑海中的是搜索栏:

图 6.21 – 搜索栏已选中
现在我们已经强制要求填充以使我们的应用工作,我们想确保这一点始终存在,所以让我们为这个编写一个 UI 测试:
func testExample() throws {
// UI tests must launch the application that they test.
let app = XCUIApplication()
app.launch()
}
正如注释正确指出的那样,为了使测试成功,应用程序需要启动,这是由launch()函数处理的。然而,一旦我们的应用程序启动,我们如何让它检查 UITextField,更重要的是,一个特定的 UITextField(将来,我们可以在屏幕上有多个)?
要做到这一点,我们必须从基础开始:
- 我已经编辑了函数的名称,使其更适用于我们在这里进行的测试。正如以下高亮代码所示,我们告诉测试选择
textFields元素并点击它:
func testThatUsernameSearchBarIsAvailable() throws {
let app = XCUIApplication()
app.launch()
app.textFields.element.tap()
}
- 点击左边的菱形来运行测试,看看你的应用程序在模拟器中如何生动起来。如果你够快,你会在应用程序关闭前看到光标进入文本框。
太棒了!测试通过了,这意味着你已经编写了你的第一个 UI 测试。
关于我们之前的测试,我们没有具体说明要识别的元素。目前来说,这没问题,但构建一个更大、更复杂的应用程序可能需要你测试特定元素的某些方面。让我们看看我们如何实现这一点:
-
一种方法是为我们的 UITextField 设置一个可访问性标识符——一个用于可访问性目的的特定标识符,反过来,这将允许我们的 UI 测试识别我们想要测试的控制项。
-
回到我们的
RepoTableViewController.swift文件,为相关的 UITextField 对象创建一个IBOutlet,并添加以下代码,记得将出口连接到你的 ViewController:
@IBOutlet weak var usernameTextField: UITextField! {
didSet {
usernameTextField.accessibilityIdentifier =
"input.textfield.username"
}
}
- 在设置好这些之后,将我们的通用
UTextField点击测试注释掉或替换为以下内容:
app.textFields.element(matching: .textField, identifier:
"input.textfield.username").tap()
- 现在,运行你的测试并观察它通过。太棒了!
注意我们正在识别一个textField,然后从textField中匹配一个控件类型。当我们在应用程序的特定视图中测试嵌套组件时,这种方法将非常有效。例如,你可能想要搜索并匹配一个你知道嵌入在特定的 UIScrollView 中的特定 UIButton:
app.scrollViews.element(matching: .button, identifier:
"action.button.stopscrolling").tap()
- 完成这些后,让我们将测试再进一步。注意我们在元素识别的末尾调用的
.tap()函数。有更多选项可供选择,但我们将从将元素拆分为其自己的变量开始:
let textField = app.textFields.element(matching: .textField,
identifier: "input.textfield.username")
- 注意我们已经移除了
.tap()函数。现在,我们可以通过textField变量简单地调用这个函数和其他任何可用的函数:
textField.tap()
textField.typeText("MrChrisBarker")
- 运行这个命令,自己看看效果。现在,如果我们再进一步呢?添加以下行,然后再次运行代码:
app.keyboards.buttons["return"].tap()
希望到这一点,你能够看到我们的方向。需要注意的是,由于我们这里没有模拟数据,我们正在进行一个实时、异步的 API 调用,这取决于你的连接速度或 API,可能会因测试而异。
- 为了检查结果,我们需要我们的 UI 测试“等待”一个特定元素进入视图。按照设计,我们知道我们期望的是一个带有填充单元格的
UITableView,因此让我们基于这一点编写我们的测试:
let tableView = app.tables.staticTexts["XcodeValidateJson"]
XCTAssertTrue(tableView.waitForExistence(timeout: 5))
上一段代码的第一行现在应该对我们来说已经很熟悉了——我们正在基于 UITableView 中的单元格构建一个元素(当时我们没有具体指定)来查找具有XcodeValidateJson标签的特定单元格。
然后,我们对这个元素执行 XCAssert。为此,我们允许有 5 秒的超时时间来等待这个元素出现。如果它提前出现,测试将通过;如果没有,测试将失败。
还有更多...
到目前为止,我们已经看到了如何在与应用交互时使用.tap()和.typeText()等函数。然而,这些并不是UIButton到UITextField的标准函数。当我们识别控件时,我们得到的返回类型是XCUIElement()。
我们有更多选项可以使用来增强我们的 UI 测试,从而允许进行复杂而有价值的自动化测试。让我们看看我们可用的额外选项:
tap()
doubleTap()
press()
twoFingerTap()
swipeUp()
swipeDown()
swipeLeft()
swipeRight()
pinch()
rotate()
每个测试都带有额外的参数,允许你具体化并覆盖应用中用户体验的所有方面(例如,press()有一个持续时间参数)。
在本节的开头,我提到 UI 测试带来了一个很大的额外好处,这是我们之前已经看到的东西:可访问性。可访问性是构建移动应用时的重要因素,苹果通过 Xcode 和 Swift 编程语言为我们提供了最好的工具来做这件事。然而,从理论的角度来看,如果你花时间将我们的可访问性构建到你的应用中,你间接地使构建和调整 UI 测试围绕这些标识符变得容易得多——几乎为你完成了 50%的工作——同时包括一个惊人的功能。
或者,编写一个好的 UI 测试可以提高你应用的可访问性,使你在构建应用时一个可以很好地补充另一个变得非常容易。
另请参阅
你可以在developer.apple.com/documentation/xctest/xcuielement找到更多关于XCUITest的信息。
向后兼容性
向后兼容性是不可避免的。除非你为 iOS 的最新版本构建应用并计划只支持那个版本,否则你将不得不在某个时候处理向后兼容性。在这个菜谱中,我们将看看苹果为构建使用旧版 Swift 构建的 API 提供了什么。
我们还将探讨从 Swift 的先前版本迁移的选项,以及是否以及如何将遗留项目更新到最新版本。
如何做到这一点...
我们都希望在我们的应用程序中使用最新的闪亮功能。幸运的是,苹果通过使用 #available 检查使我们能够相对容易地处理这个问题。那么,这是怎么工作的呢?好吧,主要来说,它可以在三个层面上工作:在函数级别、在类级别和在内联 API 级别。
让我们从后者开始,看看我们如何在 API 级别做这件事:
- 这里是一个在
UIView()中设置maskedCorners的示例:
UIView().layer.maskedCorners = [.layerMinXMaxYCorner,
.layerMaxXMaxYCorner]
这对于 iOS 11 及以上版本的 API 是标准的,但如果你支持 iOS 会怎样呢?在一个理想的世界里,你可能只想支持两个 iOS 版本(包括当前版本),但这并不总是可能的。例如,在一些零售应用程序中,你可能需要支持一定比例的现有客户。这种情况在现实世界中也会发生。
因此,如果你的 Xcode 项目已经设置为支持 iOS 10,你实际上会得到一个生成的错误,类似于以下内容:

图 6.22 – 可用 API 错误
- 点击左侧的红色指示器,你会看到以下选项:

图 6.23 – 可用 API 错误选项
如我们之前提到的,这里有三种选择:对 API 本身添加版本检查、对方法添加检查,或者封装整个类。
- 点击第一个选项的“修复”。你会看到以下内容:
if #available(iOS 11.0, *) {
UIView().layer.maskedCorners = [.layerMinXMaxYCorner,
.layerMaxXMaxYCorner]
} else {
// Fallback on earlier versions
}
这里,我们被赋予了编译针对特定 iOS 版本的 API 的选项,允许我们使用该 API,并在需要时使用回退或应急 API。
这是一种很好的方式,可以了解 iOS 所做的最新更改,并保持你的代码库新鲜。然而,这也可能带来一些缺点。例如,如果你针对的 API 是针对特定功能的,你可能会发现自己很难找到一个合适的回退方案(或者更糟,不得不依赖第三方库)。你还必须考虑测试——当你可能只需要对 iOS 的早期版本进行轻量级回归测试时,你实际上可能需要加倍测试努力。现在,你必须确保某些功能在多个版本上进行测试。
- 接下来,让我们看看封装实例方法,它允许我们将整个函数围绕特定的版本检查进行包装:
@available(iOS 11.0, *)
func availableCheck() {
UIView().layer.maskedCorners = [.layerMinXMaxYCorner,
.layerMaxXMaxYCorner]
}
如我们所见,我们的函数及其内容保持不变——我们只是用高亮显示的更改装饰了函数。
- 这一切都很不错,但让我们尝试从其他地方调用这个函数:

图 6.24 – 可用方法错误选项
对了——我们遇到了之前遇到过的同样的问题,但这确实有一些优点。例如,如果你的函数依赖于大量具有较高 API 级别的代码——这将是一个维护和管理代码库的好方法——当需要升级到更高 SDK 支持时,重构就不会成为一项艰巨的任务。
- 最后的“封装类”选项遵循与方法类似的方法,但这是在类级别发生的。你的类只是这样装饰的:
@available(iOS 11.0, *)
class ReposTableViewController: UITableViewController { }
更多内容...
自从 2013 年发布以来,Swift 已经以多种形态和大小出现,每个新版本都提供了更广泛的 API 和稳定性。向开源的转变也在其中发挥了巨大作用,但每次发布从一个版本迁移到另一个版本通常都得到了 Xcode 迁移工具的帮助。
但要小心:你不能简单地拿一个用 Swift 1.1 构建的应用程序,并使用 Xcode 12 将其迁移到 Swift 5.3(尽管这听起来很美好...)。
从 Swift 3.0 开始的每个版本,都允许你通过迁移工具进行迁移。例如,Xcode 9 会让你从Swift 2.2迁移到Swift 3,Xcode 10 会让你从Swift 3迁移到Swift 4,依此类推。
这并不意味着你必须使用最新版本的 Xcode 来支持 Swift 的最新版本——升级选项也具有向后兼容性。
例如,我们的 CocoaTouch 项目使用Swift 5,但通过 Xcode 的构建设置,可以使用Swift 4.2和Swift 4的选项:

图 6.25 – 选择 Swift 语言版本选项
如果你需要回溯到更早的版本,你将不得不从苹果开发者门户下载 Xcode 的早期版本。通常情况下,多个版本的 Xcode 可以很好地协同工作,尽管这只有在 Xcode 9 及以后的版本中才真正得到支持——你已经收到警告了。
参见
你可以在www.javatpoint.com/history-of-swift找到有关Swift 版本历史的更多信息。
Swift 游乐场
在整本书中,我们一直在使用 Swift 游乐场来处理代码示例,以探索 Swift 语言。游乐场非常适合这种用途,因为它们允许你在不需要 iOS、macOS 或 tvOS 应用程序的基础设施的情况下探索代码和框架 API。
它们的功能超出了我们在本书中迄今为止的使用,在本章中,我们将探索其中的一些功能,从使用额外的代码和资源到创建完全交互式的体验。
在本章中,我们将介绍以下食谱:
-
使用 Swift 游乐场进行 UI 开发
-
将资源导入游乐场
-
将代码导入游乐场
-
多页游乐场
-
在 iPadOS 上使用 Swift 游乐场
第八章:技术要求
本章的所有代码都可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/Swift-Cookbook-Second-Edition/tree/master/Chapter07
查看以下视频以查看代码的实际效果:bit.ly/37t4f0A。
使用 Swift 游乐场进行 UI 开发
我们可以使用游乐场来实验 UI 并测试自定义视图和界面。在本食谱中,我们将构建一个条形图视图,我们可以用它以图表形式显示数值数据,并使用游乐场来测试它。
准备工作
首先,我们将创建一个基于 iOS 的游乐场来构建我们的条形图。在第一章《Swift 基础》中,我们学习了如何创建一个新的游乐场,所以如果你需要复习,请回到那里。
我们将创建一个自定义视图,以条形图的形式显示信息,并使用它来测试游乐场的一些功能。你可以将以下代码输入到一个新的 iOS 游乐场中,或者从本书的 GitHub 仓库下载名为 Simple_iOS.playground 的游乐场:
- 创建一个
Color结构体:
import UIKit
struct Color {
let red: Float
let green: Float
let blue: Float
let alpha: Float = 1.0
var displayColor: UIColor {
return UIColor(red: CGFloat(red),
green: CGFloat(green),
blue: CGFloat(blue),
alpha: CGFloat(alpha))
}
}
- 创建一个
Bar结构体和BarView:
struct Bar {
var value: Float
var color: Color
}
class BarView: UIView {
init(frame: CGRect, color: UIColor) {
super.init(frame: frame)
backgroundColor = color
}
required init?(coder: NSCoder) {
super.init(coder: coder)
backgroundColor = .red
}
}
- 创建
BarChart视图:
class BarChart: UIView {
private var barViews: [BarView] = []
private var maxValue: Float = 0.0
var interBarMargin: CGFloat = 5.0
var bars: [Bar] = [] {
didSet {
self.barViews.forEach { $0.removeFromSuperview() }
var barViews = [BarView]()
let barCount = CGFloat(bars.count)
// Calculate the max value before calculating size
for bar in bars {
maxValue = max(maxValue, bar.value)
}
var xOrigin: CGFloat = interBarMargin
let margins = interBarMargin * (barCount+1)
let width = (frame.width - margins) / barCount
for bar in bars {
let height = barHeight(forValue: bar.value)
let rect = CGRect(x: xOrigin,
y: bounds.height - height,
width: width,
height: height)
let view = BarView(frame: rect,
color: bar.color.displayColor)
barViews.append(view)
addSubview(view)
xOrigin = view.frame.maxX + interBarMargin
}
self.barViews = barViews
}
}
private func barHeight(forValue value: Float) -> CGFloat {
return (frame.size.height / CGFloat(maxValue)) *
CGFloat(value)
}
}
如何操作...
在我们定义的 准备 部分的代码中,可以使用框架和背景颜色创建 BarChart 视图,然后可以通过包含值和颜色的 Bar 结构体以条形的形式添加条。BarChart 视图使用这些信息来创建正确相对大小和比例的子视图,以表示条形的值。
让我们编写一些代码来利用我们的 BarChart 视图:
- 在游乐场的底部输入以下内容:
let barView = BarChart(frame: CGRect(x: 0, y: 0, width: 300, height:
300))
barView.backgroundColor = .white
let bar1 = Bar(value: 20, color: Color(red: 1, green: 0, blue: 0))
let bar2 = Bar(value: 40, color: Color(red: 0, green: 1, blue: 0))
let bar3 = Bar(value: 25, color: Color(red: 0, green: 0, blue: 1))
barView.bars = [bar1, bar2, bar3]
-
按下游乐场窗口左下角的蓝色播放按钮来执行代码。随着代码的执行,你将看到游乐场侧边栏充满了信息。
-
在第一章《Swift 构建块》中,我们了解到游乐场有一个时间轴,它提供了关于每行执行的信息。当你将光标移过某行时,你会看到一个眼睛形状的图标,它将显示该行执行的结果预览。如果该行涉及 UI 元素,如
view,游乐场将渲染该视图并在预览框中显示它:

图 7.1 – 柱状图预览框
- 这同样适用于通过按时间轴中的方形按钮获得的固定内联预览:

图 7.2 – 柱状图内联预览
这些功能对于测试和调整视图代码非常有用。
如果游乐场的目的是演示或实验自定义视图组件,并且你希望获得更突出的视图输出,你可以使用游乐场的实时视图功能:
- 在游乐场的顶部导入
PlaygroundSupport框架:
import PlaygroundSupport
PlaygroundSupport框架提供了一系列功能,用于访问游乐场的各种功能。
- 添加以下内容以设置我们的
BarChart视图为游乐场的实时视图:
PlaygroundPage.current.liveView = barView
- 如果游乐场的实时视图不可见,你可以从菜单中显示它。转到编辑器 | 实时视图:

图 7.3 – 实时视图
当游乐场中的代码发生变化时,此视图将更新。尝试更改柱子的值,看看视图如何变化。
它是如何工作的...
游乐场的实时视图可以是任何符合PlaygroundLiveViewable的组件。在 iOS 上,UIView和UIViewController都符合PlaygroundLiveViewable,macOS 上的等效组件NSView和NSViewController也是如此。
在前面的代码中,我们构建了一个BarChart,它是一个UIView,并将其分配给当前PlaygroundPage的liveView属性。
这些实时视图对触摸事件的响应就像在 macOS 应用程序或 iOS 模拟器中一样。因此,你可以使用它们来测试交互式视图和控制。
不幸的是,游乐场目前不支持界面构建器布局文件,即.xibs和.storyboard文件。因此,为了使用带有自定义视图的游乐场,你必须以编程方式布局你的视图。
请注意,基于 iOS 的游乐场支持 iOS SDK 中许多但不是所有框架,因此这可能会限制你在游乐场中能做的事情。
还有更多...
在前面的示例中,以及本书的大部分内容中,我们专注于基于 iOS 的游乐场。然而,基于 macOS 的游乐场对于 macOS 平台同样有用,也可以用于 UI 测试和实验。
您可以在本书的 GitHub 仓库中找到一个名为Simple_macOS.playground的基于 macOS 的游乐场,它也创建了一个简单的条形图视图。github.com/PacktPublishing/Swift-Cookbook-Second-Edition/tree/master/Chapter07/01_Using_Swift_Playgrounds_for_UI
或者,您也可以创建一个新的基于 macOS 的游乐场,并输入以下代码:
- 创建一个
Color结构体:
import PlaygroundSupport
import Cocoa
struct Color {
let red: CGFloat
let green: CGFloat
let blue: CGFloat
let alpha: CGFloat = 1.0
var displayColor: NSColor {
return NSColor(calibratedRed: red,
green: green,
blue: blue,
alpha: alpha)
}
}
- 创建一个
Bar结构体和BarView:
struct Bar {
var value: Float
var color: Color
}
class BarView: NSView {
let color: NSColor
init(frame: NSRect, color: NSColor) {
self.color = color
super.init(frame: frame)
}
required init?(coder: NSCoder) {
self.color = .red
super.init(coder: coder)
}
override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect)
color.set()
NSBezierPath.fill(dirtyRect)
}
}
- 创建
BarChart视图:
class BarChart: NSView {
let color: NSColor
init(frame: NSRect, color: NSColor) {
self.color = color
super.init(frame: frame)
}
required init?(coder: NSCoder) {
self.color = .white
super.init(coder: coder)
}
var bars: [Bar] = [] {
didSet {
self.barViews.forEach { $0.removeFromSuperview() }
var barViews = [BarView]()
let barCount = CGFloat(bars.count)
// Calculate the max value before calculating size
for bar in bars {
maxValue = max(maxValue, bar.value)
}
var xOrigin: CGFloat = interBarMargin
let margins = interBarMargin * (barCount+1)
let width = (frame.width - margins) / barCount
for bar in bars {
let height = barHeight(forValue: bar.value)
let rect = NSRect(x: xOrigin,
y: 0,
width: width,
height: height)
let view = BarView(frame: rect,
color: bar.color.displayColor)
barViews.append(view)
addSubview(view)
xOrigin = rect.maxX + interBarMargin
}
self.barViews = barViews
}
}
var interBarMargin: CGFloat = 5.0
private var barViews: [NSView] = []
private var maxValue: Float = 0.0
override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect)
color.set()
NSBezierPath.fill(dirtyRect)
}
private func barHeight(forValue value: Float) -> CGFloat {
return (frame.size.height / CGFloat(maxValue)) *
CGFloat(value)
}
}
- 使用
BarChart来显示信息:
let frame = CGRect(x: 0, y: 0, width: 300, height: 300)
let barView = BarChart(frame: frame,
color: .white)
PlaygroundPage.current.liveView = barView
let bar1 = Bar(value: 20, color: Color(red: 1, green: 0, blue: 0))
let bar2 = Bar(value: 40, color: Color(red: 0, green: 1, blue: 0))
let bar3 = Bar(value: 25, color: Color(red: 0, green: 0, blue: 1))
barView.bars = [bar1, bar2, bar3]
PlaygroundPage.current.liveView = barView
我们自定义的barView在 macOS 版本上与 iOS 版本完全相同,基于 macOS 的游乐场的实时视图与其基于 iOS 的对应版本完全相同。
与 iOS 类似,基于 macOS 的游乐场支持许多,但不是所有在 macOS SDK 中可用的框架,因此这可能会限制您在游乐场中能做的事情。
希望您可以从本食谱中看到,Swift Playgrounds 对于在 iOS 和 macOS 上查看 UI 实验确实非常有用。
相关内容
Apple 的 Playground Support 框架参考可以在swiftbook.link/docs/playgroundsupport找到。
将资源导入到游乐场中
在构建应用程序时,我们通常会需要包含资源,例如图片。我们如何才能在游乐场中做到这一点,以便我们的 UI 可以包含这些图片?这正是本食谱将要探讨的内容。
我们将通过在上一个食谱中的条形图自定义视图中添加半透明图像来提供纹理,来改进我们的条形图自定义视图。
准备工作
对于这个食谱,我们将从上一个食谱中的游乐场开始。这个游乐场的名字是Simple_iOS.playground,您可以从本书的 GitHub 仓库中获取它。github.com/PacktPublishing/Swift-Cookbook-Second-Edition/tree/master/Chapter07/01_Using_Swift_Playgrounds_for_UI
在本食谱中,我们将使用半透明纹理图像。您可以提供自己的图像,或者从这里下载一个示例:github.com/PacktPublishing/Swift-Cookbook-Second-Edition/tree/master/Chapter07/02_Import_Resources_into_Playgrounds/EmbeddedResource.playground/Resources
如何操作...
让我们看看以下步骤,了解如何将我们的图片添加到游乐场中:
- 我们需要打开 Xcode 的项目导航器,通常在打开游乐场时默认不可见。要显示项目导航器,从菜单中选择视图 | 导航器 | 项目。或者,您也可以选择 Xcode 窗口左上角的最左侧面板展开按钮:

图 7.4 – 项目导航器
Playgrounds 将列在项目导航器的顶部,与展开三角形一起。
-
选择三角形以显示名为
Sources和Resources的文件夹。 -
将纹理图像从 Finder 拖入
Resources文件夹:

图 7.5 – 向项目中添加文件
现在我们已经将纹理图像嵌入到我们的 Playground 中,我们需要使用它。我们希望条形图中的每个条都有可设置的颜色,但纹理要放在这个颜色之上。
- 更新我们代码中的
BarView部分以使用纹理图像:
class BarView: UIView {
init(frame: CGRect, color: UIColor) {
super.init(frame: frame)
backgroundColor = color
setupTexture()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
backgroundColor = UIColor.red
setupTexture()
}
private func setupTexture() {
guard let textureImage = UIImage(named: "texture") else {
return }
let textureColor = UIColor(patternImage: textureImage)
let frame = CGRect(origin: .zero, size: bounds.size)
let textureView = UIView(frame: frame)
textureView.backgroundColor = textureColor
addSubview(textureView)
}
}
- 我们可以通过引用图像的文件名(不包含文件扩展名)在
UIImage初始化器中检索图像,就像在完整的应用程序中做的那样:
let textureImage = UIImage(named: "texture")
当 Playground 执行时,你会看到我们的条形图看起来要有趣得多:

图 7.6 – 纹理条形图
它是如何工作的...
要了解我们添加的图像存储在哪里,了解 Playgrounds 的结构很有帮助。
实际上,Playground 是一个文件夹,但具有.playground文件扩展名。我们可以通过右键单击或按住Ctrl键单击文件来显示上下文菜单。从该菜单中选择“显示包内容”:

图 7.7 – 查看包内容
这将打开 Playground 作为文件夹,显示其内容。在那里,你会找到许多文件,包括一个名为Contents.swift的文件,它包含执行代码的 Swift 文件。还有一个名为Resources的文件夹,其中包含我们导入的纹理图像:

图 7.8 – 包内容
通过将图像拖入文件导航器,Xcode 创建了此文件夹并将图像放入其中。或者,我们也可以手动创建此文件夹并将图像放入其中。Playgrounds 会寻找名为Resources的文件夹,并将其中所有资源都提供给 Playground。
参见
本食谱的结果可在本书的 GitHub 仓库中找到,位于chapter 7文件夹下的EmbeddedResources.playground。
将代码导入 Playgrounds
正如我们在本章和本书中看到的,Playgrounds 是探索 API、框架和自定义代码的绝佳画布。然而,如果你想探索你自己的代码的用途,似乎你需要将所有需要的代码都包含在 Playground 中,这可能会使它变得很长且难以管理。
不一定需要这样。在本食谱中,我们将看到如何将 Swift 代码嵌入到 Playground 中,并从 Playground 代码中使用它。
准备工作
对于这个菜谱,我们将使用上一个菜谱中的游乐场,名为 EmbeddedResources.playground,可以从本书的 GitHub 仓库中检索到,网址为 github.com/PacktPublishing/Swift-Cookbook-Second-Edition/tree/master/Chapter07/02_Import_Resources_into_Playgrounds。
如何操作...
我们将把 BarChart 自定义视图和相关代码移动到游乐场中嵌入的单独文件中,这样我们就可以自由地使用游乐场来实验我们的自定义视图:
-
如果游乐场的项目导航器不可见,请从菜单中选择视图 | 导航器 | 项目。
-
选择
Sources文件夹,然后从菜单中选择文件 | 新建 | 文件来在你的**Sources**文件夹中创建一个新的 Swift 文件:

图 7.9 – Sources 文件夹
-
如果你已经有想要嵌入到游乐场中的 Swift 文件,你可以像我们在上一个菜谱中处理纹理图像那样,将这些文件拖拽到
Sources文件夹中。 -
将新文件
Color.swift重命名为Color,因为我们将会使用这个文件来存放当前主游乐场内容中的Color结构体。 -
将以下代码输入到
Color.swift文件中:
import UIKit
public struct Color {
let red: Float
let green: Float
let blue: Float
let alpha: Float
public init(red:Float, green:Float, blue:Float, alpha:Float = 1) {
self.red = red
self.green = green
self.blue = blue
self.alpha = alpha
}
var displayColor: UIColor {
return UIColor(red: CGFloat(red),
green: CGFloat(green),
blue: CGFloat(blue),
alpha: CGFloat(alpha))
}
}
注意,我们已经为 Color 结构体及其初始化器添加了 public 访问控制;随着我们的进展,我们将了解更多关于这一点。
- 如同我们之前所做的那样,在
Sources文件夹中创建另一个新的 Swift 文件,命名为BarChart.swift,然后输入定义BarChart自定义视图所需的其余代码,从Bar结构体开始:
import UIKit
public struct Bar {
var value: Float
var color: Color
public init(value: Float, color: Color) {
self.value = value
self.color = color
}
}
这之后是 BarView:
class BarView: UIView {
init(frame: CGRect, color: UIColor) {
super.init(frame: frame)
backgroundColor = color
setupTexture()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
backgroundColor = UIColor.red
setupTexture()
}
private func setupTexture() {
guard let textureImage = UIImage(named: "texture") else {
return }
let textureColor = UIColor(patternImage: textureImage)
let textureView = UIView(frame: bounds)
textureView.backgroundColor = textureColor
addSubview(textureView)
}
}
最后,我们有 BarChart:
public class BarChart: UIView {
private var barViews: [BarView] = []
private var maxValue: Float = 0.0
var interBarMargin: CGFloat = 5.0
public var bars: [Bar] = [] {
didSet {
self.barViews.forEach { $0.removeFromSuperview() }
var barViews = [BarView]()
let barCount = CGFloat(bars.count)
// Calculate the max value before calculating size
for bar in bars {
maxValue = max(maxValue, bar.value)
}
var xOrigin: CGFloat = interBarMargin
let margins = interBarMargin * (barCount+1)
let width = (frame.width - margins) / barCount
for bar in bars {
let height = barHeight(forValue: bar.value)
let rect = CGRect(x: xOrigin,
y: bounds.height - height,
width: width,
height: height)
let view = BarView(frame: rect,
color: bar.color.displayColor)
barViews.append(view)
addSubview(view)
xOrigin = view.frame.maxX + interBarMargin
}
self.barViews = barViews
}
}
private func barHeight(forValue value: Float) -> CGFloat {
return (frame.size.height / CGFloat(maxValue))*
CGFloat(value)
}
}
- 在
Sources文件夹中包含的BarChart实现代码,游乐场内容可以仅用于实验BarChart自定义视图。移除我们放在其他文件中的代码,你将得到以下内容:
import PlaygroundSupport
import UIKit
let barView = BarChart(frame: CGRect(x: 0, y: 0, width: 300, height:
300))
barView.backgroundColor = .white
let bar1 = Bar(value: 20, color: Color(red: 1, green: 0, blue: 0))
let bar2 = Bar(value: 40, color: Color(red: 0, green: 1, blue: 0))
let bar3 = Bar(value: 25, color: Color(red: 0, green: 0, blue: 1))
barView.bars = [bar1, bar2, bar3]
PlaygroundPage.current.liveView = barView
工作原理...
在将 BarChart 实现移动到嵌入的 Swift 文件中时,我们在想要从游乐场内容中访问的地方添加了 public 访问控制。这是因为 Sources 文件夹中的代码在访问控制方面充当一种轻量级模块。
任何具有默认 internal 访问控制的代码仅对 Sources 文件夹内的其他代码可访问。为了使其对主游乐场内容中的代码可访问,它需要声明为 public 或 open。这非常有用,因为它允许你控制暴露给游乐场内容的内容;因此,你可以提供设计良好的 API,而不暴露底层复杂性。
如果你需要复习访问控制或想了解更多,请查看第二章 掌握构建块 中名为 使用访问控制控制访问 的菜谱。
参见
本菜谱的结果可以在本书 GitHub 仓库的EmbeddedSources.playground中找到。
关于 Swift 访问控制的更多信息可以在第二章,掌握构建块中找到。
多页面游乐场
我们已经讨论了游乐场如何成为探索 API 和实验 UI 的强大工具。然而,游乐场也可以用于记录 API,并提供丰富、可链接的内容。Swift 游乐场在注释和多个页面内容中提供了对丰富文本格式的支持,我们将在本菜谱中探索这些功能。
准备工作
我们将从上一个菜谱中使用的游乐场开始,该游乐场显示了我们的自定义BarChart视图。你可以从本书的 GitHub 仓库中获取游乐场,名为EmbeddedSources.playground,网址为github.com/PacktPublishing/Swift-Cookbook-Second-Edition/tree/master/Chapter07。
我们将使用我们的BarChart视图来显示三种不同的加密货币在 2020 年 1 月至 2020 年 6 月之间的 6 个月期间的价格(美元)。我们可以在不同的游乐场页面上显示每种类型的货币。
如果你想了解更多关于加密货币的信息,你可以观看这个解释视频:
swiftbook.link/videos/cryptocurrencies。
如何做到这一点...
默认情况下,游乐场只有一个 Swift 内容文件,但为了我们的目的,我们想在游乐场中有三个页面,每个页面对应我们将要记录的三种加密货币:比特币、以太坊和莱特币。让我们开始吧:
-
如果项目导航器不可见,你应该通过菜单选择视图 | 导航器 | 项目来使其可见。
-
要创建一个新的游乐场页面,你可以点击项目导航器左下角的加号按钮,或者从菜单中选择文件 | 新建 | 游乐场页面:

图 7.10 – 新游乐场页面
当创建一个新的游乐场页面时,现有的游乐场内容将成为一个游乐场页面,并且会创建另一个空白页面:

图 7.11 – 在游乐场中创建新页面
- 创建总共三个页面,因为我们将在三个不同的加密货币上显示数据,并将它们重命名为以下名称:
-
Bitcoin -
Etherium -
Lightcoin
每个页面都可以使用我们在上一个菜谱中添加到“源”文件夹中的BarChart代码,因此我们可以在每个页面上创建一个BarChart视图来绘制每种货币的价值。
- 将以下代码输入到
Bitcoin游乐场页面:
import PlaygroundSupport
import UIKit
let frame = CGRect(x: 0, y: 0, width: 300, height: 300)
let barView = BarChart(frame: frame)
barView.backgroundColor = .white
let green = Color(red: 0, green: 1, blue: 0)
let jan2020 = Bar(value: 9388.88, color: green)
let feb2020 = Bar(value: 8639.59, color: green)
let mar2020 = Bar(value: 6483.74, color: green)
let apr2020 = Bar(value: 8773.11, color: green)
let may2020 = Bar(value: 9437.05, color: green)
let jun2020 = Bar(value: 9164.54, color: green)
barView.bars = [jan2020,
feb2020,
mar2020,
apr2020,
may2020,
jun2020]
PlaygroundPage.current.liveView = barView
- 接下来,将以下代码输入到
Etherium游乐场页面:
import PlaygroundSupport
import UIKit
let frame = CGRect(x: 0, y: 0, width: 300, height: 300)
let barView = BarChart(frame: frame)
barView.backgroundColor = .white
let blue = Color(red: 0, green: 0, blue: 1)
let jan2020 = Bar(value: 181.73, color: blue)
let feb2020 = Bar(value: 223.5, color: blue)
let mar2020 = Bar(value: 133.76, color: blue)
let apr2020 = Bar(value: 209.42, color: blue)
let may2020 = Bar(value: 245.76, color: blue)
let jun2020 = Bar(value: 225.71, color: blue)
barView.bars = [jan2020,
feb2020,
mar2020,
apr2020,
may2020,
jun2020]
PlaygroundPage.current.liveView = barView
- 最后,将以下代码输入到
Lightcoin游乐场页面:
import PlaygroundSupport
import UIKit
let frame = CGRect(x: 0, y: 0, width: 300, height: 300)
let barView = BarChart(frame: frame)
barView.backgroundColor = .white
let red = Color(red: 1, green: 0, blue: 0)
let jan2020 = Bar(value: 67.58, color: red)
let feb2020 = Bar(value: 58.09, color: red)
let mar2020 = Bar(value: 39.13, color: red)
let apr2020 = Bar(value: 46.19, color: red)
let may2020 = Bar(value: 44.23, color: red)
let jun2020 = Bar(value: 41.21, color: red)
barView.bars = [jan2020,
feb2020,
mar2020,
apr2020,
may2020,
jun2020]
PlaygroundPage.current.liveView = barView
每个页面在运行时都会以条形图的形式显示价值历史,你可以使用项目导航器在它们之间切换。
它是如何工作的...
就像我们在之前的菜谱中做的那样,我们可以查看游乐场内部,看看每个游乐场页面是如何表示的。右键点击游乐场,或者按住 Ctrl 键点击它。从该菜单中选择显示包内容:

图 7.12 – 查看包内容
你会看到我们之前看到的Contents.swift文件夹已经被一个包含三个.xcplaygroundpage文件的文件夹所取代,每个文件对应游乐场中的一个页面:

图 7.13 – 提取的内容
这些.playgroundpage文件本质上就是一个游乐场。你可以右键点击.playgroundpage并选择显示包内容,你将看到我们之前看到的相同的游乐场结构。就像正常的游乐场一样,.xcplaygroundpage文件可以包含Sources和Resources子文件夹,将代码和资源放在这些文件夹中,将使它们仅对该页面可见。
还有更多...
由于我们现在可以添加多页内容,并且有嵌入代码和资源的能力,Swift 游乐场在交互式代码文档方面看起来非常有用。为了帮助这个用例,如果我们能对我们的注释的展示有所控制那就太好了;实际上我们可以,因为游乐场支持 Markdown 注释。
Markdown 是一种轻量级文本格式化语法,由John Gruber发明,广泛用于编写可以以富文本格式渲染的文本。有关 Markdown 的更多详细信息,请参阅swiftbook.link/markdown/docs。
我们不会深入 Markdown 语法,但你可以在swiftbook.link/markdown/cheatsheet找到一个有用的速查表。
在我们的游乐场中,通过选择菜单视图 | 检查器 | 文件打开文件检查器窗口,然后在游乐场设置下查看。你会看到一个渲染文档的选项。确保在我们编写一些 Markdown 注释时,这个选项是关闭的:

图 7.14 – 游乐场设置
现在让我们在我们的Bitcoin页面上添加一些注释:
/*:
# Crypto Currencies
## Bitcoin
*/
import PlaygroundSupport
import UIKit
/*:
### Usage
* Create Bar Chart
* Create Bars and add to chart
* Make Bar Chart the LiveView
*/
let barView = BarChart(frame: CGRect(x: 0, y: 0, width: 300, height:
300))
barView.backgroundColor = .white
let green = Color(red: 0, green: 1, blue: 0, alpha: 1.0)
/*:
* Note:
Bitcoin Price (in USD)
- Jan 2017 - $970.17
- Feb 2017 - $960.05
- Mar 2017 - $1203.02
- Apr 2017 - $1076.90
- May 2017 - $1390.24
- Jun 2017 - $2414.11
Taken from [Statista](https://www.statista.com/statistics/326707/bitcoin-price-index)
*/
let jan2017 = Bar(value: 970.17, color: green)
let feb2017 = Bar(value: 960.05, color: green)
let mar2017 = Bar(value: 1203.02, color: green)
let apr2017 = Bar(value: 1076.90, color: green)
let may2017 = Bar(value: 1390.24, color: green)
let jun2017 = Bar(value: 2414.11, color: green)
barView.bars = [jan2017, feb2017, mar2017, apr2017, may2017, jun2017]
PlaygroundPage.current.liveView = barView
为了让游乐场知道你的注释包含 Markdown 格式,在注释块的开始后添加一个冒号,:。这对于多行注释/*:和单行注释//:都适用。
在这些注释到位后,让我们将渲染文档功能重新打开,看看注释看起来如何:

图 7.15 – 渲染的文档
此外,游乐场还支持在游乐场页面之间创建 Markdown 链接;你可以使用@next链接到下一页,使用@previous链接到上一页。所以,在 Markdown 中,链接将如下所示:
//: Next page
//: Previous page
将添加 Markdown 注释到其他两个页面并建立页面之间的链接,留作读者的练习。
参见
本食谱的结果可以在本书的 GitHub 仓库中的MultiplePages.playground文件找到,网址为github.com/PacktPublishing/Swift-Cookbook-Second-Edition/tree/master/Chapter07/04_Multi-Page_Playgrounds/MultiplePages.playground。
有关 Markdown 语法的更多信息可以在swiftbook.link/markdown/docs找到。
在 iPadOS 上使用 Swift Playgrounds
2016 年,苹果发布了一个仅适用于 iPad 的应用程序,名为 Swift Playgrounds。Swift Playgrounds for iPadOS 借鉴了 Xcode 中 Playgrounds 的成功,并进一步为应用程序添加了一个额外的教育元素。2020 年,苹果还发布了一个 macOS 版本,为教育目的以及那些可能对 Xcode IDE 感到有些畏惧的初学者打开了大门。
在这个食谱中,我们将探讨如何在 iPad 上更轻松地复制类似于第六章中“使用 Swift 构建 iOS 应用程序”的食谱。
准备工作
对于这个食谱,你需要一台运行 iOS 14.0 的 iPad 来从 App Store 下载 Swift Playgrounds,或者你也可以从 Mac App Store 下载 macOS 版本,并跟随这个食谱进行操作。
如何操作...
- 我们将首先从你的设备启动 Swift Playgrounds 应用程序。你应该会看到以下内容:

图 7.16 – iPadOS 上的 Swift Playgrounds
-
接下来,你可以选择滚动浏览“获取游乐场”轮播图,直到看到空白页面,或者点击左上角靠近“位置”的新文档图标。
-
将创建一个新的文档并将其添加到你的游乐场中。点击它以打开。
欢迎来到游乐场编辑器。这是我们编写代码的地方,你将感受到与在 Xcode 中开发时的熟悉感:

图 7.17 – 我的游乐场
我们将从一个简单的操作开始,只是为了熟悉 IDE(是的,我在称呼它为 IDE——从某种意义上说,它确实如此...):
-
点击屏幕以弹出键盘。如果键盘没有弹出,只需按下底部工具栏右侧的箭头键。当你处于那里时,看看一些可用的关键字建议——let、var、if 等等。
-
前往并点击 let 常量。你会注意到以下内容为你自动填充:
let name = value
- 在名称占位符高亮显示的情况下,只需开始输入你的变量名,创建一个名为
isSwitchedOn的变量,然后按tab键将高亮显示的占位符移动到value这里。然后,输入单词true。
就像在 Swift 中预期的那样,类型推断开始工作,我们创建了一个名为isSwitchedOn的布尔常量,其值为true。
- 接下来,在新的一行上,点击
if并完成以下突出显示的代码片段:
let isSwitchedOn = true
if isSwitchedOn {
print("Switched On")
}
- 完成后,点击“运行我的代码”按钮,你应该会注意到按钮左侧的图标上出现了一个红色指示器。点击它以显示控制台窗口并检查你的打印语句。
真的很酷啊!好的,那么让我们来做一些更复杂、更有趣的事情。创建另一个项目,并将其命名为Quotes(或你喜欢的任何名称):
- 首先,如果还没有添加,我们需要添加几个导入:
import UIKit
import PlaygroundSupport
PlaygroundPage.current.needsIndefiniteExecution = true
这后面是第三行,它将游乐场设置为持续运行。我们将在“如何工作...”部分中详细介绍为什么我们需要这样做。
- 接下来,我们将创建几个结构体,如下所示:
struct RootResponse: Codable {
let response: [Response]?
}
struct Response: Codable {
let id: String?
let quote: String
}
- 这些与即将调用的 API 的响应类型相匹配。接下来,让我们创建一个类似于
fetchRepos()函数的函数:
func fetchQuotes(completionHandler: @escaping ([Response]?) -> Void)
-> URLSessionDataTask? {
var session = URLSession.shared
let urlString = "https://api.bobross.dev/api/10"
guard let url = URL(string: urlString) else {
return nil
}
var request = URLRequest(url: url)
let task = session.dataTask(with: request) { (data, response,
error) in
// First unwrap the optional data
guard let data = data else {
completionHandler(nil)
return
}
do {
let decoder = JSONDecoder()
let responseObject = try
decoder.decode(RootResponse.self, from: data)
completionHandler(responseObject.response)
} catch {
completionHandler(nil)
}
}
task.resume()
return task
}
为了简单起见,我在这里做了一些小的调整,如前述代码中突出显示的那样。我们只是调整了return类型,返回我们在上一步中创建的可编码对象。
我们还调用了一个不同的 API,它将返回一个包含引言的数组,我们可以遍历它。
- 接下来,让我们调用我们的函数:
fetchQuotes { (response) in
guard let quotes = response as? [Response] else { return }
for item in quotes {
print(item.quote ?? "")
}
}
在这里,我们只是在遍历响应,但你应该看到控制台图标旁边出现以下红色徽章:

图 7.18 – 停止运行按钮
- 好的,请点击图标并查看结果是否已记录到控制台。如果一切顺利,你现在应该能看到一个包含 10 条引言的列表。
所有这些都工作正常后,让我们看看我们如何更好地组织我们的代码,就像 Xcode 中的 Playgrounds 一样。我们的主要 Swift 文件嵌入在一个根文件中;然而,我们通过添加共享代码文件得到了对模块的支持:
- 要访问这些,请点击右上角靠近关闭按钮的导航图标。按下时,它应该看起来像这样:

图 7.19 – 项目导航窗口
不要过于担心文件结构,我们将在“如何工作...”部分稍后介绍;现在你需要知道的是,所有可以由游乐场文件使用(或共享)的代码都在一个名为 UserModule 的模块中(如果你愿意,可以重命名)。
-
在这里有一个名为
SharedCode.swift的文件。突出显示它并将主项目中的可编码结构体剪切并粘贴到这里。如果你喜欢,可以将SharedCode.swift重命名为Models.swift。 -
再次运行项目,你会注意到你得到了以下编译器错误——属性不能声明为 public,因为它的类型使用了一个内部类型.基本上,由于 Swift Playgrounds 解释外部文件的方式,你需要将结构体声明为 public:
public struct RootResponse: Codable {
public let response: [Response]?
}
public struct Response: Codable {
public let id: String?
public let quote: String
}
再次运行,你会看到逻辑再次以所有它的辉煌工作,但尽管在输出窗口中看到这一点很令人高兴,但让我们再次看看我们如何将其添加到我们的 liveView 画布中:
- 我们首先通过编程创建一个
TableViewController():
class TableViewController: UITableViewController { }
- 我们将重写
viewWillLoad()并在这里添加我们的代码来调用fetchQuotes()函数:
override func viewDidLoad() {
super.viewDidLoad()
// Fetch Quotes code to go here
}
- 现在,我们需要添加几个类属性,所以结合以下内容:
var quotes = [String]()
var session = URLSession.shared
URLSession 将用于我们的 fetchQuotes() 函数,正如我们之前需要的那样。引用数组将是我们存储从 API 响应中获取的所有引用的地方。
-
接下来,将
fetchQuotes()函数复制到TableViewController类中(目前这将位于外部)。 -
现在,我们可以扩展
viewDidLoad()来调用我们的函数:
override func viewDidLoad() {
super.viewDidLoad()
fetchQuotes { (response) in
guard let quotes = response as? [Response] else { return }
for item in quotes {
self.quotes.append(item.quote)
}
DispatchQueue.main.async {
self.tableView.reloadData()
}
}
}
注意,在前面的代码中,我们不会将输出打印到控制台,而是将其添加到我们的引用数组中。一旦我们的数组被填充,并且我们不再迭代响应,我们就可以在我们的表格视图中调用 reloadData(),这需要在 DispatchQueue.main.async 中执行以强制在主线程上重新加载(因为我们目前处于来自 API 的异步响应回调中)。
- 一旦完成,让我们添加一些必要的
UITableView代理,以便我们的表格视图可以显示我们的数据:
override func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return quotes.count
}
我们现在需要按任何顺序添加 numberOfSections(),它将返回 1,然后是 numberOfRowsInSection(),它将返回我们数组中的引用数量。
- 最后,我们需要添加
cellForRowAt()代理:
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let quote = quotes[indexPath.row]
var cell = UITableViewCell()
if let _ = tableView.dequeueReusableCell(withIdentifier:
"table.view.cell") {
cell = UITableViewCell(style: .default, reuseIdentifier:
"table.view.cell")
}
cell.textLabel?.text = quote
return cell
}
在这里,我们简单地创建一个 UITableViewCell() 的实例。如果没有设置重用标识符,我们将为我们的单元格添加一个(我们称之为 table.view.cell,但你可以称它为任何你想要的)。然后,使用正在调用的单元格的当前索引,我们从引用数组中获取文本。
- 最后,我们需要将
TableViewContoller分配给我们的 Live 视图:
PlaygroundPage.current.liveView = TableViewController()
- 按下“运行我的代码”并你应该看到以下内容:

图 7.20 – 结果实时视图
无论你是在 iPad 上使用 Playground 还是通过 Mac App Store,它当然是一个比 Xcode 更强大的替代品。
它是如何工作的...
Swift Playgrounds 有一些强大的功能,特别是如果你习惯使用 Xcode,你将立即开始进行比较(我知道我就是这样)。但退一步,看到这一切是如何美妙地交织在一起,确实让我对不仅编写代码,还在 iPad 上进行开发有了新的认识。
让我们看看代码补全。通常,在 Xcode 中,我们期望它在我们在输入时显示,在我们文本旁边的小对话框/表格中,但使用 iPadOS 上的 Swift Playgrounds,我们在屏幕底部得到一个水平滚动条的形式:

图 7.21 – 语法建议
在这里,我们可以看到一个在UITableView上的代码补全示例。注意,我们立即得到了多个仅针对 UITableView 的选项。继续重新实现您之前所做的代理,然后您会在开始输入numberOf时看到另一个建议出现 – 它就是那么简单。
另一点需要注意的是编译错误,其显示方式与 Xcode 类似(尽管更符合编辑器中的代码)。当运行您的代码时,将显示以下带有错误描述的红色符号。点击按钮将折叠错误,让您可以更详细地检查您的代码:

图 7.22 – 错误检查
或者,如果您有很多错误,您可以直接点击菜单栏中+图标左侧的红色图标来显示当前错误列表:

图 7.23 – 错误指示器
有了这个前提,编译器可能并不总是给您错误提示,您可能被迫“逐步执行”您的代码,以找出您的逻辑中究竟发生了什么。如果我们回顾一下如何做…部分,您会记得我们需要添加以下代码行来使我们的函数正常工作:
PlaygroundPage.current.needsIndefiniteExecution = true
这是因为我们的沙盒在 API 返回调用之前就完成了执行。让我们暂时注释掉这一行,然后运行我们的代码,您会注意到它从未显示我们的引用列表,但我们想看看发生了什么。
在“运行我的代码”按钮的左侧有一个计时器图标。点击这个图标可以看到以下选项:

图 7.24 – 运行我的代码选项
运行我的代码默认高亮显示,但还有两个额外的选项 – 逐步执行我的代码和慢速执行。这些选项的作用(本质上是一样的,尽管一个执行得比另一个慢)是在代码执行或打算执行时突出显示每一行代码,让您可以跟踪并检查任何潜在的逻辑问题。对于已经熟悉 Xcode IDE 的人来说,这是调试的常见做法。
点击这两个选项之一,并注释掉前面的代码,您会看到我们的代码永远不会结束(因为主同步函数在完成处理程序触发之前就结束了)。如果您尝试几次,您会注意到它偶尔会在完成处理程序的不同点停止。取消注释这一行,然后观察它如何逐步执行到结束。
另一点需要注意的是右上角的对象菜单。按下加号按钮会提供一大堆代码片段供您选择,同时您还可以将图片导入到类似目录的资产中,这样您就可以像在 Xcode 中一样引用图像文字:

图 7.25 – 布局结果
最后,我想再次提及左侧的文件检查器。如果您再次打开它,您会看到一个编辑按钮,它实际上就是它所说的那样。它允许您编辑、重命名和重新排序项目中的模块的 Swift 文件。
如前所述,Swift Playgrounds 是为 iPadOS 和 macOS 设计的,两者以相同的方式工作,您应该能够在两者上完美地遵循这个食谱(提示,我一半的食谱是在 iPad 上写的,另一半是在 Mac 版本上写的,只是为了测试它!)。
还有更多
值得注意的是,最后一个部分是工具部分。点击右上角+图标右侧的三个点。现在您应该会看到以下内容:

图 7.26 – 操场选项
我们将逐一或集体地过一遍这些内容:
- Playgrounds 帮助 / 文档
如您所预期,帮助功能集中在应用程序/IDE 和界面上,为您提供可用性的概述,而文档则是苹果特定的 API 文档。
- 拍照 / 创建 PDF
这些选项共享画布当前状态的截图或 PDF。其他标准的 iPadOS 共享选项也可用。
- 录制电影 / 直播广播
录制电影开始一个类似于“屏幕录制”的场景,带有出现在屏幕顶部的停止和录制控制。广播选项可以连接到在 Apple App Store 上可以找到的第三方应用程序,这些应用程序支持广播。
- 分享 / 高级
分享将启动默认的 iPadOS 共享表单,以便分享您当前的操作剧本。
高级选项提供两个选项,一个是导出您的操作剧本,另一个是更深入地查看您当前操作剧本的层次结构。
- 许可协议
这没有什么值得大书特书的,只是您通常的软件许可协议信息。
参见
苹果的 Swift Playgrounds 页面:www.apple.com/swift/playgrounds/
服务器端 Swift
从其诞生之初,Swift 就被设计成一种通用编程语言,适用于多种用例和多个平台,而不仅仅是用于构建 Apple 平台的应用程序。除了构建应用程序之外,另一个明显的用例是创建服务器端代码。毕竟,与服务器交互是几乎所有应用程序的一个关键组成部分。支撑互联网的大多数服务器都运行在 Linux 上,这在某种程度上比任何 Apple 平台更适合这项任务。因此,能够在 Linux 上运行 Swift 对于使 Swift 成为可行的服务器端编程语言选项至关重要。
在本章中,我们将探讨在 Linux 上安装 Swift 工具链,使用 Web 服务器框架构建 REST API,并通过托管服务托管我们的 API。
在本章中,我们将介绍以下食谱:
-
在 Linux 上运行 Swift
-
使用 Vapor 和 Fluent 构建 REST API
-
使用 Fluent 和 Postgres 进行数据库持久化
-
在 Heroku 上托管你的 Vapor 应用程序
-
Swift 包管理器
-
使用 WebSockets 进行实时通信
-
在服务器和应用程序之间打包和共享模型
第九章:技术要求
本章的所有代码都可以在这个书的 GitHub 仓库中找到:github.com/PacktPublishing/Swift-Cookbook-Second-Edition/tree/master/Chapter08
查看以下视频,了解代码的实际应用:bit.ly/3kgvhxe
在 Linux 上运行 Swift
Linux 操作系统在后台服务器领域是一个主导力量,因此为了在服务器端开发中发挥作用,Swift 需要在 Linux 上可用。幸运的是,Swift 开源发布的一部分包括 Linux 上的 Swift 工具链。让我们通过在 Linux 上的 Swift“Hello World”程序来启动它。
入门
这个食谱将使用 Ubuntu 20.04,因为这个 Linux 发行版非常受欢迎且广泛使用,20.04 是最新版的长期支持(LTS)版本。此外,Swift 开源团队还为这个发行版提供了预构建的二进制文件。作为一名 Mac 用户,我已经在 Ubuntu 的实例上测试了此过程,该实例在 VirtualBox 虚拟化环境中运行,但在裸机上运行时应该没有差异。
如何操作...
让我们安装 Swift 工具链并在 Linux 上运行我们的第一个 Swift 代码:
- 我们需要安装一些与编译器相关的依赖项。如果你使用的是带有图形用户界面(GUI)的 Ubuntu,请打开一个终端窗口,并在提示符下运行以下命令以更新软件仓库:
sudo apt-get update
- 运行以下命令以安装 Swift 工具链所需的多个依赖项:
sudo apt-get install \
binutils \
git \
gnupg2 \
libc6-dev \
libcurl4 \
libedit2 \
libgcc-9-dev \
libpython2.7 \
libsqlite3-0 \
libstdc++-9-dev \
libxml2 \
libz3-dev \
pkg-config \
tzdata \
zlib1g-dev
Swift 团队为 Ubuntu 发布了预构建的工具链,因此我们需要下载最新发布的版本和相关signature文件。这些发布版本的链接可以在swift.org/download/#releases找到。
- 下载相关平台的工具链和 PGP 签名文件。例如,在撰写本文时,最新的发布版本可以使用以下
wget命令下载:
wget https://swift.org/builds/swift-5.3-release/ubuntu2004/swift-5.3-RELEASE/swift-5.3-RELEASE-ubuntu20.04.tar.gz
wget https://swift.org/builds/swift-5.3-release/ubuntu2004/swift-5.3-RELEASE/swift-5.3-RELEASE-ubuntu20.04.tar.gz.sig
- 我们下载的签名文件可以用来验证工具链存档在下载过程中没有被篡改或修改。验证将使用 Swift 项目提供的公共 PGP 密钥与签名文件一起进行。如果您之前还没有这样做,可以使用以下命令检索 PGP 密钥并将它们导入到您的密钥环中:
wget -q -O - https://swift.org/keys/all-keys.asc | gpg --import -
- 接下来,我们需要检查是否有任何密钥吊销证书:
gpg --keyserver hkp://pool.sks-keyservers.net --refresh-keys Swift
- 完成所有这些后,我们可以验证下载的工具链存档的完整性(将下面的签名文件名替换为下载的文件名):
gpg --verify swift-5.3-RELEASE-ubuntu20.04.tar.gz.sig
- 响应应包含
gpg: Good signature from "Swift 5.x Release Signing Key <swift-infrastructure@swift.org>"行;如果是这样,存档已成功验证,如果不是,下载可能已被篡改,应从受信任网络上的受信任源检索。
如果它说密钥不是来自受信任的signature,请不要惊慌。这是因为系统无法从您的计算机到该密钥找到未中断的证书链;可以忽略此警告。
- 在验证存档后,让我们解压存档并查看里面有什么(将下面的文件名替换为下载的工具链):
tar -xzf swift-5.3-RELEASE-ubuntu20.04.tar.gz
在存档中有一个usr文件夹,在该文件夹中,有一个bin文件夹,其中包含与构建 Swift 相关的多个二进制文件;其中最重要的是swift二进制文件。
- 我们希望仅使用
swift命令与 Swift 工具链交互;为此,我们需要告诉系统在哪里查找 Swift 二进制文件。使用您首选的文本编辑器打开~/.profile,并将包含您的 Swift 工具链的文件夹添加到$PATH导出命令中:
PATH="$HOME/bin:$HOME/.local/bin:<path to extracted swift toolchain>/usr/bin:$PATH"
-
保存你的
.profile文件。 -
退出并重新登录以使更改生效。
-
现在,当您输入
swift并按Enter时,Swift REPL 将启动。它代表读取-评估-打印循环(REPL);这是一种以非常快速、简单的方式与 Swift 语言交互的方法,就像游乐场一样。
在 REPL 中,您可以编写 Swift 命令并按Enter键执行它们,每个命令都在与上一个命令相同的范围内。
- 在 REPL 中,输入以下 Swift 表达式:
let greeting = "Hello world!"
- 按下Enter,REPL 将显示您命令的结果,就像游乐场一样:
greeting: String = "Hello world!"
- 现在我们可以打印我们的问候语:
print(greeting)
我们得到预期的响应:
Hello world!
- 要离开 REPL 并返回到您的正常命令行,请输入以下内容并按Enter:
:quit
恭喜!你刚刚在 Ubuntu Linux 上执行了 Swift 代码。服务器端 Swift 的世界在等待着你。
还有更多...
在命令行中尝试 Swift 代码是很好的,但我们真正需要的是将我们的代码编译成一个可执行的二进制文件,我们可以按需运行它。让我们将我们的“Hello world!”示例编译成一个二进制文件:
- 打开你喜欢的文本编辑器,将以下内容保存到一个名为
HelloWorld.swift的文件中:
print("Hello world!")
- 在命令行中,在包含我们的 Swift 文件的文件夹中,我们可以使用
swiftc编译我们的二进制文件。我们可以指定要编译的文件或文件,并使用-o标志为输出二进制文件提供名称:
swiftc HelloWorld.swift -o HelloWorld
- 现在,我们可以运行这个二进制文件:
> ./HelloWorld
> Hello world!
编译一个文件是很好的,但为了执行任何有用的任务,我们可能需要多个 Swift 文件来定义诸如模型、控制器和其他逻辑等,那么我们如何将它们编译成一个单一的、可执行的二进制文件呢?
当我们有多于一个文件时,我们需要能够在应用入口点定义一个。当编译包含多个文件的 Swift 二进制文件时,其中一个应该被命名为main.swift。这个文件作为入口点,所以当二进制文件运行时,main.swift文件中的代码会从头到尾执行。
让我们创建两个 Swift 文件,第一个文件命名为Model.swift:
// Model.swift
class Person {
let name: String
init(name: String) {
self.name = name
}
}
接下来,我们将创建main.swift文件:
// main.swift
let keith = Person(name: "Keith")
print("Hello \(keith.name)")
现在,让我们将这些两个文件编译成一个名为Greeter的二进制文件:
swiftc Model.swift main.swift -o Greeter
这可以被执行:
> ./Greeter
> Hello Keith!
我们现在已经在 Ubuntu 上的 Swift 中编写并编译了一个由多个文件组成的二进制文件。
使用 Vapor 和 Fluent 构建 REST API
服务器端 Swift 的主要用例之一是构建 REST API。与网络数据交互是几乎所有应用的关键功能,直到现在,这个服务器端组件必须由具有相关服务器端技能的其他人构建。或者,它要求应用开发者频繁地在编程语言和开发环境之间切换,以构建应用的前端和后端代码。
服务器端的 Swift 为开发者提供了在应用的所有方面工作的可能性,并且可以在客户端和服务器端之间无缝切换。
Swift 团队已经做了很多工作来鼓励在服务器上使用 Swift,包括创建一个新的用于运行服务器的网络框架。这个新的框架被称为 SwiftNIO,并且可以在 GitHub 上以开源的形式获得:github.com/apple/swift-nio.
这个框架相当底层,存在许多高级框架,它们使用 Swift 使创建 REST API 变得更加容易。
在这个菜谱中,我们将使用一个更受欢迎的框架,Vapor,来构建一个用于存储用户任务的应用的 REST API。
入门
Vapor 是一个用于构建网络服务的 Swift 框架,它使完成常见任务变得非常容易。在撰写本文时,Vapor 处于版本 4,这是与 Swift 5.2 一起要求的。有关 Vapor 的更多信息,请访问其网站:vapor.codes.
对于这个配方,我们将假设您正在 Mac 上开发 Swift 网络服务,即使它最终可能部署到 Linux 服务器。
当编写将在 Linux 上部署的 Swift 代码时,通过实际上在 Linux 上运行代码来测试您的代码非常重要,尤其是如果它最初是在 macOS 上开发的。Swift 在 macOS 和 Linux 上的操作之间存在重大差异。这些差异通常是由于不同平台对 Foundation 框架的不同实现。
让我们验证我们是否安装了正确的 Swift 版本以运行 Vapor。在终端中运行以下命令以检查您的 Swift 版本:
swift --version
输出应至少指示版本 5.2;如果不是这种情况,您需要更新您的 Xcode 版本。
接下来,我们需要安装 Vapor Toolbox,它是一组命令行工具,用于简化与基于 Vapor 的项目的工作。Vapor CLI 可通过 Homebrew 获得,Homebrew 是 macOS 的包管理器。您可以在其网站上找到有关 Homebrew 的更多详细信息:brew.sh.
在撰写本文时,Homebrew 尚未更新以支持最近发布的 Apple Silicon Macs。如果您使用的是这些 Mac 之一,请访问 Homebrew 网站以获取兼容性信息。
如果您尚未安装 Homebrew,请在终端中运行以下命令:
/usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"
安装 Homebrew 后,我们可以添加 Vapor 的tap:
brew install vapor
让我们运行 Vapor 来检查它是否正确安装。您应该会看到如何使用 Vapor 的说明:
vapor
我们现在可以使用 Vapor 来构建我们的 REST API 了。
如何做到这一点...
我们将创建一个网络服务来存储和管理来自 iOS 应用的任务:
- 创建一个名为
TaskAPI的新 Vapor 项目,然后移动到创建的新文件夹:
vapor new TaskAPI
cd TaskAPI
在此过程中,您将被询问“您想使用 Fluent 吗?”回答“是”。然后您将被询问您想使用哪个数据库。选择Postgres(这应该是推荐的选项)。
此外,您将被询问是否想安装Leaf。目前选择否,因为这稍微超出了本配方的范围。
您可以使用任何 IDE 创建您的 Vapor 网络服务,但我们熟悉使用 Xcode,所以让我们使用它。
- Vapor 支持创建包含我们的 Vapor 代码的 Xcode 项目,所以让我们为我们的 Vapor 项目启动 Xcode:
vapor xcode
Xcode 将打开,项目依赖项将自动获取并出现在项目导航器中的 Swift 包依赖项下。请耐心等待——这可能需要几分钟,您可能一开始看不到任何活动。
一旦所有依赖项都已获取,构建和运行按钮将变为活动状态。
-
构建并运行项目以启动本地 Web 服务器,该服务器将处理对
http://127.0.0.1:8080的请求。 -
在浏览器中打开
http://127.0.0.1:8080,您将看到以下网页:

图 8.1 – Vapor 项目
它工作了!现在您已经有了您的裸骨 Vapor 项目并正在运行。现在让我们看看 Vapor 为我们创建的 Xcode 项目。
在项目导航器中,您将找到一个名为“源”的组,以及两个子组:运行和应用程序。运行组包含main.swift文件,这是您运行应用程序时将执行的文件。应用程序组包含一些用于设置 REST API 的示例代码的多个文件。
打开main.swift文件;它将包含以下模板代码:
import App
import Vapor
var env = try Environment.detect()
try LoggingSystem.bootstrap(from: &env)
let app = Application(env)
defer { app.shutdown() }
try configure(app)
try app.run()
您实际上并不需要编辑此文件;它只是执行所需的设置并启动为我们提供 API 的 Web 服务器。我们需要做的是使用此设置过程来配置 Vapor 以提供我们所需的 API。
让我们看看默认 Vapor 模板是如何设置这些内容的。在应用程序组中,打开configure.swift文件:
import Vapor
// configures your application
public func configure(_ app: Application) throws {
// uncomment to serve files from /Public folder
// app.middleware.use(FileMiddleware(publicDirectory:
// app.directory.publicDirectory))
if let databaseURL = Environment.get("DATABASE_URL"),
var postgresConfig = PostgresConfiguration(url: databaseURL) {
postgresConfig.tlsConfiguration =
.forClient(certificateVerification: .none)
app.databases.use(.postgres(configuration: postgresConfig),
as: .psql)
} else {
app.databases.use(.postgres(
hostname: Environment.get("DATABASE_HOST") ?? "localhost",
port: Environment.get("DATABASE_PORT").flatMap(
Int.init(_:)) ?? PostgresConfiguration.ianaPortNumber,
username: Environment.get("DATABASE_USERNAME") ??
"vapor_username",
password: Environment.get("DATABASE_PASSWORD") ??
"vapor_password",
database: Environment.get("DATABASE_NAME") ??
"vapor_database"
), as: .psql)
}
app.migrations.add(CreateTodo())
// register routes
try routes(app)
}
在此文件中,我们导入了Vapor模块,该模块包含设置和管理 Vapor Web 服务器的所有代码。我们还有我们的数据库代码,但我们会稍后进一步介绍。如果您回顾一下main.swift文件,您会看到它导入了App模块;这是包含您设置 Web 服务器和管理请求和响应的所有代码的模块。
注意在前面代码中,我突出显示了对最新版本 Vapor 的必要更改,这可能已包含在最新的模板中,如果您不确定,只需将前面的代码从此处复制到您的项目中,或者查看源文件。
接下来,让我们切换到Routes.swift文件,我们会看到这是我们的路由配置的地方:
import Vapor
func routes(_ app: Application) throws {
app.get { req in
return "It works!"
}
app.get("hello") { req -> String in
return "Hello, world!"
}
}
路由定义了 Web 服务器可能接收的请求类型以及要发送的响应。
您会注意到定义了"plaintext"路径,这是我们访问以确定一切配置正确的 URL:
app.get("hello") { req -> String in
return "Hello, world!"
}
此路由是为GET请求,带有路径字符串参数plaintext;当我们的 Vapor 服务器在本地运行时,这与 URLhttp://0.0.0.0:8080相关。在 Vapor 中定义路由的过程涉及提供一个闭包,该闭包接受一个Request对象并返回符合ResponseRepresentable的对象;Vapor 已经定义了字符串符合ResponseRepresentable,因此可以在字符串中返回“Hello, world!”。
如果尚未运行,构建并运行运行方案,并访问http://0.0.0.0:8080/hello以查看定义的 JSON。
它是如何工作的...
TaskAPI将接受包含任务 JSON 表示的 POST 请求中的新任务,并通过GET请求返回所有现有任务。
首先,让我们定义一个任务 – 在你的 Xcode 项目的 App 组中,在名为Models的新组中创建一个名为Task.swift的新文件。
创建此文件后,请确保它是 App 目标的成员,而不是其他任何成员。我们新文件的目标成员面板应如下所示:

图 8.2 – 目标成员
现在,让我们定义我们的任务具有两个属性:description和category,以及一个标识符:
class Task {
let id: String
var description: String
var category: String
init(id: String, description: String, category: String) {
self.id = id
self.description = description
self.category = category
}
}
我们现在可以在代码中创建一个Task,如下所示:
let task = Task(id: "1", description: "Remember the milk", category:
"shopping")
然而,我们将始终从POST请求接收到的 JSON 创建我们的Task对象,所以能够直接从 JSON 创建我们的Task对象将非常有帮助,并且由于我们将返回我们的Task对象作为 JSON,所以能够将它们转换为 JSON 也将非常有帮助。
Vapor 有一个名为 Content 的框架,用于处理 JSON 数据。Content 是 Vapor 的Codable版本(我们在第六章,使用 Swift 构建 iOS 应用)中了解到的)。
我们创建的 Vapor 项目反过来创建了我们将要经历的示例代码。如果你在任何时候遇到困难,只需参考模板对应部分以获取指导,了解你可能出错的地方。
让我们给我们的Task模型对象添加Content一致性:
import Foundation
import Vapor
final class Task: Content {
let id: String = UUID().uuidString
var description: String
var category: String
}
当我们创建一个新任务时,我们不会期望 JSON 包含标识符,所以如果它缺失,我们可以使用Foundation框架生成一个UUID。
现在我们有了可以转换为 JSON 并从 JSON 转换的Task模型对象,让我们为我们的 API 创建一些路由。
在Routes.swift中,我们需要一个Task对象的数组来保存创建的任务;目前,我们只需将其添加到文件顶部:
var tasks = [Task]()
接下来,我们将向routes()方法添加两个路由:
app.post("task") { request -> String in
let task = try request.content.decode(Task.self)
tasks.append(task)
return "Task Added"
}
app.get("task") { request in
return tasks
}
在第一个路由中,我们查找对/task路径的POST请求。如果请求包含 JSON,则我们可以使用它来尝试使用 JSON 创建一个Task对象;一旦创建,我们就可以将其存储在tasks数组中。
下一个路由查找对同一路径 – /task – 的get请求,并返回我们存储在数组中的任务的 JSON 表示。这是利用了 Vapor 有一个扩展名为Sequence的事实,而Array符合Sequence,如果Sequence中的所有项目都符合JSONRepresentable,则Sequence也可以表示为 JSON。定义了这些路由后,让我们构建并运行 Run 方案,并测试它们。
我们将添加一个任务,通过向http://0.0.0.0:8080/task发送POST请求;我们可以使用curl命令来做这件事:
curl -H "Content-Type: application/json" -X POST -d '{"description":"Remember the Eggs","category":"Shopping"}' http://0.0.0.0:8080/task
这应该返回我们刚刚创建的任务的 JSON 表示,如下所示,尽管id将不同:
{
"id": "CEC93BB9-2487-4207-97D8-E41196B44D24",
"category": "Shopping",
"description": "Remember the Eggs"
}
接下来,让我们测试我们的路由以显示所有现有任务:
curl http://0.0.0.0:8080/task
这应该返回我们创建的一个Task数组:
[
{
"id": "CEC93BB9-2487-4207-97D8-E41196B44D24",
"category": "shopping",
"description": "Remember the milk"
}
]
现在我们有一个简单的 API 来存储我们的任务。
尝试添加更多任务并检查它们是否从GET请求返回。
还有更多...
我们迄今为止创建的 API 允许我们在任务上执行一些操作;即创建一个任务和列出所有当前任务。实现这些操作,以及其他如修改、删除和列出单个任务的操作,在构建 REST API 时非常常见,因此 Vapor 使这变得非常简单。
在 Vapor 中,你可以定义一个资源,它将定义所有这些操作是如何执行的,Vapor 会处理遵循 REST API 标准实践的路线设置。
让我们创建一个名为TaskController.swift的新文件——这将是我们新的TaskControllerAPI,它将负责存放我们的 REST API 响应逻辑,并将允许我们的路由逻辑和业务逻辑之间有一定的分离:
import Foundation
import Vapor
var tasksByID = [String: Task]()
final class TaskControllerAPI {
typealias Model = Task
func getTasks(req: Request) -> [String: Task] {
return tasksByID
}
func createTasks(request: Request) throws -> String {
let task = try request.content.decode(Task.self)
tasksByID[task.id] = task
return "Task Added"
}
}
我们正在将创建的任务存储在字典中,键是任务 ID;这使我们能够轻松检索当 ID 作为 URL 参数传递时。
要创建我们的资源,我们提供函数,为每个我们希望支持的REST操作提供响应。这些区域在此列出:
-
index -
store -
show -
replace -
modify -
destroy -
clear -
aboutItem -
aboutMultiple
如您从前面的代码中看到的,我们刚刚创建了一些以开始。我们可以根据需要稍后添加其余部分。
现在让我们添加一个扩展,使其符合RouteCollection:
extension TaskControllerAPI: RouteCollection {
func boot(routes: RoutesBuilder) throws {
routes.get("task", use: getTasks)
routes.post("create", use: createTasks)
}
}
这里是我们将针对TaskControllerAPI核心逻辑的特定路由提取出来,并按需定义它们的地方。在这里添加了路由代码后,我们不再需要在Routes.swift文件中保留这些代码,所以现在就去那里,用以下单行代码替换它们:
try app.register(collection: TaskControllerAPI())
重新运行项目,并测试是否一切仍然按预期工作。让我们创建一个任务:
curl -H "Content-Type: application/json" -X POST -d '{"description":"Remember the milk","category":"shopping"}' http://0.0.0.0:8080/create
然后,获取所有任务以检查我们新创建的任务是否被返回(注意我们如何将 URL 更改为create):
curl http://0.0.0.0:8080/task
我们现在已经创建了一个简单的 REST API 来存储和检索任务。
在下一个菜谱中,我们将在此基础上构建,使其真正有用。
参见
更多关于 Vapor 框架的信息可以在其网站上找到:vapor.codes。
这里有一些其他流行的 Swift 网络框架:
-
Kitura:
www.kitura.io -
完美:
perfect.org -
Zewo:
www.zewo.io
使用 Fluent 和 Postgres 进行数据库持久性
在我们之前的项目中,只要服务器保持运行,我们的任务编辑器就会工作得很好,但一旦快速重启,我们保存的所有内容都会永远丢失——这就是为什么持久性,尤其是在数据库方面,在软件开发中起着至关重要的作用。
在本节中,我们将继续构建我们的 TaskAPI 项目,添加持久性,以确保我们的数据始终得到保护。
准备工作
对于本节,你需要在你的 Mac 上安装 Docker。点击此链接获取有关 Docker 和容器化的信息,以及安装程序的链接:www.docker.com/products/docker-desktop。
如何操作...
首先,我们需要创建我们的数据库。幸运的是,Vapor 可以为我们做这件事:
- 首先,在
Migration文件夹下创建一个名为CreateTasks.swift的新文件,并复制以下内容:
struct CreateTask: Migration {
func prepare(on database: Database) -> EventLoopFuture<Void> {
return database.schema("task")
.id()
.field("description", .string, .required)
.field("category", .string, .required)
.create()
}
func revert(on database: Database) -> EventLoopFuture<Void> {
return database.schema("task").delete()
}
}
注意我们的结构符合 Migration – 不要担心,这是正常的。迁移在创建数据库时也会使用。在先前的代码中,我们正在执行以下操作:
-
创建一个名为
task的数据库模式 -
在我们的数据库中创建名为
"description"、"category"的字段,以及一个 ID 列
就这样,任务完成了。然而,我们需要告诉我们的服务器运行代码并执行迁移(对我们来说是 create)。
- 返回到
Configure.swift并添加以下行:
app.migrations.add(CreateTask())
try app.autoMigrate().wait()
当我们的服务器应用程序启动时,数据库将被创建。不要担心 – Vapor 只会这样做一次,所以如果你的数据库已经存在,它将不会尝试再次创建它。
- 接下来,我们需要调整我们的代码,以便我们的模型可以持久化到数据库中,将模型中的属性与刚刚创建的字段相匹配。回到
Task.swift文件并做出以下突出显示的更改:
import Fluent
final class Task: Content, Model {
static let schema = "task"
@ID(key: .id)
var id: UUID?
@Field(key: "description")
var description: String
@Field(key: "category")
var category: String
init() { }
init(id: UUID? = nil, description: String, category: String) {
self.id = id
self.description = description
self.category = category
}
}
我们将在 如何工作... 部分稍后介绍这个结构的细节,但你现在只需确保更改已经实施。
- 接下来,回到我们的
TaskController.swift并进行以下更改:
final class TaskControllerAPI {
func index(req: Request) throws -> EventLoopFuture<[Task]> {
return Task.query(on: req.db).all()
}
func create(req: Request) throws -> EventLoopFuture<Task> {
let task = try req.content.decode(Task.self)
return task.save(on: req.db).map { task }
}
func delete(req: Request) throws -> EventLoopFuture<HTTPStatus> {
return Task.find(req.parameters.get("taskID"), on: req.db)
.unwrap(or: Abort(.notFound))
.flatMap { $0.delete(on: req.db) }
.transform(to: .ok)
}
}
extension TaskControllerAPI: RouteCollection {
func boot(routes: RoutesBuilder) throws {
let tasks = routes.grouped("tasks")
tasks.get(use: index)
tasks.post(use: create)
tasks.group(":taskID") { task in
tasks.delete(use: delete)
}
}
}
我们几乎准备就绪了。现在我们只需要启动并运行我们的数据库实例。我们将通过使用 Docker 容器来实现这一点。
- 如果你在使用 Mac(你应该在工具栏上有一个代表 Docker 的图标),在终端中输入以下命令:
$ docker run --name postgres -e POSTGRES_DB=vapor_database \
-e POSTGRES_USER=vapor_username \
-e POSTGRES_PASSWORD=vapor_password \
-p 5432:5432 -d postgres
注意它们完美地匹配我们在 Configure.swift 文件中的详细信息。请随意根据您的具体需求/约定进行调整。
如果一切顺利,你应在终端中看到以下内容:
$ Unable to find image 'postgres:latest' locally
$ latest: Pulling from library/postgres
这意味着一切顺利。一旦安装完成,我们就准备好启动我们新的改进后的 REST API。从 Xcode 中运行项目。你应该会在 Xcode 的控制台窗口中注意到数据库迁移/创建。
- 现在已启动,使用我们现有的 cURL 命令向我们的数据库添加多个项目:
curl -H "Content-Type: application/json" -X POST -d '{"description":"Remember the eggs","category":"shopping"}' http://0.0.0.0:8080/tasks
现在,通过 Xcode 重新启动服务器并调用我们的 get 请求。你应该能看到我们最近持久化的所有项目的列表。从现在起,你可以继续使用 put 和 delete 命令以及其他符合 API 标准的命令来更新你的 API。由于本书专注于 Swift 作为编程语言,我们将把这个留给你自己去尝试。
如何工作...
我们的 API 现在已经上线并运行,让我们深入探讨一下我们是如何实现持久化的。让我们首先仔细看看我们的Task.swift类模型:
import Fluent
final class Task: Content, Model {
static let schema = "task"
@ID(key: .id)
var id: UUID?
@Field(key: "description")
var description: String
@Field(key: "category")
var category: String
init() { }
init(id: UUID? = nil, description: String, category: String) {
self.id = id
self.description = description
self.category = category
}
}
如你所见,我们的类现在符合 Model 协议——这是 Fluent 框架使用的协议。这允许我们向属性添加属性,以便我们可以将这些属性绑定到数据库中的字段,例如category属性:
@Field(key: "category")
var category: String
没有这个,Fluent 将不知道如何绑定它,这会导致持久化失败。另一个额外的优点是我们的属性不绑定到数据库的命名约定;当从零开始时这不是问题,但与遗留系统一起工作时这可能很有帮助。
你还会注意到我们那里的schema = "task"常量,这又是我们绑定内部工作的一部分,允许我们的模型仅绑定到特定的数据库模式。
回到我们的TaskControllerAPI()类,你会看到我们做的更改引用了一个.db属性。让我们来看看这些:
func create(req: Request) throws -> EventLoopFuture<Task> {
let task = try req.content.decode(Task.self)
return task.save(on: req.db).map { task }
}
在前面的代码中,我们使用content解码我们的数据,将其绑定到我们的模型(Task())。我们现在能够对task对象执行.save操作,因为现在它符合 Fluent 的Model。
保存操作是在req.db上执行的,然后依次被.map(映射)——一旦操作完成并成功,我们可以看到任务被返回(这就是为什么我们在执行 cURL 命令时在输出窗口中看到这个)。
如果你分解前面的内容,语法会更有意义:
func create(req: Request) throws -> EventLoopFuture<Task> {
let task = try req.content.decode(Task.self)
return task.save(on: req.db).map {
task // Closure here is part of the return function, so will be
// passed back up
}
}
对于熟悉 Vapor 中之前实现数据库方式的人来说,你肯定会欣赏这种新的、改进后的设置持久化的方式。
参考以下内容
Vapor 提供了一些其他数据库提供程序,可以用作 Postgres 的替代:
在 Heroku 上托管你的 Vapor 应用
在本章前面的示例中,我们创建了一个 REST API,该 API 从 Postgres 数据库存储和检索信息。我们的 Vapor 网络服务器在我们的本地机器上运行,并且可以通过 HTTP 请求与之交互;然而,除非你打算让你的机器对公众互联网开放,否则这用处有限,我们需要找到一个地方来托管我们的数据和 REST 接口。
在撰写本文时,Swift 在托管服务上的支持是例外而不是常态;然而,支持正在增长。Heroku 是一个流行的托管服务,它提供资源的动态扩展和非常简单的部署机制。它还支持 Swift 和 Postgres,因此在本菜谱中,我们将部署我们的 REST API 到 Heroku。
入门
Heroku 为您的服务器端项目提供简单且可扩展的基础设施。一旦部署,您的应用实例被称为 Dymos,并且可以启动额外的 Dymos 以应对增加的负载。
将应用部署到 Heroku 是通过远程 Git 仓库完成的。当您准备好部署应用时,只需将代码推送到 Heroku。
首先,访问 www.heroku.com 并注册一个免费账户。接下来,我们将安装 Heroku CLI(命令行界面),因为这有助于与 Heroku 交互。由于我们在之前的菜谱中安装了 Homebrew,我们可以使用它来获取 Heroku CLI:
brew install heroku
接下来,我们需要使用我们刚刚创建的账户登录到 Heroku CLI。运行 login 命令并遵循指示:
heroku login
现在我们已经设置了 Heroku CLI 并准备好使用。
如何操作...
由于 Heroku 的部署机制是 Git,我们需要创建一个本地 Git 仓库。
在终端中,导航到包含我们之前菜谱中构建的 TaskAPI 应用程序的文件夹,并创建一个本地 Git 仓库:
git init
现在我们需要暂存所有文件:
git add .
然后,提交所有代码:
git commit -m "Initial commit"
接下来,我们将设置这个 Vapor 项目以使用 Heroku 并遵循以下说明:
vapor heroku init
您将被询问是否希望为您的应用提供一个自定义名称。如果您选择不使用自定义应用名称,则将随机分配一个由两个单词和一个数字组成的应用名称;例如,afternoon-bastion-18185。因此,应用的 URL 将是 https://afternoon-bastion-18185.herokuapp.com。
您还将被询问是否想要提供自定义构建包和自定义可执行名称。您可以对这两个问题都回答“否”。
我们需要将我们的本地 Git 仓库与刚刚创建的 Heroku 应用关联起来。运行以下命令,将最后的参数替换为 Heroku 为您创建的应用名称:
heroku git:remote -a afternoon-bastion-18185
为了在我们的本地机器上运行数据库支持的 API,我们运行了一个 Postgresql 服务,并使用 Config/secret 中的配置 JSON 文件配置了 Vapor 的连接。然而,这个配置仅针对我们的本地机器,因此我们需要在 Heroku 上启动一个 PostgreSQL 数据库服务,并配置 Vapor 以连接到它。
幸运的是,Heroku CLI 使得这个过程变得非常简单;只需运行以下命令:
heroku addons:create heroku-postgresql:hobby-dev
在这个命令中,hobby-dev 是用于数据库的定价计划,最多支持 10,000 条数据库记录,且免费。
这将使得数据库在你部署应用时对 Heroku Dymo 可用。通过运行 heroku config,我们可以看到创建了一个名为 DATABASE_URL 的环境变量,其中包含了数据库的连接 URL。
现在,我们需要确保 Vapor 在 Heroku 上运行时知道如何连接到数据库。为此,我们将在 Procfile 中添加一个配置,这是 Heroku 用于设置环境所使用的。
在项目的根目录下创建一个名为 Procfile 的文件;打开此文件并添加以下内容:
web: Run serve --env production" --hostname 0.0.0.0 --port \$PORT
这将允许 Vapor 使用在 Heroku 上设置的 PostgreSQL 数据库。
现在,让我们提交更改:
git add .
git commit -m "Added Procfile with databse URL"
现在,我们已经准备好将代码推送到 Heroku,然后它将被部署:
git push heroku master
在撰写本书时 – 有一个针对缺失文件 LinuxTests.swift 问题的公开拉取请求,如果 Heroku 在执行上述推送时给你错误,只需创建一个名为 LinuxTests.swift 的空文件,然后重新提交并再次推送。
可能需要一段时间,但最终,Heroku 将会报告代码已部署,并提供已部署应用的 URL,其外观将类似于以下这样:
https://afternoon-bastion-18185.herokuapp.com
你有一个在 Heroku 上运行的 TaskAPI 部署版本。你可以重新运行上一配方中的所有 cURL 测试,但将前面的主机名替换为 http://0.0.0.0:8080。
相关内容
我们在本章中构建的只是 Vapor 能力的冰山一角。Vapor 内置了对身份验证、模板化等功能的支持,因此请查看 vapor.codes 以获取完整的文档。
Vapor 背后的团队推出了自己的托管服务,Vapor Cloud。这极大地简化了将你的 Vapor 应用上线的过程。在撰写本文时,此服务处于公开测试版;你可以在 vapor.cloud 获取更多信息并注册。
Swift 包管理器
依赖包管理在软件开发中已经存在了一段时间 – 能够在不进行任何重大集成的情况下将依赖框架或代码片段添加到你的代码库中,这通常被视为许多人的好处。
如 NuGet 和 NPM 这样的管理器被广泛使用并由社区维护。特别是对于 iOS 开发,CocoaPods 和 Carthage 一直是主要玩家;直到 Swift Package Manager 或简称 SPM 的引入。
尽管它存在的时间可能比你想象的要长,但最近在 WWDC 2019 上宣布,Xcode 11 将对 Swift 包管理器提供完整的集成支持。
在这个配方中,我们将学习如何使用 Swift 包管理器将依赖项添加到我们的项目中。
入门
对于这个项目,我们将继续使用我们最近实现的 Xcode 项目,其中包含了 Fluent 和 Postgres – 你可以在 GitHub 上的示例配方中跟随操作,或者你也可以在磁盘上创建当前项目的副本并从那里开始工作。
如何操作...
- 首先,打开 Xcode 并从文件树中找到以下文件名:
Package.swift
- 这里,您应该看到以下代码的开始部分:
import PackageDescription
let package = Package(
name: "TaskAPI",
platforms: [
.macOS(.v10_15)
],
dependencies: [
// A server-side Swift web framework.
.package(url: "https://github.com/vapor/vapor.git", from:
"4.0.0"),
.package(url: "https://github.com/vapor/fluent.git", from:
"4.0.0"),
.package(url: "https://github.com/vapor/fluent-postgres-
driver.git", from: "2.0.0"),
],
// ...
)
在先前的代码中,我已突出显示我们的主要关注区域。在这里,我们目前有一份现有依赖项的列表——在我们的案例中,是 Vapor 框架和两个 Fluent 依赖项。
- 所以让我们再添加一个:
.package(url: "https://github.com/vapor/vapor.git", from: "4.0.0"),
.package(url: "https://github.com/vapor/fluent.git", from: "4.0.0"),
.package(url: "https://github.com/vapor/fluent-postgres-driver.git",
from: "2.0.0"),
.package(url: "https://github.com/Kitura/BlueSocket.git", from:
"1.0.0"),
- 给 Xcode 一点时间,它应该开始下载新的依赖项。如果失败,您可以使用以下命令在终端中推动这个过程:
$ vapor build
如果您的包文件有任何错误,您将在这里看到它们。完成后,您将在 Xcode 左侧的文件资源管理器中看到您的新依赖项与现有依赖项并列。
Xcode 还允许您通过 GUI 从 Xcode 添加包依赖项,选择 File | Swift Packages 查看选项列表。不幸的是,这些选项目前仅支持 .xcproject 文件(Vapor 不使用此类文件)。
它是如何工作的...
因此,我们现在已经添加了我们的框架,SPM 是如何施展魔法的呢?我们将再次查看 Packages.swift 文件的依赖项部分:
.package(url: "https://github.com/vapor/vapor.git", from: "4.0.0"),
.package(url: "https://github.com/vapor/fluent.git", from: "4.0.0"),
.package(url: "https://github.com/vapor/fluent-postgres-driver.git",
from: "2.0.0"),
.package(url: "https://github.com/Kitura/BlueSocket.git", from:
"1.0.0"),
我在这里突出显示了最近添加的两个主要区域——第一个是 URL。与 Cocoapods 和 Carthage 等其他依赖项包类似,SPM 从(或您的)Git 仓库中拉取代码。
不深入探讨包是如何实际制作的,依赖项的版本控制是通过标签完成的。在先前的代码中,我们正在寻找任何从 1.0.0 版本开始的包版本(所以最新的可能是版本 10.2.2,我们无从得知),但我们已经决定至少版本 1.0.0 对我们来说已经足够好了。
但也许我们想要更具体一些。让我们看看我们该如何做到这一点:
.package(url: "https://github.com/Kitura/BlueSocket.git", .exact("1.2.3")),
这里,我们指定我们只想使用 1.2.3 版本。我们正在检查的版本号格式被称为语义版本控制。
这允许我们向我们的包添加额外的条件,例如从特定版本添加 upToNextMajor 和 upToNextMinor。以下是一个示例:
.package(url: "https://github.com/Kitura/BlueSocket.git",
.upToNextMajor("1.2.3")),
.package(url: "https://github.com/Kitura/BlueSocket.git",
.upToNextMinor("1.2.3")),
upToNextMajor 将包括所有版本,直到版本 2.0.0。
upToNextMinor 将包括所有版本,直到版本 1.3.0(即 1.2.x)。
更多时候,您实际上并不需要担心这个问题,但当一个框架依赖于其他框架时,有时这确实可以派上用场,以保持项目整洁有序。
还有更多...
最后,让我们看看 Packages.swift 文件中剩余的代码——让我们从顶部开始:
// swift-tools-version:5.2
虽然这看起来像是被注释掉了,但实际上它在我们的包文件中起着重要作用。在这里,我们正在设置我们的依赖项必须遵守的 Swift 版本。因此,如果一个正在下载的依赖项是用较旧的 Swift 版本构建的并且目前不受支持,SPM 将会通知我们。
另一方面,如果我们的依赖项版本比我们的 Swift 版本新,这也会得到处理。
接下来,让我们看看目标部分。乍一看,你可能会觉得这里发生的事情让你有些不知所措,但实际上它非常简单,对于那些熟悉其他依赖管理工具的人来说,这将是熟悉的领域:
targets: [
.target(
name: "App",
dependencies: [
.product(name: "Fluent", package: "fluent"),
.product(name: "FluentPostgresDriver", package: "fluent-
postgres-driver"),
.product(name: "Vapor", package: "vapor")
],
swiftSettings: [
// Enable better optimizations when building in Release
// configuration. Despite the use of
// the `.unsafeFlags` construct required by SwiftPM, this
// flag is recommended for Release
// builds. See <https://github.com/swift-
// server/guides#building-for-production> for details.
.unsafeFlags(["-cross-module-optimization"],
.when(configuration: .release))
]
)
// ..
]
在前面的代码中,我突出了一些关键区域。第一个是目标列表。我们在这里所做的只是识别我们的一个目标(在我们的例子中是我们的App目标)并为该目标添加一个特定的依赖列表。
这些名称是安装后的实际包名——不是 Git URL。这些在项目中的更全局范围内定义,在目标数组之外。在这里,我们只是挑选出应该用于特定目标的。
同样,对于swiftSettings也是如此,这是一个我们可以为发布或调试模式设置 Swift 特定优化构建的地方(但通常我们只为发布构建这样做)。
参见
更多信息,请参阅以下链接:
-
语义化版本控制:
semver.org -
为生产构建:
github.com/swift-server/guides#building-for-production -
CocoaPods:
cocoapods.org -
Carthage:
github.com/Carthage/Carthage
使用 WebSocket 进行实时通信
通过 WebSocket 进行通信已经存在很长时间,并在服务器到服务器、服务器到客户端和客户端到服务器通信中扮演着至关重要的角色。WebSocket 允许双方进行开放和持续的通信,从而允许在不需要“轮询”变化的情况下发送和接收数据。
你可能会在聊天窗口中看到 WebSocket 的应用,这是一个在各个设备之间(通常通过服务器)保持持续通信的窗口。把它想象成互联网的“电话线”(这实际上并不离真相太远)。
在本节中,我们将使用之前构建的 Vapor 项目,并与一个配套的 iOS 应用进行实时通信。
入门
对于本节,你需要我们上一节完成的项目。请随意继续在 Git 仓库中找到的示例项目上进行工作。
此外,你还需要TaskAPIApp项目。
如何做到这一点…
首先,让我们转到我们的Routes.swift文件,并添加以下内容:
app.webSocket("talk-back") { req, ws in
}
看起来熟悉吗?在这里,我们最终为我们的 WebSocket 创建了一个名为"talk-back"的“端点”。当成功连接时,所有数据将通过函数中的ws参数接收。但如何拦截它呢?实际上有几个方法。让我们看看:
app.webSocket("talk-back") { req, ws in
ws.onText { ws, text in
print(text)
}
ws.onBinary { (ws, buffer) in
print(buffer)
}
}
当我们的客户端通过 WebSocket 连接到我们的服务器时,它可以发送一系列特定的命令,例如发送纯文本或发送二进制文本。对于这个配方,我们将只使用onText。
添加以下代码并运行你的服务器:
app.webSocket("talk-back") { req, ws in
ws.onText { ws, text in
if text.lowercased() == "hello" {
ws.send("Is it me your are looking for...?")
}
}
}
因此,本质上,我们在这里所做的是监听来自客户端的消息(任何消息),但如果消息符合我们的特定条件,我们将直接发送响应。
现在,从 GitHub 仓库中,在 Xcode 中启动 TaskAPIApp 项目。在这里,你会注意到我们正在使用一个名为 Starscream 的第三方依赖项来使我们的应用成为 WebSocket 客户端——我们本可以自己构建,但这超出了范围,并且对于这个配方来说是不必要的。
有一个需要注意的事项:我们使用了 SPM 来获取 Starscream 依赖项——看看 Xcode 项目,并与我们的 Vapor 项目进行比较,看看它们是如何匹配的!
好的,iOS 应用加载后,检查 ViewController.swift 文件,确保 localhost URL 与你的当前设置正确(如果你在跟随操作,它应该是相同的):
guard let url = URL(string: "http://127.0.0.1:8080/talk-back") else {
return }
如果你仔细查看 ViewController.swift 文件,你会看到以下内容连接到一个 IBAction:
@IBAction func onSendRequest(_ sender: Any) {
socket.write(string: "Hello")
}
我们有通过 WebSocket 连接发送 String 的选项——砰!
启动项目(同时确保你的 Vapor 服务器在本地上运行)并点击按钮。你看到了什么?如果一切顺利,你应该会看到“你在找的是我吗...?”这些字样出现在 UILabe``l 中——不错!
它是如何工作的...
在 Vapor 中,WebSocket 的行为与路由类似。正如我们之前所说的,我们创建的回话端点的唯一区别是连接是开放的,这里的区别是没有任何额外的调用——我们的服务器不需要重建和重新连接新的 URL 请求,因此没有额外的授权或认证。我们的客户端(就像我们的服务器一样)只是在等待响应。
所有的 WebSocket 调用都是异步的——并且不会像我们在 HTTP 中看到的那样以通常的请求/响应模式回复。然而,这取决于你,客户端或服务器,来管理这个连接,确保它仍然活跃,并随时准备好处理请求。
还有更多...
我们之前提到了当从客户端接收消息时,我们可用的 onText 和 onBinary 函数,但默认情况下,WebSocket 会不断 ping 来确保它们仍然活跃(或存活)。我们可以通过以下函数来监控这些请求:
ws.onPong { (ws) in }
ws.onPing { (ws) in }
或者,我们也可以使用我们的 Vapor 项目来连接 WebSocket:
WebSocket.connect(to: "ws://talk-back.websocket.org", on: eventLoop) {
ws in
print(ws)
}
作为客户端连接时发送消息的方式与之前发送消息的方式相同,无论是通过纯文本还是二进制:
ws.send("Is it me your are looking for...?") // Plain "String" Text
ws.send([1, 2, 3]) // Binary as UIInt
虽然发送消息是异步进行的,但有时我们想知道我们的消息是否已成功发送——我们可以使用 eventLoop 的承诺来实现:
let promise = eventLoop.makePromise(of: Void.self)
ws.send("Hello", promise: promise)
promise.futureResult.whenComplete { result in
// Succeeded or failed to send.
}
最后,我们有以下选项来关闭和处理我们的开放 WebSocket 连接:
ws.close()
ws.close(promise: nil)
ws.onClose.whenComplete { result in
// Succeeded or failed to close.
}
WebSockets 确实是一种打开服务器,以便与客户端建立实时连接的方式。它通常可以用于许多事情,例如仪表板报告和聊天消息。
参见
WebSockets: docs.vapor.codes/4.0/websockets/
在服务器和应用程序之间打包和共享模型
服务器端 Swift 被认为是网络应用程序开发者能够直接利用 Swift 编程语言的力量。这,加上其背后的开源社区的力量——即使在它仍被视为婴儿期的情况下——意味着服务器端 Swift 已经是一个值得生产质量的选项。
但其他优势又如何呢?特别是对于现在使用 Swift 构建应用程序的 iOS 开发者来说?除了对语法和特定 API 的了解之外,他们还能从中得到什么好处?
正如我们在本章前面所看到的,随着 Swift 包管理器集成到 Xcode 中,我们现在可以将一些代码构建成一个模块,我们可以在服务器端 Swift 应用程序和 iOS 应用程序中重用它。
开始使用
在本节中,我们将继续我们的 Vapor 项目,并继续在 Mac 上使用 Xcode 进行工作。
如何做到...
让我们暂时从我们的 Vapor 应用程序中退一步。我们将首先创建一个新的 Swift 包模块。在 Xcode 中,点击 文件 | 新建 | Swift 包——命名为 TaskModule 并点击保存。你现在应该会看到一个全新的项目。
如果你查看文件资源管理器,你会看到一些熟悉的文件,例如 Packages.swift。我们主要关注位于 Sources 文件夹中的文件——这里将存放你的文件和类,并将作为你想要使用的模块的一部分而增长:

图 8.3 – Sources 文件夹
让我们首先创建一个名为 TaskViewModel.swift 的新文件——这个文件将是我们在 Vapor 项目中创建的模型的一个视图模型。
视图模型基本上是一个只包含用于渲染特定视图所需数据的模型,例如,来自服务器的响应模型可能有 20 个属性,包含诸如日期时间戳、创建者等信息,但我们的渲染视图可能只对其中的一小部分信息感兴趣,或者可能想要将一些数据操作或计算成一个单一的属性。
添加以下代码:
import Foundation
enum Category: String {
case shopping = "shopping"
case work = "work"
}
public final class TaskViewModel {
var category: Category?
var title: String?
var imageUrl: URL?
public convenience init(title: String, category: String) {
self.init()
self.category = Category(rawValue: category)
self.title = title
self.imageUrl = URL(string: "https://www.test.com/\(category)")
}
}
在前面的代码中,我们构建了一个非常基本的视图模型,一个枚举类,将我们的类别绑定到标题属性的 1-1 匹配,以及一个用于图像 URL 的计算属性。
目前,这段代码可以用于我们的服务器端 Swift 项目或 iOS 应用程序——但我们如何让它两者都能做呢?很简单!因为我们将其创建为一个 Swift 包,我们可以直接将其作为 Swift 包导入。请从 Xcode 中关闭此项目(非常重要)并转到你的 Vapor 应用程序中的 Packages.swift。
添加以下突出显示的更改。注意,本地路径必须是你的机器上的路径——这需要指向你的 Packages.swift 文件所在的目录:
dependencies: [
// A server-side Swift web framework.
.package(url: "https://github.com/vapor/vapor.git", from: "4.0.0"),
.package(url: "https://github.com/vapor/fluent.git", from:
"4.0.0"),
.package(url: "https://github.com/vapor/fluent-postgres-
driver.git", from: "2.0.0"),
.package(path: "/path/to/your/TaskModule")
],
targets: [
.target(
name: "App",
dependencies: [
.product(name: "Fluent", package: "fluent"),
.product(name: "FluentPostgresDriver", package: "fluent-
postgres-driver"),
.product(name: "Vapor", package: "vapor"),
.product(name: "TaskModule", package: "TaskModule"),
],
在这里,我们做的就是像之前一样——添加我们的包,然后为我们的特定目标引用包中的模块名称。
给 Xcode 一点时间来获取这个更改,就像魔法一样,你将看到我们最近创建的模块在依赖树中。让我们继续尝试使用它。
目前,让我们将这个内容添加到我们的 TaskControllerAPI() 文件中,因为正是在这里我们找到了从响应中绑定我们的原始 Task 模型。
我们将开始导入我们的模块:
import TaskModule
如果一切顺利,这应该可以很好地解决。现在让我们尝试访问我们的视图模型:
let viewTask = TaskViewModel(title: "", category: "")
真的是这样简单。现在让我们从另一个角度来看这个问题,看看我们如何在 iOS 应用程序中使用这个相同的模块:
- 首先,我们需要确保我们的
TaskModule文件夹被初始化为一个 Git 仓库,并且执行了一个初始提交——我很快会解释原因,但现在是简单地执行以下命令在终端中为项目初始化一个仓库:
$ git init
$ git add .
$ git commit -m "Initial commit"
现在打开我们的现有项目,TaskAPIApp,在 Xcode 中,选择 文件 | Swift 包 | 添加包依赖。
你将看到一个 GUI,要求输入 Git 仓库的 URL。由于我们目前是本地的,我们需要以下格式提供:
file:///Users/chris/Source/TaskModule
Xcode 需要一个活动的 Git 仓库来拉取依赖项——所以我们已经在本地上创建了一个,而不是在远程。Xcode 很高兴,并允许我们继续:

图 8.4 - 选择包仓库
下一个屏幕现在应该看起来像这样:

图 8.5 - 选择包选项
指定一个 分支 并输入 master,然后点击 下一步。如果一切顺利,Xcode 应该准备好导入你的依赖项——点击完成。
返回到你的 Xcode 项目,你现在将看到 TaskModule 成功导入到你的项目中。让我们确保一切按预期工作。前往你的 ViewControler.swift 文件,让我们尝试使用我们的视图模型。
我们将开始导入我们的 TaskModule:
import TaskModule
现在我们应该能够从 iOS 代码库的任何地方完美地访问我们的视图模型:
let _ = TaskViewModel(title: "", category: "")
太棒了——我们现在已经在 iOS 和服务器端 Swift 项目中成功创建并重用了我们的视图模型。
它是如何工作的...
能够在我们的服务器端 Swift 和 iOS 项目中使用我们的代码的美妙之处并不仅限于视图模型;特定的计算逻辑也可以放在这里,但你必须注意几件事:记住你的服务器端 Swift 项目有 99%的可能性是在 Linux 服务器上运行的,所以像 UIKit 这样的框架将不可用;然而,这也同样适用。
在 Vapor 中,我们的 Codable 等价物 Content 在我们的 iOS 项目中不可用(因为它在那里根本不需要),所以仔细考虑你想要如何分割和共享你的逻辑。花时间了解你的需求以及开发应用程序时的关注点分离。
除了这些,还有方法可以针对特定的操作系统进行目标定位,我们的应用程序可能在这些操作系统上运行。让我们看看我们如何实现这一点:
#if os(Linux)
import LinuxModule
#else
import GlobalModule
#endif
如果你需要更加具体一些,你可以使用以下方式来识别操作系统:
#if os(macOS)
#if os(iOS)
#if os(watchOS)
#if os(tvOS)
再次强调,如果你在 macOS 上运行你的服务器端 Swift 项目,那么你没问题——但你并没有真正为你的代码提供未来保障,因为你可能会在未来某个时刻发现,在 Linux 服务器上运行你的应用程序将更加经济高效且灵活。
深入了解 Swift 包管理器的工作原理并不像你想象的那么复杂——尽管,我可能要补充一点,它具有非凡的便利性和功能。
对于熟悉 Xcode 中的工作区的人来说,这遵循了一个不太不同的模式——本质上,你的模块是我们之前创建的项目的一个链接。被拉入你项目的代码版本由我们指定的版本号、分支甚至提交来定义。
还有更多...
创建一个用于内部使用的包是一回事,但将其分发给他人——无论是在你的组织内部还是社区中——则是另一回事。
这就是为什么将你的包托管在远程 Git 仓库中非常有用。这不仅允许你独立地对包中的更改进行版本控制,而且你可以选择使用私有仓库来管理这些更改,从而实现受控的内部使用。
Swift 中的性能和响应性
在前面的章节中,我们已经覆盖了大量的内容,并且我们在工具箱中有许多 Swift 工具。现在是时候深入研究更高级的主题,看看某些 Swift 类型的实现方式,它们的使用方法以及它们的性能特征。我们还将探讨如何通过 Dispatch 框架和基于 GCD 的高层操作在 Foundation 框架中执行异步任务。
理解所有 Apple 平台上可用的多线程环境以及你使用的 Swift 构造的性能特征对于构建快速响应的应用程序至关重要。
在本章中,我们将涵盖以下内容:
-
值和引用语义
-
使用 Dispatch 队列进行并发操作
-
并发队列和调度组
-
实现操作类
第十章:技术要求
本章的所有代码都可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/Swift-Cookbook-Second-Edition/tree/master/Chapter09
查看以下视频以查看代码的实际操作:bit.ly/3bn2l2O
值和引用语义
在第一章“Swift 基础”中,我们了解到某些 Swift 类型与其他类型的行为不同,特别是在属性的所有权和修改方面。我们甚至定义了这种差异,指出类是引用类型,而结构体和枚举是值类型。在本节中,我们将探讨为什么这些类型的行为不同以及这种差异带来的性能影响。
让我们创建一个应用程序的模型,允许用户安排他们每天都会进行的事件,并在这些事件应该发生时提醒他们。
准备工作
我们需要决定如何建模我们的日常事件。这个决定的关键在于我们希望我们的事件具有引用语义还是值语义。我们在第一章“Swift 基础”中讨论了这两种语义的区别,但让我们重新审视这些区别。
值类型是简单的数据结构,你可以将其视为仅仅是数据包。Swift 通过允许这些类型拥有方法使其更加有用,但任何对底层数据的更改或修改都会导致一个全新的数据包。相比之下,引用类型是更复杂的数据结构,它们在其组件属性之外具有一个身份。因此,组件属性的任何更改都将通过任何对该对象的引用而可用。
值类型的简单组合在资源创建和维护方面具有非常低廉的优势。然而,这种简单性是以动态调度为代价的,它使得子类化成为可能。
如何操作...
给定这种区别,我们希望我们的日常活动有什么行为?如果我们更改事件名称,我们应该期望任何引用它的事物也会看到这种更改吗?这听起来就像是我们想要的行为,所以我们的日常活动应该是一个引用类型:
class DailyEvent {
var name: String
init(name: String) {
self.name = name
}
}
让我们检查这是否会给我们期望的行为:
var event1 = DailyEvent(name: "have bath")
var event2 = event1
print("Event 1 - \(event1.name)") // have bath
print("Event 2 - \(event2.name)") // have bath
event1.name = "have shower"
print("Event 1 - \(event1.name)") // have shower
print("Event 2 - \(event2.name)") // have shower
我们希望在每天特定时间提醒我们的活动,但就我们的目的而言,Foundation 中的 Date 确实有点过度,因为它包含了日期和时间信息,而我们只需要维护时间信息。让我们创建一个表示时间的实体,而不考虑日期。我们时间模型最合适的行为什么?它应该具有引用语义还是类型语义?
让我们尝试两种方法,看看哪一种似乎最能准确地模拟我们想要的情况:
- 我们将创建一个表示时间的类,具有引用语义:
class ClockTime {
var hours: Int
var minutes: Int
init(hours: Int, minutes: Int) {
self.hours = hours
self.minutes = minutes
}
}
- 现在,让我们看看当其属性更改时,这会如何表现:
let defaultEventTime = ClockTime(hours: 6, minutes: 30)
var event1Time = defaultEventTime // 6:30
var event2Time = defaultEventTime // 6:30
// Event 2 has been moved to 9:30
event2Time.hours = 9
print("Event 1 - \(event1Time.hours):\(event1Time.minutes)")
// Event 1 - 9:30
print("Event 2 - \(event2Time.hours):\(event2Time.minutes)")
// Event 2 - 9:30
当我们更改 ClockTime 实例的属性时,它会产生一个意想不到的后果,即更改对该相同 ClockTime 实例的所有引用。由于引用语义并不完全适合 ClockTime,让我们将其更改为值类型,看看这是否更合适。
- 现在,Swift 中对于值类型我们有两种选择;我们可以将
ClockTime模型化为struct或enum。枚举非常适合用于具有少量有限值的概念的建模。虽然一天中有有限分钟的数目,但这不是一个小的数字,我们可能想要对ClockTime中的小时和分钟进行数学计算,所以struct更合适:
struct ClockTime {
var hours: Int
var minutes: Int
}
- 让我们看看当我们更改
ClockTime实例的属性时,这会如何改变行为:
let defaultEventTime = ClockTime(hours: 6, minutes: 30)
var event1Time = defaultEventTime // 6:30
var event2Time = defaultEventTime // 6:30
// Event 2 has been moved to 9:30
event2Time.hours = 9
print("Event 1 - \(event1Time.hours):\(event1Time.minutes)") // Event 1 - 6:30
print("Event 2 - \(event2Time.hours):\(event2Time.minutes)") // Event 2 - 9:30
由于 ClockTime 是值类型,更改 ClockTime 实例的属性会导致创建一个新的实例,因此更改不会产生我们当它是引用类型时所看到的意外后果。
最后,让我们考虑一下,如果我们把 ClockTime 作为值类型,我们会放弃的一些动态特性。我们是否想要将 ClockTime 作为子类?这似乎不太可能,而且将 ClockTime 描述为简单的数据包是合适的。因此,在这种情况下,将 ClockTime 模型化为值类型是正确的决定。
- 为了完成模型,我们将在
DailyEvent类中添加一个ClockTime属性:
class DailyEvent {
var name: String
var time: ClockTime
init(name: String, time: ClockTime) {
self.name = name
self.time = time
}
}
它是如何工作的...
我们已经讨论了值类型与引用类型的不同之处。现在,让我们探讨它们为什么会有不同的行为。
当在内存中存储类型的新的实例时,Swift 有两种不同的数据结构可以用于存储:栈和堆。这些结构在许多编程语言中都是常见的。值类型存储在栈上,而引用类型存储在堆上**。即使在我们将要覆盖的表面层次上理解数据在这些结构中的存储方式,也将帮助我们理解为什么值类型和引用类型具有不同的行为。
栈可以被视为数据块序列。一个类型的实例可能由多个数据块表示,并且可以使用其实例的第一块数据的内存位置来引用实例。维护一个栈指针,它是对栈末尾内存位置的引用。新实例总是添加到栈的末尾,然后更新栈指针的位置到新的栈末尾。
让我们通过一个简化版的栈图来了解如何添加一个值类型实例。在添加任何内容之前,栈指针位于栈顶:

图 9.1 – 栈和栈指针的表示
为 07:00 添加一个ClockTime结构体到栈上。它占用三个块,栈指针移动到栈上的下一个空块:

图 9.2 – 添加 7:00 的 ClockTime 结构体后的栈指针
为 09:30 添加另一个ClockTime结构体到栈上。它占用四个块,栈指针移动到栈上的下一个空块:

图 9.3 – 添加 9:30 的 ClockTime 结构体后的栈指针
一旦数据被放置在栈上,它就是不可变的。为了了解为什么这是一个重要的限制,让我们尝试更改栈上的第一个ClockTime实例。栈的简单性和效率基于它是一个连续的内存块的事实。如果我们尝试更改栈上已经存在的任何数据,它可能会占用更多空间,这会导致后续块中的数据被覆盖:

图 9.4 – 修改栈上的第一个 ClockTime 实例
因此,对struct的任何更改都会在栈底追加一个新的、更改后的struct版本。
让我们逐步修改一个ClockTime实例,并看看在我们的简化栈表示中它看起来如何:
- 我们将一个
ClockTime结构体赋值给名为event1Time的变量:
var event1Time = ClockTime(hours: 9, minutes: 0)
以下图表显示了这种可能的外观:

图 9.5 – 将 event1Time 变量赋给 09:00 的 ClockTime 结构体
event1Time的值也被赋给一个新的变量,称为event2Time:
var event2Time = event1Time

图 9.6 – 将 event1Time 的值赋给 event2Time
- 当我们修改
event2Time,将分钟值改为30时,一个新的具有更改后的分钟值的ClockTime实例被放置在栈底:
event2Time.minutes = 30

图 9.7 – 放置在栈底的新 ClockTime 实例
event2Time变量现在指向这个新的栈位置,而event1Time继续指向 09:00 的原始ClockTime结构体的栈位置。
如前所述的示例所示,栈是一个非常简单且高效的数据结构,其属性解释了当我们使用struct和enums等值类型时看到的行为。
相比之下,引用类型,如类对象,存储在堆上,这以牺牲效率为代价,使得行为更加动态和复杂。
对堆分配的准确分析超出了本书的范围,但让我们非常简化地看看引用类型实例是如何存储在堆上的。堆不是一个连续的块链,而是一个可能空闲或已分配的内存区域:

图 9.8 – 堆的表示
当一个类分配到堆上时,它必须搜索堆以找到适合其大小的空闲块集。引用类型实例可能通过存储它们的栈位置来包含对其他引用类型或值类型的引用:

图 9.9 – 堆中的类分配
多个变量可以持有对同一实例的引用:

图 9.10 – 持有对同一实例引用的多个变量
当引用类型被修改时,它们不会被复制。相反,必须找到额外的空间来容纳额外的信息。所有对实例的引用都有更改后的信息:

图 9.11 – 修改引用类型
这个配方描述了引用语义和值语义之间的区别,并希望说明这些行为源于它们在内存中的存储方式。
它们都有其用途,并且在构建你的模型时选择正确的类型非常重要。
参见
Swift 博客:值类型和引用类型:swiftbook.link/blog/type-semantics
Apple 开发者视频(需要开发者账户):
- WWDC 2016 - UIKit 应用中的协议和面向值编程:
developer.apple.com/videos/play/wwdc2016/419
使用调度队列进行并发
我们生活在一个多核计算的世界。从我们的笔记本电脑、移动电话到我们的手表,到处都可以找到多核处理器。这些多个核心带来了并行工作的能力。这些并行的工作流被称为线程,以多线程方式编程可以使你的代码充分利用处理器的核心。决定何时以及如何创建新线程和管理可用资源是复杂任务,因此苹果为我们构建了一个框架来处理这些困难的工作。它被称为Grand Central Dispatch。
Grand Central Dispatch(GCD)负责线程维护并监控可用资源,同时提供了一个简单、基于队列的接口来执行并发工作。随着 Swift 的开放源代码,苹果也将 GCD 以libdispatch的形式开源,因为 Swift 还没有内置的并发功能。
在这个菜谱中,我们将探索libdispatch的一些特性,也称为调度框架,并看看我们如何使用并发来构建高效且响应迅速的应用程序。
准备工作
我们将看到如何使用 GCD 提高应用程序的响应性,因此首先,我们需要从一个需要改进的应用程序开始。访问github.com/PacktPublishing/Swift-Cookbook-Second-Edition/tree/master/Chapter09/PhotobookCreator_DispatchGroups。在这里,你可以找到一个应用程序的仓库,该应用程序将一组照片转换成 PDF 相册。你可以直接从 GitHub 或使用git下载应用程序源代码:
git clone https://github.com/PacktPublishing/Swift-Cookbook-Second-
Edition/tree/master/Chapter09/PhotobookCreator_DispatchGroups/ PhotobookCreator.git
如果你构建并运行应用程序,你将看到一系列样本图像,并能够添加更多:

图 9.12 – 样本图像
当你点击生成相册时,应用程序将选择你选择的照片,将它们调整到相同的大小,并将它们保存为可以导出或分享的多页 PDF。根据包含的照片数量和设备的性能,这个过程可能需要一些时间来完成。在这段时间内,整个界面都无响应;例如,你不能滚动图片。
如何操作...
让我们分析为什么在生成相册时应用程序会变得无响应,以及我们如何修复这个问题:
- 打开
PhotoBookCreator项目并导航到PhotoCollectionViewController.swift。在这个文件中,你会找到以下方法:
func generatePhotoBook(with photos: [UIImage]) {
let resizer = PhotoResizer()
let builder = PhotoBookBuilder()
// Scale down (can take a while)
var photosForBook = resizer.scaleToSmallest(of: photos)
// Crop (can take a while)
photosForBook = resizer.cropToSmallest(of: photosForBook)
// Generate PDF (can take a while)
let photobookURL = builder.buildPhotobook(with:
photosForBook)
let previewController = UIDocumentInteractionController(url:
photobookURL)
previewController.delegate = self
previewController.presentPreview(animated: true)
}
在这个方法中,我们调用三个可能需要相当长时间才能完成的函数。我们取一个函数的输出并将其输入到下一个函数中,结果是相册的 URL,然后我们使用一些 UI 来预览和导出。
这个工作,包括调整和裁剪照片,然后生成相册,正在处理 UI 触摸事件的同一个队列中进行,也就是主队列,这就是为什么我们的 UI 变得无响应。
- 为了释放主队列以处理 UI 事件,我们可以创建自己的私有队列,我们可以使用它来执行我们的长时间运行函数:
import Dispatch
class PhotoCollectionViewController: UIViewController {
//...
let processingQueue = DispatchQueue(label: "Photo processing
queue")
func generatePhotoBook(with photos: [UIImage]) {
processingQueue.async { [weak self] in
let resizer = PhotoResizer()
let builder = PhotoBookBuilder()
// Get smallest common size
let size = resizer.smallestCommonSize(for: photos)
// Scale down (can take a while)
var photosForBook = resizer.scaleWithAspectFill(photos,
to: size)
// Crop (can take a while)
photosForBook = resizer.centerCrop(photosForBook, to:
size)
// Generate PDF (can take a while)
let pbURL = builder.buildPhotobook(with: photosForBook)
// Show preview with export options
let previewController =
UIDocumentInteractionController(url: pbURL)
previewController.delegate = self
previewController.presentPreview(animated: true)
}
}
}
通过在我们的 DispatchQueue 上调用 async 方法并提供一段代码块,我们正在安排该代码块被执行。当资源可用时,GCD 将执行该代码块。现在,我们的长时间运行代码不会阻塞主队列,因此我们的 UI 将保持响应;然而,如果你只运行这个更改后的应用,当应用尝试显示预览视图控制器时,你会得到一些非常奇怪的行为。
我们刚才讨论了 UI 触摸事件被发送到主队列的事实,这就是为什么我们想要避免阻塞它;然而,UIKit 预期所有 UI 事件都在主队列上发生。由于我们目前是从我们的私有队列创建和展示预览视图控制器,我们违反了 UIKit 的这个预期,这可能导致许多错误,包括 UI 元素永远不会出现,或者出现得比它们展示的时间长得多。
- 为了解决这个问题,我们需要确保当我们准备好展示我们的 UI 时,我们在主队列上执行这个操作:
func generatePhotoBook(with photos: [UIImage], using builder:
PhotoBookBuilder) {
processingQueue.async { [weak self] in
let resizer = PhotoResizer()
let builder = PhotoBookBuilder()
// Get smallest common size
let size = resizer.smallestCommonSize(for: photos)
// Scale down (can take a while)
var photosForBook = resizer.scaleWithAspectFill(photos, to:
size)
// Crop (can take a while)
photosForBook = resizer.centerCrop(photosForBook, to: size)
// Generate PDF (can take a while)
let pbURL = builder.buildPhotobook(with: photosForBook)
DispatchQueue.main.async {
// Show preview with export options
let previewController = UIDocumentInteractionController
(url: pbURL)
previewController.delegate = self
previewController.presentPreview(animated: true)
}
}
}
现在,如果你运行应用,你会发现你可以在与 UI 交互的同时生成相册;例如,能够滚动表格视图。
它是如何工作的...
GCD 使用队列在多线程环境中管理工作代码块。队列按照 先进先出(FIFO)策略操作。当 GCD 确定资源可用时,它将从队列中取出下一个代码块并执行它。一旦代码块执行完成,它将从队列中移除:

图 9.13 – FIFO 策略
DispatchQueue 有两种类型:串行 和 并发。在队列的最简单形式中,即串行队列,GCD 将一次只从队列顶部执行一个代码块。当每个代码块执行完成时,它将从队列中移除,每个代码块向上移动一个位置。
处理所有 UI 事件的主队列是一个串行队列的例子,这也解释了为什么在主队列上执行长时间运行的操作会导致你的 UI 变得无响应。当你的长时间运行操作正在执行时,主队列上的其他任何操作都不会执行,直到长时间运行的操作完成。
使用第二种类型的队列,即并发队列,GCD 将在允许的资源范围内,在不同的线程上执行尽可能多的代码块。下一个要执行的代码块将是堆栈顶部最近的一个尚未执行的代码块,当代码块执行完成后,它们将从堆栈中移除:

图 9.14 – 添加第二种队列时的执行情况
当你有许多相互独立的操作时,并发队列非常有用。我们将在 并发队列和调度组 菜谱中进一步探讨并发队列。
参见
-
libdispatch的 GitHub 仓库:github.com/apple/swift-corelibs-libdispatch
并发队列和分发组
在上一个菜谱中,我们探讨了使用私有串行队列来通过将长时间运行的操作从主队列中移除来保持我们的应用响应。在本菜谱中,我们将把我们的操作分解成更小、独立的块,并将它们放置在并发队列中。
准备工作
我们将在上次菜谱中改进的应用程序的基础上构建,这是一个从照片集合中生成 PDF 照片书的应用程序。您可以在github.com/PacktPublishing/Swift-Cookbook-Second-Edition/tree/master/Chapter09获取此应用程序的代码,并选择PhotobookCreator_DispatchGroups文件夹。
在 XCode 中打开项目,并导航到PhotoCollectionViewController.swift文件。
如何做到这一点...
在上一个菜谱中,我们看到了分发队列如何按照 FIFO 策略操作。GCD 将执行队列顶部的块,并在执行完成后将其从队列中移除。GCD 允许同时执行的块的数量将取决于所使用的队列类型。串行队列在任何时候都只会执行一个代码块;队列中的其他块将不得不等待队列顶部的块执行完毕。然而,对于并发队列,GCD 将并发执行尽可能多的块,直到有可用资源。我们可以通过将工作分解成更小、独立的块来更有效地使用并发队列,允许它们并发执行。
看看generatePhotoBook方法的当前实现。自上次菜谱以来,唯一的变化是我们现在在传递给generatePhotoBook方法的完成回调中呈现预览 UI。这简化了方法,并防止我们在async块中弱捕获self:
func generatePhotoBook(with photos: [UIImage], completion: @escaping
(URL) -> Void) {
processingQueue.async {
let resizer = PhotoResizer()
let builder = PhotoBookBuilder()
// Get smallest common size
let size = resizer.smallestCommonSize(for: photos)
// Scale down (can take a while)
var photosForBook = resizer.scaleWithAspectFill(photos,
to: size)
// Crop (can take a while)
photosForBook = resizer.centerCrop(photosForBook, to: size)
// Generate PDF (can take a while)
let photobookURL = builder.buildPhotobook(with: photosForBook)
DispatchQueue.main.async {
// Fire completion handler which will show the preview UI
completion(photobookURL)
}
}
}
我们正在执行的工作是在一个代码块中,我们将其放置在队列上。让我们看看我们是否可以将它分解成更小、独立的工作块,这些工作块可以并发执行。我们不能并发执行缩放和裁剪操作,因为它们将操作相同的UIImage对象,如果图像在缩放之前被裁剪,我们将不会得到预期的结果。
然而,我们可以分别对每张照片应用缩放和裁剪操作,并在其他照片上并发执行该操作。一旦每张照片都被缩放和裁剪,我们可以使用处理过的图像来生成照片书:

图 9.15 – 串行方法和并发方法
以这种方式分割工作可能不会使整体操作更快,因为每个工作块都有开销。将工作分割成并发块带来的效率提升将取决于涉及的操作以及可以运行的并发操作的数量。
我们现在有可以并发运行的工作块,但我们给自己带来了一个新的问题;我们如何协调所有这些并发工作,以便我们知道它们都已经完成,我们可以开始生成相册?在这里,GCD 可以帮助我们。我们可以使用DispatchGroup来协调我们对每个图像的操作,并在它们全部完成时得到通知。
分发组就像体育场的大门。每次有人进入体育场,他们都会通过大门,并且有一个人被计算为在体育场内,到了最后一天,当人们离开体育场并通过大门时,体育场内的人数会减少。一旦体育场内没有人了,就可以关灯了。
让我们使用分发组来协调相册创建者的工作:
- 首先,我们将创建一个分发组:
let group = DispatchGroup()
- 每次我们开始一个块来调整照片大小时,我们将进入组:
group.enter()
- 工作完成后,我们将离开小组:
group.leave()
- 最后,我们将要求小组在最后一次调整大小操作完成后并离开小组时通知我们。然后,我们可以取走处理过的文件并生成相册:
group.notify(queue: processingQueue) {
//.. generate photo book
//.. execute completion handler
}
- 让我们看看我们的
generatePhotoBook方法,现在使用并发队列和分发组:
let processingQueue = DispatchQueue(label: "Photo processing
queue", attributes: .concurrent)
func generatePhotoBook(with photos: [UIImage], completion: @escaping
(URL) -> Void) {
let resizer = PhotoResizer()
let builder = PhotoBookBuilder()
// Get smallest common size
let size = resizer.smallestCommonSize(for: photos)
let processedPhotos = NSMutableArray(array: photos)
let group = DispatchGroup()
for (index, photo) in photos.enumerated() {
group.enter()
processingQueue.async {
// Scale down (can take a while)
var photosForBook = resizer.scaleWithAspectFill(
[photo], to: size)
// Crop (can take a while)
photosForBook = resizer.centerCrop([photo], to: size)
// Replace original photo with processed photo
processedPhotos[index] = photosForBook[0]
group.leave()
}
}
group.notify(queue: processingQueue) {
guard let photos = processedPhotos as? [UIImage] else {
return }
// Generate PDF (can take a while)
let photobookURL = builder.buildPhotobook(with: photos)
DispatchQueue.main.async {
completion(photobookURL)
}
}
}
它是如何工作的...
分发队列默认是串行的,因此要创建一个并发队列,我们可以在创建时传递.concurrent属性:
let processingQueue = DispatchQueue(label: "Photo processing queue",
attributes: .concurrent)
在我们遍历所有照片之前,我们设置任何不是针对每张照片的特定内容:
let resizer = PhotoResizer()
let builder = PhotoBookBuilder()
// Get smallest common size
let size = resizer.smallestCommonSize(for: photos)
let processedPhotos = NSMutableArray(array: photos)
let group = DispatchGroup()
这包括创建DispatchGroup,我们将用它来协调工作。由于我们的照片调整大小现在将并发进行,我们需要一个地方来收集处理过的照片。我们可以使用 Swift 数组来做到这一点;然而,Swift 数组是一个值类型,所以我们不能在多个块中使用它,因为每个块都会复制数组,而不是原始数组本身。
要用 Swift 数组解决这个问题,我们需要在视图控制器中将processedPhotos数组属性设置为,这意味着我们必须在需要解包的块中弱捕获 self。解决这个问题的更简单的方法是使用具有引用语义的集合;Foundation框架以NSArray和NSMutableArray的形式提供这种语义。正如我们在本章前面所看到的,理解正在使用的构造的语义并选择合适的工具来完成工作是很重要的:
for (index, photo) in photos.enumerated() {
group.enter()
processingQueue.async {
// Scale down (can take a while)
var photosForBook = resizer.scaleWithAspectFill([photo],
to: size)
// Crop (can take a while)
photosForBook = resizer.centerCrop([photo], to: size)
// Replace original photo with processed photo
processedPhotos[index] = photosForBook[0]
group.leave()
}
}
对于每张照片,我们进入组,并将缩放工作放在并发队列上。我们可以使用之前使用的相同的缩放和裁剪方法,只需传递包含一张照片的数组。一旦工作完成,我们将用处理过的照片替换数组中的原始照片,并离开组。
一旦每个块都离开组,这个notify块将执行。我们检索处理过的照片,并使用它们来生成照片簿。最后,我们确保完成处理程序在主队列上执行:
group.notify(queue: processingQueue) {
guard let photos = processedPhotos as? [UIImage] else { return }
// Generate PDF (can take a while)
let photobookURL = builder.buildPhotobook(with: photos)
DispatchQueue.main.async {
completion(photobookURL)
}
}
如果你构建并运行应用程序,你仍然可以生成照片簿,UI 仍然响应,现在 GCD 可以最好地利用可用资源来生成我们的照片簿。
相关内容
-
与派发队列相关的文档:
swiftbook.link/docs/dispatchqueue -
与派发组相关的文档:
swiftbook.link/docs/dispatchgroup
实现操作类
到目前为止,我们将长时间运行的操作作为代码块,称为闭包,在派发队列上进行调度。这使得将长时间运行代码从主队列中移除变得非常容易,但如果我们的意图是重用这段长时间运行的代码,传递它,跟踪其状态,并以面向对象的方式处理它,闭包并不是理想的选择。
为了解决这个问题,Foundation框架提供了一个名为Operation的对象,它允许我们将工作块封装在一个封装的对象中。
在这个菜谱中,我们将使用本章中使用的照片簿应用程序,并将我们的长时间运行代码块转换为Operation实例。
准备工作
我们将在上一个菜谱中改进的应用程序的基础上进行构建,这是一个从照片集合生成 PDF 照片簿的应用程序。你可以在这个应用程序的代码在github.com/PacktPublishing/Swift-Cookbook-Second-Edition/tree/master/Chapter09中找到,并选择PhotobookCreator_StartOperations文件夹。
打开文件夹并导航到PhotoCollectionViewController.swift文件。
如何做到这一点...
让我们回顾一下我们如何将工作分解成独立的部分:

图 9.16 – 并发方法块
我们可以将这些工作块中的每一个转换成单独的操作:
-
让我们创建一个操作来缩放和裁剪每张照片。
-
我们通过子类化
Operation类来定义一个操作,因此在这个项目中,创建一个新的 Swift 文件,并将其命名为PhotoResizeOperation.swift。 -
在最简单的
操作实现中,我们只需要重写一个方法,即main()方法,因此让我们从我们的generatePhotobook方法中复制并粘贴相关代码。这个main()方法将在操作开始时执行:
import UIKit
class PhotoResizeOperation: Operation {
override func main() {
// Scale down (can take a while)
var photosForBook = resizer.scaleWithAspectFill([photo],
to: size)
// Crop (can take a while)
photosForBook = resizer.centerCrop([photo], to: size)
// Replace original photo with processed photo
processedPhotos[index] = photosForBook[0]
}
}
- 仅复制和粘贴代码是不够的,因为之前有一些依赖项被代码块捕获。现在我们必须明确地提供这些依赖项给操作:
class PhotoResizeOperation: Operation {
let resizer: PhotoResizer
let size: CGSize
let photos: NSMutableArray
let photoIndex: Int
init(resizer: PhotoResizer, size: CGSize,
photos: NSMutableArray, photoIndex: Int) {
self.resizer = resizer
self.size = size
self.photos = photos
self.photoIndex = photoIndex
}
override func main() {
// Retrieve the photo to be resized.
guard let photo = photos[photoIndex] as? UIImage else {
return }
// Scale down (can take a while)
var photosForBook = resizer.scaleWithAspectFill([photo],
to: size)
// Crop (can take a while)
photosForBook = resizer.centerCrop(photosForBook, to: size)
photos[photoIndex] = photosForBook[0]
}
}
- 我们已经将调整大小的代码块转换成了操作。现在我们需要对生成照片书的代码块做同样的处理:
import UIKit
class GeneratePhotoBookOperation: Operation {
let builder: PhotoBookBuilder
let photos: NSMutableArray
var photobookURL: URL?
init(builder: PhotoBookBuilder, photos: NSMutableArray) {
self.builder = builder
self.photos = photos
}
override func main() {
guard let photos = photos as? [UIImage] else { return }
// Generate PDF (can take a while)
photobookURL = builder.buildPhotobook(with: photos)
}
}
我们将依赖项传递给操作,就像在PhotoResizeOperation中一样。这个操作的输出是一个结果照片书的 URL。我们将其作为操作的属性公开,以便可以在操作外部检索。
- 在将我们的工作代码块转换为操作后,让我们切换到
PhotoCollectionViewController.swift并更新我们的generatePhotoBook方法以使用这个新操作:
let processingQueue = OperationQueue()
func generatePhotoBook(with photos: [UIImage], completion: @escaping
(URL) -> Void) {
let resizer = PhotoResizer()
let builder = PhotoBookBuilder()
// Get smallest common size
let size = resizer.smallestCommonSize(for: photos)
let processedPhotos = NSMutableArray(array: photos)
let generateBookOp = GeneratePhotoBookOperation(builder:
builder, photos: processedPhotos)
for index in 0..<processedPhotos.count {
let resizeOp = PhotoResizeOperation(resizer: resizer,
size: size,
photos: processedPhotos,
photoIndex: index)
generateBookOp.addDependency(resizeOp)
processingQueue.addOperation(resizeOp)
}
generateBookOp.completionBlock = { [weak generateBookOp] in
guard let pbURL = generateBookOp?.photobookURL else {
return
}
OperationQueue.main.addOperation {
completion(pbURL)
}
}
processingQueue.addOperation(generateBookOp)
}
让我们一步一步地看看这些更改:
- 在我们之前使用
DispatchQueue来管理代码块的执行的地方,现在使用OperationQueue来管理操作:
let processingQueue = OperationQueue()
- 以下代码中的方法签名和我们需要提前生成的依赖项保持不变:
func generatePhotoBook(with photos: [UIImage], completion: @escaping (URL) -> Void) {
let resizer = PhotoResizer()
let builder = PhotoBookBuilder()
// Get smallest common size
let size = resizer.smallestCommonSize(for: photos)
let processedPhotos = NSMutableArray(array: photos)
- 接下来,我们创建生成照片书的操作,传递依赖项:
let generateBookOp = GeneratePhotoBookOperation(builder: builder,
photos: processedPhotos)
- 尽管操作将在最后执行,但我们首先创建它,以便我们可以使其依赖于我们即将创建的调整大小操作。一个操作在创建时不会立即执行。它只有在调用
Operation的start()方法时才会执行,这可以手动调用,或者如果将Operation放置在OperationQueue上,它将由队列在适当的时候调用:
for index in 0..<processedPhotos.count {
let resizeOp = PhotoResizeOperation(resizer: resizer,
size: size,
photos: processedPhotos,
photoIndex: index)
generateBookOp.addDependency(resizeOp)
processingQueue.addOperation(resizeOp)
}
现在,正如您可以从前面的代码中看到的那样,我们遍历我们打算处理的照片数量,并为每一张照片创建一个调整大小的操作,传递依赖项。
在我们转向使用Operation的过程中,我们失去了一件事,那就是DispatchGroup的使用,我们曾用它来确保只有在所有照片调整大小代码块完成之后才生成照片书。然而,我们可以通过操作依赖项达到相同的目标。一个操作可以被声明为依赖于一组其他操作,因此它将不会开始执行,直到它所依赖的操作完成。为了确保我们刚刚创建的generateBookOp操作仅在所有PhotoResizeOperation操作完成时执行,我们将它们中的每一个都添加为generateBookOp的依赖项。
完成这些操作后,我们可以将每个PhotoResizeOperation放置在OperationQueue上:
generateBookOp.completionBlock = { [weak generateBookOp] in
guard let pbURL = generateBookOp?.photobookURL else {
return
}
OperationQueue.main.addOperation {
completion(pbURL)
}
}
Operation有一个completionBlock属性;任何设置在这里的块将在操作完成后执行。我们可以使用这个属性在主队列上触发我们的完成处理程序。由于我们需要向完成处理程序提供由generateBookOp创建的相册的 URL,我们可以在块内部检索它,因为我们知道操作将完成,URL 将存在。然而,我们需要小心。我们向generateBookOp提供了一个闭包,它将被保留,并且我们在同一个块中使用它,因此捕获并保留了generateBookOp操作。这将导致保留循环,generateBookOp将永远不会从内存中释放。为了避免这种保留循环,我们在提供的块中指定我们想要弱捕获generateBookOp,使用[weak generateBookOp]捕获列表。这不会增加保留计数,从而防止保留循环的发生。
与DispatchQueue类似,OperationQueue有一个可用的属性,它提供了一个对主队列的引用,UI 事件在此队列上处理。此外,OperationQueue有一个便利方法,它将代码块包装在一个Operation中,并将其添加到队列中。我们使用这个方法来确保完成处理程序在主队列上执行:
processingQueue.addOperation(generateBookOp)
作为最后一步,我们将generateBookOp操作放在处理队列上。我们这样做作为最后一步非常重要,因为一旦放置在队列上,操作可能会立即执行,但我们不希望它立即执行。我们只想在所有调整大小操作完成后执行generateBookOp,如果我们在此之前将操作放在队列上,这可能会发生。
现在我们已经将我们的应用程序过渡到使用Operation,让我们构建并运行它,然后验证一切是否如预期那样工作。
我们相册应用程序的用户目前没有在过程开始后取消相册生成的能力,所以让我们添加这个功能:
- 我们将检查我们的两个操作,寻找检查
isCancelled属性并提前退出的机会。切换到PhotoResizeOperation.swift并在main()方法中添加isCancelled检查:
override func main() {
// Check if operation has been cancelled
guard isCancelled == false else { return }
guard let photo = photos[photoIndex] as? UIImage else { return }
// Scale down (can take a while)
var photosForBook = resizer.scaleWithAspectFill(
[photo], to: size)
// Check if operation has been cancelled
guard isCancelled == false else { return }
// Crop (can take a while)
photosForBook = resizer.centerCrop(photosForBook, to: size)
photos[photoIndex] = photosForBook[0]
}
在每项长时间运行的工作之前,我们检查isCancelled属性,如果它是true,我们就提前返回,这将完成操作。
- 我们同样可以在
GeneratePhotoBookOperation.swift中这样做:
override func main() {
// Check if operation has been cancelled
guard isCancelled == false else { return }
guard let photos = photos as? [UIImage] else { return }
// Generate PDF (can take a while)
photobookURL = builder.buildPhotobook(with: photos)
}
-
接下来,我们需要添加一些用户界面,允许用户在生成过程中取消相册的生成。这是一个练习,或者你可以切换到
end-operations分支来查看我是如何实现的。 -
一旦用户选择取消生成相册,我们可以调用以下命令:
processingQueue.cancelAllOperations()
这将在队列中的所有操作上触发cancel()方法。
现在我们有一个具有可取消的长时间运行操作的应用程序。
它是如何工作的...
OperationQueue 如何知道何时开始一个操作以及何时从队列中移除它?它是通过监控操作的状态来知道的。Operation 类在其生命周期中会经历多个状态转换。以下图表描述了这些状态转换是如何发生的:

图 9.17 – 操作生命周期
通过 Operation 上的多个布尔属性暴露操作的状态信息,操作队列使用这些属性来知道何时对操作执行某些操作。让我们逐一查看这些属性:
var isReady: Bool
当所有依赖项完成时,操作将返回 true 给 isReady。如果没有依赖项,它将始终返回 true。队列只有在 isReady 为 true 时才会开始执行操作:
var isExecuting: Bool
一旦对操作调用 start,无论是手动还是由队列调用,isExecuting 将返回 true,当操作执行完毕时,isExecuting 将恢复为返回 false。
由于操作会保留在队列中,直到它们完成,因此队列使用 isExecuting 属性来确保它不会对一个已经开始的操作调用 start:
var isFinished: Bool
一旦操作完成了所需的任何处理,isFinished 应该返回 true。当 isFinished 开始返回 true 时,它将被从队列中移除,并且队列将不再保持对操作的引用。对于我们在前面实现的 Operation 的最简单实现,isFinished 在 main() 方法执行完毕时会自动返回 true:
var isCancelled: Bool
可以通过在操作上调用 cancel() 方法来取消操作。一旦调用,isCancelled 属性将返回 true。这可以用来提前退出长时间运行的操作,但是检查 isCancelled 方法并在它返回 true 时中断任何长时间运行的代码取决于你。
参见
与 Operation 类相关的文档:swiftbook.link/docs/operation
SwiftUI 和 Combine 框架
在 2019 年的 Apple 全球开发者大会(WWDC)上,苹果公司宣布了 SwiftUI,这是一个全新的、从头开始用 Swift 编写的 用户界面(UI)框架,让很多人感到惊讶。
利用声明式编程范式,SwiftUI 不仅提供了一种强大的方式来程序化创建和设计你的 UI,还提供了一种功能性和逻辑性的方法。
在 WWDC 19 的许多其他公告中,苹果公司还宣布了其进入响应式编程流的新框架,名为 Combine。Combine 替换了我们在 iOS 和 macOS 开发中习惯使用的传统代理模式。
随着 SwiftUI 对 UI 模式编写程序化动态的改变,Combine 是 SwiftUI 框架的一个受欢迎的补充。在本章中,我们将深入了解 SwiftUI 的内部工作原理以及如何构建我们自己的应用程序——同时,我们将整合 Combine 的强大功能,以提供真正独特和响应式的流程。
在本章中,我们将涵盖以下食谱:
-
声明式语法
-
函数构建器、属性包装器和不可见返回类型
-
在 SwiftUI 中构建简单视图
-
Combine 和 SwiftUI 中的数据流
第十一章:技术要求
你可以在 GitHub 上找到本章的代码文件,链接为 github.com/PacktPublishing/Swift-Cookbook-Second-Edition/tree/master/Chapter10
观看以下视频以查看代码的实际操作:bit.ly/3qE2mpv
声明式语法
随着 SwiftUI 的引入,出现了一种新的编程范式,称为声明式语法。好吧,我说是“新的”——实际上它已经存在一段时间了;只是我们从未真正在 iOS 或 macOS 开发中使用过。在本节中,我们将探讨声明式语法的确切含义以及它与我们已经习惯看到的语法风格的比较。
准备工作
对于本节,你需要从 Mac App Store 获取的最新版本的 Xcode。
如何操作...
-
打开 Xcode 并选择 文件 | 新建 | Playground,然后选择 空白 以打开一个新的 Playground 画布进行工作。
-
打开之后,添加以下语法:
import PlaygroundSupport
import SwiftUI
我们之前已经看到过的第一个 import 语句,应该已经熟悉了。下一个是我们对 SwiftUI 的 import 语句——为什么需要它,解释得很清楚。
- 现在,让我们通过添加以下突出显示的代码来在 SwiftUI 中创建一个视图:
import PlaygroundSupportimport SwiftUI struct MyView: View {
var body: some View {
VStack {
Text("Swift Cookbook")
}
}
}
所有 SwiftUI 视图都是在符合 View 类型的结构体中构建的——然后它包含另一个结构体,这个结构体看起来有点像计算属性 body,它反过来又符合 some View。在这个属性(或称为“函数构建器”,我们将在本章后面讨论)内部,我们有某些元素开始构成我们的 UI。
有一个VStack或垂直堆叠,它将把所有封装的视图“垂直”地包裹在其中。VStack 再次是一个View。
在这里,我们有一个Text()视图,我们设置要显示的文本。
- 如果我们在 Playground 中添加以下内容,我们就能看到 SwiftUI 的实际应用:
PlaygroundPage.current.setLiveView(MyView())
然而,声明式语法在这个过程中的作用在哪里?嗯,它已经做到了;你已经在你的 struct 中写下了它。让我们更深入地了解声明式语法是如何工作的。
它是如何工作的...
在 SwiftUI 中,一切都是由View组成的,从展示给应用窗口的主容器,到文本、按钮,甚至是切换器。
回想一下 UIKit 的工作方式,这个理论并不太相似——大多数对象都是UIView()的子类。
唯一的基本区别是,在 SwiftUI 中,所有这些布局和构建都更加可见;这是声明式语法的作用。最好以功能和逻辑的方式思考声明式语法。
我想在视图中垂直对齐项目:
VStack {}
然后我想添加一个Text框:
Text("Swift Cookbook")
然后,让我们添加一个按钮:
Button(action: {
print("Set Action Here...")
}, label: {
Text("I'm going to perform an action")
})
甚至按钮的构建本身也是声明式的:设置一个动作;设置一个标签。一切都是功能性的。
另一种思考方式可以类似于我们处理食物食谱的方式:
-
切洋葱。
-
炒洋葱。
-
添加调味料。
-
以此类推...
使用我们更传统的编程风格(或称为命令式编程,正如它所知),你可能执行的事情会有所不同,并且逻辑性会稍微差一些:
-
获取调味料
-
获取洋葱
-
剥洋葱
-
切洋葱
-
热锅
-
以此类推...
虽然使用声明式语法,所有前面的步骤仍然需要存在才能使其工作,但构建它的框架为你做了很多工作——我们只是简单地“告诉它”要做什么。
还有更多...
声明式语法已经存在了一段时间;你可能在使用它之前甚至没有意识到。让我们看看以下结构化查询语言(SQL)的语法:
SELECT column1, column2, ...
FROM table_name
WHERE condition;
注意到什么熟悉的东西了吗?没错:就在那里...给我从特定表格中的column1和column2,其中满足这个条件。
最近,声明式语法已经进入甚至更多的 UI 框架,如 Google 的 Flutter,以及最新的 Android 的新 Jetpack Compose,这两个框架都使用声明式语法风格,允许开发者和设计师构建 UI。
我们已经提到好几次了,声明式语法给我们提供了一个更功能性和逻辑性的编程方法。它们是作为整体声明范式之下的范式。例如,SQL 位于领域特定语言(DSL)中,以及 HTML 和其他标记语言。
参见
-
Android Jetpack Compose:
developer.android.com/jetpack/compose -
Google 的 Flutter:
flutter.dev/
函数构建器、属性包装器和不可见返回类型
SwiftUI 确实带来了很多好处,尤其是在它完全使用 Swift 的核心构建时。这本身就有很多好处,包括利用我们将在本节中介绍的一些功能。
准备工作
对于这一部分,你需要从 Mac App Store 获取的最新版本的 Xcode。
如何做到这一点...
- 继续使用我们现有的 Playground 项目,让我们再次看看事物是如何“堆叠”起来的。我们将首先再次审视我们的
VStack:
VStack {
Text("Swift Cookbook")
Button(action: {
print("Set Action Here...")
}, label: {
Text("I'm going to perform an action")
})
}
这里有一段代码,在 SwiftUI 术语中,这是一个要显示的视图。这个视图是一个垂直堆叠——想象一下UITableView,但同时不要把它和UITableView相比较,因为试图将 SwiftUI 与 UIKit 相比较是不好的做法。
所有的代码都位于我们的VStack内部,将垂直显示,然后返回到主视图,但添加我们的Text()和Button()视图到VStack的逻辑在哪里?没有item或row用于索引(看,与UITableView比较是不好的);也没有在构建数组时你会看到的.add()或.append()函数。所有的一切都位于所谓的函数构建器内部。
- 为了增加趣味性,我们再添加一个:
VStack {
Text("Swift Cookbook")
Button(action: {
print("Set Action Here...")
}, label: {
Text("I'm going to perform an action")
})
HStack {
Text("By Keith & Chris")
Image(systemName: "book")
}
}
在前面的代码中,我们添加了一个HStack,正如你所猜到的,它给我们提供了一个水平堆叠的视图——另一个像之前一样的函数构建器,这次它包含了一个Text()和一个Image()视图。
注意我们如何在现有的VStack内部添加了我们的HStack函数构建器?正如我们之前所说的:我们的堆叠只是视图,所以顶层VStack只是把它当作那样处理,而HStack则负责安排它的Text()和Image()视图。
但这里返回的是什么?在函数中程序性地构建视图时,你可能会期望看到return关键字,以及特定于返回对象类型的返回类型。
- 然而,随着 Swift 5.1 的推出,我们可以利用不可见返回类型的强大功能。让我们回顾一下我们的 SwiftUI 视图的主体:
struct MyView: View {
var body: some View {
}
}
注意some View的返回类型。这是一个不可见返回类型,它允许 SwiftUI 返回任何符合 View 协议的类型,例如Text、Button、Image等等。没有这个,SwiftUI 在允许我们构建视图方面就不会如此灵活,我们的视图构建器也将不存在。
但不可见返回类型的美丽之处在于它们不是 SwiftUI 特有的;它们只是 Swift 语言的自然演变,再次证明了 SwiftUI 是如何从核心 Swift 编程语言构建起来的。
在这里我们看到的另一个特点是省略了return关键字。再次强调,这是 Swift 5.1 中引入的新特性:我们的 SwiftUI 代码现在可以解释一个最终的返回类型,并将其传递回视图层次结构。那么我们的HStack或VStack呢?嗯,因为这些是函数构建器,它们不会被作为返回值返回;更多的是它们被添加到栈中,然后栈再向上传递。
然而,总有这样的可能性,你可能需要一个HStack与一个VStack并排放置,如下所示:
VStack { }
HStack {
Text("I'm sitting underneath a HStack")
}
到目前为止,编译器需要一点帮助。不幸的是,我们无法简单地添加一个return关键字,因为我们希望两者都返回,所以我们可以将这些添加到另一个 Stack 中——但因为我们实际上不需要一个,所以这是不必要的,所以我们只是将这些包装在Group()视图中:
Group {
VStack { }
HStack {
Text("I'm sitting underneath a HStack")
}
}
Group视图本身也是一个可以向上传递为some View的视图——真不错!
- 我们当然正在收集所有必要的成分,以便开始使用 SwiftUI,但在我们深入之前,让我们看看 SwiftUI 中引入的另一个特性,再次来自我们不断发展的 Swift 编程语言。
SwiftUI 中的属性包装器是真正帮助其发光的特性之一,用于广泛的各种用途。每个包装器的主要目的是减少你对特定视图所需的维护量。让我们看看你可能使用的一些更常见的例子:
@State
@State允许 SwiftUI 在不调用特定函数的情况下修改特定视图的特定属性。例如,对你的代码进行以下突出显示的更改:
struct MyView: View {
@State var count: Int = 0
var body: some View {
Group {
VStack {
Text("Swift Cookbook")
Button(action: {
count += 1
}, label: {
if actionPerformed > 0 {
Text("Performed \(count) times")
} else {
Text("I'm going to perform an action")
}
})
HStack {
Text("By Keith & Chris")
Image(systemName: "book")
}
}
HStack {
Text("I'm sitting underneath a HStack")
}
}
}
}
我们添加了一个名为count的变量,并给它赋予了@State属性包装器,现在我们更新了按钮点击,使整数增加1。接下来,我们根据count的值添加一些逻辑。
通过改变count的值,我们现在将 SwiftUI 中使用的属性绑定到值和任何更改,从而使 SwiftUI 布局无效,并使用新值重建我们的视图。
好吧——在 Playground 中运行它,亲自试试。
@Binding是另一个常用的属性包装器,专门用于与传递给可能存在于另一个视图中的状态属性值一起使用。让我们看看我们可能如何做到这一点,首先通过分离一些代码并创建另一个 SwiftUI 视图。我们可以在当前的MyView下面这样做:
struct ResultView: View {
@Binding var count: Int
var body: some View {
Text("Performed \(count) times")
}
}
在这里,我们只是创建了一个返回Text视图的 SwiftUI 视图,但这是一种很好的方式,可以看看如何轻松地分离出你可能想要单独工作(或使其可重用)的特定视图逻辑。
注意,我们在这里也使用了count变量,尽管这次使用了@Binding包装器。这是因为我们不会在这个视图中控制count的值;这将在MyView外部完成:
struct MyView: View {
@State var count: Int = 0
var body: some View {
Group {
VStack {
Text("Swift Cookbook")
ResultView(count: $count)
Button(action: {
count += 1
}, label: {
Text("Perform Action")
})
HStack {
Text("By Keith & Chris")
Image(systemName: "book")
}
}
HStack {
Text("I'm sitting underneath a HStack")
}
}
}
}
在前面的高亮代码中,请注意我们仍然有我们的@State变量,并且我们的Button动作仍然在每次点击时更新这个值。我们还添加了新的ResultView,传递我们的@State变量并将其“绑定”到ResultView中的变量,从而在count更新时强制更改该视图。现在就试试吧。
还有更多...
我们已经介绍了一些您在 SwiftUI 中一开始可能会遇到的属性包装器,但还有很多其他的,其中一些我们将在本章后面介绍,特别是当涉及到与 Combine 框架一起工作时。然而,这里是一些其他属性包装器的概述以及它们能提供什么:
@EnvironmentObject
将其视为一个全局对象——有时您可能想在您的应用中跟踪某些事物,而这些事物您可能并不一定需要或觉得有必要传递给每个视图。然而,重要的是要知道EnvironmentObject不是一个单一的真实来源;它是数据——它只是从源中引用它,如果源发生变化,EnvironmentObject将触发状态变化(这正是我们想要的)。
我们可以使用这个示例来创建一个我们想要观察的类,使其符合ObservableObject:
class BookStatus: ObservableObject {
@Published var released = true
@Published var title = ""
@Published var authors = [""]
}
然后,您可以从我们的 SwiftUI 项目中的任何地方引用它,如下所示:
@EnvironmentObject var bookStatus: BookStatus
另一个很棒且确实方便的属性包装器是@AppStorage,它用作访问存储在UserDefaults中的数据的方式。截至 iOS 14,我们可以直接将其集成到我们的 SwiftUI 视图中,而无需额外的逻辑或函数。让我们看看我们如何做到这一点:
@AppStorage("book.title") var title: String = "Book Title"
注意这里,如果没有已经持久化的数据,我们将有一个默认值。如果我们想写入这个值,我们只需将属性赋值:
title = "Swift Cookbook"
在您的 Playgrounds 中尝试这个。如果您遇到困难,请查看 GitHub 资源以了解我是如何做到的。
由于 SwiftUI 的架构,将有一个——并且将会是一个不断增长的可用属性包装器列表。我们将在本章后面介绍更多内容,但这里有一些其他需要了解的:
-
@GestureState——跟踪正在执行的手势 -
@FetchRequest——执行对 Core Data 实体的检索
参见
在 SwiftUI 中构建简单视图
我们已经介绍了一些从 Swift 编程语言构建 SwiftUI 的基础知识,但现在是我们深入了解如何在 SwiftUI 中构建实际应用的时候了。
在本节中,我们将把到目前为止所学的所有知识应用到构建一个类似于我们之前创建的应用程序列表中。
准备工作
对于本节,您需要从 Mac App Store 获取 Xcode 的最新版本。
如何做到...
- 让我们开始吧。首先,我们将创建一个全新的项目——在 Xcode 中,点击文件 | 新建 | 项目。然后,选择单视图应用程序,并确保你已经选择了
SwiftUI作为界面样式,就像我这里做的那样:

图 10.1 – 创建新项目
- 点击“下一步”并选择你的磁盘上的一个位置。一旦完成,熟悉的 Xcode 界面应该会出现;然而,你可能注意到一些新内容。在右侧,你会看到实时窗口屏幕。点击“Resume”——你应该能看到以下内容:

图 10.2 – Xcode 和实时窗口屏幕
这里,我们有一个我们样板 SwiftUI 代码的生成预览。注意我们的 ContentView() 结构体,正如我们预期的那样,有它的 body。现在,看看下面的结构体:
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
这是我们的 PreviewProvider 结构体,它允许我们在设计时测试我们的 SwiftUI 视图,而无需不断重新运行模拟器和重新构建我们的应用程序——真方便!
现在,对于我们的初始列表,我们需要一些模拟数据:
- 创建以下结构体(如果你愿意,可以放在一个新文件中):
struct Task: Identifiable {
var description: String
var category: String
var id = UUID()
}
将其与 第八章 中的 Task 模型进行比较,服务器端 Swift,在属性和我们要它持有的数据方面几乎完全相同。我们唯一需要做出的不同是让我们的模型符合 Identifiable 并赋予一个唯一的 ID——这是 SwiftUI 对任何我们将迭代的内容的要求。
- 接下来,让我们创建一个用于一些模拟数据的小助手函数:
struct MockHelper {
static func getTasks() -> [Task] {
var tasks = [Task]()
tasks.append(Task(description: "Get Eggs", category:
"Shopping"))
tasks.append(Task(description: "Get Milk", category:
"Shopping"))
tasks.append(Task(description: "Go for a run", category:
"Health"))
return tasks
}
}
这组模拟数据在 SwiftUI 中将非常有用,但我们会很快看到。让我们将其连接到我们的应用程序。
- 返回到我们的
ContentView,将Hello World文本视图替换为以下内容:
List(MockHelper.getTasks()) { task in
Text(task.description)
}
注意到我们的 List() 视图有什么特点?没错:另一个函数构建器接受一个项目数组的参数,其中项目符合 Identifiable。在闭包中返回一个变量给我们,代表这些项目中的每一个,这样我们就可以在列表构建器中使用它们,就像我们想要的那样。
- 在这里,我们只是暂时将描述添加到一个文本视图中。如果实时预览中没有显示,请点击 Resume(有时在 Xcode 中需要这样做),你现在应该能看到以下内容:

图 10.3 – 预览屏幕
现在,在模拟器中运行这个,你应该看到完全相同的内容——做得好!
现在是时候进行一点重构了,所以请对 ContentView 进行以下突出显示的更改:
var tasks = [Task]()
var body: some View {
List(tasks) { task in
Text(task.description)
}
}
在这里,我们正在移除对模拟数据助手的调用,因为在生产代码中,我们不应该在这里调用它。进行此更改后,让我们转到 PreviewProvider 并进行以下突出显示的更改:
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(tasks: MockHelper.getTasks())
}
}
由于我们的 ContentView 结构体现在有一个非可选的 tasks 变量,它需要我们传递一些数据;在这里,我们将传递我们的 MockHelper 函数。
如果还没有显示,请继续进行实时预览。一切顺利的话,一切应该都按预期工作。然而,让我们看看在模拟器中运行时会发生什么——没错:没有数据。
如果你仔细看看我们刚才做的更改,你就会明白为什么我们现在只通过预览提供者将模拟数据注入到我们的ContentView中,这样当我们的实际应用程序运行时,我们的tasks数组是空的。
但这是正确的;因为我们将在我们的应用程序中连接到我们在第八章中创建的表示状态传输应用程序编程接口(REST API),通过预览提供者注入模拟数据,我们可以在构建任何网络功能之前继续构建我们的 UI,所以让我们继续。
记得在上一节中我们如何重构了我们的Result视图吗?我们将在这里为List视图中的每一行做同样的事情。
创建一个新的 SwiftUI 文件,并将其命名为ListRowView,然后更新模板代码,使其看起来如下:
var description: String = ""
var category: String = ""
var body: some View {
VStack {
Text(description)
Text(category)
}
}
从这里,回到ContentView.swift并做出以下突出显示的更改:
var body: some View {
List(tasks) { task in
ListRowView(description: task.description,
category: task.category)
}
}
就像之前一样,我们用我们刚刚创建的视图替换了我们的文本视图。点击“继续”以查看实时预览并亲自看看。
现在我们知道它正在工作,我们想要稍微修改一下ListRowView的样式,所以现在让我们回到那里,并首先更新预览提供者,这样我们就可以从那里开始工作:
struct ListRowView_Previews: PreviewProvider {
static var previews: some View {
ListRowView(description: "Description Field",
category: "Category Field")
}
}
如前所述的突出显示代码所示,我们为我们的实时预览添加了一些模拟数据。如果这还没有显示,请点击“继续”,你应该会看到以下内容:

图 10.4 – 带有模拟数据的实时预览屏幕
它确实可以工作,但看起来并不像List行,但这没关系——我们只需要告诉预览提供者我们打算用它来做什么:
List {
ListRowView(description: "Description Field",
category: "Category Field")
}
真的是很简单。我们只是将其包裹在一个List视图中,SwiftUI 就会完成剩下的工作,现在我们可以开始装饰我们的行。
在我们的ListRowView的主体中,进行以下突出显示的更改:
var body: some View {
VStack(alignment: .leading) {
Text(description)
.font(.title)
.padding(EdgeInsets(top: 0,
leading: 0,
bottom: 2,
trailing: 0))
.foregroundColor(.blue)
Text(category)
.font(.title3)
.foregroundColor(.blue)
}
}
在这里,我们向我们的视图添加了修饰符。修饰符允许我们装饰和样式化我们的视图,就像我们在 UIKit 中使用属性一样,每个修饰符都与特定类型的视图紧密相关。
SwiftUI 在可用的一些修饰符方面又前进了一步,为我们提供了丰富的选项。以.font修饰符为例:
.font(.largeTitle) // A font with the large title text style.
.font(.title) // A font with the title text style.
.font(.title2) // Create a font for second level hierarchical headings.
.font(.title3) // Create a font for third level hierarchical headings.
.font(.headline) // A font with the headline text style.
.font(.subheadline) // A font with the subheadline text style.
.font(.footnote) // A font with the footnote text style.
.font(.caption) // A font with the caption text style.
.font(.caption2) // Create a font with the alternate caption text
// style.
之前提到的字体都可以直接使用;然而,如果你仍然想指定自己的字体,你可以使用以下方式:
public static func system(_ style: Font.TextStyle, design: Font.Design
= .default) -> Font
让我们通过添加基于类别类型的图片来完善我们应用程序的基础:
HStack {
VStack(alignment: .leading) {
Text(description)
.font(.title)
.padding(EdgeInsets(top: 0,
leading: 0,
bottom: 2,
trailing: 0))
.foregroundColor(.blue)
Text(category)
.font(.title3)
.foregroundColor(.blue)
}
Spacer()
Image(systemName: "book")
.foregroundColor(.blue)
.padding()
}
注意在前面代码中,我们如何现在引入了一个 HStack 并将其包裹在当前的 VStack 中,这使得我们现在可以添加原始 VStack 之外的观点,并使它们水平对齐,就像我们对 Image 视图所做的那样。
在 SwiftUI 中,Spacer() 视图的用法将我们的两个水平视图(左边的 VStack 和右边的 Image)推离,使它们作为父视图(在这个例子中是主体)的引导和尾随视图。
它是如何工作的...
我们的应用程序的基础现在已准备好连接到外部数据源,但首先,让我们了解一下修饰符的工作原理以及我们如何创建自己的修饰符。你可以将以下代码添加到你的 ListRowView.swift 文件中,或者创建一个新的文件(由你决定):
struct CategoryText: ViewModifier {
func body(content: Content) -> some View {
content
.font(.title3)
.foregroundColor(.blue)
}
}
在这里,我们创建了一个名为 CategoryText 的结构体,它符合 ViewModifier 协议。在这里,有一个名为 Body 的函数,我们正在设置 .font 和 .foregroundColor 的修饰符。这些修饰符适用于从 View 继承的任何内容。
随意尝试一些可用的修饰符。你可以添加以下内容,并真正为 Category 标签增添一些活力:
struct CategoryText: ViewModifier {
func body(content: Content) -> some View {
content
.font(.footnote)
.foregroundColor(.blue)
.padding(4)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.blue, lineWidth: 2)
)
.shadow(color: .grey, radius: 2, x: -1, y: -1)
}
}
现在让我们将它添加到我们的 Text 视图中:
Text(category)
.modifier(CategoryText())
我们使用 .modifier(修饰符)来调用我们的自定义结构体;从可读性的角度来看,这很好,因为它允许你快速识别任何可能被自定义的内容,而不是系统 API 中的内容。然而,如果你像我一样,想要它看起来恰到好处,只需创建 View 的扩展:
extension View {
func styleCategory() -> some View {
self.modifier(CategoryText())
}
}
然后,像这样使用它:
Text(category)
.styleCategory()
你可能已经注意到我们略过了为某个类别分配特定图像的过程。这是出于很好的原因,因为我想要进一步探讨 SF Symbols 在 SwiftUI 中的使用。
SF Symbols 也可以与 UIKit 一起使用,在 SwiftUI 中表现异常出色,尤其是在使用我们刚刚玩过的修饰符时。
SF Symbols 正如其名——符号(不是图像),它们是字体,也可以像字体一样处理:
Image(systemName: "book")
.font(.system(size: 32, weight: .regular))
.foregroundColor(.blue)
.padding()
没有必要拉伸图像或2x或3x图像的资产。SF Symbols 将像处理你自己的矢量一样处理这些,额外的优点是它全部包含在 Swift API 中,无需额外的资产来增加你的应用程序的体积。
由于符号的名称只是以纯文本形式书写,让我们编写一个简单的函数来确定我们需要显示的内容:
struct Helper {
static func getCategoryIcon(category: String) -> String {
switch category.lowercased() {
case "shopping":
return "bag"
case "health":
return "heart"
default:
return "info.circle"
}
}
}
在理想的世界里,我们的类别将是具有 String 值的枚举,我们可以将其转换为,但在这个演示中,基本的字符串匹配就足够了。现在,将 Image 构造函数中的静态文本替换为调用这个新的静态函数:
Image(systemName: Helper.getCategoryIcon(category: category))
唯一的缺点是以下:你想要使用的图像是否包含在库中?
在 iOS 14 中,苹果引入了更广泛的 SF Symbols。甚至有一个你可以下载的 Mac 应用程序,它会以漂亮的图形用户界面(GUI)为你整理所有这些符号。
还有更多...
我们已经几次提到了预览提供者,但 SwiftUI 这个小巧实用的功能还有一些其他技巧。
默认情况下,它将预览的设备将是当前在 Xcode 中选定的设备,但如果你想要更改这一点,只需添加以下内容:
struct ListRowView_Previews: PreviewProvider {
static var previews: some View {
List {
ListRowView(description: "Description Field",
category: "Category Field")
}
.previewDevice(PreviewDevice(rawValue: "iPhone 12 Pro Max"))
.previewDisplayName("iPhone 12 Pro Max")
}
}
.previewDevice——这指定了你想要使用的设备——原始值字符串与特定设备的内部枚举值匹配(基本上是你在模拟器列表中看到的字符串名称)。
.previewDisplayName——这是为该设备提供的自定义名称,如实时预览窗口中所示。
显示名称在其他情况下也可能很有用,特别是如果我们有多个预览正在运行:
struct ListRowView_Previews_MockData2: PreviewProvider {
static var previews: some View {
List {
ListRowView(description: "Very Long Description Field, Very
Long Description Field", category: "Very Long Category
Field, Very Long Category Field")
}
.previewDevice(PreviewDevice(rawValue: "iPhone 12 Pro"))
.previewDisplayName("iPhone 12 Pro - Data #2")
}
}
如同所强调的,我们创建了一个额外的预览来在我们的实时预览窗口中运行,它反过来传递不同的数据和在不同设备上的测试。
通过这种方式,我们可以为每个我们想要的条件或样式创建模拟数据,并在所有类型的设备上预览,这样我们就可以直接测试,而无需在每个模拟器版本上启动。
SwiftUI 无疑非常强大,尤其是在 2020 年 WWDC 上最新发布的 iOS 14 中,事物有了极大的改进,但我们不能忘记,它还不到 2 年的历史,仍然处于婴儿期,你将不止一次需要回退到 UIKit 来使用某些组件。
幸运的是,Apple 已经为我们准备好了 UIViewRepresentable 协议,这是一个我们可以用来利用 UIKit 组件并将其作为 SwiftUI 视图返回的协议。
一个很好的例子是 UITextView(),目前 SwiftUI 或任何直接等效功能中都没有(尽管在 iOS 14 中,SwiftUI 的 TextEditor 现在已经做了我们想要的很多事情,但仍然不是直接替代品)。
创建一个新的 Swift 文件,并将其命名为 TextView,然后逐个粘贴以下方法:
struct TextView: UIViewRepresentable {
@Binding var text: String
func makeUIView(context: Context) -> UITextView {
let textView = UITextView()
return textView
}
}
我们的 UIViewRepresentable 协议要求我们符合某些函数,例如 makeUIView(),它反过来负责实例化我们想要包装的 UIKit 组件。
接下来,添加以下内容:
struct TextView: UIViewRepresentable {
// ...
func updateUIView(_ uiView: UITextView, context: Context) {
uiView.text = text
}
func makeCoordinator() -> Coordinator {
Coordinator($text)
}
// ...
}
通过 updateUIView(),我们设置我们的 UITextView 实例为我们想要的任何内容。在这里,我们正在设置变量的 text 值。
接下来,我们将添加 makeCoordinator() 函数,它返回一个 Coordinator 实例,填充我们的 @Binding 文本字段。将 Coordinator 视为处理我们可能用于 UIKit 组件的代理方法的一种方式。添加以下内容,这应该会更有意义:
struct TextView: UIViewRepresentable {
// ...
class Coordinator: NSObject, UITextViewDelegate {
var text: Binding<String>
init(_ text: Binding<String>) {
self.text = text
}
func textViewDidChange(_ textView: UITextView) {
self.text.wrappedValue = textView.text
}
}
}
看看我们的 Coordinator 如何符合 UITextViewDelegate 协议,并且我们在其中实现了 textViewDidChange() 方法。由于我们传递的文本变量是一个 Binding 字符串,所做的更改将反映在被调用的代理方法中,就像在 UIKit 中一样:
@State var textViewString = ""
TextView(text: $textViewString)
为了调用这个功能,我们只需像添加任何其他 SwiftUI 视图一样添加它。
参见
SF Symbols Mac 应用:developer.apple.com/design/human-interface-guidelines/sf-symbols/overview/
UIViewRepresentable:developer.apple.com/documentation/swiftui/uiviewrepresentable
SwiftUI 中的组合和数据流
多年来,响应式编程流在 iOS 和 macOS 的开发架构中扮演了重要角色。你可能听说过 RxSwift 和 RxCocoa,这是一个致力于响应式流的庞大社区,它允许异步事件被处理。
如果你不太熟悉 Rx 或响应式编程的术语,你可能已经在你的代码库中看到了发布者、订阅者和操作符的使用。如果你看到了,那么你很可能在某些时候受到了响应式编程的影响。
在本节中,我们将探讨苹果为响应式编程提供的解决方案,称为Combine。在 2019 年 WWDC 上与 SwiftUI 一同推出,Combine 是 SwiftUI 新布局和结构的完美伴侣(尽管不仅仅局限于 SwiftUI)。我们将探讨如何从 REST API 创建无缝的数据流,直到我们的 UI 层。
准备工作
对于本节,你需要从 Mac App Store 获取最新版本的 Xcode 以及上一节的项目。
如何做到这一点...
首先,我们将通过以下突出显示的更改将我们的Task模型更新为类:
class Task: Identifiable {
var id = UUID()
let response: TaskResponse
init(taskResponse: TaskResponse) {
self.response = taskResponse
}
var category: String {
return response.category ?? ""
}
var description: String {
return response.description ?? ""
}
}
在这里,我们已经将我们的模型转换为类(关于这一点稍后讨论),并添加了一个自定义初始化器和几个计算属性。
我们还添加了一个类型为TaskResponse的变量,所以现在让我们在新的文件中创建它:
struct TaskResponse: Codable {
let category: String?
let description: String?
}
在这里,我们有一个来自我们之前创建的 REST API 的基本codable响应。
现在,为了编写一些样板式的网络代码;创建一个名为NetworkManager.swift的新文件,并将以下代码添加到其中:
class NetworkManager {
static func loadData(url: URL, completion: @escaping
([TaskResponse]?) -> ()) {
URLSession.shared.dataTask(with: url) { data, response, error
in
guard let data = data, error == nil else {
completion(nil)
return
}
if let response = try? JSONDecoder().decode(
[TaskResponse].self, from: data) {
DispatchQueue.main.async {
completion(response)
}
}
}.resume()
}
}
在这里,我们有一个基本的URLSession实现,它接收一个统一资源定位符(URL),将JavaScript 对象表示法(JSON)响应解析为一个Codable对象(我们的TaskResponse模型)。我们创建的函数有一个完成处理程序,如果响应和解码成功,则返回一个TaskResponse模型的数组。
接下来,创建一个名为TaskViewModel的新文件,并添加以下代码:
class TaskViewModel: ObservableObject {
init() {
getTasks()
}
@Published var tasks = [Task]()
private func getTasks() {
guard let url = URL(string: "http://0.0.0.0:8080/tasks") else {
return
}
NetworkManager.loadData(url: url) { taskResponse in
if let taskResponse = taskResponse {
self.tasks = taskResponse.map(Task.init)
}
}
}
}
在前面的代码中,我突出显示了一些感兴趣的区域。首先是我们的类如何符合ObservableObject——这是必需的,因为我们的tasks变量有@Published包装器,并且将根据它们发生的情况寻找变化。
接下来是我们传递给NetworkingManager的本地 URL——这是我们之前创建的 REST API 的本地实例。
现在,回到我们的ContentView.swift文件,进行以下突出显示的更改:
struct ContentView: View {
@ObservedObject var model = TaskViewModel()
var body: some View {
List(model.tasks) { task in
ListRowView(description: task.description,
category: task.category)
}
}
}
我们现在已将tasks变量重命名为model,这反过来又创建了一个新的TaskViewModel()实例。
由于我们在这里更新了一些东西,因此我们需要调整注入模拟数据的方式的结构,因此对我们的MockHelper函数进行以下突出显示的更改:
var task = [Task]()
task.append(Task(taskResponse: TaskResponse(category: "Get Eggs",
description: "Shopping")))
task.append(Task(taskResponse: TaskResponse(category: "Get Milk",
description: "Shopping")))
task.append(Task(taskResponse: TaskResponse(category: "Go for a run",
description: "Health")))
let taskViewModel = TaskViewModel()
taskViewModel.tasks = task
return taskViewModel
由于我们的ContentView现在接受TaskResponse数组,我们已相应地进行了调整。
接下来是魔法——从我们之前的项目中,启动我们的本地 Task API 实例,并在数据库中添加一些内容:
curl -H "Content-Type: application/json" -X POST -d '{"description":"Remember the Eggs","category":"Shopping"}' http://0.0.0.0:8080/tasks
curl -H "Content-Type: application/json" -X POST -d '{"description":"Bread","category":"Shopping"}' http://0.0.0.0:8080/tasks
curl -H "Content-Type: application/json" -X POST -d '{"description":"Ring Mandy","category":"Home"}' http://0.0.0.0:8080/tasks
完成这些后,是时候见证魔法发生了。启动应用,如果一切顺利,你应该会看到类似这样的东西:

图 10.5 – 启动应用
这是一个简单但非常有效的演示,说明了 Combine 如何在 SwiftUI 中使用,以及应该如何使用。现在让我们看看这一切是如何实际工作的。
它是如何工作的...
让我们从ContentView开始,逐步回溯:
@ObservedObject var model = TaskViewModel()
这里有两个需要注意的地方——我们的模型是@ObservedObject的模型,这意味着对模型所做的任何更改都将触发更新并因此强制刷新我们的 UI(就像我们之前看到的@State一样)。
接下来,我们在ContentView渲染时实例化TaskViewModel()。让我们深入了解TaskViewModel并看看为什么:
class TaskViewModel: ObservableObject {
init() {
getTasks()
}
@Published var tasks = [Task]()
// ...
}
我们之前已经提到我们的类符合ObservableObject。这就是我们能够在ContentView中声明@ObservedObject的原因(我们正在创建一种数据流连接,可以说)。
注意这里,我们还添加了对getTasks()函数的调用,这样当我们在ContentView中初始化类时,我们将启动一个网络请求以获取任务列表。
如果我们现在快速查看我们的getTasks()函数,你会看到一旦我们得到成功的响应,我们就将其分配给我们的@Published tasks变量:
NetworkManager.loadData(url: url) { articles in
if let articles = articles {
self.tasks = articles.map(Task.init)
}
}
一旦变量更新,我们的Observable对象类就会让任何监听者知道有变化(例如,我们的ContentView中的@ObservedObject)。
如果你回顾一下UITableView的工作方式,如果有任何更新或更改数据源,我们随后必须手动调用UITableView.reloadData(),并在我们的 UI 层中。
采用这种方法,一切都被处理得恰到好处,并且处于正确的位置,将数据更改从真实来源传递到 UI 层。
参见
- Swift Combine:
developer.apple.com/documentation/combine
使用 Swift 中的 CoreML 和 Vision
Swift 编程语言自从首次推出以来已经走了很长的路,与许多其他编程语言相比,它仍然处于婴儿期。
然而,考虑到这一点,随着 Swift 及其在开源社区中的地位每一次发布,我们都看到它在如此短的时间内从强到更强。我们已经在第八章中介绍了服务器端 Swift,服务器端 Swift,这是又一次由开源社区推动的演变。
另一个快速发展的领域是机器学习,它再次受到社区力量的推动,并且行业中的巨头,如 TensorFlow,现在也支持 Swift 编程语言。
在本章中,我们将探讨苹果的机器学习产品——CoreML——以及我们如何使用 Swift 构建应用程序来读取和处理机器学习模型,从而实现智能图像识别。
我们还将探讨苹果的 Vision 框架以及它是如何与 CoreML 协同工作,使我们能够实时处理流到我们设备上的视频,并在飞行中识别对象。
在本章中,我们将介绍以下食谱:
-
构建图像捕获应用程序
-
使用 CoreML 模型检测图像中的对象
-
构建视频捕获应用程序
-
使用 CoreML 和 Vision 框架实时检测对象
第十二章:技术要求
您可以在 GitHub 上找到本章中提供的代码文件,网址为github.com/PacktPublishing/Swift-Cookbook-Second-Edition/tree/master/Chapter11
查看以下视频以查看代码的实际操作:bit.ly/2NmP961
构建图像捕获应用程序
在这个第一个食谱中,我们将创建一个应用程序,它可以捕获来自您的相册或相机拍摄的照片。这将使我们的 iOS 应用程序准备好,以便我们能够将 CoreML 集成到我们的照片中检测对象。
准备工作
对于这个食谱,您需要从 Mac App Store 获取的最新版本的 Xcode。
如何操作...
打开 Xcode 后,让我们开始吧:
-
在 Xcode 中创建一个新的项目。转到文件 | 新建 | 项目 | iOS App。
-
在
Main.storyboard中添加以下内容:-
添加带有两个选项的
UISegmentedControl(照片/相册和实时相机)。 -
接下来,在下面添加一个
UILabel视图。 -
在下面添加一个
UIImageView视图。 -
最后,添加一个
UIButton组件。
-
-
使用 AutoLayout 约束相应地间隔使用
UIImageView作为突出对象:

图 11.1 – 摄像头/照片应用程序
- 一旦我们有了这个,就让我们将这些连接到我们的
ViewController.swift文件中:
@IBOutlet weak var imageView: UIImageView!
@IBOutlet weak var labelView: UILabel!
@IBAction func onSelectPhoto(_ sender: Any)
注意,在前面的内容中,我们有两个IBOutlet和一个IBAction(我们不需要UIButton的出口,我们只关心它的动作)。
- 接下来,用以下代码填充
IBAction:
@IBAction func onSelectPhoto(_ sender: Any) {
let picker = UIImagePickerController()
picker.delegate = self
picker.allowsEditing = false
picker.sourceType =
UIImagePickerController.isSourceTypeAvailable(.camera) ?
.camera : .photoLibrary
present(picker, animated: true)
}
- 现在,让我们创建一个
UIViewController的扩展。如果你喜欢,可以在ViewController类的底部做这件事:
extension ViewController: UIImagePickerControllerDelegate,
UINavigationControllerDelegate
- 我们的扩展需要遵守
UIImagePickerControllerDelegate和UINavigationControllerDelegate协议。现在我们可以继续填充我们的扩展,以下是一个委托方法:
func imagePickerControllerDidCancel(_ picker:
UIImagePickerController) {
dismiss(animated: true, completion: nil)
}
func imagePickerController(_ picker: UIImagePickerController,
didFinishPickingMediaWithInfo info:
[UIImagePickerController.InfoKey : Any]) {
guard let image = info[UIImagePickerController.InfoKey
.originalImage] as? UIImage else {
return
}
imageView.image = image
labelView.text = "This is my image!"
dismiss(animated: true, completion: nil)
}
- 在我们继续之前,我们需要在我们的
info.plist中添加几行:
NSCameraUsageDescription
NSPhotoLibraryUsageDescription
- 使用以下字符串描述添加这些内容:
Chapter 11 wants to detect cook Stuff。这是一个 iOS 安全功能,当任何应用/代码尝试访问相机、照片库或位置服务时,会提示用户。如果没有添加这个,可能会导致应用崩溃。
对于我们的应用,我们可以添加任何我们想要的,但对于一个生产应用,确保你输入的文本对用户有用且信息丰富。苹果会在审查你的应用时检查这一点,并且已知在解决这个问题之前可能会阻止发布。
继续运行你的代码,然后启动应用。以下情况之一应该会发生:
-
如果你正在从模拟器运行应用,我们的
UIButton点击应该会显示照片选择器(以及 iOS 模拟器提供的默认图片)。 -
如果你正在从设备上运行,那么你应该会看到相机视图,允许你拍照。
无论哪种方式,无论是选择了照片还是拍了照片,最终的结果图像都应该显示在 UIImageView 中!
它是如何工作的...
让我们一步一步回顾一下我们刚刚所做的工作。我们从 IBAction 开始,看看我们创建的 UIPickerView 视图:
let picker = UIImagePickerController() // 1
picker.delegate = self // 2
picker.allowsEditing = false // 3
picker.sourceType = UIImagePickerController.isSourceTypeAvailable
(.camera) ? .camera : .photoLibrary // 4
present(picker, animated: true) // 5
让我们逐行分析这一行:
-
我们实例化一个
UIImagePickerController的实例——这是一个可用的 API,它将允许我们根据特定的来源选择一个图片。 -
我们将委托设置为
self,这样我们就可以利用由UIImagePickerController引起的任何结果或操作。 -
我们将
allowEditing设置为false,这用于在相机是我们来源时隐藏控件。 -
在这种情况下,我们根据相机是否可用设置源类型(因此它与模拟器配合得很好)。
-
最后,我们展示我们的视图控制器。
现在,让我们看看我们的委托方法:
func imagePickerControllerDidCancel(_ picker: UIImagePickerController)
func imagePickerController(_ picker: UIImagePickerController,
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey
: Any])
第一个方法相当直观;imagePickerControllerDidCancel 处理用户取消 UIImagePickerController 的任何实例。在我们的情况下,我们只是关闭返回的实例——任务完成!
didFinishPickingMediaWithInfo 是有趣的事情发生的地方。注意我们得到的响应中有一个 info 字典。在这里,我们有各种信息片段。我们要找的是在 UIImagePickerController.InfoKey.originalImage 键下的。这给我们一个 UIImage,显示我们刚刚选择的图片,允许我们直接将其分配给 UIImageView。
现在我们有一个允许我们拍照或选择照片的应用程序,我们可以利用 CoreML 和对象检测的力量将其应用于一些实际工作。
更多...
简要说明:你也会注意到我们被要求使我们的扩展符合 UINavigationControllerDelegate。这是 iOS 所要求的,以便正确处理和展示从其“呈现”堆栈(在我们的实例中是 ViewController)的 UIImageContoller。
参见
关于 UIImagePickerController 的更多信息,请参阅 developer.apple.com/documentation/uikit/uiimagepickercontroller。
使用 CoreML 模型检测图像中的对象
在这个菜谱中,我们将使用我们刚刚构建的应用程序并集成 CoreML 框架,以便在我们的图像中检测对象。
我们还将查看可用于我们使用和直接从苹果开发者门户下载的生成的 CoreML 模型。
准备工作
对于这个菜谱,你需要从 Mac App Store 获取的最新版本的 Xcode。
接下来,前往以下地址的 Apple 开发者门户:developer.apple.com/machine-learning/models/。
在这里,你将了解到更多关于我们可以下载和使用在我们 Xcode 项目中的模型的信息。
你会注意到有图像模型和文本模型的选择。对于这个菜谱,我们将使用图像模型,具体是一个名为 Resnet50 的模型,它使用残差神经网络,试图识别和分类它感知到的图像中的主要对象。
关于不同类型机器学习模型的更多信息,请参阅本菜谱末尾的 参见 部分的链接。
从这里,下载 Resnet50.mlmodel(32 位)模型。如果你在下载文件时遇到麻烦,你可以直接从我们的 GitHub 仓库中的示例项目中复制一份。
下载完成后,只需将其拖动到我们之前应用程序的文件资源管理器树中即可将其添加到你的 Xcode 项目中。
如何操作...
让我们从我们上一个项目中中断的地方开始:
- 一切准备就绪后,返回
ViewController.swift并将以下全局变量添加到我们的viewDidLoad()函数中:
var model: Resnet50!
override func viewDidLoad() {
super.viewDidLoad()
model = Resnet50()
}
-
现在,前往示例项目并获取一个名为
ImageHelpers.swift的文件;将其添加到我们的项目中。一旦添加,我们将回到didFinishPickingMediaWithInfo代理并进一步扩展它。 -
添加以下突出显示的更改:
guard let image = info[UIImagePickerController.InfoKey.
originalImage] as? UIImage else {
return
}
let (newImage, pixelBuffer) =
ImageHelper.processImageData(capturedImage: image)
imageView.image = newImage
var imagePredictionText = "no idea... lol"
guard let prediction = try? model.prediction(
image: pixelBuffer!) else {
labelView.text = imagePredictionText
dismiss(animated: true, completion: nil)
return
}
imagePredictionText = prediction.classLabel
labelView.text = "I think this is a \(imagePredictionText)"
dismiss(animated: true, completion: nil)
一切准备就绪后,运行应用程序并选择一张照片。只要你没有对着空墙拍照,你应该会看到一些有趣的反馈。
一切就绪后,让我们分析我们刚刚所做的更改,以便更好地理解刚刚发生了什么。
它是如何工作的...
第一件事是查看我们添加的以下行:
let (newImage, pixelBuffer) =
ImageHelper.processImageData(capturedImage: image)
在这里,我们添加了一个从我们的示例项目中取出的辅助方法的调用。这个辅助方法包含以下两个函数:
static func processImageData(capturedImage: UIImage) -> (UIImage?,
CVPixelBuffer?)
static func exifOrientationFromDeviceOrientation() ->
CGImagePropertyOrientation
这些函数及其功能略超出了本书的范围,尤其是这一章。然而,从非常高的层面来看,第一个函数processImageData()接受一个UIImage实例并将其转换为CVPixelBuffer格式。
这实际上将UIImage对象返回到其捕获的原始格式(UIImage仅仅是我们的真实原始图像的 UIKit 包装器)。
在这个过程中,我们还需要翻转方向,就像所有捕获的图像一样。这几乎肯定是在横幅模式下(而且通常情况下,你是在肖像模式下拍照或选择照片)。
进行这一操作的另一个原因是我们的 ResNet50 模型被训练来观察只有 224 x 224 像素的图像。因此,我们需要调整捕获的图像到这个大小。
如果你需要关于项目中模型的更多信息,只需在文件资源管理器中选择文件,然后在主窗口中查看详细信息。从这里,预测标签将提供你需要的所有关于输入文件的信息。
因此,在实现辅助函数之后,我们接收一个新的UIImage对象(修改为我们的新规范)和CVPixelBuffer格式的图像,所有这些都准备好传递给 CoreML 进行处理。
现在,让我们看一下下面的代码:
guard let prediction = try? model.prediction(image: pixelBuffer!) else {
labelView.text = imagePredictionText
dismiss(animated: true, completion: nil)
return
}
imagePredictionText = prediction.classLabel
在前面的代码中,我突出显示了几个感兴趣的区域。首先是我们的prediction()函数调用在model对象上。在这里,我们传入从之前辅助方法中获取的CVPixelBuffer格式的图像。基于此,在try语句中包裹,CoreML 现在将尝试在照片中检测一个对象。如果成功,我们将优雅地退出guard语句,并能够访问prediction变量中可用的属性。
如果你查看我们 ResNet50 模型中可用的属性,你会看到我们拥有的各种选项:
.classLabel
.classLabelProbs
我们已经看到了类标签,但类标签概率将返回一个字典,其中包含我们图像最可能的类别及其基于置信度分数的值。
每个模型都将根据其预期的意图和构建方式拥有自己的属性集。
还有更多...
在本节的开头,我们获得了一个允许我们在图像中检测对象的模型。进一步探讨这个主题,模型是一组经过训练以识别某种描述的模式或特征的数据集。
例如,我们想要一个检测猫的模型;因此,我们通过给它大约 10,000 张不同猫的图像来训练我们的模型。我们的模型训练将识别共同的特征和形状,并相应地进行分类。
当我们给模型提供一个猫的图像时,我们希望它能够识别图像中那些分类特征并成功识别出猫。
你训练的图片越多,性能就越好;然而,这仍然取决于图片的完整性。用同一只猫的图片(只是不同的姿势)训练 1,000 次可能给你带来的结果与拍摄 10,000 张同一只猫的图片(再次是不同的姿势)的结果相同。
同样的情况也适用于相反的方向;如果你用 50,000 张豹子的图片和 50,000 张小猫的图片进行训练,这根本就不会起作用。
离开 CoreML,你现在可以使用 Swift 和 TensorFlow 训练模型。TensorFlow 是谷歌的产品,在机器学习领域处于领先地位,拥有不断增长的开发者社区,以及 Swift 自己的开源社区。这一特定技术的进步前景确实光明。
相关内容
更多信息,请参阅以下链接:
-
Apple CoreML 文档:
developer.apple.com/documentation/coreml -
TensorFlow Swift:
www.tensorflow.org/swift/tutorials/model_training_walkthrough
构建视频捕获应用
所以,到目前为止,我们所看到的 CoreML 内容至少是非常不错的。但是回顾一下到目前为止的章节,我们可能花在构建应用以利用 CoreML 功能上的时间比实际实现它的时间还要多。
在这一节中,我们将通过流式传输实时摄像头视频来进一步扩展我们的应用,这样我们就可以拦截每一帧并在实时中检测对象。
准备工作
对于这一部分,你需要从 Mac App Store 获取最新版本的 Xcode。
请注意,对于这一部分,你需要连接到真实设备才能使其工作。目前,iOS 模拟器没有模拟前后摄像头的方法。
如何做到这一点...
让我们开始:
- 请转到我们的
ViewContoller.swift文件,并做出以下修改:
import AVFoundation
private var previewLayer: AVCaptureVideoPreviewLayer! = nil
var captureSession = AVCaptureSession()
var bufferSize: CGSize = .zero
var rootLayer: CALayer! = nil
private let videoDataOutput = AVCaptureVideoDataOutput()
private let videoDataOutputQueue = DispatchQueue(label:
"video.data.output.queue", qos: .userInitiated, attributes: [],
autoreleaseFrequency: .workItem)
- 现在,创建一个名为
setupCaptureSession()的函数,我们首先添加以下内容:
func setupCaptureSession() {
var deviceInput: AVCaptureDeviceInput!
guard let videoDevice =
AVCaptureDevice.DiscoverySession(deviceTypes:
[.builtInWideAngleCamera], mediaType: .video,
position: .back).devices.first else {
return
}
do {
deviceInput = try AVCaptureDeviceInput(device: videoDevice)
} catch {
print(error.localizedDescription)
return
} // More to go here
}
在前面的代码中,我们正在检查设备上是否有可用的摄像头,特别是后置的.builtInWideAngleCamera。如果找不到设备,我们的保护措施将会失败。
-
接下来,我们使用新的
videoDevice对象初始化AVCaptureDeviceInput。 -
现在,继续在我们的函数中添加以下代码:
captureSession.beginConfiguration()
captureSession.sessionPreset = .medium
guard captureSession.canAddInput(deviceInput) else {
captureSession.commitConfiguration()
return
}
captureSession.addInput(deviceInput)
if captureSession.canAddOutput(videoDataOutput) {
captureSession.addOutput(videoDataOutput)
videoDataOutput.setSampleBufferDelegate(self, queue:
videoDataOutputQueue)
} else {
captureSession.commitConfiguration()
return
}
do {
try videoDevice.lockForConfiguration()
let dimensions = CMVideoFormatDescriptionGetDimensions(
(videoDevice.activeFormat.formatDescription))
bufferSize.width = CGFloat(dimensions.width)
bufferSize.height = CGFloat(dimensions.height)
videoDevice.unlockForConfiguration()
} catch {
print(error)
}
实际上,在这里我们正在将设备连接到一个捕获会话中,这样我们就可以将设备输入(摄像头)处理的结果程序化地直接输入到我们的代码中。现在我们只需将其指向我们的视图,这样我们就可以看到输出。
- 在我们的函数中添加以下附加代码:
captureSession.commitConfiguration()
previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
previewLayer.videoGravity = AVLayerVideoGravity.resizeAspectFill
rootLayer = imageView.layer
previewLayer.frame = rootLayer.bounds
rootLayer.addSublayer(previewLayer)
通过我们刚刚添加的代码,我们实际上是从当前的捕获会话中创建了一个可见层。为了让我们能够在屏幕上处理它,我们需要将其分配给rootLayer(我们之前添加的CALayer变量)。虽然这看起来有点过度,我们本可以直接将其添加到UIImageView的层中,但我们正在为我们的下一个菜谱做准备。
- 最后,当我们的摄像头和设备都设置好了,是时候让摄像头开始工作了:
captureSession.startRunning()
现在运行应用程序。再次注意,这只能在真实设备上工作,而不是在模拟器上。一切顺利的话,你应该有来自摄像头的实时流。
它是如何工作的...
最好的解释方式是将捕获会话想象成设备硬件和软件之间的包装器或配置。摄像头硬件有很多选项,因此我们配置捕获会话以选择我们特定实例所需的内容。
让我们回顾一下这一行代码:
AVCaptureDevice.DiscoverySession(deviceTypes:
[.builtInWideAngleCamera], mediaType: .video, position: .back)
在这里,你可以根据 UI 切换控制枚举基,使用户能够指定要使用哪个摄像头。你甚至可以使用以下:
captureSession.stopRunning()
重新配置会话然后再次调用startRunning()。本质上(尽管在更复杂的层面上),这就是当你从前置摄像头切换到后置摄像头拍照时发生的事情。
在会话捕获后,我们现在可以直接将输出流式传输到我们喜欢的任何视图,就像我们在这里做的那样:
previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
但有趣的是,当我们想要通过逐帧捕获来操作正在流式传输的图像时。我们通过实现AVCaptureVideoDataOutputSampleBufferDelegate协议来完成这项工作,它允许我们重写以下代理方法:
func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer:
CMSampleBuffer, from connection: AVCaptureConnection) { }
注意这里有什么熟悉的地方...我们被提供了sampleBuffer,就像我们在UIImagePickerDelegate中得到的那样。这里的区别在于,这将在每一帧上被调用,而不仅仅是当选择了一个时。
还有更多...
在捕获会话和AVCaptureOutputs上玩耍是一个昂贵的操作。始终确保在不需要时停止会话运行,并确保你的代理在不需要时不要不必要地处理数据。
另一个需要注意的事项是,在某些情况下,捕获设备的初始化可能很慢,所以请确保你有适当的 UI 来处理它可能引起的潜在阻塞。
最后的注意事项:如果你在内存泄漏和高 CPU 时间上遇到困难,请查看一套名为 Instruments 的工具。Xcode Instruments 的工具包可以提供一系列性能跟踪工具,这可以帮助你充分利用 Swift 代码。
参见
更多信息,请参阅以下链接:
-
AVFoundation:
developer.apple.com/documentation/avfoundation
使用 CoreML 和 Vision 框架进行实时物体检测
我们已经看到了 CoreML 在物体检测方面的能力,但考虑到我们迄今为止所做的一切,我们当然可以更进一步。苹果的 Vision 框架提供了一套独特的检测工具,从图像中的地标检测和面部检测到跟踪识别。
对于后者,跟踪识别,Vision 框架允许我们使用用 CoreML 构建的模型,并将其与 CoreML 的物体检测结合使用,以识别和跟踪相关对象。
在本节中,我们将利用我们迄今为止所学的所有知识,从 AVFoundation 的工作原理到实现 CoreML,并使用设备摄像头构建一个实时物体检测应用程序。
准备工作
对于本节,您需要从 Mac App Store 获取的最新版本的 Xcode。
接下来,前往以下地址的 Apple 开发者门户:developer.apple.com/machine-learning/models/。
在这里,您将了解到有关我们可以在 Xcode 项目中下载和使用的一些模型的更多信息。您会注意到有图像模型或文本模型的选择。对于这个食谱,我们将使用图像模型,特别是名为 YOLOv3 的一个模型,它使用残差神经网络,试图识别和分类它感知到的图像中的主要对象。
关于不同类型机器学习模型的更多信息,请参阅本食谱末尾的 See also 部分的链接。
从这里,下载 YOLOv3.mlmodel(32 位)模型。如果您在下载文件时遇到麻烦,您可以直接从我们的 GitHub 仓库中的示例项目中复制一份。
下载完成后,只需将其拖动到我们之前应用程序的文件资源管理器树中,即可将其添加到您的 Xcode 项目中。
如何做到这一点...
我们将创建一个新的 UIViewController 来处理所有视觉工作,在 Xcode 中:
-
前往 File | New | File。
-
选择 Cocoa Touch Class。
-
将其命名为
VisionViewController。 -
使其成为
UIViewController的子类。
完成这些后,我们现在可以前往我们的新 VisionViewController 并添加以下高亮代码。我们首先导入 Vision 框架:
import Vision
现在,我们将对现有的 ViewController 进行子类化,以便我们可以兼得两者之长(无需大量代码重复):
class VisionViewController: ViewController
完成这些后,我们现在可以覆盖一些我们在 ViewContoller.swift 中的函数。我们首先从 setupCaptureSession() 开始:
override func setupCaptureSession() {
super.setupCaptureSession()
setupDetectionLayer()
updateDetectionLayerGeometry()
startVision()
}
当从另一个类覆盖时,始终记得首先调用基类函数。在前面代码的情况下,可以通过调用 super.setupCaptureSession() 来实现,如高亮所示。
您会注意到在 ViewControler.swift 文件中一些我们尚未创建的函数。让我们现在逐一过一遍:
- 首先,我们将向之前创建的
rootLayer中添加一个检测层。这个CALayer将用作我们检测到的对象区域的绘图平面:
func setupDetectionLayer() {
detectionlayer = CALayer()
detectionlayer.name = "detection.overlay"
detectionlayer.bounds = CGRect(x: 0.0,
y: 0.0,
width: bufferSize.width,
height: bufferSize.height)
detectionlayer.position = CGPoint(x: rootLayer.bounds.midX, y:
rootLayer.bounds.midY)
rootLayer.addSublayer(detectionlayer)
}
如代码所示,我们根据从bufferSize属性(在ViewController类中共享)中获取的高度和宽度创建其边界。
- 接下来,我们需要在
detectionLayer()中添加一些几何形状。这将根据设备的当前几何形状重新调整和缩放检测层:
func updateDetectionLayerGeometry() {
let bounds = rootLayer.bounds
var scale: CGFloat
let xScale: CGFloat = bounds.size.width / bufferSize.height
let yScale: CGFloat = bounds.size.height / bufferSize.width
scale = fmax(xScale, yScale)
if scale.isInfinite {
scale = 1.0
}
CATransaction.begin()
CATransaction.setValue(kCFBooleanTrue, forKey:
kCATransactionDisableActions)
detectionlayer.setAffineTransform(CGAffineTransform(rotationAngle:
CGFloat(.pi / 2.0)).scaledBy(x: scale, y: -scale))
detectionlayer.position = CGPoint(x: bounds.midX, y:
bounds.midY)
CATransaction.commit()
}
- 最后,让我们连接我们的
startVision()函数:
func startVision(){
guard let localModel = Bundle.main.url(forResource: "YOLOv3",
withExtension: "mlmodelc") else {
return
}
do {
let visionModel = try VNCoreMLModel(for: MLModel(
contentsOf: localModel))
let objectRecognition = VNCoreMLRequest(model: visionModel,
completionHandler: { (request, error) in
DispatchQueue.main.async(execute: {
if let results = request.results {
self.visionResults(results)
}
})
})
self.requests = [objectRecognition]
} catch let error {
print(error.localizedDescription)
}
}
- 这将带来一个新的函数,
visionResults()。请在前面的VisionViewController中也创建这个函数。
我们本可以直接在我们的原始ViewController中扩展所有这些新功能,但这样做可能会使我们的视图控制器过载,变得难以维护。此外,我们的UIImagePicker的逻辑和扩展也在这里,所以这种分离是很好的。
- 现在,让我们构建
visionResults()函数。我们将分部分进行,以确保一切都有意义:
func visionResults(_ results: [Any]) {
CATransaction.begin()
CATransaction.setValue(kCFBooleanTrue, forKey:
kCATransactionDisableActions)
detectionlayer?.sublayers = nil
// Other code to follow
}
我们从一些基本的维护工作开始;执行CATransaction会将我们即将对CALayer所做的任何更改锁定在内存中,在我们最终提交它们以供使用之前。在这段代码中,我们将修改detectionLayer。
- 接下来,我们将遍历
results参数,提取出任何属于类类型VNRecognizedObjectObservation的对象:
for observation in results where observation is
VNRecognizedObjectObservation {
guard let objectObservation = observation as?
VNRecognizedObjectObservation else {
continue
}
let labelObservation = objectObservation.labels.first
let objectBounds = VNImageRectForNormalizedRect(
objectObservation.boundingBox, Int(bufferSize.width), Int(bufferSize.height))
let shapeLayer = createRoundedRectLayer(with: objectBounds)
let textLayer = createTextSubLayer(with: objectBounds,
identifier: labelObservation?.identifier ?? "",
confidence: labelObservation?.confidence ?? 0.0)
shapeLayer.addSublayer(textLayer)
detectionlayer.addSublayer(shapeLayer)
updateDetectionLayerGeometry()
CATransaction.commit()
}
从这里,我们将继续使用 Vision 通过VNImageRectForNormalizedRect获取已识别对象(的)Rect和位置。我们还可以获取一些关于检测到的对象的文本信息并加以利用。
- 最后,我们将优雅地关闭对
detectionLayer的任何更改,并更新几何形状以匹配检测到的对象。您会注意到我们刚刚引入了两个新函数:
createRoundedRectLayer()
createTextSubLayer()
这些同样是辅助函数,一个用于绘制检测对象的矩形,另一个用于写入文本。这些函数是通用的样板代码,可以从 Apple 的文档中获得。请随意根据您的需求进行尝试。有一点我要提到:您会注意到我们如何再次使用层而不是添加UIView和UILabel来完成所有这些。这再次是因为 UIKit 是围绕许多核心功能的一个包装器。但在另一个组件之上添加 UIKit 组件是不必要的,而且考虑到这已经是一个密集的程序,通过直接更新和操作 UIKit 对象上的层来执行这可以更加高效。
这些对象可以在 GitHub 上的示例项目中找到;只需将它们复制到你的项目中(无论是VisionViewController还是你自己的辅助文件)。
在我们的 AV Foundation 相机流就绪,Vision 和 CoreML 准备施展魔法之后,我们还需要在VisionViewController中添加一个最后的重写:
override func captureOutput(_ output: AVCaptureOutput, didOutput
sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer)
else {
return
}
let exifOrientation =
ImageHelper.exifOrientationFromDeviceOrientation()
let imageRequestHandler = VNImageRequestHandler(cvPixelBuffer:
pixelBuffer, orientation: exifOrientation, options: [:])
do {
try imageRequestHandler.perform(self.requests)
} catch {
print(error)
}
}
使用 AV Foundation 的代理,我们再次抓取每一帧,将其转换为 CVPixelBuffer 以创建 VNImageRequestHander。现在,在 startVision() 函数中启动请求,将一切很好地拼接在一起。
我们几乎完成了;让我们现在完成一些将所有这些联系在一起的片段:
- 转到
ViewController.swift并添加我们之前创建的UISegmentedControl的以下IBAction和逻辑:
@IBAction func onInputTypeSelected(_ sender: UISegmentedControl) {
switch sender.selectedSegmentIndex {
case 0:
captureSession.stopRunning()
case 1:
startLivePreview()
default:
print("Default case")
}
}
- 现在,创建一个名为
startLivePreview()的函数:
func startLivePreview() {
captureSession.startRunning()
}
-
从
setupCaptureSession()中移除captureSession.startRunning()。 -
最后,在我们的
Main.storyboard视图控制器中,将类从ViewController更改为VisionViewController。 -
现在,运行应用程序。一切顺利的话,你应该能够实时检测图像,并带有如下覆盖层:

图 11.2 – 视觉检测
如您所见,视觉和 CoreML 都成功检测到了我的手机及其在图像中的位置(全部为实时)。
它是如何工作的...
高级概述大致如下:
-
捕获实时相机流(使用 AV Foundation)。
-
使用训练好的 CoreML 模型来检测图像中是否包含(它所识别的)物体。
-
使用视觉检测图片中物体的位置。
我们在之前的配方中涵盖了相机流元素,但让我们更深入地看看步骤 2 和 3 是如何工作的。
让我们实际上从 步骤 3 开始。在上一个部分中,我们看到了我们如何使用 VNImageRequestHander 来传递每个图像帧的 CVPixelBuffer。现在,这将在我们的 setupVision() 函数中触发调用,让我们在那里仔细看看。
首先,我们从应用的包中获取我们的模型,以便我们可以将其传递给视觉:
guard let localModel = Bundle.main.url(forResource: "YOLOv3",
withExtension: "mlmodelc") else {
return
}
接下来,我们回到 步骤 2,在那里我们创建一个 VNCoreMLModel() 的实例,传入我们的 localModel。有了这个 visionModel,我们现在可以创建我们的 VNCoreMLRequest 调用,以及它的完成处理程序,它将来自通过我们的 AV Foundation 代理传入的请求。
这个简单的请求完成了视觉框架和 CoreML 的双重工作——首先检测是否找到了物体,然后提供有关该物体在图像中位置的详细信息。
这是我们大部分工作的地方。如果您再次查看我们的 visionResults() 函数以及其中的所有辅助函数,这些只是解析返回数据的几种方式,并进而装饰我们的视图。
在我们来自 VNCoreMLRequest() 响应的“结果”中,我们取一个 VNRecognizedObjectObservation 的实例,它反过来给我们两个属性,一个标签(CoreML 认为它找到了什么)以及一个置信度分数。
参见
更多关于 CALayer 的信息,请参阅 developer.apple.com/documentation/quartzcore/calayer。


浙公网安备 33010602011771号