译自 Using generics to load any kind of Codable data
更多内容,欢迎关注公众号 「Swift花园」
喜欢文章?不如来个 🔺💛➕三连?关注专栏,关注我 🚀🚀🚀
使用泛型加载任意类型的 Codable 数据
我们给 Bundle 加了一个扩展,以便从我们的 app bundle 中加载特定类型的 JSON 数据,但是现在又来了一个类型: missions.json 。它包含更复杂的 JSON 。
- 每个任务有一个 ID 数字,意味着我们可以很方便地使用
Identifiable。 - 每个任务有一个描述,它是从维基百科上摘录的文本。
- 每个任务都有一个机组成员数组,其中每位机组成员都有名字和角色。
- 除了一个任务,所有任务都有发射日期。不幸的是,阿保罗 1 号从未发射,因为一次例行测试中指令舱发生大火,让机组三名宇航员全部丧生。
让我们把这些转换成代码。机组角色需要用结构体表示,存储姓名和角色名。我们创建一个新的 Swift 文件,取名叫 Mission.swift ,添加代码:
struct CrewRole: Codable {
let name: String
let role: String
}对于任务,应该要有一个 ID 整数,一个 CrewRole数组以及一个描述字符串。但对于发射日期 —— 它可能有,也可能没有。我们应该怎么做呢?
思考一下:Swift 在别的地方是如何表示这种 “可能有,可能没有” 的情况?”可能是一个字符串,可能什么也没有“ 这种情况我们如何存储?答案很明显:我们用可选型。实际上,如果我们把一个属性标记为可选型,Codable 会在输入的 JSON 中对应属性的值缺失的情况下自动跳过。
把第二个结构体添加到 Mission.swift :
struct Mission: Codable, Identifiable {
let id: Int
let launchDate: String?
let crew: [CrewRole]
let description: String
}在我们说到如何加载 JSON 之前,我想要再演示一样东西: CrewRole结构体是专门用来存放任务数据的,因此我们实际上可以把 CrewRole 结构体放进 Mission 结构体里面,像这样:
struct Mission: Codable, Identifiable {
struct CrewRole: Codable {
let name: String
let role: String
}
let id: Int
let launchDate: String?
let crew: [CrewRole]
let description: String
}这被称为 嵌套结构体 ,其实就是处于一个结构体内部的结构体。当然,是否使用嵌套并不影响项目的逻辑,但在别的地方这会有助于保持代码的条理性:在这个项目中是由CrewRole到Mission.CrewRole的转换。想象一个有几百个自定义类型的大项目,这个额外的上下文真的会有帮助。
现在,让我们思考如何加载 missions.json 到一个 Mission 数组。我们已经给 Bundle 添加了一个扩展,可以加载某个 JSON 文件到 Astronaut 数组,所以当然简单的复制粘贴,然后稍作修改,从加载宇航员变成加载任务。但是,这里有更好的解决方案:我们可以借助 Swift 的泛型系统,这是一个高级特性,我们在第三个项目中曾经粗略带过。
泛型让我们编写可以适用各种不同类型的代码。在这个项目中,我们已经写完了用于处理宇航员数组的 Bundle 扩展,但实际上我们希望能够处理宇航员数组,任务数组,或者很多其他的可能的东西。
让一个方法泛型化,我们需要给类型一个占位符。这是通过在方法名之后,参数列表之前,把占位符放进一对尖括号(<和 >) 来实现的,像下面这样:
func decode<T>(_ file: String) -> [Astronaut] {对于这个占位符,我们可以使用任意标识 —— 我们可以写 ”Type“ , “TypeOfThing”,甚至 “Fish”,都没关系。 “T” 基本上算是编程上的一种约定,作为 "type" 的速记占位符。
在方法内部,我们就可以在各处使用 “T” 来替换我们本来用[Astronaut]标识的类型 —— 它就是一个我们要处理的类型的占位符。因此,我们不是返回 [Astronaut] ,而是返回 T :
func decode<T>(_ file: String) -> T {小心: T 和 [T]之间有重大区别。记住, T 代表我们要用的类型的占位符,所以,如果我们表示 ”解码一个宇航员数组“ ,那么 T 就变成 [Astronaut]。如果我们试图从 decode() 返回 [T] ,那么我们实际上是在返回 [[Astronaut]] —— 这是一个宇航员数组的数组!
在 decode() 方法的最后,还有一处使用 [Astronaut] 的地方:
guard let loaded = try? decoder.decode([Astronaut].self, from: data) else {这里也需要改成 T,像这样:
guard let loaded = try? decoder.decode(T.self, from: data) else {到这里,我们说 decode() 方法会被用于某种类型,比如[Astronaut],并且它应该试图加载跟声明的类型匹配的文件。
如果你尝试编译代码,会在 Xcode 遭遇错误:“Instance method 'decode(_:from:)' requires that 'T' conform to 'Decodable’” 。这句话的意思是 T 可能是任何东西:它可能是一个宇航员数组,或者完全不同的另外一种东西。问题在于 Swift 无法确定要处理的类型会遵循 Codable 协议。为了不冒险,它拒绝编译代码。
我们可以用一个 约束 修复这个问题:我们告诉 Swift 这个泛型可以是任何东西,只要这个东西遵循 Codable 协议。这样一来 Swift 就知道使用这个东西是安全的,并且会确保我们不能尝试传入一个不遵循 Codable的东西来使用这个方法。
把方法签名改成下面这样以添加约束:
func decode<T: Codable>(_ file: String) -> T {再次编译代码,还是有问题,但这次是不同的原因:“Generic parameter 'T' could not be inferred”,出现在 ContentView 的 astronauts 属性上。这行代码之前是可以工作的,但这里有一个重要的改动:先前decode() 总是会返回宇航员数组,但现在它可能返回任何东西,只要这东西遵循 Codable。
我们知道它实际上还是返回一个宇航员数组,因为实际的底层数据并没有发生变化,但 Swift 并不知道这一点。问题在于 decode() 可以返回任何遵循 Codable的东西,但是 Swift 需要知道更多信息 —— 它希望精确地知道这个类型是什么。
解决这个问题我们需要用到类型注释,以便 Swift 精确地知道 astronauts 会是什么类型:
let astronauts: [Astronaut] = Bundle.main.decode("astronauts.json")终于可以工作了 —— 现在我们把 mission.json 也加载进ContentView的另一个属性。把下面的代码添加到 astronauts那行下面:
let missions: [Mission] = Bundle.main.decode("missions.json")这正是泛型的能力所在:我们可以相同的decode() 方法从 bundle 中加载任何 JSON 数据到遵循Codable的 Swift 类型 —— 我们不再需要一堆相同方法的变体。
结束之前,我要再解释一件事。先前你看到 “Instance method 'decode(_:from:)' requires that 'T' conform to 'Decodable’” 的消息,你可能在想 Decodable 是什么 —— 毕竟,我们用的是 Codable 。是这样的,在幕后,Codable其实只是两个独立的协议组合在一起的别名:它们是 Encodable 和 Decodable。你既可以用 Codable ,也可以用Encodable 加 Decodable ,这完全取决于你。
译自 Formatting our mission view
格式化 Mission 视图
现在我们的所有的数据都就位了,可以先看首屏的设计:这是一个包含所有任务的列表,每个任务的旁边是任务徽章。
我们早先添加的 assets 包含名字像 “apollo1@2x.png” 这样的以及类似的图片,因此可以通过 “apollo1”,“apollo12” 等来访问图片。我们的 Mission 结构体有一个 id整数,提供了数字的部分,所以我们可以用像 "apollo\(mission.id)" 这样的字符串插值来组成图像名, 以及 "Apollo \(mission.id)"这样的格式化字符串来显示任务名称。
不过这里我们要用一个不同的方法:我们要添加一些计算属性到 Mission 结构体,返回跟字符串插值相同的数据。结果是一样的 —— 都是 “apollo1” 和 “Apollo 1” —— 但是现在代码在一个地方: Mission 结构体里面。这意味着其他任何视图都可以使用相同的数据,不必重复写字符串插值,因此假如我们想要修改这些字符串的格式 —— 比如,把图像名称改成 “apollo-1” 或者别的什么 —— 那么我们只需要修改 Mission 里一处属性就可以更新全部的代码。
把这两个属性加到 Mission结构体:
var displayName: String {
"Apollo \(id)"
}
var image: String {
"apollo\(id)"
}有了这两个属性,我们开始填充 ContentView:它将包含一个带有标题的NavigationView ,一个用任务数组作为输入的 List,其中每行都是一个 NavigationLink ,包含图片,名字,发射日期。唯一有点复杂的地方是我们的发射日期是可选型,所以我们需要用空合运算符来保证有值用于文本视图显示。
下面是 body 的代码:
NavigationView {
List(missions) { mission in
NavigationLink(destination: Text("Detail view")) {
Image(mission.image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 44, height: 44)
VStack(alignment: .leading) {
Text(mission.displayName)
.font(.headline)
Text(mission.launchDate ?? "N/A")
}
}
}
.navigationBarTitle("Moonshot")
}如你所见,我们用到了 resizable(), aspectRatio(contentMode: .fit) 和 frame() 来确保图像占据 44x44 的空间并且保持原始高宽比。这个场景很常见,SwiftUI 提供了一种快捷方式:与其用 aspectRatio(contentMode: .fit) ,我们可以只写 scaledToFit() ,像这样:
Image(mission.image)
.resizable()
.scaledToFit()
.frame(width: 44, height: 44)这个会自动导致图像按比例缩放到填充容器的大小,在这里是 44x44 的边框 。
运行程序,看起来还不错,但是那些日期呢?尽管我们可以看懂 “1968-12-21” ,明白这是 1968 年 12 月 21 号,这对于许多人来说仍然是一个不自然的日期格式。我们可以优化它!
Swift 的 JSONDecoder 类型有一个属性叫 dateDecodingStrategy,它决定了如何解码日期。我们可以提供一个 DateFormatter 实例给它,描述我们的日期应当如何格式化。在我们的案例中,日期是以年-月-日的形式写的,但在现实世界中的日期可没有这么简单:第一个月要写成 “1”, “01”, “Jan” 还是 “January” ?年要写成 “1968” 还是 “68” ?
我们已经通过 dateStyle 和 timeStyle 属性使用过DateFormatter 的内建样式,但这次我们要用它的dateFormat 属性来指定一个精确的格式:“y-MM-dd” 。这在 Swift 中表示 “年,然后短折线,然后以零填充的月,然后短折线,再然后以零填充的日“ —— 其中以零填充意味着一月份是写作 “01” 而不是 “1” 。
警告:日期格式是大小写敏感的,所以 mm 代表 “以零填充的分钟” ,而 MM 代表 “以零填充的月份”
打开 Bundle-Decodable.swift 把下面的代码添加到 let decoder = JSONDecoder()后面:
let formatter = DateFormatter()
formatter.dateFormat = "y-MM-dd"
decoder.dateDecodingStrategy = .formatted(formatter)这告诉 decoder 我们期望的日期解析格式。再运行代码,你发现什么都没有变化。是的,什么都没有变化。这是 Swift 没有意识到 launchDate 是一个日期。毕竟,我们声明它的时候是这样写的:
let launchDate: String?现在,既然解码的代码已经知道日期要怎么格式化,我们可以把这个属性改成可选型的Date:
let launchDate: Date?现在代码编译不过了。
问题出在 ContentView.swift 里的这一行:
Text(mission.launchDate ?? "N/A")它试图在文本视图里使用一个可选型Date,在日期为空的时候用 “N/A” 字符串作为替代。
这又是一个计算属性可以发挥的地方:我们可以让任务本身提供一个格式化好的字符串,它会把可选型日期转换成整齐格式化的字符串,如果日期不存在返回则 “N/A” 。
var formattedLaunchDate: String {
if let launchDate = launchDate {
let formatter = DateFormatter()
formatter.dateStyle = .long
return formatter.string(from: launchDate)
} else {
return "N/A"
}
}把 ContentView出错的地方替换成下面这样:
Text(mission.formattedLaunchDate)经过这些修改,我们的日期现在能以一种更自然的方式渲染。更好的是,它是以适合用户所在国家和地区的样式呈现的 —— 你看到的不一定是我看到的。
我的公众号 这里有Swift及计算机编程的相关文章,以及优秀国外文章翻译,欢迎关注~
