原文:
zh.annas-archive.org/md5/a4345f119d5ed6596d7b88df6838014c译者:飞龙
第十三章:12
利用苹果智能和机器学习变得聪明
2022 年 11 月 ChatGPT 的发布并不是第一个出现的 人工智能 (AI) 工具,但它确实是将人工智能推到 聚光灯下 的那个。
有些人可能会认为苹果比其他公司晚进入人工智能领域。 也许吧,但可以肯定的是,iOS 为用户和开发者都提供了机器学习能力。
机器学习几乎在每个我们可以想到的领域都开辟了新的能力——从搜索、统计和洞察力到理解图像和声音。 甚至有一些基于人工智能和机器学习能力的应用程序。
目前,这些功能大多是基于服务器的。 尽管如此,手机 系统级芯片 (SoC) 性能的持续改进使它们能够在设备上执行预测,这开辟了 新的机会。
在本章中,我们将 完成以下内容:
-
涵盖人工智能和机器学习的基础知识,学习不同术语,了解机器学习的工作原理以及训练 模型 的含义
-
探索内置的机器学习框架,例如 自然语言处理 (NLP)、视觉和 声音分析
-
向我们的 Core Spotlight 实现 添加语义搜索
-
使用 Create 机器学习 (ML) 应用程序和 Core ML 框架 构建和集成自定义机器学习模型
机器学习是一个广泛的主题,我们有很多内容要介绍,所以让我们直接进入主题,了解 基础知识。
技术要求
您必须从苹果的 App Store 下载 16.0 或更高版本的 Xcode,用于本章。
您还需要运行最新版本的 macOS(Ventura 或更高版本)。 在 App Store 中搜索 <st c="1619">Xcode</st> ,选择并下载最新版本。 启动 Xcode,并遵循系统可能提示的任何附加安装说明。 一旦 Xcode 完全启动,您就准备好 开始了。
本章包含许多代码示例,其中一些可以在以下 GitHub 存储库中找到: github.com/PacktPublishing/Mastering-iOS-18-Development/tree/main/Chapter12。
请注意,本章中的一些示例需要在设备上运行,而不是在模拟器上。
回顾人工智能和机器学习的基础知识
在我们深入之前,让我们承认其复杂性——人工智能和机器学习是两个巨大的主题,不可能在一章或甚至一本书中涵盖。
然而,如果我们想在项目中实现一些机器学习功能,建议我们先了解基础知识。
因此,让我们从了解机器学习和人工智能之间的区别开始。
学习人工智能和机器学习之间的区别
人工智能被认为是计算机科学中的一个新兴话题,并且自ChatGPT发布以来,这一趋势得到了加速。
尽管机器学习不是一项新技术,但许多人仍然需要澄清机器学习和人工智能之间的区别。 这并不是说它们没有关系——它们是有关系的。 尽管如此,鉴于苹果公司已经将人工智能深度集成到其系统中,作为 iOS 专业开发者,现在有一个清晰的概述这些区别是至关重要的。
那么,什么是机器学习(ML)呢? 机器学习技术专注于开发算法和统计模型,以帮助计算机执行预测和分类等任务。 例如,一个模型可以接收一个图像并回复它是否包含猫,或者一个模型可以处理一些文本并定位动词和名词。
机器学习模型是一个执行预测和分类的算法
相反,人工智能是一系列技术和方法,能够创建一个能够执行与人类通常所做任务相似的系统
一个很好的例子是**LLM(大型语言模型)服务,如ChatGPT或Gemini**。另一个例子是涉及许多机器学习模型的项目,如自动驾驶汽车,这些项目包括目标检测和决策
我们现在明白,机器学习模型是人工智能的一个构建块
让我们讨论这个重要的主题
具有决策树算法的模型包含一个树,其中叶子代表不同的决策或预测
但这里的*数据* 是什么意思呢?
在本章接下来的内容中,我们将把构建和创建模型数据称为训练
训练模型
两个不同的机器学习模型可以拥有相同的算法和结构,但由于训练过程的不同,数据可能不同。 使用训练过程,我们教会模型根据输入数据做出准确的预测和决策。 这个过程涉及优化模型的数据(参数),以便在未见过的数据上准确地进行预测。 这个过程包括优化模型的数据(参数)以在未见过的数据上准确执行预测。
训练一个模型需要我们执行几个步骤:
-
数据收集:我们需要准备一个相对较大的数据集来训练我们的模型。 我们还必须通过处理缺失值、清理无关数据项和归一化值来预处理数据。
-
分割数据集:现在我们有了数据集,我们必须将其分为训练数据、验证集和测试集。 我们在不同的训练阶段使用这些数据集。
-
选择我们的机器学习算法:每种算法旨在解决不同的问题。 例如,逻辑回归算法解决分类问题,线性回归算法解决回归问题。
-
前向传播:我们将训练数据通过模型来做出预测。
-
验证:我们使用验证数据集来评估模型的表现,并根据结果调整模型。
-
测试:我们使用测试数据来评估我们的模型在实时使用案例中,使用未见过的输入数据时的性能。
这是训练过程的概要图。 在实践中,这个过程包含更多步骤,例如计算损失和优化。 然而,目标是让你对训练过程有一个大致的了解,以便你能理解以下主题。 别担心——我们很快就会一起构建和训练一个机器学习模型!
既然我们已经了解了机器学习模型是什么,让我们尝试理解它如何与 iOS 相关。
苹果的智能和机器学习
当 ChatGPT 变得流行时,许多人觉得苹果在人工智能和机器学习方面落后了。 这本书不是讨论那个问题的场所; suffice it to say that ML has since been an integral part of iOS for years. iOS 使用机器学习来根据其内容优化我们的照片。 键盘预测涉及机器学习模型,iOS 保存电池的方式也基于机器学习。
所有这些特性和功能都对用户透明,并在幕后执行。 然而,iOS 18 通过许多功能,如改进的 Siri、图像游乐场和 写作工具,将 AI 推到了聚光灯下。
iOS 18 也为我们开发者提供了一些实用的功能,但它特别将 ML 和 AI 的领域带到了我们的注意中。 例如,语义搜索是 iOS ML 功能开发者可用的新功能之一。
在我们深入研究 Core ML 并学习如何训练我们的模型之前,让我们先从 iOS SDK 中提供的模型开始,因为有很大机会我们可以在不训练 新模型的情况下快速找到我们需要的。
探索内置机器学习框架
当我们回顾 AI 和 ML 的基础知识时,我们看到了训练模型意味着什么——这是一个漫长且复杂的过程。 这个过程需要我们准备相对较大的数据集,包括验证集和测试集。 即使那样,我们仍然需要微调我们的 ML 模型并将其包含在我们的应用程序中,同时试图减小其大小。
但是,不要误解我。 在某些情况下,训练我们的 ML 模型是必不可少的,但在我们开始训练过程之前,了解 iOS 提供的内容是非常重要的。
在 iOS 中与 ML 框架一起工作并不新鲜。 这些框架几年前就已经被引入,其中一些甚至是在 iOS 10(2016 年)中。 然而,很少有开发者使用它们,可能是因为他们认为它们难以 集成。
我们将从 ML 工具集中最实用的框架之一—— 自然语言处理 开始。
使用 NLP 解析文本
解析和理解文本可以为许多 应用程序 提供显著的价值。 例如,NLP 可以帮助我们理解诸如搜索短语、文本输入或从 导入的文本中提取信息之类的字符串。
iOS SDK 内置了一个名为 NaturalLanguage 的 自然语言处理 框架
import NaturalLanguage
NaturalLanguage 框架帮助我们高效地在 设备上解析文本。
为了理解其工作原理,我们首先必须了解 NLP 在幕后是如何工作的以及其基本术语。
NLP 模型通过 找到文本不同部分之间的关系 来工作。 尽管这个任务很复杂,但看到它是如何工作的很有趣。
理解 NLP 是如何工作的
NLP 过程涉及文本处理和几个算法来提取必要的信息。有三个基本步骤 – 预处理,特征提取,和 建模。让我们逐一介绍。
预处理
在这个步骤中,NLP 模型首先开始 清理输入,例如删除重复项,将文本分割成单词和句子,将文本转换为小写,以及执行词干提取和词形还原。 以下文本作为 示例:
"Running is fun! I love to run."
这将预处理成以下类似的内容::以下:
"run fun love run".
在这个例子中,NLP 移除了停用词(例如 <st c="10120">is</st>)并将整个字符串转换为小写。
特征提取
在字符串预处理之后,我们将其转换成一个特征集,我们可以使用它与 ML 算法一起使用。 在大多数 情况下,这涉及到捕获不同的模式和单词频率。 例如,上一步的字符串,run fun love run,可以转换成以下:
{
"run": 2,
"fun": 1,
"love": 1
}
在这个例子中,NLP 模型接收输入字符串并分析每个单词的频率。这种技术被称为 词袋模型 (BoW),模型使用它来确定字符串中不同单词的重要性。请注意,有许多特征提取技术,BoW 只是其中之一。 现在我们已经有了特征提取数据,我们可以选择模型。
建模
在建模步骤中,我们将字符串和 特征提取作为模型算法的输入。 NLP 使用多个算法来分析字符串 – 逻辑回归、朴素贝叶斯和神经网络。 模型选择的算法取决于它需要完成的任务。
例如,如果 NLP 框架需要执行情感分析,它将使用基于神经网络的模型。 简单的文本处理任务将使用基于规则的 `系统模型。
这三个步骤展示了理解简单文本的复杂性。 幸运的是, <st c="11448">自然语言</st> `框架为我们执行了所有这些步骤。 。
让我们看看如何使用 <st c="11535">自然语言</st> `框架 API。
使用自然语言 API
最后,我们将编写一些代码! 自然语言 `框架有两个主要用途 – 分类 和词性标注。 让我们从分类开始。
文本分类
使用 文本分类 <st c="11802">,我们可以分析文本情感以确定它是正面</st> 还是负面。`
例如,让我们看看以下文本:
The latest update made everything so much better. Great job!
为了使用 <st c="12054">自然语言</st> <st c="12069">框架分析这个句子的情感,我们将使用</st> NLTagger 类:
let sentimentAnalyzer = <st c="12135">NLTagger</st>(tagSchemes:
[<st c="12158">.sentimentScore</st>])
sentimentAnalyzer.string = userInput
let (sentiment, _) = <st c="12235">sentimentAnalyzer.tag</st>(at:
userInput.startIndex, unit: .paragraph, scheme:
.sentimentScore)
if let sentiment = sentiment, let score =
Double(sentiment.rawValue) {
// here we can use the analyzed score
} else {
print("Unable to analyze sentiment")
}
<st c="12483">NLTagger</st> 是我们用于处理自然语言文本的主要类。 当我们初始化它时,我们传递我们感兴趣的信息。 在我们的例子中,我们传递了 <st c="12641">sentimentScore</st> – 一种帮助我们确定 文本情感的方案。
我们的下一步是设置文本输入并调用标记函数,同时传递相关参数,例如范围、单位类型和我们想要分析的方案。 要分析。
标记函数执行文本分析并返回一个介于-1 和 1 之间的分数,其中负分数表示负面情感,正分数表示 `正面情感。
如果我们在这段代码示例之前运行这段代码 我们的示例句子,我们将得到 1.0 的分数 – 一个极其 积极的文本!
尽管文本分类非常容易使用,但它也非常强大。 我们可以使用这种能力来分析用户反馈/评论、聊天机器人和调查,甚至根据用户的情感 `和情绪调整界面。
我们提到文本分类完全是关于理解文本情感。 然而,我们可以使用自然语言处理技术通过 <st c="13551">词性标注</st> `来分析文本。
<st c="13564">词性标注</st>
词性标注 是将文本分解成组件并为文本中的每个短语分配标签的过程,指示其 <st c="13702">语法类别</st>。
让我们以以下文本的例子:
She enjoys reading books in the library
如果我们尝试将这个句子分解成语法类别,它将类似于 She (代词),enjoys (动词),reading (动词),books (名词),in (介词),the (限定词),以及 library (名词)。
文本的不同部分被称为 标记,它们的语法类别被称为 标签。
<st c="14113">The</st> <st c="14118">NaturalLanguage</st> <st c="14133">框架帮助我们执行分词和标记</st> <st c="14182">其标记。</st>
让我们看看以下代码:
let inputText = "She enjoys reading books in the library"
let tagger = NLTagger(tagSchemes: [.lexicalClass])
tagger.string = inputText
let options: NLTagger.Options = [.omitPunctuation,
.omitWhitespace]
tagger.enumerateTags(in:
inputText.startIndex..<inputText.endIndex, unit: .word,
scheme: .lexicalClass, options: options) { tag,
tokenRange in
if tag == .verb {
verb = String(inputText[tokenRange])
return false
}
return true
}
上述代码示例使用了之前相同的句子,对其进行分词,并定位到它找到的第一个动词。
我们首先初始化 <st c="14797">NLTagger</st>,类似于我们在文本分类中所做的。然而,这次我们通过传递 <st c="14895">lexicalClass</st> 作为 <st c="14911">其方案</st> 来实现这一点。
然后,我们提供输入 <st c="14949">文本</st> 并省略标点和空白。我们这样做是因为我们希望我们的文本尽可能干净。
在我们清理完文本后,我们调用 <st c="15177">enumerateTags</st> 函数。该函数遍历给定范围内的文本中的单词并提取不同的标签。我们比较传递的闭包内的标签类型并将其存储在一个 <st c="15369">实例变量</st> 中。
在我们的例子中,我们定位到第一个动词,它是 <st c="15439">enjoys</st>。
尽管词性标注和文本分类是 <st c="15497">NLTagger</st> 的两个主要用例,但它们也可以用于其他用例,例如用于识别文本的语言:
let tagger = NLTagger(tagSchemes: [<st c="15650">.language</st>])
tagger.string = inputText
if let language = tagger.dominantLanguage {
identifiedLanguage =
Locale.current.localizedString(forLanguageCode:
language.rawValue) ?? "Unknown"
} else {
identifiedLanguage = "Unknown"
}
在上一个示例中, <st c="15906">NLTagger</st> 接收输入文本并提取其语言。 它可以识别 50 种不同的语言——对于一个设备上的 NLP 模型来说非常令人印象深刻!
我们可以使用语言识别 来识别用户区域设置,并建议更改应用程序的首选语言,或者我们可以将此信息作为分析数据发送到 我们的服务器。
自然语言处理(NLP)的另一个绝佳例子是 词嵌入。此功能可以帮助我们的应用程序 变得更智能。
词典中的每个单词都与其他 单词相关。 例如, house 与 building 和 apartment相关,而 cat 与 dog相关联。
我们可以轻松地使用名为 NLEmbedding 的类 找到相关单词:
guard let embedding = <st c="16557">NLEmbedding.</st>wordEmbedding(for:
.english) else {
neighborsText = "Failed to load word
embedding." return
} <st c="16664">let neighbors = embedding.neighbors(for:</st>
<st c="16704">embedding.vector(for: inputWord) ??</st> <st c="16741">[], maximumCount: 5)</st> if neighbors.isEmpty {
neighborsText = "No neighbors found for
'\(inputWord)'." } else {
neighborsText = neighbors.map { "\($0.0)
(\($0.1))" }.joined(separator: ", ")
}
在上一个示例中, <st c="16957">NLEmbedding</st> 接收输入测试,计算其向量,并找到其邻近的邻居。 如果你想知道这为什么实用,想想一个搜索引擎,即使内容与用户 搜索的内容不完全一致,也能找到相关内容。
在本节中,我们使用 <st c="17243">NaturalLanguage</st> 框架 分析了文本。 我们学习了 NLP 的工作原理,如何对文本进行分类,以及提取诸如词性标注甚至 词嵌入 等额外信息。 然而,iOS 应用程序不仅仅包含文本;它们还包括图像。 我们能否也分析图像 呢?
使用 Vision 框架分析图像
分析图像是 iOS 应用程序中的 基本 主题。 分析图像有许多用例,例如检测条形码、扫描文档或 图像编辑。
在 iOS 中分析图像,我们需要 使用苹果的 Vision 框架。 该框架于 2017 年随 iOS 11 的发布而引入,提供了 高级功能以执行各种图像 分析任务。
理解图像分析的工作原理
从某种意义上说,图像分析的工作方式与文本分析类似,它通过不同的步骤在将数据插入 模型之前对其进行清理和准备 数据。
图像分析使用一个 CNN (卷积神经网络),这是一种专为 视觉数据 设计的神经网络。
将 CNN 视为一系列过滤器,可以帮助模型更好地理解图像。 如果 NaturalLanguage 模型预处理了文本,移除了空白和 重复的单词 ,CNN 将执行类似的过程。
首先,CNN 扫描图像以检测相似的模式,例如线条、边缘和纹理。 然后,它过滤掉它认为的非重要特征,并将图像缩小以包含最重要的信息。 信息。
现在我们有了更小、更干净的图像,CNN 试图判断图像是什么——例如,“它是一只 猫。”
检测模式、边缘、过滤它们以及分析图像是复杂的技术,需要 大量的训练。 幸运的是,Vision 框架为我们完成了所有繁重的工作 。
让我们看看它能为 我们做什么。
探索 Vision 框架的功能
自从 iOS 18 开始,Vision 框架 API 已经变得极其简单,但甚至更加强大。
为了理解 Vision 框架 API 的工作原理,我们需要记住它基于两种类型——请求 和观察。
要进行图像分析,我们首先创建一个 请求。然后,我们请求特定的图像并接收一个包含结果(如果我们 有任何)的 观察 。
让我们看看两个流行的用例——检测条形码 和面部。
检测条形码
查看以下代码以 查看条形码检测 的实际操作:
func analyze(url: URL) async {
let request = <st c="19632">DetectBarcodesRequest()</st> do {
let barcodeObservations = <st c="19687">try await</st>
<st c="19696">request.perform(on: url)</st> barcodeIdentifier = <st c="19742">barcodeObservations.first?.payloadString ??</st> ""
} catch let error {
print("error analyzing image –
\(error.localizedDescription)")
}
}
前面的代码块使用 Vision 框架执行条形码检测。 首先,我们创建 <st c="19973">DetectBarcodesRequest</st>,它表示在给定的 图像 URL 中扫描条形码的请求。
然后,我们调用请求的 <st c="20090">perform</st> 函数,在存在多个条形码的情况下,它返回一个包含观察结果的数组 。
接下来,我们取出第一个观察有效负载并将其存储在一个变量中。 该有效负载代表 条形码标识符。
请注意,扫描操作可能是一个繁重的任务,这就是为什么它是一个 异步函数。
Vision 框架的一个有趣的应用示例是检测图像中的人脸——让我们看看 一个示例。
人脸检测
人脸检测的工作原理与 条形码检测 类似。 让我们看看一个 代码示例:
func analyze(url: URL) async {
let request = <st c="20652">DetectFaceRectanglesRequest()</st> do {
let observations = try await
request<st c="20723">.perform</st>(on: url)
if let <st c="20749">observation</st> = observations.first {
rect = observation.boundingBox.cgRect
}
} catch let error {
print(error.localizedDescription)
}
}
前面的代码示例几乎与之前的条形码示例相同。 首先,我们创建请求。 然而,这次,请求的类型是 <st c="21040">DetectFaceRectanglesRequest</st>。接下来,我们在给定的图像 URL 上执行检测操作,并检索到一个观察数组。 每个观察实例包含图像中一个 人脸的矩形。 如果图像包含多个面部,我们将为每个面部获得一个观察结果。
人脸检测和条形码是 Vision 框架用例的两个常见示例。 然而,Vision 框架充满了惊喜和检测能力。 让我们看看我们还能用它做什么。
探索更多检测能力
正如提到的,Vision 框架充满了能够检测我们想要的几乎所有东西的机器学习模型。 条形码 和人脸只是冰山一角。
以下是一些 额外的检测器:
-
图像美学分析:从美学角度分析图像
-
显著性分析:用于找到图像中最重要的对象
-
对象跟踪:用于跟踪对象在一系列 图像中的运动
-
人体检测:类似于人脸检测,用于在图像中定位手臂、人体、眼睛、嘴巴和鼻子
-
人体和手势姿态:用于在图像中定位手臂以及检测 它们的姿态。
-
文本检测:用于检测图像中的文本
-
动物检测:用于检测图像中的猫和狗以及 它们的姿态
-
背景移除和对象提取:用于从图像中移除背景和提取对象 的
不同请求类型的列表看起来令人印象深刻,确实如此。 审查这些请求反映了视觉框架变得多么强大。 我们可以看到通常为 高端图像编辑应用程序保留的功能,例如背景移除或对象提取,现在只需三行 代码即可实现。
这为我们的应用程序中的独特功能开辟了新的可能性,例如与相机一起工作或根据 它们的信息来优先处理图像。
我们已经讨论了分析文本和图像,这些被认为是我们在通常使用中最常见的数据源。 文本和图像分析技术不同,但实现起来很简单。
现在,让我们转向我们可以分析的不同类型的源—— 声音。
使用声音分析框架进行音频分类
对于许多开发者来说,处理音频不是一项流行的 专业知识。 事实上,与开发者习惯的相比,音频被认为是一个复杂且独特的 世界。
为了减轻这一点,iOS SDK 还包括一个分析框架,可以使用 ML 模型对音频进行分类。
与 声音分析框架 一起工作与我们习惯的视觉框架的简单性不同。 但别担心——它仍然很容易使用。
声音分析框架包含三个 不同的组件:
-
SNAudioFileAnalyzer:协调分析工作的主要类
-
SNClassifySoundRequest:声音 检测请求
-
SNResultsObserving:我们需要实现的一个协议,用于观察分析器的 结果
要查看这三个组件的实际效果,请查看以下 代码:
func analyze(at url: URL) {
do {
let audioFileAnalyzer = try <st c="24082">SNAudioFileAnalyzer</st>(url: url)
let request = try <st c="24131">SNClassifySoundRequest</st>(classifierIdentifier:
.version1)
let resultsObserver = <st c="24210">ClassificationResultsObserver</st>()
try audioFileAnalyzer.add(request,
withObserver: resultsObserver)
audioFileAnalyzer.analyze()
} catch {
print("Error: \(error.localizedDescription)")
}
}
在这个例子中,我们首先 创建 <st c="24434">SNAudioFileAnalyzer</st> 实例,并用音频文件的 URL 初始化它。 然后,我们创建一个分类声音请求的请求,将 <st c="24581">version1</st> 作为一个 参数。 <st c="24610">version1</st> 参数指定了模型的预训练分类版本。 在撰写本文时,没有其他版本可用。
然后,我们创建 <st c="24774">resultsObserver</st> 实例(我们稍后会简要讨论)并协调一切,使用我们之前创建的分析器。
我们如何获取结果? 与视觉 框架不同,接收结果可以简化。 <st c="25006">ClassificationResultsObserver</st> 是一个自定义类,它符合 <st c="25071">SNResultsObserving</st>。让我们看看 类的实现:
class ClassificationResultsObserver: NSObject,
SNResultsObserving {
func request(_ request: SNRequest, didProduce result:
SNResult) {
guard let result = result as? SNClassificationResult else { return }
if let classification =
result.classifications.first {
<st c="25389">let result = classification.identifier</st> }
}
func request(_ request: SNRequest, didFailWithError
error: Error) { }
func requestDidComplete(_ request: SNRequest) {}
}
该 <st c="25557">SNResultsObserving</st> 协议有三个基本请求方法 – <st c="25623">didProduce</st>, <st c="25635">didFailWithError</st>, 和 <st c="25657">requestDidComplete</st>。
太好了! 然而,不幸的是,在这种情况下,我们似乎需要回到过去,并使用代理模式来 观察声音分析框架的结果。
结果是描述我们传递给分析器的声音的字符串。 本书 GitHub 存储库中的代码示例显示了一个婴儿哭泣的声音文件。 在这种情况下,结果将是 <st c="26037">baby_crying</st>。
苹果公司尚未正式公布声音分析框架可以识别的声音类别数量。然而,在大多数情况下,这应该足以满足 日常使用。
声音分析框架非常适合监控应用,添加**SDH** (为听障人士或听力受损人士提供的字幕)到视频字幕,以及 分析视频。
到目前为止,我们已经讨论了如何分析 不同类型的数据 – 声音、图像和文本。 然而,机器学习在其它领域也很有价值,例如 应用搜索。
使用 Core Spotlight 执行语义搜索
在我们讨论 NLP 的 *使用 NLP 解释文本 *部分时,我们说,最常见的 NLP 用例之一是分析搜索 短语以构建智能 搜索查询。
尽管 <st c="26793">NaturalLanguage</st> 框架 API 强大且直观,执行语义搜索 被认为是一项 复杂任务。
从 iOS 18 开始,Core Spotlight 框架支持语义搜索。 在我们深入细节之前,让我们明确一下 语义搜索这个术语。
理解语义搜索是什么
让我们一起来思考一下标准应用中搜索查询是如何工作的,我们将通过 一个示例 来进行。
想象一下,我们有一个课程目录应用,用户可以搜索特定的课程,假设我们在本地的 数据存储 中有以下课程列表:
-
为员工的管理
-
数据科学
-
数字营销
-
ML 和 AI
我们的用户想要提高他们的领导技能,所以他们在这个课程列表 中搜索管理课程。
搜索查询的基本形式是匹配一个特定的短语。 例如,如果用户搜索 <st c="27656">管理</st>,我们只过滤包含 管理的课程。我们还需要确保输出查询 是不区分大小写的。
然而,如果用户搜索 <st c="27819">经理</st>呢?在这种情况下,我们的查询没有返回任何结果,尽管一个典型的用户如果想要搜索关于管理的课程,他们可以搜索 经理 。 他们想要关于管理的课程。
在这种情况下,我们可以使用 <st c="27991">NaturalLanguage</st> 框架来尝试对搜索短语进行词形还原。 词形还原 是一种将单词还原到其基本形式的技巧。 因此,经理 的基本形式是 管理。
然而,如果我们想匹配搜索短语 管理,我们还需要所有包含单词 管理 的记录都包含单词 管理 ,这样我们才能相应地过滤结果。 这意味着我们必须为每条记录中的每个单词保持基本形式。
但事情可能会比这更复杂。 假设用户使用短语 领导力搜索管理课程?在这种情况下,我们将不得不像在本章的 *单词标记 部分 中学到的那样,用嵌入的单词索引我们的记录。
结论是,基本搜索很简单。 然而,语义搜索,虽然更有效,但也更复杂。
如前所述,语义搜索建立在 Core Spotlight 框架之上,从 iOS 18 开始。 Core Spotlight 框架并非新事物——它于 2015 年作为 iOS 9 的一部分被引入,帮助开发者索引应用程序内容并使其可搜索,使用 iOS 中的 Spotlight 功能 进行搜索。
本章不涵盖使用 Core Spotlight 框架。 然而,我们将简要回顾 Core Spotlight 原则,以了解如何启用语义搜索。 让我们开始吧。
探索 Core Spotlight 框架
Spotlight 框架 通过执行查询来索引本地数据并检索它。
Core Spotlight 框架有三个主要部分——创建可搜索项、索引和查询。 让我们逐一介绍这些部分。
创建可搜索项
假设我们在本地存储中有书籍结构的实例,并希望实现 Core Spotlight 以允许用户搜索 书籍。
首先,我们需要将所有我们的 <st c="29753">Book</st> 实例 映射到 <st c="29771">CSSearchableItem</st>:
let searchableItems: [<st c="29812">CSSearchableItem</st>] = books.map { book
in
let attributeSet =
CSSearchableItemAttributeSet(contentType:
.text)
attributeSet.title = book.title
attributeSet.contentDescription = book.author <st c="30000">let item = CSSearchableItem(uniqueIdentifier:</st>
<st c="30045">book.id, domainIdentifier: "books",</st>
<st c="30081">attributeSet: attributeSet)</st> return item
}
在先前的代码示例中,我们 取了一个包含 <st c="30175">Book</st> 的数组,并将其映射到一个包含 <st c="30209">CSSearchableItem</st>的数组中。 我们通过创建一个 <st c="30252">CSSearchableItemAttributeSet</st> 来完成这项工作——这是一个包含可搜索项一般信息的项。 然后,我们初始化一个新的 <st c="30378">CSSearchableItem</st>,传递我们的 <st c="30408">CSSearchableItemAttributeSet</st> 并提供一个唯一标识符,这有助于我们在需要时检索 <st c="30501">Book</st> 记录 。
索引
现在我们有一个 包含 <st c="30564">CSSearchableItem</st>的数组,我们需要为 Core Spotlight 框架索引数组项。 我们通过 创建 <st c="30672">CSSearchableIndex</st>来完成这项工作:
let index = <st c="30704">CSSearchableIndex</st>(name: "SpotlightSearchIndex")
index.<st c="30759">indexSearchableItems</st>(searchableItems) { error
in
if let error = error {
print("Indexing error:
\(error.localizedDescription)")
} else {
print("Books successfully indexed!")
}
}
在先前的示例中,我们创建了一个新的 <st c="30981">CSSearchableIndex</st> 并调用了 <st c="31014">indexSearchableItems</st> 函数,该函数使用我们在上一步中创建的包含 <st c="31063">CSSearchableItem</st> 的数组。 请注意 这是一个异步操作,并且被认为是非常密集的。
查询
现在我们有了索引,我们可以 执行一个查询来根据一个 搜索短语检索数据:
let searchContext = CSUserQueryContext()
searchContext.fetchAttributes = ["title"]
searchContext.enableRankedResults = true
var items: [CSSearchableItem] = []
let query = CSUserQuery(userQueryString: query,
userQueryContext: searchContext)
do {
for try await element in query.responses {
switch(element) {
case .item(let item):
items.append(item.item)
break
case .suggestion(let suggestion):
// handle suggestions. break
@unknown default:
break
}
}
self.searchResults = items
} catch let error {
print(error.localizedDescription)
}
在先前的示例中,我们创建了一个包含各种查询信息的搜索上下文。 基于 搜索上下文和搜索短语,我们初始化了一个 <st c="32001">CSUserQuery</st> 对象,并通过调用其 <st c="32057">responses</st> 获取器来获取搜索结果。
结果是包含 <st c="32103">CSSearchableItem</st>的数组,我们可以通过使用每个记录的唯一标识符来检索原始项。
现在我们知道了如何使用 Core Spotlight 框架实现搜索,让我们看看如何实现一个 <st c="32313">语义搜索</st>。
实现语义搜索
将语义搜索功能添加到现有的 Core Spotlight 搜索中很简单。 我们所需做的只是使用以下 静态函数加载 ML 模型一次:
CSUserQuery.prepare()
<st c="32550">prepare</st> 函数使 Core Spotlight 框架准备好加载其用于 语义搜索的 ML 模型。
如果搜索索引由于隐私问题具有保护级别,我们还需要调用prepreProtectionClasses 函数:
CSUserQuery.prepareProtectionClasses([.completeUnlessOpen])
此功能准备搜索标记为具有completeUnlessOpen 保护级别的索引。
什么是保护级别?
术语**保护级别**指的是用户具有特定资源的可访问级别,考虑到设备的安全条件。这里有三种主要 保护级别:
NSFileProtectionNone:索引始终可访问,即使设备锁定时也是如此
NSFileProtectionCompleteUntilFirstUserAuthentication:设备重启后,用户首次认证成功,索引可访问
NSFileProtectionComplete:索引仅在设备解锁时才可访问
记住,准备 ML 模型需要时间和内存,所以最好在搜索用户界面立即之前调用prepare 函数。
我们讨论了各种内置 ML 模型,我们可以看到它们涵盖了我们可以使用 ML 功能的许多用例。然而,有些情况下,iOS SDK 并没有提供我们需要的确切 ML 解决方案。 幸运的是,我们可以使用 CoreML 框架集成我们的模型。
使用 CoreML 集成自定义模型
通常,ML 模型是训练来执行特定任务的——识别句子的情感、检测人类或分析声音都是使用不同模型完成的不同任务的例子。这意味着尽管现有模型潜力巨大,但我们仍然在能做什么方面受到限制。 这意味着 即使现有模型的潜力巨大,我们仍然在能做什么方面受到限制。
这就是CoreML 框架进入场景的地方。 使用 CoreML,我们可以集成 iOS SDK 之外的 ML 模型,我们甚至可以训练自己的模型并添加更多智能能力。
最好通过一个例子来说明如何做这件事,比如检测垃圾邮件。
假设我们正在开发一个消息应用。 最受欢迎的消息应用功能之一是检测垃圾邮件,以提高用户体验和 增加用户留存率。
我们必须创建一个 ML 模型来将消息分类为垃圾邮件,以实现一个 垃圾邮件检测器。
为了实现这一点,我们可以使用一个名为 Create ML 的桌面应用程序,它是 Xcode 套件的一部分。 让我们先来了解一下 Create ML!
了解 Create ML 应用程序
Create ML 于 2018 年作为 苹果公司持续努力使机器学习对开发者更易获取的一部分而推出。 我们可以使用 Create ML 在各个领域构建、训练和部署 ML 模型。
要打开 Create ML,请按照 以下步骤操作:
-
打开 Xcode。
-
在 Dock 上的 Xcode 图标上 右键单击。
-
选择 打开开发者工具 | Create ML。
另一种打开 <st c="35374">Create ML</st> 的方法是在 Mac 的 Spotlight 中搜索它,并 选择它。
打开它并点击 新建文档 按钮后,我们会看到以下屏幕(图 12.1**.1):
https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_12_1.jpg
图 12.1:创建 ML 模板选择器
图 12**.1 显示了 Create ML 模板选择器屏幕。 每个模板代表我们模型的不同配置,每个都是为处理不同类型的数据而设计的。 例如, 图像分类 模板是为处理图像而设计的。 由于我们想要对 文本消息进行分类,我们将选择 文本分类 模板并点击 下一步 按钮。
这将带我们进入项目详情屏幕(*图 12.2**):
https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_12_2.jpg
图 12.2:项目详情表
在项目详情表中,我们将填写一些关于我们的 ML 模型的一般信息,例如名称、作者、许可证和描述,然后 点击 下一步。
我们的下一个屏幕是项目窗口(*图 12.3):
https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_12_3.jpg
图 12.3:SpamClassifier 项目窗口
在 图 12**.3中,我们可以看到 <st c="37663">SpamClassifier</st> 项目窗口。 项目窗口是我们构建模型的主要窗口。 让我们回顾一下不同的 窗口组件:
-
左侧面板:左侧面板列出了项目的不同来源 - 机器学习模型及其数据来源,用于训练和测试
-
设置选项卡:该 设置 选项卡是我们定义不同阶段和一般 训练参数
-
训练选项卡:该 训练 选项卡显示了训练操作的进度
-
评估选项卡:该 评估 选项卡显示了我们的模型在不同阶段 的性能
-
预览选项卡:我们可以在 预览* 选项卡中* 与我们的机器学习模型互动并体验它
-
输出选项卡:该 输出 选项卡是我们部署 我们的模型
组件列表反映了我们在构建 我们的模型时必须经历的各个阶段。
现在我们知道了 Create ML 是什么,让我们开始构建 我们的模型。
构建我们的垃圾邮件分类器模型
我们的垃圾邮件分类器 模型构建过程基于三个 数据来源 - 训练数据、验证数据和测试数据。 这三个数据来源是我们在本章前面*训练* 模型 部分中提到的。
首先,让我们看看我们将如何准备 我们的数据。
准备我们的数据
由于我们正在构建一个 垃圾邮件分类器模型,我们必须准备一个包含垃圾邮件和非垃圾邮件的数据集。 文本分类模板要求我们的数据集以 CSV 文件的形式存在,包含两列 – <st c="39116">文本</st> 和 <st c="39125">标签</st>。在我们的案例中, <st c="39149">文本</st> 列代表短信的内容,而 <st c="39212">标签</st> 列是分类 – <st c="39249">true</st> 表示垃圾邮件,而 <st c="39277">false</st> 表示非垃圾邮件。
垃圾邮件和非垃圾邮件之间的比例需要反映现实世界的分布。 在我们的案例中,我们有一个包含 300,000 条记录的数据集文件,其中 10%是垃圾邮件,90%是 非垃圾邮件。
要设置 训练数据集,我们可以将 CSV 文件拖放到 训练数据 框中(图 12**.4):
https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_12_4.jpg
图 12.4:包含 300,000 条记录的训练数据
图 12**.4 显示了 <st c="40156">真实</st> 和 <st c="40165">虚假</st>,如前所述。 此外,我们还在左侧面板中有一个新的 数据源 – 我们作为训练数据集导入的文件。
现在我们有了训练数据,我们可以处理 验证数据 了。 提醒一下,作为训练过程的一部分,我们将使用验证数据来调整模型。 我们可以提供自己的验证数据,但 Create ML 允许我们从我们刚刚提供的训练数据集中分割它。 我们可以在评估步骤中稍后添加 测试数据集。
第三个数据集是 测试数据。我们使用测试数据来查看模型如何对未见过的文本进行分类。 我们可以在评估步骤中稍后添加 测试数据集。
除了选择不同的数据集,我们还可以设置训练将进行的迭代次数和 模型算法。
每次迭代中,训练过程可以通过回顾前一次迭代的错误并调整其参数(如神经网络中的权重)来自我调整。我们的直觉可能会说,迭代次数越多,我们的模型就越聪明。然而,事实并非如此。首先,在某个点上,再进行迭代并不能提高模型,只会消耗计算资源。但真正的问题是所谓的过拟合。过拟合是指机器学习模型学习训练数据过于完美,包括其噪声。在这种情况下,分析未见数据将会有问题。
另一个参数是模型算法(图 12.5):
https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_12_5.jpg
图 12.5:选择模型算法
图 12.5 显示了弹出菜单,我们可以从五个不同的选项中选择模型学习算法。算法概述不在本章的范围内,但简而言之,不同的算法适合不同的需求,并消耗其他资源。例如,BERT算法非常适合语义理解,而条件随机字段算法非常适合序列标注。在我们的案例中,我们将选择最大熵算法,它非常适合分类。
现在我们已经准备好了所有数据集,我们可以点击左上角的训练按钮并开始训练。
执行训练
现在,我们已经到达了主要环节——训练阶段。在训练阶段,Create ML 应用程序会使用我们在准备我们的数据部分定义的算法遍历训练数据集。让我们尝试描述这个过程:
-
在每次迭代中,模型使用验证数据集来验证自身。记住,验证数据集可以是不同的。然而,默认情况下,它是训练数据集的一个子集。
-
训练 *阶段的时间长度由三个主要因素决定 ——数据集大小、迭代次数和 选择的算法。
-
模型不需要执行我们在 设置 选项卡中定义的迭代次数。 如果验证准确率达到高水平, *训练将提前停止 * 以节省资源并 避免过拟合。
在训练 过程结束时,我们将看到以下图表(图 12**.6):
https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_12_6.jpg
图 12.6:训练过程结束时的训练选项卡
图 12**.6 显示了我们在训练阶段的表现。 我们可以看到,在仅经过两次迭代后,我们就达到了高准确率。 在这种情况下,这是因为我们的训练数据集结构良好且可靠。 然而,情况并不总是如此,因此我们需要在这个步骤中保持耐心。
现在我们的模型已经训练好了,我们需要对其进行测试。 为此,我们将使用我们的测试数据集作为评估步骤的一部分(图 12**.7):
https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_12_7.jpg
图 12.7:模型评估步骤
图 12**.7 显示了评估步骤以及用于训练和验证模型的不同的数据集。 我们还可以看到测试数据包含一个包含 1,000 个项目的数据集。 测试数据集 的结构与训练和验证数据集相似。 点击 测试 按钮将在数据集中的所有 1,000 个项目上运行分类,并测量它们的分类准确率。 让我们看看测试结果(图 12**.8):
https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_12_8.jpg
图 12.8:评估结果
图 12**.8 展示了我们需要熟悉的一些术语,以便理解这份报告:
-
<st c="45825">true</st>或<st c="45833">false</st>(根据具体类别而定)并且是正确的。 例如,对于<st c="45931">false</st>类别,93%的精确率意味着模型识别为<st c="46002">false</st>的所有消息实际上 确实是<st c="46022">false</st>。 -
<st c="46102">true</st>类别意味着模型正确识别了所有实际 垃圾邮件消息的 93%。 -
F1 分数:F1 分数是精确率和召回率的平衡。
F1 分数 不仅涉及测量模型的准确性。 它平衡了两个重要的指标—— 精确率 和 召回率 ——并且反映了更好的模型性能测量。 在我们的案例中,0.96 的分数被认为是非常 高的性能。
我们的下一个选项卡是 预览,在那里我们可以在游乐场区域内进行操作(图 12**.9):
https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_12_9.jpg
图 12.9:预览选项卡
图 12**.9 显示了我们的模型的 预览 选项卡,其中有一个示例消息说, 现在打电话获取邀请。我们的 模型以 92%的置信度将该消息识别为垃圾邮件。 干得好!
现在,让我们看看我们如何部署 我们的模型。
部署我们的模型
如果我们不能在 Xcode 中部署它,那么拥有一个出色的训练过程是没有意义的。 这就是为什么我们有 输出 选项卡(图 12**.10):
https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_12_10.jpg
图 12.10:创建 ML 输出选项卡
输出 选项卡显示了我们的模型摘要,包括我们之前从未见过的细节——模型大小。 模型大小。
更重要的是, <st c="48229">mlmodel</st> 扩展。
为了在我们的项目中使用 <st c="48259">mlmodel</st> 扩展,我们需要使用 Core ML。 这就是我们的 下一个主题。
使用我们的模型与 Core ML
<st c="48372">The</st> <st c="48377">Core ML framework</st> <st c="48394">’s goal is to allow us</st> <st c="48417">to integrate ML models into</st> <st c="48446">our projects.</st>
<st c="48459">Our first step is to add the</st> <st c="48489">mlmodel</st> <st c="48496">file that we saved from the Create ML application to Xcode.</st> <st c="48557">We can do that by dragging the file to the project navigator</st> <st c="48618">in Xcode.</st>
<st c="48627">The main class in the Core ML framework we will use is</st> <st c="48683">MLModel</st> <st c="48690">, which represents a ML model loaded into the system.</st> <st c="48744">To</st> <st c="48746">load our Spam Classifier model, we initialize the model in</st> <st c="48806">our code:</st>
class MessageClassifier {
let model: MLModel
init(configuration: MLModelConfiguration =
MLModelConfiguration()) throws { <st c="48937">model = try SpamClassifier(configuration:</st>
<st c="48978">configuration).model</st> }
}
<st c="49003">In the preceding code example, we created a new class, called</st> <st c="49066">MessageClassifier</st> <st c="49083">, which encapsulates our ML integration with the Spam</st> <st c="49137">Classifier model.</st>
<st c="49154">We then initiate the class, passing a new</st> <st c="49197">MLModelConfiguration</st> <st c="49217">. This contains different options, but we can pass an empty instance at</st> <st c="49289">this stage.</st>
<st c="49300">Our class also contains an</st> <st c="49328">MLModel</st> <st c="49335">instance.</st> <st c="49346">To initiate the model instance, we use the</st> <st c="49389">SpamClassifier</st> <st c="49403">class, passing</st> <st c="49419">our configuration.</st>
<st c="49437">But wait – where did the</st> <st c="49463">SpamClassifier</st> <st c="49477">class</st> <st c="49484">come from?</st>
<st c="49494">When we added the Spam Classifier</st> <st c="49529">mlmodel</st> <st c="49536">file into our Xcode project, Core ML generated three interfaces – the <st c="49607">SpamClassifier</st> <st c="49621">class,</st> <st c="49629">SpamClassifierInput</st> <st c="49648">,</st> <st c="49650">and</st> <st c="49654">SpamClassifierOutput</st> <st c="49674">.</st>
<st c="49675">Now that we have our model, let’s write a function that can predict whether a message</st> <st c="49762">is spam:</st>
func prediction(text: String) throws -> Bool {
let input = SpamClassifierInput(text: text)
if let result = try? model.prediction(from: input)
{ <st c="49915">let value = result.featureValue(for:</st>
<st c="49951">"label")!.stringValue</st> return value == "true"
}
return false
}
<st c="50013">In the preceding example, we created a</st> <st c="50053">prediction</st> <st c="50063">function that receives a text message as input and</st> <st c="50114">returns</st> <st c="50123">a Boolean.</st>
<st c="50133">It starts by creating a</st> <st c="50158">SpamClassifierInput</st> <st c="50177">instance with the text input.</st> <st c="50208">Then, it generates a prediction result for this input by running the model’s</st> <st c="50285">prediction()</st> <st c="50297">function.</st> <st c="50308">We then get the value from the feature, called</st> <st c="50355">label</st> <st c="50360">, and compare it</st> <st c="50377">to</st> <st c="50380">true</st> <st c="50384">.</st>
<st c="50385">This code example demonstrates how to easily use a custom ML model in our</st> <st c="50460">Xcode projects.</st>
现在,让我们尝试理解在我们的 Xcode 中使用自定义机器学习是否 这么简单。
接下来该往哪里去
本书的 Core ML 部分是独特的。 在大多数情况下,我已经简化了复杂的话题,使其对开发者更容易理解。 然而,我认为 Core ML 主题 是不同的。
机器学习是一个广泛的话题,超出了本章的范围。 此外,它是一个复杂的话题。 训练不仅仅是提供数据集。 理解不同类别之间的数据集混合,选择正确的算法,并仔细阅读评估 结果至关重要。
并且记住,我们创建的模型是自定义的。 这意味着我们无法控制其算法的工作方式,需要随着时间的推移进行观察和微调 它。
总的来说,机器学习是一个复杂的话题,如果我们想进入这个领域,我们需要比阅读 15 页的内容更深入地了解。
总结
这是一章关于当代最激动人心的主题之一——机器学习(ML)和人工智能(AI)的漫长但引人入胜的章节。 和人工智能。
我们回顾了人工智能和机器学习的基础知识,了解了训练机器学习模型意味着什么。 我们探索了 iOS SDK 中内置的机器学习模型,包括自然语言处理(NLP)、使用 Vision 框架分析图像,以及使用声音分析框架对音频进行分类。 我们学习了如何向 Core Spotlight 框架添加语义搜索功能,而且如果还不够的话,我们还学习了如何创建和集成自定义机器学习模型到 我们的项目中。
现在,我们可以给我们的应用添加一些智能功能! 我们的应用!
说到智能,我们下一章将讨论如何使用 App Intents 集成 Siri。 机器学习阶段还没有结束 呢!
第十四章:13
使用应用程序意图将您的应用程序暴露给 Siri
多年来,应用程序一直独立于系统空间中生活和运行。 每个应用程序与其他应用程序完全隔离,没有能力进行通信或 暴露数据。
多年来,事情发生了一些变化。 应用程序获得的最令人兴奋的 特性之一是**应用程序意图**的增强。此时,你应该熟悉应用程序意图——我们在**第五章**中讨论了它们。 然而,在 iOS 18 中,应用程序意图变得更加强大,因为它们与 Apple Intelligence 紧密合作,而不仅仅是与 WidgetKit。 这就是为什么我们将更详细地介绍应用程序意图。
在本章中,我们将学习以下内容: 以下内容:
-
理解应用程序 意图概念
-
创建一个简单的 应用程序意图
-
使用 应用程序实体正式化我们的内容
-
调整我们的应用程序意图以与 Apple Intelligence
应用程序意图打开我们应用程序的能力真正令人印象深刻,充满潜力。 但首先,让我们了解应用程序 意图概念。
技术要求
对于本章,从 App Store 下载 Xcode 版本 16.0 或更高版本是必不可少的。 App Store。
确保您正在使用最新的 macOS 版本(Ventura 或更新版本)。 只需在 App Store 中搜索 Xcode,选择最新版本,然后继续下载。 打开 Xcode 并完成出现的任何进一步设置说明。 在 Xcode 完全运行后,您 就可以开始了。
本章包含许多代码示例,其中一些可以在以下 GitHub 仓库中找到: https://github.com/PacktPublishing/Mastering-iOS-18-Development/tree/main/Chapter%2013。
请注意,本章中的一些示例需要在现代设备上运行,例如 iPhone 15 Pro/Max、搭载 M1 及更高版本的 iPad 或 Apple Silicon Mac。
理解应用程序意图概念
我们第一次遇到应用意图是在 第一章 然后是在 第五章 ,当我们讨论 WidgetKit 时。 但我们真的理解应用意图的概念吗? 让我们简要了解一下苹果在多个应用中,包括 第三方应用中,将人工智能深度集成到系统中的努力。
为了实现这一集成,我们需要为我们的应用创建一个 API,该 API 可以公开应用的核心内容和主要操作。 例如,一个待办事项应用可以创建一个 API,允许 Siri 或其他系统组件创建一个新任务,完成现有任务,或从核心数据中检索任务列表。 一个配送应用可以有一个 API,返回配送服务是否现在开放或配送到达的时间。
这个名为 App Intents 的 API,是我们向世界展示我们应用的主要用例和内容的方式。 它是我们用来将应用与苹果智能集成 的工具之一。
如果这听起来很复杂,你会惊讶于创建应用意图有多么简单。 让我们看看它是如何工作的。
创建一个简单的应用意图
为了演示如何创建 应用意图,让我们想象我们有一个令人惊叹的待办事项列表。 不仅仅是令人惊叹,甚至是强大的! 所以,我们将它称为 它 <st c="2980">MightyTasksList</st>。
我们的 <st c="3001">MightyTasksList</st> 应用如此出色,以至于我们的用户要求他们在开车时使用它与 Siri 配合。 因此,我们决定创建一个 应用意图。
为此,我们将打开一个新文件并编写以下代码: 以下代码:
<st c="3209">import AppIntents</st> struct GetTasksIntent: AppIntent {
static var title: LocalizedStringResource { "Get the number of opened tasks" }
@MainActor
func perform() async throws -> some ProvidesDialog {
let tasks = TaskManager().tasks <st c="3438">return .result( dialog: "Number of the opened tasks is \(tasks.count)")</st> }
}
这就是全部了吗? 是的! 编写一个简单的 应用意图非常简单。 让我们缩小一下我们 在这里做了什么:
-
我们导入了
<st c="3630">AppIntents</st>框架。 在这种情况下,我们需要这个框架来拥有<st c="3701">AppIntent</st>协议。 -
我们创建了一个符合
<st c="3781">AppIntent</st>协议的<st c="3734">GetTasksIntent</st>结构。 这个结构定义了我们的 意图功能。 -
作为
<st c="3865">AppIntent</st>协议的一部分,我们必须实现两件事。 第一件事是意图的<st c="3945">标题</st>,它出现在快捷方式应用中的意图画廊中(我们将在 *在快捷方式应用中运行意图 部分中很快就会看到)。 第二件事是我们需要实现的是<st c="4136">perform()</st>函数,这是意图运行时实际执行的代码。
<st c="4224">The</st> <st c="4229">perform()</st> 函数始终返回一个基于 <st c="4266">IntentResult</st> 协议的类型——在这种情况下,我们返回一个符合 <st c="4370">IntentResult</st> 协议的 <st c="4325">ProvidesDialog</st> 实例,并向用户显示一条消息。 然而,还有其他类型,我们将在接下来的章节中讨论。`
下一个,让我们使用快捷方式应用来运行我们的意图。
运行意图的快捷方式应用
The Shortcuts app is a powerful automation tool that allows users to create shortcuts for routines and actions in their system. Users can also use the Shortcuts app to create scripting, automation, conditional statements, and complex logic.
苹果公司在 2017 年收购了快捷方式应用,该应用最初由一家名为 Workflows 的初创公司开发。
当我们构建和运行我们的应用时,iOS 会扫描符合 <st c="5035">AppIntent</st> 协议的结构,并将它们添加到快捷方式应用中的 快捷方式 画廊(*图 13.1):
在 *图 13.1中,我们可以看到在搜索我们应用的操作时,快捷方式应用中获取打开任务数量的快捷方式的意图。然后,我们可以将操作添加为新快捷方式。接下来,让我们看看当我们运行意图时会发生什么(图 13.2):
图 13**.2 展示了当我们运行我们的应用意图时会发生什么。 我们可以看到我们定义的消息作为 <st c="5730">perform()</st> 函数的结果。
做得好! 我们创建了 我们的第一个 应用 意图!
现在,让我们尝试简化用户的使用,并将快捷方式作为 应用的一部分。
创建应用快捷方式
我们不是让用户根据我们提供的操作(意图)创建快捷方式,而是为他们创建一个可以使用的快捷方式。
为了做到这一点,我们需要创建一个符合 <st c="6096">AppShortcutsProvider</st> 协议的结构来创建一个 预配置的快捷方式:
import AppIntents <st c="6181">struct AppShortcuts: AppShortcutsProvider {</st> @AppShortcutsBuilder
static var appShortcuts: [AppShortcut] { <st c="6287">AppShortcut</st>(intent: GetTasksIntent(),
phrases: ["What is left in \(.applicationName)?", "How many tasks left in \(.applicationName)"], shortTitle: "My tasks", systemImageName: "circle.badge.checkmark")
}
}
在这段代码中,我们有一个名为 <st c="6532">AppShortcuts</st>的结构体。这个结构体有一个变量需要实现——<st c="6588">appShortcuts</st>,它包含一个应用快捷方式的列表。
在这种情况下,我们创建了一个新的 <st c="6677">AppShortcut</st> 实例,它包含以下内容:
-
将要执行的意图。 在这里,我们将前面 部分(
<st c="6822">GetTasksIntent</st>)中创建的意图放入。 -
用户必须对 Siri 说的确切短语。 在我们的例子中,我们添加了两个短语。 请注意,短语必须包含 应用程序名称。
-
快捷方式的标题和系统图像。
一旦我们运行了我们的应用,用户不需要使用快捷方式应用来创建快捷方式——快捷方式已经准备好供用户使用 Siri 了。
现在我们已经创建了第一个意图和快捷方式,让我们深入研究更复杂的 用例。
向我们的应用意图添加一个参数
在 创建简单的应用意图 部分,我们 创建了一个 <st c="7361">GetTasksIntent</st> 结构体,它访问持久存储并返回用户的打开任务数量。 现在,让我们看看我们如何使用 <st c="7497">AppIntents</st> 框架来创建一个将新任务插入到 系统中的操作。
我们将打开一个新文件并添加以下代码:
struct AddTaskIntent: AppIntent {
static var title: LocalizedStringResource { "Create new task" } <st c="7727">@Parameter(title: "Title")</st><st c="7753">var title: String</st> @MainActor
func perform() async throws -> some <st c="7819">ReturnsValue<String></st> {
TaskManager().addTask(Task(title: title))
TaskManager().saveTasks() <st c="7910">return .result(value: title)</st> }
}
<st c="7942">AddTaskIntent</st> 稍微复杂一些,但并不复杂。
首先,我们创建一个新的 <st c="8033">AddTaskIntent</st> ,它符合 <st c="8068">AppIntent</st> 协议,类似于 <st c="8099">GetTasksIntent</st>。我们还为 Shortcuts 应用提供了一个可读的标题。 但随后我们看到一个新的变量——一个带有 <st c="8224">@</st>``<st c="8225">Parameter</st> 属性的标题。
我们说过应用意图实际上是我们应用的 API。 一些 API 需要输入,所以当添加一个新任务时,标题是我们的 Intent 输入。 我们可以通过 Siri、对话框或甚至另一个 intent 来提供这个输入。 当我们运行 <st c="8472">AddTaskIntent</st>时,用户必须为 <st c="8521">任务</st> 提供一个标题。
当我们到达 <st c="8549">perform()</st> 函数时,我们可以看到我们如何使用 <st c="8595">title</st> 参数将一个新任务插入到持久存储中。 我们还可以看到与 <st c="8700">GetTasksIntent</st> 示例相比的一个变化,即函数如何返回 一个值。
在 <st c="8763">GetTasksIntent</st>中,我们使用了 <st c="8786">the</st> <st c="8791">ProvidesDialog</st> 协议。 现在,我们使用 <st c="8828">ReturnsValue<String></st>,它返回一个值给我们的快捷方式。 我们可以使用返回的值作为其他动作的输入。 例如,在这种情况下,我们可以使用任务标题在 Reminders 应用中创建具有相同标题的提醒,甚至可以用这个标题给其他人发送消息。 这个功能使得 App Intents 和 Shortcuts 应用对高级用户来说非常有用。
让我们看看它在 Shortcuts 应用中的样子:
图 13.3:包含两个操作的快捷方式
在 图 13**.3中,我们可以看到我们的快捷方式包含两个操作:
-
第一个是
<st c="9466">AddTaskIntent</st>来自 `我们的应用。 -
第二个是,我们的意图的结果是任务标题和来自 发送消息 动作的消息应用。
我们可以看到,可以将操作串联起来,创建 <st c="9650">强大的流。</st>
让我们看看当我们运行我们的快捷方式时(图 13**.4)的样子:
图 13.4:运行 AddTaskIntent
图 13**.4 显示,当我们通过提供标准输入字段来运行我们的快捷方式时,系统会要求输入任务标题。 除了 Siri 集成之外,这是我们免费获得的部分——一个标准用户界面 ,为我们处理所有这些。
然而,我们也可以为快捷方式创建自己的用户界面! 让我们看看如何 做到这一点。
返回自定义视图
我们在前面的部分中添加了两个重要的 意图。 第一个接收一个包含打开任务数量的字符串,第二个是一个应用程序意图,它在持久存储中创建一个新任务。
让我们讨论另一个用例——获取打开的任务列表。 在这种情况下,我们希望向用户展示一个自定义视图,因为快捷方式和 Siri 无法原生地处理实体列表。
因此,让我们创建一个 自定义视图:
struct MiniTasksList: View {
let tasks: [Task]
var body: some View {
VStack {
ForEach(tasks) { task in
TaskView(task: task)
}
}
}
}
我们的代码示例包含一个名为 <st c="10816">MiniTasksList</st> 的结构,其中包含一个显示 <st c="10867">数组</st> 的 <st c="10878">TaskView</st>。
这里有两个奇怪的地方:
-
首先,为什么我们需要创建一个专门的列表视图? 我们不能在我们应用程序中重用已经存在的视图吗?
-
其次,为什么我们使用 VStack 而不是列表视图?
这些论点 通常是有效的。 我们应该尽可能重用我们的代码,并为正确的行为使用正确的视图。 然而,一个限制是我们不能将列表或滚动视图作为我们自定义视图的一部分使用。 我们也不能显示动画或允许用户交互。 如果我们想实现更多功能,我们应该使用应用程序本身或创建额外的应用程序意图来满足 我们的需求。
现在我们有一个自定义视图,让我们创建一个应用程序意图,使用它:
struct <st c="11567">GetTasksListIntent</st>: AppIntent {
static var title: LocalizedStringResource { "Get my Tasks's List" }
@MainActor
func perform() async throws -> some <st c="11715">ShowsSnippetView</st> {
let tasks = TaskManager().tasks <st c="11766">return .result(view: MiniTasksList(tasks: tasks))</st> }
}
<st c="11824">GetTasksListIntent</st> 结构与我们前面部分创建的意图类似。 它也有一个 <st c="11939">标题</st> 属性和一个 <st c="11960">perform()</st> 函数。
这里有两个重要的 变化:
-
perform()函数的返回类型现在是<st c="12041">ShowsSnippetView</st>。如果我们想将自定义视图作为函数的结果展示,我们使用<st c="12092">ShowsSnippetView</st>。 `我们的函数。 -
我们返回了一个不同的意图结果,使用了我们创建的
<st c="12221">MiniTasksList</st>视图。
现在,让我们使用快捷方式应用运行我们的意图,看看会发生什么(图 13**.5):
图 13.5:意图响应中的任务列表
图 13**.5 显示了 <st c="12567">MiniTasksList</st> 作为我们的意图响应。 <st c="12605">通过查看列表的显示方式,我们可以理解为什么苹果限制了我们可以如何自定义此视图。</st> 目标是使我们的视图尽可能简单,并与其他意图保持一致。
返回一个视图 <st c="12821">是很好的。</st> 但如果我们想返回一个自定义视图和可以用于其他目的的值呢? <st c="12925">这是否可能?</st> 让我们找出是否以及如何 `做到这一点。
具有多个结果类型
想象一下,除了获取任务列表,用户还希望向其快捷方式添加另一个步骤。 <st c="13030">如果任务数量超过,比如说,五个,他们希望打开日历来重新安排</st> 这一天。
因此,我们希望显示任务列表,并返回 `它们的数量。
我们可以通过返回 多种类型来实现这一点:
struct GetTasksListIntent: AppIntent {
static var title: LocalizedStringResource { "Get my Tasks's List" }
@MainActor
func perform() async throws ->
some <st c="13490">ShowsSnippetView & ReturnsValue<Int></st> {
let tasks = TaskManager().tasks
return <st c="13568">.result(value: tasks.count,</st>
<st c="13595">view: MiniTasksList(tasks: tasks))</st> }
}
在我们的代码示例中, <st c="13656">perform()</st> 返回两种类型的结果—— <st c="13697">ShowsSnippetView</st> 用于显示任务列表,以及 <st c="13748">ReturnsValue<Int></st> 用于在其他意图中使用任务数量。
我们还修改了函数的 <st c="13835">IntentResult</st> 返回语句:
return .result(value: tasks.count,
view: MiniTasksList(tasks: tasks))
请注意,我们返回的值需要与我们在函数签名中声明的 <st c="14014">ReturnsValue</st> 实例相同。
添加确认和条件
让应用执行操作可能导致更复杂的使用场景。 例如,有些情况下我们需要与用户确认特定的操作,比如删除或订购某物。 在其他情况下,我们可能想通知用户我们无法执行该操作,甚至请求 更多信息。
<st c="14424">AppIntents</st> 协议具有创建与用户对话的能力。 这个对话框可以与 Siri 一起使用,使过程感觉 更像对话。
让我们回到我们的待办事项应用,创建一个允许用户删除所有 其任务的 应用意图:
struct DeleteAllTasksIntent: AppIntent {
static var title: LocalizedStringResource { "Delete all tasks" } <st c="14789">func perform() async throws -> some ProvidesDialog {</st> let taskManager = TaskManager()
if taskManager.tasks.count == 0 {
return .result(dialog: .init("Sorry, there are no tasks to delete"))
} <st c="14979">try await requestConfirmation(actionName: .go,</st>
<st c="15025">dialog: IntentDialog("Are you sure you want to delete all your tasks?"))</st> TaskManager().deleteAllTasks()
return .result(dialog: .init("All of your tasks have been deleted."))
}
}
这个意图比我们之前的例子要复杂一些 并且更智能。 在 <st c="15302">perform()</st> 函数的开始,我们检查持久存储中是否有任何要删除的任务。 如果没有要删除的任务,我们通过返回一个简单的 文本对话框 来通知用户。
接下来,由于这是一个破坏性操作,我们希望与用户确认。 因此,我们使用 <st c="15575">requestConfirmation()</st> 函数。 这个函数显示一个带有给定文本和确认按钮的对话框(图 13**.6):
图 13.6:一个确认对话框
图 13**.6 显示了从 <st c="15865">requestConfirmation()</st> 函数派生出的确认对话框。 注意,我们可以从一组确认按钮标题中选择。 在我们的 情况下,我们选择了 Go 标题。
接下来的步骤很简单:我们执行删除操作,并通知用户该操作已 执行。
到目前为止,我们的应用意图返回的是原始类型,如字符串和 int。 但是,我们如何处理我们的应用类型呢? 是否可以将它们作为快捷操作链的一部分进行传输? 这就是 <st c="16325">AppEntity</st> 的作用。
使用应用实体正式化我们的内容
在我们的应用意图中,当我们创建 一个任务时,我们返回了任务的标题的字符串值。 然而,任务不仅仅是标题——它还包含描述、状态、ID 以及许多其他属性。 换句话说,任务不仅仅是一个字符串或一个 <st c="16705">Task</st>。
App Intents 的问题在于,没有其他应用或系统知道 <st c="16788">Task</st> 结构是什么,因为它是我们应用的内部类型。 我们需要将类型暴露给系统意图世界,以便系统通过 Task 使其成为一个已知类型。
让我们将其与一个用例联系起来:在应用中创建并打开一个任务。 为了使其模块化,我们希望创建两个意图:创建任务和打开任务。 当我们有了这两个意图时,我们可以在 快捷方式中串联它们。
让我们首先让系统知道 <st c="17205">Task</st> 是什么。
遵循 AppEntity
遵循 <st c="17256">AppEntity</st> 协议 使得应用实体对 Siri 和快捷方式可用。 这意味着当我们的应用意图返回我们的一个实体时,我们可以将其作为输入传递给链中的下一个意图。 在链中。
让我们看看我们如何将我们的 <st c="17477">Task</st> 结构转换为 符合 于 <st c="17515">AppEntity</st>:
struct Task: Identifiable, Codable, <st c="17563">AppEntity</st> {
static var <st c="17586">typeDisplayRepresentation</st>: TypeDisplayRepresentation { .init(stringLiteral: "Task") }
init(id: UUID = UUID(), title: String,
description: String = "") {
self.id = id
self.title = title
self.description = description
}
var <st c="17809">displayRepresentation</st>: DisplayRepresentation { DisplayRepresentation(stringLiteral: "title: \(title)") }
let id: UUID
@<st c="17929">Property</st>(title: "Title")
var title: String
@<st c="17975">Property</st>(title:"Description")
var description: String
static var defaultQuery = <st c="18057">TaskQuery</st>()
}
让我们分解 的 <st c="18093">AppEntity</st> 协议实现:
-
<st c="18127">typeDisplayRepresentation</st>: 我们的实体需要在系统中有一个名称,这样我们就可以在快捷方式应用中显示它。 在这种情况下,我们 返回<st c="18269">Task</st>。 -
<st c="18274">displayRepresentation</st>: 虽然<st c="18305">typeDisplayRepresentation</st>显示了实体类型名称,但<st c="18363">displayRepresentation</st>属性返回实体值表示。 在这种情况下,这是标题值(例如, Call my mom)。 -
<st c="18576">@Property</st>属性用于实体的一些 属性,我们定义了实体结构以用于 快捷方式应用。 -
<st c="18692">defaultQuery</st>:仅声明我们的应用程序实体是不够的;我们还需要向系统提供一个检索它们的方法。 我们的下一步将是创建系统将用于检索我们的实体的查询。
现在我们的 <st c="18919">Task</st> 结构已被系统所知,让我们通过创建 <st c="18999">TaskQuery</st>来完成实现:
struct TaskQuery: <st c="19029">EntityQuery</st> {
func entities(for identifiers: [UUID]) async throws -> [Task] {
return TaskManager().tasks.filter {identifiers.contains($0.id)}
}
func suggestedEntities() async throws -> [Task] {
return TaskManager().tasks
}
}
在这个代码示例中,我们可以看到 <st c="19296">TaskQuery</st> 结构符合 <st c="19332">EntityQuery</st> 协议。
系统使用第一个函数, <st c="19390">entities()</st>,通过标识符检索实体。 此时,我们到达应用服务(在这个例子中, <st c="19502">TaskManager</st>)以获取、过滤并返回实体数组。 这就是为什么这个函数 是必需的。
第二个函数(<st c="19626">suggestedEntities()</st>)不是必需的,但它可以帮助系统在我们获取实体列表时向用户展示实体列表。 在获取实体列表的过程中。
我们知道如何定义 <st c="19800">AppEntity</st> 及其查询,但我们需要将其连接到应用意图 以了解它们是如何被使用的。
让我们通过创建一个 <st c="19950">打开一个</st> <st c="19957">任务</st> 意图来做到这一点。
创建一个打开任务意图
创建一个 <st c="20013">打开一个任务</st> 意图与之前示例中的不同不大。 这次,我们将新的应用意图与我们已经创建的 <st c="20152">AppEntity</st> 结构 集成:
struct OpenTaskIntent: AppIntent {
static var title: LocalizedStringResource { "Open a task" } <st c="20284">@Parameter(title: "Task")</st>
<st c="20309">var task: Task?</st>
<st c="20325">static let openAppWhenRun: Bool = true</st> @MainActor
func perform() async throws -> some ProvidesDialog{
let taskToOpen: Task
if let task {
taskToOpen = task
} else {
taskToOpen = <st c="20503">try await $task.requestDisambiguation(</st>
<st c="20541">among: TaskManager().tasks,</st>
<st c="20569">dialog: "What task would like to open?")</st>
<st c="20610">}</st> Navigator.shared.path.append(taskToOpen)
return .result(dialog: "Opening your task")
}
}
我们的 <st c="20706">打开任务</st> 意图的结构 类似于我们之前的意图示例。 尽管如此,我们还需要讨论一些额外的更改:
-
我们向
<st c="20853">Task</st>中添加了<st c="20839">@Parameter</st>。使用<st c="20865">@Parameter</st>对我们来说并不新鲜——我们在 在我们的应用意图中添加参数 部分讨论过。 然而,这次,我们通过<st c="21002">Task</st>结构本身 来实现这一点。 我们可以这样做,因为<st c="21048">Task</st>现在符合<st c="21069">AppEntity</st>。 -
我们将
<st c="21087">openAppWhenRun</st>属性设置为<st c="21114">true</st>,这样我们就可以打开应用并显示<st c="21158">任务详情</st>。 -
如果应用意图没有接收到任务参数,我们可以使用
<st c="21271">requestDisambiguation</st>函数让用户选择一个任务。 此函数向用户显示一个包含给定任务列表的对话框,并要求他们选择<st c="21398">一个任务</st>。
在获得任务后,我们调用应用导航器来打开任务详情。 (要了解更多关于 SwiftUI 中导航如何工作,请参阅 第四章.)
现在,让我们看看运行此意图时会发生什么(图 13**.7):
图 13.7:打开任务意图
图 13**.7 显示了在任务参数 <st c="22055">打开任务</st> 意图 `为 nil 的两个阶段中其外观。
首先,它打开应用(这是因为我们将 <st c="22182">openAppWhenRun</st> 变量 设置为 <st c="22209">true</st>)。
然后,它显示一个原生对话框,用户可以从中选择一个任务。 请注意,任务显示名称(<st c="22318">标题:<任务标题></st>)是我们定义在 <st c="22374">displayRepresentation</st> 变量中,当我们遵循 <st c="22426">AppEntity</st> (在 遵循 AppEntity 部分)。
稍后,我们将导航到我们的任务详情屏幕,并通过返回一个包含相应信息的对话框来通知用户。
让用户选择一个任务来显示是一个很好的用例,但这并不是意图真正强大之处。 让我们尝试通过链式连接将 <st c="22735">打开任务</st> 意图整合到另一个意图中。
让我们尝试将 <st c="22735">打开任务</st> 意图通过链式连接整合到另一个意图中。
链式连接应用意图
让我们回到 <st c="22839">AddTaskIntent</st>,这是我们 在 向我们的应用意图添加参数 部分中创建的,并检查其 <st c="22940">perform()</st> 函数:
func perform() async throws -> some <st c="22996">ReturnsValue<String></st> {
TaskManager().addTask(Task(title: title))
TaskManager().saveTasks() <st c="23087">return .result(value: title)</st> }
<st c="23140">perform()</st> 函数中的返回类型是 <st c="23162">ReturnsValue<String></st>。让我们修改这个函数以返回一个 <st c="23223">Task</st> 实例:
func perform() async throws -> some <st c="23274">ReturnsValue<Task></st> {
let newTask = Task(title: title)
TaskManager().addTask(newTask)
TaskManager().saveTasks()
return .result(value: <st c="23407">newTask</st>)
}
在新 <st c="23430">perform()</st> 函数中,我们只更改了两个部分——返回类型(现在它是 <st c="23504">ReturnsValue<Task></st>)和返回语句,现在它返回我们新创建的任务。
让我们回到 Shortcuts 应用,并将 <st c="23638">AddTaskIntent</st> 和 <st c="23656">OpenTaskIntent</st> 一起链式连接(图 13**.8):
Figure 13.8: A shortcut with Create and Open a task intents
现在我们有一个快捷方式可以创建一个新任务并在应用中打开它,而且我们用非常 少的代码就做到了!
但是关于我们定义的作为 <st c="24041">Task</st> 实体一部分的属性呢? 我们还没有使用它们! 让我们看看如何使用它们与其他意图一起。 其他意图。
将我们的意图与其他意图集成
我们已经看到了如何将添加的任务与 <st c="24214">打开任务</st> 意图链式连接,但这很简单——我们创建了两个意图,所以它们都知道 <st c="24317">Task</st> 实体。 但是当我们需要将 <st c="24375">Task</st> 实体返回给另一个应用开发者的意图时,我们该怎么办呢? 第一个选择是选择其中一个 属性。
选择其中一个属性
关于 <st c="24542">AppEntity</st> 的一个好处 是它创建了一个可以在 我们的系统中 利用的结构:
struct Task: Identifiable, Codable, <st c="24659">AppEntity</st> { <st c="24671">static</st> <st c="24677">var</st> typeDisplayRepresentation: TypeDisplayRepresentation { .init(stringLiteral: "Task") }
@<st c="24769">Property</st>(title: "Title")
var title: String
@<st c="24815">Property</st>(title:"Description")
var description: String
我们的 <st c="24875">Task</st> 结构 包含一个显示名称(<st c="24915">Task</st>)和两个属性 – <st c="24944">Title</st> 和 <st c="24954">Description</st>。我们可以使用这些属性之一传递给 Shortcuts 应用中的下一个操作。 Shortcuts 应用。
例如,让我们假设我们想要创建一个新的任务并在消息中发送其标题。 因为我们定义了 <st c="25163">标题</st> 变量为一个 <st c="25184">AppEntity</st> 属性`,它将显示在快捷方式应用中(图 13**.9):
图 13.9:在快捷方式应用中选择标题属性
图 13**.9 展示了我们如何选择一个 <st c="25538">AppEntity</st> 属性` 并将其传递给 发送 消息 意图。
传递 <st c="25609">标题</st> 是显而易见的——<st c="25626">标题</st> 是一个字符串,我们可以轻松地将其用作其他操作的输入。 但如果我们使用 <st c="25722">任务</st> 作为 发送消息 操作的输入呢? 我们在 链式应用意图 部分做过这样的事情,但那是在同一应用的两个操作之间。 我们能否在两个 不同的应用 之间共享 一个实体?
这就是为什么我们有 Transferable 协议。 让我们 使用它!
使用 Transferable 协议传递整个实体
让我们暂时跳出 《st c=“26072”>AppIntent<st c="26081">》的框架。</st> <st c="26096">共享数据的思想并不局限于</st>AppIntent`——当我们需要共享数据时,我们还有更多的用例。 例如,在视图之间或甚至在不同应用之间拖放是共享数据的一个例子。 另一个例子是在屏幕或应用之间复制粘贴。
在执行共享时,主要挑战是找到每个应用都同意的数据类型。达成共识。
为了解决这个共享问题,苹果在 iOS 16 中引入了 <st c="26513">Transferable</st> 协议,使得在应用或不同 位置之间共享数据变得容易。
<st c="26603">Transferable 的主要用途是复制粘贴和拖放,但它也非常适合在 <st c="26735">快捷方式应用</st> 中共享应用实体。
现在,让我们扩展 <st c="26768">任务</st> 以符合 <st c="26784">Transferable</st>:
extension Task: <st c="26818">Transferable</st> { <st c="26833">static var transferRepresentation: some TransferRepresentation {</st>
<st c="26897">…</st>
<st c="26898">}</st> }
《st c=“26906”>可传输<st c="26918">协议有一个名为</st>transferRepresentation`的静态变量。这个变量允许我们定义在与其他应用 或视图共享结构时如何表示。
当使用 <st c="27118">AppIntent</st> 框架时,我们有几种方法来满足 <st c="27175">transferRepresentation</st> 变量:
-
<st c="27207">数据表示</st>:我们使用<st c="27236">数据表示</st>将我们的对象转换为 RTF 或 PNG 图像等数据格式 -
<st c="27321">文件表示</st>:我们使用<st c="27350">文件表示</st>将我们的实体导出为文件,例如 PDF -
<st c="27414">代理表示</st>:这为其他表示都不合适时提供了一个替代方案
让我们看看我们如何支持 RTF 和 纯文本:
extension Task: Transferable {
static var transferRepresentation: some TransferRepresentation { <st c="27671">DataRepresentation</st>(exportedContentType: .rtf)
{ task in
task.asRTF()! } <st c="27744">ProxyRepresentation</st>(exporting: \.title)
}
}
在我们的代码示例中,我们使用了 <st c="27823">数据表示</st> 和 <st c="27846">代理表示</st> 来支持 RTF 和 纯文本。
这意味着当我们尝试 共享 <st c="27943">任务</st> 实体时, <st c="27960">可传输</st> 机制将尝试首先导出 RTF,然后作为后备方案转向 <st c="28028">代理表示</st> 。
此外,在快捷方式应用中,用户可以选择他们想要导出到脚本下一步的数据类型(图 13**.10):
图 13.10:具有不同数据格式的快捷方式应用
图 13**.10 展示了用户如何选择导出项目的数据格式。 导出项。
我们在应用实体中支持更多格式和数据类型,用户在使用我们的数据时就有更多的选择。
在这个阶段,我们知道如何导出 我们的应用意图和实体。 从某种意义上说,系统知道我们的应用能做什么。 让我们看看我们如何更进一步,并调整它以与 苹果智能技术一起工作。
调整我们的应用意图以与苹果智能技术一起工作
在上一章中,我们讨论了 如何利用一些 iOS 的机器学习和人工智能功能。 iOS 18 中在该领域演变的一件事是 Siri。 Siri 现在比以往任何时候都更聪明,并允许用户以 自然语言执行任务。
例如,用户可以说 将这张照片发送给我的妈妈 给 Siri,Siri 可以处理这个任务而无需 确切的短语。
Siri 的新功能正是我们的应用程序意图所在。 想象一下,在一侧,我们有 Siri,它可以理解用户意图。 在另一侧,我们有我们向系统公开的各种操作。 因此,我们必须找到一种方法来绑定用户意图,正如 Siri 所理解的那样,以及我们的应用程序操作。 这就是我们所说的 助手模式。
探索助手模式
助手模式 的想法简单而先进,充满潜力。
让我们看看 图 13**.11,它描述了助手 模式的工作方式:
图 13.11:助手模式流程
图 13**.11 描述了助手模式 流程的工作方式:
-
用户以 自由而自然的短语 请求 Siri 执行特定操作。 在这种情况下,用户说,“将这封邮件发送给我的妻子。”
-
Siri 和苹果智能使用复杂的机器学习模型将请求转换为系统预定义的其中一个模式。 在这种情况下,苹果智能将用户请求转换为来自
<st c="30775">createDraft</st>模式 的<st c="30803">mail</st>域。 -
现在 Siri 知道所选模式后,它会寻找工具箱中匹配的意图。 我们可以将一些应用程序意图与特定的模式关联起来。
-
Siri 启动并执行上一步(工具箱步骤)中做出的相应应用程序意图。
苹果智能负责理解我们的用户并选择正确的操作,这使得我们的工作作为开发者变得简单——我们只需要确保我们的意图与预定义的 助手模式 匹配。
让我们看看如何做到这一点。 想象一下 我们有一个我们构建的令人惊叹的邮件客户端应用。 用户可以使用我们的应用浏览他们的电子邮件账户,并创建和发送新的 电子邮件消息。
该应用的主要操作之一是创建新草稿;因此,我们创建了一个应用意图来暴露 该功能:
struct <st c="31625">SendDraftIntent</st>: AppIntent {
static var title: LocalizedStringResource { "Send new email" }
@Parameter(title: "Body")
var body: String? @MainActor
func perform() async throws -> some ReturnsValue
<MailDraftEntity>{
let mailDraftEntity = MailDraftEntity(body: EntityProperty(title: LocalizedStringResource(stringLiteral: body!)))
ComposeDraftManager.shared.isPresentingCompose = true
return .result(value: mailDraftEntity)
}
}
在这个代码示例中,我们创建了一个 <st c="32087">SendDraftIntent</st> 应用意图,它接受一个 <st c="32127">body</st> 变量,创建一个邮件草稿实体,并启动邮件编辑器。
很棒的是,我们有一个 创建草稿 操作,用户可以将它添加到他们的快捷方式中。 然而,Siri 并不在乎我们给我们的应用意图命名为 <st c="32344">SendDraftIntent</st> ,我们希望我们的意图成为其苹果智能工具箱的一部分。 换句话说 – 将其添加到 助手模式。
为此,我们添加了一个特殊的 Swift 宏 ,称为 <st c="32524">AssistantIntent(schema:)</st>:
<st c="32550">@AssistantIntent(schema: .mail.createDraft)</st> struct SendDraftIntent: AppIntent {
将 <st c="32641">AssistantIntent</st> Swift 宏与 <st c="32678">.mail.createDraft</st> 模式添加到我们的应用意图中,向苹果智能发出信号。 这是一个 创建草稿 应用意图,无论我们如何命名它。
Swift 宏 – 提醒
Swift 宏 是苹果公司为 iOS 17 新增的一项功能,它可以注入新的属性和函数,并操纵我们的代码以满足各种需求。 要了解更多关于 Swift 宏的信息,请参阅 第十章。
.mail.createDraft 模式 由两部分组成 – 域(<st c="33099">mail</st>)和模式(<st c="33123">createDraft</st>)。 我们可以在 <st c="33170">mail</st> 域中使用更多模式,例如 <st c="33191">deleteDraft</st>,<st c="33204">saveDraft</st>,或 <st c="33218">replyMail</st>。此外,我们还有更多域和模式可以与之配合,例如演示文稿、支付、浏览、照片、书籍等等。 要查看完整的域和模式列表 ,请访问苹果公司的文档 ,网址为 https://developer.apple.com/documentation/AppIntents/app-intent-domains。
那么,这个 <st c="33524">AssistantIntet</st> Swift 宏到底做了什么?
它首先做的是添加一个新的静态变量 名为 <st c="33614">__assistantSchemaIntent</st>:
<st c="33754">createDraft</st> schema. It also ensures that our intent conforms to the <st c="33822">AssistantSchemaIntent</st> protocol, which gives it more capabilities.
<st c="33887">Once that happens, it’s time to adjust</st> <st c="33926">our code according to what the</st> <st c="33958">compiler requires:</st>
* <st c="33976">We can remove the</st> `<st c="33995">title</st>` <st c="34000">static variable, as the App Intents frameworks implement it</st> <st c="34061">for us.</st>
* <st c="34068">The same goes for the</st> `<st c="34091">@Parameter</st>` <st c="34101">argument.</st> <st c="34112">The App Intents framework implements that for us, so we can also</st> <st c="34177">remove that.</st>
* <st c="34189">We must add more properties to our app intent that are part of the</st> `<st c="34257">createDraft</st>` <st c="34268">assistant schema –</st> `<st c="34288">account</st>`<st c="34295">,</st> `<st c="34297">attachments</st>`<st c="34308">,</st> `<st c="34310">to</st>`<st c="34312">,</st> `<st c="34314">cc</st>`<st c="34316">,</st> `<st c="34318">bcc</st>`<st c="34321">,</st> <st c="34323">and</st> `<st c="34327">subject</st>`<st c="34334">.</st>
<st c="34335">Our new modified</st> `<st c="34353">SendDraftIntent</st>` <st c="34368">now looks</st> <st c="34379">like this:</st>
@AssistantIntent(schema: .mail.createDraft) struct SendDraftIntent: AppIntent { var account: MailAccountEntity?
var attachments: [IntentFile]
var to: [IntentPerson]
var cc: [IntentPerson]
var bcc: [IntentPerson]
var subject: String? var body: String? @MainActor
func perform() async throws -> some ReturnsValue
<MailDraftEntity>{
let mailDraftEntity = MailDraftEntity(body:
EntityProperty(title: LocalizedStringResource
(stringLiteral: body!)))
ComposeDraftManager.shared.isPresentingCompose =
true
return .result(value: mailDraftEntity)
}
<st c="34929">In this code example, we can see the new modified version of the</st> `<st c="34995">SendDraftIntent</st>` <st c="35010">struct.</st> <st c="35019">The new properties, such as</st> `<st c="35047">attachments</st>`<st c="35058">,</st> `<st c="35060">to</st>`<st c="35062">,</st> `<st c="35064">cc</st>`<st c="35066">, and</st> `<st c="35072">bcc</st>`<st c="35075">, have particular types, such as</st> `<st c="35108">IntentFile</st>` <st c="35118">and</st> `<st c="35123">IntentPerson</st>`<st c="35135">. The</st> `<st c="35141">AppIntent</st>` <st c="35150">framework uses this type to identify people and files and have a clear interface that the system can work with.</st> <st c="35263">Besides adding them to the</st> `<st c="35290">SendDraftIntent</st>` <st c="35305">struct, we don’t need to do anything with them except use them in our</st> `<st c="35376">perform()</st>` <st c="35385">function.</st>
<st c="35395">When we look at the code, one question arises: How do we know what properties to add for each domain</st> <st c="35497">and schema?</st>
<st c="35508">At this time of writing, there is clear documentation of what properties each schema requires.</st> <st c="35604">However, adding the</st> `<st c="35624">AssistantIntent</st>` <st c="35639">Swift macro and building the project creates new errors that provide information about the</st> <st c="35731">missing information.</st>
<st c="35751">One exception, though, is the</st> `<st c="35782">account</st>` <st c="35789">property, which requires</st> <st c="35814">us to declare an</st> `<st c="35832">AssistantEntity</st>`<st c="35847">-based struct.</st> <st c="35863">Let’s</st> <st c="35869">discuss it.</st>
<st c="35880">Creating AssistantEntity</st>
<st c="35905">When we discussed</st> `<st c="35924">SendDraftIntent</st>`<st c="35939">, we reviewed</st> <st c="35952">several properties, such as</st> `<st c="35981">attachments</st>`<st c="35992">,</st> `<st c="35994">to</st>`<st c="35996">, and</st> `<st c="36002">bcc</st>`<st c="36005">. We saw that for each one, the</st> `<st c="36037">AppIntents</st>` <st c="36047">framework provides a dedicated type, such as</st> `<st c="36093">IntentPerson</st>` <st c="36105">and</st> `<st c="36110">IntentFile</st>`<st c="36120">.</st>
<st c="36121">The case of the</st> `<st c="36138">account</st>` <st c="36145">property is a little</st> <st c="36167">bit different:</st>
MailAccountEntity 不是 <st c="36264">AppIntent</st> 的框架的一部分——它是一个我们必须满足 Assistant Schema 要求的自定义类型,类似于我们在 <st c="36376">SendDraftIntent</st> 中所做的那样。让我们看看如何实现它:
@AssistantEntity(schema: .mail.account)
struct MailAccountEntity {
let id = UUID()
var emailAddress: String
var name: String
static var defaultQuery = AccountQuery()
struct AccountQuery:EntityStringQuery {
func entities(matching string: String)
async throws -> [MailAccountEntity] {
[]
}
init() {}
func entities(for identifiers: [MailAccountEntity.ID])
async throws -> [MailAccountEntity] {
[]
}
}
var displayRepresentation: DisplayRepresentation
{ DisplayRepresentation(stringLiteral: name) }
}
在这个例子中,我们可以看到我们的 `<st c="36919">In this example, we can see that our</st>` `<st c="36957">MailAccountEntity</st>` <st c="36974">struct 有一个名为</st> `<st c="37006">@AssistantEntity(schema: .mail.account)</st>`<st c="37045"> 的 Swift 宏。这个宏使得我们的实体符合 `<st c="37086">AssistantSchemaEntity</st>` <st c="37107">,并要求结构体实现重要的属性,例如</st> `<st c="37175">emailAddress</st>` <st c="37187">和</st> `<st c="37192">name</st>`<st c="37196">。</st>
<st c="37197">Swift 宏还要求我们添加一个默认查询,以便在需要时帮助系统检索和定位账户</st> <st c="37299">when needed.</st>
我们需要实现的第二个实体是 `<st c="37311">The second entity</st>` <st c="37329">we need to implement</st> <st c="37351">is</st> `<st c="37354">MailDraftEntity</st>`<st c="37369">:</st>
@AssistantEntity(schema: .mail.draft)
struct MailDraftEntity {
static var defaultQuery = Query()
struct Query: EntityStringQuery {
init() {}
func entities(for identifiers: [MailDraftEntity.ID])
async throws -> [MailDraftEntity] { [] }
func entities(matching string: String)
async throws -> [MailDraftEntity] { [] }
}
var displayRepresentation: DisplayRepresentation
{ DisplayRepresentation(stringLiteral: "\(subject ?? "")") }
let id = UUID()
var to: [IntentPerson]
var cc: [IntentPerson]
var bcc: [IntentPerson]
var subject: String? var body: String? var attachments: [IntentFile]
var account: MailAccountEntity
}
`<st c="37986">MailDraftEntity</st>` <st c="38002">包含类似于</st> `<st c="38040">SendDraftIntent</st>`<st c="38055"> 的属性。这是因为它是 `<st c="38095">SendDraftIntent</st>` `<st c="38110">perform()</st>` <st c="38120">函数的结果,Siri 可以用它将信息链式连接到其工具箱中的其他操作。</st>
<st c="38208">添加 `<st c="38221">MailDraftEntity</st>` <st c="38236">和</st> `<st c="38241">MailAccountEntity</st>` <st c="38258">可能会很烦人——它要求我们调整我们的信息以适应特定的接口。</st> <st c="38343">然而,这样做使得我们的 Siri 集成无懈可击且有效。</st>
<st c="38413">一旦我们设置好一切</st> <st c="38437">set, the user can see a photo and say something like, “Email this photo using MyMailComposer app,” and Siri will launch our app with a</st> <st c="38573">new draft.</st>
<st c="38583">关于本节代码片段的重要免责声明</st>
<st c="38647">苹果智能</st> <st c="38666">在撰写本书时尚未推出。</st> <st c="38732">这意味着代码已成功编译,但尚未测试与苹果智能兼容。</st> <st c="38839">当苹果智能到达您的地区时,您可能需要调整代码,以便它能够按预期与 Siri 协同工作。</st> <st c="38969">。</st>
如苹果公司一位高级经理曾说过,我们应该将我们所有的应用操作视为应用意图。<st c="39088">这种做法为我们</st><st c="39132">提供了与我们的</st> <st c="39158">应用互动的无限可能性。</st>
<st c="39172">总结</st>
<st c="39180">这是一个令人兴奋的章节!</st> <st c="39211">这不仅是因为应用意图是一个非常令人兴奋的话题,而且还因为这是我们第一次真正将我们的代码与苹果最重要的</st> <st c="39369">技术之一集成。</st>
<st c="39394">在本章中,我们讨论了应用意图的概念,创建了一个具有不同用例的简单应用意图,使用应用实体正式化我们的内容,甚至调整它们以与苹果智能协同工作。</st> <st c="39606">现在,我们应该准备好在</st> <st c="39661">短时间内将 Siri 引入我们的应用!</st>
<st c="39669">下一章将从不同的角度审视我们的应用——</st> <st c="39735">质量。</st>
第十五章:14
使用 Swift Testing 提升应用质量
为什么测试是编程书籍的一部分呢? 测试不是 质量保证 (QA)** 团队职责的一部分吗?
你很快会发现,测试是我们作为 iOS 开发者开发周期和文化的一部分。 许多开发者认为测试是一项重要的任务,但他们没有时间去做。 不幸的是,他们最终会因 bug 和 长久的重构而付出代价。
在本章中,我们将做以下事情:
-
理解测试的重要性
-
学习 Xcode 的测试 历史
-
探索 Swift Testing 框架的基本知识
-
了解如何使用套件、测试计划和 方案来管理测试
-
学习可以帮助我们维护 测试的技巧
在本章结束时,你将准备好利用 Swift Testing 来提升你的测试技能。
在我们回答 如何 问题之前,让我们先从 为什么 开始。
技术要求
你必须从 Apple 的 App Store下载 16.0 或更高版本的 Xcode,用于本章。
你还需要运行最新版本的 macOS(Ventura 或更高版本)。 在 App Store 中搜索 <st c="1030">Xcode</st> ,选择并下载最新版本。 启动 Xcode,并遵循系统可能提示的任何附加安装说明。 一旦 Xcode 完全启动,你就可以开始了。
本章包含许多代码示例,其中一些可以在以下 GitHub 仓库中找到:https://github.com/PacktPublishing/Mastering-iOS-18-Development/tree/main/Chapter14
理解测试的重要性
对于许多开发者来说,测试 是他们编写代码时必须处理的一个不必要的开销。
这种思维方式在某种程度上是可以理解的。 我们已经完成了代码的编写,构建了一个应用程序,并看到一切按预期运行。 我们不需要移动到下一个任务,我们需要更改目标,添加一个测试函数,只是为了再次确认它运行良好。 为什么要在上面浪费时间 呢?
此外,在许多情况下,编写这些测试函数需要大量的工作。 我们如何测试 SwiftUI 视图或网络调用呢? 这甚至意味着什么?
这些总结说明了为什么测试不是一种常见的做法,或者至少 不够。
这个问题的根源在于开发者如何处理测试和编写代码。 测试不仅仅是检查我们的函数是否按预期运行;它还涉及到代码结构、关注点的分离、编写过程、工作文化和我们对待日常工作的方式。 我们的日常工作的处理方式。
让我们看看 以下函数:
func canUserAddTask(to list: List, user: User) -> Bool {
if list.isLocked {
return false
}
if !list.allowedRoles.contains(user.role) {
return false
}
return [.privateList,
.publicList].contains(list.sharingAttribute)
}
这个函数检查用户是否可以根据标准,如权限、列表类型和状态,将任务添加到特定的列表中。 现在,假设我们需要确保这个函数能够正常工作。 我们如何做到这一点呢? 我们需要在不同的状态下运行我们的应用程序来查看 结果吗?
我们都知道,确保我们的代码能够正确运行是我们开发过程的一部分。 这是一个经典的例子,说明了编写测试用例和在应用程序的不同状态下运行可以简化我们的开发过程。 当我们添加未来的任务时,我们理解测试为什么如此重要,比如 重构和 错误修复。
在我们深入研究 Swift 测试之前,让我们了解 Apple 平台上的测试历史。
学习 Apple 平台上的测试历史
随着 Apple 开发工具 多年的发展,测试工具 也得到了 发展。
第一个为 Apple 平台定制的测试框架 是 SenTestingKit,基于 OCUnit 开源框架。
SenTestingKit 于 2005 年推出,并集成到 Xcode 中,为编写和运行 Objective-C 代码提供了基本功能。
2013 年,Apple 引入了 XCTest,它采用了更现代的测试方法,具有更好的 Xcode 集成以及对 Objective-C 和 Swift的支持。
让我们看看 理解测试的重要性 部分中的代码示例,并看看一个 XCTest 测试的例子:
class CanUserAddTaskTests: XCTestCase {
func testCanAddTaskWhenListIsLocked() {
let list = List(id: "1", isLocked: true,
sharingAttribute: .privateList, allowedRoles:
[.admin, .member])
let user = User(role: .admin)
XCTAssertFalse(canUserAddTask(to: list, user:
user), "User should not be able to add a task
when the list is locked")
}
}
在这个用户示例中,我们看到一个简单的 测试函数,该函数测试用户是否可以将任务添加到受保护的列表中。
有几件事情 值得注意:
-
测试函数是
<st c="4527">CanUserAddTaskTests</st>类的一部分,继承自<st c="4573">XCTestCase</st>超类。 -
测试函数名称以
<st c="4635">test</st>短语开头。 该<st c="4652">test</st>短语表示 XCTest 框架,这是一个 测试函数。 -
测试验证表达式是通过一个特定的函数(
<st c="4788">XCTAssertFalse</st>)完成的,该函数检查特定的表达式是否<st c="4853">为假</st>。我们有一系列用于 各种条件的函数。
虽然这些都是我们在 Xcode 中编写测试的一部分,但它们与现代 Swift/SwiftUI 方法不匹配——使用结构体、宏以及更简单和通用的 Swift 函数。 这就是 Swift 测试出现的原因。
让我们一起来探索 Swift 测试。
探索 Swift 测试基础
我们将从将 Swift 测试框架添加到现有项目 开始我们的旅程。
从 Xcode 的菜单中选择 文件 | 新建 | 目标 来完成此操作。 然后,在模板选择器中,找到 单元测试包 并选择它(图 14**.1):
https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_14_1.jpg
图 14.1:新的目标模板选择器
图 14**.1 显示了 Xcode 中的模板选择器窗口。 在执行测试搜索时,单元测试包 很容易找到。 请注意,我们还有一个 UI 测试包 模板。 然而,在 Swift 测试中目前还不支持 UI 测试,所以现在我们将专注于 单元测试。
我们如何进行 UI 测试?
UI 测试,也称为端到端测试,是应用测试中的一个不同 主题。 它也被我们称为“黑盒”测试,意味着测试函数不知道内部代码,只知道用户界面组件。 进行 UI 测试的基本方法是使用 XCTest,苹果之前使用的测试框架。 然而,有一些服务提供更简单或跨平台的远程运行 UI 测试的方法。
一旦你选择了 单元测试包 模板,点击 下一步。现在,我们需要填写一些关于我们新测试目标(图 14**.2)的详细信息:
https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_14_2.jpg
图 14.2:为我们的新测试目标选择选项
在 图 14**.2中,我们可以填写目标的名字、团队和包标识符。 我们还可以在旧的 XCTest 和新 Swift Testing 框架之间进行选择。 在这种情况下,我们将选择 Swift Testing。
点击 保存,恭喜你——你有一个新的 测试目标 了!
让我们编写我们的 第一个测试!
添加基本测试
我们的模板 包含一个基本的、空的 测试函数:
import Testing
struct Chapter14Tests {
@Test func testExample() async throws {
// Write your test here and use APIs like
`#expect(...)` to check expected conditions. }
}
尽管代码 非常简洁,但我们可以看到与 XCTest 相比的一些变化:
-
<st c="7545">Testing</st>,并且我们应该将其导入到我们想要测试的每个文件中。 要测试的每个文件中。 -
<st c="7698">XCTestCase</st>,我们在 Swift Testing 中使用结构体。 结构体 不仅更轻量级且易于使用,而且在尝试并行运行测试时也更有帮助。 记住,结构体是值类型,这意味着每次我们传递一个结构体时,我们都会得到数据的一个副本。 这有助于在测试时检查状态。 -
<st c="8031">@Test</st>``<st c="8081">@Test</st>宏,它帮助 SwiftData 框架管理 其测试。 -
<st c="8159">#expect</st>``<st c="8197">XCTAssert</st>函数,我们使用<st c="8229">#expect</st>宏,这对于我们想要测试的任何表达式都很有帮助。 要测试的表达式。
我们可以通过点击测试函数旁边的菱形按钮或按 ⌘**U来快速运行我们的测试。如果一切如预期进行,我们的测试应该通过。
现在,让我们用一些实际的测试来填充我们的代码。 在我们的例子中,我们有一个处理计数器的视图模型。 我们有 <st c="8570">increment</st> 和 <st c="8584">decrement</st> 函数以及一个 <st c="8610">count</st> 变量:
class CounterViewModel: ObservableObject {
@Published var count: Int = 0
func increment(by value: Int) { }
func decrement(by value: Int) {}
func reset() {}
}
让我们使用 Swift Testing 测试CounterViewModel的功能。
我们需要做的第一件事是向 Swift Testing 提供对我们的应用目标的访问权限:
@testable import Chapter14
我们将@testable属性添加到import命令中,以启用对内部实体的访问。
现在,让我们编写我们的第一个测试函数:
@Test func testViewModelIncrement() async throws {
// preparation
let viewmodel = CounterViewModel()
viewmodel.count = 5
// execution
viewmodel.increment(by: 1)
// verification
#expect(viewmodel.count == 6)
}
在我们的测试函数中,我们初始化视图模型,调用其增加函数,并验证结果。如果#expect宏函数内的表达式为false,则测试失败。
这三个阶段——准备、执行和验证——是任何测试流程的一部分,无论我们使用 Swift Testing 还是任何其他测试框架。
现在,让我们将包含此测试的结构体(CounterViewModelTests)重命名,并运行我们的测试。
在 Xcode 中,我们可以通过标签页打开左侧面板(或者直接按⌘6),然后我们可以看到我们的测试列表(图 14.3):
https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_14_3.jpg
图 14.3:Xcode 中列出的测试
在图 14.3中,我们可以看到测试面板上我们的测试结构,这反映在我们创建结构体和测试函数的方式上。
在本章开头,我们通过检查一个简单的代码示例来讨论了 Swift Testing 和 Xcode 之间的区别。其中之一的变化是使用@Test宏。
除了指示一个测试函数,@Test 宏还有额外的功能帮助我们配置测试。
例如,让我们使用@Test宏为我们的测试函数提供一个名称。
为我们的测试函数提供名称
为测试函数提供有表达力和意义的名称至关重要,并且当我们在项目中拥有数百个测试时,这可能会很有价值。
在 XCTest 中,为了做到这一点,我们需要将测试函数重命名为类似以下的内容:
func testViewModelIncremenetFunction_incrementBy1_accept5_expect6
函数名称正确地描述了测试,但感觉笨拙且不自然,尤其是当我们有成百上千的 测试函数时。
使用 <st c="11542">@Test</st> Swift 宏,我们可以为每个测试提供一个可读的名称:
@Test("Test the increment function. Accepts 5 and expect 6\. ") func testViewModelIncrement()
将测试描述添加到 <st c="11735">@Test</st> Swift 宏中使其更易于阅读,并且它还很好地与 Xcode 集成(图 14**.4):
https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_14_4.jpg
图 14.4:Xcode 中的测试面板,带有自定义名称
图 14**.4 显示了之前相同的测试函数,现在有一个可读的并且 有意义的名称。
The <st c="12136">@Test</st> Swift 宏不仅提供了为我们的函数命名。 我们还可以用它来禁用和启用测试。 让我们看看如何 做到这一点。
启用和禁用测试
有时,一个测试可能变得 无关紧要,我们希望暂时将其从测试列表中删除。 我们可以删除它或对其注释。 然而,这些解决方案可能需要更舒适和实用的长期解决方案。 因此,让我们使用 <st c="12547">@Test</st> 宏来使其更加优雅。
在 Swift 测试中,所有测试默认启用。 要禁用特定测试,我们可以使用 <st c="12681">disabled()</st> 函数:
@Test("Test the incremenet function. Accepts 5 and expect 6\. ", .disabled()) func testViewModelIncrement()
我们可以看到 <st c="12829">disabled()</st> 函数现在是 <st c="12867">@Test</st> 参数之一。 在这种情况下,测试函数将不会运行,我们还可以看到该函数现在在测试面板中已禁用(图 14**.5):
https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_14_5.jpg
图 14.5:测试面板中的禁用测试
图 14**.5 显示了我们的测试函数变为灰色。 在这种情况下,执行整个测试运行将跳过 该测试。
然而,有些情况下我们需要我们的测试函数仅在特定条件下运行,例如当用户登录或处于特定的 A/B 测试条件下。
在这种情况下,我们将在测试函数中实现条件作为保护语句,从而使测试函数成功。 但这听起来不是一个好办法——当测试函数没有运行时,它成功。
幸运的是,基于特定标准的测试函数启用是 Swift 测试支持的功能。 我们只需要在 <st c="13879">@Test</st> 宏头中添加 enabled 函数,包括一个 布尔表达式:
@Test("Test the decrement function.", .<st c="14110">testTheDecrementFunction</st>. We added a condition to the test function that would run only if we enabled the ability to decrement in the app settings. In this case, the <st c="14276">AppSettings.CanDecrement</st> expression returns <st c="14320">false</st>. Therefore, Swift Testing skips the test function at runtime.
<st c="14388">When using the enabled function, precisely defining the test goal is essential.</st> <st c="14469">For example, when using</st> `<st c="14493">AppSettings</st>`<st c="14504">, we may want to test the results of the decrement function when the feature is turned off.</st> <st c="14596">We need to disable tests according to a Boolean expression only when it’s clear that the function is irrelevant under</st> <st c="14714">specific conditions.</st>
<st c="14734">If we try to run a test when the</st> `<st c="14768">enabled()</st>` <st c="14777">function returns</st> `<st c="14795">false</st>`<st c="14800">, we’ll see something like</st> *<st c="14827">Figure 14</st>**<st c="14836">.6</st>*<st c="14838">:</st>
<https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_14_6.jpg>
<st c="15020">Figure 14.6: A skipped test function due to a specific false condition</st>
<st c="15090">In</st> *<st c="15094">Figure 14</st>**<st c="15103">.6</st>*<st c="15105">, we can see that the test function is not grayed out, as in the case of using the</st> `<st c="15188">disabled()</st>` <st c="15198">function.</st> <st c="15209">However, it wasn’t running, and we can also see the skipped icon on</st> <st c="15277">the right.</st>
<st c="15287">We have seen how to provide readable names</st> <st c="15330">to test functions and how to disable or enable tests.</st> <st c="15385">Now, let’s discuss another excellent Swift Testing feature –</st> *<st c="15446">tags</st>*<st c="15450">.</st>
<st c="15451">Tagging our test functions</st>
<st c="15478">Generally, we group tests</st> <st c="15504">according to our project</st> <st c="15529">structure.</st> <st c="15541">For example, we could create a structure of test functions for a specific class or a structure.</st> <st c="15637">Another example would be to create a test structure for a particular feature or service.</st> <st c="15726">However, there are additional ways we can organize our test functions.</st> <st c="15797">We could arrange them according to priority – critical or sanity tests – or according to their system levels, such as UI or business</st> <st c="15930">logic layers.</st>
<st c="15943">Instead of finding workarounds for that organization problem, Swift Testing</st> <st c="16019">provides an organization feature</st> <st c="16053">called</st> **<st c="16060">tags</st>**<st c="16064">.</st>
<st c="16065">We’ll start by defining a new tag in the</st> <st c="16107">test bundle:</st>
extension Tag {
@Tag static let critical: Self
}
<st c="16168">We extended the</st> `<st c="16185">Tag</st>` <st c="16188">structure in this code and added a new static variable,</st> <st c="16245">named</st> `<st c="16251">critical</st>`<st c="16259">.</st>
<st c="16260">We can define and use as many</st> <st c="16290">tags as we want across our bundle.</st> <st c="16326">Therefore, it is a best practice</st> <st c="16358">to manage all our tags in one place and a</st> <st c="16401">separate file.</st>
<st c="16415">Now that we have a new tag, let’s add it to one of</st> <st c="16467">our tests:</st>
@Test(“测试重置函数”, .tags(.critical)) func testResetFunction() {
<st c="16555">In this code example, another</st> `<st c="16586">@Test</st>` <st c="16591">macro function, called</st> `<st c="16615">tags()</st>`<st c="16621">, provides the new</st> `<st c="16640">critical</st>` <st c="16648">static variable we created in the previous</st> <st c="16692">code example.</st>
<st c="16705">Note that we can provide multiple tags to the same</st> <st c="16757">test function:</st>
.tags(.critical, .calculations, .performance))
<st c="16817">In this example, we marked a specific function with three</st> <st c="16876">different tags.</st>
<st c="16891">The ability to mark a function with multiple tags can be powerful, as it provides flexibility with our</st> <st c="16995">tests’ organization.</st>
<st c="17015">One thing is missing here – even though tagging functions look lovely, we haven’t discussed how to actually use</st> <st c="17128">our tagging.</st>
<st c="17140">Let’s look at</st> *<st c="17155">Figure 14</st>**<st c="17164">.7</st>*<st c="17166">:</st>
<https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_14_7.jpg>
<st c="17384">Figure 14.7: The Tags section of the test pane in Xcode</st>
*<st c="17439">Figure 14</st>**<st c="17449">.7</st>* <st c="17451">shows a new section called</st> `<st c="17542">critical</st>` *<st c="17550">tag</st>* <st c="17554">we defined for our</st> `<st c="17574">reset()</st>` <st c="17581">function in the last code example.</st> <st c="17617">Xcode scans our tags’ usage and organizes them for us.</st> <st c="17672">This is how deep the Swift Testing integration with</st> <st c="17724">Xcode is.</st>
<st c="17733">Now that we have all our tags</st> <st c="17763">listed, we can run all our critical</st> <st c="17799">tests (</st>*<st c="17807">Figure 14</st>**<st c="17817">.8</st>*<st c="17819">):</st>
<https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_14_8.jpg>
<st c="17828">Figure 14.8: Running all critical tests</st>
<st c="17867">In</st> *<st c="17871">Figure 14</st>**<st c="17880">.8</st>*<st c="17882">, the run button is on the right.</st> <st c="17916">Tapping it will run all our critical</st> <st c="17953">marked tests.</st>
<st c="17966">Now, for the practical usage of tags in testing, working with tags is similar to how tagging works in other places.</st> <st c="18083">When we group tests in files, we usually do that by</st> *<st c="18135">concern</st>* <st c="18142">– a layer, service, module, and so on.</st> <st c="18182">Conversely, tagging helps us group tests by their</st> *<st c="18232">types</st>* <st c="18237">(sanity, smoke or regression, integration, or unit) or a</st> *<st c="18295">property</st>* <st c="18303">(a priority,</st> <st c="18317">for example).</st>
<st c="18330">What are smoke tests?</st>
<st c="18352">We write</st> **<st c="18362">smoke tests</st>** <st c="18373">to check a system’s operations</st> <st c="18404">by testing basic functionality.</st> <st c="18437">While they may sound like a sanity test, they are much lighter and faster than that.</st> <st c="18522">For example, we can try to perform a login and a basic data sync, and the results can indicate</st> <st c="18616">whether we have a severe problem with our app or</st> <st c="18666">the backend.</st>
<st c="18678">Working methodologically with the tagging system can enhance our testing and open</st> <st c="18761">new possibilities.</st>
<st c="18779">Our</st> `<st c="18784">@Test</st>` <st c="18789">macro</st> <st c="18795">features</st> <st c="18804">list doesn’t end with tagging.</st> <st c="18836">Let’s examine a Swift Testing feature that can save us a lot of time –</st> *<st c="18907">arguments</st>*<st c="18916">.</st>
<st c="18917">Working with arguments</st>
<st c="18940">Imagine the following</st> <st c="18962">scenario.</st> <st c="18973">We wrote a Swift function</st> <st c="18998">that performs a very clever calculation – for example, a function that converts meters</st> <st c="19086">to yards:</st>
struct UnitConverter {
static func metersToYards(_ meters: Double) -> Double {
return meters * 1.09361
}
}
<st c="19202">Our function takes a</st> `<st c="19224">meters</st>` <st c="19230">parameter and returns its value in yards.</st> <st c="19273">It looks like a straightforward function, but we must perform several tests to see whether it works</st> <st c="19373">as expected.</st>
<st c="19385">So, let’s write tests for</st> <st c="19412">this function:</st>
struct UnitConverterTests {
@Test func testConvertingMetersToYards_1meter() {
#expect(UnitConverter.metersToYards(1.0) ==
1.09361)
}
@Test func testConvertingMetersToYards_3_5meter() {
#expect(UnitConverter.metersToYards(3.5) ==
3.827635)
}
}
<st c="19669">In this code example, we wrote</st> <st c="19700">two test functions that perform the same test but with different parameters.</st> <st c="19778">They even have very similar names.</st> <st c="19813">Even though this solution works fine, it doesn’t scale up very nicely.</st> <st c="19884">What if we want to test 10 different</st> <st c="19920">variants or parameters?</st> <st c="19945">And what if we need to change the function name we</st> <st c="19996">are testing?</st>
<st c="20008">One option is to perform one test function that contains all of the</st> <st c="20077">different options:</st>
@Test func testConvertingMetersToYards () {
#expect(UnitConverter.metersToYards(1.0) == 1.09361)
#expect(UnitConverter.metersToYards(3.5) == 3.827635)
}
<st c="20249">We created one test function with two</st> `<st c="20288">#expect</st>` <st c="20295">statements in this code example.</st> <st c="20329">That will probably work; however, managing and monitoring them is more challenging now that we have both statements in</st> <st c="20448">one function.</st>
<st c="20461">To solve that, Swift Testing has a feature named</st> **<st c="20511">arguments</st>**<st c="20520">, which allows us to run our tests with different</st> <st c="20570">values repeatedly.</st>
<st c="20588">Let’s see that</st> <st c="20604">in action:</st>
@Test(参数:arguments: [(1.0, 1.09361), (3.5, 3.827635)])
func testConvertingMetersToYards(data: <st c="20708">(Double,</st>
Double)) {
#expect(UnitConverter.metersToYards<st c="20764">(data.0) ==</st>
data.1) }
<st c="20786">This code example may look a little cumbersome, but it is straightforward.</st> <st c="20861">We performed three</st> <st c="20880">changes here:</st>
* <st c="20893">We added the</st> `<st c="20907">arguments</st>` *<st c="20916">parameter</st>* <st c="20926">to the</st> `<st c="20934">@Test</st>` <st c="20939">macro, which contains an array of tuples.</st> <st c="20982">Each tuple represents a few meters and its corresponding number of yards.</st> <st c="21056">For example, the</st> `<st c="21073">(1.0, 1.09361)</st>` <st c="21087">tuple represents a conversion between 1 meter and 1.09361 yards.</st> <st c="21153">This array is the list of test variants we are going</st> <st c="21206">to do.</st>
* <st c="21212">We added a</st> *<st c="21224">new tuple parameter called</st>* `<st c="21251">data</st>` *<st c="21255">to our test function</st>*<st c="21276">. With each test run, Swift Testing passes a tuple from the arguments list to the function using this parameter.</st> <st c="21389">The parameter type must be aligned with the</st> <st c="21433">argument type.</st>
* <st c="21447">In the</st> `<st c="21455">#expect</st>` <st c="21462">macro, we now</st> *<st c="21477">compare the two tuple values</st>* <st c="21505">instead of fixed sizes, like in the</st> <st c="21542">previous examples.</st>
<st c="21560">The term</st> *<st c="21570">arguments</st>* <st c="21579">can be misleading.</st> <st c="21599">In the context</st> <st c="21613">of testing, it means</st> <st c="21634">that arguments allow us to run our code in different use cases</st> <st c="21698">and states.</st>
<st c="21709">And if passing all the different use cases within the</st> `<st c="21764">@Test</st>` <st c="21769">macro is cumbersome, we can store them in a</st> <st c="21814">separate variable:</st>
let convertingTests: [(Double, Double)] = [(1.0, 1.09361),
(3.5, 3.827635)]
struct UnitConverterTests {
@Test(arguments: convertingTests)
func testConvertingMetersToYards(data: (Double,
Double)) {
#expect(UnitConverter.metersToYards(data.0) ==
data.1)
}
}
<st c="22088">In this code example, we moved our use cases into a dedicated constant for</st> <st c="22164">better readability.</st>
<st c="22183">If we look at the Xcode testing pane again, we can now see a list of our use cases and their states (</st>*<st c="22285">Figure 14</st>**<st c="22295">.9</st>*<st c="22297">):</st>
<https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_14_9.jpg>
<st c="22519">Figure 14.9: Argument testing in the Xcode testing pane</st>
*<st c="22574">Figure 14</st>**<st c="22584">.9</st>* <st c="22586">shows why argument testing in Swift Testing is so powerful.</st> <st c="22647">Instead of having several test functions in the list, we can see one with several</st> <st c="22729">use cases.</st>
<st c="22739">Argument testing adds another</st> <st c="22769">layer to our testing, something</st> <st c="22801">we don’t have</st> <st c="22816">in XCTest.</st>
<st c="22826">Why doesn’t XCTest support parametrized testing?</st>
<st c="22875">Using attributes to perform parametrized</st> <st c="22916">testing is not new in the testing world.</st> <st c="22958">Most testing frameworks support adding arguments to their test functions out of the box.</st> <st c="23047">However, even though it is possible to perform parametrized tests in XCTest, it requires creating several test functions that call a central function that performs the actual test.</st> <st c="23228">This is an ad hoc and unnatural solution.</st> <st c="23270">The reason is that Apple wanted to create a simple testing framework, and locating the test function</st> <st c="23370">in XCTest works according to a simple function signature (functions that start with the phrase</st> `<st c="23466">test</st>`<st c="23470">).</st> <st c="23474">Adding arguments made the locating</st> <st c="23509">process complex.</st>
<st c="23525">Now that we have reviewed the Swift Testing basics, let’s see how to manage</st> <st c="23602">our tests.</st>
<st c="23612">Managing our tests</st>
<st c="23631">Anyone who has previously worked</st> <st c="23664">with tests knows that writing tests is one thing and managing them in the long run</st> <st c="23748">is another.</st>
<st c="23759">If you don’t have testing experience, you might think that simply running all your tests one after the other is sufficient.</st> <st c="23884">But down the road, things become much more complex – different configurations, environments, and even test goals – all translating to a need for a more robust testing</st> <st c="24051">management system.</st>
<st c="24069">Before managing our testing system, let’s review our Xcode</st> <st c="24129">testing structure.</st>
<st c="24147">Going over the testing structure</st>
<st c="24180">So far, we have discussed</st> <st c="24206">how to write testing functions, but besides grouping them in structures, we haven’t discussed anything related to</st> <st c="24321">managing them.</st>
<st c="24335">A whole set of tools can help us manage our test efficiency in Xcode.</st> <st c="24406">Let’s review the different blocks that can help us adapt a flexible system to</st> <st c="24484">our needs:</st>
* **<st c="24494">A testing suite</st>**<st c="24510">: A testing suite can group</st> <st c="24538">several testing functions and</st> <st c="24569">child suites.</st>
* **<st c="24582">A test plan</st>**<st c="24594">: A test plan groups different test</st> <st c="24631">functions and test suites.</st> <st c="24658">It can include or exclude test functions marked with tags.</st> <st c="24717">But it doesn’t stop there – test plans can run multiple times in different configurations with different data and environments.</st> <st c="24845">This is a powerful tool that can help scale up our</st> <st c="24896">testing strategy.</st>
* **<st c="24913">A Scheme</st>**<st c="24922">: Inside each</st> *<st c="24937">Scheme</st>*<st c="24943">, we have several build options.</st> <st c="24976">One is</st> **<st c="24983">Test</st>**<st c="24987">, where we must describe what will happen when testing that specific</st> *<st c="25056">Scheme</st>*<st c="25062">. In the</st> **<st c="25071">Test Build</st>** <st c="25081">option, we can define</st> <st c="25104">precisely what test plans we will run</st> <st c="25141">and on</st> <st c="25149">which target.</st>
<st c="25162">When we look at the different testing building blocks, we can see that the testing structure is complex and requires</st> <st c="25280">some thinking.</st>
<st c="25294">Let’s try to explain the hierarchy by examining</st> *<st c="25343">Figure 14</st>**<st c="25352">.10</st>*<st c="25355">:</st>
<https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_14_10.jpg>
<st c="25501">Figure 14.10: Relations between the different building blocks of testing</st>
*<st c="25573">Figure 14</st>**<st c="25583">.10</st>* <st c="25586">shows the relations between the different building blocks of testing.</st> <st c="25657">Next, we will learn how to build them together, starting with</st> <st c="25719">test suites.</st>
<st c="25731">Grouping our test functions into test suites</st>
<st c="25776">The first building block</st> <st c="25801">we are going to discuss is the</st> **<st c="25833">test suite</st>**<st c="25843">. In fact, we have already built a test suite in</st> <st c="25892">this chapter:</st>
struct UnitConverterTests {
@Test func testConvertingMetersToYards_1meter() {
#expect(UnitConverter.metersToYards(1.0) ==
1.09361)
}
}
<st c="26040">Do you remember this code example?</st> <st c="26076">We wrote it in the</st> *<st c="26095">Working with arguments</st>* <st c="26117">section and created a similar test suite for earlier examples.</st> <st c="26181">So, yes, the struct containing our test functions is considered to be a test suite, and Swift Testing recognizes and displays this in the</st> <st c="26319">test pane.</st>
<st c="26329">However, we can annotate a test suite with the</st> `<st c="26377">@Suite</st>` <st c="26383">attribute for better customization.</st> <st c="26420">Let’s add it to our latest</st> <st c="26447">test suite:</st>
@Suite(“单位转换器测试”) struct UnitConverterTests {
@Test func testConvertingMetersToYards_1meter() {
#expect(UnitConverter.metersToYards(1.0) ==
1.09361}
}
<st c="26622">In this code example, we added</st> <st c="26653">the</st> `<st c="26658">@Suite</st>` <st c="26664">swift macro to our</st> `<st c="26684">UnitConverterTests</st>` <st c="26702">structure and, by doing so, gave it a more</st> <st c="26746">readable name.</st>
<st c="26760">Let’s see what our test suite looks like in the test pane in Xcode (</st>*<st c="26829">Figure 14</st>**<st c="26839">.11</st>*<st c="26842">):</st>
<https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_14_11.jpg>
<st c="26971">Figure 14.11: The suite in the Xcode test pane</st>
<st c="27017">In</st> *<st c="27021">Figure 14</st>**<st c="27030">.11</st>*<st c="27033">, we can see our new test suite displayed in the</st> <st c="27082">test pane.</st>
<st c="27092">If using the</st> `<st c="27106">@Suite</st>` <st c="27112">macro sounds like how we used the</st> `<st c="27147">@Test</st>` <st c="27152">macro, you are not mistaken; it’s the same idea – providing more information by using</st> <st c="27239">a macro.</st>
<st c="27247">And, just like the</st> `<st c="27267">@Test</st>` <st c="27272">macro, we can also mark test suites</st> <st c="27309">with tags:</st>
@Suite(“单位转换器测试”, .tags(.critical))
struct UnitConverterTests {
}
<st c="27400">In this code example, we marked our new test suite with the critical tag we declared in the</st> *<st c="27493">Tagging our test</st>* *<st c="27510">functions</st>* <st c="27519">section.</st>
<st c="27528">In addition, we can also disable the whole test suite using the same</st> `<st c="27598">disabled()</st>` <st c="27608">function we used in the</st> *<st c="27633">Enabling and disabling</st>* *<st c="27656">tests</st>* <st c="27661">section:</st>
@Suite(“单位转换器测试”, .disabled())
struct UnitConverterTests {
}
<st c="27746">In this code example, we disabled</st> <st c="27780">the</st> `<st c="27785">Unit converter tests</st>` <st c="27805">test suite, and Swift Test will not execute any of its tests in the next</st> <st c="27879">test run.</st>
<st c="27888">Another neat usage for a test suite is its ability to contain nested</st> <st c="27958">test suites:</st>
@Suite(“单位转换器测试”) struct UnitConverterTests { @Suite(“从米到码”) struct FromMetersToYardsTests {
// 我们的测试函数
}
}
<st c="28118">In this code example, we have a test suite named</st> `<st c="28168">From meters to yards</st>`<st c="28188">, which is part of a bigger test suite named</st> `<st c="28233">Unit</st>` `<st c="28238">converter tests</st>`<st c="28253">.</st>
<st c="28254">Let’s see how this is reflected in the Xcode pane (</st>*<st c="28306">Figure 14</st>**<st c="28316">.12</st>*<st c="28319">):</st>
<https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_14_12.jpg>
<st c="28553">Figure 14.12: Nested test suites in the test pane</st>
*<st c="28602">Figure 14</st>**<st c="28612">.12</st>* <st c="28615">shows how our new nested test suite is reflected in the test pane.</st> <st c="28683">We can also customize the nested suites, such as adding tags and</st> <st c="28748">disabling them.</st>
<st c="28763">Now that we know how to define a test suite and tags, it is recommended that we remember each feature’s role.</st> <st c="28874">We use test suites to group different test methods by concern – typically, by writing test functions for a specific class or</st> <st c="28999">a structure.</st>
<st c="29011">Conversely, we use tags to mark our tests</st> <st c="29053">by their type –</st> `<st c="29070">critical</st>`<st c="29078">,</st> `<st c="29080">performance</st>`<st c="29091">,</st> `<st c="29093">integration</st>`<st c="29104">, and so on.</st> <st c="29117">If these are the different roles for tags and test suites, what do we do when we want to manage something such as a sanity or a</st> <st c="29245">regression test?</st>
<st c="29261">That’s what</st> *<st c="29274">test plans</st>* <st c="29284">are for.</st>
<st c="29293">Building test plans</st>
<st c="29313">To better understand</st> <st c="29334">the different testing components, we can think of an app with two layers – the business logic and the UI.</st> <st c="29441">The business logic layer is important, but it doesn’t describe how a user will use our app – the different use cases</st> <st c="29558">and flows.</st>
<st c="29568">We must build the UI layer to complete our app, which handles user stories and flows.</st> <st c="29655">The business logic is analogous to the different testing suites and functions.</st> <st c="29734">These are the building blocks of our testing.</st> <st c="29780">However, testing is always in the context of a specific</st> <st c="29836">development process.</st>
<st c="29856">Let’s try to come up with different</st> <st c="29892">development processes:</st>
* **<st c="29915">Feature development</st>**<st c="29935">: We build new features, often adding new classes, structures,</st> <st c="29999">and entities</st>
* **<st c="30011">Fixing bugs</st>**<st c="30023">: We modify</st> <st c="30036">existing code</st>
* **<st c="30049">Refactoring code</st>**<st c="30066">: We modify existing code for better scalability, maintenance,</st> <st c="30130">or performance</st>
* **<st c="30144">Deployment</st>**<st c="30155">: We prepare an app for deployment</st> <st c="30191">for QA</st> <st c="30198">or production</st>
<st c="30211">This is only a partial list of different development processes, but it demonstrates that we are always in the context of a process when</st> <st c="30348">we develop.</st>
<st c="30359">When we build our testing system, we can describe this process using a test plan.</st> <st c="30442">Let’s add a new test plan to see how</st> <st c="30479">it works.</st>
<st c="30488">Adding a new test plan</st>
**<st c="30511">Test plans</st>** <st c="30522">are a new feature in Xcode, added</st> <st c="30556">to Xcode 11 in 2019\.</st> <st c="30578">They allow us to pick tests or test suites and run them in a specific configuration and environment.</st> <st c="30679">Test plans are our way of expressing how our test functions will</st> <st c="30744">be executed.</st>
<st c="30756">We always run our tests as part of a test plan.</st> <st c="30805">By default, Xcode creates a test plan for us automatically (</st>*<st c="30865">Figure 14</st>**<st c="30875">.13</st>*<st c="30878">):</st>
<https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_14_13.jpg>
<st c="31151">Figure 14.13: The autocreated test plan</st>
<st c="31190">In</st> *<st c="31194">Figure 14</st>**<st c="31203">.13</st>*<st c="31206">, we can see that Xcode</st> <st c="31229">created a test plan for us called</st> *<st c="31264">Chapter 14</st>*<st c="31274">. To create a new test plan, we can tap the test plan pop-up menu and select</st> **<st c="31351">New Test Plan</st>**<st c="31364">. After we provide a name for our new test plan, we can see it in our Xcode main pane (</st>*<st c="31451">Figure 14</st>**<st c="31461">.14</st>*<st c="31464">):</st>
<https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_14_14.jpg>
<st c="32011">Figure 14.14: The new test plan in Xcode</st>
*<st c="32051">Figure 14</st>**<st c="32061">.14</st>* <st c="32064">shows a new test plan called</st> **<st c="32094">Sanity</st>**<st c="32100">, which has its own</st> <st c="32120">customization screen.</st>
<st c="32141">There are many things we can do to customize</st> <st c="32186">our new</st> <st c="32195">test plan:</st>
* <st c="32205">We can define precisely which test target we want to run.</st> <st c="32264">So far, we have worked on a single test target, but it is possible to have several test targets.</st> <st c="32361">Once we choose the different test targets, we will see the list of test suites and functions and mark what tests we should include</st> <st c="32492">or exclude.</st>
* <st c="32503">We can include or exclude tests marked with specific tags.</st> <st c="32563">For example, we can choose to include only tests marked with the</st> `<st c="32628">critical</st>` <st c="32636">tag for the</st> `<st c="32741">performance</st>` <st c="32752">tag.</st> <st c="32758">This is where the tags become</st> <st c="32788">extremely helpful.</st>
* <st c="32806">If we already have many tests written in XCTest, we can include them in our test plan.</st> <st c="32894">This capability is crucial to preserve</st> <st c="32933">backward compatibility.</st>
<st c="32956">As we can see, the test plan is very flexible in deciding what tests will</st> <st c="33031">be included.</st>
<st c="33043">However, control over the list of test suites and functions is only a fraction of what we can do with test plans.</st> <st c="33158">We can do even more</st> <st c="33178">with configurations.</st>
<st c="33198">Configuring our test plan</st>
<st c="33224">When we started explaining</st> <st c="33251">test plans, we said that part of the idea of creating one is defining the environment in which the test plan runs.</st> <st c="33367">One example of such an environment is localization – language, region, and location can influence our app in certain</st> <st c="33484">use cases.</st>
<st c="33494">Trying to simulate an environment for our test functions can be challenging; therefore, test plans have a feature called</st> **<st c="33616">Configurations</st>** <st c="33630">(</st>*<st c="33632">Figure 14</st>**<st c="33641">.15</st>*<st c="33644">):</st>
<https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_14_15.jpg>
<st c="34627">Figure 14.15: The test plan’s Configurations tab</st>
*<st c="34675">Figure 14</st>**<st c="34685">.15</st>* <st c="34688">shows a tab bar at the top of the</st> **<st c="34723">Sanity</st>** <st c="34729">main pane.</st> <st c="34741">The</st> **<st c="34745">Tests</st>** <st c="34750">tab defines the included tests in the test plan, and the</st> **<st c="34808">Configurations</st>** <st c="34822">tab defines the different configurations for the</st> <st c="34872">test plan.</st>
<st c="34882">To add a new configuration, we tap the plus button at the bottom of</st> <st c="34951">the window.</st>
<st c="34962">A test plan can have many configurations.</st> <st c="35005">Each configuration contains a list of settings that can affect our test results.</st> <st c="35086">Let’s examine</st> <st c="35100">them briefly:</st>
* **<st c="35113">Arguments</st>**<st c="35123">: Each app can run with different Launch and environment variables.</st> <st c="35192">We can use them to override our A/B test configuration or define a specific API endpoint.</st> <st c="35282">Arguments are powerful tools that help us adjust our app to</st> <st c="35342">our needs.</st>
* **<st c="35352">Localization</st>**<st c="35365">: Language, region, and location are all part of the localization list of settings that we can define.</st> <st c="35469">These settings can influence available features, texts, measurement units, and</st> <st c="35548">other behavior.</st>
* **<st c="35563">UI testing</st>**<st c="35574">: If our test plan includes UI tests (not supported yet in Swift Testing), we can decide what happens during screen capturing if there is a</st> <st c="35715">test failure.</st>
* **<st c="35728">Distribution</st>**<st c="35741">: Some APIs can behave differently when running on the App Store than on TestFlight – for example, collecting beta testers’ feedback, sandbox issues, and enabling/disabling beta</st> <st c="35920">testing features.</st>
* **<st c="35937">Test Execution</st>**<st c="35952">: Here, we can define the test plan execution behavior, including the execution order, timeouts, and</st> <st c="36054">repetition settings.</st>
* **<st c="36074">Runtime API Checking, Runtime Sanitization</st>**<st c="36117">: Different runtime settings such as memory management, main thread checker,</st> <st c="36195">and sanitization.</st>
<st c="36212">That’s a long list of settings!</st> <st c="36245">I felt that when I looked at</st> *<st c="36274">Figure 14</st>**<st c="36283">.15</st>*<st c="36286">, but now we have confirmation after reviewing almost</st> <st c="36340">each one.</st>
<st c="36349">However, the idea behind configurations</st> <st c="36389">is that we don’t need to redefine all the settings each time we create a new configuration.</st> <st c="36482">If you open your Xcode and create a new test plan, you can see something called</st> **<st c="36562">Shared Settings</st>** <st c="36577">(</st>*<st c="36579">Figure 14</st>**<st c="36588">.16</st>*<st c="36591">):</st>
<https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_14_16.jpg>
<st c="36721">Figure 14.16: Shared Settings</st>
*<st c="36750">Figure 14</st>**<st c="36760">.16</st>* <st c="36763">focuses on the list of configurations with</st> **<st c="36807">Shared Settings</st>** <st c="36822">at the top.</st> <st c="36835">The</st> **<st c="36839">Shared Settings</st>** <st c="36854">configuration contains the settings for all configurations unless we explicitly change a specific setting for a</st> <st c="36967">particular configuration.</st>
<st c="36992">Consider a typical use case – we would probably want the same settings for all configurations except for one or two (e.g., a configuration for different locations or distributions).</st> <st c="37175">In this case, we will have the same settings except for the region or the</st> <st c="37249">distribution method.</st>
<st c="37269">Xcode executes all the configurations in a sequence when running a test plan.</st> <st c="37348">However, you can disable a specific configuration by right-clicking on it in the configurations list and</st> <st c="37453">selecting</st> **<st c="37463">Disable</st>**<st c="37470">.</st>
<st c="37471">So, let’s say we created a sanity test plan</st> <st c="37515">and a regression test plan.</st> <st c="37544">What do we do from here?</st> <st c="37569">How can we tell Xcode what to execute when running tests?</st> <st c="37627">This is where the</st> *<st c="37645">Scheme</st>* <st c="37651">comes</st> <st c="37658">into play.</st>
<st c="37668">Setting up a Scheme</st>
<st c="37688">This chapter is about Swift Testing, not the Xcode</st> <st c="37739">build system, but we can’t discuss testing and</st> <st c="37787">ignore</st> **<st c="37794">Schemes</st>**<st c="37801">.</st>
<st c="37802">Schemes are fundamental to managing our project’s build and execution configurations.</st> <st c="37889">A</st> *<st c="37891">Scheme</st>* <st c="37897">defines how our project is built, executed,</st> <st c="37942">and tested.</st>
<st c="37953">We can write dozens of test functions and create as many test plans as we want, but the bottom line is that when we select</st> **<st c="38077">Test</st>** <st c="38081">from the Xcode menu or run tests from our CI/CD environment, the</st> *<st c="38147">Scheme</st>* <st c="38153">defines precisely what</st> <st c="38177">will happen.</st>
<st c="38189">What is CI/CD?</st>
**<st c="38204">CI/CD</st>** <st c="38210">stands for</st> **<st c="38222">Continuous Integrations/Continuous Deployment</st>**<st c="38267">. We use these practices</st> <st c="38291">to automate our app build and deploy process.</st> <st c="38338">A crucial part of this process is testing – before we deploy a build to TestFlight or the App Store, we want to perform testing to ensure we don’t have regressions or other issues.</st> <st c="38519">When we build our CI/CD process, we often choose what Scheme</st> <st c="38580">to execute.</st>
<st c="38591">Looking at the Xcode window, we can locate the</st> *<st c="38639">Scheme</st>* <st c="38645">name next to the project name.</st> <st c="38677">Tapping it will open a list of Schemes where we can change the current</st> *<st c="38748">Scheme</st>* <st c="38754">or edit it (</st>*<st c="38767">Figure 14</st>**<st c="38777">.17</st>*<st c="38780">):</st>
<https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_14_17.jpg>
<st c="39710">Figure 14.17: Editing the current Scheme</st>
*<st c="39750">Figure 14</st>**<st c="39760">.17</st>* <st c="39763">shows how to reach</st> <st c="39782">the pop-up</st> *<st c="39794">Scheme</st>* <st c="39800">menu.</st> <st c="39807">Tapping on the</st> **<st c="39822">Edit Scheme…</st>** <st c="39834">option leads us to the</st> **<st c="39858">Edit Scheme</st>** <st c="39869">screen (</st>*<st c="39878">Figure 14</st>**<st c="39888">.18</st>*<st c="39891">):</st>
<https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_14_18.jpg>
<st c="40152">Figure 14.18: The Edit Scheme screen</st>
*<st c="40188">Figure 14</st>**<st c="40198">.18</st>* <st c="40201">shows that the</st> *<st c="40217">Scheme</st>* <st c="40223">has six different actions—</st>**<st c="40250">Build</st>**<st c="40256">,</st> **<st c="40258">Run</st>**<st c="40261">,</st> **<st c="40263">Test</st>**<st c="40267">,</st> **<st c="40269">Profile</st>**<st c="40276">,</st> **<st c="40278">Analyze</st>**<st c="40285">, and</st> **<st c="40291">Archive</st>**<st c="40298">. In this screenshot, we will focus on the</st> **<st c="40341">Test</st>** <st c="40345">action.</st>
<st c="40353">Besides choosing the configuration (</st>**<st c="40390">Debug</st>** <st c="40396">or</st> **<st c="40400">Release</st>** <st c="40407">in our case), we can determine what test plans to run.</st> <st c="40463">We can add an existing or new test plan using the plus button at the</st> <st c="40532">bottom left.</st>
<st c="40544">That’s where we decide what happens when executing the</st> **<st c="40600">Test</st>** <st c="40604">action on our</st> *<st c="40619">Scheme</st>*<st c="40625">. Having several</st> *<st c="40642">Schemes</st>* <st c="40649">configured differently for various purposes can be valuable when we connect our project to a</st> <st c="40743">CI/CD system.</st>
<st c="40756">For example, we can run a performance test once a month and sanity every night, just by creating two different Schemes that run different</st> <st c="40895">test plans.</st>
<st c="40906">Now that we know how to create</st> <st c="40937">test functions, suites, test plans, and Schemes, let’s flip to the other side of the equation and see how to write</st> <st c="41053">testable code.</st>
<st c="41067">Tips to write testable code</st>
<st c="41095">One of the biggest challenges</st> <st c="41126">developers face when they try to write tests for code is struggling to write tests for existing functions that could be more testable – for example, functions that contain code that performs network requests or functions that have external dependencies that are difficult to</st> <st c="41401">set up.</st>
<st c="41408">Writing testable code usually goes hand in hand with writing clean and efficient code.</st> <st c="41496">However, we should still follow some writing guidelines if we want our functions to</st> <st c="41580">be testable.</st>
<st c="41592">Let’s explore some of</st> <st c="41615">them now.</st>
<st c="41624">Writing pure functions</st>
**<st c="41647">Pure functions</st>** <st c="41662">are functions that, given the same</st> <st c="41697">input, always return</st> <st c="41718">the same output and don’t rely on external states or have any</st> <st c="41781">side effects.</st>
<st c="41794">For instance, take the</st> <st c="41818">following example:</st>
class NumberFilter {
var numbers: [Int] = []
var filteredNumbers: [Int] = [] <st c="41914">func filterNumbers(predicate: (Int) -> Bool) {</st> self.filteredNumbers =
self.numbers.filter(predicate)
}
}
<st c="42018">This code example contains a</st> `<st c="42048">NumberFilter</st>` <st c="42060">class with a function called</st> `<st c="42090">filterNumbers</st>`<st c="42103">. This class performs a predicate on an instance variable and stores the results in another</st> <st c="42195">instance variable.</st>
<st c="42213">This is a classic example of a non-pure function, since it relies on an external variable and has a side effect.</st> <st c="42327">Now, imagine we want to test this function – it requires us to set up a</st> `<st c="42399">NumberFilter</st>` <st c="42411">instance and set the</st> `<st c="42433">numbers</st>` <st c="42440">variable.</st> <st c="42451">In addition, we need to check the result using the same</st> `<st c="42507">NumberFilter</st>` <st c="42519">instance, with the</st> `<st c="42539">filtersNumbers</st>` <st c="42553">instance.</st>
<st c="42563">The class can change down the road and may require more setup than before, breaking</st> <st c="42648">our test.</st>
<st c="42657">Instead, we can make this function</st> <st c="42692">pure,</st> <st c="42699">like this:</st>
func filterNumbers(_ numbers: [Int], predicate: (Int) -> Bool) -> [Int] {
return numbers.filter(predicate)
}
<st c="42818">In the modified example, our function</st> <st c="42856">receives the input as a parameter and returns the results as part of its output.</st> <st c="42938">This change makes it agnostic to external states and easy</st> <st c="42996">to test.</st>
<st c="43004">Separating your code based on concerns</st>
<st c="43043">As always, a good separation</st> <st c="43073">is crucial for our project maintenance (which we will cover in more detail in</st> *<st c="43151">Chapter 15</st>*<st c="43161">).</st> <st c="43165">However, separation is also essential</st> <st c="43203">for testing.</st>
<st c="43215">The fundamental separation of concerns idea states that each part of our code, whether a variable, function, class, or module, should have one and only</st> <st c="43368">one responsibility.</st>
<st c="43387">Let’s take the following code as</st> <st c="43421">an example:</st>
func processAndSaveData(_ input: String) -> Bool {
// 数据处理
let processedData = // <执行一些数据操作
code>
// 数据保存
return databaseService.saveData(processedData)
}
<st c="43627">The</st> `<st c="43632">processAndSaveData</st>` <st c="43650">function is responsible for two tasks – processing the input data and saving it to the</st> <st c="43738">database service.</st>
<st c="43755">We can see that the string processing code uses the same function that performs data saving.</st> <st c="43849">If we want to test whether the string processing succeeded, we must also ensure that the output has been saved successfully.</st> <st c="43974">These two responsibilities are coupled, which makes the code very difficult</st> <st c="44050">to test.</st>
<st c="44058">To solve that, we can separate the processing code into</st> <st c="44115">another function:</st>
func processAndSaveData(_ input: String) -> Bool {
// 数据处理
let processedData = processData(input)
// 数据保存
return databaseService.saveData(processedData)
} private func processData(_ input: String) -> String {
return input.reversed() }
<st c="44385">In this example, we gave the processing data task its own function, and now it is possible to test it regardless</st> <st c="44497">of the</st> <st c="44505">data-saving part.</st>
<st c="44522">Our last tip also discusses coupling but, in another context –</st> *<st c="44586">protocols</st>*<st c="44595">.</st>
<st c="44596">Performing mocking using protocols</st>
<st c="44631">Sometimes, we don’t have a choice</st> <st c="44665">but to test functions</st> <st c="44687">that reach our network or any external service that can’t really simulate</st> <st c="44762">during tests.</st>
<st c="44775">To overcome that, we can easily create mocks for these services</st> <st c="44840">using</st> **<st c="44846">protocols</st>**<st c="44855">.</st>
<st c="44856">Look at the</st> <st c="44869">following code:</st>
class UserViewModel {
private let networkService<st c="44933">: NetworkServiceProtocol</st> var user: User? init(networkService<st c="44994">: NetworkServiceProtocol)</st> {
self.networkService = networkService
}
func fetchUserDetails(for userId: String, completion:
@escaping () -> Void) {
networkService.fetchUserDetails(for: userId) {
[weak self] user in
self?.user = user
completion()
}
}
}
<st c="45243">This code example contains a</st> `<st c="45273">UserViewModel</st>` <st c="45286">class that fetches user details from the server and stores the results in an instance variable.</st> <st c="45383">Testing the</st> `<st c="45395">fetchUserDetails</st>` <st c="45411">function requires performing a request to the server, which can make our</st> <st c="45485">test unstable.</st>
<st c="45499">To solve that, we can create a mock class that conforms to</st> `<st c="45559">NetworkServiceProtocol</st>` <st c="45581">and simulate the</st> <st c="45599">network service:</st>
class MockNetworkService: NetworkServiceProtocol {
var userToReturn: User? func fetchUserDetails(for userId: String, completion:
@escaping (User?) -> Void) {
completion(userToReturn)
}
}
<st c="45802">This example demonstrates a mock</st> <st c="45835">class that accepts</st> <st c="45854">a user’s return and can easily mock the whole network process.</st> <st c="45918">We achieved that by using a protocol and dependency injection, and we can do the same to store data, authenticate, and</st> <st c="46037">so on.</st>
<st c="46043">Summary</st>
<st c="46051">Testing is crucial to our mission to produce stable, high-quality code.</st> <st c="46124">Remember, writing tests is not just a fundamental part of being a professional iOS developer – it is also part of a culture of doing</st> <st c="46257">things right.</st>
<st c="46270">In this chapter, we’ve learned about the testing history in Xcode, covered the Swift Testing basics by writing simple tests, learned how to manage our tests using suites, test plans, and Schemes, and even discussed some useful tips to make our code more testable.</st> <st c="46535">Now, we should be able to set up a new test plan for</st> <st c="46588">our project!</st>
<st c="46600">Our following and final chapter, on architecture, touches on some of the principles we discussed here and will also help us create a</st> <st c="46734">stable project.</st>
第十六章:15
探索适用于 iOS 的架构
在前一章中,我们讨论了 Swift 测试,这是一个帮助我们测试 Swift 代码的必要框架。 应用测试不仅是一个技术话题——它也是一种文化。 这种文化的一部分是将我们的项目视为一组类和具有一定逻辑的整体结构。 这就是为什么测试和架构密不可分——它们都把我们的项目视为一个设计良好的系统。 这种整体方法对于满足我们的产品需求 至关重要。
在本章中,我们将涵盖以下主题: 理解架构的重要性
-
理解架构的重要性 。
-
学习架构究竟是什么
-
了解不同的架构,如多层、模块化、和六边形
-
通过分离、测试、和维护来比较不同的架构
首先,让我们了解为什么架构 如此重要。
技术要求
为了本章,您必须从 Apple 的 App Store 下载 Xcode 版本 16.0 或更高版本。
您还需要运行最新版本的 macOS(Ventura 或更高版本)。 在 App Store 中搜索 Xcode,选择并下载最新版本。 启动 Xcode 并遵循系统可能提示的任何附加安装说明。 一旦 Xcode 完全启动,您就准备好了 出发。
理解架构的重要性
为了理解 架构的重要性,让我们尝试理解 iOS 开发知识 是如何构建的。
许多人认为 iOS 开发是以 Swift 为中心的——如果我们知道 Swift,我们就知道 iOS 开发了。
尽管如此,iOS 开发包含许多知识层次,Swift 语言只是其中之一 。
让我们尝试将 iOS 开发 结构化到不同的层次: 。
-
IDE:熟悉 Xcode,其调试工具、配置、模拟器、构建器和代码签名 至关重要。
-
语言:无论是 Swift、Objective C 还是 C++,语言是 iOS 开发的根本部分。 它是我们每天实现应用逻辑和 设计模式的基础。
-
系统:理解 iOS 的独特特性、优势和局限性是关键。 最终,我们是在一个具有自己规则 和政策的环境中开发。
-
SDK:SDK 提供了工具集,让我们能够做我们想要做的任何事情。 SwiftUI、UIKit、Foundation、Core Animation 以及许多其他框架都是 SDK 的一部分,有了它们,我们可以创建带有用户输入组件和 持久存储的美丽界面。
-
设计模式:这些是解决我们日常遇到的常见问题和任务的解决方案。 我们。
-
架构:我们代码和项目的整体组织结构被称为 其架构。
我们可以继续学习更多知识层面——测试、数据库、网络、安全等。 随着时间推移,知识范围变得非常广泛,需要更多的能力和 知识。
尽管如此,许多 iOS 开发者在构建应用时并没有关注架构,这有一些明显的理由。 例如,开发者更喜欢看到即时的结果。 有时,这不仅仅是一个选择的问题——有截止日期要遵守,资源不足迫使我们必须尽快 发布我们的功能。
然而,忽视良好的架构规划通常是由于缺乏经验和短期关注,这为我们提供了关于架构重要性的线索。 架构。
让我们列出一些良好的架构对我们 项目 的影响:
-
可维护性:我们的项目可能会变得更大,维护起来更具挑战性。 良好的架构使我们的代码库更容易理解和阅读。 它还使修改 和重构变得更加容易。
-
可扩展性:在保持我们的项目简单和稳定的同时添加更多功能是应用成功的关键。 糟糕的架构可能需要在添加 新功能时进行重大重构。
-
灵活性:良好的架构使我们能够根据需求的变化快速更改应用的工作方式。 它还帮助我们添加新功能或替换 第三方框架。
这些都是良好建筑的一些好处,但图景很清晰——我们将主要讨论 长期影响。短期内努力创建更多类、层和协议似乎是一个大麻烦。 除了编码之外,良好的架构还需要前期努力,如良好的规划、技术设计和对范式和模式的良好理解 。
在我们讨论不同类型的建筑之前,让我们定义一下建筑的含义以及什么构成了 好的建筑。
了解建筑究竟是什么 意味着
许多开发者 在架构和我们所说的“设计模式”之间感到困惑。 我们之前在讨论不同知识层次时(在 理解架构的重要性 部分)提到了这一点,尽管这听起来像是一个语义上的差异,但理解这种区别至关重要。 虽然架构指的是我们应用的 高级组织 ,例如层、模块和组件,但设计模式是 针对常见问题的可重用解决方案 *。
为了更好地解释这一点,让我们想象一座建筑。 在规划私人住宅时,我们必须决定其楼层数、入口、屋顶和车库。简而言之,这就是 房子的建筑。
与此相反,每一层楼都有自己的目标和指定用途。例如,一层可以是厨房和客厅,而第二层则是卧室。 为了实现这一点,我们需要为每一层楼规划内部设计,决定房间的尺寸、门的布局以及不同的电线和水管。 在大多数情况下,这里没有技巧可言——需要遵循标准。 这些楼层的内部设计可以被视为设计模式——针对常见问题的可重用、特定解决方案。
现在,让我们回到我们的移动应用。我们应该将移动应用的结构想象成一个私人住宅。数据在不同的层级中流动——数据、业务逻辑和**用户界面(UI**)。我们可以将每一层看作是我们家里的不同楼层。在每一层(或楼层)中,我们可以使用各种设计模式来解决其他问题。例如,我们可以使用 Singleton 来管理共享资源或协调器来简化复杂的导航需求。
我们知道的更多设计模式,我们就有更多的解决方案。
此外,让我们继续使用房屋的隐喻。在这种情况下,我们可以得出另一个结论——我们关于架构的选择会影响我们用于楼层的设计模式,包括楼层的尺寸和形状,甚至它们是如何连接的。
那么,有哪些不同的架构类型可供选择,我们如何选择一个适合我们需求的架构呢?
概述不同的架构
开发者在选择项目架构时犯有两个常见的错误。首先,他们经常说,“我正在使用什么架构来构建我的应用? MVVM, 当然!”
MVVM 不是一个架构——它是一个旨在解决特定屏幕状态和逻辑管理的模式。它不仅不处理应用结构,甚至也不描述我们通常如何处理我们的屏幕。它只描述了特定的屏幕,例如登录或设置屏幕。
第二个错误是认为我们只能从列表中选择最常见和最受欢迎的架构之一用于我们的项目。实际上,你读到的大多数架构实际上是一套原则,可以帮助我们决定如何构建项目。
一些原则提供了灵活性和解耦,而另一些可能会增加项目开销。我们应该始终考虑权衡;这些在架构设计中变得更加重要。
让我们从最基本的设计理念开始:多层架构。
将我们的项目分层
值得花点时间讨论一下我这里使用的两个重要术语。 第一个是一个 项目* 而不是一个 *应用**。之所以这样,是因为我们的架构决策与整个项目相关——pods、Swift 包、扩展,甚至是其他应用。 当我们谈论结构时,应用只是我们产品的表达以及我们如何 部署它。
第二个术语是使用 层* 而不是 层* ——开发者常见的错误。 当我们讨论将系统分层时,我们通常指的是硬件分离——不同的计算机、服务器、路由器或其他硬件组件。 在讨论将软件如应用 或 SDK 分层时,我们应该使用术语 *层**。
将项目分层,通常分为三层,是许多项目中的常见架构决策。这个想法是,一个基本项目至少有三个不同层次的数据和逻辑处理(图 15**.1):
https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_15_1.jpg
图 15.1:三层架构
*图 15.1 显示了 我们通常将应用分为的三个层。
让我们尝试理解 这些层:
-
数据 有时也可以称为 服务。数据层处理数据的持久存储、模型实体、网络处理以及主要处理低级别数据的各种服务,而不考虑 项目的逻辑。
-
业务逻辑 有时也可以称为 领域。业务逻辑层处理应用的主要逻辑,包括规则和 数据处理。
-
表示层 处理 UI、用户交互 和导航。
有些模式甚至有更多层——例如,一个 应用* 层,它处理不同的用例,可以放在表示层之下,或者一个 基础设施* 层,它处理类扩展、工具 等。
如果你是一位经验丰富的开发者,将项目分离的想法应该是显而易见的。 分离代码创建了一个可测试和可维护的结构,随着时间的推移可以扩展。 然而,使用层的想法并不 总是显而易见。
最终,这取决于数据在应用中的流动方式。
控制应用数据流
数据流是 任何程序的核心主题。 为了阐明这个术语,我们必须检查不同应用组件之间消息和数据是如何流动的。 例如,当用户在其屏幕上的一个按钮上点击 保存 按钮时,我们需要将这个点击转换成一个实际的逻辑 决策,并将其继续到持久存储,在那里我们可以将信息本地保存。 数据流不会在这里结束 – 在这一点上,我们需要向 UI 发送一个消息,表示持久存储中已经发生了变化,我们应该更新屏幕上的内容。
此示例仅演示了一个单一用例。 标准移动应用可能有数百种此类情况,强调了考虑如何划分 我们的项目的重要性。
现在我们了解了数据流,让我们来讨论 开放 和 封闭 层。 在三层架构中,如 图 15**.1所示,表示层与业务逻辑层进行通信。 但是,这意味着表示层也可以与 数据层进行通信吗?
例如,表示层可能直接从数据层接收数据变更的更新。 在这些情况下,与业务逻辑作为中间件一起工作可能更加复杂和繁琐。 在这种情况下,我们必须决定我们的层是开放的 还是封闭的。
开放层允许 上层和下层之间的直接交互。 虽然开放层提供了更高的灵活性和简单性,但它们也可能增加耦合并减少关注点的分离。
一个 封闭层强制执行严格的交互,并且其相邻层之间的每次通信都必须通过封闭层本身。 封闭层可以增加关注点的分离并减少耦合,同时降低灵活性和 增加复杂性。
在处理三层架构时,讨论封闭层和开放层可能听起来有些奇怪。 中间层(业务逻辑)是唯一一个可以是开放或封闭的层。 然而,我们可以决定层是严格封闭还是 选择性封闭。
每一层都是由一组组件构建的。 例如,表示层可以由不同的应用程序屏幕或流程构建。 业务逻辑层可以由应用程序的不同逻辑部分构建,而数据层是由不同的服务构建的,例如网络、数据 和安全。
在某些情况下,表示层中的组件必须直接与 数据层进行通信。
查看 图 15**.2,它 展示了消息应用程序的基本三层架构:
https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_15_2.jpg
图 15.2:选择封闭层
图 15**.2 显示了之前讨论过的相同三层——表示层、业务逻辑层和数据层。 然而,这次,我们将层分解为不同的组件。 此外,我们展示了多个组件之间的各种通信路径。 例如,登录 UI 组件与应用程序的入职逻辑部分通信,而消息逻辑部分与数据和安全组件通信。 尽管大多数通信都通过业务逻辑层进行,但我们看到一些例外。 例如,以下例外 可能适用:
-
登录 UI 组件直接与安全组件通信,可能为了了解当前的 认证状态
-
线程 UI 组件与网络组件通信,以在 UI 中展示网络状态 的
我们可以动脑筋想出一种方法让这些情况通过业务逻辑层;然而,在某些情况下,绕过它并直接访问数据层是完全可接受的。 在某些情况下。
我们选择的架构服务于我们的项目需求,而不是相反。 然而,我们需要定义一种绕过业务逻辑层的策略,因为每个例外,包括在 图 15**.2中描述的,都会在我们的结构中造成另一种耦合。
我们讨论了架构的三个层级,但这是否总是三个层级? 我们是否有更多的层级? 让我们找出是否有可能创建一个更复杂但更有用的架构。 是可能的。
添加更多层级
与 三层协同工作是在简单性和良好分离之间的最佳平衡点。 然而,在大项目中,有时仍需要满足关注点分离的原则。 尽管只有一个层用于展示和另一个层用于业务逻辑看起来非常直接,但仍然存在一些需要澄清的困境。 需要澄清。
以 iOS 应用程序中可能拥有的两个不同组件为例——用户服务和支付服务。 两者都是应用程序业务逻辑的一部分。 当用户想要进行购买时,我们想使用用户服务来检查他们的角色,然后转到支付服务进行购买。 购买后,我们想将用户导航到屏幕并显示支付成功。 因此,我们可以看到我们有一个涉及整合不同的业务逻辑服务和协调不同屏幕的用例(图 15**.3):
https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_15_3.jpg
图 15.3:支付用例,结合多个组件和层
我们需要 在展示逻辑、业务逻辑,还是在这里和那里的一半来管理该用例吗? 好吧,这种逻辑可能分布在组件中,或者集中在一个屏幕视图模型中。 记住,在大多数情况下,视图模型处理的是 UI 状态而不是应用程序逻辑。
处理与导航捆绑的用例的问题并不新鲜,在更复杂且需要更多灵活性的应用程序中,在设计我们的 应用程序结构时需要考虑这一点。
因此,为了分离我们的关注点,我们可以添加另一个层—— 应用 层,它可以处理特定的应用程序用例(图 15**.4):
https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_15_4.jpg
图 15.4:四层架构
图 15**.4 显示了与 图 15**.2相似的一种架构设计,这次加入了应用层。 应用层有四个用例:登录,创建联系人,删除联系人,以及 创建群聊。这些用例处理从调用其他组件中的函数到导航的所有事情。 应用层使业务层和表示层从特定的 应用逻辑中变得更加清晰。
我们还能添加更多层吗?
应用层协调多个组件以创建特定于应用的逻辑。 我们可以在架构的底部实现相同的概念,即在业务逻辑和数据层之间。
例如,让我们讨论一个数据同步过程。 从网络检索数据并将其存储在持久存储中是一个复杂的过程,涉及错误处理和处理各种边缘情况。 这是业务逻辑的一部分还是 数据层的一部分?
数据操作 和**创建、读取、更新和删除** (CRUD)操作也是不清楚在哪个层处理这些任务的。
因此,为了处理不是业务逻辑但专注于从各种来源访问和管理数据的任务,我们可以添加 另一个称为 数据访问层 (图 15**.5):
https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_15_5.jpg
图 15.5:数据访问层
图 15**.5 显示了我们的 架构从 图 15**.4,现在增加了一个额外的层——数据访问层,该层处理同步服务、CRUD 操作和数据映射,将数据模型对象转换为业务 逻辑实体。
拥有超过三层可能会听起来过于复杂,并暗示过度设计。 然而,这种策略确保了不同层之间关注点的优秀分离。 业务逻辑不涉及数据操作,表示层不处理复杂用例。 在中型和大型应用中,当我们的应用变得更大时,将我们的项目分为四到五层可能会带来回报。
将项目分层只是我们可以考虑的视角之一。 我之前提到过,像这样的架构模式更像是一种原则。 真正的力量在于我们结合不同的模式。 让我们来探讨模块化架构模式。
将我们的项目划分为模块
我提到过 将项目分层只是我们可以从项目中观察到的视角之一。 然而,这究竟意味着什么?
以我们的即时通讯应用程序为例。 不同的层代表不同的关注点:展示、业务逻辑和数据。 我们的应用程序数据从 UI 层流向数据层,然后再返回。
另一种看待我们的应用程序的方式是通过封装一组功能或特定业务领域单位的代码单元。 我们可以将这些代码单元称为**模块**。
了解在模块开发中需要考虑的不同因素
将我们的项目划分为模块需要仔细考虑,因为这个步骤对于应用程序的结构随时间发展至关重要。 例如,在即时通讯应用程序中,模块可以是用户认证模块、用户资料模块、联系人模块和消息模块。 这些模块反映了应用程序的不同领域,并且将我们的应用程序划分为模块的决定非常灵活。
然而,一些关键因素可以帮助我们决定:
-
功能性和业务领域:我们已经在上一段中提到过这一点。 将应用程序分解为核心功能可以是我们理解项目不同模块——逻辑、歌曲播放器、提醒、入门等——的一个很好的开始。
-
可重用性:将我们在应用程序的不同部分使用的功能分组是理解如何创建模块的另一种方式。 例如,如果我们的应用程序执行不同的 HTTP 请求,我们可能会创建一个网络模块来管理所有的 API 调用。 另一个例子是共享组件——如果我们使用相同的按钮在不同的屏幕上,这可能是一个迹象,表明它应该是包含不同可重用组件的 UI 模块的一部分。
-
解耦:我们的模块应该尽可能地与其他模块解耦。 模块之间的相互依赖程度可以定义是否将其创建为模块是一个优秀的决定。 此外,如果可以为模块创建一个清晰的接口,这也是它可能是一个 好模块的另一个迹象。
-
协作:想象一下 几个团队正在我们的项目上工作。 他们可以不互相干扰地工作,这表明模块的分离做得很好。 请注意,无论我们是一个人的团队还是由六个开发者组成的五个团队,这条规则的相关性都是一样的。 原则是 最重要的。
我们必须问自己:我们能否创建另一个应用程序,并在新应用程序中使用我们的一些模块,就像使用乐高积木一样? 我们能否单独测试每个模块? 这些问题可以让我们对模块是否真正独立或存在 紧密耦合有一定的认识。
组织我们项目中的代码
关于将我们的代码组织成模块的几点说明 ——模块是一个抽象的定义,因为没有任何官方的技术方法可以将我们的代码正式地分成模块。 然而,我们可以区分两种方法—— 物理 和 功能:
-
在物理方法中,我们使用专用工具创建我们的模块。 例如,CocoaPods、Swift Packages 和 XCFrameworks 提供了一种将我们的代码物理封装成 代码单元的方法。
-
在功能方法中,我们不使用任何特定工具,而是将代码组织成文件夹。 这种简单的方法非常适合小型项目 或团队。
在这里的主要考虑因素很明显:物理方法中的可重用性和独立性,与功能方法中的简洁性和灵活性。 然而,让我们更深入地探讨,使这种比较更加实用和与我们作为 iOS 开发者日常工作的相关性。 iOS 开发者。
创建一个新项目并理解不同的模块可能相当具有挑战性。 一方面,良好的规划对于我们的项目开发在时间上的成功至关重要。 另一方面,预测我们的项目在未来几年如何发展是不可能的。 因此,我们在一开始需要的是灵活性。 因此,从功能方法开始,即通过文件夹创建模块,可能是大多数项目的正确方法。
随着项目的增长,我们模块的灵活性可能成为其劣势。 物理方法的一个优点是我们通过将代码封装到 pod 或包中,为我们的模块创建了清晰的边界。 这些边界阻止我们在不正确处理不同依赖项的情况下包含外部类和类型。 它们还迫使我们为模块声明一个清晰的接口,因为私有和内部函数和类从外部不可访问。 随着项目的演变和开发团队的壮大,这些限制是基本且有价值的。 不同的 pod 或包允许其他团队独立地对每个模块进行工作、构建和测试。 它甚至允许我们在 不同的项目中共享相同的模块。
既然我们已经确信模块很重要,那么层级的想法如何适应呢? 我们必须在层级和模块之间做出选择吗? 还是它们是 同一件事?
让我们尝试将事物 整理有序。
将多层架构与模块相结合
我们之前 提到,多层和模块更像是架构模式或概念。 它们是我们构建应用程序的指南,在我们的项目中结合不同的概念和模式是常见的做法,而不是仅仅 坚持一种模式。
以应用入职模块为例(图 15**.6):
https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_15_6.jpg
图 15.6:入职模块
图 15**.6 展示了分层的入职模块结构,分为四个不同的层级。 将模块化和多层架构相结合的一种方法是将每个模块分离到不同的层级中创建一个矩阵结构。 在这种情况下,入职模块包括展示、协调器、业务逻辑, 和数据。
另一种情况涉及由几个模块构建的层。 在这种情况下,每一层都是由几个模块构建的业务单元。
让我们比较 这两个架构:
https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_15_7.jpg
图 15.7:比较模块和层的两种组合方法
图 15**.7 展示了我们讨论的两种方法并排展示。 乍一看,这两种方法看起来很相似,只是从不同的角度来看。 然而,它们代表了两个不同的项目需求,并且极大地影响了可扩展性、独立性和耦合性。
例如,让我们看看数据模块。 在右侧(包含多个层的模块),我们可以看到每个模块都有自己的数据模块。 然而,在左侧(由多个模块构建的层),我们有一个可以服务于各种屏幕和业务单元的数据模块。 对于更多数据层模块,如分析、网络和安全性也是如此。
当我们考虑时,为了使模块真正独立,它需要包含所有层和服务。 这也意味着在某些情况下,我们可能需要复制一些代码在 某些情况下。
一如既往,我们在封装性和独立性以及集中逻辑和一致性之间有一个权衡。 因此,在实践中,我们必须平衡这一点,创建一个混合方法,并结合两种方法(图 15**.8)的元素:
https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_15_8.jpg
图 15.8:由模块构建的层和包含多个层的模块的组合
图 15**.8 展示了我们讨论的混合方法。 注意,我们有几个模块—— 用户、消息和**通知** ——每个都包含屏幕和业务逻辑。 然而,数据、网络和分析服务是在不同的模块之间共享的。
混合方法意味着不同的模块只有部分独立性。 另一方面,它表达了重用性和封装性之间的一种很好的平衡。
我们可以更进一步,共享更多的逻辑、实用工具和 UI 组件。
多层和模块架构对大多数开发者来说都很直观。 它们代表了一种检查应用和项目的逻辑方式——无论是通过关注层、领域 ,还是两者结合。
我们能否采用不同的架构方法? 让我们尝试以不同的方式看待我们的应用——使用 六边形架构。
构建六边形架构
多层架构描述了应用作为数据通过不同关注层流动。 模块架构描述了应用作为不同模块之间的通信。
为了探讨一种不同的架构方法,我们讨论一下应用的意义。 什么是应用? 它是屏幕吗? 它是 逻辑吗?
在六边形架构中,我们 认为业务逻辑是应用的核心和灵魂。 以我们的消息应用为例。 应用的核心是消息逻辑,包括我们的认证方式和不同的数据模型。 我们将这部分业务逻辑称为 领域模型 。
那么,关于不同的屏幕、核心数据和网络层呢? 在六边形架构中,这些应用部分并不在核心位置。 UI 屏幕看起来像是领域模型的客户端,而网络和核心数据部分为 领域层提供服务。
看看 图 15**.9:
https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_15_9.jpg
图 15.9:领域模型及其客户端和服务
图 15**.9 显示了领域模型位于中心,而其客户端和服务 围绕其周围。
与六边形架构相关的下一个概念是不同的参与者如何连接到领域模型。 这些参与者通过端口 和适配器进行连接。
学习端口和适配器的概念
将我们的 应用 视为一个计算机系统。 计算机有它的主板、CPU、GPU 和内存。 我们可以连接外部输入设备,例如键盘、鼠标或触摸板。 我们还可以连接输出设备,例如显示器、扬声器或 打印机。
我们知道 计算机是如何构建的——如果我们需要指出被认为是计算机核心的东西,那不会是打印机或显示器,而是其主板和 CPU。 然而,我们能否将任何我们想要的设备连接到 计算机上?
要做到这一点,我们需要 两样东西:
-
计算机上的一个端口,允许我们连接设备;例如,USB 或 HDMI
-
安装在设备上且知道如何与计算机所需的端口和接口工作的驱动程序 计算机需要
每个键盘、打印机或鼠标都有一个适合计算机端口的插头和一个实现某些协议的驱动程序,允许此设备与计算机通信。 一般来说,只要它符合计算机要求的协议,我们就可以连接任何我们想要的设备。
当我们回到六边形架构时,我们可以将领域模型视为计算机本身,将网络或 UI 视为打印机和键盘。 此外,我们还有两个更多术语——端口 和适配器:
-
端口:这是一个 领域内外部的入口或出口点。 在 Swift 中,我们使用协议来描述 端口。
-
适配器:当一个特定类别想要连接到端口时,它需要实现 端口协议。
大多数 iOS 开发者都熟悉端口和适配器的概念。 最终,这将是通过协议解耦两个元素的另一种方式。 然而,在六边形架构中,所有想要与领域模型通信的元素都必须 使用协议。
有两种类型的适配器——驱动和被驱动。 它们之间的区别对于理解六边形架构的概念至关重要。
理解驱动适配器
驱动适配器充当外部世界的入口点,并负责与领域模型进行任何交互的初始化。
如果我们回到计算机的例子,适配器可以被视为外部键盘或鼠标。 我们称它们为 *驱动 ,因为它们通过调用其 用例来驱动应用程序。
驱动适配器最常见的例子是 UI。 屏幕通常执行驱动我们系统采取有意义行动的操作,例如登录系统、播放音乐或从网络或本地 持久存储中获取数据。
然而,驱动适配器并不仅限于 UI 屏幕。 我们可以将通知中心、应用/场景代理、位置服务和通用链接视为 驱动适配器。
驱动适配器依赖于领域模型,并且仅通过 协议(端口)与它通信。
现在,让我们了解被驱动 适配器是什么。
理解被驱动适配器
领域 模型使用被驱动适配器与外部系统或服务通信,例如网络、持久存储或 第三方服务。
在计算机的例子中,我们可以将驱动适配器视为外部显示器或 打印机。
我们可以将整个六边形架构视为一个 I/O 系统——驱动适配器是输入设备,而被驱动适配器是输出设备,执行对本地存储的更新或执行 API 调用。
现在我们已经了解了端口、驱动适配器和被驱动适配器是什么(图 15**.10):
https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_15_10.jpg
图 15.10:完整的六边形架构
图 15**.10 展示了 不同的适配器,分为驱动和被驱动。 它还 显示我们需要一个端口来访问 领域模型。
到目前为止,我们主要在理论上讨论了六边形架构。 让我们考察一些如何在实践中实现这一概念的 例子。
在实践中实现六边形架构
让我们通过一个简单的流程,比如登录,来演示 六边形架构。
定义不同的端口
我们首先 定义不同的端口。 第一个端口是登录用例本身:
protocol LoginUseCaseProtocol {
func login(username: String,
password: String,
completion: @escaping (Result<User, Error>)
-> Void)
}
现在, <st c="32793">LoginUseCaseProtocol</st> 协议定义了驱动适配器或应用程序用户界面如何与应用程序代码(即领域模型)进行通信。
我们的第二个端口是我们用来连接到驱动适配器的一个端口,例如 网络服务:
enum NetworkRequestType{
case login
}
protocol NetworkServiceProtocol {
func performRequest(requestType: NetworkRequestType,
params: [String: Any],
completion: @escaping (Result<User,
Error>) -> Void )
}
现在, <st c="33228">NetworkServiceProtocol</st> 协议帮助领域模型与外部服务(如网络服务)进行通信。
创建登录用例
现在我们已经定义了不同的端口,我们可以创建位于领域模型核心的登录用例:
class LoginUseCase: LoginUseCaseProtocol {
let authService: NetworkServiceProtocol
init(authService: NetworkServiceProtocol) {
self.authService = authService
}
func login(username: String, password: String,
completion: @escaping (Result<User, any Error>) ->
Void) {
authService.performRequest(requestType: .login,
params: ["username" : username,
"password" : password],
completion: completion)
}
}
现在, <st c="33898">LoginUseCase</st> 类 实现了 <st c="33932">LoginUseCaseProtocol</st> 协议,这是我们之前讨论过的端口之一。 它还使用 <st c="34019">NetworkServiceProtocol</st> 协议作为依赖。 此时,我们将登录逻辑封装在一个协议中,并使用协议与网络服务进行通信。 这意味着我们的应用程序领域逻辑与可能存在的驱动或驱动适配器完全解耦,这正是 我们想要的。
创建网络服务
现在,让我们 创建一个 网络服务:
class NetworkService { }
extension NetworkService: <st c="34459">NetworkServiceProtocol</st> {
func performRequest(requestType: NetworkRequestType,
params: [String : Any],
completion: @escaping (Result<User, any Error>) -> Void) {
// implementation needed
}
}
现在, <st c="34653">NetworkService</st> 类实现了 <st c="34689">NetworkServiceProtocol</st> 协议,这样 我们就可以将其用作领域模型依赖。
创建登录界面
现在,让我们 转向驱动适配器并创建一个 登录界面:
import SwiftUI
struct LoginView: View {
@State var username: String = ""
@State var password: String = "" <st c="34969">let loginUseCase: LoginUseCaseProtocol</st> var body: some View {
VStack {
TextField("Username", text: $username)
SecureField("Password", text: $password)
Button("Login") {
loginUseCase.login(username: username,
password: password) { result in
// handle result
}
}
}
.padding()
在这个 例子中,我们创建了一个简单的登录界面(用户名和密码),它使用其协议与登录用例一起工作。 如果我们需要高级状态管理,我们可以使用一个 视图模型。
将所有东西连接起来
现在,我们只需要将 所有东西连接起来:
@main
struct HexagonalAppApp: App {
var body: some Scene {
WindowGroup {
let networkService = NetworkService()
let loginUserCase =
LoginUseCase(networkService: networkService)
LoginView(loginUseCase: loginUserCase)
}
}
}
在应用程序初始化过程中,我们首先创建驱动适配器( <st c="35822">NetworkService</st> 类),将它们注入到领域模型(登录用例)中,然后将领域模型注入到驱动适配器( <st c="35965">LoginView</st> 结构)中。
乍一看,我们似乎创建了太多的协议,并且使用了比平时更多的依赖注入。 虽然使用如六边形这样的架构确实有这种成本,但让我们来看看 这里的收益:
-
不同的关注点非常清晰。 我们确切地知道应用的核心逻辑是什么,外部服务是什么,以及这些模块的客户是什么。
-
由于它们彼此解耦并且只通过协议进行通信,因此维护每个适配器或核心逻辑案例变得极其容易。 当我们说维护时,我们指的是测试、重构和 错误修复。
-
在我们的应用中更换部分,如服务或用例,变得非常容易。 让我们尝试回忆一下我们曾经工作过的应用或甚至系统。 想象一下替换网络服务、持久存储或甚至 一个屏幕需要付出多少努力。
-
添加更多功能和模块不需要对我们项目进行重大更改。 在添加新屏幕或用例时重用现有代码变得容易。 重用现有代码
记住,就像多层和模块化架构一样,六边形架构提供了一套指导原则和原则,用于进行结构良好且易于维护的 项目架构。
那么,这些 原则是如何比较的呢?
比较不同的架构
我们能使用的 最佳架构是什么? 这里甚至有对错之分吗? 我们如何消化所有 这些信息?
因此,我们看到了如何结合模块化和多层架构,并强调每个架构的优势。 六边形架构也是如此——让我们回顾一下我们学到的不同原则 :
-
使用协议来解耦与 外部服务的通信
-
将领域模型作为 应用的核心
这些原则不仅适用于六边形架构,也适用于 其他架构。
让我们尝试使用几个 重要指标来比较不同的架构。
通过关注点分离
关注点分离是项目结构化中的一个重要原则,并且所有三种架构都很好地实现了这一原则。
然而,每种方法都以略微不同的方式分离关注点。 例如, 多层架构 的分离清晰且直接,但如果实施不当,可能会导致紧密耦合。
另一方面,在 模块化架构中,由于每个模块都包含其自己的不同层并且是自包含的,因此分离易于维护和扩展。 然而,定义模块之间的明确边界可能很复杂。
这种 六角形架构 专注于将应用程序核心与外部服务分离。 当将许多外部系统适配到应用程序时,这种方法是实用的。 然而,它需要一个复杂的设置,在小应用程序中可能会造成负担。
所有这些架构都有很好的关注点分离,因为这是设计架构最重要的原则之一。 然而,每个架构都采用不同的方法来实现这一点,而主导架构的选择取决于项目需求。
让我们看看如何从测试的角度比较不同的架构。
通过测试
测试和 关注点分离原则相互关联。 关注点分离原则鼓励隔离不同的类和模块,这使得为应用程序的特定部分编写单元测试变得更加简单。 根据关注点分离我们的应用程序也使得管理依赖关系更加容易,这是测试中的一个关键因素。 然而,由于每个架构执行分离的方式不同,它也影响了测试。
例如,在 多层架构中,独立测试每一层变得更加容易。 我们可以以简单的方式执行核心数据或特定业务逻辑测试。 然而,如果我们想编写集成测试(涉及与多个组件一起工作的测试),由于层之间的依赖关系,多层架构会使它变得更加复杂。
然而,编写集成测试是模块化架构的一个优点,因为模块内部的不同接口定义得很好。 另一方面,尝试为特定应用程序层编写单元测试现在可能变得更加复杂。
在 六边形架构中,我们使用适配器和端口。 这意味着与外部服务的松散耦合和许多协议,这使得我们能够轻松地模拟外部服务并轻松地测试 应用程序核心。
总的来说,测试是开发中的一个重要主题,每种架构都很好地支持了它。 要了解架构选择如何影响测试,我们需要问自己我们想要测试的核心单元是什么——是模块、层还是应用程序核心? 此外,集成测试对我们来说重要吗? 回答这些问题可以帮助我们了解哪种架构更适合我们的 项目。
那么维护和可扩展性呢? 现在让我们看看。
通过维护和可扩展性
在我们看到每种架构在维护和可扩展性方面的突出之处之前,让我们确切地了解这意味着什么。 维护是持续的,以保持我们的项目与不断变化的需求保持一致。 这包括修复错误、创建新功能及改进、重构和优化。 可扩展性描述了我们在不重新设计项目的情况下增加功能数量的能力。 一般来说,维护良好的项目通常被认为是可扩展的。 然而,就像测试和分离一样,每种架构都有不同的方法。
多层架构非常适合中等规模的项目。 由于层与层之间的紧密耦合,在大项目中保持清晰的分层架构随着时间的推移可能会具有挑战性。 模块化架构被认为在大项目中具有高度的可扩展性,因为不同的业务单元之间有明确的边界和独立性。 然而,在早期阶段定义这些单元可能是一个挑战。 六边形架构在扩展方面非常出色——清晰的领域分离有助于向项目中添加更多服务,并在一段时间内对其进行测试。 然而,由于我们需要管理许多适配器,维护可能会变得繁重。 to manage.
每种架构都适合不同规模的项目和需求。 与模块化架构相比,中等规模的项目可能更适合多层架构,而六边形架构对于具有一个可以随时间增加的应用程序核心的大型项目来说可能非常出色。
让我们通过比较不同的架构与 不同方面来尝试总结:
| 方面 | 多层 | 模块化 | 六边形 |
|---|---|---|---|
| 关注点分离 分离 | 清晰的分层(UI、逻辑、数据);随着依赖性增加可能变得不太灵活 | 独立、强分离,灵活的接口 | 与外部系统有明确的分离;核心逻辑通过端口和适配器进行隔离 |
| 测试 | 在层内容易,层间复杂 | 单个模块易于测试,模块内的集成测试也是如此 | 核心逻辑非常易于测试;易于模拟适配器 |
| 维护 | 由于紧密耦合可能具有挑战性 | 由于模块化;对模块的影响最小 | 由于与外部变化的隔离而变得简单 |
| 可扩展性 | 受限于层交互 | 模块可以独立扩展,因此具有高度可扩展性 | 通过添加新的适配器进行扩展;核心保持稳定 |
此表可以让我们对不同架构在不同方面的性能有一个概念。 这里没有分数! 我们需要根据我们的需求选择和混合架构概念。 。
总结
关注正确的架构是一个影响我们项目时间的战略决策。 如果你对自己的应用程序适合什么感到困惑,这是很自然的。 记住,正确的事情是将不同的架构视为不同的原则——我们应该以适合我们项目需求的方式结合所有世界的最佳之处。 。
在本章中,我们学习了架构的重要性以及它究竟意味着什么。 我们还比较了不同的架构——多层、模块化和六边形。 到现在为止,你应该能够设计你应用程序的不同组件,以帮助你在时间上对其进行扩展、维护和测试。 。
这本书的最后一章讨论建筑并非巧合。 从某种意义上说,建筑将我们所学的一切串联起来,提供了一个使所有元素能够和谐共存的框架。 此外,应用架构是我们实现书中所学所有概念的基础设施。 我们的旅程结束了;这是一个开始体验所有高级 iOS 功能的好机会。 祝你好运!
702



被折叠的 条评论
为什么被折叠?



