Skip to content

第51天 项目 10 第三部分

多年前,升阳电脑公司(Sun Microsystems)提出了一句远超当时时代的口号:“网络即电脑”。如今,这句话几乎已成共识:我们依靠手机、笔记本电脑,甚至智能手表随时随地保持连接,从而接收来自世界各地的推送消息、电子邮件、推特等内容。

不妨想想:我们现在使用的iPhone,其名称源自iPod,而iPod的名称又源自iMac——这款产品早在1998年就已推出。为“iMac”命名的营销人员肯·西格尔(Ken Segall)明确表示,其中的“i”代表“互联网”(internet),因为在90年代,上网远没有现在这么容易。

因此,我们的iPhone——即“互联网手机”——将网络置于其核心定位,也就不足为奇了。正是由于几乎随时随地都能联网,众多应用程序才变得更加丰富、实用。如今,你终于要为自己的应用添加网络功能了,希望你能感受到iOS让这一切变得多么简单!

今天你需要完成三个主题的学习,在此过程中,你将构建结账流程,然后使用URLSession通过互联网发送和接收数据。

  • 验证地址有效性
  • 为结账做准备
  • 通过互联网发送和接收订单

又一个应用程序完成了——别忘了和其他人分享你的进展!

验证地址有效性

作者:Paul Hudson 2024年8月21日

我们项目的第二步是让用户在表单中输入地址,不过在此过程中,我们要添加一些验证功能——只有当地址看起来有效的时候,用户才能进入第三步。

要实现这一点,我们可以在之前创建的AddressView结构体中添加一个Form视图,该视图将包含四个文本字段:姓名、街道地址、城市和邮政编码。然后,我们可以添加一个NavigationLink,用于跳转到下一个屏幕,用户将在该屏幕上看到最终价格并完成结账。

为了让流程更清晰,我们首先创建一个名为CheckoutView的新视图,当用户准备好后,地址视图将跳转到这个视图。这样做可以避免我们现在先放置一个占位视图,之后又忘记回来完善的问题。

因此,创建一个新的SwiftUI视图,命名为CheckoutView,并为其添加与AddressView相同的Order属性和预览代码:

swift
struct CheckoutView: View {
    var order: Order

    var body: some View {
        Text("Hello, World!")
    }
}

#Preview {
    CheckoutView(order: Order())
}

同样,我们之后会回来完善这个视图,不过首先让我们实现AddressView。正如前面所说,这个视图需要包含一个表单,表单中的四个文本字段分别与Order对象的四个属性绑定,此外还需要一个NavigationLink,用于将控制权传递给结账视图。

首先,我们需要在Order中添加四个新属性,用于存储配送信息:

swift
var name = ""
var streetAddress = ""
var city = ""
var zip = ""

现在,将AddressView现有的body替换为以下代码:

swift
Form {
    Section {
        TextField("姓名", text: $order.name)
        TextField("街道地址", text: $order.streetAddress)
        TextField("城市", text: $order.city)
        TextField("邮政编码", text: $order.zip)
    }

    Section {
        NavigationLink("结账") {
            CheckoutView(order: order)
        }
    }
}
.navigationTitle("配送信息")
.navigationBarTitleDisplayMode(.inline)

如你所见,这段代码将order对象又向下传递了一层,传递给了CheckoutView,这意味着现在有三个视图指向同一份数据。

这段代码会抛出很多错误,但只需一个小修改就能解决——将order属性修改为如下形式:

swift
@Bindable var order: Order

之前你已经了解到,即使属性是使用@Observable宏定义的类,Xcode也能让我们很好地绑定到本地的@State属性。这之所以能实现,是因为@State属性包装器会自动为我们创建双向绑定,我们可以通过$语法(如$name$age等)来访问这些绑定。

我们在AddressView中没有使用@State,是因为我们不是在这里创建这个类,而是从其他地方接收它。这意味着SwiftUI无法获取我们通常会使用的双向绑定,这就产生了问题。

不过,我们知道这个类使用了@Observable宏,这意味着SwiftUI能够监听这份数据的变化。因此,@Bindable属性包装器的作用就是为我们创建缺失的绑定——它生成的双向绑定能够与@Observable宏配合使用,而且无需使用@State来创建本地数据。这个属性包装器在这里非常适用,在未来的项目中你也会经常用到它。

现在重新运行应用程序,我想让你看看这一切的意义所在。在第一个屏幕上输入一些数据,在第二个屏幕上也输入一些数据,然后尝试返回第一个屏幕,再向前导航到最后一个屏幕——也就是说,先回到第一个屏幕,然后点击底部的按钮两次,再次进入结账视图。

你会发现,无论在哪个屏幕上,你输入的所有数据都能保存下来。是的,这是使用类来存储数据的自然结果,但它却为我们的应用程序带来了一项即时功能,而且无需额外操作——如果我们使用本地状态,那么当我们返回到原始视图时,之前输入的所有地址信息都会消失。

现在AddressView已经可以正常工作了,接下来我们要阻止用户在不满足某些条件的情况下进入结账环节。那么,具体是什么条件呢?这由我们自己决定。虽然我们可以对四个文本字段分别进行长度检查,但这种做法往往会带来问题——有些姓名只有四五个字母,如果我们添加长度验证,可能会意外地将这些用户排除在外。

因此,我们只需检查订单的namestreetAddresscityzip属性是否为空即可。我更倾向于在数据内部添加这种复杂的检查逻辑,这意味着你需要在Order中添加一个新的计算属性,代码如下:

swift
var hasValidAddress: Bool {
    if name.isEmpty || streetAddress.isEmpty || city.isEmpty || zip.isEmpty {
        return false
    }

    return true
}

现在,我们可以将这个条件与SwiftUI的disabled()修饰符结合使用——将该修饰符附加到任意视图上,并传入一个需要检查的条件,如果条件为真,该视图将停止响应用户交互。

在我们的场景中,需要检查的条件就是刚刚编写的计算属性hasValidAddress。如果该属性为false,那么包含NavigationLink的表单分区就应该被禁用,因为我们需要用户先填写完配送信息。

因此,在AddressView中第二个分区的末尾添加以下修饰符:

swift
.disabled(order.hasValidAddress == false)

添加后的代码如下:

swift
Section {
    NavigationLink("结账") {
        CheckoutView(order: order)
    }
}
.disabled(order.hasValidAddress == false)

现在运行应用程序,你会发现只有四个地址字段都至少输入一个字符后,才能继续下一步操作。更棒的是,当条件不满足时,SwiftUI会自动将按钮置灰,让用户能清晰地判断按钮何时可交互、何时不可交互。

为结账做准备

作者:Paul Hudson 2024年6月21日

我们应用程序的最后一个屏幕是CheckoutView,它的功能可以分为两部分:第一部分是基础的用户界面,对你来说应该没什么难度;第二部分则是全新的内容:我们需要将Order类编码为JSON格式,通过互联网发送出去,并接收响应。

我们很快就会学习编码和传输相关的完整内容,但首先让我们处理简单的部分:为CheckoutView创建用户界面。更具体地说,我们要创建一个ScrollView,其中包含一张图片、订单的总价,以及一个用于触发网络请求的“提交订单”按钮。

关于图片,我已经将一张纸杯蛋糕的图片上传到了我的服务器上,我们将使用AsyncImage远程加载这张图片——我们本可以将图片存储在应用程序中,但使用远程图片意味着我们可以动态地将其替换为季节性图片或促销图片。

至于订单价格,我们的数据中实际上并没有为纸杯蛋糕设置任何定价,所以我们可以自行设定一个价格——毕竟我们并不是真的要向用户收费。我们将使用以下定价规则:

  • 每个纸杯蛋糕的基础价格为2美元。
  • 款式更复杂的蛋糕价格会更高一些。
  • 额外加霜糖,每个蛋糕加收1美元。
  • 额外加糖屑,每个蛋糕加收0.5美元。

我们可以将所有这些逻辑封装在Order的一个新计算属性中,代码如下:

swift
var cost: Decimal {
    // 每个蛋糕2美元
    var cost = Decimal(quantity) * 2

    // 复杂款式的蛋糕价格更高
    cost += Decimal(type) / 2

    // 额外加霜糖,每个蛋糕1美元
    if extraFrosting {
        cost += Decimal(quantity)
    }

    // 额外加糖屑,每个蛋糕0.5美元
    if addSprinkles {
        cost += Decimal(quantity) / 2
    }

    return cost
}

视图本身的实现很简单:我们将在垂直方向的ScrollView中放置一个VStack,然后依次添加图片、价格文本和“提交订单”按钮。

稍后我们会完善按钮的动作逻辑,不过首先让我们完成基础布局——将CheckoutView现有的body替换为以下代码:

swift
ScrollView {
    VStack {
        AsyncImage(url: URL(string: "https://hws.dev/img/cupcakes@3x.jpg"), scale: 3) { image in
                image
                    .resizable()
                    .scaledToFit()
        } placeholder: {
            ProgressView()
        }
        .frame(height: 233)

        Text("您的订单总价为 \(order.cost, format: .currency(code: "USD"))")
            .font(.title)

        Button("提交订单", action: { })
            .padding()
    }
}
.navigationTitle("结账")
.navigationBarTitleDisplayMode(.inline)

到目前为止,这些内容对你来说应该都很熟悉了,但在完成这个屏幕之前,我想向你展示一个可以添加在这里的小巧但实用的SwiftUI修饰符:scrollBounceBehavior()

使用滚动视图(ScrollView)可以确保无论用户启用了多大的动态字体(Dynamic Type)大小,布局都能正常显示,但这也会带来一个小麻烦:当视图刚好能在单个屏幕上完全显示时,用户上下滑动屏幕,视图仍然会有轻微的回弹效果。

scrollBounceBehavior()修饰符可以帮助我们在没有内容可滚动时禁用这种回弹效果。在navigationBarTitleDisplayMode()下方添加以下代码:

swift
.scrollBounceBehavior(.basedOnSize)

添加这个修饰符后,当有可滚动的内容时,我们会看到正常的滚动回弹效果;否则,滚动视图就像不存在一样,不会有回弹。

完成这最后一个小调整后,就该着手完成这个项目中较难的部分了——网络功能!

通过互联网发送和接收订单

作者:Paul Hudson 2024年4月8日

iOS提供了一些出色的网络处理功能,尤其是URLSession类,它让发送和接收数据变得异常简单。如果我们将URLSessionCodable(用于在Swift对象和JSON之间进行转换)结合使用,再利用新的URLRequest结构体来精确配置数据的发送方式,只需大约20行代码就能实现强大的功能。

首先,让我们创建一个可以从“提交订单”按钮调用的方法——在CheckoutView中添加以下代码:

swift
func placeOrder() async {
}

就像使用URLSession下载数据一样,上传数据也是异步进行的。

现在,将“提交订单”按钮修改为以下代码:

swift
Button("提交订单", action: placeOrder)
    .padding()

这段代码无法正常工作,Swift会清晰地提示原因:它从一个不支持并发的函数中调用了异步函数。这意味着按钮期望能立即执行其动作,不理解如何等待某个操作——即使我们写成await placeOrder(),它仍然无法工作,因为按钮不想等待。

之前我提到过,onAppear()无法与这些异步函数配合使用,我们需要改用task()修饰符。但在这里,由于我们是要执行一个动作,而不是仅仅附加修饰符,所以这种方式并不适用。不过Swift提供了另一种解决方案:我们可以凭空创建一个新的任务(task),就像task()修饰符一样,这个任务可以运行任何类型的异步代码。

实际上,只需将await调用放在一个任务中即可,代码如下:

swift
Button("提交订单") {
    Task {
        await placeOrder()
    }
}

现在一切就准备就绪了——这段代码可以正常异步调用placeOrder()方法。当然,这个函数目前还没有实际功能,所以让我们来完善它。

placeOrder()方法中,我们需要完成三件事:

  1. 将当前的order对象转换为可发送的JSON数据。
  2. 告诉Swift如何通过网络请求发送这些数据。
  3. 执行该请求并处理响应。

第一件事很简单,让我们先完成它。我们将使用JSONEncoder对订单进行归档,在placeOrder()中添加以下代码:

swift
guard let encoded = try? JSONEncoder().encode(order) else {
    print("订单编码失败")
    return
}

这段代码目前还无法工作,因为Order类没有遵循Codable协议。不过这很容易修改——将类的定义修改为以下形式:

swift
class Order: Codable {

第二步需要使用一种名为URLRequest的新类型,它类似于URL,但提供了更多选项,比如可以添加请求类型、用户数据等额外信息。

我们需要以一种非常特定的方式附加数据,这样服务器才能正确处理这些数据。这意味着除了订单数据之外,我们还需要提供另外两条关键信息:

  1. 请求的HTTP方法决定了数据的发送方式。HTTP方法有多种,但实际上常用的只有GET(“我要读取数据”)和POST(“我要写入数据”)。我们这里要写入数据,所以将使用POST方法。
  2. 请求的内容类型决定了所发送数据的类型,这会影响服务器处理数据的方式。内容类型通过所谓的MIME类型来指定,MIME类型最初是为在电子邮件中发送附件而设计的,它包含数千种高度特定的类型。

因此,placeOrder()方法的下一段代码将创建一个URLRequest对象,然后将其配置为使用HTTP POST请求发送JSON数据。之后,我们就可以使用这个请求通过URLSession上传数据,并处理返回的结果。

当然,真正的问题是要将请求发送到哪里。我想你不会为了跟随本教程而专门搭建自己的Web服务器。因此,我们将使用一个非常实用的网站:https://reqres.in——这个网站允许我们发送任何数据,并会自动将数据返回给我们。这是原型化网络代码的绝佳方式,因为你能从发送的请求中获得真实的数据响应。

现在,在placeOrder()中添加以下代码:

swift
let url = URL(string: "https://reqres.in/api/cupcakes")!
var request = URLRequest(url: url)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpMethod = "POST"

第一行代码中,URL(string:)初始化器返回的是可选类型的URL,但我们使用了强制解包(!),这表示“这个初始化器返回一个可选的URL,但请强制将其转换为非可选类型”。从字符串创建URL可能会失败(比如字符串格式有误),但在这里,我是手动输入的URL,可以确定它始终是有效的——这里没有可能导致问题的字符串插值。

至此,我们已经准备好执行网络请求了。我们将使用一个名为URLSession.shared.upload()的新方法,并传入刚才创建的URL请求。因此,继续在placeOrder()中添加以下代码:

swift
do {
    let (data, _) = try await URLSession.shared.upload(for: request, from: encoded)
    // 处理结果    
} catch {
    print("结账失败:\(error.localizedDescription)")
}

接下来是关键工作:当一切正常时,我们需要读取请求的结果。如果出现问题(比如没有网络连接),catch代码块会执行,因此我们在这里无需处理错误情况。

由于我们使用的是ReqRes.in网站,实际上我们会收到之前发送的相同订单数据。这意味着我们可以使用JSONDecoder将返回的JSON数据转换回对象。

为了确认一切正常,我们将显示一个警报,其中包含订单的一些详细信息,但我们会使用从ReqRes.in返回的、经过解码的订单数据。当然,这个解码后的订单应该与我们发送的订单完全相同,如果不同,就说明我们在编码过程中出现了错误。

要显示警报,我们需要一些属性来存储警报消息以及警报是否可见。因此,请在CheckoutView中添加以下两个新属性:

swift
@State private var confirmationMessage = ""
@State private var showingConfirmation = false

我们还需要附加一个alert()修饰符来监听这个布尔值(showingConfirmation),一旦该值变为true,就显示警报。在CheckoutView的导航标题修饰符下方添加以下修饰符:

swift
.alert("感谢您的订单!", isPresented: $showingConfirmation) {
    Button("确定") { }
} message: {
    Text(confirmationMessage)
}

现在,我们可以完善网络代码了:我们将对返回的数据进行解码,用解码后的数据设置确认消息属性,然后将showingConfirmation设为true,从而显示警报。如果解码失败(比如服务器返回的不是订单数据),我们只需打印一条错误消息。

placeOrder()中添加以下最终代码,替换// 处理结果注释:

swift
let decodedOrder = try JSONDecoder().decode(Order.self, from: data)
confirmationMessage = "您订购的 \(decodedOrder.quantity)\(Order.types[decodedOrder.type].lowercased()) 纸杯蛋糕正在配送中!"
showingConfirmation = true

现在尝试运行应用程序,你应该可以选择心仪的蛋糕款式,输入配送信息,然后点击“提交订单”按钮,此时会弹出一个警报——一切都正常工作了!

不过,我们还没有完全完成,因为目前我们的网络功能存在一个小但不易察觉的问题。为了找出这个问题,我想向你介绍Xcode中的一个简单调试技巧:我们将暂停应用程序,以便检查某个特定的值。

首先,点击let url = URL…这行代码旁边的行号。此时会出现一个蓝色箭头,这是Xcode在表示我们已经在这里设置了一个断点。断点会告诉Xcode,当执行到这行代码时暂停程序,以便我们查看所有数据。

现在重新运行应用程序,输入一些配送信息,然后提交订单。如果一切正常,应用程序会暂停,Xcode会切换到前台,并且那行代码会被高亮显示,因为程序即将执行到这里。

正常情况下,你应该能在Xcode窗口右下角看到调试控制台(debug console)——这里通常会显示Apple的所有内部日志消息,但现在应该显示“(lldb)”。LLDB是Xcode调试器的名称,我们可以在这里运行命令来查看数据。

请在调试控制台中运行以下命令:p String(decoding: encoded, as: UTF8.self)。这条命令会将编码后的数据流转换回字符串并打印出来。你会发现,输出结果中包含很多带有下划线的变量名,以及@Observable宏为我们提供的观察注册器(observation registrar)相关内容。

我们的代码其实并不关心这些内容,因为我们发送的所有属性都带有下划线,ReqRes.in服务器会将这些属性原样返回,然后我们再将它们解码回带有下划线的属性。但当你与真实的服务器交互时,这些名称就非常重要了——你需要发送实际的属性名,而不是@Observable宏生成的那些特殊名称。

这意味着我们需要为Order类创建一些自定义的编码键(coding keys)。对于像Order这样需要保存和加载多个属性的类来说,这一过程可能有些繁琐,但这是确保网络功能正常工作的最佳方式。

因此,打开Order类,在其中添加以下嵌套枚举:

swift
enum CodingKeys: String, CodingKey {
    case _type = "type"
    case _quantity = "quantity"
    case _specialRequestEnabled = "specialRequestEnabled"
    case _extraFrosting = "extraFrosting"
    case _addSprinkles = "addSprinkles"
    case _name = "name"
    case _city = "city"
    case _streetAddress = "streetAddress"
    case _zip = "zip"
}

再次运行代码,你可以按向上箭头键并回车,重新运行之前的p命令。这一次,发送和接收的数据会清晰得多。

添加完这最后一段代码后,我们的网络代码就完整了,实际上我们的应用程序也已经全部完成。

我们的工作到此结束!好吧,是“我的”工作结束了——你还有一些挑战需要完成呢!

本站使用 VitePress 制作