跳转至

1.设计数据模型

在本节中,你将建立一个名为On This DaySwiftUI应用,从API中提取一天中值得注意的事件,并以各种方式显示。该应用使用了许多macOS应用中出现的界面风格,包括导航侧边栏、详情区、工具栏、菜单和偏好窗口。你最终会得到一个看起来像这样的应用程序:

img

当开始一个新的应用程序时,非常诱人的是直接跳入界面设计,但这次你要从制定数据模型开始。这有两个原因。首先,在SwiftUI应用程序中,数据驱动显示,所以在你开始布置界面之前,制定数据结构是有意义的。其次,这些数据来自于外部来源,可能没有你想要的结构。现在花点时间来分析和解析将为你以后节省大量的时间和精力。

在本章中,你将使用一个Playground来获取数据,分析其结构并为应用程序创建数据模型。

数据模型设计是任何应用程序开发中至关重要的第一步,所以通过本章的工作将是一次宝贵的经验。但是,如果你已经熟悉了下载数据、解析JSON以及创建数据结构和类,可以随意跳过。在第2章"与Windows一起工作"中,你将下载并导入本节中使用的数据模型文件,并开始构建用户接口。

数据从哪里来?

你将使用ZenQuotes.ioAPI。在你的浏览器中进入today.zenquotes.io,在页面上看一下。在顶部,你会看到某年某月某日发生的有趣事件,你可以向下滚动查看更多:

img

继续滚动,你会看到"快速入门",他们会告诉你获得数据的链接格式。也要看看使用限制。在测试时,很容易碰到这个限制,所以你的首要任务之一是下载一个数据样本集来工作。

点击文档链接,获得更多关于API调用返回的JSON结构的信息。在本章的其余部分,你会得到详细的探索。

保存一个样本数据集

从起始文件夹中打开playground。这是一个macOSplayground,设置了一些函数来让你开始工作。最重要的一个是getDataForDay(month:day:),它接收数字月和日,将它们组合成一个URL,然后使用URLSession从该URL下载JSON

如果返回的数据可以转换为String,你就要把它保存到一个文件中。但是你应该把它保存在哪里呢?与iOS不同,macOS允许你完全访问文件系统。正如你在后面的章节中所了解的,应用程序沙盒可能会限制这一点,但在Playground中,你可以访问一切。由于这是你正在下载的数据,把它保存在Downloads文件夹中是最合理的,所以现在你需要制定Downloads文件夹的文件路径,以便你能使用它。

在文件系统中工作

你的第一个想法可能是把文件路径建立为一个字符串。也许~/Downloads会起作用。但请记住,不是每个人都使用英语作为他们的系统语言。我的下载文件夹在/Users/sarah/Downloads,但如果我把系统语言切换到法语,它就在Utilisateurs/sarah/Téléchargements。所以,你不能假设会有一个叫做Downloads的文件夹。FileManager是一个提供文件系统接口的实用类,你将用它来获得这个路径。

PlaygroundSources部分包含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
}

这里有不少东西可能对你来说是陌生的:

  1. 要处理文件和文件夹,你需要使用默认的FileManager。可能由于其Unix背景,FileManager将文件夹称为目录。
  2. FileManager可以尝试找到标准文件夹的URL
  3. downloadsDirectoryFileManager.SearchPathDirectory枚举中列出的文件夹之一,它为所有常用的文件夹分配了常量,避免了任何转换问题。
  4. FileManager.SearchPathDomainMask列出了可能要搜索的域。这里,你想在用户的文件夹中搜索,所以userDomainMask是正确的选择。
  5. FileManager在几乎所有的搜索中都忽略了appropriateFor参数,但是create: true告诉它,如果缺少这个文件夹,就建立它。
  6. 如果FileManager找到了用户的下载文件夹,附加上样本数据文件名以创建最终的URL
  7. 如果FileManager出现了问题,catch将打印错误并返回nil

保存Files.swift并返回到playground页面。

获取数据

由于URLSession使用了awaitgetDataForDay(month:day:)被标记为async,你必须异步地调用它,所以它的用法被包裹在一个Task中。点击playground最后一行旁边的Play按钮,等待它去API服务器,收集数据并返回。

Note

如果你在水沟中没有看到播放按钮,你的playground被设置为自动运行。长按代码底部的播放按钮,选择Manually Run

一旦下载完成,你会在控制台看到一条消息,说playground已经把样本数据保存到你的Downloads文件夹:

img

进入你的Downloads文件夹并打开SampleData.json。在我的电脑上,它在Xcode中打开,但你可能有一个不同的应用程序设置为打开JSON文件:

img

格式化JSON

根据你使用的应用程序,它可能已经将JSON格式化为更可读的形式,但正如你所看到的,Xcode没有,所以这里有一个技巧,使JSON的格式化在任何Mac上变得轻而易举。

选择JSON文件中的所有文本并复制它。打开Terminal并输入以下一行(不要复制和粘贴,否则你会覆盖剪贴板中的JSON)。

pbpaste | json_pp | pbcopy

Return

这一连串的三个shell命令将剪贴板的内容粘贴到json_pp命令中,该命令将其打印出来,然后使用pbcopy将格式整齐的JSON复制回剪贴板。macOS将剪贴板称为粘贴板,这就是为什么它使用pbpastepbcopy

回到你原来的SampleData.json文件,删除内容并按Command-V粘贴打印好的JSON,然后再次保存该文件:

img

如果你犯了一个错误,丢失了样本数据,请再次运行Playground以取回它。

Note

要了解更多关于json_pp的信息,回到你的终端,右击该命令,选择Open man Page,在新窗口中打开内置的帮助页面。你也可以输入man json_pp,但这是在你的工作终端窗口显示信息。使用一个新的窗口可以使你更容易阅读和测试命令。

使用样本数据

现在你已经保存了数据并进行了格式化,你可以开始使用它而不是每次都调用API服务器。这样做更快,而且可以避免触及使用限制。

首先,注释掉整个Task部分,但不要删除它,以防你需要在任何时候重新获取数据。接下来,添加这段代码来访问保存的数据:

if let data = readSampleData() {
  print(data.count)
}

最后,再次运行该playground,你会看到一个数字显示样本文件中的数据量:

img

我特意选择2月29日作为样本日,以尽量减少数据量。据推测,2月29日只发生了通常数量的四分之一的有趣事件。] 你可能会得到一个不同的数字,因为网站增加和删除了一些事件。

探索JSON

为了更容易检查返回的JSON数据的结构,打开代码折叠。如果你使用的是Xcode,进入Preferences ▸ Text Editing ▸ Display并勾选Code folding ribbon。现在你就可以在代码折叠色带中点击行号旁边的代码折叠,来折叠和展开数据节点。

img

通过折叠几乎所有的节点,你可以看到布局的根结构,它包含四个元素。datadate是你在这里需要的。没有办法混淆这两个。 :] 你可以忽略infoupdated元素。

img

data中,有三个节点分别代表三种不同类型的事件:Births, DeathsEvents。每个节点内的数据都有相同的结构,所以在展开Births以显示第一个节点后,你会看到这样的结构:

img

三个顶级元素是htmllinkstext。如果这是在网页中显示,html会很重要,但对于一个应用程序,text更有用。请注意它是如何包括HTML实体的,并且是以年份开始的。

links部分的结构很奇怪,键是字符串中的数字。每个链接有三个元素,"0"是完整的HTML链接,"1"包含URL"2"包含链接的文本。

解码顶层的内容

现在你已经探索了JSON并知道你从API服务器上得到了什么,现在是时候开始解码了。这个JSON的整体数据模型将是一个叫做Day的结构,因为它包含了一个特定日期的信息。它将有datadate属性。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需要两个属性。textlinks - 你可以忽略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,你会看到日期和一个数字,显示有多少值得注意的出生是在这一天:

img

拼图的最后一块是链接,所以创建一个名为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
  }
}

刚才还那么干净,那么简单,现在看看吧! 那么这一切是在做什么?

  1. links已经变成了一个EventLink对象的数组。
  2. 由于该结构将手动解码JSON,解码器必须知道使用什么键。
  3. 该结构现在有一个自定义的init(from:)用于解码。
  4. 使用CodingKeys从解码器的容器中获取指定键的数据值。
  5. values中解码text元素。在将其分配给text属性之前,这不需要任何进一步的处理。
  6. links元素解码为一个字典。
  7. 循环浏览字典中的值,并尝试从每个值中创建一个EventLink对象。
  8. 将有效的条目分配给links

为了测试这一点,在其他两个语句下添加第三个调试打印语句。它强行解开了Births数组,这在生产中是个坏主意,但在playground所却很好:

print(day.data["Births"]![0].links)

现在运行playground,这一次,它将需要一段时间才能完成。随着Event.init(from:)的循环,你将能看到右边的计数器。做多个循环是playground努力的方向,但这在一个应用里面是非常快的。

链接输出不是很可读,但你可以看到它们都在那里,每个都有一个标题和一个URL

img

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以看到更新的日期字符串:

img

除了格式化的日期,这里没有什么不同,但你更容易获得信息。

识别数据对象

花点时间思考一下你的应用程序如何显示Day中的信息。displayDate是一个String,可以使用。然后你有一个包含EventEventLink的数组,你的应用程序中的视图需要以某种方式进行循环。在SwiftUI中循环浏览数据数组时,重要的是每个元素都有一个独特的标识符。这使得SwiftUI引擎能够跟踪哪些元素发生了变化、移动或消失,因此它可以尽可能有效地更新显示。

做到这一点的最好方法是使模型结构符合Identifiable。这个协议要求符合的类型包含一个叫做id的属性,它可以是任何东西,但通常是一个字符串、一个数字或一个唯一的ID。有些数据可能已经有了ID。在这种情况下,没有明显的唯一性,所以你要给每个EventEventLink添加一个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
}

这里发生了什么事?

  1. values中解码text元素,就像以前一样,但把它分配给一个常量。
  2. HTML实体分割rawText,两边加一个空格。
  3. 如果分割的结果是两部分,把第一部分分配给年份,第二部分分配给text。如果文本不包含该实体或包含不止一次,则将year设为问号,text设为解码器的完整值。
  4. 使用playground开始的String扩展对文本中的任何HTML实体进行解码。

是时候添加另一个调试打印语句了:

print(day.births[0].year)

再次运行playground,你会看到类似这样的东西:

img

把一切结合起来

到目前为止,你已经创建了一系列的数据结构。Day, EventEventLink。现在,是时候把它们整合到一个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]
  }
}

这里有几件重要的事情要看:

  1. 与其他数据对象不同,这个对象是一个类,它符合ObservableObject,所以你的SwiftUI视图可以观察它并对任何变化做出反应。
  2. days持有一个Day数据对象的字典,以其日期为索引。这使用了@Published属性包装器,这意味着任何观察这个对象的SwiftUI视图在这个属性发生变化时都会得到通知。
  3. 最后,有一个方便的方法,用于返回所提供的月号和日号的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,你会在控制台看到这样的结果:

img

用实时数据进行测试

作为最后的检查,重新启用实际的下载并确保你的代码能够正确地处理实时数据如何?现在,下载会将数据保存到一个文本文件中,所以你需要改变下载函数,使其将这些数据解码为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回来,将其添加到appStatedays中,并调用测试函数。

如果有下载错误或解码错误,catch块将打印出来。

再次点击Execute Playground按钮。你会看到一条报告保存文件路径的信息,然后你会看到调试报告。

img

为了好玩,你可以试着改变playground顶部的monthNumdayNum,然后再运行它,获取一些不同的数据。如果你想看看你得到了什么,可以在testData()中添加更多的打印语句。

关键点

  • 设计你的数据模型是构建应用程序的一个重要步骤。
  • Playgrounds使数据设计的迭代比在应用程序中进行迭代要容易得多,因为你必须在每次更改后建立和运行。
  • macOS为你提供了更多对文件系统的访问,你可以使用FileManager来处理这些文件。
  • 当从外部来源获取数据时,你无法控制其格式,但仍有可能处理数据以适应你的应用程序。
  • 计算属性对于使特定的数据容易被访问很有用。
  • 如果你正在构建一个iOS应用程序,你可以通过类似的代码来完成一个类似的过程。这是一个开始开发任何应用程序的可靠方法。

接下来去哪?

当你想开始构建一个真正的macOS应用时,这一章可能看起来很辛苦,但在下一章,你会看到这些前期工作意味着应用可以迅速开始成形。

如果你有兴趣了解更多关于async/await网络的信息,请查看这些链接: