第49天 项目 10 第一部分
你已经有几天没有跟进项目了,希望你利用这段时间复习了所学内容、编写了一些自己的代码,并回顾了昨天视频中提到的知识点。
正如已故的伟大人物齐格·齐格勒(Zig Ziglar)所说:“有两种肯定会失败的方式:只思考不行动,或者只行动不思考。” 好了,今天显然是一个“行动”的日子:我们要构建一个新的项目,这意味着我们要学习一些新的技术。
具体来说,我们将首次涉足网络编程领域,让你看看 Swift 是如何轻松地从互联网上获取数据并在我们的应用中使用的。当与 Codable 结合起来处理 JSON 数据时,这意味着我们可以直接在 SwiftUI 中读取来自服务器的数据——正如你将看到的,整个过程非常顺畅。
今天你需要学习四个主题,在这些主题中,你将了解 URLSession、disabled() 修饰符等内容。
- 纸杯蛋糕小店(Cupcake Corner):简介
- 使用 URLSession 和 SwiftUI 发送与接收 Codable 数据
- 从远程服务器加载图片
- 表单验证与禁用
纸杯蛋糕小店(Cupcake Corner):简介
作者:Paul Hudson 2024年4月11日
在这个项目中,我们将构建一个多屏幕的纸杯蛋糕订购应用。这个应用会用到几个表单,表单对你来说已经不是新知识了,但你还将学习如何通过互联网发送和接收订单数据、如何验证表单等知识。
随着我们对 Codable 的深入了解,希望你能不断感受到它的灵活性和安全性。特别要记住的是,它与较旧的 UserDefaults API 有很大不同——不用再担心字符串输入必须完全准确,这真是太棒了!
好了,我们有很多内容要学习,现在就开始吧:使用 App 模板创建一个新的 iOS 应用,并将其命名为 Cupcake Corner。如果你还没有下载本书的项目文件,请现在下载:https://github.com/twostraws/HackingWithSwift
和往常一样,我们首先从完成这个项目所需的新技术开始学习……
使用 URLSession 和 SwiftUI 发送与接收 Codable 数据
作者:Paul Hudson 2023年11月10日
iOS 为我们提供了内置工具,用于从互联网发送和接收数据。如果我们将其与 Codable 支持结合使用,就可以将 Swift 对象转换为 JSON 格式进行发送,然后接收返回的 JSON 数据并将其转换回 Swift 对象。更棒的是,当请求完成后,我们可以立即将其数据分配给 SwiftUI 视图中的属性,从而触发用户界面的更新。
为了演示这一点,我们可以从苹果的 iTunes API 加载一些示例音乐 JSON 数据,并在 SwiftUI 的 List 中显示所有数据。苹果的数据包含大量信息,但我们会将其简化为两种类型:Result 用于存储曲目 ID、曲目名称及其所属专辑,Response 用于存储一个 Result 类型的数组。
首先编写以下代码:
struct Response: Codable {
var results: [Result]
}
struct Result: Codable {
var trackId: Int
var trackName: String
var collectionName: String
}现在,我们可以编写一个简单的 ContentView 来显示 Result 类型的数组:
struct ContentView: View {
@State private var results = [Result]()
var body: some View {
List(results, id: \.trackId) { item in
VStack(alignment: .leading) {
Text(item.trackName)
.font(.headline)
Text(item.collectionName)
}
}
}
}起初,这个列表不会显示任何内容,因为 results 数组是空的。这时候就需要我们进行网络请求了:我们将请求 iTunes API 返回泰勒·斯威夫特(Taylor Swift)的所有歌曲列表,然后使用 JSONDecoder 将这些结果转换为 Result 实例数组。
不过,要实现这一点,你需要了解两个重要的 Swift 关键字:async 和 await。要知道,任何能够运行 SwiftUI 的 iPhone 每秒都能执行数十亿次操作——速度非常快,大多数任务在我们意识到它开始之前就已经完成了。但另一方面,网络操作(从互联网下载数据)可能需要几百毫秒甚至更长时间才能完成,对于一台习惯在这段时间内完成数十亿次其他操作的计算机来说,这速度极其缓慢。
Swift 不会让整个程序在网络操作进行时停滞不前,而是为我们提供了一种能力:“这项工作需要一些时间,请在等待它完成的同时,让应用的其他部分继续正常运行。”
这种让部分代码运行的同时,主应用代码仍能继续工作的功能,被称为“异步”(asynchronous)函数。同步(synchronous)函数会在返回所需值之前完整运行,而异步函数则可以暂停一段时间,等待其他工作完成后再继续执行。在我们的案例中,这意味着在网络代码执行期间,函数会暂停,这样应用的其他部分就不会冻结好几秒。
为了更容易理解,我们分几个步骤来编写代码。首先是基本的方法框架——请将以下代码添加到 ContentView 结构体中:
func loadData() async {
}注意其中的新关键字 async——我们在告诉 Swift,这个函数可能需要暂停才能完成工作。
我们希望在 List 显示出来后立即运行这个函数,但不能直接使用 onAppear(),因为它无法处理会暂停的函数——它要求传入的函数是同步的。
SwiftUI 为这类任务提供了另一个修饰符,其名称非常好记:task()。它可以调用可能需要暂停一段时间的函数;Swift 只要求我们用第二个关键字 await 标记这些函数,以明确表示我们知道函数可能会暂停。
现在,将这个修饰符添加到 List 上:
.task {
await loadData()
}提示: 可以把 await 看作和 try 类似——await 表示我们知道函数可能会暂停,就像 try 表示我们知道可能会抛出错误一样。
在 loadData() 内部,我们需要完成三个步骤:
- 创建我们要读取数据的 URL。
- 获取该 URL 对应的数据源。
- 将获取到的数据解码为
Response结构体。
我们会逐步添加这些步骤,首先是 URL 的创建。URL 需要有精确的格式:以 “itunes.apple.com” 开头,后面跟着一系列参数——如果你在网上搜索“iTunes Search API”,就能找到完整的参数列表。在我们的案例中,将使用搜索关键词“Taylor Swift”和实体类型“song”,现在将以下代码添加到 loadData() 中:
guard let url = URL(string: "https://itunes.apple.com/search?term=taylor+swift&entity=song") else {
print("无效的 URL")
return
}第二步是从该 URL 获取数据,这一步很可能会出现函数暂停的情况。我说“很可能”,是因为也存在例外——iOS 会对数据进行一些缓存,如果连续两次请求同一个 URL,数据会立即返回,而不会触发暂停。
无论如何,暂停的可能性是存在的,只要存在暂停的可能,我们就需要在要运行的代码前使用 await 关键字。同样重要的是,这里也可能会抛出错误——例如,用户当前可能没有连接到互联网。
因此,我们需要同时使用 try 和 await。请在前面代码的后面直接添加以下代码:
do {
let (data, _) = try await URLSession.shared.data(from: url)
// 后续代码将在此处添加
} catch {
print("无效的数据")
}这段代码引入了三个重要概念,我们来逐一拆解:
- 我们的操作是通过
data(from:)方法完成的,该方法接收一个 URL 作为参数,并返回该 URL 对应的Data对象。这个方法属于URLSession类,你可以手动创建和配置URLSession实例,但也可以使用一个带有合理默认设置的共享实例(shared)。 data(from:)方法的返回值是一个元组,包含 URL 对应的数据源以及描述请求情况的元数据。我们不需要使用元数据,但需要 URL 对应的数据源,因此使用下划线(_)——我们创建一个本地常量来存储数据,同时忽略元数据。- 当同时使用
try和await时,必须写成try await——不允许写成await try。这没有特别的原因,只是开发者们选择了这种更符合自然阅读习惯的写法。
所以,如果下载成功,data 常量将被赋值为从 URL 返回的数据源;如果下载失败(无论出于何种原因),代码会打印“无效的数据”,然后不再执行其他操作。
这个方法的最后一步是使用 JSONDecoder 将 Data 对象转换为 Response 对象,然后将 Response 对象内部的数组赋值给我们的 results 属性。这和我们之前使用过的方法完全一样,所以应该不会感到陌生——现在,将“// 后续代码将在此处添加”这条注释替换为以下代码:
if let decodedResponse = try? JSONDecoder().decode(Response.self, from: data) {
results = decodedResponse.results
}现在运行代码,短暂等待后,你应该会看到泰勒·斯威夫特的歌曲列表——考虑到最终效果如此好,实际编写的代码量其实并不算多。
到目前为止,我们只处理了“下载”数据的情况。在本项目的后续部分,我们将学习一种略有不同的方法,以便你能够“发送”Codable 类型的数据,不过目前这些内容已经足够了。
从远程服务器加载图片
作者:Paul Hudson 2023年11月10日
SwiftUI 的 Image 视图在处理应用资源包中的图片时表现出色,但如果想要从互联网加载“远程”图片,则需要使用 AsyncImage。创建 AsyncImage 时,需要传入图片的 URL,而不是简单的资源名称或 Xcode 生成的常量,但 SwiftUI 会处理剩下的所有工作——它会下载图片、缓存下载的图片,并自动显示图片。
因此,我们能创建的最简单的图片如下所示:
AsyncImage(url: URL(string: "https://hws.dev/img/logo.png"))我创建的这张图片高度为 1200 像素,但显示时你会发现它比实际大得多。这直接体现了使用 AsyncImage 的一个基本难题:在代码运行并下载图片之前,SwiftUI 对图片一无所知,因此无法提前确定合适的图片尺寸。
如果我将这张 1200 像素的图片加入项目,实际上会将其命名为 logo@3x.png,同时还会添加一张 800 像素的图片并命名为 logo@2x.png。这样一来,SwiftUI 会自动处理图片的加载,确保加载适合当前设备的图片,使其显示清晰且尺寸合适。但在使用远程图片的情况下,SwiftUI 会按照图片的原始尺寸(1200 像素高)来加载它——这会导致图片比屏幕大得多,而且看起来还会有些模糊。
要解决这个问题,我们可以提前告诉 SwiftUI,我们要加载的是一张 3 倍缩放(3x)的图片,代码如下:
AsyncImage(url: URL(string: "https://hws.dev/img/logo.png"), scale: 3)现在运行代码,你会发现图片的尺寸变得合理多了。
如果你想给图片设置一个“精确”的尺寸呢?或许你会先尝试这样写:
AsyncImage(url: URL(string: "https://hws.dev/img/logo.png"))
.frame(width: 200, height: 200)这种写法行不通,但这可能不会让你感到惊讶,因为对于普通的 Image 视图,这种写法同样无效。于是你可能会尝试让图片可调整大小,代码如下:
AsyncImage(url: URL(string: "https://hws.dev/img/logo.png"))
.resizable()
.frame(width: 200, height: 200)……但这种写法同样行不通,实际上情况更糟,因为代码甚至无法编译。要知道,我们在这里应用的修饰符并不是直接作用于 SwiftUI 下载的图片上——这是不可能的,因为在实际获取到图片数据之前,SwiftUI 无法知道如何应用这些修饰符。
实际上,这些修饰符是作用于图片的“包装器”(即 AsyncImage 视图)上的。这个包装器最终会包含我们下载完成的图片,但在图片加载过程中,它会显示一个“占位视图”(placeholder)。当应用运行时,你可能会短暂地看到这个占位视图——那个 200x200 的灰色方块就是,图片加载完成后,它会自动消失。
要调整图片的样式,你需要使用一种更高级的 AsyncImage 创建方式:当图片加载完成后,它会将最终的图片视图传递给我们,然后我们就可以根据需要自定义图片样式了。此外,这种方式还提供了第二个闭包,用于自定义占位视图。
例如,我们可以让加载完成的图片视图既可调整大小,又能按比例适配,同时将 Color.red(红色)作为占位视图,这样在学习过程中更容易观察到占位视图的变化。
AsyncImage(url: URL(string: "https://hws.dev/img/logo.png")) { image in
image
.resizable()
.scaledToFit()
} placeholder: {
Color.red
}
.frame(width: 200, height: 200)可调整大小的图片和 Color.red 都会自动占据所有可用空间,这意味着 frame() 修饰符现在可以正常工作了。
占位视图可以是任何你想要的视图。例如,如果你将 Color.red 替换为 ProgressView()(就这么简单),那么占位视图就会变成一个小的旋转活动指示器,而不是纯色块。
如果你想对远程图片进行“完全”控制,还有第三种创建 AsyncImage 的方式,它会告诉我们图片是加载成功、加载失败还是尚未加载完成。这种方式在需要为下载失败(例如 URL 不存在或用户处于离线状态等情况)显示专门视图时非常有用。
具体代码如下:
AsyncImage(url: URL(string: "https://hws.dev/img/bad.png")) { phase in
if let image = phase.image {
image
.resizable()
.scaledToFit()
} else if phase.error != nil {
Text("图片加载失败,请稍后重试。")
} else {
ProgressView()
}
}
.frame(width: 200, height: 200)这样一来,如果图片加载成功,就会显示图片;如果下载失败(无论出于何种原因),就会显示错误提示文本;如果下载仍在进行中,就会显示旋转的活动指示器。
表单验证与禁用
作者:Paul Hudson 2023年11月10日
SwiftUI 的 Form 视图能让我们快速便捷地存储用户输入,但有时还需要更进一步——在继续操作之前,“检查”这些输入是否有效。
幸好,我们有专门用于此目的的修饰符:disabled()。它接收一个需要检查的条件,如果该条件为 true,那么它所附加的视图就不会响应用户输入——按钮无法点击,滑块无法拖动,等等。你可以在这里使用简单的属性,也可以使用任何条件表达式:读取计算属性、调用方法等等。
为了演示这一点,我们来创建一个接收用户名和电子邮件地址的表单:
struct ContentView: View {
@State private var username = ""
@State private var email = ""
var body: some View {
Form {
Section {
TextField("用户名", text: $username)
TextField("电子邮箱", text: $email)
}
Section {
Button("创建账户") {
print("正在创建账户……")
}
}
}
}
}在这个示例中,我们不希望用户在两个字段都未填写的情况下创建账户,因此可以通过添加 disabled() 修饰符来禁用包含“创建账户”按钮的表单分区,代码如下:
Section {
Button("创建账户") {
print("正在创建账户……")
}
}
.disabled(username.isEmpty || email.isEmpty)这行代码的意思是“如果用户名为空或电子邮箱为空,就禁用这个分区”,这正是我们想要的效果。
你可能会发现,将验证条件提取到一个单独的计算属性中会更清晰,例如:
var disableForm: Bool {
username.count < 5 || email.count < 5
}现在,你只需在修饰符中引用这个计算属性即可:
.disabled(disableForm)无论采用哪种方式,建议你运行应用,观察 SwiftUI 是如何处理禁用状态的按钮的——当验证条件不满足时,按钮文本会变成灰色;一旦验证条件满足,按钮就会立即变成蓝色(可点击状态)。