精通-IOS18-开发-全-

精通 IOS18 开发(全)

原文:zh.annas-archive.org/md5/a4345f119d5ed6596d7b88df6838014c

译者:飞龙

协议:CC BY-NC-SA 4.0

第一章:前言

在我们开始旅程之前,让我欢迎你加入 iOS 18 开发!

回顾 2008 年,试图回忆当时 iOS SDK 的情况,我对它多年来如何发展和演变感到惊讶。 当时,作为 iOS 开发者,我们所需要知道的就是如何创建一个 <st c="279">UITableView</st>,添加一些按钮,并成为 MVC 这种神奇设计模式的专家。 这足以制作一个标准应用,甚至被雇佣为 iOS 开发者。

但我们已经不在 2008 年了,事情已经有所改变——好吧,可能不仅仅是有点改变。 有什么改变? 一切! 编程语言、UI 框架、设计模式和甚至 IDE。 但不仅仅是改变了什么,还有什么 被添加了。

在 2008 年,iOS SDK(之前被称为 iPhone SDK)包含不到 25 个框架。 现在,我们有超过 200 个框架——这几乎是十倍之多!

我们有用于动画、游戏、测试、机器学习和人工智能、安全、数据等许多框架。 如今,成为一名 iOS 开发者不仅仅是添加列表和按钮——它还意味着理解 iOS SDK 的能力,并选择正确的方法和技术。 和科技。

这正是本书的目标。 这不是苹果技术的参考或文档——你可以在网上找到这些,而且可能更及时。 本书是 iOS SDK 能力的窗口,让你能够进一步提升你的开发技能。 甚至更远。

本书的信息是精心挑选的,以涵盖现代 iOS 开发中最激动人心和最有价值的部分,包括持久存储、测试、高级 SwiftUI 概念、网络、宏、架构,甚至机器学习和人工智能。 不可能涵盖一切,这也不是目的。

然而,理解本书中的主题将帮助你充分利用 iOS SDK。

本书面向对象

本书不是为初学者准备的! 阅读本书的开发者必须具备 Swift、SwiftUI、Xcode 和 iOS 开发基本概念(如动画、网络和持久数据)的基本知识。 因此,这不是一本“开始 iOS 开发”的书——我假设你已经用 Swift 编写了一些代码,并在 SwiftUI 中创建了一些出色的 UI。

本书的主要目标受众是以下三个主要角色:

  • 希望紧跟最新苹果技术的 iOS 高级开发者

  • 希望利用团队技能的 iOS 团队技术负责人

  • 希望晋升到高级开发领域的中级开发者

本书涵盖的内容

第一章**,iOS 18 的新功能,提供了 iOS 18 的概述,并涵盖了 SDK 的变化和新功能。 它还讨论了 iOS 18 的方法和 不同的趋势。

第二章**,使用 SwiftData 简化我们的实体,介绍了一个苹果公司的新框架,用于替换 Core Data 以实现持久存储。 本章涵盖了从设置、执行操作、查询 到迁移的各个方面。

第三章**,理解 SwiftUI 观察,提供了一个关于 SwiftUI 关键方面的概述。 本章讨论了苹果公司的新观察框架,重构了我们对于不同属性包装器角色的理解,并深入探讨了它们 在底层的工作方式。

第四章**,使用 SwiftUI 进行高级导航,涵盖了 iOS 的另一个重大主题。 它讨论了 SwiftUI 导航的复杂性,并提供了处理它的实际示例和模式。

第五章**,使用 WidgetKit 增强 iOS 应用,解释了小部件的概念;涵盖了如何添加、维护和设计小部件;并涵盖了小部件的新功能,例如在 iOS 18 中的交互和控制小部件。

第六章**,SwiftUI 动画和 SF 符号,将帮助我们的应用更加愉悦和吸引人。 我们将了解动画在 SwiftUI 中的重要性及其概念,执行基本动画,并动画化 SF 符号。

第七章**,使用 TipKit 改进功能探索,讨论了一个有趣的 SDK,它弥合了开发者和产品视角之间的差距。 我们将学习如何向我们的应用添加提示,设计它们,并控制它们的 外观规则。

第八章**,连接和从网络获取数据,涉及 iOS 中最基本的话题之一:从网络获取数据。 我们将了解如何处理 HTTP 请求并将 Combine 框架连接到 我们的流程中。

第九章**,使用 Swift Charts 创建动态图表,是本书中最丰富多彩的一章。 我们将了解不同类型的图表,创建不同的图表,甚至实现用户交互,让我们的用户获得更多的价值。

第十章**,Swift 宏,是一个高级章节,涵盖了复杂而强大的主题。 本章深入探讨了 SwiftSyntax 框架,它是 Swift 宏背后的框架,帮助我们添加和测试我们的第一个 Swift 宏。 随着许多框架的 API 基于 Swift 宏,这个主题变得至关重要。

第十一章**,使用 Combine 创建管道,涵盖了声明式编程的基本概念。 本章讨论了 Combine 框架的基础知识,深入探讨了不同的 Combine 组件,如发布者、订阅者和操作符,并提供工具将 Combine 集成到 现实世界的流程中。

第十二章**,利用 Apple 智能和机器学习变得聪明,探索了机器学习的迷人世界。 我们将回顾机器学习和人工智能的基础知识,并尝试 iOS 中内置的机器学习框架,如 NLP、视觉和声音分析。 不仅如此,本章还解释了如何训练我们自己的模型并在 我们的应用中使用它。

第十三章**,使用 App Intents 将您的应用暴露给 Siri,将我们现有的应用的能力,如动作和内容,暴露给 Siri。 本章提供了一种为我们的应用准备迎接 人工智能时代的方法。

第十四章**,使用 Swift 测试改进应用质量,涉及 iOS 开发中一个关键但不受欢迎的话题。 新的 Swift 测试框架使测试更加直接和自然。 我们将设置测试目标,编写我们的第一个测试函数,并了解如何管理不同的测试计划、套件和配置。

第十五章**,探索 iOS 架构,旨在解释不同的架构概念,并帮助您选择一个能够平衡简单性、可扩展性和长期可维护性的典范架构。 如果你不知道如何建造你的房子,即使有一个出色的房间想法又有什么用呢?

为了充分利用本书

您需要了解苹果的平台工程——Xcode、Swift、SwiftUI,并且有一些编写 iOS 应用的经验——仅仅编写一个简单的屏幕是不够的,因为本书的内容需要具有价值。

书中涵盖的软件/硬件 要求 操作系统 要求
Xcode macOS
iOS SDK
Create ML

如果您使用的是本书的数字版,我们建议您亲自输入代码或访问 本书的 GitHub 仓库中的代码(下一节将提供链接)。 这样做将帮助您避免与代码复制和粘贴相关的任何潜在错误。 这将有助于您避免与代码复制和粘贴相关的任何潜在错误。

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件 https://github.com/PacktPublishing/Mastering-iOS-18-Development

如果代码有更新,它将在 GitHub 仓库中更新。

我们还有其他来自我们丰富的书籍和视频目录的代码包,可在 https://github.com/PacktPublishing/找到。查看它们吧!

使用的约定

本书中使用了多种文本约定。

<st c="7535">文本中的代码</st>:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。 以下是一个示例:“在 <st c="7725">NavigationStack</st>中,有一个名为 <st c="7777">navigationDestination</st>的新视图修饰符,它允许我们根据状态变化分别定义一个目标。”

代码块设置 如下:

 enum Screen: Hashable {
    case signin
    case onboarding
    case mainScreen
    case settings
}
@State var path: [Screen] = []

当我们希望您注意代码块中的特定部分时,相关的行或项目将被 加粗:

 struct CoordinatorView: View {
    @ObservedObject private var coordinator = Coordinator()
    var body: some View { <st c="8259">NavigationStack</st>(path: $coordinator.path) {
            AlbumListView()
                .navigationDestination(for: PageAction.self, destination: { pageAction in
                    coordinator.buildView(forPageAction: pageAction)
                })
        }
        .environmentObject(coordinator)
    }
}

任何命令行输入或输出都按照以下方式编写:

 @testable import Chapter14

粗体:表示新术语、重要单词或您在屏幕上看到的单词。 例如,菜单或对话框中的单词以 粗体显示。以下是一个示例:“代码创建了一个蓝色圆圈和一个写着 开始的按钮。”

提示或重要说明

看起来像这样。

取得联系

我们欢迎读者的反馈。

一般反馈:如果您对本书的任何方面有任何疑问,请通过电子邮件发送至 customercare@packtpub.com 并在邮件主题中提及书名。

勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。 如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。 请访问 www.packtpub.com/support/errata 并填写 表格。

盗版:如果您在互联网上遇到我们作品的任何形式的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。 请通过电子邮件联系 copyright@packt.com 并提供 材料的链接。

如果您想成为作者:如果您在某个主题领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请 访问 authors.packtpub.com

分享您的想法

一旦您阅读了 精通 iOS 18 开发,我们非常乐意听听您的想法! 请点击此处直接进入亚马逊评论页面 为此书并分享 您的反馈。

您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供的是优秀的 高质量内容。

下载此书的免费 PDF 副本

感谢您购买 这本书!

您喜欢在路上阅读,但无法携带您的印刷 书籍到任何地方吗?

您的电子书购买是否与您选择的 设备不兼容?

不用担心,现在每本 Packt 书籍都附赠一本免费的 DRM-free PDF 版本,无需额外费用。 无需额外费用。

在任何地方、任何设备上阅读。 直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

这些福利远不止于此,您将获得独家折扣、新闻通讯以及每天在您的 收件箱中获取的优质免费内容

按照以下简单步骤获取 以下好处:

  1. 扫描下面的二维码或访问 以下链接

https://packt.link/free-ebook/9781835468104

  1. 提交您的购买 证明

  2. 这就完了! 我们将直接将您的免费 PDF 和其他福利发送到您的 电子邮件

第一部分:iOS 18 开发入门

在本部分中,您将回顾 iOS 18 的所有新功能。 我们将探讨令人兴奋的主题,例如 SwiftData、Observation 和 SwiftUI 导航。 此外,我们将使用 WidgetKit 构建小部件,动画化我们的视图,向我们的应用程序添加提示和图表,并学习如何构建一个出色的 网络层。

本部分包含以下章节: 以下章节:

  • 第一章, iOS 18 的新功能

  • 第二章, 使用 SwiftData 简化我们的实体

  • 第三章, 理解 SwiftUI Observation

  • 第四章, 使用 SwiftUI 进行高级导航

  • 第五章, 使用 WidgetKit 提升 iOS 应用程序

  • 第六章, SwiftUI 动画和 SF 符号

  • 第七章, 使用 TipKit 提高特性探索

  • 第八章, 从网络连接和获取数据

  • 第九章, 使用 Swift Charts 创建动态图表

第二章:1

iOS 18 新增功能

苹果在 2024 年的 WWDC 上推出了 iOS 18,作为其年度开发者大会的一部分,与 macOS、tvOS、iPadOS、watchOS 和 visionOS 一起。

利用我们应用在每个主要操作系统版本中的最新特性和功能,使我们获得竞争优势。 以下是苹果选择在 SDK 中改进特定领域的原因——市场研究或技术趋势是采用新技术的充分理由。

然而,要理解 iOS 18 的改进之处,我们首先必须了解这个版本背后的背景——这是本章的一个目标。

在本章中,我们将涵盖以下主题:

  • 理解 iOS 18 背景

  • 探索 Swift 测试

  • 了解新的 Swift 数据改进

  • 尝试新的 缩放过渡效果

  • 在我们的 iPad 应用中添加浮动标签栏

  • 在 SwiftUI 中对滚动视图有更多控制

  • 更改文本渲染行为

  • 从另一个视图定位子视图

  • 进入 人工智能革命

如果这听起来像是一个令人兴奋的章节,您并没有错。 让我们先了解 iOS 18 的背景。

技术要求

对于本章,从 App Store 下载 Xcode 版本 16.0 或更高版本是必不可少的。

确保您正在使用最新的 macOS 版本(Ventura 或更高版本)。 只需在 App Store 中搜索 Xcode,选择最新版本,然后继续下载。 打开 Xcode 并完成出现的任何进一步设置说明。 在 Xcode 完全运行后,您 就可以开始了。

本章包含许多代码示例,可以在以下 GitHub 仓库中找到:

https://github.com/PacktPublishing/Mastering-iOS-18-Development/tree/main/Chapter%201

理解 iOS 18 背景

发布一个主要的 iOS 版本总是一件大事,即使它是第 18 版本。 让我们在 iOS 18 之前尝试分析 iOS SDK:

  • SwiftUI 正在变得更加成熟和强大。 然而,一些功能,如复杂的动画或过渡、手势处理、导航和绘图,仍然使用 SwiftUI 实现起来具有挑战性

  • Core Data 是大多数 iOS 开发者作为存储 持久数据的解决方案的首选框架。

  • 虽然 XCTest 被认为是一个强大且方便的测试框架,但它缺乏在其他平台上常见的功能,例如参数化测试和更好的 测试组织。

  • WidgetKit的流行证明了在当今世界,能够一目了然地展示信息的能力至关重要。

没有人会否认这个列表的重要性。 然而,一个苹果直到 WWDC 2024 才开始关注的至关重要的话题是 人工智能。

OpenAI 的 ChatGPT 的兴起,随后是数千种机器学习和 AI 工具,使苹果陷入了一个尴尬的境地。 这不是苹果第一次落后于一些暂时性的趋势,但这一次情况不同。 AI 对人类潜在的影响表明,这不仅仅是一个常规趋势或技术演变;实际上,这是一场将改变 世界的革命。

问题是,苹果在它的一系列平台和技术方面处于什么位置? 它对 AI 革命有什么答案吗?

在深入探讨那个问题之前,让我们首先回顾一下 iOS 18 中引入的新特性和框架,并探讨最新版本如何应对我们在 iOS 开发中面临的一些关键挑战。 不过,不用担心——我们将在最后一节和整本书中涵盖 AI 革命。 现在,让我们讨论一个新的框架—— Swift 测试。

介绍 Swift 测试

Swift 测试框架 采用了一种新颖且令人耳目一新的测试方法。 Swift 测试框架包含现代特性,如宏,这些宏与结构体而不是类一起工作,并且可以标记测试和 测试套件。

Swift 测试框架旨在取代 2013 年作为 Xcode 5 部分引入的 XCTest。 XCTest 属于 Objective-C 为主流语言的旧时代。 然而,Swift 取代了 Objective-C,苹果意识到 iOS 开发者需要一个现代化的 测试框架。

这是一个简单的 测试函数:

<st c="4003">@Test("Test view model increment function", .enabled(if: AppSettings.CanDecrement), .tags(.critical))</st> func testViewModelIncrement() async throws {
//         preparation
        let viewmodel = CounterViewModel()
        viewmodel.count = 5
//        execution
        viewmodel.increment(by: 1)
//        verification <st c="4277">#expect(viewmodel.count == 6)</st> }.

我们可以看到在 Swift Testing 中编写测试函数是多么简单。 注意前面的 Swift 宏,它除了提供测试描述外,还配置并标记了函数为关键。

如果你的应用没有测试函数,Swift Testing 是一个很好的开始方式(要了解更多关于 Swift Testing 的信息,请参阅 第十四章)。

现在,让我们讨论另一个处理我们 持久存储的新框架。

介绍 Swift 数据改进

Swift Data 是在 WWDC 2023 作为 iOS 17 的一部分引入的,其目标是取代旧但流行的 Core Data 框架。

Swift Data 提供了一个基于 Swift 的现代 API,可以帮助减少与持久存储工作时的摩擦。 我们在 Apple 开发工具中看到的一个趋势是,从 GUI 转向基于代码的工具。 一个很好的例子是 SwiftUI – 尽管可以拖放组件来构建用户界面,但主要的实现方式是代码。 App Intents 和 Swift Package Managers 也是如此。 数据层也遵循同样的概念 – 在 Swift Data 中,我们没有数据模型编辑器,所以我们使用 仅代码来构建数据模型。

例如,这是为 <st c="5504">Book</st> 实体创建数据模型的方法:

<st c="5516">@Model</st> class Book {
    var author: String
    var title: String
    var publishedDate: Date
}

乍一看,这似乎是一个普通的 <st c="5641">Book</st> 类 – 确实如此! 这次,我们添加了 <st c="5689">@Model</st> 宏,它完成了所有的魔法。

当 Swift Data 介绍时,它已经拥有许多功能,例如关系和删除规则。 尽管如此,许多开发者认为这个框架还不够成熟,无法取代 Core Data。

在 iOS 18 中,Apple 为 Swift Data 添加了一些功能,如果它还没有,这将使其更接近它 应该达到的地方。

唯一值

iOS 18 的第一个也许是最重要的 新特性 是能够根据 模型属性 构建一个 唯一值

<st c="6273">Book</st> class’s unique identifier is based on combining the <st c="6330">name</st> and <st c="6339">publicationName</st> attributes.
			<st c="6366">History API</st>
			<st c="6378">Another new and exciting feature</st> <st c="6411">is the History API.</st> <st c="6432">Using the History API, we can fetch</st> <st c="6467">transactions and changes that have been made to our Swift Datastore over a particular time range.</st> <st c="6566">This capability allows us to update our app when we work with extensions such as widgets or sync changes to</st> <st c="6674">the server.</st>
			<st c="6685">Reading the transaction history is not the only “pro” feature added to Swift Data.</st> <st c="6769">Let’s talk about Core Data for</st> <st c="6800">a second.</st>
			<st c="6809">Custom data stores in Swift Data</st>
			<st c="6842">Core Data fundamentals</st> <st c="6865">included the ability to work with</st> <st c="6899">any data store type we wanted – XML, SQLite, CSV files, or even a remote server.</st> <st c="6981">Although almost all apps that implement Core Data work with SQLite as their data store, it was built to be agnostic to whatever</st> <st c="7109">happens underneath.</st>
			<st c="7128">Starting with iOS 18, Apple also brings custom data stores to</st> <st c="7191">Swift Data.</st>
			<st c="7202">For example, let’s say that we want to base our data store on a CSV file.</st> <st c="7277">We start by creating a new data store configuration</st> <st c="7328">specifically for CSV</st> <st c="7350">data</st> <st c="7354">stores:</st>

final class CSVStoreConfiguration: DataStoreConfiguration {

typealias Store = CSVDataStore

var name: String

var schema: Schema? var fileURL: URL

init(name: String, schema: Schema? = nil, fileURL: URL)

{

    self.name = name

    self.schema = schema

    self.fileURL = fileURL

}

static func == (lhs: CSVStoreConfiguration, rhs:

CSVStoreConfiguration) -> Bool {

    return lhs.name == rhs.name

}

func hash(into hasher: inout Hasher) {

    hasher.combine(name)

}

}


			<st c="7804">The</st> `<st c="7809">CSVStoreConfiguration</st>` <st c="7830">class is a new data store configuration that accepts the name and the schema (similar to how Swift Data configuration setup works today), and we added an additional parameter, which is</st> `<st c="8016">fileURL</st>` <st c="8023">– the location of our</st> <st c="8046">CSV file.</st>
			<st c="8055">In the</st> `<st c="8063">init()</st>` <st c="8069">function, we can also check whether the CSV file exists or whether we need to create a</st> <st c="8157">new one.</st>
			<st c="8165">Notice that there’s a</st> `<st c="8188">typealias</st>` <st c="8197">named</st> `<st c="8204">Store</st>`<st c="8209">, which represents</st> <st c="8227">a new type</st> <st c="8238">called</st> `<st c="8246">CSVDataStore</st>`<st c="8258">. This is the actual store class where everything happens.</st> <st c="8317">Let’s create</st> <st c="8330">it now:</st>

最终类 CSV 数据存储: 数据存储 {

typealias 配置 = <st c="8403">CSV 存储配置</st> typealias 快照 = 默认快照

var 配置: <st c="8481">CSV 存储配置</st> var 名称: String

var 架构: 架构

var 标识符: String

必须初始化(_ 配置: CSV 存储配置,

迁移计划: (任何 SchemaMigrationPlan 类型)?)

抛出 {

    self.configuration = configuration

    self.name = configuration.name

    self.schema = configuration.schema! self.identifier =

    配置文件 URL 的最后路径组件

}

}


			<st c="8836">Our</st> `<st c="8841">CSVDataStore</st>` <st c="8853">class conforms to the</st> `<st c="8876">DataStore</st>` <st c="8885">protocol and has similar properties, such as</st> `<st c="8931">name</st>` <st c="8935">and</st> `<st c="8940">schema</st>`<st c="8946">.</st>
			<st c="8947">The</st> `<st c="8952">CSVDataStore</st>` <st c="8964">class must handle a persistent store’s basic operations, such as inserting new items and deleting or updating</st> <st c="9075">existing ones.</st>
			<st c="9089">Notice that the</st> `<st c="9106">init()</st>` <st c="9112">function includes a migration type, so we can even handle migrations when our</st> <st c="9191">schema changes.</st>
			<st c="9206">To handle all of these</st> <st c="9229">operations, we need to implement two important</st> <st c="9276">methods that are part of the</st> `<st c="9306">DataStore</st>` <st c="9315">protocol –</st> `<st c="9327">fetch()</st>` <st c="9334">and</st> `<st c="9339">save()</st>`<st c="9345">:</st>

func 获取(_ 请求: 数据存储获取请求)

抛出 -> 数据存储获取结果<T,默认快照>

where T : 持久模型 {

    let 判断 = 请求描述符判断

    返回 数据存储获取结果(描述符:

    请求描述符,获取快照: [],

    相关快照: [:])

    . // 执行获取操作

}

func 保存(_ 请求:

数据存储保存更改请求<默认快照>)

抛出 -> 数据存储保存更改结果<默认快照>

{

    var remappedIdentifiers = [持久标识符:

    持久标识符]()

    对于请求插入的快照 {

        // 插入新项目

    }

    对于请求更新的快照 {

        // 更新现有项目

    }

    对于请求删除的快照 {

        // 删除项目

    }

    返回

    数据存储保存更改结果<默认快照>(for:

    self.identifier,

    remappedIdentifiers: remappedIdentifiers)

}

			<st c="10142">These two functions perform all the magic underneath.</st> <st c="10197">In this code example, I left the function implementation empty – it is up to you to fill it in according to the specific data store implementation.</st> <st c="10345">Once we modify our CSV file, we can return the results to</st> <st c="10403">the app.</st>
			<st c="10411">The</st> `<st c="10416">History</st>` <st c="10423">API, the</st> `<st c="10433">DataStore</st>` <st c="10442">protocol, and the ability</st> <st c="10468">to provide uniqueness</st> <st c="10490">to entities make Swift Data much more mature and capable.</st> <st c="10549">To get started with Swift Data, read</st> *<st c="10586">Chapter 2</st>*<st c="10595">.</st>
			<st c="10596">Next, let’s talk about an exciting improvement in</st> <st c="10647">SwiftUI transition.</st>
			<st c="10666">Introducing zoom transition</st>
			<st c="10694">This is a small improvement, but it may</st> <st c="10734">indicate an interesting direction Apple is taking with SwiftUI.</st> <st c="10799">In general, UIKit’s transitioning capabilities are very robust and provide us with the flexibility to create any transition we want.</st> <st c="10932">Even before that, from the beginning, UIKit had some nice built-in transitions we could use to make our navigation</st> <st c="11047">more appealing.</st>
			<st c="11062">In iOS 18, Apple added a new transition that allows us to navigate to a new view using a</st> <st c="11152">zoom animation.</st>
			<st c="11167">Let’s create an album grid that, when tapping on the album, transitions to a full album screen with a</st> <st c="11270">zoom animation:</st>

@Namespace() var 命名空间 var body: some View {

    NavigationStack {

        ScrollView {

            LazyVGrid(columns: [

            网格项(.自适应(minimum: 150)) ]) {

                对于专辑专辑 {专辑 in

                    NavigationLink {

                        Image(album.imageName)

                            .可调整大小() <st c="11512">.导航过渡(.缩放(sourceID:专辑 ID,在:</st>

命名空间) } 标签: {

                        Image(album.imageName)

                            .可调整大小()

                            .scaledToFit()

                            .frame(minWidth: 0,

                            最大宽度: .infinity)

                            .frame(height: 150)

                            .cornerRadius(8.0)

                    } <st c="11720">.matchedTransitionSource(id:</st>

专辑 ID,在:命名空间中 }

            }

        }

    }

    .padding()

}

			<st c="11794">This example shows a simple grid view</st> <st c="11832">of albums, a NavigationStack, and a NavigationLink.</st> <st c="11885">The idea of performing the zoom transition is to match the source (the image we tapped on) to the destination (the image we</st> <st c="12009">zoomed into).</st>
			<st c="12022">We do that by adding two</st> <st c="12048">view modifiers:</st>

				*   `<st c="12063">navigationTransition</st>`<st c="12084">: We add this modifier to the source view.</st> <st c="12128">The source view, in our case, is the album view in the grid.</st> <st c="12189">We select the type of animation (currently, it’s a zoom animation) and the</st> <st c="12264">source ID.</st>
				*   `<st c="12274">matchedTransitionSource</st>`<st c="12298">: We add this modifier to the destination view.</st> <st c="12347">In our example, the destination view is the full-screen view of the album.</st> <st c="12422">Again, we provide the ID of the album we want to present so SwiftUI can perform the zoom animation between</st> <st c="12529">these views.</st>

			<st c="12541">Creating the match between the views allows SwiftUI to perform a nice zoom animation, similar to what we see in the Photos app.</st> <st c="12670">Look at</st> *<st c="12678">Figure 1</st>**<st c="12686">.1</st>*<st c="12688">:</st>
			![Figure 1.1: Zoom transition between photos grid and a full-screen view](https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_01_01.jpg)

			<st c="12691">Figure 1.1: Zoom transition between photos grid and a full-screen view</st>
			*<st c="12761">Figure 1</st>**<st c="12770">.1</st>* <st c="12772">shows how the zoom animation looks in a couple of frames based on the p</st><st c="12844">receding</st> <st c="12854">code example.</st>
			<st c="12867">Zoom transitions serve</st> <st c="12890">more than aesthetic purposes.</st> <st c="12921">They inform the user about the changes occurring on the screen, helping them</st> <st c="12998">stay oriented.</st>
			<st c="13012">To read more about navigation in iOS, read</st> *<st c="13056">Chapter 4</st>*<st c="13065">.</st>
			<st c="13066">Speaking of navigation, iPadOS navigation gained a unique and valuable capability – the</st> <st c="13155">floating bar.</st>
			<st c="13168">Adding a floating tab bar</st>
			<st c="13194">iPad is not the focus</st> <st c="13216">of this book.</st> <st c="13231">This is not because iPadOS is unimportant but because most, if not all, of the topics we discuss here are also suitable</st> <st c="13351">for iPadOS.</st>
			<st c="13362">However, there are special features that are relevant to iPadOS that are worth mentioning.</st> <st c="13454">One of them is the float</st> <st c="13479">tab bar.</st>
			<st c="13487">The tab bar has existed in iOS since its very beginning.</st> <st c="13545">It allows users to navigate between different sections of an app.</st> <st c="13611">In both iOS and iPadOS, the tab is located at the bottom of the screen.</st> <st c="13683">While it looks perfectly fine on small devices, a tab bar on big screens seems stretched and doesn’t use the</st> <st c="13792">large space.</st>
			<st c="13804">One solution for handling navigation in a iPadOS is to implement a sidebar – a view on the side that displays the different sections of</st> <st c="13941">the app.</st>
			<st c="13949">In iPadOS 18, the position</st> <st c="13976">of the sidebar changed, and it is now located at the top of the screen, floating over the app content.</st> <st c="14080">Not only that; the user can also transition between a tab bar and a sidebar.</st> <st c="14157">Let’s see how to do that</st> <st c="14182">in code:</st>

结构体 ContentView: View {

var body: some View {

    TabView {

        Tab("主页",系统图像:"house.fill") {  }

        Tab("个人资料",系统图像:

        "person.crop.circle") { }

        Tab("设置",系统图像:"gear") { }

    }

    .tint(.red) <st c="14402">.tabViewStyle(.sidebarAdaptable)</st> }

}


			<st c="14438">This code example looks straightforward but includes a view modifier called</st> `<st c="14515">tabViewStyle</st>`<st c="14527">. Currently, it has only one option to choose from –</st> `<st c="14580">sidebarAdaptable</st>`<st c="14596">. When we add this view modifier, a button is added to the tab bar that allows the user to change the layout.</st> <st c="14706">Let’s see how it looks (</st>*<st c="14730">Figure 1</st>**<st c="14739">.2</st>*<st c="14741">):</st>
			![Figure 1.2: The Tab bar adapts a sidebar layout](https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_01_02.jpg)

			<st c="14831">Figure 1.2: The Tab bar adapts a sidebar layout</st>
			*<st c="14878">Figure 1</st>**<st c="14887">.2</st>* <st c="14889">shows the two layouts</st> <st c="14911">for our tab bar.</st> <st c="14929">The new sidebar improves the user experience and makes navigating and focusing on content easier.</st> <st c="15027">It also resembles Apple’s apps, such as the TV app, which aligns with what users can expect from</st> <st c="15124">our app.</st>
			<st c="15132">Another important aspect of SwiftUI that required improvement is scroll views.</st> <st c="15212">Let’s go over major changes in</st> <st c="15243">that area.</st>
			<st c="15253">Having more control over scroll views</st>
			<st c="15291">Controlling and observing scroll view behavior</st> <st c="15338">was part of the reason why UIKit developers ha</st><st c="15385">dn’t moved to</st> <st c="15400">SwiftUI yet.</st>
			<st c="15412">Scroll views are crucial in mobile apps, not just because of the small screen, which often requires the user to scroll for more content, but also because they help reuse visible content to minimize memory usage or adjust our UI based on</st> <st c="15650">scroll position.</st>
			<st c="15666">However, why is handling scroll views in SwiftUI</st> <st c="15716">more complex than in UIKit?</st> <st c="15744">We can think of</st> <st c="15760">two reasons:</st>

				1.  **<st c="15772">SwiftUI is relatively new</st>**<st c="15798">: SwiftUI is still considered to be a new framework.</st> <st c="15852">Think how much time it took for UIKit to become a mature framework.</st> <st c="15920">Obviously, we can achieve this in several years and 17 years</st> <st c="15981">of development.</st>
				2.  `<st c="16336">@State</st>` <st c="16342">variable or a</st> <st c="16357">view modifier.</st>

			<st c="16371">These reasons lead to many workarounds that developers use to achieve the desired user experience.</st> <st c="16471">Fortunately, iOS 18 gives us two view modifiers that make SwiftUI scroll views more appealing than ever.</st> <st c="16576">We’ll start</st> <st c="16588">with</st> `<st c="16593">onScrollGeometryChange</st>`<st c="16615">.</st>
			<st c="16616">Observing the scroll view position</st>
			<st c="16651">Up until now, SwiftUI hasn’t provided</st> <st c="16689">any direct API to observe the scroll view position.</st> <st c="16742">Many developers had to find a workaround or use UIKit as a solution.</st> <st c="16811">Now, we have an</st> `<st c="16827">onScrollGeometryChange</st>` <st c="16849">view modifier that allows us to observe any change in the</st> <st c="16908">scroll position.</st>
			<st c="16924">Let’s say we have a</st> `<st c="16945">VStack</st>` <st c="16951">view within a scroll view, and we wish to show a</st> **<st c="17001">Scroll to the top</st>** <st c="17018">button whenever the user scrolls down to allow them to return to the top of</st> <st c="17095">the list.</st>
			<st c="17104">Let’s look at the</st> <st c="17123">following code:</st>

ScrollViewReader {代理 in

        ScrollView {

            VStack(alignment: .leading, spacing: 16) {

                ForEach(albums) {专辑 in

                    提取视图(album:专辑)

                        .id(album.id)

                }

            }

        }

        .overlay(alignment: .bottom) {

            如果显示滚动到顶部 {

                Button("滚动到顶部") {

                        代理滚动到(albums[0].id,

                        锚点: .top)

                }

                .buttonStyle(.borderedProminent)

            }

        } <st c="17458">.onScrollGeometryChange(for: Bool.self) {</st>

几何形状在

几何内容偏移量.y <

几何内容内边距底部 + 300

} 动作:{旧值,新值 in

withAnimation {

显示滚动到顶部 = !newValue

}

} }


			<st c="17658">In this code example, we can see a</st> `<st c="17693">VStack</st>` <st c="17699">view inside a scroll view.</st> <st c="17727">The</st> `<st c="17731">VStack</st>` <st c="17737">view contains a list of albums.</st> <st c="17770">Notice that we have an</st> `<st c="17793">onScrollGeometryChange</st>` <st c="17815">view modifier for the scroll view itself.</st> <st c="17858">The view modifier has a closure that runs each time the scroll position changes with a</st> `<st c="17945">geometry</st>` <st c="17953">parameter.</st> <st c="17965">Within the closure, we inspect the scroll view content offset, and if it reaches a specific threshold, we show/hide the</st> **<st c="18085">Scroll to top</st>** <st c="18098">button using a specific</st> <st c="18123">state variable.</st>
			<st c="18138">The</st> `<st c="18143">ScrollViewReader</st>` <st c="18159">view, which wraps</st> <st c="18177">the scroll view, provides a proxy to the scroll view so we can scroll to the top when the user presses</st> <st c="18281">the button.</st>
			<st c="18292">We can use the</st> `<st c="18308">onScrollGeometryChange</st>` <st c="18330">method for more use cases than just toggling a button.</st> <st c="18386">For example, we can use it to perform a network request in an infinity list where we need to load more content from the server when the user reaches the bottom.</st> <st c="18547">Additional examples would be having a sticky header or a progress indicator, or even just sending analytics.</st> <st c="18656">These use cases were complex to implement before iOS 18 and are now</st> <st c="18724">extremely simple.</st>
			<st c="18741">The improvement in the second scroll view seems to belong to the same family.</st> <st c="18820">Let’s review</st> <st c="18833">it now.</st>
			<st c="18840">Observing items’ visibility</st>
			<st c="18868">Checking whether a view is visible</st> <st c="18903">inside a scroll view is not easy.</st> <st c="18938">Up until now, we had to calculate the view frame versus the scroll view content offset, not to mention observe that during a scroll view.</st> <st c="19076">Lucky for us, we now have a new modifier</st> <st c="19117">called</st> `<st c="19124">onScrollVisibilityChange</st>`<st c="19148">.</st>
			<st c="19149">Suppose we want to change a view while it enters our scroll view.</st> <st c="19216">For example, we might want to report analytics or print to</st> <st c="19275">the console.</st>
			<st c="19287">Let’s look at the</st> <st c="19306">following example:</st>

对于专辑 {专辑 in

ExtractedView(album: album)

    .id(album.id)

    .<st c="19395">onScrollVisibilityChange(threshold: 0.9) {</st>

visible in

if visible {

print("(album.title) appears")

}

} }


			<st c="19500">This code example shows the same album row we created in the previous example (in the</st> *<st c="19586">Observing the scroll view position</st>* <st c="19620">section).</st> <st c="19631">This time, we added a new view modifier to the view itself –</st> `<st c="19692">onScrollVisibilityChange</st>`<st c="19716">. This view modifier has two parameters –</st> `<st c="19758">threshold</st>` <st c="19767">and</st> `<st c="19772">closure</st>` <st c="19779">with a</st> `<st c="19787">Bool</st>` <st c="19791">parameter (named</st> `<st c="19809">visible</st>` <st c="19816">in our case).</st> <st c="19831">Let’s review</st> <st c="19844">them now:</st>

				*   `<st c="19853">threshold</st>`<st c="19863">: The</st> `<st c="19870">threshold</st>` <st c="19879">parameter defines how much the change must occur for the closure to run.</st> <st c="19953">For example, a threshold of 0.2 means that we need 20% of the view to be visible or hidden before it runs the closure and reports</st> <st c="20083">the change.</st>
				*   `<st c="20094">closure</st>`<st c="20102">: The closure with the</st> `<st c="20126">Bool</st>` <st c="20130">parameter runs each time the view reaches the threshold.</st> <st c="20188">The</st> `<st c="20192">Bool</st>` <st c="20196">parameter contains the change –</st> `<st c="20229">true</st>` <st c="20233">for visible and</st> `<st c="20250">false</st>` <st c="20255">for hidden.</st>

			<st c="20267">In our code example, we set the threshold to</st> `<st c="20313">0.9</st>`<st c="20316">. This means that we need to view it to reveal 90% of its size before the closure runs.</st> <st c="20404">Inside the closure, we check whether the view is visible before we report it to</st> <st c="20484">the console.</st>
			<st c="20496">We can use this view modifier for many purposes.</st> <st c="20546">For example, we can perform a specific animation when the view enters, load additional information, or adjust the screen interface if needed.</st> <st c="20688">Something that was complex to do before is now simple to accomplish</st> <st c="20755">using one</st> <st c="20766">view modifier.</st>
			<st c="20780">Scroll view is not the only topic we have more control of.</st> <st c="20840">Let’s talk</st> <st c="20851">about texts.</st>
			<st c="20863">Changing the text rendering behavior</st>
			<st c="20900">Handling texts on screen</st> <st c="20925">was also a very mature area where UIKit provided great frameworks such as TextKit.</st> <st c="21009">We could manipulate texts and create almost any effect that</st> <st c="21069">we wanted.</st>
			<st c="21079">In iOS 18, Apple introduced TextRenderer, a protocol that can help us change the default behavior of our texts</st> <st c="21191">in SwiftUI.</st>
			<st c="21202">Let’s say that we want a title with a different opacity for each line and even rotate the lines a bit.</st> <st c="21306">This creates a nice effect for the titles in our app.</st> <st c="21360">So, let’s see how to do that</st> <st c="21389">in SwiftUI:</st>

struct CustomTextRenderer: TextRenderer {

func draw(layout: Text.Layout, in ctx: inout

GraphicsContext) {

for (index, line) in layout.enumerated() {

ctx.opacity = Double(index + 1) * 0.1

ctx.rotate(by: Angle(degrees: Double(index) *

1))

ctx.draw(line)

}

}

} struct ContentView: View {

var body: some View {

    Text("SwiftUI 中的文本新增了众多新特性")

        .font(.system(size: 60)) <st c="21784">.textRenderer(CustomTextRenderer())</st> }

}


			<st c="21823">This code example has a new structure called</st> `<st c="21869">CustomTextRender</st>`<st c="21885">, which conforms to the</st> `<st c="21909">TextRenderer</st>` <st c="21921">protocol.</st> <st c="21932">We have one important function to implement – the</st> `<st c="21982">draw()</st>` <st c="21988">function.</st> <st c="21999">In this function, we receive an important parameter –</st> `<st c="22053">ctx</st>` <st c="22056">– the graphic context.</st> <st c="22080">The</st> `<st c="22084">TextRenderer</st>` <st c="22096">protocol also provides us access to the different lines and slices we have in our text.</st> <st c="22185">In our example, we can iterate the different lines using the</st> `<st c="22246">layout</st>` <st c="22252">parameter, change their opacity, and even</st> <st c="22295">rotate them.</st>
			<st c="22307">Once we have the</st> `<st c="22325">CustomTextRender</st>` <st c="22341">structure, we can</st> <st c="22359">add it to our</st> `<st c="22374">Text</st>` <st c="22378">component using the</st> `<st c="22399">textRenderer</st>` <st c="22411">view modifier.</st>
			<st c="22426">Let’s see how it looks (</st>*<st c="22451">Figure 1</st>**<st c="22460">.3</st>*<st c="22462">):</st>
			![Figure 1.3: The Text component with custom text rendering](https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_01_03.jpg)

			<st c="22467">Figure 1.3: The Text component with custom text rendering</st>
			*<st c="22524">Figure 1</st>**<st c="22533">.3</st>* <st c="22535">shows our text with a different opacity and rotation for each line.</st> <st c="22604">Adding effects to text can give a dynamic visualization</st> <st c="22659">for titles and paragraphs and add more life to</st> <st c="22707">our apps.</st>
			<st c="22716">Next, let’s see how SwiftUI has become more mature and capable than ever with positioning sub -iews from</st> <st c="22822">other views.</st>
			<st c="22834">Positioning sub-views from another view</st>
			<st c="22874">What does it mean to position</st> <st c="22904">sub-views from another view?</st> <st c="22934">While this description sounds weird and unclear, it is a nice addition to SwiftUI that can help us provide more dynamic and</st> <st c="23058">reusable content.</st>
			<st c="23075">To understand what it means, let’s take the following code as</st> <st c="23138">an example:</st>

struct NewsView: View {

var body: some View {

    Text("可再生能源领域取得重大突破:新型太阳能电池技术承诺提高 30%的效率")

    Text("全球市场对突然加息作出反应:股市全面下跌")

    Text("历史性的和平协议达成:领导人签署协议结束长达数十年的冲突")

    Text("创新人工智能工具革新医疗保健:医生拥抱机器学习进行诊断")

    Text("自然灾害袭击:沿海城市发生大规模地震,救援行动正在进行")

}

}


			<st c="23682">This code example shows a view called</st> `<st c="23721">NewsView</st>` <st c="23729">with a list of</st> `<st c="23745">Text</st>` <st c="23750">components, each containing a news headline.</st> <st c="23795">If we look closely, we can see that there’s no layout – no VStack, group, or List.</st> <st c="23878">We are not used to this in SwiftUI, and that’s okay because that view is</st> <st c="23951">for display.</st>
			<st c="23963">The</st> `<st c="23968">NewsView</st>` <st c="23976">job is to be a container</st> <st c="24001">for components.</st> <st c="24018">Let’s see how we can use</st> <st c="24043">this container:</st>

struct ContentView: View {

var body: some View {

    ScrollView {

        VStack {

            Text("最新头条")

                .font(.title)

Group(subviews: NewsView()) { collection in if let firstHeadline = collection.first

                {

                    firstHeadline

                        .font(.title2)

                    Spacer()

                }

                ForEach(collection.dropFirst()) {

                newsItem in

                    newsItem

                        .font(.headline)

                    Spacer()

                }

            }

        }

        .padding()

    }

}

}


			<st c="24399">In this example, we added a SwiftUI group, but this time, from the</st> `<st c="24467">NewsView</st>` <st c="24475">view:</st>

Group(subviews: NewsView()) { collection in


			<st c="24525">This line creates a group that iterates over the specific view’s sub-views and allows us to position and modify</st> <st c="24638">them ourselves.</st>
			<st c="24653">In our example, we change the font of the first sub-view and present all the views with a spacer</st> <st c="24751">between them.</st>
			<st c="24764">The ability to reposition views</st> <st c="24796">within other views unlocks new possibilities.</st> <st c="24843">For instance, we can reuse the same views but with different layouts, sequences, or styles.</st> <st c="24935">Treating our views as containers for smaller components makes our code</st> <st c="25006">more reusable.</st>
			<st c="25020">Now, let’s move to our chapter’s last section – the</st> <st c="25073">AI revolution.</st>
			<st c="25087">Entering the AI revolution</st>
			<st c="25114">AI and machine learning</st> <st c="25139">are not new areas for Apple and the iOS platform.</st> <st c="25189">Apple uses AI to adjust photos taken, suggest apps to users according to their usage, optimize battery charging, and</st> <st c="25306">many more.</st>
			<st c="25316">For developers, Apple provides the CoreML framework and tools such as Create ML to help users train and create their own machine</st> <st c="25446">learning models.</st>
			<st c="25462">However, the rising popularity of services such as ChatGPT and Gemini proved that CoreML is insufficient, and that Apple needs to integrate AI deeper into</st> <st c="25618">the system.</st>
			<st c="25629">So, what did Apple prepare for us, the developers, regarding AI in</st> <st c="25697">iOS 18?</st>
			<st c="25704">Apple integrated AI into iOS 18 by letting iOS understand what’s happening in the system and helping the user perform common tasks using natural language understanding, similar</st> <st c="25882">to ChatGPT.</st>
			<st c="25893">For example, let’s say we’re working on a word-processing app and created an App Intent that allows the user to add an image to</st> <st c="26022">a document.</st>
			<st c="26033">Until iOS 18, we would have had to define a specific phrase for the user to use with Siri.</st> <st c="26125">However, in iOS 18, the user can say something such as “Add this image to the page I’m working on,” and Siri uses a set of machine-learning models to convert this phrase to our app intent model.</st> <st c="26320">Not only that, but Siri can also understand the current context on screen and even search our app by indexing our app content in</st> <st c="26449">the spotlight.</st>
			<st c="26463">Integrating our app into Siri requires</st> <st c="26502">little effort.</st> <st c="26518">We mainly need to focus on structuring our main actions and entities.</st> <st c="26588">Apple Intelligence does all</st> <st c="26616">the rest.</st>
			<st c="26625">To read more about using App Intents with Siri, go to</st> *<st c="26680">Chapter 13</st>*<st c="26690">.</st>
			<st c="26691">Summary</st>
			<st c="26699">There’s no other way of looking at iOS 18 than as an exciting one.</st> <st c="26767">The addition of Apple Intelligence is only part of the story – Apple took care of many system and SDK aspects such as testing, persistent data, UI,</st> <st c="26915">and more.</st>
			<st c="26924">In this chapter, we explored the basics of the new Swift Testing framework, learned about Swift Data improvements, and discussed enhancements in SwiftUI such as zoom transition, floating tab bar, scroll views, and text rendering.</st> <st c="27155">We even scratched the surface of Apple Intelligence and tried to understand how it is integrated with App Intents.</st> <st c="27270">By now, you should be familiar with the most exciting and new topics in</st> <st c="27342">iOS 18.</st>
			<st c="27349">A few code examples are just not enough.</st> <st c="27391">We are developers, and we need more!</st> <st c="27428">So, let’s jump straight into SwiftData and explore Apple’s new persistent data framework in the</st> <st c="27524">next chapter.</st>

第三章:2</st c="0">

使用 SwiftData 简化我们的实体</st c="2">

让我们以苹果在过去几年中发布的最重要和最有用的框架之一——</st c="41">SwiftData</st c="182">——开始我们的 iOS 18 精通之旅。</st c="182">

SwiftData 是 Swift 宏使用的优秀示例,将老旧且深受喜爱的 Core Data 框架提升到了一个全新的简单化水平,并使其适应 Swift 和声明式编程</st c="380">的现代世界。</st c="209">

在本章中,我们将做以下事情:</st c="404">

  • 了解 SwiftData 的</st c="447">背景</st c="463"></st c="463">

  • 定义数据模型,包括其</st c="483">关系</st c="533">和属性</st c="533"></st c="533">

  • 了解 SwiftData 容器</st c="547">和配置</st c="584"></st c="584">

  • 使用模型上下文</st c="602">检索和操作数据</st c="639"></st c="639">

  • 将我们的数据迁移到新的</st c="652">版本模式</st c="677"></st c="677">

这将是一次漫长的旅程,将会有一个令人兴奋的新框架!</st c="692">因此,在技术要求之后,让我们从框架的背景开始。</st c="754">框架</st c="828">

技术要求</st c="842">

本章包含许多代码示例,其中一些可以在以下</st c="865">GitHub 仓库</st c="952">中找到:</st c="952">

[https://github.com/PacktPublishing/Mastering-iOS-18-Development/tree/main/Chapter 2</st c="970"]](https://github.com/PacktPublishing/Mastering-iOS-18-Development/tree/main/Chapter 2)

要运行它们,我们需要 Xcode 16</st c="1056">或更新的版本。</st c="1092">

理解 SwiftData 的背景</st c="1101">

要了解 SwiftData 的背景及其根源,重要的是要退一步</st c="1138">了解</st c="1234">Core</st c="1250">数据</st c="1250">框架</st c="1255"></st c="1255"></st c="1259">

Core Data 多年来一直是苹果平台的主要数据框架,甚至在 iOS</st c="1270">诞生之前。</st c="1369">

Core Data 是在 iOS 3 中添加到 iOS 的,它将处理数据图的能力灵活且高效地带给移动设备。</st c="1378">请注意,我没有提到单词</st c="1504">数据库</st c="1543">或</st c="1504">持久性</st c="1551">,这有一个很好的原因。</st c="1555">我们应该记住,Core Data 不是一个</st c="1598">SQLite</st c="1642">包装器,尽管其持久存储在大多数情况下基于 SQLite。</st c="1648">Core Data 的主要目标是处理我们应用的</st c="1725">数据层</st c="1778"></st c="1778">

但处理应用程序的数据层意味着什么呢? 嗯,大多数应用程序都与几个层一起工作——UI、业务逻辑和数据层。 数据层建立在定义我们应用程序所处理的核心项目的数据实体之上。 例如,一个待办事项应用程序可以具有诸如 列表 任务 提醒之类的实体。 一个音乐应用程序可以具有诸如 专辑 歌曲 播放列表之类的实体。

数据层定义了不同的实体以及它们之间的关系。 例如,一个专辑可以包含许多歌曲,一个列表可以包含许多任务。 如果需要持久化,数据层也处理了不同实体的数据如何保存到磁盘上的问题。 根据我们对数据层的理解,Core Data 通过定义其数据模型、处理持久化、迁移甚至撤销操作来履行其作为应用程序数据层的角色。 所以,如果 Core Data 是处理数据如此出色的框架,为什么我们 还需要 SwiftData?

Core Data 是一个伟大的框架,但它是为不同的时代设计的,当时我们使用 Objective-C 进行编码,而 UIKit 甚至还没有被创建。 从那时起,iOS 开发世界已经发生了显著变化——我们现在有了 Swift,而且更重要的是,我们有了 SwiftUI。 尽管 Core Data 已经更新以支持 Swift 和 SwiftUI,但它仍然在类型安全、多线程和声明式编程的世界中感觉过时。 在 Core Data 中获取和观察数据变化变得繁琐,因为我们使用了更适合 UIKit/Objective-C 时代的设计模式。 在这种情况下,SwiftData 承诺带来一个现代、直接的框架来更完美地处理数据,利用 Swift 和 Combine 的全部力量。

SwiftData 最好的事情之一是它使用了 Swift 宏 ——我们在 第十章中学到的相同的 Swift 宏。 这些宏帮助我们优雅地实现 SwiftData,而无需使用 样板代码。

是时候进入商业领域并创建我们的第一个 SwiftData 模型了!

定义 SwiftData 模型

通常,在讨论数据框架时,我们通常会从基本设置开始。 然而,这次,我们将从模型本身开始。 为什么是那样呢? 因为我想要展示如何简单地将现有的数据模型转换为 SwiftData 模型,使用以下代码片段:

<st c="4058">import SwiftData</st>
<st c="4075">@Model</st> class Book {
    var author: String
    var title: String
    var publishedDate: Date
    init(author: String, title: String, publishedDate:
      Date) {
        self.author = author
        self.title = title
        self.publishedDate = publishedDate
    }
}

在这段代码中,我们看到一个标准的 <st c="4327">Book</st> ,添加了一个名为 <st c="4374">@Model</st>的宏。在我们展开 <st c="4403">@Model</st> 宏并查看它确切地做了什么之前,让我们关注当我们 添加它时会发生什么。

添加 <st c="4503">@Model</st> 就足以将一个常规类转换为带有持久存储的模型。 类似于 Core Data 实体的工作方式,类名是实体名,其变量是 <st c="4705">实体属性。</st>

当我们 将其与 Core Data 进行比较时,我们可以看到模型声明过程是相反的——在 Core Data 中,我们在模型编辑器中声明模型,然后生成其类,而在 SwiftData 中,我们取一个常规类并将其 转换为模型。

但是 <st c="4984">@Model</st> 宏实际上做了什么? 让我们展开它 并看看。

展开 @Model 宏

我们已经 知道了 Swift 宏的能力,SwiftData 是探索一种新的 宏实现 的好机会。

要展开宏,我们可以右键单击 <st c="5228">@Model</st> 名称,并从弹出菜单中选择展开宏 现在类体看起来 是这样的:

 @Transient
private var _$backingData: any SwiftData.BackingData<Book>
  = Book.createBackingData()
public var persistentBackingData: any
  SwiftData.BackingData<Book> {
    get {
        _$backingData
    }
    set {
        _$backingData = newValue
    }
}
static var schemaMetadata:
  [SwiftData.Schema.PropertyMetadata] {
  return [
    SwiftData.Schema.PropertyMetadata(name: "author",
      keypath: \Book.author, defaultValue: nil, metadata:
      nil),
    SwiftData.Schema.PropertyMetadata(name: "title",
      keypath: \Book.title, defaultValue: nil, metadata:
      nil),
    SwiftData.Schema.PropertyMetadata(name:
      "publishedDate", keypath: \Book.publishedDate,
      defaultValue: nil, metadata: nil)
  ]
}
required init(backingData: any SwiftData.BackingData<Book>) {
  _author = _SwiftDataNoType()
  _title = _SwiftDataNoType()
  _publishedDate = _SwiftDataNoType()
  self.persistentBackingData = backingData
}
@Transient
private let _$observationRegistrar = Observation.ObservationRegistrar()
struct _SwiftDataNoType {
}
extension Book: SwiftData.PersistentModel {
}
extension Book: Observation.Observable {
}

那么,我们的美丽且简约的 <st c="6402">Book</st> 发生了什么看起来 <st c="6434">@Model</st> 在这里非常 活跃。

为了简化,让我们尝试将其 分解:

  • <st c="6523">Book</st> <st c="6598">PersistentModel</st> <st c="6613">和</st> Observable PersistentModel 协议帮助 SwiftData 与我们的风格协同工作并访问其属性。 <st c="6722">Observable</st> <st c="6736">协议允许我们通知数据的</st> 更改。`

  • <st c="6914">PersistentModel</st> 协议是,我们将发现它需要实现两个变量——<st c="7014">backingData</st> <st c="7031">schemaMetaData</st>。我们可以在我们的宏展开代码中直接看到它们的实现。 这些变量帮助 SwiftData 专门为我们存储和检索我们的实体信息。 并且也许这正是 Swift 宏真正强大的地方——能够生成针对 我们的类定制的代码。

  • 我们有属性宏:如果我们查看类属性,我们可以看到它们现在有自己的宏。 展开它们会显示它们现在已成为计算变量,因此我们可以从我们的内存以及我们的后端 数据存储中存储和检索数据:

    <st c="7635">@_PersistedProperty</st> var author: String <st c="7675">@_PersistedProperty</st> var title: String <st c="7713">@_PersistedProperty</st> var publishedDate: Date
    

额外的代码行 将所有内容组合在一起,例如观察和 注册属性。

这很复杂吗? 有一点。 但这就是拥有宏的好处之一——简化复杂的实现。 重要的是要理解,每个带有 <st c="8048">@Model</st> 宏的类立即获得自己的存储,并添加到 SwiftData 模式。

然而,要添加更复杂的数据模型,我们需要能够定义模型之间的关系。 让我们看看它是如何工作的。

添加关系

与现实生活不同,在 SwiftData 中,关系 很简单。

一个 关系 是一个数据库模式,它定义了实体如何相互关联,在 Core Data 中,我们有两种类型的关系—— 一对一 多对多。简而言之,一个 一对一* 关系意味着对于每个实体实例,我们都会有一个其他类型的实例。 一个例子就是汽车和引擎——每辆汽车都有一台,且只有一台引擎,因此它们之间的关系将是一个 一对一* 关系。 然而,汽车和轮子有一个 多对多* 关系,因为一辆汽车可以有 多个轮子。

尽管解释足够简单,但在 SwiftData 中,它变得更简单。 如果我们想在模型之间定义关系,我们只需要创建另一个变量,就像 这里 所示:

 @Model
class Book {
    var title: String
    var publishedDate: Date <st c="9127">var author: Author</st><st c="9145">var pages: [Page]</st> init(author: Author, title: String, publishedDate:
      Date) {
        self.title = title
        self.publishedDate = publishedDate <st c="9277">self.author = author</st><st c="9297">self.pages = []</st> }
}

在我们的 示例中,我们向 <st c="9379">Book</st> 类中添加了以下两个属性:

  • <st c="9390">Author</st>:这是一个 *一对多 关系到 <st c="9437">Author</st> 实体,因为在我们这个例子中,每本书只有一个 作者

  • <st c="9502">页数</st>:在 <st c="9526">Page</st> 实体的情况下,我们 有一个 *多对多 关系,因为一本书可以包含 多个页面

有一点需要注意,我们还需要用 <st c="9663">Page</st> <st c="9672">Author</st> 实体标记上 <st c="9697">@Model</st> 宏,因为它们必须是我们模式的一部分。 这可以在以下代码中看到:

<st c="9786">@Model</st> class Author {
    var name: String
    init() {
        self.name = ""
    }
} <st c="9854">@Model</st> class Page {
    var content: String
    var order: Int
    init(content: String, order: Int) {
        self.content = content
        self.order = order
    }
}

添加模型这么简单吗? 简短的答案是,是的! 在 SwiftData 中将实体相互链接与添加一个属性一样简单。 Linking entities to each other in SwiftData is as easy as adding a property.

更长的答案是,嗯,我们需要做一些额外的工作来稍微定制一下这些关系。 让我们来认识一下 <st c="10238">@</st>``<st c="10239">关系</st> 宏。

如果你熟悉 Core Data 关系,你可能知道除了声明 *多对多 一对多.

多对多和一对多关系

一对多关系 表示实体之间的关联,其中 一个实体的一个实例与另一个不同实体的单个实例相关联。 相反,多对多关系表示关联,其中一个实体的一个实例可以与另一个实体的多个实例相关联。 例如,在一个书店数据库中,一对多关系可以将“书”实体与“作者”实体连接起来,因为每本书只有一个作者。 相比之下,多对多关系可以将“书”实体与“类别”实体连接起来,因为一本书可以属于 多个类别。

我们可以通过使用 <st c="11028">@Relationship</st> 宏来以两种主要方式自定义我们的关系。

让我们从定义 删除规则。

SwiftData 关系删除规则

如果我们删除一本书, <st c="11169">页面</st> <st c="11179">作者</st> 实体会发生什么? 从逻辑上讲,所有书籍页面 都需要被删除,但作者需要保留,因为他们可能与其他书籍相关联。 我们可以用 删除规则来表示这种逻辑;如果你熟悉 Core Data,它基本上与 SwiftData 相同。

这就是我们如何在 SwiftData 中定义属性 的逻辑:

<st c="11653">cascade</st>.
			<st c="11661">We have four different</st> <st c="11685">deletion rules:</st>

				*   `<st c="11700">cascade</st>`<st c="11708">: Deletes any</st> <st c="11723">related objects</st>
				*   `<st c="11738">deny</st>`<st c="11743">: Prevents deletion of an object if it contains one or more references to</st> <st c="11818">other objects</st>
				*   `<st c="11831">nullify</st>`<st c="11839">: Nullifies the related object’s reference to the</st> <st c="11890">deleted object</st>
				*   `<st c="11904">noAction</st>`<st c="11913">: In this case, nothing will happen to the</st> <st c="11957">other object</st>

			<st c="11969">We should remember that a deletio</st><st c="12003">n rule is not arbitrary; it should be based on our app</st> <st c="12059">business ideas.</st>
			<st c="12074">For example, the reason why a book has a</st> *<st c="12116">to-one</st>* <st c="12122">connection to an author sounds logical, but there are books with co-authors as well.</st> <st c="12208">So, this is something that should be aligned with our</st> <st c="12262">product manager.</st>
			<st c="12278">Most of us</st> <st c="12290">are more familiar with the term</st> *<st c="12322">one-to-many</st>* <st c="12333">than</st> *<st c="12339">to-many</st>*<st c="12346">. This is because relationships between objects go both ways – the fact that each book has one author doesn’t mean that each author has only one book.</st> <st c="12497">So, as part of th</st><st c="12514">e relationship definition, we also need to define its</st> <st c="12569">inverse relationship.</st>
			<st c="12590">Defining the inverse relationship</st>
			<st c="12624">Why do we need to define the inverse relationship?</st> <st c="12676">We need to realize that relationships</st> <st c="12714">always have two sides (like in real life!), and we need to maintain them to have a proper</st> <st c="12804">data schema.</st>
			<st c="12816">When establishing a relationship between a book and its pages, it’s better to define the inverse relationship as well.</st> <st c="12936">This way, we can create a proper reference back to</st> <st c="12987">the book.</st>
			<st c="12996">Let’s see how to create an inverse relationship between a book and its pages through the</st> <st c="13086">following code:</st>

@模型

class Book {

@关系(inverse: \Page.book) var pages: [Page] =

[]

}

@模型

class Page {

var content: String <st c="13225">var book: Book?</st> init(content: String) {

    self.content = content

}

}


			<st c="13291">Looking at the code, we can see that we define the relationship as</st> <st c="13359">a keypath:</st>

\Page.book


			<st c="13380">A keypath can</st> <st c="13395">help us avoid typos and mistakes when defining the</st> <st c="13446">inverse property.</st>
			<st c="13463">Moreover, if we add a new page to the</st> `<st c="13502">pages</st>` <st c="13507">property, SwiftData will automatically set the Page’s</st> `<st c="13562">book</st>` <st c="13566">property to the</st> <st c="13583">new book:</st>

let newPage = Page(content: "Swift 数据")

newPage.book = book

// book.pages 属性 包含 'newPage'


			<st c="13696">SwiftData knows how to do that using our</st> <st c="13738">inverse declaration.</st>
			<st c="13758">The inverse relationship may sound like an obvious feature – if we have a book with several pages, and each page is related to a book, isn’t it obvious that the</st> `<st c="13920">book</st>` <st c="13924">property in the</st> `<st c="13941">page</st>` <st c="13945">class is the inverse relationship?</st> <st c="13981">However, in reality, it’s not obvious.</st> <st c="14020">There are several real-world use cases when relationships can be much</st> <st c="14090">more complex.</st>
			<st c="14103">Let’s take, for example, the data structure of a folder tree – each folder has its sub-folders.</st> <st c="14200">This means that a folder has a</st> *<st c="14231">to-one</st>* <st c="14237">relationship to its parent and a</st> *<st c="14271">to-many</st>* <st c="14278">relationship to its children.</st> <st c="14309">Let’s see that in</st> <st c="14327">the code:</st>

@模型

class Folder {

var parent: Folder? @关系(inverse: \Folder.parent) var subFolders:

[文件夹]

var name: String

var id: UUID

init(parent: Folder? = nil, subFolders: [Folder], name:

String, id: UUID) {

    self.parent = parent

    self.subFolders = subFolders

    self.name = name

    self.id = id

}

}


			<st c="14632">This example</st> <st c="14646">demonstrates what a</st> `<st c="14666">Folder</st>` <st c="14672">class looks like when trying to create a multi-level hierarchical structure.</st> <st c="14750">In this case, we must define the inverse relationship to</st> <st c="14807">avoid cycles.</st>
			<st c="14820">Now that we know how relationships work in SwiftData, let’s see more ways to customize our model, using the</st> `<st c="14929">@</st>``<st c="14930">Attribute</st>` <st c="14939">macro.</st>
			<st c="14946">Adding the @Attribute macro</st>
			<st c="14974">So far, we have learned how to declare new entities, properties, and even relationships between</st> <st c="15071">our entities.</st> <st c="15085">It looks like we can do anything with our data entities!</st> <st c="15142">Now, it’s essential to drill down to the</st> <st c="15183">property level.</st>
			<st c="15198">Along with our</st> `<st c="15214">@Model</st>` <st c="15220">and</st> `<st c="15225">@Relationship</st>` <st c="15238">macros, we now have the</st> `<st c="15263">@Attribute</st>` <st c="15273">macro to define the behavior of a</st> <st c="15308">specific property.</st>
			<st c="15326">If you remember from Core Data, each attribute has an inspector window where we can configure an attribute’s behavior (</st>*<st c="15446">Figure 2</st>**<st c="15455">.1</st>*<st c="15457">):</st>
			![Figure 2.1: The Attribute inspector in Core Data](https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_02_1.jpg)

			<st c="15805">Figure 2.1: The Attribute inspector in Core Data</st>
			*<st c="15853">Figure 2</st>**<st c="15862">.1</st>* <st c="15864">shows what it looks like when we select one of the attributes (</st>`<st c="15928">firstName</st>` <st c="15938">in this example) and how we can customize</st> <st c="15981">its behavior.</st>
			<st c="15994">We can</st> <st c="16002">define some of these settings in SwiftData as part of the property declaration.</st> <st c="16082">For example, the Optional feature, as seen in</st> *<st c="16128">Figure 2</st>**<st c="16136">.1</st>*<st c="16138">, is defined by marking a property as Swift optional type, and the default value is part of</st> <st c="16230">variable initialization:</st>

var firstName: String? = "MyName"


			<st c="16288">However, other settings need to be declared as part of the</st> `<st c="16348">@</st>``<st c="16349">Attribute</st>` <st c="16358">macro.</st>
			<st c="16365">Let’s start with the most common one,</st> `<st c="16404">unique</st>`<st c="16410">, and making attributes unique is an important feature of many databases,</st> <st c="16484">including</st> *<st c="16494">SQLite</st>*<st c="16500">.</st>
			<st c="16501">The following are a few</st> <st c="16526">reasons why:</st>

				*   **<st c="16538">Setting up a primary key</st>**<st c="16563">: A primary key represents a record’s unique identifier.</st> <st c="16621">We use a primary key to ensure that there are no duplicates in</st> <st c="16684">our table.</st>
				*   **<st c="16694">Supporting indexing</st>**<st c="16714">: Unique attributes can help us index our database for searching</st> <st c="16780">and retrieval.</st>
				*   **<st c="16794">Helping with data validation</st>**<st c="16823">: Utilizing unique attributes goes beyond primary keys and extends to other distinctive attributes, enhancing our ability to validate data</st> <st c="16963">during insertion.</st>

			<st c="16980">Even though</st> *<st c="16993">SQLite</st>* <st c="16999">supports unique attributes, Core Data doesn’t have a built-in way to support</st> <st c="17077">unique identifiers, derived from its design philosophy to offer complete flexibility</st> <st c="17162">to developers.</st>
			<st c="17176">Conversely, SwiftData supports unique attributes out of</st> <st c="17233">the box:</st>

@属性 宏与 .unique 选项使我们的数据库特定属性值唯一。

        `<st c="17389">UUID</st>` <st c="17394">是属性唯一值的经典示例,但我们可以将其应用于任何其他类型的属性,例如用户 ID</st> <st c="17520">和名称。</st>

        <st c="17530">但是,将属性设置为</st> *<st c="17579">唯一</st>*<st c="17585">究竟意味着什么?当我们尝试插入一个已经存在</st> <st c="17663">唯一属性</st>的实例时,会发生什么?

        <st c="17680">在唯一属性的情况下,SwiftData 执行</st> `<st c="17769">INSERT</st>` <st c="17775">或</st> `<st c="17779">UPDATE</st>` <st c="17785">操作。</st> <st c="17797">这意味着如果已存在具有唯一值的实例,SwiftData 将不会在其存储中创建新对象,而是更新</st> <st c="17940">现有实例。</st>

        使用 `<st c="17958">@Attribute</st>` <st c="18000">宏将属性声明为唯一属性是直接的。</st> <st c="18037">然而,有时我们需要更复杂的功能。</st> <st c="18094">例如,假设我们有一个</st> `<st c="18127">Book</st>` <st c="18131">模型,具有</st> `<st c="18143">name</st>` <st c="18147">和</st> `<st c="18152">publicationName</st>` <st c="18167">属性。</st> <st c="18180">在我们的情况下,我们可以有两个同名或同</st> `<st c="18246">publicationName</st>`<st c="18261">的书籍,但我们不能有两个属性完全相同的书籍。</st> <st c="18327">`<st c="18346">publicationName</st>` <st c="18361">和</st> `<st c="18366">name</st>` <st c="18370">的组合构成了书籍的唯一标识。</st>

        <st c="18406">一个解决方案是维护一个属性,尝试从这两个属性中构建一个唯一的 ID。</st> <st c="18505">另一个优雅的选项是使用</st> `<st c="18542">#Unique</st>` <st c="18549">宏来定义更复杂的</st> `<st c="18579">唯一性要求:</st>
 @Model
class Book { <st c="18624">#Unique<Book>([\.name, \.publicationName])</st> var publicationName: String = "Packt"
    var name: String
}
        <st c="18723">在这个代码示例中,我们通过组合两个键路径来强制执行</st> `<st c="18779">Book</st>` <st c="18783">模型的唯一性。</st> <st c="18818">就像属性参数一样,</st> `<st c="18853">.unique</st>`<st c="18860">,如果我们尝试插入一个新书籍实例,而我们已经有了一个同名和出版名称相同的实例,SwiftData 将执行一个</st> `<st c="18994">upsert</st>` <st c="19000">操作并更新</st> `<st c="19022">该实例。</st>

        <st c="19036">尽管 SwiftData 处理唯一属性很好,但确保我们根据应用程序的要求仔细选择唯一属性和键路径是很重要的。</st> <st c="19211">过多的唯一属性可能导致复杂性和</st> <st c="19263">性能问题。</st>

        <st c="19282">唯一属性非常适合简化处理重复实例的任务。</st> <st c="19369">另一个可以简化我们生活的属性特性是</st> *<st c="19429">瞬态</st>*<st c="19438">。</st>

        <st c="19439">使用瞬态属性进行非持久化</st>

        <st c="19475">与 SwiftData 一起工作的好处是,所有属性都自动成为实体的</st> <st c="19577">属性,并且被持久保存到本地数据存储中。</st> <st c="19640">然而,有时,我们可能想要一个仅存在于内存中而不被持久保存的属性。</st> <st c="19717">内存中</st> *<st c="19728">并且不持久保存</st> *<st c="19757">的属性是一个很好的例子。</st> <st c="19842">实现这一目标的一种方法是创建一个函数或计算变量,然后根据相关属性返回一个值。</st> <st c="19973">然而,在其他情况下,计算变量或函数可能不是方便的解决方案。</st> <st c="20074">假设我们想要一个临时的计数器或维护一个仅与应用程序当前</st> <st c="20176">生命周期相关的标志。</st>

        <st c="20187">对于这类情况,我们</st> <st c="20217">有一个</st> *<st c="20224">瞬态</st> *<st c="20233">属性。</st> <st c="20245">瞬态属性不是一个新概念——Core Data 从早期版本就支持瞬态属性。</st> <st c="20345">由于 SwiftData 基于 Core Data 的基本原理,它默认支持瞬态属性。</st>

        <st c="20445">以下是我们在 SwiftData 中声明</st> *<st c="20471">瞬态</st> *<st c="20480">属性</st> <st c="20490">的方法:</st>
 @Transient
var openCounter: Int = 0
        <st c="20539">在这个代码片段中,</st> `<st c="20566">openCounter</st>` <st c="20577">变量不会被保存到本地持久</st> <st c="20631">存储中,并且每次我们从</st> <st c="20696">我们的数据库中检索实体时都会重新初始化。</st>

        <st c="20709">瞬态属性可能听起来是一个小功能,但在许多情况下,它确实能带来差异,瞬态宏提供了这种灵活性。</st> <st c="20877">全名或计算年龄是很好的例子</st> <st c="20926">。</st>

        <st c="20934">探索容器</st>

        <st c="20958">到目前为止,我们讨论了如何使用</st> `<st c="21031">@Model</st>` <st c="21037">宏声明不同的实体,使用</st> `<st c="21082">@Relationship</st>` <st c="21095">宏定义它们的关系,以及使用</st> `<st c="21144">@</st>``<st c="21145">Attribute</st>` <st c="21154">宏自定义它们的属性。</st>

        <st c="21161">然而,我们还没有讨论如何设置 SwiftData 与模式和一个</st> <st c="21241">持久存储</st>一起工作。</st>

        <st c="21258">当我们深入研究</st> `<st c="21283">@Model</st>` <st c="21289">宏时,与 Core Data 的比较是直接的,并且现在仍然是如此。</st> <st c="21370">在 Core Data 中,我们使用</st> `<st c="21410">NSPersistentContainer</st>`<st c="21431">设置堆栈,它将数据模型、存储和上下文等不同组件封装到一个我们可以</st> <st c="21557">与之工作的堆栈中。</st>

        <st c="21567">在 SwiftData 中,我们使用</st> `<st c="21589">模型容器</st>`<st c="21603">,它具有相同的职责。</st>

        <st c="21639">让我们尝试理解它是如何工作的。</st> <st c="21668">。</st>

        <st c="21677">设置模型容器</st>

        `<st c="21703">模型容器</st>` <st c="21718">对于使用 SwiftData 至关重要。</st> <st c="21732">原因是 SwiftData 有三个主要组件,容器将它们封装并</st> <st c="21851">一起包装:</st>

            +   `<st c="21962">@Model</st>` <st c="21968">宏到</st> <st c="21978">我们的实体</st>

            +   **<st c="21990">存储</st>**<st c="22000">:我们将保存数据的后端存储

            +   **<st c="22048">上下文</st>**<st c="22060">:这是我们与存储和沙盒的链接,我们可以添加、编辑和删除</st> <st c="22145">不同的记录</st>

        <st c="22162">以下是以基本和最小的方式创建</st> <st c="22207">容器的方法:</st>
 var container: ModelContainer = {
        do {
            return try <st c="22271">ModelContainer</st>(for:
              Schema([<st c="22300">Book.self, Author.self, Page.self</st>]) )
        } catch {
            fatalError("Could not create ModelContainer:
              \(error)")
        }
    }()
        <st c="22411">在这段代码中,我们从一个</st> `<st c="22455">模型容器</st>` <st c="22469">类型创建一个对象,并为其提供我们在</st> *<st c="22535">定义 SwiftData</st>* *<st c="22556">模型</st>* <st c="22561">部分中早先创建的三个模型。</st>

        <st c="22570">请注意,在我们的情况下,我们有一个参数,</st> `<st c="22618">模式</st>`<st c="22624">,它包含与我们的容器相关的所有不同模型 –</st> `<st c="22691">书籍</st>`<st c="22695">,`<st c="22697">作者</st>`<st c="22703">,`<st c="22705">和</st> `<st c="22709">页面</st>`<st c="22713">。</st>

        <st c="22714">我们需要提供一个模型列表的事实可能会让人感到惊讶 – 为什么我们需要这样做呢?</st> <st c="22818">Xcode 不能定位所有模型并自动将它们添加进去吗?</st> <st c="22880">的确,</st> `<st c="22884">@Model</st>` <st c="22890">宏在编译时扩展代码,但这并不意味着 SwiftData 在应用运行开始时设置时就知道我们所有的实体。</st> <st c="23044">因此,每次我们添加一个新的模型时,我们必须将其添加到我们的</st> `<st c="23121">模式</st>` <st c="23127">参数中的模型列表中。</st>

        <st c="23138">关于独立包含</st> `<st c="23163">书籍</st>` <st c="23167">实体,而不是</st> `<st c="23198">作者</st>` <st c="23204">实体 – 当我们将</st> `<st c="23230">书籍</st>` <st c="23234">模型添加到模型列表中时,它自动包含所有相关模型,包括那些与</st> <st c="23369">层次结构中更下方的模型</st> <st c="23380">相关的模型。</st> <st c="23380">这意味着,从理论上讲,我们可以在进行类似操作时只包含根对象:</st>
 Schema([Book.self])
        <st c="23498">这</st> <st c="23508">就足够包含</st> `<st c="23534">作者</st>` <st c="23540">和</st> `<st c="23545">页面</st>`<st c="23549">。</st>

        <st c="23550">所以,我们将如何使用我们刚刚创建的容器实例呢?</st> <st c="23616">让我们在下一节中看看。</st> <st c="23633">译文:</st>

        <st c="23646">使用模型容器修饰符连接容器</st>

        <st c="23705">现在我们有了模型容器,我们希望以某种方式将其与我们的 UI 连接起来,以便我们可以开始使用它。</st> <st c="23715">译文:</st>

        <st c="23809">为了做到这一点,我们将使用`<st c="23838">modelContainer</st>` `<st c="23852">修饰符将容器连接到我们的场景:</st> <st c="23890">译文:</st>
 var body: some Scene {
        WindowGroup {
            ContentView()
        } <st c="23954">.modelContainer(container)</st> }
        <st c="23982">在我们的代码示例中,我们将`<st c="24014">modelContainer</st>` `<st c="24028">修饰符添加到我们的`<st c="24045">WindowGroup</st>` `<st c="24056">中,使其在整个应用程序中可用。</st> <st c="24090">译文:</st>

        <st c="24100">我们不需要创建连接器并将其连接到`<st c="24154">WindowGroup</st>`,我们可以使用另一个`<st c="24186">modeContainer</st>` `<st c="24199">init</st>` `<st c="24204">方法,并仅传递实体列表:</st> <st c="24235">译文:</st>
 .modelContainer(for: [Book.self, Author.self, Page.self])
        <st c="24304">传递实体列表可以是一种简单且易于设置容器的方法。</st> <st c="24390">那么,为什么我们需要</st> `<st c="24413">ModelContainer</st>` <st c="24427">类呢?</st> <st c="24435">简单的回答是,一如既往,为了提供更多的定制。</st> <st c="24499">让我们</st> <st c="24505">看看吧!</st> <st c="24513">译文:</st>

        <st c="24513">译文:</st>

        <st c="24545">`<st c="24550">ModelContainer</st>` `<st c="24564">不仅提供了模式传递的功能;它还赋予我们配置特定模型的`<st c="24638">SwiftData</st>` `<st c="24647">存储并对其进行定制以满足我们`<st c="24698">特定需求`的能力。</st> <st c="24705">译文:</st>

        <st c="24722">为了做到这一点,我们将使用`<st c="24751">ModelConfiguration</st>` `<st c="24769">结构体,如下所示:</st> <st c="24778">译文:</st>
 var modelContainer: ModelContainer = {
        do {
            let schema = Schema([Book.self, Author.self,
              Page.self]) <st c="24891">let modelConfiguration =</st>
 <st c="24915">ModelConfiguration(schema: schema,</st>
 <st c="24950">isStoredInMemoryOnly: true)</st> return try ModelContainer(for: schema, <st c="25018">configurations: [modelConfiguration]</st>)
        } catch {
            fatalError("Could not create ModelContainer:
              \(error)")
        }
    }()
        <st c="25128">让我们尝试理解这个代码片段中正在发生的事情。</st> <st c="25193">首先,我们创建一个包含我们模型列表的模式。</st> <st c="25246">然后,我们声明一个模型配置结构体,传递模式,并将其后端存储设置为内存。</st> <st c="25350">最后,我们根据我们的模式和刚刚创建的配置集合返回一个模型容器。</st> <st c="25449">译文:</st>

        <st c="25462">整个过程感觉有点笨拙、笨拙和重复——如果我们再次传递相同的模式,为什么还需要创建配置呢?</st> <st c="25613">而且为什么它是集合形式呢?</st> <st c="25634">主要配置思想是为不同的模型集合提供不同的行为。</st> <st c="25715">译文:</st>

        <st c="25725">这里有一个例子。</st> <st c="25745">想象一下,我们有一个头脑风暴草图应用。</st> <st c="25786">我们想在应用程序的持久存储中绘制并存储我们的概念,而白板画布上的所有绘图都保留在内存中。</st> <st c="25910">译文:</st>

        在此情况下,我们可以<st c="25920">创建两个配置,一个用于内存,一个用于</st> <st c="25942">持久存储和</st> **<st c="26022">CloudKit</st>** <st c="26030">集成:</st>
 var modelContainer: ModelContainer = {
      do {
          let <st c="26092">brainstormDataConfiguration</st> =
            ModelConfiguration("brainstorm_configuration",
            schema: schemaForBrainstorm,
            isStoredInMemoryOnly: true)
          let <st c="26230">projectsDataConfiguration</st> =
            ModelConfiguration("projects_configuration",
            schema: schemaForProjects,
            cloudKitDatabase: .automatic)
          return try ModelContainer(for: fullSchema,
            configurations: [<st c="26420">brainstormDataConfiguration,</st>
 <st c="26449">projectsDataConfiguration</st>])
        } catch {
            fatalError("Could not create ModelContainer:
              \(error)")
        }
    }()
        <st c="26550">在我们的例子中,我们创建了两个不同的模式——一个用于头脑风暴的模型列表和一个用于</st> <st c="26666">用户项目的模型列表。</st>

        <st c="26680">基于这些模型,我们创建了两个不同的配置。</st> <st c="26744">头脑风暴配置保存在内存中,而项目配置保存在本地并同步到</st> <st c="26860">CloudKit。</st>

        <st c="26872">使用两个不同的配置和两个应用程序功能的模式是模型配置使用的绝佳例子。</st> <st c="26997">我们可以使用模型配置进行额外的自定义,例如</st> <st c="27070">以下内容:</st>

            +   <st c="27084">不同的</st> <st c="27095">存储文件</st>

            +   <st c="27106">不同的</st> <st c="27117">组容器</st>

            +   <st c="27133">不同的</st> <st c="27144">自动保存机制</st>

        <st c="27166">然而,假设我们不需要模型配置来为不同的模型组配置不同的行为</st> <st c="27260">。</st> <st c="27292">在这种情况下,我们可以直接与模型容器一起工作,并使用整个模式</st> <st c="27377">来初始化它。</st>

        <st c="27391">我们现在知道如何声明和分组我们的模型以用于模型容器中的模式。</st> <st c="27486">但还有一个关键的东西缺失——如何插入、更新和获取数据。</st> <st c="27565">我们将通过放置拼图中的缺失部分——</st> <st c="27626">上下文。</st> 来完成这些操作。

        <st c="27638">使用模型上下文获取和操作我们的数据</st>

        <st c="27693">熟悉 Core Data 的开发者也熟悉**<st c="27764">上下文</st>**<st c="27771">的概念。上下文是我们的数据</st> <st c="27793">沙盒。</st> <st c="27802">这是我们可以操作和获取数据的地方,也是我们模型和</st> <st c="27844">持久存储之间的</st> <st c="27878">链接。</st>

        <st c="27922">要访问我们的上下文以从我们的 SwiftUI 视图中获取,我们可以使用一个名为</st> <st c="28011">modelContext</st> <st c="28029">的环境变量:</st>
 struct ContentView: View { <st c="28059">@Environment(\.modelContext)</st> private var modelContext
}
        <st c="28114">当使用</st> `<st c="28205">modelContainer</st>` <st c="28219">修饰符设置场景时,`<st c="28119">modelContext</st>` <st c="28131">环境变量始终可用。</st>

        <st c="28229">在非 SwiftUI</st> <st c="28245">实例中,我们可以使用我们的模型容器</st> `<st c="28308">mainContext</st>` <st c="28319">属性来访问上下文:</st>
 let modelContext = modelContainer.mainContext
        <st c="28375">为了理解</st> <st c="28390">如何与模型上下文一起工作,我们将</st> <st c="28430">从最基本的操作开始,为我们的存储保存新的对象。</st>

        <st c="28500">保存新对象</st>

        <st c="28519">在本章的开头,在</st> *<st c="28561">定义 SwiftData 模型</st>* <st c="28587">部分,我们了解到我们的模型只是标记有</st> `<st c="28661">@</st>``<st c="28662">Model</st>` <st c="28667">宏的 Swift 类。</st>

        <st c="28674">我们在 SwiftData 中定义模型的方式也意味着新实例的创建对我们来说非常直接</st> <st c="28686">:</st>
 let newBook = Book(name: "Mastering iOS 18 – the future")
        <st c="28845">我们的下一步是将该书籍实例添加到</st> <st c="28892">我们的上下文中:</st>
 modelContext.insert(newBook)
        <st c="28933">添加</st> `<st c="28941">newBook</st>` <st c="28948">到模型上下文并不一定意味着它被保存到我们的持久存储中,但它确实意味着它在我们的上下文中,并且已准备好被推送到我们的存储。</st> <st c="29129">在我们的上下文中,我们可以进行更改,添加和删除信息,而无需实际将这些操作保存到我们的数据存储中。</st> <st c="29247">上下文在处理并发操作或当我们想要管理</st> <st c="29466">撤销操作时非常有用。</st>

        <st c="29482">要实际保存到持久存储,我们可以使用上下文的</st> `<st c="29548">save()</st>` <st c="29554">方法:</st>
 try? modelContext.<st c="29593">save()</st> method pushes changes to the store for each model, according to its configuration.
			<st c="29682">The way the</st> `<st c="29695">save()</st>` <st c="29701">method works resembles how Core Data works.</st> <st c="29746">But there’s one difference here.</st> <st c="29779">SwiftData allows us to have an</st> *<st c="29810">auto-save</st>* <st c="29819">feature for the</st> <st c="29836">model container:</st>

var body: some Scene {

    WindowGroup {

        ContentView()

    }

    .modelContainer(for: Book.self, <st c="29938">isAutosaveEnabled:</st>

false)

}

			<st c="29966">In our code</st> <st c="29979">example, we set the</st> `<st c="29999">isAutosaveEnabled</st>` <st c="30016">parameter to</st> `<st c="30030">false</st>`<st c="30035">. By default, SwiftData auto-saves every change we make to the persistent store, so there’s no need to call the</st> `<st c="30147">save()</st>` <st c="30153">function unless you have a</st> <st c="30181">perfect reason.</st>
			<st c="30196">Due to performance considerations, SwiftData doesn’t save every single time we perform a change to the context but, rather, in the following</st> <st c="30338">two situations:</st>

				*   <st c="30353">During the app life cycle – for example, when moving from the foreground to</st> <st c="30430">the background</st>
				*   <st c="30444">In a certain time period after we perform</st> <st c="30487">the change</st>

			<st c="30497">Now that we know how to create and insert new objects, we can move on</st> <st c="30568">to fetching.</st>
			<st c="30580">Fetching objects</st>
			<st c="30597">Fetching objects in SwiftData is slightly different than what we know from Core Data, as there are</st> <st c="30697">two primary ways to</st> <st c="30717">retrieve data.</st>
			<st c="30731">The first way</st> <st c="30746">is to fetch an object, or objects,</st> *<st c="30781">based on a predicate</st>* <st c="30801">as part of an app flow – for example, fetching objects to sync with the server or to make some kind</st> <st c="30902">of calculation.</st>
			<st c="30917">The second way is to fetch objects</st> *<st c="30953">based on a query</st>* <st c="30969">and bind them to the SwiftUI view.</st> <st c="31005">An example would be when we want to bind a collection of objects to</st> <st c="31073">a list.</st>
			<st c="31080">Let’s go over both ways and explore new structures and macros that SwiftData brings to</st> <st c="31168">our project.</st>
			<st c="31180">Fetching objects using FetchDescriptor</st>
			`<st c="31219">FetchDescriptor</st>` <st c="31235">is a struct equivalent to</st> `<st c="31262">NSFetchRequest</st>` <st c="31276">in</st> <st c="31280">Core Data.</st>
			<st c="31290">Like</st> `<st c="31296">NSFetchRequest</st>`<st c="31310">,</st> `<st c="31312">FetchDescriptor</st>` <st c="31327">also works with a specific type of object; to use it, we can pass an optional predicate and</st> <st c="31420">sort descriptor.</st>
			<st c="31436">Here’s</st> <st c="31444">an example of how to</st> <st c="31465">use</st> `<st c="31469">FetchDescriptor</st>`<st c="31484">:</st>

let fetchDesciprtor = FetchDescriptor(predicate:

Predicate { $0.name == "My Book"})

    let book = try? modelContext.fetch(fetchDesciprtor).first

			<st c="31636">If you look closely, you can see that</st> `<st c="31675">FetchDescriptor</st>` <st c="31690">is not the only new type we encounter in this context, as we also have a new</st> `<st c="31768">Predicate</st>` <st c="31777">macro that creates</st> `<st c="31797">PredicateExpression</st>` <st c="31816">(a new type in</st> <st c="31832">iOS 17).</st>
			<st c="31840">Unlike the familiar</st> `<st c="31861">NSPredicate</st>`<st c="31872">, the</st> `<st c="31878">Predicate</st>` <st c="31887">macro works a little bit differently.</st> <st c="31926">Instead of creating a query, we have a closure where we define the condition of the return instances, like the array</st> <st c="32043">filter method.</st>
			<st c="32057">The following example returns books with more than</st> <st c="32109">10 pages:</st>

let fetchDesciprtor = FetchDescriptor(predicate:

Predicate { book in

        return book.pages.count > 10

    })

			<st c="32226">Using the</st> `<st c="32237">#Predicate</st>` <st c="32247">macro is simple and doesn’t require us to use a special syntax to perform</st> <st c="32322">complex queries.</st>
			<st c="32338">In most cases, we won’t have to use</st> `<st c="32375">FetchDescriptor</st>`<st c="32390">. If we want to connect data to our SwiftUI views, SwiftData has a better solution – the</st> `<st c="32479">@</st>``<st c="32480">Query</st>` <st c="32485">macro.</st>
			<st c="32492">Conn</st><st c="32497">ecting data to a view using the @Query macro</st>
			<st c="32542">Data is there to be seen.</st> <st c="32569">Showing information to the user is perhaps the most common task</st> <st c="32633">for iOS developers, and SwiftData’s</st> <st c="32669">goal is just to</st> <st c="32685">simplify that.</st>
			<st c="32699">As part</st> <st c="32708">of the SwiftData package, we now have an</st> `<st c="32749">@Query</st>` <st c="32755">macro that helps us present information in</st> <st c="32799">SwiftUI views:</st>

@Query private var books: [Book] var body: some View {

List {

    ForEach(books) { book in

        Text(book.name)

    }

}

}


			<st c="32922">This example displays a simple list of</st> `<st c="32962">Book</st>` <st c="32966">items based on the</st> `<st c="32986">books</st>` <st c="32991">variable.</st> <st c="33002">The</st> `<st c="33006">@Query</st>` <st c="33012">macro before the variable declaration makes the variable a state of the view, ensuring that data is constantly updated.</st> <st c="33133">This means that we get an instant UI update whenever we insert a new book into our</st> <st c="33216">persistent store.</st>
			<st c="33233">This is pretty remarkable for just one</st> <st c="33273">additional word!</st>
			<st c="33289">The</st> `<st c="33294">@Query</st>` <st c="33300">macro also has two important additional features – filter</st> <st c="33359">and sorting.</st>
			<st c="33371">Filtering the query</st>
			<st c="33391">The chances</st> <st c="33404">that we will fetch</st> *<st c="33423">all</st>* <st c="33426">the items of a particular entity are pretty low, and the previous example of fetching all the books and presenting them is more common in tutorials and</st> <st c="33579">demo presentations.</st>
			<st c="33598">In real life, we want to filter our queries.</st> <st c="33644">To do that, we can use</st> `<st c="33667">#Predicate</st>`<st c="33677">, which we learned about in the</st> *<st c="33709">Fetching objects using</st>* *<st c="33732">FetchDescriptor</st>* <st c="33747">section:</st>

@Query(filter: #Predicate {

$0.pages.count > 300

@Query 仅返回包含超过 300 页的书籍。

        <st c="33951">当然,我们可以通过升级在谓词内的 Swift 表达式来执行更复杂的查询:</st> <st c="34045">:</st>
 @Query(filter: <st c="34075">#Predicate<Book> {</st>
 <st c="34093">$0.pages.count > 300 && (!$0.isRead ||</st>
 <st c="34132">$0.isFavorite)</st> }) private var bigBooks: [Book]
        <st c="34179">在这个例子中,我们过滤出包含超过 300 页的书籍,但这次,我们还想</st> <st c="34278">接收那些我们尚未阅读或标记为收藏的书籍。</st> <st c="34341">我们使用 Swift 表达式来过滤结果,这使得我们的查询更加描述性和强大</st> <st c="34451">,比</st> `<st c="34456">NSPredicate</st>`<st c="34467">.</st>

        <st c="34468">然而,当在列表中显示数据时,仅仅过滤是不够的;还需要对其进行排序。</st> <st c="34572">这就是我们第二个主要</st> `<st c="34606">@</st>``<st c="34607">Query</st>` <st c="34612">功能的作用。</st>

        <st c="34621">对数据进行排序</st>

        <st c="34638">排序是向用户展示信息的一个基本方面。</st> <st c="34710">我们应该记住</st> <st c="34729">排序不是一个轻量级任务;它需要一个复杂的算法才能高效完成。</st>

        <st c="34824">这就是为什么我们需要确保我们可以按符合 iOS 15 中引入的`<st c="34912">SortComparator</st>` <st c="34926">协议的类型属性进行排序。</st>

        <st c="34966">让我们看看我们如何对</st> <st c="34997">过滤后的书籍进行排序:</st>
 @Query(filter: #Predicate<Book> {
        $0.pages.count > 300
    }, <st c="35071">sort: [SortDescriptor(\Book.name),</st>
 <st c="35105">SortDescriptor(\Book.pages.count)]</st>) private var
         bigBooks: [Book]
        <st c="35172">在这个例子中,我们传递了一个`<st c="35210">SortDescriptor</st>` <st c="35224">数组——我们首先按书名排序,然后按页数排序。</st> <st c="35291">使用`<st c="35315">SortDescriptor</st>` <st c="35329">非常简单——我们使用一个指向所需属性的键路径来初始化它,就像在先前的例子中一样。</st>

        使用 SwiftData 进行排序极其简单。<st c="35426">然而,在底层,它需要运行必须针对性能进行优化的算法,以便高效工作。</st> <st c="35482">当我们处理 100 或 200 条记录时,我们不需要这些优化。</st> <st c="35519">然而,当我们的数据存储包含数千条记录时,情况就不同了。</st> <st c="35607">在这些情况下,我们需要对数据进行索引。</st> <st c="35679">我们的数据。</st>

        <st c="35798">添加#Index 宏以提高性能</st>

        <st c="35838">在我们对数据进行索引之前,让我们先了解一下这究竟意味着什么。</st> <st c="35912">当执行排序或查询等读取操作时,我们希望我们的应用程序能够与数千条记录无缝工作。</st> <st c="36037">显然,对整个表进行全表扫描以查找名为</st> *<st c="36098">Mastering iOS 18</st>* <st c="36114">的书籍是不高效的。</st> <st c="36131">那么我们该怎么办呢?</st> <st c="36150">就像书籍索引一样,数据库索引包含键,帮助它定位特定记录。</st> <st c="36241">例如,如果我们想对书籍的</st> `<st c="36285">name</st>` <st c="36289">属性进行索引,我们可以创建一个数据结构,如 B 树,它可以帮助我们根据</st> <st c="36407">其名称定位精确实例。</st>

        <st c="36416">在 SwiftData 中,我们不需要创建任何结构来索引我们的数据。</st> <st c="36488">我们只需要将</st> `<st c="36517">#Index</st>` <st c="36523">宏添加到</st> <st c="36533">我们的模型中:</st>
 @Model
class Book { <st c="36564">#Index<Book>([\.name], [\.name, \.publicationName])</st> var publicationName: String = "Packt"
    var name: String
}
        <st c="36672">如果前面的代码看起来很熟悉,那是因为我们在</st> *<st c="36803">添加 @Attribute</st>* *<st c="36825">宏</st>* <st c="36830">部分添加了</st> `<st c="36769">#Unique</st>` <st c="36776">宏到我们的模型中时,我们做了类似的事情。</st>

        <st c="36839">在这种情况下,我们决定为我们</st> <st c="36887">模型添加两个索引:</st>

            +   <st c="36897">第一个是索引名称属性,允许应用程序按名称排序记录或查询特定</st> `<st c="37008">书籍名称</st>` <st c="37095">的数据。</st>

            +   <st c="37018">第二个索引是基于</st> `<st c="37071">name</st>` <st c="37075">和</st> `<st c="37080">publicationName</st>` <st c="37095">属性的</st>

        <st c="37106">如果您还记得从</st> *<st c="37132">添加 @Attribute 宏</st>* <st c="37159">部分,我们决定这个组合定义了我们书籍的独特性。</st> <st c="37233">为这个组合创建索引可以帮助我们在需要时快速找到特定的书籍</st> <st c="37313">。</st>

        <st c="37325">索引看起来像魔法——我们向索引列表中添加另一个键路径,然后一切运行得更快。</st> <st c="37431">那么,为什么不将此应用于所有属性呢?</st> <st c="37471">有什么</st> <st c="37478">问题吗?</st>

        <st c="37488">这是因为</st> <st c="37502">索引是有代价的。</st> <st c="37531">首先,我们需要复制一些我们的数据。</st> <st c="37577">如果我们需要索引名称属性,我们需要创建一个包含所有名称的结构。</st> <st c="37675">这导致我们的应用程序需要额外的存储空间。</st> <st c="37723">但添加索引并不止于存储——它还会影响性能。</st> <st c="37800">索引不是一次性操作,因为它需要维护。</st> <st c="37866">每次</st> `<st c="37871">插入</st>`<st c="37877">、</st> `<st c="37879">更新</st>`<st c="37885">或</st> `<st c="37890">删除</st>` <st c="37896">操作都需要 SwiftData 维护索引结构,从而影响</st> <st c="37973">操作性能。</st>

        <st c="37995">总的来说,索引是 SwiftData 的一个优秀功能。</st> <st c="38047">然而,请谨慎使用,并权衡其好处与</st> <st c="38103">成本。</st>

        <st c="38113">我们到目前为止已经学到了很多东西!</st> <st c="38151">我们学习了如何定义模型、创建实例、获取它们并将它们连接到</st> <st c="38237">UI。</st>

        <st c="38244">但我们知道,维护持久存储远不止这些。</st> <st c="38263">我们的第一个应用程序版本与我们的第 50 个版本大不相同,这也意味着我们的数据模式将在应用程序版本的生命周期中发生变化。</st> <st c="38472">但我们已经有一个数据满载的存储库,我们应该怎么办呢?</st> <st c="38537">这就是我们下一个主题——如何执行</st> <st c="38576">数据迁移。</st>

        <st c="38591">将我们的数据迁移到新架构</st>

        <st c="38626">对于那些使用过 Core Data 的人来说,数据迁移不是一个奇怪的表达。</st> <st c="38710">很明显,随着我们的应用发展,我们需要更改我们的</st> <st c="38724">架构。</st>

        <st c="38777">有两种类型的迁移——</st> *<st c="38814">轻量级</st>* <st c="38825">和</st> *<st c="38830">自定义</st>* <st c="38836">迁移。</st> <st c="38848">在轻量级迁移中,我们执行不需要自定义逻辑的更改。</st> <st c="38928">例如,添加实体、属性和关系都是轻量级迁移的好例子。</st> <st c="39038">相反,更改属性类型、使属性唯一以及基于其他属性创建新属性都是自定义迁移的例子。</st> <st c="39191">现在我们知道了有哪些迁移类型,了解何时进行迁移是重要的。</st>

        <st c="39310">在我们处于开发阶段时,在我们拥有 App Store 上的官方版本之前,迁移是不必要的。</st> <st c="39437">我们只需要在最终用户持有</st> <st c="39494">较旧架构的版本时进行迁移。</st> <st c="39526">这也意味着,如果我们对几个版本进行架构更改,我们必须确保 SwiftData 知道如何在整个</st> <st c="39663">这些版本中进行迁移。</st>

        <st c="39678">现在,让我们讨论 SwiftData 迁移的工作原理以及基本迁移</st> <st c="39761">组件。</st>

        <st c="39776">学习基本迁移过程</st>

        <st c="39813">SwiftData 迁移有三个</st> <st c="39826">主要组件:</st>

            +   `<st c="39862">版本架构</st>`<st c="39877">: 描述特定的</st> <st c="39901">架构版本</st>

            +   `<st c="39915">迁移阶段</st>`<st c="39930">: 描述同一架构版本之间的迁移过程</st>

            +   `<st c="40004">架构迁移计划</st>`<st c="40024">: 描述架构迁移阶段是基于</st> <st c="40086">迁移阶段</st>

        <st c="40102">让我们尝试描述如何使用</st> *<st c="40160">图 2</st>**<st c="40168">.2</st>*<st c="40170">来展示所有事物之间的联系:</st>

        ![图 2.2:三个不同版本之间的迁移过程](https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_02_2.jpg)

        <st c="40295">图 2.2:三个不同版本之间的迁移过程</st>

        *<st c="40359">图 2</st>**<st c="40368">.2</st>* <st c="40370">展示了三个不同版本的三个不同版本架构。</st> <st c="40439">每次我们将应用程序从一个版本迁移到另一个版本时,我们都会创建一个迁移</st> <st c="40461">阶段。</st> <st c="40525">一旦我们有了各种阶段,我们就可以将它们封装成一个大的</st> <st c="40588">迁移计划。</st>

        <st c="40603">回到我们的书籍应用,让我们尝试将我们的架构迁移以支持</st> `<st c="40678">副标题</st>` <st c="40686">为我们</st> `<st c="40695">Book</st>` <st c="40699">实体。</st>

        <st c="40707">首先,我们需要创建我们的</st> <st c="40737">版本架构。</st>

        <st c="40753">创建版本架构</st>

        <st c="40779">为了将</st> <st c="40791">我们的书籍迁移到新的架构,我们需要创建两个版本架构——第一个是我们当前的架构,第二个是</st> <st c="40912">目标架构:</st>
 enum BookSchemaV1: VersionedSchema {
    static var versionIdentifier: Schema.Version
    { return .init(1, 0, 0) }
    static var models: [any PersistentModel.Type] {
        [Book.self]
    }
    @Model class Book {
        var name: String
        init(name: String) {
            self.name = name
        }
    }
}
enum BookSchemaV2: VersionedSchema {
    static var versionIdentifier: Schema.Version
    {return .init(1, 1, 0) }
    static var models: [any PersistentModel.Type] {
        [Book.self]
    }
    @Model class Book {
 <st c="41373">var subtitle: String = ""</st> var name: String
        init(subtitle: String, name: String) { <st c="41455">self.subtitle = subtitle</st> self.name = name
        }
    }
}
        <st c="41502">在这段代码中,我们创建了两个符合</st> `<st c="41558">VersionedSchema</st>` <st c="41573">协议的枚举。</st> <st c="41584">作为协议定义的一部分,我们需要定义版本标识符以及哪些模型</st> <st c="41677">将发生变化。</st>

        <st c="41689">在这种情况下,我们向第二个版本添加了一个新的</st> `<st c="41705">副标题</st>` <st c="41713">属性。</st> <st c="41759">我们需要更新整个应用中使用的架构,包括新的</st> <st c="41824">属性。</st>

        <st c="41842">我们的下一步是定义不同的阶段和</st> <st c="41899">迁移计划。</st>

        <st c="41914">创建迁移阶段和计划</st>

        <st c="41953">我们应该</st> <st c="41964">将版本架构视为我们迁移过程的构建块。</st> *<st c="42044">图 2</st>**<st c="42052">.2</st>* <st c="42054">显示我们根据</st> <st c="42110">版本架构创建迁移阶段。</st>

        <st c="42128">这是一个迁移阶段的示例:</st>
 static let migrateV1toV2 = <st c="42196">MigrationStage.lightweight</st>(fromVersion:
  BookSchemaV1.self, toVersion: BookSchemaV2.self)
        <st c="42285">`<st c="42290">migrateV1toV2</st>` <st c="42303">阶段处理从</st> `<st c="42337">BookSchemaV1</st>` <st c="42349">到</st> `<st c="42353">BookSchemaV2</st>`<st c="42365">的迁移。</st> 注意,这是一个轻量级迁移——我们只添加了一个属性,所以这就是我们需要创建</st> <st c="42475">阶段的所有内容。</st>

        <st c="42485">关于自定义迁移呢?</st> <st c="42517">使用自定义迁移,我们需要提供一个闭包来处理迁移阶段前后数据,在那里我们执行所有</st> <st c="42652">所需的变化。</st>

        <st c="42669">这是一个从版本 V2 到 V3 的自定义过渡示例,其中我们移除了副标题属性并将其作为</st> <st c="42806">书名的一部分:</st>
 static let migrateV2toV3 = <st c="42844">MigrationStage.custom</st>(fromVersion: BookSchemaV2.self,
  toVersion: BookSchemaV3.self, <st c="42929">willMigrate</st>: { context in
        if let books = try? context.fetch(FetchDescriptor<Book>()) {
            for book in books {
                let newName = book.name + " " +
                  book.subtitle
book.name = newName
            }
        }
        try? context.save()
    }, didMigrate: nil)
        <st c="43147">正如我们可以在代码示例中看到的那样,我们的</st> `<st c="43187">willMigrate</st>` <st c="43198">闭包接收一个上下文来工作,SwiftData 在需要时执行该闭包</st> <st c="43276">。</st>

        <st c="43288">我们获取所有书籍并从书名及其副标题属性中组装一个新的名称。</st> <st c="43382">在关闭代码的末尾,我们</st> <st c="43417">调用</st> `<st c="43422">context.save()</st>`<st c="43436">。</st>

        <st c="43437">现在我们有了迁移步骤,我们可以创建我们的</st> <st c="43495">迁移计划:</st>
<st c="43510">enum MyMigrationPlan: SchemaMigrationPlan</st> {
    static var schemas: [VersionedSchema.Type] {
        [BookSchemaV1.self, BookSchemaV2.self,
          BookSchemaV3.self]
    } <st c="43660">static var stages: [MigrationStage] {</st>
 <st c="43697">[migrateV1toV2, migrateV2toV3]</st>
 <st c="43728">}</st> static let migrateV1toV2 =
      MigrationStage.lightweight(fromVersion:
      BookSchemaV1.self, toVersion: BookSchemaV2.self)
    static let migrateV2toV3 =
      MigrationStage.custom(fromVersion: BookSchemaV2.self,
      toVersion: BookSchemaV3.self, willMigrate:{context in
        if let books = try? context.fetch(FetchDescriptor<Book>()) {
            for book in books {
                let newName = book.name + " " +
                  book.subtitle
                book.name = newName
            }
        }
        try? context.save()
    }, didMigrate: nil)
}
        <st c="44174">迁移</st> <st c="44189">计划只是符合</st> `<st c="44229">SchemaMigrationPlan</st>`<st c="44248">的另一个枚举,其中静态变量描述了模式列表和阶段(不是我们之前没有</st> <st c="44340">见过的东西)。</st>

        <st c="44353">现在,我们有了迁移计划,但 SwiftData 不知道如何处理它。</st> <st c="44432">我们的下一步将是将迁移计划连接到我们的</st> <st c="44491">SwiftData 容器。</st>

        <st c="44511">将迁移计划连接到我们的容器</st>

        <st c="44558">将</st> <st c="44570">迁移计划连接到我们的容器可能是这个过程中最直接的一步。</st>

        <st c="44662">`<st c="44667">ModelContainer</st>` <st c="44681">结构体有一个</st> `<st c="44695">migrationPlan</st>` <st c="44708">属性专门用于此,我们需要传递迁移计划</st> <st c="44780">枚举类型:</st>
 return try ModelContainer(for: schema, <st c="44830">migrationPlan:</st>
 <st c="44844">MyMigrationPlan.self,</st> configurations:
   [modelConfiguration])
        <st c="44904">注意 SwiftData 在语言范式方面迁移的工作方式。</st> <st c="44985">我们不需要初始化任何东西,因为我们只传递模式、阶段和计划类型。</st> <st c="45079">原因是 SwiftUI 的工作方式——由于我们在不可变环境中工作,使用静态变量和类型而不是实例要方便得多</st> <st c="45235">。</st>

        <st c="45248">在 SwiftData 中迁移不是一个简单的任务。</st> <st c="45294">它涉及到遵守多个协议、维护模式版本,以及理解如何构建存储以在轻量级和</st> <st c="45442">自定义迁移之间切换。</st>

        <st c="45459">但这是因为迁移,总的来说,是一个复杂且敏感的过程。</st> <st c="45539">在事先仔细规划我们的模式看起来如何时,可以减少模式版本和阶段的数量,简化我们在考虑</st> <st c="45684">我们将在某个时候迁移我们的存储时</st> <st c="45727">的过程。</st>

        <st c="45738">摘要</st>

        <st c="45746">SwiftData 对希望支持 iOS 17 及以上版本的 iOS 开发者具有重要意义,它代表了从苹果之前框架 Core Data 的自然演进。</st> <st c="45911">在声明式 Swift 环境中,SwiftData 比以前更无缝地</st> <st c="45999">对齐。</st>

        <st c="46011">在本章中,我们了解了 SwiftData 的背景,定义了不同的 SwiftData 模型,创建了关系,并自定义了模型属性。</st> <st c="46173">然后我们转向容器——一个将所有内容包装在一起、执行获取和保存操作的组件。</st> <st c="46277">最后,我们使用轻量级和自定义迁移将数据从不同的模式版本迁移过来。</st> <st c="46378">在整个章节中,我们看到了 Swift 宏和协议的广泛使用,这些在 Swift 的现代世界中比 Objective-C 更合适。</st> <st c="46517">。</st>

        <st c="46532">这章内容很多!</st> <st c="46563">请记住,数据层是复杂的,管理和维护它需要学习很多。</st> <st c="46596">数据层是项目的一侧;当然,另一侧是 UI。</st> <st c="46741">为了完整理解数据层,探索 UI 如何监控变化是至关重要的。</st> <st c="46848">这就是为什么我们即将到来的章节将专注于</st> <st c="46902">观察框架。</st>


第四章:3

理解 SwiftUI 观察系统

在第 第二章中,我们讨论了 SwiftData,这是我们数据管理的一个基本框架。 然而,为了使数据管理有效,我们还需要另一侧能够观察变化并向用户显示它们的东西。

SwiftUI 包含允许我们有效地观察这些变化并将它们绑定到操作和 UI 更新的工具。 然而,这些工具在多年中变得复杂且令人困惑。

现在,我们将探讨观察如何变得简单得多,同时深入到 SwiftUI 数据流的 核心。

在本章中,我们将涵盖以下主题:

  • 回顾 SwiftUI 观察系统并讨论 其问题

  • 添加 <st c="719">@Observable</st> 宏并学习它是如何 工作的

  • 讨论观察属性,包括 计算变量

  • 使用环境变量并将它们适应到 新框架

  • 讨论新的 <st c="906">@Bindable</st> 属性包装器

  • 学习如何将我们的应用程序迁移到与 观察框架 一起工作

准备好开始了吗?

技术要求

本章包含许多代码示例,其中一些可以在以下 GitHub 仓库中找到: https://github.com/PacktPublishing/Mastering-iOS-18-Development/tree/main/Chapter3

要运行它们,我们需要 Xcode 15 或更高版本。

回顾 SwiftUI 观察系统

在我们讨论当前的 SwiftUI 观察系统之前,让我们回顾一下 SwiftUI 观察系统。

在 Xcode 15 之前,九个属性包装器处理了 SwiftUI 中的状态和数据更新

让我们尝试按 应用程序级别 对它们进行分组:

  • <st c="1564">@</st>``<st c="1565">Binding</st>, <st c="1574">@Environment</st>

  • <st c="1600">@State</st>, <st c="1608">@Binding</st>, <st c="1618">@</st>``<st c="1619">StateObject</st>, <st c="1632">@Environment</st>

  • <st c="1668">@</st>``<st c="1669">ObservableObject</st>, <st c="1687">@Published</st>

  • <st c="1715">@AppStorage</st><st c="1726">@</st> <st c="1729">SceneStorage</st><st c="1741">@EnvironmentObject</st>

<st c="1762">不同的级别让我们了解不同包装器的不同角色。</st> <st c="1850">让我们来探讨一些这些包装器,以了解系统是如何工作的。</st>

<st c="1923">一个本地的</st> <st c="1932">@State</st> 属性包装器管理视图内部原始属性的状态。例如,一个特定视图是否隐藏、可用按钮的数量、当前排序方法等都是由这个包装器管理的。

<st c="2161">我们使用 <st c="2186">@State</st> 属性包装器的原因是 SwiftUI 视图是不可变的。 这意味着 SwiftUI 每次发生变化时都会重建视图,但 @State 值在渲染会话之间不会改变。`

<st c="2396">问题开始于我们基于数据模型信息构建视图的时候。</st> <st c="2465">例如,一个书店应用从本地数据文件中显示书籍列表的情况。</st> <st c="2563">在这种情况下,我们的视图必须使用 ObservableObject` 协议与另一个数据模型对象协同工作。

<st c="2663">现在我们来回顾一下</st>

<st c="2685">遵守 ObservableObject 协议</st>

我们可以使用 <st c="2729">ObservableObject</st> 协议与 <st c="2795">@ObservedObject</st> 属性包装器一起用于需要被观察的类。

<st c="2865">这是一个 <st c="2889">UserData</st> 类的例子,它成为一个 <st c="2921">@ObservedObject</st> 属性包装器:`

 class UserData: <st c="2971">ObservableObject</st> { <st c="2990">@Published</st> var username = "Avi Tsadok"
}
struct ContentView: View {
 <st c="3058">@ObservedObject var userData = UserData()</st> var body: some View {
        Text("Welcome, \(userData.username)!")
            .padding()
    }
}

<st c="3175">实现数据类观察有三个部分:</st>

  1. <st c="3254">ObservableObject</st>:如果我们想让一个类在 SwiftUI 中被观察,它必须遵守 <st c="3342">ObservableObject</st> 协议。这表示 SwiftUI,任何从这个类派生出的实例都可以在视图中被观察。

  2. <st c="3475">@Published</st> <st c="3536">@Published</st> 属性包装器,SwiftUI 创建了一个发布者,并在 SwiftUI 视图中使用它。

  3. <st c="3658">@ObservedObject</st> <st c="3697">@ObservedObject</st> 属性包装器在视图和对象之间建立连接,允许视图在变化时被通知。

记住,<st c="3870">@ObservedObject</st> 属性包装器仅用于观察目的——这意味着视图不能直接修改观察对象的属性。

如果我们想更改观察对象属性,我们必须使用另一个属性包装器—— <st c="4112">@</st>``<st c="4113">StateObj</st><st c="4121">ect</st>

一个 <st c="4129">@StateObject</st> 属性包装器与 <st c="4173">@State</st>类似,只是它适用于可观察对象而不是 原始值。

然而,这还没有结束——如果我们想在视图和其子视图之间创建双向连接,我们需要 在子视图中添加一个 <st c="4376">@Binding</st> 属性包装器,并在父视图中添加一个 <st c="4423">@State</st> 属性包装器。

解释当前观察情况的问题

对当前 SwiftUI 中观察数据方式的简要回顾强调了在 SwiftUI 中观察数据是多么复杂和令人困惑。

<st c="4687">ObservableObject</st> 协议 为例,在大多数情况下,我们希望将所有属性标记为使用 <st c="4777">@Published</st> 属性包装器。如果这样,为什么我们还需要努力工作呢?难道我们没有一种方法可以将 <st c="4777">@Published</st> 属性包装器添加到所有 属性中吗?

观察框架在这里使用 Swift 宏,这是一个可以帮助我们减少样板代码的功能。 要了解更多信息,请访问 第十章 并了解 Swift 宏。

添加@Observable

观察框架的主要目标是尽可能简化我们的工作,它通过大量使用宏来实现这一点。

让我们以 <st c="5286">Book</st> 为例:

 class Book: <st c="5323">ObservableObject</st> { <st c="5342">@Published</st> var title:String = "" <st c="5375">@Published</st> var author: String = "" <st c="5410">@Published</st> var publishedYear: Date = Date() <st c="5454">@Published</st> var numberOfPages: Int = 0
}

<st c="5498">Book</st> 类是一个标准的ObservableObject <st c="5539">类,包含四个属性,每个属性都使用@Published` 属性包装器。

使用 <st c="5622">Observation</st> 框架,我们可以摆脱所有的属性包装器和 <st c="5697">ObservableObject</st> 协议,只需在 类声明中添加一个宏:

<st c="5779">@Observable</st> class Book {
    var title:String = ""
    var author: String = ""
    var publishedYear: Date = Date()
    var numberOfPages: Int = 0
}

<st c="5917">@Observable</st> 宏,像大多数宏一样,为我们处理繁琐的工作。 它使<st c="6007">Book</st> 结构体可观察,并为它的属性添加了一个发布者。

让我们尝试在一个 <st c="6091">Book</st> 类中 `使用视图:

 struct ContentView: View { <st c="6140">var book:Book = Book()</st> var body: some View {
        VStack {
            Text(book.title)
            Button("Change") { <st c="6230">book.title = "Mastering iOS 17"</st> }
        }
        .padding()
    }
}

在上面的代码中,我们有一个按钮和一个带有 <st c="6339">Text</st> 视图的视图,该视图显示书籍标题。 点击按钮会更改书籍标题。

书籍标题的更改更新了文本;然而,即使书籍没有标记为 <st c="6542">@ObserverdObject</st> <st c="6562">@StateObject</st> 属性包装器,更新也会发生!

这怎么可能?

让我们深入一点, 看看发生了什么!

了解 @Observable 宏的工作原理

我知道谈论宏可能会让你感到厌烦,但你记得 <st c="6775">@Observable</st> 是一个宏,而且我们可以 展开它吗?

所以,让我们展开它,看看发生了什么:

 @Observable
class Book { <st c="6902">@ObservationTracked</st> var title:String = ""
 <st c="6944">@ObservationIgnored private var _title: String = ""</st>
 <st c="6995">{</st>
 <st c="6997">@storageRestrictions(initializes: _title)</st>
 <st c="7039">init(initialValue) {</st>
 <st c="7060">_title = initialValue</st>
 <st c="7082">}</st>
 <st c="7084">get {</st>
 <st c="7090">access(keyPath: \.title)</st>
 <st c="7115">return _title</st>
 <st c="7129">}</st>
 <st c="7131">set {</st>
 <st c="7137">withMutation(keyPath: \.title) {</st>
 <st c="7170">_title = newValue</st>
 <st c="7188">}</st>
 <st c="7190">}</st>
 <st c="7192">}</st>
 <st c="7194">@ObservationTracked</st> var author: String = "" <st c="7239">@ObservationTracked</st> var publishedYear: Date = Date() <st c="7292">@ObservationTracked</st> var numberOfPages: Int = 0 <st c="7339">@ObservationIgnored private let _$observationRegistrar</st>
 <st c="7393">= Observation.ObservationRegistrar()</st>
 <st c="7430">internal nonisolated func access<Member>(</st>
 <st c="7472">keyPath: KeyPath<Book , Member></st>
 <st c="7504">) {</st>
 <st c="7508">_$observationRegistrar.access(self, keyPath:</st>
 <st c="7553">keyPath)</st>
 <st c="7562">}</st>
 <st c="7564">internal nonisolated func withMutation<Member,</st>
 <st c="7611">MutationResult>(</st>
 <st c="7628">keyPath: KeyPath<Book , Member>,</st>
 <st c="7661">_ mutation: () throws -> MutationResult</st>
 <st c="7701">) rethrows -> MutationResult {</st>
 <st c="7732">try _$observationRegistrar.withMutation(of: self,</st>
 <st c="7782">keyPath: keyPath, mutation)</st>
 <st c="7810">}</st>
<st c="7812">}</st>
<st c="7813">extension Book: Observation.Observable {</st>
<st c="7853">}</st>

这为一个 微小的宏做了很多工作!

看起来还有 内部宏,例如 <st c="7951">@ObservationTracked</st>,其中之一 我已经展开了。

所以,这里发生了什么?

这里有五件事情我们可以 看到:

  • <st c="8157">Observable</st>,不是一个协议。 该协议本身是空的,但 SwiftUI 使用它来标记类为被观察的。 使用扩展,你 可以在宏代码的末尾看到协议的符合情况。

  • <st c="8378">observationRegistrar</st><st c="8405">observationRegistrar</st> 变量是一个单例结构体,负责管理被观察类属性的注册。 SwiftUI 依赖于这个结构体来检测当被观察属性被访问 或修改时。

  • <st c="8709">Observation</st> 框架需要这些获取器和设置器来跟踪每个访问或 修改尝试。

  • <st c="8947">@Observable</st> 宏为每个原始变量添加了一个私有变量,仅为此目的。 获取器和设置器使用私有变量来返回和修改存储的值。

  • <st c="9172">access()</st> <st c="9185">withMutation()</st> 方法。 计算变量调用这些方法来通知 <st c="9265">observationRegistrar</st> 实例关于任何数据修改访问。 之后, <st c="9346">observationRegistrar</st> 实例会告诉 SwiftUI 这些变化。

我们下面有这么多代码的原因是, Observation 框架的目标是简化观察数据模型的过程。 没有宏,使类符合 Observable 协议是不够的 – 在 SwiftUI 视图中仍然需要用 <st c="9660">@ObservedObject</st> 标记实际的模型。 Observation 框架通过其 getter 和 setter 方法跟踪每个属性,这使得它在我们的视图中实现起来更加简洁。

请注意,在我们之前讨论的展开代码中有一个小的宏 – <st c="9938">@</st>``<st c="9939">ObservationIgnored</st>

使用 @ObservationIgnored 排除属性观察

我们已经 了解到,与之前为每个变量添加 <st c="10093">@Published</st> 属性包装器的模式不同,在 <st c="10147">@Observable</st> 宏中,所有属性默认都是被观察的。

让我们思考一下这个的后果 – 它会如何影响 我们的工作?

每个属性现在都被观察的事实意味着每次它在我们的 SwiftUI 视图中出现并且我们修改它时,我们的视图 都会被更新。

SwiftUI 确实是一个高度优化的框架,但它是优化了,因为它只在需要时更新视图。 如果一个特定的数据模型属性不需要是动态的和被观察的,我们应该将其排除在跟踪之外。 保持我们的 UI 响应并影响 其性能,观察许多属性是至关重要的。

让我们尝试添加一个不应该 被观察的属性:

 @Observable
class Book {
    var title:String = ""
    var author: String = ""
    var publishedYear: Date = Date()
    var numberOfPages: Int = 0 <st c="10944">@ObservationIgnored</st>
 <st c="10963">var lastPageRead: Int = 0</st> }

在这个代码示例中,我们添加了一个名为 <st c="11039">lastPageRead</st>的属性。这是一个重要的属性,但它不影响我们的 UI 状态,我们在布局视图时不会显示或考虑它。 因此,我们将使用 <st c="11220">@</st>``<st c="11221">ObservationIgnored</st> 宏来忽略它。

<st c="11246">与 @ObservationTracked 宏不同,该宏是 <st c="11295">@Observable</st> <st c="11306">宏用来创建观察属性获取器和设置器的, <st c="11385">@ObservationIgnored</st> <st c="11404">不会修改属性。</st> <st c="11434">SwiftUI 只使用该宏来确定它不使用 观察 注册 对象。`

<st c="11549">默认观察所有属性为我们提供了一个即插即用的令人兴奋且强大的功能——观察 <st c="11666">计算变量</st> <st c="11684">。</st>

<st c="11685">观察计算变量

<st c="11714">首先,提醒一下——计算变量是一个具有获取器和可选设置器的属性。</st> <st c="11811">这意味着计算变量没有自己的存储,其值是从其他变量(也可以是计算变量)派生出来的。</st>

<st c="11958">看看下面的代码:</st>

 class Book: ObservableObject {
    @Published var pages: Int = 0
    @Published var averageWordsPerPage: Int = 0 <st c="12092">@Published var totalWordsInBook: Int {</st>
 <st c="12130">return pages * averageWordsPerPage</st>
 <st c="12165">}</st> }

<st c="12169"> <st c="12173">Book</st> <st c="12177">类遵循古老的 ObservableObject 协议。`

<st c="12235">注意, <st c="12252">totalWordsInBook</st> <st c="12268">属性是一个计算变量——它将 <st c="12321">pages</st> <st c="12326">和 <st c="12331">averageWordsPerPage</st> <st c="12350">变量相乘,以返回书中的总字数。</st>

我们希望观察计算变量,以便在我们的 SwiftUI 视图中展示其结果,因此我们使用 <st c="12409"> <st c="12533">@Published</st> <st c="12543">属性包装器。</st>

<st c="12561">遗憾的是,这是不可能的。</st> <st c="12597">尝试使用以下错误编译结果:</st> <st c="12629">

<st c="12645">属性包装器不能应用于计算属性</st> <st c="12686">

<st c="12703">遵循 ObservableObject 协议有一个很大的缺点,因为它可能是一个有用的用例。`

<st c="12807">使用 Observable 宏可以很好地解决这个问题:</st> <st c="12849">

 @Observable
class MyBook {
    var pages: Int = 0
    var averageWordsPerPage: Int = 0
    var totalWordsInBook: Int {
        return pages * averageWordsPerPage
    }
}

在前面的代码中,我们只是添加了计算变量,并且我们可以没有问题地在我们的视图中观察它 <st c="13018"> <st c="13050"> <st c="13118">

<st c="13130">它是如何工作的?</st> <st c="13149">如果一个计算变量没有其值的后端存储,我们如何观察它?</st>

因此,我总是确保解释事物底层工作原理的原因。 如果我们回到 *学习@Observable 宏的工作原理 部分,我们扩展了 `@Observable 宏,并看到了观察和跟踪工作的有趣细节。 每个 观察属性都成为一个计算值,并使用 getter 和 setter 进行跟踪。

因此,当我们添加一个值从另一个观察属性派生的计算变量时,这意味着每次我们访问这个计算变量时,也会访问其他属性。 这种访问触发了 观察框架。

*图 3**.1 以可视化的方式展示了观察计算变量是如何工作的:

图 3.1:SwiftUI 如何观察计算变量

图 3.1:SwiftUI 如何观察计算变量

*图 3**.1 很好地展示了计算变量是如何从其他属性派生的,以及访问它们最终会如何 下降到 `observationRegister 对象。

让我们看看它是如何 付诸实践的:

 @Observable
class Book {
    var title:String = ""
    var pages: Int = 0
    var averageWordsPerPage: Int = 0 <st c="14340">var totalWordsInBook: Int {</st>
 <st c="14367">return pages * averageWordsPerPage</st>
 <st c="14402">}</st> }
struct ContentView: View {
    var book:Book = Book()
    var body: some View {
        VStack {
            Text(book.title)
            Button("Change") { <st c="14524">book.averageWordsPerPage = 300</st>
 <st c="14554">book.pages = 200</st>
 <st c="14571">}</st>
 <st c="14573">Text("number of pages in the book:</st>
 <st c="14608">\(book.totalWordsInBook)")</st> .padding()
    }
}

在之前的代码中,当我们点击 <st c="14687">averageWordsPerPage</st> <st c="14706">和</st> pages 属性时,会更新 ,当我们点击 **Change 按钮。

更新触发观察框架并更新视图,因为我们访问了 totalWordsInBook ,即使在下一行中它是一个 计算变量。

然而,将 <st c="14943">@ObservationIgnored</st> <st c="14962">属性添加到这两个属性(</st>averageWordsPerPage <st c="15026">pages</st> <st c="15031">)中不会触发</st> totalWordsInBook 计算属性,因为 <st c="15099">@Observation</st> <st c="15111">框架无法知道有什么东西发生了变化。</st> <st c="15161">好事是我们通过扩展我们的</st> @``Observable 宏来了解了它是如何工作的。

到目前为止, 我们非常清楚 `@Observable 宏是如何工作的,以及变量和计算变量是如何被观察的。

现在,让我们再进一步,看看如何将这些观察变量用作 环境变量。

使用环境变量

直接与观察对象工作的视图是一个常见的用例。 例如,一个视图可以与一个 <st c="15607">ViewModel</st> 类一起工作,或者有一个 SwiftData 查询从 持久存储中检索数据模型。

然而,也有一些 情况,我们有一个在多个视图中共享的观察对象。

此类用例的一些示例如下:

  • 应用设置:用户资料是应用设置的一部分,可以存储在一个 环境变量

  • 主题和样式:主颜色色调、字体样式、间距,以及更多

  • 用户认证状态:登录状态是环境变量的一个好例子 环境变量

在视图层次结构中共享相同的对象可能会很麻烦,但 SwiftUI 提供了一个有用的功能,称为 环境变量。虽然环境变量不是 iOS 最近才添加的(它们在 iOS 17 之前就已经可用),但 Observation 框架提供了 全面的支持。

有两种方法可以将环境变量添加到我们的项目中——按类型或按键。 让我们从更直接的方法开始: 按类型。

按类型添加环境变量

让我们尝试为我们的 书籍项目添加主题支持。我们将首先创建我们的 <st c="16662">Themes</st> 类:

<st c="16675">@Observable</st> class Themes {
    var primaryColor: Color = .red
}

我们的 <st c="16740">Themes</st> 类目前只有一个属性:主颜色。 注意,我们添加了 <st c="16828">@Observable</st> 宏来更新我们的 UI,当 主题改变时。

接下来,我们将我们的观察对象添加到我们的 <st c="16932">BookApp</st> 结构体中:

 @main
struct BookApp: App { <st c="16976">var themes: Themes = Themes()</st> var body: some Scene {
        WindowGroup {
            ContentView() <st c="17057">.environment(themes)</st> }
    }
}

<st c="17091">BookApp</st> 结构体中,我们进行了两个更改:

  • <st c="17264">@State</st> <st c="17274">@ObservedObject</st>

  • <st c="17392">主题</st> 对象易于访问。

现在,让我们转向我们的视图 并看看我们如何 使用它:

struct ContentView: View { <st c="17496">@Environment(Themes.self) var themes</st> var book: Book = {
      let book = Book()
      book.title = "Mastering iOS 17"
      return book
  }()
  var body: some View {
    VStack {
      Text(book.title)<st c="17665">.foregroundStyle(themes.primaryColor)</st> }
  }
}

将主题实例添加到我们的 <st c="17743">ContentView</st> 结构体中很简单。我们使用 <st c="17798">@Environment</st> 属性包装器来注入我们之前创建的主题对象。

我们在主体部分使用主题的主要颜色来为我们的 <st c="17939">书名</st> 着色。

现在,我们必须注意,我们可以在层次结构中的每个视图中使用环境变量,即使我们没有使用环境修改器初始化它。

这里是一个例子:

 struct ContentView: View {
    var body: some View {
        VStack {
            MyTitle(text: "Mastering iOS 17")
        }
    }
}
struct MyTitle: View { <st c="18255">@Environment(Themes.self) var themes</st> let text: String
    var body: some View {
        Text(text).foregroundStyle(<st c="18358">themes.primaryColor</st>)
    }
}

在前面的代码中,我们创建了一个名为 <st c="18452">MyTitle</st> 的另一个 SwiftUI 组件,它具有环境变量 themes

<st c="18508">MyTitle</st> 视图是 <st c="18536">ContentView</st> 层次结构的一部分。因此,它可以直接访问 <st c="18598">themes</st> 变量。

通过类型传递环境变量很简单!然而,当在大规模工作的时候,它有一些缺点。我相信主要缺点是我们将代码耦合到了一个特定的类型。在 <st c="18822">themes</st> 的例子中,我们处理的是一个显式的类型(<st c="18872">Themes</st>)。

SwiftUI 提供了一种更好的方式来管理环境变量,那就是使用环境键。

通过键添加环境变量

当我们的项目变得更重要时,管理环境变量会更好。

使用环境键提高了我们的视图和实际变量之间的分离。

为了更好地管理环境值,SwiftUI 有两个主要组件:

  • <st c="19281">EnvironmentValues</st> 结构体:这是一个以键值形式结构化的不同环境值的容器。它可以从应用中的任何视图访问。我们可以扩展这个结构体并添加新的变量。

  • <st c="19485">EnvironmentKey</st> 协议:它允许我们为新的变量添加一个键,并使用该键添加新的环境值。

让我们看看它在实践中是如何工作的:

 struct ThemesKey: <st c="19660">EnvironmentKey</st> {
    static let defaultValue = Themes()
}
extension <st c="19724">EnvironmentValues</st> {
    var themes: Themes {
        get { self[ThemesKey.self]}
        set { self[ThemesKey.self] = newValue}
    }
}

我们首先做的是添加一个新的 <st c="19878">EnvironmentKey</st> 类型,命名为 <st c="19904">ThemesKey</st>。部分 <st c="19927">EnvironmentKey</st> 协议是设置变量的默认值,在这种情况下,是一个 <st c="20016">Themes</st> 实例。

一旦我们有一个新的环境键,我们必须将其添加到我们的 <st c="20091">EnvironmentValues</st> 容器中。 我们通过扩展容器并添加一个名为 <st c="20199">themes</st>的新计算变量来实现这一点。

获取器和设置器都很直接 – <st c="20259">get</st> 函数根据相关键(<st c="20321">ThemesKey</st>)返回值,而 <st c="20343">set</st> 函数在该键上存储一个新的变量。

在扩展容器之后 ,我们可以轻松地从任何我们拥有的视图中访问该键:

 struct ContentView: View { <st c="20514">@Environment(\.themes) var themes</st> // rest of the view
}

还记得之前提到的环境修饰符吗? 现在我们可以移除它了:

 ContentView() <st c="20696">EnvironmentValues</st> struct, we extended the global variables container of our app. That’s the reason why we have access from any view.
			<st c="20828">Other than accessing the values from any view, working with environment variable keys has several</st> <st c="20927">additional advantages:</st>

				*   **<st c="20949">Quickly replacing the variable type in the future</st>**<st c="20999">: Unlike adding an environment value by type, we are not tied to a specific type when adding the variable by key.</st> <st c="21114">We can easily replace the type itself in one place and not have to replace it in all views as long as we keep the</st> <st c="21228">same interface.</st>
				*   **<st c="21243">Great for testing</st>**<st c="21261">: Another advantage of not being coupled to a specific type is the ability to create mocks and add</st> <st c="21361">unit tests.</st>
				*   `<st c="21505">get</st>` <st c="21508">and</st> `<st c="21513">set</st>` <st c="21516">functions in the</st> `<st c="21534">EnvironmentValues</st>` <st c="21551">struct?</st> <st c="21560">Now, we can customize them the way we</st> <st c="21598">want to.</st>

			<st c="21606">We can understand why environment keys are essential for big projects by looking at the list</st> <st c="21700">of advantages.</st>
			<st c="21714">No matter how we work with environment variables, they are crucial for a clean and simple SwiftUI code, especially when we combine them with</st> `<st c="21856">@</st>``<st c="21857">Observable</st>` <st c="21867">objects.</st>
			<st c="21876">By now, we already know how to create an observed object and inject it into child views using</st> <st c="21971">environment variables.</st>
			<st c="21993">Our next topic revolves</st> <st c="22017">around the compatibility problem that the</st> *<st c="22060">Observation</st>* <st c="22071">framework created for us, specifically</st> <st c="22111">reg</st><st c="22114">arding binding.</st>
			<st c="22130">Binding objects using @Bindable</st>
			<st c="22162">Let’s start with a short recap of what</st> <st c="22202">binding is.</st>
			<st c="22213">In some cases, a view and its</st> <st c="22244">child must share a state and create a two-way connection for reading and modifying a value.</st> <st c="22336">To do that, we use</st> <st c="22354">something</st> <st c="22365">called</st> **<st c="22372">binding</st>**<st c="22379">.</st>
			<st c="22380">One classic</st> <st c="22392">example is</st> `<st c="22404">TextField</st>` <st c="22413">– a</st> `<st c="22418">TextField</st>` <st c="22427">view is a SwiftUI component with a</st> `<st c="22463">text</st>` <st c="22467">variable.</st> <st c="22478">Both</st> `<st c="22483">TextField</st>` <st c="22492">and its parent view share the same value of text.</st> <st c="22543">Therefore, it’s a</st> <st c="22561">binding</st> <st c="22569">variable:</st>

struct ContentView: View { @State var email: String = "" var body: some View {

    VStack {

        TextField("电子邮件", text: <st c="22692">$email</st>)

    }

}

}


			<st c="22706">We see that the</st> `<st c="22723">email</st>` <st c="22728">variable is marked as a state, but the</st> `<st c="22768">TextField</st>` <st c="22777">view is the one that updates it.</st> <st c="22811">The binding occurs using the</st> `<st c="22840">$</st>` <st c="22841">character.</st>
			<st c="22851">We can create a binding variable ourselves using the</st> `<st c="22905">@Binding</st>` <st c="22913">proper</st><st c="22920">ty wrapper:</st>

struct MyCounter: View { @Binding var value: Int var body: some View {

    VStack {

        Button("增加") {

            value += 1

        }

    }

}

}

struct ContentView: View { @State var count: Int = 0 var body: some View {

    VStack {

        MyCounter(value: <st c="23154">$count</st>)

        Text("值 = \(count)")

    }

}

}


			<st c="23193">The</st> `<st c="23198">count</st>` <st c="23203">variable in the parent</st> <st c="23227">view (</st>`<st c="23233">ContentView</st>`<st c="23245">) and the</st> `<st c="23256">value</st>` <st c="23261">variable in the child view (</st>`<st c="23290">ContentView</st>`<st c="23302">) share</st> <st c="23311">the same state, and now we have a two-way connection</st> <st c="23364">between them.</st>
			<st c="23377">We can connect a binding variable to a</st> `<st c="23417">@State</st>` <st c="23423">property wrapper (such as in the example we just saw) or a</st> `<st c="23483">@</st>``<st c="23484">ObservedObject</st>` <st c="23498">variable.</st>
			<st c="23508">Can you guess what the problem is</st> <st c="23543">with trying to create a binding connection using the</st> `<st c="23596">Observation</st>` <st c="23607">framework?</st>
			<st c="23618">So, apparently, classes</st> <st c="23642">that are marked with the</st> `<st c="23668">@Observed</st>` <st c="23677">macro are not eligible for</st> `<st c="23705">@State</st>` <st c="23711">or</st> `<st c="23715">@ObservedObject</st>`<st c="23730">, so we can’t use</st> `<st c="23748">@Binding</st>` <st c="23756">with them.</st>
			<st c="23767">Fortunately, with the</st> *<st c="23790">Observation</st>* <st c="23801">framework, we have a new property wrapper</st> <st c="23844">called</st> **<st c="23851">@Bindable</st>**<st c="23860">.</st>
			<st c="23861">Let’s see a short</st> <st c="23880">example of how to use</st> `<st c="23902">@Bindable</st>` <st c="23911">with a</st> <st c="23919">counter object:</st>

struct ContentView: View { var counter = Counter() var body: some View {

    VStack {

        CounterView(counter: <st c="24038">counter</st>)

        Text("值 = \(counter.value)")

    }

}

}

struct CounterView: View {

@Bindable var counter: Counter var body: some View {

    VStack {

        Button("增加") { <st c="24197">counter.increment()</st> }

    }

}

}


			<st c="24224">The code example has two views as before – a</st> `<st c="24270">ContentView</st>` <st c="24281">view and a child view named</st> `<st c="24310">CounterView</st>`<st c="24321">. The</st> `<st c="24327">ContentView</st>` <st c="24338">view has a variable called</st> `<st c="24366">counter</st>` <st c="24373">of the</st> `<st c="24381">Counter</st>` <st c="24388">type.</st> <st c="24395">The</st> `<st c="24399">Counter</st>` <st c="24406">class is marked</st> <st c="24422">with</st> `<st c="24428">@Observed</st>`<st c="24437">, so we don’t need to mark the property as</st> `<st c="24480">@State</st>` <st c="24486">or</st> `<st c="24490">@ObservedObject</st>`<st c="24505">.</st>
			<st c="24506">In the</st> `<st c="24514">CounterView</st>` <st c="24525">structure, we</st> <st c="24540">also have a counter from the same type, but it is marked with</st> `<st c="24602">@Bindable</st>`<st c="24611">. This means we need to bind it to an object with a</st> <st c="24663">similar type.</st>
			<st c="24676">The</st> `<st c="24681">CounterView.counter</st>` <st c="24700">and</st> `<st c="24705">ContentView.counter</st>` <st c="24724">variables are linked – whenever we change the value in the child view, it automatically reflects in the parent view.</st> <st c="24842">Notice that with</st> `<st c="24859">@Bindable,</st>` <st c="24869">we don’t need to add any</st> `<st c="24895">$</st>` <st c="24896">signs to the variable expression.</st> <st c="24931">Everything</st> <st c="24942">just works.</st>
			<st c="24953">Binding is a critical usage of SwiftUI – it stands at the heart of many input views such as text fields, toggles, sheets,</st> <st c="25076">and more.</st>
			<st c="25085">Working with the</st> `<st c="25103">@Bindable</st>` <st c="25112">macro can be confusing – we now have both</st> `<st c="25155">@Binding</st>` <st c="25163">and</st> `<st c="25168">@Bindable</st>` <st c="25177">at the same time!</st> `<st c="25196">@Binding</st>` <st c="25204">is used for states and observable objects and</st> `<st c="25251">@Bindable</st>` <st c="25260">is used for...</st> <st c="25276">observed objects?</st>
			<st c="25293">So yes, it feels like we are in a transition era.</st> <st c="25344">The good news is that we can solve the issue easily by migrating our project</st> <st c="25421">to</st> *<st c="25424">Observable</st>*<st c="25434">.</st>
			<st c="25435">Migrating to Observable</st>
			<st c="25459">Before migrating to</st> *<st c="25480">Observable</st>*<st c="25490">, we must ensure that our app deployment target is at least 17\.</st> <st c="25554">Remember that this</st> <st c="25573">feature (and most of the new features described in this book) are from iOS 17, and some are irrelevant if our app deployment target is</st> <st c="25708">not 17.</st>
			<st c="25715">Let’s try to recap the different</st> <st c="25749">Observable attributes:</st>

				*   `<st c="25771">@State</st>`<st c="25778">: This is used to manage the state within a specific view.</st> <st c="25838">A change to a</st> `<st c="25852">@State</st>` <st c="25858">property triggers a view update.</st> <st c="25892">For example, data related to a list or view visibility can be marked</st> <st c="25961">as</st> `<st c="25964">@State</st>`<st c="25970">.</st>
				*   `<st c="25971">@Observable</st>`<st c="25983">: This can</st> <st c="25995">be applied to a class to make the class observable.</st> <st c="26047">Each class property is automatically marked with</st> `<st c="26096">@Published</st>` <st c="26106">unless we mark them as</st> `<st c="26130">@ObservataionIgnored</st>`<st c="26150">.</st> `<st c="26152">@Observable</st>` <st c="26163">can be added to view models or business</st> <st c="26204">logic classes.</st>
				*   `<st c="26218">@Bindable</st>`<st c="26228">: This creates a two-way connection between a property and another value.</st> <st c="26303">Text field input, toggles, or a counter are examples of views for implementing a</st> `<st c="26384">@</st>``<st c="26385">Bindable</st>` <st c="26393">connection.</st>
				*   `<st c="26405">@Environment</st>`<st c="26418">: Mark an object to be shared down the view hierarchy with this attribute.</st> <st c="26494">For example, configuration or a theme can be shared with all views in the hierarchy using the</st> `<st c="26588">@</st>``<st c="26589">Environemnt</st>` <st c="26600">attribute.</st>

			<st c="26611">This list aims to summarize the different attributes in the Observable framework and their</st> <st c="26703">use cases.</st>
			<st c="26713">Once we decide to move to the</st> *<st c="26744">Observable</st>* <st c="26754">framework, there are a few things we need</st> <st c="26797">to do:</st>

				*   <st c="26803">Remove the pro</st><st c="26818">tocol conformation to</st> `<st c="26841">ObservableObject</st>` <st c="26857">and add the</st> `<st c="26870">@Observable</st>` <st c="26881">macro for all the</st> <st c="26900">relevant classes</st>
				*   <st c="26916">Remove the</st> `<st c="26928">@Published</st>` <st c="26938">property wrapper and add</st> `<st c="26964">@ObservationIgnored</st>` <st c="26983">for the properties we don’t want</st> <st c="27017">to observe</st>
				*   <st c="27027">Remove the</st> `<st c="27039">@ObservedObject</st>` <st c="27054">property wrapper</st>
				*   <st c="27071">Rename</st> `<st c="27079">@Binding</st>` <st c="27087">to</st> `<st c="27091">@Bindable</st>` <st c="27100">for the properties that are based</st> <st c="27135">on classes</st>

			<st c="27145">Once we finish migrating to the</st> `<st c="27178">Observable</st>` <st c="27188">framework, things will be clearer and more straightforward, with fewer property wrappers and less protocol conformation.</st> <st c="27310">The binding can also be simple – now it’s</st> `<st c="27352">@Binding</st>` <st c="27360">for primitive values and</st> `<st c="27386">@Bindable</st>` <st c="27395">for classes.</st> <st c="27409">That’s not perfect, but not too bad either.</st> <st c="27453">It’s time to</st> <st c="27466">enjoy</st> *<st c="27472">Observable</st>*<st c="27482">!</st>
			<st c="27483">Summary</st>
			<st c="27490">This was another chapter that made use of Swift macros and other advanced Swift techniques.</st> <st c="27583">A small note: to understand topics such as</st> *<st c="27626">Observable</st>*<st c="27636">, I recommend having good knowledge of Swift.</st> <st c="27682">Otherwise, it becomes just another boring tutorial.</st> <st c="27734">Knowing how things work on the inside is fascinating and can only make</st> <st c="27805">us better.</st>
			<st c="27815">In this chapter, we did a recap of the SwiftUI observation system, and we discussed its problem.</st> <st c="27913">We added the</st> `<st c="27926">@Observable</st>` <st c="27937">macro and explored how it works.</st> <st c="27971">We talked about computed variables, environment variables, and bindable.</st> <st c="28044">Ultimately, we discussed migrating from the “old” observation system to the new</st> *<st c="28124">Observable</st>* <st c="28134">framework.</st>
			<st c="28145">Remember – observation is a core feature of SwiftUI and is crucial to delivering a superior experience to</st> <st c="28252">our users.</st>
			<st c="28262">In the next chapter, we will learn about another critical feature, especially in mobile – navigation</st> <st c="28364">and search.</st>

第五章:4

使用 SwiftUI 进行高级导航

在第 *第二章中,我们讨论了与 观察 框架 一起工作。 观察框架有助于管理我们应用程序不同部分之间的通信,并且是 SwiftUI 声明式编程的基本构建块之一。 然而,它也是我们将用于实现良好导航系统的工具之一。

为什么我们会有一个关于导航的整章内容? 难道不是当用户在列表中选择一个项目时只是显示不同的视图吗?

导航是移动开发中的一个重大主题。 一个标准应用程序可能有数十个屏幕,一个更广泛的应用程序可能有数百个。 了解如何管理我们应用程序中如此多的不同路由,对于我们的应用程序成功至关重要。

在本章中,我们将进行以下操作: 以下操作:

  • 理解为什么 SwiftUI 导航是 一个挑战

  • 探索 SwiftUI 的 <st c="873">NavigationStack</st>

  • 使用不同的数据模型来 触发导航

  • 使用协调者模式来更好地管理我们的 关注点

  • 实现 SwiftUI 的 <st c="1036">NavigationSplitView</st> 以创建一个 基于列的导航

我们有很多内容要介绍! 但在我们开始之前,让我们尝试理解为什么 SwiftUI 导航可能是一个挑战。

技术要求

对于本章,您需要从 Apple 的 App Store 下载 Xcode 版本 16.0 或更高版本。

您还需要运行最新版本的 macOS(Ventura 或更高版本)。 只需在 App Store 中搜索 Xcode,选择并下载最新版本。 启动 Xcode 并遵循系统可能提示您进行的任何其他安装说明。 一旦 Xcode 完全启动,您就可以开始了。

从以下 GitHub 链接 下载示例代码:https://github.com/PacktPublishing/Mastering-iOS-18-Development/tree/main/Chapter%204

理解为什么 SwiftUI 导航是一个挑战

为了回答那个问题,我们需要理解导航是如何直观工作的。用户点击按钮、链接或其他可能发生的事件。然后,应用响应该事件并将视图过渡到另一个屏幕。

从某种意义上说,我们理解这听起来像是一个事件驱动范式。当我们讨论 SwiftUI 和 UIKit 之间的区别时,我们实际上是在讨论声明式编程和<st c="2255">imperative programming</st>之间的区别。

命令式 UI,如 UIKit,也是事件驱动的,而声明式 UI,如 SwiftUI,则表示当前状态。因此,我们可以理解为什么在 UIKit 中导航看起来更简单,并且可能感觉更自然。

许多开发者都在 SwiftUI 导航上挣扎。他们在一个<st c="2593">UIHostingController</st>中包裹一个 SwiftUI 视图,并使用 UIKit 导航系统。这是一个合理的解决方案,用于实现一些在 SwiftUI 中难以完成的复杂导航技术。然而,我们需要记住,SwiftUI 已经发展多年,提供了优秀的导航工具。

让我们从基本的导航工具<st c="2904">NavigationStack</st>开始。

探索<st c="2904">NavigationStack</st>

当 SwiftUI 被引入时,基本的导航机制是基于一个名为<st c="3034">NavigationView</st>的视图。然而,<st c="3059">NavigationView</st>对于大多数应用来说过于简单,因此<st c="3108">NavigationStack</st>取代了它。实际上,苹果从 iOS 18 开始弃用了<st c="3163">NavigationView</st>

<st c="3213">NavigationView</st>相比,<st c="3229">NavigationStack</st>给这个堆栈增加了一点点复杂性,这为我们提供了新的功能。

让我们看看<st c="3364">NavigationStack</st>的一个简单用法示例:

 struct ContentView: View {
    var body: some View { <st c="3436">NavigationStack {</st> NavigationLink("Tap here to go to the next
            screen") {
                Text("Next Screen!")
            }
        }
    }
}

这个代码示例看起来非常简单!

然而,<st c="3585">NavigationStack</st>比它看起来要强大得多。

怎么样?<st c="3658">NavigationStack</st>的概念是由四个组件构成的:

  1. <st c="3786">NavigationView</st>。在 <st c="3805">NavigationStack</st>中,<st c="3822">NavigationLink</st> 描述了发生了什么,而 <st c="3870">navigationDestination</st> 视图修饰符描述了我们去哪里。

  2. 数据与目的地之间的链接:在某种程度上,这是前面点的进一步发展。 目的地链接到一个数据类型。 这意味着我们可以有多个导航链接指向同一个目的地,仅仅因为它们共享相同 的数据类型。

  3. 允许我们读取和更新路径:这里,我们对我们想法的另一个发展。 因为数据和屏幕现在是链接的,我们可以将路径表示为数据实例的数组。 修改路径数组也会改变我们的 视图栈。

  4. <st c="4509">NavigationLink</st> 也具有这种能力,但 <st c="4574">NavigationStack</st> 的引入使其变得过时。

现在让我们详细讨论这四个组件中的每一个,我们将从目的地开始。 我们从目的地开始。

使用 navigationDestination 视图修饰符分离导航目标

如果你已经阅读过我的 上一本书 《Pro iOS Testing》 《Mastering Swift Package Manager》 Apress出版,以及 《The Ultimate iOS Interview Playbook》 Packt Publishing出版),那么有一个重要的原则我一直反复强调:关注点分离 (SoC). 在 SoC 中,我们将代码分解成具有特定和明确责任的独立模块或 组件。 这使得我们的代码更加模块化、灵活,并且易于维护。

当我们回顾 <st c="5232">NavigationLink</st>时,我们可以看到它有多于一个的责任——它是用户实际点击的控制,同时也包含下一个 屏幕视图。

<st c="5392">NavigationStack</st>中,有一个新的视图修饰符称为 <st c="5444">navigationDestination</st>,它允许我们根据状态变化分别定义一个目标。

让我们看看 <st c="5571">navigationDestination</st>的一个例子,基于一个 绑定变量:

 struct ContentView: View {
    @State var isNextScreenDisplayed: Bool = false
    var body: some View {
        NavigationStack {
            Button("Go to next screen") {
                isNextScreenDisplayed = true
            }
            .<st c="5799">navigationDestination(isPresented:</st>
 <st c="5834">$isNextScreenDisplayed) {</st>
 <st c="5860">Text("Next Screen!")</st> }
        }
    }
}

在我们的代码示例中,我们可以看到一个包含按钮的 <st c="5924">NavigationStack</st> 视图。 请注意,没有任何 <st c="5989">NavigationLink</st> 视图,这是因为我们不需要它。 我们通过改变名为 <st c="6096">@State</st> <st c="6118">isNextScreenDisplayed</st> 属性来触发导航,而不是使用一个<st c="6160">NavigationLink</st> 视图。

按钮还有一个名为 <st c="6224">navigationDestination</st>的视图修饰符。 <st c="6251">navigationDestination</st> 视图修饰符有一个绑定布尔变量,它与<st c="6340">isNextScreenDisplayed</st> 状态变量相关联。 <st c="6378">它还有一个包含我们的下一个屏幕(类似于NavigationLink`)的视图构建器。

点击按钮会切换<st c="6495">isNextScreenDisplayed</st> 并导航到我们的下一个屏幕。

使用<st c="6595">NavigationLink</st> 触发导航的能力在 SwiftUI 的早期版本中是可用的,但现在已被弃用。 但不用担心——将目的地与实际控件解耦使我们的代码更加灵活,并为我们提供了更多机会。

例如,想象我们正在进行异步操作,如网络请求或图像处理,并且我们想要移动到下一个屏幕——可以通过切换布尔变量轻松实现。

拥有独立目的地的一个重要方面是,我们可以从不同的地方触发相同的导航。 我们可以通过异步操作和按钮切换 Boolean。 响应状态遵循声明式方法,而不是 <st c="7300">NavigationView</st> 方法,后者是响应按钮点击。

当导航到与任何数据无关的新屏幕时,切换布尔变量是很有用的。 例如,从我们的主屏幕移动到设置是一个使用布尔绑定的经典例子。 Boolean binding。

但我 承诺 <st c="7581">NavigationStack</st> 不仅仅如此, 不是吗?

那么,让我们看看我们如何将导航目的地绑定到 数据模型。

使用数据模型触发导航

对于习惯于使用 UIKit 导航的开发者来说,使用数据模型的想法可能很奇怪。毕竟,切换布尔值进行导航与命令式编程非常相似,但数据模型与导航有什么关系呢?

我们理解到许多屏幕都与特定的数据模型相关。例如,如果我们有一个电影列表,点击一个电影会带我们到一个单独的电影屏幕。 另一个例子是行程应用,点击特定的行程会带我们到一个专门针对该行程的屏幕。 (注:此处省略了代码中的特殊字符标识,实际翻译时请保留。)

如果我们再深入思考,我们可以使用数据模型在我们的应用中表示许多屏幕。我们可以使用包含 枚举的数据模型来区分屏幕。

在我们开始思考探索潜在的可能性和实现方式之前,让我们看看基于基本数据导航是什么样子:(注:此处省略了代码中的特殊字符标识,实际翻译时请保留。)

 struct ContentView: View { <st c="8603">private let countries = ["England", "France", "Spain",</st>
 <st c="8657">"Italy"]</st> var body: some View {
        NavigationStack {
            List(countries, id: \.self) { country in <st c="8748">NavigationLink(country, value: country)</st> } <st c="8790">.navigationDestination(for: String.self)</st> { item
                in
                Text(item)
            }
        }
    }
}

和往常一样,我已经在前面代码中突出了有趣的部分。我们有一个 SwiftUI 视图,显示一个国家列表(基于一个 常量变量)。

每一行都有一个 <st c="9039">NavigationLink</st> 视图来显示国家名称,但这次它没有自己的目的地。 相反,它使用国家作为链接的 值参数。

我们只有在查看导航目的地时才能理解将国家作为值发送的含义。使用 navigationDestination 视图修饰符分离导航目的地 部分中的代码示例中,导航目的地被链接到一个布尔状态变量。 在这种情况下,导航目的地仅在 存在特定数据类型的链接时执行 (在这种情况下,是一个字符串类型,就像一个 国家值)。

换句话说,点击一个国家会通过 <st c="9746">NavigationLink</st>将其值发送到导航堆栈。导航目的地捕获这个值并定义我们的下一个屏幕。(注:此处省略了代码中的特殊字符标识,实际翻译时请保留。)

我们可以通过定义多个导航目的地来使用数据模型导航到不同的地方,每个目的地对应不同的数据模型类型。(注:此处省略了代码中的特殊字符标识,实际翻译时请保留。)

这里是使用导航目的地向个人资料屏幕添加导航的另一个例子:(注:此处省略了代码中的特殊字符标识,实际翻译时请保留。)

 struct Profile<st c="10109">: Hashable</st> {
    let firstName: String
    let lastName: String
    let email: String
}
struct ContentView: View {
    let profile = Profile(firstName: "Avi", lastName:
    "Tsadok", email: "myemail@domain.com")
    let countries = ["England", "France", "Spain", "Italy"]
    var body: some View {
        NavigationStack {
            List(countries, id: \.self) { country in <st c="10439">NavigationLink(country, value: country)</st> }.toolbar(content: { <st c="10500">NavigationLink("Go to profile", value:</st>
 <st c="10538">profile)</st> }) <st c="10551">.navigationDestination(for: String.self)</st> { item
                in
                Text(item)
            } <st c="10615">.navigationDestination(for: Profile.self)</st> {
              profile in
                VStack {
                    Text(profile.firstName)
                    Text(profile.lastName)
                    Text(profile.email)
                }
            }
        }
    }
}

在前面的代码中,我们看到一个来自<st c="10851">Profile</st> <st c="10858">类型的数据模型的另一个导航目的地。要导航到配置文件屏幕,我们在屏幕工具栏中添加了另一个<st c="10909">NavigationLink</st> <st c="10923">视图并发送了配置文件<st c="10972">数据模型</st>

我们的导航系统是动态的,因为我们可以使用不同的数据模型。但这并没有停止。<st c="11093">NavigationStack</st> <st c="11108">可以</st> <st c="11112">揭示并甚至修改当前视图的堆栈。</st>我们使用<st c="11167">path</st> <st c="11193">绑定变量</st>来做到这一点。

《st c="11210">响应路径变量</st>

<st c="11242">目的地</st> <st c="11258">与其导航链接</st>分离是很好的,但<st c="11309">NavigationStack</st> <st c="11324">’s观察和更新其视图堆栈的能力非常强大。

如前所述,一个<st c="11394">NavigationStack</st> <st c="11411">视图有一个名为<st c="11462">path</st>的绑定变量,并且<st c="11476">path</st>变量可以包含通过其<st c="11529">数据模型</st>的视图列表。

使用链表很容易证明这一点:

 struct ContentView: View {
    let list: LinkedList<Int> = {
        let list = LinkedList<Int>()
        list.head = ListNode(1)
        list.head?.next = ListNode(2)
        list.head?.next?.next = ListNode(3)
        return list
    }() <st c="11786">@State var path: [ListNode<Int>]</st> = []
    var body: some View { <st c="11846">NavigationStack(path: $path)</st> {
            VStack {
                NavigationLink("Start", value: list.head)
            }
            .navigationDestination(for: ListNode<Int>.self)
              { node in
                NavigationLink("\(node.value)", value:
                node.next)
              }
        }
    }
}

我选择使用链表来演示<st c="12082">path</st> <st c="12086">使用,因为它是一个很好的数据结构,类似于导航堆栈(来自相同类型的链接项)。

如果我们在导航期间观察<st c="12232">path</st> <st c="12236">变量,我们可以看到它包含当前作为视图活动的列表节点集合。

<st c="12345">path</st> <st c="12383">变量</st>绑定到<st c="12413">NavigationStack</st> <st c="12428">的事实非常棒,因为我们能够操作和修改`它:

 path.append(ListNode(4))

<st c="12524">path</st> <st c="12528">添加新的列表节点会触发导航并将用户引导到一个新的屏幕`。

我们也可以使用<st c="12634">path</st> <st c="12638">变量</st>来创建整个堆栈:

 path = [ListNode(1), ListNode(2)]

设置新的节点数组会创建相应视图的新堆栈。这是一个实现深度链接或将用户引导到应用内的特定位置的好方法。

你现在可能正在挠头,在想我们如何在应用内部实现它?在哪些用例中,我们需要使用相同的数据模型类型在层次结构中导航几个级别?

因此,数据模型类型不一定是 <st c="13105">Task</st>, <st c="13111">Album</st>, 或 <st c="13121">Contact</st>。数据模型也可以描述一个屏幕或一个功能。 这样,数据集合可以描述应用内的导航路径。

以下是一个可以描述屏幕的数据类型示例,后面跟着一个 导航路径:

 enum Screen: Hashable {
    case signin
    case onboarding
    case mainScreen
    case settings
}
@State var path: [Screen] = []

枚举 <st c="13475">Screen</st> 描述了我们要导航到的屏幕类型,并且是构建 堆栈 的一个简单方法:

 path  = [.mainScreen, .settings]

这一行简短的代码在第一个视图是主屏幕,后面跟着一个 设置屏幕 时构建了一个视图堆栈。

使用枚举来显示不同类型的屏幕是很好的。 然而,使用枚举处理不同类型的数据不太方便。 为了解决这个问题,我们有一个比实例集合更复杂的解决方案,它被称为 <st c="13963">NavigationPath</st>

使用 NavigationPath 处理不同类型的数据

<st c="14036">NavigationPath</st> 是与 <st c="14078">NavigationStack</st>一起引入的,它允许我们更好地控制我们的导航流程。 实际上, <st c="14167">NavigationPath</st> 使得使用 SwiftUI 进行导航结合了声明式和命令式编程,并且与 UIKit 的导航模式更为相似。

假设我们有一个音乐应用,其主屏幕上有一个歌曲和专辑列表。 点击歌曲会跳转到 <st c="14429">歌曲</st> 视图,而点击专辑则会导航到一个 <st c="14481">专辑</st> 视图。 在上一个部分中,我们使用枚举来管理这一点,尝试将枚举值映射到屏幕视图。 使用 <st c="14601">NavigationPath</st>,只要其类型符合 可哈希 ,我们就可以将任何我们想要的值附加到 <st c="14661">path</st> 变量上。

让我们看看下面的 代码:

 struct ContentView: View { <st c="14785">@State private var navigationPath = NavigationPath()</st> @State private var albums: [Album] = [Album(title:
      "Album 1"), Album(title: "Album 2")]
    @State private var songs: [Song] = [Song(title: "Song
      1"), Song(title: "Song 2")]
    var body: some View { <st c="15030">NavigationStack(path: $navigationPath) {</st> VStack {
                List {
                    Section(header: Text("Songs")) {
                        ForEach(songs) { song in
                            Button(action: { <st c="15162">navigationPath.append(song)</st> }) {
                                Text(song.title)
                            }
                        }
                    }
                    Section(header: Text("Albums")) {
                        ForEach(albums) { album in
                            Button(action: { <st c="15296">navigationPath.append(album)</st> }) {
                                Text(album.title)
                            }
                        }
                    }
                } <st c="15356">.navigationDestination(for: Song.self) {</st>
 <st c="15396">song in</st>
 <st c="15404">SongDetailView(song: song,</st>
 <st c="15431">navigationPath: $navigationPath)</st>
 <st c="15464">}</st>
 <st c="15466">.navigationDestination(for: Album.self) {</st>
 <st c="15507">album in</st>
 <st c="15516">AlbumDetailView(album: album)</st>
 <st c="15546">}</st> }

请注意,前面的代码示例是不完整的,它不包括 子视图。

我们的音乐应用主屏幕包含四个重要的部分,用于处理我们的 导航系统:

我们从声明一个状态变量开始,该变量保存我们的path 变量 ,称为NavigationPath

 @State private var navigationPath = NavigationPath()

如前所述,与之前我们使用的<st c="15918">path</st> <st c="15922">变量不同,在<st c="15948">NavigationPath</st> <st c="15962">的情况下,我们不需要定义其类型。只要它符合 <st c="16058">Hashable</st> <st c="16066">,它就可以保存我们想要的任何类型。

接下来,我们将使用我们的新NavigationPath 来初始化 <st c="16091">NavigationStack</st> ,这与我们在之前的例子中做的是类似的:

 NavigationStack(path: $navigationPath) {

注意,我们使用了一个类似的签名,但是类型不同——使用Binding 而不是 <st c="16326">Binding<Data></st>

现在我们有了NavigationPath,我们可以通过将相应的对象添加到导航路径中来导航到一个歌曲 视图或一个专辑 视图:

 navigationPath.append(song)

或者,你可以这样做:

 navigationPath.append(album)

添加操作会触发navigationDestination 视图修饰符,传递所选的歌曲或专辑:

 .navigationDestination(for: Song.self) { song in
    SongDetailView(song: song, navigationPath:
      $navigationPath)
}
.navigationDestination(for: Album.self) { album in
    AlbumDetailView(album: album)
}

在这个例子中,我们为每种类型我们传递的navigationDestination 视图修饰符都不同:

我们可以添加任何我们想要的实体的事实使得NavigationPath 成为一个灵活的导航系统的理想组件。

我们也可以使用NavigationPath 通过移除最后一个组件来执行一个Back 操作:

 Button("Back") {
     navigationPath.removeLast()
}

在这个例子中,我们添加了一个返回按钮,当点击时移除导航路径的最后几个组件

因为我们仍然处于一个声明性世界中,我们对导航栈所做的任何更改,无论是通过添加或移除组件,都会反映在我们的 UI 中

与协调器模式一起工作

<st c="17545">NavigationPath</st> <st c="17564">NavigationStack</st> 的组合是健壮的,并且在管理导航方面提供了灵活性。然而,随着我们的应用扩展,控制用户如何从一个屏幕移动到另一个屏幕变得更加复杂。

例如,假设我们有一个引导流程,并希望为不同的用户配置文件提供不同的屏幕集。 或者,我们希望在不同的流程中重用相同的屏幕。 在每个流程中,屏幕应该继续到 不同的屏幕。

在每种情况下,当我们处于屏幕上下文时,理解我们的下一个视图都变得很困难。 实际上,管理我们的导航这个问题不仅与 SwiftUI 有关,而且大多数开发者都知道 从 UIKit。

为了尝试改进我们的导航机制,我们可以使用所谓的 协调器模式 ——一种将导航逻辑委托给 专用组件的模式。

让我们尝试理解这意味着什么。

理解协调器的原理

在我们编写 第一个协调器之前, 让我们回顾一些 基本原理:

  • 协调器是一个组件,它保存当前的导航路径和一般上下文。 它知道显示的是哪个屏幕以及一般的当前流程。 协调器还会将新的视图添加到堆栈中,弹出,并显示模态或 表单视图。

  • 视图不知道用户应该导航到的下一个视图。 它所知道的是用户执行的操作。 从某种意义上说,视图与导航逻辑是隔离的,并且不了解 一般上下文。

  • 协调器代表一个流程。 在我们的应用中,我们可以有多个流程,每个流程有多个协调器。

根据这些原则,我们可以理解协调器模式是我们分离应用关注点的一种改进方式。 应用关注点。

看看 图 4**.1

图 4.1:协调器模式

图 4.1:协调器模式

图 4**.1 展示了基本的协调器模式。 我们有一个 专辑列表 视图,当用户选择一个专辑时,操作会被发送到 协调器。然后,协调器决定通过将操作发送到 <st c="19669">NavigationPath</st>来导航到 专辑详情 视图。

在这个模式中,专辑列表不知道接下来应该发生什么。 例如,协调器可以决定在某些情况下,我们应该向用户展示一个升级屏幕。 或者,如果它是入职流程的一部分,协调器可以确定专辑列表只是一个演示,我们应该继续到入职流程的下一个步骤。 入职流程。

但我们如何构建一个协调器模式呢? 它是如何工作的,尤其是在 SwiftUI 的世界中?

在 SwiftUI 中构建协调器有许多方法。 我这里描述的协调器模式只是一个 示例,用于展示基本原理,我们可以根据项目的需求调整这个示例。

我们将从最基本的部分开始——协调器本身。

构建协调器对象

协调器是 定义不同用户操作和导航选项的中心对象。 它还持有导航路径,以便执行导航操作。

我们将首先定义一个基本的 协调器类:

 class Coordinator: ObservableObject {
    @Published var path = NavigationPath()
}

我们创建了一个包含一个 <st c="20819">导航路径</st> 对象的协调器类。 这个 <st c="20846">导航路径</st> 对象是必不可少的——它允许协调器向堆栈中添加更多项目,执行弹出操作,并理解当前的堆栈。 请注意,协调器遵循 <st c="21043">可观察对象</st> 协议,并且路径是一个已发布对象——这是因为我们希望路径在使用时成为 <st c="21157">导航堆栈</st> 的一部分。

接下来,我们定义不同的用户和 页面操作:

 enum PageAction: Hashable {
    case gotoAlbumView(album: Album)
    case gotoSettingsView
}
enum UserAction {
    case albumTappedInAlbumsList(album: Album)
    case settingButtonTapped
}

在这个例子中,我们 创建了 两个枚举:

  • <st c="21453">页面操作</st>:这个枚举描述了协调器需要执行的一些导航操作,例如导航到一个 <st c="21566">专辑</st> 视图或一个 设置视图。

  • <st c="21596">用户操作</st>:这个枚举描述了用户执行的操作,例如在专辑列表中点击专辑或在 设置按钮上点击。

请注意,一些枚举包含关联值,例如相关的 <st c="21812">专辑</st> 对象。

现在我们有了我们的枚举,我们将创建两个 重要的函数:

 func performedAction(action: UserAction) {
        switch action {
        case .albumTappedInAlbumsList(let album):
            path.append(PageAction.gotoAlbumView(album:
            album))
        case .settingButtonTapped:
            path.append(PageAction.gotoSettingsView)
        }
    }
    @ViewBuilder
    func buildView(forPageAction pageAction: PageAction) ->
      some View {
        switch pageAction {
        case .gotoAlbumView(let album):
            AlbumDetailView(album: album)
        case .gotoSettingsView:
            SettingsView()
        }
    }

首先是 <st c="22342">performAction()</st> 函数。 这个函数接收 <st c="22391">UserAction</st> 作为参数,并将相应的页面操作追加到 <st c="22462">NavigationPath</st>。这个函数是协调器的“大脑”——当我们决定用户执行特定操作时要导航到哪里时。

在这个例子中,当用户在专辑列表中点击专辑时,我们导航到 <st c="22686">专辑</st> 视图,传递 <st c="22710">专辑</st> 对象。 当用户点击设置按钮时,我们导航到设置屏幕。 这种逻辑可能听起来很明显,也可能过于复杂。 然而,在一个复杂的世界里,我们有权限、A/B 测试和其他变化,一个集中处理所有这些的地方可以是非常宝贵的。

第二个函数将页面操作映射到一个 SwiftUI 视图。 我们现在将在构建 <st c="23098">CoordinatorView</st>时使用它。

添加 CoordinatorView

协调器 类是健壮的,包含了我们所有的导航逻辑。 然而,我们不能使用协调器来执行实际的导航。 为了做到这一点,我们必须用 <st c="23330">CoordinatorView</st>包裹我们的视图,它知道如何与 我们的协调器一起工作。

所以,让我们看看 <st c="23412">CoordinatorView</st> 是什么样子:

 struct CoordinatorView: View {
    @ObservedObject private var coordinator = Coordinator()
    var body: some View { <st c="23549">NavigationStack</st>(path: $coordinator.path) {
        AlbumListView()
          .navigationDestination(for:
            PageAction.self, destination: { pageAction in
                coordinator.buildView(forPageAction:
                  pageAction)
                })
        }
        .environmentObject(coordinator)
    }
}

<st c="23772">CoordinatorView</st> 是一个简单的 SwiftUI 视图,它有三个组件:

  • <st c="23840">coordinator</st>: 在 <st c="23862">CoordinatorView</st>中,我们添加了我们刚刚构建的 <st c="23907">Coordinator</st> 类的一个实例。 我们将该协调器制作为一个可观察对象,这样我们就可以使用其路径来添加和从 <st c="24047">堆栈</st>中移除视图。

  • <st c="24057">NavigationStack</st>: 这与我们在本章中遇到的 <st c="24093">NavigationStack</st> 相同。 如前所述,我们使用协调器路径作为 <st c="24178">NavigationStack</st>,但更重要的是,还有两个附加事项——我们使用根视图(<st c="24285">AlbumListView</st>)初始化堆栈,并使用协调器 <st c="24330">buildView</st> 函数将页面操作映射到视图,以将相应的视图添加到 堆栈。

  • <st c="24426">环境对象</st>:我们在协调器中添加了一个 <st c="24457">environmentObject</st> 视图修饰符来声明一个环境对象。 我们这样做是为了让所有在 <st c="24584">NavigationStack</st> 下的视图都能访问协调器,以便它们可以调用不同的 用户操作。

这三个组件 负责将 我们的视图连接到我们 构建的协调器逻辑。

现在,让我们看看 <st c="24799">AlbumListView</st> 是如何与 协调器一起工作的。

直接从视图调用协调器

记住我们的一条 协调器原则——视图的职责只是说 发生了什么,而不是 接下来会发生什么 接下来会发生什么是协调器的职责。

让我们看看 <st c="25081">AlbumListView</st> 是如何处理它的:

 struct AlbumListView: View { <st c="25139">@EnvironmentObject private var coordinator: Coordinator</st> var body: some View {
        List(albums) { album in
            VStack(alignment: .leading) {
                Text(album.title)
                    .font(.headline)
                Text(album.artist)
                    .font(.subheadline)
            }
            .onTapGesture { <st c="25363">coordinator.performedAction(action:</st>
 <st c="25398">.albumTappedInAlbumsList(album: album))</st> }
        }
        .navigationTitle("Albums")
        .toolbar {
            ToolbarItem(placement: .navigationBarTrailing)
            {
                Button(action: { <st c="25546">coordinator.performedAction(action:</st>
 <st c="25581">.settingButtonTapped)</st> }) {
                    Image(systemName: "gear")
                }
            }
        }
    }
}

<st c="25648">AlbumListView</st> 结构体 包含用户专辑列表和一个带有 设置按钮 的导航栏。

点击其中一个专辑会调用协调器的 <st c="25802">performedAction()</st> 函数,该函数返回相应的枚举值和 选中的专辑。

此外,点击设置按钮会调用相同的 <st c="25950">performedAction()</st> 函数,但使用不同的 枚举值。

回到本节的开头,在 构建协调器对象 部分,我们现在可以理解一切是如何连接起来的。

我们还可以理解为什么协调器实例是一个环境对象——这样我们就可以直接从 视图 中调用它。

到目前为止,我们讨论了 <st c="26293">NavigationStack</st> 和协调器模式。 我们可能会认为导航仅仅是关于改变当前视图。 然而,在大屏幕上,如 iPad 屏幕上的导航,通常涉及处理不同的列。 所以,让我们来认识 <st c="26532">NavigationSplitView</st> ,看看 我们是如何将其固定下来的(我告诉你导航是一个复杂的话题,对吧?)。

使用 NavigationSplitView 进行列导航

当我们为 padOS 或 macOS 开发应用时,我们知道我们需要利用大屏幕的优势。 但这意味着什么呢? 有时,它可能意味着使用网格而不是列表。 然而,在导航的上下文中,这意味着当每个列显示不同的视图而不是每次用户导航时替换整个屏幕时,我们可以处理多个列。

换句话说——我们需要 分割屏幕。

为了做到这一点,我们可以使用一个名为 <st c="27190">NavigationSplitView</st>的视图,它以两列或三列的形式展示视图。

当用户选择一个视图中的一个项目时,它会更新其他列中的视图。

创建 NavigationSplitView

为了演示 如何使用 <st c="27395">NavigationSplitView</st>,我们将使用我们的音乐应用示例并将其调整 为 padOS。

让我们从一些重要的术语开始——我们有三种不同的 列类型:

  • <st c="27551">侧边栏</st>:从左侧数的第一列。 这是我们的导航开始的主要列。

  • <st c="27647">内容</st>:当有三列时, <st c="27692">内容</st> 列显示与 <st c="27754">侧边栏</st> 列中选定的项目相关的数据。

  • <st c="27769">详情</st> <st c="27783">详情</st> 列展示 <st c="27831">内容</st> 列或 <st c="27853">侧边栏</st> 列中选定的项目。 一般来说,它是分割视图层次结构中最后的项。

这三个术语最初可能听起来有些令人困惑,所以让我们直接跳到代码,了解它们是如何相互关联的。 以下是一个 <st c="28093">NavigationSplitView</st> 的示例,它显示了一个专辑列表,并且当点击一个 专辑时,应用会显示该专辑的 歌曲列表:

 var body: some View { <st c="28229">NavigationSplitView</st> {
            List(albums, selection: $selectedAlbum) { album
              in
                NavigationLink(album.title, value: album)
            }
        } <st c="28348">detail: {</st> if let selectedAlbum = selectedAlbum {
                List(selectedAlbum.songs, selection:
                  $selectedSong) { song in
                    Text(song.title)
                }
                .navigationTitle(selectedAlbum.title)
            } else {
                Text("Select an album")
            }
        }
    }

我们的代码显示了 <st c="28570">NavigationSplitView</st> 的两个部分——侧边栏(第一个块)和详情。 侧边栏显示了一个专辑列表。 点击一个专辑会更新 <st c="28721">selectedAlbum</st> 状态变量。 详情 块展示了关于 <st c="28761">selected album</st> 所选专辑的歌曲列表。

让我们看看在横向排列的 iPad 上它看起来如何(图 4**.2):

图 4.2: iPad 上分割视图的两个列 – 横向

图 4.2: iPad 上分割视图的两个列 – 横向

这是它在 竖直排列 中的外观(图 4**.3):

图 4.3: 分割视图中竖直排列的两个列

图 4.3: 分割视图中竖直排列的两个列

图 4.2 图 4.3 展示了我们的代码在 iPad 的竖直和横向排列中的运行情况。 在竖直排列中,侧边栏视图出现在抽屉中,而在横向排列中,屏幕被分割,两个视图 都是可见的。

但在 iPhone 上会发生什么? 我们需要为较小的设备创建一个专用视图吗? 让我们看看相同的代码在 iPhone 上会发生什么(图 4**.4):

图 4.4: iPhone 上的 NavigationSplitView

图 4.4: iPhone 上的 NavigationSplitView

图 4**.4 显示,相同的 <st c="29674">NavigationSplitView</st> 仅在 iPhone 上运行时有效。 在小设备上, <st c="29754">NavigationSplitView</st> 构建了一个单页导航机制,类似于在 <st c="29825">NavigationStack</st> 或甚至在 <st c="29872">UIKit 的</st> UINavigationController`》中看到的。

现在,让我们使事情变得稍微复杂一些,并添加一个 第三列。

移动到三列

在许多应用中,数据 层次结构基于两个级别。 在我们的例子中,它是专辑和歌曲,但在其他情况下,我们可以找到组和用户,团队和玩家,或项目和工作。 基于这一点,我们将不得不与一个三级的 导航系统 一起工作:

  • 第一级:第一级项目的列表

  • 第二级:基于第一级选择,第二级项目的列表

  • 第三级:所选第二级项目的详细信息

尽管我们可以在模态屏幕中呈现所选第一级项目的详细信息,但我们可以在 iPad 屏幕的第三列中考虑显示它。

创建 NavigationSplitView 部分中,我们说 <st c="30649">Detail</st> 列显示最后一个选中项的信息。 这意味着,如果我们想添加另一个列,它将位于 <st c="30784">Detail</st> 列和 <st c="30806">Sidebar</st> 列之间——这就是 <st c="30835">Content</st> 列。

因此,让我们在我们的音乐应用中添加一个 <st c="30867">Content</st> 列:

 var body: some View { <st c="30922">NavigationSplitView {</st> List(albums, selection: $selectedAlbum) { album
              in
                NavigationLink(album.title, value: album)
            } <st c="31039">} content: {</st> if let selectedAlbum = selectedAlbum {
                List(selectedAlbum.songs, selection:
                  $selectedSong) { song in
                    NavigationLink(song.title, value: song)
                }
                .navigationTitle(selectedAlbum.title)
            } else {
                Text("Select an album")
            } <st c="31268">} detail: {</st> if let selectedSong = selectedSong {
                VStack {
                    Text("Song Title:
                      \(selectedSong.title)")
                    Text("Artist: \(selectedSong.artist)")
                }
                .padding()
                .navigationTitle(selectedSong.title)
            } else {
                Text("Select a song")
            }
        }
    }

在前面的代码示例中,我们将歌曲列表放在我们新的 <st c="31561">Content</st> 块中,并将歌曲详情放在 <st c="31603">Detail</st> 列中。 我们还使用了相同的 <st c="31653">selectedSong</st> 状态变量,并相应地更新了我们的 UI。!

让我们看看现在在 iPad 上看起来怎么样 图 4**.5):

图 4.5:iPad 上的三列 NavigationSplitView

图 4.5:iPad 上的三列 NavigationSplitView

图 4**.5 显示了 iPad 上的一个 三列 <st c="31938">NavigationSplitView</st> ,现在中间显示的是专辑列表。

总结

本章涉及移动开发中的一个关键主题。 导航始终是一个问题,在 UIKit 中也是如此。 然而,我们可以通过基于产品需求和平衡灵活性与简洁性的方法进行周密规划,实现有效的导航机制。

在本章中,我们讨论了为什么 SwiftUI 是一个挑战,探讨了 <st c="32414">NavigationStack</st>,回顾了协调器模式, 甚至讨论了基于列的导航 ,使用了 <st c="32515">NavigationSplitView</st>。!

到目前为止,我们完全有能力在我们的应用中创建一个令人惊叹的导航!

我们下一章将讨论一个完全不同但令人兴奋的话题:如何使用 WidgetKit 突破我们应用的边界,并在我们的沙盒之外添加功能 。!

第六章:5

使用 WidgetKit 增强 iOS 应用程序

随着 iPhone 这些年的发展,新增了功能以利用大屏幕、内存容量和 强大的处理器。

这些功能之一是主屏幕小部件——扩展我们的应用程序并在 新位置提供信息和甚至交互的绝佳方式。

在本章中,我们将涵盖以下主题:

  • 小部件的概念

  • 了解小部件是如何 工作的

  • 添加我们的第一个小部件并构建条目 时间线

  • 添加一个 用户可配置的小部件

  • 确保我们的小部件是最新的

  • 自定义小部件动画

  • 添加用户交互,例如按钮 和开关

  • 将控制小部件添加到控制中心和 锁屏

因此,让我们从基础知识开始——小部件的概念是什么?

技术要求

对于本章,从 App Store下载 Xcode 版本 15.0 或更高版本是必要的。

确保您正在运行最新版本的 macOS(Ventura 或更新版本)。 只需在 App Store 中搜索 Xcode,选择最新版本,然后继续下载。 打开 Xcode 并完成出现的任何进一步设置说明。 在 Xcode 完全运行后,您 就可以开始了。

要获得额外的功能,例如在 widget 和应用程序之间共享数据,您必须在您的配置文件中设置 AppGroups 并定义您的 AppGroups。

从以下 GitHub 链接下载示例代码:

https://github.com/PacktPublishing/Mastering-iOS-18-Development/tree/main/Chapter%205

小部件的概念

添加小部件不是 iOS 或,实际上,在 苹果生态系统中的新概念。

小部件早在 2005 年的Tiger 版本中就存在于 macOS 中,作为仪表板功能的一部分。 苹果公司采纳了这个想法,并在 iOS 8 的Today Widgets 中引入了通知中心,而在 iOS 14 中,苹果公司引入了主屏幕小部件,类似于 Android 操作系统中已经存在的小部件。 在 iOS 18 中,苹果公司增加了第三方应用向控制中心和主屏幕添加小部件的能力。

小部件的理念不是作为一个完整的应用程序——小部件不应该是我们应用的迷你版本或其屏幕之一,而应该是我们当前应用功能的扩展。 小部件的存在是为了增强用户便利性和生产力,总的来说,是提升整体体验。

小部件旨在提升用户便利性和生产力,通常来说,是提升整体体验。

在 iOS 中,小部件有三个关键角色:

  • 一目了然的信息** ——小部件为我们应用的用户提供最新和重要的信息。 这可能包括配送状态、股票价值、事件日历或任何其他在日常生活中有用的信息。

  • 通往我们应用的捷径** ——点击小部件可以打开我们的应用,在许多情况下,还可以打开我们应用的特定屏幕。 在 watchOS 中,使用小部件打开我们的应用尤为重要,因为与 iOS 不同,watchOS 的启动界面不是用户的默认视图。 对于许多应用开发者来说,这是一种很好的推广他们应用的方式,并在主屏幕上*争夺 用户的注意力。

  • 执行基本操作** ——从 iOS 17 开始,苹果公司增加了交互式小部件,允许用户在不打开应用的情况下执行基本操作,例如完成任务、打开车库门或接受支付请求。 在 iOS 18 中,这一功能更进一步,我们甚至可以将小部件添加到控制中心,或者使用 iPhone 15 设备上的动作按钮打开它们。

在回顾不同的苹果平台时,我们可以看到,一目了然地展示信息的想法非常普遍——在 iOS、iPadOS、macOS 和 watchOS 中,我们有主屏幕和锁定屏幕的小部件、复杂性和实时活动。

例如,Yahoo! 天气应用显示了用户当前位置的天气,而苹果的提醒应用显示了用户的未完成提醒。

对于苹果来说,将不同平台之间的线条拉直成一个单一框架是顺理成章的—— WidgetKit

理解小部件的工作原理

如本章开头所述,小部件不是迷你应用程序。 相反,小部件是简单的视图,显示相关信息,并根据声明的时间线应用程序事件进行更新。

小部件 在不同于应用程序的进程中运行。 它们接收运行时来执行任何代码,因此它们作为静态视图工作,向我们的用户展示预先准备好的信息。 但是,由于我们的用户数据正在不断更新,我们可以创建一个条目数组,每个条目都包含信息和日期。 WidgetCenter 负责为每个条目创建不同的视图,存储它,并根据条目的日期替换 小部件 UI 这个条目数组被称为 一个 时间线

一个很好的例子是 下一个事件 小部件。 下一个事件 小部件显示我们日历中的下一个事件,由于我们可以访问用户的日历,我们可以根据日历事件列表构建时间线并刷新小部件数据。 我们所需提供的只是包括每个 时间线条目的不同数据的时间线

使用时间线来更新小部件的内容,使小部件成为一种极其有效的方式向用户展示信息,无论是在电池使用还是在 处理时间上。

然而,时间线也给我们与小部件一起工作的方式带来了一些挑战,因为与下一个事件 小部件不同,并不是每个时间线都可以预先构建。

但在我们深入到解决问题的解决方案并尝试添加我们的 第一个小部件之前,让我们先等等。

添加小部件

小部件 在应用程序外部运行和存在,因此它们被视为我们应用程序的 *扩展 之一

要添加一个新的小部件——我们需要通过选择小部件扩展 目标来创建一个新的文件 -> 新建 -> 目标…

然后,在 为您的新的目标选择一个模板 窗口中,我们搜索小部件并添加小部件扩展(见 图 5**.1):

图 5.1:为您的新的目标窗口选择一个模板

图 5.1:为您的新的目标选择一个模板窗口

在点击 下一步后,我们应该为我们的小部件提供一个名称,就像我们添加的任何目标一样。 此外,取消选中 *包含配置应用程序 * *意图 * 复选框。

一旦添加了小部件,我们就可以看到一个新的目标,其名称是我们提供的。 Xcode 会为我们创建一些文件,作为小部件模板的一部分(假设目标名称 <st c="6268">MyWidget</st>):

  • <st c="6279">MyWidgetBundle</st> – 小部件包是我们扩展所包含的不同小部件的容器。 目前,我们只有一个小部件,但可以添加更多。

  • <st c="6444">MyWidget</st> – 包含小部件代码本身,包括其 UI 和配置。

  • <st c="6524">Assets</st> – 一个专门为小部件扩展设计的资产目录。

  • <st c="6589">Info.plist</st> – 就像任何目标一样,小部件扩展包含一个 <st c="6657">plist</st> 文件,其中包含有关小部件扩展的通用信息。

现在,是时候 阐明小部件是什么了——我们为小部件有不同的尺寸并不意味着它们是不同的小部件,因为相同的小部件可以有多个尺寸。 不同的小部件通常是指不同的产品、不同的 UI 和不同的用例。 在我们的案例中,小部件包描述了不同的小部件,而不是不同的小部件尺寸。

现在我们已经将小部件添加到我们的项目中,我们可以运行我们的应用程序并将新小部件添加到我们的启动板上(图 5**.2):

图 5.2:在启动板中的我们的新模板小部件

图 5.2:在启动板中的我们的新模板小部件

我们可以在 图 5**.2 中看到,新的小部件由当前时间和一些表情符号组成。 这是玩它并尝试添加额外的 小部件尺寸的好时机。

配置我们的小部件

我们设置小部件的外观和行为的方式是通过确定其配置。 我们有 几个配置可以工作,并且它们都符合一个名为 WidgetConfiguration`

我们可用的配置之一是 <st c="7706">StaticConfiguration</st> <st c="7727">StaticConfiguration</st> 允许我们创建一个没有任何 用户可配置选项的小部件。

让我们看看当我们在 Xcode 中添加一个新小部件时,它提供的 <st c="7840">StaticConfiguration</st> new widget:

 struct MyWidget: Widget {
    let kind: String = "MyWidget"
    var body: some WidgetConfiguration { <st c="7999">StaticConfiguration(kind: kind, provider:</st>
 <st c="8040">Provider()) { entry in</st>
 <st c="8063">MyWidgetEntryView(entry: entry)</st>
 <st c="8095">.containerBackground(.fill.tertiary, for:</st>
 <st c="8136">.widget)</st>
 <st c="8144">}</st>
 <st c="8146">.configurationDisplayName("My Widget")</st> .description("This is an example widget.")
    }
}

我们可以看到 <st c="8247">StaticConfiguration</st> 具有与所有配置类型共享的几个属性。 让我们深入看看它们,如下所示:

  • <st c="8357">kind</st> – 这是小部件配置的唯一标识符。 它帮助我们使用 WidgetCenter向特定小部件配置发送请求。

  • <st c="8501">configurationDisplayName</st> – 这是小部件显示名称,当用户想要选择要添加的正确小部件时,它将显示给用户。 to add.

  • <st c="8634">description</st> – 这是小部件的描述,它显示在用户旁边,紧邻其 显示名称。

除了这三个参数之外,我们还有其他一些重要的参数。supportedFamilies 决定了小部件支持的尺寸。 以下是一个如何限制小部件只以中等尺寸显示的示例: medium size:

 .supportedFamilies([.systemMedium])

另一个属性是 <st c="9009">backgroundTask</st>,它允许我们的小部件在系统给它时间时执行后台操作。

注意 <st c="9126">WidgetConfiguration</st> 只是一个协议 – 当创建小部件时,我们需要在组件体中返回一个符合该协议的结构,并且 <st c="9278">StaticConfiguration</st> 只是实现这一点的其中一种方式。

目前,我们有三种配置可供我们使用: for us:

  • <st c="9385">StaticConfiguration</st> – 如前所述,此配置允许我们创建一个不可配置的小部件

  • <st c="9499">AppIntentConfiguration</st> – 这使用户能够自定义他们的小部件,例如,为天气小部件选择一个城市,或为 提醒应用

  • <ActivityConfiguration> – 这项配置显示了实时活动小部件的实时数据

一个小部件只能包含一个配置。如果我们需要多个配置,那么这是一个很好的迹象,表明我们需要创建具有不同配置的多个小部件,并在它们之间共享一些代码。

所有这些小部件配置听起来都很吸引人!让我们从探索StaticConfiguration开始。

工作与静态配置

一个静态小部件是一个没有用户可配置选项的小部件。例如,一个显示特定城市当前时间的小部件不能是静态的,因为用户需要指定一个城市或位置来配置小部件。

然而,一个静态小部件的好例子是一个显示整个月视图并标记当前日期的日历小部件,或者是一个显示最近播放的歌曲的音乐应用小部件。

尽管日历和音乐应用小部件显示的信息不是由用户更新的,但它们需要不时地更新自己。

如果我们回顾静态配置示例(在《配置我们的小部件》部分),我们可以看到一个名为provider的参数,它包含一个名为entry的视图构建器闭包参数。

使用providerentry,我们可以以高效的方式在时间上为我们的小部件提供数据。

小部件的一个关键方面是提供时间上的数据,我们使用时间线提供者来实现这一点。现在,让我们了解时间线提供者是什么意思。

理解小部件的时间线提供者

有一个原因,为什么苹果公司花了近 14 年时间才在 iOS 主屏幕上支持小部件。主要原因是性能,包括电力和内存性能。虽然今天的设备功能强大,但在 Springboard 上拥有大量活动小部件会消耗大量的电力。因此,我们需要找到更有效的方法来高效地加载我们的小部件。

我们在 理解小部件如何工作 部分提到了效率,所以让我们深入细节。 与应用不同,小部件即使在可见时也不是活跃的。 我们可以在特定时间“唤醒”这些小部件以重新加载它们的视图。 为了设置特定时间段,我们需要创建一个时间线 – 一个包含时间点和相关数据的条目数组。 创建一个更长的时线可以最大化我们小部件的更新频率。

例如,如果我们想重新加载显示下一个事件的日历小部件,我们可以创建一个包含每个事件的条目的时间线。 每个条目包含事件时间和随后发生的事件名称。

相反,如果我们想创建一个显示全天信息的日历小部件,我们可能需要为每一天创建一个包含条目的时间线。 在这种情况下,每个条目包含一天开始的时间和当天的活动列表。

创建一个更长的时线可以最大化我们小部件的更新频率。

现在,让我们转向代码并创建我们的第一个时间线。 以下是一个显示下一个事件的时线提供者的示例:

 struct EventEntry: TimelineEntry {
    let date: Date
    let nextEvent: String
}
struct Provider: TimelineProvider {
    func placeholder(in context: Context) -> EventEntry {
        EventEntry(date: Date(), nextEvent: "Loading")
    }
    func getSnapshot(in context: Context, completion:
      @escaping (EventEntry) -> Void) {
        let entry = EventEntry(date: Date(), nextEvent: "Go
          to the book store")
        completion(entry)
    }
    func getTimeline(in context: Context, completion:
      @escaping (Timeline<EventEntry>) -> Void) {
        let entries: [EventEntry] = getListOfEnties()
        let timeline = Timeline(entries: entries, policy:
          .atEnd)
        completion(timeline)
    }
    func getListOfEnties()->[EventEntry] {
      …
    }
}

前面的代码包含两个结构体 – <st c="13321">EventEntry</st> <st c="13336">Provider</st>.

<st c="13345">EventEntry</st> 是一个符合 <st c="13386">TimeLineEntry</st> 协议的结构体。 <st c="13414">TimeLineEntry</st> `协议表示小部件时间线中的单个条目。 该协议包含一个必需的变量 名为 date:

 var date: Date { get }

<st c="13568">date</st> 变量包含我们期望小部件重新加载的时间点。 除了<st c="13664">date</st>之外,我们还添加了一个表示条目下一个事件标题的变量,名为nextEvent<st c="13756">

我们的第二个结构体是<st c="13769">Provider</st>Provider <st c="13801">结构体符合TimeLineProvider<st c="13837"><st c="13855">Provider</st> 结构体的目的是生成一个时间线,以便<st c="13904">WidgetCenter</st> 在需要时重新加载小部件。 让我们看看<st c="13970">Provider</st> 是如何做到这一点的。

生成时间线

我之前提到时间线是一系列时间线条目,但实际情况要复杂一些。 查看时间线提供者实现,我们可以看到几个函数,这些函数可以帮助我们在任何给定时间提供静态 UI。

Provider 结构是一个协议实现

没有必要直接调用 <st c="14347">Provider</st> 函数。 我们将时间线提供者传递给组件配置,配置在需要时使用 <st c="14466">Provider</st> 函数。

第一个和主要的功能是 <st c="14532">getTimeLine</st>。让我们看看 <st c="14585">getTimeline</st> 函数的实现:

 func getTimeline(in context: Context, completion: @escaping
    (Timeline<EventEntry>) -> Void) {
        let entries: [EventEntry] = getListOfEntries()
        let timeline = Timeline(entries: entries, policy:
          .atEnd)
        completion(timeline)
    }

<st c="14838">getTimeline()</st> 函数 创建一个条目数组,将其包装在一个 <st c="14906">Timeline</st> 结构中,并使用 <st c="14948">completion</st> 闭包返回它。 这里有两个有趣的地方可以观察 – <st c="15023">Context</st> 参数和 Timeline 重载策略

  • <st c="15073">上下文</st> <st c="15087">Context</st> 参数包含有关小部件环境的信息,例如小部件系列(它是一个小部件吗? 也许是一个中等大小的?),或者实际的小部件大小。 如果小部件 UI 在大尺寸时显示更多信息,我们可能希望将更多数据加载到我们的时间线条目中。 但这里最重要的信息可能是 <st c="15424">isPreview</st> 属性,它指示小部件是否出现在小部件库中。 一般来说,在组件库中展示我们的组件的真实用户数据是最佳实践,但这并不仅限于安全或网络问题。 因此,我们可以通过检查 <st c="15752">isPreview</st> 属性来为组件库提供模拟数据。

  • <st c="15771">策略</st> <st c="15778">– 我们提供给小部件的时间线有一个最终条目数。</st> <st c="15850">那么,当它们完成,时间线达到其尽头时会发生什么?</st> <st c="15921">这正是</st> <st c="15940">策略</st> <st c="15952">参数在描述时间线重新加载行为时的作用。</st> <st c="16017">有几个选项 –</st> <st c="16045">atEnd</st> <st c="16050">(</st> <st c="16052">WidgetKit</st> <st c="16062">请求一个新的时间线),</st> <st c="16088">never</st> <st c="16093">(</st> <st c="16095">WidgetKit</st> <st c="16105">不请求一个新的时间线),以及</st> <st c="16142">after(date:Date)</st> <st c="16158">(</st> <st c="16160">WidgetKit</st> <st c="16170">在特定日期生成一个新的时间线)。</st> <st c="16216">策略有助于</st> <st c="16237">WidgetCenter</st> `更好地优化时间线重新加载机制。

<st c="16302">在我们继续之前,关于时间线重新加载优化的几点说明。</st> <st c="16374">我们希望尽可能长地构建我们的时间线并不意味着我们的小部件需要不断重新加载。</st> <st c="16495">*<st c="16499">WidgetCenter</st> <st c="16511">为每个主屏幕上的小部件都有一个“预算”,指定一天中它执行刷新的时间。</st> <st c="16623">优化我们的时间线结构和“节省”系统预算符合我们的利益。</st> <st c="16724">精心规划时间线条目和重新加载策略可以帮助我们实现相关的事件驱动</st> <st c="16825">刷新间隔。</st>

<st c="16843">回到</st> <st c="16862">TimelineProvider</st> <st c="16878">协议,我们可以看到另外两个函数 –</st> <st c="16927">placeholder</st> <st c="16938">和</st> <st c="16943">getSnapshot</st> <st c="16954">。让我们</st> <st c="16962">实现它们。</st>

<st c="16977">第一个函数是</st> <st c="17000">getTimeline</st> <st c="17011">,它返回一个</st> <st c="17029">Timeline</st> <st c="17037">结构,其中包含特定时间段的实际数据条目列表。</st> <st c="17116">但这是否足以让我们的小部件完全功能正常?</st>

<st c="17171">答案是:不——在提供实际数据可能不足以满足的两种情况下。</st>

<st c="17265">*<st c="17270">placeholder</st> <st c="17281">函数解决了第一个用例。</st> <st c="17319">当用户将小部件添加到他们的主屏幕时,</st> <st c="17369">WidgetKit</st> <st c="17379">需要在小部件从我们的应用程序获取</st> <st c="17445">或更新真实数据之前立即显示某些内容。</st> <st c="17480">*<st c="17484">placeholder</st> <st c="17495">函数返回临时数据,仅用于向</st> <st c="17554">用户</st> <st c="17555">显示:</st>

 func placeholder(in context: Context) -> EventEntry {
        EventEntry(date: Date(), nextEvent: "English
          class")
    }

在我们的示例中,我们可以看到一个 <st c="17702">占位符</st> 函数,它返回 <st c="17740">English</st> <st c="17748">类</st> 文本。

返回临时数据而不是加载指示器很重要,例如,这是因为我们希望用户体验保持一致和流畅。 同时,创造性地提出优雅的信息会更好。 例如,如果我们的小部件有一个计时器或时间,显示 00:00 是一个好主意,以向用户表明计时器应该 出现在这里。

第二个函数是 <st c="18155">getSnapshot</st><st c="18172">getSnapShot</st> 函数甚至比 <st c="18221">placeholder</st>` 更重要。当用户浏览小部件画廊时,系统会展示不同的小部件。 这些小部件在没有系统生成的时间线的情况下被展示。

getSnapshot</st> 函数返回一个基于 <st c="18428">TimelineEntry</st>的具有要在小部件画廊中呈现的数据的结构。

以下是一个 <st c="18523">getSnapshot</st> 函数的示例:

 func getSnapshot(in context: Context, completion:
      @escaping (EventEntry) -> Void) {
        let entry = EventEntry(date: Date(), nextEvent: "Go
          to the book store")
        completion(entry)
    }

在这段代码中, <st c="18739">getSnapshot</st> 函数返回一个包含当前日期的示例事件。 这个快照向用户展示了我们的 <st c="18866">小部件</st> 的目的。

请注意,在 <st c="18899">占位符</st> <st c="18915">getSnapshot</st>中,我们都有与</st>Context<st c="18952">参数</st> <st c="18963">相同的参数,就像我们在</st>getTimeline<st c="18999">函数中拥有的那样。</st> <st c="19010">我们需要</st>Context<st c="19029">的原因与之前相同——为了了解围绕</st>我们的小部件` 的环境。

现在我们了解了如何生成时间线提供者,让我们来讨论 <st c="19190">TimelineEntry</st>

构建我们的 <st c="19245">TimelineEntry</st> 结构

我们现在可以看到,TimelineProvider 协议很简单,因为只需要实现三个函数。 其中我们需要设计的一个是 <st c="19410">TimelineEntry</st>`,它之所以重要,是因为它包含了我们需要的信息,不仅用于确定何时呈现信息,还包括呈现什么。 信息。

时间线条目 <st c="19593">的结构</st> 需要符合我们的小部件目标并与它的 UI 对齐。 因为我们根据时间线预先生成了所有条目,我们应该提前进行所有计算并生成一个可以轻松更新小部件 内容的结构。

实际上, <st c="19861">时间线条目</st> 可能由 四个组件组成:

  • <st c="19906">日期</st> – 我们希望小部件重新加载特定条目信息的日期。 注意,在大多数情况下, <st c="20020">日期</st> 属性不是屏幕上展示的信息的一部分。 例如,在日历小部件中,我们可能将日期属性作为 <st c="20173">时间线条目</st> 协议的一部分,并为实际 事件时间使用类似 <st c="20225">eventDate</st> 的属性。

  • <st c="20401">标题</st>, <st c="20408">正文内容</st>, 和 <st c="20422">时间字符串</st>, 可以简化我们的代码甚至 提高性能。

  • <st c="20819">时间线条目</st> 是用户与之交互时我们所拥有的全部。

  • <st c="20902">相关性</st> 属性是我们作为 <st c="20973">时间线条目</st> 协议的一部分拥有的可选属性。 <st c="21004">相关性</st> 属性中,我们可以确定条目对用户的关联优先级。 例如,一个向用户展示下一个任务的待办事项应用可能希望将高分数设置给包含关键任务的条目。 或者,一个在小部件中展示最新新闻的运动应用可能希望将高分数设置给包含用户喜欢的球队新闻的条目。 条目的相关性值帮助 WidgetKit 决定如何在系统中何时展示小部件。 例如, WidgetKit 可能决定旋转堆叠小部件并展示一个高相关性信息的小部件。 让我们看看如何为 <st c="21608">相关性</st> 设置 <st c="21622">一个</st> 时间线条目`的例子:

     struct EventEntry: TimelineEntry {
        let date: Date
        let nextEvent: String
        var relevance: TimelineEntryRelevance? }
    let entry = EventEntry(date: date, nextEvent: "Go to
      the book store", <st c="21823">relevance:</st>
    <st c="21897">relevance</st> property to our <st c="21923">EventEntry</st> struct and set a score of <st c="21960">1.0</st>. It is worth noting that any efforts to manipulate the system and set high scores for all entries won’t succeed – Apple has built an algorithm that filters out widgets that have unrealistic values. As with many iOS frameworks, this is a situation where we need to follow the platform’s intended usage guidelines.
    

现在我们已经创建了一个时间线,让我们转向主要话题,即构建我们的 小部件 UI。

构建我们的小部件 UI

创建条目时间线对于我们的小部件向用户提供准确和相关信息至关重要。 但要做到这一点,我们还需要渲染小部件 UI。 我们进行渲染的地方是小部件的结构中,正如我们在本章开头在 *配置我们的 * *小部件 * *部分 中所看到的。

让我们再次看看 配置:

 StaticConfiguration(kind: kind, provider: Provider()) {
    entry in <st c="22809">MyWidgetEntryView(entry: entry)</st>
 <st c="22840">.containerBackground(.fill.tertiary, for:</st>
 <st c="22881">.widget)</st> }

正如我们所见, <st c="22910">StaticConfiguration</st> 有一个返回 SwiftUI 视图的视图构建器,这可能是我们在 WidgetKit 中需要理解的第一件事——小部件仅使用 SwiftUI 构建。 如果你还没有任何 SwiftUI 的经验, WidgetKit 是一个开始学习的好机会。

可能引起你注意的可能是 <st c="23241">containerBackground</st> 视图修饰符。 如果你还记得,我们讨论过小部件现在可以在苹果生态系统的不同地方显示—— iOS (主屏幕和锁屏), padOS macOS,以及 watchOS。但将我们的小部件放在不同的平台上的主要问题可能是小部件的背景。 小部件的背景。

添加 <st c="23566">containerBackground</st> 视图修饰符确保小部件的背景会根据其容器进行调整,并且无论出现在哪里都始终看起来很好。 它出现在哪里都一样。

如果我们再次查看 我们的代码示例,我们可以看到 <st c="23774">MyWidgetEntryView</st> 接收一个参数,即当前的 时间线条目。 让我们看看我们能从中学习到什么。

处理时间线条目

将时间线条目连接到小部件视图是理解小部件工作原理的核心。 WidgetCenter 的主要作用是生成时间线,并在正确的时间为我们的小部件提供正确的时间线条目。

小部件 配置视图构建器有一个参数,即特定的时间线条目,因此我们可以返回一个包含相关数据的 小部件视图。

这是一个使用特定 时间线条目的 小部件视图的示例:

 struct MyWidgetEntryView: View { <st c="24370">let entry:</st> EventEntry
    var body: some View {
        VStack(alignment: .leading) {
            Text("Next Event:")
                .font(.headline)
            Text(<st c="24486">entry.nextEventTitle</st>)
                .font(.title)
                .foregroundColor(.blue)
            Text("Time: \(<st c="24562">entry.nextEventTime</st>)")
                .font(.subheadline)
            Spacer()
        }
        .padding()
    }
}

此代码 示例显示了一个简单的视图,它在使用 时间线条目时显示下一个事件标题和时间。

从时间线条目与组件视图协同工作的方式中,我们可以学到两件事: 组件视图:

  • 条目应包含所有组件的数据 —— 我们在讨论时间线提供者时提到了这一点,但现在我们可以看到原因了。 组件需要尽可能静态和简单。 我们不希望在视图显示时执行任何数据获取操作。

  • 没有状态 —— 与常规 SwiftUI 视图不同,我们的组件视图没有状态。 有些情况下,我们可能希望根据不同情况显示不同的视图。 例如,在我们的下一个组件视图示例中,如果我们希望用户尚未批准其日历权限,我们可能想显示一条消息,提示用户连接到您的日历**。 如果用户尚未批准其日历权限,我们可能想显示一条消息,提示用户连接到您的日历**。 为了做到这一点,我们需要生成不同的时间线条目,并在静态配置闭包中显示不同的视图。 无论如何,我们应该提前进行这些检查。

尽管组件自然是静态的,但它们的 UI 不必保持静态和粗体。 <st c="25723">WidgetKit</st>`中,我们可以通过动画变化来使我们的组件生动起来。

添加动画

我们已经 了解了 iOS 开发中动画的工作原理——视图动画通过在两个或多个状态之间转换来实现。 例如,如果一个特定的视图的透明度为<st c="25994">1.0</st> ,我们将其更改为<st c="26018">0.5</st>,如果喜欢,UIKit 和 SwiftUI 可以对该变化进行动画处理。

组件是用 SwiftUI 编写的,在 SwiftUI 中,我们可以对状态变化进行动画处理。 然而,组件并不使用状态。 相反,我们通过时间线提供者(也许我们可以这样说,在某种程度上,时间线条目就是我们的组件状态)来更改组件内容。

从 iOS 16 开始,每当<st c="26374">WidgetCenter</st> 重新加载组件并使用新条目更改其内容时,它会自动执行此<st c="26464">转换</st>

即使我们没有在组件中设置状态,我们能否自定义这个动画? 当然可以, 使用<st c="26588">contentTransition</st>

如前所述,在大多数情况下,SwiftUI 根据状态变化执行动画。 例如,看看以下代码: 以下代码:

 @State private var isRed = false
    var body: some View {
        VStack {
            Color(isRed ? .red : .blue)
                .frame(width: 100, height: 100)
                .cornerRadius(10)
            Button("Change Color") { <st c="26898">withAnimation {</st>
 <st c="26913">self.isRed.toggle()</st>
 <st c="26933">}</st> }
        }
    }

在这个代码示例中,我们有一个视图和一个按钮。 点击按钮会改变视图的颜色,它是通过使用 <st c="27066">withAnimation</st> 函数来实现的。 显然,这在一个组件中是不可行的,因为我们需要一个状态来 完成这个操作。

相反,我们需要做的是定义内容在动画时如何变化。 为了做到这一点,我们可以 使用 <st c="27268">contentTransition</st>:

 Color(isRed ? .red : .blue)
                .frame(width: 100, height: 100)
                .cornerRadius(10)
 <st c="27367">.contentTransition(.opacity)</st> Button("Change Color") {
                withAnimation() {
                    self.isRed.toggle()
                }
            }

<st c="27462">contentTransition</st> 是一种我们可以添加到视图中的视图修改器,用于定义它们的过渡方法。 想象一下,所有在组件中的内容更改都是通过 <st c="27613">withAnimation</st> 来完成的,而我们唯一需要做的就是改变 过渡方法。

例如,以下 代码片段:

 Text(text)<st c="27818">withAnimation()</st> function, it will change its content with a nice numeric transition (you can try it yourself). If you are not familiar with the <st c="27962">withAnimation</st> function, *<st c="27986">Chapter 6</st>* provides a brief discussion on it.
			<st c="28030">In widgets, all we need to do is to add these to views with content that is based on our timeline entry, and SwiftUI will take care of the</st> <st c="28170">animation itself.</st>
			<st c="28187">Look at our widget again, now</st> <st c="28218">with</st> `<st c="28223">contentTransition</st>`<st c="28240">:</st>

struct MyWidgetEntryView : View {

var entry: Provider.Entry

var body: some View {

    VStack {

        Text("时间:")

        Text(entry.nextEventTime, style: .time)

        Text("下一个事件")

        Text(entry.nextEvent) <st c="28429">.contentTransition(.numericText())</st> }

}

}


			<st c="28469">Even though</st> <st c="28482">there is no state or</st> `<st c="28503">withAnimation</st>` <st c="28516">function, the</st> `<st c="28531">nextEvent</st>` <st c="28540">title will animate its transition.</st> <st c="28576">The</st> `<st c="28580">contentTransiton</st>` <st c="28596">view modifier has additional options, such as opacity and symbol effects.</st> <st c="28671">Despite the fact that it is not designed explicitly for widgets, it’s the best way to make our widgets</st> <st c="28774">more alive.</st>
			<st c="28785">Customize our widget</st>
			<st c="28806">Up until now, we have discussed widgets based on a</st> `<st c="28858">staticConfiguration</st>`<st c="28877">. The</st> `<st c="28883">staticConfiguration</st>` <st c="28902">set is great for most widgets.</st> <st c="28934">However, there are cases where we</st> <st c="28968">want to provide our users the ability to customize</st> <st c="29019">and configure t</st><st c="29034">heir widgets with</st> <st c="29053">additional entities.</st>
			<st c="29073">Going back to our calendar widget, we want to allow the user to filter the next event information based on a</st> <st c="29183">specific calendar.</st>
			<st c="29201">To do that, we’ll start by creating a new file and add a struct called</st> `<st c="29273">CalendarWidgetIntent</st>` <st c="29293">that conforms</st> <st c="29308">to</st> `<st c="29311">WidgetConfigurationIntent</st>`<st c="29336">.</st>
			<st c="29337">Adding intent</st>
			<st c="29351">A</st> `<st c="29354">WidgetConfigurationIntent</st>` <st c="29379">is an App Intent we can use to configure widgets, and our</st> `<st c="29438">CalendarWidgetIntent</st>` <st c="29458">contains all the configuration information</st> <st c="29502">we need.</st>
			<st c="29510">Here is</st> <st c="29519">a basic</st> `<st c="29527">CalendarWidgetIntent</st>` <st c="29547">implementation:</st>

struct CalendarWidgetIntent: WidgetConfigurationIntent {

static var title: LocalizedStringResource = "Select

Calendar"

@Parameter(title: "Calendar") var calendar:

CalendarEntity

}


			<st c="29743">In the preceding code, we can see</st> <st c="29778">two properties:</st>

				*   `<st c="29793">title</st>` <st c="29799">– The title of the intent.</st> <st c="29827">It is important to note that we don’t see the title in the widget configuration string but rather in Siri Shortcuts.</st> <st c="29944">But we must add it since it is part of the</st> `<st c="29987">AppIntent</st>` <st c="29996">protocol (the</st> `<st c="30011">WidgetConfigurationIntent</st>` <st c="30036">inheritance from</st> `<st c="30054">AppIntent</st>` <st c="30063">protocol).</st>
				*   `<st c="30074">calendar</st>`<st c="30083">– This is the widget parameter that allows the user to configure the calendar the event belongs to.</st> <st c="30184">We can see that the</st> `<st c="30204">calendar</st>` <st c="30212">variable is prefixed by the</st> `<st c="30241">@Parameter</st>` <st c="30251">macro, which manages this property for the</st> <st c="30295">user’s configuration.</st>

			<st c="30316">Now, let’s add the</st> <st c="30336">App Intent.</st>
			<st c="30347">Adding AppEntity</st>
			<st c="30364">As you</st> <st c="30372">have noticed, the calendar variable is based on a type</st> <st c="30427">called</st> `<st c="30434">CalendarEntity</st>`<st c="30448">.</st>
			<st c="30449">If we want to support our own entity type, it needs to conform to</st> `<st c="30516">AppEntity</st>`<st c="30525">. Let’s see the</st> `<st c="30541">CalendarEntity</st>` <st c="30555">type implementation:</st>

struct CalendarEntity: AppEntity {

let id: String

let name: String

static var typeDisplayRepresentation:

TypeDisplayRepresentation = "Calendar"

static var defaultQuery = CalendarQuery()

var displayRepresentation: DisplayRepresentation {

    DisplayRepresentation(title: name)

}

}


			<st c="30852">The</st> `<st c="30857">CalendarEntity</st>` <st c="30871">struct represents the data model for the</st> `<st c="30913">intent</st>` <st c="30919">parameter.</st> <st c="30931">First, we need to add the parameters we need in order to support the item when displaying</st> <st c="31021">the widget, such as</st> `<st c="31041">id</st>` <st c="31043">and</st> `<st c="31048">name</st>`<st c="31052">. Next, we’ll add some representation variables, such as</st> `<st c="31109">typeDisplayRepresentation</st>` <st c="31134">and</st> `<st c="31139">displayRepresentation</st>`<st c="31160">.</st>
			<st c="31161">Finally, we’ll add a static variable that handles the actual data fetching, and that’s the</st> `<st c="31253">defaultQuery</st>` <st c="31265">property.</st> <st c="31276">Remember that the user needs to select the desired calendar based on a list of calendars.</st> <st c="31366">To do that, we need to provide</st> *<st c="31397">WidgetKit</st>* <st c="31407">with a way to query our data to support the selection</st> <st c="31461">UI flow.</st>
			<st c="31469">So, what does the query look like?</st> <st c="31505">Let’s</st> <st c="31511">find out.</st>
			<st c="31520">Building the EntityQuery</st>
			<st c="31545">Sometimes, having a</st> <st c="31566">list of options for the user relies on a data store, and sometimes on</st> <st c="31636">static information.</st>
			<st c="31655">Regardless of the model type, if we want to provide options to the user, we need to have a simple and effective interface to work with, and that’s what the</st> `<st c="31812">EntityQuery</st>` <st c="31823">protocol</st> <st c="31833">is for.</st>
			<st c="31840">In our current</st> `<st c="31856">AppIntent</st>` <st c="31865">example, we let the user choose one of its calendars, so we need to build a struct named</st> `<st c="31955">CalendarQuery</st>` <st c="31968">that conforms</st> <st c="31983">to</st> `<st c="31986">EntityQuery</st>`<st c="31997">.</st>
			<st c="31998">Let’s look</st> <st c="32010">at a simple</st> `<st c="32022">CalendarQuery</st>` <st c="32035">example:</st>

struct CalendarQuery: EntityQuery {

func entities(for identifiers: [CalendarEntity.ID])

async throws -> [CalendarEntity] {

    allCalendars.filter { identifiers.contains($0.id) }

}

func suggestedEntities() async throws ->

[CalendarEntity] {

    allCalendars

}

func defaultResult() async -> CalendarEntity? {

    nil

}

}


			<st c="32352">Assume that</st> `<st c="32365">allCalendars</st>` <st c="32377">is an array containing all the</st> <st c="32409">user calendars.</st>
			<st c="32424">In this case,</st> `<st c="32439">CalendarQuery</st>` <st c="32452">implements three methods.</st> <st c="32479">Let’s quickly go</st> <st c="32496">over them:</st>

				*   `<st c="32506">entities(for identifiers:)</st>` <st c="32533">– This function returns calendar entities based on a list of IDs.</st> *<st c="32600">WidgetKit</st>* <st c="32610">uses it to show the</st> <st c="32630">selected calendar</st>
				*   `<st c="32647">suggestedEntities()</st>` <st c="32667">– This returns the list of entities in the</st> <st c="32711">pop-up menu</st>
				*   `<st c="32722">defaultResult()</st>` <st c="32738">– This returns the value when nothing</st> <st c="32777">is selected</st>

			<st c="32788">Now, let’s</st> <st c="32800">see how it looks (</st>*<st c="32818">Figure 5</st>**<st c="32827">.3</st>*<st c="32829">):</st>
			![Figure 5.3: The widget configuration menu](https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_05_3.jpg)

			<st c="32885">Figure 5.3: The widget configuration menu</st>
			<st c="32926">In</st> *<st c="32930">Figure 5</st>**<st c="32938">.3</st>*<st c="32940">, we can see the</st> `<st c="33153">Bool</st>` <st c="33157">or</st> `<st c="33161">String</st>`<st c="33167">, and</st> *<st c="33173">WidgetKit</st>* <st c="33183">will create their corresponding</st> <st c="33215">input control.</st>
			<st c="33229">Let’s flip to the other side now and go to the widget UI to use the</st> `<st c="33298">AppEntity</st>` <st c="33307">the</st> <st c="33312">user selected.</st>
			<st c="33326">Using the AppEntity in our Widget</st>
			<st c="33360">Going back</st> <st c="33372">to our widget code, let’s examine the widget configuration</st> <st c="33431">code again:</st>

AppIntentConfiguration(kind: kind, intent:

CalendarWidgetIntent.self, provider:

ConfigurableProvider(), content: { entry in

        ConfigurableWidgetView(entry: entry)

    })

			<st c="33606">The</st> `<st c="33611">AppIntentConfiguration</st>` <st c="33633">struct has an important property, which is the intent type it uses, and in this case, it is</st> `<st c="33726">CalendarWidgetIntent</st>`<st c="33746">. If we go back to the</st> *<st c="33769">Customize our widget</st>* <st c="33789">section, we can see that</st> `<st c="33815">CalendarWidgetIntent</st>` <st c="33835">contains all the information we need to present our widget according to the</st> <st c="33912">user configuration.</st>
			<st c="33931">Indeed, the timeline provider is now conforming to a different protocol,</st> `<st c="34005">AppIntentTimelineProvider</st>`<st c="34030">, which supports the intent configuration now.</st> <st c="34077">Let’s see how it creates</st> <st c="34102">a timeline:</st>

struct ConfigurableProvider: AppIntentTimelineProvider {

func timeline(for configuration: CalendarWidgetIntent,

in context: Context) async ->

    Timeline<ConfiguredNextEventEntry>

			<st c="34290">We can see that the timeline function inside</st> `<st c="34336">ConfigurableProvider</st>` <st c="34356">now receives the configuration parameter.</st> <st c="34399">From this point, all we need to do is use the information we have inside the configuration and create the relevant</st> <st c="34514">timeline entries.</st>
			<st c="34531">By now, we know how to set up a new widget, animate it, create its timeline, and even let the user configure it.</st> <st c="34645">Next, we’ll learn how to ensure our widgets stay up</st> <st c="34697">to date.</st>
			<st c="34705">Keeping our widgets up to date</st>
			<st c="34736">We have learned that we need to look ahead and create a timeline with different entries and dates to</st> <st c="34838">keep our widget up to date.</st> <st c="34866">But how does our widget work under</st> <st c="34901">the hood?</st>
			<st c="34910">Widgets don’t get any running time – once we generate the timeline entries,</st> *<st c="34987">WidgetCenter</st>* <st c="34999">generates their different views, keeps them persistently, and just switches them according to the</st> <st c="35098">provided timeline.</st>
			<st c="35116">So, there’s no way to update our widget without reloading the timeline, and when we created our timeline, we had to define its</st> <st c="35244">reload policy:</st>

let timeline = Timeline(entries: entries, WidgetCenter to reload the timeline immediately, due to data changes or any other alterations.

        <st c="35451">让我们看看它是如何发生的。</st>

        <st c="35477">使用 WidgetCenter 重新加载小部件</st>

        <st c="35515">在整个章节中,我经常提到</st> *<st c="35557">WidgetCenter</st>* <st c="35569">,但我还没有解释它的含义。</st>

        *<st c="35619">WidgetCenter</st>* 是一个包含当前使用配置的不同小部件信息的对象,它还提供了一个选项来 <st c="35758">重新加载它们。</st>

        <st c="35770">要使用 <st c="35778">WidgetCenter</st><st c="35790">,我们需要调用 <st c="35812">shared</st> <st c="35818">属性来访问其 <st c="35842">单例引用:</st>
 WidgetCenter.shared
        <st c="35882">WidgetCenter</st> 与我们迄今为止处理的其他代码之间的区别在于,我们是从应用中调用 <st c="35998">WidgetCenter</st> 而不是调用 <st c="36036">widget 扩展。</st>

        <st c="36053">让我们看看如何调用 <st c="36084">WidgetCenter</st> 来获取活动小部件的列表:</st>
 func getConfigurations() { <st c="36157">WidgetCenter.shared.getCurrentConfigurations</st> { result
      in
        if let widgets = try? result.get() {
            // handle our widgets
        }
    }
}
        <st c="36278">The</st> `<st c="36283">getCurrentConfigurations</st>` <st c="36307">函数使用闭包来返回一个活动小部件的数组。</st> <st c="36370">它们中的每一个都是 <st c="36394">WidgetInfo</st> <st c="36404">类型 – 一个包含有关特定 <st c="36450">配置小部件</st> <st c="36467">信息的结构。</st>

        <st c="36485">The</st> `<st c="36490">WidgetInfo</st>` <st c="36500">结构有三个属性 – kind, family,和 configuration:</st>

            +   `<st c="36566">kind</st>` <st c="36571">– 这是我们创建小部件配置时设置的字符串(再次查看 *<st c="36660">配置我们的</st> * *<st c="36676">小部件</st> * *<st c="36682">部分)。</st>

            +   `<st c="36692">family</st>` <st c="36699">– 小部件的家庭大小 – 小型、中型或大型。</st>

            +   `<st c="36758">configuration</st>` <st c="36772">– 包含用户配置信息的意图。</st> <st c="36832">The</st> `<st c="36836">configuration</st>` <st c="36849">属性是可选的。</st>

        <st c="36871">如果需要,我们可以使用这些信息来重新加载特定类型的小部件的时间线。</st> <st c="36964">例如,如果我们想重新加载具有 <st c="37023">MyWidget</st> <st c="37031">类型的小部件,我们需要调用</st> <st c="37049">以下内容:</st>
 WidgetCenter.shared.reloadTimelines(ofKind: "MyWidget")
        <st c="37119">注意,该函数说的是 <st c="37150">Timelines</st> <st c="37159">而不是 <st c="37168">Timeline</st>,因为可能存在多个相同类型的小部件。</st>

        <st c="37237">如果我们想重新加载我们应用中的所有小部件,我们可以调用 <st c="37296">reloadAllTimelines()</st> <st c="37316">函数:</st>

        `<st c="37326">WidgetCenter.shared.reloadAllTimelines()</st>`

        <st c="37367">在我们的小部件时间线中重新加载有几个很好的用例,例如当我们收到推送通知,或者当用户数据或设置发生变化时。</st> <st c="37526">如果你还记得,当我们讨论小部件时间线时,在</st> *<st c="37588">生成时间线</st>* <st c="37609">部分,我们提到了小部件每天可以重新加载的次数有一定的预算。</st> <st c="37729">但好消息是,如果我们的应用在前台或使用某些其他技术,例如在</st> <st c="37931">后台播放音频</st>,调用 `<st c="37767">reloadTimelines</st>` <st c="37782">或</st> `<st c="37786">reloadAllTimelines</st>` <st c="37804">函数不计入这个预算。</st>

        <st c="37946">在大多数</st> <st c="37955">情况下,</st> `<st c="37962">reloadTimelines</st>` <st c="37977">当更新后的数据已经在设备上或在我们的应用中时,工作得很好。</st> <st c="38051">但当我们本地的持久存储没有更新时,我们应该怎么办呢?</st>

        <st c="38120">我们执行一个网络请求,</st> <st c="38151">当然!</st>

        <st c="38161">前往网络获取更新</st>

        <st c="38191">在移动应用中,执行网络请求以更新本地数据是一种典型操作。</st> <st c="38281">但在小部件中它是如何工作的呢?</st> <st c="38289">does it work</st> <st c="38302">in widgets?</st>

        <st c="38313">让我们再次看看</st> `<st c="38332">getTimeline</st>` <st c="38343">函数:</st>
 func getTimeline(in context: Context, <st c="38465">getTimeline</st> function is an asynchronous function. It means that when we build our timeline, we can perform async operations such as open URL sessions and fetching data.
			<st c="38633">Let’s see an example of requesting the next</st> <st c="38678">calendar events:</st>

func getTimeline(in context: Context, completion: @escaping

(Timeline<SimpleEntry>) -> Void) {

    var entries: [SimpleEntry] = [] <st c="38822">calendarService.fetchNextEvents { result in</st> switch result {

        case .success(let events):

            for event in events {

                let entry = SimpleEntry(date:

                event.alertTime, nextEvent:

                    event.title, nextEventTime:

                    event.date)

                entries.append(entry)

            }

        case .failure(let error):

            print("Error fetching next events:

            \(error.localizedDescription)")

        }

    let timeline = Timeline(entries: entries, policy:

    .atEnd)

    completion(timeline)

}

}

			<st c="39230">The</st> `<st c="39235">getTimeline</st>` <st c="39246">function implementation is similar to the previous</st> `<st c="39298">getTimeline</st>` <st c="39309">implementation we saw in the</st> *<st c="39339">Generating a timeline</st>* <st c="39360">section, and this time, we are fetching the</st> <st c="39405">events using the</st> `<st c="39422">calendarService</st>` <st c="39437">instance.</st> <st c="39448">The</st> `<st c="39452">calendarService</st>` <st c="39467">goes to our server and returns an array of events.</st> <st c="39519">Afterward, we loop the events, generate timeline entries, and return a timeline using the</st> `<st c="39609">completion</st>` <st c="39619">block.</st>
			<st c="39626">Up until now, we have seen how to create a widget, animate it, and ensure it is updated as much as we can.</st> <st c="39734">But if we want to make our widget shine, we need to add some</st> <st c="39795">user-interactive capabilities.</st>
			<st c="39825">Interacting with our widget</st>
			<st c="39853">Besides</st> <st c="39862">providing us with a glance at our app information, widgets are a great way to open our app in a specific location or manipulate data without even opening</st> <st c="40016">the app.</st>
			<st c="40024">As mobile developers, we sometimes wonder why implementing user interaction with a widget is such a big deal.</st> <st c="40135">After all, our users interact with our app daily, so why is it such a problem?</st> <st c="40214">But when we remember that widgets don’t really run, we can understand</st> <st c="40284">the challenge.</st>
			<st c="40298">The most basic way we have to allow interaction with our widgets is by using</st> <st c="40376">deep links.</st>
			<st c="40387">Opening a specific screen using links</st>
			<st c="40425">If you are not familiar with the concept of deep links, now is the time to straighten things out.</st> <st c="40524">A deep</st> <st c="40531">link is a link that opens our app on a specific screen.</st> <st c="40587">Today’s deep links format is similar to website URLs.</st> <st c="40641">For example, a deep link that opens our app in a specific calendar event screen can look something</st> <st c="40740">like this:</st>

http://www.myGreatCalendarAp.com/event//


			<st c="40800">To do that, the app needs to do</st> <st c="40833">three things:</st>

				*   *<st c="40846">Register to that specific domain</st>* <st c="40879">by placing a special JSON file on the</st> <st c="40918">relevant server</st>
				*   <st c="40933">Add the domain</st> *<st c="40949">entitlement</st>* <st c="40960">to</st> <st c="40964">our app</st>
				*   <st c="40971">Respond to</st> *<st c="40983">launching the app from a deep link</st>*<st c="41017">, parse the URL, and direct the user to the corresponding location within</st> <st c="41091">the app</st>

			<st c="41098">To learn more about deep links, I recommend reading about it in the Apple</st> <st c="41173">Developer website:</st>
			[<st c="41191">https://developer.apple.com/documentation/xcode/allowing-apps-and-websites-to-link-to-your-content</st>](https://developer.apple.com/documentation/xcode/allowing-apps-and-websites-to-link-to-your-content)
			<st c="41290">Going back to our topic, let’s see an example of adding a deep link to our</st> `<st c="41366">Next</st>` `<st c="41371">Event</st>` <st c="41376">widget:</st>

struct MyWidgetEntryView : View {

var entry: Provider.Entry

var body: some View {

    VStack {

        Text("时间:")

        Text(entry.nextEventTime, style: .time)

        Text("Next Event")

        Text(entry.nextEvent) <st c="41571">.widgetURL(URL(string:</st>

"https://www.myGreatCalendarApp.com/event/(entry.eventID)/")) }

}

}


			<st c="41663">In the</st> <st c="41671">preceding code example, we can see that we added a view modifier called</st> `<st c="41743">widgetURL</st>` <st c="41752">to the</st> `<st c="41760">Next Event</st>` `<st c="41770">Text</st>` <st c="41775">component.</st>
			<st c="41786">The</st> `<st c="41791">Next Event</st>` `<st c="41801">Text</st>` <st c="41806">component is indeed the view that accepts the user’s touch and opens the app in the specific deep link.</st> <st c="41911">But when the widget is small (</st>`<st c="41941">.systemSmall</st>`<st c="41953">), we can add only one deep link that is acceptable in the</st> <st c="42013">whole widget.</st>
			<st c="42026">In widgets with medium and large sizes, we can add multiple links to</st> <st c="42096">multiple components.</st>
			<st c="42116">It is worth noting that in terms of security, deep links work even when the device is locked, but require</st> *<st c="42223">FaceID</st>* <st c="42229">or passcode when tapping</st> <st c="42255">on them.</st>
			<st c="42263">In iOS 17, deep links are not the only option we have to allow users to interact with our widget, as it is possible to add buttons and toggles</st> <st c="42407">as well.</st>
			<st c="42415">Adding interactive capabilities</st>
			<st c="42447">Deep links</st> <st c="42459">in widgets are great, but they have one big problem – tapping on the widget always opens the app.</st> <st c="42557">But, sometimes, we want to update data or confirm something without entering the app.</st> <st c="42643">For example, maybe we want to accept a calendar invitation, approve a payment, or mark a task</st> <st c="42737">as completed.</st>
			<st c="42750">Because</st> <st c="42759">widgets don’t actually run, it’s a challenge to respond to a user interaction.</st> <st c="42838">Fortunately, there is a solution that we have already encountered in configurable widgets, and that’s</st> <st c="42940">App Intents.</st>
			<st c="42952">Using App Intents to add interactive widgets</st>
			<st c="42997">App Intents</st> <st c="43010">made especially for this kind of use case – to allow runtime for</st> <st c="43075">specific actions.</st>
			<st c="43092">So, how do App Intents help us?</st> <st c="43125">Let’s look at</st> *<st c="43139">Figure 5</st>**<st c="43147">.4</st>*<st c="43149">:</st>
			![Figure 5.4: App Intent event flow](https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_05_4.jpg)

			<st c="43315">Figure 5.4: App Intent event flow</st>
			<st c="43348">In</st> *<st c="43352">Figure 5</st>**<st c="43360">.4</st>*<st c="43362">, we can see that the first stage is tapping on a button inside the widget.</st> <st c="43438">Starting from iOS 17, the</st> *<st c="43464">WidgetKit</st>* <st c="43474">framework has its own type of button, which can be linked to a specific</st> <st c="43546">App Intent:</st>

Button("Turn (entry.isAlarm ? "Off" : "On") Alarm" , role:

nil, entry.isAlaram value. 但更有趣的是,我们还有一个额外的参数叫做 Intent,其中我们传递一个名为 MyWidgetIntent 的结构体,以及 eventID

        <st c="43979">让我们谈谈应用意图,但这次是在</st> <st c="44046">用户交互</st>的上下文中。</st>

        <st c="44063">使用意图执行数据更改</st>

        我们已经说过,小部件不管理任何状态。因此,真正的 widget 状态是本地存储和 timeline provider 之间的一种组合。

        `<MyWidgetIntent>`接收一个`eventID`并负责联系`EventKit`并更新实际事件的警报信息。

        让我们看看`App Intent`:
 struct MyWidgetIntent: AppIntent {
    init() {
    }
    var eventID: String = ""
    init(eventID: String) {
        self.eventID = eventID
    }
    static var title: LocalizedStringResource = "Changing '
      event alarm settings." func perform() async throws -> some IntentResult {
        // working with EventKit and updating the event alarm data. return .result()
    }
}
        除了我们在*自定义我们的小部件*部分讨论的`LocalizedStringResource`静态属性之外,我们还有一个主要函数叫做`perform()`。`perform()`函数在用户点击与该 App Intent 关联的按钮时执行。请注意,`perform()`函数也是一个异步函数,它允许我们执行更重的任务,例如写入数据库或执行 URL 请求。

        一旦`perform()`函数完成执行,App Intent 就会触发*WidgetCenter*。

        更新小部件 UI

        现在本地存储已更新,是时候让*WidgetCenter*重新加载 Timeline Provider 了。我们应该已经熟悉这个过程了——Timeline Provider 获取相关的本地数据,并根据我们刚刚执行的变化构建时间线。最后,小部件 UI 正在被更新。

        如果我们想在不同的应用组件之间共享代码执行,使用 App Intent 也是很好的。例如,我们可以在我们的 widget 和 Siri Shortcut 之间共享逻辑代码。

        我们应该记住,即使小部件可能有它自己的运行时,将我们的代码分离以获得更好的灵活性和模块化仍然是一个好的实践。

        App Intent 的另一个很好的用途是控制小部件,这是 iOS 18 的另一个伟大补充。现在让我们来了解一下。

        添加控制小部件

        *<st c="46114">WidgetKit</st>* <st c="46125">提供了在启动器中展示我们的应用程序的方法。</st> <st c="46139">然而,它并不止于此。</st> <st c="46211">从 iOS 18 开始,我们可以在控制中心和锁屏上展示小部件,甚至可以将应用程序意图附加到 iPhone 15 Pro 的操作按钮上。</st>

        <st c="46375">将小部件添加到控制中心或锁屏上</st> <st c="46433">很容易。</st>

        <st c="46441">类似于我们通过遵循小部件协议创建小部件的方式,我们需要遵循</st> `<st c="46540">ControlWidget</st>` <st c="46553">协议来创建控制小部件。</st> <st c="46591">例如,想象我们有一个帮助我们控制智能家居配件的应用程序,我们想要创建一个可以打开和关闭我们家的主门的小部件。</st> <st c="46704">让我们从创建一个简单的控制小部件开始,命名为</st> `<st c="46803">MaindoorControl</st>`<st c="46818">:</st>
 struct MaindoorControl: ControlWidget {
    var body: some ControlWidgetConfiguration {
        StaticControlConfiguration(
            kind: "com.avitsadok.MaindoorControl"
        ) {
// rest of the widget goes here
        }
    }
}
        <st c="47012">在这个代码示例中,</st> `<st c="47039">MaindoorControl</st>` <st c="47054">小部件包含了从</st> `<st c="47075">body</st>` <st c="47079">变量到</st> `<st c="47106">ControlWidgetConfiguration</st>`<st c="47132">的时间点。</st> 这与我们如何在</st> *<st c="47204">配置我们的</st>* *<st c="47220">小部件</st>* <st c="47226">部分创建一个主屏幕小部件非常相似。</st>

        <st c="47235">在这种情况下,我们返回一个</st> `<st c="47279">StaticControlConfiguration</st>` <st c="47305">类型的实例,这意味着我们不向用户提供配置它的能力。</st> <st c="47376">然而,类似于主屏幕小部件,我们也可以通过返回</st> `<st c="47484">AppIntentControlConfiguration</st>` <st c="47513">(查看</st> *<st c="47527">自定义我们的</st>* *<st c="47541">小部件</st>* <st c="47547">部分)来添加一个用户可配置的控制小部件。</st>

        <st c="47557">我们可以添加两个控制小部件控件 – 一个切换和一个按钮。</st> <st c="47622">在我们的家庭主门状态控制的情况下,我们需要添加一个切换。</st> <st c="47702">让我们修改我们的代码并添加一个</st> `<st c="47734">ControlWidgetToggle</st>` <st c="47753">实例:</st>
 struct MaindoorControl: ControlWidget {
    var body: some ControlWidgetConfiguration {
        StaticControlConfiguration(
            kind: "com.avitsadok.MaindoorControl"
        ) { <st c="47918">ControlWidgetToggle(</st>
 <st c="47938">"Main door control",</st>
 <st c="47959">isOn: HouseManager.shared.isOpen,</st>
 <st c="47993">action: MaindoorIntent()</st>
 <st c="48018">) { isOn in</st>
 <st c="48030">Label(isOn ?</st> <st c="48044">"Opened" : "Closed",</st>
 <st c="48064">systemImage: isOn ?</st>
 <st c="48084">"door.left.hand.open" :</st>
 <st c="48108">"door.left.hand.closed")</st>
 <st c="48133">}</st> }
    }
}
        <st c="48141">在这个</st> <st c="48150">代码示例中,我们添加了</st> `<st c="48175">ControlWidgetToggle</st>`<st c="48194">,包含以下参数:</st>

            +   **<st c="48232">一个标题</st>** <st c="48240">– 在小部件画廊中显示的小部件标题。</st>

            +   `<st c="48310">isOn</st>` <st c="48315">– 在这里,我们将小部件连接到我们应用程序中的实际状态。</st> <st c="48369">。</st>

            +   `<st c="48377">action</st>` <st c="48384">– 当用户点击我们的控制小部件时运行的 App Intent。</st> <st c="48451">我们将在本节的后面部分介绍这一点。</st>

            +   `<st c="48545">标签</st>` <st c="48550">显示控制状态的标题和</st> <st c="48590">一个图像。</st>

        <st c="48599">小部件实例很简单。</st> <st c="48640">让我们看看它在我们的控制中心(</st>*<st c="48686">图 5</st>**<st c="48695">.5</st>*<st c="48697">)中的样子:</st>

        ![图 5.5:控制中心中的我们的控制小部件](https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_05_5.jpg)

        <st c="48702">图 5.5:控制中心中的我们的控制小部件</st>

        *<st c="48754">图 5</st>**<st c="48763">.5</st>* <st c="48765">显示了控制中心的主门控制小部件。</st> <st c="48819">然而,我们的控制小部件还有另一个方面</st> <st c="48849">——将控制小部件连接到打开和关闭主门的操作。</st> <st c="48955">让我们看看我们在</st> `<st c="48973">MaindoorIntent</st>` <st c="48987">参数中看到的</st> `<st c="49009">action</st>` <st c="49015">结构:</st>
 struct MaindoorIntent: SetValueIntent {
    static let title: LocalizedStringResource = "Maindoor
      opening"
    @Parameter(title: "is open")
    var value: Bool
    func perform() throws -> some IntentResult {
      HouseManager.shared.isOpen = value
        return .result()
    }
}
        <st c="49275">在这个代码示例中,我们看到了</st> `<st c="49309">MaindoorIntent</st>` <st c="49323">实现。</st> <st c="49340">`<st c="49344">MaindoorIntent</st>` <st c="49358">结构符合`<st c="49385">SetValueIntent</st>` <st c="49399">协议,该协议包含一个我们可以设置的值。</st> <st c="49445">在这个例子中,值来自`<st c="49484">Bool</st>` <st c="49488">类型,我们可以用它来执行</st> <st c="49527">所需的操作。</st>

        <st c="49545">将控制小部件添加到我们的应用程序中涉及我们在添加主屏幕小部件和允许我们在小部件和其他</st> <st c="49725">应用程序组件之间共享代码的应用程序意图时看到的类似做法。</st>

        <st c="49740">摘要</st>

        <st c="49748">小部件是我们在 iOS 开发中可以与之交互的有趣且有趣的 UI 元素。</st> <st c="49828">它们提供流畅的 UI、出色的动画和可一览无余的用户体验。</st> <st c="49902">我们已经看到,每个 iOS 版本都添加了有趣的新小部件功能,使小部件比以往任何时候都更强大</st> <st c="50013">。</st>

        <st c="50023">在本章中,我们了解了小部件的概念、如何添加小部件、创建时间线以及添加用户可配置选项。</st> <st c="50158">此外,我们还学习了如何创建自定义动画,甚至添加用户交互。</st> *<st c="50238">WidgetKit</st>* <st c="50248">已经成为一个令人着迷的框架。</st> <st c="50297">在下一章中,我们将继续探讨如何改进用户体验,这次我们将使用</st> <st c="50396">SwiftUI 动画。</st>


第七章:6

SwiftUI 动画和 SF 符号

上一章讨论了一个令人愉快的话题——小部件。 它们的美学水平既愉快又有效,这使得与它们一起工作变得既有趣又简单。 现在,我们将通过 SwiftUI 动画将这种感觉进一步深化。

动画是 iOS 开发中的一个关键主题,因为它丰富了体验,并使我们的应用程序更直观、更易于使用。 如果您习惯了 UIKit 动画,您会注意到 SwiftUI 动画与 UIKit 的方法不同,它提供了一个声明式 API 来动画化 状态变化。

随着这些新挑战的到来,也带来了确保我们的逻辑状态和 UI 始终一致的机会。

在本章中,我们将 执行以下操作:

  • 讨论动画的重要性 动画

  • 理解 SwiftUI 动画概念

  • 使用视图修改器和 <st c="851">withAnimation</st> 函数执行基本动画

  • 执行高级动画,如过渡和 关键帧动画

  • 动画 SF 符号

解释为什么我们需要动画听起来很奇怪,有些人可能会对这个话题皱眉头。 因此,我们的首要任务是在我们开始在屏幕上移动一个像素之前,将这个话题从桌面上移开。 那么,让我们回答以下问题——为什么我们需要关心 动画?

技术要求

对于本章,从 App Store下载 Xcode 版本 16.0 或更高版本是至关重要的。

确保您正在使用最新的 macOS 版本(Ventura 或更高版本)。 只需在 App Store 中搜索 Xcode,选择最新版本,然后继续下载。 打开 Xcode 并完成出现的任何进一步设置说明。 Xcode 完全运行后,您 就可以开始了。

从以下 GitHub 链接下载示例代码:

https://github.com/PacktPublishing/Mastering-iOS-18-Development/tree/main/Chapter%206

动画的重要性

有些人可能认为执行动画主要是为了娱乐,实际上并不会真正影响一个 应用程序的可用性。 但事实是,动画在增强用户参与度和界面设计方面起着至关重要的作用,尤其是在移动应用程序中。 以下是使用动画的一些好处: 使用动画:

  • 首先,动画为用户的操作提供视觉反馈——当用户点击按钮时,按钮会变大,这有助于他们知道他们触摸了正确的位置。

  • 动画还可以提供指导和导航——页面之间的转换表明我们是“向前”还是“向后”移动我们的流程。

  • 动画还有助于错误处理——我们可以动画化错误消息和一般问题,并减少用户的挫败感。

  • 最重要的是,在许多情况下,动画是应用品牌和独特性的一部分,并提供那种特殊的触感,加强用户与应用之间的联系。

现在我们已经了解了动画的重要性,让我们看看 SwiftUI 的声明式方法如何与该概念相一致。

理解 SwiftUI 动画的概念

对于一个来自 UIKit 并在 SwiftUI 中迈出第一步的开发者来说,在声明式框架中编写动画的概念可能会感觉有点尴尬。

 UIView.animate(withDuration: 2.0, animations: {
            sampleView.alpha = 0.0
        }) { (finished) in
}

在这个示例中,我们修改了 <st c="3436">sampleView</st><st c="3456">UIView</st> 动画闭包中的 alpha 级别。

虽然这看起来很简单,但它带来一个显著的缺点——需要将动画动作同步到屏幕状态。

然而,在 SwiftUI 中,屏幕状态始终与 UI 保持同步,这对动画也是如此。

在 SwiftUI 中实现动画有几种方法;有些确实很简单,而另一些则允许我们提供高级和复杂的动画。

让我们热身,并从一些 基本动画 开始。

执行基本动画

理解 SwiftUI 动画工作原理的基本方法是将一个状态值与特定的 动画流程 相关联。

在 SwiftUI 中执行基本动画有三种方式:

  • <st c="4617">animation</st> 修饰符 – 将动画添加到一个 特定的视图

  • <st c="4687">withAnimation</st> 全局函数 – 通过改变 几个状态 来执行动画

  • <st c="4773">animation()</st> 方法 – 将动画附加到一个 绑定值

开发者通常会感到困惑,并认为这里有一些重复 – 执行相同功能的不同方法。 但事实是,这三种方法都服务于不同的目的和需求。 取决于我们根据特定的代码结构和流程来决定合适的方法。 有时,你可能想要对特定的视图执行特定的动画;偶尔,它可能是多个视图共享的体验。 理解不同的用例可以帮助我们决定如何正确地执行动画。

让我们首先给一个特定的视图添加一个动画。

使用动画视图修饰符

动画 视图修饰符的目标是在某个值发生变化时向特定的视图添加动画。 以下是一个使用动画 视图修饰符的示例:

 struct UsingAnimationModifier: View {
    @State var width: CGFloat = 50
    @State var height: CGFloat = 50
    var body: some View {
        ZStack {
            Circle()
                .frame(width:width, height:height)
                .foregroundColor(.blue) <st c="5797">.animation(.easeIn, value: width)</st> .onTapGesture {
                    width += 50
                    height += 50
                }
        }
    }
}

前面的代码通过向其宽度和高度添加 <st c="5932">50</st> 点来改变圆的尺寸,并且它是通过使用动画视图修饰符来做到这一点的。 请注意,动画视图修饰符有一个值参数 – 动画修饰符监视其变化的值。 在这种情况下,我们使用 <st c="6164">width</st> 状态变量。

动画 视图修饰符非常适合在特定值发生变化时改变特定的视图。 然而,在某些情况下,这种方法可能会令人困惑。 在这种情况下,我们在代码的特定位置定义动画,但在另一个位置执行更改。 此外,如果我们想要执行多个动画,使用动画视图修饰符可能会很麻烦。

如果我们想执行多个更改,我们可以使用 <st c="6621">withAnimation:</st> 函数。 让我们看看如何 利用它。

使用 withAnimation 函数

在其基本形式中, <st c="6731">withAnimation:</st> 函数接受一个闭包作为参数,并动画化 在该闭包内所做的任何更改。 通常,这是通过触发事件来完成的。 让我们看看一个简单的 代码示例:

 struct UsingWithAnimationFunction: View {
    @State var greenCircleYPosition: CGFloat = 400
    @State var redCircleYPosition: CGFloat = 800
    var body: some View {
        VStack {
            ZStack {
                Circle()
                    .size(width: 100.0, height: 100.0)
                    .foregroundColor(.green)
                    .position(x: 400, y:
                      greenCircleYPosition)
                Circle()
                    .size(width: 100.0, height: 100.0)
                    .foregroundColor(.red)
                    .position(x: 200, y:
                      redCircleYPosition)
            }
            Button("Animate") { <st c="7335">withAnimation {</st>
 <st c="7350">greenCircleYPosition =</st>
 <st c="7373">greenCircleYPosition == 400 ?</st> <st c="7404">800 :</st>
 <st c="7409">400</st>
 <st c="7413">redCircleYPosition = redCircleYPosition</st>
 <st c="7453">== 800 ?</st> <st c="7463">400 : 800</st>
 <st c="7472">}</st> }
        }
    }
}

此代码示例在按钮被点击时同时动画化两个圆的位置。 我们可以看到,与动画视图修改器不同,通过使用 <st c="7645">withAnimation:</st> 函数,我们可以更清晰、更简单地绑定动画的变化。 和简单。

另一个 优势是 <st c="7754">withAnimation:</st> 具有的是 执行一个 完成代码 一旦动画结束。

让我们看一下以下 代码示例:

 struct WithAnimationCompletionBlock: View {
    @State var yPos: CGFloat = 300
    @State var isReset: Bool = false
    var body: some View {
        VStack {
            Circle()
                .foregroundColor(.blue)
                .frame(width: 50, height:50)
                .position(x: 200, y:yPos)
            Button(isReset ? "Reset" : "Start") { <st c="8155">withAnimation</st> {
                    if isReset {
                        yPos = 300
                    } else {
                        yPos = 500
                    } <st c="8217">} completion: {</st>
 <st c="8232">isReset.toggle()</st>
 <st c="8249">}</st> }
        }
    }
}

此代码创建了一个蓝色圆圈和一个写着 开始的按钮。一旦用户点击按钮,圆圈 动画化其位置,并在最后,按钮标题变为 重置。然后,点击按钮将圆圈带回,并在 反向动画结束时,按钮标题返回 开始

动画中的完成块对于同步流程阶段至关重要。 例如,折叠侧边栏并在结束时导航到新屏幕是一个出色的完成 块使用的例子。

现在,是时候给我们的动画增添更多活力了。

用弹簧动画给我们的动画增添一些活力

如果你已经尝试过你之前看到的代码示例,你可能已经注意到动画运行得相当流畅,但有点,嗯,无聊。 这是因为动画是线性运行的,并不 那么有趣。

尝试将以下参数添加到 前面的示例中:

 withAnimation<st c="9179">(.bouncy(extraBounce: 0.3))</st> {
       if isReset {
           yPos = 300
       } else {
           yPos = 500
       }
   } completion: {
       isReset.toggle()
   }
}

在这个例子中,我们在 <st c="9319">.bouncy(extraBounce: 0.3)</st> 中添加了 <st c="9352">withAnimation</st> 函数。 运行代码显示与之前相同的动画,但现在,当圆圈到达末端时它会弹跳。 这是一个微小但重要的添加——弹跳效果为我们的动画增添了现实感,并可以提高 用户参与度。

我们可以添加到动画中的有趣视觉过渡有几个。 例如,我们可以使用 <st c="9750">.</st>``<st c="9751">smooth</st> 函数来使弹跳更平滑:

 withAnimation(.smooth(extraBounce: 0.3))

我们还可以通过使用小的 弹跳量来使弹跳更迅速,通过使动画更快:

 withAnimation(.snappy)

建议查看 Apple 的文档以发现更多我们可以轻松应用到我们 动画中的视觉过渡: https://developer.apple.com/documentation/swiftui/animation

到目前为止,我们只执行了非常基本的动画。 但现代应用需要现代体验。 让我们继续探索一些创建 高级动画的更多方法。

执行高级动画

我们提到过渡对于指导和导航非常出色,这部分概念的一部分是 在我们的画布上提供关于进入和离开视图的清晰度。 从底部滑动视图可以提供抽屉打开和关闭的感觉,缩放视图可以直观地表示一个 正在进行的过程的进度。

到目前为止,我们已经讨论了如何将视图从一个状态动画化到另一个状态。 现在,我们将探索过渡——一种在视图出现 或消失时动画化视图的方法。

执行过渡

实现 视图过渡很简单——我们有一些很好的内置过渡可供选择,如果还不够,我们还可以创建一个 自定义过渡。

让我们从一些基本的、内置的过渡开始。 过渡。

实现内置过渡

要添加 过渡效果,我们应该使用 <st c="11113">transition</st> 修饰符与我们要动画化的特定视图,通过使用 <st c="11200">withAnimation</st> 函数来触发它,该函数我们在 使用 withAnimation 函数 *部分中了解过。

这是一个简单的 过渡中的幻灯片示例:

 struct BuiltInTransitionsView: View {
    @State var showSlideText: Bool = false
    var body: some View {
        VStack {
            Button("Slide in text") { <st c="11473">withAnimation {</st> showSlideText.toggle()
                }
            }
            if showSlideText {
                Text("Hello, slided
                  text")<st c="11561">.transition(.slide)</st> }
      }
}

代码示例由 <st c="11616">VStack</st> 和一个按钮以及文本组成。 我们还有一个状态来决定文本是可见还是隐藏。

点击按钮使用 <st c="11768">withAnimation</st> 函数显示文本。 但是文本 也有一个描述其如何出现的过渡视图修饰符,在这种情况下,使用一个 滑动进入过渡。

过渡视图修饰符描述了视图的显示方式和消失方式。

The <st c="12033">slide</st> transition inserts the view by moving it from the leading edge and removing it toward the trailing edge. 请注意,滑动过渡的方向不能更改,它们由 SwiftUI 框架设置。 但是,我们还可以使用几个其他过渡来实现我们 期望的行为:

  • <st c="12335">move</st>: 将视图移动到/从特定边缘:

     Text("Hello, moved text")
                        .transition(.move(edge: .bottom))
    
  • <st c="12442">scale</st>: 以特定数量和从特定锚点缩放视图:

     Text("Hello, scaled text")
                        .transition(.scale(scale: 0.5, anchor: .center))
    
  • <st c="12591">opacity</st>: 对视图执行“淡入/淡出”效果:

     Text("Hello, opacity text")
                        .transition(.opacity)
    

这些类型的过渡在 Apple 网站和 SDK 中有很好的文档记录,我们也可以通过章节的 GitHub 仓库 来尝试它们。

需要注意的是,我们可以使用这些过渡来显示和隐藏动画。 然而,在某些情况下,我们可能更喜欢显示和隐藏时使用不同的动画。 隐藏和显示使用不同的动画称为 非对称过渡。让我们 看看一个代码示例 来演示这一点:

 Text("Text scaled in. Now it will slide out")
 <st c="13183">.transition(.asymmetric(insertion: .scale, removal:</st>
<st c="13272">scale</st> animation for the insertion of text and a <st c="13320">slide</st> animation for the removal of text.
			<st c="13360">Sometimes, we may want to combine several animations.</st> <st c="13415">For example, we may want to scale</st> <st c="13449">and slide at the same time.</st> <st c="13477">We can do that using the</st> `<st c="13502">combined</st>` <st c="13510">function:</st>

Text("缩放和滑动") .transition(.scale.combined(with:

.slide))


			<st c="13586">We can even combine a</st> <st c="13609">combined transition!</st>

.transition(.scale.combined(with: .slide.combined(with:

.opacity)))


			<st c="13696">However, if things become too complicated, it could be a sign that we should build a</st> <st c="13782">custom transition.</st>
			<st c="13800">Creating a custom transition</st>
			<st c="13829">Building</st> **<st c="13839">custom transitions</st>** <st c="13857">gives us complete control and flexibility of how transitions</st> <st c="13919">work and is useful when other compound transition methods</st> <st c="13977">don’t provide the</st> <st c="13995">expected results.</st>
			<st c="14012">The idea of building a custom transition is built around providing two</st> <st c="14084">view modifiers:</st>

				*   <st c="14099">One that represents the</st> *<st c="14124">identity</st>* <st c="14132">state of the view (before we started</st> <st c="14170">the transition)</st>
				*   <st c="14185">One that represents the</st> *<st c="14210">active</st>* <st c="14216">state of the view (after</st> <st c="14242">the transition)</st>

			<st c="14257">Both view modifiers must be of the same type so that SwiftUI has the same properties</st> <st c="14343">to transition.</st>
			<st c="14357">Let’s create a custom transition that takes a view and inserts it with rotation, opacity,</st> <st c="14448">and scale.</st>
			<st c="14458">We will start by creating a view modifier that handles all the</st> <st c="14522">three properties:</st>

struct ViewRotationModifier: ViewModifier {

let angle: Angle

let opacity: CGFloat

let scale: CGFloat

func body(content: Content) -> some View {

    content

        .rotationEffect(angle)

        .scaleEffect(scale)

        .opacity(opacity)

}

}


			<st c="14756">The</st> `<st c="14761">ViewRotationModifier</st>` <st c="14781">view modifier receives three properties,</st> `<st c="14823">angle</st>`<st c="14828">,</st> `<st c="14830">opacity</st>`<st c="14837">, and</st> `<st c="14843">scale</st>`<st c="14848">, and applies them to the content.</st> <st c="14883">This view modifier is like any view modifier we’re</st> <st c="14934">accustomed to.</st>
			<st c="14948">Now, we can</st> <st c="14961">build our custom transition.</st> <st c="14990">If we look at the built-in transitions we covered in the previous</st> *<st c="15056">Implementing built-in transitions</st>* <st c="15089">section and their code’s documentation, we can see that they are from the type</st> `<st c="15169">AnyTransition</st>`<st c="15182">.</st> `<st c="15184">AnyTransition</st>` <st c="15197">is a struct that describes a SwiftUI transition between</st> <st c="15254">two states.</st>
			<st c="15265">Let’s build our</st> `<st c="15282">rotate</st>` `<st c="15288">AnyTransition</st>`<st c="15302">:</st>

let rotate = AnyTransition.modifier(

active: <st c="15350">ViewRotationModifier</st>(angle: .degrees(360),

opacity: 0.0, scale: 0.0),

identity: <st c="15431">ViewRotationModifier</st>(angle: .degrees(0),

opacity: 1.0, scale: 1.0)

)


			<st c="15500">The</st> `<st c="15505">AnyTransition</st>` <st c="15518">struct we created receives the</st> `<st c="15550">active</st>` <st c="15556">and</st> `<st c="15561">identity</st>` <st c="15569">view modifiers, each with</st> <st c="15596">different parameters.</st>
			<st c="15617">We can</st> <st c="15625">use the new transition in the same way as the</st> <st c="15671">built-in transitions:</st>

struct CustomizedTransitionView: View {

@State private var showRectangle: Bool = false

var body: some View {

    VStack {

        Spacer()

        if showRectangle {

            Rectangle()

                .frame(width: 100, height: 100)

                .foregroundColor(.blue) <st c="15907">.transition(rotate)</st> }

        Spacer()

        Button("插入矩形") {

            withAnimation {

                showRectangle.toggle()

            }

        }

    }

}

}


			<st c="16015">The preceding code creates a rectangle and a button.</st> <st c="16069">Tapping on the button toggles the</st> `<st c="16103">showRectangle</st>` <st c="16116">state variable, which reveals the rectangle using our</st> <st c="16171">new transition.</st>
			<st c="16186">So far, we have discussed great animations that were pretty simple and short.</st> <st c="16265">However, if we</st> <st c="16280">want to provide more sophisticated animations that may require multiple stages and different timing,</st> `<st c="16381">AnyTransition</st>` <st c="16394">structure is insufficient.</st> <st c="16422">For much more advanced animations, we should try to implement</st> <st c="16484">keyframe animations.</st>
			<st c="16504">Executing keyframe animations</st>
			<st c="16534">The idea</st> <st c="16544">of</st> **<st c="16547">keyframe animations</st>** <st c="16566">in SwiftUI is similar to how they are implemented</st> <st c="16617">in UIkit.</st>
			<st c="16626">With</st> <st c="16632">keyframe animations, we declare different changes in different properties over time.</st> <st c="16717">There are four primary components</st> <st c="16751">in</st> <st c="16754">keyframe animations:</st>

				*   `<st c="16900">AnimationsProperties</st>` <st c="16920">struct can define the opacity, scale, or color in different</st> <st c="16981">animation phases.</st>
				*   `<st c="16998">KeyFrameAnimator</st>`<st c="17015">: The keyframe animator defines the different animation tracks we have and what happens with the view in</st> <st c="17121">each track.</st>
				*   `<st c="17132">KeyframeTrack</st>`<st c="17146">: Each track handles a different animation property and defines the various phases (key frames) for that property.</st> <st c="17262">Tracks work in parallel with each other.</st> <st c="17303">A keyframe animator can have</st> <st c="17332">multiple tracks.</st>
				*   `<st c="17348">KeyFrame</st>`<st c="17357">: Defines a single change for a specific property within the</st> <st c="17419">keyframe track.</st>

			<st c="17434">With these</st> <st c="17446">four primary components, we can build amazing and complex animations.</st> <st c="17516">Let’s build our first keyframe animation with SwiftUI, but we’ll start by explaining the concept behind</st> <st c="17620">keyframe animations.</st>
			<st c="17640">Understanding a keyframe animation</st>
			<st c="17675">Describing a</st> <st c="17689">keyframe animation can be slightly confusing at first, mainly because it is a way to create complex animations.</st> <st c="17801">Let’s try to explain it in a diagram (</st>*<st c="17839">Figure 6</st>**<st c="17848">.1</st>*<st c="17850">):</st>
			![Figure 6.1: A key frame animation as a diagram](https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_06_1.jpg)

			<st c="17894">Figure 6.1: A key frame animation as a diagram</st>
			*<st c="17940">Figure 6</st>**<st c="17949">.1</st>* <st c="17951">shows two tracks – scale and opacity – positioned on a timeline.</st> <st c="18017">In each track, we see two keyframes.</st> <st c="18054">The number inside each keyframe describes the value, and the keyframe length describes its duration.</st> <st c="18155">For example, in the scale track, we have two keyframes – the first sets the scale to 0.7, and the second brings it back to 1.0\.</st> <st c="18283">We can also see that the durations of both the scale and opacity tracks</st> <st c="18355">are equal.</st>
			<st c="18365">If you think that that resembles a video editing application such as</st> *<st c="18435">iMovie</st>* <st c="18441">or</st> *<st c="18445">Premiere</st>*<st c="18453">, that’s because it is based on the</st> <st c="18489">same concept.</st>
			<st c="18502">Let’s try to create a breathing animation using the concept of keyframe animation.</st> <st c="18586">A breathing animation mimics the way something breathes, such as a balloon slowly inflating</st> <st c="18678">and deflating.</st>
			<st c="18692">Let’s see how to do that</st> <st c="18718">in code:</st>

struct AnimationProperties {

var scale = 1.0

var opacity = 1.0

}

struct KeyFrameAnimations: View {

var body: some View {

    Circle()

        .foregroundColor(.red)

        .frame(width:100, height:100)

        .<st c="18911">关键帧动画器</st>(initialValue:

        AnimationProperties(), repeating: true) {

            content, value in

            content

                .opacity(value.opacity)

                .scaleEffect(value.scale)

        } <st c="19064">关键帧</st>: { _ in <st c="19083">KeyframeTrack</st>(\.scale) { <st c="19109">CubicKeyframe</st>(0.7, duration: 0.8) <st c="19144">CubicKeyframe</st>(1.0,

                            duration: 0.8)

            } <st c="19181">关键帧轨道</st>(\.opacity) { <st c="19209">CubicKeyframe</st>(0.3, duration: 0.8) <st c="19244">CubicKeyframe</st>(1.0, duration: 0.8)

            }

        }

}

}


			<st c="19286">The code example seems long!</st> <st c="19316">However, upon closer examination, we can see that it is not that complex and contains the different components we</st> <st c="19430">discussed earlier.</st>
			<st c="19448">Let’s explain</st> <st c="19463">what we’ve</st> <st c="19474">done here:</st>

				1.  <st c="19484">We created a circle and added a view modifier called</st> `<st c="19538">keyframeAnimator</st>`<st c="19554">, which handles the general animations.</st> <st c="19594">We initialized it with the</st> `<st c="19621">AnimationProperties</st>` <st c="19640">struct that holds the properties we want to modify during the animation phases, and we defined that animator to repeat by passing</st> `<st c="19771">true</st>` <st c="19775">in the</st> <st c="19783">corresponding parameter.</st>
				2.  <st c="19807">The animator has another closure parameter with the content view and the value.</st> <st c="19888">That’s where we can</st> *<st c="19908">modify our view</st>* <st c="19923">according to the animation properties.</st> <st c="19963">In this example, we changed the view opacity</st> <st c="20008">and scale.</st>
				3.  <st c="20018">Right after the closure, we define our tracks.</st> <st c="20066">We have two properties we want to change over time, so we’ve created two tracks – one for scale and one for opacity.</st> <st c="20183">Because we wanted a</st> *<st c="20203">breathing</st>* <st c="20212">animation, we’ve created two keyframes – one for exhaling (scale down and reduce opacity) and one for inhaling (scale up and</st> <st c="20338">increase opacity).</st>
				4.  <st c="20356">We can see that each one of the frames is declared as</st> `<st c="20411">CubicKeyframe</st>`<st c="20424">. Before we explain what</st> `<st c="20449">CubicKeyframe</st>` <st c="20462">means, let’s talk about keyframes, which are fundamental concepts</st> <st c="20529">in animations.</st>

			<st c="20543">A keyframe specifies an object’s state at a particular point in time.</st> <st c="20614">The animator’s responsibility is to perform the animations between these keyframes.</st> <st c="20698">In a way, it’s like animating a state change, but in this case, we define the different</st> <st c="20786">modifications upfront.</st>
			<st c="20808">In the case of SwiftUI’s</st> `<st c="20834">keyframeAnimator</st>`<st c="20850">, the keyframes align with the concept of states – each keyframe defines a change in a specific property</st> <st c="20955">over time.</st>
			<st c="20965">In SwiftUI, we have different types of keyframes, each representing a</st> <st c="21036">different experience:</st>

				*   `<st c="21057">CubicKeyframe</st>`<st c="21071">: This</st> <st c="21079">is the keyframe we used</st> <st c="21103">in our code example.</st> `<st c="21124">CubicKeyframe</st>` <st c="21137">provides</st> <st c="21147">a smooth transition to the next keyframe while computing something called</st> **<st c="21221">Catmull-Rom splines</st>**<st c="21240">. Catmull-Rom splines are curves used in computer animations to provide</st> <st c="21312">smooth movement.</st>
				*   `<st c="21328">SpringKeyframe</st>`<st c="21343">: This</st> <st c="21351">represents a transition</st> <st c="21375">that emulates a spring experience, including a</st> <st c="21422">bouncy effect.</st>
				*   `<st c="21436">MoveKeyframe</st>`<st c="21449">: This</st> <st c="21457">type of keyframe modifies</st> <st c="21483">the given</st> <st c="21493">value immediately.</st>
				*   `<st c="21511">LinearKeyframe</st>`<st c="21526">: This</st> <st c="21534">keyframe animates</st> <st c="21552">the change without a defined curve and, instead, does that in a simple</st> <st c="21623">linear interpolation.</st>

			<st c="21644">SwiftUI is</st> <st c="21656">intelligent enough to smoothly handle the combination of different keyframes on the same track.</st> <st c="21752">For example, let’s see what happens when we define velocity on one of</st> <st c="21822">our keyframes:</st>

CubicKeyframe(0.5, duration: 0.2, 起始速度: 0.5, 结束速度: 0.8)

CubicKeyframe(0.7, duration: 0.5)


			<st c="21944">We can see that the end velocity of the first keyframe is</st> `<st c="22003">0.8</st>`<st c="22006">. However, we haven’t defined any initial velocity for the second keyframe.</st> <st c="22082">In this case, the second keyframe’s</st> `<st c="22118">startVelocity</st>` <st c="22131">value will be the end value of the previous keyframe, which</st> <st c="22192">means</st> `<st c="22198">0.8</st>`<st c="22201">.</st>
			<st c="22202">Now, let’s discuss another crucial aspect of keyframe animations –</st> <st c="22270">animation duration.</st>
			<st c="22289">Handling keyframe animation duration</st>
			<st c="22326">The keyframe animator is a hierarchal structure with three levels – the animator, the tracks, and the keyframe.</st> <st c="22439">This means that different keyframes can have different durations, and these duration values don’t always add up nicely.</st> <st c="22559">That makes duration management complex, especially for long and</st> <st c="22623">intricate animations.</st>
			<st c="22644">How do we</st> <st c="22655">ensure that all the keyframe durations are always aligned with each other and maintain the same scale?</st> <st c="22758">The answer is to use relative duration, not</st> <st c="22802">absolute duration.</st>
			<st c="22820">An absolute duration specifies the exact time an animation should take, regardless of the initial state, or without comparing it to the</st> <st c="22957">other keyframes.</st>
			<st c="22973">Conversely, relative duration reflects the duration time, considering the total animation duration.</st> <st c="23074">For example, if the relative duration is 0.5 and the total animation duration is 3 seconds, the actual keyframe duration would be 1.5 (0.5 *</st> <st c="23215">3.0 seconds).</st>
			<st c="23228">By using relative duration, we can establish an animation’s overall duration and allocate specific durations for each keyframe, relative to the</st> <st c="23373">total duration.</st>
			<st c="23388">Let’s take our “breathing” example and try to implement</st> <st c="23445">relative duration:</st>

let duration: TimeInterval = 1.8 var body: some View {

    Circle()

        .foregroundColor(.red)

        .frame(width:100, height:100)

        .keyframeAnimator(initialValue:

        AnimationProperties(), repeating: true) {

        content, value in

            content

                .opacity(value.opacity)

                .scaleEffect(value.scale)

        } keyframes: { _ in

            KeyframeTrack(\.scale) {

                CubicKeyframe(0.7, <st c="23795">持续时间: 0.5 *</st>

持续时间)

                CubicKeyframe(1.0,

                duration: <st c="23851">0.5 * duration</st>)

            }

            KeyframeTrack(\.opacity) {

                CubicKeyframe(0.3, <st c="23916">持续时间: 0.5 *</st>

持续时间)

                CubicKeyframe(1.0, <st c="23962">持续时间: 0.5 *</st>

持续时间)

            }

        }

			<st c="23992">In this code example, we have a keyframe animation with two keyframes, similar to our previous example.</st> <st c="24097">The first keyframe handles the scale animation, and the second handles</st> <st c="24168">the opacity.</st>
			<st c="24180">We can see</st> <st c="24192">that we have a total duration variable, currently set to</st> `<st c="24249">1.8</st>`<st c="24252">. With each keyframe, we set the duration relative to that value.</st> <st c="24318">In this case, it is</st> `<st c="24338">0.5</st>` <st c="24341">of the total duration, but this can vary from one example</st> <st c="24400">to another.</st>
			<st c="24411">Relative duration can help us set a dynamic overall duration time and change it according to our needs, even</st> <st c="24521">at runtime.</st>
			<st c="24532">SwiftUI animations are extremely powerful and easy to use, and keyframe animations make them even more powerful by allowing us to build complex animations with multiple steps</st> <st c="24708">and durations.</st>
			<st c="24722">However, in many cases, animating views is one of the many challenges that app developers face.</st> <st c="24819">After all, animating simple shapes such as a rectangle or a circle isn’t always what we desire.</st> <st c="24915">So, what about the assets?</st> <st c="24942">Fortunately, the iOS SDK contains a fantastic resource called SF Symbols.</st> <st c="25016">Let’s explore</st> <st c="25030">it now.</st>
			<st c="25037">Animating SF Symbols</st>
			**<st c="25058">SF Symbols</st>** <st c="25069">is a library that</st> <st c="25088">contains over 5,000 symbols that developers can integrate within their text, using the</st> *<st c="25175">San</st>* *<st c="25179">Francisco</st>* <st c="25188">font.</st>
			<st c="25194">Don’t be</st> <st c="25204">confused – SF Symbols are not emojis.</st> <st c="25242">Emojis are meant to express feelings and emotions within text.</st> <st c="25305">Conversely, SF Symbols are excellent replacements for icons that represent states, actions,</st> <st c="25397">and tools.</st>
			<st c="25407">Here’s a basic example of displaying a clock alarm symbol with text next</st> <st c="25481">to it:</st>

var body: some View {

    HStack { <st c="25519">Image(systemName:</st>

"alarm.waves.left.and.right.fill") Text("闹钟")

    }.font(.system(size: 30))

}

			<st c="25613">We can see no surprises here – we use a basic</st> `<st c="25660">Image</st>` <st c="25665">view with the</st> `<st c="25680">systemName</st>` <st c="25690">parameter to provide the</st> <st c="25716">image name.</st>
			<st c="25727">As mentioned earlier in this section, there are thousands of symbols available.</st> <st c="25808">To get the full symbols catalog, we need</st> <st c="25849">to download a Mac application called</st> *<st c="25886">SF Symbols</st>* <st c="25896">(what a coincidence, uh?)</st> <st c="25923">from</st> [<st c="25928">https://developer.apple.com/sf-symbols/</st>](https://developer.apple.com/sf-symbols/)<st c="25967">.</st>
			<st c="25968">The app is simple to use, as we can see in</st> *<st c="26012">Figure 6</st>**<st c="26020">.2</st>*<st c="26022">:</st>
			![Figure 6.2: The SF Symbol Mac app](https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_06_2.jpg)

			<st c="27085">Figure 6.2: The SF Symbol Mac app</st>
			<st c="27118">By exploring</st> <st c="27132">the SF Symbol app, we can see how the symbols differ from emojis.</st> <st c="27198">They are not only vector illustrations (meaning they can scale to any size) but also built</st> <st c="27289">as layers.</st>
			<st c="27299">To understand why the SF symbols contain layers, try to perform a bounce animation using the app.</st> <st c="27398">Doing so lets us see how the layers create a</st> *<st c="27443">sense of depth</st>*<st c="27457">, making them bounce at</st> <st c="27481">different intervals.</st>
			<st c="27501">Other than the bounce effect, SF Symbols supports other effects such as pulse, scale, and replace.</st> <st c="27601">We can perform the same animations in our SwiftUI code using the</st> `<st c="27666">symbolEffect</st>` <st c="27678">view modifier:</st>

struct SFSymbolsAnimationView: View {

@State private var animate = false

var body: some View {

    HStack {

        Image(systemName:

        "alarm.waves.left.and.right.fill") <st c="27851">.symbolEffect(.bounce, options: .repeating,</st>

值: animate) Text("10:30")

    }.font(.system(size: 40))

        .onTapGesture {

        animate = true

    }

}

}


			<st c="27987">The</st> `<st c="27992">symbolEffect</st>` <st c="28004">view modifier has several parameters.</st> <st c="28043">The first is the</st> `<st c="28060">effect</st>` <st c="28066">type, the same as those found in the SF Symbol app.</st> <st c="28119">The second parameter is</st> `<st c="28143">options</st>` <st c="28150">– we can make the effect repeat itself or even set</st> <st c="28202">its speed.</st>
			<st c="28212">The third</st> <st c="28223">parameter is the</st> `<st c="28240">value</st>` <st c="28245">parameter – the state variable that triggers the animation.</st> <st c="28306">In this case, we trigger the animation by tapping on the</st> `<st c="28363">HStack</st>` <st c="28369">view that contains both the symbol and the</st> <st c="28413">attached text.</st>
			<st c="28427">To read more about SF Symbols, it is recommended to visit Apple’s</st> <st c="28494">website:</st> [<st c="28502">https://developer.apple.com/sf-symbols/</st>](https://developer.apple.com/sf-symbols/)<st c="28542">.</st>
			<st c="28543">Even though this chapter mainly concerns SwiftUI animations, there is much more to SF Symbols than just animations, such as supporting multiple colors.</st> <st c="28696">Let’s see how we can modify different</st> <st c="28734">symbol colors.</st>
			<st c="28748">Modifying symbol colors</st>
			<st c="28772">The fact</st> <st c="28782">that SF Symbols are built with different layers helps not only with animation but also with</st> <st c="28874">coloring them.</st>
			<st c="28888">Let’s take, for instance, the</st> *<st c="28919">two persons</st>* *<st c="28931">waving</st>* <st c="28937">symbol:</st>

Image(systemName: "person.2.wave.2")


			*<st c="28982">Figure 6</st>**<st c="28991">.3</st>* <st c="28993">shows what the symbol</st> <st c="29016">looks like:</st>
			![Figure 6.3: The person.2.wave.2 symbol](https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_06_3.jpg)

			<st c="29029">Figure 6.3: The person.2.wave.2 symbol</st>
			<st c="29067">We can see two different types of image components – on the one hand, two people, and on the</st> <st c="29161">other hand, their waves.</st> <st c="29186">So, unlike a regular image, we can set one color for the people and another for</st> <st c="29266">the waves.</st>
			<st c="29276">Every SF Symbol has a</st> **<st c="29299">primary</st>** <st c="29306">and</st> **<st c="29311">secondary</st>** <st c="29320">color, and SwiftUI knows how to color</st> <st c="29359">it accordingly.</st>
			<st c="29374">For example, let’s set a primary color of brown and a secondary color of blue.</st> <st c="29454">We will use the</st> `<st c="29470">foregroundStyle</st>` <st c="29485">view modifier</st> <st c="29500">for that:</st>

Image(systemName: "person.2.wave.2")

            .foregroundStyle(.棕色, .蓝色)

			<st c="29578">There are symbols that even have a third color, such as in the case of the three-person symbol (</st>*<st c="29675">Figure 6</st>**<st c="29684">.4</st>*<st c="29686">):</st>
			![Figure 6.4: The person.3.sequence.fill symbol](https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_06_4.jpg)

			<st c="29691">Figure 6.4: The person.3.sequence.fill symbol</st>
			<st c="29736">To use the third color, we just need to add one more color as</st> <st c="29799">a parameter:</st>

Image(systemName: "person.3.sequence.fill")

            .foregroundStyle(.red, .blue, .<st c="29887">棕色</st>)

			<st c="29895">Anyone who has had to manage multi-color icons knows the complexity of supporting different themes and colors, especially when we need to</st> <st c="30033">animate them.</st>
			<st c="30046">So, we know</st> <st c="30059">how to add an SF Symbol, animate it nicely, and color it.</st> <st c="30117">However, we can also use vector multi-layer symbols, which is known</st> <st c="30185">as localization.</st>
			<st c="30201">Localizing our symbols</st>
			<st c="30224">Localizing our apps is a crucial topic today, more than ever.</st> <st c="30287">However, how many of us pay attention</st> <st c="30325">to icon localization and try to adjust them according to the app</st> <st c="30390">layout direction?</st>
			<st c="30407">The excellent news about SF Symbols is that they can adjust to the current app locale.</st> <st c="30495">The even better news is that we can force them to do that if</st> <st c="30556">we want.</st>
			<st c="30564">But why do SF Symbols even need to</st> <st c="30600">support localization?</st>
			<st c="30621">Let’s take the</st> `<st c="30637">arrowshape.turn.up.forward</st>` <st c="30663">SF Symbol (</st>*<st c="30675">Figure 6</st>**<st c="30684">.5</st>*<st c="30686">):</st>

			![Figure 6.5: The arrowshap.turn.up.forward SF Symbol](https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_06_5.jpg)

			<st c="30691">Figure 6.5: The arrowshap.turn.up.forward SF Symbol</st>
			<st c="30742">The forward</st> <st c="30755">icon arrow points to the right, which fits</st> <st c="30798">nicely in</st> **<st c="30808">LTR</st>** <st c="30811">(</st>**<st c="30813">Left-to-Right</st>**<st c="30826">) layout views.</st> <st c="30843">But what about</st> **<st c="30858">RTL</st>** <st c="30861">(</st>**<st c="30863">Right-to-Left</st>**<st c="30876">) layouts, such as in Hebrew or Arabic</st> <st c="30916">localized applications?</st>
			<st c="30939">Well, in this case, we will have to flip the icon direction.</st> <st c="31001">With SF Symbol, this adjustment is done automatically</st> <st c="31055">for us.</st>
			<st c="31062">Moreover, we can set the icon localization regardless of the view settings, using the</st> `<st c="31149">environment</st>` <st c="31160">view modifier:</st>

Image(systemName: "arrowshape.turn.up.forward") .environment(.layoutDirection, .rightToLeft)


			<st c="31269">In the preceding code, we force the SF Symbol to have an RTL layout direction, which flips the forward arrow to the</st> <st c="31386">left direction.</st>
			<st c="31401">Having localization</st> <st c="31422">support doesn’t stop with layout direction.</st> <st c="31466">Some symbols even change their look according to the</st> <st c="31519">current locale.</st>
			<st c="31534">For example, let’s take the</st> `<st c="31563">character.book.closed</st>` <st c="31584">SF Symbol (</st>*<st c="31596">Figure 6</st>**<st c="31605">.6</st>*<st c="31607">):</st>
			![Figure 6.6: The character.book.closed SF Symbol](https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_06_6.jpg)

			<st c="31612">Figure 6.6: The character.book.closed SF Symbol</st>
			<st c="31659">In the case of the symbol in</st> *<st c="31689">Figure 6</st>**<st c="31697">.6</st>*<st c="31699">, we can see that in addition to its layout direction (LTR), it also has a letter</st> <st c="31781">on it.</st>
			<st c="31787">In the case of the Hebrew locale, not only does the symbol’s direction change but also the letter (</st>*<st c="31887">Figure 6</st>**<st c="31896">.7</st>*<st c="31898">):</st>
			![Figure 6.7: The character.book.closed SF Symbol in a Hebrew locale](https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_06_7.jpg)

			<st c="31903">Figure 6.7: The character.book.closed SF Symbol in a Hebrew locale</st>
			<st c="31969">We can force the symbol to retrieve a specific locale using the</st> `<st c="32034">environment</st>` <st c="32045">view modifier, similar to the</st> <st c="32076">layout direction:</st>

Image(systemName: "character.book.closed") .environment(.locale, .init(identifier: "he"))


			<st c="32184">To sum up, SF Symbols</st> <st c="32207">contain so much power and valuable features.</st> <st c="32252">Trying to support standard icons in different environments, such as locales and themes, can be a hassle, and animating them without creating a dedicated image sequence is almost impossible.</st> <st c="32442">So, getting all these features for free is l</st><st c="32486">ike a massive present from</st> <st c="32514">Apple engineers.</st>
			<st c="32530">Summary</st>
			<st c="32538">iOS animations are like salt – they can enhance the user experience, but too much</st> <st c="32621">is overwhelming.</st>
			<st c="32637">The great thing about SwiftUI animations is that they are aligned to the screen state because of the declarative implementation.</st> <st c="32767">However, it’s a significant change to how they work</st> <st c="32819">in UIkit.</st>
			<st c="32828">Because of that, in this chapter, we went from understanding the basic concepts and performing fundamental animations to custom transitions and keyframe animations, and we even discussed a great present that Apple gave us,</st> <st c="33052">SF Symbols.</st>
			<st c="33063">Now, we should be able to easily animate changes on our screen in a meaningful and</st> <st c="33147">expressive way!</st>
			<st c="33162">In our next chapter, we’ll explore enhancing user engagement using a built-in solution –</st> <st c="33252">TipKit.</st>

第八章:7

使用 TipKit 改进功能探索

在上一章中,我们学习了 SwiftUI 动画。现在我们知道,SwiftUI 动画是教用户如何使用 我们的应用的一个很好的方法。

然而,有时这还不够,我们需要比花哨的动画更多的东西。 这就是 TipKit 的作用所在。 TipKit 的目标是提供另一个重要主题的解决方案:功能探索。 功能探索影响我们的应用用户参与度和使用情况,最终影响用户满意度和体验。

在本章中,我们将涵盖以下主题: 以下主题:

  • 在移动应用中学习提示的重要性

  • 添加新的提示——内联 和弹出视图

  • 自定义我们的提示的感觉 和外观

  • 支持 提示操作

  • 为我们的提示定义显示规则 我们的提示

  • 使用 TipGroup 对提示进行 分组

  • 调整 显示频率

现在,让我们从基本问题开始——为什么我们需要 TipKit? 需要 TipKit?

技术要求

对于本章,从 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%207

学习提示的重要性

为小屏幕(如智能手机屏幕)创建应用的一个挑战是为用户提供探索有价值功能的方法。 让用户使用更多功能是提高用户参与度的一部分——衡量用户在多大程度上积极参与并连接到 我们的产品。

功能探索是一个真正的挑战。 一方面,我们旨在创建一个干净直观的用户界面,另一方面,我们旨在添加更多对用户极其有用的功能。 我们的用户。

每个产品经理都会面临这个挑战 – 有时,解决方案是创建一个 什么是新功能 弹出窗口,发送营销邮件,或在应用内 FAQ 屏幕中添加更多信息。

最有价值的技巧之一是提供一个提示 – 一个在正确的时间和地点弹出以解释新功能的小文本框,甚至可以添加一个动作来帮助用户 使用它。

让我们深入探讨一下,并讨论苹果的 提示框架(TipKit)中的提示基础。

理解提示框架的基础

有些人可能 认为显示提示的主要挑战是创建包含相关信息并 展示信息的视图。

然而,如果是那样的话,我们就不需要一个完整的框架。 相反,我们应该将提示框架视为一个 完整的系统。

让我们看看 图 7**.1

图 7.1:提示基础设施

图 7.1:提示基础设施

图 7**.1 展示了 iOS 中提示框架结构的必要组件。 首先,有 提示中心,一个单例,负责管理应用中所有提示的出现。 提示中心有几个职责:

  • 确保提示在用户取消或删除后不再显示

  • 触发提示,以确保提示不会相互重叠

  • 根据 特定规则显示提示

在提示中心之后,我们有 提示模型 – 一个表示特定提示声明的结构。 基于提示模型,我们可以使用 提示视图 来创建和显示一个实例 – 这是提示的视觉表示。

提示框架的基础结构看起来比实际更复杂 – 许多 iOS 框架都与某些框架中心、模型和视图一起工作。 然而,这里的想法是向你展示,虽然提示提供了视觉组件,但其核心功能在于确定它们何时何地出现的规则。

现在,理论介绍就到这里。 让我们看看提示是什么样子!

提示是什么样子?

添加提示的结果 是一个向用户展示新功能的视图(图 7**.2):

图 7.2:在 TipKit 中显示技巧的两种不同方式

图 7.2:在 TipKit 中显示技巧的两种不同方式

根据 图 7**.2,我们可以 看到有两种显示 技巧的方式:

  • 内联技巧:内联技巧会嵌入到屏幕布局中,其出现会相应地修改和推动其他视图。 内联技巧 非常适合 VStacks Lists,我们可以在不 干扰屏幕交互 的情况下查看它们。

  • 弹出提示:与内联提示不同,弹出提示出现在当前屏幕上方,通常与按钮或其他控件相关联。 使用弹出提示时,用户必须关闭提示或执行其操作才能继续使用应用程序。 此外,我们无法同时显示多个弹出提示。

乍一看,显示技巧的两种方式只是设计上的问题。 显示弹出提示不仅是一种不同的体验,也是一种不同的用例。 在内联技巧中,我们以一种非侵入性的方式包含视图,使用户能够逐渐发现新功能。 相比之下,弹出提示非常适合上下文帮助或需要指导的复杂功能。 记住,将更多视图添加到我们设备的小屏幕上有时可能会让人感到不知所措,我们必须谨慎做出这个 决定。

让我们添加我们的第一个技巧,并说服用户使用我们新的 标记为 收藏 功能。

添加我们的第一个技巧

尽管我们兴奋不已,迫不及待地想要添加我们的第一个技巧,但我们仍然需要先做一些小准备,那就是为 技巧状态设置我们的持久存储。 技巧状态。

为什么我们需要设置持久存储? TipKit 需要在关闭我们的应用程序后管理技巧的状态。 这是因为我们不希望用户(或应用程序)决定 关闭它们后,提示再次出现。

我们可以使用一个名为 configure() 的静态函数来设置持久存储:

 import TipKit
@main
struct MyApp: App {
    init() { <st c="6439">try?</st> <st c="6444">Tips.configure()</st> }
}

在我们的代码示例中,我们可以看到我们调用 <st c="6514">configure</st> 函数是应用初始化过程的一部分,因为我们需要在第一个屏幕 加载后让 TipKit 拥有所有信息。

我们还可以通过在 组容器中定义它来与更多的应用和扩展共享提示的状态存储:

 try? Tips.configure([
  .<st c="6794">datastoreLocation</st>(.groupContainer(identifier:
    "MyAppGroupContainer"))])

在这个 示例中,我们在名为 <st c="6954">MyAppGroupContainer</st>的组容器中配置了提示的状态数据存储。不用担心——从应用的角度来看,用户体验将保持 不变。

什么是组容器?

组容器是同一应用组内多个应用和扩展共享的目录。 它允许我们在应用之间共享数据。

我们的下一步是定义我们的 <st c="7262">T</st><st c="7263">ip</st> 模型。

定义我们的提示模型

<st c="7300">Tip</st> 模型(基于 Tip 协议)定义了提示的行为和外观。

让我们看看 一个简单的 提示声明:

 struct MarkAsFavoriteTip: Tip {
    var id: String { "InlineTipView"}
    var title: Text {
        Text("Save as a Favorite")
    }
    var message: Text? {
        Text("You can mark items as Favorite and add them
          to your favorites list.")
    }
    var image: Image? {
        Image(systemName: "star")
    }
}

在这个代码示例中,我们声明了一个名为 <st c="7730">MarkAsFavoriteTip</st>的结构体,它符合 <st c="7771">Tip</st> 协议。 我们可以看到 <st c="7801">MarkAsFavoriteTip</st> 有几个属性。 标题、消息和图像定义了提示视图的内容,正如我们可以在 图 7**.3中看到的那样:

图 7.3:保存为收藏提示视图

图 7.3:保存为收藏提示视图

图 7**.3中,我们可以看到提示视图,其中包含了我们在 <st c="8146">MarkAsFavoriteTip</st>中声明的所有内容。现在,让我们看看我们如何将这个提示添加到我们的 SwiftUI 视图中:

 struct InlineTipView: View { <st c="8250">var tip = MarkAsFavoriteTip()</st> var body: some View {
        VStack { <st c="8311">TipView(tip)</st> List(workouts) { workout in
                WorkoutView(workout: workout)
            }
        }
    }
}

此代码示例包含一个 SwiftUI 视图,显示一系列锻炼。 要在列表顶部显示提示,我们创建了一个之前定义的 <st c="8528">MarkAsFavoriteTip</st> 结构体的实例,然后创建了一个 <st c="8580">TipView</st> 视图,并将该 提示实例传递给它。

图 7**.4 显示了它的外观:

图 7.4:带有内联提示视图的锻炼列表

图 7.4:带有内联提示视图的锻炼列表

图 7**.4 展示了提示如何很好地适应屏幕,将列表向下 <st c="9191">VStack</st> 视图。 轻触提示的关闭按钮将提示从屏幕上移除,并将列表向上推以占据 其位置。

很简单,对吧? 现在,让我们看看如何添加一个 弹出提示。

添加弹出提示

如前所述,弹出提示与内联提示服务于不同的用例。 它阻止了用户与屏幕上其他元素的交互,并且更具上下文性。 在弹出提示中,我们将弹出视图链接到屏幕上的特定控件——通常是按钮或 切换器。

我们添加弹出提示的方式是通过使用一个名为 <st c="9735">popoverTip</st>的视图修改器,传递我们的提示实例(<st c="9773">tip</st>)(就像在内联提示中一样)和一个可选的 箭头方向:

 struct PopoverTipView: View { <st c="9872">var tip = PopoverTip()</st> var body: some View {
        List {
            // some list information
        }
        .navigationTitle("Popover Tip")
        .toolbar(content: {
            Button("Settings", systemImage: "gearshape") {
            }
            .buttonStyle(.plain) <st c="10073">.popoverTip(tip, arrowEdge: .top)</st> })
    }
}

我们的代码示例显示了与我们在内联提示中看到相似的图案。 我们创建了一个提示实例,这次,我们通过将提示添加到屏幕上的视图修改器(屏幕工具栏按钮)来将提示添加到我们的屏幕上。 图 7**.5 展示了它的外观:

图 7.5:弹出提示

图 7.5:弹出提示

关于弹出提示的优点 是,我们不需要关心诸如定位、深度或创建弹出指针等问题——这一切都由我们完成,类似于弹出视图 修改器。

我们看到内联和弹出提示都有关闭按钮。 让我们进一步讨论这个问题,因为这是我们开始揭示提示真正 附加价值的地方。

忽略提示

你可能想知道忽略提示视图与提示的 附加价值有何关系。

我们讨论了 真正的提示力量不在于 UI 层,而在于其展示逻辑。 每次我们忽略一个提示,TipKit 都会将其标记为无效,并且不会再显示。 TipKit 还会永久存储无效状态,这意味着在应用重新启动后,它也不会显示。

除了关闭提示外,还可以通过在代码中使提示失效来关闭提示。 让我们再次看看之前的代码示例中的内联提示(在 *定义我们的提示模型 *部分下)。 该示例中的提示帮助用户探索应用程序的收藏功能。 这也意味着每当用户将一项锻炼标记为收藏时,我们可以假设提示不再需要,并自行使其失效

要使提示失效,我们需要调用提示的 <st c="11710">invalidate()</st> 函数:

 List(workouts) { workout in
                WorkoutView(workout: workout,
                  onFavoriteButtonTap: { <st c="11814">tip.invalidate(reason:</st>
 <st c="11836">.actionPerformed)</st> })
            }

在这个代码示例中,我们每次用户点击 <st c="11893">invalidate()</st> 函数时都调用它。

记住 SwiftUI 是一个声明式框架——提示状态是视图状态的一部分,SwiftUI 在更改后刷新屏幕。

在代码示例中,我们还可以看到失效的原因。 在这种情况下,我们发送了<st c="12201">actionPerformed</st> ,因为这正是发生的事情——用户执行了提示建议的操作。

此时可能还会出现另一个问题:TipKit 如何知道那个特定的提示是否已经显示过? 此外,是否有方法可以重置持久数据并再次显示 提示?

提示 ID 就派上用场了。

定义提示 ID

如果你阅读了 “定义我们的提示模型” 部分下的代码示例,你可能已经注意到了 以下这一行:

 var id: String { "InlineTipView"}

<st c="12707">id</st> 变量是<st c="12734">Tip</st> 协议的一部分;我们使用该属性为每个提示定义一个特定的标识符。 TipKit 使用该标识符来管理不同的 提示状态。

你可以做一个小的实验:创建一个带有提示的小程序(或使用我们 GitHub 仓库中的代码示例)并使提示失效。 重新启动应用程序,你会看到提示不再显示。 现在,修改提示标识符以使用不同的名称。 重新启动应用程序,你会看到提示再次可见。 此外,删除应用程序后重新安装应用程序(删除后)将重置本地 持久存储。

另一种重置本地持久存储的方法是在应用程序启动时调用静态 <st c="13350">resetDatastore</st> 函数:

 struct MyApp: App {
    init() { <st c="13418">try?</st> <st c="13423">Tips.resetDatastore()</st> try? Tips.configure()
    }
}

注意,我们在<st c="13530">configure</st>函数之前调用了<st c="13495">resetDatastore</st>函数。

提示标识符是<st c="13584">Tip</st>协议的一部分,在这个例子中,标识符在所有<st c="13659">struct</st>实例之间共享:

 var id: String { "InlineTipView"}

由于标识符是共享的,一旦你使其中一个无效,基于<st c="13774">struct</st>实例的所有提示视图都将关闭。

在大多数情况下,这被认为是正常行为和最佳实践。如果用户将特定行标记为收藏夹,他们已经了解这个功能,即使它出现在另一个屏幕上。

然而,情况并不总是如此,因此相应地规划标识符。

现在我们知道了如何展示提示,无论是内联还是弹出式。我们也知道如何关闭它,甚至重置持久状态。然而,TipKit 提供了更多功能。让我们看看我们如何自定义我们的提示。

自定义我们的提示

因此,TipKit 为我们应用中展示基于持久性的提示提供了一个优秀的基础设施。然而,TipKit 框架的开发者知道,处理提示需要比仅仅用图像和两个文本使普通视图无效更多的思考。

让我们看看我们如何根据我们的需求自定义提示。我们将从它们的显示风格开始。

自定义我们的提示显示风格

与苹果提供的许多其他基于 UI 的框架不同,TipKit 允许我们很好地自定义提示视图。这可能是因为 SwiftUI 是一个声明性框架,表达视觉内容变得更加自然。然而,在 TipKit 的情况下,苹果理解开发者将 TipKit 设计与其应用程序对齐的需求。

有两种方式可以自定义提示的显示风格。第一种是修改提示的属性,应用基本更改而不改变提示的布局和组件。

第二种方式是实现一个新的提示视图样式,这允许你完全控制提示的感觉和外观。让我们从第一种方式开始:修改提示属性。

修改提示属性

如我之前所述,SwiftUI 的其中一个优点是其表达性框架,我们可以使用视图修饰符来调整提示的显示风格以符合我们的风格。

让我们再次看看提示的<st c="15605">title</st> <st c="15610">属性</st>

 var title: Text { Text("Save as a Favorite") }

注意,我们返回的不是<st c="15709">String</st> <st c="15715">而是</st> <st c="15722">Text</st> <st c="15726">值</st>,这是一个 SwiftUI 视图。<st c="15759">这意味着我们可以修改其外观,使其看起来像任何其他</st> <st c="15816">SwiftUI 视图</st>

例如,我们可以通过应用<st c="15894">foregroundStyle</st> <st c="15909">视图修饰符</st>来改变标题文本颜色:

 var title: Text {
        Text("Save as a Favorite") <st c="15970">.foregroundStyle(.red)</st> }

代码示例很简单:我们取了文本视图并改变了其外观。<st c="16075">此外,因为我们可以通过组合多个文本视图来构建一个</st> <st c="16108">Text</st> <st c="16112">视图,所以我们可以混合样式和颜色</st> <st c="16172">:</st>

 var title: Text {
        Text("Save as a ") <st c="16221">.fontWeight(.light)</st> +
        Text("Favorite") <st c="16260">.fontWeight(.bold)</st>
 <st c="16278">.foregroundStyle(.red)</st> }

在这个例子中,我们取了我们的<st c="16331">Save as a Favorite</st> <st c="16349">文本</st>并将<st c="16371">Favorite</st> <st c="16379">文本</st>改为红色和粗体,以区分它与其他<st c="16439">标题</st>

我们也可以对<st c="16449">Image</st> <st c="16485">属性</st>进行修改,例如改变其颜色或<st c="16532">渲染模式</st>

 var image: Image? {
        Image(systemName:
          "externaldrive.fill.badge.icloud") <st c="16621">.symbolRenderingMode(.multicolor)</st> }

*<st c="16659">第六章</st>*中,我们了解到 SF 符号有多个层级,这样我们就可以将不同的颜色应用到不同的层级。在这个例子中,我们将我们的符号的渲染模式改为<st c="16842">多色</st> <st c="16852">。</st>

修改提示属性是给我们的提示视图用户界面添加基本触感的好方法。然而,我们知道设计在 iOS 应用中是多么关键,有时,仅仅改变颜色和字体样式是不够的。因此,我们可以使用<st c="17096">TipViewStyle</st> <st c="17108">进行</st> <st c="17113">进一步定制</st>

使用<st c="17135">TipViewStyle</st>

给定的提示视图设计仅在我们需要不同的 UI 布局或更复杂的提示视图时才有效。因此,我们必须考虑不同的设计模式来满足该需求。

我最喜欢提到的最重要的开发原则之一是关注点分离原则——即不同的组件应该有不同的责任。

当我们查看<st c="17571">Tip</st> <st c="17574">协议</st>的工作方式时,一些责任被混合在一起。一方面,Tip 协议结构定义了我们的提示内容——标题、消息和图像。另一方面,结构也定义了其设计,这可能是不同的责任。

这些责任混合的事实也限制了我们的提示设计——我们无法将新布局定义为结构的一部分。

然而,内容和设计是 SwiftUI 本质的一部分,也是它作为声明性语言的一个优势。 幸运的是,我们有一个解决方案: 视图样式。视图样式是定义视图组件外观的一种方式。

以下是一个定义带边框按钮的例子:

 Button("Sign In", action: signIn) <st c="18326">Button</st>) but apply a specific style.
			<st c="18363">In TipKit, we can also define our tip appearance by applying a custom</st> <st c="18434">view style:</st>

struct ImageAtTheCornerViewStyle: TipViewStyle { func makeBody(configuration: Configuration) -> some

视图 {

    VStack {

        if let title = configuration.title, let message

        = configuration.message {

            title

                .multilineTextAlignment(.center)

                .font(.title2) <st c="18691">分隔符()</st> message

                .multilineTextAlignment(.leading)

                .font(.body)

        }

        HStack {

            Spacer()

            Image(systemName: "star")

        }

        .padding()

    }

}

}


			<st c="18820">The View</st> <st c="18830">Style we just created takes a</st> `<st c="18860">Tip</st>` <st c="18863">view and returns a new view with the same content but a different layout and design.</st> <st c="18949">It even adds a new view component, such as a</st> `<st c="18994">Divider</st>` <st c="19001">and</st> `<st c="19006">Spacer</st>` <st c="19012">component.</st> <st c="19024">The magic happens in the</st> `<st c="19049">makeBody</st>` <st c="19057">function, which receives a</st> `<st c="19085">Configuration</st>` <st c="19098">parameter that contains all the</st> <st c="19131">tip information.</st>
			<st c="19147">To apply our new View Style on a tip, we can use the</st> `<st c="19201">tipViewStyle</st>` <st c="19213">method:</st>

TipView(tip) TipView 视图具有我们新的自定义样式和布局,看起来像这样 (图 7**.6):

        ![图 7.6:使用 TipViewStyle 自定义我们的提示](https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_07_6.jpg)

        <st c="19466">图 7.6:使用 TipViewStyle 自定义我们的提示</st>

        <st c="19515">命名</st> <st c="19524">`TipViewStyle` <st c="19550">协议时使用一个通用且描述性的名称,例如</st> `<st c="19616">ImageAtTheCornerViewStyle</st>`<st c="19641">,这样将更容易与我们的其他提示共享。</st> <st c="19693">。</st>

        <st c="19702">到目前为止,我们已经学习了如何定义提示、在不同位置展示它以及如何设计它。</st> <st c="19812">然而,我们的提示丰富之旅并没有结束,因为我们还可以通过添加</st> <st c="19920">操作</st> **<st c="19927">来添加一些用户交互**<st c="19934">。</st>

        <st c="19935">添加操作</st>

        <st c="19950">操作是提示信息中非常有价值的补充。</st> <st c="19996">在许多情况下,我们的提示建议用户采取</st> <st c="20047">行动——例如,转到设置屏幕、添加新任务或进入我们应用的新编辑模式。</st> <st c="20149">在提示视图中添加一个执行该特定操作的按钮不是更好吗?</st>

        <st c="20239">除了标题、消息和图像外,提示协议还包含一个操作属性——一个描述提示将显示的按钮的结构数组。</st>

        <st c="20399">让我们通过一个例子来看看这个属性:</st> <st c="20427">示例:</st>
 struct ChangeEmailTip: Tip { <st c="20468">var actions: [Action] {</st>
 <st c="20491">Action(id: "go-to-settings", title: "Go to</st>
 <st c="20534">settings")</st>
 <st c="20545">Action(id: "change-now", title: "Change email now")</st>
 <st c="20597">}</st> }
        <st c="20601">代码示例展示了</st> `<st c="20626">ChangeEmailTip</st>` <st c="20640">结构包含两个操作。</st> <st c="20669">(注意这个提示是部分展示的;假设我们已经实现了其余的属性,例如</st> `<st c="20774">标题</st>` <st c="20779">和</st> `<st c="20784">消息</st>`<st c="20791">。) </st>

        <st c="20793">每个操作初始化函数有两个参数:</st> `<st c="20850">标题</st>` <st c="20855">和</st> `<st c="20860">id</st>`<st c="20862">。 `<st c="20868">标题</st>` <st c="20873">参数</st> <st c="20884">表示按钮上显示的标题。</st> `<st c="20933">id</st>` <st c="20937">参数描述了这个操作的目标,我们用它来确定用户点击了哪个按钮。</st>

        *<st c="21041">图 7</st>**<st c="21050">.7</st>* <st c="21052">展示了操作在弹出提示中的外观:</st>

        ![图 7.7:弹出提示视图中的两个操作](https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_07_7.jpg)

        <st c="21222">图 7.7:弹出提示视图中的两个操作</st>

        <st c="21267">与其它属性一样,TipKit 决定如何布局显示操作,以及按钮的外观。</st> <st c="21366">按钮的外观。</st>

        <st c="21379">现在我们已经定义并展示了操作,让我们看看我们如何响应用户的选择。</st> <st c="21476">现在我们为每个操作都有一个 ID,响应用户的选择变得容易了。</st> <st c="21548">我们在</st> *<st c="21597">添加弹出提示</st> * <st c="21617">部分讨论的</st> `<st c="21552">popoverTip</st>` <st c="21562">视图修饰符有一个额外的闭包来处理操作选择。</st> <st c="21683">让我们看看一个代码示例:</st> <st c="21708">来展示这个:</st>
 Button("Settings", systemImage: "gearshape") {
                gotoSettings = true
            }
            .buttonStyle(.plain)
            .popoverTip(tip, arrowEdge: .top) <st c="21842">{ action in</st>
 <st c="21853">if action.id == "go-to-settings" {</st>
 <st c="21888">gotoSettings = true</st>
 <st c="21908">}</st>
 <st c="21910">}</st>
        <st c="21912">这个代码示例展示了精确的弹出提示实现,现在包含处理</st> <st c="22008">所选操作</st> <st c="22029">的闭包。</st> <st c="22029">在闭包内部,我们检查操作 ID 并执行所需操作(例如,导航到设置屏幕)。</st>

        <st c="22154">将这些 ID 添加到静态常量中更清晰:</st>
 struct ChangeEmailTip: Tip {
    // rest of the tip
var actions: [Action] {
        Action(id: <st c="22304">ChangeEmailTip.goToSettingsAction</st>,
          title: "Go to settings")
        Action(id: ChangeEmailTip.changeEmailAction, title:
          "Change email now")
    }
 <st c="22438">static let goToSettingsAction = "go-to-settings"</st> static let changeEmailAction = "change-now"
}
…
.popoverTip(tip, arrowEdge: .top) { action in
                if action.id == <st c="22597">ChangeEmailTip.goToSettingsAction</st> {
                    gotoSettings = true
                }
            }
        <st c="22656">这个代码示例展示了在应用</st> <st c="22722">最佳实践</st> <st c="22737">时 Swift 可以多么美丽!</st>

        <st c="22737">说到美丽,我们讨论了如何使用</st> `<st c="22803">TipViewStyle</st>`<st c="22815">来设计我们的提示,因此我们也可以使用相同的技巧来设计我们的操作:</st> <st c="22861">以下是一个代码示例:</st>
 List(<st c="22882">configuration.actions</st>) { action in
                Button(action:{
                    // perform action
                }) {
                    action.label()
                }
            }
        <st c="22976">在这个代码示例中,我们创建了一个按钮列表,每个按钮处理不同的操作。</st> <st c="23062">我们需要将这段代码添加到</st> `<st c="23102">makeBody</st>` <st c="23110">函数中,我们在</st> *<st c="23144">使用</st> * *<st c="23150">TipViewStyle</st> * <st c="23162">部分学到的。</st>

        <st c="23171">到目前为止,我们已经学到了很多关于提示的知识!</st> <st c="23221">好消息是我们还有更多惊喜。</st> <st c="23282">让我们揭示它们并讨论**<st c="23316">规则</st>**<st c="23321">功能。</st>

        <st c="23330">添加提示规则</st>

        <st c="23348">在本章中,我们迄今为止主要关注提示显示的 UI 方面。</st> <st c="23439">然而,我们已经知道提示不仅仅是美观的视图——它们必须与某些应用逻辑或状态相对应。</st> <st c="23527">例如,也许当用户登录时,我们会展示一些提示。</st> <st c="23556">在照片应用中,当用户拍摄了一定数量的照片后,我们可以显示一个提示,建议添加相册。</st>

        <st c="23743">提示必须经常让用户意识到他们的流程和状态。</st> *<st c="23763">让用户意识到</st>** <st c="23773">用户的状态。</st> <st c="23802">这就是为什么 TipKit 还包含一个名为规则的功能。</st>

        <st c="23857">有两种规则类型:</st>

            +   **<st c="23883">基于状态</st>**<st c="23898">:根据特定状态显示或隐藏提示。</st> <st c="23949">用户登录,执行特定操作,</st> <st c="24000">等等。</st>

            +   **<st c="24009">事件跟踪</st>**<st c="24025">:根据用户执行的事件数量显示或隐藏提示。</st> <st c="24099">例如,如果用户在过去一周内多次进入设置中的特定屏幕,我们可以为他们提供创建该屏幕的快捷方式。</st> <st c="24245">的屏幕。</st>

        <st c="24257">让我们从添加基于状态的规则开始。</st>

        <st c="24306">添加基于状态的规则</st>

        <st c="24337">基于状态创建规则</st> <st c="24353">是建立提示显示逻辑的常见方法。</st> <st c="24401">什么是状态?</st> <st c="24420">状态可以是一个认证状态(用户是否已登录?),解锁商品,功能使用,</st> <st c="24535">等等。</st>

        <st c="24544">实现基于状态的规则有三个步骤:</st>

            1.  **<st c="24616">添加参数</st>**<st c="24635">:我们需要添加一个规则将基于的变量。</st>

            1.  **<st c="24696">定义规则</st>**<st c="24712">:规则在提示内部定义,应考虑我们讨论的参数。</st>

            1.  **<st c="24799">将参数连接到应用逻辑</st>**<st c="24838">:如果我们想让规则基于我们应用的真正状态,我们需要维护并与其同步应用状态。</st>

        <st c="24956">信不信由你,基于规则的提示实现甚至比看起来还要简单!</st> <st c="25036">让我们尝试构建一个提示,鼓励我们的用户使用仅限高级功能的操作,比如更改应用主题。</st> <st c="25134">应用主题。</st>

        <st c="25144">添加参数</st>

        <st c="25163">规则需要依赖于应用可以轻松修改的持久数据来跟踪应用状态。</st> <st c="25260">为了做到这一点,我们使用</st> <st c="25283">@parameter</st> <st c="25293">宏将跟踪状态变量添加到</st> <st c="25336">我们的提示中。</st>

        <st c="25344">宏是什么?</st>

        <st c="25361">宏是</st> <st c="25373">Swift 的一个特性,它帮助编译器根据当前代码和参数生成代码。</st> <st c="25469">您可以在</st> *<st c="25498">第十章</st>*<st c="25508">中了解更多关于宏的信息。</st>

        <st c="25509">让我们添加一个名为</st> `<st c="25539">isPremiumUser</st>` <st c="25552">的参数来跟踪</st> <st c="25562">高级资格:</st>
 struct ChangeAppThemeTip: Tip {
    // rest of the tip implementation <st c="25649">@Parameter</st> static var isPremiumUser: Bool = false
}
        <st c="25700">展开宏可以看到一个</st> <st c="25731">简单的实现:</st>
 static var $isPremiumUser: Tips.Parameter<Bool> =
  Tips.Parameter(Self.self, "+isPremiumUser", false)
  {
    get {
            $isPremiumUser.wrappedValue
    }
    set {
            $isPremiumUser.wrappedValue = newValue
    }
}
        <st c="25941">让我们深入了解宏的实现。</st> <st c="25985">由于 TipKit 想要与通用类型一起工作,宏创建了一个名为</st> `<st c="26075">$isPremiumUser</st>` <st c="26089">的变量,它是</st> `<st c="26097">Tips</st>` <st c="26103">参数</st> <st c="26112">类型(基于</st> `<st c="26128">Bool</st>`<st c="26132">)的,并且默认值为</st> `<st c="26158">false</st>` <st c="26163">(如我们最初在静态变量中定义的那样)。</st>

        <st c="26210">该宏</st> <st c="26221">还创建了一个</st> **<st c="26236">获取器</st>** <st c="26242">和一个</st> **<st c="26249">设置器</st>** <st c="26255">,这样我们的提示就可以响应应用</st> <st c="26286">状态变化。</st>

        <st c="26300">然而,宏处理了另一件有助于我们的事情:使我们的参数值</st> **<st c="26384">持久化</st>**<st c="26394">。在这种情况下,对于“用户是否是高级用户?”这个问题,答案可能已经是持久的了。</st> <st c="26491">然而,有些情况并不那么明显。</st> <st c="26545">例如,功能使用跟踪通常不是</st> <st c="26588">持久的。</st>

        <st c="26608">现在我们有了参数,让我们添加第一个</st> <st c="26658">显示规则。</st>

        <st c="26671">定义我们的显示规则</st>

        <st c="26698">我们是在定义显示“规则”</st> <st c="26732">(复数形式)吗?

        <st c="26742">是的!</st> <st c="26748">TipKit</st> <st c="26755">支持多个显示规则以支持更复杂的情况。</st> <st c="26823">然而,首先,让我们从</st> <st c="26856">一个提示</st> <st c="26863">开始:</st>
 struct ChangeAppThemeTip: Tip {
    @Parameter
    static var isPremiumUser: Bool = false <st c="26947">var rules: [Rule] {</st>
 <st c="26966">[</st>
 <st c="26968">#Rule(Self.$isPremiumUser) {</st>
 <st c="26997">$0 == true</st>
 <st c="27008">}</st>
 <st c="27010">]</st>
 <st c="27012">}</st> }
        <st c="27016">在这个代码示例中,我们使用宏创建了一个名为</st> `<st c="27081">Rule</st>` <st c="27085">的数据类型,它包含一个谓词表达式。</st> <st c="27124">该谓词表达式将给定的类型与一个</st> <st c="27179">特定值进行比较。</st>

        <st c="27194">在这种情况下,我们比较了</st> `<st c="27224">$isPremiumUser</st>` <st c="27238">的值</st> <st c="27245">与</st> `<st c="27248">true</st>`<st c="27252">。</st>

        <st c="27253">现在,让我们回到</st> <st c="27268">rules</st> <st c="27285">变量。</st> <st c="27296">我们可以添加更多支持我们的提示显示逻辑的规则。</st> <st c="27354">TipKit 在不同的提示之间执行一个</st> `<st c="27373">AND</st>` <st c="27376">运算符,如果结果是 true,则显示提示(除非用户或应用显然将其关闭)。</st>

        <st c="27520">我们如何修改规则所依据的值?</st> <st c="27571">让我们看看。</st>

        <st c="27581">将参数连接到我们的应用逻辑</st>

        <st c="27623">我们需要将提示参数连接到我们的应用逻辑以完成我们的工作。</st> <st c="27701">注意参数</st> <st c="27727">是一个静态变量。</st> <st c="27749">这意味着我们可以从我们的应用的任何地方修改它,即使我们没有提示实例的引用。</st>

        <st c="27863">让我们看看一个重要的</st> <st c="27887">参数修改:</st>
 let tip = ChangeAppThemeTip()
    var body: some View {
        VStack {
            Button("Change isPremium parameter") { <st c="28011">ChangeAppThemeTip.isPremiumUser.toggle()</st> }
            TipView(tip)
        }
    }
        <st c="28070">此代码示例展示了具有一个切换静态</st> `<st c="28144">isPremiumUser</st>` <st c="28157">变量(我们在之前的提示中创建)的按钮的基本 UI。</st> <st c="28205">切换该值也会在 VStack 中显示和隐藏</st> `<st c="28250">TipView</st>` <st c="28257">视图。</st>

        <st c="28279">然而,添加一个切换提示的按钮并不是使用规则参数的真实世界示例。</st> <st c="28379">一个更实际的例子是将它直接连接到用户的付费状态,使用一个</st> `<st c="28472">Combine</st>` <st c="28479">流 – 类似于以下代码:</st>
 let premiumManager = PremiumPurchaseManager()
let premiumStatusSubscription =
  premiumManager.premiumPurchasePublisher <st c="28642">.assign(to: \.isPremiumUser, on:</st>
<st c="28796">isPremiumUser</st> parameter. This is a more elegant way to link the rule logic to our app.
			<st c="28882">Now let’s discuss the other type of rules –</st> <st c="28927">events.</st>
			<st c="28934">Adding a rule based on events</st>
			<st c="28964">When we display a tip based on a state, it’s usually only displayed when the user can use a particular</st> <st c="29068">feature.</st> <st c="29077">However, there are cases when we want to display</st> <st c="29126">a tip when we think the user is ready to take our app to the following</st> <st c="29197">usage level.</st>
			<st c="29209">For example, if we create a music app and the user adds a few songs, maybe it’s a good idea to tell them about making a playlist.</st> <st c="29340">Or, if we are working on a dating app, maybe it is worth suggesting modifying the search filter if the user hasn’t chosen any of the</st> <st c="29473">profiles viewed.</st>
			<st c="29489">For these types of tips, we can create a rule based on tracking events.</st> <st c="29562">The idea is to define an event representing the user’s relevant action.</st> <st c="29634">For example, I can add a task, view a profile, and more.</st> <st c="29691">Afterward, we create a rule based on the number of events tracked within a time frame</st> <st c="29777">or generally.</st>
			<st c="29790">Let’s see a code example for a tip suggesting the user add a list of to-dos.</st> <st c="29868">We’ll start by defining</st> <st c="29892">our tip:</st>

struct AddListTip: Tip { 静态 let didAddATaskEvent = Event(id:

"didAddATaskEvent") var rules: [Rule] { #Rule(Self.didAddATaskEvent) {

$0.donations.count > 3

} }

}


			<st c="30065">The tip goal is to suggest the user add to a list of to-dos.</st> <st c="30127">We create an event called</st> `<st c="30153">didAppTaskEvent</st>` <st c="30168">that helps us track the number of times the user adds a</st> <st c="30225">new to-do.</st>
			<st c="30235">The second thing</st> <st c="30253">we do here is to create a new rule that returns</st> `<st c="30301">true</st>` <st c="30305">if the number of tracked events</st> <st c="30338">exceeds three.</st>
			<st c="30352">This is a different</st> <st c="30373">rule constructor that handles event tracking instead of</st> <st c="30429">a state.</st>
			<st c="30437">The last piece of the puzzle shows the tip and track of</st> <st c="30494">an event:</st>

struct EventRuleTipExample: View {

let tip = AddListTip()

@State var todos: [Todo] = []

var body: some View {

    VStack {

        TipView(tip)

        List(todos) { todo in

            Text(todo.title)

        }

        Spacer()

        Button("添加任务") {

            todos.append(Todo(title: "新建任务")) <st c="30745">任务{ await</st>

AddListTip.didAddATaskEvent.donate()

} }

}

}


			<st c="30802">The event</st> <st c="30813">tracking operation is referred to as</st> `<st c="30850">donate()</st>`<st c="30858">, while the</st> <st c="30870">total number of tracked events is known</st> <st c="30910">as</st> **<st c="30913">donations</st>**<st c="30922">.</st>
			<st c="30923">We can also</st> <st c="30936">check for events tracked in a specific</st> <st c="30975">time range:</st>

$0.donations.donatedWithin(.days(3)).count > 3

$0.donations.donatedWithin(.week).count < 3


			<st c="31077">This example checks whether the number of events exceeds three in the last three days or</st> <st c="31167">one week.</st>
			<st c="31176">Now, it’s important to distinguish between the number of events tracked and just checking the database for the number</st> <st c="31295">of to-dos.</st>
			<st c="31305">We could easily check the user’s number of to-dos in their database and change that to a state-based rule.</st> <st c="31413">However, this solves a different use case – not the number of times the user added a task with the app, but rather the number of tasks the user has</st> <st c="31561">in general.</st>
			<st c="31572">Grouping tips with TipGroup</st>
			<st c="31600">When our app becomes more extensive and feature-rich, handling a large set of tips can become</st> <st c="31695">cumbersome.</st> <st c="31707">Trying to coordinate all these tips using rules can lead to a situation wherein tips appear outside the intended order and</st> <st c="31830">even together.</st>
			<st c="31844">To address that, we can use the</st> `<st c="31877">TipGroup</st>` <st c="31885">class to group tips and present them individually in a</st> <st c="31941">particular order.</st>
			<st c="31958">Let’s see an example for a</st> `<st c="31986">TipGroup</st>` <st c="31994">class usage:</st>

@State var tips = TipGroup(.ordered) {

FirstTip()

SecondTip()

} var body: some View {

    Button("设置") {

    }.popoverTip(<st c="32128">tips.currentTip</st>)

}

			<st c="32148">In this example, we created a state variable called</st> `<st c="32201">tips</st>` <st c="32205">of the TipGroup type.</st> <st c="32228">We passed</st> `<st c="32238">.ordered</st>` <st c="32246">for its priority parameter and added two tips using its builder.</st> <st c="32312">In the code itself, we attached our</st> `<st c="32348">TipGroup</st>` <st c="32356">instance to a button using the</st> `<st c="32388">popoverTip</st>` <st c="32398">view modifier, passing the group’s</st> <st c="32434">current tip.</st>
			<st c="32446">Using the .</st>`<st c="32458">ordered</st>` <st c="32466">parameter ensures that the tips will appear in the order in which we added them to the builder.</st> <st c="32563">TipKit will show the next tip once all the previous tips have</st> <st c="32625">been invalidated.</st>
			<st c="32642">The other parameter we can use is</st> `<st c="32677">firstAvailable</st>`<st c="32691">, which shows the next tip that is eligible</st> <st c="32735">for display.</st>
			<st c="32747">Grouping tips together can help manage a large collection of tips in our project.</st> <st c="32830">However, looking at the code example again, we can see that there might be a problem with the way we implemented the TipGroup in the view.</st> <st c="32969">Imagine we have a TipGroup with a</st> `<st c="33003">SettingsTip</st>` <st c="33014">type and a</st> `<st c="33026">ProfileTip</st>` <st c="33036">type.</st> <st c="33043">When using the TipGroup for settings and profile buttons, we can’t control which tip</st> <st c="33128">appears where.</st>
			<st c="33142">To solve</st> <st c="33152">that, we can cast the</st> `<st c="33174">currentTip</st>` <st c="33184">variable to the desired tip type.</st> <st c="33219">Let’s see that in the</st> <st c="33241">following code:</st>

@State var tips = TipGroup(.ordered) {

    SettingsTip()

    ProfileTip()

}

var body: some View {

    Button("设置") {

    }.popoverTip(<st c="33381">tips.currentTip as?</st> <st c="33402">SettingsTip</st>)

    Button("个人资料") {

    }.popoverTip(<st c="33449">tips.currentTip as?</st> <st c="33470">ProfileTip</st>)

}

			<st c="33484">In this code example, we have a TipGroup with two tips – for the settings button and for the</st> <st c="33578">profile button.</st>
			<st c="33593">When we use the</st> `<st c="33610">popoverTip</st>` <st c="33620">view builder, we cast the</st> `<st c="33647">currentTip</st>` <st c="33657">instance to the corresponding type according to the button.</st> <st c="33718">This technique takes advantage of how the</st> `<st c="33760">popoverTip</st>` <st c="33770">signature looks:</st>

public func popoverTip(_ tip: (any Tip)?...)


			<st c="33832">Since</st> `<st c="33839">popoverTip</st>` <st c="33849">accepts</st> `<st c="33858">nil</st>` <st c="33861">as an argument, we can ensure that only relevant tips will appear from</st> <st c="33933">the TipGroup.</st>
			<st c="33946">Rules are only one aspect of defining the appearance logic.</st> <st c="34007">Another crucial element is determining its frequency.</st> <st c="34061">Let’s see how to customize that</st> <st c="34093">as well.</st>
			<st c="34101">Customizing display frequency</st>
			<st c="34131">I</st><st c="34133">n the previous section, we discussed creating display logic for our tips using rules and tip groups.</st> <st c="34234">However, tips can overwhelm users; there’s a fine line between helping the user and</st> <st c="34318">disturbing them.</st> <st c="34335">Adjusting all the rules to set a reasonable limit on the number of tips the user sees can be challenging.</st> <st c="34441">For that problem, we can manage the frequency at which our</st> <st c="34500">tips display.</st>
			<st c="34513">Let’s start with setting the max display count for</st> <st c="34565">a tip.</st>
			<st c="34571">Setting the max display count for a specific tip</st>
			<st c="34620">The first and essential thing we can do is set the maximum number of a specific tip type that can</st> <st c="34719">be displayed.</st>
			<st c="34732">We do</st> <st c="34739">that by adding a new variable to our tip</st> <st c="34780">called</st> `<st c="34787">options</st>`<st c="34794">:</st>

struct AddListTip: Tip {

var options: [TipOption] { <st c="34849">最大显示次数(2)</st> }

}


			<st c="34876">In this code example, we use the</st> `<st c="34910">MaxDisplayCount</st>` <st c="34925">static function of the</st> `<st c="34949">Tips</st>` <st c="34953">namespace.</st> <st c="34965">That definition means that the tip will be displayed a maximum of two times, and afterward, it will be invalidated, overriding the rest of the rule’s logic.</st> <st c="35122">That’s a great way to ensure that a specific tip doesn’t</st> <st c="35179">overwhelm users.</st>
			<st c="35195">However, there’s another excellent way to ensure a calmer user experience:</st> <st c="35271">display frequency.</st>
			<st c="35289">Setting our tips’ display frequency</st>
			<st c="35325">We just learned how to limit a particular tip to a certain number of appearances.</st> <st c="35408">Another</st> <st c="35416">way to handle tip appearance is to define</st> <st c="35458">its frequency.</st>
			<st c="35472">Let’s look at the</st> <st c="35491">following code:</st>

struct MyApp: App {

init() { <st c="35536">try?</st> <st c="35541">配置显示频率为每日</st> }

}


			<st c="35588">The code example shows how we can limit the total number of tips displayed to one</st> <st c="35671">per day.</st>
			<st c="35679">The</st>`<st c="35683">.displayFrequency(.daily)</st>` <st c="35708">expression means that TipKit will show no more than one tip per day.</st> <st c="35778">Obviously, we have additional frequency options: hourly, weekly, monthly,</st> <st c="35852">and immediate.</st>
			<st c="35866">We can configure specific tips to ignore the system</st> <st c="35919">display frequency:</st>

struct AddListTip: Tip {

var options: [TipOption] { <st c="35990">忽略显示频率</st> }

}


			<st c="36028">In this code example, the</st> `<st c="36055">AddListTip</st>` <st c="36065">tip ignores the system definition for general</st> <st c="36112">display frequency.</st>
			<st c="36130">Setting the max display count for a specific tip and defining a display frequency for all tips is a great way to fine-tune the user’s</st> <st c="36265">tips experience.</st>
			<st c="36281">Summary</st>
			<st c="36289">In this chapter, we discussed the importance of TipKit, added our first tip, customized its design and behavior, learned how to manage tips better by grouping them, and minimized their appearance by setting their display frequency.</st> <st c="36522">By now, we are fully prepared to implement TipKit in</st> <st c="36575">our apps.</st>
			<st c="36584">TipKit touches on a severe app aspect: engagement and feature exploration.</st> <st c="36660">It looks like it supports many</st> <st c="36691">product requirements!</st>
			<st c="36712">In the next chapter, we’ll discuss how to work seamlessly with one of our most important data sources:</st> <st c="36816">the network.</st>


第九章:8

从网络连接和获取数据

找到一个不与服务器连接的应用程序极其困难。大多数应用程序并非独立运行——它们需要验证用户身份、获取信息,并允许用户执行最终将同步回 服务器的操作。

因此,了解网络的工作方式很重要——不是了解 HTTP 的一般工作方式,而是了解 iOS 应用程序如何高效且简单地与服务器交互

在本章中,我们将涵盖以下主题:

  • 理解 移动网络

  • 处理 HTTP 请求,包括 它们的响应

  • 在应用程序流程中集成网络调用

  • 探索 Combine 如何与网络 一起工作

让我们开始理解网络如何融入我们的 应用程序架构。

技术要求

对于本章,您必须从 Apple 的 App Store 下载 Xcode 版本 15.0 或更高版本。 在 App Store 中搜索 Xcode,选择并下载最新版本。 启动 Xcode,并遵循系统可能提示您进行的任何其他安装说明。 一旦 Xcode 完全启动,您就可以开始了

您需要运行最新版本的 macOS(Ventura 或更高版本)。

您还可以从以下 GitHub 链接下载示例代码: https://github.com/PacktPublishing/Mastering-iOS-18-Development/tree/main/Chapter%208

理解移动网络

与网络 一起工作是在开发 iOS 应用程序的过程中一个至关重要的部分。 理解网络如何融入我们的应用程序架构是至关重要的,但它究竟意味着什么? 观看关于执行一个 <st c="1565">URLSession</st> 请求的简单教程是可以的,但现实世界中的应用程序并不这样工作。 不是这样。

在我们深入之前,让我们回顾一下基本的应用程序架构 看起来是什么样子:

  • UI 层:这是负责向用户展示 UI,包括响应用户输入。 UI 层包括 SwiftUI/UIKit 视图和 视图模型。

  • 业务逻辑:这是负责在管理基本 应用程序逻辑的同时操作数据

  • 数据层:这是负责存储和检索与业务逻辑相关的数据实体的。

我猜这个三层架构不会让你感到惊讶,因为大多数移动应用都采用类似的架构。

当我们开始理解网络任务的位置时,我们将不得不查看数据层,在某些特定情况下,业务逻辑层(例如,当与分析或第三方库一起工作时)也需要查看。然而,为什么我们需要查看这些层呢?为了理解为什么我们的网络活动与数据层相关,让我们回顾我们的主要网络目标:

  • 同步信息 到和从 我们的后端

  • 处理认证

  • 需要服务器的逻辑活动

在大多数应用中,网络是同步数据与后端所必需的。数据层作为实体真实信息的首要存储库运行。尝试从其他层直接从网络访问实体将破坏这一原则。

图 8**.1 显示了一个基本的应用架构和网络位置:

图 8.1:基本应用架构

图 8.1:基本应用架构

图 8**.1 中,我们可以看到网络是构建数据层的一个组件。大多数应用的基本思想是网络成为数据源,并填充应用的数据存储。

例如,在一个音乐应用中,网络层可能会连接到后端,获取专辑和歌曲,并将它们存储在本地存储中,例如Core Data。网络层也是建立在不同的组件之上以正确运行的。

我们可以将网络操作想象成一个工厂生产线。我们请求一条信息,并处理返回的数据包,将其通过几个阶段传输,直到我们将其正确地存储在我们的本地存储中或展示出来。

在我们回顾数据包可能经历的各个阶段之前,让我们尝试一起构建一个网络请求。我们将从回顾基本的 HTTP 请求方法开始。

处理 HTTP 请求

HTTP 请求是客户端发送到服务器以请求信息和执行操作的 消息。 服务器处理该请求并向客户端返回响应。 客户端确实会异步执行 HTTP 请求以释放主线程。 然而,客户端与服务器之间的连接是 同步 的,因为客户端等待服务器的响应以完成 请求操作。

HTTP 请求的主要组件是请求方法,它指示请求的主要目标。 现在让我们来了解一下一些基本的 HTTP 方法。

基本的 HTTP 请求方法

REST API 基于请求-响应风格的理念,并且是与后端单向通信。 REST API 在与后端通信时共有八种方法可供使用。 然而,在大多数情况下,我们将使用以下 四种方法:

  • <st c="4796">GET</st>:这用于仅从服务器检索信息。 它应该是一个安全的调用,这意味着执行一个 <st c="4914">GET</st> 请求不应该影响 后端数据。

  • <st c="4960">POST</st>:通常使用 <st c="4972">POST</st> 方法向后端提交 数据。 在许多情况下, <st c="5048">POST</st> 方法在后台数据存储中执行更改或更改 用户状态。

  • <st c="5127">PUT</st>:我们使用 <st c="5141">PUT</st> 来创建或更新 对象。 <st c="5185">POST</st> 方法不同, <st c="5198">PUT</st> 被认为是幂等的。 我们可以发送多个相同的 <st c="5259">PUT</st> 请求,并期望与发送一个请求相同的效果。

  • <st c="5322">DELETE</st>:正如其名所示,我们使用 <st c="5359">DELETE</st> 来删除 对象。 显然,我们可以使用 <st c="5407">POST</st> 来做这件事,但使用 <st c="5433">DELETE</st>,我们与 标准保持一致。

值得一提的是,技术上我们甚至可以使用以下内容 <st c="5534">GET</st> 来更改服务器。 然而,正确的方法确保了可预测性和可靠性,并且与 REST 原则相一致。

要执行基本的 HTTP 请求,我们首先应该熟悉 <st c="5746">URLSession</st> 类。

使用 URLSession

我们可以使用一个名为 的类 <st c="5814">URLSession</st> 来执行和管理 网络请求。 <st c="5865">URLSession</st> 是苹果所说的 URL 加载系统的一部分,而 URL 加载系统 又是 Foundation 框架的一部分。

<st c="5983">URLSession</st> 类负责协调我们应用中的不同 URL 请求。 让我们看看如何使用 <st c="6100">URLSession</st> 执行基本的 <st c="6103">GET</st> 响应:

 let urlString =
  "https://jsonplaceholder.typicode.com/posts"
if let url = URL(string: urlString) {
    var request = <st c="6245">URLRequest</st>(url: url)
    request.httpMethod = "GET"
 <st c="6294">let session = URLSession(configuration: .default)</st> let task = session.dataTask(with: request) { (data,
      response, error) in
      }
    task.resume()
}

此代码示例创建了一个名为 <st c="6477">URLRequest</st> 的对象,基于特定的 URL。 <st c="6519">URLRequest</st> 类封装了执行特定 URL 请求所需的信息。 它通常由以下信息组成:

  • 请求 基本 URL

  • 请求方法 – <st c="6700">GET</st>, <st c="6705">POST</st>, <st c="6711">PUT</st>, <st c="6719">DELETE</st>

  • 请求 HTTP 头

请注意, <st c="6767">URLRequest</st> 结构 不执行实际的 HTTP 请求 或包含其响应信息。 <st c="6869">URLSession</st> 类负责执行和管理不同的 HTTP 请求。

初始化一个 <st c="6993">URLSession</st> 实例 有两种方式:

  • 我们可以调用静态的 <st c="7037">shared</st> 属性 并将其用作一个 单例。如果我们想简化我们的实现,而不需要自定义处理请求的方式,或者在不同区域有不同的要求,我们就这样做:

     let session = URLSession.shared
    
  • 如果我们需要更多的灵活性,我们可以创建一个 <st c="7338">URLSession</st> 实例 (就像上一个代码示例中那样)并用我们自己的配置初始化它。

一个配置对象允许我们更好地调整我们的请求。 例如,我们可以定义每个请求包含额外的头信息,设置超时和缓存,甚至接受 cookie 接受策略。

以下是一个设置具有特定超时时间和无缓存的 <st c="7676">URLSession</st> 类代码示例:

 let configuration = URLSessionConfiguration.default
    configuration.timeoutIntervalForRequest = 10
    configuration.requestCachePolicy =
      .reloadIgnoringLocalCacheData
    let session = <st c="8024">timeoutIntervalForRequest</st> value to <st c="8059">10</st>, and defined the cache policy to be ignored.
			<st c="8106">When we work</st> <st c="8119">with a shared</st> `<st c="8134">URLSession</st>` <st c="8144">object, there’s no way to customize</st> <st c="8180">its configuration, and it will use the</st> <st c="8220">default one.</st>
			<st c="8232">Now that we know how to perform a basic</st> `<st c="8273">GET</st>` <st c="8276">or</st> `<st c="8280">POST</st>` <st c="8284">request, let’s see what we can do with</st> <st c="8324">the response.</st>
			<st c="8337">Handling the response</st>
			<st c="8359">The request response</st> <st c="8380">is handled</st> <st c="8391">using three stages: error handling, serialization, and data storage.</st> <st c="8461">We need to handle each one of the stages carefully and even consider having a dedicated class or function to simplify the process and separate</st> <st c="8604">the concerns.</st>
			<st c="8617">As mentioned, the first stage is error handling.</st> <st c="8667">Let’s discuss it, as it is a crucial part</st> <st c="8709">of networking.</st>
			<st c="8723">Implementing error handling</st>
			<st c="8751">I believe error handling</st> <st c="8776">wouldn’t get a whole section</st> <st c="8805">in many frameworks.</st> <st c="8826">It is usually a straightforward topic: we perform a task, something goes wrong, and we receive</st> <st c="8921">an error.</st>
			<st c="8930">However, with networking, we are working in a volatile environment where many things have the potential to fail</st> <st c="9043">the process.</st>
			<st c="9055">Here’s a partial list of things that can</st> <st c="9097">go wrong:</st>

				*   <st c="9106">There is</st> <st c="9116">no network</st>
				*   <st c="9126">There’s a network, but the device cannot reach</st> <st c="9174">the internet</st>
				*   <st c="9186">The device can reach the internet but with a very</st> <st c="9237">slow connection</st>
				*   <st c="9252">We have a stable connection, but the request cannot reach</st> <st c="9311">the backend</st>
				*   <st c="9322">The request found the backend, but it</st> <st c="9361">didn’t respond</st>

			<st c="9375">The error list can go on and on, ranging from network issues to security to</st> <st c="9452">server errors.</st>
			<st c="9466">To simplify the idea, we can divide the errors into two main groups: network-related issues and</st> <st c="9563">server-side problems.</st>
			<st c="9584">To understand the difference between network and server-related issues, let’s have another look at how we created a</st> <st c="9701">data task:</st>

let task = session.dataTask(with: request) { (data,

response, error)


			<st c="9780">We can see that the data task response</st> <st c="9819">contains three</st> <st c="9834">parameters –</st> `<st c="9848">data</st>`<st c="9852">,</st> `<st c="9854">response</st>`<st c="9862">,</st> <st c="9864">and</st> `<st c="9868">error</st>`<st c="9873">.</st>
			<st c="9874">Network-related errors are part of the</st> `<st c="9914">error</st>` <st c="9919">object, and server-related errors are mostly part of the</st> `<st c="9977">response</st>` <st c="9985">object and sometimes even part of the</st> `<st c="10024">data</st>` <st c="10028">object.</st>
			<st c="10036">To handle a network error, we should look</st> <st c="10079">into</st> `<st c="10084">URLError</st>`<st c="10092">:</st>

if let error = error as? URLError {

switch error.code {

case .cannotFindHost:

    // 通知用户。default:

    print("错误:\(error)")

}

return

}


			<st c="10237">In this code example, we performed a switch statement to understand our network error.</st> <st c="10325">In this case, we decided to handle one use case of</st> `<st c="10376">cannotFindHost</st>`<st c="10390">. However, there are at least 20 different error codes we can handle.</st> <st c="10460">To read the full and updated list, we should look at Apple documentation</st> <st c="10533">at</st> [<st c="10536">https://developer.apple.com/documentation/foundation/urlerror</st>](https://developer.apple.com/documentation/foundation/urlerror)<st c="10597">.</st>
			<st c="10598">Unlike network-related errors, server-related errors are more complex.</st> <st c="10670">First, we are dependent on another partner—our server.</st> <st c="10725">How the server implements its error-handling logic significantly influences how we handle it in</st> <st c="10821">our app.</st>
			<st c="10829">Let’s understand that by examining the</st> <st c="10869">server response:</st>

if let httpResponse = response as? HTTPURLResponse {

        switch httpResponse.statusCode {

        case 200..<300:

            print("成功:

            \(httpResponse.statusCode)")

        case 400..<500:

            print("客户端错误:

            \(httpResponse.statusCode)")

        case 500..<600:

            print("服务器错误:

            \(httpResponse.statusCode)")

        default:

            print("其他状态码:

            \(httpResponse.statusCode)")

        }

    } else {

        print("无效的 HTTP 响应")

    }

			<st c="11272">We first cast the response</st> <st c="11299">into the</st> `<st c="11309">HTTPURLResponse</st>` <st c="11324">type, representing</st> <st c="11343">a general</st> <st c="11354">URL response.</st>
			<st c="11367">The response includes a status code, which the server sends back to us.</st> <st c="11440">In most cases, the code will be part of the following</st> <st c="11494">three groups:</st>

				*   `<st c="11507">200..299</st>`<st c="11516">: The server successfully responded to</st> <st c="11556">our request</st>
				*   `<st c="11567">400..499</st>`<st c="11576">: The server returns an error due to a bad</st> <st c="11620">client request</st>
				*   `<st c="11634">500..599</st>`<st c="11643">: The server returned an error due to an internal</st> <st c="11694">server error</st>

			<st c="11706">In short, there are three cases – everything went well, it is the client’s fault, or it is the</st> <st c="11802">server’s fault.</st>
			<st c="11817">However, in real life, things</st> <st c="11847">are more complex.</st> <st c="11866">Sometimes, the server</st> <st c="11887">returns a response code of</st> `<st c="11915">200</st>` <st c="11918">(success) but includes an error in the response data.</st> <st c="11973">There is nothing wrong with doing that – the server can choose how to handle problems.</st> <st c="12060">It’s our responsibility to parse the</st> <st c="12097">response correctly.</st>
			<st c="12116">If we need to parse the response ourselves to extract the error, it is better to create a function that receives the data, response, and error parameters and throws an error in case it</st> <st c="12302">finds one:</st>

func handleResponse(data: Data?, response: URLResponse?, error: Error?) throws {

if let error = error {

    throw error

}

guard let httpResponse = response as? HTTPURLResponse

else {

    throw NetworkingError.invalidResponse

}

switch httpResponse.statusCode {

case 200..<300:

    if let responseData = data {

        if let errorData = try? JSONDecoder().decode(ErrorResponse.self,

        from: responseData) {

            throw NetworkingError.dataError

        }

    }

case 400..<500:

    throw NetworkingError.clientError(statusCode:

    httpResponse.statusCode)

case 500..<600:

    throw NetworkingError.serverError(statusCode:

    httpResponse.statusCode)

default:

    throw NetworkingError.otherError

}

}


			<st c="12952">This long</st> `<st c="12963">handleResponse</st>` <st c="12977">function</st> <st c="12986">does precisely</st> <st c="13001">what we’ve discussed.</st> <st c="13024">In case of a successful response, it checks the error object, the response code, and the</st> <st c="13113">data itself.</st>
			<st c="13125">To use that function, we need to call it within the</st> <st c="13178">response closure:</st>

let task = session.dataTask(with: request) { (data,

response, error) in

do {

    try handleResponse(data: data, response: response,

    error: error)

} catch let error {

    print("错误:\(error)")

}

}


			<st c="13386">The great thing about the</st> `<st c="13413">handleResponse</st>` <st c="13427">function is that we can ensure that we can continue handling the response data after the</st> `<st c="13517">try</st>` <st c="13520">statement because we have dealt with</st> <st c="13558">any error.</st>
			<st c="13568">If you look again</st> <st c="13586">at the</st> `<st c="13594">handleResponse</st>` <st c="13608">function, you’ll see that we decode</st> <st c="13644">the response to look for</st> <st c="13670">an error.</st>
			<st c="13679">Deserializing the response is a major step in handling a network response.</st> <st c="13755">Let’s discuss it a little</st> <st c="13781">bit further.</st>
			<st c="13793">Deserializing a network response</st>
			<st c="13826">In most apps, the response</st> <st c="13853">we get from the server is based on JSON data</st> <st c="13898">structure.</st> <st c="13910">JSON is an industry standard for delivering network responses along</st> <st c="13978">with XML.</st>
			<st c="13987">Swift has built-in support for parsing JSON structures into Swift structures, using tools such as the</st> `<st c="14090">Codable</st>` <st c="14097">protocol and</st> `<st c="14111">JSONDecoder</st>` <st c="14122">classes.</st>
			<st c="14131">In theory, it sounds perfect—all we need to do is decode our response to a data model.</st> <st c="14219">However, there are more factors we need</st> <st c="14259">to consider:</st>

				*   `<st c="14413">handleResponse</st>` <st c="14427">function example, we saw a response that may have contained an error message.</st> <st c="14506">This means that when we think about our data models, general network responses should be</st> <st c="14595">among them.</st>
				*   **<st c="14606">Assuming there’s always an object array</st>**<st c="14646">: Decoding a single object is straightforward, but in many cases, we also need to handle decoding an array of objects.</st> <st c="14766">That sounds trivial, but supporting both formats can be a hassle.</st> <st c="14832">To simplify the decoding process, it is better to always support an array of objects, which is a decision that we need to coordinate with our</st> <st c="14974">backend developers.</st>
				*   **<st c="14993">Mixed structures</st>**<st c="15010">: A response can contain different model types and even nested data structures.</st> <st c="15091">This is not always trivial, so our data structures must be more dynamic and modular to support</st> <st c="15186">various responses.</st>
				*   **<st c="15204">Model transformations</st>**<st c="15226">: Our local app models are structured to be efficient and convenient to use with the business logic and UI layers.</st> <st c="15342">However, who said that the backend response structure is aligned with what is suitable for our app?</st> <st c="15442">This means we must transform the response data model to our local</st> <st c="15508">data model.</st>

			<st c="15519">Deserializing data models</st> <st c="15545">is indeed a complex task, and trying to match our data models</st> <st c="15607">to the response structure we receive from our backend is only sometimes the best idea.</st> <st c="15695">Remember that our data models must suit our app needs and not necessarily follow the</st> <st c="15780">backend methodology.</st>
			<st c="15800">Let’s take a simple JSON received from</st> <st c="15840">the server:</st>

{

"id": 1,

"name": "John Doe",

"email": "john@example.com"

}


			<st c="15912">That’s a contact structure.</st> <st c="15941">However, we want to use different names in our app so we can use the</st> `<st c="16010">CodingKey</st>` <st c="16019">protocol to ensure we perform the</st> <st c="16054">transformation correctly:</st>

struct Contact: Codable {

let id: Int

let fullName: String

let userEmail: String

// 定义自定义编码键以匹配 JSON 键

private enum CodingKeys: String, CodingKey {

    case id

    case fullName = "name"

    case userEmail = "email"

}

}


			<st c="16313">Decoding the server response using the</st> `<st c="16353">Contact</st>` <st c="16360">structure now becomes</st> <st c="16383">much simpler:</st>

let errorData = try? JSONDecoder().decode(Contact.self,

from: responseData)


			<st c="16472">In this example, we map the</st> `<st c="16501">name</st>` <st c="16505">value to</st> `<st c="16515">fullName</st>` <st c="16523">and</st> `<st c="16528">email</st>` <st c="16533">to</st> `<st c="16537">userEmail</st>`<st c="16546">. We decode it using the</st> `<st c="16571">JSONDecoder</st>` <st c="16582">class.</st> <st c="16590">Understanding the</st> `<st c="16608">CodingKey</st>` <st c="16617">protocol is a crucial key to decoding</st> <st c="16656">server responses.</st>
			<st c="16673">There are cases where the whole structure</st> <st c="16715">of the server response</st> <st c="16738">is entirely different than our data models, and in those cases, we need to create a dedicated structure to parse the response.</st> <st c="16866">However, sometimes, we can still use our data model as part of the structure.</st> <st c="16944">Let’s look at the</st> <st c="16962">following example:</st>

struct ServerResponse: Codable {

let responseID: String

let timestamp: String

let orgID: String

let contact: Contact

}

let jsonString = """

{

"responseID": "12345",

"timestamp": "2024-03-25T12:00:00Z",

"orgID": "5678",

"contact": {

"id": 1,

"fullName": "John Doe",

"userEmail": "john@example.com"

}

}

"""

let jsonData = jsonString.data(using: .utf8)! let response = try

JSONDecoder().decode(ServerResponse.self, from: jsonData)


			<st c="17408">In this code example, the server returns additional information besides the contact object.</st> <st c="17501">So, we can create a dedicated data structure for the response—</st>`<st c="17563">ServerResponse</st>` <st c="17578">(in this case).</st> <st c="17595">In addition to general information, the</st> `<st c="17635">ServerResponse</st>` <st c="17649">struct contains the</st> `<st c="17670">Contact</st>` <st c="17677">struct.</st> <st c="17686">So, we can see a modular approach here—we can parse our server response</st> <st c="17757">using</st> `<st c="17764">Codable</st>` <st c="17771">and still use our data model objects</st> <st c="17808">to receive</st> <st c="17820">the information.</st>
			<st c="17836">The next step is to store our data model in our</st> <st c="17885">data store.</st>
			<st c="17896">Building a data store</st>
			<st c="17918">A disclaimer: not every network</st> <st c="17950">call requires</st> <st c="17964">us to store the results in a data store.</st> <st c="18006">For instance, authentication and logic calls have different goals.</st> <st c="18073">However, this chapter will focus mainly on data-related calls responsible for building our local</st> <st c="18170">data store.</st>
			<st c="18181">That leads us to our next point: what is the role of the</st> <st c="18239">data store?</st>
			<st c="18250">So, a data store is a structured mechanism for managing and storing data that serves the application’s main business logic</st> <st c="18374">and UI.</st>
			<st c="18381">Unlike many online examples, the application business logic usually doesn’t work directly with the network responses – these need to be adjusted and saved in our store, which acts as the UI</st> <st c="18572">data source.</st>
			<st c="18584">Let’s look at</st> *<st c="18599">Figure 8</st>**<st c="18607">.2</st>*<st c="18609">:</st>
			![Figure 8.2: Working with the datastore](https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_08_2.jpg)

			<st c="18682">Figure 8.2: Working with the datastore</st>
			*<st c="18720">Figure 8</st>**<st c="18729">.2</st>* <st c="18731">shows how the data layer works directly with the data store, as the network layer fills the data store with</st> <st c="18840">more information.</st>
			<st c="18857">The data store</st> <st c="18872">doesn’t have to be persistent—that’s an engineering</st> <st c="18925">decision.</st> <st c="18935">However, in most cases, it is a structured store.</st> <st c="18985">A structured store has pre-defined models, relations between entities, and often even query capabilities.</st> <st c="19091">These characteristics distinguish the data store from simply caching the</st> <st c="19164">network responses.</st>
			<st c="19182">To follow the separation of concerns principle, it is better to have dedicated classes to handle each step of</st> <st c="19293">the process.</st>
			<st c="19305">First, we’ll create a</st> `<st c="19328">DataStore</st>` <st c="19337">class:</st>

class DataStore {

private var contacts: [Contact] = []

func updateContacts(with newContacts: [Contact]) {

    contacts = newContacts

}

func getAllContacts() -> [Contact] {

    return contacts

}

}


			<st c="19532">The</st> `<st c="19537">DataStore</st>` <st c="19546">class not only stores the data but also has methods that help store and</st> <st c="19619">retrieve entities.</st>
			<st c="19637">Assuming we already have a network</st> <st c="19672">handler from the previous</st> <st c="19698">examples, we are now going to create a sync class that coordinates the process of fetching the data and</st> <st c="19803">storing it:</st>

class SyncManager {

private let dataStore: DataStore

init(dataStore: DataStore) {

    self.dataStore = dataStore

}

func syncData() {

    NetworkHandler.fetchData { result in

        switch result {

        case .success(let data):

            do {

                let contacts = try JSONDecoder().decode([Contact].self, from: data)

                self.dataStore.updateContacts(with: contacts)

                print("数据同步成功")

            } catch {

                print("JSON 解码数据错误:", error)

            }

        case .failure(let error):

            print("获取数据错误:", error)

        }

    }

}

}


			<st c="20294">The</st> `<st c="20299">SyncManager</st>` <st c="20310">class uses the</st> `<st c="20326">NetworkHandler</st>` <st c="20340">class to fetch the information from our backend, parses the results into</st> `<st c="20414">Contact</st>` <st c="20421">entities, and stores them in our data store.</st> <st c="20467">Using this design</st> <st c="20484">pattern, we can easily replace the data store implementation</st> <st c="20545">to be persistent without modifying the</st> <st c="20585">other classes.</st>
			<st c="20599">Now that we have a data store, let’s try to understand how to make our app</st> <st c="20675">more efficient.</st>
			<st c="20690">Integrating network calls within app flows</st>
			<st c="20733">We already know how to perform</st> <st c="20764">a network call, parse</st> <st c="20786">it to data objects, and create a data store.</st> <st c="20832">We also know how to handle errors, and we learned that it’s important to separate the concerns into different classes</st> <st c="20950">and components.</st>
			<st c="20965">However, it feels like a technical discussion.</st> <st c="21013">Performing a URL connection in iOS is one of the most basic tasks.</st> <st c="21080">Let’s try to upgrade our discussion and</st> <st c="21120">discuss methodology.</st>
			<st c="21140">First, we should think of streaming data from the network as an atomic task in our app’s data synchronization mechanism.</st> <st c="21262">It’s up to us to decide when to call our server for more data.</st> <st c="21325">From our discussions, it looks like we need to contact the server just before we want to display the information, but it doesn’t have to be</st> <st c="21465">like that.</st>
			<st c="21475">Let’s discuss the different strategies we can use when working with our backend.</st> <st c="21557">We’ll start with the</st> **<st c="21578">just-in-time</st>** <st c="21590">fetching technique.</st>
			<st c="21610">Just-in-time fetching</st>
			<st c="21632">The just-in-time fetching technique</st> <st c="21668">is very common and also the simplest one.</st> <st c="21711">With it, we don’t present anything on the screen before we get a response from the server.</st> <st c="21802">Instead, we show a loader indicating that we are</st> <st c="21851">fetching data.</st>
			<st c="21865">In just-in-time fetching, we don’t preserve the information in a data store; instead, we store the information in the view state or the view model.</st> <st c="22014">Here’s a simple example of</st> <st c="22041">just-in-time fetching:</st>

import SwiftUI

struct ContactsView: View {

@State private var contacts: [Contact] = []

@State private var isLoading = false

var body: some View {

    NavigationView {

        List(contacts) { contact in

            VStack(alignment: .leading) {

                Text(contact.name).font(.headline)

                Text(contact.phoneNumber).font(.subheadline)

            }

        }

        .navigationTitle("联系人")

        .onAppear {

            fetchContacts()

        }

        .overlay {

            if isLoading {

                ProgressView("加载中...")

            }

        }

    }

}

private func fetchContacts() {

    isLoading = true

    NetworkHandler().fetchData { fetchedContacts in

        contacts = fetchedContacts

        isLoading = false

    }

}

}


			<st c="22635">In this code example, we have a list that is based on the state variable of contacts.</st> <st c="22722">When the view appears, we call the</st> `<st c="22757">fetchContacts</st>` <st c="22770">method to fetch the list of contacts and, in the meantime, show a</st> <st c="22837">loading message.</st>
			<st c="22853">Besides its simplicity, the just-in-time technique is great for apps that must ensure that the data they display is up to date, such as financial apps or live sports scores.</st> <st c="23028">The downside here is that we provide a poor user experience</st> <st c="23087">and depend on the</st> <st c="23106">network state.</st>
			<st c="23120">If possible, we should pick a slightly better technique, often called</st> **<st c="23191">read-through cache</st>**<st c="23209">.</st>
			<st c="23210">Read-through cache</st>
			<st c="23229">The read-through cache technique</st> <st c="23262">is also a popular way to present data to the user, even though most developers are unaware of</st> <st c="23357">its name.</st>
			<st c="23366">Using the read-through cache approach, we display our local data to the user while going to our backend to refresh</st> <st c="23482">our data.</st>
			<st c="23491">Let’s see a code example</st> <st c="23517">for that:</st>

import SwiftUI

struct ContactsView: View {

@State private var contacts: [Contact] = []

var body: some View {

    NavigationView {

        List(contacts) { contact in

            VStack(alignment: .leading) {

                Text(contact.name).font(.headline)

                Text(contact.phoneNumber).font(.subheadline)

            }

        }

        .navigationTitle("联系人")

        .onAppear {

            loadContacts()

        }

    }

}

private func loadContacts() {

    contacts = loadFromCache()

    NetworkHandler().fetchData { fetchedContacts in

        contacts = fetchedContacts

        saveToCache(contacts: fetchedContacts)

    }

}

}


			<st c="24033">In this code example, we load the contacts from the cache (or from the local store) when the screen appears and then go to the network to refresh our data set.</st> <st c="24194">The read-through cache technique is great when quick access to data is crucial because it is not up-to-date, for example, in news or</st> <st c="24327">e-commerce apps.</st>
			<st c="24343">You’ve probably noticed that both the just-in-time and read-through cache techniques require us to load the page information fully from the backend, regardless of the amount of information</st> <st c="24533">we have.</st>
			<st c="24541">Now, what if we know upfront</st> <st c="24570">that we have a huge number of records to fetch, so big that it can even cause our request to time out?</st> <st c="24674">In this case, we can choose the</st> **<st c="24706">incremental</st>** **<st c="24718">loading</st>** <st c="24725">technique.</st>
			<st c="24736">Incremental loading</st>
			<st c="24756">There are cases wherein</st> <st c="24780">we can expect to fetch a vast number of records.</st> <st c="24830">A social feed, for instance, can have an infinite number of posts.</st> <st c="24897">Well, it’s not really infinite, but we can relate to that number</st> <st c="24962">as infinity.</st>
			<st c="24974">When the number is too big to fetch in one request, we can use</st> <st c="25038">incremental loading.</st>
			<st c="25058">With incremental loading, we fetch a set of records each time with each request and store the last record index for the</st> <st c="25179">next time.</st>
			<st c="25189">Here’s an example of</st> <st c="25211">incremental loading:</st>

class IncrementalLoader {

var currentPage = 1

let itemsPerPage = 10

var contacts = [Contact]()

func loadNextPage() {

    guard let url = URL(string:

"https://api.example.com/contacts?page=(currentPage)&limit=(itemsPerPage)") else {

        print("无效的 URL")

        return

    }

    let task = URLSession.shared.dataTask(with: url) { [weak self] (data, response, error) in

        guard let self = self else { return }

        do {

            let newContacts = try JSONDecoder().decode([Contact].self, from: data)

            DispatchQueue.main.async {

                self.contacts.append(contentsOf: newContacts)

                print("获取联系人: \(newContacts)")

                self.currentPage += 1

            }

        } catch {

            print("JSON 解码错误: \(error)")

        }

    }

    task.resume()

}

}


			<st c="25905">In this example, we have a class named</st> `<st c="25945">IncrementalLoading</st>`<st c="25963">, which is responsible for loading the next set of records with the function named</st> `<st c="26046">loadNextPage</st>`<st c="26058">. Incremental loading is also called</st> `<st c="26177">IncrementalLoading</st>` <st c="26195">example, we have an index that points to the last record index fetched, and a variable named</st> `<st c="26289">itemsPerPage</st>` <st c="26301">that defines how many items to fetch on</st> <st c="26342">each page.</st>
			<st c="26352">While incremental loading solves handling a large amount of data, there are several factors</st> <st c="26444">we need</st> <st c="26453">to consider:</st>

				*   `<st c="26720">List</st>` <st c="26724">view or a UIKit</st> `<st c="26741">TableView</st>` <st c="26750">view.</st> <st c="26757">In these cases, we would like to fetch the next set of records when the user reaches the bottom of the list.</st> <st c="26866">However, things can become complex when we allow the user to edit or delete records since that can affect the</st> <st c="26976">index variable.</st>
				*   **<st c="26991">Memory consumption</st>**<st c="27010">: It’s true that incremental loading is optimized to handle a significant amount of information.</st> <st c="27108">However, we are still talking about storing a large amount of information in our memory.</st> <st c="27197">While the user is paging through our data, our local data store can become bigger, mainly if it contains rich media such as images and videos.</st> <st c="27340">It is essential to have a mechanism that can release records in case of high</st> <st c="27417">memory usage.</st>
				*   **<st c="27430">Contextual relevance</st>**<st c="27451">: We need to remember that our chosen design pattern needs to support a specific product need.</st> <st c="27547">Incremental loading is relevant in cases wherein we don’t need all the data at once.</st> <st c="27632">Social feeds or search results are great examples of data that can be browsed chunk by chunk.</st> <st c="27726">However, in cases where the user requires immediate access to all the data, such as in data analysis, incremental loading</st> <st c="27847">might not</st> <st c="27858">be suitable.</st>

			<st c="27870">Considering the different factors mentioned, we understand that, similar to many design patterns in computer science, incremental loading presents a tradeoff between different aspects such as performance, complexity, experience, and more.</st> <st c="28110">It’s up to us to choose the right design pattern that fits</st> <st c="28169">our needs.</st>
			<st c="28179">The three design patterns we discussed now require different endpoints for different types of data and other screens, which sounds logical.</st> <st c="28320">However, there’s another way to handle data that changes over time and still provides an amazing experience to the user –</st> <st c="28442">delta updates.</st>
			<st c="28456">Full data sync with delta updates</st>
			<st c="28490">Before we discuss full data sync with the delta updates method, let’s talk about problems that we have with</st> <st c="28598">multiple endpoints:</st>

				*   **<st c="28618">Efficient network calls</st>**<st c="28642">: The need to request the same data repeatedly, even if nothing has changed, seems inefficient.</st> <st c="28739">We can use the cache to present previous results, but that only solves performance issues.</st> <st c="28830">We still need to perform the same request to understand whether there</st> <st c="28900">are updates.</st>
				*   **<st c="28912">Incomplete database</st>**<st c="28932">: Each endpoint retrieves different data and sometimes different entities.</st> <st c="29008">We know that in many cases, the entities are related (such as to-one and to-many relationships), and having multiple endpoints to fetch them probably means our data won’t be complete.</st> <st c="29192">That seems acceptable – we’re focused on a mobile app and not a server.</st> <st c="29264">However, having an incomplete data store can result in a poor experience.</st> <st c="29338">Users may encounter updated information on one screen, navigate to another, and view outdated data while waiting for the screen to refresh from the server.</st> <st c="29494">If both screens contain related data, it can result in a</st> <st c="29551">poor experience.</st>
				*   **<st c="29567">App performance</st>**<st c="29583">: We often believe that performance is only about CPU and Swift code efficiency.</st> <st c="29665">However, our devices are strong enough to handle most tasks without a hiccup.</st> <st c="29743">In contrast, network requests</st> <st c="29772">cause users to wait even if they have the latest hardware.</st> <st c="29832">Having a network call on each screen greatly impacts the</st> <st c="29889">user experience.</st>

			<st c="29905">Delta updates</st> <st c="29919">are a solution that can handle some of the problems we described with endpoints in the previous section.</st> <st c="30025">With delta updates, we fetch all the information at the app’s initial launch and, from this point, retrieve only</st> <st c="30138">the changes.</st>
			<st c="30150">We do that by storing a bookmark representing our data’s last updated timestamp.</st> <st c="30232">When we ask the server, “Do you have any updates for me?”, we send this bookmark, get the new changes (if any), receive a new bookmark, and</st> <st c="30372">store it.</st>
			<st c="30381">Here’s a code example for contacts</st> <st c="30416">delta sync.</st> <st c="30429">We start with the</st> `<st c="30447">syncContacts</st>` <st c="30459">function:</st>

class ContactsSyncManager {

let userDefaults = UserDefaults.standard

let lastUpdatedKey = "lastUpdatedTime"

let syncEndpoint = URL(string:

"https://example.com/api/sync/contacts")! func syncContacts() {

    var request = URLRequest(url: syncEndpoint)

    request.httpMethod = "POST"

    request.addValue("application/json",

    forHTTPHeaderField: "Content-Type")

    let lastUpdatedTime = userDefaults.double(forKey:

    lastUpdatedKey)

    let requestBody = ["lastUpdatedTime":

    lastUpdatedTime]

    request.httpBody = try? JSONSerialization.data(withJSONObject:

    requestBody)

    URLSession.shared.dataTask(with: request) { [weak

    self] data, response, error in

        self?.processDeltaUpdates(response: response)

    }.resume()

}

			<st c="31154">The code example does exactly</st> <st c="31184">what we described earlier—it saves a bookmark called</st> `<st c="31238">lastUpdatedDate</st>`<st c="31253">. Initially, we fetch all the data and save the new</st> `<st c="31305">lastUpdatedDate</st>` <st c="31320">value we get from the server.</st> <st c="31351">The next time we perform the sync operation, we get only the changes.</st> <st c="31421">Now, let’s implement the</st> `<st c="31446">processDeltaUpdates</st>` <st c="31465">function:</st>

private func processDeltaUpdates(response:

ContactsDeltaUpdateResponse) {

    // 这里可以根据需要处理新增、删除和更新的联系人

    print("新联系人:

    \(response.newContacts.count)")

    print("Deleted Contacts:

    \(response.deletedContacts.count)")

    print("Updated Contacts:

    \(response.updatedContacts.count)")

    userDefaults.set(response.lastUpdated, forKey:

    lastUpdatedKey)

}

}


			<st c="31863">The</st> `<st c="31868">processDeltaUpdates</st>` <st c="31887">function receives a response that contains only the changes that have occurred in the server since the</st> <st c="31991">last sync.</st>
			<st c="32001">That’s why the response</st> <st c="32025">is structured into three groups: deleted, new, and updated.</st> <st c="32086">With each one, we need to handle the</st> <st c="32123">data differently.</st>
			<st c="32140">Some critical notes we need to consider</st> <st c="32180">here are</st> <st c="32190">as follows:</st>

				*   **<st c="32201">Extremally large data sets</st>**<st c="32228">: The delta updates pattern is not relevant for very large data sets.</st> <st c="32299">For example, a social app feed can have millions of records, and fetching all of them from the start is impossible.</st> <st c="32415">For that issue, we can</st> <st c="32438">use pagination.</st>
				*   **<st c="32453">The initial loading can be long</st>**<st c="32485">: Since we fetch all the data at the beginning, we need to deliver a corresponding</st> <st c="32569">user experience.</st>
				*   **<st c="32585">Deleted items</st>**<st c="32599">: Syncing deleted items is always a crucial topic.</st> <st c="32651">We need to actively delete items that no longer exist, so the response from the server should contain items we need</st> <st c="32767">to delete.</st>
				*   **<st c="32777">Sync triggers</st>**<st c="32791">: Since we perform the sync operation at the beginning, it looks like it’s the only time we should do that.</st> <st c="32900">However, there are more occasions when we need to refresh our data.</st> <st c="32968">For example, when we perform data changes such as calling the server to add a new item or receiving a push notification, we should think about the different cases when something can change in our server during the app runtime</st> <st c="33193">and try to refresh</st> <st c="33213">our data.</st>

			<st c="33222">It’s important to understand that none of the solutions are perfect.</st> <st c="33292">Sometimes, it is a good idea to combine different approaches—for example, use delta sync in general, but maybe use pagination for a</st> <st c="33424">specific screen.</st>
			<st c="33440">We should consider the different approaches as a toolbox with several tools, each suitable for various problems or</st> <st c="33556">data structures.</st>
			<st c="33572">Now that we understand how to handle requests and use different patterns to incorporate the calls in our app flows, let’s see another way to handle networking</st> <st c="33732">in iOS.</st>
			<st c="33739">Exploring Networking and Combine</st>
			<st c="33772">Networking is a great place</st> <st c="33800">to start if you</st> <st c="33816">haven’t worked with Combine.</st> <st c="33846">Combine is a framework that declaratively handles a stream of values over time while supporting</st> <st c="33942">asynchronous operations.</st>
			<st c="33966">Based on that description, it looks like Combine was made for</st> <st c="34029">networking operations!</st>
			<st c="34051">In this chapter, we are not going to discuss what Combine is – for that, we’ve got</st> *<st c="34135">Chapter 11</st>*<st c="34145">. However, we are going to discuss it now because Combine is a great way to solve many networking</st> <st c="34243">operations problems.</st>
			<st c="34263">Since Combine is built upon publishers and operators, it is simple to create new publishers that stream data from</st> <st c="34378">the network.</st>
			<st c="34390">Let’s try to request the list of contacts from previous examples using a Combine stream.</st> <st c="34480">We’ll start with creating a publisher that performs data fetching from the network and publish a list</st> <st c="34582">of contacts:</st>

class ContactRequest {

func fetchData() -> AnyPublisher<[Contact], Error> {

    let url = URL(string:

    "https://api.example.com/contacts")! return URLSession.shared.dataTaskPublisher(for:

    url)

        .map { $0.data }

        .decode(type: [Contact].self, decoder:

        JSONDecoder())

        .eraseToAnyPublisher()

}

}


			<st c="34880">The publisher utilizes</st> <st c="34903">URLSession’s</st> `<st c="34917">dataTaskPublisher</st>` <st c="34934">method</st> <st c="34941">to execute the network request and publish the retrieved data.</st> <st c="35005">We then extract the data using the map operation and decode it into a list of</st> `<st c="35083">Contact</st>` <st c="35090">items.</st> <st c="35098">If something goes wrong, the publisher will report an Error.</st> <st c="35159">We wrap this function in a class named</st> `<st c="35198">ContactRequest</st>` <st c="35212">to</st> <st c="35216">maintain separation.</st>
			<st c="35236">Now, let’s create a small</st> `<st c="35263">DataStore</st>` <st c="35272">class so we can store the results and</st> <st c="35311">publish them:</st>

class DataStore {

@Published var contacts: [Contact] = []

}


			<st c="35384">The</st> `<st c="35389">@Published</st>` <st c="35399">property wrapper creates a publisher for contacts so that we can observe the</st> <st c="35477">changes easily.</st>
			<st c="35492">Now, we can use the</st> `<st c="35513">fetchData()</st>` <st c="35524">function</st> <st c="35533">to read the results</st> <st c="35553">and</st> <st c="35558">store them:</st>

class ContactsSync {

let contactRequest = ContactRequest()

let dataStore = DataStore()

func syncContacts() { <st c="35679">contactRequest.fetchData()</st> .sink(receiveCompletion: { completion in

            switch completion {

            case .finished:

                print("Data fetch completed

                successfully")

            case .failure(let error):

                print("Error fetching data: \(error)")

            }

        }, receiveValue: { [weak self] contacts in <st c="35935">self?.dataStore.contacts = contacts</st> })

        .store(in: &cancellables)

}

private var cancellables = Set<AnyCancellable>()

}

let contactsSync = ContactsSync()

contactsSync.syncContacts()


			<st c="36114">The</st> `<st c="36119">ContactsSync</st>` <st c="36131">job is to fetch</st> <st c="36147">contacts using the</st> `<st c="36167">ContactRequest</st>` <st c="36181">class and to store</st> <st c="36200">them in the data store using the</st> `<st c="36234">DataStore</st>` <st c="36243">class.</st>
			<st c="36250">The Combine example has</st> <st c="36274">several advantages:</st>

				*   **<st c="36294">Clear and consistent interface</st>**<st c="36325">: The publisher interface is consistent and known.</st> <st c="36377">It is always built from data/void and an optional error.</st> <st c="36434">New developers don’t need to learn and understand how to</st> <st c="36491">read/use it.</st>
				*   **<st c="36503">Built-in error handling</st>**<st c="36527">: Not only do we have a consistent interface that also contains errors, but also, when one of the stages encounters an error, it interrupts the flow and channels it downstream.</st> <st c="36705">We have already seen that error handling is a critical topic in networking in</st> <st c="36783">many cases.</st>
				*   **<st c="36794">Asynchronous operations support</st>**<st c="36826">: We often think that a network operation contains one asynchronous operation: the request itself.</st> <st c="36926">However, many steps in the stream can be asynchronous – including preparing the request by reading local data, processing the response, and storing the data at the end of the stream.</st> <st c="37109">Combine streams are perfect for performing all those</st> <st c="37162">steps asynchronously.</st>
				*   **<st c="37183">Modularity</st>**<st c="37194">: The capability of building</st> <st c="37223">a modular code is reserved not only for the Combine framework, but the custom publishers and the different operators make Combine streams a joyful framework to implement when dealing with networking.</st> <st c="37424">Remember that we said that networking is like a production line (under the</st> *<st c="37499">Understanding mobile networking</st>* <st c="37530">section)?</st> <st c="37541">So, Combine makes it easier to insert more steps into the stream; some of them are even built into</st> <st c="37640">the framework.</st>

			<st c="37654">Adding reactive methods</st> <st c="37678">to our code doesn’t mean we need to discard all the design patterns and principles we discussed when we covered networking—it’s just another</st> <st c="37819">way to</st> <st c="37827">implement them.</st>
			<st c="37842">For example, let’s try to implement the delta updates design pattern using the</st> <st c="37922">Combine framework:</st>

URLSession.shared.dataTaskPublisher(for: request)

        .tryMap { output in

            guard let response = output.response as? HTTPURLResponse, response.statusCode ==

            200 else {

                throw URLError(.badServerResponse)

            }

            return output.data

        }

        .decode(type: ContactsDeltaUpdateResponse.self,

        decoder: JSONDecoder())

        .receive(on: DispatchQueue.main)

        .sink(receiveCompletion: { completion in

            switch completion {

            case .finished:

                break

            case .failure(let error):

                print("Error during sync:

                \(error.localizedDescription)")

            }

        }, receiveValue: { [weak self] response in

            self?.processDeltaUpdates(response:

            response)

        })

        .store(in: &cancellables)

			<st c="38552">Looking at the code</st> <st c="38572">example, we can</st> <st c="38588">see that it looks pretty much like the previous Combine code—that’s part of the idea of consistent interface and modular code.</st> <st c="38716">We perform the request, check the response code, decode it, change it to the main thread, and process the</st> <st c="38822">response data.</st>
			<st c="38836">Summary</st>
			<st c="38844">Connecting to our backend and retrieving data is a basic task in most mobile apps.</st> <st c="38928">Doing so lets us present valuable and interesting information to</st> <st c="38993">our users.</st>
			<st c="39003">Performing a simple request is easy – however, there are many other factors to bear in mind, and doing that properly is crucial to having an</st> <st c="39145">efficient app.</st>
			<st c="39159">This chapter reviewed the different network components, such as the request itself, error handling, and data storage.</st> <st c="39278">We also discussed our different design patterns to work with our backend.</st> <st c="39352">We ended up incorporating Combine into our flows.</st> <st c="39402">We should now be perfectly able to set up a fantastic network infrastructure for</st> <st c="39483">our app.</st>
			<st c="39491">Now, let’s flip to the other side of our architecture, the UI, and discuss a library that can enrich our app easily –</st> **<st c="39610">Charts</st>**<st c="39616">!</st>

第十章:9

使用 Swift Charts 创建动态图表

Swift Charts 是苹果的一个框架,允许 我们以美丽和富有表现力的图表展示数据。 与图表一起工作不是一个次要的话题——数据是移动应用中的一个基本主题,展示洞察力和趋势的快速信息对于我们的应用 用户体验至关重要。

在本章中,我们将涵盖以下主题: 以下内容:

  • 了解为什么我们需要在应用中使用图表 我们的应用中

  • 了解 Swift Charts 框架

  • 创建条形图、折线图、饼图、面积图和 点图

  • 使用图表可视化函数 图表

  • 使用 ChartProxy 实现图表的 用户交互

  • 通过遵循 可绘制协议允许不同数据类型与图表一起工作

在我们创建第一个图表之前,让我们了解图表为什么很重要以及它们带来了什么价值。

技术要求

对于本章,您必须从苹果的 App Store下载 Xcode 版本 15.0 或更高版本。

您还需要运行最新的 macOS 版本(Ventura 或更高版本)。 只需在 App Store 中搜索 Xcode,选择并下载最新版本。 启动 Xcode,并遵循系统可能提示的任何其他安装说明。 一旦 Xcode 完全启动,您就准备好 开始了。

从以下 GitHub 链接下载示例代码:https://github.com/PacktPublishing/Mastering-iOS-18-Development/tree/main/Chapter9/Chapter9.swiftpm

为什么需要图表?

以下内容可能不是 特定于移动端的章节,但仍然非常重要。 许多应用以文本方式显示有用的信息,例如表格、列表或网格。 虽然以列表或网格的形式显示信息可能有益,但用这种方式讲述故事要困难得多。

用户有时难以处理信息的文本表示,而将其可视化可能有助于他们获得有趣的见解并做出决策。 可能会有不同类型的见解,这些见解可以是数据点之间的关系、趋势和 重复的模式。

由于屏幕尺寸和信息在网格中展示的挑战,数据在手机上可能更难消化。 然而,屏幕尺寸并不是手机唯一的挑战——用户通常期望快速了解数据洞察,而不是分析电子表格。 移动用户体验与桌面体验不同,因为它们有不同的用例和行为。 由于这种差异,图表在移动应用中的价值甚至比在桌面应用中更大,因为它们提供了一种以视觉方式展示信息的方法。 信息。

尽管如此,避免过度使用图表或在图表比表格或列表更有意义的地方使用图表是至关重要的。 例如,一个显示用户最新交易的银行应用会使用列表而不是图表。 列表是一种以可扫描的格式展示原始数据的好方法,它既交互性强,又允许用户执行操作或查看更多细节。 更多信息。

正如我们已经有列表、表格和集合视图一样,我们现在有了 Swift Charts,这是一个专门用于以信息丰富、可视化方式展示数据的框架。 方式。

介绍 Swift Charts 框架

创建简单易用的图表一直是一个挑战。 与表格、集合视图或列表不同,大多数第三方图表框架 从未在 UIKit/SwiftUI中感觉自然。

在 iOS 16 中,苹果公司宣布了 Swift Charts,这是一个 SwiftUI 框架,它以图表的形式展示结构化数据,并且非常适合在 SwiftUI 视图中使用。 很好地 适应了 SwiftUI 视图。

让我们看看一个 条形图 的例子:

 import Charts
struct BarMarkView: View {
    struct Sales: Identifiable {
        var id: UUID = UUID()
        let itemType: String
        let qty: Int
    }
    let data: [Sales] = [
        Sales(itemType: "Apples", qty: 50),
        Sales(itemType: "Oranges", qty: 60),
        Sales(itemType: "Watermelons", qty: 30)
    ]
    var body: some View {
        VStack { <st c="3731">Chart(data) {</st>
 <st c="3744">BarMark(</st>
 <st c="3753">x: .value("Fruit", $0.itemType),</st>
 <st c="3786">y: .value("qty", $0.qty)</st>
 <st c="3811">)</st>
 <st c="3813">}</st> }
    }
}

尽管代码示例看起来很长,但它简单易读易懂。 此示例显示了一个 <st c="3924">BarMark</st> 图表,展示了不同水果的销售数据。 它有一个 <st c="3991">Sales</st> 结构,包含特定水果类型的单一销售信息,以及一个 <st c="4080">data</st> 数组,包含关于几种 水果类型的销售信息。

在 SwiftUI 的主体部分,我们添加了一个名为 <st c="4201">Chart</st> 的新视图,并将</st> data<st c="4220">数组作为参数。</st> <st c="4243">在那个</st>Chart<st c="4260">视图中,我们添加了一个</st>BarMark<st c="4283">视图——一种以条形展示数据信息的方式——传递来自我们</st>Sales<st c="4376">结构体的</st>x<st c="4348">和</st>y` 值。

图 9**.1 显示了结果:

图 9.1:条形标记图表

图 9.1:条形标记图表

图 9**.1 显示了我们的代码结果——一个包含图例和标题的三个红色条形图视图。我们可以看到创建图表有多容易,就像我们创建 <st c="4625">列表</st> <st c="4635">垂直堆叠</st> 视图一样。

让我们探索和学习如何创建不同的图表类型,并了解 它们的用法。

创建图表

在我们继续之前,让我们了解 Swift Charts 框架中图表的视图结构。 正如我们从最后一个代码示例中可以看到的,图表视图被称为 <st c="4921">Chart</st>

<st c="4928">Chart</st>(data) { <st c="4943">BarMark</st>(
                    x: .value("Fruit", $0.itemType),
                    y: .value("qty", $0.qty)
                )
            }

图表中的每个数据点 被称为 <st c="5138">条形标记</st> 类型。 如果图表接收一个数组作为参数,它会在幕后执行一个 <st c="5213">ForEach</st> 循环并创建 几个标记。

实际上,我们可以编写与以下相同的代码:

<st c="5323">Chart</st> { <st c="5332">ForEach</st>(data, id:\.id) { item in <st c="5366">BarMark</st>(x: .value("Fruit",
                              item.itemType),
                            y: .value("qty", item.qty))
                }
            }

在这个代码示例中,我们使用之前相同的数据数组,使用一个 <st c="5522">ForEach</st> 循环迭代它,并为每个数组项创建一个 <st c="5549">条形标记</st> 视图。 这个例子对于理解图表是如何构建的至关重要,这样我们就可以在未来自定义和配置 它们。

现在,让我们进一步探索 <st c="5722">条形标记</st> 图表

创建条形标记图表

我们可以使用基于 <st c="5786">条形标记</st>的图表 来比较不同的数据点,例如销售额和各国人口规模。 我们看到了创建具有多个条形标记的图表是多么简单。

然而,使用条形标记视图创建图表的工作还没有结束。 我们还有更多选项来扩展这个标记,以提供更多信息。

我们将从一个堆叠的条形图开始。

添加堆叠标记

标准标记代表二维数据点,比较一个值与另一个值。 有时,数据集可能有一个更深层的故事,因为每个条形可能由几个值组成。

例如,让我们拿我们刚刚创建的销售图表来讨论苹果的销售情况。 苹果销售当前值为 50 件。 也许我们想展示这个值是如何在绿色和红色苹果之间分配的。 在这种情况下,我们可以使用 堆叠标记。

现在,我们将向我们的 现有图表 添加一个堆叠条形图。

首先,我们需要调整 我们的 <st c="6686">Sales</st> 结构以包含我们的 fruit color:

 struct Sales: Identifiable {
    var id: UUID = UUID()
    let itemType: String
    let qty: Int <st c="6816">var fruitColor: String = ""</st> }

现在,我们已经向 <st c="6870">fruitColor</st> 属性添加到 <st c="6897">Sales</st> 结构中,我们可以更新 我们的数据集:

 let data: [Sales] = [
        Sales(itemType: "Apples", qty: 20, fruitColor:
          "Green"),
        Sales(itemType: "Apples", qty: 30, fruitColor:
          "Red"),
        Sales(itemType: "Oranges", qty: 60),
        Sales(itemType: "Watermelons", qty: 30)
]

目前,我们的更新后的数据集 有两个与苹果销售相关的记录,每个记录都包含 销售的颜色。

现在我们已经有了所有需要的数据,让我们创建一个图表并将每个属性分配给图表中的正确角色:

 Chart(data) {
            BarMark(x: .value("Fruit", $0.itemType),
                    y: .value("qty", $0.qty)) <st c="7469">.foregroundStyle(by: .value("Color",</st>
 <st c="7505">$0.fruitColor))</st> }

在这个代码示例中,我们唯一的不同之处在于 <st c="7580">foregroundStyle</st> 视图修改器,它有助于区分不同的水果颜色。 让我们看看 图 9**.2的结果。

图 9.2:堆叠条形图视图

图 9.2:堆叠条形图视图

图 9**.2中,我们可以看到苹果条形图是由两种类型的值构建的。 蓝色代表绿色苹果,绿色代表 红色苹果。

我们看到了当我们添加具有相同 x 值的几个标记时,图表框架知道如何将它们堆叠在一起。

接下来,让我们看看当我们没有向我们的 y 值添加数据时会发生什么。

添加一维条形标记

大多数图表都是二维的,这意味着 它们有一个 x y 轴,用于比较不同的数据类别。 然而,我们可以专注于一个类别(这意味着图表将只有一个 y 轴值)并创建一个 一维图表。

例如,让我们从上一个例子中的苹果类别开始,并尝试基于它创建一个一维条形图。

首先,让我们丰富我们的数据,并添加 <st c="8484">黄色</st> 作为额外的 水果颜色:

 let data: [Sales] = [
        Sales(itemType: "Apples", qty: 20, fruitColor:
              "Green"),
        Sales(itemType: "Apples", qty: 30, fruitColor:
              "Red"), <st c="8655">Sales(itemType: "Apples", qty: 40, fruitColor:</st>
 <st c="8701">"Yellow"),</st> ]

我们的数据集现在包括 <st c="8743">绿色</st> <st c="8750">红色</st>,和 <st c="8759">黄色</st> 水果 颜色。

接下来,让我们创建我们的图表,但这次,我们不会定义 y-轴:

 Chart(data) {
                BarMark( <st c="8876">x: .value("Qty", $0.qty)</st> )
                .foregroundStyle(by: .value("Color",
                  $0.fruitColor))
            }

在这个代码示例中,我们只传递了 <st c="8999">x</st> <st c="9000">BarMark</st> 参数。 然而,如果我们检查 <st c="9046">BarMark</st> 标题,我们可以看到有一个只需要 <st c="9118">x</st> 参数的方法:

 public init<X>(<st c="9316">init()</st> function in this code example is the method that we are using. Now, let’s see what the chart we create looks like when it’s only one-dimensional (*<st c="9469">Figure 9</st>**<st c="9478">.3</st>*):
			![Figure 9.3: A 1D chart](https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_09_3.jpg)

			<st c="9510">Figure 9.3: A 1D chart</st>
			<st c="9532">In</st> *<st c="9536">Figure 9</st>**<st c="9544">.3</st>*<st c="9546">, our data is presented in a one-dimensional chart presenting three different types</st> <st c="9630">of apples.</st>
			<st c="9640">One thing still bothers us here: notice</st> <st c="9680">that the fruit colors don’t match</st> <st c="9714">the actual colors the Charts framework assigned to each fruit when it created the chart.</st> <st c="9804">That’s because the Charts framework generates the colors while encoding the value.</st> <st c="9887">If we want to match the fruit color to the chart presented color, we need to use the</st> `<st c="9972">chartForegroundStyleScale</st>` <st c="9997">view modifier:</st>

Chart(data) {

            BarMark(

                x: .value("Qty", $0.qty)

            )

            .foregroundStyle(by: .value("Color",

            $0.fruitColor))

        } <st c="10118">.chartForegroundStyleScale("Green" :</st>

Color.green, "Red" : Color.red,

chartForegroundStyleScale函数是一个我们可以应用于 Chart 和不同ShapeStyle协议的视图修改器。在这种情况下,我们使用反映水果颜色的颜色并提高清晰度。

        *<st c="10431">图 9</st>**<st c="10440">.4</st>* <st c="10442">显示了如何将颜色匹配到</st> <st c="10503">名称后图表的外观:</st>

        ![图 9.4:具有自定义颜色的 1D 图表

        <st c="10547">图 9.4:具有自定义颜色的 1D 图表</st>

        <st c="10588">我们可以使用</st> `<st c="10600">chartForegroundStyleScale</st>` <st c="10625">不仅适用于 1D 图表,也适用于所有其他类型的</st> <st c="10678">图表。</st>

        <st c="10688">我们看到了如何</st> <st c="10699">使用 BarMarks</st> <st c="10715">进行堆叠和一维标记。</st> <st c="10755">另一种我们可以使用 BarMarks 的方法是用于区间</st> <st c="10807">条形图。</st>

        <st c="10818">添加区间条形图</st>

        <st c="10845">我们使用</st> **<st c="10853">区间条形图</st>** <st c="10872">来表示按区间分组的数据,例如</st> <st c="10898">时期、年龄组、或</st> <st c="10920">数值范围。</st>

        <st c="10965">例如,假设我们想显示一份工人及其在一天中工作的时间间隔的列表。</st>

        <st c="11080">首先,让我们创建一个表示一系列</st> <st c="11138">工作时段的数据集:</st>
 let emma = "Emma Johnson"
let liam = "Liam Patel"
let sophia = "Sophia Garcia"
let data: [EmployeDayWork] = [
        EmployeDayWork(name:emma, startTime: 10, endTime:
          12),
        EmployeDayWork(name:liam, startTime: 8, endTime:
          11),
        EmployeDayWork(name: sophia, startTime: 10.5,
          endTime: 11.5),
        EmployeDayWork(name: emma, startTime: 14, endTime:
          15),
        EmployeDayWork(name: liam, startTime: 13.5,
          endTime: 14.2),
        EmployeDayWork(name: sophia, startTime: 15,
          endTime: 16)
]
        <st c="11610">数据</st> `<st c="11628">数组中的每个项目</st>` <st c="11632">代表一名员工的工时。</st> <st c="11681">请注意,我们并不关心项目的顺序——Charts 框架负责正确地排序它们。</st> <st c="11795">然而,我们关心与员工姓名的一致性,因此 Charts 框架也可以正确地</st> <st c="11904">分组</st> <st c="11905">这些项目。</st>

        <st c="11914">让我们看看我们如何基于</st> <st c="11941">那个数据集</st> <st c="11965">构建</st> <st c="11969">一个区间图表:</st>
 Chart(data) {
                BarMark( <st c="12006">xStart</st>: .value("Start", $0.startTime), <st c="12046">xEnd</st>: .value("End", $0.endTime), <st c="12080">y</st>: .value("Employee", $0.name)
                )
            }
        <st c="12114">在这个代码示例中,我们创建了一个包含新参数的 BarMark 初始化器——</st>`<st c="12198">xStart</st>`<st c="12205">,它表示区间开始的价值,</st> `<st c="12261">xEnd</st>`<st c="12265">,详细说明它结束的位置,以及</st> `<st c="12296">y</st>`<st c="12297">,员工的姓名。</st>

        <st c="12319">现在,让我们看看当我们运行它时区间图表看起来像什么(</st>*<st c="12379">图 9</st>**<st c="12388">.5</st>*<st c="12390">):</st>

        ![图 9.5:一个区间图表](https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_09_5.jpg)

        <st c="12444">图 9.5:一个区间图表</st>

        <st c="12473">在</st> *<st c="12477">图 9</st>**<st c="12485">.5</st>*<st c="12487">中,我们可以看到一个时间线,其中每个员工都代表一行,他们的工作周期是这个时间线中的</st><st c="12600">区间。</st> <st c="12622">区间条形图是一个复杂的组件的例子,从头开始构建可能很复杂,而 Charts 框架可以简化</st> <st c="12771">这个过程。</st>

        <st c="12783">BarMark 看起来是一个非常灵活的图表类型,这也是它如此常见的原因之一。</st> <st c="12878">它允许我们展示不同类型的信息,无论是比较值还是随时间变化的不同趋势,在堆叠、一维或</st> <st c="13019">区间布局中。</st>

        <st c="13036">然而,有时,选择一个更具体的图表</st> <st c="13077">来更精确地表达数据</st> <st c="13107">可能是一个更好的选择。</st>

        <st c="13143">所以,让我</st><st c="13156">们设置</st> <st c="13164">LineMark 图表。</st>

        <st c="13179">创建 LineMark 图表</st>

        <st c="13204">在表格中展示数据的一个挑战是展示趋势和模式。</st> <st c="13245">尽管 BarMark 图表类型比表格做得更好,但还有更好的方法来展示趋势,尤其是在处理大量</st> <st c="13439">信息时。</st>

        <st c="13454">为了更有效地展示趋势和模式,我们可以使用 LineMark 图表,它使用表示一系列</st> <st c="13590">数据点的线来表示数据。</st>

        <st c="13602">让我们以一个显示随时间变化的手机销售图表为例。</st> <st c="13670">我们创建了一个名为</st> `<st c="13698">SalesFigure</st>` <st c="13709">的结构</st>,其中包含有关产品类型、销售日期和</st> <st c="13790">总金额的信息:</st>
 struct SalesFigure: Identifiable {
    var id: UUID = UUID()
    let product: String
    let day: Date
    let amount: Double
}
        <st c="13915">现在我们有了结构,让我们创建</st> <st c="13958">我们的数据集,就像我们在所有</st> <st c="13990">之前的示例中所做的那样:</st>
 let phoneProduct = "Phone"
let salesFigures: [SalesFigure] = [
        SalesFigure(product: phoneProduct, day:
          Date(timeIntervalSince1970: 1714078800), amount:
            100),
        SalesFigure(product: phoneProduct, day:
          Date(timeIntervalSince1970: 1714165200), amount:
            120),
        SalesFigure(product: phoneProduct, day:
          Date(timeIntervalSince1970: 1714251600), amount:
            90),
        SalesFigure(product: phoneProduct, day:
          Date(timeIntervalSince1970: 1714338000), amount:
            70)
    ]
        <st c="14450">`salesFigures`</st> <st c="14455">变量包含有关四天销售的信息。</st> <st c="14467">LineMark 图表适合处理多个条目,但我们只使用四个进行</st> <st c="14611">演示目的。</st>

        <st c="14634">现在,让我们使用</st> `<st c="14658">salesFigures</st>` <st c="14670">变量通过</st> `<st c="14701">LinkMark</st>` <st c="14709">视图连接到一个图表:</st>
 Chart(salesFigures) { <st c="14738">LineMark(</st>
 <st c="14747">x: .value("time", $0.day),</st>
 <st c="14774">y: .value("amount", $0.amount)</st>
 <st c="14805">)</st> }
        <st c="14809">我们在图表内创建了一个 LineMark,将日期设置为</st> *<st c="14872">x</st>* <st c="14873">轴,数量设置为</st> *<st c="14901">y</st>* <st c="14902">轴。</st> <st c="14909">运行此代码应显示一个类似于</st> *<st c="14966">图 9</st>**<st c="14974">.6</st>*<st c="14976">:</st>

        ![图 9.6:一个 LineMark 图表](https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_09_6.jpg)

        <st c="15010">图 9.6:一个 LineMark 图表</st>

        <st c="15038">图</st> *<st c="15052">9</st>**<st c="15060">.6</st>* <st c="15062">显示了数据集期间手机销售的下降趋势。</st> <st c="15129">关于折线图的好处是,它很容易比较一个 LineMark 与另一个。</st> <st c="15213">我们只需要更新我们的数据集。</st> <st c="15257">因此,让我们也添加平板电脑销售以与</st> <st c="15308">手机销售进行比较:</st>
<st c="15320">let tabletProduct = "Tablet"</st> let salesFigures: [SalesFigure] = [
        SalesFigure(product: phoneProduct, day:
          Date(timeIntervalSince1970: 1714078800), amount:
            100), <st c="15481">SalesFigure(product: tabletProduct, day:</st>
 <st c="15521">Date(timeIntervalSince1970: 1714078800), amount:</st>
 <st c="15570">70),</st> // …
        SalesFigure(product: phoneProduct, day:
          Date(timeIntervalSince1970: 1714338000), amount:
            70), <st c="15675">SalesFigure(product: tabletProduct, day:</st>
 <st c="15715">Date(timeIntervalSince1970: 1714338000), amount:</st>
 <st c="15764">110)</st> ]
        <st c="15771">在这个代码示例中,我们通过向数组中添加平板电脑销售数据项来更新</st> <st c="15804">我们的数据集。</st>

        <st c="15866">为了使图表在两种产品类型之间区分开来,我们使用</st> `<st c="15936">foregroundStyle</st>` <st c="15951">视图修饰符:</st>
 LineMark(
     x: .value("time", $0.day),
     y: .value("amount", $0.amount)
     )<st c="16099">foregroundStyle</st> view modifier applies different styles to different product types. Looking at the code, we can see that the chart can distinguish between these two types.
			<st c="16269">Let’s see what the chart looks like after we have added the tablet sales figures (</st>*<st c="16352">Figure 9</st>**<st c="16361">.7</st>*<st c="16363">):</st>
			![Figure 9.7: LineMark chart with two types of product sales figures](https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_09_7.jpg)

			<st c="16416">Figure 9.7: LineMark chart with two types of product sales figures</st>
			*<st c="16482">Figure 9</st>**<st c="16491">.7</st>* <st c="16493">shows tablet sales compared to phone sales.</st> <st c="16538">We can see that while the phone sales declined, the tablet sales increased.</st> <st c="16614">That’s an insight that is difficult to get just from</st> <st c="16667">the dataset.</st>
			<st c="16679">Thus far, we have created two primary</st> <st c="16717">types of charts: bar and line charts.</st> <st c="16756">These two types are pretty popular, as they are simple to understand and work for many</st> <st c="16843">use cases.</st>
			<st c="16853">Another popular chart type Apple added in iOS 17 is</st> **<st c="16906">SectorMark</st>**<st c="16916">, also known as a</st> <st c="16934">pie chart.</st>
			<st c="16944">Creating a SectorMark chart</st>
			<st c="16972">A SectorMark, or pie, chart</st> <st c="17000">provides a way to visualize the proportions of different values.</st> <st c="17066">Unlike the other charts, the pie chart is based on a circular shape divided into slices, and each slide represents a different</st> <st c="17193">item value.</st>
			<st c="17204">Apparently, the SectorMark chart looks like another form of Stacked Marks we covered earlier (under</st> *<st c="17305">Adding</st>* *<st c="17312">Stacked Marks</st>*<st c="17325">).</st>
			<st c="17328">However, SectorMark charts became more popular than Stacked Marks as they are visually appealing and easier to understand.</st> <st c="17452">Moreover, StackedMark and SectorMark charts are excellent for comparing different parts and seeing their contribution to the whole.</st> <st c="17584">However, stacked marks are practical when we want to compare one whole to another, and SectorMark charts are helpful when we want to focus on</st> <st c="17726">one whole.</st>
			<st c="17736">Like the previous examples, to create a SectorMark chart, we need to prepare a dataset.</st> <st c="17825">So, let’s create a dataset representing a poll result about</st> <st c="17885">consuming fruits:</st>

let data: [最喜欢的水果] = [

    最喜欢的水果(名称:"Apple",价值:30),

    最喜欢的水果(名称:"Banana",价值:25),

    最喜欢的水果(名称:"Orange",价值:20),

    最喜欢的水果(名称:"Strawberries",价值:15),

    最喜欢的水果(名称:"Grapes",价值:10)

]

			<st c="18148">In this example, we created a structure named</st> `<st c="18195">FavoriteFruit</st>`<st c="18208">, which contains the name of the fruit and the number of people who chose</st> <st c="18282">that fruit.</st>
			<st c="18293">To use the data dataset, we will add a</st> `<st c="18333">SectorMark</st>` <st c="18343">view to</st> <st c="18352">our chart:</st>

图表(数据){item in 扇形标记(角度:.value("Value", item.value)) .foregroundStyle(by: .value("Fruit",

                item.name))

    }

			<st c="18480">The</st> `<st c="18485">SectorMark</st>` <st c="18495">structure has an angle parameter that reflects the numeric value of the slice.</st> <st c="18575">We also added the</st> `<st c="18593">foregroundStyle</st>` <st c="18608">view modifier, which colors the slice according to the item’s</st> <st c="18671">fruit property.</st>
			<st c="18686">Let’s look at what the SectorMark chart looks like when running our code (</st>*<st c="18761">Figure 9</st>**<st c="18770">.8</st>*<st c="18772">):</st>
			![Figure 9.8: SectorMark chart](https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_09_8.jpg)

			<st c="18777">Figure 9.8: SectorMark chart</st>
			*<st c="18805">Figure 9</st>**<st c="18814">.8</st>* <st c="18816">shows a beautiful, colorful pie</st> <st c="18848">chart, including the legend titles.</st> <st c="18885">We can even set an inner radius to add a</st> **<st c="18926">donut style</st>** <st c="18937">to</st> <st c="18941">the chart:</st>

图表(数据){item in

扇形标记(角度:.value("Value", item.value),<st c="19020">内半径:50</st>)

.foregroundStyle(by: .value("Fruit", item.name))

}


			<st c="19088">The addition of the inner radius creates a</st> **<st c="19132">hole</st>** <st c="19136">in the pie chart, as we can see in</st> *<st c="19172">Figure 9</st>**<st c="19180">.9</st>*<st c="19182">:</st>
			![Figure 9.9: A SectorMark chart with an inner radius](https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_09_9.jpg)

			<st c="19185">Figure 9.9: A SectorMark chart with an inner radius</st>
			*<st c="19236">Figure 9</st>**<st c="19245">.9</st>* <st c="19247">shows a donut-shaped SectorMark chart.</st> <st c="19287">This shape allows us to provide more information in the center of the chart.</st> <st c="19364">Some even say that this form is more readable to users as it eliminates the need to</st> <st c="19448">compare angles.</st>
			<st c="19463">Until now, we have</st> <st c="19482">created</st> `<st c="19491">BarMark</st>`<st c="19498">,</st> `<st c="19500">LineMark</st>`<st c="19508">, and</st> `<st c="19514">SectorMark</st>` <st c="19524">charts.</st> <st c="19533">The following chart combines two charts we discussed – the LineMark and stacked BarMark charts.</st> <st c="19629">That’s the</st> `<st c="19640">AreaMark</st>` <st c="19648">chart.</st>
			<st c="19655">Creating an AreaMark chart</st>
			<st c="19682">The stacked BarMark chart</st> <st c="19708">we discussed under the</st> *<st c="19732">Adding Stacked Marks</st>* <st c="19752">section shows two important figures – the total value of a category and how that total is divided into sub-categories while observing the different proportions.</st> <st c="19914">The LineMark chart, on the other hand, shows the trend or patterns between different</st> <st c="19999">data points.</st>
			<st c="20011">However, what if we want to combine these two types of marks, showing how a value is composed of different categories</st> <st c="20130">over time?</st>
			<st c="20140">That’s what the AreaMark chart</st> <st c="20172">is for.</st>
			<st c="20179">Let’s take our LineMark sales figures example.</st> <st c="20227">We have a dataset representing phone and tablet sales over time.</st> <st c="20292">Now, we want to see the total sales of these two types of products over time while still observing the different trends of</st> <st c="20415">each product.</st>
			<st c="20428">So, we can create an</st> `<st c="20450">AreaMark</st>` <st c="20458">chart based on the</st> <st c="20478">same dataset:</st>

图表(销售数据){ data in AreaMark(

            x: .value("Date", data.day),

            y: .value("Sales", data.amount)

        )

        .foregroundStyle(by: .value("Product",

        data.product))

    }

			<st c="20651">Our code example is identical</st> <st c="20681">to the LineMark example we discussed under the</st> *<st c="20729">Creating LineMark charts</st>* <st c="20753">section; the only difference is that we are now using AreaMark instead</st> <st c="20825">of LineMark.</st>
			<st c="20837">However, the result is different (</st>*<st c="20872">Figure 9</st>**<st c="20881">.10</st>*<st c="20884">):</st>
			![Figure 9.10: An AreaMark chart for total sales](https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_09_10.jpg)

			<st c="20889">Figure 9.10: An AreaMark chart for total sales</st>
			<st c="20935">At first glance,</st> *<st c="20953">Figure 9</st>**<st c="20961">.10</st>* <st c="20964">shows similar information as</st> *<st c="20994">Figure 9</st>**<st c="21002">.7</st>*<st c="21004">—trends of product sales figures.</st> <st c="21039">However, there are differences.</st> <st c="21071">The filled area in</st> *<st c="21090">Figure 9</st>**<st c="21098">.10</st>* <st c="21101">represents the</st> **<st c="21117">total sales</st>** <st c="21128">of products for both phones and tablets, and each color represents a different product type.</st> <st c="21222">On the other hand,</st> *<st c="21241">Figure 9</st>**<st c="21249">.7</st>* <st c="21251">only shows a comparison between these two product types, side</st> <st c="21314">by side.</st>
			<st c="21322">The AreaMark chart</st> <st c="21341">is great for market share analysis, financial data visualization, and general information, including data trends and</st> <st c="21459">cumulative totals.</st>
			<st c="21477">However, charts can give us much more than data comparison and trends.</st> <st c="21549">Let’s meet our final chart, PointMark, which can provide a different level</st> <st c="21624">of insight.</st>
			<st c="21635">Creating a PointMark chart</st>
			<st c="21662">Until now, we have discussed</st> <st c="21691">marks that have helped us compare sales figures or observe trends.</st> <st c="21759">What about areas such as correlation analysis or predictive modeling?</st> <st c="21829">To fulfill that need, the PointMark chart, also known as the</st> **<st c="21890">scatterplot chart</st>**<st c="21907">, aims to show the relatio</st><st c="21933">nships</st> <st c="21940">between</st> <st c="21949">two variables.</st>
			<st c="21963">Let’s find the correlation</st> <st c="21990">between students’ study hours and grades.</st> <st c="22033">First, we create a dataset representing</st> <st c="22073">the data:</st>

struct StudentData: Identifiable {

var id: UUID = UUID()

var hoursStudied: Double

var examScore: Double

}

let studentDataSet: [StudentData] =

学生数据(学习时长:1.7 小时,考试成绩:61.8 分),

学生数据(学习时长:7.9 小时,考试成绩:78.6 分),

学生数据(学习时长:4.1 小时,考试成绩:44.3 分),

学生数据(学习时长:4.7 小时,考试成绩:63.4 分),

学生数据(学习时长:7.8 小时,考试成绩:90.4 分),

学生数据(学习时长:8.6 小时,考试成绩:83.2 分),

学生数据(学习时长:2.8 小时,考试成绩:29.7 分),

学生数据(学习时长:6.3 小时,考试成绩:72.9 分),

学生数据(学习时长:6.4 小时,考试成绩:73.8 分),

`)`

<st c="28753">chartOverlay</st> view modifier in this code example.


			<st c="22717">This code example has a</st> `<st c="22742">StudentData</st>` <st c="22753">structure containing information about student study time and grades.</st> `<st c="22824">studentsDataSet</st>` <st c="22839">is an array that contains information about</st> <st c="22884">ten students.</st>
			<st c="22897">Now, let’s create a</st> `<st c="22918">PointMark</st>` <st c="22927">chart based on</st> <st c="22943">that array:</st>

<st c="29988">图 9.14:图表和 chartOverlay 结构</st>

<st c="23025">y: .value("score", $0.examScore))</st> }


			<st c="23061">Like previous charts, the</st> `<st c="23087">PointMark</st>` <st c="23096">structure</st> <st c="23106">has</st> `<st c="23111">x</st>` <st c="23112">and</st> `<st c="23117">y</st>` <st c="23118">parameters.</st> <st c="23131">The</st> `<st c="23135">x</st>` <st c="23136">parameter represents the hours studied, and the</st> `<st c="23185">y</st>` <st c="23186">parameter represents</st> <st c="23208">the score.</st>
			*<st c="23218">Figure 9</st>**<st c="23227">.11</st>* <st c="23230">shows what the</st> `<st c="23246">PointMark</st>` <st c="23255">chart looks like when running</st> <st c="23286">the code:</st>

			![Figure 9.11: PointMark chart			<st c="23300">Figure 9.11: PointMark chart</st>			*<st c="23328">Figure 9</st>**<st c="23337">.11</st>* <st c="23340">shows that most students achieve high grades when studying more hours.</st> <st c="23412">We can also identify one student who managed to achieve a mid-level grade almost without studying</st> <st c="23510">at all!</st>			<st c="23517">Even though PointMark is less common than the previous charts we reviewed, it can be helpful in financial, CRM, or</st> <st c="23633">education apps.</st>			<st c="23648">Speaking of education apps, many apps require other types of charts.</st> <st c="23718">That includes charts that are based on functions</st> <st c="23766">and not datasets.</st> <st c="23785">With Charts, we can also work more dynamically and straightforwardly visualize functions.</st> <st c="23875">Let’s see how to</st> <st c="23892">do that.</st>			<st c="23900">Visualizing functions with Charts</st>			<st c="23934">Until now, we have discussed</st> <st c="23963">how to build charts using datasets, which include raw data</st> <st c="24022">information such as sales figures, market shares, or usage trends.</st> <st c="24090">However, we don’t have to use datasets to create charts, as functions can also perform as a data source for</st> <st c="24198">our charts.</st>			<st c="24209">For example, we may want to display a normal distribution line graph next to our BarMark chart.</st> <st c="24306">We could also create an education app that displays mathematical functions such as circles or a</st> <st c="24402">sinus function.</st>			<st c="24417">To do that, we need to use a different</st> <st c="24456">type of chart</st> <st c="24471">called</st> **<st c="24478">plot</st>**<st c="24482">.</st>			<st c="24483">The Charts framework has two types of plots –</st> `<st c="24530">LinePlot</st>` <st c="24538">and</st> `<st c="24543">AreaPlot</st>`<st c="24551">. Let’s see an example of</st> `<st c="24577">LinePlot</st>` <st c="24585">sh</st><st c="24588">owing a graph for a</st> <st c="24609">sinus function:</st>````}`        `return sin(x)`    `Chart { <st c="25162">AreaPlot</st>(x:"x", y:"y") { x in``Chart(studentDataSet) { <st c="22979">PointMark(x: .value("hours", $0.hoursStudied),````swift			<st c="24681">In this (very!) short code example, we added a</st> `<st c="24729">LinePlot</st>` <st c="24737">chart with a closure that returns the</st> `<st c="24776">y</st>` <st c="24777">value of a given</st> `<st c="24795">x</st>` <st c="24796">value.</st> <st c="24804">In this case, we used a simple</st> `<st c="24835">sin</st>` <st c="24838">function.</st> *<st c="24849">Figure 9</st>**<st c="24857">.12</st>* <st c="24860">shows what the chart</st> <st c="24882">looks like:</st>			![Figure 9.12: A LinePlot chart](https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_09_12.jpg)

			<st c="24895">Figure 9.12: A LinePlot chart</st>
			<st c="24924">In</st> *<st c="24928">Figure 9</st>**<st c="24936">.12</st>*<st c="24939">, we can see the</st> `<st c="24956">LinePlot</st>` <st c="24964">chart generated from a simple</st> <st c="24995">mathematical function.</st>
			<st c="25017">As mentioned earlier</st> <st c="25038">in this section, the second chart type we can use to visualize</st> <st c="25101">functions is</st> `<st c="25115">AreaPlot</st>`<st c="25123">, the equivalent</st> <st c="25140">of</st> `<st c="25143">AreaMark</st>`<st c="25151">:</st>

y: .value("amount", $0.amount)

    `return sin(x)`

`.foregroundStyle(by: .value("Product",`

}


			<st c="25210">In this code example, we only changed the chart type from</st> `<st c="25269">LinePlot</st>` <st c="25277">to</st> `<st c="25281">AreaPlot</st>`<st c="25289">.</st> `<st c="25291">AreaPlot</st>` <st c="25299">visualizes the function by filling the area it defines.</st> <st c="25356">Let’s see the output in</st> *<st c="25380">Figure 9</st>**<st c="25388">.13</st>*<st c="25391">:</st>
			![Figure 9.13: The AreaPlot chart type](https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_09_13.jpg)

			<st c="25394">Figure 9.13: The AreaPlot chart type</st>
			*<st c="25430">Figure 9</st>**<st c="25439">.13</st>* <st c="25442">shows the same sinus function graph, now filled</st> <st c="25491">with color.</st>
			<st c="25502">Using the LinePlot and AreaPlot chart types to visualize math functions is about much more than just showing how the sinus function behaves.</st> <st c="25644">It is excellent for education, scientific research, finance, health, and business apps.</st> <st c="25732">Now that we know how to create LinePlot and AreaPlot, we have whole</st> <st c="25800">new options.</st>
			<st c="25812">We went over many chart</st> <st c="25836">types, and by now, we can quickly</st> <st c="25870">create charts, just like creating a</st> <st c="25907">simple list!</st>
			<st c="25919">The</st> **<st c="25924">List</st>** <st c="25928">type provides a way to interact</st> <st c="25960">with its items, allowing us to navigate or delve into more information.</st> <st c="26033">So, let’s see how to make our</st> <st c="26063">charts interactive!</st>
			<st c="26082">Allowing interaction using ChartProxy</st>
			<st c="26120">Now that we know how to create</st> <st c="26151">charts, let’s discover more hidden tricks by adding user interaction capabilities.</st> <st c="26235">User interaction in charts, with its many uses, allows users to explore the chart’s data using touch.</st> <st c="26337">Here are some use cases for user interaction</st> <st c="26382">with charts:</st>

				*   `<st c="26446">BarMark</st>` <st c="26453">or</st> `<st c="26457">SectorMark</st>` <st c="26467">charts, the user can navigate to a new screen that shows additional information about the particular data point.</st> <st c="26581">For example, if the</st> `<st c="26601">BarMark</st>` <st c="26608">chart shows information about watermelon sales, we can navigate the user to a screen that details the watermelon</st> <st c="26722">sales deals.</st>
				*   `<st c="26794">LineMark</st>` <st c="26802">charts, for example, provides insights to the user on data points not originally part of the dataset if our</st> `<st c="26911">LinkMark</st>` <st c="26919">chart includes information about the growing population in a specific city over time, touching a particular point the chart can display the population value on a</st> <st c="27082">specific date.</st>
				*   **<st c="27096">Comparing data marks</st>**<st c="27117">: The user can highlight and compare multiple data marks, which is extremely useful in</st> <st c="27205">BarMark-based charts.</st>

			<st c="27226">Moreover, learning how to add interaction capabilities can help us explore more things with our charts, such as how the charts are built and how their calculation</st> <st c="27390">logic works.</st>
			<st c="27402">To understand how interaction works, we need to get to know more Swift Charts</st> <st c="27481">framework components:</st>

				*   `<st c="27502">chartOverlay</st>`<st c="27515">: This is a view modifier that helps us add an overlay view to a chart.</st> <st c="27588">We can use the</st> `<st c="27603">chartOverlay</st>` <st c="27615">view modifier to add more graphic details to our chart, such as rulers and texts.</st> <st c="27698">We can also use the</st> `<st c="27718">chartOverlay</st>` <st c="27730">view modifier to observe gestures and</st> <st c="27769">user interaction.</st>
				*   `<st c="27786">ChartProxy</st>`<st c="27797">: This is a proxy that lets us access the chart values based on the chart area.</st> <st c="27878">Using</st> `<st c="27884">ChartProxy</st>`<st c="27894">, we can convert locations to values and</st> <st c="27935">vice versa.</st>

			`<st c="27946">ChartOverlay</st>` <st c="27959">and</st> `<st c="27964">ChartProxy</st>` <st c="27974">are essential components when handling user interaction; therefore, they come hand in hand.</st> <st c="28067">When adding a</st> `<st c="28081">chartOverlay</st>` <st c="28093">view modifier, it comes with a prox</st><st c="28129">y</st> <st c="28131">to have complete access to</st> <st c="28159">the chart.</st>
			<st c="28169">Let’s try to take a LineMark chart and add a horizontal ruler that allows users to drag their fingers across it.</st> <st c="28283">We’ll start by adding</st> <st c="28305">an overlay.</st>
			<st c="28316">Adding an overlay to our chart</st>
			<st c="28347">The solution for providing</st> <st c="28374">an overlay to our chart consists</st> <st c="28407">of a common practice in SwiftUI using a view modifier.</st> <st c="28463">Look at the following</st> <st c="28485">code example:</st>

Chart(salesFigures){}

        `<st c="29629">我们将根据用户的</st>` `<st c="29680">点击位置</st>` 

            `x: .value("time", $0.day),`

            `]`

        `}`

        `<st c="28801">我们可以看到</st>` `<st c="28818">chartOverlay</st>` `<st c="28830">附带了一个</st>` `<st c="28844">代理</st>` `<st c="28849">变量,它就是之前讨论过的</st>` `<st c="28873">ChartProxy</st>` `<st c="28883">组件。</st>`

        `$0.product))`

    `<st c="28644">.chartOverlay { proxy in`

StudentData(hoursStudied: 6.1, examScore: 77.6)

        `LineMark(`

        `<st c="28915">ChartOverlay</st>` `<st c="28928">不是一个视图,而是一个视图修饰符,它允许我们在图表中添加新的视图。</st>` `<st c="29004">因此,为了识别手势并添加一个仪表,我们可以添加一个带有拖动手势的透明视图,并添加一个</st>` `<st c="29107">仪表视图:</st>`
 .chartOverlay { proxy in
            ZStack(alignment: .topLeading) {
                    Rectangle().fill(.clear)
                        .contentShape(Rectangle())
                        .gesture(
                            DragGesture()
                                .onChanged { value in
                            }
                        )
                    let lineHeight = proxy.plotSize.height
                    Rectangle()
                        .fill(.red)
                        .frame(width: 2, height:
                          lineHeight)
                        .position(x: markerX, y:
                          lineHeight/2)
            }
        }
        `Chart { <st c="24633">LinePlot</st>(x:"x", y:"y") { x in`
 @State var markerX: CGFloat = 50
        `![图 9.14:图表和 chartOverlay 结构](https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_09_14.jpg)`

        `<st c="29420">在这个代码示例中,我们添加了一个</st>` `<st c="29454">ZStack</st>` `<st c="29460">视图,它有一个清晰的矩形覆盖整个图表,在其上方是一个红色的仪表视图。</st>` `<st c="29554">仪表视图</st>` `<st c="29569">x</st>` `<st c="29570">轴是一个</st>` `<st c="29581">状态变量:</st>`

        `<st c="29924">要查看我们的视图结构,请看</st>` `<st c="29960">图</st>**<st c="29968">9</st>**<st c="29971">.14</st>` `<st c="29971">:</st>`

        `<st c="30310">响应用户的手势</st>`

        `}`

        `<st c="30034">图</st>**<st c="30043">9</st>**<st c="30046">.14</st>` `<st c="30046">显示了我们的图表视图和通过</st>` `<st c="30105">chartOverlay</st>` `<st c="30117">视图修饰符添加的矩形。</st>` `<st c="30133">我们还可以看到它们是通过</st>` `<st c="30183">代理对象连接的。</st>`

        `<st c="30196">此外,我们还添加了</st>` `<st c="30211">一个拖动手势</st>` `<st c="30226">到矩形上。</st>` `<st c="30245">让我们看看如何使用它来相应地改变我们的仪表</st>` `<st c="30289">位置。</st>`

        `<st c="29694">注意,我们使用了我们的</st>` `<st c="29719">代理</st>` `<st c="29724">对象来确定</st>` `<st c="29744">仪表视图的图表大小。</st>` `<st c="29780">这是一个关键的代理</st>` `<st c="29801">使用,因为我们还需要在其他场合使用它,例如在特定位置显示不同视图的计算。</st>`

        `<st c="30343">为了响应用户的手势</st>` `<st c="30376">并将水平仪表移动到最接近的数据点,我们需要实现</st>` `<st c="30459">onChanged</st>` `<st c="30468">闭包:</st>`
 .onChanged { value in <st c="30499">mark</st><st c="30503">erX = value.location.x</st>
 <st c="30526">if let closestDate = getClosestDateForLocation(x:</st>
 <st c="30576">value.location.x, proxy: proxy) {</st>
 <st c="30610">if let positionX = proxy.position(forX:</st>
 <st c="30650">closestDate) {</st>
 <st c="30665">markerX = positionX</st> }
}
        <st c="30689">The</st> `<st c="30694">onChanged</st>` <st c="30703">closure implementation does</st> <st c="30732">three things:</st>

            +   <st c="30745">首先,它</st> *<st c="30756">根据点击位置和代理找到最近的销售额数据点</st>* <st c="30790">。 <st c="30836">我们将在一分钟内介绍</st> `<st c="30856">getClosestDateForLocation</st>` <st c="30881">函数。</st>

            +   <st c="30903">在我们根据点击位置找到最近的销售数据点后,我们使用代理对象来</st> *<st c="31007">检索它在图表上的位置</st>* <st c="31028">。 <st c="31043">代理的一个功能是将数据点转换为位置,反之亦然。</st>

            +   <st c="31128">当我们获得最近的数据点位置时,我们通过设置</st> `<st c="31226">markerX</st>` <st c="31233">状态变量</st>来调整尺子位置。

        <st c="31249">这段代码是使用代理对象可以做什么的一个很好的</st> <st c="31278">示例。</st>

        <st c="31333">有关代理对象的使用,让我们看看</st> `<st c="31377">getClosestDateForLocation</st>` <st c="31402">函数。</st>

        <st c="31412">找到用户触摸点最近的数据点</st>

        <st c="31463">`<st c="31468">getClosestDateForLocation</st>` <st c="31493">`函数的目标是</st> <st c="31507">根据一个特定的位置找到最近的数据点。</st> <st c="31569">`根据一个特定的位置找到最近的数据点。</st>

        <st c="31587">该函数接收两个参数 - 位置(</st>`<st c="31641">CGFloat</st>`<st c="31649">)和</st> <st c="31660">代理对象:</st>
 func getClosestDateForLocation(x: CGFloat, proxy: ChartProxy) -> Date? {
        var returnedSalesFigure: SalesFigure? if let date = proxy.value(atX: x) as Date? {
            var mDistance: TimeInterval = .infinity
            for salesFigure in salesFigures {
                let distance =
                  abs(salesFigure.day.distance(to: date))
                if distance < mDistance {
                    returnedSalesFigure = salesFigure
                    mDistance = distance
                }
            }
        }
        return returnedSalesFigure?.day
    }
        <st c="32079">记住我们的图表看起来像什么 -</st> *<st c="32121">y</st>* <st c="32122">轴代表时间线,而</st> *<st c="32161">x</st>* <st c="32162">轴代表特定日期的总销售额。</st>

        <st c="32214">因此,我们可以使用代理对象来找到特定</st> `<st c="32279">x</st>` <st c="32280">值的日期,这是我们</st> <st c="32303">的第一步:</st>
 if let date = proxy.value(atX: x) as Date? {
        <st c="32359">代理的</st> `<st c="32371">value(atX:)</st>` <st c="32382">函数计算特定</st> `<st c="32433">x</st>` <st c="32434">值的日期值。</st>

        <st c="32441">然而,返回的值是任意的;为了定位最近的数据点,我们必须遍历我们的数据集并搜索最近的</st> `<st c="32581">SalesFigure</st>` <st c="32592">对象。</st> <st c="32601">一旦确定,函数就可以返回它。</st>

        <st c="32650">尽管允许用户与图表交互并不复杂,但它包括一些有趣的视图修改器和对象,使我们能够访问图表数据,执行计算,并显示叠加 UI 组件。</st> <st c="32866">我们不必仅仅为了交互而使用</st> `<st c="32891">代理</st>` <st c="32896">对象和</st> `<st c="32912">chartOverlay</st>` <st c="32924">视图修改器——我们可以显示更多信息,改进图表设计,在罕见的情况下,甚至可以</st> <st c="33054">创建自己的图表。</st>

        <st c="33064">到目前为止,我们使用的是具有基础类型的数据集 –</st> `<st c="33118">String</st>`<st c="33124">,</st> `<st c="33126">Double</st>`<st c="33132">, 和</st> `<st c="33138">Date</st>`<st c="33142">。然而,当我们查看 Swift Charts 框架的头文件时,我们发现</st> <st c="33212">一些有趣的东西:</st>
 func position<P>(forX value: P) -> CGFloat? where P : <st c="33289">Plottable</st> public struct LineMark {
     init<X, Y>(x: <st c="33338">PlottableValue</st><X>, y: PlottableValue<Y>)
     where X : <st c="33389">Plottable</st>, Y : <st c="33404">Plottable</st> }
        <st c="33415">似乎不同的图表函数</st> <st c="33458">只与符合</st> `<st c="33500">Plottable</st>` <st c="33509">协议的类型一起工作。</st> <st c="33520">让我们来看看</st> <st c="33540">那是什么。</st>

        <st c="33548">符合 Plottable 协议</st>

        <st c="33585">到目前为止,我们一直假设</st> <st c="33630">我们抛到图表上的任何数据集都会工作。</st> <st c="33684">然而,我们发现代理对象可以执行一些有趣的计算,而这些计算用任何数据都是不可能完成的,这也是我们数据类型需要支持在</st> <st c="33885">图表中绘制能力的原因之一。</st>

        <st c="33893">因此,Swift Charts 框架只与符合</st> `<st c="33979">Plottable</st>` <st c="33988">协议的数据类型一起工作,这允许数据在</st> <st c="34032">图表中绘制。</st>

        <st c="34040">首先,每个原始数据类型已经符合</st> `<st c="34098">Plottable</st>` <st c="34107">协议。</st> <st c="34118">此外,我们在上一个示例中使用的</st> `<st c="34128">Date</st>` <st c="34132">类也符合</st> `<st c="34188">Plottable</st>` <st c="34197">协议。</st> <st c="34208">我们甚至可以在苹果</st> <st c="34242">头文件中看到这一点:</st>
 extension Date : Plottable, PrimitivePlottableProtocol
extension String : Plottable, PrimitivePlottableProtocol
        <st c="34367">然而,仅与原始类型或 Foundation 类型一起工作并不</st> <st c="34432">总是实用。</st>

        <st c="34449">以,例如,我们的</st> `<st c="34479">Sales</st>` <st c="34484">结构体</st> 从 *<st c="34504">Adding Stacked</st>* *<st c="34519">Marks</st>* <st c="34524">部分:</st>
 struct Sales: Identifiable {
    var id: UUID = UUID()
    let itemType: String
    let qty: Int <st c="34619">var fruitColor: String = ""</st> }
        <st c="34648">将</st> `<st c="34662">itemType</st>` <st c="34670">属性声明为字符串</st> <st c="34691">并不总是最佳实践。</st> <st c="34723">通常,类型是封闭列表的一部分,使用字符串可能会导致拼写错误和重复。</st> <st c="34819">我们可能更愿意使用枚举,因为它</st> <st c="34872">更适合处理类型列表:</st>
 enum FruitType {
    case Apples
    case Oranges
    case Watermelons
}
struct Sales: Identifiable {
    var id: UUID = UUID() <st c="35032">let itemType: FruitType</st> let qty: Int
    var fruitColor: String = ""
}
        <st c="35098">在这个例子中,我们创建了一个</st> `<st c="35129">FruitType</st>` <st c="35138">枚举来替换来自 `<st c="35178">String</st>` 的</st> `<st c="35159">itemType</st>` <st c="35167">类型。</st>

        <st c="35185">我们的下一步是使</st> `<st c="35215">FruitType</st>` <st c="35224">枚举符合</st> `<st c="35241">Plottable</st>`<st c="35250">:</st>
 extension FruitType: <st c="35274">Plottable</st> {
    var <st c="35290">primitivePlottable</st>: String {
        rawValue
    }
}
        <st c="35332">在这个例子中,我们使用了</st> `<st c="35362">primitivePlottable</st>` <st c="35380">变量获取器来返回类型的原始值。</st> <st c="35435">这将使</st> `<st c="35455">FruitType</st>` <st c="35464">类型有资格在 Charts 中使用。</st>

        <st c="35504">尽管不是每种类型都可以在图表中使用,但我们可以轻松地使它们有资格。</st> <st c="35543">遵守</st> `<st c="35621">Plottable</st>` <st c="35630">协议既简单又直接,并允许我们在图表中使用自定义类型。</st> <st c="35713">这样,我们就可以在我们的图表中使用几乎任何我们想要的数据类型。</st>

        <st c="35727">总结</st>

        <st c="35735">Swift Charts 框架非常令人兴奋。</st> <st c="35776">它允许我们使用简单的数据集创建令人惊叹的图表,这使得展示数据洞察、趋势和比较变得容易得多。</st>

        <st c="35919">本章回顾了 Swift Charts 框架的不同图表类型,包括 BarMark、LineMark、SectorMark、AreaMark 和 PointMark。</st>

        <st c="36064">我们还讨论了每个图表的不同用途和目标,学习了如何自定义它们,并添加了用户交互以增加更多功能。</st> <st c="36207">最后,我们回顾了</st> `<st c="36233">Plottable</st>` <st c="36242">协议,它允许我们的图表使用几乎任何我们想要的数据类型。</st> <st c="36314">到目前为止,我们应该能够快速在我们的</st> <st c="36367">应用中实现图表。</st>

        <st c="36380">我们的下一章包括一个高级但非常强大的主题——</st> <st c="36445">Swift 宏。</st>


第二部分:使用高级技术完善你的 iOS 开发

在本部分,你将提升你的 iOS 开发技能,并探索高级主题,如 Swift 宏、测试、Combine、架构、机器学习ML)和 AI。 如果你想要充分利用 iOS 18,这部分内容是必读的。

本节包含以下章节:

  • 第十章Swift 宏

  • 第十一章使用 Combine 创建管道

  • 第十二章利用 Apple 智能和 ML 变得聪明

  • 第十三章, 通过应用意图将您的应用暴露给 Siri

  • 第十四章, 使用 Swift 测试提高应用质量

  • 第十五章, 探索 iOS 架构

第十一章:10

Swift 宏

开发者经常在他们的 IDE 中遇到各种挑战,通常与缺失的功能有关,大多数是关于缺失的功能。 随着每个新的 Xcode 或 Swift 版本的发布,Apple 都会引入增强生产力和简化任务的新功能。 然而,即使是 Apple 也很难满足我们的需求和期望。 幸运的是,这次,我们可以使用 Swift 宏 来创建自定义功能。

Swift 宏 是 Xcode 15 和 iOS 17 中新增的一个令人兴奋的新功能,本章将帮助我们通过利用我们的 IDE 来提高我们的生产力。

在本章中,我们将涵盖以下主题: 以下主题:

  • 了解 Swift 宏

  • 探索 <st c="694">SwiftSyntax</st> 库,它是 Swift 宏 背后的

  • 创建我们的第一个 Swift 宏

  • 处理错误并在出错时提供更多清晰度

  • 测试我们的宏,确保它按预期运行 随时间推移

但现在,让我们从基础知识开始,探索 Swift 宏。

技术要求

您必须从 Apple 的 App Store 下载本章节所需的 Xcode 16.0 或更高版本。

您还需要运行最新版本的 macOS (Ventura 或更高版本)。 在 App Store 中搜索 Xcode,选择并下载最新版本。 启动 Xcode 并遵循系统可能提示的任何附加安装说明。 一旦 Xcode 完全启动,您就准备好了 开始。

什么是 Swift 宏?

您可能之前在编程的上下文中听说过“宏”这个术语。 这可能是因为像 C/C++ 这样的编程语言也有宏

一个 是一个结构,它允许我们定义一个代码模式,编译器会用一组特定的指令来替换它。

让我们看看一个简短的 C 示例:

 #define SQUARE(x) ((x) * (x))
int num = 5;
int result = SQUARE(num);

在我们的前一个代码中,我们声明了一个名为 <st c="1845">SQUARE</st> 的宏,它接收一个名为 <st c="1886">X</st>的参数,并且我们的编译器将其替换为 <st c="1923">(</st>``<st c="1924">x) *(x)</st>

首先出现在脑海中的问题是这个:为什么? 我们不能只定义一个函数吗?

因此,在这种情况下,一个简单的计算数字平方的函数可能会有所帮助。

但宏的主要目标不是 替换函数,因为它们有以下几个优点:

  • 代码重用:请注意,代码重用不是“功能重用”。代码重用是指我们取一个实际的代码片段,并在不同的地方重用它。 例如,如果我们经常在声明类时重复相同的行序列,宏可以帮助我们避免 重复自己。

  • 提高抽象:宏可以帮助我们在代码中添加另一个抽象层。 想象一下编写一个生成函数声明的宏。 这是我们可以构建的另一个代码构建 层次。

  • 性能:在某些情况下,宏可以帮助我们优化代码。 有时,优化与可读性/简单性之间的权衡可以通过宏来解决。 宏可以生成更难阅读的代码,但仍然可以进行优化。 宏可以优化的一个特性是循环展开 ——一种通过指令级并行性更快地迭代循环的方法。 循环展开 生成的代码可读性较低,但速度要快得多。

最后,宏 只是一个工具,它用另一段代码替换一段代码,并在编译前插入特定的代码片段。 但 C 宏 存在许多问题。 它们难以测试,没有类型安全,错误不够清晰,并且与其他开发者共享它们并不简单。 作为 Xcode 15 的一部分,Swift 团队发布了一个名为 Swift 宏 的新工具——这是 Swift 版本的宏,它允许我们更高效、更优雅地创建宏。

让我们来看一个简单的 宏使用 例子。

在我们的项目中,我们想要添加一个宏,该宏将一个名为 <st c="3706">log(issue:String)</st> 的函数添加到类和结构体中。 该函数将问题打印到我们的日志中,并添加类或结构体的名称。 我们可以将这个宏命名为 <st c="3852">@AddDebugLogger</st>,并且我们可以这样使用它:

<st c="3898">@AddDebugerLogger</st> class MyClass {
}

在上面的代码中,我们声明了一个名为 <st c="3984">MyClass</st> 的类,并附加了一个名为 <st c="4019">@AddDebugerLogger</st>的宏,它展开为以下代码:

 class MyClass {
    func printLog(issue: String) {
        #if DEBUG
        print("In class named MyClass - \(issue)")
        #endif
    }
}

宏添加了一个名为 <st c="4218">printLog()</st>的函数,该函数将问题打印到控制台,并在日志消息中提及类名。 这作为主要宏使用的一个示例,展示了该工具的能力。

但是宏是如何了解类名的呢? 它是如何在类内部正确位置生成一个新函数的? 为了回答这些问题,我们首先需要了解 <st c="4594">SwiftSyntax</st>,这是一个 位于 Swift 宏核心的 库。

探索 SwiftSyntax

<st c="4680">SwiftSyntax</st> 不是一个 新库,它从 Swift 的早期版本开始就是 Swift 代码库的一部分。 实际上,Swift 宏是 <st c="4810">SwiftSyntax</st>的一部分,并使用 其功能。

在我们深入 <st c="4873">SwiftSyntax</st> (而且有很多东西可以深入)之前,让我们了解一下 Swift 编译器是如何工作的(图 10**.1):

Figure 10.1: The Swift compiler process

图 10.1:Swift 编译器过程

不要害怕你在 图 10**.1中看到的不同的表达式。这个图展示了编译器如何将我们的源代码转换成设备可以运行的机器代码(即 <st c="5219">*.o</st> 文件)的概览。我们不需要理解这个流程中的每一个步骤,但了解它是如何工作的至关重要,尤其是在 <st c="5340">SwiftSyntax</st> 在过程中的位置。

让我们一起来回顾一下 这些步骤:

  1. 解析和抽象语法树(AST):编译器将我们的源代码转换成一个 AST。 AST 代表我们的代码的层次结构,包括类、结构体、变量和表达式。

  2. 语义分析(sema):在这个 阶段,编译器对我们的生成的 AST 执行语义分析。 分析检查我们的代码中的语义问题,并处理类型检查、名称解析等问题(当我们构建阶段看到“语义”问题时;这是该阶段的结果)。

  3. Swift 中间语言生成(SILGen):在这个阶段,编译器 生成一个表示,它捕捉了代码的语义结构。

  4. 中间表示生成 (IRGen):在 IRGen 中,编译器将 SILGen 的结果 转换为接近机器级代码的二进制代码。 这个过程是在 低级虚拟机 (LLVM) 的帮助下完成的,代码会经过 几个优化。

  5. LLVM 链接:LLVM 将所有内容链接在一起,并为最终 二进制创建做好准备。

这个过程可能看起来令人恐惧且复杂,但请记住,这对我们作为 iOS 开发者来说是一项重要的丰富,并且对于理解 Swift 宏不是必需的。 我演示它是因为前两个步骤 – 解析和 AST。 让我们再谈一谈。

解析和 AST

解析 Swift 代码 不是一项容易的任务。 此外,构建 AST 甚至 更加复杂。

在构建过程中,我们只看到了解析,AST 由 <st c="6919">SwiftSyntax</st> 处理。 因此,当我们与 <st c="6966">SwiftSyntax</st> 一起工作时,我们拥有完整的编译器功能。 这意味着我们可以解析代码,分析它,甚至像编译器一样生成新的代码。 SwiftSyntax 库是处理 Swift 宏时的强大且必要的工具,因为当我们想到它时,它就是 Swift 宏的全部 – 理解给定的代码并生成一个 新的代码。

我们理解学习 SwiftSyntax 是编写 Swift 宏的先决条件,所以让我们 深入探讨。

设置 SwiftSyntax

<st c="7451">SwiftSyntax</st> 是一个 Swift 包,这意味着 它可以轻松地链接到现有的 iOS 或 macOS 项目。

什么是 Swift 包?

Swift 包 是 Swift 代码分发的单元。 它是一种组织、共享和管理跨 不同项目 的 Swift 代码的方式。

为了玩转和学习 SwiftSyntax ,我们将创建一个新的项目,并将 <st c="7788">SwiftSyntax</st> 作为一个 Swift 包添加到该项目中,包括一个 playground。 为此,请按照 以下步骤 进行操作:

  1. 让我们从打开 Xcode 并添加一个 新项目 开始。

  2. 然后,我们将通过选择 <st c="7967">SwiftSyntax</st> Swift 包来添加我们的 文件 | **添加 包依赖…

    现在,我们处于 Xcode 的添加依赖项窗口中(图 10**.2):

图 10.2:添加依赖项的 Xcode 窗口

图 10.2:添加依赖项的 Xcode 窗口

依赖项窗口?

如果你第一次看到那个窗口,那么这是一个进行简要介绍的好机会。 当 Swift 包管理器刚开始时,其管理完全是手动的,使用 终端。

多年来,Swift 包管理器 已成为 Xcode 的一个重要组成部分,现在,甚至可以直接在 Xcode 中管理集合和搜索包。 :

你可以在 https://www.swift.org/documentation/package-manager/了解更多。

  1. 回到 Xcode – 在添加依赖项窗口的右上角,我们可以填写 <st c="9674">SwiftSyntax</st> GitHub 仓库:

     https://github.com/apple/swift-syntax
    
  2. 我们将从左侧列中选择 <st c="9762">swift-syntax</st> 包,并点击 **添加 ** **按钮。

  3. 现在,Xcode 将解析 Swift 包并展示其库,以便我们可以选择想要导入到项目中的内容(图 10**.3):

图 10.3:选择 SwiftSyntax 包产品

图 10.3:选择 SwiftSyntax 包产品

我们将选择 <st c="10465">SwiftSyntax</st> 库,并点击 <st c="10542">SwiftSyntax</st> 将其 添加到我们的项目中!

现在,让我们添加一个 playground 文件(我们喜欢的任何地方)并探索 <st c="10639">SwiftSyntax</st> 是什么。

构建我们的抽象语法树

为了尝试使用 <st c="10740">SwiftSyntax</st> 库分析一段 Swift 代码,我们需要生成一些 Swift 代码并对其进行处理。

我们打开上一节中创建的 playground 文件,并添加以下代码:

 import SwiftSyntax
import SwiftSyntaxParser <st c="10949">let</st> <st c="10952">sourceCode</st> = """
func hello() {
    print("Hello World")
}
"""

我们的代码从导入两个重要的库开始 – <st c="11069">SwiftSyntax</st> <st c="11085">SwiftSyntaxParser</st> <st c="11108">SwiftSyntaxParser</st> 库包含 <st c="11147">SwiftParser</st> 类,它有助于将源代码转换为我们可以遍历和分析的树。

我们添加了一个名为 sourceCode 的字符串 常量,其中包含一个简单的 “Hello World” 函数,以查看其工作原理。 想象一下 <st c="11354">sourceCode</st> 代表了一个 Swift 文件 的内容。

为了解析“Hello World”代码,我们将 使用 <st c="11448">SwiftParser</st>

 do { <st c="11467">let syntax = try SyntaxParser.parse(source: sourceCode)</st> } catch {
    print("Error parsing code: \(error)")
}

解析代码很简单。 <st c="11610">SyntaxParser</st> 调用解析方法,使用我们之前定义的 <st c="11655">sourceCode</st> 常量,并返回一个语法。 但这是什么语法呢? 嗯,那就是我们完整的代码树了! 语法变量来自类型 <st c="11805">SourceFileSyntex</st>,而这个类型代表了我们代码的语法结构。 它是最高级别的语法节点,封装了所有源代码的导入、类、 和函数。

现在,是时候了解这个语法树 看起来是什么样子了。

调查树

关于与Swift Playgrounds 一起工作的最好之处之一是,它不仅非常适合玩代码 片段,而且还可以检查它们的结果,而无需在我们的代码中设置断点。

在我们运行我们的 Playground 代码 后,我们可以在窗口的右侧列中看到类型 <st c="12334">SourceFileSyntax</st> 当我们点击它旁边的方形图标时,我们可以看到语法常量是如何构建的(见 图 10**.4):

Figure 10.4: The syntax object structure

图 10.4:语法对象结构

这是一个极佳的时刻,花点时间亲自运行它,并尝试理解我们在 图 10**.4中看到的内容。 注意到我已经标记了所有 有趣的部分。

语法实例包含一系列语句。 一个 语句 是我们能处理的一切——一个导入、一个类声明,甚至是一个表达式。 一个语句可以包含其 自己的语句。

基本语句类是 <st c="13850">代码块项目列表语法</st>,每种语句类型都带有不同的 子类 CodeBlockItemListSyntax`

在我们的情况下,我们有一个来自 <st c="14011">函数声明语法</st>类型的语句,这表明一个 函数声明。

展开 <st c="14081">函数声明语法</st> 可以揭示有关函数的更多信息。 例如,它的名称由 <st c="14195">标识符</st> 属性表示(在图 10**.4中用框突出显示)。

<st c="14256">函数声明语法</st> 有一个 <st c="14282">体</st> 属性,它包含具有所有函数语句的语句属性,包括对 <st c="14401">print</st> 函数的调用。

因此,我们可以看到 <st c="14437">SwiftParser</st> 已经为我们做了所有脏活。 现在我们有一个树,我们可以遍历它。 让我们提取 函数语句:

 if let <st c="14575">funcDecl</st> = syntax.statements.first?.item.as(<st c="14619">FunctionDeclSyntax</st>.self) {
       // We'll fill that part soon
 }

在前面的代码中,我们正在尝试将第一个语句项转换为函数声明类型。

存在各种声明类型,每种类型都提供特定的工具来帮助我们遍历和提取更多信息。 以下是一些我们可以尝试 提取的最常见类型:

  • <st c="14975">变量声明语法</st>:这是用于 变量的

  • <st c="15018">枚举声明语法</st>:这是用于 枚举声明

  • <st c="15064">类声明语法</st>:这是用于 类声明

  • <st c="15112">协议声明语法</st>:这是用于 协议声明

  • <st c="15166">类型别名声明语法</st>:这是用于类型 别名声明

  • <st c="15223">初始化器声明语法</st>:这是用于 构造声明

  • <st c="15280">运算符声明语法</st>:这是用于 运算符声明

这些只是 SwiftSyntax 中可用的 一些语法节点类型,将现有的语句项转换为它们对应的类型可以为我们提供所需的 功能。

让我们继续我们的代码示例,看看我们能得到什么 来自 <st c="15575">FunctionDeclSyntax</st>:

 if let <st c="15603">funcCallExpression</st> = funcDecl.body?.statements.first?.item.as(<st c="15665">FunctionCallExprSyntax</st>.self) {
   // Checking the print function
  }

让我们剖析前面的代码片段,了解它完成了什么。 有了函数声明,我们可以深入分析它包含的不同语句。 在这个例子中,我们可以找到一个来自 <st c="15971">FunctionCallExprSyntax</st>类型的语句。这种类型表示一个函数调用,具体来说,是调用 <st c="16057">print()</st>

现在我们已经将语句转换为正确的类型,我们可以获取更多关于它的信息:

 let functionName = funcCallExpression.<st c="16197">calledExpression</st>.firstToken?.text
            if functionName == "print" {
                let value = funcCallExpression.<st c="16292">argumentList</st>.first?.expression.as(StringLiteralExprSyntax.self)?
                    .segments
                    .first?.firstToken?.text
            }

<st c="16395">funcCallExpression</st> 有一个 <st c="16421">calledExpression</st> 属性,它封装了关于实际 表达式组件的信息。

<st c="16516">firstToken</st> 包含 函数名本身。 但“令牌”是什么意思呢? 嗯, <st c="16736">text</st> 属性返回 函数名。

接下来,我们检查函数名是否确实是 <st c="16823">print</st>,现在我们可以通过检查函数参数列表来检查被打印的值。 一旦我们将第一个表达式转换为 <st c="16957">StringLiteralExprSyntax</st>,我们就可以提取其第一个段令牌并将其存储在 <st c="17041">value</st> 常量中。

听起来是不是有点混乱,有点繁琐? 嗯,我们应该记住, <st c="17144">SwiftSyntax</st> 库并不被认为容易使用。 它有一个陡峭的学习曲线,有很多选项和功能。

但这种复杂性并非巧合——解析和分析编程语言,尤其是像 Swift 这样的高级和功能齐全的语言,并不简单。 就像我们有 <st c="17448">funcCallExpression</st> <st c="17468">calledExpression</st> <st c="17488">StringLiteralExprSyntax</st>一样,我们为不同的表达式有数十种不同的类型。 查看 <st c="17589">SwiftSyntax</st> 文档是学习遍历和分析更多 语言的最佳方式。

现在我们了解了使用 <st c="17735">SwiftSyntax</st> 进行 Swift 代码分析,让我们探索如何利用 <st c="17782">SwiftSyntax</st> 进行反向操作——如何生成 Swift 代码。

生成 Swift 代码

SwiftSyntax 中生成代码基于内置类型和字符串字面量。 我们可以尝试通过创建 字符串实例来结构化 Swift 代码:

 let initString: String = "<st c="18052">init(title: String) {</st>
<st c="18150">SwiftSyntax</st> types:

let initSyntax = try InitializerDeclSyntax("init(title: String)") { ExprSyntax("self.title = title")

    }

			<st c="18273">In the preceding code,</st> `<st c="18297">InitializerDeclSyntax</st>` <st c="18318">is a constructor declaration, and</st> `<st c="18353">ExprSyntax</st>` <st c="18363">is a base type</st> <st c="18379">for expressions.</st>
			<st c="18395">In the context of Swift Macros, in most cases, using</st> `<st c="18449">String</st>` <st c="18455">literals will be enough.</st> <st c="18481">That’s because the</st> `<st c="18500">SwiftSyntax</st>` <st c="18511">types support</st> `<st c="18526">String</st>` <st c="18532">literals.</st> <st c="18543">However, using the built-in</st> <st c="18570">expressions will ensure the generated code will be valid in future</st> <st c="18638">Swift updates.</st>
			<st c="18652">Speaking of Swift Macros, let’s create our first Swift macro now that we know what</st> `<st c="18736">SwiftSyntax</st>` <st c="18747">is and how</st> <st c="18759">it works.</st>
			<st c="18768">Creating our first Swift macro</st>
			<st c="18799">As I</st> <st c="18805">mentioned earlier (in the</st> *<st c="18831">What is a Swift macro?</st>* <st c="18853">section), the Swift Macros feature is part of the</st> `<st c="18904">SwiftSyntax</st>` <st c="18915">library.</st> <st c="18925">Macros don’t run as part of our app but as a plugin in</st> <st c="18980">the IDE.</st>
			<st c="18988">Macros can be created by adding a new Swift package with a</st> <st c="19048">macro template.</st>
			<st c="19063">It is obvious why Apple selected the Swift package feature to create macros – a Swift package is a great way to encapsulate code, including tests</st> <st c="19210">and documentation.</st>
			<st c="19228">Let’s add our first Swift macro by creating a new</st> <st c="19279">Swift package.</st>
			<st c="19293">Adding a new Swift macro</st>
			<st c="19318">To create a new</st> <st c="19335">Swift macro, we should open Xcode and follow</st> <st c="19380">these steps:</st>

				1.  <st c="19392">Select</st> **<st c="19400">File</st>** <st c="19404">|</st> **<st c="19407">New</st>** <st c="19410">|</st> **<st c="19413">Package…</st>**<st c="19421">.</st>
				2.  <st c="19422">Then, select</st> **<st c="19436">Swift Macro</st>** <st c="19447">followed by tapping on</st> **<st c="19471">Next</st>** <st c="19475">(see</st> *<st c="19481">Figure 10</st>**<st c="19490">.5</st>*<st c="19492">):</st>

			![Figure 10.5: Selecting Swift Macro in the choose template window](https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_figure_10.05.jpg)

			<st c="19632">Figure 10.5: Selecting Swift Macro in the choose template window</st>

				1.  <st c="19696">In the</st> <st c="19704">opening screen, we will give a name for our struct and press the</st> `<st c="19936">StructInit</st>` <st c="19946">(see</st> *<st c="19952">Figure 10</st>**<st c="19961">.6</st>*<st c="19963">):</st>

			![Figure 10.6: Adding a StructInit macro](https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_figure_10.06.jpg)

			<st c="20054">Figure 10.6: Adding a StructInit macro</st>

				1.  <st c="20092">After saving, Xcode</st> <st c="20113">opens a window with our new package containing an</st> <st c="20163">example macro.</st>

			<st c="20177">Let’s see how a Swift Macros package is</st> <st c="20218">built next!</st>
			<st c="20229">Examining our Swift Macros package structure</st>
			<st c="20274">Now that we</st> <st c="20286">have a Swift Macros package, we can reveal its file’s structure (</st>*<st c="20352">Figure 10</st>**<st c="20362">.7</st>*<st c="20364">):</st>
			![Figure 10.7: The Swift Macros package file’s structure](https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_figure_10.07.jpg)

			<st c="20612">Figure 10.7: The Swift Macros package file’s structure</st>
			<st c="20666">Looking at the Swift Macros package (</st>*<st c="20704">Figure 10</st>**<st c="20714">.7</st>*<st c="20716">), we can see that</st> `<st c="20736">SwiftSyntax</st>` <st c="20747">is defined as a dependency of the package for us, with the latest stable version already linked to</st> <st c="20847">our package.</st>
			<st c="20859">The macro itself is built upon three different</st> <st c="20907">source files:</st>

				*   `<st c="20920">StructInit</st>`<st c="20931">: That’s our macro definition file.</st> <st c="20968">Here, we define the macro name</st> <st c="20999">and type.</st>
				*   `<st c="21008">StructInitClient</st>`<st c="21025">: That’s our Swift Macros package executable product.</st> <st c="21080">This is where we add an executable code that uses</st> <st c="21130">our macro.</st>
				*   `<st c="21140">StructInitMacros</st>`<st c="21157">: That’s our macro implementation and where all the</st> <st c="21210">magic happens.</st>

			<st c="21224">In addition, we also have a</st> `<st c="21253">Test</st>` <st c="21257">target where we can test our</st> <st c="21287">macro code.</st>
			<st c="21298">Our first step toward the</st> `<st c="21325">StructInit</st>` <st c="21335">macro is by declaring its name</st> <st c="21367">and type.</st>
			<st c="21376">Declaring our macro</st>
			<st c="21396">If we</st> <st c="21402">open the</st> `<st c="21412">StructInit</st>` <st c="21422">file, we can see it has a concise yet</st> <st c="21461">important declaration:</st>

@独立的表达式

public macro stringify(_ value: T) -> (T, String) = #externalMacro(module: "StructInitMacros",

类型:"StringifyMacro")

			<st c="21631">This short declaration has</st> <st c="21659">many components:</st>

				*   `<st c="21675">@freestanding(expression)</st>`<st c="21701">: That’s the macro role.</st> <st c="21727">We’ll go over roles in the</st> *<st c="21754">Giving our macro a</st>* *<st c="21773">role</st>* <st c="21777">section.</st>
				*   `<st c="21786">public macro stringify<T></st>`<st c="21812">: The</st> <st c="21819">macro name.</st>
				*   `<st c="21830">(_ value: T) -> (T, String)</st>`<st c="21858">: The macro parameters</st> <st c="21882">and output.</st>
				*   `<st c="21893">#externalMacro</st>`<st c="21908">: This means that the macro will be used as a plug in</st> <st c="21963">the compiler.</st>
				*   `<st c="21976">module: "StructInitMacros"</st>`<st c="22003">: The name of the plugin that will</st> <st c="22039">be used.</st>
				*   `<st c="22047">type: "StringifyMacro"</st>`<st c="22070">: That’s the macro type, as defined in the</st> `<st c="22114">Package.swift</st>` <st c="22127">file.</st>

			<st c="22133">The first component is the macro role, so let’s discuss what</st> <st c="22195">roles are.</st>
			<st c="22205">Giving our macro a role</st>
			**<st c="22229">Macro roles</st>** <st c="22241">define</st> <st c="22249">the fundamental behavior of our macros.</st> <st c="22289">There are two primary</st> <st c="22311">role categories:</st>

				*   `<st c="22473">#</st>` <st c="22474">sign.</st>

    <st c="22479">Here’s an example of a</st> <st c="22503">freestanding macro:</st>

    ```

    #URL("https://swift.org/")

    ```swift

    <st c="22549">The</st> `<st c="22554">#URL</st>` <st c="22558">macro checks whether the provided value is a valid URL.</st> <st c="22615">If not, it raises an error on compile time.</st> <st c="22659">Otherwise, it returns a</st> <st c="22683">non-optional value.</st>

    <st c="22702">We can see</st> <st c="22713">that the</st> `<st c="22723">#URL</st>` <st c="22727">macro can be anywhere in our code.</st> <st c="22763">That’s why it is</st> <st c="22780">called</st> *<st c="22787">freestanding</st>*<st c="22799">.</st>

    				*   `<st c="22974">@</st>` <st c="22975">sign.</st>

    <st c="22980">Here’s an example of an</st> <st c="23005">attached macro:</st>

    ```

    <st c="23020">@StructInit</st> struct Book {

        var id: Int

        var title: String

        var subtitle: String

        var description: String

        var author: String

    }

    ```swift

    <st c="23142">In the preceding code, the</st> `<st c="23170">@StructInit</st>` <st c="23181">macro is “attached” to the</st> `<st c="23209">Book</st>` <st c="23213">struct and inserts an</st> `<st c="23236">init</st>` <st c="23240">function based on the</st> <st c="23263">struct properties.</st>

			<st c="23281">The two categories of macro types, namely freestanding and attached, represent distinct sets of roles.</st> <st c="23385">Here is the list of</st> <st c="23405">all roles:</st>

				*   `<st c="23415">#freestanding(expression)</st>`<st c="23441">: This</st> <st c="23448">just returns a new expression based on an</st> <st c="23491">existing one</st>
				*   `<st c="23503">#freestanding(declaration)</st>`<st c="23530">: This creates a</st> <st c="23548">new declaration</st>
				*   `<st c="23563">@attached(peer)</st>`<st c="23579">: This adds new declaration next to the</st> <st c="23620">attached one</st>
				*   `<st c="23632">@attached(accessor)</st>`<st c="23652">: This adds accessors to</st> <st c="23678">a property</st>
				*   `<st c="23688">@attached(memberAttribute)</st>`<st c="23715">: This adds attributes to the declarations in the type it’s</st> <st c="23776">attached to</st>
				*   `<st c="23787">@attached(member)</st>`<st c="23805">: This adds new declarations inside the type it’s</st> <st c="23856">attached to</st>
				*   `<st c="23867">@attached(conformance)</st>`<st c="23890">: This</st> <st c="23898">adds conformance to the type it’s</st> <st c="23932">attached to</st>

			<st c="23943">The role we define when we declare the macro tells the created plugin</st> *<st c="24014">how to</st>* <st c="24020">change an</st> <st c="24031">existing code.</st>
			<st c="24045">The role is the first part of declaring a macro.</st> <st c="24095">Let’s continue with the rest of</st> <st c="24127">the declaration.</st>
			<st c="24143">Defining the StructInit macro</st>
			<st c="24173">Our</st> `<st c="24178">StructInit</st>` <st c="24188">macro goal is to</st> <st c="24205">create the init method for a struct.</st> <st c="24243">Our macro doesn’t exist independently; its purpose is to insert new declarations into an existing struct.</st> <st c="24349">Therefore, we will choose the</st> `<st c="24379">@attached(member)</st>` <st c="24396">macro from the roles list in the</st> *<st c="24430">Giving our macro a</st>* *<st c="24449">role</st>* <st c="24453">section:</st>

@附加的成员


			<st c="24480">However, mentioning the role type is not enough.</st> <st c="24530">We also need to specify what declaration types we expect our macro to generate.</st> <st c="24610">In this case, we expect the macro to generate an</st> `<st c="24659">init</st>` <st c="24663">function.</st> <st c="24674">Let’s add that to the</st> <st c="24696">role declaration:</st>

@附加的成员,名称:命名(init)


			<st c="24751">Adding role types helps the compiler cover different cases where the macro generates something else that was not declared.</st> <st c="24875">It also behaves as a documentation for</st> <st c="24914">our macro.</st>
			<st c="24924">Here is another example of</st> `<st c="24952">names</st>` <st c="24957">argument usage:</st>

@附加的成员,名称:命名(rawValue)


			<st c="25015">In this case, the</st> `<st c="25034">names</st>` <st c="25039">argument declares a usage of the</st> `<st c="25073">RawValue</st>` <st c="25081">declaration.</st>
			<st c="25094">We can also add</st> `<st c="25111">arbitrary</st>` <st c="25120">for</st> <st c="25125">general purposes:</st>

@附加的成员,名称:任意


			<st c="25178">Using</st> `<st c="25185">arbitrary</st>` <st c="25194">counts for all types</st> <st c="25216">of declarations.</st>
			<st c="25232">Moving forward, we will reconfigure the predefined macro with the</st> <st c="25299">following declaration:</st>

@附加的成员,名称:命名(init)

public macro StructInit() = #externalMacro(module:

"<st c="25541">StructInit</st>.

        <st c="25552">这个宏虽然简短,但讲述了它的目标和行为。</st> <st c="25617">接下来是重要的部分——</st> <st c="25653">宏的实现。</st>

        <st c="25674">实现宏</st>

        <st c="25697">与其他 Swift 类型不同,在</st> <st c="25727">宏中,我们将声明和实现分开到不同的文件中。</st> <st c="25804">从某种意义上说,这类似于 Objective-C 或 C++,当时头文件和实现是</st> <st c="25891">其他部分。</st>

        <st c="25903">我们将打开我们的</st> `<st c="25921">StructInitMacros</st>` <st c="25937">文件,并清除其内容以从头开始。</st> <st c="25984">之后,我们可以继续导入</st> <st c="26024">相关库:</st>
 import SwiftCompilerPlugin
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros
        <st c="26140">这些是我们将要编写的宏中的标准库。</st> <st c="26204">请注意,我们有</st> `<st c="26224">SwiftSyntax</st>` <st c="26235">和</st> `<st c="26240">SwiftSyntaxBuilder</st>` <st c="26258">作为我们在</st> *<st c="26296">探索</st> * *<st c="26306">SwiftSyntax</st> * <st c="26317">部分学到的内容的一部分。</st>

        <st c="26326">现在,让我们进入主菜——</st> `<st c="26369">StructInit</st>` <st c="26379">结构体。</st>

        <st c="26387">声明 StructInit 结构体</st>

        <st c="26419">在 Swift 宏中,Apple</st> <st c="26443">继续其主要使用结构体和协议而不是类</st> <st c="26527">和继承的趋势。</st>

        <st c="26543">要实现一个新的宏,我们将添加一个新的结构体,其名称与名为</st> <st c="26649">MemberMacro</st> <st c="26660">的协议</st> <st c="26643">相符合</st>:
<st c="26662">public</st> struct StructInit: <st c="26688">MemberMacro</st> {
   public static func expansion() {
      //Implementation details are detailed in the next section
   }
}
        <st c="26796">编译器会在</st> *<st c="26900">添加新的 Swift 宏</st>* <st c="26924">部分下寻找与我们之前声明的宏名称相同的结构体。</st> <st c="26934">我们还声明了</st> `<st c="26955">StructInit</st>` <st c="26965">为</st> `<st c="26969">public</st>` <st c="26975"> – 记住,宏是 Swift 包的一部分,因此我们需要确保它可以从其他模块中访问。</st>

        <st c="27088">那么,什么是</st> `<st c="27105">MemberMacro</st>` <st c="27116">协议呢?</st> <st c="27127">`<st c="27131">MemberMacro</st>` <st c="27142">协议包含一个执行展开操作的关键函数,其名称非同寻常,为</st> `<st c="27250">expansion()</st>`<st c="27264">。</st>

        <st c="27265">然而,我们不会在创建每个宏时都使用</st> `<st c="27288">MemberMacro</st>` <st c="27299">,因为它只与宏的</st> `<st c="27368">attached(member)</st>` <st c="27384">角色相关。</st> <st c="27391">每个角色都有一个我们需要</st> <st c="27437">遵守的协议。</st>

        <st c="27448">以下是不同角色及其</st> <st c="27499">对应协议的列表:</st>

            +   `<st c="27522">@freestanding(expression) -></st>` `<st c="27552">ExpressionMacro</st>`

            +   `<st c="27567">@freestanding(declaration) -></st>` `<st c="27598">DeclarationMacro</st>`

            +   `<st c="27614">@attached(peer) -></st>` `<st c="27634">PeerMacro</st>`

            +   `<st c="27643">@attached(accessor) -></st>` `<st c="27667">AccessorMacro</st>`

            +   `<st c="27680">@attached(memberAttribute) -></st>` `<st c="27711">MemberAttributeMacro</st>`

            +   `<st c="27731">@attached(member) -></st>` `<st c="27753">MemberMacro</st>`

            +   `<st c="27764">@attached(conformance) -></st>` `<st c="27791">ConformanceMacro</st>`

        <st c="27807">由于我们正在使用</st> <st c="27831">Swift 宏并具有</st> `<st c="27853">@attached(member)</st>` <st c="27870">角色,我们将只关注</st> `<st c="27899">MemberMacro</st>`<st c="27910">,尽管这个概念与其他协议类似。</st>

        <st c="27970">让我们一起来了解一下!</st>

        <st c="27997">实现展开函数</st>

        <st c="28033">我将首先</st> <st c="28048">向您展示</st> `<st c="28064">expansion</st>` <st c="28073">函数:</st>
 public static func expansion(
        of node: AttributeSyntax,
        providingMembersOf declaration: some
          DeclGroupSyntax,
        in context: some MacroExpansionContext
    ) throws -> [SwiftSyntax.DeclSyntax]
        <st c="28269">虽然这个函数可能看起来有点复杂,但我们需要记住</st> <st c="28340">两件事:</st>

            1.  <st c="28351">在函数签名中提到的多数类型应该对我们来说已经熟悉,因为它们是</st> `<st c="28467">SwiftSyntax</st>` <st c="28478">库的组成部分。</st>

            1.  <st c="28487">这个协议中只有一个函数。</st> <st c="28532">无需实现</st> <st c="28553">另一个函数!</st>

        <st c="28565">扩展函数的目的是接收有关附加对象或宏参数的信息,并返回一个表示为</st> `<st c="28728">SwiftSyntax</st>` <st c="28739">表达式数组(</st>`<st c="28753">DeclSyntax</st>`<st c="28764">)的 Swift 代码片段。</st>

        <st c="28767">扩展函数有三个参数:</st>

            1.  `<st c="28812">节点:AtributeSyntax</st>`<st c="28833">:此节点表示原始 Swift 代码中的实际宏。</st>

            1.  `<st c="28911">声明:some DeclGroupSyntax</st>`<st c="28945">:描述宏附加到的结构/类的声明结构。</st>

            1.  `<st c="29028">上下文:some MacroExpansionContext</st>`<st c="29064">:上下文为我们提供了更多关于编译器的信息。</st> <st c="29133">记住,编译器作为宏函数的“环境”。</st>

        <st c="29217">现在,我们可以开始创建我们的结构</st> `<st c="29256">init</st>` <st c="29260">方法。</st>

        <st c="29268">首先,我们需要有一个包含所有结构属性的列表,包括名称和类型。</st> <st c="29356">为此,我们需要使用</st> `<st c="29402">SwiftSyntax</st>`<st c="29413">分析代码,这是我们本章刚刚学习的内容(在</st> *<st c="29462">探索</st>* *<st c="29472">SwiftSyntax</st>* <st c="29483">部分)。</st>

        <st c="29493">因此,让我们获取</st> <st c="29507">我们需要的所有结构信息:</st>
 let members = declaration.memberBlock.members <st c="29595">// 1</st> let variableDecl = members.compactMap {
  $0.decl.as(VariableDeclSyntax.self) } <st c="29678">// 2</st> let variablesName = variableDecl.compactMap {
  $0.bindings.first?.pattern } <st c="29758">// 3</st> let variablesType = variableDecl.compactMap {
  $0.bindings.first?.typeAnnotation?.type } <st c="29851">// 4</st>
        <st c="29855">让我们逐行解释前面的代码:</st>

            1.  <st c="29903">我们使用声明参数来获取所有</st> <st c="29952">结构成员。</st>

            1.  <st c="29967">所有结构成员也包括它们的功能,所以我们只</st> <st c="30042">过滤变量。</st>

            1.  <st c="30055">我们使用变量的</st> `<st c="30115">pattern</st>` <st c="30122">属性创建所有变量名的数组。</st>

            1.  <st c="30133">我们使用变量的</st> `<st c="30201">typeAnnotation</st>` <st c="30215">属性创建所有变量类型的数组。</st>

        <st c="30226">现在我们有了所有需要的信息,我们可以生成</st> `<st c="30312">init</st>` <st c="30316">函数的 Swift 代码。</st>

        <st c="30326">首先,我们根据变量名</st> `<st c="30350">init</st>` <st c="30354">函数签名基于变量名列表和类型:</st>
 var code = "<st c="30433">init(</st>"
for (name, type) in zip(variablesName, variablesType) {
    code += <st c="30506">"\(name): \(type),</st> "
}
code = String(code.dropLast(2))
code += "<st c="30570">)</st>"
        <st c="30573">前面的代码首先创建一个可变字符串,遍历所有变量名和类型,并将它们添加到函数签名中。</st> <st c="30714">一旦代码添加了所有函数参数,它就通过一个</st> <st c="30779">闭括号</st>结束。

        <st c="30799">接下来,是时候</st> <st c="30819">添加函数体了。</st> <st c="30842">我们可以使用一个特殊的</st> `<st c="30873">SwiftSyntax</st>` <st c="30884">结构体,它代表一个初始化器声明</st> <st c="30935">称为</st> `<st c="30942">InitializerDeclSyntax</st>`<st c="30963">:</st>
 let initializer = try <st c="30988">InitializerDeclSyntax</st>(SyntaxNodeString
  (stringLiteral: code)) {
      for name in variablesName {
          ExprSyntax("self.\(name) = \(name)")
      }
}
        <st c="31121">`<st c="31126">InitializerDeclSyntax</st>` <st c="31147">“init”函数接收两个参数——函数签名和一个包含“init”体</st> <st c="31260">的闭包,该体由</st> `<st c="31263">ExprSyntax</st>`<st c="31273">表示。</st>

        <st c="31274">现在我们有了</st> `<st c="31292">initializer</st>`<st c="31303">,我们可以返回一个</st> <st c="31328">DeclSyntax</st>`<st c="31331">数组:</st>
 return [DeclSyntax(initializer)]
        <st c="31376">让我们看看</st> <st c="31391">完整的代码:</st>
 let members = structDecl.memberBlock.members
        let variableDecl = members.compactMap {
          $0.decl.as(VariableDeclSyntax.self) }
        let variablesName = variableDecl.compactMap {
          $0.bindings.first?.pattern }
        let variablesType = variableDecl.compactMap {
          $0.bindings.first?.typeAnnotation?.type }
        var code = "<st c="31700">init(</st>"
        for (name, type) in zip(variablesName,
          variablesType) {
            code += <st c="31773">"\(name): \(type),</st> "
        }
        code = String(code.dropLast(2))
        code += "<st c="31837">)</st>"
        let initializer = try InitializerDeclSyntax(SyntaxNodeString
          (stringLiteral: code)) {
            for name in variablesName {
                ExprSyntax("<st c="31967">self.\(name) = \(name)</st>")
            }
        }
        return [DeclSyntax(initializer)]
        <st c="32030">该代码接受变量结构体列表并生成自己的</st> `<st c="32097">init</st>` <st c="32101">函数。</st>

        <st c="32111">它看起来怎么样?</st> <st c="32130">让我们</st> <st c="32135">用一个</st> <st c="32160">小的结构体</st> <st c="32165">来演示:</st>
 struct Book {
    var id: Int
    var title: String
}
        <st c="32219">`<st c="32224">expansion</st>` <st c="32233">方法</st> <st c="32241">创建了以下</st> `<st c="32263">init</st>` <st c="32267">函数:</st>
 init(id: Int, title: String) {
    self.id = id
    self.title = title
}
        <st c="32342">但我们定义宏行为的事实并不意味着我们可以使用它。</st> <st c="32424">记住,宏作为编译器插件运行。</st> <st c="32475">这是我们</st> <st c="32486">下一步要做的。</st>

        <st c="32496">添加编译器插件</st>

        <st c="32523">编译器插件是我们的</st> <st c="32551">宏“product”,或者换句话说,宏</st> <st c="32598">入口点。</st>

        <st c="32610">在 iOS 中,宏在无网络访问和系统文件更改的沙盒中调用。</st> <st c="32699">问题是这样的:编译器如何实例化和存储 Swift 宏以用作</st> <st c="32792">插件?</st>

        <st c="32801">答案是否定的。</st> <st c="32833">如果我们再次审视我们的代码,我们会注意到 Swift 宏函数都是静态的,这在创建一个</st> <st c="32973">新宏时是一个重要的问题。</st>

        <st c="32983">因此,要创建一个编译器插件,我们需要定义一个新的符合</st> `<st c="33069">CompilerPlugin</st>` <st c="33083">协议并具有</st> `<st c="33105">@main</st>` <st c="33110">属性标记的</st>结构体:</st>
 @main
struct struct_initial_macroPlugin: CompilerPlugin {
    let providingMacros: [Macro.Type] = [
        StructInit.self,
    ]
}
        <st c="33243">前面的代码显示,`<st c="33274">struct_initial_macroPlugin</st>` <st c="33300">实现了一个变量</st> `<st c="33325">get</st>` <st c="33328">方法——</st> `<st c="33338">providingMacros</st>` <st c="33353">——并返回一个宏类型的数组而不是实例。</st>

        <st c="33413">在此处需要注意的另一件重要事情是结构体名称(</st>`<st c="33473">struct_initial_macroPlugin</st>`<st c="33500">)。</st> <st c="33504">只要它符合</st> <st c="33566">`<st c="33573">CompilerPlugin</st>`</st> <st c="33587">协议并具有</st> `<st c="33609">@</st>``<st c="33610">main</st>` <st c="33614">属性,我们给它取什么名字都无关紧要。</st>

        <st c="33625">现在我们有了编译器插件,我们的编译器已经准备好</st> <st c="33687">运行它。</st>

        <st c="33694">使用客户端运行我们的宏</st>

        <st c="33727">宏可执行文件与应用程序或库不同,因为它们在编译器环境中运行。</st> <st c="33825">如果我们回到本章中创建 Swift 宏 Swift 包的部分(</st>*<st c="33917">检查我们的 Swift 宏包结构</st>* <st c="33962">部分),我们会看到 Swift 宏还有一个名为</st> `<st c="34020">StructInitClient</st>`<st c="34043">的文件夹。</st>

        `<st c="34044">StructInitClient</st>` <st c="34061">是我们 Swift 宏可执行文件,也定义在宏的</st> `<st c="34121">package.swift</st>` <st c="34134">清单文件中:</st>
 .executable(
    name: "StructInitClient",
    targets: ["StructInitClient"]
),
        <st c="34220">现在,我们可以将我们在</st> `<st c="34264">main.swift</st>` <st c="34274">文件中的代码改为</st> <st c="34283">以下内容:</st>
 import StructInit
import Foundation <st c="34334">@StructInit</st> struct Book {
    var id: Int
    var title: String
    var subtitle: String
    var description: String
    var author: String
}
        <st c="34455">在前面</st> <st c="34473">的代码中,我们有一个名为</st> `<st c="34509">Book</st>`<st c="34513">的简单结构体,但现在,我们还附加了我们刚刚创建的</st> `<st c="34550">@StructInit</st>` <st c="34561">宏。</st>

        <st c="34584">右键单击宏本身并选择</st> **<st c="34628">展开宏</st>**<st c="34640">,这将揭示生成的代码(</st>*<st c="34676">图 10</st>**<st c="34686">.8</st>*<st c="34688">):</st>

        ![图 10.8:Swift 宏展开](https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_figure_10.08.jpg)

        <st c="35057">图 10.8:Swift 宏展开</st>

        <st c="35091">使用我们的宏可执行文件是查看宏实际运行效果的好方法!</st> <st c="35162">此时,一切应该都按预期工作。</st> <st c="35213">现在是时候通过一些</st> <st c="35270">错误处理</st>来提升我们的宏实现。</st>

        <st c="35285">处理宏错误</st>

        <st c="35308">当我们创建一个</st> <st c="35326">Swift 宏时,对我们这些宏开发者来说显而易见的事情,对我们这些</st> <st c="35409">宏用户来说并不明显。</st>

        <st c="35421">我们的</st> `<st c="35426">StructInit</st>` <st c="35436">宏设计为仅与结构体一起使用,而不是类。</st> <st c="35506">因此,我们需要检查附加的元素是否确实是</st> <st c="35573">一个结构体。</st>

        <st c="35582">在</st> `<st c="35594">expansion()</st>` <st c="35605">函数内部,我们可以执行一个简单的</st> `<st c="35640">guard</st>` <st c="35645">语句,并在附加的声明不是</st> <st c="35715">结构体的情况下抛出一个错误:</st>
 guard let structDecl = declaration.as(StructDeclSyntax.self)
    else {
      throw StructInitError.onlyStructs
    }
        <st c="35828">在上面的代码中,</st> `<st c="35852">StructInitError</st>` <st c="35867">是一个符合</st> <st c="35893">的枚举</st> `<st c="35896">Error</st>`<st c="35901">:</st>
 enum StructInitError: CustomStringConvertible, Error {
    case onlyStructs
    var description: String {
        switch self {
        case . onlyStructs: return "@StructInit can only be applied to a structure"
        }
    }
}
        <st c="36097">拥有一个具有不同错误类型和消息的枚举可以使开发者的生活变得更加容易。</st> <st c="36194">记住,这个错误出现在编译时(</st>*<st c="36244">图 10.9</st>**<st c="36254">.9</st>*<st c="36256">):</st>

        ![图 10.9:实现 Swift 宏时抛出一个错误信息](https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_figure_10.09.jpg)

        <st c="36309">图 10.9:实现 Swift 宏时抛出一个错误信息</st>

        <st c="36380">但有时,我们想要处理更复杂的错误。</st> <st c="36435">例如,有时我们只想显示警告,而不仅仅是错误。</st> <st c="36504">或者,在其他情况下,我们甚至想为</st> <st c="36570">开发者的问题提供一个解决方案。</st>

        <st c="36584">在这些情况下,我们可以</st> <st c="36608">添加一个名为</st> `<st c="36631">Diagnostic</st>` <st c="36641">的结构体。</st> <st c="36650">一个</st> `<st c="36652">Diagnostic</st>` <st c="36662">结构体更适合在编译器环境中显示错误,并且比仅仅</st> <st c="36768">抛出错误</st> 具有更多功能。

        <st c="36784">让我们创建一个</st> `<st c="36800">DiagnosticMessage</st>` <st c="36817">枚举和一个</st> `<st c="36829">Diagnostic</st>` <st c="36839">结构体:</st>
 enum CustomDiagnostic: String, DiagnosticMessage {
    case notAStruct
    var <st c="36919">severity</st>: DiagnosticSeverity { return .error}
    var <st c="36970">message</st>: String {
        switch self {
        case .notAStruct:
            return "@StructInit can only be applied to a structure"
        }
    }
    var <st c="37085">diagnosticID</st>: MessageID {
        return MessageID(domain: "StructInitMacro",
                         id: rawValue)
    }
} <st c="37174">let diagnostic = Diagnostic(node: node,</st>
<st c="37383">SwiftSyntax</st> library.
			<st c="37403">If you wondered why we need the</st> `<st c="37436">context</st>` <st c="37443">parameter in the</st> `<st c="37461">expansion</st>` <st c="37470">function, now you’ll have</st> <st c="37497">the answer:</st>

context.diagnose(diagnostic)


			<st c="37537">Remember we said that context links us to the compiler environment?</st> <st c="37606">So, we use it to invoke a</st> <st c="37632">diagnostic message.</st>
			<st c="37651">Let’s see the</st> `<st c="37666">guard</st>` <st c="37671">declaration now that we have a</st> `<st c="37703">diagnostic</st>` <st c="37713">structure:</st>

guard let structDecl = declaration.as(StructDeclSyntax.self) else {

        let diagnostic = Diagnostic(node: node,

                                    message: MyLibDiagnostic.notAStruct) <st c="37870">context.diagnose(diagnostic)</st> throw StructInitError.onlyAStruct

    }

			<st c="37934">We can see that</st> `<st c="37951">SwiftSyntax</st>` <st c="37962">is like peeling an onion – we uncover new features every time we dig deeper, and</st> `<st c="38044">Diagnostic</st>` <st c="38054">is one of</st> <st c="38065">these features.</st>
			<st c="38080">Now, we have a significant error handling – descriptive and precise.</st> <st c="38150">But what about checking our macro in various</st> <st c="38195">use cases?</st>
			<st c="38205">To see our macro at work, we used</st> `<st c="38240">StructInitClient</st>`<st c="38256">. However, relying on the client to verify that</st> <st c="38303">our macro works as expected is not sustainable</st> <st c="38351">over time.</st>
			<st c="38361">So, another great feature we get from having a macro written in a Swift package is</st> <st c="38445">unit tests.</st>
			<st c="38456">Let’s see how we test</st> <st c="38479">a macro.</st>
			<st c="38487">Adding tests</st>
			<st c="38500">The principle of testing a macro</st> <st c="38533">is to test a code block</st> *<st c="38558">before and after</st>* <st c="38574">the</st> <st c="38579">macro expansion.</st>
			<st c="38595">As part of our Swift Macros package, we have a test target (</st>*<st c="38656">Figure 10</st>**<st c="38666">.10</st>*<st c="38669">):</st>
			![Figure 10.10: A testing target for StructInitMacro](https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/ms-ios18-dev/img/B21795_figure_10.10.jpg)

			<st c="38877">Figure 10.10: A testing target for StructInitMacro</st>
			<st c="38927">Each Swift package comes with a testing target, and in this case, we already have one test with the</st> `<st c="39028">stringify</st>` <st c="39037">macro that comes when we create a new Swift</st> <st c="39082">Macros package.</st>
			<st c="39097">Let’s clear the test file and start our</st> <st c="39138">own test.</st>
			<st c="39147">To test a macro, we need to create the</st> `<st c="39187">XCTestCase</st>` <st c="39197">subclass and create a new method called</st> `<st c="39238">testMacro</st>`<st c="39247">. Remember that test functions in</st> `<st c="39281">XCTest</st>` <st c="39287">always start with the phrase “test” followed by the</st> <st c="39340">test name.</st>
			<st c="39350">To test a macro expansion, we will use a particular</st> `<st c="39403">SwiftSyntax</st>` <st c="39414">function called</st> `<st c="39431">assertMacroExpansion</st>`<st c="39451">. The most important function parameters are</st> <st c="39496">as follows:</st>

				*   `<st c="39507">_originalSource</st>`<st c="39523">: The original code before the expansion, including the macro</st> <st c="39586">attribute itself</st>
				*   `<st c="39602">expandedSource</st>`<st c="39617">: The code</st> *<st c="39629">after</st>* <st c="39634">the expansion</st>
				*   `<st c="39648">macros</st>`<st c="39655">: The list of macro types</st> <st c="39682">being tested</st>

			<st c="39694">Let’s see a basic</st> <st c="39712">test case for testing our</st> `<st c="39739">StructInit</st>` <st c="39749">macro:</st>

let testMacros: [String: Macro.Type] = [

"StructInit": StructInit.self,

]

final class StructInitTests: XCTestCase {

func testMacro() {

    assertMacroExpansion(

        """

        @StructInit

        struct Book {

            var id: Int

            var title: String

            var subtitle: String

        }

        """,

        expandedSource:

        """

        struct Book {

            var id: Int

            var title: String

            var subtitle: String

            init(id: Int, title: String,

            subtitle: String) {

                self.id = id

                self.title = title

                self.subtitle = subtitle

            }

        }

        """,

        macros: testMacros

    )

}

}


			<st c="40226">We can see</st> <st c="40238">that</st> `<st c="40243">assertMacroExpansion</st>` <st c="40263">received the three parameters I</st> <st c="40296">mentioned earlier.</st>
			<st c="40314">We compare the</st> `<st c="40330">Book</st>` <st c="40334">struct expansion with the</st> `<st c="40361">Book</st>` <st c="40365">struct desired structure, including the</st> `<st c="40406">init</st>` <st c="40410">function.</st>
			`<st c="40420">assertMacroExpansion</st>` <st c="40441">compares the expanded code of the macro to the</st> `<st c="40489">expandedSource</st>` <st c="40503">parameter, and if there are any differences, it fails</st> <st c="40558">the test.</st>
			<st c="40567">Testing is a crucial part of Swift packages in general.</st> <st c="40624">Swift packages are meant to be reusable and rely on testing to ensure</st> <st c="40694">their stability.</st>
			<st c="40710">Things get even more important when creating Swift macros since they run as a compiler plugin, which makes it harder to debug.</st> <st c="40838">So, we shouldn’t give up tests, especially not</st> <st c="40885">in macros.</st>
			<st c="40895">Practice exercises</st>
			<st c="40914">Swift Macros is a complex topic, and it is a challenge to understand how to create a Swift macro without trying it yourself.</st> <st c="41040">Here are two exercises that can help you</st> <st c="41081">get started:</st>

				*   <st c="41093">Create an attached Swift macro that adds a function called</st> `<st c="41153">printVariables</st>`<st c="41167">. The function prints the list of the class properties and</st> <st c="41226">their values.</st>
				*   <st c="41239">Create a freestanding macro called</st> `<st c="41275">#colorhex</st>` <st c="41284">that receives a hex color value and generates an RGB color expression.</st> <st c="41356">For example,</st> `<st c="41369">#colorhex("#FFFFFF")</st>` <st c="41389">will generate</st> `<st c="41404">Color(red: 0.0, green: 0.0,</st>` `<st c="41432">blue: 0.0)</st>`<st c="41442">.</st>

			<st c="41443">In addition, here are some links that can help you get more insights about</st> <st c="41519">Swift Macros:</st>

				*   **<st c="41532">Swift Macros documentation from the Swift.org</st>** **<st c="41579">projects</st>**<st c="41587">:</st> <st c="41589">https://docs.swift.org/swift-book/documentation/the-swift-programming-language/macros/</st>
				*   **<st c="41676">A GitHub repository about great Swift macros we can use and learn</st>** **<st c="41743">from</st>**<st c="41747">:</st> [<st c="41750">https://github.com/krzysztofzablocki/Swift-Macros</st>](https://github.com/krzysztofzablocki/Swift-Macros)

			<st c="41799">Summary</st>
			<st c="41807">This chapter covered a new and exciting feature of Xcode 15 and iOS 17 –</st> <st c="41881">Swift Macros.</st>
			<st c="41894">We explored the</st> `<st c="41911">SwiftSyntax</st>` <st c="41922">library and learned how to set up, parse, and generate Swift code.</st> <st c="41990">We also created our first Swift macro, handled errors, and even wrote</st> <st c="42060">one test.</st>
			<st c="42069">Swift Macros is a comprehensive, complex, yet effective feature, and by now, you are ready to implement it in your</st> <st c="42185">own projects!</st>
			<st c="42198">In the next chapter, we’ll discuss another exciting framework –</st> <st c="42263">Combine.</st>


第十二章:11 (此处内容为代码,无需翻译)

创建 Combine 中的管道 (此处内容为代码,无需翻译)

数据流是编程的核心主题,不仅限于 iOS 开发。实际上,我们有许多解决方案和设计模式来处理数据流管理。 确实,直到 1997 年,计算机科学界才引入了响应式编程——一种关注数据流的编程范式,它使 (此处内容为代码,无需翻译) (此处内容为代码,无需翻译)

Apple 的响应式编程版本是 Combine,这是一个提供构建应用中数据流基础设施的框架。(此处内容为代码,无需翻译) 它也是 SwiftUI 的基础设施,使其成为一个声明式框架。(此处内容为代码,无需翻译) (此处内容为代码,无需翻译)

在本章中,我们将进行以下操作:(此处内容为代码,无需翻译) (此处内容为代码,无需翻译)

  • 讨论在项目中使用 Combine 的原因 (此处内容为代码,无需翻译) (此处内容为代码,无需翻译)

  • 复习基本知识 (此处内容为代码,无需翻译) (此处内容为代码,无需翻译)

  • 深入了解 Combine (此处内容为代码,无需翻译) (此处内容为代码,无需翻译)

  • 通过示例学习 Combine (此处内容为代码,无需翻译) (此处内容为代码,无需翻译)

在我们开始介绍 Combine 框架之前,让我们了解为什么我们应该使用 Combine。(此处内容为代码,无需翻译) (此处内容为代码,无需翻译)

技术要求 (此处内容为代码,无需翻译)

对于本章,从 App Store 下载 Xcode 版本 16.0 或更高版本是至关重要的。(此处内容为代码,无需翻译) (此处内容为代码,无需翻译)

确保您正在使用最新的 macOS 版本(Ventura 或更高版本)。只需在 App Store 中搜索 Xcode,选择最新版本,然后继续下载。 打开 Xcode 并完成出现的任何进一步设置说明。 在 Xcode 完全运行后,您 可以开始。

从以下 GitHub 链接下载示例代码:(此处内容为代码,无需翻译) Combine 框架 (此处内容为代码,无需翻译)

为什么使用 Combine?(此处内容为代码,无需翻译)

Apple 的 Combine 框架被认为学习曲线陡峭,但这并不是因为它在技术上复杂。(此处内容为代码,无需翻译) (此处内容为代码,无需翻译) 这是因为许多开发者不理解为什么、如何以及在哪里应该在他们的应用中使用 Combine。(此处内容为代码,无需翻译) (此处内容为代码,无需翻译)

为了回答这些问题,让我们尝试了解 Combine。(此处内容为代码,无需翻译) Combine 是 Apple 的响应式框架,它提供了一个统一的 API 来处理异步事件和数据流。(此处内容为代码,无需翻译) (此处内容为代码,无需翻译)

但为什么我们需要一个响应式框架?(此处内容为代码,无需翻译) 我们不是已经拥有所有需要的东西了吗?(此处内容为代码,无需翻译) (此处内容为代码,无需翻译)

让我们看看我们的 iOS SDK 中有哪些内容:(此处内容为代码,无需翻译) (此处内容为代码,无需翻译)

  • 通知 允许我们发送任何对象 都可以观察到的消息

  • 代表 允许对象响应 由其他对象触发的事件或变化

  • 闭包 是自包含的功能 块,我们可以传递并随时调用

  • 键值观察 (KVO) 允许我们观察对象属性中的值变化

这是一个强大的工具箱! 但是,虽然我们有一个具有许多选项的工具箱,但这些选项也有一些我们需要 讨论的缺点。

例如,通知可能被认为是一种反模式,主要是因为它们具有隐式通信。 想象一个基于通知的项目,其中对象主要使用通知中心相互通信。 该项目可能需要时间来管理和理解数据流 和依赖关系。

代表有助于解决这些问题。 但是,当我们想要在不同对象之间连续传递数据时,它们要求我们创建许多协议,并要求每个对象调用另一个对象,这使得理解 正在发生的事情变得困难。

与代表相比,闭包实际上是一个显著的进步,但它们在嵌套或被其他闭包捕获时也会创建复杂性。

想象我们有一个带有视图模型的视图控制器。 视图模型有一个 <st c="3181">消息</st> 属性,我们总是希望 UILabel 的文本与 <st c="3246">消息</st> 属性值匹配。

使用 Combine,我们会做 以下事情:

 messageSubscriber = viewModel.$message
            .sink { [weak self] message in
                self?.label.text = message
            }

<st c="3414">sink</st> 操作符接收 <st c="3468">消息</st> 属性中任何变化的更新,并有一个包含新 <st c="3516">消息</st> 属性的闭包。 我们直接将新的 <st c="3551">消息</st> 属性存储在标签的 <st c="3592">文本值</st> 中。

这个例子将标签的文本属性绑定到 <st c="3656">viewModel</st> 文本属性。 不需要定义特定的接口 用于委托、观察、发布通知或定义 闭包。

Combine 有很多东西可以提供,但在我们深入研究更多实际示例之前,让我们先了解 基础知识。

了解基础知识

作为一个响应式框架,Combine 建立在发布更新(发布者)和订阅更新(订阅者)的组件之上。

在这之间,我们有操作符,它们可以操作数据并控制流。 让我们从 发布者开始,来了解一下 Combine 的概览。

从发布者开始

解释 Combine 如何工作的最好方法是通过谈论发布者。 发布者 是可以随时间传递一系列值的类型。 我们在 第十章中看到了一个例子:

 URLSession.shared.dataTaskPublisher(for: url)

为了使一个类型成为发布者,它需要遵守 <st c="4565">发布者</st> 协议,并且 <st c="4589">URLSession</st> 并不是唯一一个这样做的类型。 <st c="4637">计时器</st> <st c="4647">通知中心</st> 也是具有它们自己的发布者的类型:

 NotificationCenter.default.publisher(for:
  Notification.Name("DataValueChanged"))

或者,它可以是 <st c="4806">计时器</st> 发布者:

 let timerPublisher = Timer.publish(every: 1.0, on: .main,
  in: .default)
    .autoconnect()

对于没有发布者的类型,只要它们的属性 符合 KVO(键值观察)规范,我们就可以添加一个发布者:

 extension UserDefaults {
@objc dynamic var test: Int { return integer(forKey:
  "myProperty") }
}
let userDefaultsPublisher = UserDefaults.standard
  .publisher(for: \. myProperty)

我们还可以创建一个自定义的 发布者,我们将在稍后学习如何 做到这一点。 发布者只有在订阅者想要接收它们时才会发出值。 所以接下来,让我们来认识 订阅者

设置订阅者

一个 <st c="5484">订阅者</st> 实例位于流的末尾,并处理传入的值。 Combine 框架有两个内置的订阅者, <st c="5625">接收器</st> <st c="5634">分配</st>;两者在大多数情况下都简化了 Combine 的使用。

让我们从 以下内容 <st c="5708">接收器</st>开始:

 import Combine
import Foundation
let subscriber = Timer.publish(every: 1.0, on: .main, in:
  .default)
    .autoconnect()
    .sink( receiveValue: { value in
        print("Received value: \(value)")
    })
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
    subscriber.cancel()
}

在这个代码示例中,我们创建了一个 <st c="6011">计时器</st> 发布者,每秒发送一个值。 它发送的值来自 <st c="6091">日期</st> 类型,但这对我们来说并不重要—— <st c="6136">接收器</st> 订阅者可以接收任何值。

接下来,我们在五秒后取消订阅者。 一旦没有订阅者监听,发布者就停止发送值。 这是 Combine 的一个基本 概念,称为 需求驱动模型。使用这种方法,我们确保有效的资源管理,并避免在没有 目标的情况下执行任何工作。

在这个代码示例中,我们将接收到的值打印到了控制台。然而,在许多情况下,我们希望将其分配给特定的属性。 例如,我们可能下载一个文件并接收其进度的更新。 在这种情况下,我们希望更新一个进度属性来显示 下载状态。

我们可以使用 <st c="6796">sink</st> 闭包 来接收值并将其设置到相关属性,但我们有一个更优雅的方法,那就是 <st c="6916">assign</st> 订阅者:

 import Combine
import Foundation
class DateContainer {
    var date: Date
    init() { date = Date() }
}
let container = DateContainer()
let cancellable = Timer.publish(every: 1.0, on: .main, in:
  .default)
    .autoconnect()
    .assign(to: \.date, on: container)
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
    cancellable.cancel()
}

在这个例子中,我们有一个与上一个例子相同的计时器。 然而,这次我们有一个 <st c="7370">DateContainer</st> 的实例,它有一个 <st c="7391">date</st> 属性。 流末尾的 <st c="7410">assign</st> 订阅者确保我们使用 <st c="7538">键路径</st> 将接收到的值分配给特定的属性。

在这种情况下, <st c="7566">assign</st> 订阅者输入值 必须与发布者 输出值相匹配。

显然,我们可以使用 <st c="7687">sink</st> 闭包 来达到相同的结果:

 .sink( receiveValue: { value in
        container.date = value
    })

然而,使用 <st c="7777">assign</st> 订阅者要优雅得多,而且不仅仅是语义上的。 使用键路径提高了我们的代码类型安全性,并使其更加声明性和简洁。

我们已经了解到发布者的输出 应该与 订阅者的输入相匹配。 但如果我们需要执行一些转换和处理才能实现这一点怎么办呢? 这就是为什么我们有 操作符。

连接操作符

Combine 流系列的第三部分是 操作符。操作符接收上游 数据(前一步骤的输出),对其进行处理,并将其发送到下游。 下游意味着下一步——订阅者或 另一个操作符。

操作符实际上是 帮助我们构建我们称之为 管道 Combine 流的东西。

让我们尝试构建一个 简单的流:

 let numbersPublisher = Array(1...20).publisher
let subscription = numbersPublisher
    .filter { $0 % 2 == 0 }
    .map { "The number is \($0)" }
    .sink(receiveValue: { print($0) })

这个简单的代码示例取了一个介于 <st c="8751">1</st> <st c="8757">20</st>之间的数字数组,使其成为一个使用 <st c="8793">publisher</st> 变量的发布者。

<st c="8812">numbersPublisher</st> 每次从数组中发出一个新值。 该值流向下游的 <st c="8907">filter</st> 操作符,只有当它为偶数时才会重新发布该值。 过滤后的值移动到 <st c="9003">map</st> 操作符,它将其转换为字符串消息并再次重新发布。

在流的末尾,我们有 <st c="9123">sink</st> 订阅者,它将消息打印到 控制台。

恭喜! 我们已经创建了我们的第一个管道。 看看 图 11**.1

图 11.1:我们的第一个 Combine 时间线

图 11.1:我们的第一个 Combine 时间线

图 11**.1 展示了不同的管道操作符,例如 <st c="9425">filter</st> <st c="9436">map</st>。我在这里突出显示了每个操作符的输入和输出。 我们可以看到,一个时间线组件的输出是下一个元素的输入。

这引出了我们还没有讨论过的问题——发布者和订阅者究竟是什么? 它们在内部是如何工作的? 让我们 深入探讨。

深入探讨 Combine 组件

到目前为止,我们在 Combine 中创建了简单的示例 以进行热身。 然而,如果我们想更高级地使用 Combine,我们需要更好地理解 其内部的工作原理。

我们必须理解的第一件事是 Combine 并不是魔法。 Combine 本身不包含任何复杂的代码。 最终,我们谈论的是一组协议,它帮助我们订阅变化并创建一个更新管道。

为了深入探讨,我们将回顾不同的协议,并构建我们自己的自定义发布者、操作符和订阅者,以了解内部的工作原理。

让我们从 发布者开始。

创建自定义发布者

我刚刚提到,Combine 是一组相互通信的协议,发布者是我们要首先 审查的第一个协议。

让我们看看到目前为止我们对 发布者 了解到了什么:

  • 发布者 *向一个或多个订阅者 发出值

  • 发布者输出类型 必须与 订阅者的输入

  • 发布者还可以 传递错误

基于此,让我们以我们的 <st c="10785">Int</st> 数组发布者 示例为例,尝试创建我们自己的发布者,该发布者 传递数字:

 class CustomNumberPublisher: Publisher {
    typealias <st c="10923">Output</st> = Int
    typealias <st c="10946">Failure</st> = Never
    private let numbers: [Int]
    init(numbers: [Int]) {
        self.numbers = numbers
    }
    func receive<S: Subscriber>(subscriber: S) where
      S.Input == Output, S.Failure == Failure {
        for number in numbers {
            _ = subscriber.receive(number)
        }
        subscriber.receive(completion: .finished)
    }
}

<st c="11235">CustomNumberPublisher</st> 类有三个 基本部分:

  • <st c="11289">输出</st> – 这是我们定义发布者输出类型的地方。 在这种情况下,它是一个 <st c="11373">Int</st> 类型。

  • <st c="11382">失败</st> – 这是我们定义发布者错误类型的地方。 在这种情况下,发布者从不发出 错误。

  • <st c="11492">receive</st> – 这是主要的发布者方法。 Combine 在订阅者订阅发布者时调用 <st c="11556">receive</st> 方法。 我们可以看到, <st c="11642">receive</st> 函数具有订阅者的参数,并且它还验证订阅者输入类型和错误是否与 发布者定义匹配。

当发布者想要发出新值时,它将调用订阅者的 <st c="11857">receive</st> 方法,并传递新值。 当发布者完成发送值时,它将调用订阅者的 <st c="11963">receive</st> 函数,并传递 <st c="11989">completion</st> 参数。

让我们看看我们 如何使用 <st c="12032">CustomNumberPublisher</st>

 let subscriber = CustomNumberPublisher(numbers: [1, 2, 3,
  4, 5])
    .sink { value in
        print(value)
}

运行此代码将在控制台打印 <st c="12182">1,2,3,4,5</st> ,正如预期的那样。

<st c="12224">CustomNumberPublisher</st> 示例解释了发布者是如何工作的。 但有时,我们希望强制发送值。 我们可能希望在一个现有的项目代码中实现 Combine 简化事情。

因此,让我们认识一个特殊的发布者类型 ,称为 <st c="12466">Subject</st>

与 Subjects 一起工作

一个 <st c="12597">send(_:)</st> 方法。

让我们从最基本的 Subject—— <st c="12656">PassthroughSubject</st>

理解 PassthroughSubject

让我们看看一个基本的 Subject 使用示例

 import Combine
let subject = PassthroughSubject<Int, Never>()
let subscriber = subject.sink { value in
    print("Received value: \(value)")
}
subject.send(1)
subject.send(2)
subject.send(3)

代码示例简单且 易于理解。 我们创建了一个 <st c="13000">Subject</st> 实例(它是一个发布者),其类型为 <st c="13047">PassthroughSubject</st> <st c="13072">PassthroughSubject</st> 可以不传递任何值进行初始化,并且第一次打开流是在我们调用其 <st c="13186">send(_:)</st> 函数之后。

请注意,我们的 <st c="13221">Subject</st> 只是发送值 但永远不会关闭流。 然而,我们从自定义发布者实现中学到,有时,发布者会关闭其流并向 订阅者发送完成信号。

我们还可以使用 <st c="13458">send(_:)</st> 函数来关闭 Combine 流

 subject.send(1)
subject.send(2)
subject.send(completion: .finished)
subject.send(3)

在这个代码示例中,我们使用我们的 Subject 发送两个值—— <st c="13651">1</st> <st c="13657">2</st>。发送这些值后,我们通过调用带有 <st c="13723">send</st> 函数的 <st c="13746">.</st>``<st c="13747">finished</st> 参数来关闭流。

之后,Subject 尝试发送另一个值(<st c="13821">3</st>),但流已经关闭,订阅者将不会 收到它。

发布者生命周期对于 Combine 方法至关重要,并且适用于 我们的 Subjects。

<st c="13988">PassthroughSubject</st> 非常适合向订阅者发送值 然而,它并不适合保存状态。 例如,假设我们想要存储当前的认证登录状态或文件下载进度。 一个解决方案是将接收到的值存储在实例变量中。 然而,使用实例变量可能会很麻烦,尤其是当有多个订阅者时。

另一个选择是使用另一种类型的 Subject ,称为 <st c="14429">CurrentValueSubject</st>

使用 CurrentValueSubject Subject 保持状态

PassthroughSubject不同,CurrentValueSubject 非常适合用于保存状态。 它有一个初始状态和一个表示当前值的value 属性。

让我们看看CurrentValueSubject 的基本用法示例:

 import Combine
let subject = CurrentValueSubject<String, Never>("Initial
  Value")
let currentValue = subject.value
print("Current value: \(currentValue)")
let subscriber = subject.sink { value in
    print("Received value: \(value)")
}
subject.send("New Value")

在这个代码示例中,我们创建了一个CurrentValueSubject 并用一个值(<st c="15055">"</st>``<st c="15057">Initial Value"</st>)初始化它。

然后,我们将 Subject 的当前值打印到控制台,并使用一个简单的sink 函数订阅它,同时打印每个更新。 as well.

在最后一行,我们使用我们的 Subject 发送一个新的值。

在这种情况下,控制台将显示以下内容:

 Current value: Initial Value
Received value: Initial Value
Received value: New Value

乍一看,控制台输出看起来很奇怪。 为什么我们没有使用Received value: Initial Value 发送它时,会看到这个值?

答案是,当初始化时,CurrentValueSubject 已经包含了一个值,当我们第一次订阅它时,我们已经接收到了 当前值。

这就是为什么CurrentValueSubject 非常适合状态管理的原因。 这种行为确保了我们的订阅者始终与当前的 Subject 值同步。

<st c="15876">PassthroughSubject</st> 没有value 属性,我们无法读取它的当前值。 然而,它在调用send(_😃 函数之前不发出其值,在某些情况下这可能是一个优势。

让我们看看 一个例子:

 let subject1 = PassthroughSubject<Int, Never>()
let subject2 = PassthroughSubject<Int, Never>()
let subscriber = subject1
    .merge(with: subject2)
    .sink { value in
        print("Transformed value: \(value)")
    }
subject1.send(1)
subject1.send(2)
subject2.send(3)
subject2.send(4)

在这个例子中,我们有两个PassthroughSubject 发布者和 Combine 流中的merge() 操作符。 merge() 操作符将两个发布者发出的值合并成一个单独的流。 如果其中一个 Subject 发送了一个值,merge() 操作符就会将其移动到 流的下方。

因此,在这种情况下,输出将是以下内容:

 Transformed value: 1
Transformed value: 2
Transformed value: 3
Transformed value: 4

<st c="16794">PassthroughSubject</st> 可以作为 Combine 管道中的中间步骤,允许我们组合多个发布者,并在它到达订阅者之前执行数据转换。 这是我们不能使用 <st c="17018">CurrentValueSubject</st>` 做到的。

到目前为止,我们一直使用内置的 <st c="17076">sink</st> 订阅者 来处理传入的值。 但就像发布者一样,我们也可以创建一个自定义订阅者。 学习如何创建自定义订阅者可以丰富我们对 Combine 的了解。 让我们 深入探讨!

创建自定义订阅者

如果发布者是提供更新的元素,那么订阅者是需求这些更新的元素。

我们已经理解 Combine 使用的是一个 *供需 模型。 这意味着订阅者需要一个机制来处理和请求 传入的值。

让我们为 <st c="17603">CustomNumberPublisher</st>:

 class CustomNumberSubscriber: Subscriber {
    typealias Input = Int
    typealias Failure = Never
    func receive(subscription: Subscription) {
        subscription.request(.unlimited)
    }
    func receive(_ input: Int) -> Subscribers.Demand {
        print("Received: \(input)")
        return .unlimited
    }
    func receive(completion: Subscribers.Completion<Never>)
    {
        print("Received completion: \(completion)")
    }
}

订阅者协议 包含发布者对应的类型别名, <st c="18078">Input</st> <st c="18088">Failure</st>。两者都需要匹配发布者的 <st c="18132">Output</st> <st c="18143">Failure</st> 数据类型。

查看订阅者实现,我们可以看到三个额外的 <st c="18227">接收</st> 函数,我们将在 下一小节中查看。

接收(subscription: Subscription)

<st c="18326">接收(subscription: Subscription)</st> 在订阅者成功订阅发布者时被调用。 <st c="18439">subscription</st> 对象处理订阅,并且它有一个重要的方法——定义从发布者的需求。 我们通过请求 无限值:

 subscription.request(.unlimited)

我们还可以限制我们请求的项目数量。 例如,让我们请求最多三个 额外项目:

 subscription.request(.max(3))

我们也可以完全不请求任何项目:

 subscription.request(.none)

注意,发布者需要显式调用 <st c="18895">receive(subscription: Subscription)</st> 方法。 这意味着如果我们构建一个自定义发布者(如 创建自定义发布者 部分中所述),我们必须确保我们亲自调用该 函数。

现在我们已经建立了订阅,我们需要处理传入的值,我们使用 <st c="19208">receive(_input:Int)</st> 方法来做到这一点。

receive(_ input: Int) -> Subscribers.Demand

如果我们回顾 <st c="19303">CustomNumberPublisher</st> 订阅者中创建的 创建自定义发布者 部分,我们可以看到我们的发布者直接调用 订阅者:

 _ = subscriber.receive(number)

这就是我们需要作为订阅者协议的一部分实现的 <st c="19494">receive(_ input:Int)</st> 方法。 此方法处理传入的更新,类似于我们在 <st c="19658">sink</st> 函数(在 设置订阅者 *部分)中看到的闭包。

注意,该 <st c="19732">receive</st> 函数返回 <st c="19757">Subscribers.Demand</st>。这与我们在上一个函数中讨论的相同的需求类型。 当订阅者完成处理输入后,它必须通知发布者它还需要多少项。 请求更多项不会替换订阅者首次向发布者建立订阅时发送的需求。 新的需求请求是一个需要由 发布者处理的附加值。

查看以下 代码:

 func receive(subscription: Subscription) {
        subscription.request(.max(2))
    }
    func receive(_ input: Int) -> Subscribers.Demand {
        print("Received: \(input)")
        return .max(3)
    }

让我们尝试跟随这个 代码示例:

  • 订阅者订阅发布者,并调用 <st c="20495">receive(subscription: Subscription)</st> 函数,返回最大值为 <st c="20574">1</st>。总需求现在是 1`

  • 发布者向订阅者发出一个值,并调用 <st c="20659">receive(_ input:Int)</st> 函数,返回最大值为 <st c="20723">3</st>。总需求现在是 4`

如前所述,管理订阅者的需求是 发布者的责任。 如果我们创建自定义发布者,我们需要考虑这一点。

既然我们知道如何启动和管理订阅,了解如何 完成它同样重要。

receive(completion: Subscribers.Completion)

发布者在完成发布时调用 订阅者的 <st c="21100">receive(completion:)</st> 函数。 这可能是因为发布者没有更新,或者 发生了错误。

这就是订阅者需要执行清理、更新 UI 或应用程序状态或打印日志的地方,主要是在发生 错误时。

以下是 <st c="21383">receive(completion:)</st> 函数的例子:

 func receive(completion: Subscribers.Completion<Never>) {
    switch completion {
    case .finished:
        print("Subscription completed successfully.")
    case .failure(let error):
        print("Subscription failed with error: \(error)")
    }
}

这是 receive(completion:)</st> 函数的基本实现。

我们现在知道如何创建自定义发布者和自定义订阅者。 现在,让我们看看如何 将它们连接起来。

连接自定义发布者和订阅者

为了完整地了解订阅者和发布者如何协同工作,我们必须回到发布者那里,并响应我们的订阅者 需求请求。

让我们看看如何在 <st c="22056">发布者</st> 端实现一个 <st c="22080">接收</st> 函数的例子:

 func receive<S: Subscriber>(subscriber: S) where
      S.Input == Output, S.Failure == Failure {
        for number in numbers {
            guard subscriber.receive(number) != .none else
            {
                subscriber.receive(completion: .finished)
                return
            }
        }
        subscriber.receive(completion: .finished)
    }

在代码示例中,只要订阅者继续要求更多更新,发布者就会继续向订阅者发送更多更新。 当订阅者停止要求更多更新时,发布者关闭流并调用订阅者的 <st c="22592">receive(completion:)</st> 函数。

在这个阶段,我们应该熟悉 订阅者和发布者如何协同工作。 我们创建了自定义发布者和订阅者并执行了基本订阅。 让我们通过操作符来改进这些订阅,这是我们 几乎未讨论过的。

使用操作符

订阅和发布者 很棒,但 Combine 的真正力量来自于 操作符。

与订阅和发布者不同,操作符不是协议或实例。 操作符只是发布者的方法,它们将更新重新发布到下游,并创建一系列数据操作链,直到订阅者到达管道的 末端。

运算符帮助我们修改更新、过滤它们、合并它们,并执行许多操作,这使我们能够实现一个理想的结果。

<st c="23375">Combine 框架</st> 内置了许多运算符。现在我们只介绍其中的一些,但您可以在 Apple 网站上查看完整列表:https://developer.apple.com/documentation/combine/publishers-catch-publisher-operators

让我们从一些基本运算符开始。

从基本运算符开始

在 Combine 中,运算符最基本的使用案例之一是用于 filter 过滤 发布者提供的更新。

例如,我们可以使用 <st c="23824">filter</st> 运算符:

 let cancellable = (1...10).publisher
    .filter{ $0 % 2 == 0 }
    .sink { value in
        print(value)
    }

在这个代码示例中,我们创建了一个发布者,它会从 <st c="24001">1</st><st c="24006">10</st> 发出值。<st c="24014">filter</st> 运算符确保只有偶数会继续流向下游。这段代码将在控制台打印 <st c="24108">2</st>,《st c="24109">4,《st c="24111">6</st>,《st c="24112">810`。

另一个过滤运算符的例子是 <st c="24186">removeDuplicates</st>

 let cancellable = [1,2,2,3,3,3,4,5].publisher <st c="24251">.removeDuplicates()</st> .sink { value in
    print(value)
}

代码示例显示了一个发布者,它会发出重复的值。<st c="24370">removeDuplicates</st> 运算符会过滤掉在最后更新中发送的值。控制台将显示以下内容:

 1
2
3
4
5

让我们尝试创建一个自定义运算符来了解运算符是如何在底层工作的。

创建自定义运算符

当我们尝试检查 Apple 的头文件中的 <st c="24644">filter</st> 运算符时,我们可以看到以下内容:

 extension Publisher {
…
public func filter(_ isIncluded: @escaping (Self.Output) ->
  Bool) -> Publishers.Filter<Self>
}

<st c="24834">filter()</st> 是一个接受具有 <st c="24919">Output</st> 泛型类型参数的闭包并返回发布者的函数。这个函数扩展了我们在 创建 自定义发布者 中讨论的 <st c="24977">Publisher</st> 协议。

这里需要注意的重要事情是, <st c="25099">过滤器</st> 函数重新发布值,并允许多个运算符链接在一起以创建复杂的数据 处理管道。

这与 SwiftUI 中的视图修饰符的工作方式类似 – 它们修改当前视图并返回一个新的 视图。

要创建我们自己的自定义 <st c="25371">运算符</st>,让我们尝试做同样的事情并创建一个 <st c="25426">乘法</st> 运算符。 我们的 <st c="25449">乘法</st> 运算符接受一个 <st c="25478">Int</st> 值,并在乘以一个 <st c="25532">特定因子</st> 的同时重新发布它:

 extension Publisher where Output == Int {
    func multiply(by factor: Int) -> Publishers.Map<Self,
      Int> {
        return self.map { value in
            return value * factor
        }
    }
}

在我们的代码示例中,我们还扩展了 <st c="25748">发布者</st> 协议,同时确保 <st c="25786">输出</st> 类型需要是 <st c="25807">Int</st>

然后我们创建了一个接受一个因子作为参数并返回一个 <st c="25832">乘法</st> 函数的新发布者。

在我们的实现中,我们使用了一个 <st c="25948">map</st> 运算符来将我们的值转换成一个新的值,这意味着我们需要返回一个 <st c="26032">Map</st> 发布者。 让我们看看如何使用我们的 <st c="26072">新运算符</st>

 let cancellable = [1, 2, 3, 4, 5].publisher
    .multiply(by: 2)
    .sink { value in
        print("Received value: \(value)")
    }

我们添加了我们的新 <st c="26217">乘法</st> 运算符到以五个数字数组开始的组合流。 此代码的输出将如下所示:

 Received value: 2
Received value: 4
Received value: 6
Received value: 8
Received value: 10

我们创建了我们的 第一个运算符!

然而,如果你像我一样,新地图发布者的返回 可能会让你感到烦恼。 让我们尝试理解为什么会发生这种情况以及我们可以做些什么 来解决这个问题。

与 AnyPublisher 一起工作

我们的直觉告诉我们 ,如果 <st c="26667">乘法</st> 是一个接受 <st c="26706">Int</st> 类型并返回新值的函数,为什么我们需要使用一个 <st c="26764">Map 发布者</st>

因此,我们需要记住运算符会重新发布我们的值。 函数不返回值,而是返回一个发布新值的发布者。 这可能听起来并不明显,但我们的目标是创建一个发布者链,而乘法,尽管其名称如此,是这个链的一部分。

因此,我们的解决方案是返回某种类型的 通用发布者,或者在我们 Combine 中所说的 – <st c="27159">AnyPublisher</st>

<st c="27172">AnyPublisher</st> 是一个类型擦除发布者,我们用它来向我们的发布者提供一个更抽象的接口。

让我们看看我们的 <st c="27298">乘法</st> 操作符 版本,现在它返回 <st c="27343">AnyPublisher</st> 而不是 <st c="27363">Publisher.Map</st>

 extension Publisher where Output == Int {
    func multiply(by factor: Int) -> AnyPublisher<Int,
      Failure> {
        return self.map { value in
            return value * factor
        }
        .eraseToAnyPublisher()
    }
}

在这个代码示例中,我们进行了 两个更改:

  • 我们将函数的返回类型更改为 <st c="27654">AnyPublisher<Int, Failure></st>。这样,我们隐藏了实现细节以及我们使用了 Map 发布者的事实。

  • 我们使用 <st c="27814">eraseToAnyPublisher()</st> 函数擦除了发布者类型,该函数擦除发布者类型并 返回 <st c="27890">AnyPublisher</st>

乍一看,它似乎 <st c="27935">AnyPublisher</st> 只是为了语义上的原因。 但当我提到返回 Map 发布者 让我感到烦恼 时,并不是因为它看起来不漂亮。 而是因为 <st c="28095">AnyPublisher</st> 对我们构建 Combine 流的方式有实际的影响。

一个原因是 API 设计。使用 <st c="28201">AnyPublisher</st> 允许我们设计一个更灵活和多态的 API 接口。 我们之前版本的 <st c="28309">乘法</st> 函数返回了一种特定的发布者类型。 返回 <st c="28376">AnyPublisher</st> 使得将发布者链在一起更容易,因为它们属于同一类型。

另一个原因是 解耦 – 通过返回 <st c="28529">AnyPublisher</st>,我们解耦了发布者的实现与其使用。 通过这种方式,我们使代码更加模块化和易于维护。

The <st c="28668">filter</st> <st c="28679">removeDuplicates</st> 操作符,以及 <st c="28718">map</st>,非常适合简化 和操作管道中的值 我们还在讨论与 Subjects 一起工作 部分时回顾了 <st c="28815">merge</st> 操作符。 但是 Combine 提供了更多高级的操作符。 现在让我们回顾一些 它们。

探索高级操作符

让我们面对现实,到目前为止,我们讨论了即使没有 Combine 也容易完成的任务的操作符。 是的,使用 <st c="29127">map</st> <st c="29130">和</st> filter 操作符非常有价值,但它们并不反映 Combine 真正的附加价值。

Combine 框架的一个目标之一是创建更加复杂和高级的流,这些流在没有它的情况下可能会出错。

让我们理解我的意思并探索 <st c="29407">zip</st> <st c="29410">操作符。</st>

使用 zip 操作符

The <st c="29448">zip</st> 操作符将来自两个发布者的值组合起来,并且只有在每个发布者都发出其值之后才发出一个元组。

一旦 zip `操作符从所有发布者那里收到值,它就会发出一个元组并 *重置 自己。 这意味着它再次等待从所有发布者那里接收值,然后才会发出一个新的元组。

让我们看看一个简单的 <st c="29765">代码示例:</st>

 import Combine
let publisher1 = PassthroughSubject<Int, Never>()
let publisher2 = PassthroughSubject<Int, Never>()
let cancellable = publisher1
    .zip(publisher2)
    .sink { value in
    print("Zipped value: \(value)")
}
publisher1.send(1)  // no output
publisher2.send(10) // output is (1,10)
publisher1.send(2)  // no output
publisher2.send(20) // output is (2,20)

在这个代码示例中,我们使用了两个 Subject 向我们的订阅者发送值。 我们将它们组合在一起并打印 输出。

我们可以看到,在 <st c="30282">publisher1</st> 发送一个值之后,流不会继续,而是等待 <st c="30350">publisher2</st> 发送其值。 只有当 <st c="30391">publisher2</st> 发送一个值后,流才会继续并打印 <st c="30451">(1,10)</st> 到控制台。 此时, <st c="30493">zip</st> 操作符被重置,并且再次等待两个发布者都 发出值。

<st c="30576">zip</st> 操作符不仅限于两个发布者。 我们还可以使用 <st c="30639">zip</st> 为三个发布者,并接收一个包含三个元素的元组

<st c="30698">zip</st> 操作符属于一组处理多个发布者的组合操作符。 我们已经在 <st c="30813">merge</st> 操作符下看到了 *主题一起工作 部分。

属于这一类别的另一个操作符是 <st c="30920">combineLatest</st>。现在让我们来回顾一下 它。

使用 combineLatest 组合多个值

<st c="31007">zip</st> 操作符将多个发布者的输出组合成一个元组。 然而,它每次都等待所有发布者发送值

<st c="31137">combineLatest</st> 操作符只等待所有发布者第一次发出值,从这一点开始,每次其中一个发布者发送一个新值时,它都会发出一个新元组

让我们看看 <st c="31342">combineLatest</st> 的一个例子:

 let publisher1 = PassthroughSubject<Int, Never>()
let publisher2 = PassthroughSubject<Int, Never>()
let cancellable = publisher1
    .combineLatest(publisher2)
    .sink { value in
    print("Combined value: \(value)")
}
publisher1.send(1)  // no output
publisher2.send(10) // output will be 1,10
publisher1.send(2) // output will be 2,10
publisher2.send(20) // output will be 2,20

在这个代码示例中,我们还有两个发送值的主题 这次,我们使用 它们 结合 使用 <st c="31827">combineLatest</st>

<st c="31848">publisher1</st> 发送了它的第一个值之后, <st c="31882">combineLatest</st> 停止流,因为它等待 <st c="31929">publisher2</st> 发送一个值。

一旦 <st c="31962">publisher2</st> 发送了它的第一个值, <st c="31996">combineLatest</st> 会发出一个包含值(<st c="32040">1,10</st>)的元组。

接下来, <st c="32059">publisher1</st> 发送了一个新值—— <st c="32090">2</st>。这次, <st c="32104">combineLatest</st> 不需要等待 <st c="32135">publisher2</st> 发送新值,并发出一个新元组——(<st c="32191">2,10</st>

每次其中一个发布者发送新值时,都会发出一个新元组的行为使 <st c="32291">combineLatest</st> 成为处理异步操作的首选操作符。

想象一下,你有一个由多个来源更新的屏幕,例如实时体育更新的搜索结果屏幕,并且每次我们得到一个新的更新时,我们希望我们的屏幕刷新其 UI 以反映新的状态。

<st c="32579">combineLatest</st> 在这种情况下是理想的,因为它在任何一个发布者发出新值时都会在下游创建一个新的元组。

我们可以使用更多有用的操作符;你可以在苹果的网站上找到它们。 然而,将 Combine 应用于我们的项目中的真正挑战是理解如何在现实生活中的用例中实现它们。

通过示例学习 Combine

到目前为止,我们已经讨论了几个 Combine 组件,并通过创建我们的自定义发布者、订阅者和操作符来深入了解 Combine 是如何工作的。

尽管如此,许多开发者需要帮助将 Combine 框架整合到现实场景中。

不同的发布者和操作符在理论上大多是清晰的,但想象它们作为我们项目中使用的中心设计模式的一部分可能会有困难。

让我们回顾一些示例,以帮助我们了解如何在我们的项目中实现 Combine。 我们将从一个基本的示例开始,该示例在视图模型中管理 UI 状态。

在视图模型中管理基于 UIKit 的视图状态

SwiftUI 视图状态 自然是声明式的。 这意味着我们可以将视图状态,例如项目列表,绑定到 UI 组件,例如一个List 视图。 这是在 SwiftUI 中处理状态的唯一方法。

然而,在 UIKit 中实现该设计模式需要时间和精力。

使用 Combine,我们可以创建一个发布者并将我们的表格视图数据源绑定到反映来自服务器的任何变化。

以下是一个这样的视图模型的代码示例:

 class MyViewModel {
    struct Item: Codable {
        let title: String
        let description: String
    }
    var dataPublisher: AnyPublisher<[Item], Error> {
        return URLSession.shared.dataTaskPublisher(for:
          URL(string: "https://api.example.com/data")!)
            .map { $0.data }
            .decode(type: [Item].self, decoder:
              JSONDecoder())
            .eraseToAnyPublisher()
    }
}

我们已经讨论了AnyPublisher 表单,那是一个其用法的好例子。 我们创建了一个以 URL 请求开始的发布者,使用map 操作符提取其数据,并将其解码为 Items 数组。 为了隐藏发布者的实现,我们擦除了AnyPublisher的类型。 现在,由于我们有发布者,将视图模型连接到视图控制器变得简单了:

 viewModel.dataPublisher
            .sink(receiveCompletion: { completion in
            }, receiveValue: { [weak self] data in
                self?.updateTableView(with: data)
            })
            .store(in: &cancellables)

在这个代码示例中,我们订阅了我们的新 <st c="34981">dataPublisher</st> 并使用 <st c="35026">数据</st> 更新我们的表格视图。

为了使我们的项目 更加模块化,我们可以将 <st c="35086">URLSession</st> <st c="35101">dataTaskPublisher</st> 函数移动到它自己的类中,并保持关注点分离原则。

从多个来源执行搜索

iOS 开发中最受欢迎的 用例之一是从服务器和本地数据库执行搜索。

此类搜索的要求首先是显示来自本地数据存储的结果,然后转到服务器并 返回结果。

这是一个常见的要求,使用 Combine 也很容易。 让我们看看一个简单的例子

 func searchLocalDatabase(query: String) -> AnyPublisher<[SearchResult], Never> {
    return Just([
        SearchResult(id: 1, title: "Local Result 1"),
        SearchResult(id: 2, title: "Local Result 2")
    ])
    .delay(for: .seconds(1), scheduler: DispatchQueue.main)
    .eraseToAnyPublisher()
}
func searchServer(query: String) ->
  AnyPublisher<[SearchResult], Never> {
    return Future { promise in
        DispatchQueue.global().asyncAfter(deadline: .now()
          + 2) {
            promise(.success([
                SearchResult(id: 3, title: "Server Result
                  1"),
                SearchResult(id: 4, title: "Server Result
                  2")
            ]))
        }
    }
    .eraseToAnyPublisher()
}
var cancellables = Set<AnyCancellable>()
let query = "example"
var totalResults = [SearchResult]()
searchLocalDatabase(query: query)
    .merge(with: searchServer(query: query))
    .sink(receiveCompletion: { _ in }, receiveValue: {
      results in
        totalResults.append(contentsOf: results)
        print("Search results: \(totalResults)")
    })
    .store(in: &cancellables)

在这个代码 示例中,我们执行了 三个 主要步骤:

  1. <st c="36901">Just</st> <st c="36910">Future</st>。我们可以使用 <st c="36929">Just</st> 来启动流,而 <st c="36957">Future</st> 是我们用来执行任务并异步发出值的发布者。

  2. <st c="37167">合并</st> 操作符。 记住,当其中一个来源发出新值时,合并 操作符会发出更新。 我们也可以使用 <st c="37291">combineLatest</st>,但 <st c="37310">combineLatest</st> 在发出组合值之前会等待所有发布者发出值。

  3. <st c="37510">totalResults</st> 数组。 我们的数据流不必在这里结束。 我们可以将 <st c="37582">totalResults</st> 转换为 <st c="37597">CurrentValueSubject</st> 实例,并将结果传递给视图模型或视图本身。 如果我们使用 SwiftUI,我们可以将 <st c="37732">totalResults</st> 转换为 <st c="37747">@Published</st> 变量以自动刷新搜索结果的 UI。

这里有一个很好的教训,与在我们的项目中使用 Combine 有关。 如果我们为不同的数据源创建发布者并确保它们发出值,那么创建更新管道并将它们连接到项目的其余部分就变得容易了。

以下示例处理另一个日常用例,即 表单验证。

表单验证

表单是任何面向用户的平台中的常见用例,而不仅仅是 iOS。 创建表单的最基本责任之一是能够验证 它们的输入。

让我们看看如何使用组合来验证一个简单的 登录表单:

 struct FormView: View {
    @ObservedObject var viewModel = FormViewModel()
    var body: some View {
        VStack {
            TextField("Username", text:
              $viewModel.username)
              .padding()
             .textFieldStyle(RoundedBorderTextFieldStyle())
            SecureField("Password", text:
              $viewModel.password)
              .padding()
             .textFieldStyle(RoundedBorderTextFieldStyle())
            Button("Login") {
                if viewModel.isFormValid {
                    print("Login successful!")
                } else {
                    print("Please fill in all fields.")
                }
            }
            .padding()
            .disabled(!viewModel.isFormValid)
        }
        .padding()
    }
}

我们的表单包含两个文本字段—— <st c="38930">用户名</st> <st c="38943">密码</st>。我们还有一个附加到视图的视图模型。 视图模型有几个 <st c="39028">@Published</st> 变量,例如 <st c="39058">用户名</st> <st c="39068">密码</st> <st c="39082">isFormValid</st><st c="39099">用户名</st> <st c="39112">密码</st> 变量连接到视图 `文本字段。

现在,让我们看看 <st c="39189">FormViewModel</st> 类:

 class FormViewModel: ObservableObject {
    @Published var username: String = ""
    @Published var password: String = ""
    @Published var isFormValid: Bool = false
    private var cancellables = Set<AnyCancellable>()
    init() {
        Publishers.combineLatest($username, $password)
            .map { username, password in
                !username.isEmpty && !password.isEmpty
            }
            .assign(to: \.isFormValid, on: self)
            .store(in: &cancellables)
    }
}

当我们初始化视图 模型时,我们基于 <st c="39682">combineLatest</st> 操作符创建一个组合流来观察 <st c="39731">用户名</st> <st c="39744">密码</st> 变量的变化。<st c="39768">map</st> 操作符确保两个变量都不为空,并将结果(<st c="39851">Bool</st>)分配给 <st c="39866">isFormValid</st> 变量。

视图观察 <st c="39910">isFormValid</st> 值,并使用它来打开和关闭 `登录按钮。

这个流是基本的;我们可以不使用组合来实现相同的结果。 然而,表单在某些时候可能会变得非常复杂。 我们创建的组合管道是更复杂表单的绝佳基础设施。

即使对于 <st c="40215">用户名</st> <st c="40228">密码</st> 的简单规则也可以很容易地使用我们的流来强制执行,就像在这个例子中一样:

 Publishers.combineLatest($username, $password)
            .map { username, password in
                let isUsernameValid = !username.isEmpty &&
                  username.count >= 6
                let isPasswordValid = !password.isEmpty &&
                  password.count >= 8 && password.contains(
                    where: { $0.isNumber })
                return isUsernameValid && isPasswordValid
            }
            .assign(to: \.isFormValid, on: self)
            .store(in: &cancellables)

在这个代码示例中,我们使用我们的组合流 来强制执行规则——密码需要至少包含一个数字的八个字符,用户名需要至少六个字符。 <st c="40838">map</st> 操作符非常适合集中化这个逻辑,输出一个布尔值,并将其分配给 <st c="40941">isFormValid</st> 值。

总结

Combine 使我们的代码在 SwiftUI 视图之外变得响应式。 它是一个可以帮助我们处理复杂任务,如搜索、网络请求和 状态管理。

本章回顾了基本 Combine 组件,例如发布者、订阅者和操作符。 我们还深入研究了每个组件的定制版本。 我们学习了如何创建具有数据转换和网络请求的管道。 最后,我们学习了如何将 Combine 应用于常见的 用例。

到目前为止,我们应该能够开始在现有的 项目中使用 Combine。

下一章将涉及许多 iOS 开发者感到烦恼的另一个主题—— Core Data。

第十三章: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大型语言模型)服务,如ChatGPTGemini。另一个例子是涉及许多机器学习模型的项目,如自动驾驶汽车,这些项目包括目标检测和决策

我们现在明白,机器学习模型是人工智能的一个构建块

让我们讨论这个重要的主题

具有决策树算法的模型包含一个树,其中叶子代表不同的决策或预测

但这里的数据 是什么意思呢?

在本章接下来的内容中,我们将把构建和创建模型数据称为训练

训练模型

两个不同的机器学习模型可以拥有相同的算法和结构,但由于训练过程的不同,数据可能不同。 使用训练过程,我们教会模型根据输入数据做出准确的预测和决策。 这个过程涉及优化模型的数据(参数),以便在未见过的数据上准确地进行预测。 这个过程包括优化模型的数据(参数)以在未见过的数据上准确执行预测。

训练一个模型需要我们执行几个步骤:

  1. 数据收集:我们需要准备一个相对较大的数据集来训练我们的模型。 我们还必须通过处理缺失值、清理无关数据项和归一化值来预处理数据。

  2. 分割数据集:现在我们有了数据集,我们必须将其分为训练数据、验证集和测试集。 我们在不同的训练阶段使用这些数据集。

  3. 选择我们的机器学习算法:每种算法旨在解决不同的问题。 例如,逻辑回归算法解决分类问题,线性回归算法解决回归问题。

  4. 前向传播:我们将训练数据通过模型来做出预测。

  5. 验证:我们使用验证数据集来评估模型的表现,并根据结果调整模型。

  6. 测试:我们使用测试数据来评估我们的模型在实时使用案例中,使用未见过的输入数据时的性能。

这是训练过程的概要图。 在实践中,这个过程包含更多步骤,例如计算损失和优化。 然而,目标是让你对训练过程有一个大致的了解,以便你能理解以下主题。 别担心——我们很快就会一起构建和训练一个机器学习模型!

既然我们已经了解了机器学习模型是什么,让我们尝试理解它如何与 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,请按照 以下步骤操作:

  1. 打开 Xcode。

  2. 在 Dock 上的 Xcode 图标上 右键单击。

  3. 选择 打开开发者工具 | Create ML

另一种打开 <st c="35374">Create ML</st> 的方法是在 Mac 的 Spotlight 中搜索它,并 选择它。

打开它并点击 新建文档 按钮后,我们会看到以下屏幕(图 12.1**.1):

图 12.1:创建 ML 模板选择器

图 12.1:创建 ML 模板选择器

图 12**.1 显示了 Create ML 模板选择器屏幕。 每个模板代表我们模型的不同配置,每个都是为处理不同类型的数据而设计的。 例如, 图像分类 模板是为处理图像而设计的。 由于我们想要对 文本消息进行分类,我们将选择 文本分类 模板并点击 下一步 按钮。

这将带我们进入项目详情屏幕(*图 12.2**):

图 12.2:项目详情表

图 12.2:项目详情表

在项目详情表中,我们将填写一些关于我们的 ML 模型的一般信息,例如名称、作者、许可证和描述,然后 点击 下一步

我们的下一个屏幕是项目窗口(*图 12.3):

图 12.3:SpamClassifier 项目窗口

图 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):

图 12.4:包含 300,000 条记录的训练数据

图 12.4:包含 300,000 条记录的训练数据

图 12**.4 显示了 <st c="40156">真实</st> <st c="40165">虚假</st>,如前所述。 此外,我们还在左侧面板中有一个新的 数据源 – 我们作为训练数据集导入的文件。

现在我们有了训练数据,我们可以处理 验证数据 了。 提醒一下,作为训练过程的一部分,我们将使用验证数据来调整模型。 我们可以提供自己的验证数据,但 Create ML 允许我们从我们刚刚提供的训练数据集中分割它。 我们可以在评估步骤中稍后添加 测试数据集

第三个数据集是 测试数据。我们使用测试数据来查看模型如何对未见过的文本进行分类。 我们可以在评估步骤中稍后添加 测试数据集

除了选择不同的数据集,我们还可以设置训练将进行的迭代次数和 模型算法

每次迭代中,训练过程可以通过回顾前一次迭代的错误并调整其参数(如神经网络中的权重)来自我调整。我们的直觉可能会说,迭代次数越多,我们的模型就越聪明。然而,事实并非如此。首先,在某个点上,再进行迭代并不能提高模型,只会消耗计算资源。但真正的问题是所谓的过拟合。过拟合是指机器学习模型学习训练数据过于完美,包括其噪声。在这种情况下,分析未见数据将会有问题。

另一个参数是模型算法(图 12.5):

图 12.5:选择模型算法

图 12.5:选择模型算法

图 12.5 显示了弹出菜单,我们可以从五个不同的选项中选择模型学习算法。算法概述不在本章的范围内,但简而言之,不同的算法适合不同的需求,并消耗其他资源。例如,BERT算法非常适合语义理解,而条件随机字段算法非常适合序列标注。在我们的案例中,我们将选择最大熵算法,它非常适合分类

现在我们已经准备好了所有数据集,我们可以点击左上角的训练按钮并开始训练

执行训练

现在,我们已经到达了主要环节——训练阶段。在训练阶段,Create ML 应用程序会使用我们在准备我们的数据部分定义的算法遍历训练数据集。让我们尝试描述这个过程:

  • 在每次迭代中,模型使用验证数据集来验证自身。记住,验证数据集可以是不同的。然而,默认情况下,它是训练数据集的一个子集。

  • 训练 *阶段的时间长度由三个主要因素决定 ——数据集大小、迭代次数和 选择的算法。

  • 模型不需要执行我们在 设置 选项卡中定义的迭代次数。 如果验证准确率达到高水平, *训练将提前停止 * 以节省资源并 避免过拟合。

在训练 过程结束时,我们将看到以下图表(图 12**.6):

图 12.6:训练过程结束时的训练选项卡

图 12.6:训练过程结束时的训练选项卡

图 12**.6 显示了我们在训练阶段的表现。 我们可以看到,在仅经过两次迭代后,我们就达到了高准确率。 在这种情况下,这是因为我们的训练数据集结构良好且可靠。 然而,情况并不总是如此,因此我们需要在这个步骤中保持耐心。

现在我们的模型已经训练好了,我们需要对其进行测试。 为此,我们将使用我们的测试数据集作为评估步骤的一部分(图 12**.7):

图 12.7:模型评估步骤

图 12.7:模型评估步骤

图 12**.7 显示了评估步骤以及用于训练和验证模型的不同的数据集。 我们还可以看到测试数据包含一个包含 1,000 个项目的数据集。 测试数据集 的结构与训练和验证数据集相似。 点击 测试 按钮将在数据集中的所有 1,000 个项目上运行分类,并测量它们的分类准确率。 让我们看看测试结果(图 12**.8):

图 12.8:评估结果

图 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):

图 12.9:预览选项卡

图 12.9:预览选项卡

图 12**.9 显示了我们的模型的 预览 选项卡,其中有一个示例消息说, 现在打电话获取邀请。我们的 模型以 92%的置信度将该消息识别为垃圾邮件。 干得好!

现在,让我们看看我们如何部署 我们的模型。

部署我们的模型

如果我们不能在 Xcode 中部署它,那么拥有一个出色的训练过程是没有意义的。 这就是为什么我们有 输出 选项卡(图 12**.10):

图 12.10:创建 ML 输出选项卡

图 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.1:我们的意图在快捷方式应用中显示

在 *图 13.1中,我们可以看到在搜索我们应用的操作时,快捷方式应用中获取打开任务数量的快捷方式的意图。然后,我们可以将操作添加为新快捷方式。接下来,让我们看看当我们运行意图时会发生什么(图 13.2):

图 13.2:运行获取打开任务数量的快捷方式

图 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:包含两个操作的快捷方式

图 13**.3中,我们可以看到我们的快捷方式包含两个操作:

  1. 第一个是 <st c="9466">AddTaskIntent</st> 来自 `我们的应用。

  2. 第二个是,我们的意图的结果是任务标题和来自 发送消息 动作的消息应用。

我们可以看到,可以将操作串联起来,创建 <st c="9650">强大的流。</st>

让我们看看当我们运行我们的快捷方式时(图 13**.4)的样子:

图 13.4:运行 AddTaskIntent

图 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>

这里有两个奇怪的地方:

  1. 首先,为什么我们需要创建一个专门的列表视图? 我们不能在我们应用程序中重用已经存在的视图吗?

  2. 其次,为什么我们使用 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:意图响应中的任务列表

图 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:一个确认对话框

图 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:打开任务意图

图 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

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:在快捷方式应用中选择标题属性

图 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:具有不同数据格式的快捷方式应用

图 13**.10 展示了用户如何选择导出项目的数据格式。 导出项。

我们在应用实体中支持更多格式和数据类型,用户在使用我们的数据时就有更多的选择。

在这个阶段,我们知道如何导出 我们的应用意图和实体。 从某种意义上说,系统知道我们的应用能做什么。 让我们看看我们如何更进一步,并调整它以与 苹果智能技术一起工作。

调整我们的应用意图以与苹果智能技术一起工作

在上一章中,我们讨论了 如何利用一些 iOS 的机器学习和人工智能功能。 iOS 18 中在该领域演变的一件事是 Siri。 Siri 现在比以往任何时候都更聪明,并允许用户以 自然语言执行任务。

例如,用户可以说 将这张照片发送给我的妈妈 给 Siri,Siri 可以处理这个任务而无需 确切的短语。

Siri 的新功能正是我们的应用程序意图所在。 想象一下,在一侧,我们有 Siri,它可以理解用户意图。 在另一侧,我们有我们向系统公开的各种操作。 因此,我们必须找到一种方法来绑定用户意图,正如 Siri 所理解的那样,以及我们的应用程序操作。 这就是我们所说的 助手模式

探索助手模式

助手模式 的想法简单而先进,充满潜力。

让我们看看 图 13**.11,它描述了助手 模式的工作方式:

图 13.11:助手模式流程

图 13.11:助手模式流程

图 13**.11 描述了助手模式 流程的工作方式:

  1. 用户以 自由而自然的短语 请求 Siri 执行特定操作。 在这种情况下,用户说,“将这封邮件发送给我的妻子。”

  2. Siri 和苹果智能使用复杂的机器学习模型将请求转换为系统预定义的其中一个模式。 在这种情况下,苹果智能将用户请求转换为来自 <st c="30775">createDraft</st> 模式<st c="30803">mail</st> 域。

  3. 现在 Siri 知道所选模式后,它会寻找工具箱中匹配的意图。 我们可以将一些应用程序意图与特定的模式关联起来。

  4. 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):

图 14.1:新的目标模板选择器

图 14.1:新的目标模板选择器

图 14**.1 显示了 Xcode 中的模板选择器窗口。 在执行测试搜索时,单元测试包 很容易找到。 请注意,我们还有一个 UI 测试包 模板。 然而,在 Swift 测试中目前还不支持 UI 测试,所以现在我们将专注于 单元测试。

我们如何进行 UI 测试?

UI 测试,也称为端到端测试,是应用测试中的一个不同 主题。 它也被我们称为“黑盒”测试,意味着测试函数不知道内部代码,只知道用户界面组件。 进行 UI 测试的基本方法是使用 XCTest,苹果之前使用的测试框架。 然而,有一些服务提供更简单或跨平台的远程运行 UI 测试的方法。

一旦你选择了 单元测试包 模板,点击 下一步。现在,我们需要填写一些关于我们新测试目标(图 14**.2)的详细信息:

图 14.2:为我们的新测试目标选择选项

图 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):

**图 14.3**:Xcode 中列出的测试

图 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):

图 14.4:Xcode 中的测试面板,带有自定义名称

图 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):

图 14.5:测试面板中的禁用测试

图 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>
			![Figure 14.6: A skipped test function due to a specific false condition](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>
			![Figure 14.7: The Tags section of the test pane in Xcode](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>
			![Figure 14.8: Running all critical tests](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>
			![Figure 14.9: Argument testing in the Xcode testing pane](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>
			![Figure 14.10: Relations between the different building blocks of testing](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>
			![Figure 14.11: The suite in the Xcode test pane](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>
			![Figure 14.12: Nested test suites in the test pane](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>
			![Figure 14.13: The autocreated test plan](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>
			![Figure 14.14: The new test plan in Xcode](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>
			![Figure 14.15: The test plan’s Configurations tab](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>
			![Figure 14.16: Shared Settings](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>
			![Figure 14.17: Editing the current Scheme](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>
			![Figure 14.18: The Edit Scheme screen](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):

图 15.1:三层架构

图 15.1:三层架构

*图 15.1 显示了 我们通常将应用分为的三个层。

让我们尝试理解 这些层:

  • 数据 有时也可以称为 服务。数据层处理数据的持久存储、模型实体、网络处理以及主要处理低级别数据的各种服务,而不考虑 项目的逻辑。

  • 业务逻辑 有时也可以称为 领域。业务逻辑层处理应用的主要逻辑,包括规则和 数据处理。

  • 表示层 处理 UI、用户交互 和导航。

有些模式甚至有更多层——例如,一个 应用* 层,它处理不同的用例,可以放在表示层之下,或者一个 基础设施* 层,它处理类扩展、工具 等。

如果你是一位经验丰富的开发者,将项目分离的想法应该是显而易见的。 分离代码创建了一个可测试和可维护的结构,随着时间的推移可以扩展。 然而,使用层的想法并不 总是显而易见。

最终,这取决于数据在应用中的流动方式。

控制应用数据流

数据流是 任何程序的核心主题。 为了阐明这个术语,我们必须检查不同应用组件之间消息和数据是如何流动的。 例如,当用户在其屏幕上的一个按钮上点击 保存 按钮时,我们需要将这个点击转换成一个实际的逻辑 决策,并将其继续到持久存储,在那里我们可以将信息本地保存。 数据流不会在这里结束 – 在这一点上,我们需要向 UI 发送一个消息,表示持久存储中已经发生了变化,我们应该更新屏幕上的内容。

此示例仅演示了一个单一用例。 标准移动应用可能有数百种此类情况,强调了考虑如何划分 我们的项目的重要性。

现在我们了解了数据流,让我们来讨论 开放 封闭 层。 在三层架构中,如 图 15**.1所示,表示层与业务逻辑层进行通信。 但是,这意味着表示层也可以与 数据层进行通信吗?

例如,表示层可能直接从数据层接收数据变更的更新。 在这些情况下,与业务逻辑作为中间件一起工作可能更加复杂和繁琐。 在这种情况下,我们必须决定我们的层是开放的 还是封闭的。

开放层允许 上层和下层之间的直接交互。 虽然开放层提供了更高的灵活性和简单性,但它们也可能增加耦合并减少关注点的分离。

一个 封闭层强制执行严格的交互,并且其相邻层之间的每次通信都必须通过封闭层本身。 封闭层可以增加关注点的分离并减少耦合,同时降低灵活性和 增加复杂性。

在处理三层架构时,讨论封闭层和开放层可能听起来有些奇怪。 中间层(业务逻辑)是唯一一个可以是开放或封闭的层。 然而,我们可以决定层是严格封闭还是 选择性封闭。

每一层都是由一组组件构建的。 例如,表示层可以由不同的应用程序屏幕或流程构建。 业务逻辑层可以由应用程序的不同逻辑部分构建,而数据层是由不同的服务构建的,例如网络、数据 和安全。

在某些情况下,表示层中的组件必须直接与 数据层进行通信。

查看 图 15**.2,它 展示了消息应用程序的基本三层架构:

图 15.2:选择封闭层

图 15.2:选择封闭层

图 15**.2 显示了之前讨论过的相同三层——表示层、业务逻辑层和数据层。 然而,这次,我们将层分解为不同的组件。 此外,我们展示了多个组件之间的各种通信路径。 例如,登录 UI 组件与应用程序的入职逻辑部分通信,而消息逻辑部分与数据和安全组件通信。 尽管大多数通信都通过业务逻辑层进行,但我们看到一些例外。 例如,以下例外 可能适用:

  • 登录 UI 组件直接与安全组件通信,可能为了了解当前的 认证状态

  • 线程 UI 组件与网络组件通信,以在 UI 中展示网络状态

我们可以动脑筋想出一种方法让这些情况通过业务逻辑层;然而,在某些情况下,绕过它并直接访问数据层是完全可接受的。 在某些情况下。

我们选择的架构服务于我们的项目需求,而不是相反。 然而,我们需要定义一种绕过业务逻辑层的策略,因为每个例外,包括在 图 15**.2中描述的,都会在我们的结构中造成另一种耦合。

我们讨论了架构的三个层级,但这是否总是三个层级? 我们是否有更多的层级? 让我们找出是否有可能创建一个更复杂但更有用的架构。 是可能的。

添加更多层级

三层协同工作是在简单性和良好分离之间的最佳平衡点。 然而,在大项目中,有时仍需要满足关注点分离的原则。 尽管只有一个层用于展示和另一个层用于业务逻辑看起来非常直接,但仍然存在一些需要澄清的困境。 需要澄清。

以 iOS 应用程序中可能拥有的两个不同组件为例——用户服务和支付服务。 两者都是应用程序业务逻辑的一部分。 当用户想要进行购买时,我们想使用用户服务来检查他们的角色,然后转到支付服务进行购买。 购买后,我们想将用户导航到屏幕并显示支付成功。 因此,我们可以看到我们有一个涉及整合不同的业务逻辑服务和协调不同屏幕的用例(图 15**.3):

图 15.3:支付用例,结合多个组件和层

图 15.3:支付用例,结合多个组件和层

我们需要 在展示逻辑、业务逻辑,还是在这里和那里的一半来管理该用例吗? 好吧,这种逻辑可能分布在组件中,或者集中在一个屏幕视图模型中。 记住,在大多数情况下,视图模型处理的是 UI 状态而不是应用程序逻辑。

处理与导航捆绑的用例的问题并不新鲜,在更复杂且需要更多灵活性的应用程序中,在设计我们的 应用程序结构时需要考虑这一点。

因此,为了分离我们的关注点,我们可以添加另一个层—— 应用 层,它可以处理特定的应用程序用例(图 15**.4):

图 15.4:四层架构

图 15.4:四层架构

图 15**.4 显示了与 图 15**.2相似的一种架构设计,这次加入了应用层。 应用层有四个用例:登录,创建联系人删除联系人,以及 创建群聊。这些用例处理从调用其他组件中的函数到导航的所有事情。 应用层使业务层和表示层从特定的 应用逻辑中变得更加清晰。

我们还能添加更多层吗?

应用层协调多个组件以创建特定于应用的逻辑。 我们可以在架构的底部实现相同的概念,即在业务逻辑和数据层之间。

例如,让我们讨论一个数据同步过程。 从网络检索数据并将其存储在持久存储中是一个复杂的过程,涉及错误处理和处理各种边缘情况。 这是业务逻辑的一部分还是 数据层的一部分?

数据操作 创建、读取、更新和删除 (CRUD)操作也是不清楚在哪个层处理这些任务的。

因此,为了处理不是业务逻辑但专注于从各种来源访问和管理数据的任务,我们可以添加 另一个称为 数据访问层 (图 15**.5):

图 15.5:数据访问层

图 15.5:数据访问层

图 15**.5 显示了我们的 架构从 图 15**.4,现在增加了一个额外的层——数据访问层,该层处理同步服务、CRUD 操作和数据映射,将数据模型对象转换为业务 逻辑实体。

拥有超过三层可能会听起来过于复杂,并暗示过度设计。 然而,这种策略确保了不同层之间关注点的优秀分离。 业务逻辑不涉及数据操作,表示层不处理复杂用例。 在中型和大型应用中,当我们的应用变得更大时,将我们的项目分为四到五层可能会带来回报。

将项目分层只是我们可以考虑的视角之一。 我之前提到过,像这样的架构模式更像是一种原则。 真正的力量在于我们结合不同的模式。 让我们来探讨模块化架构模式。

将我们的项目划分为模块

我提到过 将项目分层只是我们可以从项目中观察到的视角之一。 然而,这究竟意味着什么?

以我们的即时通讯应用程序为例。 不同的层代表不同的关注点:展示、业务逻辑和数据。 我们的应用程序数据从 UI 层流向数据层,然后再返回。

另一种看待我们的应用程序的方式是通过封装一组功能或特定业务领域单位的代码单元。 我们可以将这些代码单元称为模块

了解在模块开发中需要考虑的不同因素

将我们的项目划分为模块需要仔细考虑,因为这个步骤对于应用程序的结构随时间发展至关重要。 例如,在即时通讯应用程序中,模块可以是用户认证模块、用户资料模块、联系人模块和消息模块。 这些模块反映了应用程序的不同领域,并且将我们的应用程序划分为模块的决定非常灵活。

然而,一些关键因素可以帮助我们决定:

  • 功能性和业务领域:我们已经在上一段中提到过这一点。 将应用程序分解为核心功能可以是我们理解项目不同模块——逻辑、歌曲播放器、提醒、入门等——的一个很好的开始。

  • 可重用性:将我们在应用程序的不同部分使用的功能分组是理解如何创建模块的另一种方式。 例如,如果我们的应用程序执行不同的 HTTP 请求,我们可能会创建一个网络模块来管理所有的 API 调用。 另一个例子是共享组件——如果我们使用相同的按钮在不同的屏幕上,这可能是一个迹象,表明它应该是包含不同可重用组件的 UI 模块的一部分。

  • 解耦:我们的模块应该尽可能地与其他模块解耦。 模块之间的相互依赖程度可以定义是否将其创建为模块是一个优秀的决定。 此外,如果可以为模块创建一个清晰的接口,这也是它可能是一个 好模块的另一个迹象。

  • 协作:想象一下 几个团队正在我们的项目上工作。 他们可以不互相干扰地工作,这表明模块的分离做得很好。 请注意,无论我们是一个人的团队还是由六个开发者组成的五个团队,这条规则的相关性都是一样的。 原则是 最重要的。

我们必须问自己:我们能否创建另一个应用程序,并在新应用程序中使用我们的一些模块,就像使用乐高积木一样? 我们能否单独测试每个模块? 这些问题可以让我们对模块是否真正独立或存在 紧密耦合有一定的认识。

组织我们项目中的代码

关于将我们的代码组织成模块的几点说明 ——模块是一个抽象的定义,因为没有任何官方的技术方法可以将我们的代码正式地分成模块。 然而,我们可以区分两种方法—— 物理 功能

  • 在物理方法中,我们使用专用工具创建我们的模块。 例如,CocoaPods、Swift Packages 和 XCFrameworks 提供了一种将我们的代码物理封装成 代码单元的方法。

  • 在功能方法中,我们不使用任何特定工具,而是将代码组织成文件夹。 这种简单的方法非常适合小型项目 或团队。

在这里的主要考虑因素很明显:物理方法中的可重用性和独立性,与功能方法中的简洁性和灵活性。 然而,让我们更深入地探讨,使这种比较更加实用和与我们作为 iOS 开发者日常工作的相关性。 iOS 开发者。

创建一个新项目并理解不同的模块可能相当具有挑战性。 一方面,良好的规划对于我们的项目开发在时间上的成功至关重要。 另一方面,预测我们的项目在未来几年如何发展是不可能的。 因此,我们在一开始需要的是灵活性。 因此,从功能方法开始,即通过文件夹创建模块,可能是大多数项目的正确方法。

随着项目的增长,我们模块的灵活性可能成为其劣势。 物理方法的一个优点是我们通过将代码封装到 pod 或包中,为我们的模块创建了清晰的边界。 这些边界阻止我们在不正确处理不同依赖项的情况下包含外部类和类型。 它们还迫使我们为模块声明一个清晰的接口,因为私有和内部函数和类从外部不可访问。 随着项目的演变和开发团队的壮大,这些限制是基本且有价值的。 不同的 pod 或包允许其他团队独立地对每个模块进行工作、构建和测试。 它甚至允许我们在 不同的项目中共享相同的模块。

既然我们已经确信模块很重要,那么层级的想法如何适应呢? 我们必须在层级和模块之间做出选择吗? 还是它们是 同一件事?

让我们尝试将事物 整理有序。

将多层架构与模块相结合

我们之前 提到,多层和模块更像是架构模式或概念。 它们是我们构建应用程序的指南,在我们的项目中结合不同的概念和模式是常见的做法,而不是仅仅 坚持一种模式。

以应用入职模块为例(图 15**.6):

图 15.6:入职模块

图 15.6:入职模块

图 15**.6 展示了分层的入职模块结构,分为四个不同的层级。 将模块化和多层架构相结合的一种方法是将每个模块分离到不同的层级中创建一个矩阵结构。 在这种情况下,入职模块包括展示、协调器、业务逻辑, 和数据。

另一种情况涉及由几个模块构建的层。 在这种情况下,每一层都是由几个模块构建的业务单元。

让我们比较 这两个架构:

图 15.7:比较模块和层的两种组合方法

图 15.7:比较模块和层的两种组合方法

图 15**.7 展示了我们讨论的两种方法并排展示。 乍一看,这两种方法看起来很相似,只是从不同的角度来看。 然而,它们代表了两个不同的项目需求,并且极大地影响了可扩展性、独立性和耦合性。

例如,让我们看看数据模块。 在右侧(包含多个层的模块),我们可以看到每个模块都有自己的数据模块。 然而,在左侧(由多个模块构建的层),我们有一个可以服务于各种屏幕和业务单元的数据模块。 对于更多数据层模块,如分析、网络和安全性也是如此。

当我们考虑时,为了使模块真正独立,它需要包含所有层和服务。 这也意味着在某些情况下,我们可能需要复制一些代码在 某些情况下。

一如既往,我们在封装性和独立性以及集中逻辑和一致性之间有一个权衡。 因此,在实践中,我们必须平衡这一点,创建一个混合方法,并结合两种方法(图 15**.8)的元素:

图 15.8:由模块构建的层和包含多个层的模块的组合

图 15.8:由模块构建的层和包含多个层的模块的组合

图 15**.8 展示了我们讨论的混合方法。 注意,我们有几个模块—— 用户消息通知 ——每个都包含屏幕和业务逻辑。 然而,数据、网络和分析服务是在不同的模块之间共享的。

混合方法意味着不同的模块只有部分独立性。 另一方面,它表达了重用性和封装性之间的一种很好的平衡。

我们可以更进一步,共享更多的逻辑、实用工具和 UI 组件。

多层和模块架构对大多数开发者来说都很直观。 它们代表了一种检查应用和项目的逻辑方式——无论是通过关注层、领域 ,还是两者结合。

我们能否采用不同的架构方法? 让我们尝试以不同的方式看待我们的应用——使用 六边形架构

构建六边形架构

多层架构描述了应用作为数据通过不同关注层流动。 模块架构描述了应用作为不同模块之间的通信。

为了探讨一种不同的架构方法,我们讨论一下应用的意义。 什么是应用? 它是屏幕吗? 它是 逻辑吗?

在六边形架构中,我们 认为业务逻辑是应用的核心和灵魂。 以我们的消息应用为例。 应用的核心是消息逻辑,包括我们的认证方式和不同的数据模型。 我们将这部分业务逻辑称为 领域模型

那么,关于不同的屏幕、核心数据和网络层呢? 在六边形架构中,这些应用部分并不在核心位置。 UI 屏幕看起来像是领域模型的客户端,而网络和核心数据部分为 领域层提供服务。

看看 图 15**.9

图 15.9:领域模型及其客户端和服务

图 15.9:领域模型及其客户端和服务

图 15**.9 显示了领域模型位于中心,而其客户端和服务 围绕其周围。

与六边形架构相关的下一个概念是不同的参与者如何连接到领域模型。 这些参与者通过端口 和适配器进行连接。

学习端口和适配器的概念

将我们的 应用 视为一个计算机系统。 计算机有它的主板、CPU、GPU 和内存。 我们可以连接外部输入设备,例如键盘、鼠标或触摸板。 我们还可以连接输出设备,例如显示器、扬声器或 打印机。

我们知道 计算机是如何构建的——如果我们需要指出被认为是计算机核心的东西,那不会是打印机或显示器,而是其主板和 CPU。 然而,我们能否将任何我们想要的设备连接到 计算机上?

要做到这一点,我们需要 两样东西:

  • 计算机上的一个端口,允许我们连接设备;例如,USB 或 HDMI

  • 安装在设备上且知道如何与计算机所需的端口和接口工作的驱动程序 计算机需要

每个键盘、打印机或鼠标都有一个适合计算机端口的插头和一个实现某些协议的驱动程序,允许此设备与计算机通信。 一般来说,只要它符合计算机要求的协议,我们就可以连接任何我们想要的设备。

当我们回到六边形架构时,我们可以将领域模型视为计算机本身,将网络或 UI 视为打印机和键盘。 此外,我们还有两个更多术语——端口 和适配器:

  • 端口:这是一个 领域内外部的入口或出口点。 在 Swift 中,我们使用协议来描述 端口。

  • 适配器:当一个特定类别想要连接到端口时,它需要实现 端口协议。

大多数 iOS 开发者都熟悉端口和适配器的概念。 最终,这将是通过协议解耦两个元素的另一种方式。 然而,在六边形架构中,所有想要与领域模型通信的元素都必须 使用协议。

有两种类型的适配器——驱动和被驱动。 它们之间的区别对于理解六边形架构的概念至关重要。

理解驱动适配器

驱动适配器充当外部世界的入口点,并负责与领域模型进行任何交互的初始化。

如果我们回到计算机的例子,适配器可以被视为外部键盘或鼠标。 我们称它们为 *驱动 ,因为它们通过调用其 用例来驱动应用程序。

驱动适配器最常见的例子是 UI。 屏幕通常执行驱动我们系统采取有意义行动的操作,例如登录系统、播放音乐或从网络或本地 持久存储中获取数据。

然而,驱动适配器并不仅限于 UI 屏幕。 我们可以将通知中心、应用/场景代理、位置服务和通用链接视为 驱动适配器。

驱动适配器依赖于领域模型,并且仅通过 协议(端口)与它通信。

现在,让我们了解被驱动 适配器是什么。

理解被驱动适配器

领域 模型使用被驱动适配器与外部系统或服务通信,例如网络、持久存储或 第三方服务。

在计算机的例子中,我们可以将驱动适配器视为外部显示器或 打印机。

我们可以将整个六边形架构视为一个 I/O 系统——驱动适配器是输入设备,而被驱动适配器是输出设备,执行对本地存储的更新或执行 API 调用。

现在我们已经了解了端口、驱动适配器和被驱动适配器是什么(图 15**.10):

图 15.10:完整的六边形架构

图 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 功能的好机会。 祝你好运!

posted @ 2025-10-26 08:56  绝不原创的飞龙  阅读(2)  评论(0)    收藏  举报