第40天 项目 8 第二部分
今天,我们将开始开发应用程序的第一部分,重点是确保数据处理正确。我知道数据处理可能不是那么令人兴奋,但随着你的技能不断提升,你会逐渐发现数据才是决定应用程序功能的关键——无论你添加多么精美的设计,或是多么流畅的动画,如果数据处理不当,应用程序终究无法正常运行。
在此过程中,你将接触到 Swift 中的一个重要特性——泛型(generics)。我明确将其归为 Swift 进阶内容,但你会发现,只需多花一点心思,泛型就能帮助我们创建高度可复用的代码。
可复用代码至关重要,因为它能让我们用更少的工作量实现更强大的功能。不过,正如拉尔夫·约翰逊(Ralph Johnson)曾经说过的:“软件要先能用,才能谈复用”——泛型固然好用,但我们只有先通过更简单的方式解决问题,才会开始真正运用泛型。
今天你需要完成三个主题的学习,在这些内容中,你将获得更多使用 Codable 的实战经验,初步尝试泛型的用法,以及学习更多相关知识。
- 加载特定类型的 Codable 数据
- 使用泛型加载任意类型的 Codable 数据
- 设计任务视图(Mission View)的格式
加载特定类型的 Codable 数据
作者:Paul Hudson 2024年4月4日
在这个应用程序中,我们需要将两种不同的 JSON 数据加载到 Swift 结构体中:一种是宇航员(astronauts)数据,另一种是任务(missions)数据。要以易于维护、不冗余的方式实现这一功能,需要一些思考,我们将逐步推进。
首先,导入该项目所需的两个 JSON 文件。你可以在本书的 GitHub 仓库中找到它们,路径为 “project8-files”——分别是 astronauts.json 和 missions.json,将这两个文件拖入项目导航器(Project Navigator)即可。同时,还需将所有图片资源复制到资源目录(Asset Catalog)中,这些图片位于 “Images” 子文件夹内。宇航员图片和任务徽章图片均由美国国家航空航天局(NASA)提供,根据《美国法典》第17编第1章第105节的规定,这些资源属于公有领域,可供我们自由使用。
打开 astronauts.json 文件,你会发现每个宇航员的信息包含三个字段:ID(如 “grissom”、“white”、“chaffee” 等)、姓名(如 “Virgil I. "Gus" Grissom” 等),以及一段源自维基百科的简短介绍。如果你打算在正式发布的项目中使用这些文本,务必注明维基百科及其作者的版权,并明确说明相关内容依据知识共享署名-相同方式共享许可协议(CC-BY-SA)授权,该协议详情可查阅:https://creativecommons.org/licenses/by-sa/3.0。
现在,我们将宇航员数据转换为 Swift 结构体。按下 Cmd+N 创建新文件,选择 “Swift File”,并命名为 Astronaut.swift。在文件中添加以下代码:
struct Astronaut: Codable, Identifiable {
let id: String
let name: String
let description: String
}如你所见,我们让该结构体遵循了 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 中添加以下代码:
extension Bundle {
func decode(_ file: String) -> [String: Astronaut] {
guard let url = self.url(forResource: file, withExtension: nil) else {
fatalError("无法在资源包中找到 \(file) 文件。")
}
guard let data = try? Data(contentsOf: url) else {
fatalError("无法从资源包中加载 \(file) 文件。")
}
let decoder = JSONDecoder()
guard let loaded = try? decoder.decode([String: Astronaut].self, from: data) else {
fatalError("无法从资源包中解码 \(file) 文件。")
}
return loaded
}
}我们稍后会再回顾这段代码,你会发现其中大量使用了 fatalError():如果文件找不到、加载失败或解码失败,应用程序都会崩溃。但和之前一样,只有在你操作失误时(例如忘记将 JSON 文件复制到项目中)才会出现这种情况,正常情况下不会发生。
你可能会疑惑,为什么我们选择编写扩展而非普通方法?答案很快就会揭晓——当我们在内容视图中加载 JSON 数据时,你就能明白这种方式的优势。现在,在 ContentView 结构体中添加以下属性:
let astronauts = Bundle.main.decode("astronauts.json")没错,只需这一行代码即可完成加载。诚然,我们只是将代码从 ContentView 移到了扩展中,但这并无不妥——任何能让视图保持简洁、专注的做法都是值得的。
如果你想验证 JSON 数据是否加载成功,可以将默认的 body 属性修改为以下代码:
Text(String(astronauts.count))此时屏幕应显示数字 32,而不是默认的 “Hello World”。
在结束本部分之前,我们再仔细看看这个扩展。当前代码在本项目中完全可用,但如果想在未来的项目中复用,建议添加一些额外代码以便排查问题。
将方法的后半部分替换为以下代码:
let decoder = JSONDecoder()
do {
return try decoder.decode([String: Astronaut].self, from: data)
} catch DecodingError.keyNotFound(let key, let context) {
fatalError("无法从资源包中解码 \(file) 文件,原因是缺少键 '\(key.stringValue)'——\(context.debugDescription)")
} catch DecodingError.typeMismatch(_, let context) {
fatalError("无法从资源包中解码 \(file) 文件,原因是类型不匹配——\(context.debugDescription)")
} catch DecodingError.valueNotFound(let type, let context) {
fatalError("无法从资源包中解码 \(file) 文件,原因是缺少 \(type) 类型的值——\(context.debugDescription)")
} catch DecodingError.dataCorrupted(_) {
fatalError("无法从资源包中解码 \(file) 文件,该文件似乎是无效的 JSON 格式。")
} catch {
fatalError("无法从资源包中解码 \(file) 文件:\(error.localizedDescription)")
}这一修改虽小,但能让方法在解码失败时明确告知具体原因——当你的 Swift 代码与 JSON 文件结构不匹配时,这一功能会非常实用!
使用泛型加载任意类型的 Codable 数据
作者:Paul Hudson 2024年4月4日
我们为 Bundle 添加了扩展,用于从应用资源包中加载特定类型的 JSON 数据,但现在我们需要处理第二种数据类型:missions.json。该文件中的 JSON 结构稍复杂一些:
- 每个任务都有一个 ID 编号,因此很容易让其遵循
Identifiable协议。 - 每个任务都有一段描述文本,内容源自维基百科。
- 每个任务都包含一个机组人员(crew)数组,数组中的每个元素都包含姓名(name)和角色(role)字段。
- 除一个任务外,所有任务都有发射日期。遗憾的是,阿波罗1号(Apollo 1)在发射演练时,指令舱发生火灾,导致机组人员全部遇难,最终未能发射。
首先,我们将这些数据转换为代码。机组人员的角色信息需要用单独的结构体表示,存储姓名和角色字符串。创建一个新的 Swift 文件,命名为 Mission.swift,并添加以下代码:
struct CrewRole: Codable {
let name: String
let role: String
}至于任务结构体,它应包含 ID 整数、CrewRole 类型的数组以及描述字符串。但发射日期该如何处理呢?有些任务有发射日期,有些则没有。这种情况该如何表示?
不妨思考一下:Swift 中是如何表示“可能有、可能没有”这种情况的?如果要存储“可能是字符串,也可能是空值”,答案应该很明确——使用可选类型(optionals)。事实上,如果将某个属性标记为可选类型,Codable 在解析 JSON 数据时,若该字段不存在,会自动跳过。
因此,在 Mission.swift 中添加第二个结构体:
struct Mission: Codable, Identifiable {
let id: Int
let launchDate: String?
let crew: [CrewRole]
let description: String
}在学习如何加载 JSON 数据到该结构体之前,我还想演示一点: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
}这被称为嵌套结构体(nested struct),即一个结构体包含在另一个结构体内部。这一修改不会影响本项目中的代码,但在其他场景下,它有助于代码组织:之后引用机组人员角色时,需写成 Mission.CrewRole,而不是单独的 CrewRole。想象一下,若项目中有数百个自定义类型,这种带有上下文的命名方式能极大提升代码的可读性!
现在,我们来思考如何将 missions.json 数据加载为 Mission 结构体的数组。之前我们为 Bundle 添加的扩展只能加载宇航员字典类型的 JSON 数据,我们完全可以复制粘贴那段代码,再修改为加载任务数据。但还有一种更优的方案:利用 Swift 的泛型系统。
泛型允许我们编写可处理多种不同类型数据的代码。在本项目中,我们之前编写的 Bundle 扩展仅适用于宇航员字典,但实际上我们希望它能处理宇航员字典、任务数组,甚至其他更多类型的数据。
要创建泛型方法,需为方法指定类型占位符。类型占位符写在方法名之后、参数列表之前,用尖括号(< 和 >)包裹,示例如下:
func decode<T>(_ file: String) -> [String: Astronaut] {类型占位符的名称可以任意选择——你可以写成 “Type”、“TypeOfThing”,甚至 “Fish”,都没有问题。在编程中,“T” 是一种常用约定,作为 “type”(类型)的缩写占位符。
在方法内部,之前使用 [String: Astronaut] 的地方,现在都可以用 “T” 替代——它本质上就是我们要处理的目标类型的占位符。因此,方法的返回类型也应改为 T,代码如下:
func decode<T>(_ file: String) -> T {务必注意: T 和 [T] 有很大区别。请记住,T 是我们指定的目标类型的占位符,例如,如果我们要“解码宇航员字典”,那么 T 就代表 [String: Astronaut]。如果我们试图让 decode() 方法返回 [T],那么实际返回的会是 [[String: Astronaut]]——即宇航员字典的数组!
在 decode() 方法的中间部分,还有一处使用了 [String: Astronaut]:
return try decoder.decode([String: Astronaut].self, from: data)同样,将其改为 T,代码如下:
return try decoder.decode(T.self, from: data)这样一来,我们就明确了 decode() 方法的功能:它可用于处理任意类型(如 [String: Astronaut]),并尝试将加载的文件解码为该类型。
如果你尝试编译这段代码,会在 Xcode 中看到一个错误:“实例方法 'decode(_:from:)' 要求 'T' 遵循 'Decodable' 协议”。这个错误的含义是:T 可以是任意类型——既可以是宇航员字典,也可以是其他完全不同的类型。问题在于,Swift 无法确定我们要处理的类型是否遵循 Codable 协议,为避免风险,它会拒绝编译代码。
幸运的是,我们可以通过约束(constraint) 来解决这个问题:我们可以告诉 Swift,T 可以是任意类型,但必须遵循 Codable 协议。这样一来,Swift 就能确定该类型是安全可用的,并且会阻止我们在该方法中使用不遵循 Codable 协议的类型。
要添加约束,需将方法签名修改为:
func decode<T: Codable>(_ file: String) -> T {如果再次尝试编译,你会发现问题依然存在,但错误原因变了:在 ContentView 的 astronauts 属性处,出现“无法推断泛型参数 'T'”的错误。之前这段代码能正常运行,但现在情况发生了变化:之前的 decode() 方法始终返回宇航员字典,而现在它可以返回任何遵循 Codable 协议的类型。
我们知道,由于底层数据没有变化,它仍然会返回宇航员字典,但 Swift 并不知道这一点。问题的核心在于,decode() 方法可返回任何遵循 Codable 协议的类型,而 Swift 需要更多信息来确定具体类型。
因此,要解决这个问题,我们需要添加类型标注,让 Swift 明确 astronauts 的类型:
let astronauts: [String: Astronaut] = Bundle.main.decode("astronauts.json")终于!经过这些步骤后,我们现在还可以在 ContentView 中添加另一个属性,用于加载 missions.json 数据。在 astronauts 属性下方添加以下代码:
let missions: [Mission] = Bundle.main.decode("missions.json")这就是泛型的强大之处:我们可以使用同一个 decode() 方法,将资源包中的任意 JSON 数据加载为任何遵循 Codable 协议的 Swift 类型——无需为不同类型编写多个重复的方法。
在结束本部分之前,我还想解释一个知识点。 earlier你看到过错误提示“实例方法 'decode(_:from:)' 要求 'T' 遵循 'Decodable' 协议”,你可能会疑惑 Decodable 是什么——毕竟我们一直使用的是 Codable。事实上,在底层,Codable 只是两个独立协议(Encodable 和 Decodable)的别名。你可以根据需要选择使用 Codable,也可以选择明确使用 Encodable 和 Decodable——具体取决于你的偏好。
设计任务视图(Mission View)的格式
作者:Paul Hudson 2024年8月9日
现在我们已经处理好了所有数据,接下来可以开始设计第一个屏幕:展示所有任务的网格布局,每个任务旁配有对应的任务徽章。
之前添加的资源文件中,包含名为 “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 的内容了:该视图将包含一个带有标题的 NavigationStack、一个使用 missions 数组作为数据源的 LazyVGrid,网格中的每一行都是一个 NavigationLink,包含任务图片、任务名称和发射日期。其中唯一的小难点是,发射日期是可选字符串,因此我们需要使用空合运算符(nil coalescing)确保文本视图(Text)始终有值可显示。
首先,在 ContentView 中添加以下属性,用于定义自适应的列布局:
let columns = [
GridItem(.adaptive(minimum: 150))
]然后,将现有的 body 属性替换为以下代码:
NavigationStack {
ScrollView {
LazyVGrid(columns: columns) {
ForEach(missions) { mission in
NavigationLink {
Text("详情视图")
} label: {
VStack {
Image(mission.image)
.resizable()
.scaledToFit()
.frame(width: 100, height: 100)
VStack {
Text(mission.displayName)
.font(.headline)
Text(mission.launchDate ?? "无数据")
.font(.caption)
}
.frame(maxWidth: .infinity)
}
}
}
}
}
.navigationTitle("登月计划(Moonshot)")
}我知道目前这个布局看起来比较简陋,但我们很快就会优化它。首先,我们来梳理一下当前的代码逻辑:一个可滚动的垂直网格,通过 resizable()、scaledToFit() 和 frame() 方法,让图片在保持原始宽高比的同时,固定在 100x100 的尺寸范围内。
现在运行程序,除了布局不够美观外,你还会发现日期的显示效果不佳——虽然我们能看懂 “1968-12-21” 代表 1968年12月21日,但这种日期格式对大多数人来说都不够直观。我们可以做得更好!
Swift 中的 JSONDecoder 类型有一个 dateDecodingStrategy 属性,用于指定日期的解码方式。我们可以为该属性提供一个 DateFormatter 实例,定义日期的格式。在本项目中,JSON 数据中的日期格式为“年-月-日”,在 DateFormat 中对应的格式字符串为 “y-MM-dd”——其中 “y” 代表年份,“MM” 代表带前导零的月份(例如1月表示为 “01”,而非 “1”),“dd” 代表带前导零的日期,各部分用短横线连接。
警告: 日期格式区分大小写!mm 代表“带前导零的分钟”,而 MM 代表“带前导零的月份”。
因此,打开 Bundle-Decodable.swift 文件,在 let decoder = 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 ?? "无数据")该行代码试图将可选的 Date 类型用于文本视图,若日期为空,则显示 “无数据”。这种情况下,使用计算属性会是更好的选择:我们可以让任务结构体自己提供格式化后的发射日期——将可选的日期类型转换为格式美观的字符串,若日期为空,则返回 “无数据”。
这里将使用我们之前用过的 formatted() 方法,对你来说应该不算陌生。在 Mission 结构体中添加以下计算属性:
var formattedLaunchDate: String {
launchDate?.formatted(date: .abbreviated, time: .omitted) ?? "无数据"
}然后,将 ContentView 中那段报错的文本视图代码替换为:
Text(mission.formattedLaunchDate)这样修改后,日期会以更直观的方式显示,而且更棒的是,它会根据用户的地区自动适配显示格式——你看到的格式不一定和我看到的完全相同。
现在,我们来解决更关键的问题:当前的布局实在太单调了!
为了让界面更美观,我将向你介绍两个实用功能:如何便捷地共享自定义应用颜色,以及如何为应用强制设置深色主题。
首先是颜色设置。有两种常用方法,各有优势:一种是在资源目录中添加带有特定名称的颜色,另一种是通过 Swift 扩展添加颜色。资源目录的方式支持可视化操作,而代码扩展的方式更便于通过 GitHub 等工具跟踪变更。
在这两种方式中,我更倾向于代码扩展,因为在团队协作时,它能让变更跟踪更清晰。因此,我们将通过 Swift 扩展来定义应用的颜色。
如果我们直接为 Color 编写扩展,虽然能在 SwiftUI 的多个地方使用这些颜色,但 Swift 还提供了一种更优的方式,只需多写几行代码就能实现。你可能知道,Color 遵循一个更通用的协议——ShapeStyle,正是这个协议让我们能够将颜色、渐变、材质等视为同一类元素使用。
background() 修饰符使用的就是 ShapeStyle 协议,因此我们真正需要的是:为 Color 编写扩展,但仅在 ShapeStyle 协议被用作颜色时生效。
要实现这一点,创建一个新的 Swift 文件,命名为 Color-Theme.swift,将文件中的 Foundation 导入语句改为 SwiftUI 导入语句,然后添加以下代码:
extension ShapeStyle where Self == Color {
static var darkBackground: Color {
Color(red: 0.1, green: 0.1, blue: 0.2)
}
static var lightBackground: Color {
Color(red: 0.2, green: 0.2, blue: 0.3)
}
}这段代码定义了两个新颜色:darkBackground(深背景色)和 lightBackground(浅背景色),每个颜色都有精确的红、绿、蓝(RGB)数值。更重要的是,这些颜色被定义在一个特定的扩展中,这使得我们在 SwiftUI 中所有需要 ShapeStyle 的地方,都能使用这些颜色。
现在,我们立即使用这些新颜色来优化界面。首先,找到包含任务名称和发射日期的 VStack——它目前应该已经有 .frame(maxWidth: .infinity) 修饰符了,将修饰符的顺序修改为:
.padding(.vertical)
.frame(maxWidth: .infinity)
.background(.lightBackground)接下来,我们希望 NavigationLink 的整个标签(即外层的 VStack)在网格中看起来更像一个“卡片”,这需要为其添加边框,并略微裁剪形状。要实现这种效果,在外层 VStack 的末尾添加以下修饰符:
.clipShape(.rect(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(.lightBackground)
)第三步,我们需要添加一些内边距,让元素与边缘保持一定距离。首先,在任务图片的 100x100 尺寸框架之后,添加内边距修饰符:
.padding()然后,为网格添加水平和底部内边距:
.padding([.horizontal, .bottom])重要提示: 该修饰符应添加到 LazyVGrid 上,而非 ScrollView。如果给 ScrollView 添加内边距,滚动条也会受到影响,看起来会很奇怪。
现在,我们可以将白色背景替换为之前定义的自定义背景色——在 ScrollView 的 navigationTitle() 修饰符之后,添加以下背景色修饰符:
.background(.darkBackground)到目前为止,自定义布局已基本完成,但还有最后两处颜色问题需要解决:任务文本使用的浅蓝色在当前背景下显示效果不佳;顶部的“登月计划(Moonshot)”标题是黑色的,在深蓝色背景上几乎无法看清。
对于文本颜色问题,我们可以为两个文本视图分别指定颜色:
VStack {
Text(mission.displayName)
.font(.headline)
.foregroundStyle(.white)
Text(mission.formattedLaunchDate)
.font(.caption)
.foregroundStyle(.white.opacity(0.5))
}使用半透明的白色作为前景色,能让背景色的质感略微透出来,视觉效果更好。
至于“登月计划(Moonshot)”标题,它属于 NavigationStack,其颜色会根据用户当前的模式(浅色模式或深色模式)自动切换为黑色或白色。要解决这个问题,我们可以告诉 SwiftUI,当前视图始终优先使用深色模式——这样无论用户启用了哪种外观模式,标题都会显示为白色,同时导航栏背景等其他元素也会自动适配深色风格。
因此,为了完成当前视图的设计,请在 ScrollView 的背景色修饰符下方,添加以下最终修饰符:
.preferredColorScheme(.dark)现在运行应用程序,你会看到一个美观的任务网格,它能流畅适配各种设备尺寸;无论用户启用了哪种外观模式,导航栏文本都会显示为亮白色,背景为深色;点击任意任务,还会跳转到一个临时的详情视图。这是一个很棒的开始!