1.设计数据模型¶
在本节中,你将建立一个名为On This Day
的SwiftUI
应用,从API
中提取一天中值得注意的事件,并以各种方式显示。该应用使用了许多macOS
应用中出现的界面风格,包括导航侧边栏、详情区、工具栏、菜单和偏好窗口。你最终会得到一个看起来像这样的应用程序:
当开始一个新的应用程序时,非常诱人的是直接跳入界面设计,但这次你要从制定数据模型开始。这有两个原因。首先,在SwiftUI应用程序中,数据驱动显示,所以在你开始布置界面之前,制定数据结构是有意义的。其次,这些数据来自于外部来源,可能没有你想要的结构。现在花点时间来分析和解析将为你以后节省大量的时间和精力。
在本章中,你将使用一个Playground
来获取数据,分析其结构并为应用程序创建数据模型。
数据模型设计是任何应用程序开发中至关重要的第一步,所以通过本章的工作将是一次宝贵的经验。但是,如果你已经熟悉了下载数据、解析JSON
以及创建数据结构和类,可以随意跳过。在第2章"与Windows
一起工作"中,你将下载并导入本节中使用的数据模型文件,并开始构建用户接口。
数据从哪里来?¶
你将使用ZenQuotes.io
的API
。在你的浏览器中进入today.zenquotes.io,在页面上看一下。在顶部,你会看到某年某月某日发生的有趣事件,你可以向下滚动查看更多:
继续滚动,你会看到"快速入门",他们会告诉你获得数据的链接格式。也要看看使用限制。在测试时,很容易碰到这个限制,所以你的首要任务之一是下载一个数据样本集来工作。
点击文档链接,获得更多关于API
调用返回的JSON
结构的信息。在本章的其余部分,你会得到详细的探索。
保存一个样本数据集¶
从起始文件夹中打开playground
。这是一个macOS
的playground
,设置了一些函数来让你开始工作。最重要的一个是getDataForDay(month:day:)
,它接收数字月和日,将它们组合成一个URL
,然后使用URLSession
从该URL
下载JSON
。
如果返回的数据可以转换为String
,你就要把它保存到一个文件中。但是你应该把它保存在哪里呢?与iOS
不同,macOS
允许你完全访问文件系统。正如你在后面的章节中所了解的,应用程序沙盒可能会限制这一点,但在Playground
中,你可以访问一切。由于这是你正在下载的数据,把它保存在Downloads
文件夹中是最合理的,所以现在你需要制定Downloads
文件夹的文件路径,以便你能使用它。
在文件系统中工作¶
你的第一个想法可能是把文件路径建立为一个字符串。也许~/Downloads
会起作用。但请记住,不是每个人都使用英语作为他们的系统语言。我的下载文件夹在/Users/sarah/Downloads
,但如果我把系统语言切换到法语,它就在Utilisateurs/sarah/Téléchargements
。所以,你不能假设会有一个叫做Downloads
的文件夹。FileManager
是一个提供文件系统接口的实用类,你将用它来获得这个路径。
Playground
的Sources
部分包含Files.swift
,它拥有保存和读取样本数据的函数。展开Sources
部分,如果它还没有展开的话,打开Files.swift
。
Note
如果你看不到文件列表,按Command-1
打开项目导航器。
第一个函数是sampleFileURL()
,它返回一个可选的URL
。目前它返回nil
,所以用这个替换return nil
:
// 1
let fileManager = FileManager.default
do {
// 2
let downloadsFolder = try fileManager.url(
// 3
for: .downloadsDirectory,
// 4
in: .userDomainMask,
// 5
appropriateFor: nil,
create: true)
// 6
let jsonFile = downloadsFolder
.appendingPathComponent("SampleData.json")
return jsonFile
} catch {
// 7
print(error)
return nil
}
这里有不少东西可能对你来说是陌生的:
- 要处理文件和文件夹,你需要使用默认的
FileManager
。可能由于其Unix
背景,FileManager
将文件夹称为目录。 FileManager
可以尝试找到标准文件夹的URL
。downloadsDirectory
是FileManager.SearchPathDirectory
枚举中列出的文件夹之一,它为所有常用的文件夹分配了常量,避免了任何转换问题。FileManager.SearchPathDomainMask
列出了可能要搜索的域。这里,你想在用户的文件夹中搜索,所以userDomainMask
是正确的选择。FileManager
在几乎所有的搜索中都忽略了appropriateFor
参数,但是create: true
告诉它,如果缺少这个文件夹,就建立它。- 如果
FileManager
找到了用户的下载文件夹,附加上样本数据文件名以创建最终的URL
。 - 如果
FileManager
出现了问题,catch
将打印错误并返回nil
。
保存Files.swift
并返回到playground
页面。
获取数据¶
由于URLSession
使用了await
,getDataForDay(month:day:)
被标记为async
,你必须异步地调用它,所以它的用法被包裹在一个Task
中。点击playground
最后一行旁边的Play
按钮,等待它去API
服务器,收集数据并返回。
Note
如果你在水沟中没有看到播放按钮,你的playground
被设置为自动运行。长按代码底部的播放按钮,选择Manually Run
。
一旦下载完成,你会在控制台看到一条消息,说playground
已经把样本数据保存到你的Downloads
文件夹:
进入你的Downloads
文件夹并打开SampleData.json
。在我的电脑上,它在Xcode
中打开,但你可能有一个不同的应用程序设置为打开JSON
文件:
格式化JSON
¶
根据你使用的应用程序,它可能已经将JSON
格式化为更可读的形式,但正如你所看到的,Xcode
没有,所以这里有一个技巧,使JSON
的格式化在任何Mac
上变得轻而易举。
选择JSON
文件中的所有文本并复制它。打开Terminal
并输入以下一行(不要复制和粘贴,否则你会覆盖剪贴板中的JSON
)。
pbpaste | json_pp | pbcopy
按Return
。
这一连串的三个shell
命令将剪贴板的内容粘贴到json_pp
命令中,该命令将其打印出来,然后使用pbcopy
将格式整齐的JSON
复制回剪贴板。macOS
将剪贴板称为粘贴板,这就是为什么它使用pbpaste
和pbcopy
。
回到你原来的SampleData.json
文件,删除内容并按Command-V
粘贴打印好的JSON
,然后再次保存该文件:
如果你犯了一个错误,丢失了样本数据,请再次运行Playground
以取回它。
Note
要了解更多关于json_pp
的信息,回到你的终端,右击该命令,选择Open man Page
,在新窗口中打开内置的帮助页面。你也可以输入man json_pp
,但这是在你的工作终端窗口显示信息。使用一个新的窗口可以使你更容易阅读和测试命令。
使用样本数据¶
现在你已经保存了数据并进行了格式化,你可以开始使用它而不是每次都调用API
服务器。这样做更快,而且可以避免触及使用限制。
首先,注释掉整个Task
部分,但不要删除它,以防你需要在任何时候重新获取数据。接下来,添加这段代码来访问保存的数据:
if let data = readSampleData() {
print(data.count)
}
最后,再次运行该playground
,你会看到一个数字显示样本文件中的数据量:
我特意选择2月29日作为样本日,以尽量减少数据量。据推测,2月29日只发生了通常数量的四分之一的有趣事件。] 你可能会得到一个不同的数字,因为网站增加和删除了一些事件。
探索JSON
¶
为了更容易检查返回的JSON
数据的结构,打开代码折叠。如果你使用的是Xcode
,进入Preferences ▸ Text Editing ▸ Display
并勾选Code folding ribbon
。现在你就可以在代码折叠色带中点击行号旁边的代码折叠,来折叠和展开数据节点。
通过折叠几乎所有的节点,你可以看到布局的根结构,它包含四个元素。data
和date
是你在这里需要的。没有办法混淆这两个。 :] 你可以忽略info
和updated
元素。
在data
中,有三个节点分别代表三种不同类型的事件:Births
, Deaths
和Events
。每个节点内的数据都有相同的结构,所以在展开Births
以显示第一个节点后,你会看到这样的结构:
三个顶级元素是html
、links
和text
。如果这是在网页中显示,html
会很重要,但对于一个应用程序,text
更有用。请注意它是如何包括HTML
实体的,并且是以年份开始的。
links
部分的结构很奇怪,键是字符串中的数字。每个链接有三个元素,"0"
是完整的HTML链接,"1"
包含URL
,"2"
包含链接的文本。
解码顶层的内容¶
现在你已经探索了JSON
并知道你从API
服务器上得到了什么,现在是时候开始解码了。这个JSON
的整体数据模型将是一个叫做Day
的结构,因为它包含了一个特定日期的信息。它将有data
和date
属性。date
是一个字符串,所以从这个开始。
首先,在playground
添加这段代码:
struct Day: Decodable {
let date: String
}
这建立了Day
作为一个符合Decodable
协议的结构。因为这个数据永远不会被重新编码,所以没有必要符合Codable
,它是Decodable & Encodable
的类型别名。
要测试这一点,请用这个替换print(data.count)
:
do {
let day = try JSONDecoder().decode(Day.self, from: data)
print(day.date)
} catch {
print(error)
}
然后再次运行playground
,你会看到控制台中打印出"February_29"
。
注意
如果你在运行playground
时曾经得到一个错误,说在范围内找不到某些类型,这是因为你正在运行playground
中类型声明之前的代码。请使用代码和控制台之间的分隔线上的Execute Playground
按钮来代替。你可能需要点击它一次来停止,再点击一次来运行playground
。
深入了解¶
解码data
元素并不那么简单,因为里面有不同类型的数据。所以现在,是时候考虑低级别的数据模型了。
你可以将"Births"
、"Deaths"
和"Events"
元素中的每个条目解码为一个Event
数据模型。Event
需要两个属性。text
和links
- 你可以忽略html
。要设置这个,请在playground
上添加一个新的结构:
struct Event: Decodable {
let text: String
let links: [String: [String: String]]
}
现在,links
是一个丑陋的包含数组的字典,但这足以让它解码。
接下来,在Day
中插入新的data
属性:
let data: [String: [Event]]
最后,在print(day.date)
之后添加第二个调试print
:
print(day.data["Births"]?.count ?? 0)
再次运行playground
,你会看到日期和一个数字,显示有多少值得注意的出生是在这一天:
拼图的最后一块是链接,所以创建一个名为EventLink
的新结构来处理它们:
struct EventLink: Decodable {
let title: String
let url: URL
}
这是每个链接的重要数据,但传入的JSON
并不是这样的结构。为了处理进来的数据,Event
需要做更多的工作。
处理链接¶
现在,你的Event
结构将其链接存储在一个字典中,这可以对它们进行解码,但并不能让应用程序轻松使用这些链接。通过给Event
添加一个自定义的init(from:)
,你可以将传入的JSON
处理成一个更可用的格式。
用这个版本替换Event
:
struct Event: Decodable {
let text: String
// 1
let links: [EventLink]
// 2
enum CodingKeys: String, CodingKey {
case text
case links
}
// 3
init(from decoder: Decoder) throws {
// 4
let values = try decoder.container(keyedBy: CodingKeys.self)
// 5
text = try values.decode(String.self, forKey: .text)
// 6
let allLinks = try values.decode(
[String: [String: String]].self,
forKey: .links)
// 7
var processedLinks: [EventLink] = []
for (_, link) in allLinks {
if let title = link["2"],
let address = link["1"],
let url = URL(string: address) {
processedLinks.append(EventLink(title: title, url: url))
}
}
// 8
links = processedLinks
}
}
刚才还那么干净,那么简单,现在看看吧! 那么这一切是在做什么?
links
已经变成了一个EventLink
对象的数组。- 由于该结构将手动解码
JSON
,解码器必须知道使用什么键。 - 该结构现在有一个自定义的
init(from:)
用于解码。 - 使用
CodingKeys
从解码器的容器中获取指定键的数据值。 - 从
values
中解码text
元素。在将其分配给text
属性之前,这不需要任何进一步的处理。 - 将
links
元素解码为一个字典。 - 循环浏览字典中的值,并尝试从每个值中创建一个
EventLink
对象。 - 将有效的条目分配给
links
。
为了测试这一点,在其他两个语句下添加第三个调试打印语句。它强行解开了Births
数组,这在生产中是个坏主意,但在playground
所却很好:
print(day.data["Births"]![0].links)
现在运行playground
,这一次,它将需要一段时间才能完成。随着Event.init(from:)
的循环,你将能看到右边的计数器。做多个循环是playground
努力的方向,但这在一个应用里面是非常快的。
链接输出不是很可读,但你可以看到它们都在那里,每个都有一个标题和一个URL
:
让day
更容易使用¶
现在你正在对JSON
进行解码,并建立了基本的数据结构,现在是时候考虑应用程序将如何使用这些数据,以及你可以添加什么来使其更容易。
首先看一下Day
,如果有一种更直接的方式来访问各种类别的事件,而不是每次都使用day.data["Births"]
这样的选项,那就很方便了。
有三种类型的事件,所以为了避免使用神奇的字符串作为字典的键,首先添加这个枚举来描述它们:
enum EventType: String {
case events = "Events"
case births = "Births"
case deaths = "Deaths"
}
传统上,枚举中的情况以小写字母开始,但原始字符串值被设置为出现在JSON
中的标题大小写字符串,所以它们将作为data
字典的键来工作。
随着枚举的到位,将这些计算属性添加到Day
中。
var events: [Event] { data[EventType.events.rawValue] ?? [] }
var births: [Event] { data[EventType.births.rawValue] ?? [] }
var deaths: [Event] { data[EventType.deaths.rawValue] ?? [] }
这些属性使用原始值来返回一个相关事件的数组或一个空数组。
现在,你可以改变那些不优雅的调试打印语句,使它们不使用选项和强制解包:
print(day.births.count)
print(day.births[0].links)
在Day
中,第二个有用的功能是用一种更好的方式来显示日期。现在,在月和日之间有一个下划线。你可以使用自定义的init(from:)
来改变解码的方式,但你要使用另一个计算属性。把这个添加到Day
:
var displayDate: String {
date.replacingOccurrences(of: "_", with: " ")
}
为了测试这一点,将调试打印语句的第一条改为:
print(day.displayDate)
再次运行playground
以看到更新的日期字符串:
除了格式化的日期,这里没有什么不同,但你更容易获得信息。
识别数据对象¶
花点时间思考一下你的应用程序如何显示Day
中的信息。displayDate
是一个String
,可以使用。然后你有一个包含Event
和EventLink
的数组,你的应用程序中的视图需要以某种方式进行循环。在SwiftUI
中循环浏览数据数组时,重要的是每个元素都有一个独特的标识符。这使得SwiftUI
引擎能够跟踪哪些元素发生了变化、移动或消失,因此它可以尽可能有效地更新显示。
做到这一点的最好方法是使模型结构符合Identifiable
。这个协议要求符合的类型包含一个叫做id
的属性,它可以是任何东西,但通常是一个字符串、一个数字或一个唯一的ID
。有些数据可能已经有了ID
。在这种情况下,没有明显的唯一性,所以你要给每个Event
和EventLink
添加一个UUID
。
从EventLink
开始,编辑结构声明以包括Identifiable
并添加一个id
属性:
struct EventLink: Decodable, Identifiable {
let id: UUID
let title: String
let url: URL
}
这导致在init(from:)
创建EventLink
对象时出现"Missing argument"
的错误。让Xcode
应用其建议的修正,用UUID()
替换占位符,这样创建每个EventLink
的代码就会变成这样:
processedLinks.append(
EventLink(id: UUID(), title: title, url: url))
对于Event
,你想做类似的事情。添加Identifiable
和一个id
属性,但在这种情况下,声明将初始化UUID
。替换struct Event: Decodable {
改为:
struct Event: Decodable, Identifiable {
let id = UUID()
如果你对EventLink
使用了这种技术,你会看到一个关于不可变属性的警告,它不会被解码。这对Event
来说不是问题,因为你已经设置了CodingKeys
,它告诉解码器哪些属性要使用,哪些要忽略。
整理事件文本¶
现在你已经准备好循环浏览事件和链接了,现在是时候看看事件的文本了。在你的调试打印语句中,将打印出links
的那一行替换为这一行,然后再次运行这个playground
:
print(day.births[0].text)
在控制台中,你会看到"1468 – Pope Paul III (d. 1549)"
或类似的内容。你可以看到文本字符串以年份开始,然后使用HTML
实体的"en dash"
将其与信息分开。为了显示的目的,把这两部分分开成不同的属性似乎是很有用的。
首先,给Event
添加一个year
属性。你可能很想把年份转换成Int
,但请记住,有些事件发生在很久之前,可能包括"BC"
或"BCE"
,所以年份需要保持为字符串。
let year: String
将init(from:)
中设置text
的那一行改为这样:
// 1
let rawText = try values.decode(String.self, forKey: .text)
// 2
let textParts = rawText.components(separatedBy: " – ")
// 3
if textParts.count == 2 {
year = textParts[0]
// 4
text = textParts[1].decoded
} else {
year = "?"
// 4
text = rawText.decoded
}
这里发生了什么事?
- 从
values
中解码text
元素,就像以前一样,但把它分配给一个常量。 - 用
HTML
实体分割rawText
,两边加一个空格。 - 如果分割的结果是两部分,把第一部分分配给
年份
,第二部分分配给text
。如果文本不包含该实体或包含不止一次,则将year
设为问号,text
设为解码器的完整值。 - 使用
playground
开始的String
扩展对文本中的任何HTML
实体进行解码。
是时候添加另一个调试打印语句了:
print(day.births[0].year)
再次运行playground
,你会看到类似这样的东西:
把一切结合起来¶
到目前为止,你已经创建了一系列的数据结构。Day
, Event
和EventLink
。现在,是时候把它们整合到一个ObservableObject
中了,它是你应用程序中的主要数据模型。
把这个定义添加到playground
上:
// 1
class AppState: ObservableObject {
// 2
@Published var days: [String: Day] = [:]
// 3
func getDataFor(month: Int, day: Int) -> Day? {
let monthName = Calendar.current.monthSymbols[month - 1]
let dateString = "\(monthName) \(day)"
return days[dateString]
}
}
这里有几件重要的事情要看:
- 与其他数据对象不同,这个对象是一个类,它符合
ObservableObject
,所以你的SwiftUI
视图可以观察它并对任何变化做出反应。 days
持有一个Day
数据对象的字典,以其日期为索引。这使用了@Published
属性包装器,这意味着任何观察这个对象的SwiftUI
视图在这个属性发生变化时都会得到通知。- 最后,有一个方便的方法,用于返回所提供的月号和日号的
Day
,如果它可用的话。
要测试这一点,请到Playground
的顶部,在import
行的后面添加这些行:
let appState = AppState()
let monthNum = 2
let dayNum = 29
func testData() {
if let day = appState.getDataFor(
month: monthNum, day: dayNum
) {
print(day.displayDate)
print("\(day.deaths.count) deaths")
} else {
print("No data available for that month & day.")
}
}
这将创建一个AppState
对象,设置一个测试月份和日期,然后添加一个测试结果的函数。这些定义需要在顶部,因为Playground
是从上到下运行的,在任何东西试图使用它们之前,这些定义必须被设置好。
向下滚动到你读取样本数据文件并打印出一些调试信息的地方。将所有的打印语句替换为以下内容:
appState.days[day.displayDate] = day
testData()
运行playground
,你会在控制台看到这样的结果:
用实时数据进行测试¶
作为最后的检查,重新启用实际的下载并确保你的代码能够正确地处理实时数据如何?现在,下载会将数据保存到一个文本文件中,所以你需要改变下载函数,使其将这些数据解码为Day
并返回。
首先,找到getDataForDay(month:day:)
,将其签名行替换为这个签名行,将其设置为返回Day
:
func getDataForDay(month: Int, day: Int) async throws -> Day {
接下来,在你将数据保存到文件的地方,添加这个块,它试图将下载的数据解码成Day
,如果不能,则抛出一个错误:
do {
let day = try JSONDecoder().decode(Day.self, from: data)
return day
} catch {
throw FetchError.badJSON
}
最后,注释掉以if let data = readSampleData() {
开头的整个代码块,并在其后面添加以下内容:
Task {
do {
let day = try await getDataForDay(
month: monthNum, day: dayNum)
appState.days[day.displayDate] = day
testData()
} catch {
print(error)
}
}
Tips
点击if let data = readSampleData() {
左边的代码折叠带,将该块折叠成一行。三次点击折叠后的行来选择整个块,然后按Command-/
来注释它。
这与你用来获取样本数据的Task
非常相似,但这个版本等待解码后的Day
回来,将其添加到appState
的days
中,并调用测试函数。
如果有下载错误或解码错误,catch
块将打印出来。
再次点击Execute Playground
按钮。你会看到一条报告保存文件路径的信息,然后你会看到调试报告。
为了好玩,你可以试着改变playground
顶部的monthNum
和dayNum
,然后再运行它,获取一些不同的数据。如果你想看看你得到了什么,可以在testData()
中添加更多的打印语句。
关键点¶
- 设计你的数据模型是构建应用程序的一个重要步骤。
Playgrounds
使数据设计的迭代比在应用程序中进行迭代要容易得多,因为你必须在每次更改后建立和运行。macOS
为你提供了更多对文件系统的访问,你可以使用FileManager
来处理这些文件。- 当从外部来源获取数据时,你无法控制其格式,但仍有可能处理数据以适应你的应用程序。
- 计算属性对于使特定的数据容易被访问很有用。
- 如果你正在构建一个
iOS
应用程序,你可以通过类似的代码来完成一个类似的过程。这是一个开始开发任何应用程序的可靠方法。
接下来去哪?¶
当你想开始构建一个真正的macOS
应用时,这一章可能看起来很辛苦,但在下一章,你会看到这些前期工作意味着应用可以迅速开始成形。
如果你有兴趣了解更多关于async/await
网络的信息,请查看这些链接:
- Apple — Meet async/await in Swift
- raywenderlich.com — async/await in SwiftUI
- raywenderlich.com — WWDC 2021: Intro to async/await