精通-IOS14-编程-全-
精通 IOS14 编程(全)
原文:
zh.annas-archive.org/md5/f60f394cc5d588672efa3ad099fe1fda译者:飞龙
前言
iOS 开发环境已经显著成熟,随着苹果用户在 App Store 上花费更多金钱,为专业 iOS 开发者提供了大量的开发机会。然而,掌握 iOS 开发以及 iOS 14 的新特性并非易事。本书将帮助你顺利、轻松地完成这一转变。借助 Swift 5.3,你不仅将学习如何为 iOS 14 编程,还将学习如何编写高效、可读性和可维护性强的 Swift 代码,并保持行业最佳实践。
精通 iOS 14 编程将帮助你构建实际的应用程序,并反映实际的开发流程。你还将找到详尽的背景信息和实际案例,教你如何开始实施你新获得的知识。
在本书结束时,你将能够掌握构建利用高级技术并充分利用 iOS 14 最新和最佳功能的 iOS 应用程序。
本书面向的对象
如果你是一位有 iOS 编程经验的开发者,并希望通过使用 Swift 解锁最新 iOS 版本的潜力来提升你的技能,构建出色的应用程序,那么这本书就是为你准备的。本书假设你对 Swift 和 iOS 开发有一定程度的熟悉。
本书涵盖的内容
第一章, iOS 14 的新特性,探讨了刚刚发布的最新 API 以及 iOS 中一些当前前沿的特性,以及 Swift 5 中的新变化。
第二章, 使用深色模式,教你如何通过几个简单的步骤,在将深色模式实现到你的新或现有 iOS 应用中时,产生巨大的差异。
第三章, 使用列表和表格,让你掌握如何在 iOS 中处理列表和表格,同时解释了它们如何工作的细节。
第四章, 创建详情页,通过构建从我们的列表和表格中获取的具体详情页,将我们迄今为止所学的内容进一步深化。
第五章, 通过动画沉浸你的用户,在设置好我们的应用程序基础后,探讨 UIKit 在 Swift 和 iOS 中提供的动画功能。
第六章, 理解 Swift 类型系统,帮助你掌握 Swift 类型系统背后的理论,这在 Swift 编程语言中起着至关重要的作用。
第七章, 使用协议、泛型和扩展进行灵活编码,让你能够将应用程序的结构进一步扩展,学习 Swift 中软件开发的核心原则。
第八章, 将 Core Data 添加到您的应用中,介绍了 Apple 的 CoreData 框架作为在您的应用中包含用户数据数据库的方法。
第九章, 从网络获取并显示数据,展示了如何利用 Web API 获取并显示数据。
第十章, 使用 CoreML 制作更智能的应用,解释了机器学习是什么,它是如何工作的,如何在您的应用中使用训练好的机器学习模型,如何使用 Apple 的 Vision 框架分析图像,以及您将看到它是如何与 CoreML 集成以实现强大的图像检测。最后,您将学习如何使用新的 CreateML 工具来训练您自己的模型。
第十一章, 向您的应用添加媒体,涵盖了播放音频和视频、拍照以及在可用时从照片中提取深度数据。
第十二章, 使用位置服务改进应用,展示了应用如何实现位置跟踪以增强和改善用户体验的几种方法。
第十三章, 使用 Combine 框架工作,涵盖了 Combine 框架,让您学习并理解事件驱动编程的基础知识,包括为什么以及如何在日常应用中使用它。
第十四章, 为您的应用创建 App Clip,专注于为现有应用创建一个新的 App Clip,了解其限制、设计指南和可用选项。
第十五章, 使用 Vision 框架进行识别,解释了 Vision 框架以及如何在 iOS 14 中识别图像中的文本和视频流中的手部地标。
第十六章, 创建您的第一个小部件,专注于为现有应用创建一个新的小部件,了解不同选项、尺寸和功能,这些都可以为用户提供。
第十七章, 使用增强现实,介绍了 ARKit 及其所有可用功能,包括如何使用 3D 模型和 Scene Kit 来构建您应用的增强现实世界。
第十八章, 使用 Catalyst 创建 macOS 应用,教授 Mac Catalyst,这是一种将 iPadOS 应用开发为原生 macOS 应用的方法。它通过一个示例项目将其转换为一个完全功能化的 macOS 应用,该应用可以分发到 Mac App Store。
第十九章, 通过测试确保应用质量,展示了如何为 iOS 应用设置测试。
第二十章,将您的应用提交到 App Store,展示了如何通过 TestFlight 分发应用,以及如何提交应用以供审查以便发布到 App Store。
要充分利用本书
本书中的所有示例代码都是使用 Swift 5.3 编写的,在编写本书时,Swift 5.4 是最新的版本,本书与新的版本兼容,在运行 macOS Big Sur 的 Mac 上使用 Xcode 12.4。要跟随本书中的所有示例,您必须在您的机器上至少安装 Xcode 12.4。建议您还至少在 Mac 上安装 macOS Big Sur,因为并非所有代码示例都与较旧的 macOS 版本兼容。
本书假设您对 Swift 和 iOS 开发有一定了解。如果您对 Swift 完全没有经验,建议您浏览 Apple 的 Swift 手册,并了解 iOS 开发的基础知识。您不必成为 iOS 开发的专家,但坚实的基座不会对您有害,因为本书的节奏针对的是有一定经验的开发者。

如果您使用的是本书的数字版,我们建议您亲自输入代码或通过 GitHub 仓库(下一节中提供链接)访问代码。这样做将帮助您避免与代码的复制和粘贴相关的任何潜在错误。
下载示例代码文件
您可以从 GitHub 下载本书的示例代码文件:github.com/PacktPublishing/Mastering-iOS-14-Programming-4th-Edition。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还提供了来自我们丰富的图书和视频目录中的其他代码包,可在 github.com/PacktPublishing/ 获取。查看它们!
下载彩色图像
我们还提供了一份包含本书中使用的截图/图表的彩色图像的 PDF 文件。您可以从这里下载:static.packt-cdn.com/downloads/9781838822842_ColorImages.pdf。
使用的约定
本书使用了多种文本约定。
文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和推特用户名。以下是一个示例:“请打开本章代码包中名为 CryptoWidget_start 的项目。”
代码块按照以下方式设置:
func doSomething(completionHandler: (Int) -> Void) {
// perform some actions
var result = theResultOfSomeAction
completionHandler(result)
}
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
func doSomething(completionHandler: (Int) -> Void) {
// perform some actions
var result = theResultOfSomeAction
completionHandler(result)
}
任何命令行输入或输出都按照以下方式编写:
$ mkdir css
$ cd css
CryptoWidget_start,转到文件 | 新建 | 目标 | 小部件扩展。
小贴士或重要提示
看起来是这样的。
联系我们
我们欢迎读者的反馈。
一般反馈: 如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并通过 customercare@packtpub.com 发送邮件给我们。
勘误: 尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告这一点,我们将不胜感激。请访问 www.packtpub.com/support/errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版: 如果您在互联网上以任何形式发现我们作品的非法副本,如果您能向我们提供位置地址或网站名称,我们将不胜感激。请通过 copyright@packt.com 联系我们,并提供材料的链接。
如果您有兴趣成为作者: 如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com。
评论
请留下评论。一旦您阅读并使用了这本书,为何不在您购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!
如需了解 Packt 的更多信息,请访问 packt.com。
第一章:第一章: iOS 14 的新功能是什么?
在 2020 年的 WWDC 上,苹果介绍了 iOS 14 中包含的新功能和改进。使用您应用的最新功能可以在用户参与度、正面评价和整体用户体验方面为用户带来巨大差异。它还可以带来其他好处,例如在 App Store 中被苹果推荐,以及在同类竞品应用中具有优势。
苹果于 2020 年 3 月 24 日发布了 Swift 5.2,供开发者使用。同年晚些时候,苹果发布了 Swift 5.3。这些版本侧重于质量、性能改进,新语言特性,以及增加对 Windows 平台和 Linux 发行版的支持。
在 2020 年的 WWDC 上还有另一个重大公告:苹果硅。苹果向世界推出了自己的处理器。开发者可以从 2020 年底开始构建应用并发布它们,这将开启一个为期两年的过渡。这个过渡将在所有苹果产品中建立统一的架构。有了统一的架构,将更容易为整个苹果生态系统创建应用。
在本章中,你将学习 iOS 14 中两个最重要的新功能的基础:App Clips 和小部件。我们还将介绍增强现实、机器学习和用户隐私的最新更新。在本章结束时,你将通过一些代码示例了解 Swift 语言的最新更新。
在本章中,我们将介绍 iOS 14、Swift 5.2 和 Swift 5.3 的以下主要主题:
-
介绍 App Clips
-
介绍 WidgetKit
-
增强现实改进
-
机器学习改进
-
用户隐私改进
-
介绍 Swift 5.2
-
介绍 Swift 5.3
技术要求
本章的代码可以在以下位置找到:github.com/PacktPublishing/Mastering-iOS-14-Programming-4th-Edition/tree/master/Chapter%201%20-%20Whats%20New.
如果你想在阅读本章时尝试 Swift 5.2 的功能,你需要安装 Xcode 版本 11.4 或更高版本:itunes.apple.com/app/xcode/id497799835.
介绍 App Clips
App Clips 允许用户以快速和轻量级的方式发现您的应用。使用 App Clips,用户可以快速使用您应用的功能,即使没有在他们的手机上安装该应用。让我们看看一个 App Clip 的例子:

图 1.1 − App Clip UI
App Clips 应该轻量级、简洁,并在几秒钟内完成用户任务。让我们看看一些 App Clips 的用例:
-
当你经过咖啡店门口并点击 NFC 标签时,可以订购咖啡的 App Clip。
-
通过扫描停在街上的电动自行车上的二维码,即可租用该电动自行车。此外,您还可以使用 Apple 登录和 Apple Pay 来避免填写表格和界面复杂性,让您能在几秒钟内租用自行车。
-
一个 App Clip,可以预先从餐厅菜单中订购,并在等待就座时节省时间。
-
当你在艺术画廊或博物馆的 NFC 点周围轻触时触发的 App Clip,并在你的 iPhone 上显示增强现实场景。
如您所见,App Clips 的可能性是无限的。现在我们已经介绍了什么是 App Clip,我们将解释用户使用 App Clip 的旅程(从调用到结束)。我们将涵盖调用方法(如何触发 App Clip 出现)。最后,我们将探讨构建 App Clip 时推荐的指南。
App Clip 用户旅程
现在我们将更详细地探索整个过程和步骤,从用户发现您的 App Clip 到用户完成 App Clip 之旅。
让我们想象一下,我们有一个在街头租用电动自行车的应用。App Clip 流程涉及几个阶段:
![Figure 1.2 − App Clip 流程和步骤
![img/Figure_1.02_B14717.jpg]
图 1.2 − App Clip 流程和步骤
AppClip 的步骤如下:
-
调用方法:App Clip 调用方法是用户如何触发和打开 App Clip 的方式。以我们电动自行车租赁的例子,用户使用设备摄像头扫描放置在自行车上的二维码,App Clip 就会在主屏幕上打开。在这种情况下,调用方法是二维码。我们将在本章后面进一步探讨更多调用方法。
-
用户旅程:在调用后,App Clip 会向用户展示一些选项供其选择(例如,1 小时租赁费用为 2 美元,24 小时租赁费用为 5 美元)。用户在 App Clip 内进行所需的选择。
-
账户和支付:在我们的自行车租赁示例中,我们的 App Clip 需要识别哪个用户在租用自行车,并且用户需要为服务付费。一些 App Clip 可能不需要注册用户账户或支付即可工作;这一步是可选的。
-
完整应用推荐:当自行车租赁完成并准备就绪时,您的 App Clip 可以推荐用户下载您的完整应用,这样用户下次就可以使用完整应用而不是 App Clip。建议整个应用是一个可选步骤,但非常推荐。
现在我们已经概述了 App Clip 的高级别步骤,让我们更详细地回顾一些部分。
App Clip 调用方法
我们已经看到,为了显示 App Clip,用户需要调用它或发现它。我们之前讨论过,它可以通过二维码、NFC 标签或消息中的链接来调用。以下是可用选项的总结:
-
App Clip 代码:每个 App Clip 代码都包含一个二维码和一个 NFC 标签,以便用户可以使用他们的摄像头扫描它或轻触它。它也适用于单个 NFC 标签和二维码。
-
Safari 应用横幅
-
消息中的链接
-
在地图中放置卡片
-
iOS 14 新应用库中最近使用的 App Clips 类别
让我们讨论一下苹果在设计和开发您的 App Clip 时推荐的指南。
App Clips 指南
为了使 App Clips 对用户来说有效、轻量且易于使用,苹果有几项指南:
-
专注于您应用的核心任务:假设您有一个拥有许多不同功能的咖啡店应用,包括允许用户收集积分、订购咖啡、保存用户偏好、购买咖啡礼品卡等。您的应用不应一次性显示如此庞大的功能集。App Clip 应仅提供最重要的任务(在本例中,仅为订购咖啡的功能)。如果用户需要更多功能,他们可以下载完整的应用。
-
App Clips 应该从开始到结束整个过程都快速且易于使用。避免使用复杂的用户界面、过多的菜单、详细视图和其他可能导致用户花费过多时间的元素。
-
App Clips 应该体积小,下载速度快。在 App Clip 内包含所有必要的资产,但避免大文件下载。
-
在您的 App Clip 中避免复杂的用户账户创建过程。只需“使用 Apple 登录”。
-
避免要求用户输入复杂且易出错的信用卡表单和详细信息。当需要时,尝试使用 Apple Pay。
-
当用户完成 App Clip 任务后,他们无法返回。您的 App Clip 可以建议用户安装完整的应用以保持用户后续的参与度。但要以非侵入性和礼貌的方式进行,例如,在用户旅程结束后,不要使其成为强制性的。
-
App Clips 提供在启动后 8 小时内发送或安排通知的选项,以满足任何所需任务。但不建议将此功能用于纯粹的市场营销目的。
在本节中,您已经了解了什么是 App Clip,用户在使用它时会经历什么样的旅程,不同的调用方法,以及构建您的 App Clip 时推荐的指南。在第十四章,“为您的应用创建 App Clip”,我们将为现有应用创建一个 App Clip,以查看一个实际示例。
现在,让我们跳转到 iOS 14 带来的另一个令人兴奋的新功能:WidgetKit。
介绍 WidgetKit
用户和开发者已经多年以来一直在请求一个特定的功能:他们所有人都希望能够在他们的主屏幕上拥有小部件。小部件允许用户从主屏幕配置、个性化并消费相关数据的片段。它们还允许开发者向用户提供可快速查看的内容,并为他们的应用创造附加价值。
下面是小部件(在这种情况下是日历和提醒小部件)在 iPhone 主屏幕上的预览:
![Figure 1.3 − iOS Home screen with Widgets]
![img/Figure_1.03_B14717.jpg]
图 1.3 − 带有小部件的 iOS 主屏幕
现在,在 iOS 14、macOS 11 及更高版本上,这是可能的。开发者可以使用 WidgetKit 和 SwiftUI 的新 小部件 API 在 iOS、iPadOS 和 macOS 上创建小部件。
iOS 14 中的 智能堆叠 包含一组不同的小部件,包括用户经常打开的小部件。如果用户启用了 智能旋转,Siri 可以突出显示自定义堆叠中的相关小部件。
在 iOS 13 及更早版本上创建的小部件
在 iOS 14 或 macOS 11 上创建的小部件不能放置在主屏幕上,但它们仍然可在 Today View 和 macOS 通知中心中查看。
在介绍完新小部件之后,让我们看看在构建小部件时有哪些选项,并查看苹果的设计指南。
小部件选项
用户可以在 iOS 的主屏幕或 Today View 上,或在 iPad 的 Today View 和 macOS 的通知中心上放置小部件。
小部件有三种尺寸:小、中、大。每种尺寸应有不同的用途。这意味着一个更大尺寸的小部件不应该只是小尺寸的字体和图像更大,而应该包含更多信息。例如,天气小部件在小尺寸版本中只提供当前温度,但在中尺寸版本中还会包括一周的天气预报。
用户可以在屏幕的不同部分排列小部件,甚至创建堆叠小部件来分组它们。
为了开发小部件,开发者需要为他们的应用创建一个新的扩展:一个小部件扩展。他们可以使用时间线提供者来配置小部件。时间线提供者在需要时更新小部件信息。
如果小部件需要一些配置(例如,在天气应用中选择默认城市,或在大型天气小部件中显示多个城市),开发者应在小部件扩展中添加自定义 Siri 意图。这样做会自动为用户的小部件提供定制界面。
小部件指南
当为 iOS 14 或 macOS 11 创建小部件时,请考虑以下设计指南:
-
将你的小部件聚焦于你应用的功能。如果你的应用是关于股市的,你的小部件可以显示用户的投资组合总价值。
-
每种尺寸的小部件应显示不同数量的信息。如果你的骑行追踪小部件在小尺寸小部件中显示当天燃烧的卡路里,它也可以在中尺寸小部件中显示一周内每天的卡路里,并在大尺寸小部件中添加额外的信息,如行驶的公里数/英里数。
-
更倾向于动态信息,这些信息在一天中会变化,而不是固定信息;这将使你的小部件对用户更具吸引力。
-
相比于配置选项更多的复杂小部件,更倾向于简单的小部件,配置选项较少。
-
小部件提供点击目标和检测功能,允许用户选择并点击它们以在应用中打开详细信息。小型小部件支持单个点击目标,中型和大型小部件支持多个目标。尽量保持简单。
-
支持暗黑模式。同时,考虑使用 SF Pro 作为字体,如果需要,使用 SF Symbols。
在本节中,我们介绍了新的小部件和 WidgetKit。我们涵盖了构建小部件时可用选项和设计指南。在下一节中,我们将介绍 iOS 14 中增强现实的新改进和新增功能。
增强现实改进
在 iOS 14 和 iPadOS 14 的新 ARKit 4 中,有四个主要的新特性:
-
位置锚点 允许开发者将 AR 场景放置在地理坐标上。位置锚点使用户能够在全球特定位置、地标和地点显示这些 AR 体验。
-
扩展人脸 跟踪支持允许通过具有 A12 Bionic 芯片或更高版本的设备的正面摄像头访问 AR 体验。
-
RealityKit 将使开发者能够将视频纹理添加到 AR 场景或 AR 对象的任何部分。视频纹理还包括空间化音频。
-
ARView–环境对象。场景理解包含四个选项:遮挡,其中现实世界对象遮挡虚拟对象;接收光照,允许虚拟对象在现实世界对象上投下阴影;物理,使虚拟对象能够以物理方式与真实世界交互;以及 碰撞,使虚拟对象与现实世界对象之间发生碰撞。注意
激活 接收光照 选项会自动开启 遮挡。激活 物理 选项会自动开启 碰撞。
在本节中,我们看到了增强现实的改进。现在让我们回顾一下机器学习中的新内容。
机器学习改进
WWDC2020 期间展示的 Core ML 改进将帮助开发者开发他们的机器学习应用,通过改进升级您的应用模型,确保它们的安全,并将它们分组到定向集合中。在本节中,我们将介绍新的 Core ML 模型部署,新的具有定向部署的模型集合,以及新的模型加密。
Core ML 模型部署、集合和定向部署
WWDC 2020 为 Core ML 引入的最显著特性之一是来自云端的 mlmodel 文件。
开发者将能够在云上创建机器学习模型集合并使用 CloudKit 更新它们。应用将下载这些集合并保持更新,中间没有版本升级过程。然而,开发者不控制下载过程。应用将检测到有新的模型版本可用,并在系统决定合适的时机(例如,在手机锁定且在 Wi-Fi 连接下充电的背景中)下载它。因此,开发者应该考虑到模型更新可能或可能不会快速或实时。操作系统将拥有最终决定权。
模型集合的一个有用特性是它们可以被针对不同的用户(例如,在具有不同能力的设备上的用户,如 iPhone 与 iPad)。可以通过对集合应用定向部署来为不同的用户分配不同的模型。有六个选项可用于配置和针对设备将要部署的模型:语言代码、设备类别、操作系统、操作系统版本、区域代码和应用程序版本。
模型加密
从 iOS 14 和 macOS 11 开始,Core ML 可以自动加密 Core ML 模型。
Xcode 将加密编译后的模型,mlmodelc(而不是原始的 mlmodel)。解密发生在应用实例化时,并在设备上发生。此外,解密结果不会存储在任何地方;它只是加载到内存中。
关于这个好消息:Xcode 将帮助你创建加密密钥,将其与你的开发者账户关联,并且它将自动存储在苹果服务器上。你可以随时下载一个本地副本,但这个过程并不流畅。
当加密密钥存储在苹果服务器上时,文件是 .mlmodelkey。当你想加密你的模型时,你只需将 --encrypt {YourModel}.mlmodelkey 添加到编译器标志中。如果你更喜欢使用 CloudKit,你只需在创建模型存档时提供加密密钥。
这个过程的缺点是:当应用实例化时,它需要与苹果服务器建立互联网连接来下载加密密钥并解密你的模型。如果由于任何原因没有连接,你需要在新的 {YourModel}.load() 方法的完成错误中实现你的回退流程。如果加密密钥不可用,完成处理程序将抛出 modelKeyFetch 错误,你可以相应地采取行动。
重要提示
你不应该在你的应用包中包含加密密钥。这并不是必要的,而且可能会危及你的数据。
在本节中,我们发现了如何在不更新我们的 app 的情况下升级我们的机器学习模型,如何将模型分组到集合中并将它们分配给不同类型的用户/设备,以及如何通过加密我们的模型并无需努力来保护我们的机器学习数据。在下一节中,我们将介绍对用户隐私的改进。
用户隐私改进
在 iOS 14 中,苹果以不同的方式让用户对他们的隐私和个人数据有更多的控制:
-
应用商店将显示每个 app 的隐私实践,因此用户在下载 app 之前可以检查它们。
-
当一个应用使用相机或麦克风时,手机右上角会出现一个指示器来表示。控制中心会记录最近使用过这些功能的 app。
-
应用可以提供用户保留他们已有的账户,但将其与使用 Apple ID 登录集成。
-
近似位置是针对位置服务的新选项,为那些不需要精确位置的应用提供更不准确的位置。
-
限制照片库访问:用户现在可以仅授予对所选照片的访问权限,而不是整个库。
在 iOS 的每个新版本中,苹果都在给用户和开发者提供越来越多的隐私设置控制权。在本节中,我们看到了新的改进,这些改进使得用户的位置和照片更加私密,改进了让用户知道应用何时使用相机或麦克风的机制,以及在应用商店页面上添加了关于应用使用数据和隐私的额外信息。下一节将重点介绍 Swift 5.2 在语言中引入的变化。
介绍 Swift 5.2
由苹果(2020 年 3 月 24 日推出)引入的 Swift 5.2 具有针对改进开发者体验和提供额外语言特性的实用功能。一些新的语言特性似乎旨在增强函数式编程风格。让我们通过一些代码示例来回顾这些新特性。
键路径表达式作为函数
这个新功能允许开发者使用键路径表达式,如\Root.value,在允许(Root) -> Value函数的任何地方使用。让我们看看它是如何工作的。
让我们创建一个具有两个属性brand和isElectric的Car结构体:
struct Car {
let brand: String
let isElectric: Bool
}
然后,让我们实例化一个包含两辆车的Car结构体数组,一辆是电动汽车,另一辆不是:
let aCar = Car(brand: "Ford", isElectric: false)
let anElectricCar = Car(brand: "Tesla", isElectric: true)
let cars = [aCar, anElectricCar]
现在,如果我们想过滤这个cars数组并只获取其中的电动汽车,我们过去是这样做的:
let onlyElectricCars = cars.filter { $0.isElectric }
我们也可以这样做:
let onlyElectricCarsAgain = cars.filter { $0[keyPath: \Car.isElectric] }
现在,随着 Swift 5.2 的推出,我们能够更简洁地做到这一点:
let onlyElectricCarsNewWay = cars.filter(\.isElectric)
如果你打印结果,你会看到输出是相同的:
print(onlyElectricCars)
print(onlyElectricCarsAgain)
print(onlyElectricCarsNewWay)
输出如下:
[__lldb_expr_5.Car(brand: "Tesla", isElectric: true)]
[__lldb_e xpr_5.Car(brand: "Tesla", isElectric: true)]
[__lldb_expr_5.Car(brand: "Tesla", isElectric: true)]
注意,这适用于更多的情况,例如map、compactMap以及允许(Root) -> Value函数的任何地方。
SE-0249 提案包含了这次变化背后的所有细节。对于额外的参考和提案背后的动机,你可以查看原始文档github.com/apple/swift-evolution/blob/master/proposals/0249-key-path-literal-function-expressions.md。
用户定义名义类型的可调用值
这个新特性允许具有以callAsFunction为基本名称的方法的值像函数一样被调用。
用一个简单的例子更容易解释这个概念。让我们创建一个名为MyPow的结构体,帮助我们计算给定基数的一个数的幂:
import Foundation
struct MyPow {
let base: Double
func callAsFunction(_ x: Double) -> Double {
return pow(base, x)
}
}
现在,我们可以通过以下方式计算base的pow:
let base2Pow = MyPow(base: 2)
print(base2Pow.callAsFunction(3))
此print语句将产生以下结果:
8.0
现在,使用 Swift 5.2,我们可以计算基数的pow,但使用这种方法:
print(base2Pow(3))
这导致了相同的输出:
8.0
Swift SE-0253 提案文档包含了这次变化背后的所有细节。对于额外的参考和提案背后的动机,你可以查看原始文档github.com/apple/swift-evolution/blob/master/proposals/0253-callable.md。
下标现在可以声明默认参数
当声明下标时,我们现在可以为参数分配一个默认值。
让我们用一个例子来看看它的实际效果。我们创建一个名为Building的结构体,它包含一个表示楼层名称的String数组。我们添加一个下标来获取给定索引的楼层名称。如果索引不存在,我们希望获取默认值,Unknown:
struct Building {
var floors: [String]
subscript(index: Int, default default: String = "Unknown") -> String {
if index >= 0 && index < floors.count {
return floors[index]
} else {
return `default`
}
}
}
let building = Building(floors: ["Ground Floor", "1st", "2nd", "3rd"])
我们可以在以下输出中看到,当我们使用building[0]访问索引0时,我们返回值Ground Floor:
print(building[0])
控制台输出如下:
Ground Floor
在以下场景中,当我们使用building[5]访问索引5时,我们返回值Unknown:
print(building[5])
控制台输出如下:
Unknown
此代码示例展示了在使用下标时如何使用默认参数,以及它如何有助于处理边缘情况。
懒过滤顺序现在已反转
当与懒数组一起使用过滤时,对过滤链中应用的运算顺序有新的变化。请看以下代码:
let numbers = [1,2,3,4,5]
.lazy
.filter { $0 % 2 == 0 }
.filter { print($0); return true }
_ = numbers.count
在 Swift 5.2 中,此代码将打印以下内容:
2
4
这是因为.filter { $0 % 2 == 0 }语句是在.filter { print($0); return true }语句之前应用的。
然而,如果我们在这个 5.2 之前的 Swift 版本中执行此代码,我们会注意到顺序是相反的。首先,我们将打印所有数字;然后,我们将过滤并只获取偶数。.filter语句将从下到上执行。
如果我们从代码中移除.lazy,这种行为将再次改变。然后,无论 Swift 版本如何,我们都会看到输出仅为2和4。过滤器将按预期从上到下应用。
重要提示
这种变化可能会破坏您的代码和应用程序的逻辑。确保在将代码更新到 Swift 5.2 或更高版本时,审查任何类似的场景。
新增和改进的诊断
在 Swift 5.2 中,错误信息在质量和精度上都有所提高。在之前的版本中,编译器试图通过将表达式分解成更小的部分来猜测错误的精确位置。但这种方法遗漏了一些错误。
现在当编译器在尝试推断类型时遇到失败,它会记录这些元素的位置。这些记录允许编译器在需要时检测到确切的错误。
让我们看看在 Swift 5.1 和 Swift 5.2 中编译的例子,以及每个版本上的输出。看看这个包含错误的代码:
enum Test { case a, b }
func check(t: Test) {
if t != .c {
print("okay")
}
}
在 Swift 5.2 中,我们可以在错误发生的确切位置得到一个清晰的错误,并且有准确的原因:
error: Chapter 1.5.playground:14:12: error: type 'Test' has no member 'c'
if t != .c {
~^
如您所见,编译器告诉我们我们正在尝试使用一个不存在的枚举成员,c。
如果我们尝试在 Swift 5.1 中编译相同的代码,我们将看到不同的(并且是错误的)错误信息:
error: binary operator '!=' cannot be applied to operands of type 'Test' and '_'
if t != .c {
~ ^ ~~~~~~
编译器错误改进使得 iOS 开发者日常调试变得更加舒适。
在本节中,您已经了解了语言最新的添加内容以及 Swift 5.2 上的改进诊断,并伴随着代码示例。现在,让我们跳转到 Swift 5.3 的特性。
介绍 Swift 5.3
由苹果在 2020 年引入,Swift 5.3 的主要目标是提升质量和性能,并通过添加对 Windows 和额外的 Linux 发行版的支持来扩展 Swift 可用的平台数量。
现在,让我们回顾一些新的语言特性。
多模式catch子句
使用这个新特性,Swift 将允许在do catch子句内部有多个错误处理块。看看下面的例子。
假设我们有一个performTask()函数,它可以抛出不同类型的TaskError错误:
enum TaskError: Error {
case someRecoverableError
case someFailure(msg: String)
case anotherFailure(msg: String)
}
func performTask() throws -> String {
throw TaskError.someFailure(msg: "Some Error")
}
func recover() {}
在 Swift 5.3 之前,如果我们想在do catch块内部处理不同的TaskError情况,我们需要在catch子句中添加一个switch语句,这使得代码变得复杂,如下所示:
do {
try performTask()
} catch let error as TaskError {
switch error {
case TaskError.someRecoverableError:
recover()
case TaskError.someFailure(let msg),
TaskError.anotherFailure(let msg):
print(msg)
}
}
现在 Swift 5.3 允许我们定义多个catch块,这样我们可以使我们的代码更易读,如下面的例子所示:
do {
try performTask()
} catch TaskError.someRecoverableError {
recover()
} catch TaskError.someFailure(let msg),
TaskError.anotherFailure(let msg) {
print(msg)
}
我们不再需要在catch块内部使用switch。
多个尾随闭包
从 Swift 开始,它就支持尾随闭包语法。看看使用UIView.animate方法时的经典例子:
UIView.animate(withDuration: 0.3, animations: {
self.view.alpha = 0
}, completion: { _ in
self.view.removeFromSuperview()
})
在这里,我们能够将尾随闭包语法应用到completion块中,通过从括号中提取completion并移除其标签,使我们的代码更短、更易读:
UIView.animate(withDuration: 0.3, animations: {
self.view.alpha = 0
}) { _ in
self.view.removeFromSuperview()
}
这种闭包语法也有一些副作用。如果开发者不习惯我们的方法,它可能会使我们的代码难以阅读(想想我们自己的 API 库,它不像 UIKit 方法那样广为人知)。它也使代码显得有些无结构。
在 Swift 5.3 中,当我们同一个方法中有多个闭包时,我们现在可以在第一个未标记参数之后提取并标记所有这些闭包:
UIView.animate(withDuration: 0.3) {
self.view.alpha = 0
} completion: { _ in
self.view.removeFromSuperview()
}
注意现在我们有了括号外的两个闭包,UIView.animate(withDuration: 0.3)。也请注意,标记 completion 方法使它更容易理解,并且代码在结构上现在看起来更加对称,所有闭包都以相同的方式编写。
为枚举类型合成的可比较遵从
Swift 5.3 允许没有关联值或只有 Comparable 值的 enum 类型有资格进行合成遵从。让我们看看一个例子。在 Swift 5.3 之前,如果我们想要比较 enum 的值,我们需要遵从 Comparable,并且需要实现 < 和 minimum 方法(以及其他实现此目的的方法):
enum Volume: Comparable {
case low
case medium
case high
private static func minimum(_ lhs: Self, _ rhs: Self) -> Self {
switch (lhs, rhs) {
case (.low, _), (_, .low ):
return .low
case (.medium, _), (_, .medium):
return .medium
case (.high, _), (_, .high ):
return .high
}
}
static func < (lhs: Self, rhs: Self) -> Bool {
return (lhs != rhs) && (lhs == Self.minimum(lhs, rhs))
}
}
这段代码难以维护;一旦我们向 enum 添加更多值,我们就需要一次又一次地更新方法。
在 Swift 5.3 中,只要枚举没有关联值或者它只有 Comparable 关联值,实现就会为我们合成。查看以下例子,我们定义了一个名为 Size 的枚举,并且我们能够对 Size 实例的数组进行排序(无需进一步实现 Comparable 方法):
enum Size: Comparable {
case small(Int)
case medium
case large(Int)
}
let sizes: [Size] = [.medium, .small(1), .small(2), .large(0)]
如果我们使用 print(sizes.sorted()) 打印数组,我们将在控制台得到以下内容:
[.small(1), .small(2), .medium, .large(0)]
注意排序的顺序与定义我们的情况的顺序相同,假设它是递增顺序:当我们排序值时,.small 出现在 .large 之前。对于包含关联值(如 .small(Int) 和 .large(Int)) 的相同情况的实例,我们在排序时应用相同的原则:.small(1) 出现在 .small(2) 之前。
当不太可能发生引用循环时,增加逃逸闭包中隐式 self 的可用性
有时候,强制所有逃逸闭包中的 self 使用都是显式的规则增加了样板代码。一个例子是当我们在一个 Struct 中使用闭包时(在这种情况下,引用循环不太可能发生)。在 Swift 5.3 的这个新变化中,我们可以省略 self,就像下面的例子一样:
struct SomeStruct {
var x = 0
func doSomething(_ task: @escaping () -> Void) {
task()
}
func test() {
doSomething {
x += 1 // note no self.x
}
}
}
当需要时,还有一种新的方式在捕获列表中使用 self(只需添加 [self] in),这样我们就可以避免在闭包内部反复使用 self。请看以下例子:
class SomeClass {
var x = 0
func doSomething(_ task: @escaping () -> Void) {
task()
}
func test() {
doSomething { [self] in
x += 1 // instead of self.x += 1
x = x * 5 // instead of self.x = self.x * 5
}
}
}
这个变化减少了在许多情况下使用 self,并在不需要时完全省略它。
基于类型的程序入口点 – @main
到目前为止,在开发 Swift 程序(如终端应用程序)时,我们需要在 main.swift 文件中定义程序的启动点。现在我们能够在一个结构体或基类(在任何文件中)上标记 @main 并定义一个 static func main() 方法,程序启动时它将被自动触发:
@main
struct TerminalApp {
static func main() {
print("Hello Swift 5.3!")
}
}
重要提示
考虑以下关于 @main 的内容:如果已经存在 main.swift 文件,则不应使用它;它应在基类(或结构体)中使用,并且只能定义一次。
在上下文泛型声明中使用 where 子句
我们可以在具有泛型类型和扩展的函数中使用 where 子句。例如,看看以下代码:
struct Stack<Element> {
private var array = [Element]()
mutating func push(_ item: Element) {
array.append(item)
}
mutating func pop() -> Element? {
array.popLast()
}
}
extension Stack {
func sorted() -> [Element] where Element: Comparable {
array.sorted()
}
}
我们将这个 Stack 结构体扩展中的 sorted() 方法限制为 Comparable 元素。
枚举案例作为协议见证
这个提案旨在取消一个现有的限制,即枚举案例不能参与协议见证匹配。这在将枚举符合协议要求时引起了问题。请看以下定义 maxValue 变量的协议示例:
protocol Maximizable {
static var maxValue: Self { get }
}
我们可以这样使 Int 符合 Maximizable:
extension Int: Maximizable {
static var maxValue: Int { Int.max }
}
但如果我们尝试用枚举做同样的事情,我们会遇到编译问题。现在可以这样做:
enum Priority: Maximizable {
case minValue
case someValue(Int)
case maxValue
}
此代码现在可以与 Swift 5.3 正确编译。
精炼 didSet 语义
根据 Swift 提案,这是一个非常直接的变化:
-
如果
didSet观察者在其主体中不引用oldValue,则将跳过获取oldValue的调用。我们称这种didSet为“简单”的didSet。 -
如果我们有一个“简单”的
didSet而没有willSet,那么我们可以允许修改就地发生。
Float16
Float16 已添加到标准库中。Float16 是一个半精度(16b)浮点值类型。在 Swift 5.3 之前,我们有 Float32、Float64 和 Float80。
在本节中,你通过代码示例学习了 Swift 5.3 中语言的新增功能。现在,让我们以本章总结结束。
摘要
在本章中,我们涵盖了 iOS 14、Swift 5.2 和 Swift 5.3 的新功能。我们首先介绍了 App Clips 以及它们为 iOS 和 macOS 带来的惊人可能性。我们列出了一些现实世界的例子,并学习了调用它们的不同方式。我们研究了设计指南,并通过使用 Apple 登录和 Apple Pay 简化了流程。后来,我们跳入了 WidgetKit 中的小部件。我们描述了如何创建三种不同大小的小部件,并研究了它们的设计指南。我们发现了增强现实和机器学习的新功能和改进。隐私也得到了一些更新,允许最终用户更详细地控制他们分享的内容。最后,我们还学习了 Swift 5.2 和 Swift 5.3 的新语言功能。
在我们的下一章中,我们将探讨 iOS 14 的深色模式,涵盖你需要了解的所有内容,并将其付诸实践。
进一步阅读
-
苹果人机界面指南(App Clips):
developer.apple.com/design/human-interface-guidelines/app-clips/overview/ -
苹果人机界面指南(小部件):
developer.apple.com/design/human-interface-guidelines/ios/system-capabilities/widgets/
第二章:第二章: 与 DarkMode 一起工作
我们都喜欢它……嗯,至少我们大多数人都是这样,而且那些喜欢它的人也已经期待它有一段时间了。苹果首次在 2018 年 macOS Mojave 中尝试 Dark Mode,不仅改变了用户与操作系统交互的方式,还为开发者首次构建原生深色主题应用铺平了道路。
iPhone 的 DarkMode 直到 2019 年 WWDC 才宣布,但我们都知道它即将到来,而且有了 AppKit 所提供的一切,我们知道 UIKit 将提供什么,我们将会享受到一份大礼。
在本章中,我们将涵盖您需要了解的所有内容,以便在 iOS 和 iPadOS 中启动并运行 DarkMode;从将现有应用进行调整以支持 DarkMode,到在构建我们的应用时添加的所有小而隐藏的额外功能,以确保我们为用户提供最佳体验。我们还将讨论最佳实践——注意我们可以做的那些小事,让 UIKit 中的 DarkMode 从一开始就使我们的生活变得容易得多。
本章将涵盖以下主题:
-
什么是 DarkMode?
-
在 DarkMode 中与视图一起工作
-
与资产一起工作
-
进一步探索 Dark Mode
技术要求
对于本章,您需要从苹果的 App Store 下载 Xcode 版本 11.4 或更高版本。
您还需要运行最新版本的 macOS(Catalina 或更高版本)。只需在 App Store 中搜索 Xcode,选择并下载最新版本。启动 Xcode,并遵循系统可能提示的任何附加安装说明。一旦 Xcode 完全启动,您就可以开始了。
从以下 GitHub 链接下载示例代码:github.com/PacktPublishing/Mastering-iOS-14-Programming-4th-Edition。
什么是 DarkMode?
在本节中,我们将首先探讨 Dark Mode 究竟是什么,我们如何使用它,以及它不仅对最终用户,也对开发者能做什么。我们将从在我们的设备上启用它,到在 Xcode 中使用环境覆盖以及在模拟器中的开发者选项,涵盖所有内容。
理解为什么我们需要 DarkMode
正如我在本章引言中提到的,我们大多数人已经非常渴望 iOS 中的 DarkMode 有一段时间了。我们开发者早在 2018 年就解决了 Xcode 的问题——但我在很多次(尤其是在过去 12 个月里)被问到的热门问题之一是……为什么?
这可能只是因为一天中的某个简单时间。卫星导航系统在我们的汽车中已经这样做了很多年——一旦太阳下山,我们的系统就会切换,屏幕上就会弹出更加轻松、微妙的回家路线——那么为什么不为我们的应用做同样的事情呢?
嗯,事实证明,一些应用程序已经这样做了一段时间(在一定程度上),尽管它们并不一定提供自动夜间模式。iOS 的 Twitter 应用在 WWDC 19 宣布之前就提供了“暗黑模式”选项。
让我们停下来,思考一下这种控制的逻辑,以及你需要改变的一切来实现这一点。我相信像 Twitter 这样的大公司已经编写了自己的内部框架来处理这个问题,但底层基本上看起来会像以下这样:
var isDarkMode = false
var profileLabel: UILabel? {
didSet {
profileLabel?.textColor = isDarkMode ? .white : .black
}
}
var profileBackground: UILabel? {
didSet {
profileBackground?.textColor = isDarkMode ? .black : .white
}
}
从文本颜色到可能装饰你的 UIButton 或 UIViews 的阴影,所有这些都需要考虑。
背景也是一个需要考虑的重大变化。许多 iOS 开发者遵循的一个常见模式是,简单地在白色画布上开发全新的应用程序;从这里开始,我们不需要担心控制背景颜色或用 IBOutlet 跟踪它——它只是我们应用程序其余部分的桌布。
在实现暗黑模式功能后,一切都需要改变——甚至那些自豪地坐在一种背景风格上的资产图像,在另一种背景下可能会消失。让我们看看在实现暗黑模式时,Xcode 附带的一些开发者功能。
暗黑模式的核心开发者概念
让我们先从如何在设备上开启暗黑模式来开发开始看起。如果你还没有这样做,你可以通过前往设置 | 显示与亮度来简单地切换它,你应该会看到以下屏幕:
![Figure 2.1 – Display and brightness
![img/Figure_2.01_B14717.jpg]
图 2.1 – 显示与亮度
你还会注意到自动切换选项,这让我们能够使用日落至日出或自定义计划,这将自动在亮暗外观之间切换(就像我们的卫星导航一样)。
现在我们已经覆盖了这一点,让我们看看 iOS 模拟器提供给开发者的选项。让我们先采取以下步骤:
-
打开 Xcode。
-
启动模拟器(Xcode | 打开开发者工具 | 模拟器)。
在比最终用户版本的 iOS 略有不同的位置,你会在开发者设置下找到暗黑模式切换(设置 | 开发者 | 暗黑外观):
![Figure 2.2 – Dark mode developer settings
![img/Figure_2.02_B14717.jpg]
图 2.2 – 暗黑模式开发者设置
与我们之前看到的复杂界面不同,我们只看到了标准的切换按钮。现在让我们看看作为开发者,我们可以用暗黑模式做些什么。
Xcode 内部的暗黑模式
现在我们已经了解了 iOS 如何处理切换到暗黑模式,让我们看看我们,作为开发者,如何在 Xcode 中做到同样的事情。
默认情况下,所有针对 iOS 13 SDK 的新项目都将自动支持暗黑模式;然而,针对任何更早的 SDK 的构建则不会。
这对现有应用可能没有所有必要的调整来支持深色模式有些帮助,而且你不想发布更新后发现自己破坏了运行深色模式的应用。
然而,如果你将你的项目更新到 iOS 13 SDK,那么你可能会遇到这个问题,但不用担心,我们将在本章后面的 将现有应用迁移到深色模式 部分介绍如何让你的现有应用为深色模式做好准备。
让我们先看看故事板——我们都喜欢它们(或者讨厌它们),但多年来它们所做的一件事就是在一个比白色还要白的画布上展示自己。
让我们开始吧:
-
打开 Xcode 并创建一个新的 单视图 - 故事板项目。
-
你可以将其命名为任何你想要的名字(我将命名为“第二章 - 深色模式”)。
你可以跟随本章的内容进行学习,或者从 GitHub 下载示例代码。
创建完成后,点击 Main.Storyboard,你应该会看到以下内容:

图 2.3 – Xcode 界面风格
在前面的屏幕截图中,我突出显示了我们的兴趣区域——在这里,我们可以在故事板中预览浅色和深色外观的切换,因此我们可以快速看到我们添加到画布上的对象的外观,而无需启动模拟器。
现在,这并不总是能帮到我们,因为我们的某些 UILabel 或 UIButtons 可能会通过编程方式装饰。然而,这是一个很好的开始,并且肯定会在任何应用的开发周期中派上用场。
让我们看看我们的标签在实际应用中的样子。这里,我们直接添加了一个 UILabel。浅色外观被选中,标签看起来就像我们在这个阶段习惯看到的那样:

图 2.4 – 主故事板
现在,让我们切换到 深色外观并看看会发生什么:

图 2.5 – 主故事板 – 深色模式
就像魔法一样,我们的画布进入了深色模式,我们的 UILabel 的颜色会自动调整。我们可以立即看到,无需在设备或模拟器上编译或运行应用,每个界面风格下的外观。
我想一百万个问题中的一个是 iOS 是如何知道切换 UILabel 字体的颜色的? 好问题,我们将在本章后面的 与视图和深色模式一起工作 部分更详细地介绍。
然而,正如我之前提到的,有些时候你需要在模拟器中测试你的应用。标签和视图不总是静态的,可能会动态生成——这就是环境覆盖的作用所在。
我们首先在模拟器中启动我们的应用。一旦成功启动,你应该在 Xcode 中看到以下高亮选项:

图 2.6 – 环境覆盖
点击此图标,你将看到一个 环境覆盖 弹出窗口。在这里,你可以选择切换 界面样式 覆盖,这反过来又允许你选择亮色和暗色外观。
如果你切换开关并在每个选项之间切换,你会在模拟器中看到你的应用自动更新,无需关闭应用、更改设置和重新启动。确实非常不错——感谢,Xcode!
在我们继续之前,有一点需要指出:我们之前提到,使用旧版 iOS SDK 开发的现有应用不会受到深色模式的影响,但如果你选择将你的应用更新到 iOS 13 SDK,你可能会遇到一些问题。
紧迫的截止日期和紧急的 bug 修复可能不会给你机会在你的应用中采用深色模式,所以 Xcode 给你提供了强制亮色外观的选项,无论用户的偏好如何。
在 Info.plist(或 Light:
UIUserInterfaceStyle

图 2.7 – Info.plist – 用户界面样式
现在,你会发现,即使在环境覆盖的情况下,你也不能切换到暗色模式。
在本节中,我们开始使用 iOS 和,更重要的是,Xcode 的深色模式,并了解了 Xcode 为我们准备开发具有亮色和暗色外观的应用所做的小事情。在下一节中,我们将开始探讨 Xcode 如何处理视图,并介绍语义“动态”颜色。
在深色模式下使用视图
到目前为止,在本章中,我们不仅介绍了深色模式是什么,还介绍了它从开发角度提供了什么。
在本章中,我们将进一步深入研究深色模式,看看 Xcode 如何动态处理我们的 UIView(以及从 UIView 派生的对象)。
我们首先将理解自适应和语义颜色的核心概念,并通过遵循一个简单的模式,Xcode 可以为我们做很多繁重的工作。
然后,我们将进一步深入,看看我们可用的各种语义颜色级别,包括主要、次要和三级选项,但更重要的是,我们何时会期望使用它们。
什么是自适应颜色?
对我来说,这是让开发者参与设计和开发他们的深色模式应用的一个重大步骤,当然,苹果公司也有兴趣让开发者体验尽可能无缝。
自适应颜色是定义特定外观的单个颜色类型或风格的一种方式。让我们直接进入 Xcode,看看我们自己的情况:
-
回到你之前创建的项目,并突出显示你添加的 UILabel。
-
现在,看看 属性检查器 窗口中的 颜色 属性:

图 2.8 – 标签属性
你会注意到选中的颜色是默认(标签颜色)——标签颜色是我们的自适应颜色。
但这究竟意味着什么?实际上,这非常简单:它意味着对于一种界面风格,它是一种颜色,而对于另一种,则是不同的颜色。
在我们之前的例子中,我们的 UILabel 在浅色模式下是黑色,在深色模式下是白色——这说得通,对吧?
好吧,在某种程度上是的,但当然这取决于我们的 UILabel 所在的背景类型——让我们看看。
回到我们的故事板中,突出显示我们的视图的背景,然后再次转到属性检查器窗口:

图 2.9 – 背景颜色属性
再次,这里我们有我们的自适应颜色,系统背景颜色。当我们需要切换外观时,Xcode 会为我们做所有的工作。
该节前面的部分是两个主要颜色(在我们的标签中使用黑色和白色)之间对比的一个很好的例子,这本身就是对浅色和深色外观中颜色应该是什么的典型理解——但我们并不总是使用黑色或白色,对吧?
因此,苹果已经更新了所有可用的系统颜色以使其具有自适应功能。让我们看看。
回到 Xcode,突出显示我们的 UILabel,并将颜色更改为系统靛蓝色:

图 2.10 – 字体颜色属性
现在,让我们使用 Xcode 的故事板中的切换按钮在浅色模式和深色模式之间切换。我们看到的是什么?正是我们预期的靛蓝色:

图 2.11 – 带有靛蓝色文字的浅色模式
以下截图显示了深色模式的屏幕:

图 2.12 – 带有靛蓝色文字的深色模式
然而,每个系统颜色都已被专门调整为每个外观。让我们看看每个外观的 RGB 值:
-
R 94: G 92: B 230 -
R 88: G 86: B 214
尽管每个 RGB 值之间有细微的差别,但在外观上它有巨大的影响,并且使其能够突出显示苹果定义的(如我们的系统背景颜色)的其他自适应颜色。
现在我们已经了解了所有关于自适应颜色的知识,让我们看看语义颜色以及苹果如何帮助我们预先定义我们想要使用的颜色以及某种颜色应该在哪里使用。
什么是语义颜色?
要回答本节的问题,我们需要快速回顾一下我们在什么是自适应颜色?部分已经覆盖的内容,因为我们已经触及了语义颜色。
记得我们 UILabel 中的标签颜色和系统背景颜色吗?这些都是语义颜色——与其物理颜色无关,更多的是与其定义和预期用途有关。
通过语义颜色,苹果创建了一个专为标签、背景以及表格视图等分组内容设计的预定义的自适应颜色范围。每个颜色都有额外的首选、次要和三级变体。
让我们将这些应用到我们的当前 Xcode 项目中:

图 2.13 – 带语义变体的 UILabel
我在这里添加了几个更多的 UILabel,并做了一点简单的重新排列(没有什么特别的),但我所做的是为每个标签设置了相应的语义变体 – 让我们看看:

图 2.14 – 颜色选项
如果我们展开我们的 UILabel 的颜色选项,我们可以看到所有可用的预定义自适应/语义和系统以及变体颜色的列表。我突出显示了为每个新标签选择的颜色。
现在,让我们切换外观到暗色,看看效果如何:

图 2.15 – 暗色模式下的语义标签
让我们更进一步,添加一些更多自适应内容。在这里,我添加了一个 UIView 作为内容之间的分隔符,一个 UIButton,它将是一个 URL 链接,以及一个 UITableView:

图 2.16 – 分隔符和其他背景颜色
我为我的每个新视图分配了以下语义颜色:
-
分隔符:分隔符颜色
-
按钮:链接颜色
-
表格视图:分组表格视图背景颜色
让我们在 iOS 模拟器中运行它,并看看并排效果。你会注意到一些有趣的事情:

图 2.17 – 亮色和暗色模式下的表格视图
在亮色外观中,你可以清楚地看到表格视图的分组背景颜色与系统背景颜色形成对比;然而,如果我们看看暗色外观,你就不太能看到它。这是因为随着更深的背景颜色,分隔不需要那么明显;黑色对黑色不会丢失,看起来更自然,而白色对白色则不然。
所有这些都看起来在 Interface Builder 中构建得很好,但现在让我们看看我们如何以编程方式实现它。
使用程序化方法
让我们先为我们的每个对象创建 IBOutlets。如果你不熟悉创建出口,简单地说,在ViewController中,我们做以下操作:
-
首先声明所有我们的出口属性。
-
然后,从
IBOutlet连接器(位于您的属性左侧),按Command + 主光标点击。 -
按住并拖动此控件到您想要连接的 UIView 或对象上。
在单独的窗口中打开 Interface Builder 和
ViewController将真正有助于这个过程:![图 2.18 – 创建出口]()
图 2.18 – 创建出口
-
我们需要在
ViewController.swift文件中的类声明内创建这些。将以下突出显示的代码复制到你的类中:class ViewController: UIViewController { @IBOutlet weak var headerImageView: UIImageView! @IBOutlet weak var primaryLabel: UILabel! @IBOutlet weak var secondaryLabel: UILabel! @IBOutlet weak var tertiaryLabel: UILabel! @IBOutlet weak var linkButton: UIButton! @IBOutlet weak var separatorView: UIView! @IBOutlet weak var tableView: UITableView! override func viewDidLoad() { super.viewDidLoad() } } -
现在,我们可以通过编程方式分配我们的颜色。在
viewDidLoad()函数内部,添加以下突出显示的代码:override func viewDidLoad() { super.viewDidLoad() primaryLabel.textColor = UIColor.label secondaryLabel.textColor = UIColor.secondaryLabel tertiaryLabel.textColor = UIColor.tertiaryLabel linkButton.titleLabel?.textColor = UIColor.link separatorView.backgroundColor = UIColor.separator tableView.backgroundColor = UIColor.systemGroupedBackground }
如果你启动模拟器中的应用,你会看到一切应该保持不变。如果我们真的想测试我们的逻辑,请回到界面构建器,并将我们的一个 UILabel 设置为系统绿色颜色。重新运行应用,并观察程序代码如何优先级更高并覆盖界面构建器。
在本节中,我们探讨了如何在界面构建器或通过编程方式使用自适应和语义颜色与视图一起工作,我们了解了使用颜色变体的价值,并看到了它们在浅色和深色外观中的效果。在下一节中,我们将探讨资产目录以及我们如何为我们的应用创建自定义的自适应颜色和图像。
使用资产目录进行深色模式的工作
由于在 Xcode 9 中添加颜色到资产目录的能力变得可用,现在有更多理由充分利用 Xcode 的宝贵资产之一。
在本节中,我们将探讨我们如何使用资产目录不仅创建我们的自定义颜色,还可以创建我们自己的自适应颜色和图像,从而让我们在开发动态外观应用时充分利用 Xcode 的功能。
使用自定义自适应颜色
继续使用我们当前的项目,转到文件检查器,并突出显示Assets.xcassets文件夹。在以下布局可见的情况下,点击以下截图中的突出显示的+按钮,并从选项列表中选择新建颜色集:

图 2.19 – 创建颜色集
添加另外三个颜色集,并命名为以下:
-
brandLabel -
brandSecondaryLabel -
brandTertiaryLabel
突出显示brandLabel,然后突出显示中央资产预览窗口中的选项。注意现在在属性检查器面板中向我们提供的属性选项列表:

图 2.20 – 添加颜色集
如您所见,我们现在可以定义我们想要使用的brandLabel颜色。但首先,让我们使其自适应。在属性检查器面板中,将外观从无更改为任何,浅色,深色。
你会在下拉菜单中注意到还有一个选项是任何,深色,那么让我们来看看这代表什么:
-
无:这是一个默认颜色,不会适应你选择的外观。
-
任何,深色:在这种情况下,任何将支持你应用的旧版本,以及任何其他不是深色的变体(基本上就是浅色)。深色将会是深色…
-
任何,浅色,深色:与前面相同,但将允许你为旧版和浅色(以及深色)选择特定的值。
因此,关于这一点我们已经了解了,现在让我们添加一些颜色。正如之前提到的,这是你可以非常具体地选择颜色的地方,无论是根据个人喜好还是你必须遵循的品牌指南。对我来说,我只需要点击显示颜色选择器并选择我最喜欢的颜色:
-
任何(旧版) 和浅色使用橙汁色
-
深色模式下更细腻的哈密瓜色:

图 2.21 – 选择颜色
对于 brandSecondaryLabel 和 brandTertiaryLabel 也做同样的操作,记得根据你打算使用的语义目的稍微调整颜色。
完成这些后,回到 Interface Builder,突出显示 primaryLabel,然后打开 属性检查器 中的颜色选项。你应该会看到以下内容:

图 2.22 – 默认标签颜色
你在资产目录中创建的所有颜色集都可以在 Interface Builder 中直接使用。继续为每个标签添加它们,并通过在 Interface Builder 中切换外观来查看它们的外观:

图 2.23 – 颜色集,浅色模式与深色模式
这样做之后,你就在 Xcode 的力量下为你的应用创建了自己的自适应、语义和动态颜色 – 所有这些都在 Xcode 的能力范围内。
如果你想要通过编程使用颜色,你可以通过简单地以几种不同的方式引用资产名称来实现。
首先是直接引用资产名称:
primaryLabel.textColor = UIColor(named: "brandLabel")
或者,你也可以通过按 Shift + CMD + M 并从图标选项中选择显示颜色调板来直接从媒体库中选择资产,并选择你想要的颜色。
这将在你的代码中直接插入来自资产目录的颜色作为色样:

图 2.24 – 通过编程分配颜色集
或者,如果你真的想保持你的代码干净,你可以创建一个 UIColor 的扩展,允许你定义自己的属性:
extension UIColor {
static var brandLabel: UIColor {
return UIColor(named: "brandLabel") ?? UIColor.label
}
}
现在可以这样使用:
primaryLabel.textColor = UIColor.brandLabel
这是一种很好、干净且易于管理的方式来通过编程管理你的自定义颜色集,但这完全是个人的偏好,各花入各眼。如果你正在处理一个大型替代颜色指南,将主颜色在一个扩展中更改,将自动将更改推广到你的整个应用,无需担心遗漏一个或两个标签。
接下来,让我们看看同样的方法,但这次是针对图像的。
使用自定义自适应图像
我们在前一节中学习了关于资产目录如何与自适应图像一起工作的很多知识,自定义自适应颜色,幸运的是,我们可以在为我们的项目创建自适应图像时充分利用这一点。
就像我们创建新的颜色集一样,让我们按照以下步骤进行:
-
回到Assets.xcassets。
-
创建一个新的图像集:
![图 2.25 – 新的图像集]()
图 2.25 – 新的图像集
-
将你的新图像命名为header,高亮显示它,并在属性检查器窗口中将外观更改为任何,暗。你现在应该会看到以下内容:

图 2.26 – 添加新的图像集
当向图像目录添加图像时,你会得到添加1x、2x或3x图像的选项 – 这些是你可以为不同屏幕大小设置的不同图像比例。有关更多信息,请参阅以下来自 Apple 文档的内容。
对于这个示例,我们将向2x选项添加两张不同的图像:一张用于任何,另一张用于暗。你可以从 GitHub 中找到我使用的示例项目中的图像,或者选择你自己的图像 – 这取决于你。从 Finder 中,只需将图像拖放到 Xcode 中2x占位符即可。完成操作后,你应该会看到以下内容:

图 2.27 – 新的图像集变体
现在,回到你的故事板,并在项目中添加一个 UIImageView。将其添加到ViewController的顶部以作为头部。
一旦设置好,请转到属性检查器面板,并选择图像选项的下拉菜单 – 在那里,你应该能看到你新创建的资产,头部:

图 2.28 – 从图像集中设置头部
选择它并查看(根据你选择的图像大小,你可能需要将内容模式设置为填充 – 这些选项也可以在属性检查器中找到)。
运行模拟器并查看本章到目前为止你所取得的成就,记得通过使用 Xcode 中的环境覆盖来在亮模式和暗模式之间切换…看起来相当不错,对吧?

图 2.29 – 亮模式与暗模式下的头部灯光
就像我们处理颜色集一样,如果我们愿意,当然可以以编程方式处理。让我们给我们的应用程序添加另一个扩展来处理这一点:
extension UIImage {
static var header: UIImage {
return UIImage(named: "header") ?? UIImage()
}
}
我们可以再次像之前一样使用:
headerImageView.image = UIImage.header
我们通过将头部图像直接分配给我们的 UIImageView 来实现这一点。
在本节中,我们利用了资产目录的力量,使我们能够为我们的应用创建自定义的适应性和动态颜色和图像。在下一节中,我们将探讨如何利用我们迄今为止所学的一切来最佳地更新遗留应用以支持深色模式,以及如何最佳地识别我们可以做的那些小事来为各种外观确保我们的应用未来可兼容。
进一步探索深色模式
在前面的章节中,我们向您介绍了在特定情况下创建或迁移现有应用至深色模式时需要考虑的许多因素。在本节中,我们将探讨一些应该始终牢记在心的“小贴士”,这些小贴士在您接近深色模式时应该放在心中。
使用 SwiftUI 深色模式
2019 年 6 月宣布 SwiftUI 以来,对基于 UI 的开发的关注发生了巨大转变。与深色模式同时发布,正如预期的那样,SwiftUI 充分利用了外观切换。
让我们先看看如何在 SwiftUI 中程序化地检测深色模式:
-
首先,我们将创建一个环境变量,使我们能够访问设备当前的外观状态:
@Environment(\.colorScheme) var appearance -
接下来,让我们使用一个简单的三元运算符根据当前外观显示一些文本:
Text(appearance == .dark ? "Dark Appearance" : "Light Appearance")
真的是非常简单。
现在,让我们看看自动预览窗口中可用的选项。SwiftUI 使用 PreviewProvider,它允许我们动态地显示我们正在设计/开发的内容。
要在 PreviewProvider 中启用深色模式,只需添加以下突出显示的代码并启用热刷新:
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
.environment(\.colorScheme, .dark)
}
}
在这里,我们添加了一个修改器来设置 .colorScheme 环境变量为 .dark。如果我们想并排预览 .light 和 .dark,我们可以简单地做以下操作:
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
Group {
ContentView().environment(\.colorScheme, .light)
ContentView().environment(\.colorScheme, .dark)
}
}
}
小贴士
要了解更多关于 SwiftUI 的信息,请查看 Packt Publishing 提供的 Learn SwiftUI:www.packtpub.com/business-other/learn-swiftui。
使用特性集合程序化处理变化
在开发您的新应用期间,可能会有一些场合需要根据当前的外观处理特定的场景。然而,我们需要采取与之前在 SwiftUI 示例中不同的方法来处理这个问题。
接口样式是 UITraitCollection 类的一部分(它反过来又是 UIKit 的一部分)。我们可以在 ViewController 中的任何地方使用以下方式对值进行条件检查:
traitCollection.userInterfaceStyle == .dark
与 SwiftUI 不同,我们不能简单地使用三元运算符,因为 userInterfaceStyle 有超过两个值:
public enum UIUserInterfaceStyle : Int {
case unspecified
case light
case dark
}
未指定也是一个选项(想想我们资产目录中的 Any),因此在检测我们界面样式的变化时最好使用另一种方法。
让我们先回到我们的 ViewController.swift 文件,并添加以下 override 函数:
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
// Logic here
}
每当对特性(如外观)进行更改时,都会调用此重写方法。从这一点出发,我们现在可以采取任何我们想要进行的更改,但我们面临的问题是特性不仅用于外观,而且这个重写方法可能因各种原因而被调用。
因此,如果我们特别关注外观的变化,我们可以使用传递给我们的代理函数的 previousTrait 属性,并与当前系统特性进行比较——如果有差异,我们就知道外观已经改变。让我们看看我们如何做到这一点:
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
let interfaceAppearanceChanged = previousTraitCollection?.hasDifferentColorAppearance(comparedTo: traitCollection)
}
通过使用 hasDifferentColorAppearance 方法,我们现在可以轻松地比较之前的特性与当前的特性,看看是否有任何变化——这个方法返回一个布尔值,因此我们可以方便地使用它。
为视图、ViewController 和窗口指定外观
在某些情况下,你可能希望根据你的应用程序的特定区域指定外观,或者如果你正在迁移到暗黑模式(但需要更多时间来实现某个功能)。只需插入以下适当的代码即可满足你的需求。
视图
在这里,我们将创建并实例化一个基本的 UIView:
let view = UIView()
view.overrideUserInterfaceStyle = .dark // .light
我们分配亮色或暗色值。
ViewController
如果我们想在 UIViewController 中实现这个功能,我们只需做以下操作:
overrideUserInterfaceStyle = .dark
再次强调,我们通常在 viewDidLoad() 方法中分配亮色或暗色值(通常如此)。
窗口
如果我们需要访问当前窗口,可以按照以下方式操作:
for window in UIApplication.shared.windows {
window.overrideUserInterfaceStyle = .dark
}
(这不是一个推荐的方法,而且你很难找到任何真正的理由想要这样做……)
暗黑模式下的可访问性
询问一下,有人会开玩笑说 暗黑模式 在 iOS 中已经存在多年了,无论是作为 经典反转 还是 智能反转 的可访问性功能。我甚至在官方宣布暗黑模式两个月前的一个会议幻灯片中提到了它。
但考虑到这一点,关于 iOS 中可访问性的讨论开始增多——一些评论将暗黑模式称为“苹果终于支持可访问性”,这让我非常难过。
无论外观如何,可访问性始终在 iOS 中扮演着重要角色——但是,即使引入了暗黑模式,这一点仍然没有改变,因为暗黑模式支持所有可访问性功能。
如果你回顾本章早先的部分,暗黑模式的核心开发概念,你会记得我们提到了可以安排我们的亮色和暗色外观——就像你可以在 iOS 9 中使用的 Nightshift 一样,这同样是一个关注可访问性的元素。
在本节中,我们在暗黑模式方面做了一些创新,脱离了基本实现,使我们能够查看可用的更广泛选项以及在我们应用程序中实现暗黑模式时需要考虑的事情。
摘要
在本章中,我们详细介绍了深色模式的相关内容——不仅从编程的角度,还包括了我们应用中使用的颜色外观和目的背后的理论。
我们首先查看 Xcode 和 iOS 如何为深色模式进行设置,学习了 Xcode 中使用的环境覆盖,以及我们如何在开发过程中切换 Storyboard 中的外观。
接下来,我们介绍了自适应和语义颜色,并学习了如何使用苹果的默认系统颜色,以及我们如何自己创建动态和自适应的颜色集。
在学习了颜色集之后,我们将这些知识应用到图像上,并利用了资产目录的力量。
最后,我们介绍了一些“值得了解”的主题,例如 SwiftUI 中的深色模式、程序化召唤外观以及无障碍性。
在下一章中,我们将探讨 iOS 14 中的列表,涵盖关于 UITableViews 和 UICollectionViews 你需要知道的一切。
进一步阅读
-
苹果人机界面指南(深色模式):
developer.apple.com/design/human-interface-guidelines/ios/visual-design/dark-mode/ -
苹果人机界面指南(颜色):
developer.apple.com/design/human-interface-guidelines/ios/visual-design/color/
第三章:第三章:使用列表和表格
很有可能您之前已经构建了一个简单的应用,或者您可能尝试过但并未完全成功。如果是这种情况,您可能已经使用了UITableView或UICollectionView,因为这两个都是许多 iOS 应用的核心组件。
如果一个应用显示项目列表,它很可能是使用UITableView构建的。本章将确保您熟悉UITableView和UICollectionView的方方面面。除了涵盖基础知识,例如我们如何使用代理模式,您还将学习如何访问用户数据——在这种情况下,他们的联系人——这些数据将在UITableView和UICollectionView对象中呈现。
我们将本章的结尾放在查看 SwiftUI 中的列表上,这是苹果在 2019 年宣布的新 UI 框架。我们将探讨 SwiftUI 和 UIKit 提供的根本区别。
本章将涵盖以下主题:
-
使用
UITableView -
进一步探索表格视图
-
使用
UICollectionView -
进一步探索集合视图
-
在 SwiftUI 中使用列表
技术要求
对于本章,您需要从 Apple 的 App Store 下载 Xcode 版本 11.4 或更高版本。
您还需要运行最新版本的 macOS(Catalina 或更高版本)。只需在 App Store 中搜索 Xcode,选择并下载最新版本。启动 Xcode,并遵循系统可能提示的任何其他安装说明。一旦 Xcode 完全启动,您就可以开始了。
从以下 GitHub 链接下载示例代码:
github.com/PacktPublishing/Mastering-iOS-14-Programming-4th-Edition
使用 UITableView
在本节中,我们将首先查看UITableView,这是在 iOS 中显示列表数据最常见——如果不是最常见的方法之一。
设置项目
每次您在 Xcode 中开始一个新项目时,您都有选择为您的应用选择模板的选项。每个模板都包含一小部分代码或一些样板代码以帮助您开始。在大多数情况下,甚至已经为您设置了一个基本布局。在本书中,您应该默认使用单视图应用模板。不要被它的名字所迷惑;您可以为您的应用添加尽可能多的视图。这个模板只是为您提供了一个视图以开始。
在本章中,您将创建一个名为 My Contacts 的应用。这个应用将在您设置的UITableView组件中显示用户的联系人列表。现在让我们为这个应用创建一个项目。
在菜单栏中,执行以下操作:
-
选择文件 | 新建 | 项目。
-
选择单视图应用。
-
将您的项目命名为
联系人列表或您喜欢的任何名称。 -
确保您的编程语言设置为Swift,用户界面设置为Storyboard——它应该类似于以下内容:![图 3.1 – Xcode 新项目选项
![img/Figure_3.01_B14717.jpg]()
图 3.1 – Xcode 新项目选项
-
从这里,点击下一步然后点击创建。
-
一旦您的项目加载完成,打开左侧导航树中名为
Main.storyboard的文件。
故事板文件用于布局您应用程序的所有视图,并将它们连接到您编写的代码。您用于操作故事板的编辑器称为界面构建器。
如果您以前使用过UITableView,您可能使用过UITableViewController。UITableViewController类是一个常规UIViewController类的子类。
不同之处在于UITableViewController包含了许多您否则必须自己执行设置,无论是在界面构建器中还是通过编程方式。为了完全理解UITableView是如何配置和设置的,我们在这个例子中不会使用UITableViewController。
在 Xcode 中,您会注意到右上角有一个带有加号符号的按钮。点击此按钮以打开对象浏览器。一旦打开,搜索Table View。如果您开始输入潜在组件的名称,您应该会看到一系列建议选项变得可用——就像以下截图所示:

图 3.2 – 添加对象
一旦找到表格视图,直接将其拖动到界面构建器中的画布上。不用担心它放置得是否尴尬,我们现在将通过使用自动布局添加一些约束来修复这个问题。
在我们的画布中,突出显示我们刚刚添加的UITableView对象,然后点击以下截图中突出显示的图标。添加顶部、前导、尾部和底部约束为0:

图 3.3 – 设置约束
完成后,点击UITableView对象,使其完美地固定在屏幕的每个边缘。无论在什么尺寸的设备上显示,这些约束也将得到遵守。
自动布局使您能够创建能够自动适应任何现有屏幕大小的布局。您当前的布局使用固定坐标和尺寸来布局表格视图。例如,您的表格视图被设置为在 0 的位置,大小为(375,667)。这个大小非常适合 iPhone 8 和 SE 等设备,但与 iPhone 11 或 iPad Pro 不太搭配。一个视图的位置和尺寸的组合被称为框架。
自动布局使用约束来定义布局而不是框架。例如,为了使表格视图适应整个屏幕,您会添加约束,将表格视图的每个边缘固定到其父视图的相应边缘。这样做会使表格视图始终匹配其父视图的大小。
获取联系人数据
为了让我们能够从我们的设备中获取用户的联系人信息,我们首先需要通过Contacts框架获得访问权限。
苹果对隐私保护非常重视,因此,每当一个应用第一次尝试从地址簿中读取时,他们要求用户“允许”访问。这不仅仅局限于地址簿;这同样适用于相机访问、位置服务、照片等等。
就像我们的情况一样,当你需要访问隐私敏感信息时,你必须指定一个原因,说明你为什么想要访问这些信息。不需要太详细——但足以让用户放心,知道你为什么想要访问他们的数据。
这通过在你的项目中添加一个条目到Info.plist文件来完成。每次你需要访问隐私敏感信息时,你都需要在你的应用的Info.plist文件中指定这一点。
为了将此信息添加到Info.plist中,请按照以下步骤操作:
-
从左侧的项目导航器中的文件列表中打开它。
-
一旦打开,将鼠标悬停在文件顶部的
信息属性列表上。 -
应该会出现一个加号图标。点击它将在列表中添加一个新的空条目,并带有搜索字段。
-
当你开始输入
隐私 - 联系人时,Xcode 会为你过滤选项,直到只剩下一个选项供你选择。 -
这个选项被称为隐私 - 联系人使用描述,这是我们正在寻找的键。
这个新添加的键的值应该描述你需要访问指定信息的原因。在这种情况下,“读取联系人并在列表中显示它们”应该是一个充分的解释。当用户被要求允许访问他们的联系人时,你在这里指定的原因将会显示,所以请确保你添加一个信息性的消息。
小贴士
确保你选择一个与你的应用相关的信息性消息。如果苹果审查后认为这不合适,他们可能会质疑你,甚至更糟糕的是,拒绝你的应用提交。
现在,让我们开始编写一些代码。在你能够读取联系人之前,你必须确保用户已经为你提供了适当的权限来访问联系人数据。为此,代码必须首先读取当前的权限状态。一旦完成,用户必须被提示允许访问他们的联系人,或者必须获取联系人信息。
将以下高亮代码添加到ViewController.swift中;我们将分部分介绍细节——但别担心,最终一切都会变得清晰:
import UIKit
import Contacts
class ViewController: UIViewController {
override func viewDidLoad() {
requestContacts()
}
首先,我们将Contacts框架导入到我们的ViewController类中;通过这样做,我们允许Contacts框架 API 不仅存在于我们的项目中,而且特别存在于我们的ViewController类中。
接下来,我们在viewDidLoad()中添加了对名为requestContacts的函数的调用——我们现在需要创建这个函数:
private func requestContacts() {
let store = CNContactStore()
let authorizationStatus = CNContactStore.authorizationStatus(for: .contacts)
if authorizationStatus == .notDetermined {
store.requestAccess(for: .contacts) { [weak self] didAuthorize, error in
if didAuthorize {
self?.retrieveContacts(from: store)
}
}
} else if authorizationStatus == .authorized {
retrieveContacts(from: store)
}
}
基本上,不深入太多细节,这强制 iOS(如果尚未)请求授权你的应用程序访问联系人数据。如果当前状态是未知的(或 notDetermined),则将请求权限。如果情况不是这样,并且框架响应 didAuthorize == true,那么我们现在可以尝试访问联系人信息。我们还在其中添加了一个额外的条件来检查我们是否已经被授权。你会注意到 store.requestAccess 的调用看起来与常规函数调用略有不同;这是因为它使用了一个完成处理程序。
在异步编程中,经常使用完成处理程序。它们允许你的应用程序在后台执行一些工作,然后在工作完成后调用完成处理程序。你将在许多框架中找到完成处理程序。如果你实现了一个非常简单的带有回调的功能,它可能看起来如下所示:
func doSomething(completionHandler: (Int) -> Void) {
// perform some actions
var result = theResultOfSomeAction
completionHandler(result)
}
调用一个完成处理程序就像调用一个函数一样。之所以这样做,是因为完成处理程序是一段代码,称为闭包。闭包与函数非常相似,因为它们都包含一个可能可重用的代码块,预期在调用时执行。
现在,让我们通过添加我们的函数来检索联系人,来完成拼图的最后一块:
func retrieveContacts(from store: CNContactStore) {
let containerId = store.defaultContainerIdentifier()
let predicate = CNContact.predicateForContactsInContainer(withIdentifier: containerId)
let keysToFetch = [CNContactGivenNameKey as CNKeyDescriptor,
CNContactFamilyNameKey as CNKeyDescriptor, CNContactImageDataAvailableKey as CNKeyDescriptor,
CNContactImageDataKey as CNKeyDescriptor]
let contacts = try! store.unifiedContacts(matching: predicate, keysToFetch: keysToFetch)
}
对前面代码的简要说明:我们传递一个 CNContactStore 的实例(我们之前已被授权访问),然后使用 CNKeyDescriptor 数组设置我们想要获取的特定信息请求。
最后,调用被发起,并将获取到的信息以 CNContact 对象的形式返回给我们。
准备 UITableView 来显示我们的联系人
准备就绪后,让我们回到 Interface Builder 中,添加一个表格单元格:
-
高亮显示我们添加到画布上的
UITableView对象。 -
点击
Table View Cell。 -
现在,将这个对象拖到
UITableView上。
你会注意到这次有一点不同:我们拖动的 UITableViewCell 对象自动吸附到了我们的 UITableView 对象的位置上——别担心,这是正常的,这是因为 UITableViewCell 的位置是由其 UITableView 对象的配置控制的。接下来,我们将为我们的代码创建一个 IBOutlet。就像我们在 第二章 中所做的那样,使用暗黑模式,在 ViewController.swift 文件中以编程方式创建一个出口,然后使用 Interface Builder 连接它们。
这里是你将要创建的出口的示例:
@IBOutlet weak var tableView: UITableView!
现在,我们需要为 UITableViewCell 创建一个类——通过这样做,我们可以向 UITableViewCell 添加自定义属性,例如姓名、联系信息,甚至图片。
在我们的 ViewController.swift 文件内部(但不在 ViewController 类声明外部),添加以下代码:
class ContactCell: UITableViewCell {
@IBOutlet weak var nameLabel: UILabel!
@IBOutlet weak var contactImageView: UIImageView!
}
在这里,我们创建了一个自定义单元格,它是 UITableViewCell 的子类,并将携带我们需要的所有表格视图单元格特性。我还为将要显示的数据添加了一些 IBOutlet 组件。
现在,让我们将其连接起来。回到 Interface Builder,并选择我们添加的 UITableViewCell 对象。
一旦高亮显示,点击右侧工具窗口中的 Identity 检查器,并将 ContactCell 作为 类名 添加:


图 3.4 – 表格视图类
然后,点击属性检查器,并输入 contactCell 作为 标识符:


图 3.5 – 表格视图单元格标识符
将 UITableViewCell 的类覆盖为我们自定义的类,这将允许我们使用 Interface Builder 将对象连接到我们刚刚创建的 IBOutlet 组件。我们将在本章后面讨论标识符,但一开始就处理这些事情总是好的。
现在,让我们向 UITableViewCell 添加一些对象。我们首先从 UILabel 开始,然后是 UIImageView(以相同的方式添加表格视图和单元格——图片位于单元格的左侧)。
添加后,尝试使用我们之前学到的 Autolayout 约束。掌握 Auto Layout 的最好方法是试错——如果你卡住了,只需参考本章的示例项目来引导你。
完成后,你的单元格将看起来像这样:


图 3.6 – 带约束的标签
现在,让我们将这些连接到我们创建的 IBOutlet 组件——如果一切顺利,Interface Builder 应该会识别关联的类(ContactCell),并将允许无问题地连接出口。
太棒了,我们正在取得很大的进步,信不信由你,我们离在应用中显示数据已经不远了——但首先,我们需要了解一些 UITableView 的重要基础,更重要的是,iOS 严重依赖的委托模式。
理解协议和委托
在整个 iOS SDK 和 Foundation 框架中,使用了一个名为委托的设计模式。委托允许一个对象让另一个对象代表它执行工作。
当正确实现时,这是一种很好的方法,可以在你的应用中分离关注点并解耦代码。
表格视图使用两个对象来正确运行。一个是委托,另一个是数据源。每次使用表格视图时,你必须自己配置这两个对象。当表格视图需要渲染其内容时,它会向数据源请求有关要显示的数据的信息。当用户与表格视图中的项目交互时,委托就会发挥作用。
如果你查看UITableView的文档,你可以找到代理属性。代理的类型是UITableViewDelegate?。这告诉你关于代理的两件事。首先,UITableViewDelegate是一个协议。这意味着任何对象都可以作为表格视图的代理,只要它实现了UITableViewDelegate协议。其次,类型名称末尾的问号告诉你代理是一个可选属性。可选属性要么具有指定的类型值,要么是 nil。表格视图的代理属性是可选的,因为你不必设置它来创建一个功能正常的表格视图。
协议,例如UITableViewDelegate,定义了一组必须由任何想要遵守该协议的类型实现的属性和方法。并非所有方法都必须由遵守对象显式实现。有时,协议扩展提供了一个合理的默认实现。
除了代理之外,UITableView还有一个数据源属性。数据源的类型是UITableViewDataSource?,就像UITableViewDelegate一样,UITableViewDataSource也是一个协议。然而,UITableViewDelegate只有可选方法,这意味着你不需要实现任何方法来遵守UITableViewDelegate。UITableViewDataSource确实有一些必需的方法:需要实现的方法用于向表格视图提供足够的信息,以便能够显示正确数量的单元格,并包含正确的内容。
如果这是你第一次学习关于协议和委派的内容,你现在可能会感到有些迷茫。没关系;你很快就会掌握的。在这本书的整个过程中,你对这些主题的理解将逐步提高。你甚至还会了解到一个叫做协议导向编程的概念!
现在,你必须理解表格视图会请求一个不同的对象来显示所需的数据,并且它还会使用一个不同的对象来处理某些用户交互。
我们可以将表格视图显示内容的流程分解为几个步骤;当表格视图需要重新加载数据时,它会执行以下操作:
-
表格视图会检查
dataSource是否已设置,并请求它提供它应该渲染的分区数量。 -
一旦将分区数量传回表格视图,就会要求
dataSource为每个分区提供项目数量。 -
在了解需要显示的分区和项目数量后,表格视图会请求
dataSource提供它应该显示的单元格。 -
在接收到所有配置好的单元格后,表格视图最终可以将这些单元格渲染到屏幕上。
这些步骤应该能让你对表格视图如何使用另一个对象来确定它应该渲染的内容有更深入的了解。这种模式很有吸引力,因为它使表格视图成为一个极其灵活的组件。让我们将一些新获得的知识付诸实践!
符合 UITableView 协议
要使 ViewController 同时成为其表格视图的代理和数据源,它必须符合这两个协议。创建扩展以使对象符合协议是一种最佳实践。理想情况下,为每个要实现的协议创建一个扩展。这样做有助于保持代码的整洁和可维护性。
将以下扩展添加到 ViewController.swift:
extension ViewController: UITableViewDelegate, UITableViewDataSource {
}
在这样做之后,您的代码中包含了一个错误。这是因为还没有实现 UITableViewDataSource 所需的任何方法。
您需要实现两个方法来符合 UITableViewDataSource 协议。这些方法如下:
-
tableView(_:numberOfRowsInSection:) -
tableView(_:cellForRowAt:)
让我们继续修复 Xcode 显示的错误,通过稍微调整代码。我们还需要对代码进行一些小的修改,以便在表格视图中显示我们的联系人。
我们将从向 ViewController 类添加一个全局变量开始。在类声明之后添加以下内容:
var contacts = [CNContact]()
在这里,我们实例化了一个 CNContact 数组,这是我们调用 store.unifiedContacts 时在 retrieveContacts 函数中返回的内容。
现在,对我们的 retrieveContacts 函数进行以下修改:
contacts = try! store.unifiedContacts(matching: predicate, keysToFetch: keysToFetch)
完美!现在用那些代理填充空白:
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return contacts.count
}
我们的第一个代理方法 tableView(_:numberOfRowsInSection:) 要求我们返回我们想要显示的单元格数。由于我们想要显示所有联系人,我们只需将数组中的联系人数量返回,如前述代码中突出显示的那样。
接下来,让我们实现 tableView(_:cellForRowAt:) 代理。复制以下代码,我们将一步一步地分析它:
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let contact = contacts[indexPath.row]
let cell = UITableViewCell()
cell.textLabel?.text = contact.familyName
return cell
}
基本上,这个代理方法为将要生成的每个单元格调用,所以如果 contacts.count == 5,那么这将被调用 5 次。我们可以通过检查每次调用中传递的 indexPath.row 值来识别当前被调用的单元格。
如果您查看带有先前代理的第一行代码,您会看到我们通过查询 indexPath.row 值的 CNContact 数组来访问特定的联系人。从这个值,我们只是创建一个 UITableViewCell 实例,将 CNContact 的一个属性分配给 .textLabel,然后返回该实例。
我们几乎准备好看到我们的更改生效了;只需添加一些其他内容即可。
回到我们的 viewDidLoad() 函数,并添加以下突出显示的行:
override func viewDidLoad() {
tableView.delegate = self
tableView.dataSource = self
requestContacts()
}
在这里,我们正在告诉我们的 UITableView 实例,我们的当前 ViewController 类是所有 UITableView 协议操作的代理基础。简而言之——我们刚刚添加的代理将在我们尝试对表格视图执行任何操作时被调用。
最后,将以下突出显示的代码添加到我们的 retrieveContacts 函数的末尾:
contacts = try! store.unifiedContacts(matching: predicate, keysToFetch: keysToFetch)
DispatchQueue.main.async {
self.tableView.reloadData()
}
我们这样做的原因再次归结于异步编程。按照设计,我们的表格视图将在 ViewController 加载后立即尝试显示数据。此时,我们的联系人可能不可用,我们可能没有获得适当的权限,或者简单地,函数的回调可能没有及时返回所有数据。
因此,如果我们知道所有数据都准备好显示,我们只需要求表格视图重新加载即可。现在,请在模拟器中运行你的应用程序——如果一切顺利,你将被 Contacts 框架提示允许访问你的联系人权限,随后将显示联系人详细信息列表:

图 3.7 – 用户同意和用户列表
在这部分,我们了解了在 iOS 开发中协议和代理有多么重要,通过连接几个简单的函数,我们能够轻松而有效地在 UITableView 中显示数据。现在,让我们看看我们如何使用我们之前创建的 UITableViewCell 重写来进一步自定义每个单元格。
理解自定义 UITableViewCell 重写和重用标识符
在前面的部分,准备 UITableView 显示我们的联系人,你会记得我们创建了一个自定义的 UITableViewCell 重写 ContactCell,但最终我们并没有真正使用它。
我们故意这样做,首先是为了让你了解 UITableViewCell 确实有一个最小的默认提供,其中 textLabel 是为你添加所需文本而提供的。这可以作为一个非常轻量级的方式来生成 UITableView 对象并显示一些简单数据——这是一个无需麻烦的快速获胜方法,或者当一行就足够时的情况。然而,如果你想对你的单元格进行创意设计,那么这就是自定义选项发挥作用的地方。
让我们回到 tableView(_:cellForRowAt:) 方法,看看我们如何进行更改:
let contact = contacts[indexPath.row]
guard let cell = tableView.dequeueReusableCell(withIdentifier: "contactCell", for: indexPath) as? ContactCell else {
return UITableViewCell()
}
cell.nameLabel.text = contact.givenName
if let imageData = contact.imageData {
cell.conatctImageView.image = UIImage(data: imageData)
} else {
cell.conatctImageView.image = UIImage(systemName: "person.circle")
}
现在,首先,让我们看看第一部分:
tableView.dequeueReusableCell(withIdentifier: "contactCell", for: indexPath) as? ContactCell
在这里,我们使用标识符为 contactCell 的可重用 UITableViewCell 来实例化 ContactCell 类。
听起来很复杂?也许有一点,但这样想——我们创建了一个自定义的单元格类,并在 Interface Builder 中将其分配给我们的 UITableViewCell 对象。然后我们给它一个 contactCell 的标识符——在这里,我们只是调用该单元格以供使用,这样我们就可以访问其属性(记住我们添加的 nameLabel 和 contactImageView 属性)。
一旦我们访问到该单元格的实例,我们就可以简单地根据从联系人实例中获取的数据分配每个属性。注意,我们正在检查联系人的图像数据,因为有可能某个联系人还没有关联图像——在这里,我们添加了一个小的回退来显示系统图像(使用 SF Symbols)。
如果你想添加一张图片,只需在模拟器中打开“联系人”应用,并将图片从你的 Mac 上拖拽过来 - 你现在应该能够从“联系人”应用中选择这张图片并分配给它。
好吧,进行这些更改,然后再次运行应用:

图 3.8 – 带有图片的联系人列表
完美,但这个dequeueReusableCell到底是什么意思?别担心,我们将在稍后的部分,UITableView 和 UICollectionView 的进阶中介绍。
在本节中,我们学习了如何实现UITableView和自定义的UITableViewCell,通过访问Contacts框架获取数据,并在我们的应用中显示它。现在,让我们花些时间深入探讨表格视图的艺术及其工作原理。
进一步探索 UITableView
在本节中,我们将触及一些额外的细节,这将帮助你充分利用UITableView。我们还将更详细地介绍之前探索的一些领域,例如重用标识符。
深入理解重用标识符
在本章的早期部分,你学习了表格视图中的单元格重用。我们给表格视图单元格分配了一个重用标识符,这样表格视图就会知道应该使用哪个单元格来显示联系人。单元格重用是一个应用于表格视图的概念,以便它可以重用已经创建的单元格。
这意味着在内存中的单元格只有屏幕上或几乎在屏幕上的单元格。另一种选择是保留所有单元格在内存中,这可能会意味着在任何给定时间都有数十万个单元格被保留在内存中。
为了可视化单元格重用看起来是什么样子,请查看以下图表:

图 3.9 – 表格视图单元格布局
如你所见,图表中只有少数单元格不在可见屏幕上。这大致等于表格视图可能保留在内存中的单元格数量。这意味着无论你想要显示的总行数有多少,表格视图对你的应用内存使用量的压力大致是恒定的。
当在表格视图中调用dequeueReusableCell(withIdentifier:)方法且没有可用的未使用单元格时,会首先创建一个单元格。一旦单元格被重用或创建,就会在单元格上调用prepareForReuse()方法。这是一个重置单元格到默认状态的好地方,通过移除任何图片或将标签设置回默认值。
接下来,在单元格显示之前,会在表格视图的代理上调用tableView(_:willDisplay:forRowAt:)方法。你可以在这里进行一些最后的配置,但大部分工作应该在tableView(_:cellForRowAtIndexPath:)中完成。
当单元格滚动出屏幕时,会在代理上调用tableView(_:didEndDisplaying:forRowAt:)方法。这表示之前可见的单元格刚刚滚动出了视图的边界。
在考虑到所有这些单元格生命周期信息的情况下,修复图像重用错误的最佳方式是在ContactCell上实现prepareForReuse()。添加以下实现以移除之前设置的任何图像:
override func prepareForReuse() {
super.prepareForReuse()
conatctImageView.image = nil
}
现在,让我们看看我们可以通过使用预取来在我们的应用中实现的其他一些增强功能。
表格视图中的预取
除了UITableViewDelegate和UITableViewDataSource之外,还存在第三个协议,您可以实现它来提高表格视图的性能。
它被称为UITableViewDataSourcePrefetching,您可以使用它来增强数据源。如果数据源执行一些复杂的任务,例如检索和解码图像,如果在表格视图想要检索单元格的时候执行这个任务,可能会降低表格视图的性能。在这些情况下,提前一点执行这个操作可以积极影响您的应用。
那么,我们该如何实现这个功能呢?简单来说,我们首先让ViewController符合新的代理协议:
extension ViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
}
}
您会注意到这里的一个基本区别在于传入的indexPath参数。这次,我们有一个IndexPath数组,而不是单个索引,从而允许我们对表格视图希望显示的一组单元格执行批量处理。
如果您的单元格数据需要异步获取——例如图像或实时数据——这将非常理想。您真的可以在这里努力工作,以正确地执行和计算显示数据的方式,以获得最佳性能。
表格视图中的单元格选择
由于表格视图会在实现方法时调用其代理的方法,因此您不需要告诉表格视图您想要响应用户对单元格的选择。如果表格视图有一个代理,并且代理实现了tableView(_:didSelectRowAt:),则这会自动工作。
目前,您将添加到我们的应用中的实现非常简单。当用户点击一个单元格时,应用会显示一个警告框。
将以下代码添加到ViewController.swift中的扩展部分:
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let contact = contacts[indexPath.row]
let alertController = UIAlertController(title: "Contact Details", message: "Hey \(contact.givenName)!!",
preferredStyle: .alert)
let dismissAction = UIAlertAction(title: "Done", style: .default, handler: { action in
tableView.deselectRow(at: indexPath, animated: true)
})
alertController.addAction(dismissAction);
present(alertController, animated: true, completion: nil)
}
tableView(_:didSelectRowAt:)方法接收两个参数:第一个是调用此代理方法的表格视图。第二个参数是选择发生的索引路径。
您为这个方法编写的实现使用索引路径检索与被点击单元格对应的联系人,因此可以在警告框中显示联系人姓名。
您还可以从被点击的单元格中检索联系人的姓名。然而,这并不被认为是良好的实践,因为您的单元格和底层数据应该尽可能地松散耦合。
当用户在警告框中点击完成按钮时,表格视图会被告知取消选中当前行。
如果你没有取消选中选中的行,最后触摸的单元格将始终保持高亮。请注意,通过在视图控制器上调用present(_:animated:completion:)来显示警报。任何你想让视图控制器显示另一个视图控制器,例如警报控制器,你都会使用这个方法。
在本节中,你了解了很多关于使表格视图工作原理的知识,包括对重用标识符的良好理解。接下来,我们将查看UICollectionView,这是UITableView类的一个更大的(或者更年轻的,实际上)兄弟,比较每个类之间的相似之处以及关键差异。
与UICollectionView一起工作
在上一节中,我们承担了强大的UITableView——学习了关于代理模式以及如何使用自定义单元格构建我们独特的列表。在本节中,我们将查看UICollectionView,主要关注我们如何将一个类与另一个类进行比较。
从一开始,当被问及两者之间的基本区别是什么时,大多数人最初都会说同样的话:“集合视图允许水平滚动”——这是非常正确的,但它所做的是利用UITableView的力量,具有操纵和覆盖布局的能力,例如允许网格布局。
如果你需要深入了解一个复杂的自定义布局,UICollectionView再次发挥作用,它支持UICollectionViewDelegateFlowLayout协议,允许你作为开发者操纵自定义布局。
设置我们的集合视图
让我们以与表格视图相同的方式创建一个新的项目:
-
这次,在对象窗口中搜索
Collection View(你不需要添加一个CollectionView单元格,因为集合视图已经为你做了这件事)。 -
也要添加你的约束,以便它能够扩展到设备的全尺寸。
回到ViewController,我们需要创建和连接我们的IBOutlet组件,就像我们之前与表格视图所做的那样(但将你的属性命名为类似collectionView的东西)。
一旦你完成了这个步骤,我们就需要创建另一个扩展,但这次,我们的协议将略有不同:
extension ViewController: UICollectionViewDelegate, UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
contacts.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let contact = contacts[indexPath.item]
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "contactCell", for: indexPath) as? ContactCell else {
return UICollectionViewCell()
}
cell.setup(contact: contact)
return cell
}
}
之前示例中的两个代理方法如下:
-
collectionView(_:numberOfItemsInSection:) -
collectionView(_:cellForItemAt:)
它们两者都提供了与它们的UITableView对应项相同的选项;你唯一会注意到的区别是术语Item而不是Row的引用。这是因为UITableView中的布局是纯线性的,所以每个单元格都被视为一行。在UICollectionView中,情况并非如此——因此,每个单元格被称为一个项目。
接下来,我们想要创建另一个自定义单元格。复制并粘贴我们为表格视图制作的单元格,并做出以下突出显示的更改:
class ContactCell: UICollectionViewCell {
@IBOutlet weak var familyNameLabel: UILabel!
@IBOutlet weak var givenNameLabel: UILabel!
@IBOutlet weak var contactImageView: UIImageView!
func setup(contact: CNContact) {
givenNameLabel.text = contact.givenName
familyNameLabel.text = contact.familyName
if let imageData = contact.imageData {
conatctImageView.image = UIImage(data: imageData)
} else {
conatctImageView.image = UIImage(systemName: "person.circle")
}
}
}
之前代码中的差异很微妙,但其中之一非常重要:我们的子类现在是UICollectionViewCell类型(而不是UITableViewCell),我们还添加了一些额外的出口,因为我们还将添加更多数据。
为了有所不同,我们将创建一个滚动水平联系人列表和一个网格布局。让我们回到界面构建器,稍微修改一下我们的画布。
我们将从添加另一个文本字段开始。注意以下图中我如何调整单元格的大小:

图 3.10 – 收藏视图单元格
我们可以在几个地方做这件事。如果我们突出显示收藏视图并选择大小检查器,我们就可以在那里做:

图 3.11 – 收藏视图大小检查器
或者,我们也可以直接在单元格本身上做,如以下截图所示,再次通过选择大小检查器:

图 3.12 – 收藏视图单元格大小检查器
这很好,有两个原因。首先,你可以通过视觉方式设置单元格的大小,这总是做事情的一种既方便又愉快的方式。其次,即使你打算通过编程方式覆盖单元格大小(因为你需要一个更动态的方法),这也允许你可视化和设置约束,这样你就知道你可以玩什么。我现在只是将我的设置为 150 宽度 x 230 高度,这应该给我们足够的灵活性。
因此,让我们继续设置我们的界面。同样,我们需要用我们的自定义类覆盖类:

图 3.13 – 收藏视图类
然后,我们需要分配我们的单元格标识符:

图 3.14 – 收藏视图单元格标识符
然后,我们将估计高度设置为无。这阻止了我们的单元格根据单元格内内容的大小动态调整大小(例如,一个带有非常长的名字或地址的标签):

图 3.15 – 收藏视图估计的单元格大小
我们几乎完成了——只需要添加一些小东西。你注意到我们的viewDidLoad()函数中缺少了什么吗?我们在UITabelView示例中有这个函数。
是的,我们还没有将我们的代理方法设置到ViewController对象上,但在这个例子中,我将向你展示另一种方法,我们可以通过界面构建器来完成,而不是通过编程方式来完成。
高亮显示你的 CollectionView 对象,按住键盘上的 Ctrl,然后单击并按住鼠标——如果你开始拖动鼠标,你会看到一个线(就像我们连接 IBOutlet 一样)。将线拖到 ViewController 对象并释放。然后你会看到以下选项:

图 3.16 – Collection View 代理出口
选择 dataSource,然后重复此过程并选择 delegate。
就这样——除了这里和那里的几个小改动之外,我们已经在很大程度上以与我们的 UITableView 相同的方式设置了 UICollectionView。现在,让我们运行项目,看看它看起来如何:

图 3.17 – Collection View 布局
看起来很不错——我是说,除了单元格看起来有点不合适之外——但不用担心,我们将在下一部分通过介绍 UICollectionViewDelegateFlowLayout 协议来查看我们如何改变这一点。
使用 UICollectionViewDelegateFlowLayout 实现布局
在上一节中,我们根据之前创建 UITableView 项目时所学的所有内容创建了我们第一个 UICollectionView 项目。我们学到的一件事是,与表格视图相比,我们的单元格可以以不同的方式排列。
那么,我们如何操作我们的单元格,使它们做到我们想要它们做到的?为此,我们需要实现 UICollectionViewDelegateFlowLayout 协议——但它能提供什么呢?让我们先看看这个协议中最常用的代理方法,以及它如何轻松地改变我们的应用。
在我们的扩展中,添加以下突出显示的协议,与现有的协议并列:
extension ViewController: UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout
现在,按照良好的实践,你可以将每个协议分开到它自己的扩展中——但因为我们只处理每个的几个代理,所以我们现在将它们保留在一个地方是完全可以的。
小贴士
如果你的 ViewController 对象开始变得有点大,你可以将你的扩展移动到单独的文件中——这使它们更容易工作,并保持你的文件整洁。
现在,我们将添加以下 delegate 方法:
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { }
sizeForItem 代理简单地允许我们以编程方式设置单元格的大小,所以让我们来玩一下,看看我们能想出什么。将以下代码添加到前面的函数中:
let width = (collectionView.bounds.size.width / 2) – 10
return CGSize(width: width, height: 180)
因此,在这里,我们执行了一个简单而优雅的计算:我们取当前屏幕的宽度,除以 2,然后减去 10(为了有一点填充),然后将其作为单元格宽度。最后,我们将添加一个静态值作为我们的高度。运行你的应用并看看它给出了什么:

图 3.18 – Collection View 布局
很好,让我们看看我们还能做什么。简单返回屏幕的全尺寸怎么样?
return collectionView.bounds.size
接下来,让我们改变滚动方向;我们可以通过 Interface Builder 来实现,选择 Collection View。将值更改为 Horizontal:

图 3.19 – 收藏视图滚动方向
继续运行应用 – 它看起来怎么样?

图 3.20 – 收藏视图滚动方向布局
完美 – 只需稍作修改,我们就为我们的应用带来了巨大的变化,清楚地突出了 UICollectionView 相比 UITableView 的强大功能。
在我们结束关于 UICollectionView 的这一节之前,让我们快速看一下 UICollectionViewDelegateFlowLayout 提供给我们的其他代理方法。
项(单元格)的大小
当你需要操作一个项的边界或框架时,使用 collectionView(_:layout:sizeForItemAt:),它会请求代理指定项的单元格大小。
部分(Section)和间距
以下是一些用于编程调整单元格项和部分之间间距的选项(不包括头部和页脚):
-
collectionView(_:layout:insetForSectionAt:):请求代理指定应用于指定部分内容的边距 -
collectionView(_:layout:minimumLineSpacingForSectionAt:):请求代理指定部分中连续行或列之间的间距 -
collectionView(_:layout:minimumInteritemSpacing ForSectionAt:):请求代理指定部分中行或列中连续项之间的间距
页脚和头部尺寸
以下是一些用于编程调整单元格项之间间距的选项,特别是针对头部和页脚:
-
collectionView(_:layout:referenceSizeForHeaderInSection:):请求代理指定指定部分中头部视图的大小 -
collectionView(_:layout:referenceSizeForFooterInSection:):请求代理指定部分中页脚视图的大小
在本节中,我们了解了关于 UICollectionView 组件的所有内容 – 如何在 Xcode 中设置它们,以及它们与 UITableView 组件之间的区别 – 并且能够看到它们为我们带来的好处,以及通过 UICollectionViewDelegateFlowLayout 协议来定制我们的应用,使其更具视觉吸引力。在下一节中,我们将更深入地探讨 UITableView 的一些进步。
进一步探索 UICollectionView
在本节中,我们还将再次触及一些额外的小细节,就像我们的表格视图一样,这将使我们能够真正利用收藏视图的力量 – 尤其是在计算布局大小方面。我们将从查看一些我们可以利用的覆盖方法开始。
实现自定义 UICollectionViewLayout
实现一个像自定义集合视图布局这样的大型且复杂的特性,对于大多数人来说可能是一个巨大的挑战。
创建你的布局涉及到计算集合视图将要显示的每个单元格的位置。你必须确保这些计算尽可能快、尽可能高效地执行,因为你的布局计算直接影响到集合视图的性能。糟糕的布局实现最终会导致滚动缓慢和糟糕的用户体验。
幸运的是,为创建集合视图布局提供的文档相当不错,可以作为参考来了解你是否走上了正确的道路。
如果你查看苹果关于 UICollectionViewLayout 的文档,你可以了解它在集合视图中的作用。可用的信息显示,自定义布局需要你处理单元格、辅助视图和装饰视图的布局。辅助视图也被称为头部和尾部。
让我们看看我们如何开始实现它。我们首先创建自己的类来完成这项工作:
class ContactsCollectionViewLayout: UICollectionViewLayout {
override var collectionViewContentSize: CGSize {
return .zero
}
override func prepare() {
}
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
return false
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
return nil
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return nil
}
}
如前述代码所示,这里我们实现了一个 UICollectionViewLayout 的子类,并有许多可以使用的重写函数——现在让我们来了解一下这些函数。
实现 collectionViewContentSize
集合视图使用其布局中的 collectionViewContentSize 属性来确定其内容的大小。这个属性特别重要,因为它用于配置和显示集合视图的滚动指示器。
它还提供了集合视图关于滚动应启用方向的详细信息。
实现这个属性使用集合视图中的行数和列数。它还考虑了项目大小和项目间距,以确定所有内容的总大小。
实现 layoutAttributesForElements(in:)
比 collectionViewContentSize 更复杂的是 layoutAttributesForElements(in:)。这个方法负责一次性为集合视图提供多个元素的布局属性。
集合视图始终提供一个矩形,它需要布局属性。布局负责尽可能快地提供这些属性给集合视图。这个方法的实现必须尽可能高效。你的滚动性能取决于它。
尽管一次只能看到少量单元格,但集合视图在其当前视图中还有更多内容。有时它被要求跳转到特定的单元格,或者用户滚动得非常快。
有许多情况下,集合视图会一次性请求多个单元格的所有布局属性。当这种情况发生时,布局对象可以帮助单元格确定特定矩形应该显示哪些单元格。这是可能的,因为布局属性不仅包含单元格应该渲染的矩形,还知道与该特定单元格对应的IndexPath对象。
这是一个相当复杂的问题,如果你觉得有点困惑,这是完全可以理解的。只要你理解集合视图可以询问其布局在某个CGRect实例中哪些单元格存在以及它们应该如何渲染,你就理解了layoutAttributesForElements(in:)的作用。
实现 layoutAttributesForItem(at:)方法
集合视图请求其布局的布局属性的另一种方式是请求单个项目的属性。因为集合视图通过提供索引路径来这样做,所以这个方法实现起来相当简单。
你实现的布局假设集合视图中只有一个分区,并且布局属性数组按索引路径排序,因为所有项目都是按照这个顺序插入到数组中的。
实现 shouldInvalidateLayout(forBoundsChange:)方法
获取shouldInvalidateLayout(forBoundsChange:)的实现对于拥有性能出色的集合视图布局至关重要。
如果你错误地实现此方法,你可能会不断使布局无效,这意味着你需要不断重新计算。
还有可能集合视图根本不会更新其布局,即使它应该更新。集合视图会在其大小改变时调用此方法。例如,当用户旋转设备或当你的应用在 iPad 上运行时,用户在多任务模式下打开另一个应用。
为你的集合视图分配自定义布局
使用自定义布局的最终步骤是告诉你的集合视图使用你的布局。你已经在 Interface Builder 中看到,你可以为集合视图的布局分配一个自定义类。
然而,这仅在布局继承自UICollectionViewFlowLayout时才有效,而你的布局并没有继承自它。幸运的是,你还可以在代码中设置集合视图的布局。通过在ViewController.swift中的viewDidLoad方法中添加以下行来更新它:
collectionView.collectionViewLayout = ContactsCollectionViewLayout()
这行代码将你的新布局设置为当前布局。你现在可以移除ViewController.swift中的UICollectionViewDelegateFlowLayout扩展,因为它不再需要了。
现在我们已经更详细地了解了布局,让我们看看我们如何处理用户与单元格选择的交互。
集合视图中的单元格选择
虽然几乎与UITableView的对应方法相同,但我认为指出这个函数是有价值的:
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)
再次,唯一的真正区别是 Row 被替换成了 Item,这个委托方法以完全相同的方式执行。
如果我们需要以任何方式操作它,我们可以使用重用标识符并直接与我们的单元格一起工作(参见 第五章,通过动画让用户沉浸其中,了解我们在这里可以做一些令人兴奋的事情)。
在本节中,我们深入探讨了 UICollectionView 布局选项的内部工作原理,并进一步探讨了如何使用 UICollectionViewLayout 来创建我们自己的布局子类,这样,作为开发者的我们就可以在需要时利用特定的和复杂的计算。在我们接下来的最后一节中,我们将探讨苹果的新强大 UI 框架 SwiftUI 如何处理列表。
在 SwiftUI 中与列表一起工作
回到 2019 年的 WWDC,苹果向世界展示了一个全新的 UI 框架,名为 SwiftUI。从头开始构建,SwiftUI 是 UIKit 和 AppKit 的强大替代品,为开发者提供了使用声明性语法编写代码的能力。
在本节中,我们将介绍 SwiftUI 在生成列表方面能提供什么,以及如果我们需要使用目前尚未可用的事物时,我们可能需要做些什么。
创建我们的第一个 SwiftUI 项目
为了这个,我们需要创建一个新的单视图应用,就像之前一样,但这次我们需要选择用户界面为 SwiftUI,如下面的截图所示:

图 3.22 – SwiftUI "Hello, World!"
现在,让我们看看如何添加一个项目列表。
在 SwiftUI 中构建列表
我们将从简单开始,只是将已经存在的标签添加到列表中。进行以下突出显示的代码更改,并在需要时在预览辅助窗口中按 "恢复":
var body: some View {
List {
Text("Hello, World!")
}
}
没错,它确实像那样简单。继续添加几个更多的 Text 视图,看看效果如何——甚至尝试在你的模拟器中运行它,看看效果:

图 3.23 – SwiftUI 列表
简单又直接,但就像我们处理 UITableView 组件和 UICollectionView 组件一样,让我们看看我们如何添加一些外部数据。
在 ContentView.swift 中的代码进行以下突出显示的更改:
@State var contacts: [String] = [String]()
var body: some View {
List {
ForEach(contacts, id: \.self) { contact in
Text(contact)
}
}
}
在这里,我们向我们的列表中添加了一个 ForEach 函数;这将遍历我们刚刚在主体外部创建的联系人数组。
但如果我们按 "恢复",你会发现我们没有数据……让我们解决这个问题。看看 ContentView 结构下面,你会看到以下代码:
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
这个结构是如何显示我们的预览的——我们在开发 SwiftUI 视图时的自己的小内部测试/游乐场——甚至不需要运行模拟器。
让我们在其中添加一些代码来注入一些模拟数据到我们的预览中。进行以下突出显示的更改:
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
let mockData = ["Chris", "Andy", "Harry", "Nathan", "Sam"]
return ContentView(contacts: mockData)
}
}
我们只需创建一些模拟数据,可以直接将其注入到我们的视图中,如果尚未更新。在预览辅助窗口中点击 恢复,看看你的代码如何运行——然而,如果你在模拟器中运行它,你将看不到任何东西,因为预览不再有效,你看到的应用程序实际上是你的实际应用程序(我们还没有添加任何数据)。
在 SwiftUI 中创建自定义单元格的等效方法
在使用 SwiftUI 开发时,很难不将 UIKit 对象直接进行比较——但我们会这样做,而且没关系,因为通常总有一种方法可以执行类似操作或学习新的方法。
回到本节的开头,我提到 SwiftUI 全是关于视图的,当在 SwiftUI 中实现自定义 "单元格" 时,这也没有什么不同。我们将首先创建一个新的视图(我们将在你的 ContentView.swift 文件中这样做,但不在初始类声明之外):
struct RowView: View {
@State var name: String
var body: some View {
Text(name)
}
}
就像 ContentView 一样,我们的新 RowView 是一个简单的视图,可以在 SwiftUI 的任何地方使用。结构可以接受一个 name: String 变量,并在 Text 视图中显示它——就像我们在 ContentView 中做的那样。
现在,让我们修改我们的代码以利用这一点。进行以下突出显示的更改:
List {
ForEach(contacts, id: \.self) { contact in
RowView(name: contact)
}
}
真的是很简单;我们现在可以将 RowView 当作我们处理集合视图或表格视图单元格一样,装饰它们或独立于父列表进行操作。
你甚至可以只为 RowView 创建自己的预览提供者,这样你就可以在开发过程中再次注入模拟数据。
在本节中,我们介绍了 SwiftUI 作为框架,并查看创建项目所需的基本构建块。从这一点出发,我们了解了列表的使用方法以及我们如何利用预览助手在开发 SwiftUI 接口时获得优势。
摘要
在本章中,我们探讨了与列表相关的所有内容。我们首先学习了如何创建 UITableView 对象——从我们的设备中拉取联系人并以我们想要的方式显示它们。然后,我们转向 UICollectionView,将其与我们之前的实现进行比较,并查看它提供的一些细微和较大的差异——例如单元格布局和操作。
然后,我们深入研究了这些内容的每一个,特别是查看使用 UICollectionView 组件的布局,这是其最强大的功能之一。
然后,我们通过查看 SwiftUI 框架以及苹果如何使其不仅易于开发,而且易于以我们之前习惯的方式显示数据来结束本节,这得益于声明性语法和预览助手的运用。
在下一章中,我们将进一步探讨我们的列表,并为它们创建一个详情页面,以便使用本章中介绍的单元格交互进行导航。
进一步阅读
-
Apple 开发者文档关于表格视图:
developer.apple.com/documentation/uikit/views_and_controls/table_views -
Apple 开发者文档关于集合视图:
developer.apple.com/documentation/uikit/views_and_controls/collection_views -
学习 SwiftUI (Packt 出版):
第四章:第四章:创建详细页面
到目前为止,你已经成功构建了一个应用,该应用在集合视图中以自定义网格显示一组联系人。这相当令人印象深刻,但并不十分有用。通常,用户会期望在点击概览中的项目时能够看到更多信息。
在这种情况下,他们可能会期望看到更多关于被点击联系人的详细信息,例如他们的电子邮件地址和电话号码。在本章中,你将看到如何做到这一点。
我们还将首次接触到 UIStackView,这是一种无需过度复杂的自动布局解决方案,即可全面且强大地布局显示的方式。
最后,我们将讨论在从一个视图控制器传递数据到另一个视图控制器时的最佳实践。
本章将涵盖以下主题:
-
使用 segues 实现导航
-
使用
UIStackView创建布局 -
在视图控制器之间传递数据
技术要求
对于本章,你需要从 Apple 的 App Store 下载 Xcode 版本 11.4 或更高版本。
你还需要运行最新版本的 macOS(Catalina 或更高版本)。只需在 App Store 中搜索 Xcode,选择并下载最新版本。启动 Xcode 并遵循系统可能提示的任何其他安装说明。一旦 Xcode 完全启动,你就可以开始了。
从以下 GitHub 链接下载示例代码:github.com/PacktPublishing/Mastering-iOS-14-Programming-4th-Edition
使用 segues 实现导航
大多数优秀应用都有不止一个屏幕。我敢打赌,你头脑中的大多数应用想法至少涉及两个不同的屏幕。也许你希望显示一个表格视图或集合视图,这些视图可以链接到详细页面。或者,也许你希望用户以不同的方式深入到你的应用内容中。也许你没有任何详细视图,但你想显示几个用于数据输入的模态屏幕。
每当你的用户从你的应用中的一个屏幕移动到另一个屏幕时,他们就是在导航。导航是构建应用的一个基本方面,你必须了解在 iOS 平台上构建良好导航的可能性和模式。了解导航的最简单方法是通过使用故事板来探索可用的选项。
到目前为止,除了 SwiftUI 之外,你一直使用你的故事板来创建单个屏幕的布局。
然而,故事板这个名字暗示你可以做很多不仅仅是布局单个屏幕的事情。使用故事板的目的在于能够在一个地方布局你应用的所有屏幕,这样你可以轻松地看到屏幕和你的应用部分之间的关系以及用户如何在它们之间导航。
在本节中,我们将涵盖以下内容:
-
创建我们的新详细视图
-
实现 和 理解 segues
-
创建手动 segue
让我们开始吧。
创建我们的新详细视图
在本节中,你将在故事板中添加一个第二个视图控制器,当用户点击联系人时,它将作为一个详细页面工作——我们将继续从第三章,使用列表和表格中我们的集合视图项目。
让我们先做以下操作:
-
打开
Main.storyboard文件。 -
在对象库中搜索并拖出一个视图控制器(就像我们处理集合视图对象时做的那样)。
-
将其放置在现有视图控制器旁边。
-
在对象库中查找一个标签并将其添加到刚刚添加到故事板中的新视图控制器。
如果一切顺利,它应该看起来像以下图示:

图 4.1 – 包含新详细视图的故事板
在你将联系人详细页面的所有内容添加到第二个视图控制器之前,配置从概览页面到详细页面的导航是个好主意。为此,你需要创建一个选择过渡。
实现和理解过渡
过渡是从一个屏幕到另一个屏幕的转换。并非所有过渡都是动画的;有时你可能需要在不执行平滑动画的情况下展示下一个屏幕。动画和静态转换都可以通过过渡来设置。
每次你连接一个屏幕到下一个以执行导航时,你都在创建一个过渡。有些过渡是在用户点击按钮时执行的;这些被称为动作过渡。仅通过代码触发的过渡被称为手动过渡。
你在这个示例中将要使用的选择过渡是通过将表格视图单元格或集合视图单元格连接到下一个屏幕来设置的。当用户点击单元格时执行过渡。
要设置你的选择过渡,请按照以下步骤操作:
-
选择为联系人概览页面创建的原型集合视图单元格。
-
接下来,按住 Ctrl 键,从单元格拖动到第二个视图控制器。
当你将鼠标移至第二个视图控制器上时,会显示一个选项列表:

图 4.2 – 新的详细视图的过渡连接器
这个选项列表描述了如何向用户展示详细视图。
例如,从生成的列表中选择模态展示样式。这将从屏幕底部向上显示详细页面(一个模型),这并不是我们将要走的路线。然而,如果你现在启动 iOS 模拟器并选择其中一个单元格,你会看到它产生的影响。
通过将联系人添加到导航堆栈中,可以更好地显示联系人。这样做将在导航栏中显示一个返回按钮,并且由于从屏幕右侧移动新视图控制器而产生的动画,用户会立即明白他们正在查看一个详情页面。
为了设置此选项,你需要选择 Show 转场 – 高亮显示之前创建的转场,然后在键盘上按 Delete。
这个转场将新显示的视图控制器推送到现有导航控制器导航堆栈中,但直到我们告诉我们的应用我们以这种方式需要导航,它仍然被视为一个模型。
为了解决这个问题,通过对象库添加一个新的名为 Navigation Controller 的对象,并将其拖动到你的故事板中。你会注意到这会带来两个视图控制器。
第一个是导航视图控制器本身(我们关心的那个)和另一个是模板或预定义的 rootViewController,它已经连接到导航视图控制器。
为了修改这个,执行以下操作:
-
简单地删除这个
rootViewController,保留导航控制器在当前位置。 -
然后,按住 Ctrl 并从导航控制器进行主点击,然后拖动到我们的现有
ViewController。 -
释放时,你会得到一个选项来将其设置为
rootViewController。选择此选项。
需要做的最后一个更改是你会注意到一个箭头进入我们现有的 View Controller 的侧面;将这个箭头从这里拖动到 Navigation Controller – 所有这些只是在设置这个为我们的 Initial View Controller,这样当应用启动时,它就知道从哪里开始。
现在运行应用。你应该能够在点击单元格时成功导航前后。现在让我们看看如何创建一个手动转场。
创建手动转场
我们首先将删除我们刚刚创建的转场。现在从第一个视图控制器窗口顶部的黄色圆圈拖动到第二个视图控制器 – 你现在已经创建了一个手动转场。
当出现确定如何执行转场对话框时,再次选择 show,因为你不想使用不同的动画。
点击连接线来检查转场,并在 Attributes Inspector 中设置 detailViewSegue 的值。类似于在表格视图单元格和集合视图单元格上设置重用标识符,转场也使用字符串作为它们的标识符。
为了在动画之后触发转场,你必须从你的代码中手动执行。打开 ViewController.swift 并更新 collectionView(_:didSelectItemAt:) 的内容,如下面的代码片段所示:
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
self.performSegue(withIdentifier: "detailViewSegue", sender: self)
}
通过一行代码,我们可以通过其标识符连接到我们的转场,然后就可以开始了。现在运行应用,看看有多简单。
在本节中,我们开始创建和构建我们自己的详细视图页面,了解我们如何配置和控制从一个视图控制器推送到下一个视图控制器。
在下一节中,我们将查看如何创建无需Autolayout的自适应布局。
使用 UIStackView 创建我们的布局
现在我们已经设置了所有基础,让我们看看我们将如何构建我们的详细页面。
我们有几个选择。一种方法是UIStackView。
堆叠视图可以自行布局相邻或堆叠在一起的视图。这节省了你添加标签之间垂直间距约束的需要,就像我们在联系人详细信息中做的那样。
由于堆叠视图也可以布局相邻的对象,并且堆叠视图可以嵌套,因此它还可以处理为常规宽度大小类屏幕实现的二维布局。而且,最重要的是,你可以在运行时交换堆叠视图布局项的方向,这意味着你可以根据可用空间将其从水平更改为垂直。
这将大大简化需要完成的大量工作,并且使你的布局更容易维护。要使用堆叠视图,你只需要在 Interface Builder 中通过Stack View将一个添加到你的新视图控制器中)。
在堆叠视图中包含标签
让我们开始创建我们的页面布局:
-
添加六个
UILabel,其中三个将是标题,其余将是变量数据(确保你设置好颜色:见第二章,使用深色模式)。像这样会很好用:![图 4.3 – 带有标签的更新后的故事板![图片]()
图 4.3 – 带有标签的更新后的故事板
-
现在,选择联系人信息视图中的六个标签,并使用以下截图所示的嵌入到菜单将它们嵌入到堆叠视图中:![图 4.4 – 嵌入到堆叠视图
![图片]()
图 4.4 – 嵌入到堆叠视图
-
添加两个其他元素,例如图像视图或联系人名称,并将它们分组(与刚刚嵌入的标签分开)。
-
现在,对于巧妙的部分,突出显示两个堆叠视图,然后点击嵌入到以将它们嵌入到一个单独的堆叠视图中。
看起来不错。嗯,几乎是这样——我们仍然需要做一些小的调整。首先,我们将向主堆叠视图添加一个Autolayout约束0,16,16,0(基本上,除了尾部和头部之外,将其紧贴我们的边界)。
接下来,你需要在堆叠视图中的每个标签(和图像)上设置Autolayout的高度:
-
将约束值设置为
250,并将所有标签设置为25。 -
完成后,选择父堆叠视图,并在属性检查器中确保对齐设置为填充,分布设置为按比例填充。
这些设置确保项目在堆叠视图中以某种方式定位。在这种情况下,Leading 使项目粘附在堆叠视图的左侧。将此值设置为 Center 将使它们居中,而 Fill 确保堆叠的子项都拉伸以填充整个宽度。
完成这些操作后,你应该会看到以下内容:

图 4.5 – 栈视图中的详细视图
现在我们的详细视图已经准备好接收一些数据了,所以让我们看看在下一部分我们将如何进行,但首先,我们需要创建一个新的 视图控制器 文件。
在导航树中,突出显示根级别组(文件夹),然后执行以下操作:
-
右键单击以显示菜单。
-
点击 新建文件。
-
从选项列表中选择 Cocoa Touch 类(点击 下一步)。
-
将新文件命名为
DetailsViewController,它是一个UIViewController的子类。 -
点击 下一步,然后 创建。
完成此操作后,添加所有必需的出口并将它们连接起来。但在 Interface Builder 允许你连接这些出口之前,你需要设置 DetailsViewController 的类 – 就像我们在 第三章 中做的那样,使用列表和表格。
我们还需要添加以下变量,因为我们很快就会将这个模型传递给新的视图控制器:
var contact = CNContact()
完成所有这些后,我们现在可以更新我们的 retrieveContacts() 逻辑以获取所需的新数据。
在视图控制器之间传递数据
因此,我们应用程序的下一部分是将一些数据传递到我们的新详细视图中,但要做到这一点,我们需要创建一个新的视图控制器,并将我们的标签和图像连接到一些出口。
获取联系信息的代码也需要更新,以便获取联系人的电话号码、电子邮件地址和邮政地址。
最后,需要将联系数据从概览页面传递到详细页面,以便详细页面可以显示数据。这个过程涉及以下步骤:
-
更新数据加载
-
将模型传递到详细页面
-
更新我们的出口
-
最佳实践(创建视图模型)
让我们现在逐一介绍每个步骤。
更新数据加载
目前,ViewController.swift 中的代码指定,只需获取联系人的给定名称、姓氏、图像数据和图像可用性。
需要扩展以获取电子邮件地址、邮政地址和电话号码。
使用以下代码更新 retrieveContacts(store:) 方法中的 keysToFetch:
let keysToFetch = [CNContactGivenNameKey as CNKeyDescriptor,
CNContactFamilyNameKey as CNKeyDescriptor, CNContactImageDataAvailableKey as CNKeyDescriptor, CNContactImageDataKey as CNKeyDescriptor,
CNContactEmailAddressesKey as CNKeyDescriptor, CNContactPhoneNumbersKey as CNKeyDescriptor, CNContactPostalAddressesKey as CNKeyDescriptor]
在这里,我们只是明确设置我们希望从联系人中检索的数据。完成这些后,我们就准备好将数据传递到详细视图控制器了。
将模型传递到详细页面
从概览页面切换到详情页面是通过过渡动画实现的。当用户点击一个联系人时,过渡动画被触发,详情页面将显示在屏幕上。
因为这个过渡使用了过渡动画,所以可以实现一个特殊的方法来从第一个视图控制器传递数据到第二个视图控制器。这个特殊的方法被称为prepare(for:sender:)。
这个方法在执行过渡动画之前在源视图控制器中被调用,并提供对目标视图控制器的访问。
过渡的目标用于配置即将呈现的视图控制器上的数据。让我们立即实现这个功能,这样你就可以将选定的联系人传递到详情页面。
将以下扩展添加到ViewController.swift中:
extension ViewController {
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if let contactDetailVC = segue.destination as? DetailsViewController,
segue.identifier == "detailViewSegue",
let selectedIndex = collectionView.indexPathsForSelectedItems?.first {
contactDetailVC.contact = contacts[selectedIndex.row]
}
}
}
对前面代码的简要概述是检查过渡动画的标识符是否为"detailViewSegue"(因为所有过渡动画都会通过这个函数)。如果这个条件满足,它将检查目标(类型为UIViewController)是否是我们正在寻找的DetailsViewController。
如果一切顺利,我们可以将我们的联系人分配给该视图控制器上的一个属性,然后它将被传递过去。现在让我们连接我们的输出端口,以便我们可以开始绑定一些数据。
更新我们的输出端口
我们已经准备就绪;我们只需要将我们的数据连接到输出端口。为此,请在DetailsViewController中的viewDidLoad()中进行以下更改:
contactImageView.image = UIImage(data: contact.imageData ?? Data())
contactName.text = "\(contact.givenName) \(contact.familyName)"
contactPhoneNumber.text = contact.phoneNumbers.first?.value.stringValue
contactEmailAddress.text = contact.emailAddresses.first?.value as String?
contactAddress.text = contact.postalAddresses.first?.value.street
继续运行你的应用程序。看看,如果你点击你的联系人,你会直接导航到你的新视图控制器,在那里你可以看到该特定联系人的所有详细信息。
然而,再次查看我们刚刚添加的代码,它看起来有点杂乱。我们在视图控制器内部连接了givenName和familyName,并且还随机从联系人第一个持久化的地址中获取街道值。
所有这些逻辑都不应该放在我们的视图控制器中——这就是我们所说的视图模型逻辑,我的意思是我们应该创建自己的联系人模型,该模型包含我们所需要的一切,以及从我们的CNContent对象直接获取它的所有逻辑。让我们看看我们该如何做。
最佳实践——创建视图模型
在我们的例子中,我们的CNContact有多个属性,其中一些我们甚至还没有从Contacts.framework请求。正如我们在上一节中看到的逻辑,基于视图所需的内容和模型拥有的内容进行逻辑决策不应在此级别执行。
那么,我们该如何解决这个问题呢?很简单。首先,让我们创建我们的自定义模型。将以下内容复制到一个新文件中,并将其命名为ContactModel.swift:
struct ContactModel {
var fullName: String?
var primaryPhoneNumber: String?
var primaryEmailAddress: String?
var addressLine1: String?
var contactImage: UIImage?
init(_ contact: CNContact) {
fullName = "\(contact.givenName) \(contact.familyName)"
primaryPhoneNumber = contact.phoneNumbers.first?.value.stringValue
primaryEmailAddress = contact.emailAddresses.first?.value as String?
addressLine1 = contact.postalAddresses.first?.value.street
contactImage = UIImage(data: contact.imageData ?? Data()) ?? UIImage(systemName: "person.circle")
}
}
在这里,我们简单地创建了一个结构体,并添加了基于我们将要显示的确切内容的属性。然后我们将创建一个自定义初始化器,它接受一个CNContact参数。从这里,我们将原本在视图控制器中所有的逻辑移除,并将其放在这里——这是这个视图模型逻辑的一个集中位置。
现在我们只需要做一些微调。更新DetailsViewController中的类变量如下:
var contact: ContactModel?
并且调整我们的ViewController中的prepare()重写方法如下:
contactDetailVC.contact = ContactModel(contacts[selectedIndex.row])
完成这些后,再次运行应用。你会发现实际上并没有什么变化,但现在你可以离开,知道你已经迈出了编写和管理良好、可维护代码的重要一步。
在本节中,我们通过使用准备转场函数将模型传递到我们的DetailViewController,将所有东西连接在一起。
摘要
在本节中,我们首先创建了一个全新的视图控制器,专门用于显示选定的用户信息。我们学习了不同类型的转场,包括基于导航的转场和基于模型的转场。我们还介绍了通过编程和通过 Interface Builder 创建转场的方法。
一旦我们设置了所有连接器,我们就开始构建我们的新详情视图控制器,用联系人的信息填充它,并利用UIStackView的力量来布局我们的标签和图像视图。
我们通过连接一切来结束。我们执行了一些最佳实践,并创建了一个自定义视图模型,现在我们可以将其传递到我们的新prepare()重写方法中。
在下一章中,我们将深入探讨在 iOS 中使用动画和过渡的效果,随着我们的创意开始涌现!
进一步阅读
- 苹果文档:
developer.apple.com/library/archive/featuredarticles/ViewControllerPGforiPhoneOS/UsingSegues.html
第五章:第五章:通过动画让用户沉浸其中
你的应用现在看起来真的很好,而且我们已经在这几章中覆盖了很多内容,但 UIKit 还有很多令人惊叹的功能我们尚未探索——其中之一就是动画。
在本章中,你将学习一些使用 UIKit 的高级技术,UIKit 是 Apple 直接集成到 UIKit 中的动画框架。我们将从了解小事物如何产生巨大差异的基础知识开始,然后继续学习一些更高级的技术,包括UIViewPropertyAnimator以及它如何比你在前几章中实现的动画提供更多控制。你还将了解 UIKit Dynamics。UIKit Dynamics 可以通过应用物理来使对象对其周围环境做出反应。
最后,你将学习如何在从一个视图控制器移动到下一个视图控制器时实现自定义过渡。
本章将涵盖以下主题:
-
使用
UIView.animate和UIViewPropertyAnimator -
使用 UIKit Dynamics 中的弹簧实现生动的动画
-
自定义视图控制器过渡
技术要求
对于本章,你需要从 Apple 的 AppStore 下载 Xcode 版本 11.4 或更高版本。
你还需要运行最新的 macOS(Catalina 或更高版本)。只需在 App Store 中搜索Xcode,选择并下载最新版本。启动 Xcode,并遵循系统可能提示的任何其他安装说明。一旦 Xcode 完全启动,你就可以开始了。
从以下 GitHub 链接下载示例代码:
github.com/PacktPublishing/Mastering-iOS-14-Programming-4th-Edition
使用 UIView.animate 和 UIViewPropertyAnimator
正如我在简介中所说,我们的应用已经取得了很大的进步,但往往,正是我们可以做的那些小事会产生巨大的差异;你只需再次查看第二章,使用深色模式,就能体会到这一点。
在本节中,我们将首先通过使用标准实践添加一些基本动画到我们的应用中,以实现简单而有效的结果。
在完成这些后,我们将探讨如何通过重构和改进代码库的维护性来进一步扩展这一点。因此,让我们开始添加我们的第一个动画到我们的应用中。
创建我们的第一个动画
在它们最基本的形式中,动画简单易用。以下是一个典型动画的示例,可以执行:
UIView.animate(withDuration: 0.8) {
self.cell.familyNameLabel.alpha = 1.0
}
那么,这究竟意味着什么呢?嗯,UIView.animate函数(它本身也是一个闭包)正在将我们的 cell 属性的不透明度设置为1.0。如果我们假设这个属性的不透明度被设置为0.0,那么在0.8秒的动画过程中,animate函数将不透明度从0.0渐变到1.0——从而给我们一个简单但极其有效的淡入效果!
让我们将其付诸实践,继续我们上一章的项目。前往我们的DetailsViewController.swift文件。
首先,让我们将联系图片的不透明度设置为0.0。我们可以通过扩展我们的 outlet 属性来包括didSet来实现这一点。在视图控制器中做出以下突出显示的更改:
@IBOutlet weak var contactImageView: UIImageView! {
didSet {
contactImageView.alpha = 0
}
}
在这里,我们只是添加了一个 setter 并设置了一个额外的属性在我们的UIImageView上——在这种情况下,我们将不透明度设置为0。
现在,回到我们视图控制器的主体。将以下内容添加到你的viewWillAppear()函数中:
UIView.animate(withDuration: 0.8) {
self.contactImageView.alpha = 1
}
正如我们在前面的例子中看到的,我们只是设置动画的持续时间,然后在闭包中设置我们属性的不透明度值。
继续在模拟器中运行你的代码;你会看到当DetailsViewController现在加载时,你会得到一个很棒的淡入动画。只需稍微调整一个属性和几行代码,你的应用就取得了巨大的进步!
与多个动画一起工作
现在我们再进一步,给tapped时的UICollectionViewCell添加一个弹跳效果。
前往我们的视图控制器,找到didSelectItemAt:函数。记得在第三章,使用列表和表格中,我们确定了如何获取当前选中 cell 的实例,如果我们想对它做些什么?好吧,这就是我们的机会。
将以下代码添加到didSelectItemAt:cell 的开始部分:
guard let cell = collectionView.cellForItem(at: indexPath) as? ContactCell else {
return
}
与cellForItem:不同,在那里我们使用re-us标识符来回收使用我们的 cell,这里我们只关心选中的实例——这是我们想要使用并对其做些事情的 cell。
接下来,我们将添加一大块可能让人困惑的“初始”代码,所以我们会一步一步地分解它。在前面代码的下方,添加以下内容:
UIView.animate(withDuration: 0.1, delay: 0, options: [.curveEaseOut], animations: {
cell.conatctImageView.transform = CGAffineTransform(scaleX: 0.9, y: 0.9)
})
这里,我们扩展了之前看到的.animate函数,但这次你看到我们有一个延迟参数,我们将其设置为0,因为我们希望动画立即开始(但我猜如果我们想的话,我们也可以延迟它)。
接下来,我们现在有一个options参数,我们可以传递一个包含通过 UIKit 可用的动画选项的数组。在这里,我们将传递curveEaseOut(别担心,我们将在本章后面介绍不同类型的动画选项)。
最后,我们通过将 CGAffineTransform 设置为特定的 x 和 y 缩放比例来设置我们的图像视图的变换。通过在图像上设置变换,我们实际上是根据新的 x 和 y 值来缩放原始尺寸。
好吧,启动应用 – 你看到了什么?希望没有太多东西 – 你可能会想知道为什么没有。那是因为我们仍然有 performSegue 调用在那里,它在动画完成之前被调用(并执行)。暂时注释掉它,然后再次尝试。如果有任何运气,当你点击单元格时,你应该看到联系人图像缩小(或者给出一个按下外观)。
因此,在我们担心恢复 performSegue 调用之前,让我们首先确保动画看起来正确。我们新的动画块中还有一个技巧。在闭包内部,我们可以添加一个完成处理程序,该处理程序将在动画完成后立即被调用(我知道你在想什么,但让我们先完成动画)。
使用以下突出显示的行更新代码:
UIView.animate(withDuration: 0.1, delay: 0, options: [.curveEaseOut], animations: {
cell.conatctImageView.transform = CGAffineTransform(scaleX: 0.9, y: 0.9)
}, completion: { finished in
UIView.animate(withDuration: 0.1, delay: 0, options: [.curveEaseOut], animations: {
cell.conatctImageView.transform = CGAffineTransform.identity
})
})
因此,我们在这里所做的只是通过添加 completion: { finished in 并加入另一个动画函数来扩展我们初始动画函数的完成处理程序。
在这个闭包内部,我们通过将其设置为 CGAffineTransform.identity(一种快速将任何变换恢复到原始状态的好方法)来重置我们的图像视图的变换。
现在在模拟器中运行应用,一切正常;你应该会看到一个非常好的弹跳效果。现在,让我们再次扩展我们的第二个动画函数,添加一个完成处理程序,以便再次加入 performSegue:
UIView.animate(withDuration: 0.1, delay: 0, options: [.curveEaseOut], animations: {
cell.conatctImageView.transform = CGAffineTransform(scaleX: 0.9, y: 0.9)
}, completion: { finished in
UIView.animate(withDuration: 0.1, delay: 0, options: [.curveEaseIn], animations: {
cell.conatctImageView.transform = CGAffineTransform.identity
}, completion: { [weak self] finished in
self?.performSegue(withIdentifier: "detailViewSegue", sender:self)
})
})
再次运行你的应用,以全貌欣赏你美丽的动画,紧接着立即过渡到 DetailViewController – 在那里,你会看到一个微妙而有效的淡入动画,展示你的联系人图像。做得好,你做得非常出色!
在本节中,我们学习了如何处理 UIKit 中的基本动画 – 我们有所进步,探讨了如何将基本动画扩展以执行更复杂的任务。
在下一节中,我们将探讨如何使用 UIViewPropertyAnimator 来简化这个过程。
使用 UIViewPropertyAnimator 进行重构
因此,在掌握了一些基本的动画之后,我们现在可以深入探讨 iOS 提供了哪些功能。虽然我们之前的代码功能强大,代码行数也不多,但它却相当丑陋,包含嵌套的完成处理程序 – 这样的代码维护起来可能真的会变成一场噩梦,尤其是如果你需要将动画扩展得更加复杂的话。
倾向于使用 UIViewPropertyAnimator 而不是你刚刚看到的实现的一个原因是可读性。让我们看看当重构为使用 UIViewPropertyAnimator 时,同样的弹跳动画看起来是什么样子:
let downAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .easeOut) {
cell.conatctImageView.transform = CGAffineTransform(scaleX: 0.9, y: 0.9)
}
let upAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .easeIn) {
cell.conatctImageView.transform = CGAffineTransform.identity
}
downAnimator.addCompletion { _ in
upAnimator.startAnimation()
}
upAnimator.addCompletion { [weak self] _ in
self?.performSegue(withIdentifier: "detailViewSegue", sender: self)
}
downAnimator.startAnimation()
现在,乍一看,您可能会觉得这里的代码行数比之前多得多,您并没有错,但这确实使阅读和维护变得更加容易。
使用 UIViewPropertyAnimator,它确实如其名称所描述的那样:它允许您将动画分配给一个属性,然后您可以在函数中独立执行该属性。
之前的代码与我们对原始实现进行分解的简化版本没有区别。
将此添加到您的代码中并运行您的应用程序。您会发现与之前的版本没有任何区别。
示例代码使用了一个接受计时函数的 UIViewPropertyAnimator 版本,以使最终的弹跳动画更加生动。如果您查看示例代码,传递给 UIViewPropertyAnimator 初始化器的第一个参数是动画的持续时间(以秒为单位)。
第二个参数控制计时函数。计时函数描述了动画应该如何随时间进行。例如,easeIn 选项描述了动画如何以缓慢的速度开始,并随着时间的推移而加速。
下面的图表描述了一些最常用的计时函数:
![Figure 5.1 – Curve timing function scales]
![Figure 5.01_B14717.jpg]
![Figure 5.1 – Curve timing function scales]
在这些图表中,水平 轴代表动画的进度。对于每个图表,动画时间线从左到右在 x 轴上描述。动画的进度从下到上在 y 轴上可视化。在左下角,动画尚未开始。在图表的右侧,动画已经完全完成。垂直轴代表时间。
传递给 UIViewPropertyAnimator 初始化器的最后一个参数是您希望执行的动画的可选参数。这与 UIView.animate 方式执行事情非常相似;最显著的区别是您可以在创建动画器之后添加更多动画,这意味着动画的参数可以是 nil,您可以在稍后添加您希望执行的动画。这非常强大,因为您甚至可以在动画运行时向 UIViewPropertyAnimator 添加新的动画!
在您之前看到的示例代码中的第二个部分添加了完成闭包到动画器中。这两个完成闭包都接收一个单一参数。接收到的参数描述了在动画的哪个点调用了完成闭包。这个属性通常具有 .end 的值,这表示动画在结束位置结束。
然而,这并不总是正确的,因为如果您愿意,您可以在动画进行到一半时完成动画。您还可以反转动画,这意味着完成位置将是 .start。
一旦添加了完成闭包,并且属性动画器完全配置好,最后一步是通过在动画器对象上调用startAnimation()来开始动画。一旦调用startAnimation()方法,动画就会立即开始执行。如果需要,你可以通过调用startAnimation(afterDelay:)来使动画延迟开始。
现在你已经更好地理解了UIViewPropertyAnimator的工作原理,为什么不尝试更改我们在DetailViewController中添加的淡入淡出效果呢?对于这样简单的一段代码,UIViewPropertyAnimator可能有点过度,但仅仅为了乐趣可能也不错。
在示例项目中,我会包括这两种场景,并在你需要时注释掉另一个以供参考。
在本节中,我们迈出了巨大的一步,进入了 iOS 开发中的动画世界,学习了如何简单地添加动画以及如何使用UIViewPropertyAnimator构建更复杂的动画,以提升代码的可维护性。
在下一节中,我们将探讨如何控制动画。
理解和控制动画进度
UIViewPropertyAnimator的最好特性之一是你可以用它来创建可以被中断、反转或与之交互的动画。iOS 中你看到的许多动画都是交互式动画——例如,在页面上滑动以返回上一页就是一个交互式过渡。
在主屏幕上滑动页面、打开控制中心或下拉通知中心都是通过与之交互来操作的动画的例子。
尽管交互式动画的概念可能听起来很复杂,但UIViewPropertyAnimator使得实现它们变得相当简单。
例如,你将看到如何在我们的应用中实现联系人详情页上的抽屉。首先,你将准备视图,以便抽屉在应用中部分可见。一旦视图全部设置好,你将编写代码以执行抽屉的交互式显示和隐藏动画。
让我们从回到Main.storyboard并执行以下操作开始:
-
通过对象库将一个 UIView 添加到我们的画布上(确保它位于父 UIStackView 的顶部,而不是内部)。
-
设置自动布局约束以确保抽屉视图的宽度等于主视图的宽度(试行和尾行都设置为
0)。 -
将视图的高度设置为
350pt。 -
然后,将底部约束设置为
-305。
这应该会让视图刚好可见,足以覆盖屏幕底部的安全区域。接下来,我们需要在我们的新视图中添加一个按钮:
-
通过对象库添加按钮。
-
将顶部约束设置为从新视图(其父视图)顶部
8pt。 -
将首行和尾行间距设置为大约
16pts。 -
将按钮的标签设置为
Toggle。 -
同时,将背景设置为系统次要背景颜色。
如果一切顺利,你应该会有类似以下的内容:
![图 5.2 – 带有图像视图的详细视图]
![图 5.02 – 图 5.02_B14717.jpg]
![图 5.2 – 带有图像视图的详细视图]
现在我们已经整理好了布局,让我们连接所需的代码。我们的抽屉功能应实现以下功能:
-
通过点击切换按钮来切换抽屉。
-
在抽屉上滑动时,交互式地切换抽屉。
-
允许用户点击切换按钮,然后滑动抽屉来操作或反转动画。
这种行为并不简单;没有 UIViewPropertyAnimator,你将不得不编写大量的复杂代码,而且你离期望的结果还相当远。让我们看看 UIViewPropertyAnimator 是如何使实现这种效果变得可管理的。
为了准备实现抽屉,请将以下属性添加到 DetailsViewController 中:
@IBOutlet var drawer: UIView!
var isDrawerOpen = false
var drawerPanStart: CGFloat = 0
var animator: UIViewPropertyAnimator?
此外,为 DetailsViewController 添加一个扩展,其中包含一个用于点击操作的 @IBAction。@IBAction 与 @IBOutlet 类似,但它用于在响应特定用户操作时调用特定方法。使用扩展,可以很好地组织动画代码:
extension DetailsViewController {
@IBAction func toggleDrawerTapped() {
}
}
现在,让我们连接我们的输出:
-
将我们的 UIView 连接到我们刚刚添加的
IBOutlet。 -
将你的 UIButton 连接到我们在扩展中刚刚创建的
IBAction。
当你从操作拖动到按钮时,会出现一个菜单,你可以从中选择要触发 @IBAction 的操作。要响应按钮点击,请从该菜单中选择触摸内部。
最后,将以下行添加到 viewDidLoad() 的末尾:
let panRecognizer = UIPanGestureRecognizer(target: self, action:#selector(didPanOnDrawer(recognizer:)))
drawer.addGestureRecognizer(panRecognizer)
此外,将以下方法添加到之前创建的扩展中,用于 @IBAction。这是当用户在抽屉上执行平移手势时调用的方法:
@objc func didPanOnDrawer(recognizer: UIPanGestureRecognizer) {
}
现在所有占位符都已实现,让我们创建一个简单的打开抽屉动画的第一版本。
当用户点击切换按钮时,抽屉应根据抽屉的当前状态打开或关闭。以下代码片段实现了这样的动画:
animator = UIViewPropertyAnimator(duration: 1, curve: .easeOut) { [unowned self] in
if self.isDrawerOpen {
self.drawer.transform = CGAffineTransform.identity
} else {
self.drawer.transform = CGAffineTransform(translationX: 0, y: -305)
}
}
animator?.addCompletion { [unowned self] _ in
self.animator = nil
self.isDrawerOpen = !(self.drawer.transform == CGAffineTransform.identity)
}
animator?.startAnimation()
传递给属性动画器的动画使用 isDrawerOpen 的值来确定动画应该打开还是关闭抽屉。当抽屉当前打开时,它应该关闭,反之亦然。
一旦动画完成,isDrawerOpen 变量就会更新以反映抽屉的新状态。为了确定当前状态,应用程序读取抽屉的当前转换。如果抽屉没有转换,其转换将等于 CGAffineTransform.identity,则认为抽屉是关闭的。否则,认为抽屉是打开的。
现在就构建并运行你的应用程序,看看它是如何工作的。你会看到它工作得有多好。
与平移手势识别器的交互
为了允许用户通过在屏幕上拖动手指来中断或开始动画,代码必须检查是否存在正在执行动画的现有属性动画器。
如果不存在动画器或者当前动画器没有运行任何动画,应该创建一个新的动画器实例。在所有其他情况下,都可以利用现有的动画器。
让我们重构 toggleDrawerTapped() 中的动画器创建代码,以便尽可能重用动画器,并在需要时创建新的动画器。
将以下新函数 setUpAnimation() 添加到我们的扩展中:
private func setUpAnimation() {
guard animator == nil || animator?.isRunning == false else { return }
animator = UIViewPropertyAnimator(duration: 1, curve: .easeOut) { [unowned self] in
if self.isDrawerOpen {
self.drawer.transform = CGAffineTransform.identity
} else {
self.drawer.transform = CGAffineTransform(translationX: 0, y: -305)
}
}
animator?.addCompletion { [unowned self] _ in
self.animator = nil
self.isDrawerOpen = !(self.drawer.transform == CGAffineTransform.identity)
}
}
你会注意到我们刚刚从 IBAction 中提取了大部分代码——现在我们需要更新 IBAction 以调用这个新函数:
@IBAction func toggleDrawerTapped() {
setUpAnimation()
animator?.startAnimation()
}
现在,为 didPanOnDrawer(recognizer: UIPanGestureRecognizer) 添加以下实现:
switch recognizer.state {
case .began:
setUpAnimation()
animator?.pauseAnimation()
drawerPanStart = animator?.fractionComplete ?? 0
case .changed:
if self.isDrawerOpen {
animator?.fractionComplete = (recognizer.translation(in: drawer).y / 305) + drawerPanStart
} else {
animator?.fractionComplete = (recognizer.translation(in: drawer).y / -305) + drawerPanStart
}
default:
drawerPanStart = 0
let timing = UICubicTimingParameters(animationCurve: .easeOut)
animator?.continueAnimation(withTimingParameters: timing, durationFactor: 0)
let isSwipingDown = recognizer.velocity(in: drawer).y > 0
if isSwipingDown == !isDrawerOpen {
animator?.isReversed = true
}
}
这个方法会在滑动手势识别器发生任何变化时被调用。当滑动手势第一次开始时,动画被配置,然后在对动画器对象调用 pauseAnimation()。
这允许我们根据用户的滑动行为来改变动画进度。因为用户可能在动画进行中开始滑动——例如,在先点击切换按钮之后——当前 fractionComplete 的值将被存储在 drawerPanStart 变量中。
fractionComplete 的值是一个介于 0 和 1 之间的值,它与你的动画运行时间解耦。所以,想象一下你正在使用一个缓动进入和缓动退出时间参数来动画一个从 x 值为 0 到 x 值为 100 的正方形。x 值为 10 并不是动画完成所需时间的 10%。
然而,fractionComplete 将是 0.1,这对应于动画完成 10%。这是因为 UIViewPropertyAnimator 在暂停动画后会将你的动画时间尺度转换为线性。
通常,这是交互式动画的最佳行为。然而,你可以通过将你的动画器的 scrubsLinearly 属性设置为 false 来改变这种行为。如果你这样做,fractionComplete 将考虑你应用的所有时间参数。
你可以尝试玩一下这个,看看抽屉动画的感觉如何。一旦初始动画配置并暂停,用户可以移动手指。
当这种情况发生时,通过将用户手指移动的距离除以总距离来计算并设置在动画器上的 fractionComplete 属性。然后,将中断前的动画进度添加到这个新值中。
最后,如果手势结束、被取消,或者发生其他任何情况,起始位置将被重置。同时,配置了一个用于剩余动画的时间参数,并将动画设置为继续进行。跳过 durationFactor 值为 0,动画师知道在考虑其新的时间函数的同时,使用剩余的任何时间进行动画。
如果用户在动画中途点击切换按钮关闭抽屉,然后向上滑动,动画应该向上完成。最后几行处理了这个逻辑。
创建完美的动画没有正确或错误的方法。尝试调整您和您的应用感觉合适的各种值。在本节中,我们通过查看如何通过事件动作(如 UIButton 切换)或用户手势交互来控制动画,将我们关于动画所学的所有内容进一步深化。
在下一节中,我们将通过查看如何为我们的应用添加一些弹簧和弹跳来为我们的动画增添一些真正的活力!
为动画添加活力
许多 iOS 动画看起来有弹跳感,感觉自然。例如,当物体在现实世界中开始移动时,它很少是平滑的。通常,某物移动是因为其他物体对其施加了初始力,使其具有某种动量。弹簧动画帮助您将这种现实世界的动量应用到动画中。
弹簧动画通常配置了初始速度。这个速度是物体开始移动时应该具有的动量。所有弹簧动画都需要设置阻尼。
此属性的值指定了一个对象可以超出其目标值多少。较小的阻尼值会使动画感觉更有弹跳性,因为它会在其结束值周围更剧烈地浮动。
探索弹簧动画的最简单方法是对您为抽屉创建的动画进行轻微重构。
当用户点击setUpAnimation()时,不要使用easeOut动画:
guard animator == nil || animator?.isRunning == false else {
return
}
let spring: UISpringTimingParameters
if self.isDrawerOpen {
spring = UISpringTimingParameters(dampingRatio: 0.8, initialVelocity: CGVector(dx: 0, dy: 10))
} else {
spring = UISpringTimingParameters(dampingRatio: 0.8, initialVelocity: CGVector(dx: 0, dy: -10))
}
animator = UIViewPropertyAnimator(duration: 1, timingParameters: spring)
animator?.addAnimations { [unowned self] in
if self.isDrawerOpen {
self.drawer.transform = CGAffineTransform.identity
} else {
self.drawer.transform = CGAffineTransform(translationX: 0, y: -305)
}
}
animator?.addCompletion { [unowned self] _ in self.animator = nil
self.isDrawerOpen = !(self.drawer.transform == CGAffineTransform.identity)
}
当您实现弹簧动画时,您使用UIViewPropertyAnimator的特殊初始化器。由于您不能将动画传递给此初始化器,您必须通过调用addAnimations(_:)来添加它们。添加弹簧动画不需要大量的代码更改,但尝试运行应用并点击切换按钮。抽屉现在感觉更真实,因为其动画曲线不再像之前那样静态。
尝试调整弹簧阻尼和速度的值。如果您使用一些极端值,您将得到有趣的结果。请记住,阻尼应该是一个介于0和1之间的值,并且接近1的值会使动画的弹跳性更小。
由平移识别器执行动画在此阶段感觉并不好。它非常静态,没有考虑到用户在抽屉上平移的速度。
当用户结束他们的平移手势时,您可以根据实际的平移速度设置弹簧时序的initialVelocity值。这将使动画感觉更加真实,因为它现在将实际的平移速度作为动画的初始速度。
使用以下代码更新默认情况语句:
drawerPanStart = 0
let currentVelocity = recognizer.velocity(in: drawer)
let spring = UISpringTimingParameters(dampingRatio: 0.8,
initialVelocity: CGVector(dx: 0, dy: currentVelocity.y))
animator?.continueAnimation(withTimingParameters: spring, durationFactor: 0)
let isSwipingDown = currentVelocity.y > 0
if isSwipingDown == !isDrawerOpen {
animator?.isReversed = true
}
正如你所看到的,使用弹簧动画可以改善你的动画,而且它们并不难添加到你的应用中。虽然它们可能并不总是最好的解决方案,但它们的易于实现使得弹簧动画成为实验动画是否需要弹簧的一个值得尝试的候选者。
你刚刚实现的动画相当逼真和真实,但你的动画可能需要更多的真实性。下一节将介绍 UIKit Dynamics,这是一种使用物理引擎并能够检测对象之间碰撞的特殊动画方法。
使用 UIKit Dynamics 添加动态效果
大多数应用实现简单的动画,就像你在本章中看到的那样。然而,一些动画可能需要更多的真实性——这就是 UIKit Dynamics 的作用。
使用 UIKit Dynamics,你可以在使用物理引擎的场景中放置一个或多个视图,并对其包含的视图施加某些力。例如,你可以给一个特定的对象施加重力,使其从屏幕上掉落。你甚至可以让对象相互碰撞,如果你给你的视图分配一个质量,当两个对象相撞时,这个质量会被考虑进去。
当你给一个质量非常小的对象施加一定的力时,它会被移动得比质量大的对象更多,就像你在现实世界中预期的那样。
为了做到这一点,我们将在当前应用之外创建另一个小项目,这样我们就可以进行一些物理实验。
因此,让我们从在 Xcode 中创建一个新的项目开始:
-
创建一个新的项目,并将其命名为
Dynamics。 -
在
Main.Storyboard中,将预览配置为横幅。 -
添加三个大小约为
100 x 100的 UIView(对于这个项目,不用担心约束)。 -
给每个 UIView 设置一个背景颜色(想想来自第三章,使用列表和表格的颜色)。
如果一切顺利,它应该看起来像这样:
![Figure 5.3 – Main storyboard with views
![img/Figure_5.03_B14717.jpg]
图 5.3 – 主故事板中的视图
接下来,在ViewController.swift中为刚刚添加的视图添加@IBOutlet实例,并以与之前相同的方式将它们连接到故事板中。你可以将出口命名为任何你喜欢的名字,但我会将我的命名为ball1、ball2和ball3(关于这一点稍后会有更多说明)。
目前你可以实施的最简单的事情就是设置一个包含三个正方形的场景,并给它们施加一些重力。这将导致正方形从屏幕上掉落,因为一旦施加重力,它们就会开始下落,而且没有地板来阻止正方形掉出屏幕。
要设置一个如上所述的场景,请将以下突出显示的代码添加到你的ViewController.swift文件中:
var animator: UIDynamicAnimator?
override func viewDidLoad() {
super.viewDidLoad()
let balls: [UIDynamicItem] = [ball1, ball2, ball3]
animator = UIDynamicAnimator(referenceView: view)
let gravity = UIGravityBehavior(items: balls)
animator?.addBehavior(gravity)
}
如果你现在测试你的应用,你会注意到你的视图立即开始下落。使用 UIKit Dynamics 设置这样一个简单的场景很容易。
这个简单示例的缺点是它并不特别有趣。在你添加功能使这个示例更有趣之前,让我们看看前面四行代码的作用。
动态场景中的视图必须是UIDynamicItem类型。UIView 可以用作UIDynamicItem,因此通过将它们添加到具有[UIDynamicItem]的列表中,它们会自动工作。
然后,我们创建一个UIDynamicAnimator的实例,并告诉它将应用其物理引擎的视图。最后一步是配置并应用一个行为。这个例子使用UIGravityBehavior,但你的场景中还可以使用其他几种行为。
例如,你可以创建UIAttachmentBehavior将一个项目附加到另一个项目或屏幕上的某个点上。
以下代码为屏幕上每个方块实现了附加行为,并将其附加到屏幕顶部。这将导致方块暂时下落,然后它们会弹跳并轻微摆动,直到最终静止。您可以将以下代码添加到viewDidLoad()中以实现此功能:
var nextAnchorX = 250
for ball in balls {
let anchorPoint = CGPoint(x: nextAnchorX, y: 0)
nextAnchorX -= 30
let attachment = UIAttachmentBehavior(item: ball, attachedToAnchor: anchorPoint)
attachment.damping = 0.7
animator?.addBehavior(attachment)
}
在这个例子中,每个方块都设置了略微不同的附加点。请注意,附加行为有一个damping属性。
这种阻尼与弹簧动画中使用的阻尼类似。尝试调整attachment.damping的值,看看它会产生什么效果。
如果你现在运行应用程序,你会注意到每个方块都附着在屏幕上的一个看不见的点,防止它们下落。尽管如此,还有一些东西是缺失的。
现在方块可以简单地相互交叉——如果它们相互碰撞会怎么样,那会多么酷?
要做到这一点,请将以下代码行添加到viewDidLoad()中:
let collisions = UICollisionBehavior(items: balls)
animator?.addBehavior(collisions)
你相信 UIKit Dynamics 很酷吗?我也这么认为;代码如此之少就能做到这么多真是太神奇了。让我们给方块添加一些质量,使它们更具弹性,看看这会对方块碰撞有什么影响。
使用以下代码更新你的for循环:
let dynamicBehavior = UIDynamicItemBehavior()
dynamicBehavior.addItem(ball)
dynamicBehavior.density = CGFloat(arc4random_uniform(3) + 1)
dynamicBehavior.elasticity = 0.8
animator?.addBehavior(dynamicBehavior)
上述代码应该增强你已经在循环中拥有的内容;它不应该替换现有的逻辑。
通过在UIDynamicItemBehavior上设置density,引擎可以推导出项目的质量。这将改变物理引擎在项目与其他项目碰撞时如何处理该项目。
再次强调,这是你远离 Apple 提供的行为和物理引擎去玩耍的绝佳时机。现在,一个摆动方块游戏可能对任何人都没有兴趣——但将你的球属性更新如下并再次运行……现在是不是更有趣了?
@IBOutlet weak var ball1: UIView! {
didSet {
// Make a ball
ball1.layer.cornerRadius = ball1.frame.size.width/2
ball1.clipsToBounds = true
// Cool gradient effect!
let gradient = CAGradientLayer()
gradient.frame = ball1.bounds
gradient.colors = [UIColor.systemBlue.cgColor, UIColor.systemTeal.cgColor]
ball1.layer.insertSublayer(gradient, at: 0)
}
}
在最后一节中,我们将学习关于视图控制器转换所需了解的一切,这又是另一种真正改变我们应用程序默认行为的方法。
自定义视图控制器转换
实现自定义视图控制器过渡是那些需要一段时间才能习惯的事情之一。实现自定义过渡涉及实现多个对象,而且并不总是容易理解其工作原理。本节旨在详细解释自定义视图控制器过渡是如何工作的,以便您可以将另一个强大的工具添加到您的开发者工具箱中。
一个实现良好的自定义视图控制器过渡将使您的用户感到愉快并感到惊讶。使您的过渡交互式甚至可以确保您的用户在您的应用中花费更多时间玩耍,这正是您想要的。
我们将继续处理我们之前开始的联系人应用。首先,您将了解您如何实现自定义模态转换。一旦实现了这个,您将了解 UINavigationController 的自定义过渡,这样您就可以使用自定义过渡显示和隐藏联系人详情页面。模态视图控制器和联系人详情页面的关闭都将交互式,这样用户可以滑动返回到他们来的地方。
在本节中,您将完成以下步骤:
-
实现一个自定义的模态显示过渡。
-
使过渡交互式。
-
实现一个自定义的
UINavigationController过渡。
实现自定义模态显示过渡
许多应用程序实现了模态显示的视图控制器。一个模态显示的视图控制器通常是一个覆盖在当前屏幕上的视图控制器。默认情况下,模态显示的视图控制器从屏幕底部向上动画,通常用于向用户展示表单或其他临时内容。在本节中,您将了解默认的模态显示过渡以及如何自定义它以满足您的需求。
让我们从创建一个全新的视图控制器开始。为此,我们将回到我们的联系人应用项目,我们可以在那里添加这个功能(或者您可以自由地开始一个新的项目):
-
创建一个新的
TransitionViewController(UIViewController的子类)。 -
向
Main.Storyboard中添加一个新的视图控制器。 -
将那个新视图控制器的对象类设置为
TransitionViewController。完成这些后,我们将在现有的导航中添加一个条形按钮项,以便我们可以展示模态。
-
从我们的对象库中添加
BarButtonItem到rootViewContoller的导航栏中(基本上是我们的第一个视图控制器)。 -
将按钮的文本设置为
Show Modal(或您想要的任何文本)。 -
现在,按 Ctrl 并将条形按钮项的连接器拖到我们刚刚创建的新视图控制器上。
-
当出现选项时,选择以模态方式显示。
如果一切顺利,它应该看起来像以下这样:

图 5.4 – 模态动作转换
最后,给我们的新视图控制器一个系统橙色背景色,这样在稍后观察过渡时会更清晰。
如果你现在运行你的应用,你可以点击显示模态按钮,你将看到一个空视图控制器从底部弹出。
在 iOS 13 之前,你必须创建一个界面,以便用户可以关闭模态。现在,除非隐式设置,否则你可以直接向下滑动来关闭模态,这在开发 iOS 13 之前的旧应用时值得注意。
自定义视图控制器过渡使用几个对象来简化动画。你首先需要查看的是UIViewController的transitioningDelegate。transitioningDelegate属性负责提供提供自定义过渡的动画控制器。
动画控制器使用一个提供有关参与过渡的视图控制器信息的过渡上下文对象。通常,这些视图控制器将是当前视图控制器和即将被展示的视图控制器。
可以用以下步骤描述过渡流程:
-
一个过渡开始了。目标视图控制器被要求提供
transitioningDelegate。 -
transitioningDelegate被要求提供一个动画控制器。 -
动画控制器被要求提供动画持续时间。
-
动画控制器被告知执行动画。
当动画完成后,动画控制器会在过渡上下文中调用completeTransition(_:)来标记动画已完成。
如果步骤 1或步骤 2返回nil,或者根本未实现,则使用默认的过渡动画。涉及自定义过渡的对象在以下图中显示:
![Figure 5.5 – 动画过渡流程]
![Figure 5.05_B14717.jpg]
Figure 5.5 – 动画过渡流程
创建一个单独的对象来控制动画通常是一个好主意,因为它允许你重用过渡,并保持你的代码整洁。动画控制器应该是一个符合UIViewControllerAnimatedTransitioning的对象。此对象将负责将展示的视图动画化到屏幕上。
接下来,让我们创建动画控制器对象:
-
创建一个新的
CustomAnimator(使用NSObject作为子类)。 -
添加以下扩展,以便使类符合
UIViewControllerAnimatedTransitioning:extension CustomAnimator: UIViewControllerAnimatedTransitioning { }这使得新类符合成为动画控制器所需的协议。Xcode 会显示构建错误,因为你还没有实现所有方法以符合
UIViewControllerAnimatedTransitioning。
让我们逐个查看这些方法,以便你最终为动画控制器获得完整的实现。
必须为动画控制器实现的第一方法是transitionDuration(using:)。此方法的实现如下所示:
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.6
}
此方法用于确定总的过渡持续时间(以秒为单位)。在这种情况下,实现很简单——动画应该持续0.6秒。
需要实现的第二个方法是 animateTransition(using:)。它的目的是处理自定义过渡的实际动画。
此实现将目标视图控制器从屏幕顶部向下动画到其最终位置。它还将进行一些缩放,并动画视图的不透明度;为此,将使用 UIViewPropertyAnimator。
向动画器添加以下实现:
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
// 1
guard let toViewController = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to) else {
return
}
// 2
let transitionContainer = transitionContext.containerView
// 3
var transform = CGAffineTransform.identity
transform = transform.concatenating(CGAffineTransform(scaleX: 0.6, y: 0.6))
transform = transform.concatenating(CGAffineTransform(translationX: 0, y: -200))
toViewController.view.transform = transform
toViewController.view.alpha = 0
// 4
transitionContainer.addSubview(toViewController.view)
// 5
let animationTiming = UISpringTimingParameters(dampingRatio: 0.5,
initialVelocity: CGVector(dx: 1, dy: 0))
let animator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext), timingParameters: animationTiming)
animator.addAnimations {
toViewController.view.transform = CGAffineTransform.identity
toViewController.view.alpha = 1
}
// 6
animator.addCompletion { finished in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
}
// 7
animator.startAnimation()
}
代码片段中发生了很多事情。让我们一步一步地分析代码,看看发生了什么:
-
从过渡上下文中提取目标视图控制器。这允许您在即将执行的动画中使用视图控制器视图。
-
获取动画的容器视图。容器视图是一个常规的 UIView,它旨在包含所有动画视图。
-
准备目标视图控制器视图以进行动画。视图被转换,因此它离开了屏幕,透明度被设置为使视图完全透明。
-
一旦视图准备就绪,它就被添加到容器视图中。
-
动画被设置并添加到属性动画器中。
-
属性动画器的完成处理程序被配置,因此当动画正常完成时,会在上下文中调用
completeTransition(_:)。transitionWasCancelled变量用于确定动画是否正常完成。 -
启动属性动画器,以便动画开始。
现在动画控制器已完成,应在 TransitionViewController 上实现 UIViewControllerTransitioningDelegate 协议,以便它可以充当自己的 transitioningDelegate。
打开文件并添加以下代码:
extension TransitionViewController: UIViewControllerTransitioningDelegate {
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return CustomAnimator()
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return nil
}
}
现在,将以下代码添加到 TransitionViewController 中:
public required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
transitioningDelegate = self
}
此代码添加了对 UIViewControllerTransitioningDelegate 协议的遵守,并将视图控制器指定为其自己的过渡代理。animationController(forPresented:presenting:source:) 方法返回您之前创建的动画控制器。animationController(forDismissed:) 方法目前返回 nil。
好吧,测试您的自定义过渡!创建自定义显示过渡所需的全部代码都在这里。
在本章中,我们学习了如何精细调整和润色我们的应用。视觉效果在任何应用中都扮演着如此重要的角色——因此,了解动画过渡对于任何 iOS 开发者来说都是绝对必须的。
摘要
在本章中,我们用动画和活力装饰了我们的联系人应用的核心。我们首先学习了动画的基础知识以及几行代码如何带来巨大的变化。然后,我们更进一步,重构了更复杂的代码,使其不仅易于维护,而且更容易理解。
UIKit 提供的不仅仅是花哨的动画。从动态学的角度来看,我们看到了如何将物理应用到 UIView 上,从而为我们的应用带来真正令人惊叹的体验。
最后,我们探讨了过渡效果,这在 iOS 开发中非常强大,但我们却常常轻易地将其视为理所当然。我们创建了一个自定义类,使我们能够创建我们自己的模态过渡效果。
在下一章中,我们将更深入地探讨 Swift 编程语言,并了解 Swift 的类型系统。
第六章:第六章:理解 Swift 类型系统
前几章已经为你打下了坚实的基础,你可以用它来构建出色的、适应性强的应用程序。在这个时候,退一步审视你编写的代码,以更深入地理解 Swift 及其工作方式是个好主意。本节侧重于教你更多关于 Swift 作为一门语言的知识,无论你打算构建什么。
在本章中,你将了解 Swift 的出色类型系统。Swift 的类型系统是其最强大的特性之一,因为它允许开发者安全且可预测地表达复杂和灵活的原则。
本章将涵盖以下主题:
-
理解 Swift 中可用的类型
-
理解类型之间的差异
-
决定使用哪种类型
技术要求
对于本章,你需要从 Apple 的 App Store 下载 11.4 或更高版本的 Xcode。
你还需要运行最新的 macOS 版本(Catalina 或更高)。只需在 App Store 中搜索Xcode,选择并下载最新版本。启动 Xcode,并遵循系统可能提示的任何附加安装说明。一旦 Xcode 完全启动,你就可以开始了。
从以下 GitHub 链接下载示例代码:
github.com/PacktPublishing/Mastering-iOS-14-Programming-4th-Edition
理解 Swift 中可用的类型
要编写优秀的代码,你需要了解你的工具箱中都有哪些工具。这适用于构建应用程序和理解 UIKit 所能提供的功能,但也适用于你用来编写软件的语言。不同的语言都有各自的特点、最佳实践、优点和缺点。你对所使用语言的了解越深入,你就能做出越好的关于你编写代码的决定。正如之前提到的,Swift 的类型系统是使 Swift 成为专家和初学者开发中都如此出色的语言的一个特性。
在你深入研究 Swift 的类型细节以及它们如何相互比较之前,了解 Swift 提供哪些类型是至关重要的。从高层次上讲,你可以争辩说 Swift 有两种类型:
-
引用类型
-
值类型
让我们更仔细地看看每种类型,了解它们代表什么,如何工作,以及如何使用它们。
使用引用类型
在本书中你看到的类型大多是,如果不是全部都是引用类型。两种类型的对象被归类为引用类型:
-
类
-
闭包
你在这本书中已经看到了这两种对象类型。例如,你创建的所有UIViewController子类都是引用类型。你用作回调或执行动画的所有闭包也是引用类型。
那么,如果某物是引用类型,这意味着什么?为什么这对你很重要?嗯,引用类型具有可以既方便又非常令人沮丧的行为,这取决于你试图在代码中实现什么。
引用类型和类独有的一个特性是能够进行子类化。唯一可以从另一个对象继承功能的是类。当你学习到类型之间的区别时,这将会被更深入地探讨,但了解这些信息已经很好了。让我们通过在游乐场中编写一些代码来近距离考察引用类型。
在 Xcode 中创建一个新的游乐场项目,并给它起任何你喜欢的名字。然后,添加以下代码:
class Pet {
var name: String
init(name: String) {
self.name = name
}
}
func printName(for pet: Pet) {
print(pet.name)
}
let cat = Pet(name: "Jesse")
printName(for: cat)
很可能,你对这段小代码并不太兴奋。它所做的只是定义一个新的 Pet 类,创建一个实例,然后将该实例传递给 printName(for:)。然而,这段代码非常适合说明引用类型是什么。
当你调用 printName(for: cat) 时,你将你的 cat 实例的 引用 传递给 printName(for:)。这意味着任何获得这个引用的人都可以修改被引用的对象。如果这听起来很困惑,那没关系。
将以下代码添加到你所创建的游乐场中,然后运行它:
func printName2(for pet: Pet) {
print(pet.name)
pet.name = "Pugwash"
}
let dog = Pet(name: "Astro")
printName2(for: dog)
print(dog.name)
运行这段代码后,你在控制台里注意到什么?
如果你注意到狗的名字从 Astro 变成了 Pugwash,你刚刚观察到了传递引用的含义。
由于 printName2(for:) 接收了你的 Pet 实例的引用,它能够改变它的名字。如果你在其他语言中编程过,这对你来说可能很显然。如果没有,这可能会让你感到非常惊讶。
还有一点你应该注意,dog 被声明为常量。尽管如此,你仍然能够将你的实例的名字从 Astro 改成 Pugwash。
如果你认为这是显而易见的,请在你的游乐场中添加以下代码并运行它:
let point = CGPoint(x: 10, y: 10)
point.x = 10
这段代码与你之前对 Pet 实例所做的是非常相似的。你创建了一个常量实例,然后改变它的一个属性。然而,这次,当你尝试运行你的游乐场时,你应该会看到以下错误:
Cannot assign to property: 'point' is a 'let' constant
尽管你到目前为止实现的代码相当短,但它很好地展示了引用类型。你目前看到了引用类型的两个特性在行动:
-
任何接收引用类型实例的人都可以修改它。
-
你可以在引用类型上更改属性,即使持有引用类型的属性被声明为常量。
这两个特性是引用类型的典型特征。引用类型之所以这样工作,是因为被分配引用类型的变量或常量 不包含或拥有 对象。常量或变量只指向内存中存储实例的地址。
任何时候你创建引用类型的实例,它都会写入 RAM,在那里它将在特定的地址存在。RAM 是一种计算机使用的特殊内存类型,例如 iPhone,用于临时存储某个程序使用的数据。
当你将引用类型的实例分配给属性时,该属性将有一个 指针 指向该实例的内存地址。让我们再次看看以下代码行:
let dog = Pet(name: "Astro")
现在 dog 常量指向内存中存储 Pet 实例的特定地址。只要基本内存地址没有改变,你就可以更改 Pet 实例上的属性。
实际上,理论上你可以在那个内存地址处放置完全不同的内容,而 let dog 不会在意,因为它仍然指向相同的地址。
由于同样的原因,printName2(for:) 可以更改宠物的名称。你不需要传递 Pet 的实例,而是传递实例预期存在的内存地址。printName2(for:) 对 Pet 实例进行更改是可以的,因为它不会更改内存中的基本地址。
如果你尝试通过以下方式将新实例分配给 dog,你会得到一个错误:
dog = Pet(name: "Iro")
这种错误发生的原因是你不能更改 dog 指向的内存地址,因为它是常量。
现在你已经知道了引用类型是什么以及它是如何工作的,你可能已经得出结论,前面例子中看到的 CGPoint 必须是值类型。接下来,让我们看看值类型究竟是什么。
与值类型一起工作
在引用类型的例子中,你看到了以下代码片段:
let point = CGPoint(x: 10, y: 10)
point.x = 10
乍一看,你可能会期望值类型是一种特殊的类,因为这段代码看起来像创建了名为 CGPoint 的类的实例。你的观察是正确的,但你的结论是错误的。CGPoint 实际上根本不是类。
类本质上是引用类型,它们永远不会变成其他类型。那么,值类型是什么呢?
被视为值类型的对象有两种:
-
结构体
-
枚举
这两种类型都大不相同,所以让我们确保你首先理解值类型的基本知识,然后你将学习这两种类型各自是什么。
让我们再次看看 Pet 的例子,但使用结构体而不是类。
在 Xcode 中创建一个新的游乐场页面,再次将其命名为你喜欢的任何名称。
一旦创建,请添加以下代码:
struct Pet {
var name: String
}
func printName(for pet: Pet) {
print(pet.name)
pet.name = "Jesse"
}
let dog = Pet(name: "Astro")
printName(for: dog)
print(dog.name)
你会立即注意到 Xcode 会报错。
控制台中应该看到的错误告诉你 pet 是一个 let 常量,你不允许更改它的名称。你可以通过更新 printName 将 pet 转换为变量,如下所示:
func printName(for pet: Pet) {
var pet = pet
print(pet.name)
pet.name = "Jesse"
}
如果你现在运行你的游乐场,请确保仔细查看控制台。你会注意到在第二次打印中宠物的名字保持不变。
这展示了值类型的一个关键特性。不是传递内存地址的引用,而是传递对象的副本。这也解释了为什么不允许在赋值给常量的值类型上更改属性。
改变那个属性会改变值类型的值,因此也会改变常量的值。这也意味着当你将dog传递给printName时,你传递的是Pet实例的副本给printName,这意味着对该实例所做的任何更改都是局部的,并且不会应用到dog上,在这种情况下。
这种行为使得使用值类型极其安全,因为其他对象或函数很难对值类型进行不希望的改变。此外,如果你将某个东西定义为常量,它确实就是一个常量。值类型的另一个特点是它们通常非常快速且轻量,因为它们可以存在于栈上,而引用类型存在于堆上。当你我们比较引用类型和值类型时,你将了解更多关于这方面的内容。
现在你对值类型有了基本的了解,让我们来看看具体的值类型。
理解结构体
结构体的定义方式与类相似。如果你看看你之前定义的Pet类,可能会忽略它实际上是一个结构体的事实。如果你仔细观察,你会注意到一个很大的不同:你不需要为结构体编写初始化器!Swift 可以自动为结构体生成初始化器。这非常方便,并且可以为你节省大量的输入,特别是对于较大的结构体。
结构体也不能从其他对象继承功能。这意味着结构体始终有一组非常平坦且透明的属性和方法。这允许编译器对你的代码进行优化,使得结构体非常轻量且快速。
然而,结构体可以遵循协议。Swift 标准库充满了定义许多内置类型功能的协议,例如Array、Dictionary和Collection。这些内置类型中的大多数都是作为采用一个或多个协议的结构体实现的。
关于结构体,你需要了解的最后一件事是它们对是否可以被修改非常严格。考虑一个如下所示的结构体:
struct Car {
var fuelRemaining: Double
func fillFuelTank() {
fuelRemaining = 1
}
}
这个结构体会导致编译器抛出错误。
结构体默认是不可变的,这意味着你不能改变它的任何值。当方法可以修改或改变结构体时,你需要让编译器明确这一点。你可以通过在函数中添加mutating关键字来实现,如下所示:
mutating func fillFuelTank() {
fuelRemaining = 1
}
当你创建一个Car常量实例并在其上调用fillFuelTank()时,编译器将再次报错。如果你在let实例上调用一个修改函数,你将修改实例,这意味着属性的值会改变。因此,你只能对变量属性调用修改函数。
理解枚举
枚举是一种包含有限预定义值的类型。枚举通常用于表示特定状态或操作的特定结果。了解这意味着什么最好的方式是查看一个表示交通灯状态的枚举示例:
struct TrafficLight {
var state: TrafficLightState
}
enum TrafficLightState {
case green
case yellow
case red
}
这个示例展示了一个具有state属性的TrafficLight结构体。这个属性的类型是TrafficLightState,它是一个枚举。
TrafficLightState 定义了交通灯的三个可能状态。这非常方便,因为这样的枚举可以消除错误状态的可能性,因为编译器现在可以强制你不会得到一个无效的值。
枚举也可以包含属性和方法,就像结构体一样。然而,枚举还可以有一个关联值。这意味着每个可能的案例都可以在不同的类型中有表示,例如字符串。
如果你像这里所示修改TrafficLightState,它将为rawValue具有String:
enum TrafficLightState: String {
case green
case yellow
case red
}
如果你 Swift 可以推断出原始值,你不需要做任何更多的事情,只需将原始值的类型添加到枚举的类型声明中即可。在这个示例中,green枚举情况的原始值将是green字符串。如果你需要将枚举映射到不同的类型,这可能很有用——例如,将其设置为标签的文本。
就像结构体一样,枚举不能从其他对象继承功能,但它们可以遵循协议。你通过扩展使枚举遵循协议,就像你为类和结构体做的那样。
这总结了值类型的探索。现在你了解了值类型和引用类型是什么,让我们探索它们之间的一些差异!
理解类型之间的差异
了解 Swift 中可用的类型——了解它们的相似之处和,更重要的是,它们的差异——将帮助你做出更好的决策,关于你编写代码的方式。前面的部分已经列出了值类型和引用类型的几个属性。更具体地说,你学到了很多关于类、结构体和枚举的知识。闭包也是引用类型,因为它们是通过它们在内存中的位置而不是它们的值来传递的,但在这个上下文中没有太多可说的。
你能做的最明显的比较可能是结构体和类之间的比较。它们看起来非常相似,但它们具有非常不同的特性,正如你之前所看到的。枚举是另一种特殊类型;它们代表一组固定可能值的值,但在其他方面与结构体非常相似。
你需要理解的最重要区别是值类型和引用类型的一般区别,以及结构体和类之间的具体区别。让我们首先看看值类型和引用类型,这样你就能有一个大致的了解。然后,你将学习结构体和类之间的具体区别。
比较值类型和引用类型
当比较值类型和引用类型时,区分作为开发者可见的差异以及 Swift 内部差异以及你的应用最终的工作方式是至关重要的。了解这些细节将确保你可以做出考虑所有影响的明智决策,而不仅仅是关注内存使用或开发者便利性。
让我们先检查更明显和可见的差异。之后,你将了解每种类型的内存影响。
使用差异
创建一个新的游乐场,再次给它起一个你喜欢的名字,并添加以下代码:
protocol PetProtocol {
var name: String { get }
var ownerName: String { get set }
}
class Animal {
let name: String
init(name: String) {
self.name = name
}
}
class Pet: Animal, PetProtocol {
var ownerName: String
init(name: String, ownerName: String) {
self.ownerName = ownerName
super.init(name: name)
}
}
此代码定义了一个 PetProtocol,该协议要求所有符合此协议的对象都必须存在两个属性。name 属性被定义为常量,因为它只需要可获取性,而 ownerName 是变量,因为它需要 get 和 set。代码还定义了 Animal 和 Pet 类。Pet 是 Animal 的子类,并且它符合 PetProtocol,因为 Animal 满足 name 常量要求,而 Pet 本身满足 ownerName 变量要求。
尝试将 class 声明更改为 struct。现在你的游乐场将无法编译,因为结构体不能像类一样继承其他对象。这是一个有时会令人沮丧的限制,因为你可能会遇到大量的代码重复。想象一下,除了 Pet 之外,你还想创建更多类型的动物,比如 WildAnimal 或 SeaCreature。这可以通过类来实现,因为你可以从 Animal 继承。对于结构体来说,这是不可能的,所以你必须将这些类型作为结构体实现,它们需要复制它们的 Animal 逻辑。
值类型和引用类型之间的另一个区别是它们在传递时的行为。将以下代码添加到你的游乐场中:
class ImageInformation {
var name: String
var width: Int
var height: Int
init(name: String, width: Int, height: Int) {
self.name = name
self.width = width
self.height = height
}
}
struct ImageLocation {
let location: String
let isRemote: Bool
var isLoaded: Bool
}
let info = ImageInformation(name: "ImageName", width: 100, height: 100)
let location = ImageLocation(location: "ImageLocation", isRemote: false, isLoaded: false)
info 和 location 的声明看起来非常相似,但它们的底层类型完全不同。尝试编写一个函数,该函数接受 ImageLocation 和 ImageInformation 作为参数。然后,尝试更新 location 的 isLoaded 属性和 info 的 name 属性。当你尝试设置 isLoaded 时,编译器会报错,因为 ImageLocation 的参数是一个 let 常量。这个原因在关于值类型的讨论中已经描述过。
值类型是通过值传递的,这意味着改变参数的属性将完全改变值。函数的参数始终是常量。然而,当你使用引用类型时,这可能并不明显,因为改变函数内部ImageInformation上的name属性是完全可行的。这是因为当你将引用类型传递给函数时,你并没有传递整个值;你传递的是内存地址的引用。这意味着,而不是值是常量,底层的内存地址是常量。这反过来意味着你可以随意更改内存中的任何内容;你只是不能改变常量所指向的地址。
想象一下,你需要开车去某人的家,他们给你他们住址。这就是传递引用类型的感觉。他们不会给你整个房子,而是给你他们家的地址。在你开车去他们家的路上,房子可能会以许多方式改变。房主可能会粉刷它,或者更换窗户或门,任何事都可能。最后,你仍然能找到房子,因为你收到了这个房子的地址,而且只要房主不搬到一个不同的地址,你就能找到正确的房子。
如果你将这个类比改为使用值类型,那么你要找的那个人会直接给你他们房子的完整副本。所以,你不会根据地址开车去他们的家;他们不会给你地址;他们只会给你他们的整个房子。如果房主对其房子的副本进行了更改,除非他们给你一个新的副本,否则你将无法在你的房子副本上看到这些更改的反映。这也适用于你对房子副本所做的任何修改。
你可以想象,在某些情况下,发送某物的副本而不是地址可能非常高效。房子的例子可能有点极端,但我很确定,如果你订购一个包裹,你更愿意收到包裹本身而不是收到一个去取包裹的地址。这种效率就是你在接下来通过比较值类型和引用类型在内存分配方面的行为时将要了解的内容。
决定使用哪种类型
在你的应用程序中使用错误类型的对象可能会对你的应用程序产生多方面的不良影响。例如,如果你的应用程序中某个意外的位置修改了引用类型,你的应用程序可能会遭受不想要的副作用。或者,如果你在某些地方使用结构体而不是类,你可能会遇到大量的重复逻辑。如果你的应用程序选择了慢速引用类型而不是更好的值类型,你的应用程序甚至可能在性能方面受到影响。
你应该始终评估哪种类型的对象最适合你的当前用例,以确保你的代码在可维护性和性能之间达到平衡的权衡。
何时应该使用引用类型?
使用引用类型的绝佳时机是当你正在继承内置类,例如UIViewController时。在这些情况下,与系统作斗争是没有意义的,因为这肯定会造成更多的伤害而不是好处。另一个使用引用类型的时机是当你创建自己的代理协议时。
代理最好实现为弱引用类型。这意味着充当代理的对象被对象弱引用,以避免内存泄漏。
由于值类型是通过复制来传递的,因此对它们有弱引用是没有意义的。在这种情况下,你唯一的选择是使用引用类型。
如果你认为传递某个对象的副本没有意义,你也需要一个引用类型。如果你回想起开车去别人家的例子,传递房子的地址比给每个人提供房子的完整副本更有意义。你可能会认为房子有一个身份。
这意味着每一栋房子都是独特的;只有一个房子拥有那个确切的地址,复制它也没有意义。如果你正在处理一个复制它没有意义的对象,你很可能希望将其实现为引用类型,这样所有接收该类型实例的人都在查看同一个实例。
选择引用类型的最后一个原因是,如果它可以通过继承来节省大量代码。很多人认为继承是糟糕的,你通常可以避免它,但有时使用类层次结构工作会更有意义。
缺点是,许多子类可能导致功能混乱的类,这些类包含为节省几个子类中的打字而保存的功能,尽管这些功能并不适用于所有子类。但就像许多工具一样,如果正确使用,继承可以非常方便;使用它本身并不是固有的坏处。
何时使用值类型
人们常说,你应该始终从结构体开始,并在需要时更改到其他类型。这在很多情况下都是很好的建议,因为结构体通常适用于大多数情况。然而,结构体并不是唯一的价值类型,始终避免盲目使用某些东西总是好的。
如果你需要一个表示有限可能状态或结果的对象,例如网络连接状态、交通灯状态或应用的有效配置选项的有限集,你很可能需要一个枚举。
尽管值类型的价值语义使它们很棒,但枚举是避免拼写错误和表示状态的好方法。由于其本质,通常很清楚何时应该使用枚举。
结构体用于没有身份的对象。换句话说,传递它的副本是有意义的。一个很好的例子是当你创建一个可以与网络或数据库通信的结构体时。这个结构体将没有身份,因为它主要是一组属性和方法,这些属性和方法与结构体的单个版本没有关联。
一个结构体的好例子是你在本节开头读到的 CGPoint 结构体。CGPoint 表示二维网格中的一个位置。它没有身份,传递它的副本是有意义的。它只包含两个属性,因此不需要任何继承。这些特性使它成为实现为值类型的绝佳候选者。
如果你遵循始终从结构体开始的建议,试着找出你的新对象为什么不应该是一个结构体的原因。如果你找到一个不使用结构体的好理由,那么就将其改为类。通常,你不会找到使用类而不是结构体的好理由。如果是这种情况,将你的新对象改为结构体;你总是可以在以后切换到使用类。由于结构体对可变性的严格规则以及缺乏子类化,从类切换到结构体通常更困难。
摘要
在本章中,你学习了关于值类型和引用类型的大量知识。你学习了每种类型是什么以及如何使用它们。你了解到你可以在 Swift 中使用类、闭包、结构体和枚举,并且每种对象类型都有其自身的优缺点。
在了解了所有类型之后,你看到了值类型和引用类型是如何相互比较的,这为每种类型的有时微妙有时明显的使用案例提供了一些启示。你了解到结构体不能作为子类,而类可以。你还了解到传递值类型会传递每个实例的副本,而传递引用类型则不会复制每个实例,而是传递指向内存地址的指针。然后,你学习了每种类型在内存中的存储方式以及这对你创建的对象的性能意味着什么。
最后,你了解到如何通过使用一些经验法则来选择值类型和引用类型,这些法则应该可以使选择结构体、类和枚举变得相当直接,而无需盲目选择。下一章将通过展示如何使用 Swift 的泛型编写超灵活的代码,将你的 Swift 知识提升到一个新的层次。
第七章:第七章:使用协议、泛型和扩展实现灵活的代码
经验丰富的程序员(或应该知道)面向对象编程(OOP)的核心概念。它已经存在了一段时间,并且塑造了我们许多人开发软件的方式。但相对较新的范式以协议的形式出现,即协议导向编程(POP)。POP 并不是作为 OOP 的替代品,但近年来它获得了很大的关注,尤其是在 Swift 社区中。
在本章中,我们将学习关于 POP(Post Office Protocol)所需了解的一切,从标准实现到关联类型,再到泛型。到本章结束时,你将对自己在应用中实现 POP 以及理解它所能提供的内容充满信心。
本章将涵盖以下主题:
-
理解和实现协议
-
充分利用扩展
-
使用泛型增加灵活性
技术要求
对于本章,你需要从 Apple 的 App Store 下载 Xcode 版本 11.4 或更高版本。
你还需要运行最新的 macOS(Catalina 或更高版本)。只需在 App Store 中搜索Xcode,选择并下载最新版本。启动 Xcode,并遵循系统可能提示的任何附加安装说明。一旦 Xcode 完全启动,你就可以开始了。
从以下 GitHub 链接下载示例代码:github.com/PacktPublishing/Mastering-iOS-14-Programming-4th-Edition/tree/master/Chapter%207%20-%20Playground/Protocols.playground
理解和实现协议
Swift 和UIKit的设计核心是协议。你可能注意到了这一点,当你实现自定义UIViewController过渡,或者当你处理表格视图或集合视图时。当你实现这些功能时,你创建的对象充当过渡、表格视图和集合视图的代理,并将它们符合特定的协议。当你在第五章“使用动画沉浸你的用户”中处理视图控制器过渡时,我们也实现了一个符合UIViewControllerAnimatedTransitioning的NSObject子类。
说到这里,让我们更深入地探讨一下我们如何创建和设计自己的协议,以便在 Swift 应用中使用。
定义自己的协议
协议不仅限于代理行为。定义一个协议与定义一个类、结构体或枚举非常相似。主要区别在于,协议本身不实现或存储任何值。它充当调用符合协议的对象与声称符合协议的对象之间的合同。
让我们通过编写一些代码来看看这个例子,我们将创建一个新的游乐场来完成这个任务。
让我们实现一个简单的协议,定义任何声称自己是宠物的对象应满足的期望。该协议将被称为 PetType 协议。在 UIKit 和 Swift 标准库中定义的协议使用 Type、Ing 或 Able 作为后缀来表示协议定义的是行为而不是具体类型。你应该尽可能遵循这个约定,因为它使你的代码对其他开发者更容易理解:
protocol PetType {
var name: String { get }
var age: Int { get set }
static var latinName: String { get }
func sleep()
}
PetType 的定义表明,任何声称自己是 PetType 的对象必须有一个只读变量(一个常量)称为 name,一个可以更改的 age(因为它指定了 get 和 set),一个使宠物休息的 sleep() 方法,以及最后,一个静态变量,用于描述 PetType 的拉丁名。
每当你定义一个协议要求存在某个变量时,你也必须指定该变量应该是可获取的、可设置的,还是两者都可以。如果你指定必须实现某个方法,你就像平常一样编写该方法,但你在第一个花括号处停止。你只写下方法签名。
协议还可以要求实现者有一个静态变量或方法。对于 PetType 来说,这是方便的,因为宠物的拉丁名不一定属于某个特定的宠物,而是属于宠物所属的整个物种,因此将此作为类型的属性而不是实例的属性来实现是非常有意义的。
为了展示一个像 PetType 这样的小协议有多强大,你将实现两个宠物:一只猫和一只狗。你还将编写一个函数,它接受任何宠物,然后通过调用 sleep() 方法让它们打盹。
对于这个协议的面向对象方法,可以创建一个名为 Pet 的类,然后创建两个子类,Cat 和 Dog。sleep() 方法将接受一个 Pet 的实例,它看起来可能像这样:
func sleep(pet: Pet) {
pet.sleep()
}
请不要误解,前面提到的面向对象的方法是有效的,在如此小的规模上,不会出现真正的问题。
然而,当继承层次结构增长时,你通常会得到一些基类,这些类中包含的方法只与少数几个子类相关。或者,你可能会发现自己无法向某个类添加某些功能,因为继承层次结构在一段时间后就会阻碍。
让我们看看当你使用 PetType 协议解决这个挑战而不使用任何继承时,它看起来会是什么样子:
struct Cat: PetType {
let name: String
var age: Int
static let latinName: String = 'Felis catus'
func sleep() {
print('Cat: Zzzz')
}
}
struct Dog: PetType {
let name: String
var age: Int
static let latinName: String = 'Canis familiaris'
func sleep() {
print('Dog: Zzzz')
}
}
func nap(pet: PetType) {
pet.sleep()
}
我们刚刚成功实现了一个可以同时接受 Cat 和 Dog 对象并使它们打盹的单个方法。
代码不是检查类型,而是检查传入的宠物是否符合PetType协议,如果符合,则可以调用其sleep()方法,因为协议规定任何PetType实例都必须实现sleep()方法。这引出了本章的下一个主题:检查特性而不是类型。
检查特性而不是类型
在经典的面向对象编程中,你通常会创建超类和子类来将具有相似能力的对象分组在一起。如果你用类大致模拟动物王国的猫科动物群体,你最终得到的图示看起来像这样:

图 7.1 – 面向对象流程
如果你尝试模拟更多的动物,你会发现这是一个复杂的工作,因为一些动物虽然彼此在类图中相距甚远,但它们共享许多特性。
一个例子是猫和狗通常被当作宠物。这意味着它们可以有一个所有者,也许还有一个家。但猫和狗并不是唯一被当作宠物的动物,因为鱼、豚鼠、兔子甚至蛇也被当作宠物。
要找到一种合理的方式重新结构化你的类层次结构,以便你不必在层次结构中的每个宠物上重复添加所有者和家,这将是困难的,因为不可能有选择性地将这些属性添加到正确的类中。
当你编写一个打印宠物家的函数或方法时,这个问题变得更糟。你可能不得不让这个函数接受任何动物,或者为具有你所需属性的每种类型编写相同的函数的单独实现。这两种方法都不合理,因为你不想重复编写相同的函数,只是参数的类不同。即使你选择这样做,最终得到一个接受Fish实例的打印动物家的地址的方法,将GreatWhiteShark实例传递给名为printHomeAddress()的函数也没有太多意义,因为鲨鱼通常没有家地址。当然,这个问题的解决方案是使用协议。
在上一节描述的情况中,对象主要是根据它们是什么来定义的,而不是根据它们做什么。我们关心的是动物是否属于特定的家族或类型,而不是它们是否生活在陆地上。你不能区分会飞的动物和不会飞的动物,因为并非所有鸟类都会飞。
继承与这种思维方式不兼容。想象一下一个Pigeon结构的定义,看起来像这样:
struct Pigeon: Bird, FlyingType, OmnivoreType, Domesticatable
由于Pigeon是一个结构体,你知道Bird不是一个结构体或类——它是一个协议,定义了成为鸟类的一些要求。
Pigeon 结构体也符合 FlyingType、OmnivoreType 和 Domesticatable 协议。每个协议都告诉你有关 Pigeon 的能力或特性的信息。定义解释了鸽子是什么以及它能做什么,而不仅仅是传达它继承自某种类型的鸟类。
例如,几乎所有的鸟类都能飞翔,但也有一些例外。你可以用类来模拟这种情况,但这种方法可能很繁琐,并且可能不够灵活,这取决于你的需求和代码如何演变。
使用协议设置 Pigeon 结构体非常强大;你现在可以编写一个 printHomeAddress() 函数,并设置它以接受任何符合 Domesticatable 协议的对象:
protocol Domesticatable {
var homeAddress: String? { get }
}
func printHomeAddress(animal: Domesticatable) {
if let address = animal.homeAddress {
print(address)
}
}
Domesticatable 协议需要一个可选的 homeAddress 属性。并不是所有可以驯化的动物实际上都被驯化了。
例如,考虑一下鸽子;有些鸽子被当作宠物饲养,但大多数不是。这也适用于猫和狗,因为并不是每只猫或狗都有家。
这种方法很强大,但将你的思维从面向对象的思维模式(其中你考虑继承层次结构)转变为以特性为中心的协议导向思维模式并不容易。
让我们通过定义 OmnivoreType、HerbivoreType 和 CarnivoreType 来扩展示例代码。这些类型将代表动物王国中的三种主要食性类型。你可以在这些协议内部使用继承,因为 OmnivoreType 既是 HerbivoreType 也是 CarnivoreType,所以你可以让 OmnivoreType 继承自这两个协议:
protocol Domesticatable {
var homeAddress: String? { get }
}
protocol HerbivoreType {
var favoritePlant: String { get }
}
protocol CarnivoreType {
var favoriteMeat: String { get }
}
protocol OmnivoreType: HerbivoreType, CarnivoreType { }
将两个协议组合成一个,就像你在前面的例子中所做的那样,是非常强大的,但当你这样做时要小心。
你不希望创建一个像在面向对象编程中那样疯狂的继承图;你刚刚了解到继承可能非常复杂且不灵活。
想象一下编写两个新函数,一个用于打印食肉动物的 favorite meat,另一个用于打印草食动物的 favorite plant。这些函数看起来会是这样:
func printFavoriteMeat(forAnimal animal: CarnivoreType) {
print(animal.favoriteMeat)
}
func printFavoritePlant(forAnimal animal: HerbivoreType) {
print(animal.favoritePlant)
}
之前的代码可能正是你自己会写的代码。然而,这两种方法都不接受 OmnivoreType。这是完全可以接受的,因为 OmnivoreType 继承自 HerbivoreType 和 CarnivoreType。
这与你在经典面向对象编程中习惯的方式相同,主要区别在于 OmnivoreType 继承自多个协议而不是一个。
这意味着 printFavoritePlant() 函数接受一个 Pigeon 实例作为其参数,因为 Pigeon 符合 OmnivoreType,而 OmnivoreType 继承自 HerbivoreType。
使用协议以这种方式组合你的对象可以极大地简化你的代码。你不需要考虑复杂的继承结构,而是可以用定义了特定特性的协议来组合你的对象。这种方法的优点是它使得定义新对象相对容易。
想象一下发现了一种新的动物,它既能飞、能游泳,又生活在陆地上。这种奇怪的新物种很难添加到基于继承的架构中,因为它与其他动物不兼容。
当使用协议时,你可以将 FlyingType、LandType 和 SwimmingType 协议添加到你的协议中,这样你就准备好了。任何接受 LandType 动物作为参数的方法或函数都会愉快地接受你的新动物,因为它符合 LandType 协议。
掌握这种思维方式并不简单,它需要一些练习。但每次你准备创建一个超类或子类时,问问自己为什么。如果你试图在那个类中封装某种特性,尝试使用协议。
这将训练你以不同的方式思考你的对象,在你意识到这一点之前,你的代码将会更干净、更易读、更灵活,使用协议和检查特性而不是基于对象是什么来采取行动。
正如你所见,协议不需要有很多要求;有时一个或两个就足以传达正确的含义。不要犹豫,只创建具有单个属性或方法的协议;随着时间的推移,你的项目增长和需求变化,你会为自己这样做而感到庆幸。
通过默认行为扩展你的协议
之前的例子主要使用了变量作为协议的要求。协议的一个小缺点是它们可能导致一些代码重复。
例如,每个 HerbivoreType 对象都有一个 favoriteMeat 变量。这意味着你必须在每个符合 HerbivoreType 的对象中重复这个变量。通常,你希望代码重复尽可能少,反复重复变量可能看起来像是一种倒退。
即使你不需要反复声明相同的属性也很不错,但这样做也存在一定的风险。如果你的应用程序变得很大,你不可能总是记得每个类、子类和超类。这意味着更改或删除特定属性可能会在其他类中产生不希望的结果。
在符合特定协议的每个对象上声明相同的属性并不是什么大问题;通常只需要几行代码就能做到这一点。然而,协议也可以要求符合它们的对象必须存在某些方法。
反复声明它们可能会很麻烦,尤其是如果大多数对象的实现都是相同的话。幸运的是,你可以利用协议扩展来实现一定程度的默认功能。
为了探索协议扩展,让我们将 printHomeAddress() 函数移动到 Domesticatable 协议中,这样所有 Domesticatable 对象都可以打印它们自己的家庭地址。你可以采取的第一种方法是立即在协议扩展中定义方法,而不将其添加到协议的要求中:
extension Domesticatable {
func printHomeAddress() {
if let address = homeAddress {
print(address)
}
}
}
通过在协议扩展中定义 printHomeAddress() 方法,每个符合 Domesticatable 的对象都可以使用以下方法,而无需在对象本身中实现它:
let pidgeon = Pigeon(favoriteMeat: 'Insects',
favoritePlant: 'Seeds',
homeAddress: 'Greater Manchester, England')
pidgeon.printHomeAddress()
如果你想要实现与协议关联的默认行为,这种技术非常方便。
你甚至不需要将 printHomeAddress() 方法作为要求添加到协议中。然而,如果你不小心,这种方法可能会给你一些奇怪的结果。以下代码片段通过向 Pigeon 结构体添加 printHomeAddress() 的自定义实现,展示了这种奇怪结果的一个例子:
struct Pigeon: Bird, FlyingType, OmnivoreType, Domesticatable {
let favoriteMeat: String
let favoritePlant: String
let homeAddress: String?
func printHomeAddress() {
if let address = homeAddress {
print('address: \(address.uppercased())')
}
}
}
当你调用 myPigeon.printHomeAddress() 时,将使用自定义实现来打印地址。然而,如果你定义了一个函数,例如 printAddress(animal:),它接受一个 Domesticatable 对象作为参数,将使用协议提供的默认实现。
这是因为 printHomeAddress() 不是协议的要求。因此,如果你在一个 Domesticatable 对象上调用 printHomeAddress(),将使用协议扩展中的实现。如果你使用与上一节相同的代码片段,但将 Domesticatable 协议更改为以下代码所示,两次调用 printHomeAddress() 将打印相同的内容,即 Pigeon 结构体中的自定义实现:
protocol Domesticatable {
var homeAddress: String? { get }
func printHomeAddress()
}
在大多数情况下,这种行为可能是不预期的,所以通常一个好的做法是在协议要求中定义你使用的所有方法,除非你绝对确定你想要看到的行为。
协议扩展不能持有存储属性。这意味着你不能将变量添加到协议中,为它们提供默认实现。尽管扩展不能持有存储属性,但在某些情况下,你仍然可以向协议扩展添加一个计算属性,以避免在多个地方重复相同的变量。让我们来看一个例子:
protocol Domesticatable {
var homeAddress: String? { get }
var hasHomeAddress: Bool { get }
func printHomeAddress()
}
extension Domesticatable {
var hasHomeAddress: Bool {
return homeAddress != nil
}
func printHomeAddress() {
if let address = homeAddress {
print(address)
}
}
}
如果你想要检查一个 Domesticatable 是否有家庭地址,你可以添加一个布尔值的要求,hasHomeAddress。如果设置了 homeAddress 属性,hasHomeAddress 应该为真。否则,它应该是假的。
这个属性是在协议扩展中计算的,所以你不需要将这个属性添加到所有 Domesticatable 对象中。在这种情况下,使用计算属性非常有意义,因为其值的计算方式很可能在所有 Domesticatable 对象中都是相同的。
在协议扩展中实现默认行为使得我们之前看到的协议导向方法更加强大;你实际上可以模仿一个称为多重继承的功能,而不需要所有子类化的缺点。
简单地添加对协议的遵从可以为你的对象添加各种功能,如果协议扩展允许,你就不需要向你的代码中添加任何其他内容。让我们看看如何通过关联类型使协议和扩展更加强大。
使用关联类型改进你的协议
协议导向编程的一个令人惊叹的方面是关联类型的用法。关联类型是一种泛型、不存在的类型,它可以像任何实际存在的类型一样在你的协议中使用。
这个泛型的实际类型是由编译器根据其使用的上下文确定的。这个描述是抽象的,你可能不会立即理解为什么或如何关联类型可以给你的协议带来好处。毕竟,协议本身不就是一种非常灵活的方式,可以让几个不相关的对象根据它们遵守的协议满足某些标准吗?
为了说明和发现关联类型的使用,你将稍微扩展你的动物王国。你应该做的是给草食动物一个eat方法和一个数组来跟踪它们吃过的植物,如下所示:
protocol HerbivoreType {
var plantsEaten: [PlantType] { get set }
mutating func eat(plant: PlantType)
}
extension HerbivoreType {
mutating func eat(plant: PlantType) {
plantsEaten.append(plant)
}
}
这段代码乍一看看起来不错。草食动物吃植物,这是通过这个协议确立的。PlantType协议定义如下:
protocol PlantType {
var latinName: String { get }
}
让我们定义两种不同的植物类型和一个将用于演示前面代码中问题的动物:
struct Grass: PlantType{ var latinName = 'Poaceae'
}
struct Pine: PlantType{ var latinName = 'Pinus'
}
struct Cow: HerbivoreType {
var plantsEaten = [PlantType]()
}
这里不应该有什么大的惊喜。让我们继续创建一个Cow实例并给它喂食Pine:
var cow = Cow()
let pine = Pine()
cow.eat(plant: pine)
这实际上并没有什么意义。牛不吃松树;它们吃草!我们需要某种方式来限制这只牛的食物摄入量,因为这种方法是不可行的。
目前,你可以给HerbivoreType动物喂食任何被认为是植物的东西。你需要某种方式来限制你给牛喂食的食物类型。在这种情况下,你应该将FoodType限制为只有Grass,而不必为可能想要喂食的每个HerbivoreType植物类型定义eat(plant:)方法。
你现在面临的问题是所有HerbivoreType动物主要吃一种植物类型,并不是所有植物类型都适合所有草食动物。这正是关联类型成为绝佳解决方案的地方。HerbivoreType协议的关联类型可以限制某个草食动物可以吃的PlantType为HerbivoreType定义的单个类型。让我们看看这会是什么样子:
protocol HerbivoreType {
associatedtype Plant: PlantType
var plantsEaten: [Plant] { get set }
mutating func eat(plant: Plant)
}
extension HerbivoreType {
mutating func eat(plant: Plant) {
print('eating a \(plant.latinName)')
plantsEaten.append(plant)
}
}
第一行高亮显示的行将泛型Plant类型(作为一个不存在的真实类型)与协议关联起来。为Plant添加了一个约束,以确保它是一个PlantType。
第二个高亮行展示了如何将Plant关联类型用作PlantType。植物类型本身仅是符合PlantType的任何类型的别名,并用作我们用于plantsEaten和eat方法的类型。让我们重新定义Cow结构体,以查看这个关联类型的作用:
struct Cow: HerbivoreType {
var plantsEaten = [Grass]()
}
将plantsEaten定义为PlantType数组,现在它被定义为Grass数组。在协议和定义中,植物的类型现在是Grass。
编译器理解这一点,因为plantsEaten数组被定义为[Grass]。让我们定义第二个HerbivoreType,它吃不同类型的PlantType:
struct Carrot: PlantType {
let latinName = 'Daucus carota'
}
struct Rabbit: HerbivoreType {
var plantsEaten = [Carrot]()
}
如果你尝试给牛喂一些胡萝卜,或者如果你尝试给兔子喂松树,编译器将抛出错误。原因在于关联类型约束允许你在每个结构体中单独定义Plant的类型。
关于关联类型的一个注意事项是,编译器并不总是能够正确推断关联类型的实际类型。在我们的当前示例中,如果没有plantsEaten数组在协议中,就会发生这种情况。
解决方案是为符合HerbivoreType的类型定义一个typealias,这样编译器就能理解Plant代表哪种类型:
protocol HerbivoreType {
associatedtype Plant: PlantType
var plantsEaten: [Plant] { get set }
mutating func eat(plant: Plant)
}
当正确使用时,关联类型可以非常强大,但有时使用它们也可能因为编译器必须做的推断量而给你带来很多麻烦。
如果你忘记了一些微小的步骤,编译器可能会迅速失去对你的意图的跟踪,而且错误信息并不总是最明确的。
使用关联类型时,请记住这一点,并尽量确保你对你想要关联的类型尽可能明确。
有时,添加一个类型别名来帮助编译器比试图让编译器自己正确推断一切要好。
这种灵活性不仅限于协议。你还可以向函数、类、结构体和枚举添加泛型属性。让我们看看这是如何工作的,以及它如何使你的代码非常灵活。
使用泛型增加灵活性
使用泛型编程并不总是容易,但它确实使你的代码非常灵活。当你使用泛型时,你总是在你的程序简单性和代码灵活性之间做出权衡。有时引入一点复杂性以允许以其他方式不可能的方式编写代码是值得的。
例如,考虑你之前看到的Cow结构体。为了在HerbivoreType协议中指定泛型关联类型,我们在Cow结构体中添加了一个类型别名。现在想象一下,并不是所有的牛都喜欢吃草。也许有些牛更喜欢花朵、玉米或其他东西。你将无法使用类型别名来表达这一点。
为了表示你可能希望为每个牛实例使用不同的 PlantType 的情况,你可以在 Cow 本身添加一个泛型。以下代码片段显示了你可以如何做到这一点:
struct Cow<Plant: PlantType>: HerbivoreType {
var plantsEaten = [Plant]()
}
在 < 和 > 之间,泛型类型名称指定为 Plant。此泛型被限制为 PlantType 类型。
这意味着任何将作为 Plant 的类型都必须遵守 PlantType 协议。协议将看到 Cow 现在有一个泛型 Plant 类型,因此不需要添加类型别名。当你创建 Cow 的实例时,你现在可以传递每个实例自己的 PlantType:
let grassCow = Cow<Grass>()
let flowerCow = Cow<Flower>()
将泛型应用于此类实例比你想象的要常见。Array 实例使用泛型来确定它包含的元素类型。以下两行代码在功能上是相同的:
let strings = [String]()
let strings = Array<String>()
第一行使用方便的语法创建了一个字符串数组。第二行使用 Array 初始化器,并明确指定了它将包含的元素类型。
有时,你可能会发现自己正在编写一个可以从泛型参数或返回类型中受益的函数或方法。一个泛型函数的绝佳例子是 map。使用 map,你可以将项目数组转换成不同项目的数组。你可以定义自己的简单版本的 map 如下所示:
func simpleMap<T, U>(_ input: [T], transform: (T) -> U) -> [U] {
var output = [U]()
for item in input {
output.append(transform(item))
}
return output
}
在这里,simpleMap(_:transform:) 有两个泛型类型,T 和 U。这些名称是泛型的常见占位符,因此它们使任何阅读此代码的人都能清楚地知道他们即将处理泛型。
在这个示例中,该函数期望一个输入 [T],你可以将其读作某种东西的数组。它还期望一个闭包,该闭包接受一个参数 T 并返回 U。
你可以将这理解为闭包从那个某种东西的数组中取出一个元素,并将其转换成另一种东西。
该函数最终返回一个 [U] 的数组,换句话说,是另一种东西的数组。
你可以使用 simpleMap(_:transform:) 如下所示:
let result = simpleMap([1, 2, 3]) { item in
return item * 2
}
print(result) // [2, 4, 6]
泛型并不总是容易理解,如果你需要一些时间来适应它们,这是完全可以接受的。它们是一个强大而复杂的话题,我们可以写更多关于它们的内容。
最好的方法是使用它们,练习它们,并尽可能多地阅读有关它们的内容。现在,你应该有足够的想法和内容去思考和玩耍。
注意,泛型不仅限于结构体和函数。你还可以以相同的方式将泛型添加到你的枚举和类中。
摘要
在本章中,你看到了如何利用协议的强大功能来处理对象的特性和能力,而不仅仅是使用其类作为衡量其能力的唯一方式。然后,你看到了如何扩展协议以实现默认功能。这使你能够通过仅添加协议一致性来组合强大的类型,而不是创建子类。
你也看到了协议扩展如何根据你的协议要求表现,以及将协议扩展中的任何内容定义为协议要求是明智的。这使得协议行为更加可预测。
最后,你学习了关联类型的工作原理以及它们如何通过向你的协议添加泛型类型来提升你的协议水平,这些泛型类型可以针对符合你协议的每个类型进行调整。你甚至看到了如何将泛型应用于其他对象,例如函数和结构体。
本章中展示的概念相当高级、复杂且强大。要真正掌握它们的使用,你需要训练自己用特性而不是继承层次来思考。
一旦你掌握了这些,你就可以尝试协议扩展和泛型类型。如果你一开始并不完全理解这些主题,这是完全可以接受的;对于大多数有面向对象编程经验的程序员来说,这些都是全新的思维方式。
现在我们已经探索了一些关于协议和值类型的理论,在下一章中,你将学习如何通过简要回顾我们之前章节中的联系人应用来应用这些新知识,以改进你那里写的代码。
第八章:第八章:将 Core Data 添加到您的应用程序中
UserDefaults,但是当你处理更复杂的数据时,具有关系或需要某种形式的快速搜索时,Core Data 更适合你的需求。
你不需要构建一个非常复杂的应用程序或拥有大量数据,才能使 Core Data 值得你投入。无论你的应用程序大小如何,即使它非常小,只有几条记录,或者如果你正在处理数千条记录,Core Data 都会支持你。
在本章中,你将学习如何将 Core Data 添加到现有应用程序中。你将构建的应用程序将跟踪一个家庭的成员喜欢的电影列表。主要界面是一个表格视图,显示家庭成员列表。如果你点击家庭成员的姓名,你会看到他们的喜欢的电影。添加家庭成员可以通过概览屏幕完成,添加电影可以通过详细屏幕完成。
在本章中,以下主题被涵盖:
-
理解 Core Data 堆栈
-
将 Core Data 添加到现有应用程序中
-
创建 Core Data 模型
-
持久化数据和响应数据变化
-
理解
NSManagedObjectContext多实例的使用
技术要求
你不会从头开始构建这个应用程序的屏幕。本章的代码包包含一个名为 MustC 的起始项目。起始项目包含所有屏幕,因此在你开始实现 Core Data 之前,你不需要设置用户界面。
从以下 GitHub 链接下载示例代码:
github.com/PacktPublishing/Mastering-iOS-14-Programming-4th-Edition
理解 Core Data 堆栈
在你直接进入项目并添加 Core Data 之前,让我们看看 Core Data 的工作原理,它是什么,它不是什么。为了有效地使用 Core Data,你必须了解你所工作的内容。
当你使用 Core Data 时,你正在利用一个从管理对象开始到数据存储结束的层堆栈。这个数据存储通常是 SQLite 数据库,但根据应用程序的需求,你可以使用不同的存储选项与 Core Data 一起使用。让我们快速看一下与 Core Data 相关的层,并简要讨论它们在应用程序中的作用:

图 8.1 – 核心数据堆栈
在此图的右上角是 NSManagedObject 类。当你使用 Core Data 时,这是你最常与之交互的类,因为它是应用程序中所有 Core Data 模型的基类。例如,在本章中你将构建的应用程序中,家庭成员和电影模型是 NSManagedObject 的子类。
每个托管对象都属于一个NSManagedObjectContext的实例。托管对象上下文负责与持久化存储协调器通信。通常,你可能只需要一个托管对象上下文和一个持久化存储协调器。然而,使用多个持久化存储协调器和多个托管对象上下文也是可能的。甚至可以为同一个持久化存储协调器拥有多个托管对象上下文。
如果你在托管对象上执行昂贵的操作,例如导入或同步大量数据,具有多个托管对象上下文的设置可能特别有用。通常,你将坚持使用单个托管对象上下文和单个持久化存储协调器,因为大多数应用不需要超过一个。
持久化存储协调器负责与持久化存储通信。在大多数情况下,持久化存储使用 SQLite 作为其底层存储数据库。然而,你也可以使用其他类型的存储,例如内存数据库。内存数据库在编写单元测试或如果你的应用不需要长期存储时特别有用。
如果你曾经使用过 MySQL、SQLite 或其他任何关系型数据库,可能会倾向于将 Core Data 视为关系型数据库之上的一层。尽管这并不完全错误,因为 Core Data 可以使用 SQLite 作为其底层存储,但 Core Data 并不像直接使用 SQLite 那样工作;它是在其之上的一种抽象。
SQLite 和 Core Data 之间一个区别的例子是主键的概念。Core Data 不允许你指定自己的主键。此外,当你定义关系时,你也不使用外键。相反,你只需定义关系,Core Data 就会确定如何在底层数据库中存储这个关系。你将在稍后了解更多关于这一点。重要的是要知道,你不应该直接将你的 SQL 经验翻译到 Core Data 中。如果你这样做,你将遇到问题,仅仅是因为 Core Data 不是 SQL。只是碰巧 SQLite 是数据存储的一种方式,但相似之处到此为止。
总结一下,所有 Core Data 应用都有一个持久化存储。这个存储由一个内存数据库或 SQLite 数据库支持。持久化存储协调器负责与持久化存储通信。与持久化存储协调器通信的对象是托管对象上下文。一个应用可以有多个托管对象上下文实例与同一个持久化存储协调器通信。从持久化存储协调器检索的对象是托管对象。
现在你已经对 Core Data 堆栈及其使用中涉及的所有部分有了概述,让我们将 Core Data 堆栈添加到MustC应用中。
将 Core Data 添加到现有应用程序
当你在 Xcode 中创建一个新项目时,Xcode 会询问你是否想将 Core Data 添加到你的应用程序中。如果你勾选这个复选框,Xcode 将自动生成一些样板代码来设置 Core Data 栈。为了练习目的,MustC 是没有使用 Core Data 设置的,所以你必须自己将其添加到项目中。
首先,打开 AppDelegate.swift 并添加以下 import 语句:
import CoreData
接着,将以下 lazy 变量添加到 AppDelegate 的实现中:
private lazy var persistentContainer: NSPersistentContainer = {
let container = NSPersistentContainer(name: "MustC")
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error {
fatalError("Unresolved error (error), (error.userInfo)")
}
})
return container
}()
小贴士
如果你将变量声明为 lazy,则它将在访问时才初始化。这对于初始化成本高昂、依赖于其他对象或不是总是访问的变量特别有用。变量仅在需要时初始化,这会带来性能惩罚,因为变量需要在第一次访问时设置。在某些情况下,这没问题,但在其他情况下,可能会对用户体验产生负面影响。当正确使用时,lazy 变量可以提供显著的好处。
上述代码片段创建了一个 NSPersistentContainer 实例。持久化容器是持久化存储协调器、持久化存储和管理对象上下文的容器。这个单一对象管理 Core Data 栈的不同部分,并确保一切设置和管理正确。
如果你让 Xcode 为你的应用程序生成 Core Data 代码,它将添加一个类似的属性来创建 NSPersistentContainer。Xcode 还将一个名为 saveContext() 的方法添加到 AppDelegate 中。这个方法在 applicationWillTerminate(_:) 中使用,以在应用程序即将终止时执行最后的保存和更新操作。由于你正在手动设置 Core Data,Xcode 不会添加这种行为,因此你必须手动添加。
不要将 saveContext() 方法放在 AppDelegate 中,而是将其作为扩展添加到 NSPersistentContainer。这样做使得其他部分的代码能够更容易地使用这个方法,而无需依赖于 AppDelegate。
然后,在项目导航器中创建一个新的文件夹,命名为 Extensions。同时,创建一个新的 Swift 文件,命名为 NSPersistentContainer.swift。将以下实现添加到该文件中:
import CoreData
extension NSPersistentContainer {
func saveContextIfNeeded() {
if viewContext.hasChanges {
do {
try viewContext.save()
} catch {
let nserror = error as NSError
fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
}
}
}
}
这段代码通过扩展 NSPersistentContainer 实例来添加一个新的方法。这样做很方便,因为它完全解耦了保存方法与 AppDelegate。这比 Xcode 为 Core Data 应用程序提供的默认保存机制要好得多。
将以下 applicationWillTerminate(_:) 的实现添加到 AppDelegate 中,以便在应用程序终止前保存上下文:
func applicationWillTerminate(_ application: UIApplication) {
persistentContainer.saveContextIfNeeded()
}
现在,每当应用终止时,持久存储将检查 viewContext 属性指向的托管对象上下文是否有任何更改。如果有任何更改,将尝试保存它们。如果这个尝试失败,应用将崩溃并显示 fatalError。在创建自己的应用时,你可能希望更优雅地处理这种情况。完全有可能在应用终止前未能保存数据并不是导致应用崩溃的原因。如果你认为你的应用更适合不同的行为,你可以修改 saveContextIfNeeded() 的错误处理实现。例如,你可以将错误上传到你的分析或报告工具以供以后分析,或者避免使用 fatalError 并仅记录错误而不使应用崩溃。
现在你已经设置了 Core Data 栈,你需要一种方法将这个栈提供给应用中的视图控制器。实现这一目标的一种常见技术是将 AppDelegate 传递给 FamilyMemberViewController,这是应用中的第一个视图控制器。然后,FamilyMemberViewController 就负责将持久容器传递给下一个依赖于它的视图控制器,依此类推。
为了注入持久容器,你需要在 FamilyMembersViewController 中添加一个属性来持有持久容器。别忘了在文件顶部添加 import CoreData 并添加以下代码:
var persistentContainer: NSPersistentContainer!
现在,在 AppDelegate 中,修改 application(_:didFinishLaunchingWith Options:) 方法,如下所示:
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
if let navVC = window?.rootViewController as? UINavigationController,
let initialVC = navVC.viewControllers[0] as? FamilyMembersViewController {
initialVC.persistentContainer = persistentContainer
}
return true
}
这段代码正在使用依赖注入将 persistentContainer 注入到 FamilyMemberViewController。但你可以进行一个主要的改进:你知道可能有更多的视图控制器依赖于持久容器,因此你需要在它们中每个都添加一个 persistentContainer 属性。这将导致大量的重复代码。如果我们定义一个可以重用的协议,以减少每个需要 persistentContainer 的 UIViewController 实例中的重复代码,我们就可以改进我们的代码。作为一个练习,尝试通过添加一个名为 PersistentContainerRequiring 的协议来改进代码。这个协议应该添加一个对隐式解包的 persistentContainer 属性的要求。确保 FamilyMembersViewController 遵守这个协议,并修复 application(_:didFinishLaunchingWithOptions:) 的实现,以便它使用你的新协议。
你刚刚为在应用中使用 Core Data 奠定了基础。在你能够使用 Core Data 并在其中存储数据之前,你必须通过创建你的数据模型来定义你想要保存的数据。接下来,让我们看看如何做这件事。
创建 Core Data 模型
到目前为止,你已经完成了你应用的持久化层。下一步是创建你的模型,这样你就可以实际存储和检索 Core Data 数据库中的数据了。使用 Core Data 的应用程序中的所有模型都由 NSManagedObject 子类表示。当你从数据库检索数据时,NSManagedObjectContext 负责创建你的管理对象实例,并用相关检索到的数据填充它们。
MustC 应用程序需要两个模型:一个家庭成员模型和一个电影模型。当你定义模型时,你还可以定义关系。对于 MustC 中的模型,你应该定义一个关系,将多个电影与单个家庭成员链接起来。
创建模型
为了让 Core Data 理解你的应用程序使用哪些模型,你必须在使用 Xcode 的模型编辑器中定义它们。让我们创建一个新的模型文件,以便你可以在 MustC 应用程序中添加自己的模型。创建一个新文件,并在文件模板选择屏幕上选择 数据模型。首先,你将设置基本模型,然后看看你如何定义家庭成员和他们的最爱电影之间的关系:

图 8.2 – 创建新的 Core Data 模型
将你的模型文件命名为 MustC。你的项目现在包含一个名为 MustC.xcdatamodeld 的文件。打开此文件进入模型编辑器。在编辑器的左下角,你会找到一个标有 FamilyMember 的按钮。
当你通过点击选择一个实体时,你可以看到它的所有属性、关系和检索属性。让我们向家庭成员添加一个 name 属性。点击加号(name)。确保你选择此属性的类型为 String:

图 8.3 – 向 Core Data 实体添加属性
点击这个新属性以选择它。在右侧的侧边栏中,选择第四个选项卡以打开数据模型检查器。这里你可以看到关于此属性的更详细的信息。例如,你可以配置一个属性以便于快速查找。你还可以选择是否希望该属性为可选。现在,你不需要太关心索引,因为你不是通过家庭成员的姓名进行查找的,而且即使你这样做,一个家庭也不太可能有数百或数千个成员。默认情况下,可选复选框是勾选的。请确保取消勾选此框,因为你不想存储没有名字的家庭成员。
对于属性,你还有一些其他选项,比如添加验证、添加默认值和在 Spotlight 中启用索引。现在,请保持所有这些选项在默认设置:

图 8.4 – 属性属性
除了 FamilyMember 实体外,还需要创建 Movie 实体。使用之前相同的步骤创建此实体,并给它一个单一的属性:title。这个属性应该是一个字符串,并且不应该为可选。一旦添加了这个属性,您就可以设置家庭成员和他们的最爱电影之间的关系。
定义关系
在 Core Data 中,关系会将一个引用作为属性添加到实体上。在这种情况下,您想定义 FamilyMember 和 Movie 之间的关系。描述这种关系的最佳方式是一对多关系。这意味着每部电影将只有一个与之关联的家庭成员,而每个家庭成员可以有多个最喜欢的电影。
小贴士
使用从 Movie 到 FamilyMember 的一对多关系配置您的数据模型并不是定义这种关系的最高效方式。多对多关系可能更适合,因为这将允许多个家庭成员将相同的电影实例添加为他们的最爱。在这个例子中,使用一对多关系是为了保持设置简单,并使跟随示例变得容易。
选择 Movie 作为目的地。不要选择一个 Movie 具有指向 FamilyMember 的属性。确保您选择了 movies 属性。
此外,将 删除规则 的值设置为 级联:


图 8.5 – 关系属性
nil。这是删除电影时的正确行为,因为删除电影不应该删除添加此电影作为他们最爱的整个家庭成员。它应该只是从最爱列表中移除。
然而,如果一个家庭成员被删除并且关系被 nullified,您最终会有一堆没有与家庭成员关联的电影。在这个应用程序中,这些电影毫无价值;它们将不再被使用,因为每部电影只属于一个家庭成员。对于这个应用,当家庭成员被删除时,希望 Core Data 也删除他们的最爱电影。这正是 级联 选项所做的;它将删除操作级联到关系的反向。
在设置 familyMember 之后。目的地应该是 FamilyMember,而这个关系的反向是 favoriteMovies。添加这个关系后,反向将自动设置在 FamilyMember 实体上:


图 8.6 – 电影与家庭成员的关系
现在我们已经学会了如何在模型中创建和建立实体之间的关系,让我们开始使用这些实体在我们的应用中存储数据。
使用您的实体
如前所述,你的 Core Data 数据库中的每个模型或实体都由 NSManagedObject 表示。有几种方法可以创建或生成 NSManagedObject 子类。在最简单的设置中,NSManagedObject 子类只包含特定管理对象的属性,没有其他内容。如果情况是这样,你可以让 Xcode 为你生成模型类。
这实际上就是 Xcode 默认所做的。如果你现在构建项目并在 FamilyMembersViewController 中的 viewDidLoad() 中添加以下代码,你的项目应该可以顺利编译:
let fam = FamilyMember(entity: FamilyMember.entity(), insertInto: persistentContainer.viewContext)
这会自动完成;你不需要自己为模型编写任何代码。现在不必担心前面的代码做了什么;我们很快就会涉及到这一点。重点是,你看到在你的项目中存在一个 FamilyMember 类,尽管你不必自己创建它。
如果默认行为不适合你应用程序中想要的方法——例如,如果你想通过将变量定义为 private(set) 来防止代码修改你的模型——你可能想创建一个自定义子类,而不是让 Xcode 为你生成类。一个定制的 NSManagedObject 子类 FamilyMember 可能看起来像这样:
class FamilyMember: NSManagedObject {
@NSManaged private(set) var name: String
@NSManaged private(set) var favoriteMovies: [Movie]?
}
这个定制的 FamilyMember 子类确保任何外部代码都不能通过将 FamilyMember 上的设置器设置为私有来修改实例。根据你的应用程序,这可能是一个好主意,因为它将确保你的模型不会意外更改。
你还有一个选择,就是让 Xcode 在你定义的类上扩展 NSManagedObject 来生成属性。如果你想在模型上定义一些自定义存储属性,或者如果你有一个定制的 NSManagedObject 子类可以用作所有模型的基础,这特别有用。
小贴士
Xcode 为你的 Core Data 模型生成的所有代码都添加到 Xcode Derived Data 的 Build 文件夹中。你不应该修改它或直接访问它。这些文件将在你执行构建时自动由 Xcode 重新生成,所以你在生成的文件中添加的任何功能都将被覆盖。
对于 MustC 应用,如果 Xcode 生成模型定义类是可以的,因为没有需要添加的自定义属性。在模型编辑器中,选择每个实体并确保 Codegen 字段设置为 类定义;你可以在数据模型检查器面板中找到这个字段。
到目前为止,你已经设置好了在 Core Data 数据库中存储第一条数据:

图 8.7 – 实体的 Codegen 属性
在下一节中,我们将使用我们刚刚创建的模型和关系来持久化数据。
持久化数据和响应数据变化
实现你的应用数据持久化的第一步是确保你可以在数据库中存储数据。你已经定义了你想要存储在数据库中的模型,所以下一步就是实际存储你的模型。一旦你实现了数据持久化的初步版本,你将精炼代码使其更具可重用性。最后一步将是读取 Core Data 中的数据,并动态响应数据库中的变化。
理解数据持久化
每当你想要使用 Core Data 持久化一个模型时,你必须将一个新的NSManagedObject插入到NSManagedObjectContext中。这样做并不会立即持久化模型。它只是将对象放置在当前NSManagedObjectContext中的持久化阶段。如果你没有正确管理你的托管对象和上下文,这可能是潜在的错误来源。例如,不持久化你的托管对象会导致你在刷新上下文时丢失数据。尽管这听起来可能很显然,但如果你没有意识到这一点并且有管理托管对象上下文的错误,这可能会导致几个小时的不满。
如果你想要正确地保存托管对象,你必须告诉托管对象上下文将其更改持久化到持久化存储协调器。持久化存储协调器将负责在底层的 SQLite 数据库中持久化数据。
当你使用多个托管对象上下文时,需要格外小心。如果你在一个托管对象上下文中插入一个对象并持久化它,你必须手动将这些更改同步到其他托管对象上下文中。此外,托管对象不是线程安全的。这意味着你必须确保始终在一个单独的线程上创建、访问和存储托管对象。托管对象上下文有一个名为perform(_:)的辅助方法,可以帮助你完成这项工作。
插入新对象、更新它们或添加对象之间的关系时,应始终使用perform(_:)方法。原因是这个辅助方法确保你想要执行的闭包中的所有代码都在托管对象上下文所在的同一线程上执行。
既然你已经了解了 Core Data 中数据持久化的工作原理,现在是时候开始编写代码来存储家庭成员及其最喜欢的电影了。你将首先实现家庭成员的持久化。然后,你将扩展应用,以便你可以安全地为家庭成员添加电影。
持久化你的模型
你需要持久化的第一个模型是家庭成员模型。应用已经设置了一个表单,要求输入家庭成员的姓名,以及一个委托协议,每当用户想要存储一个新的家庭成员时,都会通知FamilyMembersViewController。
注意,输入数据没有任何验证;通常,你希望添加一些检查以确保用户没有尝试插入一个空的家族成员名称,例如。目前,我们将跳过这一点,因为这种验证不是 Core Data 特有的。
将持久化新家族成员的代码添加到saveFamilyMember(withName:)方法中。向FamilyMembersViewController添加以下实现;在添加代码后,我们将逐行分析它:
func saveFamilyMember(withName name: String) {
// 1
let moc = persistentContainer.viewContext
// 2
moc.perform {
// 3
let familyMember = FamilyMember(context: moc)
familyMember.name = name
// 4
do {
try moc.save()
} catch {
moc.rollback()
}
}
}
这段代码中的第一条注释标记了从persistentContainer中提取托管对象上下文的位置。所有NSPersistentContainer对象都有一个viewContext属性。这个属性用于获取存在于主线程上的托管对象上下文。
第二条注释标记了对perform(_:)的调用。这确保了新的FamilyMember实例被创建并存储在正确的线程上。
第三条注释标记了我们在托管对象上下文(moc)内部创建一个familyMember实例并更新其名称的位置。当你创建一个托管对象实例时,你必须提供实例将临时存储的托管对象上下文。
最后,保存托管对象上下文可能会失败,因此你必须将to save()调用包裹在do {} catch {}块中,以便它正确处理潜在的错误。如果托管对象上下文无法保存,所有未保存的更改都将回滚。
这段代码就是你需要的所有代码来持久化家族成员。在你实现读取现有家族成员和响应新家族成员插入所需的代码之前,让我们设置MoviesViewController,使其能够为家族成员存储电影。
存储家族成员电影的代码与你之前编写的代码非常相似。在实现以下代码片段之前,请确保在MoviesViewController文件中添加import CoreData。此外,向MoviesViewController添加一个persistentContainer属性,如下所示:
var persistentContainer: NSPersistentContainer!
为了将一部新电影与家族成员关联,你还需要一个变量在MoviesViewController中持有家族成员。向MoviesViewController添加以下声明:
var familyMember: FamilyMember?
在完成此操作后,为saveMovie(withName:)添加以下实现:
func saveMovie(withName name: String) {
guard let familyMember = self.familyMember else { return }
let moc = persistentContainer.viewContext
moc.perform {
let movie = Movie(context: moc)
movie.title = name
// 1
let newFavorites: Set<AnyHashable> = familyMember.movies?.adding(movie) ?? [movie]
// 2
familyMember.movies = NSSet(set: newFavorites)
do {
try moc.save()
} catch {
moc.rollback()
}
}
}
通过注释突出显示了添加电影和家族成员之间最重要的差异。请注意,家族成员上的movies属性是NSSet。这是一个不可变对象,因此你需要创建一个副本并将电影添加到该副本中。如果无法创建副本,因为没有创建集合,你可以创建一个新的包含新电影的集合。接下来,将这个新的、更新的集合转换回NSSet实例,以便它可以成为movies的新值。
如你所见,这两个保存方法共享大约一半的实现。你可以巧妙地使用 Swift 中的扩展和泛型来避免编写重复的代码。让我们对应用程序进行一点重构。
重要提示
注意我们是如何使用viewContext来持久化数据的(在saveFamilyMember和saveMovie方法中)。对于这个例子,它会非常完美,因为我们没有进行任何重任务。但是viewContext与应用程序的主队列相关联,因此不推荐使用它执行可能阻塞 UI 的工作(例如持久化大量数据)。在本章的最后部分,我们将通过创建一个在后台线程中工作的私有上下文来重构此代码。我们将在后台持久化数据,并在主线程中从viewContext读取更改。通过使用两个不同的上下文,一个在后台,一个在主线程,我们将遵循最佳实践,确保在持久化数据时不会阻塞 UI。
重构持久化代码
许多 iOS 开发者不喜欢使用 Core Data 时涉及的样板代码量。简单地持久化一个对象就需要你重复编写几行代码,这可能会随着时间的推移变得相当痛苦,难以编写和维护。以下示例中展示的重构持久化代码的方法深受由 Florian Kugler 和 Daniel Eggert 合著的Core Data一书中采用的方法的启发。
小贴士
如果你想要了解关于 Core Data 的更多内容,超出本书所涵盖的范围,并且想要看到更多减少样板代码的巧妙方法,你应该阅读由 Kugler 和 Eggert 合著的Core Data。
在创建代码块以将familyMember和familyMember.movies实例保存到 Core Data 之后,使用以下模式:
moc.perform {
// create managed object
do {
try moc.save()
} catch {
moc.rollback()
}
}
现在,如果你能编写以下代码来持久化数据,将每次保存对象时重复的代码减少到最小,那将非常棒:
moc.persist {
// create managed object
}
这可以通过为NSManagedObjectContext编写一个扩展来实现。在扩展文件夹中添加一个名为NSManagedObjectContext的文件,并添加以下实现:
extension NSManagedObjectContext {
func persist(block: @escaping () -> Void) {
perform {
block()
do {
try self.save()
} catch {
self.rollback()
}
}
}
}
上述代码允许你减少样板代码的数量,这是你应该始终努力实现的目标。减少样板代码极大地提高了代码的可读性和可维护性。更新家庭概览和电影列表视图控制器以利用这种新的持久化方法。
在使用前面的技巧优化了使用 Core Data 保存实体后的代码后,现在让我们看看如何读取数据,利用NSFetchRequest,它允许我们以简单有效的方式查询数据。
使用简单的获取请求读取数据
从数据库中获取数据的最简单方法是使用获取请求。管理对象上下文将获取请求转发给持久存储协调器。然后,持久存储协调器将请求转发给持久存储,持久存储将请求转换为 SQLite 查询。一旦获取到结果,它们将通过这个链路返回,并转换为NSManagedObject实例。
默认情况下,这些对象被称为故障。当一个对象是故障时,这意味着该对象的实际属性和值尚未获取,但一旦你访问它们,它们将会被获取。这是一个良好实现懒变量的例子,因为获取值是一个相当快速的操作,而一次性获取所有内容将大大增加你的应用程序的内存占用,因为所有值都必须立即加载到应用程序的内存中。
让我们来看一个简单的获取请求的例子,该请求检索所有已保存到数据库中的 FamilyMember 实例:
let request: NSFetchRequest<FamilyMember> = FamilyMember.fetchRequest()
let moc = persistentContainer.viewContext
guard let results = try? moc.fetch(request) else { return }
如你所见,获取所有家庭成员并不特别困难。每个 NSManagedObject 实例都有一个类方法,可以配置一个基本的获取请求,用于检索数据。如果你有大量数据,你可能不想一次性获取所有持久化的对象。你可以通过设置 fetchBatchSize 属性来配置你的获取请求以分批获取数据。建议你每次想在表格视图或集合视图中使用获取的数据时都使用这个属性。你应该将 fetchBatchSize 属性设置为一个略高于你预期一次显示的单元格数量的值。这确保了 Core Data 获取足够多的项目来显示,同时避免了一次性加载所有内容。
现在你已经知道了如何获取数据,让我们在家庭成员表格视图中显示一些数据。在 FamilyMembersViewController 中添加一个名为 familyMembers 的新变量。给这个属性一个初始值 [FamilyMember](),这样你就可以从一个空的家庭成员数组开始。此外,将你之前看到的示例获取请求添加到 viewDidLoad() 中。接下来,将获取请求的结果分配给 familyMembers,如下所示:
familyMembers = results
最后,更新表格视图代理方法,以便 tableView(_:numberOfRowsInSection:) 返回 familyMembers 数组中的项目数:
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return familyMembers.count
}
此外,更新 tableView(_:cellForRowAtIndexPath:) 方法,在返回单元格之前添加以下两个突出显示的行:
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: "FamilyMemberCell")
else { fatalError("Wrong cell identifier requested") }
let familyMember = familyMembers[indexPath.row]
cell.textLabel?.text = familyMember.name
return cell
}
如果你现在构建并运行你的应用,你应该能看到你已经保存的家庭成员。新的家庭成员不会立即显示。然而,当你退出应用并再次运行它时,新的成员将会出现。
你可以在插入新的家庭成员后手动重新加载表格视图,以确保它始终是最新的,但这不是最佳方法。你很快就会看到一种更好的方法来响应新数据的插入。首先让我们完成家庭成员详细视图,以便它显示家庭成员的最爱电影。将以下代码添加到 FamilyMembersViewController 视图控制器中的 prepare(for:sender:) 方法的末尾:
if let moviesVC = segue.destination as? MoviesViewController {
moviesVC.persistentContainer = persistentContainer
moviesVC.familyMember = familyMembers[selectedIndex.row]
}
方法应该看起来像这样:
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if let navVC = segue.destination as? UINavigationController,
let addFamilyMemberVC = navVC.viewControllers[0] as? AddFamilyMemberViewController {
addFamilyMemberVC.delegate = self
}
guard let selectedIndex = tableView.indexPathForSelectedRow
else { return }
if let moviesVC = segue.destination as? MoviesViewController {
moviesVC.persistentContainer = persistentContainer
moviesVC.familyMember = familyMembers[selectedIndex.row]
}
tableView.deselectRow(at: selectedIndex, animated: true)
}
上述代码将选定的家庭成员和持久容器传递给 MoviesViewController,以便它可以显示和存储当前家庭成员的最爱电影。
你要做的只是使用家庭成员的最爱电影在 MovieViewController 表视图数据源方法中显示正确的电影,如下所示:
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) ->Int {
return familyMember?.movies?.count ?? 0
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: "MovieCell"), let movies = familyMember?.movies
else { fatalError("Wrong cell identifier requested or missing family member") }
let moviesArray = Array(movies as! Set<Movie>)
let movie = moviesArray[indexPath.row]
cell.textLabel?.text = movie.title
return cell
}
你在这里不需要使用获取请求,因为你可以直接遍历家庭成员上的 movies 关系来获取他们的最爱电影。这不仅对你作为开发者来说很方便,而且对你的应用程序性能也有好处。每次你使用获取请求时,你都会强制对数据库进行查询。如果你遍历一个关系,Core Data 将尝试从内存中获取对象而不是请求数据库。
再次强调,添加新数据不会立即触发表视图更新其内容。我们将在查看如何过滤数据之后讨论这个问题。如果你想检查你的代码是否工作,构建并重新运行应用程序,以便从数据库中获取所有最新的数据。现在我们知道了如何从 Core Data 中查询,让我们通过使用 NSPredicate 类来过滤检索到的数据来执行更智能的查询。
使用谓词过滤数据
你想在数据库上执行的一个典型操作是过滤。在 Core Data 中,你使用谓词来做这件事。谓词描述了一组规则,任何被检索的对象都必须符合这些规则。
当你在模型编辑器中建模你的数据时,考虑你需要进行的过滤类型是明智的。例如,你可能正在构建一个生日日历,你经常会按日期排序或过滤。如果是这种情况,你应该确保你有这个属性的 Core Data 索引。你可以通过模型编辑器中之前看到的复选框来启用索引。如果你要求 Core Data 索引一个属性,它将显著提高在大数据集中过滤和选择数据时的性能。
编写谓词可能会让人感到困惑,尤其是当你试图把它们想象成 SQL 中的 WHERE 子句时。谓词非常相似,但它们并不完全相同。一个简单的谓词看起来如下所示:
NSPredicate(format: "name CONTAINS[n] %@", "Gu")
谓词有一个格式;这个格式总是从一个键开始。这个键代表你想要匹配的属性。在这个例子中,它将是家庭成员的名字。然后,你指定条件——例如,==, >, <, 或 CONTAINS[n]。
有更多条件可用,但列出的这些是一些你将常用到的条件的例子。最后,你将指定一个占位符,该占位符将被真实值替换。在上一个例子中,这个占位符是 %@。如果你在拿起这本书之前写过任何 Objective-C,%@ 占位符可能对你来说很熟悉,因为它在格式字符串中用作占位符。
示例谓词非常简单且简洁;它可以成为你正在构建的搜索功能的模板。通常,只要在搜索的属性中添加索引,简单的搜索就不需要比这更复杂。
如果你想要匹配多个谓词,你可以使用 NSCompoundPredicate 将它们组合起来。这个类使用 and、or 或 not 子句来组合不同的谓词。这种方法的典型用例是在你的应用中构建一个复杂的过滤器,其中谓词难以用单个语句表达。
要在获取请求中使用谓词,你将其分配给获取请求的 predicate 属性。每个获取请求都有一个 predicate 属性,你可以设置它。它可以处理单个谓词和复合谓词。如果你在执行获取请求之前设置此属性,谓词将应用于请求,你将收到一个过滤后的数据集而不是完整的数据集。
谓词非常强大,并且有很多可用的选项。
小贴士
如果你想要深入了解谓词以及你可以使用格式字符串的所有方式,我建议你阅读 Apple 的谓词编程指南,网址为 apple.co/2fF3qHc。它提供了对谓词及其应用的详细概述。
接下来,你将学习如何响应托管对象上下文中的更改——例如,当你添加新的家庭成员和电影时。
响应数据库更改
在当前状态下,MustC 应用在持久化新的托管对象时不会更新其列表。一个可能的解决方案是在插入新的家庭成员后手动重新加载表格。虽然这可能在一段时间内工作得很好,但这并不是解决这个问题的最佳方案。如果应用增长,你可能会添加从网络导入新家庭成员的功能。手动刷新表视图会有问题,因为网络逻辑不应该知道表视图。幸运的是,有一个更好的解决方案来响应数据更改。
响应数据库更改的一种方式是使用 NSFetchedResultsController。这个类非常适合监听新家庭成员的插入。你将在 FamilyMembersViewController 中实现这种方法。响应更新的第二种方式是通过通知。你将在 MoviesViewController 中实现这种方法。
实现 NSFetchedResultsController
NSFetchedResultsController 是一个专门用于获取数据和管理工作数据的辅助类。它监听其托管对象上下文中的更改,并在获取的数据发生变化时通知代理。这非常有帮助,因为它允许你响应数据集中的特定更改,而不是完全重新加载表视图。
作为获取结果控制器的代理涉及以下重要方法:
-
controllerWillChangeContent(_:)在控制器将更新传递给代理之前被调用。如果你使用带有获取结果控制器的表格视图,这是一个开始更新表格视图的完美方法。 -
controller(_:didChange:at:for:newIndexPath:)和controller(_:didChange:atSectionIndex:for:)被调用以通知代理关于获取项和部分的更新。这是处理获取数据更新的地方。例如,如果数据集中插入了新项,你可以在表格视图中插入新行。 -
controllerDidChangeContent(_:)被调用。这是你应该让表格视图知道你已经完成处理更新的地方,以便所有更新都可以应用到表格视图的界面中。
对于 MustC,由于显示家庭成员的表格视图只有一个部分,因此实现所有四个方法没有意义。这意味着不需要实现 controller(_:didChange:atSectionIndex:for:)。
要使用获取结果控制器获取存储的家庭成员,你需要创建一个 NSFetchedResultsController 实例,并将 FamilyMembersViewController 作为其代理,以便它可以响应底层数据的变化。然后你可以实现代理方法,以便你可以响应获取结果数据集的变化。从 FamilyMembersViewController 的变量声明中删除 familyMembers 数组,并添加以下 fetchedResultsController 属性:
var fetchedResultsController: NSFetchedResultsController<FamilyMember>?
viewDidLoad 方法应该调整如下:
override func viewDidLoad() {
super.viewDidLoad()
let moc = persistentContainer.viewContext
let request = NSFetchRequest<FamilyMember>(entityName: "FamilyMember")
request.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)]
fetchedResultsController = NSFetchedResultsController(fetchRequest: request, managedObjectContext: moc, sectionNameKeyPath: nil, cacheName: nil)
fetchedResultsController?.delegate = self
do {
try fetchedResultsController?.performFetch()
} catch {
print("fetch request failed")
}
}
此实现初始化 NSFetchedResultsController,分配其代理,并告诉它执行获取请求。请注意,获取请求的 sortDescriptors 属性设置为包含 NSSortDescriptor 的数组。获取请求控制器需要设置此属性,对于家庭成员列表,按姓名排序家庭成员是有意义的。
现在你有了获取结果控制器,你应该在 FamilyMembersViewController 上实现代理方法,并使其符合 NSFetchedResultsControllerDelegate。将以下扩展添加到 FamilyMembersViewController.swift:
extension FamilyMembersViewController: NSFetchedResultsControllerDelegate {
func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
tableView.beginUpdates()
}
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
tableView.endUpdates()
}
}
此扩展的实现相当直接。当获取结果控制器即将处理其数据的更改时,表格视图会收到通知,当获取结果控制器完成处理更改时,表格视图也会收到通知。大部分工作需要在 controller(_:didChange:at:for:newIndexPath) 中完成。此方法在获取结果控制器处理更新后被调用。在 MustC 中,目标是更新表格视图,但你也可以更新集合视图或将所有更新存储在列表中并对其进行其他处理。
让我们看看你如何在以下方法中处理获取数据的更改。将其添加到 FamilyMembersViewController.swift 的扩展中:
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
switch type {
case .insert:
guard let insertIndex = newIndexPath else { return }
tableView.insertRows(at: [insertIndex], with: .automatic)
case .delete:
guard let deleteIndex = indexPath else { return }
tableView.deleteRows(at: [deleteIndex], with: .automatic)
case .move:
guard let fromIndex = indexPath, let toIndex = newIndexPath else { return }
tableView.moveRow(at: fromIndex, to: toIndex)
case .update:
guard let updateIndex = indexPath else { return }
tableView.reloadRows(at: [updateIndex], with: .automatic)
@unknown default:
fatalError("Unhandled case")
}
}
这个方法包含了很多代码,但实际上并不复杂。前面的方法接收一个类型参数。这个参数是 NSFetchedResultsChangeType 的一个实例,它包含有关接收到的更新类型的信息。以下是可以发生的四种更新类型:
-
插入
-
删除
-
移动
-
更新
这些更改类型中的每一个都对应于一个数据库操作。如果一个对象被插入,你会收到一个插入更改类型。对于 MustC 来说,处理这些更新的正确方式是简单地将它们传递给表格视图。一旦收到所有更新,表格视图将一次性应用所有这些更新。
如果你同时实现了 controller(_:didChange:atSectionIndex:for:),它也会收到一个 change 类型的通知;然而,章节只处理以下两种类型的更改:
-
插入
-
删除
章节不会更新或移动,所以如果你实现这个方法,你不需要考虑所有情况,因为你不会遇到任何,除了列出的两种更改类型。
如果你仔细查看 controller(_:didChange:at:for:newIndexPath) 的实现,你会注意到它接收两个索引路径。一个是 indexPath,另一个是 newIndexPath。它们都是可选的,所以如果你使用它们,你需要确保安全地解包它们。对于新对象,只有 newIndexPath 属性将存在。对于删除和更新,indexPath 属性将被设置。当一个对象从数据集中的一个位置移动到另一个位置时,newIndexPath 和 indexPath 都将有一个值。
你需要做的最后一件事是更新 FamilyMembersViewController 中的代码,使其使用获取结果控制器而不是它之前使用的 familyMembers 数组。首先,更新 prepare(for:sender:) 方法,如下所示:
if let moviesVC = segue.destination as? MoviesViewController, let familyMember = fetchedResultsController?.object(at:
selectedIndex) {
moviesVC.persistentContainer = persistentContainer
moviesVC.familyMember = familyMember
}
这确保了一个有效的家族成员被传递给 movies 视图控制器。更新表格视图数据源方法,如下所示。一个获取结果的控制器可以根据索引路径检索对象。这使得它非常适合与表格视图和集合视图一起使用:
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return fetchedResultsController?.fetchedObjects?.count ?? 0
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: "FamilyMemberCell"), let familyMember = fetchedResultsController?.object(at: indexPath) else { fatalError("Wrong cell identifier requested") }
cell.textLabel?.text = familyMember.name
return cell
}
如果你现在运行你的应用,当你向数据库中添加一个新的家族成员时,界面会自动更新。然而,最喜欢的电影列表还没有更新。那个页面没有使用获取结果的控制器,所以它必须直接监听数据集的变化。
MoviesViewController 不使用获取结果控制器来处理电影列表的原因是,获取结果控制器始终需要下降到你的持久存储(在这个应用中是 SQLite)。如前所述,与遍历家族成员及其电影之间的关系相比,查询数据库有显著的内存开销;读取 movies 属性比从数据库中获取它们要快得多。
每当托管对象上下文发生变化时,都会向默认的NotificationCenter发布一个通知。NotificationCenter用于在应用程序内部发送事件,以便其他部分的代码可以对这些事件做出反应。
信息
使用通知而非委托可能会很有吸引力,尤其是如果你来自一个大量使用事件的环境,比如 JavaScript。不要这样做;委托更适合大多数情况,并且会使你的代码更加易于维护。只有在你不在乎谁在监听你的通知,或者设置委托关系会意味着你只是为了设置委托而创建非常复杂的不相关对象之间的关系时,才使用通知。
让我们订阅MoviesViewController对托管对象上下文变化的更改,以便在需要时能够响应数据变化。在你实现这个功能之前,添加以下方法,该方法应在托管对象上下文发生变化时被调用:
extension MoviesViewController {
@objc func managedObjectContextDidChange(notification: NSNotification) {
guard let userInfo = notification.userInfo, let updatedObjects = userInfo[NSUpdatedObjectsKey] as? Set<FamilyMember>, let familyMember = self.familyMember else { return }
if updatedObjects.contains(familyMember) {
tableView.reloadData()
}
}
}
此方法读取通知的userInfo字典以访问与当前列表相关的信息。你对当前familyMember对象的变化感兴趣,因为当这个对象发生变化时,你可以相当肯定地认为刚刚插入了一个新的电影。userInfo字典包含插入、删除和更新对象的键。在这种情况下,你应该寻找更新对象,因为用户不能在此视图中删除或插入新的家庭成员。如果家庭成员被更新,表格视图将被重新加载以显示新数据。
以下代码将MoviesViewController订阅到持久容器中托管对象上下文的变化:
override func viewDidLoad() {
super.viewDidLoad()
NotificationCenter.default.addObserver(self, selector: #selector(self.managedObjectContextDidChange(notification:)), name: .NSManagedObjectContextObjectsDidChange, object: nil)
}
当视图加载时,当前的MoviesViewController实例被添加为.NSManagedObjectContextObjectsDidChange通知的观察者。继续构建你的应用程序;现在你应该看到每当你在数据库中添加新数据时,用户界面都会更新。
在本节中,我们学习了如何使用两种不同的方法来响应数据库变化:NSFetchedResultsController和通知。在下一节中,我们将学习如何管理多个NSManagedObjectContext实例,以提高处理重任务时的用户界面响应。
理解使用多个 NSManagedObjectContext 实例
本章中多次提到,你可以使用多个托管对象上下文。在许多情况下,你可能只需要一个托管对象上下文。使用单个托管对象上下文意味着与托管对象上下文相关的所有代码都在主线程上执行。如果你执行的是小型操作,那没问题。然而,想象一下导入大量数据的情况。这样的操作可能需要一段时间。在主线程上执行长时间运行的代码会导致用户界面无响应。这不好,因为用户会认为你的应用已崩溃。那么,你该如何解决这个问题呢?答案是使用多个托管对象上下文。
在过去,使用多个托管对象上下文不容易管理;你必须自己使用正确的队列创建NSManagedObjectContext的实例。幸运的是,NSPersistentContainer有助于使复杂设置更容易管理。如果你想将数据导入后台任务,可以通过在持久容器上调用newBackgroundContext()来获取托管对象上下文。或者,你可以在持久容器上调用performBackgroundTask并传递一个闭包,该闭包包含你希望在后台执行的处理。
关于 Core Data、后台任务和多线程的一个重要理解是,你必须始终在创建托管对象上下文相同的线程上使用托管对象上下文。考虑以下示例:
let backgroundQueue = DispatchQueue(label: "backgroundQueue")
let backgroundContext = persistentContainer.newBackgroundContext()
backgroundQueue.async {
let results = try? backgroundContext.fetch(someRequest)
for result in results {
// use result
}
}
这段代码的行为可能会让你头疼。后台上下文是在一个不同于它所使用的队列中创建的。始终确保使用NSManagedObject的perform(_:)方法在创建托管对象上下文相同的队列中使用托管对象上下文。更重要的是,你还必须确保在托管对象上下文所属的队列上使用你检索到的托管对象。
通常,你会发现最好在主队列上使用viewContext持久容器来获取数据。如果需要,可以将存储数据委托给后台上下文。如果你这样做,你必须确保后台上下文是主上下文的子上下文。当在这两个上下文之间定义这种关系时,主上下文将自动在后台上下文持久化时接收更新。这非常方便,因为它减少了大量的手动维护,使你的上下文保持同步。幸运的是,持久容器为你处理了这一切。
当你发现你的应用需要配置多个托管对象上下文时,牢记本节中提到的规则至关重要。在错误位置使用托管对象或托管对象上下文导致的错误通常难以调试和发现。当谨慎实施时,具有多个托管对象上下文的复杂设置可以提高应用程序的性能和灵活性。
在我们的项目 FamilyMembersViewController 文件中的 saveFamily(…) 方法使用了 viewContext 来持久化数据。让我们重构这段代码,使用不同的托管对象上下文,并提高应用程序的性能。
重新整理持久化代码
在 viewContext(你可以在 FamilyMembersViewController 文件中的 saveFamily 方法中看到这一点)。记住我们之前说过,viewContext 与应用程序的主队列相关联。因此,如果我们用它做任何重工作(通常,持久化数据可能会引起重工作),我们可能会阻塞 UI。所以,只使用 viewContext 来读取 Core Data 的更改,并使用不同的托管对象上下文(在后台队列中)来持久化它们是个好主意。这样,我们在持久化大量数据时不会阻塞 UI。让我们进行这个重构。
在本章的代码包中,打开名为 MustC_refactor_start 的项目。打开 FamilyMembersViewController 文件,并将 saveFamilyMember(…) 替换为以下实现:
func saveFamilyMember(withName name: String) {
// 1
persistentContainer.performBackgroundTask({ (moc) in
// 2
let familyMember = FamilyMember(context: moc)
familyMember.name = name
// 3
do {
try moc.save()
} catch {
moc.rollback()
}
})
}
让我们逐行查看注释(注意我们有一个名为 saveFamilyMemberOld 的旧方法作为参考;你可以比较两者以查看差异):
-
首先,我们使用了
performBackgroundTask持久容器方法。每次调用此方法时,持久容器都会创建一个新的NSManagedObjectContext,其concurrencyType设置为.privateQueueConcurrencyType。然后,持久容器在该上下文的私有队列上执行传入的块。我们可以使用这个新的moc对象在后台队列中持久化数据,而不会阻塞用户界面。注意,在我们的上一个方法(saveFamilyMemberOld)中,我们使用的是viewContext管理对象上下文,如果需要持久化的数据量较大,这可能会阻塞用户界面。 -
第二步,我们在我们的托管对象上下文(
moc)内部创建一个familyMember实例,并更新其名称。当你创建一个托管对象实例时,你必须提供实例将临时存储的托管对象上下文。 -
最后,在第三个注释中,我们进行保存。保存托管对象上下文可能会失败,因此你必须将
save()调用包裹在do {} catch {}块中,以便正确处理潜在的错误。如果托管对象上下文无法保存,所有未保存的更改都将回滚。
现在,让我们运行应用程序并添加一个家庭成员。哎呀!什么都没有。你会看到当你添加成员时,显示现有成员的表格没有更新。然而,如果你停止并重新启动应用程序,新成员就会出现。那么,发生了什么?看起来我们正在保存数据,但用户界面并不知道(直到我们重新启动并重新加载)。为什么是这样?我们使用私有托管对象上下文来持久化数据,而 viewContext 并不知道。让我们修复这个问题。
在 viewDidLoad 方法中,添加以下行,在 let moc = persistentContainer.viewContext 之后:
moc.automaticallyMergesChangesFromParent = true
将 automaticallyMergesChangesFromParent 设置为 true 实际上使 viewContext 能够意识到其他上下文在持久化存储中执行的所有更改。如果你现在运行应用程序并添加一个家庭成员,你将看到表格如何立即反映这些更改。
现在,作为练习,你可以在 MoviesViewController 文件中做同样的事情,修改 saveMovie 方法并将 viewContext 的 automaticallyMergesChangesFromParent 属性设置为 true。试试看!
在本节中,我们学习了如何使用多个托管对象上下文来提高你的 Core Data 代码的性能。现在让我们通过总结来结束这一章。
概述
本章向你展示了如何实现一个相对简单的 Core Data 数据库,用于存储家庭成员及其最喜欢的电影。你使用 Xcode 中的 Core Data 模型编辑器来配置你想要存储的模型并定义这些模型之间的关系。一旦模型设置好,你就实现了创建模型实例的代码,以便它们可以存储在数据库中并在以后检索。
接下来,你从数据库中检索了数据,并发现当底层数据发生变化时,你的表格视图不会自动更新。你使用了一个 NSFetchedResult 控制器来获取家庭成员并监听家庭成员列表上的变化。你看到这个设置非常强大,因为你可以很容易地响应数据的变化。
最后,你通过使用不同的托管对象上下文改进了 Core Data 代码,使用后台对象上下文来持久化数据,以及主队列对象上下文来响应变化并刷新用户界面。
在下一章中,你将学习如何通过从网络获取和存储数据来丰富用户添加到数据库中的数据。
进一步阅读
-
《Core Data》一书,由 Florian Kugler 和 Daniel Eggert 撰写
-
苹果的谓词编程指南:
apple.co/2fF3qHc
第九章:第九章:从网络获取和显示数据
大多数现代应用程序都会与 Web 服务进行通信。有些应用程序严重依赖它们,仅作为从网络上读取数据并在应用程序中以表单形式显示数据的层。其他应用程序使用 Web 来检索和同步数据,使其本地可用,而有些应用程序仅将 Web 用作备份存储。当然,使用互联网数据的原因远不止上述提到的这些。
在本章中,你将扩展MustC应用程序,使其使用 Web 服务检索家庭成员添加为收藏的电影的流行度评分。这些流行度评分将存储在 Core Data 数据库中,并与电影名称一起显示。
在本章中,你将学习以下主题:
-
使用
URLSession从网络上获取数据 -
在 Swift 中使用 JSON
-
使用获取的数据更新 Core Data 对象
技术要求
本章的代码包包含一个名为URLSession.playground的起始项目。
你还需要从www.themoviedb.org/生成一个 API 密钥。在他们的网站上创建一个账户,并在账户页面上请求一个 API 密钥。设置这个过程只需几分钟,如果你想跟随本章的内容,你需要有自己的 API 密钥。
在你已在themoviedb.org上创建并验证了账户后,你可以访问以下链接来请求一个 API 密钥:www.themoviedb.org/settings/api/request。
本章的代码可以在以下位置找到:github.com/PacktPublishing/Mastering-iOS-14-Programming-4th-Edition/tree/master/Chapter%209%20-%20Fetching%20from%20Network。
使用 URLSession 从网络上获取数据
从网络上检索数据是作为 iOS 专业人士你经常会做的事情。你不仅会从网络服务中获取数据,还会向其发送数据。例如,你可能需要作为登录流程的一部分或更新用户个人资料信息时发起一个 HTTP POST 请求。随着时间的推移,iOS 在 Web 请求方面已经发展了很多,使得在应用程序中使用 Web 服务变得更加容易。
重要提示
HTTP(或 HTTPS)是一种几乎所有网络流量都用于客户端(如应用程序)与服务器之间通信的协议。HTTP 协议支持几种表示请求意图的方法。GET 用于从服务器检索信息。POST 请求表示将新内容推送到服务器的意图,例如,在提交表单时。
当您想在 iOS 中执行网络请求时,您通常会使用 URLSession 类。URLSession 类代表您执行异步网络请求。这意味着 iOS 在后台线程上从网络加载数据,确保在整个请求过程中用户界面保持响应。如果执行同步网络请求,用户界面在网络请求期间将无响应,因为线程一次只能做一件事,所以如果它在等待网络响应,它就不能响应触摸或任何其他用户输入。
如果您的用户拥有慢速的互联网连接,一个请求可能需要几秒钟。您不希望界面冻结几秒钟。即使是几毫秒也会导致其响应性和帧率明显下降。通过使用 URLSession 来执行异步网络请求可以轻松避免这种情况。
首先,您将在游乐场中实验基本的网络请求。您可以创建一个新的游乐场或使用本书代码包中提供的游乐场。在您了解了 URLSession 的基础知识之后,您将实现从开源电影数据库获取电影的方法,并将此实现用于 MustC 应用。
理解 URLSession 的基础知识
进行网络调用是每个需要获取、发布或修改远程数据的应用的基石任务之一。这是开发者每天面临的最常见任务之一。为此任务,苹果为开发者提供了 URLSession 类。URLSession 类帮助开发者轻松地处理远程数据,并通过协调一系列相关的网络数据传输任务。
以下代码片段展示了加载 apple.com 首页的示例网络请求:
import Foundation
let url = URL(string: 'https://apple.com')!
let task = URLSession.shared.dataTask(with: url) {
data, response, error in
if let data = data {
print(data)
}
if let response = response {
print(response)
}
if let error = error {
print(error)
}
}
task.resume()
这是一个基本的示例:创建一个 URL,然后使用共享的 URLSession 实例创建一个新的 dataTask。这个 dataTask 是 URLSessionDataTask 的一个实例,允许您从远程服务器加载数据。
或者,如果您正在下载文件,可以使用下载任务;如果您正在将文件上传到网络服务器,可以使用上传任务。在创建任务后,您必须调用任务上的 resume,因为新任务总是以挂起状态创建。
如果您在一个空的游乐场中运行此示例,您会发现示例无法工作。因为网络请求是异步进行的,所以游乐场在网络请求完成之前就已经执行完毕。为了解决这个问题,您应该确保游乐场无限期地运行。这样做将允许网络请求完成。将以下行添加到游乐场源文件的顶部以启用此行为:
import PlaygroundSupport
PlaygroundPage.current.needsIndefiniteExecution = true
现在沙盒运行无限期,您会发现控制台打印出来的有用数据并不多。在这种情况下,您对原始数据、HTTP 头部或错误为 nil 的事实并不感兴趣。当您从 URL 加载数据时,您通常最感兴趣的是响应的主体。响应的主体通常包含您请求的数据的字符串表示。在先前的例子中,主体是构成苹果主页的 HTML。让我们看看您如何从响应中提取这个 HTML。将数据任务完成回调替换为以下内容:
{ data, response, error in
guard let data = data, error == nil
else { return }
let responseString = String(data: data, encoding: .utf8)
print(responseString as Any)
}
之前的回调闭包确保了没有错误被网络服务返回,并且存在数据。然后,原始数据被转换成字符串,并将该字符串打印到控制台。如果您使用这个回调而不是旧的回调,您将看到苹果主页的 HTML 被打印出来。像您刚才看到的对网络服务器的简单请求,使用URLSession实现起来相对简单。
如果您需要自定义网络请求(例如,添加自定义头部),而不是使用带有 URL 的简单dataTask函数,您需要创建自己的URLRequest实例,而不是让URLSession为您创建。您看到的例子是您让URLSession代表您创建URLRequest的情况。如果您只想执行一个没有自定义头部的简单 HTTP GET 请求,这是可以的,但如果您要发送数据或包含特定的头部,您将需要更多控制请求的方式。
让我们看看带有一些参数和自定义头部的 GET 请求是什么样的。以下代码使用了来自www.themoviedb.org/的 API 密钥。如果您想尝试这个代码示例,请在他们网站上创建一个账户,并在账户页面请求一个 API 密钥。设置这个过程只需要几分钟,如果您想跟随本章内容,您将需要自己的 API 密钥。在您在themoviedb.org上创建并验证账户后,您可以访问以下链接来请求一个 API 密钥:www.themoviedb.org/settings/api/request。
let api_key = 'YOUR_API_KEY_HERE'
var urlString = 'https://api.themoviedb.org/3/search/movie/'
urlString = urlString.appending('?api_key=\(api_key)')
urlString = urlString.appending('&query=Swift')
let movieURL = URL(string: urlString)!
var urlRequest = URLRequest(url: movieURL)
urlRequest.httpMethod = 'GET'
urlRequest.setValue('application/json', forHTTPHeaderField: 'Accept')
let movieTask = URLSession.shared.dataTask(with: urlRequest) { data, response, error in
print(response as Any)
}
movieTask.resume()
之前的代码比您之前看到的例子要复杂一些。在这个例子中,配置了一个更复杂的 URL 请求,其中包含了一些 HTTP GET 参数。URLRequest的httpMethod值被指定,并提供了一个自定义的头部信息,以便通知接收者它希望接收哪种类型的响应。
执行此 URL 请求的流程与之前看到的一样。然而,加载的 URL 响应返回的是 JSON 字符串而不是 HTML 文档。JSON 被许多 API 用作在网络上传递数据的首选格式。为了使用此响应,必须将原始数据转换为有用的数据结构。在这种情况下,字典将 suffice。如果你之前没有见过或处理过 JSON,那么退一步阅读有关 JSON 数据格式的资料是个好主意,因为本章将继续假设你至少对 JSON 有些许了解。
在 Swift 中使用 JSON
以下代码片段展示了如何将原始数据转换为 JSON 字典。在 Swift 中使用 JSON 有时可能会有些繁琐,但总体来说,它是一个一般良好的体验。让我们看看以下示例:
guard let data = data, let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return }
print(json)
前面的代码片段将 URL 请求返回的原始数据转换为 JSON 对象。print语句打印出可读的响应数据版本,但它还不是可以直接使用的。让我们看看如何访问响应中第一个可用的电影。
如果你查看jsonObject(with:options:)方法返回的对象类型,你会看到它返回Any。这意味着你必须将返回的对象类型转换为可以处理的对象,例如数组或字典。当你检查 API 返回的 JSON 响应时,例如使用print使其在控制台显示,就像你处理苹果主页 HTML 那样,你会注意到有一个键名为results的字典。results对象是一个电影数组。换句话说,它是一个[String: Any]类型的数组,因为每部电影都是一个字典,其中字符串是键,值可以是几种不同类型,例如字符串、整数或布尔值。有了这些信息,你可以访问 JSON 响应中的第一部电影标题,如下面的代码所示:
guard let data = data,
let json = try? JSONSerialization.jsonObject(with: data,
options: []),
let jsonDict = json as? [String: AnyObject],
let resultsArray = jsonDict['results'] as? [[String: Any]]
else { return }
let firstMovie = resultsArray[0]
let movieTitle = firstMovie['title'] as! String print(movieTitle)
使用字典处理 JSON 并不是最佳体验。由于 JSON 对象是AnyObject类型,并且你需要将你想要访问的字典中的每个元素都进行类型转换,因此你需要添加大量的模板代码。
幸运的是,Swift 有更好的方法从 JSON 数据创建对象实例。以下示例展示了如何快速创建一个Movie结构体实例,而无需将 JSON 字典中所有键转换为Movie结构体的正确类型。
首先,让我们定义两个结构体,一个用于Movie本身,另一个用于包含Movie实例数组的响应:
struct MoviesResponse: Codable {
let results: [Movie]
}
struct Movie: Codable {
let id: Int
let title: String
let popularity: Float
}
接下来,你可以使用以下代码片段快速将 URL 请求的原始数据转换为MoviesResponse实例,其中所有电影都转换为Movie结构体实例:
let decoder = JSONDecoder()
guard let data = data,
let movies = try? decoder.decode(MoviesResponse.self, from: data) else { return }
print(movies.results[0].title)
你可能会注意到MoviesResponse和Movie都遵循了Codable协议。Codable协议是在 Swift 4 中引入的,它允许你轻松地编码和解码数据对象。唯一的要求是Codable对象的全部属性都必须遵循Codable协议。许多内置类型,如Array、String、Int、Float和Dictionary都遵循Codable。正因为如此,你可以轻松地将编码后的 JSON 对象转换为包含Movie实例的MoviesResponse实例。
默认情况下,每个属性名称应与其映射到的 JSON 响应键相对应。然而,有时你可能想要自定义这种映射。例如,我们一直在处理响应中的poster_path属性,根据一般的 Swift 属性命名指南,最好将其映射到Movie结构体上的posterPath属性。以下示例显示了如何处理这些情况:
struct Movie: Codable {
enum CodingKeys: String, CodingKey {
case id, title, popularity
case posterPath = 'poster_path'
}
let id: Int
let title: String
let popularity: Float
let posterPath: String?
}
通过指定一个CodingKeys枚举,你可以覆盖 JSON 响应中的键如何映射到你的Codable对象。你必须覆盖所有映射的键,包括你不想更改的键。正如你所看到的,Codable协议提供了处理网络数据的有力工具。自定义键映射使此协议更加强大,因为它允许你按照自己的意愿塑造对象,而不是让 URL 响应为你指定结构。
如果在编码键中你需要应用的唯一转换是将蛇形命名(poster_path)转换为驼峰命名(posterPath),你不需要自己指定编码键。当解码数据时,如果将JSONDecoder对象的keyDecodingStrategy设置为.convertFromSnakeCase,它将自动应用这种类型的转换,如下面的代码所示:
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
通过应用这些代码行,解码器将自动将属性名称如poster_path转换为posterPath语法。尝试在你的沙盒中实现这一点,并从Movie对象中移除CodingKeys以确保你的 JSON 解码仍然有效。
在本节中,我们学习了如何处理 JSON 数据以及如何将其解码为我们自己的实体。现在让我们继续学习如何将获取的数据存储在 Core Data 数据库中。
使用获取的数据更新 Core Data 对象
到目前为止,你存储在 Core Data 中的唯一内容是电影名称。你将通过电影数据库 API 对某个电影名称进行查找来扩展这一功能。获取的信息将用于在 Core Data 数据库中显示和存储电影的流行度评分。
这样的任务一开始看起来很简单;你可以想出一个如以下步骤所示的流程:
-
用户表明他们的最爱电影。
-
获取电影的流行度评分。
-
电影及其评分存储在数据库中。
用户界面随着新电影更新。乍一看,这是一个不错的策略;当你有数据时插入数据。然而,重要的是要考虑 API 调用通常是异步进行的,这样用户界面才能保持响应。更重要的是,如果你的用户没有良好的互联网连接,API 调用可能会非常慢。这意味着如果前面的步骤逐个执行,你将会在界面上看到一些非常明显的延迟。
以下是实现当前功能的更好方法:
-
用户表明他们的最爱电影。
-
用户存储电影。
-
使用新电影更新用户界面。
-
开始获取流行度。
-
更新数据库中的电影。
-
使用流行度更新用户界面。
这种方法稍微复杂一些,但它会给用户带来响应式的体验。用户界面会立即显示新电影,并在获取新数据后自动更新。在你能够获取数据并更新模型之前,必须修改 Core Data 模型以便存储给定电影的流行度评分。
打开 popularity。为此属性选择 Double 类型,因为 popularity 存储为十进制值。你必须确保这个属性是可选的,因为你不能立即提供它的值:

图 9.1 – 将流行度属性添加到电影实体
如果你之前在 iOS 10 发布之前使用过 Core Data,那么你期望在这里读到关于迁移以及如何编排它们的内容。
然而,对于像这样的简单更改,我们不需要手动管理迁移。你所需要做的就是简单地构建并运行你的应用程序来重新生成你的模型定义,对于像我们刚才所做的这种简单更改,Core Data 将会自动为我们管理迁移。
重要提示
如果你想要支持低于 iOS 10 的版本,确保你阅读有关 Core Data 迁移的内容。每次你更新你的模型时,你必须确保你的数据库能够正确地从一种模型版本迁移到另一种模型版本。在开发过程中,这并不是非常重要:当你的模型发生变化时,你只需重新安装应用程序。然而,如果 Core Data 模型与先前模型不兼容,应用程序更新将导致启动时崩溃。
现在,Core Data 模型已经更新,让我们来找出如何实现之前描述的流程。
实现获取逻辑
网络请求的异步特性使得某些任务,比如你即将要实现的任务,相当复杂。通常,当你编写代码时,其执行是非常可预测的。你的应用通常是逐行、顺序执行的,所以任何在上一行之后的行都可以假设上一行已经执行完毕。异步代码不是这样。异步代码被从主线程上移除,并独立于其他代码运行。这意味着你的异步代码可能会与其他代码并行运行。在网络请求的情况下,异步代码可能会在发起请求的函数执行几秒后执行。
这意味着你需要想出一个方法来更新和保存那些在获取评分后立即添加的电影。然而,重要的是你要意识到这并不像最初看起来那么简单。
重要的是,你要意识到你即将查看的代码是在多个线程上执行的。这意味着尽管所有代码片段都在同一个地方定义,但它们并不是按顺序执行的。网络请求的回调是在发起网络请求的代码所在的另一个线程上执行的。你已经了解到 Core Data 不是线程安全的。这意味着你不能在创建它的线程之外安全地访问 Core Data 对象。
如果你感到困惑,那没关系。你现在应该有点困惑。异步编程并不容易,一旦你遇到并发相关的问题(你会的),会让你感到沮丧。无论何时你与回调、闭包和多个线程一起工作,你应该意识到你正在做复杂的工作,而不是简单的工作。
现在你已经了解到异步代码很难,让我们更仔细地看看你即将要实现的功能。是时候开始实现获取电影流行度评分的网络请求了。你将把获取逻辑抽象到一个名为MovieDBHelper的辅助函数中。继续在 Xcode 中创建一个新的Helper文件夹,并向其中添加一个名为MovieDBHelper.swift的新 Swift 文件。
将此逻辑抽象到辅助函数中有多个优点。其中之一是简单性;它将使我们的视图控制器代码保持整洁。另一个优点是灵活性。假设你想结合多个评分网站,或者不同的 API,或者根据添加相同标题到列表中的家庭成员数量来计算电影的流行度;由于所有评分逻辑都在一个地方,因此实现起来会更简单。
将以下骨架实现添加到MovieDBHelper文件中:
struct MovieDBHelper {
typealias MovieDBCallback = (Double?) -> Void
let apiKey = 'YOUR_API_KEY_HERE'
func fetchRating(forMovie movie: String, callback: @escaping
MovieDBCallback) {
}
private func url(forMovie movie: String) -> URL? {
guard let query =
movie.addingPercentEncoding(withAllowedCharacters:
.urlHostAllowed) else { return nil }
var urlString =
'https://api.themoviedb.org/3/search/movie/'
urlString = urlString.appending('?api_key=\(apiKey)')
urlString = urlString.appending('&query=\(query)')
return URL(string: urlString)
}
}
上述代码从一行有趣的语句开始:
typealias MovieDBCallback = (Double?) -> Void
这一行指定了在获取评分时调用的回调闭包所使用的类型。这个回调将接收一个可选的Double作为其参数。如果网络请求因任何原因失败,则Double将为nil。否则,它包含请求创建的电影的评分。
该片段还包含一个fetchRating占位方法,用于执行获取操作;你很快就会实现这个方法。最后,有一个url(forMovie movie: String)方法来构建 URL。这个方法之所以是私有的,是因为它只应该在辅助结构内部使用。注意,电影被转换为一个百分编码字符串。这是必需的,因为如果你的用户添加了一个包含空格的电影,如果空格没有被正确编码,你最终会得到一个无效的 URL。
在你实现fetchRating(forMovie:callback)之前,向Helper文件夹中添加一个名为MovieDBResponse.swift的新文件。这个文件将用于定义一个结构体,它代表我们从api.themoviedb.org期望接收到的响应。将以下实现添加到这个文件中:
struct MovieDBLookupResponse: Codable {
struct MovieDBMovie: Codable {
let popularity: Double?
}
let results: [MovieDBMovie]
}
前面的代码使用嵌套结构来表示响应中的电影对象。这与你在本章开头“使用 URLSession 从网络获取数据”部分中看到的游乐场示例类似。以这种方式结构化响应使得这个辅助函数的意图非常明显,这通常使得代码更容易推理。有了这个结构,将MovieDBHelper中的fetchRating(forMovie:callback)的实现替换为以下内容:
func fetchRating(forMovie movie: String, callback: @escaping MovieDBCallback) {
guard let searchUrl = url(forMovie: movie) else {
callback(nil)
return
}
let task = URLSession.shared.dataTask(with: searchUrl) {
data, response, error in
var rating: Double? = nil
defer {
callback(rating)
}
let decoder = JSONDecoder()
guard error == nil,
let data = data,
let lookupResponse = try?
decoder.decode(MovieDBLookupResponse.self, from:
data),
let popularity =
lookupResponse.results.first?.popularity
else { return }
rating = popularity
}
task.resume()
}
这个实现看起来与你在游乐场中早期实验的非常相似。使用 URL 构建方法来创建一个有效的 URL。如果这失败了,尝试请求电影的评分就没有意义了,所以回调函数会使用一个nil参数被调用。这将通知调用此方法的用户执行已完成,但没有检索到结果。
接下来,创建了一个新的数据任务,并在这个任务上调用resume()来启动它。然而,这个数据任务的回调调用有一个有趣的方面。让我们看看以下几行代码:
var rating: Double? = nil
defer {
callback(rating)
}
在这里创建了一个rating optional Double,并给它一个初始值nil。然后有一个defer块。defer块中的代码在退出作用域之前被调用。换句话说,它是在代码从函数或闭包返回之前执行的。
由于这个defer块是在数据任务回调内部定义的,因此fetchRating(forMovie:callback:)方法的回调总是在数据任务回调退出之前被调用。这很方便,因为你只需要将评分的值设置为double,而且你不需要手动调用每个可能的退出范围的回调。当因为未满足的要求而返回时,这也适用。例如,如果在调用 API 时发生错误,你不需要调用回调。你可以简单地从闭包中返回,回调会自动调用。如果你临时实例化或配置对象,并且希望在方法、函数或闭包完成后执行一些清理操作,这种策略也可以应用。
代码的其余部分应该相当直接,因为其中大部分几乎与在游乐场中使用的代码相同。现在你已经掌握了网络逻辑,让我们看看如何实际上更新movie对象以包含流行度评分。
更新电影流行度评分
要更新movie对象,你需要实现前面概述的方法的最终步骤。你需要异步从电影数据库中获取评分,然后使用该评分来更新电影。以下代码应添加到MoviesViewController.swift中的saveMovie(withName name: String)方法,在familyMember.movies = NSSet(set: newFavorites行之后:
let helper = MovieDBHelper()
helper.fetchRating(forMovie: name) { rating in
guard let rating = rating else { return }
moc.persist {
movie.popularity = rating
}
}
你可以看到,辅助抽象为视图控制器提供了一个很好的接口。你可以简单地使用辅助工具,并给它一个电影,通过回调获取评分,然后你就准备好了。像这样抽象代码可以使你长期维护代码变得更加有趣。
在前面的代码片段中最令人惊讶的是,在helper回调内部再次调用了moc.persist。这是因为这个回调实际上是在初始persist完成很长时间后才执行的。实际上,这个回调甚至不是在它周围的代码所在的同一个线程上执行的。
要查看你的代码在没有正确持久化模型时如何失败,尝试将评分检索回调中的moc.persist块替换为以下代码:
movie.popularity = rating
do {
try moc.save()
} catch {
moc.rollback()
}
如果您现在添加一部新电影,评分仍然会被获取。然而,当您重新加载您的表格视图时,您会突然遇到问题。这是因为托管对象上下文是在后台线程上保存的。这意味着通知表格视图关于更新的通知也是在后台线程上发送的。您可以通过像以前一样将 reloadData() 调用推送到主线程来解决这个问题,但在这个情况下,这样做只会使问题变得更糟。您的应用可能在一开始运行良好,但一旦您的应用变得复杂,在多个线程中使用相同的托管对象上下文几乎肯定会导致崩溃。因此,始终确保您通过使用诸如我们为这个应用实现的 persist 方法之类的构造来在正确的线程上访问托管对象及其上下文非常重要。
现在您已经看到了所有相关的代码,让我们以更直观的方式看看所有这些关于线程的讨论意味着什么。
可视化多个线程
以下图表将帮助您理解多个线程:

图 9.2 – 线程图
当调用 saveMovie(withName:) 时,执行仍在主线程上。持久化块被打开,电影被创建,其名称被设置,创建了一个辅助对象,然后在该辅助对象上调用 fetchRating(forMovie:callback:)。这个调用本身仍在主线程上。然而,数据的获取被推送到后台线程。这在您在游乐场中尝试获取数据时已经讨论过了。
由 dataTask 触发的回调在任务本身所在的同一个后台线程上执行。代码将处理 JSON,最后调用传递给 fetchRating(forMovie:callback:) 的回调。这个回调内部的代码也在后台线程上执行。
您可以看到,在更新流程中设置的影片评分步骤被推回到主线程。这是因为您添加到托管对象上下文扩展中的 persist 方法。上下文内部使用 perform 方法来确保我们在 persist 块中执行的任何代码都在托管对象上下文所在的线程上执行。此外,由于托管对象上下文是在主线程上创建的,因此电影评分将在主线程上设置。
重要提示
如果您没有在托管对象所属的线程上设置电影评分,您将得到错误和未定义的行为。始终确保您在托管对象上下文所在的线程上操作 Core Data 对象。
线程是一个复杂的话题,但对于构建响应式应用程序来说至关重要。网络逻辑是为什么多线程很重要的一个很好的例子。如果我们不在单独的线程上执行网络操作,界面将在请求期间无响应。如果你在应用程序中还有其他可能需要较长时间的操作,考虑将它们移动到后台线程,这样它们就不会阻塞用户界面。
所有代码都已就绪,你对多线程和如何在多线程环境中使用回调有了更好的理解。然而,如果你构建并运行你的应用程序并添加一部新电影,评分尚未显示。
以下是发生这种情况的三个原因:
-
显示电影的表格视图单元格尚未更新。
-
网络请求因 应用传输安全 而失败。
-
电影对象的更新尚未被观察。
让我们按顺序解决这些问题,首先是表格视图单元格。
将评分添加到电影单元格
目前,电影表格视图显示带有标题的单元格。UITableViewCell 有一个内置选项来显示单元格的标题和副标题。
打开 Main.storyboard 并选择电影的原型单元格。在表格视图单元格的 detailTextLabel 上。这是我们显示电影评分的地方。
在 MoviesViewController 中,在设置单元格标题后,向 tableView(_:cellForRow:atIndexPath:) 添加以下行:
cell.detailTextLabel?.text = 'Rating: \(movie.popularity)'
这行代码将电影的受欢迎度评分放入字符串中,并将其分配为详细文本标签的文本。
如果你现在构建并运行你的应用程序,所有电影都应该有一个受欢迎度为 0.0。让我们通过解决网络问题来解决这个问题。
理解应用传输安全
随着 iOS 9 的推出,苹果引入了 应用传输安全(ATS)。ATS 通过禁止使用非 HTTPS 资源来使应用程序更安全、更可靠。这是一个很好的安全特性,因为它保护了用户免受在常规 HTTP 连接上执行的各种攻击。
如果你仔细关注用于获取电影的 URL,你可能已经注意到该 URL 应该是一个 HTTPS 资源,因此加载这个 URL 应该是没问题的。然而,网络请求仍然被 ATS 阻止。为什么?
好吧,苹果有严格的要求。在撰写本书时,电影数据库使用证书的 SHA-1 签名,而苹果要求 SHA-2。因此,你现在需要绕过 ATS。尽管如此,您的用户仍然应该是安全的,因为电影数据库支持 HTTPS,只是苹果认为它还不够安全。
要做到这一点,打开Info.plist文件,并向此字典添加一个名为themoviedb.org的新字典键,并添加两个布尔值到这个字典中。这两个值都应该设置为YES,并且它们应该命名为NSIncludesSubdomains和NSTemporaryExceptionAllowsInsecureHTTPLoads。参考以下截图以确保你已经正确设置:
![图 9.3 – 应用传输安全设置
![图片 B14717_09_03.jpg]
![图 9.3 – 应用传输安全设置
如果你现在为家庭成员添加一部新电影,还没有任何更新。然而,如果你回到家庭概览然后回到家庭成员,你会看到最新电影的评分已更新(确保你选择一个标题在themoviedb.org上存在的电影)。太棒了!现在,你需要确保我们观察管理对象上下文以更新电影,这样如果它们的评分发生变化,它们就会被重新加载。
观察电影评分的变化
你已经正在观察管理对象上下文的变化,但只有在当前页面上显示的家庭成员已更新时才会处理它们。这个逻辑应该被替换,以便在家庭成员或他们的喜欢的电影发生变化时重新加载表格视图。在MoviesViewController.swift中更新managedObjectContextDidChange(_:)方法如下:
@objc func managedObjectContextDidChange(notification: NSNotification) {
guard let userInfo = notification.userInfo else { return }
if let updatedObjects = userInfo[NSUpdatedObjectsKey] as?
Set<FamilyMember>,
let familyMember = self.familyMember,
updatedObjects.contains(familyMember) {
tableView.reloadData()
}
if let updatedObjects = userInfo[NSUpdatedObjectsKey] as?
Set<Movie> {
for object in updatedObjects {
if object.familyMember == familyMember {
tableView.reloadData()
break
}
}
}
}
重要提示
观察家庭成员的逻辑没有改变;其条件只是从guard语句移动到了if语句。为电影添加了一个额外的if语句。如果更新的对象集是电影列表,我们遍历电影并检查是否有电影将当前家庭成员作为其家庭成员。如果是这样,表格会立即刷新,并且退出循环。
在第二个if语句中设置循环的方式很重要,因为你可能刚刚为家庭成员 A 添加了一部电影,然后切换到家庭成员 B,而家庭成员 A 的新电影仍在加载其评分。提前退出循环确保你不会遍历比所需更多的对象。你只想在当前家庭成员的喜欢的电影更新时刷新表格视图。
好的,现在构建并运行你的应用来试驾一下!你会注意到现在一切按你的预期工作。添加新电影会触发网络请求;一旦完成,UI 就会更新为新评分。有时,这个更新会立即完成,但如果你有慢速的互联网连接,可能需要一段时间。太棒了!这个功能就到这里。
摘要
本章主要讲述向现有应用中添加一个微小、简单的功能。我们添加了从 API 加载真实数据的能力。你看到苹果公司通过URLSession和数据任务使网络变得相当简单。你还了解到这个类抽象了一些关于多线程的非常复杂的行为,因此当从网络加载数据时,你的应用仍然保持响应。接下来,你实现了一个用于网络的helper结构体,并更新了 Core Data 模型以存储电影的评分。一旦完成所有这些,你最终可以看到多线程在这个应用中的工作方式。但这还不是我们需要做的全部。你学习了 ATS 及其如何保护你的用户安全。你还了解到有时你需要绕过 ATS,我们介绍了如何实现这一点。
尽管这个功能本身并不复杂,但涉及的概念和理论可能相当令人难以承受。你突然不得不处理将来将异步执行代码。不仅如此,代码甚至使用了多个线程以确保其性能最优。多线程和异步编程的概念可以说是编程中更复杂的两个方面之一。大量练习它们,并试图记住,每次你传递闭包时,你可能会编写一些将在不同线程上执行的异步代码。
现在电影列表已经通过网络数据更新,让我们在下一章中更进一步。你将学习如何通过使用 CoreML 和 Vision Framework 功能使你的应用变得更智能。
第十章:第十章:使用 Core ML 制作更智能的应用
在过去的几年里,机器学习越来越受欢迎。然而,在移动应用中实现它一直不容易——直到苹果在 iOS 11 中发布了 Core ML 框架。Core ML 是苹果针对公司开发者在为 iOS 实现机器学习时遇到的所有问题而提供的解决方案。因此,Core ML 应该为处理复杂的机器学习模型提供最快、最有效的实现,通过尽可能简单和灵活的接口。
在本章中,你将学习机器学习是什么,它是如何工作的,以及你如何在你的应用中使用训练好的机器学习模型。你还将学习如何使用苹果的 Vision 框架分析图像,并了解它如何与 Core ML 集成以实现强大的图像检测。最后,你将学习如何使用新的 Create ML 工具来训练你的模型,如何将你的模型部署到云端,以及如何加密它们以确保安全。你将在以下部分学习这些主题:
-
理解机器学习和 Core ML
-
结合 Core ML 和计算机视觉
-
使用 Create ML 训练自己的模型
-
使用模型部署远程更新模型
-
加密 Core ML 模型
到本章结束时,你将能够训练和使用你的 Core ML 模型,使你构建的应用更加智能和引人入胜。
技术要求
本章的代码包包括三个起始项目,分别称为 TextAnalyzer、ImageAnalyzer 和 TextAnalyzerCloud。它还包括一个名为 Create ML.playground 的游乐场文件。
本章的代码可以在以下链接找到:github.com/PacktPublishing/Mastering-iOS-14-Programming-4th-Edition/tree/master/Chapter%2010%20-%20Core%20ML.
理解机器学习和 Core ML
机器学习和 Core ML 密不可分,但它们并不完全相同。机器学习的一切都是关于教会机器如何识别、分析或应用某些事物。所有这些教学的结果是一个训练好的模型,可以被 Core ML 使用来分析特定的输入,并根据训练阶段建立的规则产生输出。
在学习 Core ML 之前,了解一些机器学习的知识是很好的,这样你可以熟悉一些使用的术语,并了解机器学习是什么。
理解机器学习是什么
许多开发者在职业生涯的某个阶段都会听说机器学习、深度学习或神经网络。你可能已经听说过这些话题。如果你已经了解,你会知道机器学习是一个复杂的领域,需要特定的领域知识。然而,机器学习正变得越来越突出和受欢迎,它被用于改进许多不同类型的应用。
例如,机器学习可以用来根据用户在音乐库中已有的音乐预测他们可能在音乐应用中喜欢看到的内容,或者自动标记照片中的面孔,将它们与用户联系人列表中的人联系起来。它甚至可以根据历史数据预测特定产品或服务的成本。虽然这可能听起来像魔法,但创建类似这些机器学习体验的流程大致可以分为两个阶段:
-
训练模型
-
使用推理从模型中获取结果
必须收集大量高质量的数据来执行第一步。如果你打算训练一个应该能够识别猫的模型,你需要大量猫的图片。你还必须收集不包含猫的图片。然后,每张图片都必须适当标记,以表明图片是否包含猫。
如果你的数据集只包含朝向摄像头的猫的图片,那么你的模型可能无法从侧面识别猫。如果你的数据集确实包含来自许多不同角度的猫,但你只收集了单一品种或背景为纯白色的图片,那么你的模型可能仍然很难识别所有猫。获得高质量的训练数据并不容易,但它是至关重要的。
在模型的训练阶段,你必须提供一组尽可能高质量的输入。最小的错误可能会导致你的整个数据集变得毫无价值。收集大量高质量数据以训练模型是一项繁琐的任务。这也需要花费大量时间。某些复杂的模型可能需要几个小时来处理所有数据并自行训练。
训练好的模型有多种类型。每种类型的模型都适用于不同类型的任务。例如,如果你正在开发一个可以分类特定电子邮件消息是否为垃圾邮件的模型,你的模型可能是一个所谓的支持向量机。如果你正在训练一个在图片中识别猫的模型,你很可能在训练一个神经网络。
每个模型都有其优缺点,每个模型都是不同地创建和使用的。理解所有这些不同的模型、它们的含义以及如何训练它们是非常困难的,你可能会为每种类型的模型写一本书。
在一定程度上,这就是为什么 Core ML 如此出色的原因。Core ML 允许你在自己的应用中使用预训练的模型。在此基础上,Core ML 标准化了你在自己代码中使用的接口。这意味着你可以使用复杂的模型,甚至都不必意识到这一点。让我们更深入地了解 Core ML,好吗?
理解 Core ML
由于机器学习的复杂性和使用训练模型,苹果公司构建了 Core ML,以便将集成训练模型的过程尽可能简化。除此之外,另一个目标是确保你使用 Core ML 实现机器学习时,你的实现尽可能快且节能。由于苹果公司已经将机器学习增强到 iOS 中有几年时间了,他们在应用中实现复杂模型方面积累了丰富的经验。
如果你曾经研究过机器学习,你可能遇到过基于云的解决方案。通常情况下,你会发送一些数据到基于云的解决方案,结果作为对请求的响应返回。Core ML 非常不同,因为训练模型存在于设备上,而不是在云中。这意味着你的用户数据永远不需要离开设备,这对用户的隐私非常有利。此外,将训练模型放在设备上意味着使用 Core ML 不需要互联网连接,这节省了时间和宝贵的数据。而且由于没有潜在的响应延迟瓶颈,Core ML 能够实时计算结果。
在上一节中,你了解到存在几种类型的训练模型。每种类型的模型使用方式略有不同,所以如果你要在应用中手动实现机器学习,你将不得不为应用使用的每种不同模型编写不同的包装器。Core ML 确保你可以在应用中无需意识到这一点的情况下使用每种类型的模型;它们都共享相同的编程接口。Core ML 模型是领域无关的。
要实现领域无关性,你使用 Core ML 的所有训练模型都必须采用特定的格式。由于机器学习已经有一个充满活力的社区和几种流行的格式,苹果公司确保最流行的模型可以轻松转换为苹果自己的 .mlmodel 格式。让我们看看如何获取用于你自己在应用中使用的 .mlmodel 文件。
获取 Core ML 模型
获取用于你应用中的模型有两种方式。最简单的方式是找到一个现有的 .mlmodel 文件。你可以在苹果的机器学习网站上找到几个现成的 .mlmodel 文件,网址为 developer.apple.com/machine-learning/。这个网站包含了几种最受欢迎的模型。在撰写本文时,这些模型大多数都专注于识别图像中的主要对象,而且你很可能对你的应用有不同的需求。
如果您正在寻找 Apple 尚未转换的内容,您可以在网上几个地方尝试寻找预转换的 .mlmodel 文件,或者您可以将您在网上找到的现有模型进行转换。Apple 为几种流行的机器学习格式创建了转换器,例如 .mlmodel 文件是用 Python 编写的,并且作为 Xcode 的一部分提供。如果您的需求不符合 Apple 提供的转换器,您可以扩展 toolchain,因为转换工具是开源的。这意味着每个人都可以添加自己的转换器或调整现有的转换器。
使用 Apple 的工具转换 Core ML 模型通常只需几行 Python 代码。编写一个良好的转换脚本通常需要一点机器学习领域的专业知识,因为您需要确保转换后的模型与原始模型一样有效。
一旦您为您的应用程序获得了 Core ML 模型,无论是通过转换还是找到现有的模型,您就可以将其添加到项目中并开始使用它。让我们看看如何进行下一步。
使用 Core ML 模型
应用程序可以利用 Core ML 实现许多不同的目的。其中之一是文本分析。您可以使用训练好的模型来检测特定文本是正面还是负面情绪。要实现这样的功能,您可以使用训练和转换后的 Core ML 模型。
本章的代码包包括一个名为 @IBAction 的项目,名为 analyze()。项目文件夹还包含一个名为 SentimentPolarity.mlmodel 的文件。此文件是一个分析与特定文本相关的情绪的训练好的 Core ML 模型。将此文件拖入 Xcode 以将 Core ML 模型添加到您的项目中。
在将模型添加到您的项目后,您可以在项目导航器中点击它,以查看有关模型的更多信息,如下面的截图所示:

图 10.1 – 模型元数据
您可以看到,这个模型是由Vadym Markov在MIT许可下提供的。如果您点击预测选项卡(见前面的截图),您可以找到这个模型可以与之配合的输入和输出:

图 10.2 – 模型的输入和输出
在这个例子中,您可以看到 [String: Double] 类型。这意味着我们应该向这个模型提供一个词频字典。如果您将此模型添加到 Xcode 中,中心部分列出的 Model Class 可能会通知您该模型尚未属于任何目标。如果是这种情况,您可以像之前那样修复它,通过在窗口右侧的实用工具侧边栏中将此模型添加到您的应用程序目标中。
既然你的模型已经实现,是时候让它试运行了。首先,实现一个从任何给定的字符串中提取词数的方法。你可以使用来自新NaturalLanguage框架的NLTokenizer对象来实现这一点。
NLTokenizer是一个用于将字符串拆分为单词、句子、段落甚至整个文档的文本分析类。在这个例子中,分词器被设置为检测单个单词。以下是如何实现词数方法的示例。
按照以下方式将导入添加到ViewController.swift文件中:
import NaturalLanguage
现在将以下方法添加到同一文件中:
func getWordCounts(from string: String) -> [String: Double] {
let tokenizer = NLTokenizer(unit: .word)
tokenizer.string = string
var wordCount = [String: Double]()
tokenizer.enumerateTokens(in:
string.startIndex..<string.endIndex) { range, attributes in
let word = String(string[range])
wordCount[word] = (wordCount[word] ?? 0) + 1
return true
}
return wordCount
}
之前的代码遍历了分词器识别的所有单词,并将它们存储在[String: Double]类型的字典中。你可能想知道为什么词数使用Double类型而不是Int类型,因为词数不需要处理小数。这是真的,但是SentimentPolarity模型要求其输入为[String: Double]类型的字典,所以你必须相应地准备数据。
现在你有了为SentimentPolarity模型准备输入数据的代码,让我们看看如何使用这个模型来分析用户的输入。为analyze()方法添加以下实现:
@IBAction func analyze() {
let wordCount = getWordCounts(from: textView.text)
let model = try? SentimentPolarity(configuration: .init())
guard let prediction = try? model?.prediction(input:
wordCount) else { return }
let alert = UIAlertController(title: nil, message: "Your
text is rated: \(prediction.classLabel)", preferredStyle:
.alert)
let okayAction = UIAlertAction(title: "Okay", style:
.default, handler: nil)
alert.addAction(okayAction)
present(alert, animated: true, completion: nil)
}
你可能会惊讶这个方法如此简短,这正是 Core ML 的简单之处!首先,我们使用我们之前实现的方法检索wordCount。然后,创建一个 Core ML 模型的实例。当你将SentimentPolarity模型添加到应用目标时,Xcode 生成了一个类接口,抽象了涉及模型的所有复杂性。因为模型现在是一个简单的类,你可以通过在模型实例上调用prediction(input:)来获取文本情感预测。
prediction方法返回一个对象,其中包含在classLabel属性中的处理后的预测,以及所有可用预测的概述以及模型对每个选项的确定程度在classProbability属性中。如果你想对用户更透明地展示模型建议的不同选项以及模型对这些选项的确定程度,可以使用这个属性。
让我们看看几个示例来演示它是如何工作的。首先,启动应用。现在在文本区域中写下我喜欢彩虹,然后按下我在多云的日子里感到悲伤。现在的结果是你的文本评分为:负。这次,你的句子情感是负面的!你可以尝试自己的想法来查看模型在不同场景下的表现。
在本章的最后部分,你将学习如何使用Create ML来训练你自己的自然语言模型,以分析使用与你的应用相关的特定领域语言的文本。
使用 Core ML 进行文本分析相当简单。现在让我们看看如何将计算机视觉与 Core ML 结合使用,以确定特定图片中存在的对象类型。
结合 Core ML 和计算机视觉
当你开发一个处理照片或实时摄像头视频的应用时,你可能想使用计算机视觉做一些事情。例如,你可能想在图像中检测面部。或者,你可能想识别照片中的某些矩形区域,如交通标志。你也可能正在寻找更复杂的事情,比如检测图片中的主导对象。
要在你的应用中使用计算机视觉,苹果创建了 Vision 框架。你可以将 Vision 和 Core ML 结合起来执行一些相当复杂的图像识别。在你实现一个使用主导对象识别的示例应用之前,让我们快速了解一下 Vision 框架,这样你就知道它能够做什么,以及你可能在什么时候想使用它。
理解 Vision 框架
Vision 框架能够执行许多围绕计算机视觉的任务。它基于几个强大的深度学习技术,能够实现最先进的面部识别、文本识别、条形码检测等。
当你使用 Vision 进行面部识别时,你获得的信息远不止图像中面部位置那么简单。该框架可以识别多个面部特征,如眼睛、鼻子或嘴巴。所有这一切都得益于苹果在幕后对深度学习的广泛使用。
对于大多数任务,使用 Vision 包括以下三个阶段:
-
你创建一个请求来指定你想要的内容;例如,一个用于检测面部特征的
VNDetectFaceLandmarksRequest请求。 -
你设置了一个可以分析图像的处理程序。
-
结果观察包含了你需要的信息。
以下代码示例说明了你如何在图像中找到面部特征:
let handler = VNImageRequestHandler(cgImage: image, options: [:])
let request = VNDetectFaceLandmarksRequest(completionHandler: { request, error in
guard let results = request.results as? [VNFaceObservation]
else { return }
for result in results where result.landmarks != nil {
let landmarks = result.landmarks!
if let faceContour = landmarks.faceContour {
print(faceContour.normalizedPoints)
}
if let leftEye = landmarks.leftEye {
print(leftEye.normalizedPoints)
}
// etc
}}
)
try? handler.perform([request])
对于像检测面部轮廓或眼睛的确切位置这样复杂的事情,代码相当简单。你设置一个 handler 和一个 request。接下来,handler 被要求执行一个或多个请求。这意味着你可以在单个图像上运行多个请求。
除了启用此类计算机视觉任务外,Vision 框架还与 Core ML 紧密集成。让我们通过向你在开发的增强现实画廊应用中添加图像分类器来了解一下这种集成有多紧密!
实现图像分类器
本节代码包包含一个名为 ImageAnalyzer 的应用。这个应用使用图像选择器允许用户从他们的照片库中选择一张图片,作为你将要实现的图像分类器的输入。打开项目并探索一下,看看它做什么以及它是如何工作的。如果你想跟随本节的其余部分,请使用启动项目。
要添加一个图像分类器,你需要有一个能够对图像进行分类的 Core ML 模型。在苹果的机器学习网站上 (developer.apple.com/machine-learning/build-run-models/),有多个可用的模型可以进行图像分类。你可以使用的一个优秀的轻量级模型是 MobileNetV2 模型;请前往机器学习页面下载它。一旦下载了模型,将其拖入 Xcode 以将其添加到 ImageAnalyzer 项目中。请确保将其添加到你的应用程序目标中,以便 Xcode 可以为该模型生成类接口。
在将模型添加到 Xcode 后,你可以打开它来检查 模型预测 选项卡。参数告诉你模型将期望和提供不同类型的输入和输出。在 MobileNetV2 的情况下,输入应该是一个宽度为 224 点和高度为 224 点的图像,如下面的截图所示:

图 10.3 – 模型的输入和输出
在生成模型后,使用该模型的代码与之前使用 Vision 检测面部特征的代码非常相似。最显著的区别是使用的请求类型是一个特殊的 VNCore MLRequest。这种类型的请求除了需要一个完成处理程序外,还包含你想要使用的 Core ML 模型。
当结合 Core ML 和 Vision 时,Vision 将负责图像缩放并将图像转换为与 Core ML 模型兼容的类型。你应该确保输入图像具有正确的方向。如果你的图像以意外的方向旋转,Core ML 可能无法正确分析它。
首先,让我们导入 Vision 框架。在 ImageAnalyzer 项目的 ViewController 类顶部添加此语句:
import Vision
现在,将以下 analyzeImage(_:) 实现添加到 ViewController 类中:
func analyzeImage(_ image: UIImage) {
guard
let cgImage = image.cgImage,
let classifier = try? VNCore MLModel(for:
MobileNetV2().model)
else { return }
let request = VNCore MLRequest(model: classifier,
completionHandler: { [weak self] request, error in
guard
let classifications = request.results as?
[VNClassificationObservation],
let prediction = classifications.first
else { return }
DispatchQueue.main.async {
self?.objectDescription.text = "\(prediction.identifier)
(\(round(prediction.confidence * 100))% confidence)"
}
})
let handler = VNImageRequestHandler(cgImage: cgImage,
options: [:])
try? handler.perform([request])
}
之前的方法将 UIImage 转换为 CGImage。同时,基于 MobileNetV2 模型创建了一个 VNCore MLModel。这个特定的模型类封装了 Core ML 模型,因此它可以与 Vision 无缝工作。请求与之前看到的请求非常相似。在 completionHandler 中,提取并显示给用户的结果数组和图像分类的第一预测。分类器做出的每个预测都将有一个存储在标识符中的标签和一个存储在 confidence 属性中的介于 0 和 1 之间的置信度评分。请注意,描述标签的值是在主线程上设置的,以避免崩溃。
您已经实现了两种不同类型的 Core ML 模型,这些模型是为了通用目的而训练的。有时,这些模型可能不足以满足您的需求。例如,看看以下截图,其中机器学习模型仅以 32% 的置信度标记了一个特定的风景:

图 10.4 – 照片分析结果
在下一节中,您将学习如何使用 Create ML 训练针对您和您的应用程序特定目的的模型。
使用 Create ML 训练自己的模型
作为 Xcode 10 和苹果公司的 macOS 版本 Mojave 的一部分,他们提供了一款工具,您可以使用它通过向现有模型添加特殊化来训练自己的机器学习模型。这意味着您可以为将某些文本分类到您定义的类别中的自然语言模型进行训练。或者,您可以为识别文本中特定于您应用程序领域的某些产品名称或术语的模型进行训练。
如果您正在构建新闻应用程序,您可能想训练一个 Core ML 模型,该模型可以自动对应用程序中的文章进行分类。然后,您可以使用此模型跟踪用户阅读的文章,并在应用程序的专用页面上展示最有可能符合他们兴趣的文章。
在本节中,您将学习如何训练自然语言模型,以及您如何根据 Vision 框架训练图像识别模型。在这样做的时候,您会发现,当您想要训练机器学习模型时,创建一个大型且优化的训练集至关重要。
在本章的代码包中,您将找到一个名为 Create ML 的游乐场。这个游乐场包含了用于训练自然语言模型的所有资源。
训练自然语言模型
自然语言框架具有分析文本的出色功能。结合机器学习模型的力量,您可以对文本执行一些强大的操作。苹果公司投入了大量时间,使用大量数据训练了几个模型,以确保自然语言框架能够检测名称、地点等。
然而,有时您可能想添加自己的分析工具。为了便于这样做,自然语言框架与 Core ML 和苹果公司的新 Create ML 框架配合得很好。使用 Create ML,您可以轻松快速地创建自己的机器学习模型,并将其直接用于您的应用程序。
您可以使用多种不同的训练方式来训练自然语言模型。在本节中,您将了解两种不同的模型:
-
文本分类器
-
文本标注器
文本分类器将使用标签对特定的文本进行分类。这与您在 TextAnalyzer 示例应用程序中实现的情感分析类似。您的训练数据中的一个条目示例如下:
{
"text": "We took an exclusive ride in a flying car",
"label": "Tech"
}
这是一个属于标签为Tech类别的新闻文章标题样本。当你向模型提供大量这样的样本时,你可能会得到一个能够根据文章标题为新闻文章分配标签的分类器。当然,这假设标题足够具体并且包含足够的信息来正确训练分类器。实际上,你会发现像这样的短句并不会构成最好的模型。示例 Playground 中包含一个包含训练数据的 JSON 文件,试图将新闻文章分为政治和科技两个类别。让我们看看模型是如何训练的,这样你就可以亲自看看模型的准确性如何。
下面的代码训练并存储自定义 Core ML 模型。在 playground 文件中打开Labeller,检查代码:
import Create ML
import Foundation
let trainingData = try! MLDataTable(contentsOf: Bundle.main.url(forResource: "texts", withExtension: "json")!)
let model = try! MLTextClassifier(trainingData: trainingData, textColumn: "text", labelColumn: "label")
try! model.write(to: URL(fileURLWithPath: "/Users/marioeguiluz/Desktop/TextClassifier.mlmodel"))
let techHeadline = try! model.prediction(from: "Snap users drop for first time, but revenue climbs")
let politicsHeadline = try! model.prediction(from: "President Donald Trump is approving a new law")
训练整个模型只需要几行代码。你只需要获取你的训练数据,创建分类器,并将其保存在你的机器上的某个位置。你甚至可以在 playground 内部进行一些快速测试,看看你的模型是否工作良好。
注意,前面的代码使用了try!语句。这样做是为了使代码示例简短简单。在你的应用中,你应该始终努力进行适当的错误处理,以避免意外的崩溃。
传递给URL(fileURLWithPath:)初始化器的字符串表示你的模型将被存储的位置。请确保在这里指定完整路径,例如,使用/Users/yourUser/Desktop/TextClassifier.mlmodel,而不是~/Desktop/TextClassifier.mlmodel。请确保用你的用户名或文件夹替换yourUser。
下面的代码测试了两个不同的标题,看看模型是否正确地标记了它们:
let techHeadline = try! model.prediction(from: "Snap users drop for first time, but revenue climbs")
let politicsHeadline = try! model.prediction(from: "President Donald Trump is approving a new law")
如果你对自己的模型结果满意,你可以从保存模型的地方获取训练好的模型,并立即将其添加到你的 Xcode 项目中。从那里,你可以像使用任何其他模型一样使用该模型。
让我们看看自然语言框架中模型的另一个示例。在这种情况下,模型应该为文本中的每个单词标记标签,以将其分类为某种类型的单词。例如,你可以训练模型来识别某些品牌名称、产品名称或其他对你应用有特殊意义的单词。以下是一些你可以用来训练此类模型的训练数据示例:
{
"tokens": ["Apple", "announced", "iOS 12", "and", "Xcode
10", "at", "WWDC 2018"],
"labels": ["COMPANY", "NONE", "OPERATING_SYSTEM", "NONE",
"SOFTWARE", "NONE", "EVENT"]
}
通过收集包含你想要标记的单词的大量样本,你的模型不仅能够根据单词本身匹配标签,甚至可以根据周围的单词匹配标签。本质上,模型将了解每个单词被使用的上下文,然后确定正确的标签。一旦你收集了足够的样本数据,你就可以以类似分类器的方式训练模型:
let labelTrainingData = try! MLDataTable(contentsOf: Bundle.main.url(forResource: "labels", withExtension: "json")!)
let model = try! MLWordTagger(trainingData: labelTrainingData, tokenColumn: "tokens", labelColumn: "labels")
try! model.write(to: URL(fileURLWithPath: "/Users/marioeguiluz/Desktop/TextTagger.mlmodel"))
训练模型所需的代码量并没有变化。唯一的不同是,之前的模型基于 MLTextClassifier 类,而当前的模型基于 MLWordTagger。再次强调,你可以立即使用训练好的模型进行一些预测,然后你可以使用这些预测来验证模型是否被正确训练。提供良好的数据和经常测试是构建优秀的 Core ML 模型的关键。
除了文本分析模型之外,Create ML 还可以帮助你训练自己的图像识别模型。让我们看看这是如何工作的。
训练视觉模型
在 ImageAnalyzer 示例应用中,你看到选择一张特定车型的图片会被归类为跑车,并且置信度得分相当低。你可以训练自己的视觉模型,专门用于识别某些车型。
为图像分类器收集良好的训练数据很困难,因为你必须确保从所有侧面和许多不同的环境中收集你主题的许多图片。例如,如果你的所有汽车图像都显示汽车靠近树木或在路上,模型最终可能会将任何旁边有树木或道路的物体分类为汽车。获得完美训练集的唯一方法是实验、调整和测试。
训练视觉模型的工作方式与训练自然语言模型略有不同。你不能使用 JSON 文件来将测试数据喂给分类器。因此,相反,你应该创建包含你的图像的文件夹,其中文件夹名称是你想要应用给该文件夹内每张图像的标签。以下截图是一个包含两种标签的训练集示例:

图 10.5 – 训练集图像
一旦你收集了你的训练数据集,你可以在电脑上的任何位置存储它——例如,在桌面上。然后,你将按照以下方式将你的训练数据路径传递给你的模型训练代码:
import Create ML
import Foundation
let dataUrl = URL(fileURLWithPath: "/path/to/trainingdata")
let source = MLImageClassifier.DataSource.labeledDirectories(at: dataUrl)
let classifier = try! MLImageClassifier(trainingData: source) try! classifier.write(toFile: "/Users/marioeguiluz/Desktop/CarClassifier.mlmodel")
再次强调,你只需要几行代码就可以训练一个模型。这就是 Create ML 强大的地方。如果你想的话,可以快速测试你的图像分类器,只需将 .mlmodel 文件放入之前使用的 MobileNetV2 分类器中。
除了简单的模型训练方法之外,你还可以向不同的 Create ML 分类器传递某些参数。如果你在正确训练模型时遇到困难,你可以调整 Create ML 使用的某些参数。例如,你可以对你的训练集应用更多的迭代,这样模型就能对训练数据有更深入的理解。
如本章之前所述,机器学习是一个可以涵盖几本书的主题,尽管 Create ML 使模型训练变得简单直接,但如果没有任何先前的机器学习经验,要训练一个健壮的模型并不容易。
现在您已经学会了如何使用自己的训练数据,在下一节中,我们将学习如何从云中更新您的模型,而无需更新应用本身。
使用模型部署远程更新模型
iOS 14 为机器学习带来的新功能之一是能够在云中保留您的模型集合,让您能够在任何时间更新它们,而无需更新应用本身。
我们将使用本书代码包中可用的项目来演示此新功能。该项目的名称是TextAnalyzerCloud。它与之前使用的项目相同,但这次模型将在云中(本地副本作为后备)。
使用模型部署在我们的应用中涉及两个步骤:
-
使用 Core ML API 检索模型集合。
-
准备和部署模型。
让我们在下一小节中实现这些步骤。
使用 Core ML API 检索模型集合
让我们首先学习如何将存储在云中的模型检索到您的应用中。打开ViewController类。在这个阶段,该类仅包含一个analyze方法,该方法计算textView内的单词数量,并在存在模型的情况下进行预测。该类还包含一些向用户显示错误和成功消息的方法。请注意,我们已定义以下属性:var model: SentimentPolarity?。
在analyze方法中,我们将从云中下载一个模型,如果失败,我们将使用本地模态作为后备。让我们修改该方法以实现这一点。更新analyze方法的实现,并在//add code处添加以下代码:
//1
_ = MLModelCollection.beginAccessing(identifier: "SentimentPolarityCollection") { [self] result in
//2
var modelURL: URL?
switch result {
case .success(let collection):
modelURL =
collection.entries["SentimentPolarity"]?.modelURL
case .failure(let error):
handleCollectionError(error)
}
//3
let result = loadSentimentClassifier(url: modelURL)
//4
switch result {
case .success(let sentimentModel):
model = sentimentModel
guard let prediction = try? model?.prediction(input:
wordCount) else { return }
showResult(prediction: prediction)
case .failure(let error):
handleModelLoadError(error)
}
}
让我们回顾前面的代码块(以下数字指的是前面代码中的注释):
-
首先,在
//1中,我们正在访问新的 Core ML API,从苹果服务器上的账户中检索一组模型。我们通过使用带有集合标识符的MLModelCollection.beginAccessing方法来实现,该标识符必须与云中的标识符匹配——在我们的例子中,我们使用了SentimentPolarityCollection。 -
接下来,在
//2中,我们正在检查beginAccessing的结果。如果成功并且我们得到了一个模型集合,我们将搜索具有SentimentPolarity标识符的特定模型,并从中提取modelURL。如果出现任何错误(例如没有网络连接),我们将调用handleCollectionError方法来正确处理它(在我们的情况下,我们通过模态向用户通知)。 -
现在我们有了模型 URL,在
//3中,我们尝试加载它。我们尚未实现loadSentimentClassifier方法,但我们将很快完成它。请注意,此方法将尝试加载具有给定远程 URL 的模型,并将其包装在Result<SentimentPolarity, Error>枚举中(以正确处理错误)。 -
在最后一部分,在注释
//4下,我们检查//3中的Result。如果我们获得了一个模型,我们将它存储在model属性变量中。我们这样做是为了避免反复下载模型。在存储模型后,我们使用它来分析文本。另一方面,如果我们获得了一个错误,我们将向用户显示一条消息,通知他们有关错误的信息。
现在让我们添加loadSentimentClassifier方法,以便类可以编译。将以下方法添加到ViewController中:
private func loadSentimentClassifier(url: URL?) -> Result<SentimentPolarity, Error> {
if let modelUrl = url {
return Result { try SentimentPolarity(contentsOf:
modelUrl)}
} else {
return Result { try SentimentPolarity(configuration:
.init())}
}
}
此方法接收一个可选的模型 URL 作为参数;即我们存储在苹果服务器上的模型的 URL。它是一个可选值,因为我们尝试获取它时,可能会失败(例如,如果用户没有互联网连接)。在方法内部,我们处理两种可能性:
-
如果 URL 不为空,我们使用它通过
SentimentPolarity(contentsOf:)初始化模型,并在Result内部返回它。 -
如果 URL 为空,我们尝试使用本地版本和默认配置通过
SentimentPolarity(configuration: .init())初始化模型。同样,我们在Result内部返回它。
通过实现此方法,我们已经拥有了从网络加载模型并在我们的应用中使用它的所有必要代码。然而,我们还需要执行两个重要步骤来完成此过程:将模型以适当的格式准备上传到苹果服务器,并将模型部署到云端。
准备和部署模型
在上一节中,我们创建了从苹果服务器检索模型并将其导入我们应用的方法。现在,我们将准备我们的本地模型以便部署到云端。
在项目资源管理器中,点击名为SentimentPolarity.mlmodel的文件。现在,转到实用工具选项卡。您将看到以下内容:
![Figure 10.6 – Model Utilities tab]
![Figure 10.06_B14717.jpg]
![Figure 10.6 – Model Utilities tab]
点击创建模型存档。iOS 14 中的这个新选项将帮助我们部署我们的模型到云端的苹果服务器。当您点击它时,将出现此弹出窗口:
![Figure 10.7 – Generate Model Archive]
![Figure 10.07_B14717.jpg]
![Figure 10.7 – Generate Model Archive]
现在,请保持加密模型复选框未选中,并点击继续(我们将在本章后面探索此选项)。点击继续后,Xcode 将生成模型的存档并显示此模态:
![Figure 10.8 – The Model Archive Generated dialog]
![Figure 10.08_B14717.jpg]
![Figure 10.8 – The Model Archive Generated dialog]
您可以点击上一张截图中的第一个选项右侧的蓝色箭头,这将带您到模型存档的确切位置。您需要记住这个位置以便将存档上传到苹果服务器。您将看到一个扩展名为.mlarchive的文件,如下面的截图所示:
![Figure 10.9 – Location of the archived model]
![Figure 10.09_B14717.jpg]
![Figure 10.9 – Location of the archived model]
现在,点击第二个选项旁边的蓝色箭头,该选项读取为您可以在 Core ML 模型部署仪表板上上传模型存档。它将在以下页面打开您的网络浏览器:

图 10.10 – Core ML 模型集合页面
这是您在 Apple 服务器上管理模型集合的仪表板。我们现在需要创建一个新的集合,其中包含我们的模型引用,并上传我们刚刚创建的模型存档。让我们这样做;点击模型集合旁边的蓝色加号(+)图标,并填写出现的表单,如下所示:

图 10.11 – 创建模型集合
让我们回顾一下输入字段:
-
//1:MLModelCollection.beginAccessing(identifier: "SentimentPolarityCollection")),我们使用了标识符SentimentPolarityCollection。在这里使用相同的标识符(否则,您将无法下载集合)。 -
描述:使用此字段创建一个描述,这将帮助您在以后识别此集合。考虑到如果您在一个团队中工作,它还需要对其他开发者有用。
-
SentimentPolarity(在注释//2中:modelURL = collection.entries["SentimentPolarity"])。再次强调,这些标识符必须相互匹配。您可以通过点击模型 ID蓝色按钮添加更多模型标识符,但在这个例子中,我们的集合中只有一个模型。
最后,您可以点击蓝色创建按钮,然后您将进入以下模型集合页面:

图 10.12 – 模型集合页面
从这个页面,您最终可以将模型部署或存档到云上的引用。点击部署旁边的蓝色加号(+)按钮,并填写如此处所示的字段:

图 10.13 – 模型部署属性
让我们回顾一下字段:
-
部署 ID:您可以在此处指定任何文本来描述您为什么要部署此模型。这只是一个描述字段;它不需要与任何内容匹配。
-
.mlarchive文件是我们之前在 Xcode 中存档模型时创建的。
注意到表单的底部部分,我们可以添加附加目标规则。这是 iOS 14 的另一个新功能,允许我们根据设备特性来定位我们的模型。例如,我们可以仅将某些模型下载到 iPad 上,或者针对特定的操作系统版本。为了使这个例子简单,我们不会添加任何规则,但您应该在您的应用程序中尝试一下!
在您上传.mlarchive文件后,它应该显示如下:

图 10.14 – 我们部署的第一个模型
当状态为analyze方法将给出结论。
在本节中,您学习了如何使用 Core ML API 从云端获取模型以保持您的应用模型更新。您还学习了如何准备您的模型以及如何将它们部署到 Apple 服务器。现在您将学习如何使用 iOS 14 的新功能对这些模型进行加密,以在用户设备上保护模型数据的安全。
加密 Core ML 模型
iOS 14 Core ML 的新特性之一是能够在用户设备上加密您的机器学习模型。Xcode 12 有一个新工具可以帮助您创建一个私有密钥,您将部署到 Apple 服务器。您的应用将下载该密钥并在用户设备上安全存储,并使用该密钥解密本地(加密)模型,将解密版本加载到内存中(因此它不会以不安全的方式存储),并使其在您的应用中使用时准备就绪。
创建密钥并将其部署到 Apple 服务器的步骤非常简单。首先,在项目资源管理器中选择您的模型;在我们的例子中,打开SentimentPolarity.mlmodel文件。然后,点击实用工具选项卡:

图 10.15 – 模型加密
现在,点击创建加密密钥。在出现的弹出窗口中,选择您的应用的正确开发账户:

图 10.16 – 选择加密密钥的开发团队
这将在您的文件夹中生成一个密钥和.mlmodelkey文件:

图 10.17 – 生成加密密钥
点击蓝色箭头将带您到存储此密钥的特定文件夹。如果您想稍后将其部署到 Apple 服务器以便您的团队也能使用,则需要记住位置。点击确定并关闭弹出窗口。
现在,如果您点击创建模型存档,您会注意到这次加密模型复选框是激活的:

图 10.18 – 生成带有加密的模型存档
当您点击继续时,Xcode 这次将创建一个加密存档。接下来的步骤与我们学过的准备和部署模型部分中的步骤完全相同。
然而,您也可以告诉 Xcode 加密捆绑的模型(本地副本)。为此,在生成加密密钥(正如我们刚才所做的那样)之后,您需要点击您的项目,转到构建阶段,并打开编译源部分:

图 10.19 – 构建阶段选项卡
现在请选择SentimentPolarity.mlmodel模型,并在其行右侧双击以添加一个标志。将加密密钥的路由添加到您的项目文件夹中:
--encrypt "$SRCROOT/SentimentPolarity.mlmodelkey"
添加标志后,它应该看起来像这样:

图 10.20 – 带有加密标志的模型
现在,如果你构建应用,Xcode 将在应用内部生成模型的加密版本。
你已经学习了如何在本地加密模型(以及如何加密用于上传到苹果服务器的存档)。现在让我们看看如何在运行时加载该模型。ML Models 中有一个新的类方法 load,它将为你解密模型,从苹果服务器下载加密密钥。查看以下示例代码:
SentimentPolarity.load { [self] result in
switch result {
case .success(let model):
self.model = model
guard let prediction = try? self.model?.prediction(input:
wordCount) else { return }
showResult(prediction: prediction)
case .failure(let error):
handleDecryptError(error)
}
}
在前面的代码中,class func load 方法将尝试从苹果服务器下载加密密钥,并使用它解密模型,将其存储在内存中。我们将解密后的模型分配给我们的变量 model,它现在可以使用了。我们还处理了失败情况,显示错误信息。
在本节中,你学习了如何生成加密密钥,如何加密存档模型以便上传到苹果服务器,以及加密其本地副本,最后是如何加载和解密模型以便应用使用。
摘要
在本章中,你看到了如何利用 iOS 提供的机器学习功能。你了解到,将机器学习模型添加到应用中非常简单,因为你只需将其拖到 Xcode 中并添加到目标应用即可。你还学习了如何获取模型,以及在哪里查找将现有模型转换为 Core ML 模型的信息。创建机器学习模型并不简单,所以苹果通过在应用中嵌入训练好的模型,使得实现机器学习变得非常简单。
除了 Core ML,你还学习了 Vision 和 Natural Language 框架。Vision 结合了 Core ML 和智能图像分析的力量,创建了一个强大的框架,可以在图像上执行大量工作。如面部特征检测、文本分析等方便的请求,无需添加任何机器学习模型到你的应用中即可直接使用。如果你发现你需要更多以自定义模型形式存在的功能,你现在知道如何使用 Create ML 来训练、导出和使用你自己的自定义训练好的 Core ML 模型。你了解到 Create ML 使得训练模型变得简单,但你同时也了解到,模型的质量会受到训练数据质量的影响。
最后,你学习了如何在云中部署你的 Core ML 模型,以便在不更新应用的情况下更新它们,以及如何加密和解密它们,以确保在用户设备上安全地存储模型。
在下一章中,你将学习如何在应用中捕获、操作和使用媒体文件,包括音频、照片和视频元素。
第十一章:第十一章:将媒体添加到您的应用程序中
人们每天使用的许多应用程序都以某种方式使用媒体。一些应用程序在用户的动态信息中展示照片和视频。其他应用程序专注于播放音频或视频,同时也有一些应用程序允许用户录制媒体并与他们的同伴分享。你可能至少能说出两三个非常著名的应用程序,它们以某种方式使用此类媒体。
由于媒体在人们的日常生活中具有如此显著的存在感,了解如何将媒体集成到自己的应用程序中是很好的。iOS 对媒体播放提供了出色的支持,并提供了多种创建和消费不同类型媒体的方法。一些方法提供了较少的灵活性,但实现起来更直接。其他方法更复杂,但为你作为开发者提供了显著的力量。
在本章中,你将了解在 iOS 上播放和录制媒体的好几种方法。你将学习如何播放和录制视频、播放音频、拍照,甚至还将学习如何使用苹果的 Core Image 框架对图像应用滤镜。本章涵盖了以下主题:
-
播放音频和视频
-
录制视频和拍照
-
使用 Core Image 处理照片
到本章结束时,你将拥有一个坚实的基础,你可以在此基础上为你的用户创建引人入胜的体验,使他们不仅能够查看内容,还能在你的应用程序中创建自己的内容。
技术要求
本章的代码包包括两个起始项目,分别称为 Captured_start 和 MediaPlayback_start。你可以在代码包仓库中找到它们:
github.com/PacktPublishing/Mastering-iOS-14-Programming-4th-Edition
播放音频和视频
为了使播放音频和视频文件尽可能简单直接,苹果创建了 AVFoundation 框架。这个框架包含了许多辅助类,它们提供了对 iOS 播放音频和视频文件的低级别控制。你可以使用 AVFoundation 来构建一个功能丰富的自定义媒体播放器,以满足你的需求。
如果你正在寻找一种更简单的方法将媒体集成到你的应用程序中,AVKit 框架可能正是你所需要的。AVKit 包含了几个辅助工具,它们建立在 AVFoundation 组件之上,以提供一个支持许多功能(如字幕、AirPlay 等)的优秀默认播放器。
在本节中,你将学习如何使用 AVKit 框架中的 AVPlayerViewController 实现一个简单的视频播放器。你还将实现一个更复杂的音频播放器,使用 AVFoundation 组件在后台播放音频,并在锁屏上显示当前播放的音频轨道。
要跟随示例,你应该打开本章代码包中的 MediaPlayback_start 项目。起始应用包含一个带有标签栏和两个页面的简单界面。你将在一个页面上实现视频播放器,在另一个页面上实现音频播放器。音频页面包含一些预定义的控件和动作,你将在稍后实现。
创建简单的视频播放器
实现视频播放器的第一步是获取一个视频文件。你可以使用任何编码为 h.264 格式的视频。一个很好的示例视频是由 Blender 基金会创建的 Big Buck Bunny 示例电影。你可以在以下网址找到这个视频:bbb3d.renderfarming.net/download.html。如果你想用这个视频进行练习,请确保下载视频的 2D 版本。
如前所述,你将使用 AVPlayerViewController 来实现视频播放器。这个视图控制器围绕 AVFoundation 的几个组件提供了一个方便的包装,同时也提供了默认的视频控件,因此你不需要从头开始构建整个视频播放器,就像你稍后为音频播放器所做的那样。
AVPlayerViewController 具有高度的可配置性,这意味着你可以选择播放器是否支持 AirPlay、显示播放控件、视频播放时是否应该全屏,以及更多。要获取完整的配置选项列表,你可以参考 Apple 的 AVPlayerViewController 文档。
一旦找到你的测试视频,你应该将其添加到 MediaPlayback 项目中,并确保视频已添加到应用目标中。你可以按照以下步骤操作:
-
点击你的项目。
-
点击你的目标。
-
选择 构建阶段。
-
展开 复制资源包。
-
点击 + 并选择你的文件。
在完成此操作后,打开 VideoViewController.swift 并添加以下行以导入 AVKit:
import AVKit
你还应该在 VideoViewController 中添加一个属性来保存你的视频播放器实例。将以下行添加到 VideoViewController 类中以实现此功能:
let playerController = AVPlayerViewController()
由于 AVPlayerViewController 是 UIViewController 的子类,你应该将其添加到 VideoViewController 中作为子视图控制器。这样做将确保 VideoViewController 将任何视图控制器生命周期事件(如 viewDidLoad()),以及任何在特性集合中的变化等转发给视频播放器。为此,请将以下代码添加到 VideoViewController 中的 viewDidLoad() 方法:
// 1
addChild(playerController)
playerController.didMove(toParent: self)
// 2
view.addSubview(playerController.view)
let playerView = playerController.view!
playerView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint
.activate([
playerView.widthAnchor.constraint(equalTo: view.widthAnchor, constant: -20),
playerView.heightAnchor.constraint(equalTo: playerView.widthAnchor, multiplier: 9/16),
playerView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
playerView.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
之前的代码片段将视频播放器添加到视频视图控制器作为子视图控制器。当你添加一个视图控制器作为子视图控制器时,你必须始终在子控制器上调用didMove(toParent:)以确保它知道它已被添加为另一个视图控制器的子视图控制器。在将视频播放器作为子视图控制器添加后,视频播放器的视图被添加为视频视图控制器的子视图,并设置了一些约束来定位播放器视图。
要创建视频播放器实例并在你的视图控制器中显示它,你需要做以下所有事情。最后一步是获取你的视频文件的引用,创建一个指向视频文件的AVPlayer实例,并将其分配给播放器。添加以下代码来完成此操作:
let url = Bundle.main.url(forResource: "samplevideo", withExtension: "mp4")!
playerController.player = AVPlayer(url: url)
之前的代码查找名为samplevideo.mp4的视频文件,并为该文件获取一个 URL。然后它创建一个指向该视频文件的AVPlayer实例,并将其分配给视频播放器。AVPlayer对象负责播放视频文件。AVPlayerViewController实例使用AVPlayer实例来播放视频,并在内部管理视频的实际播放。
如果你以这种方式添加播放器后运行你的应用程序,你会发现视频播放得非常完美,并且你可以访问你可能需要的所有控件。这是一个很好的演示,说明了将基本媒体集成添加到你的应用程序是多么简单。下一步会稍微复杂一些。你将直接使用AVAudioPlayer实例来播放一个音频文件,该文件通过几个自定义媒体控件进行控制。播放器甚至可以在后台播放音频,并与锁屏集成以显示当前文件的详细信息。换句话说,你将构建一个简单的音频播放器,它做用户期望它做的所有事情。
重要提示
在模拟器中启动时,AVKit和大型电影文件可能需要一些时间来加载。尝试在真实设备上运行。
创建音频播放器
在你能够实现你的音频播放器之前,你需要获取一些你希望在播放器中使用的.mp3文件。如果你电脑上没有音频文件,你可以从freemusicarchive.org/网站获取一些文件,以获取一些你想要用于播放的免费歌曲。确保将它们添加到MediaPlayer Xcode 项目中,并确保它们包含在应用程序目标中。
你将按照以下步骤构建音频播放器:
-
实现必要的控件以启动和停止播放器以及导航到下一首和上一首歌曲。
-
实现时间刮擦器。
-
读取文件的元数据并将其显示给用户。
用户界面、输出和操作已经设置好了,所以在跟随音频播放器的实现之前,请确保熟悉现有的代码。
实现基本的音频控制
在实现音频播放器代码之前,你需要做一些准备工作。为了能够播放音频,你需要一个播放器将要播放的文件列表。除了这个列表,你还需要跟踪用户当前正在播放的歌曲,以便你可以确定下一首和上一首歌曲。最后,你还需要有音频播放器本身。你将使用AVAudioPlayer对象自己构建自己的音频播放器。AVAudioPlayer非常适合实现一个简单的音频播放器,该播放器可以播放几个本地的.mp3文件。它提供了一些方便的辅助方法,可以轻松调整播放器的音量、跳转到歌曲中的特定时间戳等。
在AudioViewController.swift中定义以下属性:
let files = ["one", "two", "three"]
var currentTrack = 0
var audioPlayer: AVAudioPlayer!
此外,别忘了添加导入:
import AVKit
确保将文件数组替换为你用于自己的音频文件的文件名。在此点audioPlayer还没有值。你将在设置音频播放器时进行设置。
在你能够播放音频之前,你需要获取一个媒体文件的引用并将其提供给AVAudioPlayer对象。任何你想加载新媒体文件的时候,你都必须创建一个新的音频播放器实例,因为一旦文件开始播放,你无法更改当前文件。向AudioViewController添加以下辅助方法以加载当前曲目并创建AVAudioPlayer实例:
func loadTrack() {
let url = Bundle.main.url(forResource: files[currentTrack], withExtension: "mp3")!
audioPlayer = try! AVAudioPlayer(contentsOf: url)
audioPlayer.delegate = self
}
此方法读取当前曲目的文件名并检索其本地 URL。然后,使用此 URL 在AudioViewController中创建并设置audioPlayer属性。视图控制器也被分配为音频播放器的代理。你目前不会实现任何代理方法,但你可以添加以下扩展,以确保AudioViewController符合AVAudioPlayerDelegate协议,从而确保你的代码可以编译:
extension AudioViewController: AVAudioPlayerDelegate {
}
现在,在viewDidLoad()中调用loadTrack()以实例化audioPlayer并加载第一首歌曲。向AudioViewController添加以下方法:
override func viewDidLoad() {
super.viewDidLoad()
loadTrack()
}
当你添加支持导航到下一首和上一首曲目时,你将实现AVAudioPlayerDelegate的一个方法。
向音频视图控制器添加以下两个方法以支持播放和暂停当前音频文件:
func startPlayback() {
audioPlayer.play()
playPause.setTitle("Pause", for: .normal)
}
func pausePlayback() {
audioPlayer.pause()
playPause.setTitle("Play", for: .normal)
}
这些方法相对简单。它们调用音频播放器的play()和pause()方法,并更新按钮的标签,以便反映当前播放器的状态。为playPauseTapped()添加以下实现,以便在用户点击播放/暂停按钮时调用播放和暂停方法:
@IBAction func playPauseTapped() {
if audioPlayer.isPlaying {
pausePlayback()
} else {
startPlayback()
}
}
如果你现在运行应用程序,你可以点击播放/暂停按钮来开始和停止当前播放的文件。确保你的设备不是静音模式,因为当设备处于静音模式时,你的应用程序的音频会被静音。你将在实现后台播放音频的功能时学习如何解决这个问题。下一步是添加播放下一曲和上一曲的支持。将以下两个实现添加到 AudioViewController 中以实现这一点:
@IBAction func nextTapped() {
currentTrack += 1
if currentTrack >= files.count {
currentTrack = 0
}
loadTrack()
audioPlayer.play()
}
@IBAction func previousTapped() {
currentTrack -= 1
if currentTrack < 0 {
currentTrack = files.count - 1
}
loadTrack()
audioPlayer.play()
}
上述代码调整当前曲目索引,加载新曲目,并立即播放。请注意,每次用户点击下一曲或上一曲按钮时,都必须通过调用 loadTrack() 创建一个新的音频播放器。如果你现在运行应用程序,你可以播放音频,暂停它,并跳转到下一曲或上一曲。
当你允许一首完整的歌曲播放时,它之后不会自动跳转到下一首。为了实现这一点,你需要为 AVAudioPlayerDelegate 中的 audioPlayerDidFinishPlaying(_:successfully:) 方法添加一个实现。将以下实现添加到调用 nextTapped(),以便在当前歌曲结束时自动播放下一首:
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
nextTapped()
}
现在第一项功能已经实现,下一步是实现时间刮擦器,它显示当前歌曲的进度并允许用户调整播放头的位置。
实现时间刮擦器
音频播放器应用的用户界面已经包含了一个与视图控制器中以下三个动作相连的刮擦器:
-
sliderDragStart() -
sliderDragEnd() -
sliderChanged()
当音频文件正在播放时,刮擦器应该自动更新以反映歌曲中的当前位置。然而,当用户开始拖动刮擦器时,它不应更新其位置,直到用户选择了刮擦器的新位置。当用户完成拖动刮擦器后,它应根据歌曲的进度再次调整自己。每当滑动条的值发生变化时,音频播放器应调整播放头,以便歌曲的进度与刮擦器匹配。
不幸的是,AVAudioPlayer 对象没有公开任何代理方法来观察当前音频文件的进度。为了定期更新刮擦器,你可以实现一个定时器,每秒更新刮擦器到音频播放器的当前位置。将以下属性添加到 AudioViewController 中,以便在创建定时器后保留它:
var timer: Timer?
此外,将以下两个方法添加到 AudioViewController 中,作为在用户开始拖动刮擦器或文件开始播放时启动定时器,以及当用户停止拖动刮擦器或播放暂停时停止定时器或保留资源的便捷方式:
func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [unowned self] timer in
self.slider.value = Float(self.audioPlayer.currentTime / self.audioPlayer.duration)
}
}
func stopTimer() {
timer?.invalidate()
}
在startPlayback()方法中添加对startTimer()的调用,并在pausePlayback()方法中添加对stopTimer()的调用。如果你完成这些操作后运行应用,进度条将在歌曲开始播放时立即开始更新其位置。然而,拖动进度条的功能还没有实现。添加以下拖动进度条动作的实现以启用手动拖动:
@IBAction func sliderDragStart() {
stopTimer()
}
@IBAction func sliderDragEnd() {
startTimer()
}
@IBAction func sliderChanged() {
audioPlayer.currentTime = Double(slider.value) * audioPlayer.duration
}
上述方法相对简单,但它们提供了一个非常强大的功能,立即让你的自制音频播放器感觉像你每天可能会使用的音频播放器。实现音频播放器功能的最后一步是显示当前歌曲的元数据。
显示歌曲元数据
大多数.mp3文件都包含 ID3 标签形式的元数据。这些元数据标签被诸如 iTunes 之类的应用程序用于提取有关歌曲的信息并显示给用户,以及用于对音乐库进行分类或过滤。你可以通过将音频文件加载到AVPlayerItem对象中并提取其内部AVAsset实例的元数据来通过代码访问音频文件的元数据。AVAsset对象包含有关媒体项的信息,例如其类型、位置等。当你使用AVPlayerItem对象加载文件时,它将自动为你创建相应的AVAsset对象。
单个资产可以在元数据字典中包含大量的元数据。幸运的是,苹果已经将所有有效的 ID3 元数据标签捕获在AVMetadataIdentifier对象中,因此一旦你提取了AVAsset实例的元数据,你就可以遍历其所有元数据来筛选出你需要的数据。以下方法就是这样做的,并将提取的值设置在AudioViewController的titleLabel变量上,如下所示:
func showMetadataForURL(_ url: URL) {
let mediaItem = AVPlayerItem(url: url)
let metadata = mediaItem.asset.metadata
var information = [String]()
for item in metadata {
guard let identifier = item.identifier else { continue }
switch identifier {
case .id3MetadataTitleDescription, .id3MetadataBand:
information.append(item.value?.description ?? "")
default:
break
}
}
let trackTitle = information.joined(separator: " - ")
titleLabel.text = trackTitle
}
确保从loadTrack()方法中调用此方法,并将你在loadTrack()中获得的音频文件 URL 传递给showMetadataForURL(_:)。如果你现在运行你的应用,你的基本功能应该都已经有了。元数据应该被正确显示,进度条应该可以工作,你应该能够跳过歌曲或暂停播放。
尽管你的媒体播放器看起来在这个阶段已经相当完善了,但你有没有注意到当你将应用发送到后台时音乐会暂停?为了让你的应用更像一个真正的音频播放器,你应该实现后台音频播放,并确保当前播放的歌曲显示在用户的锁屏上,类似于 iOS 的本地音乐应用的工作方式。这正是你接下来要添加的功能。
在后台播放媒体
在 iOS 中,后台播放音频需要特殊权限,您可以在应用的功能选项卡中启用这些权限。如果您启用了后台模式功能,可以选择音频、AirPlay 和画中画选项,使您的应用有资格在后台播放音频。以下截图显示了启用后台播放音频的功能:
![Figure 11.1 − Background Modes
![img/Figure_11.1_B14717.jpg]
图 11.1 − 后台模式
如果您想添加对后台音频播放的适当支持,您需要实现以下三个功能:
-
设置音频会话,以便音频在后台继续播放。
-
将元数据提交给“正在播放”信息中心。
-
响应来自远程源(如锁屏)的播放操作。
您只需两行代码即可为您的应用设置音频会话。当您创建音频会话时,iOS 会将您的应用播放的音频处理得略有不同;例如,即使设备设置为静音,您的歌曲也会播放。它还确保在您设置了适当的配置后,您的音频在应用处于后台时播放。将以下代码添加到viewDidLoad()中,以设置应用的音频会话:
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [.allowAirPlay])
try? AVAudioSession.sharedInstance().setActive(true, options: [])
需要添加的第二个功能是提供关于当前播放曲目信息。关于当前播放媒体文件的所有信息都应该传递给MPNowPlayingInfoCenter对象。该对象是MediaPlayer框架的一部分,负责在锁屏和命令中心显示用户关于当前播放媒体文件的信息。在将信息传递给“正在播放”信息中心之前,请确保在AudioViewController.swift文件的顶部导入MediaPlayer框架:
import MediaPlayer
接下来,将以下行代码添加到viewDidLoad()中:
NotificationCenter.default.addObserver(self, selector: #selector(updateNowPlaying), name: UIApplication.didEnterBackgroundNotification, object: nil)
在MPNowPlayingInfoCenter的文档中,Apple 表示,当应用进入后台时,您应该始终将最新的“正在播放”信息传递给信息中心。为此,音频视图控制器应监听UIApplication.didEnterBackgroundNotification通知,以便能够响应应用进入后台。将以下实现添加到AudioVideoController中的updateNowPlaying()方法:
@objc func updateNowPlaying() {
var nowPlayingInfo = [String: Any]()
nowPlayingInfo[MPMediaItemPropertyTitle] = titleLabel.text ?? "untitled"
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = audioPlayer.currentTime
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = audioPlayer.duration
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}
之前的代码配置了一个包含当前播放文件元数据的字典,并将其传递给“正在播放”信息中心。当应用进入后台时,此方法会自动调用,但您也应该在开始播放新歌曲时更新“正在播放”信息。在loadTrack()方法中添加对updateNowPlaying()的调用,以确保每次加载新曲目时都会更新“正在播放”信息。
下一步和最后一步是响应远程命令。当用户在锁屏上点击播放/暂停按钮、下一按钮或上一按钮时,这将被发送到您的应用作为远程命令。您应该明确定义 iOS 在远程命令发生时应调用的处理程序。将以下方法添加到AudioViewController中,以添加对远程命令的支持:
func configureRemoteCommands() {
let commandCenter = MPRemoteCommandCenter.shared()
commandCenter.playCommand.addTarget { [unowned self] event in
guard self.audioPlayer.isPlaying == false else { return .commandFailed }
self.startPlayback()
return .success
}
commandCenter.pauseCommand.addTarget { [unowned self] event in
guard self.audioPlayer.isPlaying else { return .commandFailed }
self.pausePlayback()
return .success
}
commandCenter.nextTrackCommand.addTarget { [unowned self] event in
self.nextTapped()
return .success
}
commandCenter.previousTrackCommand.addTarget { [unowned self] event in
self.previousTapped()
return .success
}
UIApplication.shared.beginReceivingRemoteControlEvents()
}
上述代码获取远程命令中心的引用并注册了几个处理程序。它还在应用程序对象上调用beginReceivingRemoteControlEvents()以确保它接收远程命令。在viewDidLoad()中添加对configureRemoteCommands()的调用,以确保应用在音频播放器配置后立即开始接收远程命令。作为练习,尝试自己实现控制时间刮擦器和从锁屏发送的+15和-15命令。
尝试运行您的应用并将其发送到后台。您应该能够从控制中心和锁屏控制媒体播放。当您跳转到下一首或上一首歌曲时,可见的元数据应正确更新,并且刮擦器应准确表示歌曲播放的当前位置。
到目前为止,您已经实现了一个功能相对完整且行为相当复杂的音频播放器。在 iOS 上探索媒体的下一步是发现您如何拍照和录制视频。
录制视频和拍照
除了播放现有媒体外,您还可以创建允许用户创建自己内容的 App。在本节中,您将了解如何使用内置组件来启用用户拍照。您还将了解如何使用原始视频流来录制视频。如果您想跟随本节中的示例,请确保从本章的代码包中获取Captured的起始项目。
起始项目包含几个视图控制器和一些连接的输出和动作。请注意,项目中还有一个UIViewController扩展。
此扩展包含一个辅助方法,使得向用户显示警报变得稍微简单一些。此扩展将用于显示一个警报,通知用户当他们的照片或视频存储在相机胶卷中时。
由于用户的相机和照片库被认为非常敏感,您需要确保将以下与隐私相关的键添加到应用的Info.plist中:
-
隐私 - 摄像头使用描述:此属性是访问摄像头所必需的,以便您可以拍照和录制视频。
-
隐私 - 麦克风使用描述:您必须添加此属性,以便您的视频能够录制音频,以及图像。
-
隐私 - 照片库添加使用描述:此属性允许您将照片写入用户的照片库。
确保为隐私键提供良好的描述,以便用户知道为什么你需要访问他们的相机、麦克风和照片库。你的描述越好,用户允许你的应用程序访问相关隐私敏感信息的可能性就越大。在添加键之后,你就可以看到如何使用 UIKit 内置的UIImagePickerController组件拍照了。
拍照和存储图像
当你需要用户提供图像时,他们可以通过从他们的照片库中选择图像或通过使用相机拍照来实现。UIImagePickerController支持两种选择图像的方式。在本节中,你将学习如何允许用户使用相机拍照。只要记得添加Info.plist,将示例更改为允许用户从他们的照片库中选择图像应该是简单的。
将以下viewDidLoad()的实现添加到ImageViewController类中:
override func viewDidLoad() {
super.viewDidLoad()
let imagePicker = UIImagePickerController()
imagePicker.sourceType = .camera
imagePicker.delegate = self
present(imagePicker, animated: true, completion: nil)
}
之前的实现创建了一个UIImagePickerController对象的实例,并配置它使用相机作为图像源,并将其展示给用户。请注意,视图控制器被设置为图像选择器的代理。
当用户拍照时,图像选择器会通知其代理,以便它可以提取图像并使用它。在这种情况下,图像应赋予视图控制器中的selectedImage标签,以便可以在图像视图中显示,并在用户点击保存按钮时保存,并调用saveImage()方法作为结果。
添加以下扩展使ImageViewController符合UIImagePickerControllerDelegate:
extension ImageViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate {
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
picker.dismiss(animated: true, completion: nil)
guard let image = info[.originalImage] as? UIImage else { return }
selectedImage = image
}
}
注意,此扩展还使图像视图控制器符合UINavigationControllerDelegate。图像选择器控制器的代理属性要求所有代理都符合UINavigationControllerDelegate和UIImagePickerControllerDelegate。
当用户使用相机拍照后,会调用imagePickerController(_:didFinishPickingMediaWithInfo)来通知代理关于用户所拍摄的照片。前面代码所做的第一件事是关闭选择器,因为它不再需要。用户刚刚拍摄的照片作为原始图像存储在info字典中。当从字典中提取图像时,它被设置为selectedImage。
要存储图像,请添加以下saveImage()的实现:
@IBAction func saveImage() {
guard let image = selectedImage else { return }
UIImageWriteToSavedPhotosAlbum(image, self, #selector(didSaveImage(_:withError:contextInfo:)), nil)
}
@objc func didSaveImage(_ image: UIImage, withError error: Error?, contextInfo: UnsafeRawPointer) {
guard error == nil else { return }
presentAlertWithTitle("Success", message: "Image was saved succesfully")
}
前面代码调用UIImageWriteToSavedPhotosAlbum(_:_:_:)将图像存储在用户的照片库中。当保存操作完成后,将调用didSaveImage(_:withError:contextInfo:)方法。如果没有接收到任何错误,则表示照片已成功存储在照片库中,并显示一个警告。
允许用户通过实现 UIImagePickerController 来拍照相对简单,这是在不费太多力气的情况下在您的应用中实现相机功能的好方法。有时,您可能需要更高级的相机访问权限。在这些情况下,您可以使用 AVFoundation 来获取来自摄像头的原始视频流,正如您接下来将看到的。
录制和存储视频
在上一节中,您使用了 AVFoundation 来构建一个简单的音频播放器应用。现在,您将再次使用 AVFoundation,但这次不是播放视频或音频,而是录制视频并将其存储在用户的照片库中。当使用 AVFoundation 来录制视频流时,您使用的是一个 AVCaptureSession 对象。捕获会话负责从一个或多个 AVCaptureDeviceInput 对象获取输入并将其写入 AVCaptureOutput 子类。
以下图表展示了通过 AVCaptureSession 录制媒体所涉及的对象:

图 11.2 − AVCaptureSession 实体
要开始实现视频录制功能,请确保在 RecordVideoViewController.swift 文件中导入 AVFoundation。同时,将以下属性添加到 RecordVideoViewController 类中:
let videoCaptureSession = AVCaptureSession()
let videoOutput = AVCaptureMovieFileOutput()
var previewLayer: AVCaptureVideoPreviewLayer?
大多数前面的属性应该看起来很熟悉,因为它们也出现在了展示与 AVCaptureSession 相关组件的截图里。注意,AVCaptureMovieFileOutput 是 AVCaptureOutput 的一个子类,专门用于捕获视频。预览层将在运行时用于渲染视频流,并将其展示给用户,以便他们可以看到通过摄像头捕捉到的内容。
下一步是为摄像头和麦克风设置 AVCaptureDevice 对象,并将它们与 AVCaptureSession 关联起来。将以下代码添加到 viewDidLoad() 方法中:
override func viewDidLoad() {
super.viewDidLoad()
// 1
guard let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back),
let microphone = AVCaptureDevice.default(.builtInMicrophone, for: .audio, position: .unspecified) else { return }
// 2
do {
let cameraInput = try AVCaptureDeviceInput(device: camera)
let microphoneInput = try AVCaptureDeviceInput(device: microphone)
videoCaptureSession.addInput(cameraInput)
videoCaptureSession.addInput(microphoneInput)
videoCaptureSession.addOutput(videoOutput)
} catch {
print(error.localizedDescription)
}
}
上述代码首先获取用于录制视频和音频的摄像头和麦克风的引用。第二步是创建与摄像头和麦克风关联的 AVCaptureDeviceInput 对象,并将它们与捕获会话关联起来。视频输出也被添加到视频捕获会话中。如果您检查之前看到的截图并与上述代码片段进行比较,您会发现这四个组件都存在于这个实现中。
下一步是为用户提供一个视图,显示当前的摄像头流,以便他们可以看到正在录制的内容。在捕获会话设置代码之后,将以下代码添加到 viewDidLoad() 中:
previewLayer = AVCaptureVideoPreviewLayer(session: videoCaptureSession)
previewLayer?.videoGravity = .resizeAspectFill
videoView.layer.addSublayer(previewLayer!)
videoCaptureSession.startRunning()
上述代码设置了预览层并将其与视频捕获会话关联。预览层将直接使用捕获会话来渲染相机视频流。然后启动捕获会话。这并不意味着录制会话开始;而是仅表示捕获会话将开始处理来自其相机和麦克风输入的数据。
在此阶段,预览层被添加到视图中,但它尚未覆盖视频视图。请向RecordVideoViewController中的viewDidLayoutSubviews()方法添加以下实现,以设置预览层的大小和位置,使其与videoView的大小和位置相匹配:
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
previewLayer?.bounds.size = videoView.frame.size
previewLayer?.position = CGPoint(x: videoView.frame.midX, y:videoView.frame.size.height / 2)
}
现在运行应用程序将显示相机视频流。然而,点击录制按钮目前不起作用,因为你还没有实现startStopRecording()方法。为此方法添加以下实现:
@IBAction func startStopRecording() {
// 1
if videoOutput.isRecording {
videoOutput.stopRecording()
} else {
// 2
guard let path = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
let fileUrl = path.appendingPathComponent("recording.mov")
// 3
try? FileManager.default.removeItem(at: fileUrl)
// 4
videoOutput.startRecording(to: fileUrl, recordingDelegate: self)
}
}
让我们逐步回顾前面的代码片段,看看到底发生了什么:
-
首先,检查视频输出的
isRecording属性。如果当前有活动录制,则应停止录制。 -
如果当前没有活动录制,将创建一个新的路径以临时存储视频。
-
由于视频输出无法覆盖现有文件,
FileManager对象应尝试删除临时视频文件路径上的任何现有文件。 -
视频输出将开始将录制保存到临时文件。视图控制器本身作为委托传递,以便在录制开始和停止时接收通知。
由于RecordVideoViewController尚未遵守AVCaptureFileOutputRecordingDelegate协议,你应该添加以下扩展以添加对AVCaptureFileOutputRecordingDelegate协议的遵守:
extension RecordVideoViewController: AVCaptureFileOutputRecordingDelegate {
// 1
func fileOutput(_ output: AVCaptureFileOutput, didStartRecordingTo fileURL: URL, from connections: [AVCaptureConnection]) {
startStopButton.setTitle("Stop Recording", for: .normal)
}
// 2
func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) {
guard error == nil else { return }
UISaveVideoAtPathToSavedPhotosAlbum(outputFileURL.path, self, #selector(didSaveVideo(at:withError:contextInfo:)), nil)
}
// 3
@objc func didSaveVideo(at path: String, withError error: Error?, contextInfo: UnsafeRawPointer?) {
guard error == nil else { return }
presentAlertWithTitle("Success", message: "Video was saved succesfully")
startStopButton.setTitle("Start Recording", for: .normal)
}
}
上述扩展包含三个方法。第一个是委托方法,在视频输出开始录制时调用。当录制开始时,startStopButton按钮的标题更新以反映当前状态。第二个方法也是委托方法。当录制完成时调用此方法。如果没有发生错误,视频将存储在之前设置的临时位置。然后调用UISaveVideoAtPathToSavedPhotosAlbum(_:_:_:_:),将视频从临时位置移动到用户的照片库。此方法与您用于存储图片的UIImageWriteToSavedPhotosAlbum(_:_:_:_:)方法非常相似。扩展中的第三个也是最后一个方法在视频存储在用户的照片库中时调用。当视频成功存储后,会显示一个警报,并且startStopButton按钮的标题再次更新。
现在,你可以运行应用并录制一些视频了!尽管你已经通过直接使用 AVCaptureSession 实现视频录制逻辑进行了大量的手动工作,但大部分困难的工作都是在 AVFoundation 框架内部完成的。最后,我们探索一个与媒体相关的功能,即使用 Core Image 对图像应用视觉滤镜。在许多应用中,对图像应用滤镜是一个非常流行的功能,它可以使得你的照片应用更具吸引力。
使用 Core Image 操作照片
在本章中,你已经看到了 iOS 在录制和播放媒体方面具有强大的功能。在本节中,你将学习如何使用 Core Image 操作图像。Core Image 框架提供了许多不同的滤镜,你可以使用这些滤镜处理图像和视频。你将扩展在 Captured 应用中实现的拍照功能,以便用户可以对图像进行灰度处理和裁剪。
你应用于图像的每个 Core Image 滤镜都是 CIFilter 类的一个实例。你可以按照以下方式创建滤镜实例:
let filter = CIFilter(name: "CIPhotoEffectNoir")
滤镜初始化器中的 name 参数预期是一个字符串,它引用了一个特定的滤镜。你可以参考 Apple 的 Core Image 文档和 Core Image 滤镜参考指南,以查看你可以在应用中使用的所有滤镜的概述。
每个滤镜都有一个特定的参数集,你需要设置在 CIFilter 实例上以使用该滤镜;例如,灰度滤镜要求你提供一个输入图像。其他滤镜可能需要强度、位置或其他属性。了解如何将滤镜应用于图像的最佳方式是通过示例。将以下实现添加到 ImageViewController.swift 中的 applyGrayScale() 方法,以实现灰度滤镜:
@IBAction func applyGrayScale() {
// 1
guard let cgImage = selectedImage?.cgImage,
// 2
let initialOrientation = selectedImage?.imageOrientation,
// 3
let filter = CIFilter(name: "CIPhotoEffectNoir") else { return }
// 4
let sourceImage = CIImage(cgImage: cgImage)
filter.setValue(sourceImage, forKey: kCIInputImageKey)
// 5
let context = CIContext(options: nil)
guard let outputImage = filter.outputImage, let cgImageOut = context.createCGImage(outputImage, from: outputImage.extent) else { return }
// 6
selectedImage = UIImage(cgImage: cgImageOut, scale: 1, orientation: initialOrientation)
}
之前的代码有很多有趣的小细节,用编号注释突出显示。让我们逐个查看这些注释,看看灰度滤镜是如何应用的:
-
存储在
selectedImage中的UIImage实例被转换为CGImage实例。严格来说,这种转换不是必需的,但它确实使得之后对UIImage实例应用其他滤镜变得稍微容易一些。 -
使用
CGImage而不是UIImage的一个缺点是,图像中存储的朝向信息会丢失。为了确保最终图像保持其朝向,初始朝向被存储。 -
此步骤创建了一个灰度滤镜的实例。
-
由于 Core Image 不直接支持
CGImage实例,因此CGImage实例被转换为CIImage实例,该实例可以与 Core Image 一起使用。然后通过在滤镜上调用setValue(_:forKey:)方法,将CIImage实例分配为灰度滤镜的输入图像。 -
第五步从滤镜中提取新的图像,并使用
CIContext对象将CIImage输出导出到CGImage实例。 -
第六步和最后一步是创建一个新的
UIImage实例,基于CGImage输出。初始方向被传递到新的UIImage实例,以确保它与原始图像具有相同的方向。
即使涉及很多步骤,并且你需要在不同的图像类型之间进行相当多的转换,应用过滤器相对简单。大部分前面的代码负责在图像类型之间切换,而过滤器本身只需几行即可设置。现在尝试运行应用并拍照。初始图片将是全彩色的。在你应用灰度过滤器后,图片会自动替换为图像的灰度版本,如下面的截图所示:

图 11.3 - 灰度
你接下来要实现的过滤器是裁剪过滤器。裁剪过滤器将裁剪图像,使其成为方形,而不是肖像或风景图片。实现裁剪过滤器的过程基本上与灰度过滤器相同,只是需要传递给裁剪过滤器的值不同。将以下实现添加到 cropSquare() 以实现裁剪过滤器:
@IBAction func cropSquare() {
let context = CIContext(options: nil)
guard let cgImage = selectedImage?.cgImage, let initialOrientation = selectedImage?.imageOrientation, let filter = CIFilter(name: "CICrop") else { return }
let size = CGFloat(min(cgImage.width, cgImage.height))
let center = CGPoint(x: cgImage.width / 2, y: cgImage.height / 2)
let origin = CGPoint(x: center.x - size / 2, y: center.y - size / 2)
let cropRect = CGRect(origin: origin, size: CGSize(width: size, height: size))
let sourceImage = CIImage(cgImage: cgImage)
filter.setValue(sourceImage, forKey: kCIInputImageKey)
filter.setValue(CIVector(cgRect: cropRect), forKey: "inputRectangle")
guard let outputImage = filter.outputImage, let cgImageOut = context.createCGImage(outputImage, from: outputImage.extent) else { return }
selectedImage = UIImage(cgImage: cgImageOut, scale: 1, orientation: initialOrientation)
}
}
前面的代码执行了几个计算,以确定将图像裁剪成方形的最佳方式。CGRect 实例指定了裁剪坐标和大小,然后用于创建一个 CIVector 对象。然后,这个对象被传递给过滤器作为 inputRectangle 键的值。除了指定裁剪值之外,应用过滤器的过程是相同的,所以代码应该对你来说很熟悉。
如果你现在运行应用并点击裁剪按钮,图片将被裁剪,如下面的截图所示:

图 11.4 - 裁剪图像
Core Image 中有更多可用的过滤器,你可以尝试使用它们来构建相当高级的过滤器。你甚至可以将多个过滤器应用到单个图像上,为你的应用中的图片创建复杂的效果。因为所有过滤器都以非常相似的方式工作,一旦你了解了应用过滤器的一般过程,将任何过滤器应用到你的图像上就相对容易。如果你需要提醒如何应用 Core Image 过滤器,你可以始终使用前面示例中的代码。
摘要
在本章中,你学习了关于 iOS 中媒体的大量知识。你看到了如何仅用几行代码实现视频播放器。之后,你学习了如何直接使用AVFoundation构建支持如停止和恢复播放、跳过歌曲、在歌曲中前后滚动等功能的音频播放器。你甚至学习了如何在应用进入后台或手机设置为静音模式时继续播放音频。为了给音频播放器添加最后的修饰,你学习了如何使用MediaPlayer框架在用户的锁屏上显示当前播放的文件,以及如何响应发送到应用的远程控制事件。
在实现媒体播放后,你学习了如何构建帮助用户创建媒体的应用。你看到UIImagePickerController提供了一个快速简单的界面,允许用户使用相机拍照。你还学习了如何使用AVFoundation和一个AVCaptureSession对象来实现自定义的视频录制体验。最后,你学习了关于 Core Image 框架,以及如何使用它来对图像应用滤镜。
在下一章中,你将学习关于位置服务以及如何在你的应用中使用 Core Location 所需了解的一切。根据你应用的使用场景,正确处理用户位置可能对你的应用成功至关重要。现在,这些例子已经众所周知:食品配送应用、地图应用、运动追踪应用等等。
第十二章:第十二章:使用位置服务改进应用程序
所有 iOS 设备都配备了各种芯片和传感器,可以用来增强用户体验。增强现实应用程序大量使用陀螺仪、加速度计和摄像头等传感器。如果您想拍照或想知道设备是如何移动的,这些传感器非常棒。其他应用程序需要不同的数据,例如用户在特定时间的 GPS 位置。在本章中,您将学习如何使用Core Location框架来实现这一点。
Core Location是一个框架,允许开发者访问用户的当前位置,但它还允许开发者跟踪用户是否进入或离开了特定区域,甚至可以监控用户随时间的变化位置。Core Location 的正确实现可以是您应用程序中许多优秀功能的基石,但糟糕的实现可能会迅速耗尽用户的电池。
在本章中,您将了解以下与位置相关的主题:
-
请求用户的地理位置
-
订阅位置变化
-
设置地理围栏
到本章结束时,您应该能够就如何在您的应用程序中实现 Core Location 做出明智的决定。
技术要求
本章的代码包包括一个名为LocationServices的入门项目。您可以在代码包仓库中找到它:
请求用户的地理位置
如您所想,允许应用程序访问您的确切位置是一件相当重要的事情。如果落入错误的手中,这些数据可能会让有恶意意图的人知道您在任何给定时间的确切位置,并以多种方式滥用这种知识。因此,如果您绝对需要,才请求用户的地理位置信息至关重要。仅仅为了实现一个小功能,或者确保用户在注册服务之前位于某个任意位置,可能并不总是请求用户地理位置信息的充分理由。
现在我们来看看在 iOS 中请求用户允许访问其位置数据的不同方法。
请求访问位置数据的许可
当您确实需要访问用户的地理位置信息时,您必须首先请求许可。类似于您需要在Info.plist文件中添加需要相机或用户联系人的原因一样,您还必须提供请求位置数据的原因。在位置数据的情况下,您可以在Info.plist中添加两个键:
-
隐私-位置
NSLocationWhenInUseUsageDescription) -
隐私-位置
NSLocationAlwaysAndWhenInUseUsageDescription)
当你的应用请求使用用户的位置数据时,他们可以选择仅在应用使用时允许你的应用访问他们的位置,或者他们可以选择始终允许你的应用访问他们的位置,即使应用处于后台。你还可以配置你想要请求的访问类型。如果你只需要在用户使用应用时获取用户的位置,请确保正确配置你的权限请求,这样用户就不会在应用处于后台时被要求向你的应用提供位置信息。
在将所需的密钥添加到LocationServices应用的Info.plist文件后,你需要编写一些代码来请求用户允许使用他们的位置。在这样做之前,让我们快速检查一下示例项目的结构和内容,以便你知道信息可以在哪里找到。
首先,在项目中打开Main.storyboard文件。你将找到一个带有两个视图控制器的标签栏控制器。在本章中,你将实现功能以填充这些视图控制器,并添加适当的数据。接下来,查看AppDelegate的实现。这里的实现遵循GeofenceViewController,这是你将首先工作的,以便在屏幕上显示用户的当前位置。
你会注意到在这个视图控制器中已经实现了大量的代码。稍微检查一下现有代码,你会发现所有代码都调用了LocationHelper.swift中的空方法。在本章中,你将主要关注实现与用户位置数据一起工作的 Core Location 代码,因此 UI 工作已经设置好了。当你向LocationHelper添加代码时,你会发现LocationServices的用户界面将逐步变得生动起来。
既然你对如何设置 LocationServices 应用有了更好的理解,让我们看看请求用户允许使用其位置所需的步骤。由于此应用最终将在后台跟踪位置变化,因此即使在应用处于后台时,你也应该请求用户允许访问其位置。为此,将以下viewDidAppear(_:)代码添加到GeofenceViewController中:
locationHelper.askPermission { [weak self] status in if status == .authorizedAlways {
self?.showCurrentLocation()
} else {
// handle the case where you don't always have access
}
}
这是用户将看到的第一个视图控制器,因此当这个视图出现时立即请求用户的位置是个好主意。如果你不明确你将提示用户位置,通常在显示位置访问对话框之前通知用户你为什么要请求位置权限是个好主意。要实际使权限对话框出现,你需要在LocationHelper.swift中添加一些代码。
所有位置服务相关的请求都是通过CLLocationManager的一个实例来执行的。位置管理器负责获取用户的 GPS 位置,请求访问用户位置的权限,以及更多。当位置管理器收到有关用户位置、授权状态或其他事件的更新时,它将通知其代理。位置管理器代理应遵守CLLocationManagerDelegate协议。请注意,LocationHelper已经遵守了CLLocationManagerDelegate协议,并且在这个对象上已经创建了一个CLLocationManager的实例。剩下要做的就是将辅助器分配为位置管理器的代理。在LocationHelper的init()方法末尾添加以下行以将其设置为位置管理器代理:
locationManager.delegate = self
接下来,为askPermission(_:)方法添加以下实现:
func askPermission(_ completion: @escaping (CLAuthorizationStatus) -> Void) {
let authorizationStatus =
CLLocationManager.authorizationStatus()
if authorizationStatus != .notDetermined {
completion(authorizationStatus)
} else {
askPermissionCallback = completion
locationManager.requestAlwaysAuthorization()
}
}
此实现检查是否存在当前授权状态。如果存在,则使用当前状态调用完成callback。如果当前状态尚未确定,则请求位置管理器使用requestAlwaysAuthorization()方法请求访问用户位置的授权。这将提示用户进行位置权限的请求。在这个应用中你需要永久访问用户的位置的原因是为了确保你可以在本章后面实现地理围栏。将以下方法添加到CLLocationManagerDelegate以检索用户对授权提示的响应:
func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
askPermissionCallback?(status)
askPermissionCallback = nil
}
之前的代码立即将用户的响应传递给存储的完成回调,该回调是通过askPermission(_:)传递的。在调用回调后,它被设置为 nil 以避免意外再次调用它。此时,你已经完成了请求访问用户位置所需的所有工作。接下来,让我们看看如何检索用户当前位置,以便你可以在你的应用中使用它。
获取用户的位置
一旦你的应用可以访问位置数据,你可以使用位置管理器来开始观察用户的位置、用户移动的方向等等。目前,你将专注于获取用户的当前位置。GeofenceViewController已经包含了一个名为showCurrentLocation()的方法,该方法负责请求位置辅助器提供当前位置。如果你仔细检查这个方法,你会发现它还通过调用getLocationName(for:_:)并传递获取到的位置到这个方法来请求位置名称。showCurrentLocation()方法还使用获取到的位置通过在地图视图上调用setRegion(_:animated:)来聚焦地图视图在用户的位置上。
由于视图控制器已经完全准备好处理位置更新,你所需要做的就是添加对getLatestLocation(_:)和getLocationName(for:_:)的正确实现。首先,为getLatestLocation(_:)添加以下实现:
func getLatestLocation(_ completion: @escaping (CLLocation) -> Void) {
if let location = trackedLocations.last {
completion(location)
} else if CLLocationManager.locationServicesEnabled() {
latestLocationObtainedCallback = completion
locationManager.startUpdatingLocation()
}
}
上述方法首先检查是否已经获取了位置。如果已经获取,则返回最新获取的位置。如果没有现有位置,代码将检查位置服务是否已启用。检查你即将使用的位置服务是否实际可用始终是一个好习惯。如果位置服务可用,则将completion回调存储在辅助器中,并通过调用startUpdatingLocation()告诉位置管理器开始监控用户的位置。
调用startUpdateLocation()将使位置观察者持续监控用户的 GPS 位置,并通过调用locationManager(_:didUpdateLocations:)将其相关更新发送到其代理。此方法将始终接收到管理器获取的一个或多个新位置,其中最新位置将是获取位置列表中的最后一个项。将以下实现添加到LocationHelper的CLLocationManagerDelegate扩展中:
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
latestLocationObtainedCallback?(locations.last!)
latestLocationObtainedCallback = nil
locationManager.stopUpdatingLocation()
trackedLocations += locations
}
locationManager(_:didUpdateLocations:)的实现相当简单:将最新位置传递给回调,并移除回调以防止后续的位置更新意外触发回调。此外,通过调用stopUpdatingLocation()告诉位置管理器停止监控用户的位置。最后,将获取到的位置存储以供以后使用。
重要提示
总是好的做法,如果你不会很快需要更新,就停止位置管理器监控位置更新。监控位置更新对电池寿命有相当大的影响,因此你不应该花费比所需更多的时间来跟踪用户的位置。
现在你已经可以检索用户的位置,下一步是通过在位置辅助器中实现getLocationName(for:_:_)来检索位置名称。将以下实现添加到位置辅助器中:
func getLocationName(for location: CLLocation, _ completion: @escaping (String) -> Void) {
let geocoder = CLGeocoder()
geocoder.reverseGeocodeLocation(location) { placemarks,
error in
guard error == nil else {
completion("An error ocurred:
\(error?.localizedDescription ?? "Unknown error")")
return
}
completion(placemarks?.first?.name ?? "Unkown location")
}
}
上述代码使用CLGeocoder查找与用户当前位置相对应的地点标记。请注意,此功能需要互联网连接,因此名称查找只有在用户有互联网连接时才会工作。常规的 GPS 相关功能不需要互联网访问,因此即使用户没有活跃的互联网连接,你的应用也可以监控和跟踪用户的位置。
现在尝试运行您的应用——您应该能够在地图上看到用户的当前位置,并且位置名称、纬度和经度也应显示在屏幕上。现在您已经知道如何获取用户的位置,让我们看看您如何有效地订阅应用以跟踪用户位置的变化,以便跟踪他们的位置。
订阅位置变化
订阅用户位置变化的一种方法已经在本章的前一节中介绍过了。当您在位置管理器上调用startUpdatingLocation()时,它将自动订阅用户的位置。如果您需要非常详细的位置报告,这种跟踪用户位置的方法是极好的,但通常您不需要这种详细程度。更重要的是,长时间使用这种位置跟踪会耗尽用户的电池。
幸运的是,有更好的方法来监控位置变化。一种方法是调用startMonitoringVisits()来订阅用户访问的位置。如果您不感兴趣于用户的详细移动,但只想知道用户是否在特定区域停留了较长时间,则使用此方法。这种跟踪用户位置的方式非常适合您需要低功耗方式来跟踪非常粗略的位置变化。即使您的应用在后台运行,这种跟踪方式也能很好地工作,因为如果发生访问事件,您的应用将自动被唤醒或启动。
如果您的应用因与位置相关的事件而重新启动,那么UIApplication.LaunchOptionsKey.location将存在于应用的启动选项字典中。当它存在时,您应该创建一个位置管理器的实例并将其分配一个代理以接收相关的位置更新。
如果访问监控对您的用途来说不够精确,但您不需要持续的位置跟踪,您可以使用LocationServices示例应用来查看它们是如何工作的。
如果您查看SignificantChangesViewController,您会注意到视图控制器已经完全设置好以开始监控显著的位置变化。在位置辅助器中定义的monitorSignificantChanges(_:)方法,每次发生显著的位置变化时都会调用回调。每次检索到新的位置数据时,表格视图都会重新加载以显示最新的可用数据。由于显著的位置变化可以通过应用启动选项中的特殊键唤醒应用,因此让我们更新AppDelegate以处理这种情况。在return语句之前添加以下代码application(_:didFinishLaunchingWithOptions:):
if launchOptions?[UIApplication.LaunchOptionsKey.location] != nil
{ locationHelper.monitorSignificantChanges { _ in
// continue monitoring
}
}
由于 AppDelegate 已经有对位置辅助的引用,它只需要重新启用显著位置变化的监控。这种对 AppDelegate 的小改动非常强大,因为它允许你的应用在应用未运行时也能对用户位置的变化做出响应。接下来,让我们在位置辅助中实现适当的代码。
为 LocationHelper 中的 monitorSignificantLocationChanges(_:) 方法添加以下实现:
func monitorSignificantChanges(_ locationHandler: @escaping (CLLocation) -> Void) {
guard CLLocationManager.
significantLocationChangeMonitoringAvailable() else { return }
significantChangeReceivedCallback = locationHandler
locationManager.startMonitoringSignificantLocationChanges()
isTrackingSignificantLocationChanges = true
}
此方法与之前见过的位置辅助方法非常相似。当检测到显著的位置变化时,位置管理器会调用其代理的 locationManager(_:didUpdateLocations:) 方法。由于此方法已经实现,你应该按照以下方式更新实现:
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
latestLocationObtainedCallback?(locations.last!)
latestLocationObtainedCallback = nil
if isTrackingSignificantLocationChanges == false {
locationManager.stopUpdatingLocation()
}
significantChangeReceivedCallback?(locations.last!)
trackedLocations += locations
}
注意,当显著位置变化跟踪不活跃时,位置管理器才被告知停止更新用户的位置。当你调用 stopUpdatingLocation() 时,位置管理器将停止向此代理方法提供任何位置更新。另外,请注意,在调用后 significantChangeReceivedCallback 不会被移除。这是因为调用 monitorSignificantChanges(_:) 的调用者对连续的位置更新感兴趣,所以每次调用此方法时,应该始终调用启动了显著位置变化跟踪的 SignificantChangesViewController 视图控制器。
最后,为了确保你的应用在后台时也能接收到显著的位置变化,你需要将 allowsBackgroundLocationUpdates 属性设置为 true。将以下代码行添加到位置辅助的 init() 方法中:
locationManager.allowsBackgroundLocationUpdates = true
除了订阅显著的位置变化或访问之外,你还可以使用地理围栏对用户进入或离开某个特定区域做出响应。
设置地理围栏
有时,你的应用并不真的需要知道用户的位置详情。有时,你只对跟踪用户是否离开了或离开了某个特定区域感兴趣,以便在你的应用中显示某些内容或解锁某种特殊功能。Core Location 对监控地理围栏提供了很好的支持。CLRegion 是一个子类。Core Location 提供了两种不同的区域类型,你可以使用:
-
CLCircularRegion -
CLBeaconRegion
如前所述,使用 CLCircularRegion 类型来设置地理围栏。使用 CLBeaconRegion 类型与物理 BLE iBeacon 配合使用,基本上在非常小的半径内提供地理围栏,例如,仅几米。在本节中,你将学习如何设置一个围绕用户首次检测到的位置的 CLCircularRegion 类型。使用这两种类型的区域设置地理围栏或区域监控非常相似,所以监控圆形区域的所有原则也适用于信标区域。
如果你查看 GeofenceViewController,你会注意到它有一个标记为 @IBAction 的按钮,这个按钮已经做了很多工作,但缺少一个关键元素——它没有通知位置管理器应该监控的区域。请将以下代码添加到 GeofenceViewController 中的 setGeofence() 方法末尾:
let region = CLCircularRegion(center: location.coordinate, radius: 30, identifier: "current-location-geofence") locationHelper.setGeofence(at: region, exitHandler, enterHandler)
上述代码使用从用户那里获取的位置信息,并使用它创建一个半径为 30 米的圆形区域。传递给区域的标识符应该是一个唯一定义该区域的标识符。如果你重复使用一个标识符,Core Location 将停止监控具有该标识符的旧区域,并监控新的区域。对于 LocationServices 应用来说这很完美,但如果你想让你的应用监控多个区域,你必须确保每个区域都有自己的唯一标识符。
接下来,将以下 setGeofence(at:_:_:) 的实现添加到 LocationHelper 中:
func setGeofence(at region: CLRegion, _ exitHandler: @escaping () -> Void, _ enterHandler: @escaping () -> Void) {
guard CLLocationManager.isMonitoringAvailable(for:
CLCircularRegion.self) else { return }
geofenceExitCallback = exitHandler
geofenceEnterCallback = enterHandler
locationManager.startMonitoring(for: region)
}
上述方法与其他位置助手方法非常相似。让我们直接进入实现位置管理器将调用的 CLocationManagerDelegate 方法:
func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) {
geofenceEnterCallback?()
}
func locationManager(_ manager: CLLocationManager, didExitRegion region: CLRegion) {
geofenceExitCallback?()
}
上述两个方法是 CLocationManagerDelegate 协议的一部分,当用户进入或离开某个区域时被调用。由于助手没有额外的工作要做,相应的回调立即被调用,以便 GeofenceViewController 可以相应地更新其界面。
尝试打开应用并点击 设置地理围栏 按钮。现在应该会在地图上出现一个橙色圆圈,以可视化你设置的地理围栏。如果你退出或进入该区域,状态标签应该相应更新,以显示你是否刚刚进入或离开了地理围栏。请注意,iOS 可能需要最多五分钟的时间来正确注册、监控和报告关于你的地理围栏的更新。请注意,为了使区域监控工作最佳,用户应该有一个活跃的互联网连接。
摘要
在本章中,你学习了获取和响应用户位置的一些技术。你实现了一个 LocationHelper 类,它为视图控制器提供了一个简单的接口来使用包含在助手中的位置管理器。你了解了在请求用户访问其位置数据方面的最佳实践,并且你了解到请求用户的位置是一个相当敏感的隐私问题,在没有充分理由的情况下不应该提出。
你了解到有不同方式,每种方式都有不同级别的细节,你可以用来追踪用户的位置。你看到了你可以订阅连续的变化,这对电池寿命有不良影响。你还学习了关于订阅访问和显著位置变化的内容。除了学习追踪用户的位置外,你还学习了通过实现地理围栏来监控用户是否进入或离开了某个区域。当你在自己的应用中实现 Core Location 时,始终确保将用户的隐私放在心上。如果你真的不需要位置数据,那么不要请求访问它。如果你需要,确保非常小心地处理用户的位置数据。
在下一章中,你将学习关于 Combine 框架以及如何使用它来增强你的应用。
第十三章:第十三章:使用 Combine 框架
随着 Combine 的推出,苹果为开发者提供了一种处理代码中事件的新方法;一种函数式和声明式的方法,开发者可以轻松实现流和发布者/订阅者范式,而无需外部库。借助 Combine 在你的应用中集中处理事件,可以使你的代码比使用其他传统方法(如嵌套闭包或回调)更容易理解。
在本章中,你将学习以下主题:
-
理解 Combine 框架:我们将通过代码示例回顾框架的基本组件——发布者、订阅者、主题和操作符。
-
组合发布者、订阅者和操作符:我们将在示例应用中构建一个小功能,将所有这些概念混合在一起。
-
使用操作符构建无错误流:我们将在一个实际示例应用中使用
flatMap和catch创建可以正确处理错误的流。
到本章结束时,你应该能够在你自己的应用的多个部分中使用 Combine,以生成简单、有效且易于理解的声明式代码,这将有助于你的应用代码易于理解、扩展和维护。
技术要求
本章的代码包包括两个起始项目,分别称为 CombineExample_start 和 PublishersAndSubscribers_start。你可以在代码包仓库中找到它们:
github.com/PacktPublishing/Mastering-iOS-14-Programming-4th-Edition
理解 Combine 框架
苹果在 2019 年发布了 Combine 框架,并将其定义为提供一个声明式 Swift API 以处理随时间变化的值的框架。存在 发布者,它们产生这些值,以及 订阅者,它们消费这些值。这些随时间变化的值可能代表不同的异步事件。
让我们看一下以下章节中 Publisher 和 Subscriber 协议定义的概述,以了解它们的关键概念。
理解发布者
如前所述,发布者在 Combine 中用于随时间生成值。让我们深入了解定义它们的 Swift 协议,以了解关键概念。Swift 中的 Publisher 定义如下:
public protocol Publisher {
//1
associatedtype Output
//2
associatedtype Failure : Error
//3
public func subscribe<S>(_ subscriber: S) where S : Subscriber, Self.Failure == S.Failure, Self.Output == S.Input
}
让我们更详细地解释每个编号的注释:
-
每个
Publisher实例都有一个关联的类型,Output。这定义了发布者将在随时间生成值的类型。 -
同时,
Publisher也可以生成错误,并且相关的类型Failure用于定义这些错误的类型。如果一个Publisher永远不会生成错误,则Failure可以定义为类型Never。 -
最后,
发布者允许订阅者实体订阅它,以接收随时间产生的值。请注意,为了生成一个有效的订阅,发布者的Output类型必须与订阅者的Input类型匹配。同样,两者的Failure类型也必须匹配。
下面的图显示了发布者的概要:

图 13.1 – 发布者概要
现在我们已经对发布者的关键概念有了初步描述,让我们对订阅者协议也做同样的处理。
理解订阅者
我们已经看到,发布者实体会随着时间的推移产生值。现在让我们看看订阅者实体如何消费这些值。在 Combine 中,发布者和订阅者紧密协作,所以让我们看看它们的内部细节。Swift 中的订阅者协议看起来是这样的:
public protocol Subscriber : CustomCombineIdentifierConvertible {
//1
associatedtype Input
//2
associatedtype Failure : Error
//3
func receive(subscription: Subscription)
//4
func receive(_ input: Self.Input) -> Subscribers.Demand
//5
func receive(completion: Subscribers.Completion<Self.Failure>)
}
再次,让我们回顾每一条编号的行:
-
订阅者实体将随着时间的推移接收值。关联类型Input定义了这些值的类型。 -
同时,
订阅者也可以接收错误,关联类型Failure用于定义这些错误的类型。如果一个订阅者永远不会收到错误,则Failure可以定义为类型Never。 -
此方法通知
订阅者,对发布者的订阅成功,它可能开始请求元素。 -
通知
订阅者,发布者已产生一个新项目。 -
一些订阅可能在一段时间后结束。在这些情况下,此方法被调用以通知
订阅者它将不再接收任何值。它允许我们在完成之前执行一个完成块。
下面的图显示了订阅者的概要:

图 13.2 – 订阅者概要
如果我们将发布者和订阅者图结合起来,我们得到以下架构:

图 13.3 – 发布者和订阅者架构
注意发布者和订阅者的输出-输入类型和失败类型必须相等。
现在我们已经对发布者和订阅者的基本概念有了了解,让我们看看它们是如何进行通信的。这里有三个步骤,如下图中所示:

图 13.4 – 发布者和订阅者通信过程
下面的列表更详细地描述了这个过程:
-
在第一步中,
订阅者告诉发布者它想要订阅。发布者发送回一个订阅。订阅者使用该订阅开始请求元素。订阅者可以从 N 个值请求到无限值。 -
现在
发布者可以自由地随时间发送这些值。订阅者将接收这些输入。 -
在不期望无限值的订阅中,会向
Subscriber发送一个完成事件,使其知道订阅已结束。
现在我们对发布者和订阅者有了基本的概念,也知道它们通信的步骤。这就足够理论了!这里有一个单发布者向订阅者发送数组值的实际示例。你可以在新的 Xcode playground 中尝试以下代码:
import Cocoa
import Combine
//1
let publisher = [1,2,3,4].publisher
//2
let subscriber = publisher.sink { element in
print(element)
}
在第一个注释中,我们创建了一个包含从 1 到 4 的整数的数组,并使用 Sequence 协议的便利实例属性 publisher 将其包装在一个新的 Publisher 实例中。
在第二个注释中,我们使用 sink 方法将订阅者附加到发布者上,并在其完成块中定义了一个在接收到的每个值上执行的操作。
如果你执行此代码,输出将如下所示:
1
2
3
4
初始数组包含从 1 到 4 的数字,这就是我们打印的内容。但如果我们只想打印偶数呢?我们如何转换生产者和订阅者之间的数据?幸运的是,Combine 提供了 操作符 来帮助我们。让我们接下来了解更多关于它们的信息。
理解操作符
filter、map、reduce、scan、combineLatest、merge 和 zip。
使用 filter
filter 操作符用于从流中移除匹配某些条件的值。
让我们通过使用 filter 操作符的基本示例来看看。想象一下,从之前的数组 [1,2,3,4] 中,我们只想打印数组的偶数。我们这样做:
import Cocoa
import Combine
let publisher = [1,2,3,4].publisher
let subscriber = publisher
.filter { $0 % 2 == 0}
.sink { print($0) }
注意 filter 操作符位于发布者和订阅者之间,并以声明式的方式定义了元素的修改。
如果你运行此代码,你将在控制台获得以下结果:
2
4
现在让我们看看另一个示例,说明操作符在处理 Combine 时是多么有用。记住,订阅者和发布者的第一条规则是订阅者的 Input 必须等于发布者的 Output。当它们不相等时会发生什么?嗯,操作符可以帮助我们将发布者的 Output 转换为适应订阅者适当的 Input 类型。帮助我们的一个操作符是 map。
使用 map
map 操作符帮助我们应用某种操作到流中的每个值,将其转换为不同的类型。
以下代码使用 map 操作符将发布者的 Output(整数值)转换为订阅者需要的 Input(User 实例):
let publisher = [1,2,3,4].publisher
let subscriber = publisher
.map { return User(id: $0)}
.sink { print($0.description()) }
map 操作符将一个 Int 值流 ['1,2,3,4'] 转换为一个 User 实例流。当我们使用 sink 时,我们可以调用这些用户的 description() 方法。

图 13.5 – 使用 map 在流上转换输出
上一图表示了map如何改变输出类型(而在这个例子中,失败类型保持不变)。当使用 Combine 工作时,使用这种类型的图(在文档中或在心中)对于在流的每个步骤中处理正确的类型非常有帮助。
可用的算子不仅限于filter和map。让我们看看其他有用的算子,如reduce、scan、combineLatest、merge和zip。
使用 reduce
reduce算子返回使用给定操作应用的所有流值的组合结果。
你可以在 Xcode playground 中尝试以下示例。继续并检查reduce的以下示例:
import Combine
let reduceExample = [1,2,3,4].publisher
.reduce(1, { $0 * $1 })
.sink(receiveValue: { print ("\($0)", terminator: " ") })
如果你执行此代码,控制台中的输出如下:
24
如果你已经使用了 Swift 标准库中的常规reduce函数,那么 Combine 中的reduce版本应该很容易理解。它的工作方式相同,但使用来自发布者的值。reduce所做的是应用一个操作并累积下一个值的累积结果,从给定的值开始。在我们的例子中,1, { $0 * $1 },第一个参数是初始值,因此是1,接下来的参数是要执行的操作:将当前值(存储为$0)乘以下一个传入的值($1),并保留它以供下一次迭代。因此,如果我们的输入是[1,2,3,4]并且我们的起始值是1,那么reduce所做的是1 x 1 x 2 x 3 x 4 = 24。我们将要解释的下一个算子与reduce非常相似。让我们跳到scan。
使用 scan
与reduce非常相关的算子是scan。scan算子与reduce完全相同,但它会在每个步骤中发出结果。查看以下代码:
import Combine
let scanExample = [1,2,3,4].publisher
.scan(1, { $0 * $1 })
.sink(receiveValue: { print ("\($0)", terminator: " ") })
现在,执行此操作将产生以下输出:
1 2 6 24
如您所见,它给出了与reduce相同的最终结果(scan在每个步骤中发出一个值,而不是仅在结束时。因此,使用scan,我们得到以下随时间变化的值:
-
1x1 = 1
-
1x2 = 2
-
2x3 = 6
-
6x4 = 24
这些算子(filter、map、reduce和scan)帮助我们转换来自另一个发布者的值。但一些算子将多个发布者的输入组合成一个单一的流输出。让我们看看其中的一些:combineLatest、merge和zip。
使用 combineLatest
这是一个将两个其他发布者的最新值组合在一起的发布者。这两个发布者必须具有相同的失败类型。当任何一个发布者发出新值时,下游订阅者将接收到一个元组,包含来自上游发布者的最新元素。
在 playground 中尝试以下代码:
import Combine
let chars = PassthroughSubject<String, Never>()
let numbers = PassthroughSubject<Int, Never>()
let cancellable = chars.combineLatest(numbers)
.sink { print("Result: \($0).") }
chars.send("a")
numbers.send(1)
chars.send("b")
chars.send("c")
numbers.send(2)
numbers.send(3)
控制台输出如下:
Result: ("a", 1).
Result: ("b", 1).
Result: ("c", 1).
Result: ("c", 2).
Result: ("c", 3).
注意,直到("a", 1)之前我们没有任何输出,这意味着combineLatest在所有输入发送初始值之前不会产生任何输出。之后,每当输入发送新的值时,它将产生一个值,发送每个输入的最新值。
还有其他版本的 combineLatest 可以合并三个或四个输入,而不仅仅是两个:combineLatest3、combineLatest4。
如果我们只想获取任何输入发布者的最新输出(意味着只是一个值,而不是一个元组)怎么办?对于这些情况,我们可以使用 merge。
使用合并
使用 merge,我们将多个输入发布者聚合到一个单个流中,输出将是它们中的任何一个的最新值。查看游乐场中的此代码:
import Combine
let oddNumbers = PassthroughSubject<Int, Never>()
let evenNumbers = PassthroughSubject<Int, Never>()
let cancellable = oddNumbers.merge(with: evenNumbers)
.sink { print("Result: \($0).") }
oddNumbers.send(1)
evenNumbers.send(2)
oddNumbers.send(3)
输出将如下所示:
Result: 1.
Result: 2.
Result: 3.
如您所见,输出一次只有一个值,这与我们从 combineLatest 获得的包含所有输入最新值的元组不同。
有另一个有用的方法可以处理多个发布者。让我们看看 zip 能做什么。
使用 zip
zip 是一个发布者,当两个输入发布者都发出新值时,它会发出一对元素。让我们看看它如何与 combineLatest 的相同示例不同。在游乐场中执行以下代码:
import Combine
let chars = PassthroughSubject<String, Never>()
let numbers = PassthroughSubject<Int, Never>()
let cancellable = chars.zip(numbers)
.sink { print("Result: \($0).") }
chars.send("a")
numbers.send(1)
// combineLatest output: (a,1)
// zip output: (a, 1)
chars.send("b")
// combineLatest output: (b,1)
// zip output: nothing
chars.send("c")
// combineLatest output: (c,1)
// zip output: nothing
numbers.send(2)
// combineLatest output: (c,2)
// zip output: (b,2)
numbers.send(3)
// combineLatest output: (c,3)
// zip output: (c,3)
查看每行下的注释,表示 combineLatest 和 zip 每次给出的输出。注意 zip 不会在两个发布者都发出新值之前向下游发送新的值对。当这种情况发生时,它将发送一个包含两者最老的非发出值的元组。CombineLatest 将使用最新的,并且每次一个发布者发出单个新值时(它不会等待两个发布者都发出!)就会发出一个元组。这是主要区别。
在解释了发布者、订阅者和操作符实体之后,让我们在下一节中看看 Combine 中的另一个有用实体:主题。
理解主题
根据苹果文档:
"主题是一个发布者,它公开了一个方法,允许外部调用者发布元素。"
定义相当直接。主题就像发布者一样,但它们有一个方法,send(_:),你可以用它将新元素注入它们的流中。单个 Subject 允许多个订阅者同时连接。
有两种内置的主题类型:CurrentValueSubject 和 PassthroughSubject。让我们看看它们之间的区别。
与 CurrentValueSubject 一起工作
这是一个包含初始值的主题。每次它改变时,它都会广播当前值。
当订阅者连接到 CurrentValueSubject 时,它将接收当前值,以及当它改变时的下一个值。这意味着 CurrentValueSubject 有状态。以下是一个示例(您可以在游乐场中尝试此代码):
import Combine
let currentValueSubject = CurrentValueSubject<String, Never>("first value")
let subscriber = currentValueSubject.sink { print("received: \($0)") }
currentValueSubject.send("second value")
如果你执行此代码,输出将如下所示:
received: first value
received: second value
这里有一些有趣的部分:
-
当我们初始化主题时,我们需要传递一个初始值。
-
当订阅者订阅时,它会获取主题中持有的当前值。注意在控制台输出中,即使我们在该值生成后订阅了主题,订阅者仍然打印了
first value。 -
每次我们调用
send(_:),订阅者都会接收到下一个值。
现在,让我们看看另一种内置的主题类型,PassthroughSubject。
使用 PassthroughSubject
PassthroughSubject 和 CurrentValueSubject 之间的主要区别在于 PassthroughSubject 不保留任何状态。检查以下代码(你可以在游乐场中尝试它):
import Combine
let passthroughSubject = PassthroughSubject<String, Never>()
passthroughSubject.send("first value")
let subscriber = passthroughSubject.sink { print("received: \($0)")}
passthroughSubject.send("second value")
如果你执行这段代码,这里是你将得到的输出:
received: second value
注意订阅者在第一个值发送后是如何创建的。这个第一个值没有被接收,因为没有订阅者连接。然而,第二个值在输出中显示,因为它是在订阅建立后发送的。
我们已经看到了 Publisher、Subscriber、Operator 和 Subject 的基本用法。现在,让我们创建一个更大、更复杂的示例,看看如何将 Combine 概念应用到现实世界的应用程序中。
结合发布者、订阅者和操作符
在本节中,我们将通过一个现实世界的示例功能将上一节的概念全部混合在一起。假设我们有一个包含通讯录的应用程序,并且允许用户通过输入他们的电子邮件地址订阅通讯录,使用两个 UITextFields:电子邮件和重复电子邮件字段。假设在我们的业务逻辑中,我们需要检查电子邮件是否正确,我们将进行以下检查:
-
本地检查:我们将要求用户两次输入电子邮件地址,并且两次输入应该相同。
-
本地检查:电子邮件应该包含一个 "@"。
-
本地检查:电子邮件至少应该有五个字符长。
-
远程检查:我们还将假设我们有一个远程方法在后台检查电子邮件是否唯一,这意味着它尚未存在。
一旦所有这些条件都满足,我们将启用一个 UITextfield 来重复它。你也会看到一个 combineLatest。
打开 ViewController.swift 文件。你会注意到几个被标记为属性包装器 @Published 的变量:
@Published var initialEmail: String = ""
@Published var repeatedEmail: String = ""
@Published 在这里所做的是从属性本身创建一个发布者。所以,每次 initialEmail 或 repeatedEmail 的值发生变化时,它们都会被发布给任何订阅它们的用户。你可以通过使用 $initialEmail(在属性名称前加上 $)来访问 initialEmail 的发布者。注意在同一个类中定义的两个 IBActions:
@IBAction func emailChanged(_ sender: UITextField) {
initialEmail = sender.text ?? ""
}
@IBAction func repeatedEmailChanged(_ sender: UITextField) {
repeatedEmail = sender.text ?? ""
}
通过结合 IBAction 和 @Published,我们创建了一个漏斗,每次用户在 initialEmail UITextField 中输入一些内容时,它将通过 $initialEmail 发布者发布。
这为什么方便?记住,根据上面定义的业务逻辑,我们需要确保 initialEmail 和 repeatedEmail 都相等。现在我们有两个发布者,每次用户在两个文本字段中的任何一个中输入时,都会发出它们的值。我们如何将这两个值结合起来进行比较?Combine 框架有完美的方法来做这件事:CombineLatest。将以下变量添加到 ViewController.swift 文件中:
var validatedEmail: AnyPublisher<String?, Never> {
return Publishers
.CombineLatest($initialEmail, $repeatedEmail) //1
.map { (email, repeatedEmail) -> String? in //2
guard email == repeatedEmail, email.contains("@"), email.count > 5 else { return nil }
return email
}
.eraseToAnyPublisher() //3
}
var cancellable: AnyCancellable? //4
让我们逐行分析代码注释:
-
首先,我们使用
Publishers.CombineLatest将两个不同的发布者合并为一个:$initialEmail和$repeatedEmail。这将产生一个新的流(发布者)类型为Publishers.CombineLatest<Published<String>.Publisher, Published<String>.Publisher>。不要让这个长的类型吓到你。这意味着“两个字符串发布者的发布者”。CombineLatest的魔力在于,如果两个输入中的任何一个发生变化,你将得到新的值,同时也会得到另一个输入的最新值,这在类似这种情况中非常有用。 -
第二,我们在“两个字符串发布者的发布者”上应用
map运算符。通过使用map,我们解包了底层的发布字符串,以便能够使用字符串本身,并在处理它们之后返回不同的结果。这就是我们应用业务规则的地方:如果两个电子邮件地址相等,它们包含一个“@”并且长度超过五个字符,我们返回email。否则,我们返回nil。因此,通过map,我们将流输出类型转换为新的类型,以适应我们的需求。 -
在这个阶段,如果我们检查我们所拥有的类型的类型,你会看到这个:
Publishers.Map<Publishers.CombineLatest<Published<String>.Publisher, Published<String>.Publisher>, String?>. 这非常复杂,难以阅读和使用。但是 Combine 为我们提供了一种简化这个的方法,因为重要的是发布者本身的内容,而不是围绕它的所有包装。通过使用eraseToAnyPublisher,我们将这个类型改变为仅仅是AnyPublisher<String?, Never>。这要容易理解和使用得多(如果你想在 API 中发布它,例如,其他开发者更容易消化)。 -
我们创建了一个可取消的属性
var,以便在下面的代码片段中使用。
这个流程可以表示如下

图 13.6 – validatedEmail 流
现在,将此行代码添加到viewDidLoad()方法中:
cancellable = validatedEmail.sink { print($0) }
通过调用sink,我们将一个订阅者附加到validatedEmail发布者上,并将其存储在我们新的var属性cancellable中。每次我们收到一个新的值时,我们只需将其打印到控制台进行测试。让我们试试!执行应用并输入任何满足所有条件的电子邮件地址(在两个字段中)(例如,abc@email.com)。
当你输入有效的地址时,你会在控制台中看到它。当地址无效时,你将看到nil。
我们已经看到了很多新的 Combine 概念被压缩在非常少的代码中。在我们继续我们的演示项目之前,我们将快速总结这些新概念:
-
@Published属性包装器:允许我们从属性变量创建发布者。我们可以通过在属性名称前加$来访问发布者。它只适用于类属性,不适用于结构体。 -
Publishers.CombineLatest:允许我们将两个发布者合并为一个,该发布者将在有变化时(或nil,如果没有先前的值)始终推送每个发布者的最新值。 -
map:允许我们转换流。我们对具有Output类型的发布者应用一个map,并将其转换成一个新的、不同的Output。 -
eraseToAnyPublisher:允许我们将复杂类型擦除以使用更简单的AnyPublisher<Output, Failure>流。这在将我们的类作为 API 发布时非常有用。
在这个小总结之后,我们仍然有一个功能尚未完成以满足需求列表。我们已经实现了三个电子邮件地址的本地检查,但我们还需要完成最后一个,那就是:
- 远程检查:我们还将假设我们有一个远程方法来检查后端,以确保电子邮件是唯一的,这意味着它尚未存在。
在 ViewController.swift 文件中,有一个名为 func emailAvailable(…) 的模拟方法。它只返回一个完成块。此方法旨在表示一个根据电子邮件是否已在后端存在而返回 True 或 False 的网络调用。出于演示目的,我们不会实现网络调用本身,只是模拟结果。
让我们使用 Combine 来实现这个功能。我们将创建一个新的发布者,该发布者将发出一个 Bool 值,指示用户输入的电子邮件是否存在于后端,使用一个模拟的网络调用,emailAvailable(…)。将以下代码添加到 ViewController.swift 文件中:
var isNewEmail: AnyPublisher<Bool, Never> { //1
return $initialEmail //2
.debounce(for: 1, scheduler: RunLoop.main) //3
.removeDuplicates() //4
.flatMap { email in //5
return Future { promise in
self.emailAvailable(email) { available in
promise(.success(available))
}
}
}
.eraseToAnyPublisher()
}
这里有很多新的概念,所以让我们逐个查看编号注释:
-
我们正在定义一个新的发布者,
isNewEmail,类型为<Bool, Never>。这个发布者将帮助我们发出事件,指示用户输入的电子邮件是否存在于我们的数据库中。 -
要获取用户在电子邮件字段中输入的任何新值,我们首先使用在
$initialEmail部分中定义的已发布的属性。 -
用户可以在文本字段中非常快速地输入/删除。我们的目标是每次通过
$initialEmail发布者(意味着每次用户在电子邮件字段中输入时)接收到新值时都进行网络调用。这意味着我们将对网络进行过多的查询。Combine 的.debounce方法将帮助我们减少我们正在处理的数据量。通过使用.debounce(1...),我们指定从从$initialEmail获取的所有值中,我们每1秒只处理一个值。其余的值将被丢弃。这对于处理与用户界面和网络(文本字段、按钮、搜索栏等)连接的发布者非常有帮助。 -
另一个有用的方法是
removeDuplicates()。如果用户输入了 "abc" 然后删除了 "c" 以快速重新输入 "c",我们将进行多次调用。但如果我们使用removeDuplicates(),我们将避免这种不必要的操作。 -
第五步稍微复杂一些。这是我们执行网络调用的地方。首先,我们有一个
.flatMap包裹着一切。这个函数将发布者的元素转换成新的发布者类型。在flatMap内部,我们有一个Future。Future是一个发布者,最终会发出一个值然后完成(或失败)。在Future内部,我们有一个Promise:在 Combine 中,Promise是一个接受Result的闭包的typealias。现在让我们再次描述整个过程,但这次是从内到外:网络调用emailAvailable以promise.success(…)的形式返回一个结果。这个 Promise 被包裹在一个 Future 中,变成了一个发布者流。这个流在这个时候是一个Future<Bool, Never>。现在,我们用flatMap包裹一切,所以上游的initialEmail: Published<String>.Publisher变成了AnyPublisher<Bool, Never>(也借助了eraseToAnyPublisher)。
这是生成isNewEmail的完整流程:

图 13.7 – isNewEmail 流
所以,经过这一系列的转换后,我们得到了一个发布者,isNewEmail,它会在用户在 UI 中输入时(几乎每次,除了重复和防抖动的)发出一个Bool值,指示电子邮件地址在我们的后端是否唯一。这真的很酷!这对我们的业务逻辑检查非常有用。
我们的最后一步是将本地检查的发布者(validatedEmail)与远程发布者(isNewEmail)结合起来,以得到最终的输出。所需的业务逻辑是启用String和True值,因此所有条件都满足。最好的方法是将两个不同发布者的最新值结合起来并处理它们是什么?我们上面已经使用了它!它是combineLatest。将以下代码添加到ViewController.swift文件中:
var finalEmail: AnyPublisher<String?, Never> {
return Publishers.CombineLatest(validatedEmail, isNewEmail).map { (email, isNew) -> String? in
guard isNew else { return nil }
return email
}
.eraseToAnyPublisher()
}
如前述代码所示,我们使用CombineLatest来处理两个不同发布者的最新值。从validatedEmail中,我们得到一个有效的电子邮件地址或否则的nil值。从isNewEmail中,我们得到一个Bool值,指示电子邮件是否存在于数据库中。结果是一个新的发布者,finalEmail,类型为<String?, Never>。请查看下一张图中的流程:

图 13.8 – finalEmail 流
现在如何启用和禁用viewDidLoad函数,让我们详细解释一下:
signupButtonCancellable = finalEmail
.map { $0 != nil }
.receive(on: RunLoop.main)
.assign(to: \.isEnabled, on: signupButton)
在这段代码中,我们从一个finalEmail发布者(<String?, Never>)开始,我们对其map操作,将流转换为<Bool, Never>,然后应用.receive确保我们在主线程中执行这个操作(因为我们在这里处理 UI,一个UIButton)。最后,我们将流中的值(<Bool>)分配给signupButton的isEnabled属性!查看下一张图,它详细说明了流的步骤:

图 13.9 – 将 finalEmail 分配给 signUpButton
以下是所有内容! 执行 the 应用程序,并亲自尝试:如果您输入的电子邮件地址符合所有条件(如 abc@email.com),则 注册 按钮将被启用。否则,它将被禁用。
在本节中,我们学习了大量的新 Combine 概念和方法,用于组合不同的流、转换输出、修改我们正在工作的线程、处理用户输入等。我们使用了 flatMap 来将上游元素转换为下游的不同类型。然而,flatMap 有更多用途。其中之一是帮助流从错误中恢复,这得益于 catch。在下一节中,我们将看到一个流如何失败,以及如何使用 flatMap 和 catch 来恢复它。
使用运算符构建无错流
对于本节,请打开名为 PublishersAndSubscribers_start 的代码包中的项目。查看 ViewController.swift 文件。
此文件包含一个 User 结构体:
struct User: Codable {
let id: String
static var unknown: User {
return User(id: "-1")
}
}
User 结构体相当简单。它包含一个 String 属性 id,以及一个名为 unknown 的 static var,它返回一个 id 等于 -1 的 User 实例。除了 User 结构体之外,该文件还包含 ViewController 本身。
视图控制器包含两个方法:
-
首先,
postNotification():这只是在通知中心触发一个包含id等于123的User实例的通知。通知的名称是networkResult。 -
第二个,
postNotificationThatFails():这只是在通知中心触发一个包含随机数据(这次不是User实例)的通告,这些数据是 Base-64 编码的。通知的名称是networkResult.。
我们将使用 Combine 消费这两个通知。这两种方法都代表一个虚拟的网络调用,其结果以这种方式通过通知中心发送。所以,把它们想象成当你尝试查询某些对象(在这种情况下是一个用户)并使用通知将结果传播到你的应用程序时,你将从后端获取的网络调用响应。
现在,让我们尝试调用 postNotification() 并使用 Combine 消费结果。在 viewDidLoad() 方法中调用 postNotification():
override func viewDidLoad() {
super.viewDidLoad()
postNotification()
}
现在,让我们创建一个发布者,它从通知中心发出值,并使用 cancellable 属性作为订阅者来消费它们。将 viewDidLoad() 方法更改为以下内容:
override func viewDidLoad() {
super.viewDidLoad()
//1
let publisher = NotificationCenter.default.publisher(for: Notification.Name("networkResult"))
//2
cancellable = publisher.sink { item in
print(item)
}
//3
postNotification()
}
让我们按行回顾注释:
-
首先,我们正在创建一个发布者,它从名为
networkResult.的通知中心发出任何值。这与我们在postNotification()方法中发送的通知名称相匹配。 -
我们正在订阅之前步骤中创建的发布者,并将结果存储在
cancellable属性中。在创建订阅者时,我们使用sink来定义一个完成块,该块将打印到控制台接收到的任何值。 -
最后,我们发布一个通知。
如果你执行此代码并在控制台检查,你应该看到以下结果:
name = networkResult, object = Optional(<7b226964 223a2231 3233227d>), userInfo = nil
这意味着我们的流工作正常!我们已经发送了一个通知,我们的发布者已经转发它,我们的订阅者已经将其打印到控制台。正如你在控制台输出中看到的那样,通知有三个属性:name、object和userInfo。我们想要展开object属性中的内容。所以,让我们用操作符修改我们的发布者。将发布者代码更改为以下代码:
let publisher = NotificationCenter.default.publisher(for: Notification.Name("networkResult"))
.map { notification in return notification.object as! Data }
执行它并检查控制台输出:
12 bytes
在此代码中,我们正在映射通知值并将object内容作为Data发送。在控制台输出中,你可以看到我们的订阅者现在正在接收这些数据字节,而不是完整的通知。太好了!下一步是将这些Data字节转换为User实例。为此,我们需要解码数据。Combine 有完美的辅助方法来完成这个任务。将发布者代码更改为以下代码:
let publisher = NotificationCenter.default.publisher(for: Notification.Name("networkResult"))
.map { notification in return notification.object as! Data }
.decode(type: User.self, decoder: JSONDecoder())
通过添加前面的高亮行,我们正在使用map操作中的Data并将其解码为一个User实例!这一切都在一行中完成。但是,如果你现在尝试执行,你将在subscriber的sink行得到一个编译错误,提示如下:
Referencing instance method 'sink(receiveValue:)' on 'Publisher' requires the types' Publishers.Decode<Publishers.Map<NotificationCenter.Publisher, JSONDecoder.Input>, User, JSONDecoder>.Failure' (aka 'Error') and 'Never' be equivalent
这意味着:如果你检查我们使用的sink方法,你会发现它要求消费它的Failure类型必须是Never:
extension Publisher where Self.Failure == Never
在添加decode行后,我们的发布者不再有Never类型的失败,因为decode可以产生错误。所以,编译器告诉你类型不再匹配。我们需要做一些可以捕获decode产生的任何错误并将其转换为Never失败操作的事情。Combine 还有一个有用的操作符可以帮助我们在这个场景中:catch。将发布者代码更改为以下新块:
let publisher = NotificationCenter.default.publisher(for: Notification.Name("networkResult"))
.map { notification in return notification.object as! Data }
.decode(type: User.self, decoder: JSONDecoder())
.catch {_ in
return Just(User.unknown)
}
让我们更详细地解释catch。catch将处理上游中的任何错误,并且不会使应用崩溃,而是完成/结束产生错误的发布者,并用一个新的发布者(你必须提供在return块中)替换它。
因此,在这种情况下,如果我们从decode操作中得到错误,我们的通知发布者将结束,并将被Just(User.unknown)替换。Just是一个只发出一个值然后完成的发布者。查看下一张图:



上一张图的上半部分显示了在decode阶段发生错误时catch进入行动的流。在图的下半部分,你可以看到catch如何丢弃初始发布者并用catch块中定义的(在这种情况下是一个Just发布者)替换它。
让我们尝试一下,如果我们提供一个在decode阶段会产生错误的值会发生什么。在viewDidLoad()的末尾,在postNotification()之后,添加以下行:
postNotificationThatFails()
因此,我们现在发送了两个通知,一个包含用户数据,另一个包含随机字符串。第二个在decode步骤应该失败。执行应用;您将看到以下输出:
User(id: "123")
User(id: "-1")
这太棒了!第一个通知被解码并转换成了合适的用户。第二个在解码时失败,但我们的catch块使用一个新的发布者恢复了流,并将一个未知的User结构体传递给接收者。
然而,我们的解决方案存在一个问题。在viewDidLoad()方法的末尾,在postNotificationThatFails()之后添加这一行:
postNotification()
因此,我们现在发送了三个通知:首先是一个常规通知,然后是一个失败的通知,接着是另一个常规通知。执行应用并注意输出:
User(id: "123")
User(id: "-1")
这里的问题是什么?问题是,我们只收到了两个值,尽管发送了三个通知!那么问题在哪里?问题是我们的catch块用Just发布者替换了失败的流。正如之前所说,Just发布者只发送一个值然后完成。任何在失败之后发送的值都将丢失。
让我们改进这个解决方案,以便在从错误中恢复后继续处理值。将publisher块替换为以下内容:
let publisher = NotificationCenter.default.publisher(for: Notification.Name("networkResult"))
.map { notification in return notification.object as! Data }
.flatMap { data in
return Just(data)
.decode(type: User.self, decoder: JSONDecoder())
.catch {_ in
return Just(User.unknown)
}
}
在前面高亮显示的代码中,您可以看到我们将decode和catch块包裹在flatMap + Just块中。在以下图中查看更改前后的差异:



图 14.2 – App Clip 流程和步骤
上一张图片解释了 App Clip 的不同阶段:
-
调用方法:App Clip 调用方法是用户如何触发和打开 App Clip 的方式。在我们的例子中,用户使用他们的设备摄像头扫描放置在自行车上的二维码,App Clip 就会在他们的主屏幕上打开。在这种情况下,调用方法是二维码。我们将在本章后面更详细地探讨这些内容。
-
用户旅程:在调用之后,App Clip 会向用户提供一些选项供他们选择(例如,1 小时租赁 2 美元,24 小时租赁 5 美元)。用户在 App Clip 内进行他们想要的选项。
-
账户和支付:在我们的自行车租赁示例中,我们的 App Clip 需要识别哪个用户在租赁自行车,并且用户需要为这项服务付费。一些 App Clip 可能不需要注册用户账户或支付即可工作;这一步是可选的。
-
完整应用推荐:当做出自行车租赁的决定并且用户准备继续时,您的 App Clip 可以建议用户下载您的完整应用,这样他们下次使用您的服务时就可以使用它而不是 App Clip。建议整个应用是一个可选步骤,但建议这样做。
现在我们已经概述了 App Clip 遵循的高级步骤,我们将更仔细地查看可用的调用方法。
App Clips 调用方法
我们已经看到,为了显示 App Clip,用户需要调用或发现它。之前,我们讨论了这可以通过二维码、NFC 标签、消息中的链接等方式完成。以下是可用选项的总结:
-
App Clip 代码:每个 App Clip 代码都包含一个二维码和一个 NFC 标签,以便用户可以使用他们的相机扫描它或点击它。
-
NFC 标签。
-
二维码。
-
Safari App 标签。
-
消息中的链接。
-
在地图中放置卡片。
-
iOS 14 新的 App Library 中的“最近使用过的 App Clips”类别。
在本节中,我们学习了什么是 App Clip,当用户使用它时他们的旅程是什么,以及可以用来触发它的不同调用方法。在下一节中,我们将为咖啡店构建和配置一个 App Clip。
开发您的第一个 App Clip
在本节中,我们将从一个现有的应用开始,并逐步向其中添加 App Clip。打开本书代码包中的 AppClipExample_start 项目。如果您启动该应用,您将看到我们有一个咖啡店应用,我们可以订购三种不同类型的饮料,查看订单,并通过 Apple Pay 或输入我们的信用卡详细信息进行支付:

Figure 14.3 – 我们应用的主要屏幕 – 菜单、支付和信用卡控制器
注意,这个示例应用的目的是帮助我们构建有趣的部分:App Clip。一些功能,如信用卡和 Apple Pay 支付,并未完全实现;它们只是模拟了这个功能。
在我们跳入 App Clip 流程之前,让我们花一点时间来回顾一下项目的结构和内容:

Figure 14.4 – 初始项目结构
该应用包含一个名为 AppClipExample 的单一目标。在该目标内部,我们拥有三个 ViewControllers(MenuViewController、PaymentViewController 和 CreditCardViewController)以及一些额外的视图(MenuView 和 MenuItemButton)。它仅包含一个名为 Item 的单一模型文件,该文件帮助我们处理菜单产品。我们还有其他一些常见文件,例如 AppDelegate 和 Assets – 简短且简单。然而,重要的是要记住这一点,因为当我们开始添加我们的 App Clip 时,这种架构将会演变。
在我们继续之前,请确保您在项目中使用的是您自己的 Apple 开发者账户设置。在 AppClipExample 目标中,执行以下操作:
-
选择您自己的开发团队。
-
将 App ID 更改为
{yourDomain}.AppClipExample。
在下一节中,我们将为我们的咖啡店应用创建 App Clip。我们将首先为 App Clip 创建一个新的目标。然后,我们将学习如何在我们的应用和其 App Clip 之间共享代码和图像(以及如何在不想共享完全相同的代码时创建异常)。最后,在测试之前,我们将学习如何在 App Store Connect 中配置 App Clip 的体验。
创建 App Clip 的目标
为了创建 App Clip,Xcode 项目需要为其创建一个目标。目前,我们的项目只有一个目标:AppClipExample。让我们继续创建一个用于 App Clip 的新目标。按照以下步骤操作:
-
在 Xcode 中,点击文件 | 新建 | 目标。
-
在出现的模态窗口中,选择iOS | 应用 | App Clip,如图所示:
![图 14.5 – 添加 App Clip 目标]()
图 14.5 – 添加 App Clip 目标
-
按下一步。现在,你可以配置 App Clip 目标的一些初始值。
-
按照以下方式输入名称
MyAppClip:![图 14.6 – App Clip 目标选项]()
图 14.6 – App Clip 目标选项
-
当你点击完成时,你会看到一个新弹窗:
![图 14.7 – 激活新方案]()
图 14.7 – 激活新方案
-
按激活,以便该方案可用于构建和调试。现在,看看项目结构;你会看到为 App Clip 添加了一个新的目标:

图 14.8 – App Clip 的新目标
但这并不是对项目所做的唯一更改。当 Xcode 添加新的 App Clip 目标时,在幕后做了几件事情:
-
它为构建和运行 App Clip 及其测试创建了一个新的方案。
-
它在App Clip 目标设置 | 签名与能力选项卡中添加了一个名为按需安装能力的新功能。此功能将捆绑标识为 App Clip。
-
在同一选项卡中,你还可以检查 App Clip 的 Bundle 标识符是否包含与完整应用的 Bundle 标识符相同的根。因此,如果你的应用 Bundle 标识符是
{yourDomain}.AppClipExample,则 App Clip 将具有{yourDomain}.AppClipExample.Clip。这是因为 App Clip 只对应一个父应用,因此它们共享部分 Bundle 标识符。 -
它还添加了
_XCAppClipURL。如果你编辑 App Clip 的方案,你会看到一个名为该名称的环境变量。默认值是https://example.com。但是,为了激活它,你需要激活变量名称附近的选择框。激活后,App Clip 将在启动时作为scene(_ scene: UIScene, continue userActivity: NSUserActivity)的一部分接收此 URL,以便你可以测试你想要触发的流程,具体取决于接收到的 URL。
此外,Xcode 还为你的主应用目标创建了一个新的构建阶段,该阶段将 App Clip 内嵌其中:

图 14.9 – 内嵌 App Clip 构建阶段
因此,正如你所见,尽管创建 App Clip 的目标是相对直接的,但在其内部还有很多事情在进行。现在你已经知道了所有细节。让我们在 iOS 模拟器上启动 App Clip(记得在启动时选择 MyAppClip App Clip 目标)。你将看到一个空白屏幕。这是正常的——我们仍然需要添加一些代码并准备我们的 App Clip!我们将在下一节中这样做。
与 App Clip 共享资源和代码
App Clips 通常需要从主应用中重用代码和资源。它们通常包含符合整个应用的一些功能。在我们的案例中,我们将创建一个显示主应用中所有内容的 App Clip,但不包括信用卡屏幕。为了提供一个快速且易于使用的 App Clip 体验,我们只允许用户查看菜单、查看他们的订单并使用 Apple Pay 支付;我们不希望他们在 App Clip 中输入任何信用卡详情。
让我们考虑从主应用中需要的所有文件和资源,并将它们添加到 App Clip 的目标中。让我们从资产开始。按照以下步骤操作:
-
在项目导航器中,点击
Assets.xcassets文件,并在应用和 App Clip 的Assets文件中,你可以删除MyAppClip文件夹内的Assets文件。否则,你将有两个AppIcon引用(每个资产文件中各一个),你将得到一个编译错误:![图 14.11 – 删除 MyAppClip 内部的第二个 Assets 文件![图片]()
图 14.11 – 删除 MyAppClip 内部的第二个 Assets 文件
将主应用的
Assets文件移动到项目顶部并重命名为SharedAssets也是一个好的实践。这样做可以让其他开发者知道该文件适用于所有目标:![图 14.12 – 项目顶部的 SharedAssets
![图片]()
图 14.12 – 项目顶部的 SharedAssets
一旦你做了这些更改,确保你可以构建和编译这两个目标;也就是说,应用和 App Clip。
现在,让我们包括 App Clip 目标以及我们需要的所有代码。之前我们提到,我们想要在主应用中找到的相同功能,除了信用卡屏幕。
-
在项目导航器中,选择以下文件并将它们添加到 App Clip 目标中:![图 14.13 – 与 App Clip 目标共享代码文件
![图片]()
图 14.13 – 与 App Clip 目标共享代码文件
注意我们如何在
ViewController、Views和Model文件夹中共享所有文件,除了CreditCardViewController。你现在已经共享了 App Clip 所需的所有图像和代码。然而,你仍然需要重用一些内容:故事板流程。
-
打开你的
AppClipExample目标中的Main.storyboard文件。稍微缩小一下,选择除了CreditCardViewController之外的所有内容(我们不希望在 App Clip 中包含这个):![图 14.14 – 复制你的 App 的 Main.storyboard 文件内容]()
图 14.14 – 复制你的 App 的 Main.storyboard 文件内容
-
在复制了上一张截图中的高亮元素之后,将它们粘贴到你的 MyAppClip 目标的
Main.storyboard文件中。 -
现在,选择 Navigation Controller 选项,并在右侧的 Options(选项)面板中勾选 Is Initial View Controller(是否为初始视图控制器)选项:
![图 14.15 – 为你的 App Clip 指定入口点]()
图 14.15 – 为你的 App Clip 指定入口点
现在,你的 App Clip 已经有了足够的代码、资源和流程,可以尝试运行它。
-
选择 MyAppClip 目标并启动它。此时应该没有问题地编译和运行。
然而,存在问题。如果你启动 App Clip 并下单,你会注意到我们仍然显示了 使用信用卡支付 按钮。之前我们提到,我们希望我们的 App Clip 只使用 Apple Pay 来简化服务,正如苹果公司的建议。在下一节中,我们将通过学习如何使用 Active Compilation Conditions 有条件地使用代码的一部分,根据执行它的目标来达到这一点。
使用活动编译条件
在上一节中,我们学习了如何在应用和 App Clip 之间共享代码和资源。这次,当 App Clip 执行特定文件时,我们需要“删除”一些代码。具体来说,我们希望在 App Clip 执行时隐藏 PaymentViewController。
要做到这一点,我们需要与 Active Compilation Conditions(活动编译条件)一起工作。按照以下步骤操作:
-
在
APPCLIP列表中,如图下所示截图所示:![图 14.16 – 添加活动编译条件]()
图 14.16 – 添加活动编译条件
-
在设置了
APPCLIP标志后,继续打开PaymentViewController文件。在viewDidLoad()方法的末尾添加以下代码:#if APPCLIP buttonPayByCard.isHidden = true #endif通过这段代码,我们告诉编译器,只有在我们执行 App Clip 目标时,才添加这一行。
-
让我们试一试。执行应用和 App Clip,比较两个屏幕。当 App Clip 启动时,你不应该看到 使用信用卡支付 按钮:

图 14.17 – App Clip(左侧)与 app(右侧)对比
如你所见,我们通过使用 Active Compilation Conditions 实现了显示 UI 不同部分的目标。
这太好了!我们已经配置了一个完美的 App Clip,它可以运行并显示我们希望用户看到的内容。在下一节中,我们将深入到这个过程的关键部分:调用 App Clip。
配置、链接和触发您的 App Clip
在本节中,随着 App Clip 准备就绪,我们将学习如何配置、链接和触发 App Clip。
用户可以通过使用各种调用来触发 App Clip,以下是一些示例:
-
扫描物理位置处的 NFC 标签或视觉代码
-
在 Siri 建议(基于位置的建议)中轻触
-
在 Maps 应用中轻触链接
-
在网站上轻触智能应用横幅
-
在 Messages 应用中轻触某人分享的链接(仅作为文本消息)
为了确保这些调用能够正常工作,您必须配置 App Clip 以处理链接,并且还需要配置 App Store 的 Connect App Clip Experiences。我们现在就来完成这项工作。
重要提示
当用户安装 App Clip 对应的应用时,完整的应用将替换 App Clip。从那时起,所有的调用都将启动完整的应用而不是 App Clip。因此,您的完整应用必须处理所有可能的调用并提供 App Clip 的功能。
App Clip 需要一个入口点,以便用户能够发现和启动它。在本节中,我们将回顾三个主题:
-
配置链接处理
-
配置 App Clip 体验
-
配置智能应用横幅
到本节结束时,我们的项目将有一个完全配置好的 App Clip 准备就绪。让我们开始吧!
配置链接处理
我们的第一步是配置我们的 Web 服务器和 App Clip 以处理链接。您可以使用本章代码包中的项目 AppClipExample_configure_start 来帮助完成这项工作。
如果您想在您的网站上显示您的 App Clip,您需要执行以下步骤:
-
在您的 Web 服务器上配置
apple-app-site-association文件。 -
向您的 App Clip 添加关联域名权限。
-
在您的 App Clip 中处理
NSUserActivity。 -
首先,让我们按照以下方式配置
apple-app-site-association文件:{ "appclips" : { "apps" : ["<Application Identifier Prefix>.<Bundle Identifier>"] }, … }此文件应位于服务器的根目录中。如果您已经设置了通用链接,那么您应该已经有这个文件了。您需要向其中添加高亮显示的代码,以便您能够引用您的 App Clip。请记住使用您自己的应用程序标识前缀和包标识符。
-
接下来,让我们添加关联域名权限。在 项目导航器 窗口中,选择项目和 App Clip 目标,然后转到 签名与能力:![Figure_14.18 – 签名与能力
![Figure_14.18_B14717.jpg]
图 14.18 – 签名与能力
-
接下来,添加一个新的关联域名,如图中所示:![Figure_14.19 – 添加关联域名
![Figure_14.19_B14717.jpg]
图 14.19 – 添加关联域名
现在您的服务器和 App Clip 已经配置好了,让我们来处理
NSUserActivity。 -
继续编辑 App Clip 方案。在
_XCAppClipURL变量下,将其分配以下值:https://myappclip.com/test?param=value。使用这个值设置,让我们学习如何处理它。
-
在
SceneDelegate.swift文件中。添加以下实现:func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { for activity in connectionOptions.userActivities { self.scene(scene, continue: activity) } } func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { // 1 guard userActivity.activityType == NSUserActivityTypeBrowsingWeb, let url = userActivity.webpageURL, let components = NSURLComponents(url: url, resolvingAgainstBaseURL: true) else { return } print(url) //2 guard let path = components.path, let params = components.queryItems else { return } print(path) print(params) // Handle your own navigation based on path and params }这两种方法正在处理当 App Clip 被 URL 类型的元素触发时将接收到的
NSUserActivity信息。看看在scene(…)方法中,我们是如何检查活动是否为NSUserActivityTypeBrowsingWeb类型,然后检查URL、path和components元素。在这里,你可以将你的 App Clip 导航到正确的元素。如果你启动 App Clip 并检查控制台的输出,你会看到这个:https://myappclip.com/test?param=value /test [param=value]
如你所见,我们正在处理在 _XCAppClipURL 目标环境变量中定义的测试 URL,并从中提取所需的路径和组件。当你想根据传入的 URL 在你的 App Clip 中处理不同的流程时,你可以这样测试。
如果你的应用是用 SwiftUI 构建的,那么你可以这样处理:
var body: some Scene {
WindowGroup {
ContentView().onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
guard let url = activity.webpageURL else { return }
// Navigate to proper flow based on the url
}
}
}
通过在 ContentView 中定义 onContinueUserActivity(NSUserActivityTypeBrowsingWeb),你可以使用传递的 activity 对象并从中提取传入的 URL。通过分析 URL,你可以链接到 App Clip 的正确部分。
现在我们已经配置了我们的服务器和 App Clip 以处理链接,让我们继续配置我们的 App Clip 体验。
配置我们的 App Clip 体验
随着 App Clip 和你的服务器准备好处理链接,我们可以开始配置我们的 App Clip 体验。App Clip 体验在 App Store Connect 中定义,并定义了 App Clip 卡片和不同场景下你想要处理的链接。App Clip 卡片看起来像这样:

图 14.20 – App Clip 卡片
如前一个截图所示,App Clip 卡片包含以下内容:
-
一个头部图像,描述你的应用或 App Clip 的主要功能:在这个例子中,我们展示了有人在准备咖啡。
-
一个标题,描述 App Clip 的名称:Mamine Cafe。
-
一个副标题,描述 App Clip 提供的功能:三指点单咖啡。
-
一个按钮,描述要执行的操作(例如打开视图的 App Clip):查看。
-
额外信息页脚:App Clip 的主要应用以及一个链接到 App Store 以下载它的链接。
这个 App Clip 卡片是设备将显示给用户以便他们可以启动你的 App Clip 的内容。我们将在 App Store Connect 中进行配置。
一旦你通过 App Store Connect 网站创建了相应的应用并上传了包含 App Clip 的构建版本,你将能够配置你的 App Clip 体验:

图 14.21 – App Clip 体验配置
如你所见,在默认的 App Clip 体验中,有三个主要配置项:
-
.png/.jpg。无透明度。 -
副标题的副本:最大字符数为 43。
-
行动号召:在这里,你可以选择打开、查看和播放。
您还可以点击 编辑高级体验 来配置不同的触发器和流程。如果您想从 NFC 标签或视觉码启动 App Clip,将您的 App Clip 与物理位置关联,或为多个业务创建 App Clip,那么您需要高级体验。首先,您需要指定将触发 App Clip 体验的 URL:

图 14.22 – 调用 App Clip 体验的 URL 配置
在按下 下一步 后,您可以配置 App Clip 卡:

图 14.23 – 配置高级 App Clip 卡
在这一点上,您可以配置卡的语言,甚至指定体验是否在特定位置触发。
添加高级 App Clip 体验可以让您的应用针对不同的 URL 显示不同的 App Clip。例如,如果您有一个咖啡店应用,您可以有一个用于显示菜单的 App Clip,一个用于立即订购咖啡的 App Clip,一个用于显示客户积分卡的 App Clip,等等。
在本节中,我们学习了如何在 App Store Connect 中配置 App Clip 及其体验。现在,让我们学习如何配置智能应用横幅,以便您可以在网站上触发横幅,让用户显示您的应用和 App Clip。
配置智能应用横幅
通过在您的网站上添加智能应用横幅,您为用户提供了一种快速且原生的方式来发现和启动您的应用。您需要在您的网站 HTML 文件中添加以下元标签(您希望横幅显示的位置):
<meta name="apple-itunes-app" content="app-id=myAppStoreID, app-clip-bundle-id=appClipBundleID, affiliate-data=myAffiliateData, app-argument=myAppArgument">
您需要将突出显示的值替换为您自己的。另外,请注意,当您启动 App Clip 时,app-argument 不可用。请记住,您应该将显示此横幅的任何页面的域名添加到您的应用和您的 App Clip 的关联域名权限中。
在本节中,我们学习了如何配置链接处理、App Clip 体验和智能应用横幅。在下一节中,我们将学习如何在开发过程中测试我们的 App Clip。
测试您的 App Clip 体验
一旦您完成开发并配置了您的 App Clip,就是时候测试一切,以确保您的 App Clip 体验按预期工作。有三种方法可以测试您的 App Clip 体验:
-
通过在 Xcode 中调试调用 URL(我们在这章中已经多次看到,当使用
_XCAppClipURL时)。 -
通过在 TestFlight 中为测试者创建 App Clip 体验(这样您的应用就准备好发布,并且是完整的)。
-
通过在设备上创建本地体验并在开发过程中测试来自 NFC 或视觉码的调用。
让我们更深入地探讨最后一点。让我们使用本书代码包中的 AppClipExample_test 项目,以便我们可以在其上测试我们的 App Clip 体验。
在开发过程中使用本地体验测试你的应用小部件体验的一个优点是,你不需要配置相关的域名,修改服务器,或处理 TestFlight。我们可以在本地完成所有这些操作。让我们开始吧:
-
首先,在任何设备上构建和运行你的应用和小部件。然后,在设备上,打开 设置 | 开发者 | 本地体验 并选择 注册本地体验...:![图 14.24 – 本地体验设置
![图片]()
图 14.24 – 本地体验设置
-
现在,你可以配置本地体验,如图下所示。请记住,为应用使用自己的值:

图 14.25 – 本地体验数据
要启动应用小部件卡片,你可以使用任何允许你生成与上一屏幕(在 URL 前缀 下)中指定的相同 URL 的 QR 码或 NFC 标签的工具。完成此操作后,当你用设备扫描时,你的应用小部件卡片应该会出现。
重要提示
在本地体验中定义的捆绑 ID 必须与你的应用小部件的捆绑 ID 匹配。
应用小部件必须在设备上安装。
如果相机应用没有打开应用小部件,请尝试使用 iOS 控制中心的 QR 码扫描仪(如果你没有,可以通过前往 设置 | 控制中心 来添加它)。
在本节中,我们学习了如何配置本地体验,以便在开发过程中测试我们的应用小部件卡片。现在,让我们总结本章内容。
摘要
在本章中,我们回顾了 iOS 14 最好的新功能之一:应用小部件。我们解释了什么是应用小部件,用户的旅程是什么,开发应用小部件时应关注哪些功能,以及有哪些选项可以调用它们。
在学习基础知识后,我们为一家咖啡店应用开发并配置了我们的第一个应用小部件。我们对项目进行了重构,以便在应用和小部件之间共享代码和资源。然后,我们学习了如何使用 活动编译条件 来触发代码库的一部分,但仅限于应用小部件或应用本身,以及如何配置我们的应用和服务器以处理链接。
最后,我们学习了如何在 App Store Connect 中配置应用小部件体验,以及如何在开发过程中测试它们。
在下一章中,你将了解视觉框架。
第十五章:第十五章:使用 Vision 框架进行识别
Vision 框架已经对开发者开放了几年。苹果公司一直在为其引入更好的功能,从文本识别到图像识别。在 iOS 14 中,Vision 框架带来了对文本识别和其他现有功能的更多改进,但它还允许开发者执行两种不同的操作:手部和身体姿态识别。这些新功能为开发者带来的可能性是无限的!只需想想健身房应用、瑜伽应用、健康应用等等。
在本章中,我们将学习关于 Vision 框架的基础知识以及如何使用文本识别的新进展。我们还将了解新的手部关键点识别,构建一个能够检测四个手指和拇指尖端的演示应用。本章代码包还提供了一个类似的示例,展示了人体姿态识别。以下几节将讨论这些主题:
-
Vision 框架简介
-
在图像中识别文本
-
实时识别手部关键点
到本章结束时,你将能够充满信心地使用 Vision 框架,能够将本章中解释的技术应用于实现 Vision 提供的任何类型的识别,从图像中的文本识别到视频中手部和身体姿态的识别。
技术要求
本章的代码包包括一个名为 HandDetection_start 的入门项目,以及几个名为 Vision.playground 和 RecognitionPerformance_start.playground 的游乐场文件。它还包含一个名为 BodyPoseDetection_completed 的完成示例。你可以在代码包仓库中找到它们:
https://github.com/PacktPublishing/Mastering-iOS-14-Programming-4th-Edition
Vision 框架简介
自 App Store 开始以来,许多应用都利用摄像头通过图像和视频识别构建了出色的功能。想想现在可以扫描支票或信用卡的银行应用,这样用户就不需要输入所有数字。还有可以拍照名片并提取相关信息的网络应用。甚至你 iPhone 上的照片应用也能检测照片中的面孔并将它们分类。
Vision 框架为开发者提供了一套强大的功能,使其比以往任何时候都更容易实现这些功能:从文本和图像识别到条形码检测、面部关键点分析,现在,随着 iOS 14 的推出,还有手部和身体姿态识别。
Vision 还允许使用 Core ML 模型,以便开发者能够增强他们应用中的对象分类和检测。Vision 自 iOS 11 和 macOS 10.13 以来一直可用。
Vision 中有几个概念在所有类型的检测中都是通用的(文本检测、图像检测、条形码检测等),包括VNRequest、VNRequestHandler和VNObservation实体:
-
VNRequest是我们想要执行的任务。例如,VNDetectAnimalRequest将用于在图片中检测动物。 -
VNRequestHandler是我们想要检测的方式。它允许我们定义一个完成处理程序,在那里我们可以处理结果并按需塑造它们。 -
VNObservation封装了结果。
让我们看看一个结合所有这些概念并展示 Vision 如何轻松帮助我们检测图像中文字的示例。打开名为Vision.playground的沙盒。这个示例代码从一个特定的 URL 获取图像并尝试从中提取/检测任何文本。所使用的图像是这张:


图 15.01 – 使用 Vision 提取文本的示例图像
如果我们尝试从这张图像中提取文本,我们应该得到像Swift 数据结构和算法或作者姓名,或标题下面的描述这样的结果。让我们回顾沙盒中的代码:
import Vision
let imageUrl = URL(string: "http://marioeguiluz.com/img/portfolio/Swift%20Data%20Structures%20and%20Algorithms%20Mario%20Eguiluz.jpg")!
// 1\. Create a new image-request handler.
let requestHandler = VNImageRequestHandler(url: imageUrl, options: [:])
// 2\. Create a new request to recognize text.
let request = VNRecognizeTextRequest { (request, error) in
guard let observations = request.results as? [VNRecognizedTextObservation] else { return }
let recognizedStrings = observations.compactMap { observation in
// Return the string of the top VNRecognizedText instance.
return observation.topCandidates(1).first?.string
}
// Process the recognized strings.
print(recognizedStrings)
}
// 3\. Select .accurate or .fast levels
request.recognitionLevel = .accurate
do {
// 4\. Perform the text-recognition request.
try requestHandler.perform([request])
} catch {
print("Unable to perform the requests: \(error).")
}
让我们逐条查看编号的注释:
-
首先,我们使用给定的图像 URL 创建一个
VNImageRequestHandler实例。我们实例化这个处理程序以在图像上执行 Vision 请求。记住,我们稍后需要调用perform(_:)来启动分析。 -
现在我们创建一个
request(VNRecognizeTextRequest)实例,我们将在之前实例化的requestHandler实例上执行它。你可以在一个requestHandler实例上执行多个请求。我们定义了一块代码,当请求完成时将执行该代码。在这个块中,我们从请求结果中提取观察结果(VNRecognizedTextObservation实例)。这些观察结果将包含从图像中分析出的文本的潜在结果(VNRecognizedText实例)。我们打印出每个观察结果中的topCandidate,根据 Vision 参数,这应该是最佳匹配。 -
我们可以指定请求的识别级别。在这个例子中,我们使用
.accurate(另一种选择是.fast)。我们将在稍后看到.fast的结果以及何时使用其中一个。 -
最后,我们在
requestHandler实例上执行请求,使用perform(_:)方法执行所有操作。
如果你执行代码,沙盒中的控制台将显示以下内容:
["Erik Azar, Mario Eguiluz", "Swift Data", "Structure and", "Algorithms", "Master the most common algorithms and data structures,", "and learn how to implement them efficiently using the most", "up-to-date features of Swift", "Packt>"]
这些结果看起来很棒,对吧?如果你重新检查图像,我们会从其中提取正确的文本!作者姓名、标题(每行)、描述等等!看起来是个很棒的结果!但你有没有注意到,当你执行沙盒时,它需要一段时间才能完成?这是因为我们使用了.accurate选项。让我们看看如果我们使用.fast会发生什么。在沙盒代码中更改它:
// 3\. Select .accurate or .fast levels
request.recognitionLevel = .fast
输出如下:
["Swift Data", "Structure and", "Algorithms", "upto4atefeaturesofSwift3", "Packt>", "ErfkAz•r. M•rb Eguluz", "ml5tertket(w4VIthMsarodats5tr&KtUre", "learnItolpIettmeffK1WttIY5lt1fft", "LIJJ"]
这次,分析可以做得更快,但正如你所见,对于我们所期望的结果(我们希望正确地检测文本!)来说,结果要差得多。为什么有人会优先考虑速度而不是准确性呢?嗯,对于某些应用来说,速度是关键,为了它牺牲一些准确性是可以接受的。想想基于实时摄像头的翻译或者应用实时滤镜拍照。在这些场景中,你需要快速处理。我们将在本章后面进一步讨论这个问题。
这个游乐场示例应该能帮助你了解 Vision 所包含的惊人潜力。仅仅通过几行代码,我们就能够处理并提取图像中的文本,没有任何问题或复杂的操作。Vision 允许开发者做令人惊叹的事情。让我们在接下来的章节中更深入地探讨它,从对图像文本检测的更详细分析开始。
在图像中识别文本
自从 Vision 框架的第一个迭代以来,它一直在改进图像中检测文本的能力。在本节中,我们将学习一些最先进的技术,以在 iOS 14 上获得最佳结果。
在上一节中,我们看到了在 Vision 中,文本检测可以通过两种不同的方式发生,这取决于我们在请求中指定的recognitionLevel的值:.fast和.accurate。让我们看看它们的区别:
-
.accurate。它处理旋转文本或不同字体的效果不如.accurate方法。 -
.fast但更准确(当然!)它的工作方式与我们的大脑识别单词的方式相同。如果你读“m0untain”这个词,你的大脑可以从它中提取“mountain”,并且知道 0(零)代表一个 o。如果你使用.fast,它按字符识别,0(零)在你的结果中仍然是 0(零),因为没有任何上下文被考虑。
在两种情况下,在初始识别阶段完成后,结果都会传递到一个传统的自然语言处理器进行语言处理,其结果是观察结果。整个过程仅在设备上发生。
那么,什么时候应该使用.fast呢?你可能想知道。嗯,有一些场景中它比.accurate更方便:
-
为了快速读取代码或条形码
-
当用户交互是一个关键方面,你希望从文本检测中得到快速响应时
为了展示识别级别的差异,让我们使用不同的技术分析相同的图像。你还将学习一些可以应用到你的项目中的有用技巧。按照这里给出的步骤进行:
-
请打开名为
RecognitionPerformance_start.playground的游乐场。代码与我们之前章节尝试的代码大致相同。现在我们使用的图像中包含一个 4 位数,代表书籍的序列号:
![图 15.02 – 作者名字下方带有序列号(1015)的书籍封面]()
![img/Figure_15.02_B14717.jpg]()
图 15.02 – 作者名字下方的带有序列号(1015)的书籍封面
如果你仔细观察数字字体,你会发现对于计算机来说,判断某些数字是数字还是字母可能有些棘手。这是故意为之的。在这个例子中,我们将测试 Vision 的能力。
-
继续执行 playground 代码。控制台输出应该如下所示:
["Erik Azar, Mario Eguiluz", "1015", "Swift Data", "Structure and", "Algorithms", "Master the most common algorithms and data structures,", "and learn how to implement them efficiently using the most", "up-to-date features of Swift", "Packt>"] 1.9300079345703125 seconds
我们已经成功检索到书的序列号:1015。代码也在测量完成文本识别过程所需的时间。在我们的案例中,它花费了1.93 秒(这可能会因计算机而异,也可能因执行而异)。我们能做得更好吗?让我们尝试一些可以帮助我们提高处理时间同时保持相同准确性的技术。我们将从感兴趣区域开始。
感兴趣区域
有时候,当我们使用 Vision 分析图像时,我们不需要处理整个图像。例如,如果我们处理的是一种特定的表格,我们事先知道名字总是位于文档的顶部,我们可能只想处理那个区域。如果我们只需要特定区域,处理整个表格只会浪费时间和资源。
让我们假设在之前的例子(书籍封面)中,我们想要提取的序列号总是位于左上角。我们如何加快 1.93 秒的处理时间?我们可以通过定义感兴趣区域来实现。定义感兴趣区域将告诉 Vision 只处理该区域,避免处理图像的其余部分。这将导致更快的处理时间。
regionOfInterest是VNRequest的CGRect属性:
-
它定义了一个矩形区域,请求将在该区域内执行。
-
矩形被归一化到图像的尺寸,这意味着感兴趣区域的宽度和高度从 0 到 1。
-
矩形的起点在图像的左下角,即(0,0)。右上角将是(1,1)。
-
默认值是
{{0,0},{1,1}},它覆盖了从左下角(0,0)到右上角(1,1),宽度为 1,高度为 1:整个图像。
在以下图中,你可以看到我们需要定义的感兴趣区域来捕获序列号(1015):

图 15.03 – 感兴趣区域
让我们把那个区域添加到上一节中的代码:
-
在
ScanPerformance_start.playground项目中,在将recognitionLevel设置为.accurate之后添加以下代码:request.regionOfInterest = CGRect(x: 0, y: 0.8, width: 0.7, height: 0.2) -
现在启动 playground 并在控制台中检查结果:
["Erik Azar, Mario Eguiluz", "1015"] 1.2314139604568481 seconds与之前的结果相比,有几个不同之处:
-
我们不再提取那么多文本。现在我们定义了感兴趣区域,我们只提取该区域包含的单词/数字。
-
我们将处理时间从 1.93 秒减少到 1.23 秒。这提高了 36%。
-
-
现在我们尝试将感兴趣区域缩小,仅捕获序列号。将区域修改为以下:
request.regionOfInterest = CGRect(x: 0, y: 0.8, width: 0.3, height: 0.1) -
启动游乐场。现在控制台输出如下:
.fast for recognitionLevel instead of .accurate, if what we want is speed? Let's see what happens. -
将此行修改为使用
.fast:request.recognitionLevel = .fast -
保存并执行。检查控制台输出:
["Iois"] 0.5968900661468506 seconds
你可以看到这次,处理时间再次缩短,但结果完全不精确。我们检测到的不是1015,而是错误地得到了Iois。
然而,在具有领域知识的情况下,有一种常见的解决这种情况的方法。在我们的例子中,我们知道处理后的字符应该是数字。因此,我们可以调整从视觉输出的结果来改进结果并修复误分类。例如,查看以下调整:
-
字符“I”可以是“1。”
-
字符“o”可以是“0。”
-
字符“s”可以是“5。”
让我们在代码中实现这个功能:
-
在游乐场文件的最后,添加以下方法:
extension Character { func transformToDigit() -> Character { let conversionTable = [ "s": "5", "S": "5", "o": "0", "O": "0", "i": "1", "I": "1" ] var current = String(self) if let alternativeChar = conversionTable[current] { current = alternativeChar } return current.first! } }我们通过添加一个名为
transformToDigit()的新方法来扩展Character类。这个新方法将帮助我们改进潜在的误分类。注意在方法本身中,我们有一个与形状相似的字母字符表,这些字母与数字相关。我们所做的是将这些字母转换成相应的数字。 -
现在让我们使用它。在
print(recognizedStrings)行下方,添加以下代码:if let serialNumber = recognizedStrings.first { let serialNumberDigits = serialNumber.map { $0.transformToDigit() } print(serialNumberDigits) }我们正在获取视觉处理的结果;在我们的例子中,它是
"Iois",并且对于每个字符,我们对其应用我们新的transformToDigit()方法。 -
执行代码,你将在控制台看到以下结果:
["Iois"] ["1", "0", "1", "5"] 0.5978780269622803 seconds
看起来很棒!注意现在将"Iois"转换成"1" "0" "1" "5"后看起来好多了。同时,注意处理时间并没有增加太多;这个操作相对容易计算。
现在我们总结一下本节我们做了什么,以及每一步的改进。我们首先处理了一张整个图像,并使用.accurate识别级别,这花费了我们 1.93 秒。然后,我们应用了感兴趣区域,只处理我们感兴趣的图像部分,将处理时间减少到 1.23 秒。之后,我们将.accurate改为.fast。这一改变将处理时间减少到 0.59 秒,但结果是不正确的。最后,我们实现了一个简单的算法来改进结果,使它们与.accurate级别一样好。所以,最终我们得到了完美的结果,处理时间仅为 0.59 秒,而不是 1.93 秒!
在下一节中,你将了解 iOS14 的一个新功能,即手势检测。
实时识别手势地标
iOS 14 中 Vision 的一个新增功能是手部检测。这个新功能可以检测图像和视频中的手部,允许开发者以很高的精度找到视频帧或照片中手腕和各个手指的位置。
在本节中,我们将解释手部检测背后的基础知识,并通过一个示例项目演示其工作原理。让我们从我们将能够识别的手部特征点开始。
理解手部特征点
我们将能够在手中检测到 21 个特征点:
-
拇指 4 个点
-
每个手指 4 个点(总共 16 个点)
-
腕部 1 个点
如您所见,Vision 可以区分手指和拇指。在手指和拇指上,都有 4 个感兴趣点。以下图示显示了这些特征点的分布情况:

图 15.04 – 手指和拇指特征点
注意,在手腕中间也有一个特征点。
对于四个手指,我们可以使用以下键单独访问它们:
-
小指 -
中指 -
无名指 -
indexFinger
在每个手指内部,我们可以访问四个不同的特征点:
-
指尖
-
DIP
-
PIP
-
MCP
注意,对于拇指,这些名称略有不同(TIP、IP、PIP 和 CMC)。在本节稍后我们将构建的示例代码中,我们将演示如何使用这些点和每个手指以及拇指。
Vision 能够同时检测不止一个手部。我们可以指定我们想要检测的最大手部数量。此参数将影响检测的性能。使用maximumHandCount设置限制。
为了性能和准确性,如果手部不在帧的边缘附近,如果光线条件良好,以及如果手部与摄像头角度垂直(因此整个手部都可见,而不仅仅是边缘),则更好。此外,考虑到有时脚部可能被识别为手部,因此请避免混淆。
理论就到这里;让我们直接进入代码示例!我们将构建一个演示应用程序,该程序将能够使用手机的正面视频摄像头检测手部特征点,并在检测到的点上显示叠加层。
实现手部检测
在本节中,我们将实现一个演示应用程序,该程序将能够使用手机的正面视频摄像头检测手部特征点。
该项目的代码包包含初始项目和最终结果。请打开名为HandDetection_start的项目。
该项目包含两个主要文件:一个名为CameraView.swift的UIView实例和一个名为CameraViewController.swift的UIViewController实例。
视图包含辅助方法来在坐标上绘制点。它将作为覆盖层绘制在摄像头视频流之上。只需知道,showPoints(_ points: [CGPoint], colour: UIColor) 方法将允许我们在视频摄像头流的覆盖层上绘制一个 CGPoint 结构体的数组。
视图控制器将是示例的核心,我们将在这里实现执行手部检测的相关代码。请打开 CameraViewController.swift 文件。让我们检查我们将逐步填充的代码框架。
在文件顶部,我们定义了四个属性:
-
handPoseRequest: VNDetectHumanHandPoseRequest。我们将在此视频流的顶部应用此请求,以检测每一帧中的手部关键点。如果我们检测到任何,我们将在覆盖层上显示一些点来显示它们。 -
videoDataOutputQueue,cameraView, 和cameraFeedSession。
使用 viewDidAppear 和 viewWillDisappear 方法,我们正在启动/创建和停止摄像头的 AVCaptureSession。
最后,在接下来的四个方法中,我们有四个待办事项注释,我们将逐一实现以创建此应用程序。让我们总结一下我们将要执行的待办事项:
-
待办事项 1: 只检测一只手。
-
待办事项 2: 创建视频会话。
-
待办事项 3: 在视频会话中执行手部检测。
-
待办事项 4: 处理并显示检测到的点。
我们将在以下小节中实现这四个任务。
检测手部
视觉不仅可以一次检测一只手。我们要求它检测的手越多,性能影响就越大。在我们的示例中,我们只想检测一只手。通过在请求中将 maximumHandCount 设置为 1,我们将提高性能。
让我们从在 // 待办事项 1 下方添加以下代码开始:
// TODO 1: Detect one hand only.
handPoseRequest.maximumHandCount = 1
现在,让我们创建一个视频会话来捕获设备前置视频摄像头的视频流。
创建视频会话
对于第二个任务,我们将填充 setupAVSession() 方法内的代码。请将以下代码粘贴到方法中:
// TODO 2: Create video session
// 1 - Front camera as input
guard let videoDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) else {
fatalError("No front camera.")
}
// 2- Capture input from the camera
guard let deviceInput = try? AVCaptureDeviceInput(device: videoDevice) else {
fatalError("No video device input.")
}
首先,我们通过以下方式创建 videoDevice: AVCaptureDevice 实例,查询视频前置摄像头(如果存在!):
guard let videoDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) else {
fatalError("No front camera.")
}
然后,我们使用那个 videoDevice 生成一个 deviceInput: AVCaptureDeviceInput 实例,它将是用于流的视频设备,以下代码所示:
guard let deviceInput = try? AVCaptureDeviceInput(device: videoDevice) else {
fatalError("No video device input.")
}
现在添加以下代码:
let session = AVCaptureSession()
session.beginConfiguration()
session.sessionPreset = AVCaptureSession.Preset.high
// Add video input to session
guard session.canAddInput(deviceInput) else {
fatalError("Could not add video device input to the session")
}
session.addInput(deviceInput)
let dataOutput = AVCaptureVideoDataOutput()
if session.canAddOutput(dataOutput) {
session.addOutput(dataOutput)
// Add a video data output.
dataOutput.alwaysDiscardsLateVideoFrames = true
dataOutput.videoSettings = [kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_420YpCbCr8BiPlanarFullRange)]
dataOutput.setSampleBufferDelegate(self, queue: videoDataOutputQueue)
} else {
fatalError("Could not add video data output to the session")
}
session.commitConfiguration()
cameraFeedSession = session
在创建 videoDevice 实例后,我们创建一个新的 session: AVCaptureSession 实例。会话创建后,我们将 videoDevice 作为输入,创建并配置一个输出以处理视频流。我们通过调用以下代码将类本身分配为 dataOutput AVCaptureVideoDataOutputSampleBufferDelegate:
dataOutput.setSampleBufferDelegate(self, queue: videoDataOutputQueue)
这意味着当前置视频摄像头捕获新的帧时,我们的会话将处理它们并将它们发送到我们的代理方法,我们将在下一步(待办事项 3)中实现。
在视频会话中执行手部检测
现在我们已经设置并配置了视频会话,是时候处理每一帧了,并尝试检测任何手部和它们的地标!我们需要实现captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection)方法。
在// TODO 3: Perform hand detection on the video session行下,添加以下代码:
var thumbTip: CGPoint?
var indexTip: CGPoint?
var ringTip: CGPoint?
var middleTip: CGPoint?
var littleTip: CGPoint?
我们想要检测四个手指(食指、中指、无名指和小指)以及大拇指的指尖。因此,我们创建了五个类型为CGPoint的变量来存储它们的坐标,如果找到了的话。
在这些新行之后,添加以下代码:
let handler = VNImageRequestHandler(cmSampleBuffer: sampleBuffer, orientation: .up, options: [:])
do {
try handler.perform([handPoseRequest])
guard let observation = handPoseRequest.results?.first else {
return
}
// Get observation points
} catch {
cameraFeedSession?.stopRunning()
fatalError(error.localizedDescription)
}
使用这段代码,我们要求 Vision 在sampleBuffer(视频流)上执行handPoseRequest。然后,我们使用guard(使用guard)来防止没有检测到观察结果的情况(这样如果视频帧中没有手,我们就会在这里停止)。
但是如果guard没有触发,这意味着我们有一些手部地标需要处理。在// Get observation points行之后添加以下代码:
let thumbPoints = try observation.recognizedPoints(.thumb)
let indexFingerPoints = try observation.recognizedPoints(.indexFinger)
let ringFingerPoints = try observation.recognizedPoints(.ringFinger)
let middleFingerPoints = try observation.recognizedPoints(.middleFinger)
let littleFingerPoints = try observation.recognizedPoints(.littleFinger)
guard let littleTipPoint = littleFingerPoints[.littleTip], let middleTipPoint = middleFingerPoints[.middleTip], let ringTipPoint = ringFingerPoints[.ringTip], let indexTipPoint = indexFingerPoints[.indexTip], let thumbTipPoint = thumbPoints[.thumbTip] else {
return
}
现在我们正在从观察结果中提取与拇指和四个手指相关的任何recognizedPoints()实例。请注意,我们使用try来执行此操作,因为结果并不保证。使用提取出的识别点,我们稍后使用guard语句解开每个手指和大拇指的指尖。
在这个阶段,我们应该有五个变量,分别存储每个手指的指尖坐标以及大拇指的坐标。
尽管我们已经有了我们正在寻找的五个坐标,但我们仍然需要执行一个额外的步骤。Vision 坐标与AVFoundation坐标不同。让我们转换它们;在最后一个guard语句之后添加以下代码:
thumbTip = CGPoint(x: thumbTipPoint.location.x, y: 1 - thumbTipPoint.location.y)
indexTip = CGPoint(x: indexTipPoint.location.x, y: 1 - indexTipPoint.location.y)
ringTip = CGPoint(x: ringTipPoint.location.x, y: 1 - ringTipPoint.location.y)
middleTip = CGPoint(x: middleTipPoint.location.x, y: 1 - middleTipPoint.location.y)
littleTip = CGPoint(x: littleTipPoint.location.x, y: 1 - littleTipPoint.location.y)
如您所见,两个系统中的x坐标是相同的,但y坐标不同。在 Vision 中,左下角是(0,0)。因此,我们只需要将 Vision 点的y坐标减去 1,就可以得到AVFoundation系统上的结果。
太棒了!在这个阶段,我们已经有了手部地标检测系统,并以AVFoundation的CGPoint坐标形式得到结果。最后一步是绘制这些点!
在catch块(它外面)之后添加以下代码,正好在func captureOutput(…)方法的末尾:
DispatchQueue.main.sync {
self.processPoints([thumbTip, indexTip, ringTip, middleTip, littleTip])
}
我们在主线程中调用processPoints(…)方法,因为我们希望它在 UI 上工作,所以我们通过将这项工作调度到正确的线程来确保一切工作完美。接下来,让我们实现processPoints(…)方法。
处理和显示检测到的点
在captureOutput(…)方法内部检测到手部地标后,我们现在想要将它们绘制到相机叠加层中。用以下代码替换processPoints(…)的空实现:
func processPoints(_ fingerTips: [CGPoint?]) {
// Convert points from AVFoundation coordinates to UIKit // coordinates.
let previewLayer = cameraView.previewLayer
let convertedPoints = fingerTips
.compactMap {$0}
.compactMap {previewLayer.layerPointConverted(fromCaptureDevicePoint: $0)}
// Display converted points in the overlay
cameraView.showPoints(convertedPoints, color: .red)
}
记得我们是如何使用CGPoints转换为AVFoundation坐标的吗?现在我们想要将这些点转换为UIKit预览层。我们正在对它们执行map操作,最后,我们调用cameraView辅助方法showPoints来显示它们。
一切现在都已就绪!是时候构建并运行应用程序了。你会看到自拍相机被触发,如果你将其对准你的手,你的手指和拇指的尖端应该会被红色圆点覆盖。试一试,你应该会得到以下类似的结果:

图 15.05 – TIP 检测
然而,这种方法仍然存在一些问题!试试这个:让应用程序检测你的手,然后从摄像机的视图中移除手部 – 红色圆点仍然在叠加层上!当没有检测到手时,它们没有被清理。
这有一个简单的解决方案。原因是,在captureOutput(…)方法内部,我们并不总是执行processPoints(…)方法。有时(guard语句)我们返回而不调用它。解决方案是将processPoints(…)块封装到defer中,将其移动到代码的开头,就在我们定义存储每个尖端坐标的五个属性之后。它应该看起来像这样:
public func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
var thumbTip: CGPoint?
var indexTip: CGPoint?
var ringTip: CGPoint?
var middleTip: CGPoint?
var littleTip: CGPoint?
defer {
DispatchQueue.main.sync {
self.processPoints([thumbTip, indexTip, ringTip, middleTip, littleTip])
}
}
…
}
突出的代码是我们将其封装到defer中的部分(因此它将在返回方法之前始终执行)。再次执行应用程序,你会注意到当屏幕上没有手时,红色圆点也不会出现!我们正在使用空值调用processPoints,因此没有东西被绘制。通过这一步,我们就有了一个正在运行的手部关键点检测示例!恭喜!
身体姿态检测
Vision 还为 iOS 14 提供了身体姿态检测。身体姿态检测与手部检测非常相似,所以我们不会对其进行逐步演示。但本书的代码包中包含了一个类似本节的应用程序示例,但用于身体姿态检测。你可以查看名为BodyPoseDetection_completed的项目,并查看它与手部检测项目之间的细微差别。
在本节中,我们学习了新的 Vision 方法来检测手部关键点,以及如何使用手机的视频流作为输入来检测手部关键点(而不仅仅是检测静态图像中的手部)。我们还提供了一个类似的演示,可用于身体姿态检测。让我们跳到总结,完成本章。
总结
我们从学习每个 Vision 功能的基石开始本章:如何使用VNRequest实例、其对应的VNRequestHandler实例以及产生的VNObservation实例。
在学习基础知识之后,我们将它们应用于文本识别。我们通过使用.fast和.accurate比较了不同的识别级别。我们还了解了感兴趣区域及其如何影响视觉请求的性能。最后,通过应用领域知识、修复潜在的视觉错误和误读,我们在文本识别方面提高了我们的结果。
最后,我们学习了新的手部地标识别功能。但这次,我们还学习了如何将视觉请求应用于实时视频流。我们能够在来自设备前摄像头的视频流中检测到手部地标,并显示叠加层以显示结果。本章还提供了一个类似的示例,该示例可以应用于身体姿态识别。
在下一章中,我们将学习 iOS 14 的一个全新功能:小部件!
第十六章:第十六章:创建你的第一个部件
随着 iOS 14 的推出,苹果引入了 WidgetKit。现在,用户可以在主屏幕上使用部件。通过在主屏幕上显示少量有用的信息,部件为用户提供了一个长期期待的关键功能。一些例子包括查看股市价格、天气或交通状况、日历上的下一次会议等,只需在主屏幕上扫一眼即可。用例是无限的!
在本章中,你将了解 WidgetKit 的基本原理,以及部件设计和它们的限制性关键方面。然后,我们将从头开始构建一个部件。从一个非常简单、小尺寸的部件开始,我们将通过创建新尺寸、网络调用、动态配置、占位符视图等来扩展其功能!我们将在接下来的章节中讨论所有这些主题:
-
介绍部件和 WidgetKit
-
开发你的第一个部件
到本章结束时,你将能够创建自己的部件,使你的应用能够提供独特的全新功能,从而吸引用户下载并更积极地使用你的应用。
技术要求
本章的代码包包括一个名为CryptoWidget_1_small_start的入门项目及其后续部分。你可以在代码包仓库中找到它们:
github.com/PacktPublishing/Mastering-iOS-14-Programming-4th-Edition
介绍部件和 WidgetKit
在本节中,我们将学习 WidgetKit 的基础知识以及 iOS 14 中部件的选项和指南。
用户和开发者多年来一直在请求一个特定的功能:他们都想在主屏幕上拥有部件。部件使用户能够配置、个性化并在主屏幕上消费相关的小数据块。它们还使开发者能够提供可快速查看的内容,并为他们的应用增加价值。
下面是部件(在本例中为日历和提醒事项部件)在 iPhone 主屏幕上的预览:

图 16.1 – 带有部件的 iOS 主屏幕
现在在 iOS 14 和 macOS 11 及更高版本中可以实现这一点。开发者可以使用WidgetKit和 SwiftUI 的新部件 API在 iOS、iPadOS 和 macOS 上创建部件。
iOS 14 中的智能堆叠包含一组不同的部件,包括用户经常打开的部件。如果用户启用智能旋转,Siri 可以在自定义堆叠中突出显示相关的部件。
iOS 13 及更早版本创建的部件
在 iOS 14 之前创建的部件无法放置在主屏幕上,但它们仍然可在今日视图和 macOS 通知中心中访问。
在介绍新部件功能之后,让我们看看在构建部件时有哪些选项,以及苹果的设计指南是什么。
部件选项
用户可以在 iOS 的主屏幕或 Today 视图、iPad 的 Today 视图或 macOS 的通知中心上放置小部件。
小部件有三种尺寸:小、中、大。每种尺寸应有不同的用途;小部件的大版本不应只是小尺寸版本字体和图像的放大,而是应该包含更多信息。小部件不同尺寸背后的理念是,尺寸越大,包含的信息应该越多。例如,天气小部件在小尺寸版本中只提供当前温度,但在中尺寸版本中还将包括每周天气预报。
用户可以在屏幕的不同部分排列小部件,甚至创建小部件堆叠来分组它们。
为了开发一个小部件,开发者需要为他们应用创建一个新的扩展:一个小部件扩展。他们可以使用时间线提供者来配置小部件。时间线提供者在需要时更新小部件信息。
假设一个小部件需要一些配置(例如,在天气应用中选择默认城市,或在大型天气小部件中显示多个城市)。在这种情况下,开发者应在小部件扩展中添加自定义 Siri 意图。创建自定义 Siri 意图会自动为小部件提供用户定制的界面。
小部件指南
当为 iOS 14 或 macOS 11 创建小部件时,请考虑以下设计指南:
-
将你的小部件聚焦于你应用的主要功能。例如,如果你的应用是关于股市的,你的小部件可以显示用户投资组合的总价值。
-
每个小部件的大小应显示不同数量的信息。如果你的骑行追踪小部件在小尺寸下显示今天燃烧的卡路里,它也可以在中尺寸下显示每天的周卡路里,并在大尺寸下添加额外的信息,例如行驶的公里数/英里数。
-
相比于固定信息,更倾向于动态信息,这些信息在一天中会变化;这将使你的小部件对用户更具吸引力。
-
相比于配置选项更多的小部件,更倾向于简单的小部件。
-
小部件提供点击目标和检测功能,使用户能够点击它们以在应用中打开详细信息。小型小部件支持单个点击目标;中型和大型小部件支持多个目标。尽量保持简单。
-
支持深色模式。如有需要,还可以考虑使用 SF Pro 作为字体和 SF Symbols。
在本节中,我们了解了新的小部件功能和 WidgetKit。我们涵盖了构建小部件时可用选项和设计指南。在下一节中,我们将从头开始构建一个简单的小部件,并逐步添加更多功能。
开发你的第一个小部件
在本节中,我们将使用一个现有应用,逐步创建其上的小部件。
我们将要开发的这款应用是一款加密货币行情应用,用户可以查看不同加密货币的最新价格。我们将创建一个小部件,让用户可以直接从主屏幕上查看加密货币的价格,这样他们就不必打开应用本身。
请打开本章代码包中名为 CryptoWidget_start 的项目。这是我们构建小部件的基础项目。在开始任务之前,让我们快速回顾一下基础项目本身。
构建并发布项目。应用显示加密货币价格列表:

图 16.2 – 基础应用
你还可以进入每个硬币的详细视图,但仅出于演示目的,它不包含额外的信息。由于我们将使用现有的代码库,在修改它之前,让我们突出一些关键点:
-
项目被组织成三个文件组(除了默认生成的文件和应用代理):
Views、Model和Network。 -
Views文件夹包含项目的UIView文件。视图是使用 SwiftUI 创建的。当使用 WidgetKit 构建小部件时,SwiftUI 是推荐的方式。如果你不熟悉 SwiftUI,不要担心;在这个项目中,我们只会使用基本的视图。 -
在
Network文件夹内部,我们有一个名为DataManager.swift的类。这个类包含getData()方法,负责从 CoinMarketCap 的 API 中获取加密货币价格。你可以在他们的网站上创建一个免费的开发者账户以获取最新的价格。否则,演示应用使用一个演示密钥,为我们提供的加密货币提供历史价格。如果你创建自己的账户,你只需要用你自己的密钥替换这个密钥的值:let apiKeyValue = "b54bcf4d-1bca-4e8e-9a24-22ff2c3d462c"。 -
Model文件夹包含与getData()方法结果一起工作的基本结构体:Coin和CoinList。这些结构体将包含来自 API 的加密货币符号和价格信息。
现在,让我们看看项目的主体视图,它位于 Views 文件夹内的 ContentView.swift 文件中。ContentView 结构体包含 @ObservedObject var dataManager = DataManager()。@ObservedObject 标签表示这个 SwiftUI 视图将观察 dataManager 结构体的变化,并将刷新/响应这些变化。记住,dataManager 是我们用来从网络上检索加密货币数据的类,所以我们的主要视图观察任何变化是有意义的。检查 ContentView 的主体:
var body: some View {
NavigationView {
if dataManager.loading {
Text("Loading...")
} else {
CoinListView(data: dataManager.coins.data)
}
}
}
当 dataManager 处于加载状态时,视图将显示简单的 Loading… 文本,当 dataManager 加载完成并包含一些数据时,将显示 CoinListView。很简单!现在,如果你检查 CoinListView.swift 的实现,你会看到它是一个简单的列表,显示它接收到的每个硬币的信息:
var body: some View {
VStack {
ForEach(data, id: \.symbol){ coin in
CoinRow(coin: coin)
}
}
}
目前没有什么太花哨的!到目前为止,我们有 dataManager,它调用 getData() 从 API 获取代币信息,以及 ContentView,在数据被调用时显示 Loading… 文本,并在获取到代币信息时显示代币详情列表。所有这些都是在几个类和几行代码中完成的…这就是 SwiftUI 的力量!现在我们已经对基础项目有了清晰的了解,让我们开始创建小部件扩展,以开始构建我们出色的加密货币小部件!
创建小部件扩展
将小部件添加到应用程序的第一步是创建一个小部件扩展。创建小部件扩展将为我们提供一个默认的小部件协议实现,这将帮助我们准备好基本组件。
在创建扩展之前,让我们回顾一下以下图中显示的小部件扩展的各个部分:

图 16.3 – 小部件构建块
如前图所示,以下是小部件扩展构建块的解释:
-
如果小部件可以被用户配置,它将需要一个自定义 Siri 意图配置定义。例如,显示股票的小部件可以要求用户进行配置以选择要显示的股票。
-
需要一个提供者来提供要在小部件上显示的数据。提供者可以生成占位符数据(即在用户浏览小部件画廊或加载时显示),时间线(表示随时间变化的数据),以及快照(组成时间线的单元)。
-
需要一个 SwiftUI 视图来显示数据。
当创建小部件目标时,Xcode 将自动生成所有这些类的占位符。现在让我们来做这件事;按照以下步骤操作:
-
在名为
CryptoWidget_start的项目中,转到 文件 | 新建 | 目标 | 小部件扩展。 -
您可以使用
CryptoWidgetExtension作为产品名称,并勾选 包含配置意图 选项:![图 16.4 – 小部件扩展选项]()
图 16.4 – 小部件扩展选项
-
点击以下弹出窗口中的 激活。
如果您已经按照前面的步骤操作,那么您的项目现在应该包含一个具有以下文件夹结构的新目标:

图 16.5 – 小部件目标结构
在创建小部件扩展时,Xcode 已经自动生成了两个重要的文件:CryptoWidgetExtension.swift 和 CryptoWidgetExtension.intentdefinition。现在让我们专注于 CryptoWidgetExtension.swift。打开它,让我们看一下。检查以下代码片段:
@main
struct CryptoWidgetExtension: Widget {
let kind: String = "CryptoWidgetExtension"
var body: some WidgetConfiguration {
IntentConfiguration(kind: kind, intent: ConfigurationIntent.self, provider: Provider()) { entry in
CryptoWidgetExtensionEntryView(entry: entry)
}
.configurationDisplayName("My Widget")
.description("This is an example widget.")
}
}
如您所见,正如之前讨论的那样,我们有小部件的基本构建块:
-
一个名为
IntentConfiguration的意图配置,允许用户配置小部件。 -
一个提供数据给小部件的提供者:
Provider() -
一个用于显示数据的视图:
CryptoWidgetExtensionEntryView
CryptoWidgetExtension 结构被标记为 @main,这意味着它是小部件的入口点。其主体由 IntentConfiguration 和 CryptoWidgetExtensionEntryView 组成,该视图接收一个 entry 实例作为输入。
在同一文件中,我们还有 Provider 所需方法的自动生成定义(placeholder()、getSnapshot() 和 getTimeline()):
-
placeholder(…)方法将为小部件提供第一次渲染小部件时的初始视图。占位符将使用户对小部件的外观有一个大致的了解。 -
getSnapshot(…in context…)方法将为小部件提供一个值(输入),当小部件需要在短暂情况下显示时使用。context中的isPreview属性表示小部件正在小部件画廊中显示。在这些情况下,快照必须快速:这些场景可能需要开发人员使用占位符数据并避免网络调用,以便尽可能快地返回快照。 -
getTimeline(…)方法将为小部件提供一组值,以显示当前时间(以及可选的未来时间)。
我们将稍后使用另一个重要的修饰符。在 .description("这是一个示例小部件。") 之后,添加以下行:
.supportedFamilies([.systemSmall])
这是我们配置此小部件可用不同大小的位置。在章节的后面部分,我们将添加中等大小的类型。
现在,让我们看看代码的另一个部分。在文件末尾,您将找到 Preview 部分:
struct CryptoWidgetExtension_Previews: PreviewProvider {
static var previews: some View {
CryptoWidgetExtensionEntryView(entry: SimpleEntry(date: Date(), configuration: ConfigurationIntent()))
.previewContext(WidgetPreviewContext(family: .systemSmall))
}
}
这部分代码将允许我们使用 SwiftUI 显示预览,以显示我们在开发小部件时的外观。如果您启动预览,您将看到目前它只显示时间(如果您看不到预览选项卡,请转到 Xcode 顶部菜单的 编辑 | 画布):
![Figure 16.6 – Editor canvas preview]
![img/Figure_16.06_B14717.jpg]
图 16.6 – 编辑画布预览
这太棒了!我们可以实时编码并看到最终结果。让我们分析一下我们是如何获得带有时间的小部件视图的。看看我们是如何使用 CryptoWidgetExtensionEntryView 作为预览的主视图的?
CryptoWidgetExtensionEntryView(entry: SimpleEntry(date: Date(), configuration: ConfigurationIntent()))
.previewContext(WidgetPreviewContext(family: .systemSmall))
此视图接收 SimpleEntry(仅包含日期)和一个普通的、空的 ConfigurationIntent。
然后,我们通过创建 previewContext 并将其分配为 .systemSmall 来对视图应用修饰符。通过这样做,我们在小部件预览中渲染视图!
CryptoWidgetExtensionEntryView 是如何使用 SimpleEntry 的?让我们检查实现:
struct CryptoWidgetExtensionEntryView : View {
var entry: Provider.Entry
var body: some View {
Text(entry.date, style: .time)
}
}
嗯,它只是显示带有日期的文本!所以,总的来说,预览正在执行以下操作:
-
使用
SimpleEntry作为小部件的数据输入 -
使用
CryptoWidgetExtensionEntryView作为主视图来显示数据输入 -
使用
WidgetPreviewContext修饰符来使用小型小部件作为预览的画布
在心中牢记所有这些概念后,是时候开始创建我们自己的小部件了。让我们修改前面的结构体,以显示比特币的价值而不是简单的日期。
首先,如果我们想在部件中显示一个币的价值(例如比特币),我们需要一个条目来包含这些信息。让我们将Coin数组添加到SimpleEntry结构的属性中:
struct SimpleEntry: TimelineEntry {
let date: Date
let configuration: ConfigurationIntent
let coins: [Coin]
}
通过存储coins属性,条目可以在稍后向小部件的视图传递此信息。如果你尝试构建项目,你将得到如下错误:
Cannot find type 'Coin' in scope
这是因为Coin文件只是主应用目标的一部分。我们需要选择Coin以及Views、Network和Model文件夹下的所有其他文件,并将它们添加到小部件的目标中:

图 16.7 – 从主应用共享文件到小部件目标
在将上一张截图中的文件添加到小部件目标后,编译时将出现新的不同错误。所有这些错误的主要原因是你向Coin添加了一个新属性,现在在Provider结构体中有部分地方我们在初始化Coin实例时没有那个新属性。为了修复它,我们将向Provider实现中添加一些占位符数据(目前如此),在创建Provider内部的任何SimpleEntry实例时将其作为币传递。稍后,我们将使用来自 API 的真实数据而不是这些占位符数据。
在Provider结构体内部添加以下代码。其第一行将如下所示:
struct Provider: IntentTimelineProvider {
let coins = [Coin(id: 1, name: "Bitcoin", symbol: "BTC", quote: Quote(USD: QuoteData(price: 20000))), Coin(id: 1, name: "Litecoin", symbol: "LTC", quote: Quote(USD: QuoteData(price: 200)))]
//…
我们正在创建一些假数据以生成一个包含比特币和莱特币一些值的Coin数组。现在,我们可以使用这个coins值将它们注入到Provider类内部创建SimpleEntry的三个地方:
-
首先,我们在
placeholder(…)方法内部注入它:SimpleEntry(date: Date(), configuration: ConfigurationIntent(), coins: coins) -
然后,我们在
getSnapshot(…)方法内部注入它:let entry = SimpleEntry(date: Date(), configuration: configuration, coins: coins) -
然后,我们在
getTimeline(…)方法内部注入它:let entry = SimpleEntry(date: entryDate, configuration: configuration, coins: coins)
最后,你可能在CryptoWidgetExtension_Previews结构体内部遇到完全相同的问题。previews属性正在使用SimpleEntry在部件中显示它。你需要再次添加coins属性。只需使用此代码:
CryptoWidgetExtensionEntryView(entry: SimpleEntry(date: Date(), configuration: ConfigurationIntent(), coins: [Coin(id: 1, name: "Bitcoin", symbol: "BTC", quote: Quote(USD: QuoteData(price: 20000))), Coin(id: 1, name: "Litecoin", symbol: "LTC", quote: Quote(USD: QuoteData(price: 200)))]))
.previewContext(WidgetPreviewContext(family: .systemSmall))
太好了!项目现在应该可以正确编译。尝试渲染预览以查看发生了什么。哎呀!你仍然应该在小的部件中看到日期/时间,而没有币值!为什么?我们将币值传递给小部件的条目,但小部件的视图还没有使用它。检查当前的实现:
struct CryptoWidgetExtensionEntryView : View {
var entry: Provider.Entry
var body: some View {
Text(entry.date, style: .time)
}
}
我们在内部有包含币信息的entry,但我们只显示日期。我们需要修改视图以显示新的信息!在主应用中,我们有一个视图,给定一个币,显示其名称和价格。让我们使用它。更改CryptoWidgetExtensionEntryView的实现(更改已突出显示):
struct CryptoWidgetExtensionEntryView : View {
var entry: Provider.Entry
var body: some View {
CoinDetail(coin: entry.coins[0])
}
}
现在,构建并刷新预览。太棒了!你应该在 Widget 上看到比特币的价格和名称,如下面的截图所示:

图 16.8 – 显示比特币价格的 Widget
如果你想在模拟器中尝试,只需启动 Widget 目标。记住,你应该首先启动(至少一次)主应用。
在本节中,我们学习了如何向应用添加 Widget 扩展。然后,我们探讨了主要组件及其之间的关系:提供者、条目、Widget 视图和 SwiftUI 的预览系统。最后,我们修改了所有这些组件以适应我们的需求,并创建了我们的第一个小 Widget。在下一节中,我们将学习如何添加占位符预览以及如何添加中等尺寸的 Widget!
实现多尺寸 Widget
在上一节中,我们向项目中添加了一个 Widget 目标并创建了 Widget 的第一个视图,即小尺寸视图。现在让我们做一些修改,以便开发一个中等尺寸的 Widget,以及 Widget 的占位符预览。
如果你没有跟上上一节的内容,可以使用名为 CryptoWidget_1_small_widget 的项目。让我们首先向项目中添加一个占位符预览。在第一次渲染你的 Widget 时,WidgetKit 会将其渲染为占位符。为了渲染数据,它将使用以下方法请求提供者提供一个条目:
func placeholder(in context: Context) -> SimpleEntry
但是,为了在开发过程中看到它的外观,我们可以使用 SwiftUI 创建它的预览。继续在 CryptoWidgetExtension.swift 文件中添加以下结构体:
struct PlaceholderView : View {
let coins = [Coin(id: 1, name: "Bitcoin", symbol: "BTC", quote: Quote(USD: QuoteData(price: 20000))), Coin(id: 1, name: "Litecoin", symbol: "LTC", quote: Quote(USD: QuoteData(price: 200)))]
var body: some View {
CryptoWidgetExtensionEntryView(entry: SimpleEntry(date: Date(), configuration: ConfigurationIntent(), coins: coins)).redacted(reason: .placeholder)
}
}
看看我们如何使用主要的 Widget 视图 (CryptoWidgetExtensionEntryView) 作为占位符视图,并给它提供模拟数据?然而,有趣的部分是高亮显示的部分:.redacted(reason: .placeholder)。现在我们已经使用模拟数据创建了一个占位符视图,让我们创建它的预览并检查 redacted 修改符的效果。
移除 CryptoWidgetExtension_Previews 的实现,并添加这个新的实现,修改后的代码如下所示:
struct CryptoWidgetExtension_Previews: PreviewProvider {
static var previews: some View {
Group {
CryptoWidgetExtensionEntryView(entry: SimpleEntry(date: Date(), configuration: ConfigurationIntent(), coins: [Coin(id: 1, name: "Bitcoin", symbol: "BTC", quote: Quote(USD: QuoteData(price: 20000))), Coin(id: 1, name: "Litecoin", symbol: "LTC", quote: Quote(USD: QuoteData(price: 200)))]))
.previewContext(WidgetPreviewContext(family: .systemSmall))
PlaceholderView()
.previewContext(WidgetPreviewContext(family: .systemSmall))
}
}
}
首先,我们将之前的 CryptoWidgetExtensionEntryView 视图封装在 Group 中。这是因为现在我们想要显示一组预览,CryptoWidgetExtensionEntryView 和新的 Placeholder。
然后,我们添加了新创建的 Placeholder 视图,并像之前一样应用了一个小 Widget 的 previewContext。编译并继续预览渲染;你应该看到以下内容:

图 16.9 – 带有红字修改符的占位符视图
你现在看到 .redacted(reason: .placeholder) 的效果了吗?SwiftUI 正在用占位符视图替换标签。创建你自己的小部件占位符视图非常简单!
目前,我们有一个小型小部件及其预览。让我们开始创建它的中等尺寸版本。更大的小部件应该利用额外的可用空间为用户提供额外的价值。你的中等或大尺寸小部件不应该只是简单的大尺寸版本。在我们的例子中,我们以小尺寸显示比特币的价格。现在,在中等尺寸,我们将一次性显示多种加密货币的价值。用户只需一眼就能获得市场的更大图景!
在上一节中,我们配置了supportedFamilies以允许小尺寸的小部件。我们还需要添加中等尺寸。你将在CryptoWidgetExtension结构体中找到它。将.systemMedium添加到supportedFamilies中,因此配置行应该看起来像这样:
.supportedFamilies([.systemSmall, .systemMedium])
现在让我们为中等尺寸的小部件创建一个预览。请继续在CryptoWidgetExtension_Previews中现有小部件下方添加一个新的Group。在现有的Group{ … }结束的地方添加以下代码(因此你应该有一个接一个的组):
Group {
CryptoWidgetExtensionEntryView(entry: SimpleEntry(date: Date(), configuration: ConfigurationIntent(), coins: [Coin(id: 1, name: "Bitcoin", symbol: "BTC", quote: Quote(USD: QuoteData(price: 20000))), Coin(id: 1, name: "Litecoin", symbol: "LTC", quote: Quote(USD: QuoteData(price: 200))), Coin(id: 1, name: "Ethereum", symbol: "ETH", quote: Quote(USD: QuoteData(price: 1200)))]))
.previewContext(WidgetPreviewContext(family: .systemMedium))
PlaceholderView()
.previewContext(WidgetPreviewContext(family: .systemMedium))
}
看看这个新的视图组与现有的视图是否相同,唯一的区别在于高亮的代码?我们现在在systemMedium预览中显示小部件及其占位符。如果你恢复渲染,你应该看到这两个新的预览(除了之前的小尺寸预览):


图 16.10 – 中等尺寸小部件和占位符
你可以想象,对于一个用户来说,这个结果将会非常令人失望。我们显示的信息与小型小部件完全相同,但在他们的主页上占据了更多的空间(这对他们来说非常宝贵!)让我们通过更改系统显示中等尺寸版本时我们的CryptoWidgetExtensionEntryView的布局来改进这一点。我们可以利用额外的空间一次显示不止一种货币。删除CryptoWidgetExtensionEntryView的实现,并使用以下代码:
struct CryptoWidgetExtensionEntryView : View {
var entry: Provider.Entry
//1
@Environment(\.widgetFamily) var family
//2
@ViewBuilder
var body: some View {
switch family {
//3
case .systemSmall where entry.coins.count > 0:
CoinDetail(coin: entry.coins[0])
//4
case .systemMedium where entry.coins.count > 0:
HStack(alignment: .center) {
Spacer()
CoinDetail(coin: entry.coins.first!)
Spacer()
CoinListView(data: entry.coins)
Spacer()
}
//5
default:
PlaceholderView()
}
}
}
让我们讨论代码中的编号注释:
-
我们使用
@Environment(\.widgetFamily)变量,它允许我们知道正在使用哪个小部件家族。基于这个信息,我们可以为不同尺寸使用不同的布局。 -
视图必须使用
@ViewBuilder声明其主体,因为它使用的视图类型是可变的。 -
我们使用
family(widgetFamily)属性来切换它,并为不同尺寸的小部件提供不同的视图。对于小型小部件,我们继续使用之前的CoinDetail视图。 -
对于中等尺寸的小部件,我们使用一种视图组合,使我们能够显示一种硬币的详细信息及其旁边的其他硬币列表。通过这种方式,我们增加了价值,并利用可用空间为用户提供更多信息。
-
最后,我们使用
Placeholder来处理开关的default情况。
现在你可以恢复预览以查看更改。中等尺寸的组应该看起来像这样:

图 16.11 – 中等尺寸小部件
太棒了!我们现在有一个为中等尺寸家庭提供额外价值的不同小部件!我们还有一个重要的任务要完成。我们在小尺寸和中尺寸显示的数据只是示例数据。此外,我们没有让用户选择他们想要在小尺寸小部件中显示的代币;我们强制显示比特币,他们可能对此不感兴趣。
在下一节中,我们将学习如何为小部件提供动态配置(因此用户可以配置小部件的选项)以及如何显示真实数据。
提供小部件的数据和配置
到目前为止,我们有一个具有各种大小的小部件和一个显示加密货币示例数据的占位符视图。在本节中,我们将用来自 API 的真实数据替换这些示例数据,并且我们还将允许用户配置一些选项,以进一步个性化小部件。
如果你没有跟随前面的章节,你可以使用名为 CryptoWidget_2_medium_widget 的项目。
让我们先从小部件提供真实数据开始。提供条目(因此是数据)给小部件视图的实体是 Provider。某种方式上,我们需要 Provider 了解我们的数据源并向视图提供传入的数据。在我们的主应用中,负责提供数据的结构是 DataManager。请继续在 CryptoWidgetExtension.swift 文件中向 Provider 结构添加以下属性:
@ObservedObject var dataManager = DataManager()
我们正在将 DataManager 实例添加到小部件的 Provider 中。请注意,我们使用 @ObservedObject 标签标记了这个属性。如果你之前没有在 SwiftUI 中使用过它,那么每当带有此标签的可观察属性发生变化时,它都会使依赖于它的任何视图无效。
每当 DataManager 发生变化时,依赖于它的视图将无效并刷新以反映这些更改。现在,我们可以从 Provider 中删除示例数据并使用数据管理器。删除以下行:
let coins = [Coin(id: 1, name: "Bitcoin", symbol: "BTC", quote: Quote(USD: QuoteData(price: 20000))), Coin(id: 1, name: "Litecoin", symbol: "LTC", quote: Quote(USD: QuoteData(price: 200)))]
如果你构建项目,你将得到三个编译错误——每个提供者方法中都有一个,我们使用的是刚刚删除的 coins 属性。请继续使用 dataManager.coins.data 属性来代替已删除的代币属性。这个属性来自 dataManager,包含从 API 获取的真实数据。
现在,启动主应用,从设备中删除之前的小部件,并将其再次添加到主屏幕上。你应该会看到如下内容:

图 16.12 – 小部件画廊
这是一个非常好的消息!这不再是示例数据;列表中现在有最多五个具有真实值的代币(记住,如果你使用的是本章开头讨论的沙盒端点,这些值可能不会是最新的)。
现在我们已经在 widget 中显示了真实值。下一步将是稍微改进一下小尺寸 widget。目前,小尺寸 widget 显示的是比特币的价格。但用户可能对其他加密货币感兴趣。我们将使用配置意图来允许用户输入配置值,并使我们的 widget 更加动态。
在本章开头,当我们向主应用添加 widget 扩展时,我们在 widget 扩展文件夹中选择了 CryptoWidgetExtension.intentdefinition。这是一个 Siri 意图定义文件,我们可以配置 widget 将接受的作为用户输入的选项。让我们为我们的特定情况配置意图定义。我们希望用户能够从预定义的代币名称列表中选择一个代币,以在小型 widget 中显示该代币的价格。
让我们先创建一个包含以下值的枚举:BTC、LTC 和 ETH:
-
点击
CryptoWidgetExtension.intentdefinition文件。在coinSelect中更改类型为 添加枚举。 -
这个操作将带你进入创建一个新的枚举。将枚举命名为
Coin Select并添加以下值:1.LTC2.ETH3.BTC它应该看起来像这样:
![图 16.13 – Coin Select 枚举配置
![图片]()
图 16.13 – Coin Select 枚举配置
-
现在返回到意图的 配置 部分。你可以取消选择 运行时 Siri 可以请求值 选项。确保其他选项设置如以下截图所示:![图 16.14 – 自定义意图配置
![图片]()
图 16.14 – 自定义意图配置
以这种方式配置自定义意图并创建了用于显示一些列表值的枚举后,我们就可以在 widget 中使用这个意图了。
-
返回到
CryptoWidgetExtension.swift文件并检查SimpleEntry定义。在每一个条目中,我们都可以访问到configuration属性(它是一个我们刚刚配置的ConfigurationIntent实例)。这意味着每次我们访问一个条目时,都可以访问到自定义意图的值。现在,在
CryptoWidgetExtensionEntryView中,我们有一个可用的entry(当然!这是我们想要显示的数据)。因此,我们可以访问它内部的配置意图。让我们利用它!我们将修改.systemSmallswitch 案例以使用配置意图信息并显示不同的代币,而不仅仅是显示比特币。 -
继续查找以下代码:
case .systemSmall where entry.coins.count > 0: CoinDetail(coin: entry.coins[0]) -
用这个新的替换它:
case .systemSmall where entry.coins.count > 0: switch entry.configuration.coinselect) to know which coin from the enum the user selected. Based on that, we are displaying a specific coin in the small-sized widget.Try to build the project. You may get a compile error. This error happens because the widget doesn't yet know about the custom Siri intent type (even though Xcode generated it for us). This error may be fixed in future versions of Xcode. If you have an error, check the following: -
前往主应用设置,在 支持意图 部分的
ConfigurationIntent意图下,如以下截图所示:![图 16.15 – 将你的意图添加到支持意图部分![图片]()
图 16.15 – 将你的意图添加到支持意图部分
-
再次构建项目,编译错误应该会消失。
-
如果您仍然有任何错误,请尝试以下操作:
a) 编译并运行主应用的目标。
b) 运行小部件的目标。从模拟器中删除小部件并再次添加(小尺寸的那个)。
-
现在,如果您在设备上的小尺寸小部件(或模拟器)上长按,您应该能够看到“编辑小部件”选项。它将显示您的新自定义意图,如下面的截图所示:
![图 16.16 – 小部件配置选项]()
图 16.16 – 小部件配置选项
-
尝试选择ETH或LTC。然后,您的小部件将重新加载并显示该货币!这太棒了;我们现在有一个可配置的加密货币小部件。
在本节中,我们学习了如何使用 Siri 意图使小部件可配置,因此用户可以从主屏幕选择值并编辑小部件。
现在,还有一个我们尚未讨论的话题。在Provider结构体中,我们了解到getTimeline(…)方法将为小部件提供一系列值,以在一段时间内显示,以刷新显示的信息并保持最新。但我们没有讨论如何控制小部件实际刷新的时间,甚至是否在我们控制之下。我们将在下一节中学习这一点。
刷新小部件的数据
保持小部件更新需要消耗系统资源,并可能需要大量的电池使用。因此,系统将限制每个小部件在一天内可以执行更新的次数,以节省电池寿命。
带着这个想法,我们必须理解我们并没有完全控制我们小部件的刷新时间和频率,并且小部件并不总是处于活跃状态。我们能够给系统一些提示,关于何时对我们的小部件刷新是最理想的,但最终决定权在系统手中。
系统使用预算在一段时间内分配重新加载。这个预算受以下因素的影响:
-
小部件被展示给用户的次数有多少?
-
小部件上次重新加载是什么时候?
-
小部件的主要应用是否处于活跃状态?
为小部件分配的预算可以持续 24 小时。用户频繁访问的小部件每天可以刷新高达 70 次,这意味着它大约每 15 分钟更新一次。
您可以通过在您小部件的Timeline方法中提供尽可能多的信息来帮助 WidgetKit 估算您小部件的最佳预算。以下是一些示例:
-
一个遵循食谱的烹饪小部件可以在时间线上安排不同的步骤,在特定时间点显示烹饪步骤:预热烤箱 15 分钟,烹饪 30 分钟,休息 10 分钟,等等。这将导致一个在特定分钟(15 – 30 – 10)上时间间隔分开的条目时间线。WidgetKit 将尝试在这些时间点刷新您的 小部件,以显示适当的条目。
-
为了让小部件每两小时提醒用户喝水,你可以生成一个时间线来提醒用户每两小时喝一杯水。但你可以更有效率,避免在用户睡觉的夜间进行任何刷新。这将产生一个更有效率的时间线,并节省一些 WidgetKit 可以用来在真正需要时更频繁地刷新你的小部件的预算。
现在,在我们的特定示例中,让我们修改时间线,让 WidgetKit 每隔 5 分钟刷新我们的小部件(一个非常激进的要求!)!但我们知道加密货币非常波动,对于这个例子,我们希望尽可能多地刷新价格。按照以下步骤操作:
-
现在请打开名为
CryptoWidget_4_timeline的项目,该项目位于本章的代码包中。首先,让我们在DataManager中创建一个新的方法,允许我们通过完成块获取最新的加密货币数据。 -
将以下方法添加到结构体中:
func refresh(completionHandler: @escaping (CoinList) -> Void) { guard let url = URL(string: apiUrl) else { return } var request = URLRequest(url: url) request.setValue(apiKeyValue, forHTTPHeaderField: apiKeyHeader) URLSession.shared.dataTask(with: request){ (data, _, _) in print("Update coins") guard let data = data else { return } let coins = try! JSONDecoder().decode(CoinList.self, from: data) DispatchQueue.main.async { print(coins) self.coins = coins self.loading = false completionHandler(coins) } }.resume() }看看这个方法与
getData()的相似之处,但这个方法不是private的,并且它还返回coins,以便我们可以在需要时在completion处理器中使用。 -
接下来,转到名为
CryptoWidgetExtension.swift的文件,并修改Provider结构体中的getTimeline(…)方法,用以下实现替换:func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline<Entry>) -> ()) { print("New Timeline \(Date())") dataManager.refresh { (coins) in let currentDate = Date() let futureDate = Calendar.current.date(byAdding: .minute, value: 15, to: currentDate)! let timeline = Timeline(entries: [SimpleEntry(date: Date(), configuration: configuration, coins: coins.data)], policy: .after(futureDate)) completion(timeline) } }
让我们看看方法中发生了什么:
-
首先,我们正在使用我们创建的新方法
refresh(…)来获取加密货币的最新值。 -
一旦我们在完成处理程序中准备好了硬币,我们就在未来创建一个日期,这个日期是 15 分钟之后。
-
然后,我们创建一个包含
coins最新值的SimpleEntry的Timeline,以及一个刷新策略。刷新策略设置为在 15 分钟后创建一个新的时间线(futureDate)。通常,15 分钟是 WidgetKit 再次更新你的小部件所需的最短时间。如果你尝试更低的值,你可能不会得到任何结果。因此,为了总结这个方法,当 WidgetKit 请求我们时间线时,我们调用我们的 API 来获取最新的加密货币值,然后我们将它们包装在一个准备显示的小部件视图中,并设置一个“15 分钟后”的刷新策略。
-
现在,尝试从模拟器或你的设备中删除应用程序和小部件。安装应用程序和扩展,并在主屏幕上添加一个小部件。当你添加小部件时,你应该在日志中看到时间线方法的第一个语句,类似于以下内容:
New Timeline 2021-01-23 20:51:51 +0000然后,15 分钟后,你应该再次看到它出现。刷新策略已经启动,我们再次提供了一个带有最新值的刷新版本:
New Timeline 2021-01-23 21:06:52 +0000
太棒了!我们现在知道如何刷新我们的小部件了!最后提醒一下:除了 .after 之外,还有更多的刷新策略。以下是选项:
-
TimelineReloadPolicy.after(Date): 在特定日期过后将生成一个新的时间线。 -
TimelineReloadPolicy.atEnd:在当前时间线的最后一条条目通过之后,将生成一个新的时间线。 -
TimelineReloadPolicy.never:小部件的应用将负责让 WidgetKit 知道何时下一个时间线准备就绪。
在本节中,我们学习了 WidgetKit 如何决定何时刷新您的小部件,以及我们如何提供时间线和刷新策略,以便系统更好地了解我们希望何时更新小部件。现在,让我们通过总结来结束本章。
总结
我们从学习小部件和 WidgetKit 的基础知识开始本章。我们了解了通用指南、基本选项及其目的。在介绍之后,我们直接进入了开发我们的第一个小部件。我们首先向现有应用中添加了一个小型小部件。
然后,我们在小部件中添加了一个占位符视图,以便用户对首次加载时小部件的外观有一个良好的概念。之后,我们创建了一个更大、中等大小的版本,它能够显示比小型小部件多得多的信息,并提供更多的价值。
最后,我们学习了如何在 Siri 自定义意图的帮助下使小部件可由用户配置。通过使用自定义意图,用户能够向小部件提供某些配置值以个性化体验。
在本章中,你学习了如何创建小部件并充分利用 WidgetKit。在下一章中,我们将学习关于 ARKit 的知识,这是苹果公司的增强现实框架。
第十七章:第十七章: 使用增强现实
苹果在 iOS 11 中发布的一个主要功能是ARKit。ARKit 允许开发者仅用少量代码就创建惊人的增强现实(AR)体验。苹果一直在努力改进 ARKit,在 2018 年的 WWDC 上发布了 ARKit 2,在 2019 年的 WWDC 上发布了 ARKit 3,在 2020 年的 WWDC 上发布了 ARKit 4。
在本章中,你将学习什么是 ARKit,它是如何工作的,你可以用它做什么,以及如何实现一个使用多个 ARKit 功能(如图像跟踪)的 AR 艺术画廊。我们还将了解一些来自 SpriteKit 和 SceneKit 的基本概念。
本章涵盖了以下主题:
-
理解 ARKit
-
使用 ARKit 快速查看
-
探索 SpriteKit
-
探索 SceneKit
-
实现 AR 画廊
到本章结束时,你将能够将 ARKit 集成到你的应用程序中,并实现你自己的 ARKit 体验。
理解 ARKit
在本节中,我们将了解增强现实(AR)和 ARKit。增强现实(AR)是一个长期以来一直吸引着应用程序开发者和设计师兴趣的话题。尽管实现出色的 AR 体验并不容易,但许多应用程序并没有达到预期的炒作。像照明和检测墙壁、地板和其他对象这样的小细节一直都非常复杂,而这些细节的错误会对 AR 体验的质量产生负面影响。
增强现实应用程序通常至少具有以下一些功能:
-
它们显示了一个相机视图。
-
内容在相机视图中以叠加的形式显示。
-
内容能够适当地响应设备的移动。
-
内容附着在世界的特定位置。
尽管这个功能列表很简单,但它们并不都是容易实现的。AR 体验在很大程度上依赖于读取设备的运动传感器,以及使用图像分析来确定用户的确切移动方式,并了解世界 3D 地图应该是什么样子。
ARKit 是苹果提供给开发者创建出色 AR 体验的力量的方式。ARKit 负责所有的运动和图像分析,以确保你可以专注于设计和实现优秀的内容,而不是被构建 AR 应用程序所涉及的复杂细节所拖慢。
不幸的是,ARKit 对运行 ARKit 应用程序的设备有较高的硬件要求。只有配备苹果 A9 芯片或更新的设备才能运行 ARKit。这意味着任何比 iPhone 6s 或第一代 iPad Pro 更旧的设备都无法运行 ARKit 应用程序。
在接下来的几节中,我们将首先了解 ARKit 如何在设备上渲染内容,以及它是如何跟踪周围的物理环境,以提供最佳的 AR 体验。
理解 ARKit 如何渲染内容
ARKit 本身只负责与跟踪用户所处的物理世界相关的庞大计算。要在 ARKit 应用中渲染内容,您必须使用以下三种渲染工具之一:
-
SpriteKit
-
SceneKit
-
Metal
在本章的后面部分,您将快速了解 SpriteKit 和 SceneKit,并最终使用 SceneKit 来实现您的 AR 画廊。如果您已经熟悉任何可用的渲染技术,那么在使用 ARKit 时应该会感到非常自在。
在您的应用中实现 ARKit 不仅限于手动渲染您想在 AR 中显示的内容。在 iOS 12 中,苹果增加了一个名为 ARKit Quick Look 的功能。您可以在您的应用中实现一个特殊的视图控制器,负责放置您提供的 3D 模型。如果您正在实现允许用户在现实世界中预览产品或其他对象的特性,这将非常理想。
理解 ARKit 如何跟踪物理环境
要理解 ARKit 如何渲染内容,您必须了解 ARKit 如何理解用户所处的物理环境。当您实现一个 AR 体验时,您使用一个 ARKit 会话。ARKit 会话由 ARSession 的一个实例表示。每个 ARSession 都使用 ARSessionConfiguration 的一个实例来描述它在环境中应执行的跟踪。以下图表展示了在 ARKit 会话中涉及的所有对象之间的关系:
![图 17.1 – ARKit 会话组件
![img/Figure_17.01_B14717.jpg]
图 17.1 – ARKit 会话组件
上述图表显示了会话配置如何传递给会话。然后,会话被传递给一个负责渲染场景的视图。如果您使用 SpriteKit 来渲染场景,该视图是一个 ARSKView 的实例。当您使用 SceneKit 时,这将是一个 ARSCNView 的实例。视图和会话都有一个代理,它将在 ARKit 会话期间通知某些事件。您将在实现您的 AR 画廊时了解更多关于这些代理的信息。
在会话上,您可以配置几种不同的跟踪选项。最基本的跟踪配置之一是 AROrientationTrackingConfiguration。此配置仅跟踪设备的方向,而不是用户在环境中的移动。这种跟踪使用三个自由度来监控设备。更具体地说,这种跟踪跟踪设备的 x、y 和 z 方向。如果您的实现中可以忽略用户的移动,例如 3D 视频,这种跟踪方式非常合适。
更复杂的跟踪配置是ARWorldTrackingConfiguration,也称为世界跟踪。此类配置跟踪用户的移动以及设备的方向。这意味着用户可以绕着 AR 对象走动,从不同的侧面观察它。世界跟踪使用设备的运动传感器来确定用户的移动和设备的方向。这对于短距离和小范围的移动非常准确,但不足以跟踪长时间和距离的移动。为了确保 AR 体验尽可能精确,世界跟踪还会执行一些高级计算机视觉任务,以分析摄像头流来确定用户在环境中的位置。
除了跟踪用户的移动外,世界跟踪还使用计算机视觉来理解 AR 会话存在的环境。通过检测摄像头流中的某些兴趣点,世界跟踪可以比较和分析这些点相对于用户运动的位置,以确定对象的距离和大小。这种技术还允许世界跟踪检测墙壁和地板等。
世界跟踪配置将学习到的关于环境的所有信息存储在ARWorldMap中。此地图包含所有代表会话中存在的不同对象和兴趣点的ARAnchor实例。
您可以在应用程序中使用几种其他特殊跟踪类型。例如,您可以在具有TrueDepth摄像头的设备上使用ARFaceTrackingConfiguration来跟踪用户的脸部。如果您想在 iOS 12 中添加到 iPhone X 及其后续版本中的 Apple Animoji 功能,这种跟踪方式非常完美。
您还可以配置会话,使其自动检测场景中的某些对象或图像。为此,您可以使用ARObjectScanningConfiguration来扫描特定项目,或使用ARImageTrackingConfiguration来识别静态图像。
在本节中,您已经学习了 AR 和 ARKit 的基础知识,包括 ARKit 如何在设备上渲染内容,以及它是如何跟踪周围物理环境的。在您开始实现 ARKit 会话之前,让我们探索新的ARKit 快速预览功能,看看它对您允许您的应用程序用户在 AR 中预览项目有多简单。
使用 ARKit 快速预览
在本节中,我们将了解 ARKit 快速预览功能,这是苹果公司的一项功能,允许用户使用设备的摄像头预览虚拟 3D 或 AR 模型。
AR 为最终用户带来的一个巨大好处是,现在可以在现实世界中预览某些对象。例如,当您购买新的沙发时,您可能想看看它在现实世界中的样子。当然,在 iOS 11 中使用 ARKit 实现此类功能是可能的,许多开发者已经做到了,但这并不像可能的那样简单。
iOS 用户可以使用名为 Quick Look 的功能预览内容。Quick Look 可以用于预览某些类型的内容,而无需启动任何特定应用程序。这对用户来说很方便,因为他们可以通过在 Quick Look 中预览来确定某个特定文档是否是他们正在寻找的文档。
在 iOS 12 中,苹果将 USDZ 文件格式添加到可以使用 Quick Look 预览的内容类型中。苹果的 USDZ 格式是基于皮克斯的 USD 格式的一种 3D 文件格式,用于表示 3D 对象。使用 Quick Look 预览 3D 模型不仅限于应用中;ARKit Quick Look 还可以集成到网页上。开发者可以在他们的网页上使用特殊的 HTML 标签来链接 USDZ,Safari 将在 ARKit 快速查看视图控制器中显示模型。
在实现你的 AR 画廊之前,了解 iOS 上 AR 的工作方式是一个好主意,可以通过实现 ARKit 快速查看视图控制器来展示苹果在 developer.apple.com/arkit/gallery/ 提供的其中一个模型。要下载你喜欢的模型,你只需要在你的 Mac 上导航到这个页面并点击一个图像。USDZ 文件应该会自动开始下载。
小贴士
导航到支持 ARKit 的设备的 ARKit 画廊,并点击其中一个模型,以查看 Safari 中的 ARKit 快速查看看起来是什么样子。
在本节中,我们解释了什么是快速查看。现在让我们在下一节中将其用于我们自己的应用中。
实现 ARKit 快速查看视图控制器
从苹果的画廊获取 USDZ 文件后,还确保捕获属于此文件的图像。为了测试目的,对模型进行截图应该是可以的。确保通过将截图放大到两倍和三倍大小来准备不同所需的图像尺寸。
在 Xcode 中创建一个新的项目,并为你的项目选择一个名称。本书代码包中的示例项目名为 ARQuickLook。将你的准备好的图像添加到 Assets.xcassets 文件中。此外,将你的 USDZ 文件拖动到 Xcode 中,并确保在导入文件时勾选你的应用复选框,将其添加到应用目标中:

图 17.2 – 导入 USDZ 模型
接下来,打开故事板文件,将一个图像视图拖动到视图控制器中。为图像添加适当的约束,使其在视图控制器中居中,并设置其宽度和高度为200点。确保在属性检查器中勾选用户交互启用复选框,并将你的模型图像设置为图像视图的图像。
完成此操作后,打开ViewController.swift,为图像视图添加@IBOutlet,并将故事板中的图像连接到这个出口。如果关于出口的细节现在有点模糊,请参考代码包中的示例项目以刷新记忆。示例项目中的图像视图使用了一个名为guitarImage的出口。
为 USDZ 模型实现快速查看的下一步是在图像视图上添加一个轻点手势识别器,然后当用户轻点图像时触发快速查看视图控制器。
快速查看使用委托从数据源对象中预览一个或多个项目。它还使用委托来获取快速查看预览应该动画的源视图。这种流程适用于所有可以使用快速查看预览的文件类型。
要开始实现快速查看,你必须导入QuickLook框架。将以下import语句添加到ViewController.swift的顶部:
import QuickLook
接下来,通过在viewDidLoad()中添加以下代码来为图像设置轻点手势识别器:
let tapGesture = UITapGestureRecognizer(target: self,
action: #selector(presentQuicklook))
guitarImage.addGestureRecognizer(tapGesture)
下一步是实现presentQuicklook()。这个方法将创建一个快速查看视图控制器,设置委托和数据源,然后将快速查看视图控制器呈现给用户。将以下实现添加到ViewController类中:
@objc func presentQuicklook() {
let previewViewController = QLPreviewController()
previewViewController.dataSource = self
previewViewController.delegate = self
present(previewViewController, animated: true,
completion: nil)
}
这种实现应该不会给你带来任何惊喜。QLPreviewController是UIViewController的子类,负责显示从其数据源接收到的内容。它以与其他视图控制器相同的方式呈现,通过调用present(_:animated:completion:)。
最后一步是实现数据源和委托。将以下扩展添加到ViewController.swift中:
extension ViewController: QLPreviewControllerDelegate {
func previewController(_ controller: QLPreviewController,
transitionViewFor item: QLPreviewItem) -> UIView? {
return guitarImage
}
}
extension ViewController: QLPreviewControllerDataSource {
func numberOfPreviewItems(in controller:
QLPreviewController) -> Int {
return 1
}
func previewController(_ controller: QLPreviewController,
previewItemAt index: Int) -> QLPreviewItem {
let fileUrl = Bundle.main.url(forResource:
"stratocaster", withExtension: "usdz")!
return fileUrl as QLPreviewItem
}
}
你添加的第一个扩展使ViewController遵守QLPreviewControllerDelegate协议。当预览控制器即将展示 3D 模型时,它想知道即将发生的转换的源视图是哪个。建议从这个方法返回充当快速查看操作的预览视图。在这种情况下,预览是 3D 模型的图像。
第二个扩展充当快速查看数据源。当你为 ARKit 实现快速查看时,你只能返回一个项目。所以,当预览控制器询问预览中的项目数量时,你应该始终返回1。数据源中的第二个方法提供了预览控制器中应该预览的项目。你在这里需要做的就是获取你希望预览的项目文件 URL。在示例应用中,使用了苹果画廊中的 Stratocaster 模型。如果你的模型有不同的名称,请确保使用正确的文件名。
在获取指向应用包中图像的 URL 后,应将其作为QLPreviewItem实例返回给预览控制器。幸运的是,URL 实例可以自动转换为QLPreviewItem实例。
如果你现在运行你的应用,你可以点击 3D 模型的图像来开始预览它。你可以单独预览图像,或者选择在 AR 中预览它。如果你点击这个选项,预览控制器会告诉你移动你的设备。
为了将你周围的世界进行映射,ARKit 需要一些环境样本。当你移动你的设备时,确保不要只是倾斜它,而是要物理移动它。这样做将帮助 ARKit 发现你周围可追踪的特征。
一旦 ARKit 收集了你周围足够的数据,你就可以将 3D 模型放置在环境中,通过捏合来缩放它,旋转它,并在空间中移动它。请注意,模型会自动放置在平坦的表面,如桌子或地板上,而不是尴尬地漂浮在空中:
![图 17.3 – 在场景周围移动设备
![img/Figure_17.03_B14717.jpg]
图 17.3 – 在场景周围移动设备
还要注意,ARKit 对你的物体应用了非常逼真的光照。ARKit 收集的环境视觉数据被用来创建一个光照图,并将其应用于 3D 模型,使其能够正确地融入物体放置的上下文中:
![图 17.4 – 放置在现实世界中的 AR 模型
![img/Figure_17.04_B14717.jpg]
图 17.4 – 放置在现实世界中的 AR 模型
虽然这样玩 ARKit 很有趣,但创建自己的 AR 体验更有趣。由于 ARKit 支持多种渲染技术,如 SpriteKit 和 SceneKit,接下来的两个部分将花一点时间解释 SpriteKit 和 SceneKit 的基础知识。你不会学习如何使用这些框架构建完整的游戏或世界。相反,你将学习足够的内容,以便在 ARKit 应用中开始实现任一渲染引擎。
探索 SpriteKit
在本节中,我们将探讨SpriteKit。SpriteKit 主要被开发者用来构建二维游戏。SpriteKit 已经存在一段时间了,并且它帮助开发者多年来创建了许多成功的游戏。SpriteKit 包含一个完整的物理仿真引擎,并且可以同时渲染许多精灵。精灵代表游戏中的一个图形。精灵可以是玩家的图像,也可以是硬币、敌人,甚至是玩家行走的地面。当在 SpriteKit 的上下文中提到精灵时,指的是屏幕上可见的节点之一。
由于 SpriteKit 内置了物理引擎,它可以检测物体之间的碰撞,对它们施加力,等等。这和 UIKit Dynamics 的功能非常相似。
为了渲染内容,SpriteKit 使用场景。这些场景可以被认为是游戏的水平或主要构建部分。在 AR 的上下文中,你会发现你通常只需要一个场景。SpriteKit 场景负责更新场景的位置和状态。作为开发者,你可以通过 SKScene 的 update(_:) 方法挂钩到帧的渲染。每当 SpriteKit 即将为你或 ARKit 场景渲染新帧时,都会调用此方法。确保此方法的执行时间尽可能短是很重要的,因为 update(_:) 方法的慢速实现会导致帧率下降,这是被认为不好的。你应该始终努力保持每秒 60 帧的稳定帧率。这意味着 update(_:) 方法应该始终在不到 1/60 秒的时间内完成其工作。
要开始探索 SpriteKit,请在 Xcode 中创建一个新项目,并选择 SpriteKitDefault,如下截图所示:
![图 17.5 – 创建 SpriteKit 项目]

图 17.5 – 创建 SpriteKit 项目
当 Xcode 为你生成此项目时,你应该会注意到一些之前没有见过的文件:
-
GameScene.sks -
Actions.sks
这两个文件对于 SpriteKit 游戏来说就像故事板对于常规应用一样。你可以使用这些文件来设置游戏场景的所有节点,或者设置可重用的动作,这些动作可以附加到你的节点上。我们现在不会深入这些文件,因为它们非常具体于游戏开发。
如果你构建并运行 Xcode 提供的示例项目,你可以轻触屏幕来在屏幕上创建新的精灵节点。每个节点在消失前都会执行一点动画。这本身并不特别,但它包含了很多有价值的信息。例如,它展示了如何向场景中添加内容以及如何对其进行动画处理。让我们看看这个项目是如何设置的,这样你就可以在将来想要使用 SpriteKit 构建 AR 体验时应用这些知识。
创建 SpriteKit 场景
SpriteKit 游戏使用一种特殊的视图来渲染其内容。这个特殊视图始终是 SKView 的一个实例或子类。如果你想在 SpriteKit 中使用 ARKit,你应该使用 ARSKView,因为这个视图实现了某些特殊的 AR 相关行为,例如渲染相机视频流。
视图本身通常不会在管理游戏或其子视图方面做太多工作。相反,包含视图的 SKScene 负责执行这项工作。这与其他应用中通常使用视图控制器的方式类似。
当你创建了一个场景后,你可以告诉 SKView 显示这个场景。从这一刻起,你的游戏就开始运行了。在之前创建的游戏项目示例代码中,以下行负责加载和显示场景:
if let scene = SKScene(fileNamed: "GameScene") {
scene.scaleMode = .aspectFill
view.presentScene(scene)
}
当你创建场景时,你可以选择是否想要使用 .sks 文件或以编程方式创建场景。
当你打开 Xcode 为你创建的 GameScene.swift 文件时,大部分代码应该是相当容易理解的。当场景被添加到视图中时,会创建并配置几个 SKNode 实例。这个文件中最有趣的代码行如下:
spinnyNode.run(SKAction.repeatForever(SKAction.rotate(byAng
le: CGFloat(Double.pi), duration: 1)))
spinnyNode.run(SKAction.sequence([SKAction.wait(forDuration
: 0.5), SKAction.fadeOut(withDuration: 0.5),
SKAction.removeFromParent()]))
这些行设置了当你点击屏幕时添加的旋转方块的动画序列。在 SpriteKit 中,动作是设置动画的首选方式。你可以分组、链式组合动作以实现相当复杂的效果。这是 SpriteKit 提供的许多强大工具之一。
如果你仔细检查一下代码,你会发现每次用户在屏幕上点击、移动手指或抬起手指时,都会创建 spinnyNode 的副本。每次交互都会产生一个略微不同的 spinnyNode 副本,因此你可以通过观察其外观来确定为什么将 spinnyNode 添加到场景中。
研究这段代码,尝试操作它,并确保你理解它的工作原理。你不必成为 SpriteKit 专家,但在这个部分,我们已经回顾了它的基础知识,以便你可以开始使用它。让我们看看 SceneKit 是如何准备和实现你的 AR 画廊的。
探索 SceneKit
如果你正在寻找一个对 3D 游戏有出色支持的游戏框架,SceneKit 是一个很好的选择。SceneKit 是苹果公司用于创建 3D 游戏的框架,其结构设置与 SpriteKit 非常相似。
当然,SceneKit 与 SpriteKit 完全不同,因为它用于 3D 游戏,而不是 2D 游戏。因此,SceneKit 在创建视图和将它们定位在屏幕上的方式上也非常不同。例如,当你想要创建一个简单的对象并将其放置在屏幕上时,你会看到诸如几何和材质之类的术语。这些术语应该对游戏程序员来说很熟悉,但如果你是 AR 爱好者,你可能需要习惯这些术语。
本节将指导你设置一个简单的 SceneKit 场景,它将非常类似于你稍后将要实现的 AR 画廊的一部分。这应该为你提供足够的信息,以便开始尝试使用 SceneKit。
创建基本的 SceneKit 场景
为了练习你的 SceneKit 知识,创建一个新的项目,而不是选择 Game 模板,而是选择 Single View Application 模板。当然,你可以自由探索当你选择带有 SceneKit 的 Game 模板时 Xcode 为你创建的默认项目,但这对于 AR 画廊来说并不特别有用。
创建你的项目后,打开主故事板并查找 SceneKit 视图。将此视图拖到视图控制器中。你应该注意到你刚刚添加到视图控制器的视图已经完全替换了默认视图。因此,ViewController 上的 view 属性将不是一个普通的 UIView;它将是一个 SCNView 的实例。这是将用于渲染 SceneKit 场景的视图。
在 ViewController.swift 的 viewDidLoad() 中添加以下代码,将 view 属性从 UIView 转换为 SCNView:
guard let sceneView = self.view as? SCNView
else { return }
现在,记得在顶部添加 import SceneKit,以便 SCNView 能够编译。
与 SpriteKit 的工作方式类似,SceneKit 使用场景来渲染其节点。在 viewDidLoad() 中的 guard 之后立即创建一个 SCNScene 实例,如下所示:
let scene = SCNScene()
sceneView.scene = scene
sceneView.allowsCameraControl = true
sceneView.showsStatistics = true
sceneView.backgroundColor = UIColor.black
上述代码创建了一个简单的场景,将用于渲染所有元素。除了创建场景外,还启用了几个调试功能来监控场景的性能。此外,请注意,场景视图上的 allowsCameraControl 属性被设置为 true。这将允许用户在场景中移动虚拟相机,通过在场景中滑动来探索场景。
每个 SceneKit 场景都像通过相机看一样。你必须自己添加这个相机,并且必须根据你的目的适当地设置它。SceneKit 使用相机的事实非常方便,因为当你使用 ARKit 运行场景时,你即将设置的相机将被设备的实际相机所取代。
在 viewDidLoad() 中添加以下代码行以创建和配置相机:
let cameraNode = SCNNode()
cameraNode.camera = SCNCamera()
cameraNode.position = SCNVector3(x: 0, y: 0, z: 15)
scene.rootNode.addChildNode(cameraNode)
设置基本相机并不复杂。你只需要一个 SCNNode 来添加相机,以及一个 SCNCamera,它将用于通过它查看你的场景。请注意,相机是通过 SCNVector3 对象定位的。SceneKit 场景中的所有节点都使用此对象来表示它们在三维空间中的位置。
除了使用模拟相机外,SceneKit 还模拟真实的光照条件。当你使用 ARKit 运行场景时,光照条件将由 ARKit 自动管理,使你的物体看起来就像真正是环境的一部分。然而,当你创建一个普通场景时,你需要自己添加灯光。添加以下代码行以实现一些环境光照:
let ambientLightNode = SCNNode()
ambientLightNode.light = SCNLight()
ambientLightNode.light!.type = .ambient
ambientLightNode.light!.color = UIColor.orange
scene.rootNode.addChildNode(ambientLightNode)
你可以向 SceneKit 场景添加不同类型的灯光。你可以像这个示例一样使用环境光,但也可以添加定向光,它聚焦于特定方向,聚光灯,或者照亮所有方向的点光源。
现在你已经设置了光照和相机,你可以在场景中添加一个对象。你可以在场景中使用几个预制的形状,也称为几何体。或者,你也可以将整个 3D 模型导入到场景中。如果你查看 Xcode 生成的默认 SceneKit 应用,如果你使用Game模板创建一个新项目,你可以看到一个飞机的 3D 模型被导入。
在你稍后将要构建的 AR 画廊中,艺术品将通过附加到它们所属的艺术品上的数字信息标签进行增强。为了练习构建这样的标签,你将在你的 SceneKit 场景中添加一个矩形形状,或者平面,并在其上方放置一些文本。
添加以下代码以创建一个简单的白色平面、渲染平面的节点,并将其添加到场景中:
let plane = SCNPlane(width: 15, height: 10)
plane.firstMaterial?.diffuse.contents = UIColor.white
plane.firstMaterial?.isDoubleSided = true
plane.cornerRadius = 0.3
let planeNode = SCNNode(geometry: plane)
planeNode.position = SCNVector3(x: 0, y: 0, z: -15)
scene.rootNode.addChildNode(planeNode)
如果你现在构建并运行你的应用,你会看到一个位于相机前面的白色方块。通过在场景上滑动,你可以使相机在平面上移动,从所有可能的角度查看它。请注意,尽管只设置了 15 宽和 10 高,但平面看起来相当大。你可能已经猜到这些数字代表屏幕上的点,就像在其他应用中一样。在 SceneKit 中,没有点的概念。所有的大小和距离值都必须以米为单位指定。这意味着你做的所有事情都是相对于其他对象或它们的真实世界大小进行的。当你将 SceneKit 知识应用到 ARKit 时,使用真实大小是至关重要的。
要在你的新创建的平面上添加一些文本,请使用以下代码:
let text = SCNText(string: "Hello, world!", extrusionDepth:
0)
text.font = UIFont.systemFont(ofSize: 2.3)
text.isWrapped = true
text.containerFrame = CGRect(x: -6.5, y: -4, width: 13,
height: 8)
text.firstMaterial?.diffuse.contents = UIColor.red
let textNode = SCNNode(geometry: text)
planeNode.addChildNode(textNode)
上述代码创建了一个文本几何体。由于 SceneKit 中的所有值都是以米为单位,文本的大小将比你可能预期的要小得多。为了确保文本在平面上正确定位,启用了文本换行,并使用containerFrame来指定文本的边界。由于文本字段的起点将位于显示的平面的中心,因此x和y位置从中心向负方向偏移,以确保文本出现在正确的位置。你可以尝试调整这个框架来看看会发生什么。配置好文本后,将其添加到一个节点中,然后将该节点添加到平面节点中。
如果你现在运行你的应用,你将能看到在之前创建的白色平面上渲染的Hello, World!文本。这个示例很好地展示了你接下来将要创建的内容。让我们直接开始构建你的 AR 画廊!
实现增强现实画廊
由于 ARKit 中存在的一些优秀功能,创建一个出色的 AR 体验已经变得简单得多。然而,如果你想要构建用户会喜欢的 AR 体验,还有一些事情需要牢记。
某些条件,如光照、环境,甚至用户正在做什么,都可能影响 AR 体验。在本节中,你将实现一个 AR 画廊,你将亲身体验 ARKit 既是惊人的神奇,有时又有点脆弱。
首先,你需要在 ARKit 中设置一个会话,以便你可以实现图像跟踪来在世界中找到某些预定义的图像,你将在找到的图片上方显示一些文本。然后,你将实现另一个功能,允许用户将应用中的画廊艺术作品放置在自己的房间里。
如果你想跟随步骤实现 ARKit 画廊,请确保从书籍的代码包中获取 ARGallery_start 项目。在您开始实现 AR 画廊之前,先探索一下起始项目。准备好的用户界面包含一个 ARSCNView 实例;这是将用于渲染 AR 体验的视图。为了准备用户添加自己的图像到画廊,已添加了一个集合视图,并添加了一个用于错误信息的视图,以通知用户某些可能出错的事情。
你会发现到目前为止项目相当基础。现有的所有代码只是设置了集合视图,并添加了一些代码来处理 AR 会话期间的错误。让我们来实现图像跟踪,好吗?
添加图像跟踪
当你将图像跟踪添加到你的 ARKit 应用中时,它将不断扫描环境以寻找与你在应用中添加的图像相匹配的图像。如果你想让用户在他们的环境中寻找特定的图像,以便你可以提供更多关于它们的信息,或者作为寻宝游戏的一部分,这个功能非常棒。但更复杂的实现可能存在于教科书或杂志中,扫描特定页面会使整个页面作为独特体验的一部分活跃起来。
在您能够实现图像跟踪体验之前,您必须为您的用户提供一些图像以便在应用中找到。一旦内容准备就绪,您就可以构建 AR 体验本身了。
准备图像进行跟踪
将图像添加到您的应用中进行图像跟踪相对简单。最重要的是,你需要仔细关注你添加到应用中的图像。确保你添加的图像是高质量的并且色彩饱和。ARKit 将扫描图像中的特殊特征以尝试匹配,因此你的图像需要有足够的细节、对比度和颜色。一个平滑渐变的图像可能在你看来是一个可识别的图像,但对于 ARKit 来说可能很难检测。
要将图像添加到您的项目中,请转到 Assets.xcassets 文件夹,点击左下角的 + 图标,并选择 New AR Resource Group,如图下截图所示:
![Figure 17.6 – 添加 AR 资源
![img/Figure_17.06_B14717.jpg]
Figure 17.6 – 添加 AR 资源
在添加新的资源组后,你可以将图片拖入创建的文件夹中。ARKit 会一次性加载和监控每个资源组,所以请确保不要将太多图片添加到单个资源组中,因为这可能会对你的应用性能产生负面影响。苹果建议你将大约 25 张图片添加到单个资源组中。
在你将图片添加到资源组后,Xcode 将分析图片,并在它认为你的图片有问题时发出警告。通常,Xcode 会在你添加新图片时立即通知你,因为 ARKit 需要知道你想要检测的图像的物理尺寸。所以,如果你要检测特定的画作或杂志中的一页,你必须以厘米为单位添加这些资源的尺寸,就像它们在现实世界中存在的那样。
从代码包中开始的项目包含了一些准备好的图片,你可以探索这些图片来查看你可以在自己的应用中使用的一些图片类型示例。
小贴士
如果你想要添加一些自己的内容,可以拍摄家中或办公室的艺术品或图片。你可以使用 iOS 中的 Measure 应用来测量图片的物理尺寸,并将它们添加到你的 AR 相册项目中。确保你的图片色彩饱和,没有任何眩光或反射。
一旦你找到了一些优秀的内容用于你的 AR 相册,就到了构建体验本身的时候了。
构建图像跟踪体验
要实现图像跟踪,你需要设置一个使用 ARWorldTrackingConfiguration 的 ARSession 来检测图像并跟踪用户在环境中的移动。当场景中发现你准备好的其中一个图像时,会在图片上方添加一个 SCNPlane,并附上对图片本身的简短描述。
因为 ARKit 使用摄像头,所以你的应用必须明确提供访问摄像头的原因,以便用户理解为什么你的应用需要使用他们的摄像头权限。将 NSCameraUsageDescription 键添加到 Info.plist 文件中,并添加一段简短的文字说明为什么相册需要访问摄像头。
如果你打开 ViewController.swift,你会找到一个名为 artDescriptions 的属性。确保更新这个字典,包含你添加到资源组的图片名称,并为每张图片添加一个简短描述。
接下来,更新 viewDidLoad(),以便将 ViewController 设置为 ARSCNView 和 ARSession 的代理。添加以下代码行来完成此操作:
arKitScene.delegate = self
arKitScene.session.delegate = self
场景代理和会话代理非常相似。会话代理提供了对场景中显示的内容的非常细粒度的控制,如果你自己构建渲染,你通常会广泛使用此协议。由于 AR 相册使用 SceneKit 渲染,采用 ARSessionDelegate 的唯一原因是为了响应会话跟踪状态的变化。
你应该采用的所有有趣的方法都是 ARSCNViewDelegate 的一部分。这个代理用于响应特定事件,例如,当场景中发现了新功能或添加了新内容时。
目前,你的 AR 画廊并没有做什么。你必须配置场景中的一部分 ARSession 来开始使用 ARKit。设置这一切的最佳时机是在视图控制器变得可见之前。因此,你应该在 viewWillAppear(_:) 中完成所有剩余的设置。将以下实现添加到 ViewController 中:
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// 1
let imageSet = ARReferenceImage.referenceImages(
inGroupNamed: "Art", bundle: Bundle.main)!
// 2
let configuration = ARWorldTrackingConfiguration()
configuration.planeDetection = [.vertical, .horizontal]
configuration.detectionImages = imageSet
// 3
arKitScene.session.run(configuration, options: [])
}
代码解释如下:
-
这个方法的第一步是从应用程序包中读取参考图像。这些是你添加到
Assets.xcassets中的图像。 -
接下来,创建
ARWorldTrackingConfiguration,并配置它来跟踪水平和垂直平面,以及参考图像。 -
最后,配置被传递到会话的
run(_:options:)方法中。
如果你现在运行你的应用程序,你应该已经提示了相机使用,你应该看到错误处理正在工作。尝试用手遮住相机,这应该会显示一个错误消息。
如果一个视图不再可见,保持 AR 会话活跃是非常浪费的,所以如果应用程序关闭或包含 AR 场景的视图控制器变得不可见,暂停会话是一个好主意。将以下方法添加到 ViewController 中以实现这一点:
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
arKitScene.session.pause()
}
在当前设置中,AR 会话检测你的图像,但没有做任何事情来可视化这一点。当你添加的图像之一被识别时,ARSCNViewDelegate 会收到通知。具体来说,当在场景代理上添加一个新的 SCNNode 时,会调用 renderer(_:didAdd:for:) 方法。例如,当 AR 会话发现一个平坦的表面时,它会为 ARPlaneAnchor 添加一个节点,或者当它检测到你正在跟踪的图像之一时,会添加一个 ARImageAnchor 的节点。由于这个方法可能因不同原因而被调用,因此添加逻辑来区分可能导致在场景中添加新的 SCNNode 的各种原因是非常重要的。
因为 AR 画廊将实现其他几个可能触发添加新节点的功能,你应该将针对每种不同类型的锚点想要采取的不同操作分离到专门的方法中。将以下方法添加到 ARSCNViewDelegate 中以在检测到的图像旁边添加信息平面:
func placeImageInfo(withNode node: SCNNode, for anchor:
ARImageAnchor) {
let referenceImage = anchor.referenceImage
// 1
let infoPlane = SCNPlane(width: 15, height: 10)
infoPlane.firstMaterial?.diffuse.contents = UIColor.white
infoPlane.firstMaterial?.transparency = 0.5
infoPlane.cornerRadius = 0.5
// 2
let infoNode = SCNNode(geometry: infoPlane)
infoNode.localTranslate(by: SCNVector3(0, 10, -
referenceImage.physicalSize.height / 2 + 0.5))
infoNode.eulerAngles.x = -.pi / 4
// 3
let textGeometry = SCNText(string:
artDescriptions[referenceImage.name ?? "flowers"],
extrusionDepth: 0.2)
textGeometry.firstMaterial?.diffuse.contents =
UIColor.red
textGeometry.font = UIFont.systemFont(ofSize: 1.3)
textGeometry.isWrapped = true
textGeometry.containerFrame = CGRect(x: -6.5, y: -4,
width: 13, height: 8)
let textNode = SCNNode(geometry: textGeometry)
// 4
node.addChildNode(infoNode)
infoNode.addChildNode(textNode)
}
上述代码应该对你来说有些熟悉。首先,创建一个 SCNPlane 实例。然后,将这个平面添加到 SCNNode。这个节点稍微移动以定位在检测到的图像上方。这个平移使用 SCNVector3 以便可以转换到三维。节点也稍微旋转以创建一个看起来不错的效果。
接下来,为 renderer(_:didAdd:for:) 添加以下实现:
func renderer(_ renderer: SCNSceneRenderer, didAdd node:
SCNNode, for anchor: ARAnchor) {
if let imageAnchor = anchor as? ARImageAnchor {
placeImageInfo(withNode: node, for: imageAnchor)
}
}
此方法检查发现的锚点是否为图像锚点;如果是,则调用 placeImageInfo(withNode:for:) 来显示信息标志。
现在运行您的应用!当您找到您添加到资源组中的图像之一时,应该会在其上方出现一个信息框,如下面的截图所示:

图 17.7 – 图像上方的 AR 盒
真的很棒,对吧?让我们更进一步,允许用户将收藏视图中的某些图片放置在场景中的任何位置。
在 3D 空间中放置您自己的内容
为了让 AR 画廊更加生动,能够将一些新的艺术品添加到环境中会很好。使用 ARKit,这样做变得相对简单。在实现此类功能时,需要考虑一些注意事项,但总体而言,苹果公司让 ARKit 成为一个对开发者来说易于使用的平台。
当用户在屏幕底部的收藏视图中点击其中一个图像时,他们点击的图像应该被添加到环境中。如果可能,图像应该附着在用户周围的墙壁之一上。如果这不可能,图像仍然会被添加,但会漂浮在空间的中间。
要构建此功能,您应该实现 collectionView(_:didSelectItemAt:),因为当用户在收藏视图中点击其中一个项目时,会调用此方法。当此方法被调用时,代码应获取用户在环境中的当前位置,然后插入一个新的 ARAnchor,该锚点对应于新项目应添加的位置。
此外,为了检测附近的垂直平面,例如墙壁,需要进行一些碰撞测试以查看是否存在垂直平面位于用户前方。添加以下 collectionView(_:didSelectItemAt:) 的实现:
func collectionView(_ collectionView: UICollectionView,
didSelectItemAt indexPath: IndexPath) {
//1
guard let camera =
arKitScene.session.currentFrame?.camera
else { return }
//2
let hitTestResult = arKitScene.hitTest(CGPoint(x: 0.5, y:
0.5), types: [.existingPlane])
let firstVerticalPlane = hitTestResult.first(where: {
result in
guard let planeAnchor = result.anchor as? ARPlaneAnchor
else { return false }
return planeAnchor.alignment == .vertical
})
//3
var translation = matrix_identity_float4x4
translation.columns.3.z = -
Float(firstVerticalPlane?.distance ?? -1)
let cameraTransform = camera.transform
let rotation = matrix_float4x4(cameraAdjustmentMatrix)
let transform = matrix_multiply(cameraTransform,
matrix_multiply(translation, rotation))
//4
let anchor = ARAnchor(transform: transform)
imageNodes[anchor.identifier] = UIImage(named:
images[indexPath.row])!
arKitScene.session.add(anchor: anchor)
storeWorldMap()
}
尽管这个片段中只有四个步骤,但发生了很多事情。让我们回顾一下:
-
首先,从 AR 会话的当前帧中获取相机,以便稍后用于确定用户在场景中的位置。
-
接下来,执行碰撞测试以查看场景中是否已经检测到任何平面。由于此碰撞测试将返回垂直和水平平面,因此结果被过滤以找到碰撞测试中发现的第一个垂直平面。
-
由于每个
ARAnchor的位置都表示为从世界原点开始的变换,因此第三步是确定应用于将新艺术品放置在正确位置的变换。世界原点是 AR 会话首次变得活跃的地方。在创建默认平移后,调整平移的z值,以便对象被添加到用户前面或最近的垂直平面上。接下来,通过摄像头检索用户的当前位置。在下一步中,需要调整摄像头的旋转,因为摄像头不跟随设备的方向。这意味着摄像头将始终假设x轴沿着设备的长度运行,从顶部开始向下移动到主指示器区域。一个计算属性已经添加到 AR 画廊入门项目中,以确定如何调整旋转。 -
在为锚点设置正确的变换属性后,创建一个
ARAnchor实例。然后将用户点击的唯一标识符和图像存储在imageNodes字典中,以便在新的锚点在场景中注册后添加图像到场景。
要将图像添加到场景中,你应该实现一个辅助方法,该方法将从rendered(_:didAdd:for:)中调用,类似于你为显示图像跟踪功能的信息卡添加的辅助方法。将以下代码添加到ViewController中来实现此辅助方法:
func placeCustomImage(_ image: UIImage, withNode node:
SCNNode) {
let plane = SCNPlane(width: image.size.width / 1000,
height: image.size.height / 1000)
plane.firstMaterial?.diffuse.contents = image
node.addChildNode(SCNNode(geometry: plane))
}
为了更容易地看到是否存在合适的垂直平面,你可以实现一个辅助方法来可视化 AR 会话发现的平面。将以下代码添加到ViewController类中来实现此辅助方法:
func vizualise(_ node: SCNNode, for planeAnchor:
ARPlaneAnchor) {
let infoPlane = SCNPlane(width:
CGFloat(planeAnchor.extent.x), height:
CGFloat(planeAnchor.extent.z))
infoPlane.firstMaterial?.diffuse.contents =
UIColor.orange
infoPlane.firstMaterial?.transparency = 0.5
infoPlane.cornerRadius = 0.2
let infoNode = SCNNode(geometry: infoPlane)
infoNode.eulerAngles.x = -.pi / 2
node.addChildNode(infoNode)
}
之前的方法接受一个节点和锚点来创建一个新的SCNPlane,并将其添加到新平面锚点被发现的确切位置。
实现此功能的最后一步是在需要时调用辅助方法。更新renderer(_:didAdd:for:)的实现如下:
func renderer(_ renderer: SCNSceneRenderer, didAdd node:
SCNNode, for anchor: ARAnchor) {
if let imageAnchor = anchor as? ARImageAnchor {
placeImageInfo(withNode: node, for: imageAnchor)
} else if let customImage = imageNodes[anchor.identifier]
{
placeCustomImage(customImage, withNode: node)
} else if let planeAnchor = anchor as? ARPlaneAnchor {
vizualise(node, for: planeAnchor)
}
}
如果你现在运行你的应用,你应该会看到在 ARKit 检测到的平坦区域出现橙色方块。请注意,ARKit 需要纹理和视觉标记才能正常工作。如果你尝试检测一个实心白色墙面,由于缺乏纹理,ARKit 可能无法正确识别墙面。然而,砖墙或带有一些图形的壁纸墙面应该适用于此目的。
以下截图显示了一个示例,其中图像被附加到墙上,同时显示平面指示器:

图 17.8 – 向 AR 平面上添加图像
这完成了你个人 AR 画廊的实现。关于你可以用 AR 做什么,还有很多东西要学习,所以请确保继续实验和学习,以便为你的用户提供令人惊叹的体验。
摘要
在本章中,你学到了很多。你对 AR 是什么,AR 的基本工作原理以及你可以用它做什么有了更深的了解。然后你学习了构成优秀 AR 体验的组件,并通过在应用程序中采用 Quick Look 来预览真实 AR 会话中的 AR 内容,实现了你的第一个小型 AR 体验。
然后你探索了在 AR 场景中渲染内容的不同方法。你快速浏览了 SpriteKit 和 SceneKit,并了解到 SpriteKit 是苹果的 2D 游戏开发框架。你还了解到 SceneKit 是苹果的 3D 游戏框架,这使得它在 AR 应用程序中使用极为合适。
然后你实现了一个使用图像跟踪和平面检测的 AR 画廊,允许用户将他们自己的内容添加到他们的画廊中。在这个过程中,你发现要让 ARKit 工作良好并不总是容易。不良的照明和其他因素可能会使 AR 体验远低于理想状态。
在下一章中,你将使用 Catalyst 创建一个 macOS 应用程序。
第十八章:第十八章:使用 Catalyst 创建 macOS 应用程序
在 2019 年的 WWDC 上,苹果向全球开发者推出了 Mac Catalyst。有了 Mac Catalyst,开发者可以轻松地将 iPad 应用程序带到 Mac 上。Catalyst 允许 iPad 应用程序无需太多努力即可移植到 Mac。这为 iPad 应用程序带来了全新的受众(Mac 用户),并扩大了 macOS 生态系统的可能性。
在本章中,我们将回顾 Mac Catalyst 的基础知识。我们将探索在 WWDC 2020 上引入的新功能,并使用 Catalyst 将 iPad 应用程序转换为 Mac 应用程序。我们将通过使用 Catalyst 的两种不同方式来实践这一点:缩放界面以匹配 iPad 和新的 优化界面以适应 Mac 选项。我们将比较它们之间的差异以及两种方法的优缺点。
在本章中,我们将涵盖以下主要主题:
-
探索 Mac Catalyst
-
探索新的 Mac Catalyst 功能
-
构建您的第一个 Mac Catalyst 应用程序
到本章结束时,您将能够将您的 iPad 应用程序迁移到 macOS,并在 Mac 生态系统中扩大您应用程序的受众和可能性。
技术要求
本章的代码包包括一个名为 todo_start 的入门项目及其完成版本。您可以在代码包仓库中找到它们:
github.com/PacktPublishing/Mastering-iOS-14-Programming-4th-Edition
探索 Mac Catalyst
Mac Catalyst 帮助开发者将他们的 iPad 应用程序带到 Mac 上。原生 Mac 应用程序可以与 iPad 应用程序共享代码,为用户和开发者创建一个统一的生态系统。
使用 Mac Catalyst,开发者可以将 iPad 上的触摸手势和控件适配到 Mac 应用程序中的鼠标和键盘控件。
当苹果在 Mac Catalyst 中为 Mac 添加对 UIKit 的支持时,它在 iPad 和 Mac 之间的兼容性方面迈出了巨大的一步。使用 SwiftUI 的应用程序则具有成为通用应用程序的优势,因此它们在两个系统上适应得更好。
一旦应用程序在 Mac Catalyst 的帮助下从 iPad 切换到 iPad + Mac,结果非常令人期待。有一个代码库可以服务于这两个平台。通过只有一个代码库,公司可以减少开发、维护和修复应用程序功能(针对两个系统)所需的时间和精力。
Mac Catalyst 也有一些缺点。目前并非每个 iOS 框架都得到支持。苹果每年都在不断增加。此外,一些第三方库可能不受支持,开发者有责任将它们从 Mac 系统中排除并寻找替代方案。
Mac Catalyst 的另一个缺点是,一些从 iPad 移植到 Mac 的应用程序可能会感觉有些脱离上下文。我指的是一些利用了重 iOS 外观和感觉的应用程序,并且直接移植到 Mac。UI 的某些元素在这两个系统中差异很大(复选框、弹出窗口、按钮的位置等)。一些应用程序可能需要一些额外的工作来将 UI 从 iPad 调整为 Mac 风格,但并非每个公司或团队都有资源、时间或意愿这样做。
为了帮助解决这个问题,Mac Catalyst 新增了一个名为优化界面以适应 Mac的功能。与之前的缩放界面以匹配 iPad选项不同,Mac Catalyst 允许这个新功能自动将一些 UIKit 控件转换为更符合 Mac 风格的控件。
在本节中,我们学习了 Mac Catalyst 的基础知识。让我们在下一节中讨论 WWDC 2020 期间展示的 Mac Catalyst 的新改进。
探索新的 Mac Catalyst 功能
在 WWDC 2020 期间,苹果展示了新的优化界面以适应 Mac方法。当我们使用这种方法将 iPad 应用程序移植到 Mac 时,它带来了与之前方法缩放界面以匹配 iPad的一些显著差异。差异如下:
-
内容以 1:1 渲染。使用缩放界面时,视图在 Mac 上缩放到原始大小的 77%。这可能会在某些具有AutoLayout规则的视图中引起问题,这些规则可能会破坏或简单地改变 UI 的整体形状。现在,使用 1:1 渲染,iPad 应用程序和 Mac 应用程序将保持相同的尺寸和大小。这通过在 Mac 上不缩放文本来大大提高了文本质量;文本看起来更好,更容易阅读。
-
macOS 控件用于 UIKit 对应项。通过新的优化界面以适应 Mac选项,Catalyst 使用 Mac 风格的控件而不是 iPad 应用程序中的 UIKit 控件。通过这样做,Mac 上的应用程序 UI 对 Mac 用户来说看起来更加熟悉。
-
与前一点类似,Mac Catalyst 应用程序中使用了 macOS 字体间距和标准 macOS 间距,而不是 iPad 版本中定义的间距(它们是不同的)。
-
通过 Catalyst,许多 iOS 框架现在可用于 Mac。例如,
AVFoundation、NotificationCenter、ReplayKit、StoreKit、MessageUI以及更多。 -
在 iOS 上增加了对物理键盘事件的支持。现在它们在 Mac Catalyst 上也可用,游戏可以从中受益。
-
现在可用 tvOS 的焦点引擎。
-
tableViews和collectionViews中的.selectionFollowsFocus现在可用。 -
现在我们可以根据需要隐藏 Mac 上的光标。
-
新增了颜色轮和颜色选择器。
-
UISplitViewController现在支持三列。 -
完全支持
SFSymbols。 -
Mac Catalyst 的新扩展,如照片编辑扩展,现在可用。
-
由于 Catalyst,WidgetKit 的小部件也从 iPad 扩展到 Mac。
-
用户可以享受通用购买(在 iPad 上购买项目并在 Mac 应用程序中使用)。
-
新的工具栏样式。
在本章的后面部分,当使用这两种方法构建应用程序时,你将能够看到这些差异,并且你将应用必要的修复和步骤来避免在你的应用程序中出现这些问题。
在本节中,我们了解了 2020 年为 Mac Catalyst 推出的新功能。现在,让我们在下一节开始构建我们的第一个 Mac Catalyst 应用程序!
构建你的第一个 Mac Catalyst 应用程序
在本节中,我们将开始使用一个简单的 iPad 待办事项应用程序,并使用两种不同的技术将其转换为 macOS 应用程序。基本应用程序非常基础(你甚至无法向其中添加新的待办事项元素!)但它说明了从 iPad 到 Mac 转换时需要经历哪些类型的错误、UI 修改和方法。
我们将遵循以下步骤:
-
首先,我们将探索 iPad 应用程序本身,以了解其基本元素和组件。
-
然后,我们将使用第一种方法使其与 macOS 兼容:将界面缩放以匹配 iPad。
-
最后,我们将使用新的方法,优化界面以匹配 Mac。我们将将其与缩放界面方法进行比较,以便匹配 iPad 方法,这样你将了解何时使用一个或另一个,这取决于你的应用程序。
让我们从探索我们的 iPad 待办事项应用程序开始!
探索 iPad 应用程序
在本节中,我们将快速查看基本应用程序及其组件,以便在理解我们在做什么的同时对其进行修改。
你可以在本书的代码包中找到代码。项目名称是todo_start。继续打开项目。构建并运行它。你应该在横幅模式下的 iPad 模拟器中看到类似这样的内容:
![图 18.1 – 待办事项应用横幅模式
![img/Figure_18.01_B14717.jpg]
图 18.1 – 待办事项应用横幅模式
如果你熟悉 iPad 应用程序,你将能够从这些屏幕截图中发现,此 iPad 应用程序的主要组件是SplitViewController。SplitViewController通常在其内部有两个或三个列(UIViewController实例)。在我们的例子中,我们有两个:左侧的侧边菜单和右侧的详细面板(在横幅模式下)。在纵向模式下,侧边菜单变为弹出菜单,详细面板是主视图。
让我们快速检查项目结构和突出显示其中的最重要文件:
-
MasterViewController.swift文件包含MasterViewController,它是SplitViewController的侧边菜单。它有一个表格视图及其相应的表格视图单元格(CategoryTableViewCell)。 -
DetailViewController.swift文件包含DetailViewController,它是SplitViewController的详细视图。它有一个表格视图,以及相应的表格视图单元格(EntryTableViewCell)。 -
Datasource.swift文件包含了项目的Datasource,它使用load() -> [Category]方法为视图控制器提供待办事项列表。它还包含了我们待办项目的模型。待办事项列表是通过类别(如工作、杂货或家庭)以及这些类别内的条目(如“给我的老板打电话”)构建的。Datasource.swift文件包含代表这些模型的结构体:Category、Entry和Priority。在现实世界的应用中,你会将这些模型分别放入自己的文件/目录中,但为了简单起见,我们将它们保留在Datasource本身中。
因此,为了总结应用组件,侧边菜单(MasterViewController)以表格的形式显示待办事项类别的列表(Category 和 CategoryTableViewCell 实例)。当选择一个类别时,详细视图(DetailViewController)显示一个包含不同待办事项条目的表格(Entry 和 EntryTableViewCell 实例)。所有数据都由 Datasource 提供。
每个类别中待办事项的条目由包含每个待办事项不同信息的单元格表示(EntryTableViewCell):
![Figure 18.2 – Entry cell]
![img/Figure_18.02_B14717.jpg]
图 18.2 – 条目单元格
这些表格视图单元格包含以下内容:
-
一个
UISwitch用于表示待办事项是挂起还是完成。 -
一个
UIPickerView用于表示任务的优先级(高、中或低)。 -
一个
UILabel用于描述任务。 -
一个
UIButton用于设置任务中的闹钟。
在右上角还有一个额外的按钮:
![Figure 18.3 – Add to-do button]
![img/Figure_18.03_B14717.jpg]
图 18.3 – 添加待办事项按钮
此按钮表示允许用户向待办事项列表添加新条目的操作。
目前除了显示这些元素本身之外,没有任何功能,但你在本章后面会理解为什么每个元素都存在。这是一个简单易用的应用,对吧?现在让我们从 iPad 到 Mac 开始转换过程!
为 Mac 调整你的 iPad 应用
在本节中,我们将使用 Mac Catalyst 的 Scale Interface to Match iPad 方法将 iPad 应用转换为 Mac 兼容的应用。这是苹果首次引入的将 iPad 应用轻松转换为 Mac 应用的方法。
从当前部分打开项目并转到项目导航器。在 Deployment Info 部分勾选 Mac 复选框,并在弹出窗口中按 Enable:
![Figure 18.4 – Enabling Mac support]
![img/Figure_18.04_B14717.jpg]
图 18.4 – 启用 Mac 支持
确保选项设置为 Scale Interface to Match iPad。
现在,使用 Mac 作为目标设备构建并运行应用。你应该看到以下 UI:
![Figure 18.5 – The Mac version of the to-do app]
![img/Figure_18.05_B14717.jpg]
图 18.5 – 待办事项应用的 Mac 版本
这非常简单!诚然,我们的示例应用程序非常简单直接。但通过简单的点击,它已经兼容并且“可用”在 Mac 上。我们没有做任何工作!然而,尽管应用程序可用,但它没有 Mac 风格。让我们列出一些与传统 Mac 应用程序不同的元素:
-
Mac 应用程序不使用工具栏来包含诸如+符号之类的操作。这些操作通常位于右下角。
-
例如设置闹钟的按钮看起来不像 Mac 按钮。
-
Mac 应用程序不太使用这种类型的 Picker。
-
Mac 应用程序使用复选框而不是开关。
-
视图已缩放到原始尺寸的 77%。这可能会破坏您代码中的某些约束,您可能需要审查 UI 的部分。
您的 iPad 应用程序在 iPad 上具有越复杂的 UI,使用这种方法就越感觉不像 Mac。但我们不能抱怨太多;我们只是通过一键使其兼容!
这个迭代始终是移植您的 iPad 应用程序到 Mac 的第一步。现在我们有了 Mac 应用程序,我们将致力于改进 UI,使其看起来更像 Mac。为此,我们将使用苹果公司创建的新方法:Optimize Interface for Mac。这种方法有其优点和缺点,我们将在下一节中看到它们。
优化 iPad 应用程序以适应 Mac
在本节中,我们将使用 iPad 应用程序上的Optimize Interface for Mac选项,并学习如何将结果调整以适应我们应用程序上预期的 Mac 风格界面。
在项目导航器中,在Deployment Info部分,将 Mac 选项更改为Optimize Interface for Mac:

图 18.6 – 使用 Optimize Interface for Mac
选择此选项后,将目标更改为 Mac 并启动应用程序。您应该会收到以下崩溃信息:
[General] UIPickerView is not supported when running Catalyst apps in the Mac idiom.
当我们使用此示例中显示的UIPickerView实例时,我们没有遇到任何问题。一个解决方案可能是使用 SwiftUI 的 Picker(在ComboBox下可用)。
我们现在将学习如何根据运行它的设备来使用或不在我们的应用程序中使用特定的组件。我们将在这个 iPad 上安装这个UIPickerView,但我们将从 Mac 版本中移除它(为了现在能够编译)。我们将通过使用 Storyboard 变体来实现这一点。
Storyboard 变体可以帮助我们根据某些参数(如设备、屏幕宽度、高度和色域)在视图控制器中安装或卸载特定组件。
让我们在应用程序在 Mac 上运行时从单元格中卸载UIPickerView。按照以下步骤操作:
-
打开
Main.storyboard文件并转到Detail View Controller。选择entry单元格原型:![图 18.7 – Detail View Controller]()
图 18.7 – Detail View Controller
-
现在,选择单元格的
UIPickerView,并在其属性检查器窗口中,通过点击+符号在已安装部分添加一个变体:![图 18.8 – 添加已安装变体![图片]()
图 18.8 – 添加已安装变体
-
在出现的弹出窗口中,从方言选择器中选择Mac:![图 18.9 – 添加 Mac 方言变体
![图片]()
图 18.9 – 添加 Mac 方言变体
-
现在,您想要取消选中新的变体,这样这个组件就不会在 Mac 方言中安装:

图 18.10 – 卸载 Mac 方言变体
太好了!通过在故事板中使用变体,您可以根据运行它的设备和其它因素来指定安装某些组件的时间!现在尝试再次启动应用,以 Mac 为目标。这次,应用应该不会崩溃,您将看到以下屏幕:

图 18.11 – 为待办事项应用 Mac 版本的第一次优化
太好了!我们成功地使用故事板变体来适配我们应用的 Mac 版本。理想情况下,您现在应该找到一个适用于 Mac 的替代UIPickerView(SwiftUI 的 Picker 是一个例子)。这将是您的家庭作业!
你可以在前面的屏幕截图中看到,当使用优化界面选项将 iPad 应用转换为 Mac 时,仍然存在一些常见问题:
-
在类别表格单元格中,字体大小与数字字体大小不同。在我们的 iPad 应用中,字体大小是相同的。以工作(3)的字体大小为例,仔细查看一下。
-
Mac 应用不使用工具栏中的按钮,如+。此类操作最常见的地方是窗口的右下角。
接下来让我们处理这两个问题。打开Main.storyboard文件,检查根视图控制器表格中标签使用的字体:

图 18.12 – 根视图控制器单元格标签
如果您查看这两个标签中使用的字体大小,它们并不相同。第一个标签使用的是正文字体。第二个标签使用的是系统 - 17.0字体。但为什么在缩放界面以匹配 iPad中它们看起来一样呢?原因是,在那个选项中,视图被缩放到原始大小的 77%,两种字体看起来都一样。但在优化界面以匹配 Mac中,视图保持 1:1 的比例,预定义的文本样式会适应视图内容大小。因此,如果您打算使用带有优化界面的 iPad 应用在 Mac 上,最好的做法是在您的整个应用中使用这些预定义的样式。您将不必根据设备进行调整。
为了解决这个问题,请在标签属性检查器中将系统 – 17.0字体更改为正文字体:

图 18.13 – 使用文本样式
现在在 Mac 目标上运行应用:

图 18.14 – 在 Mac 上新的字体样式结果
如前一个截图所示,工作和(3)的字体大小现在相同。如果你在 iPad 上运行应用,它们也将相同。我们不再有任何差异。
在这个修复到位后,是我们时候在DetailViewController上隐藏工具栏了。Mac 应用不使用工具栏来显示单个动作,就像我们现在所做的那样:

图 18.15 – 带有右侧动作按钮的工具栏
我们学习了如何使用故事板变体来显示/隐藏元素,但对于这个工具栏,我们将以编程方式来做。组件仍然会被安装,但我们将在 Mac 上隐藏它。打开DetailViewController文件,并更改viewWillAppear方法实现:
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if traitCollection.userInterfaceIdiom == .mac {
navigationController?.setToolbarHidden(true, animated: false)
} else {
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(createTapped))
navigationController?.setToolbarHidden(false, animated: animated)
}
}
检查高亮的代码。我们能够通过使用traitCollection的userInterfaceIdiom属性来检测我们正在哪个设备上启动应用。当它是.mac时,我们隐藏工具栏,并且当我们处于其他设备(如 iPad)上时,仅添加右侧的+按钮。
如果你构建并执行 Mac 目标上的应用,+按钮消失了。太好了!但现在我们无法创建新的待办事项!我们失去了对 Mac 上此按钮的访问。我们需要以不同的方式适应这种场景。
传统上,对于 Mac 界面,选择Main.storyboard文件。选择详细视图控制器的表格视图:

图 18.16 – 详细视图控制器表格视图
现在我们将在控制器的底部为一个新的按钮腾出空间,但仅限于 Mac。请按照以下步骤操作:
-
进入详细视图控制器的表格视图的大小检查器。
-
编辑将表格视图底部与控制器底部连接的约束,
-60:![图 18.17 – 编辑表格视图底部约束![图片]()
图 18.17 – 编辑表格视图底部约束
-
现在在视图控制器和表格之间添加一个新的
UIButton。将按钮标题设置为创建。添加以下截图显示的四个约束:![图 18.18 – 添加新按钮![图片]()
图 18.18 – 添加新按钮
-
现在从表格拖动到新添加的创建按钮,添加一个垂直间距约束(你可以按住Ctrl拖动)。
-
我们想为按钮添加一个变体。我们希望按钮仅适用于 Mac 方言。所以,请为 Mac 添加一个变体,并取消选中默认选项(就像我们在本章之前为
UIPickerView所做的那样)。通过这样做,按钮将仅在 Mac 设备上可见:![图 18.19 – 仅在 Mac 中安装新按钮![图 18.19 – Mac 应用程序最终版本
图 18.19 – 仅在 Mac 中安装新按钮
-
如果您操作得当,如果使用 iPad 作为预览设备,按钮应该会在故事板中消失。您可以将设备更改为Mac,使用Mac 方言(而不是 Mac 与 iPad 方言!),它将再次显示(您可以在故事板窗口的底部选项中这样做):![图 18.20 – 设备预览选择
![图 18.20 – 设备预览选择
图 18.20 – 设备预览选择
-
最后,我们需要再次编辑此列表中第 2 项的约束条件。您已向其中添加了
-60常数。现在我们想将其恢复到0,就像之前一样。
现在请使用 iPad 目标执行应用程序。您应该仍然在右上角看到+符号,并且看不到底部的创建按钮。现在在 Mac 目标上执行它。您应该在控制台上得到以下错误:
[LayoutConstraints] Unable to simultaneously satisfy constraints.
这是因为我们添加了两个不能共存的不同约束条件:
-
0 -
9
实际上,我们需要两个常数,一个用于 iPad 设备,另一个仅适用于 Mac。请将其中一个常数的优先级更改为250:
![图 18.21 – 更改约束优先级
![图 18.21 – Mac 的初始缩放版本
图 18.21 – 更改约束优先级
通过这种方式,我们可以同时保留两个约束条件,但它们不会相互排斥。当没有安装 Mac 按钮时,该约束条件将不会生效,另一个约束条件将应用(将表视图底部与安全区域底部对齐)。请使用 Mac 目标执行应用程序:
![图 18.22 – Mac 应用程序最终版本
![图 18.22 – Mac 应用程序最终版本
图 18.22 – Mac 应用程序最终版本
看起来很棒!现在我们有了两种不同的 UI 变体,一个用于 iPad,另一个更适合 Mac 标准。现在将其与缩放界面以匹配 iPad为我们提供的之前的 iPad 外观版本进行比较:
![图 18.23 – Mac 的初始缩放版本
![图 18.23 – Mac 的初始缩放版本
图 18.23 – Mac 的初始缩放版本
如您所见,这相当不同!新版本感觉更符合 Mac 原生风格。按钮、工具栏、控件位置以及元素的整体缩放感觉对 Mac 版本来说要好得多。这需要做更多的工作,但结果是值得的。您始终可以将 iPad 应用程序的初始端口移植到 Mac,使用缩放界面以匹配 iPad,然后稍后对优化界面以匹配 Mac进行工作!
在本节中,我们从简单的待办事项 iPad 应用开始。我们使用 Mac Catalyst 将其移植到 Mac。首先,我们使用了将界面缩放以匹配 iPad选项,使应用一键即可在 Mac 上使用。但随后,我们希望改进 UI,使其更符合 Mac 标准,因此我们使用了新的优化界面以适应 Mac选项。这个选项不像缩放选项那样直接,我们不得不调整某些尺寸,删除一些在 Mac 上不可用的 UI 控件,并为 Mac 创建不同的变体。但结果看起来很棒!
现在我们用总结来结束这一章。
总结
我们以简短的 Mac Catalyst 介绍开始了这一章。我们解释了苹果如何通过 Mac Catalyst 为开发者提供了一种简单的方法,将 iPad 应用移植到 Mac 应用,以及这一新特性带来的所有好处。
然后,我们讨论了 2020 年 Mac Catalyst 的最新改进和变化。在这些新特性中,我们提到了优化界面以适应 Mac的含义,以及它是如何增强 iPad 应用,使其成为优秀的 Mac 应用的。
最后,以 iPad 应用为起点,我们使用 Mac Catalyst 的两种方法:将界面缩放以匹配 iPad和优化界面以适应 Mac,创建了 Mac 版本。我们展示了它们的优缺点,并应用了你在使用这两种方法时最常遇到的修复和改进。通过将它们与同一应用进行比较,你对它们之间的主要区别以及何时应用哪一个或另一个有了了解。
在下一章,我们将学习如何以及何时测试你的代码。
第十九章:第十九章:通过测试确保应用质量
到目前为止的所有章节中,主要关注的是作为应用一部分运行的代码。你开发的应用程序较小,可以轻松手动测试。然而,如果你的应用程序变得更大,这种方法就不太适用了。如果你想要验证大量的不同用户输入、多个屏幕、复杂的逻辑,或者你打算在许多不同的设备上运行测试,这种方法同样不适用。
Xcode 自带内置的测试工具。这些工具允许你编写测试,以确保你的应用中的所有业务逻辑按预期工作。更重要的是,你可以测试用户界面在许多不同的自动化场景中是否按预期功能和表现。
许多开发者倾向于回避测试,直到项目结束时才进行,或者根本不进行测试。原因通常是因为很难想出如何编写合适的测试。如果你刚开始接触测试,这一点尤其正确。许多开发者觉得他们测试的逻辑中的大部分内容非常明显,为这种逻辑编写测试似乎很荒谬。如果测试方法不正确,它可能会成为负担而不是解脱,因为维护成本高且没有测试代码的关键区域。
本章作为使用 Xcode 及其内置工具编写逻辑和用户界面测试的介绍。到本章结束时,你应该能够设置一个健壮的测试套件,并了解如何利用 Xcode 提供的工具编写可测试且可靠的代码。本章涵盖了以下主题:
-
使用 XCTest 测试逻辑
-
优化代码以进行测试
-
使用 XCUITest 测试用户界面
使用 XCTest 测试逻辑
本节将帮助你发现 iOS 上XCTest的测试能力。即使你之前没有编写过任何测试,你可能也有自己的想法或想法。要开始测试代码,你不需要拥有计算机科学学位或花费几天时间学习测试代码的绝对最佳方法。实际上,你很可能已经在测试你的代码,而你甚至不知道这一点。
那么,测试代码究竟意味着什么呢?本节旨在阐明这一点。首先,你将了解你可以编写的不同类型的测试。然后,你将学习XCTest是什么以及如何为应用设置测试套件。最后,你将学习如何最优地测试实际代码,以及代码如何重构以使其更易于测试。
理解测试代码的含义
当你测试代码时,你实际上是在确保某些输入产生预期的输出。一个非常基础的测试例子就是确保调用一个将输入增加给定值的函数会产生你预期的输出。
每次你启动你的应用程序并在其中执行任何操作时,你实际上都在测试你的代码的某个部分。每次你向控制台打印某些内容以验证预期的值被打印出来时,你也在测试你的代码。一旦你这样思考测试,之前可能听起来很困难的概念实际上并不像你可能认为的那么复杂。所以,如果你只是通过使用你的应用程序,实际上已经在测试它了,那么,你应该为哪些内容编写测试呢?让我们看看如何确定何时为你的代码编写测试。
确定要编写哪些测试
当你开始测试时,通常很难决定你想要测试哪些逻辑,不想要测试哪些逻辑。造成这种情况的原因可能包括某些逻辑过于简单、过于复杂,或者不够重要以至于不值得测试。这个陈述意味着你不需要测试应用程序中的每一行代码,这是故意的。有时为代码的某个部分编写测试是不合理的。例如,你不需要测试UIKit是否按预期工作;确保他们发布的框架没有错误是苹果的工作。
确定要测试的内容很重要,而且你推迟决定是否为特定逻辑添加测试的时间越长,编写测试就越困难。一个简单的经验法则是你不需要测试苹果的框架。可以安全地假设苹果会确保他们发布的任何代码都经过测试,而且如果其中包含错误,你实际上也无法做太多来修复它。此外,你不想让你的测试在苹果的测试应该失败的地方失败。
你至少应该测试的是你的方法、结构和类的调用点。你可以将调用点视为其他对象用来执行任务的那些方法。将任何未被调用点使用的对象部分设为私有是一个好的实践,这意味着外部代码无法访问这部分代码。我们将在你学习更多关于重构代码以提高可测试性时再详细讨论这一点。
你还应该测试那些你可能认为太简单而不值得编写测试的代码。这些代码部分在开发过程的其它部分也可能被忽视。这通常会导致你和你的同事越来越不重视这些简单的代码片段,而当你意识到这一点时,可能已经引入了可能直到应用在 App Store 中才会被发现的错误。为简单的代码编写简单的测试几乎不需要时间,并且可以防止可能导致重大复杂性的小疏忽。
当你编写测试时,你应该遵循的一些简单指南如下:
-
测试简单的代码:这通常需要最少的努力。
-
测试你的对象的调用点:这些测试将确保你的公共 API 是一致的并且按预期工作。
-
不要测试苹果的框架或任何其他依赖项:做这件事是框架供应商的责任。
一旦你确定了应该测试什么,就是开始编写实际测试的时候了。然而,如果你之前听说过测试,你可能听说过诸如集成测试、单元测试、健全性测试以及其他几个术语。接下来的部分解释了几个最重要和最知名的测试类型。
选择正确的测试类型
当你编写测试时,通常一个好的想法是问问自己你正在编写什么类型的测试。你想要编写的测试类型通常会指导你如何构建和范围化你的测试。拥有范围明确、结构化和专注的测试将确保你构建的是一个稳定的测试套件,它能够正确地测试你的代码,而不会产生意外的副作用,这些副作用会影响测试的质量。现在让我们深入了解以下几种测试类型:单元测试和集成测试。
单元测试
可能最知名的一种测试类型是单元测试。很多人把写的任何其他测试都称为单元测试,这可能是为什么这个术语在测试中如此知名。单元测试之所以如此受欢迎的另一个原因是它是一种非常合理的测试类型。
单元测试的目的是确保一个隔离的对象按预期工作。这个隔离的对象通常是一个类或结构体,但它也可以是一个独立的方法。单元测试不依赖于任何其他测试或对象是很重要的。设置一个包含你单元测试所需所有先决条件的环境是完全可以接受的,但这个设置不应该是不经意的。例如,你不应该不经意地测试其他对象或依赖于测试执行的顺序。
当你编写单元测试时,创建存储在数组中的模型实例以表示模拟数据库或伪造 REST API 是常见的。创建这样的模拟数据列表是为了确保单元测试不会因为外部因素(如网络错误)而失败。如果你的测试应该依赖于某些外部因素,那么你很可能正在编写一个集成测试。
集成测试
集成测试确保你的代码的某个部分可以与其他系统组件集成。与单元测试类似,集成测试不应依赖于其他测试。这对于你编写的任何测试都很重要。每当一个测试依赖于某些先决条件时,它们必须在测试本身内设置。如果你的测试确实依赖于其他测试,这种依赖性可能一开始并不明显,但它可能导致你的测试以奇怪和意想不到的方式失败。
因为没有测试可以依赖于另一个测试,集成测试需要比单元测试更多的设置。例如,你可能需要设置一个 API 辅助工具,从 API 获取一些数据,并将其输入到数据库中。这样的测试可以验证 API 辅助工具能否与数据库层协同工作。这两个层都应该有它们各自的单元测试,以确保它们在独立工作时正常工作,而集成测试确保数据库和 API 可以协同工作。你可以编写或学习许多其他类型的测试,但就目前而言,集成测试和单元测试提供了一个极好的起点。
隔离测试
假设在你测试时是一个相当大的风险。任何时候你对你正在测试的环境中的任何假设,你的测试都不是可靠的。如果你刚开始编写测试,可能会倾向于做出假设,例如“我正在模拟器上测试,我的测试用户总是登录,因此我的测试可以假设存在一个已登录的用户”。这个假设对很多人来说很有道理,但如果你中的一个测试导致当前用户注销怎么办?
当这种情况发生时,由于你对测试环境所做的假设,你的一些测试将失败。更重要的是,即使它们正在测试的代码工作得完美无缺,这些测试也可能失败。
如前所述,测试应该测试你应用中的单个功能。它们应该尽可能少地依赖外部代码,并且应该有适当的焦点。人们用来结构化测试并提高可靠性的典型模式是 3-As 或 AAA 方法。这个模式的名称是“安排(Arrange)”、“行动(Act)”和“断言(Assert)”的缩写。以下是对每个“A”的解释。
安排
安排步骤完全是关于准备。确保存在一个已登录的用户,填充(内存中的)数据库,并创建你的模拟 API 或其他辅助工具的实例。你实际上是为你的测试环境安排好一切。请注意,这一步骤不应涉及太多的设置。如果你发现自己正在安排步骤中编写大量代码,那么你的测试可能太宽泛了。或者,你正在测试的代码依赖于太多的其他代码。你无法总是避免这种情况,但如果发生了,确保你考虑重构你的代码和测试,以保持与你要实现的目标相当的质量。
行动
在行动步骤中,你启动你的测试。你调用你要测试的对象上的方法,给它提供数据,并对其进行操作。这是你让你的代码进行比喻性试驾的地方。但是不要连续执行太多操作;太多的操作会在下一个步骤,即断言步骤中导致问题。
断言
3-As 方法中的最后一个 A 是断言。在断言步骤中,你确保你正在测试的对象的状态是你所期望的。在单个测试中,你可以多次使用行动和断言。例如,你可能想断言做一次操作将对象置于特定的状态,而再次做同样的操作将对象置于另一个状态。或者可能是状态保持不变。就像其他两个步骤一样,如果你在断言很多事物,或者如果你在测试中反复行动和断言,那么你的测试可能太宽泛了。这有时是无法避免的,但包含大量行动和断言的长测试通常表明一次测试了太多内容。
阅读有关测试的内容可能会相当枯燥,而且它往往很快就会变得抽象,所以现在我们先放下理论。你将在 Xcode 中为现有项目设置一个测试套件,并开始编写一些测试,这样你之前所学的所有信息就会变得更加具体。
使用 XCTest 设置测试套件
在本节中,你将为一个新应用程序工作:Info.plist 文件,以及你通常在项目中期望找到的所有其他文件。项目中还有一个名为 TriviaQuestions.json 的 JSON 文件。这个文件包含了一些模拟问题,你可以通过在 LoadTriviaViewController.swift 中取消注释一些代码来加载这些问题。
默认情况下,LoadTriviaViewController.swift试图从一个不存在的网络服务器加载问题。这是故意的,以展示如何通常设置这样的项目。由于你现在没有可用的网络服务器,你可以用 JSON 文件替换模拟的网络代码来测试这个应用程序。
在你编写测试或执行任何优化之前,你必须将一个测试目标添加到项目中。你添加测试目标的方式与你之前添加扩展的方式相同。唯一的区别是,你选择了一个不同的目标类型。在添加测试目标时,你应该选择iOS 单元测试包模板。以下截图显示了你应该选择的正确模板:
![Figure 19.1 – 添加单元测试目标]
![img/Figure_19.01_B14717.jpg]
Figure 19.1 – 添加单元测试目标
添加目标后,Xcode 会在你的项目中添加一个新的文件夹。如果你选择测试目标的默认名称,它被称为MovieTriviaTests。你应该将你为这个项目编写的所有测试添加到测试目标中。
如果你思考过在多个目标中使用具有扩展名的文件时的情况,你可能预期你需要将所有你想要为它们编写测试的文件添加到这两个目标中。幸运的是,情况并非如此。当你编写测试时,你可以将整个应用程序作为一个可测试的目标导入,这样你就可以为应用程序目标中的所有代码编写测试。
如果你查看 Xcode 在你添加单元测试目标时创建的 MovieTriviaTests 文件夹,你会找到一个名为 MovieTriviaTests.swift 的单个文件。这个文件包含了一些关于你的测试套件中测试应该是什么样子的提示。首先,请注意,测试类继承自 XCTestCase。所有你的测试类都应该继承自这个 XCTestCase,这样它们才能被识别为测试。
测试模板中你会找到的一种方法是 setUp() 方法。这个方法在文件中的每个测试之前执行,帮助你完成测试中的 AAA 模式的第一阶段:准备。你使用这个方法来确保你的测试的所有前提条件都得到满足。你可以确保用户已经登录或者数据库中已经填充了测试数据。当然,这个方法中设置的深度取决于你要为哪个代码单元编写测试。
此外,请注意,在 test 类中有两个以 test 前缀的方法。这些方法作为测试执行,并预期执行操作和断言步骤。大部分工作应该在这些测试方法中完成。请注意,拥有多个简短的测试方法通常比一个测试所有内容的单一测试方法要好。方法越大,维护和调试测试就越困难。
最后,你会找到一个 tearDown() 方法。这个方法旨在给你一个清理的机会。当你已经将模拟数据插入到数据库中时,在测试完成后删除这些数据通常是期望的。这将确保为下一个运行的测试提供一个干净的起点,并最小化第一个测试意外影响第二个测试的机会。如前所述,测试不应依赖于其他测试。这意味着你也不想通过留下前一个测试的痕迹来污染其他测试。
注意,setUp() 和 tearDown() 应该针对你正在测试的单元是特定的。这意味着你不能把所有的测试都放在一个类中。将测试分成几个类是一个好主意。你应该为每个你正在测试的代码单元创建一个测试类。一个测试类通常不应该测试你应用中的单个类或结构体。如果你正在编写集成测试,测试中可能涉及多个类,但你仍然应该确保你只测试一个东西,即你正在测试的集成中涉及的类之间的集成。
现在你已经建立了一个测试套件,让我们看看你如何为 MovieTrivia 应用中现有的代码编写测试,以及应用如何被重构以适当地进行测试。
优化代码以提高可测试性
现在项目有了测试目标,是时候开始向其中添加一些测试了。在你添加测试之前,你应该确定要测试什么。花些时间查看应用程序和代码,并尝试思考要测试的内容。假设应用程序已完成,并且 Trivia 问题是从服务器加载的。
你可能考虑过的一些测试事项如下:
-
确保我们可以显示我们从网络加载的数据
-
测试选择正确答案是否触发预期的代码
-
测试选择错误答案是否触发预期的代码
-
确保在显示最后一个问题之后显示第一个问题
-
测试问题索引是否递增
如果你想到了这个列表上的大多数测试,做得好。你已经成功地识别了许多好的测试用例。但是,你如何测试这些用例呢?项目有意被设计成难以测试,但让我们看看在不立即重构应用程序的情况下可以编写哪些测试。
删除 Xcode 为你生成的测试类,并创建一个新的名为LoadQuestionsTest的类。使用以下样板代码作为此文件实现的测试起点:
import XCTest
@testable import MovieTrivia
typealias JSON = [String: Any]
class LoadQuestionsTest: XCTestCase {
override func setUp() {
super.setUp()
}
func testLoadQuestions() {
}
}
注意文件顶部的@testable导入MovieTrivia行。这一行导入了整个应用程序目标,以便你可以在测试中访问它。在实现testLoadQuestions的测试主体之前,明智的做法是思考这个方法应该测试什么。如果你查看应用程序目标中的代码,Trivia 问题是在LoadTriviaViewController的viewDidAppear(_:)方法中加载的。一旦问题加载,应用程序就会转到下一个屏幕。一个重要的细节是,一旦问题加载,LoadTriviaViewController上的triviaJSON属性就会被设置。
基于这些信息,你可以编写一个测试,创建一个LoadTriviaViewController的实例,使其显示出来,以便加载问题,然后等待triviaJSON有值以验证问题是否成功加载。编写符合这种描述的测试将涉及许多移动部件,远远超过你应该感到舒适的范畴。"MovieTrivia"使用故事板,因此要获取LoadTriviaViewController的实例,故事板必须被涉及。这意味着用户界面中的任何更改或错误都会导致检查数据是否加载的逻辑测试失败。这不是所希望的,因为这项测试应该只验证是否可以加载数据,而不是在加载完成后用户界面是否更新。
这是一个开始重构一些代码并使其更具可测试性的好时机。首先应该彻底翻新的代码是问题加载代码。
介绍问题加载器
为了使MovieTrivia更容易进行测试,你应该创建一个特殊的辅助工具来加载问题。这个辅助工具将访问网络并获取问题。一旦数据被加载,就会调用一个回调来通知发起请求的对象已加载的问题。因为你已经知道你将要对新的辅助工具编写测试,你应该考虑一种方法来确保辅助工具可以与离线和在线实现一起工作,这样测试就不需要依赖互联网连接来运行。
由于测试应该尽可能少地依赖外部因素,从测试中移除网络层将是一个很好的选择。这意味着辅助工具需要分成两部分。一部分是辅助工具本身。另一部分将是一个数据获取器。数据获取器应该符合一个定义数据获取器必须具有的接口的协议,这样你就可以选择将在线或离线获取器注入到辅助工具中。
如果前面的解释对你来说有点抽象和令人困惑,那没关系。以下代码示例将逐步展示分离不同辅助工具的过程。向应用程序目标添加一个新的 Swift 文件,并将其命名为QuestionsLoader.swift。然后向其中添加以下实现:
typealias JSON = [String: Any]
typealias QuestionsLoadedCallback = (JSON) -> Void
struct QuestionsLoader {
func loadQuestions(callback: @escaping
QuestionsLoadedCallback) {
guard let url = URL(string:
"http://questions.movietrivia.json")
else { return }
URLSession.shared.dataTask(with: url) { data, response,
error in guard let data = data, let jsonObject = try?
JSONSerialization.jsonObject(with: data, options:
[]), let json = jsonObject as? JSON
else { return }
callback(json)
}
}
}
这个结构定义了一个使用回调加载问题的方法。这已经很好了,比之前更容易进行测试。现在你可以将问题加载器隔离出来,并单独对其进行测试。当前状态的辅助工具的测试看起来可能像以下代码片段中显示的测试:
func testLoadQuestions() {
let questionsLoader = QuestionsLoader()
let questionsLoadedExpectation = expectation(description:
"Expected the questions to be loaded")
questionsLoader.loadQuestions { _ in
questionsLoadedExpectation.fulfill()
}
waitForExpectations(timeout: 5, handler: nil)
}
前面的测试创建了一个QuestionLoader实例并设置了一个期望。期望用于你最终期望测试中发生某些事情时。由于QuestionLoader异步加载问题,你不能期望在测试方法执行完毕时问题已经被加载。当问题被加载时调用的回调用于在这个测试中满足期望。为了确保测试等待期望得到满足,在loadQuestions(callback:)之后调用了waitForExpectations(timeout:handler:)。如果在指定的 5 秒超时时间内期望没有得到满足,测试将失败。
仔细检查这个测试;你应该能够看到你之前读到的所有 A(安排、行动、断言)。第一个 A,安排,是创建加载器和期望的地方。第二个 A,行动,是调用loadQuestions(callback:)的时候。最后的 A,断言,是在回调内部。这个测试并没有验证传递给回调的数据是否有效,但稍后你会了解到这一点。
将加载器分离成自己的对象是很好的,但它仍然有一个问题。没有方法可以配置它是否从本地文件或网络加载数据。在生产环境中,问题加载器会从网络加载数据,这将使问题加载器的测试也依赖于网络。
这并不理想,因为依赖于网络的测试可能会因为无法控制的原因而失败。
这可以通过利用一些基于协议的编程和依赖注入模式来改进。这意味着你应该定义一个协议,该协议定义了网络层的公共 API。然后你应该在应用目标中实现一个符合该协议的网络对象。QuestionsLoader应该有一个属性来持有任何符合网络协议的对象。测试目标应该有自己的对象,该对象符合网络协议,这样你就可以使用该对象为QuestionsLoader提供模拟数据。
通过这样设置测试,你可以将整个网络逻辑从等式中去除,并安排测试,使网络无关紧要。模拟网络层将返回有效、可靠的响应,可以用作测试输入。
模拟 API 响应
在测试时模拟 API 响应是一种常见的做法。在本节中,你将实现之前描述的模拟 API,以改进MovieTrivia测试套件的品质和可靠性。按照以下步骤创建一个模拟响应来测试你的 API:
-
首先,让我们定义网络协议。在应用目标中创建一个新文件,并将其命名为
TriviaAPIProviding:typealias QuestionsFetchedCallback = (JSON) -> Void protocol TriviaAPIProviding { func loadTriviaQuestions(callback: @escaping QuestionsFetchedCallback) }协议只需要一个方法。如果你想稍后扩展此应用程序,与 Trivia API 相关的所有内容都必须添加到协议中,以确保你可以创建应用程序的在线版本和用于测试的离线版本。
-
接下来,创建一个名为
TriviaAPI的文件,并将以下实现添加到其中:struct TriviaAPI: TriviaAPIProviding { func loadTriviaQuestions(callback: @escaping QuestionsFetchedCallback) { guard let url = URL(string: "http://questions.movietrivia.json") else { return } URLSession.shared.dataTask(with: url) { data, response, error in guard let data = data, let jsonObject = try? JSONSerialization.jsonObject( with: data, options: []), let json = jsonObject as? JSON else { return } callback(json) } } } -
最后,使用以下实现更新
QuestionsLoader结构体:struct QuestionsLoader { let apiProvider: TriviaAPIProviding func loadQuestions(callback: @escaping QuestionsLoadedCallback) { apiProvider.loadTriviaQuestions(callback: callback) } }问题加载器现在有一个
apiProvider,它使用它来加载问题。目前,它将任何加载调用委托给其 API 提供者,但你会很快更新此代码以确保它将 API 返回的原始 JSON 数据转换为问题模型。 -
如下代码片段所示,更新
LoadTriviaViewController的viewDidAppear(_:)方法。此实现使用加载器结构体而不是在视图控制器中直接加载数据:override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) let apiProvider = TriviaAPI() let questionsLoader = QuestionsLoader(apiProvider: apiProvider) questionsLoader.loadQuestions { [weak self] json in self?.triviaJSON = json self?.performSegue(withIdentifier: "TriviaLoadedSegue", sender: self) } }之前的代码不仅更容易测试,而且更加简洁。下一步是在测试目标中创建模拟 API,这样你就可以用它来为问题加载器提供数据。
应该将应用目标中的 JSON 文件从应用目标中移除,并添加到测试目标中。你可以将其留在应用文件夹中,但请确保更新
Target Membership,以便 JSON 文件仅在测试目标中可用。 -
现在向测试目标添加一个名为
MockTriviaAPI的新 Swift 文件,并将以下代码添加到其中:@testable import MovieTrivia struct MockTriviaAPI: TriviaAPIProviding { func loadTriviaQuestions(callback: @escaping QuestionsFetchedCallback) { guard let filename = Bundle(for: LoadQuestionsTest.self).path(forResource: "TriviaQuestions", ofType: "json"), let triviaString = try? String(contentsOfFile: filename), let triviaData = triviaString.data( using: .utf8), let jsonObject = try? JSONSerialization.jsonObject(with: triviaData, options: []), let triviaJSON = jsonObject as? JSON else { return } callback(triviaJSON) } }此代码从测试包中检索本地存储的 JSON 文件。为了确定 JSON 文件的位置,使用了一个测试类来检索当前包。这不是获取包的最佳方式,因为它依赖于测试目标中存在的外部因素。然而,结构体不能用来查找当前包。幸运的是,如果用于确定包的类被移除,编译器将抛出一个错误,因此编译器会快速报错,错误可以被修复。在加载文件后,回调被调用,请求已成功处理。
-
现在更新
LoadQuestionsTest中的测试,使其使用模拟 API 如下:func testLoadQuestions() { let mockApi = MockTriviaAPI() let questionsLoader = QuestionsLoader(apiProvider: mockApi) let questionsLoadedExpectation = expectation(description: "Expected the questions to be loaded") questionsLoader.loadQuestions { _ in questionsLoadedExpectation.fulfill() } waitForExpectations(timeout: 5, handler: nil) }
让我们总结一下在这里我们所做的工作:我们已经将我们的 API 定义为一个协议。通过这样做,并且使用依赖注入,我们现在能够创建一个模拟类来测试 API。只要我们的模拟类符合该协议,我们就可以将其注入到需要 API 的任何地方。
许多应用程序的交互比你现在正在测试的要复杂得多。当你开始实现更复杂的场景时,关于如何架构你的应用程序和测试的主要思想保持不变,无论应用程序的复杂程度如何。
协议可以用来定义某些对象的通用接口。结合你为QuestionsLoader所做的那样使用依赖注入,有助于隔离你正在测试的代码部分,并使你能够替换代码片段,以确保如果你不需要,你不会依赖于外部因素。
到目前为止,测试套件并不特别有用。到目前为止,唯一被测试的是QuestionsLoader是否将请求传递给TriviaAPIProviding对象,以及回调是否按预期被调用。尽管从技术上讲这可以算作一个测试,但最好也测试加载器对象是否能够将加载的数据转换为应用程序可以显示的问题对象。
测试QuestionsLoader是否能够将 JSON 转换为Question模型是一个比仅仅测试回调是否被调用更有趣的测试。这样的重构可能会让你想知道是否应该添加一个新的测试或修改现有的测试。
如果你选择添加一个新的测试,你的测试套件将覆盖一个简单的案例,其中你只测试回调是否被调用,以及一个更复杂的案例,确保加载器可以将 JSON 数据转换为模型。当你更新现有的测试时,你最终得到一个测试,它验证了两件事。它将确保回调被调用,同时数据也被转换为模型。
虽然两种选择的影响相似,但第二种选择似乎假设回调将被调用。在编写测试时,总是想限制你的假设,并且在你添加更多功能时添加更多测试并无害处。然而,如果回调没有被调用,则所有测试都不会工作。因此,在这种情况下,你可以使用一个测试来确保回调被调用,并且加载器返回预期的模型。
你最终得到的测试将只有一个期望和多个断言。这样编写测试可以确保当回调被调用时,回调的期望得到满足,同时你可以使用断言来确保传递给回调的数据是有效和正确的。
通过让QuestionsLoader创建Question模型的实例而不是使用它来返回 JSON 数据字典,这不仅使测试更有趣,而且通过使代码更加整洁来改进应用程序代码。目前,应用程序使用 JSON 数据字典来显示问题。如果 JSON 发生变化,就必须更新视图控制器的代码。如果应用程序增长,你可能会在多个地方使用 JSON 数据,这使得更新过程非常痛苦且容易出错。这就是为什么使用Codable协议将原始 API 响应转换为Question模型是一个更好的主意。使用Codable对象意味着你可以从视图控制器中删除 JSON 字典,这是一个巨大的改进。
使用模型以保持一致性
将问题模型添加到MovieTrivia涉及相当多的重构。首先,你必须定义Question模型。让我们创建并使用我们的模型,而不是在代码中使用 JSON 结构。按照以下步骤操作:
-
创建一个名为
Question的新 Swift 文件,并将以下实现添加到其中:struct Question: Codable { let title: String let answerA: String let answerB: String let answerC: String let correctAnswer: Int }Question结构体符合Codable协议。 -
由于模拟的 JSON 数据包含问题列表,你需要定义一个包含响应的
Codable对象:struct QuestionsFetchResponse: Codable { let questions: [Question] }现在,
Question模型和响应容器已经就位,必须对现有代码进行一些更改。 -
修改
TriviaAPIProviding协议中的typealias定义如下:typealias QuestionsFetchedCallback = (Data) -> Void -
接下来,更新
TriviaAPI在URLSession回调中的loadTriviaQuestions(callback:)实现如下:URLSession.shared.dataTask(with: url) { data, response, error in guard let data = data else { return } callback(data) } -
此外,更新
MockTriviaApi,使其使用数据而不是 JSON 字典来执行回调:func loadTriviaQuestions(callback: @escaping QuestionsFetchedCallback) { guard let filename = Bundle(for: LoadQuestionsTest.self).path(forResource: "TriviaQuestions", ofType: "json"), let triviaString = try? String(contentsOfFile: filename), let triviaData = triviaString.data( using: .utf8) else { return } callback(triviaData) } -
更新
QuestionsLoader中的QuestionsLoadedCallbacktypealias定义如下:typealias QuestionsLoadedCallback = ([Question]) -> Void -
最后,按照以下方式更新
loadQuestions(callback:)的实现:func loadQuestions(callback: @escaping QuestionsLoadedCallback) { apiProvider.loadTriviaQuestions { data in let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase guard let questionsResponse = try? decoder.decode(QuestionsFetchResponse.self, from: data) else { return } callback(questionsResponse.questions) } }这完成了对 API 的更改。然而,在视图控制器中还有一些重构工作要做。
-
将
LoadTriviaViewController上的triviaJSON属性重命名为以下名称:var questions: [Question]?确保将所有
triviaJSON的出现替换为新的questions数组。同时,确保更改以下prepare(for:sender:)中的行:questionViewController.triviaJSON = triviaJSON -
将前面的行更改为以下内容:
questionViewController.questions = questions -
在
QuestionViewController中,将questions的类型更改为[Question]并删除triviaJSON属性。
到目前为止,您可以清除这个类守卫中所有与 JSON 相关的代码。由于编译器应该通过错误来引导您,因此您应该能够自己做到这一点。如果您遇到困难,请查看代码包中的完成项目。
到现在为止,您应该能够运行测试,并且它们应该通过。要运行测试,请点击Question模型。为了确保转换成功,您可以在测试中加载 JSON 文件,计算 JSON 文件中的问题数量,并断言它与回调中的问题数量匹配。
按照以下代码片段更新testLoadQuestions()方法:
func testLoadQuestions() {
let apiProvider = MockTriviaAPI()
let questionsLoader = QuestionsLoader(apiProvider:
apiProvider)
let questionsLoadedExpectation = expectation(
description: "Expected the questions to be loaded")
questionsLoader.loadQuestions { questions in
guard let filename = Bundle(for: LoadQuestionsTest.self).
path(forResource: "TriviaQuestions", ofType: "json"),
let triviaString = try? String(contentsOfFile:
filename),
let triviaData = triviaString.data(using: .utf8),
let jsonObject = try?
JSONSerialization.jsonObject(with: triviaData,
options: []),
let triviaJSON = jsonObject as? JSON,
let jsonQuestions = triviaJSON["questions"]
as? [JSON]
else { return }
XCTAssert(questions.count > 0, "More than 0 questions
should be passed to the callback")
XCTAssert(jsonQuestions.count == questions.count,
"Number of questions in json must match the number
of questions in the callback.")
questionsLoadedExpectation.fulfill()
}
waitForExpectations(timeout: 5, handler: nil)
}
此测试加载了模拟的 JSON 文件,并使用XCTAssert确保传递给回调的问题数量超过零,并且 JSON 文件中的问题数量与加载的问题数量匹配。
XCTAssert接受一个布尔表达式和一个描述。如果断言失败,将显示描述。添加好的描述将帮助您快速找出测试中哪个断言导致测试失败。
这个新的加载问题测试版本是对测试套件的小幅补充,但具有深远的影响。通过改进测试套件,您提高了应用程序的质量,因为现在您可以证明问题加载器正确地将 JSON 转换为模型对象。通过添加模型对象,您还改进了视图控制器中的代码。您现在读取的是模型属性,而不是原始 JSON。最后,这些更改使您的视图控制器变得更加简洁。
在本节中,您已经学会了如何通过代码创建和使用您自己的数据模型。通过这样做,您的代码更加一致,更容易测试(和维护)。
通过重构代码,另一个提高的指标是测试套件覆盖的代码量。您可以使用 Xcode 内置的代码覆盖率跟踪来衡量测试套件覆盖的代码百分比。您将在下一节学习如何使用此工具。
通过代码覆盖率获得洞察
代码覆盖率是 Xcode 中的一个工具,用于了解您的测试套件测试了多少代码。它确切地告诉您在测试期间执行了哪些代码部分以及哪些代码部分没有执行。这非常有用,因为您可以根据代码覆盖率提供的信息采取有针对性的行动。
按照以下步骤启用代码覆盖率功能:
-
要启用代码覆盖率,通过(产品 | 方案)菜单打开方案编辑器:
![图 19.2 – 编辑方案]()
图 19.2 – 编辑方案
-
选择测试操作并确保在选项选项卡上的收集覆盖率复选框被勾选:
![图 19.3 – 收集覆盖率选项]()
图 19.3 – 收集覆盖率选项
小贴士
您也可以按Command + < 快速打开方案编辑器。
-
完成此操作后,关闭方案编辑器并运行您的测试。
这次,Xcode 将监控在这次测试期间您的代码的哪些部分被执行了,哪些部分没有。这些信息可以为您提供一些关于哪些代码部分可能需要更多测试的见解。
-
要查看覆盖率数据,请打开 Xcode 左侧边栏中的报告导航器。此边栏中最右侧的图标代表报告导航器:

图 19.4 – 覆盖率选项
在您的应用程序名称下列出了几个报告。如果您选择覆盖率报告,覆盖率报告将在 Xcode 的编辑器区域中打开。您可以看到应用程序中的所有文件以及文件中由您的测试覆盖的代码百分比。以下截图显示了MovieTrivia应用程序的覆盖率:

图 19.5 – 覆盖率详情
柱状图填充得越多,表示在该文件或方法中执行的代码行数越多。您会注意到,即使在AppDelegate.swift文件上没有编写任何测试,它仍然在测试中被覆盖。这种情况发生的原因是,在测试期间应用程序必须启动,作为测试套件的宿主。这意味着AppDelegate.swift中的代码部分实际上在测试期间被执行,因此 Xcode 认为它在测试中被覆盖。
通过单击类名旁边的三角形,您可以查看特定文件中哪些方法被执行了。这使您能够确切地看到文件中的哪些部分被测试了,哪些部分没有被测试。
值得一提的代码覆盖率的一个最后特性是内联代码覆盖率。内联代码覆盖率将显示在测试期间特定代码块被执行的频率。这将直接在您的代码旁边提供代码覆盖率见解,而无需导航到报告导航器。要启用此功能,请打开您的 Xcode 预设并导航到文本编辑选项卡。在选项卡底部勾选显示迭代次数复选框。如果您现在打开一个文件,您将在编辑器窗口的右侧看到您的代码的迭代次数。
以下截图显示了loadQuestions(callback:)方法的迭代次数:

图 19.6 – 显示迭代次数
尽管代码覆盖率是一个很好的工具,可以帮助你深入了解测试,但你不应让它过多地影响你。定期检查你应用的代码覆盖率,寻找那些未测试且易于编写测试或应该测试的方法,因为它们包含重要的逻辑。代码覆盖率也非常适合发现那些应该被测试但难以测试的部分,因为它们深嵌在视图控制器中或以其他方式难以触及。
你应该始终追求尽可能多的代码覆盖率,但不要强迫自己达到 100%。这样做会让你跳过各种障碍,并且你会花费比预期更多的时间在测试上。并不是你代码中的所有路径都需要测试。然而,不要回避进行一些重构。适当的测试可以帮助你避免错误,并更好地组织你的代码。代码覆盖率只是你工具箱中的一项额外工具,帮助你确定哪些代码部分可能需要一些测试。
如果你看看当前 XCTest 的覆盖率状态,可能会觉得相当困难且繁琐。幸运的是,在本章中我们还将讨论最后一个测试工具:XCUITest。
使用 XCUITest 测试用户界面
我们已经学习了如何测试你的代码及其背后的逻辑。在本节中,我们将学习如何使用 XCUITest 测试你应用的 UI。
知道你应用的大部分逻辑都经过了测试是很好的。然而,将你的视图控制器添加到逻辑测试中并不是那么好。幸运的是,你可以使用 XCUITest 来轻松录制和编写专注于应用用户界面的测试。XCUITest 使用 iOS 中的易用性功能来访问你应用的用户界面。这意味着实现用户界面测试迫使你至少在易用性方面为你的应用付出一些努力。你应用的可访问性越好,编写 UI 测试就越容易。
XCUITest 有两个非常出色的特性,我们将更详细地探讨。首先,UI 测试可以帮助你提高应用的易用性。其次,UI 测试的入门非常简单,因为 Xcode 可以在你导航应用时记录你的测试。这可以显著增加你的测试套件覆盖的代码量,因为代码覆盖率也考虑了 UI 测试。
在我们开始录制第一个 UI 测试之前,让我们快速了解一下易用性。
使你的应用对测试可访问
关于 iOS 中功能的一个较少被考虑到的方面是易用性。苹果的设计团队努力确保 iOS 对每个人都是可访问的。这包括盲人和其他可能影响用户操作 iOS 设备能力的残疾人士。
只需查看 iOS 设置应用程序中的可访问性设置,就可以明显看出这是一个苹果投入了大量时间的主题。如果您正在开发应用程序,苹果期望您投入同样的努力。这样做将得到更多应用程序下载的回报,如果您幸运的话,甚至可能获得几条好评。在 2015 年的 WWDC 上,苹果甚至在他们的 iOS 可访问性演讲中提到,实现可访问性功能如果将来您希望应用程序出现在 App Store 中可能会有所帮助。只有最好的应用程序才能被苹果推荐,如果您的应用程序对所有人都可访问,这将显著提高您应用程序的质量。
围绕可访问性有一个常见的误解,即实现起来很困难或需要花费大量时间。有些人甚至说它看起来很丑或会妨碍美观的设计。这些说法并不完全正确。当然,使您的应用程序可访问需要一些努力,但 UIKit 框架在可访问性方面非常有帮助。使用标准组件并在设计应用程序时考虑到您的用户,将确保您的应用程序既可访问又看起来不错。
那么,iOS 上的可访问性是如何工作的?我们如何确保我们的应用程序可访问?一个有趣的实验方法是打开您设备上的 VoiceOver。按照以下步骤启用它:
-
要启用 VoiceOver,请转到 可访问性 菜单。您将找到几个与视力相关的可访问性设置。VoiceOver 应该是最上面的一个。
-
要快速启用和禁用 VoiceOver,请滚动到设置页面的底部并选择 VoiceOver 作为您的可访问性快捷键:
![图 19.7 – 设备可访问性选项]()
图 19.7 – 设备可访问性选项
这将允许您通过三击主按钮或侧按钮来切换 VoiceOver 的开关,具体取决于您的设备。
-
启用此功能后,在您的设备上运行 MovieTrivia 应用程序,并三击主按钮或侧按钮以启用 VoiceOver。
-
滑动并尝试使用应用程序。
这就是视力障碍人士如何使用您的应用程序。由于模拟问题尚未加载,您将无法通过加载屏幕,但您应该会发现启动屏幕非常易于访问,尤其是考虑到为此并没有做任何特殊的工作。UIKit 使用了出色的默认设置,以确保您的应用程序默认可访问。
您可以通过 Interface Builder 中的 Identity Inspector 设置自己的可访问性信息。您可以在界面中添加自定义标签、提示、标识符和特性,以帮助可访问性,并且巧合的是,这也有助于您的 UI 测试。以下截图显示了 可访问性 面板:

图 19.8 – 可访问性选项
对于大多数 UIKit 接口元素,你不需要自己触摸这些设置。UIKit 将确保你的对象有合理的默认值,这些默认值会自动使你的应用易于访问。现在你已经对可访问性有一些背景信息了,让我们来看看测试应用(可访问的)UI。
记录 UI 测试
在你能够记录 UI 测试之前,你必须将 UI 测试目标添加到项目中。按照之前的步骤添加新的测试目标,但这次选择 iOS UI 测试包。如果你查看项目中新创建的组,你的 UI 测试的结构看起来与单元测试的结构非常相似。
UI 测试目标和单元测试目标之间一个显著的区别是,你的 UI 测试无法访问应用内部的任何代码。UI 测试只能测试应用的界面,并基于此进行断言。
如果你打开 MovieTriviaUITest.swift 文件,你会注意到 setUpWithError () 和 tearDown() 方法。此外,所有必须执行的所有测试都是带有 test 前缀的方法。这与你已经看到的 XCUITest 非常相似。
一个很大的不同之处在于,在设置阶段,应用是明确启动的。这是因为 UI 测试目标是本质上与你的主应用界面交互的不同应用。这个限制非常有趣,也是为什么让你的应用易于访问很重要的原因。
要在 Xcode 中开始记录 UI 测试,你必须开始一个记录会话。如果你正在编辑 UI 测试目标的代码,你的代码编辑器区域左下角将出现一个新的界面元素:

图 19.9 – 记录界面
将你的输入光标放在 testExample() 方法中,并点击红色圆点。你的应用将被启动,你做的任何操作都会被记录为 UI 测试,并在运行测试时播放。如果你在加载屏幕上点击标签和活动指示器,Xcode 会在测试方法中生成以下 Swift 代码:
let app = XCUIApplication()
app.staticTexts["Loading trivia questions..."].tap()
app.activityIndicators["In progress"].tap()
你记录的 UI 测试是一组发送到应用的指令。在这个示例中,测试会在应用的 UI 中查找某个元素,并对它调用 tap()。这个测试没有做很多事情,所以它并不特别有用。为了让测试更有用,我们应该让应用知道它应该以特殊测试模式运行,这样它就可以从 JSON 文件中加载问题,而不是尝试从网络上加载。为此,你可以向应用发送启动参数。应用可以使用启动参数来启用或禁用某些功能。你可以把它们看作是当应用启动时发送到应用的变量。
向你的应用传递启动参数
为了在测试中将问题的加载从网络切换到本地文件,你可以向应用传递一个启动参数。然后,应用会读取这个启动参数,以确保它从 JSON 文件中加载问题,就像您在单元测试中做的那样,而不是尝试从服务器加载 Trivia 问题。
为了准备启动参数和加载 JSON 文件,请确保将其添加到测试目标、应用目标和 UI 测试目标中。目前您不需要在 UI 测试目标中使用它,但您稍后需要,所以您不妨在此时将其添加到 UI 测试目标中。
为了将启动参数传递给应用,UI 测试类中的setUpWithError()方法应该进行修改:
override func setupWithError() {
continueAfterFailure = false
let app = XCUIApplication()
app.launchArguments.append("isUITesting")
app.launch()
}
代表应用的XCUIApplication实例有一个launchArguments属性,它是一个字符串数组。在启动应用之前,你可以向这个数组中添加字符串。然后,你可以在应用内部提取这些字符串。按照以下代码片段修改TriviaAPI.swift中的loadTriviaQuestions(callback:)方法:
func loadTriviaQuestions(callback: @escaping
QuestionsFetchedCallback) {
if ProcessInfo.processInfo.arguments.contains(
"isUITesting") {
loadQuestionsFromFile(callback: callback)
return
}
// existing implementation...
}
上述代码应插入到该方法现有实现之上。该片段通过读取应用的启动参数来检查我们是否在进行 UI 测试。如果存在 UI 测试参数,我们将调用loadQuestionsFromFile(callback:)方法从 JSON 文件中加载问题,而不是从网络上加载。
注意,在您的生产代码中执行如上所述的检查并不是最佳实践。通常,将此类配置封装在可以轻松修改的结构体中会更好。然后,您可以在整个应用中使用这个结构体,而不是直接在整个应用中访问进程信息。此类配置的一个示例可能如下所示:
struct AppConfig {
var isUITesting: Bool {
ProcessInfo.processInfo.arguments.contains(
"isUITesting")}
}
由于这个应用很小,我们不会使用这个配置类。但就您自己的应用而言,无论应用大小如何,您可能都希望实现一个配置对象,因为从长远来看,它会导致代码更加易于维护。
如果您现在构建应用,您应该会得到一个编译器错误,因为loadQuestionsFromFile(callback:)方法尚未在 API 类中实现。为此方法添加以下实现:
func loadQuestionsFromFile(callback: @escaping
QuestionsFetchedCallback) {
guard let filename = Bundle.main.path(forResource:
"TriviaQuestions", ofType: "json"), let triviaString =
try? String(contentsOfFile: filename), let triviaData =
triviaString.data(using: .utf8)
else { return }
callback(triviaData)
}
这与单元测试中的问题加载方法非常相似;唯一的区别是它使用了一种不同的方式来获取加载问题的程序包。
如果您现在运行 UI 测试,它们将会失败。原因在于当测试框架开始寻找之前触摸的元素时,这些元素不存在。这导致测试失败,因为测试无法触摸不存在的元素。
测试应该稍作调整,因为点击加载器本身并不很有用。确保按钮可以被点击并且 UI 相应更新要更有用。为此,你可以编写一个 UI 测试,等待问题和按钮出现,点击它们,并检查 UI 是否已相应更新。在这个测试中也将加载模拟数据,以验证正确的问题被显示,并且按钮的行为符合预期。
确保 UI 按预期更新
你将编写两个测试来确保 Trivia 游戏按预期工作。第一个测试将检查问题和答案按钮是否出现以及它们是否有正确的标签。第二个测试将确保答案可以被点击,并且 UI 会相应更新。
你将手动记录测试,而不是录制它们。手动编写测试可以让你有更多的控制权,并允许你做比仅仅点击元素更多的事情。在这样做之前,你应该打开Main.storyboard文件并给 UI 元素分配可访问性标识符。按照以下步骤手动创建 UI 测试:
-
选择问题标题,并给
UILabel分配一个QuestionTitle标识符。 -
选择每个答案,并分别给它们分配
AnswerA、AnswerB和AnswerC标识符。 -
此外,给“下一步”按钮分配一个可访问性标识符
NextQuestion。以下截图显示了问题标题应该看起来是什么样子:![图 19.10 – 可访问性标识符]()
图 19.10 – 可访问性标识符
-
从
MovieTriviaUITests类中移除现有的 UI 测试testExample(),并添加以下代码片段:func testQuestionAppears() { let app = XCUIApplication() // 1 let buttonIdentifiers = ["AnswerA", "AnswerB", "AnswerC"] for identifier in buttonIdentifiers { let button = app.buttons.matching(identifier: identifier).element // 2 let predicate = NSPredicate(format: "exists == true") _ = expectation(for: predicate, evaluatedWith: button, handler: nil) } let questionTitle = app.staticTexts.matching( identifier: "QuestionTitle").element let predicate = NSPredicate(format: "exists == true") _ = expectation(for: predicate, evaluatedWith: questionTitle, handler: nil) // 3 waitForExpectations(timeout: 5, handler: nil) }-
每个元素都是通过其可访问性标识符选择的。你可以这样做,因为我们在
XCUIApplication实例中创建的实例提供了对 UI 元素的简单访问。 -
接下来,创建一个用于检查每个元素是否存在的前置条件,并创建一个期望。这个期望将不断评估前置条件是否为真,一旦为真,前置条件将自动满足。
-
最后,UI 测试将等待所有期望得到满足。
为了确保正确加载问题,你应该像之前那样加载 JSON 文件。
-
-
向测试添加以下属性,以便有一个地方来存储 Trivia 问题:
typealias JSON = [String: Any] var questions: [JSON]? -
接下来,在启动应用之前,将以下代码添加到
setUp()方法的顶部:guard let filename = Bundle(for: MovieTriviaUITests.self).path(forResource: "TriviaQuestions", ofType: "json"), let triviaString = try? String(contentsOfFile: filename), let triviaData = triviaString.data( using: .utf8), let jsonObject = try? JSONSerialization.jsonObject( with: triviaData, options: []), let triviaJSON = jsonObject as? JSON, let jsonQuestions = triviaJSON["questions"] as? [JSON] else { return } questions = jsonQuestions这段代码对你来说应该很熟悉,因为它与你之前用来加载 JSON 的代码类似。为了确保正确的问题被显示,按照以下方式更新测试方法:
func testQuestionAppears() { // existing implementation... waitForExpectations(timeout: 5, handler: nil) guard let question = questions?.first else { fatalError("Can't continue testing without question data...") } validateQuestionIsDisplayed(question) }前面的代码调用了
validateQuestionIsDisplayed(_:),但这个方法尚未实现。 -
添加以下实现:
func validateQuestionIsDisplayed(_ question: JSON) { let app = XCUIApplication() let questionTitle = app.staticTexts.matching( identifier: "QuestionTitle").element guard let title = question["title"] as? String, let answerA = question["answer_a"] as? String, let answerB = question["answer_b"] as? String, let answerC = question["answer_c"] as? String else { fatalError("Can't continue testing without question data...") } XCTAssert(questionTitle.label == title, "Expected question title to match json data") let buttonA = app.buttons.matching(identifier: "AnswerA").element XCTAssert(buttonA.label == answerA, "Expected AnswerA title to match json data") let buttonB = app.buttons.matching(identifier: "AnswerB").element XCTAssert(buttonB.label == answerB, "Expected AnswerB title to match json data") let buttonC = app.buttons.matching(identifier: "AnswerC").element XCTAssert(buttonC.label == answerC, "Expected AnswerC title to match json data") }这段代码是在检查 UI 元素存在之后运行的,因为它是在等待我们创建的期望之后运行的。第一个问题是从 JSON 数据中提取的,然后所有相关的标签都通过一个可重用的方法与问题数据进行比较,该方法验证特定问题是否当前显示。
你应该添加的第二个测试是为了检查游戏 UI 是否按预期响应。在加载一个问题后,测试将点击错误的答案,然后确保 UI 不会显示跳转到下一个问题的按钮。然后,选择正确的答案,测试将尝试导航到下一个问题。当然,测试还会验证下一个问题是否显示:
func testAnswerValidation() {
let app = XCUIApplication()
let button = app.buttons.matching(identifier:
"AnswerA").element
let predicate = NSPredicate(format: "exists == true")
_ = expectation(for: predicate, evaluatedWith: button,
handler: nil)
waitForExpectations(timeout: 5, handler: nil)
let nextQuestionButton = app.buttons.matching(identifier:
"NextQuestion").element
guard let question = questions?.first, let correctAnswer
= question["correct_answer"] as? Int else {
fatalError("Can't continue testing without question
data...")
}
let buttonIdentifiers = ["AnswerA", "AnswerB", "AnswerC"]
for (i, identifier) in buttonIdentifiers.enumerated() {
guard i != correctAnswer else { continue }
app.buttons.matching(identifier:identifier)
.element.tap()
XCTAssert(nextQuestionButton.exists == false, "Next
question button should be hidden")
}
app.buttons.matching(identifier: buttonIdentifiers[
correctAnswer]).element.tap()
XCTAssert(nextQuestionButton.exists == true, "Next
question button should be visible")
nextQuestionButton.tap()
guard let nextQuestion = questions?[1] else {
fatalError("Can't continue testing without question
data...") }
validateQuestionIsDisplayed(nextQuestion)
XCTAssert(nextQuestionButton.exists == false, "Next
question button should be hidden")
}
上述代码显示了整个测试,该测试验证 UI 是否正确响应正确和错误的答案。这样的测试相当冗长,但它们可以为你节省大量的手动测试。
当你这样测试你的 UI 时,你可以放心,你的应用至少在某种程度上是可访问的。这里的美丽之处在于,UI 测试和无障碍性都可以显著提高你的应用质量,并且它们都积极地相互帮助。
测试你的 UI 主要是一个寻找 UI 元素、检查它们的状态或可用性,并基于此进行断言的过程。在你为MovieTrivia编写的两个测试中,我们结合了期望和断言来测试现有的 UI 元素以及可能尚未出现在屏幕上的元素。请注意,你的 UI 测试将始终尝试在执行下一个命令之前等待任何动画完成。这将确保你不需要为任何带有动画的新 UI 编写异步期望。
摘要
恭喜!你已经走完了这个漫长、信息量大的章节。现在你应该对测试和无障碍性有足够的了解,可以开始探索比本章更深入的测试。
无论你的应用大小如何,编写自动化测试都将确保你的应用具有高质量。更重要的是,你不需要假设某件事因为之前工作过而工作,你的自动化测试将保证它工作,因为如果你的代码出了问题,测试不会通过。
在本章中,你学习了关于XCTest的基础知识:何时编写测试、编写哪种类型的测试(单元和集成),以及如何按照 Arrange-Act-Assert 模式隔离测试。你还学习了代码覆盖率以及如何衡量你的代码被测试的程度。最后,你学习了XCUITest以及它如何帮助你测试 UI 的各个部分。
你还了解到,编写可测试的代码有时需要你对大量代码进行重构。大多数情况下,这些重构会使得你的代码比之前处于更好的状态。易于测试的代码通常比难以测试的代码更干净、更健壮。
现在你已经知道了如何用测试来覆盖你的应用,在下一章中,我们将探讨如何将你的应用提交到 App Store。
第二十章:第二十章:将您的应用程序提交到 App Store
开发周期中最激动人心的部分之一是将您的应用程序带给一些真实用户。这样做的第一步通常是发送应用程序的测试版本,以便在您将其提交到 App Store 并向世界发布应用程序之前,获取反馈并收集有关应用程序性能的一些数据。一旦您对公测的结果感到满意,您必须将应用程序提交给苹果,以便他们在应用程序发布到 App Store 之前对其进行审核。
在本章中,您将了解如何打包您的应用程序并将其提交到苹果的 App Store Connect 门户。使用 App Store Connect,您可以开始进行应用程序的公测,并且您还可以将其提交给苹果进行审核。App Store Connect 还用于管理您的应用程序的 App Store 描述、关键词、推广图片等。本章将向您展示如何正确填写您应用程序的所有信息。在本章中,您将经历以下步骤:
-
将您的应用程序添加到 App Store Connect
-
打包和上传应用程序进行公测
-
准备您的应用程序发布
这些步骤与您准备发布应用程序时将要经历的过程非常相似。让我们直接进入正题,好吗?
将您的应用程序添加到 App Store Connect
当您准备发布应用程序时,您首先想要做的是在 App Store Connect 中注册您的应用程序。在本节中,我们将学习如何在 App Store Connect 中配置新的应用程序。要访问 App Store Connect,您必须注册苹果开发者计划。您可以通过苹果开发者门户 developer.apple.com 来完成此操作。购买会员后,您可以使用您的 Apple ID 登录到 appstoreconnect.apple.com 上的 App Store Connect 账户。
登录到您的 App Store Connect 账户后,您将看到一个带有几个图标的屏幕:

图 20.1 – App Store Connect 仪表板
此屏幕是您管理 App Store 存在的门户。从这里,您可以管理测试用户、跟踪您的应用程序下载、监控应用程序使用情况等。但最重要的是,这是您创建、上传和发布应用程序到苹果的测试分发计划,称为 TestFlight,并在完成公测后发布到 App Store 的地方。您可以先四处看看;现在可能还看不到太多内容,但熟悉 App Store Connect 门户是很好的。
让我们在 App Store Connect 门户中创建一个新的应用程序。按照以下步骤操作:
-
将您的应用程序发布给用户的第一步是导航到 我的应用程序 部分。在这个部分,您将找到您创建的所有应用程序,并且可以添加新的应用程序。要添加新应用程序,请点击左上角的 + 图标,然后点击 新建应用程序:![Figure 20.2 – Creating a New App
![img/Figure_20.02_B14717.jpg]
图 20.2 – 创建新应用程序
-
点击此按钮后,您将看到一个窗口,您可以填写有关应用程序的所有必要信息。这是您选择应用程序名称、选择应用程序将发布的平台以及一些其他属性的地方:
![Figure 20.3 – 新应用程序参数
![img/Figure_20.03_B14717.jpg]
图 20.3 – 新应用程序参数
Bundle ID 字段是一个下拉菜单,其中包含您在苹果开发者门户中为您的团队注册的所有应用程序 ID。如果您直到最后一刻都在使用免费层级的开发者账户开发,或者您没有使用任何特殊功能,如推送通知或 App Groups,那么您的应用程序的 Bundle ID 可能不在下拉菜单中。
如果是这样,您可以在开发者门户中手动注册您的 Bundle ID。按照以下步骤操作:
-
在您的浏览器中导航到
developer.apple.com/。 -
点击 账户 菜单项。您可以使用 账户 页面来管理证书、Bundle ID、设备等。其中许多都是由 Xcode 自动处理的,但您偶尔会发现自己在这个门户中。例如,让我们看看如何手动注册您的应用程序的 Bundle ID:![Figure 20.4 – Apple Developer Account dashboard
![img/Figure_20.04_B14717.jpg]
图 20.4 – 苹果开发者账户仪表板
-
要注册您的 Bundle ID,请点击页面左侧的 证书、标识符和配置文件 项。
-
在 证书、标识符和配置文件 页面上,点击左侧菜单中的 标识符。这将向您展示当前注册的应用程序列表。在列表顶部的 标识符 标题附近,有一个蓝色的 + 图标。点击此图标,然后选择 App ID 和 App 以将新的 ID 添加到您的配置文件中:
![Figure 20.5 – Certifcates, Identifiers & Profiles
![img/Figure_20.05_B14717.jpg]
图 20.5 – 证书、标识符和配置文件
要添加您的 Bundle ID,您只需填写表单字段。您希望为您的应用程序命名一个描述性的名称。它可以与您在 Xcode 中为应用程序设置的名称相同,也可以是不同的名称;它不必匹配。
确保选择 显式 App ID 字段,并从您的 Xcode 项目中复制 Bundle ID。确保您完全匹配。如果您不这样做,您可能会在以后遇到问题,因为如果 Bundle ID 不正确,您的应用程序将无法被识别。
完成这些后,你可以保存你的新 ID。你不需要选择任何功能,因为当你在功能选项卡中启用或禁用它们时,Xcode 会自动为你管理。
在手动注册你的 Bundle ID 后,你应该能够回到 App Store Connect,添加一个新应用,并选择你的应用的 Bundle ID。在你完成这些并在 App Store Connect 门户中创建你的应用后,查看你的应用设置。有很多表单字段可以填写。你将看到的第一个屏幕是应用信息屏幕。这是填写你应用信息的地方,为它在 App Store 中显示的本地化名称分配,并为你的应用分配类别:

图 20.6 – 应用仪表板屏幕
接下来是定价和可用性屏幕。这是你决定你的应用可以在哪些国家下载以及它的价格的地方。最后,还有准备提交菜单项。
每次当你添加你应用的新版本时,你应该填写这个屏幕上的表单字段,而且有很多。准备提交表单用于提供截图、关键词、你应用的描述、隐私政策等。去看看里面有什么。幸运的是,你需要填写的内容都很直接。
在本节中,我们学习了如何在 App Store Connect 中注册新应用以及生成新 Bundle ID 的过程。一旦你在 App Store Connect 上注册了你的应用,你就可以上传你的应用。为此,你使用 Xcode 打包你的应用并将其发送到 App Store Connect。让我们在下一节中看看这个过程。
为测试打包和上传你的应用
为了将你的应用发送给测试人员和最终用户,你必须首先使用 Xcode 归档你的应用。归档你的应用将打包所有内容、代码和资源。按照以下步骤归档你的应用:
-
从 Xcode 中你的应用运行的设备列表中选择通用 iOS 设备:![图 20.7 – 归档应用
![图片]()
图 20.7 – 归档应用
-
在 Xcode 顶部菜单中选择产品 | 归档,使用已选择的构建设备。当你这样做时,会开始一个比平时慢得多的构建过程。这是因为 Xcode 正在以发布模式构建你的应用,使其优化并能在任何设备上运行。
-
归档创建完成后,Xcode 会自动为你打开组织者面板。在这个面板中,你可以查看你创建的所有应用和归档的概览:![图 20.8 – 归档文件
![图片]()
图 20.8 – 归档文件
在存档您的应用之前,您应该确保您的应用已准备好发布。这意味着您必须将所有必需的应用图标资源添加到
Images.xcassets资源中。如果您的应用图标集不完整,当您尝试将其上传到 App Store Connect 时,您的应用将被拒绝,您将不得不重新生成您的存档。 -
当您准备好将构建上传到 App Store Connect 以发送给您的测试人员,并通过 App Store 最终发送给用户时,您应该选择您最新的构建并点击上传到 App Store按钮。将出现一个弹出窗口,引导您完成一些设置。您大多数时候应该使用默认设置并点击下一步。最后,您的应用将根据元数据验证并上传到 App Store Connect。如果您的存档中存在错误,例如缺少资源或未签名的代码,您将通过上传弹出窗口中的错误消息了解到这一点。
当您的上传成功时,您也会通过弹出窗口收到通知。
一旦您上传了构建,您就可以转到 App Store Connect 中您应用的活跃面板。您可以在TestFlight选项卡下看到上传的构建。如果您刚刚上传了一个构建,其状态将是处理中。有时这一步需要一些时间,但通常不超过几个小时:

图 20.9 – 上传的构建
当应用正在处理时,你可以开始准备你的TestFlight设置。为此,请按照以下步骤操作:
-
选择TestFlight菜单项并填写测试信息表单。如果您正在全球范围内推出测试,您可能需要提供多语言信息,但您不必这样做。
-
接下来,在右侧侧边栏中选择内部测试菜单项。在此面板中,你可以选择添加到你的账户用于测试的用户。这种测试通常是您做的第一种测试类型,主要是为了测试团队内部的应用或与亲密的朋友和家人。您可以通过 App Store Connect 中的用户和角色部分添加更多内部用户到您的账户。
-
一旦您的应用处理完毕,您将收到一封电子邮件,您可以选择用于内部测试的您的应用版本:
![图 20.10 – 选择要测试的应用版本]()
图 20.10 – 选择要测试的应用版本
一旦您添加了测试人员并选择了要测试的构建,您就可以点击开始测试按钮向所选测试人员发送测试邀请。他们将收到一封电子邮件,允许他们通过TestFlight应用下载您的应用。
-
一旦你的内部测试人员对你的应用满意,你可以为你的应用选择一些外部测试人员。外部测试人员通常是你的团队或组织之外的人,例如你的应用现有用户或来自你应用目标受众的一部分人。
设置外部测试版与设置内部测试相同。你甚至可以使用与内部测试相同的构建版本进行外部测试。然而,外部测试通常需要苹果进行快速审查,才能发送邀请。这些审查不会花费很长时间,通过测试版审查并不意味着你也会通过 App Store 审查。
这就是通过 TestFlight 设置测试版测试你需要知道的所有内容。当你对你的测试版测试结果满意,并且你的应用通过了现实世界的测试,那么你就可以准备通过 App Store 将你的应用发布到市场上了。
准备你的应用发布
从测试版测试过渡到发布你的应用不需要太多努力。你使用的是你已经导出并测试过的应用版本。为了能够提交你的应用供苹果审查,你必须添加更多关于你的应用的信息,并且你应该设置你的 App Store 存在。按照以下步骤操作:
-
你首先应该做的是为你的应用创建几幅截图。你将把这些截图添加到你的 App Store 页面上,因此它们应该尽可能好看,因为潜在的用户会通过截图来决定是否购买或下载你的应用。创建截图最简单的方法是在 5.5 英寸的 iPhone 和 12.9 英寸的 iPad 上进行截图。
你可以为所有存在的设备类型提供截图,但你至少需要为 5.5 英寸的 iPhone 和 12.9 英寸的 iPad 提供截图。你可以使用 App Store Connect 中的 媒体管理器 功能上传大尺寸的媒体,并使其缩放到较小设备的大小:
![图 20.11 – 媒体管理器]()
图 20.11 – 媒体管理器
-
在提交截图后,你还应该填写你的应用程序的描述和关键词。确保你的应用描述清晰、简洁、有说服力。你应该尽量使用尽可能多的关键词。苹果使用你的关键词来确定你的应用是否与用户的搜索查询匹配。
尝试想出同义词或你搜索执行你应用功能的软件时会查找的词汇。
如果你的应用包含 iMessage 或 Apple Watch 应用,你也应该上传这些应用的截图。你无法为这些扩展提供单独的关键词或描述,但它们将在 App Store 中有自己的图片库。
-
提交表单的下一步是选择你的应用二进制文件,并提供一些关于应用及其发布负责人的基本信息。通常,你将想要选择你直到发布前一直在进行 beta 测试的应用版本。
-
最后,你必须向苹果提供一些关于如何审核你的应用的信息。如果你的应用需要演示账户,提供给审核者的凭证。如果你的应用因某些内容不明确而被拒绝,通常在备注部分澄清过去的误解是个好主意。这已被证明对某些应用有帮助,使得第一次尝试就能通过审核,而不是在之后被拒绝并解释原因。当所有信息都填写完毕后,点击保存按钮以存储你刚刚输入的所有信息。然后,如果你对一切都满意,并准备好迈向发布应用的最后一步,请点击提交审核以启动审核流程。
让你的应用通过苹果的审核可能只需要一天,也可能是几天甚至更长。一旦你提交了你的应用,耐心等待苹果的通知是非常重要的。询问他们是否可以加快审核速度或者询问当前状态通常不会有结果,所以尝试推动苹果进行审核是没有意义的。
信息
如果你确实需要快速审核和发布你的应用,并且有正当的理由,你总是可以申请加速审核。如果苹果认为更快的审核不仅对你而且对你的用户都有益,你的应用可能会在几小时内得到审核。请注意,你不应该滥用这个服务。你申请加速审核的次数越多,苹果给你特例的可能性就越小。加速审核只应在特殊情况下请求。
好了,是时候喝点可可、咖啡、茶或者你喜欢的任何饮料了。现在你可以坐下来休息一会儿,等待苹果审核你的应用,以便你可以将其发布到 App Store。让我们以总结来结束这一章。
总结
这最后一章涵盖了发布应用的准备工作。你学习了如何存档和导出你的应用。你看到了如何将其上传到 App Store Connect 以及如何作为 beta 版本分发你的应用。为了总结一切,你看到了如何提交你的应用以供苹果审核,以便在 App Store 发布。发布应用是令人兴奋的;你不知道你的应用会表现如何,或者人们是否会喜欢使用它。良好的 beta 测试会有很大帮助,你将能够发现错误和可用性问题,但没有什么比让你的应用在真实用户手中更令人兴奋的了。
大多数开发者都投入了大量的时间和精力来构建他们的应用程序,而你也是其中之一。你拿起这本书,从 iOS 爱好者成长为一名 iOS 大师,确切地知道如何构建利用 iOS 最新和最伟大功能的优秀应用程序。当你准备好在 App Store 上发布自己的应用程序时,你会了解到等待苹果审查并希望批准你的应用程序是多么的既兴奋又紧张。也许你的第一次尝试会被拒绝;这是可能的。不要过于担心;即使是最大的名字有时也会被拒绝,而且通常修复拒绝的原因并不复杂。确保在提交之前阅读 App Store 审查指南;这些指南给出了关于你在应用程序中可以做什么和不可以做什么的相当好的指示。
由于这是本书的最后一章,我衷心感谢你选择这本书,并把它作为成为 iOS 编程大师的垫脚石之一。我希望我已经给你留下了所需的技能,让你能够独立探索,阅读苹果的文档,并构建令人惊叹的应用程序。再次感谢,如果你使用这本书创建了什么酷炫的东西,请随时联系我。我很乐意看到你的应用程序。




































浙公网安备 33010602011771号