[SwiftUI 100 天] 登月计划 - part3 泛型

334 阅读11分钟
译自 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
}

这被称为 嵌套结构体 ,其实就是处于一个结构体内部的结构体。当然,是否使用嵌套并不影响项目的逻辑,但在别的地方这会有助于保持代码的条理性:在这个项目中是由CrewRoleMission.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其实只是两个独立的协议组合在一起的别名:它们是 EncodableDecodable。你既可以用 Codable ,也可以用EncodableDecodable ,这完全取决于你。

译自 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” ?

我们已经通过 dateStyletimeStyle 属性使用过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及计算机编程的相关文章,以及优秀国外文章翻译,欢迎关注~