月球探测器:介绍
在这个项目中,我们将构建一个应用程序,让用户了解组成美国宇航局阿波罗太空计划的任务和宇航员。您将获得更多的使用Codable
的经验,但更重要的是,您还将使用滚动视图、导航和更有趣的布局。
是的,您将获得一些List
、Text
等方面的练习时间,但您也将开始解决重要的SwiftUI问题——如何使图像正确地适应其空间?如何使用计算属性优化代码?如何将较小的视图组合成较大的视图以帮助保持项目的组织性?
和往常一样,还有很多事情要做,所以让我们开始:使用单视图应用程序模板创建一个新的iOS应用程序,将其命名为“Moonshot”。我们将在这个项目中使用它,但是首先让我们更深入地了解一下您需要熟悉的新技术…
加载特定类型的 Codable 数据
在此应用中,我们将两种不同的JSON加载到Swift结构中:一种用于宇航员,另一种用于任务。以易于维护且不会使我们的代码混乱的方式进行此操作需要一些思考,但是我们将逐步实现它。
首先,把这两个JSON文件拖入项目。在本书的GitHub存储库中,在“project8-files”下可以找到这些文件——查找宇航员 astronauts.json
和任务 missions.json
,然后将它们拖到项目导航器中。在添加资产时,您还应该将所有图像复制到资产目录中,这些都在“ Images”子文件夹中。宇航员的照片和任务徽章都是由美国宇航局制作的,因此根据美国法典第17章第105节,它们可供我们在公共领域许可下使用。
如您在spacens.json
中看到,每个宇航员都由三个字段定义:ID(“grissom”、“white”、“chaffee”等)、他们的名字(“Virgil I.”Gus“grissom”等)和从维基百科复制的简短描述。如果您打算在自己的项目中使用该文本,请务必向Wikipedia及其作者致信,并明确说明该作品是根据CC-BY-SA授权的,可从以下网址获得:https://creativecommons.org/licenses/by-sa/3.0.
现在我们把宇航员的数据转换成Swift结构——按Cmd+N
创建一个新文件,选择Swift文件,然后将其命名为 Astronaut.Swift。这是它的代码:
struct Astronaut: Codable, Identifiable {
let id: String
let name: String
let description: String
}
如您所见,我已经使它符合Codable
,这样我们就可以直接从JSON创建这个结构的实例,而且还遵从Identifiable
协议,这样我们就可以在ForEach中
使用宇航员数组等等——这个id
字段会做得很好。
接下来,我们要将 sconasts.json 转换为一组宇航员实例,这意味着我们需要使用Bundle
找到文件的路径,将其加载到数据实例中,并通过JSONDecoder
传递。以前,我们把它放在ContentView
上的一个方法中,但在这里,我想向您展示一个更好的方法:我们将在Bundle
上编写一个扩展,以便在一个集中的地方完成这一切。
创建另一个新的Swift文件,这次称为 Bundle-Decodable.Swift。这将主要使用您以前看到的代码,但有一点不同:以前我们使用String(contentsOf:)
将文件加载到字符串中,但由于Codable
使用Data
,因此我们将使用Data(contentsOf:)
。它的工作方式与String(contentsOf:)
相同:给它一个要加载的文件URL
,它要么返回其内容,要么抛出错误。
现在将以下添加到Bundle-Decodable.swift:
extension Bundle {
func decode(_ file: String) -> [Astronaut] {
guard let url = self.url(forResource: file, withExtension: nil) else {
fatalError("Failed to locate \(file) in bundle.")
}
guard let data = try? Data(contentsOf: url) else {
fatalError("Failed to load \(file) from bundle.")
}
let decoder = JSONDecoder()
guard let loaded = try? decoder.decode([Astronaut].self, from: data) else {
fatalError("Failed to decode \(file) from bundle.")
}
return loaded
}
}
如您所见,这充分利用了fatalError():
如果找不到、加载或解码文件,应用程序将崩溃。不过,和以前一样,除非您犯了错误,否则这种情况永远不会发生,例如,如果您忘记将JSON文件复制到项目中。
现在,您可能想知道为什么我们在这里使用扩展而不是方法,但是当我们将JSON加载到内容视图中时,原因将变得很清楚。立即将此属性添加到ContentView
结构:
let astronauts = Bundle.main.decode("astronauts.json")
是的,就这些。当然,我们所做的只是将代码从ContentView
移到一个扩展中,但这并没有什么错——我们能做的任何事情都能帮助我们的视图保持小而集中,这是一件好事。
如果要再次检查JSON是否正确加载,请将默认文本视图修改为:
Text("\(astronauts.count)")
它应该显示32而不是“Hello World”。
使用泛型加载任何类型的 Codable 数据
我们添加了一个Bundle
扩展,用于从app Bundle加载一种特定类型的JSON数据,但现在我们有了第二种类型:missions.json。这包含稍微复杂一点的JSON:
- 每个任务都有一个ID号,这意味着我们可以很容易地使用
Identifiable
。 - 每个任务都有一个描述,这是一个从维基百科获取的自由文本字符串(见上面的许可证!)
- 每个任务都有一组人员,每个人员都有自己的名字和角色。
- 除了一个任务外,所有任务都有发射日期。不幸的是,阿波罗一号从未发射过,因为一次发射排练舱火灾摧毁了指挥舱并杀死了机组人员。
我们开始把它转换成代码。乘员角色需要表示为它们自己的结构体,存储名称字符串和角色字符串。因此,创建一个名为 Mission.Swift 的新 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 的泛型系统,这是我们在项目3中略微涉及的一个高级特性。
泛型允许我们编写能够处理各种不同类型的代码。在这个项目中,我们编写了Bundle
扩展来处理宇航员数组,但实际上我们希望能够处理宇航员数组列、任务数组,或者潜在的许多其他事情。
为了使方法泛型,我们给它一个特定类型的占位符。这写在方法名后面、参数前面的尖括号(<
和>
)中,如下所示:
func decode(_ file: String) -> [Astronaut] {
我们可以使用任何占位符——我们可以写“Type”,“typething”,甚至“Fish”;这无关紧要。“T”是编码中的一个约定,作为“type”的一个简短占位符。
在这个方法中,我们现在可以在任何地方使用“T”[Astronaut]
——它实际上是我们想要使用的类型的占位符。所以,与其让[Astronaut]
返回,不如用这个:
func decode(_ 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
,T
可以是我们想要的任何东西。这样Swift就知道使用它是安全的,并且将确保我们不会尝试使用不符合Codable
类型的方法。
要添加约束,请将方法签名更改为:
func decode(_ file: String) -> T {
如果您再次尝试编译,您将看到事情仍然不起作用,但现在有一个不同的原因:“Generic parameter 'T' could not be inferred”,在宇航员的ContentView
属性中。这条线以前工作得很好,但现在有一个重要的变化:之前 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()
方法将任何JSON从我们的包加载到任何符合Codable
的Swift类型中——我们不需要同一方法的半打变体。
在我们结束之前,我还有最后一件事要解释。早些时候,您看到消息“Instance method 'decode(_:from:)' requires that 'T' conform to 'Decodable’”,您可能想知道什么是Decodable
——毕竟,我们一直在各地使用Codable
。好吧,在幕后,Codable
只是两个独立协议的别名:Encodable
和Decodable
。如果你想的话,你可以使用Codable
,或者如果你喜欢具体的话,你可以使用Encodable
和Decodable
——这取决于你自己。
创建任务视图
现在我们已经有了所有数据,我们可以在第一个屏幕上开始我们的界面设计:所有任务的列表,紧挨着任务徽章。
我们之前添加的资产包含名为“[email protected]”的图片和类似图片,这意味着它们可以在资产目录中以“ 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
,一个使用我们的Missions
数组作为输入的List
,并且其中的每一行将有一个包含图像,名称,和任务的发射日期。唯一的复杂之处在于我们的发布日期是一个可选字符串,因此我们需要使用空合运算符??
来确保要显示的文本视图有一个值。
这是ContentView
的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”?
我们已经使用DateFormatter
的dateStyle和timeStyle
属性来使用一种内置样式,但是这里我们将使用其dateFormat
属性来指定一种精确的格式:“y-MM-dd”。这就是Swift的说法:“一年,然后是一个破折号,然后是一个零填充的月份,然后是一个破折号,然后是一个零填充的日期”,其中“零填充”表示一月被写为“01”而不是“1”。
警告:日期格式区分大小写!mm 表示“零填充分钟”,MM 表示“零填充月份”。
因此,打开Bundle-Decodable.swift
并在letcoder = JSONDecoder()
之后直接添加以下代码:
let formatter = DateFormatter()
formatter.dateFormat = "y-MM-dd"
decoder.dateDecodingStrategy = .formatted(formatter)
这告诉解码器以我们期望的确切格式解析日期。而且,如果您现在运行代码...一切将完全相同。是的,什么都没有改变,但是没关系:什么都没有改变,因为Swift并未意识到launchDate
是一个日期。毕竟,我们这样声明:
let launchDate: String?
现在,我们的解码代码了解日期的格式,我们可以将该属性更改为可选的Date
:
let launchDate: Date?
…现在我们的代码甚至无法编译!
现在的问题是ContentView.swift
中的以下代码行:
Text(mission.launchDate ?? "N/A")
尝试在文本视图中使用可选的Date
,如果日期为空,则将其替换为“ N / A”。这是计算属性更好地工作的另一个地方:我们可以要求任务本身提供格式化的启动日期,该日期可以将可选日期转换为整齐的格式的字符串,或者将缺少日期的“ N / A”发送回去。
它使用了以前使用的相同的DateFormatter
和dateStyle
属性,因此您应该对此有些熟悉。将此计算的属性立即添加到任务:
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)
进行此更改后,我们的日期将以更加自然的方式呈现,甚至更好的是,将以用户适合所在地区的任何方式呈现——您所看到的不一定是我所看到的。
译自
Moonshot: Introduction
Loading a specific kind of Codable data
Using generics to load any kind of Codable data
Formatting our mission view
Swift:处理多级Codable数据 | Hacking with iOS: SwiftUI Edition | Moonshot 项目(二) |
---|
赏我一个赞吧~~~