第60天 里程碑:项目 10-12
又完成了三个项目,你也掌握了更多非常重要的技术。无论你的设计有多精美,你的应用创意有多巧妙,对于任何优秀的应用来说,妥善处理用户数据几乎总是最重要的事情。
当然,真正需要探讨的是“妥善”究竟意味着什么。至少,我希望它意味着“尊重”——未经用户同意,你不会分享任何信息;未经许可,你不会追踪他们的活动;对于任何个人数据,你都会妥善存储。除此之外,你可能还需要添加搜索或筛选功能,可能需要云同步以便用户的数据在多设备间共享,可能希望让用户浏览或修改原始数据等等。
无论你如何处理用户数据,学会操作用户数据都是一项非常有用的技能,在最近这三个项目中,你已经取得了很大的进步。
现在到了挑战环节,不出所料,这次挑战会涉及获取、处理和展示大量数据。你已经掌握了制作这个优秀应用所需的所有技能,所以接下来要做的就是打开一个新的Xcode项目,全身心投入其中。
你会犯错吗?当然会——但这没关系。英国作家尼尔·盖曼(Neil Gaiman)有一段建议,希望能对你有所帮助:
我希望在接下来的这一年里,你会犯错。因为如果你在犯错,那就意味着你在创造新事物、尝试新事物、学习、体验生活、突破自我、改变自己、改变你所处的世界。你在做以前从未做过的事情,更重要的是,你在行动。
今天你需要学习三个主题,其中一个是挑战任务。
- 你学到的内容
- 重点内容
- 挑战任务
注意: 如果当天没有完成挑战任务,也不用着急——在之后的学习中,你会发现时不时会有一些空闲时间,所以你可以在之后再回过头来完成这些挑战。
你学到的内容
最近这三个项目围绕数据展开了深入学习,首先学习了如何通过互联网发送和接收数据,然后学习了SwiftData,以便了解真实应用是如何管理数据的。你在这些项目中学到的技能,其重要性可能超出你的想象,因为只要将这些技能结合起来,你现在就能够从互联网获取数据、在本地存储数据,并让用户通过筛选找到他们关心的内容。
以下是对最近三个项目中涵盖的所有新内容的快速回顾:
- 构建自定义的
Codable协议遵循 - 使用
URLSession发送和接收数据 - 用于视图的
disabled()修饰符 - 使用
@Binding构建自定义UI组件 - 为警告框添加多个按钮
- 使用
@Bindable编辑SwiftData对象 - 使用
@Query查询SwiftData对象 - 使用
SortDescriptor对SwiftData结果进行排序 - 使用
#Predicate筛选数据 - 在SwiftData模型之间创建关系
- 将SwiftData与iCloud同步
与其他一些项目相比,这个列表相对较短,但公平地说,这些主题的难度确实有了显著提升:SwiftData在某些方面比较复杂,尤其是在实现动态排序和筛选时需要做不少工作,但这绝对是值得的!
重点内容
尽管最近三个项目涵盖了很多内容,但有一个方面我想更详细地讲解:Codable的高级用法。我们在项目中已经对其有所涉及,但这个主题值得你投入更多时间去学习,原因你很快就会明白……
提示: 如果你想知道如何让SwiftData模型遵循Codable协议,建议你完整阅读本节内容。
自定义Codable键
当JSON数据的结构与我们设计的类型相匹配时,Codable的使用效果非常好。实际上,很多时候我们只需要让类型遵循Codable协议即可,Swift编译器会自动为我们生成所需的所有代码。
然而,很多时候情况并没有那么简单,处理更复杂的数据有三种方法:
- 让Swift自动转换属性名。
- 创建自定义的属性名转换规则。
- 创建完全自定义的编码和解码逻辑。
一般来说,你应该按照这个顺序优先选择,第一种方法是最理想的,第三种方法则是最不推荐的。
我们先来逐一了解前两种方法。第三种方法暂时先不讲解,因为它相对复杂一些!
当传入的JSON数据的属性命名规则与我们的代码不同时,让Swift自动转换属性名会非常有用。例如,我们接收到的JSON属性名可能使用蛇形命名法(如first_name),而我们的Swift代码中属性名使用驼峰命名法(如firstName)。
只要Codable知道要如何转换,它就能够在这两种命名法之间进行转换:我们需要为解码器设置一个名为keyDecodingStrategy的属性。
为了演示这一点,我们定义一个带有两个属性的User结构体:
struct User: Codable {
var firstName: String
var lastName: String
}该结构体使用的是Swift代码中常用的命名规则,即驼峰命名法——之所以叫这个名字,是因为将单词的首字母大写的做法,有点像骆驼背上的驼峰。
下面是一段包含这两个属性的JSON数据:
let str = """
{
"first_name": "Andrew",
"last_name": "Glouberman"
}
"""
let data = Data(str.utf8)这段JSON数据使用的是蛇形命名法,这种命名规则中,属性名全部用小写字母,单词之间用下划线分隔。
如果我们尝试将这段JSON数据解码成User实例,是无法成功的,因为两者的属性命名风格不同:
do {
let decoder = JSONDecoder()
let user = try decoder.decode(User.self, from: data)
print("Hi, I'm \(user.firstName) \(user.lastName)")
} catch {
print("Whoops: \(error.localizedDescription)")
}但是,如果我们在调用decode()方法之前修改键的解码策略,就可以让Swift在蛇形命名法和驼峰命名法之间进行转换。这样一来,解码就能成功了:
do {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let user = try decoder.decode(User.self, from: data)
print("Hi, I'm \(user.firstName) \(user.lastName)")
} catch {
print("Whoops: \(error.localizedDescription)")
}当需要在snake_case和camelCase之间进行转换时,这种方法非常好用,但如果属性名完全不同该怎么办呢?这时候就需要用到第二种方法:创建自定义的属性名转换规则。
例如,来看下面这段JSON数据:
let str = """
{
"first": "Andrew",
"last": "Glouberman"
}
"""这段数据仍然包含用户的名字和姓氏,但属性名与我们的结构体完全不匹配。
之前学习Codable时,我提到过可以创建一个编码键的枚举,用于指定哪些键需要进行编码和解码。当时我说“按照惯例,这个枚举通常命名为CodingKeys(末尾带有s),但你也可以根据需要起其他名字”,虽然这种说法是正确的,但并不完整。
实际上,我们习惯将其命名为CodingKeys是因为这个名字有“特殊能力”:如果存在一个名为CodingKeys的枚举,在我们没有提供自定义Codable实现的情况下,Swift会自动使用它来确定如何对对象进行编码和解码。
我知道这需要花点时间理解,所以最好通过代码来演示。尝试将User结构体修改为以下形式:
struct User: Codable {
enum ZZZCodingKeys: CodingKey {
case firstName
}
var firstName: String
var lastName: String
}这段代码可以正常编译,因为ZZZCodingKeys这个名字对Swift来说没有特殊意义——它只是一个嵌套枚举。但如果将这个枚举重命名为CodingKeys,你会发现代码无法编译了:这时候我们相当于告诉Swift只对firstName属性进行编码和解码,这就意味着不存在用于设置lastName属性的初始化器——这是不允许的。
了解这些很重要,因为CodingKeys还有第二个“特殊能力”:当我们为枚举成员附加原始字符串值时,Swift会将这些字符串值用作JSON的属性名。也就是说,枚举成员的名称应该与我们Swift代码中的属性名一致,而枚举成员的“值”则应该与JSON中的属性名一致。
回到之前的JSON示例:
let str = """
{
"first": "Andrew",
"last": "Glouberman"
}
"""这段JSON使用“first”和“last”作为属性名,而我们的User结构体使用firstName和lastName。这种情况下,CodingKeys就能派上用场了:我们不需要编写自定义的Codable遵循代码,只需要添加编码键,将Swift中的属性名与JSON中的属性名关联起来,代码如下:
struct User: Codable {
enum CodingKeys: String, CodingKey {
case firstName = "first"
case lastName = "last"
}
var firstName: String
var lastName: String
}现在我们已经明确告诉Swift如何在JSON和Swift的命名之间进行转换,因此不再需要使用keyDecodingStrategy——只需添加这个枚举就足够了。
所以,虽然你确实需要知道如何创建自定义的Codable遵循代码,但如果可以通过其他方法实现需求,通常还是建议优先选择其他方法。
完全自定义的Codable实现
到目前为止,你已经了解了如何让Swift在蛇形命名法和驼峰命名法之间进行映射,以及当JSON中的属性名与Swift中的属性名完全不同时,如何指定映射规则。
最后这种方法适用于更复杂的情况,例如JSON数据中将数字存储为字符串的情况。此外,当你希望让SwiftData模型遵循Codable协议时,这种方法也很有用,这一点你很快就会看到。
首先,我们来看一段能体现这个问题的新JSON数据:
let str = """
{
"first": "Andrew",
"last": "Glouberman",
"age": "13"
}
"""如你所见,这段JSON中,名字和姓氏的属性名不够直观,而且年龄(age)被存储为字符串类型。虽然对于来自外部服务器的JSON数据,我们几乎无法修改其格式,但我们肯定不希望这种不规范的格式影响到我们的代码——年龄本质上是整数,我们希望在Swift代码中将其存储为整数类型。
因此,我们可能会定义这样一个User结构体,修正名字和姓氏的属性名,并将age存储为整数类型:
struct User: Codable {
enum CodingKeys: String, CodingKey {
case firstName = "first"
case lastName = "last"
case age
}
var firstName: String
var lastName: String
var age: Int
}但现在问题来了:虽然Swift可以帮我们转换属性名,但它无法处理不同数据类型之间的转换。
要解决这个问题,我们需要创建完全自定义的Codable实现,这意味着要在User结构体中添加两部分内容:
- 一个新的初始化器,接收一个
Decoder实例,并知道如何从中读取我们需要的属性。 - 一个新的
encode(to:)方法,接收一个Encoder实例,并知道如何将我们的属性写入其中。
提示: Swift在这里使用Decoder和Encoder,是因为将数据与Swift对象进行相互转换有很多种方式——JSON只是其中一种。
这两部分都需要编写不少代码,但幸运的是,Xcode有时能提供帮助。在这种情况下,Xcode实际上可以帮我们补全实现这两部分所需的所有代码:在属性下方输入init,然后选中init(from decoder: Decoder)并按回车键;接着输入encode,选中encode(to encoder: Encoder)并按回车键。
最终的User结构体如下所示:
struct User: Codable {
enum CodingKeys: String, CodingKey {
case firstName = "first"
case lastName = "last"
case age
}
var firstName: String
var lastName: String
var age: Int
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.firstName = try container.decode(String.self, forKey: .firstName)
self.lastName = try container.decode(String.self, forKey: .lastName)
self.age = try container.decode(Int.self, forKey: .age)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.firstName, forKey: .firstName)
try container.encode(self.lastName, forKey: .lastName)
try container.encode(self.age, forKey: .age)
}
}提示: 如果这是一个类而不是结构体,那么这个新的初始化器需要添加required关键字,以确保所有子类都必须实现该初始化器。
这段代码看起来很多,但实际上真正关键的只有四行:初始化器中的两行,以及encode(to:)方法中的两行。
初始化器中第一行关键代码是:
let container = try decoder.container(keyedBy: CodingKeys.self)这行代码使用CodingKeys来读取JSON文件中所有可能的键。它会查看CodingKeys枚举,因此我们可以引用像.firstName和.age这样的枚举成员。
初始化器中第二行关键代码是:
self.firstName = try container.decode(String.self, forKey: .firstName)这行代码从键.firstName对应的位置读取一个字符串,并将其赋值给结构体的firstName属性。这里可能有点令人困惑,因为出现了两次firstName,所以我换一种方式来解释这行代码的作用:“在JSON中查找与CodingKeys.firstName对应的属性,并将其赋值给我们本地的firstName变量。”
这个过程很重要,因为CodingKeys.firstName实际上并不是“firstName”——我们之前已经将其重命名,以匹配JSON中的属性名。因此,实际上这行代码的意思是“在JSON中找到first属性,并将其赋值给结构体中的firstName属性”——它确保了所有的自动重命名都能正常进行。
如果这样解释能帮你理解,可以想象成这样阅读代码:
self.structFirstName = try container.decode(String.self, forKey: .jsonFirstName)以上是两行关键的初始化器代码。另外两行关键代码实际上是前两行的反向操作,它们都在encode(to:)方法中:
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.firstName, forKey: .firstName)第一行代码表示我们要创建一个用于存储所有CodingKeys值的“容器”,第二行代码则将当前的firstName属性写入CodingKeys.firstName所指定的位置——同样,这一点很重要,因为这样才能实现自动重命名为first的功能。
看到这里,你可能会想,自己到底该如何记住这些代码,毕竟这些代码并不是靠猜测就能写出来的。所以,我给你一个最重要的提示:
当你需要实现自定义的Codable,而Xcode无法帮你生成代码时,只需创建一个新的、简单的结构体(包含一个属性和一个只有一个成员的CodingKeys枚举),让Xcode为这个简单结构体生成Codable实现,然后参照这个实现的结构,为更复杂的类型编写自定义实现。
这一点在处理SwiftData时尤为重要,为SwiftData模型类添加Codable支持需要创建自定义实现。要记住上面所有的代码确实很麻烦,而且Xcode几乎肯定不会提供帮助,所以只需创建一个临时的结构体,让Xcode能为其生成Codable实现,然后参照这个结构,让SwiftData模型类遵循Codable协议即可。
言归正传,我们之所以讨论到这里,是因为我们想要将字符串类型的年龄转换为整数类型,这意味着需要对Xcode生成的代码做两处修改。
首先,需要修改下面这行代码:
self.age = try container.decode(Int.self, forKey: .age)这行代码尝试将age属性解析为整数,这显然会失败。相反,我们需要先将其解析为字符串,然后将字符串转换为整数;如果转换失败,就提供一个默认值。将这行代码替换为:
let stringAge = try container.decode(String.self, forKey: .age)
self.age = Int(stringAge) ?? 0第二处需要修改的地方在encode(to:)方法中,这样当我们需要生成JSON数据时,就能保持现有的格式。这里需要修改的代码是:
try container.encode(self.age, forKey: .age)这行代码会将整数写入JSON,但我们需要将其转换为字符串后再写入,修改后的代码如下:
try container.encode(String(self.age), forKey: .age)我知道创建自定义实现看起来很麻烦,但正如你所见,它能让我们对编码和解码过程拥有完全的控制权:我们可以在加载和保存数据时添加任何逻辑,比如修改属性名、转换数据类型、提供默认值等等。
挑战任务
现在到了你从零开始构建应用的时刻,今天的挑战范围相当广泛:你的任务是使用URLSession从互联网上下载一些JSON数据,使用Codable将其转换为Swift类型,然后使用NavigationStack、List等组件将数据展示给用户。
你的第一步应该是查看JSON数据的结构。你需要使用的URL是:https://www.hackingwithswift.com/samples/friendface.json —— 这是一个包含大量随机生成的示例用户数据的集合。
如你所见,数据中有一个人员数组,每个人都有ID、姓名、年龄、电子邮件地址等信息。他们还有一个标签字符串数组,以及一个好友数组,每个好友都有姓名和ID。
你可以根据自己的情况决定实现到什么程度,但至少应该完成以下内容:
- 获取数据并将其解析为
User和Friend结构体。 - 展示用户列表,并显示一些关于用户的基本信息,例如姓名以及他们当前是否处于活跃状态。
- 创建一个详情视图,当用户被点击时显示该视图,展示更多关于该用户的信息,包括他们好友的姓名。
- 在开始下载数据之前,检查
User数组是否为空,以避免每次显示视图时都重新开始下载。
如果你不确定从哪里开始,可以先设计数据类型:创建一个User结构体,包含name、age、company等属性,然后创建一个Friend结构体,包含id和name属性。完成这些之后,再编写URLSession相关代码来获取数据,并将其解码为你定义的类型。
你可能会注意到,每个用户的注册日期都有一个非常特定的格式:2015-11-10T01:47:18-00:00。这种格式被称为ISO-8601,它非常常见,因此有一个内置的dateDecodingStrategy(日期解码策略)叫做.iso8601,可以自动对这种格式的日期进行解码。
在构建这个应用的过程中,我希望你记住一点:这类应用是iOS应用开发中的核心项目——如果你能自信地完成这个挑战,那么你在成为全职应用开发者的道路上已经迈出了坚实的一步。
提示: 与往常一样,解决这个挑战的最佳方法是保持简洁——用最少的代码解决问题,并且确保自己对代码的正确性有把握。