SwiftUI 学习笔记 40:项目 8-2 宇航员
一路走来,你将遇到一个重要的Swift功能,称为generics。我绝对在初学者Swift之外也定义了此功能,但是正如你将看到的,泛型使我们仅需一点点思考就可以创建高度可重用的代码。
可重用的代码很重要,因为它可以帮助我们以更少的工作来获得更大,更好的结果。但是,正如拉尔夫·约翰逊(Ralph Johnson)所说:“在软件可重用之前,首先必须要可用” –与泛型一样好,只有先以更简单的方式解决了问题,我们才会开始使用它们。
项目名:Moonshot
加载特定种类的可编码数据
在这个应用程序中,我们将两种不同的JSON加载到Swift结构中:一种用于宇航员,另一种用于任务。以易于维护且不会使我们的代码混乱的方式进行此操作需要一些思考,但是我们将逐步实现它。
首先,拖入该项目的两个JSON文件。这些可以在本书的GitHub存储库中的“ project8-files”下找到–查找astronauts.json和missions.json,然后将它们拖到项目导航器中。在添加资产时,你还应该将所有图像复制到资产目录中-这些位于“图像”子文件夹中。宇航员和任务徽章的图像都是由NASA制作的,因此根据美国法典第1标题第17章第1章第105节,我们可以在公共领域的许可下使用它们。
如果查看astronauts.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。输入以下代码:
1 | struct Astronaut: Codable, Identifiable { |
如你所见,我已经做到了遵守,Codable因此我们可以直接从JSON创建该结构的实例,而且还Identifiable可以在内部使用ForEach更多的宇航员数组,而且该id字段也可以正常工作。
接下来,我们要将astronauts.json转换为Astronaut实例数组,这意味着我们需要使用它Bundle来查找文件的路径,将其加载到的实例中Data,然后将其传递给JSONDecoder。以前,我们在上将其放入方法中ContentView,但在这里我想向你展示一种更好的方法:我们将编写一个扩展Bundle以在一个集中的地方进行所有操作。
创建另一个新的Swift文件,这次称为Bundle-Decodable.swift。这将主要使用你之前所见过的代码,但有一个小区别:以前我们曾经String(contentsOf:)将文件加载到字符串中,但是由于Codable使用而Data我们将改为使用Data(contentsOf:)。它的工作方式与String(contentsOf:):给它要加载的文件URL 相同,它要么返回其内容,要么抛出错误。
立即将其添加到Bundle-Decodable.swift中:
1 | extension Bundle { |
如你所见,它可以自由使用fatalError():如果找不到,加载或解码文件,则应用程序将崩溃。但是,像以前一样,除非你犯了错误,否则这将永远不会真正发生,例如,如果你忘记将JSON文件复制到项目中。
现在,你可能想知道为什么我们在这里使用扩展而不是方法,但是当我们将JSON加载到内容视图中时,原因将变得很清楚。ContentView现在将此属性添加到结构中:
1 | let astronauts = Bundle.main.decode("astronauts.json") |
是的,仅此而已。当然,我们所做的只是将代码移入ContentView和移出扩展,但这没有什么错-我们可以做的一切都可以帮助我们缩小视图并集中注意力,这是一件好事。
如果你要再次检查JSON是否正确加载,请将默认文本视图修改为:
1 | Text("\(astronauts.count)") |
那应该显示32而不是“ Hello World”。
使用泛型加载任何类型的可编码数据
我们添加了一个Bundle扩展,用于从应用程序捆绑包中加载一种特定类型的JSON数据,但是现在我们有了第二种类型:missions.json。它包含稍微复杂一些的JSON:
每个任务都有一个ID号,这意味着我们可以Identifiable轻松使用。
每个任务都有说明,这是从Wikipedia提取的自由文本字符串(请参阅上面的许可证!)
每个任务都有一组人员,每个人员都有自己的名字和角色。
除一个任务外,所有任务都有启动日期。可悲的是,阿波罗1号从未发射升空,因为发射演习舱的大火摧毁了指挥舱并杀死了机组人员。
让我们开始将其转换为代码。船员角色需要表示为自己的结构,并存储名称字符串和角色字符串。因此,创建一个名为Mission.swift的新Swift文件,并为其提供以下代码:
1 | struct CrewRole: Codable { |
对于任务,这将是ID整数,的数组CrewRole和描述字符串。但是启动日期呢–我们可能有一个,但也可能没有一个。我应该说是?
好吧,考虑一下:Swift如何在其他地方表示“也许,也许不是”?我们将如何存储“可能是字符串,可能根本什么都不是”?我希望答案是明确的:我们使用可选的。实际上,如果将属性标记为可选属性Codable,并且输入JSON中缺少该值,则会自动跳过该属性。
因此,现在将第二个结构添加到Mission.swift中:
1 | struct Mission: Codable, Identifiable { |
我们来看看如何JSON加载到之前,我想证明一件事:我们的CrewRole结构是专门作出关于任务存放数据,因此我们实际上可以把CrewRole结构内的Mission这样的结构:
1 | struct Mission: Codable, Identifiable { |
这称为嵌套结构,只是一个结构放置在另一个结构中。这不会影响我们在该项目中的代码,但是在其他地方,这有助于使代码井井有条:而不是说CrewRole要编写代码Mission.CrewRole。如果你可以想象一个具有数百种自定义类型的项目,那么添加此额外的上下文确实会有所帮助!
现在,让我们考虑如何将MissionMissions.json 加载到结构数组中。我们已经添加了一个Bundle扩展程序,该扩展程序将一些JSON文件加载到Astronaut结构数组中,因此我们可以非常轻松地复制并粘贴该文件,然后对其进行调整,以便加载任务而不是宇航员。但是,有一个更好的解决方案:我们可以利用Swift的泛型系统,这是我们在项目3中稍作接触的高级功能。
泛型允许我们编写能够与各种不同类型一起使用的代码。在此项目中,我们编写了Bundle扩展程序以与宇航员阵列一起使用,但实际上我们希望能够处理宇航员阵列,任务阵列或潜在的许多其他事情。
为了使方法通用,我们为某些类型提供一个占位符。这写在方法名称之后但参数之前的尖括号(<和>)中,如下所示:
1 | func decode<T>(_ file: String) -> [Astronaut] { |
我们可以为占位符使用任何东西-我们可以写成“ Type”,“ TypeOfThing”甚至“ Fish”;没关系 “ T”在编码中有点约定俗成,是“ type”的简写占位符。
在该方法内部,我们现在可以在将要使用的任何地方使用“ T” [Astronaut]–实际上,它是我们要使用的类型的占位符。因此,[Astronaut]我们将使用以下方法而不是返回:
1 | func decode<T>(_ file: String) -> T { |
请务必小心:T和之间有很大的区别[T]。请记住,T无论你要哪种类型,它都是一个占位符,因此,如果我们说“解码一组宇航员”,则T变为[Astronaut]。如果我们试图[T]从那里返回,decode()那实际上就是在返回[[Astronaut]]–一系列的宇航员!
在decode()方法结束时,还有另一个地方[Astronaut]被使用:
1 | guard let loaded = try? decoder.decode([Astronaut].self, from: data) else { |
同样,请将其更改为T,如下所示:
1 | guard let loaded = try? decoder.decode(T.self, from: data) else { |
因此,我们所说的decode()将与某种类型的类型一起使用,例如[Astronaut],它应尝试解码已加载为该类型的文件。
如果尝试编译此代码,则会在Xcode中看到错误:“实例方法’decode(_:from :)’要求’T’符合’Decodable’”。它的意思是什么T都可以:可以是宇航员,也可以是其他东西。问题在于,Swift无法确定我们使用的类型是否符合Codable协议,因此,冒着拒绝构建代码的风险,这没有冒着风险。
幸运的是,我们可以通过约束来解决此问题:T只要条件符合,我们就可以告诉Swift 可以是我们想要的任何东西Codable。这样,Swift知道它是安全的,并且将确保我们不会尝试使用不符合的类型的方法Codable。
要添加约束,请将方法签名更改为此:
1 | func decode<T: Codable>(_ file: String) -> T { |
如果你再试一次编译,你会看到,事情仍然没有工作,但现在它是一个不同的原因:“通用参数‘T’不能推断”,比在astronauts财产ContentView。这条线以前工作得很好,但是现在有了一个重要的变化:以前decode()总是返回一系列宇航员,但是现在只要符合,它就会返回我们想要的任何东西Codable。
我们知道它仍然会返回一组宇航员,因为实际的基础数据没有改变,但是Swift 并不知道。我们的问题是decode()可以返回任何符合的类型Codable,但是Swift需要更多信息-它想确切地知道它将是哪种类型。
因此,要解决此问题,我们需要使用类型注释,以便Swift确切知道astronauts将是什么:
1 | let astronauts: [Astronaut] = Bundle.main.decode("astronauts.json") |
最后–完成所有工作!–现在,我们也可以将mission.json加载到中的另一个属性中ContentView。请在下面添加astronauts:
1 | let missions: [Mission] = Bundle.main.decode("missions.json") |
而这是仿制药的力量,我们可以用同样的decode()方法从我们的包中加载任何JSON到任何雨燕符合Codable-我们不需要半打同样的方法的变体。
在我们完成之前,我要解释的最后一件事。早先你看到消息“实例方法’decode(_:from :)’要求’T’必须符合’Decodable’”,并且你可能想知道到底Decodable是什么-毕竟,我们一直在使用它Codable。好吧,在幕后,Codable这只是两个单独协议的别名:Encodable和Decodable。你可以Codable根据需要使用,也可以根据需要使用,也可以使用Encodable,Decodable具体取决于你自己。
格式化我们的任务视图
现在我们已经有了所有数据,我们可以在第一个屏幕上查看设计:所有任务的列表,紧挨着任务徽章。
我们之前添加的资产包含名为“ apollo1@2x.png”的图片和类似图片,这意味着它们可以在资产目录中以“ apollo1”,“ apollo12”等访问。我们的Mission结构有一个id整数,提供数字部分,因此我们可以使用字符串插值法”apollo(mission.id)”来获取图像名称和”Apollo (mission.id)”任务的格式化显示名称。
不过,在这里,我们将采用另一种方法:我们将向Mission结构添加一些计算属性,以将相同的数据发送回去。结果将是相同的“ apollo1”和“ Apollo 1”,但是现在代码在一个地方:我们的Mission结构。这意味着任何其他视图都可以使用相同的数据,而不必重复我们的字符串插值代码,这反过来意味着,如果我们更改这些格式的格式,即将图像名称更改为“ apollo-1”或其他内容,则我们可以只更改属性,Mission并更新所有代码。
因此,请立即将这两个属性添加到Missionstruct中:
1 | var displayName: String { |
有了这两个位置之后,我们现在可以进行第一遍填写ContentView:它将有一个NavigationView带有标题的,List使用我们的missions数组作为输入,并且其中的每一行将NavigationLink包含一个图像,名称和启动日期。任务。唯一的小麻烦是我们的启动日期是一个可选字符串,因此我们需要使用nil合并来确保要显示的文本视图有一个值。
这是的body代码ContentView:
1 | NavigationView { |
如你所见,它使用resizable(),aspectRatio(contentMode: .fit)和frame()来使图像占据44x44的空间,同时还保持其原始纵横比。这种情况非常普遍,SwiftUI实际上给我们提供了一个小捷径:与其使用,aspectRatio(contentMode: .fit)我们不可以这样写scaledToFit():
1 | Image(mission.image) |
这将自动导致图像按比例缩放以填充其容器,在本例中为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():
1 | let formatter = DateFormatter() |
这告诉解码器以我们期望的确切格式解析日期。而且,如果你现在运行代码,则外观将完全相同。是的,什么都没有改变,但是没关系:什么都没有改变,因为Swift并没有意识到那launchDate是一个日期。毕竟,我们这样声明:
1 | let launchDate: String? |
既然我们的解码代码了解了日期的格式,我们就可以将该属性更改为可选的Date:
1 | let launchDate: Date? |
…现在我们的代码甚至无法编译!
现在的问题是ContentView.swift中的以下代码行:
1 | Text(mission.launchDate ?? "N/A") |
尝试Date在文本视图中使用可选的内容,如果日期为空,则将其替换为“ N / A”。这是计算属性更好地工作的另一个地方:我们可以要求任务本身提供格式化的启动日期,该日期可以将可选日期转换为整齐的格式的字符串,或者将缺少日期的“ N / A”发送回去。
中采用相同的DateFormatter和dateStyle我们以前使用的属性,所以这应该是你比较熟悉。将此计算的属性添加到Mission现在:
1 | var formattedLaunchDate: String { |
现在,用以下内容替换损坏的文本视图ContentView:
1 | Text(mission.formattedLaunchDate) |
进行此更改后,我们的日期将以一种更加自然的方式呈现,甚至更好的是,将以用户适合区域的任何方式呈现-你所看到的不一定是我所看到的。
参考资料
- 感谢你赐予我前进的力量