使用 Swift Codable 映射 Cloud Firestore 数据

Swift 4 中引入了 Swift 的 Codable API,支持用户利用编译器的强大功能,更轻松地将数据从序列化格式映射到 Swift 类型。

您可能已经在使用 Codable 将数据从 Web API 映射到应用的数据模型(或者反之),但其灵活的用途远不止于此。

在本指南中,我们将了解如何使用 Codable 将 Cloud Firestore 中的数据映射到 Swift 类型,以及如何实现反向映射。

Cloud Firestore 提取文档时,您的应用会收到一个键值对字典(或者一系列字典,如果其中一个操作返回多个文档的话)。

您当然可以继续直接使用 Swift 中的字典,这些字典提供的极高灵活性可能正是您的用例所需的。不过,这种方法并不具备类型安全性,很容易出现属性名称拼写错误,或者忘记映射团队在上周发布炫酷新功能时添加的新属性,从而导致引入难以排查的 bug。

过去,许多开发者通过实现简单的映射层,利用该层将字典映射到 Swift 类型,暂时克服了这些缺点。但同样,这些实现中大多数采用的方式都是手动指定 Cloud Firestore 文档与应用数据模型的对应类型之间的映射关系。

Cloud Firestore 支持 Swift 的 Codable API 之后,解决这个问题就变得简单多了:

  • 您不再需要手动实现任何映射代码。
  • 可以轻松地定义如何映射名称不同的属性。
  • 它内置了对许多 Swift 类型的支持。
  • 可以轻松添加对映射自定义类型的支持。
  • 最棒的是:对于简单的数据模型,您根本不需要编写映射代码。

映射数据

Cloud Firestore 将数据存储在文档中,并将键映射到值。如需从单个文档中提取数据,我们可以调用 DocumentSnapshot.data(),它会返回一个将字段名称映射到 Any:func data() -> [String : Any]? 的字典。

这意味着我们可以使用 Swift 的下标语法来访问每个字段。

import FirebaseFirestore

#warning("DO NOT MAP YOUR DOCUMENTS MANUALLY. USE CODABLE INSTEAD.")
func fetchBook(documentId: String) {
  let docRef = db.collection("books").document(documentId)

  docRef.getDocument { document, error in
    if let error = error as NSError? {
      self.errorMessage = "Error getting document: \(error.localizedDescription)"
    }
    else {
      if let document = document {
        let id = document.documentID
        let data = document.data()
        let title = data?["title"] as? String ?? ""
        let numberOfPages = data?["numberOfPages"] as? Int ?? 0
        let author = data?["author"] as? String ?? ""
        self.book = Book(id:id, title: title, numberOfPages: numberOfPages, author: author)
      }
    }
  }
}

虽然此代码可能看起来很简单且易于实现,但脆弱、难以维护且容易出错。

如您所见,我们对文档字段的数据类型进行了假设,但这些假设不一定对。

请注意,由于没有架构,您可以轻松地向集合添加新文档,以及为字段选择其他类型。您可能会不小心为 numberOfPages 字段选择字符串,这会导致难以找出的映射问题。此外,每当添加新字段时,您都必须更新映射代码,这非常麻烦。

而且不要忘了,我们并未利用到 Swift 的强类型系统,该系统知道 Book 的每个属性的确切类型。

Codable 到底是什么?

根据 Apple 的文档,Codable 是一种“可将自身转换为外部表示法或将外部表示法转换为自身的类型”。实际上,Codable 是 Encodable 和 Decodable 协议的类型别名。通过让 Swift 类型遵循此协议,编译器将合成从序列化格式(例如 JSON)对此类型的实例进行编码/解码所需的代码。

用于存储图书数据的简单类型可能如下所示:

struct Book: Codable {
  var title: String
  var numberOfPages: Int
  var author: String
}

如您所见,使该类型遵循 Codable 对代码的影响极小。我们只需添加对协议的遵循,无需进行其他更改。

利用这一方法,我们现在可以轻松地将图书编码为 JSON 对象:

do {
  let book = Book(title: "The Hitchhiker's Guide to the Galaxy",
                  numberOfPages: 816,
                  author: "Douglas Adams")
  let encoder = JSONEncoder()
  let data = try encoder.encode(book)
} 
catch {
  print("Error when trying to encode book: \(error)")
}

将 JSON 对象解码为 Book 实例的代码如下:

let decoder = JSONDecoder()
let data = /* fetch data from the network */
let decodedBook = try decoder.decode(Book.self, from: data)

使用 Codable 映射到 Cloud Firestore 文档中的简单类型
或对这些类型进行映射

Cloud Firestore 支持众多数据类型,从简单的字符串到嵌套映射。其中大部分都直接对应于 Swift 的内置类型。我们先来看一些简单数据类型的映射,然后再深入了解较复杂的数据类型。

如需将 Cloud Firestore 文档映射到 Swift 类型,请按照以下步骤操作:

  1. 确保您已将 FirebaseFirestore 框架添加到您的项目中。您可以使用 Swift Package Manager 或 CocoaPods 执行此操作。
  2. FirebaseFirestore 导入您的 Swift 文件。
  3. 使您的类型符合 Codable
  4. (选做,如果您想在 List 视图中使用该类型的话)向您的类型添加 id 属性,并使用 @DocumentID 指示 Cloud Firestore 将该属性映射到文档 ID。我们将在下文中对此进行详细介绍。
  5. 使用 documentReference.data(as: ) 将文档引用映射到一个 Swift 类型。
  6. 使用 documentReference.setData(from: ) 将数据从 Swift 类型映射到 Cloud Firestore 文档。
  7. (可选,但强烈建议)实现适当的错误处理。

我们来相应地更新 Book 类型:

struct Book: Codable {
  @DocumentID var id: String?
  var title: String
  var numberOfPages: Int
  var author: String
}

由于此类型已经可编码,因此我们只需添加 id 属性,并使用 @DocumentID 属性封装容器对其进行注解。

利用前面的代码段来获取和映射文档,我们可以将所有手动映射代码替换为一行代码:

func fetchBook(documentId: String) {
  let docRef = db.collection("books").document(documentId)

  docRef.getDocument { document, error in
    if let error = error as NSError? {
      self.errorMessage = "Error getting document: \(error.localizedDescription)"
    }
    else {
      if let document = document {
        do {
          self.book = try document.data(as: Book.self)
        }
        catch {
          print(error)
        }
      }
    }
  }
}

您可以在调用 getDocument(as:) 时指定文档的类型,将代码变得更简洁。这将为您执行映射,并返回包含映射的文档的 Result 类型;如果解码失败,则返回错误:

private func fetchBook(documentId: String) {
  let docRef = db.collection("books").document(documentId)
  
  docRef.getDocument(as: Book.self) { result in
    switch result {
    case .success(let book):
      // A Book value was successfully initialized from the DocumentSnapshot.
      self.book = book
      self.errorMessage = nil
    case .failure(let error):
      // A Book value could not be initialized from the DocumentSnapshot.
      self.errorMessage = "Error decoding document: \(error.localizedDescription)"
    }
  }
}

更新现有文档只需调用 documentReference.setData(from: ),就这么简单。以下代码用于保存 Book 实例,包括一些基本的错误处理:

func updateBook(book: Book) {
  if let id = book.id {
    let docRef = db.collection("books").document(id)
    do {
      try docRef.setData(from: book)
    }
    catch {
      print(error)
    }
  }
}

添加新文档时,Cloud Firestore 会自动为文档分配新的文档 ID。即使应用处于离线状态,这也可以正常运行。

func addBook(book: Book) {
  let collectionRef = db.collection("books")
  do {
    let newDocReference = try collectionRef.addDocument(from: self.book)
    print("Book stored with new document reference: \(newDocReference)")
  }
  catch {
    print(error)
  }
}

除了映射简单的数据类型外,Cloud Firestore 还支持许多其他数据类型,其中一些是可用于在文档中创建嵌套对象的结构化类型。

嵌套自定义类型

我们要在文档中映射的大多数属性都是简单值,例如书名或作者姓名。但是,当我们需要存储更复杂的对象时,该怎么办?例如,我们可能需要存储不同分辨率的图书封面的网址。

Cloud Firestore 中执行此操作的最简单方法是使用映射:

在 Firestore 文档中存储嵌套的自定义类型

在编写对应的 Swift 结构体时,我们可以利用 Cloud Firestore 支持网址这一点:在存储包含网址的字段时,该字段将被转换为字符串,反之亦然:

struct CoverImages: Codable {
  var small: URL
  var medium: URL
  var large: URL
}

struct BookWithCoverImages: Codable {
  @DocumentID var id: String?
  var title: String
  var numberOfPages: Int
  var author: String
  var cover: CoverImages?
}

请注意我们如何为 Cloud Firestore 文档中的封面图定义了结构体 CoverImages。通过将 BookWithCoverImages 中的封面属性标记为可选,我们能够处理一些文档可能不包含封面属性的情况。

为什么没有用于提取或更新数据的代码段?因为无需调整用于从 Cloud Firestore 读取数据或向其中写入数据的代码:所有这些都可通过我们在初始部分中编写的代码完成。

数组

有时,我们需要在文档中存储一组值。图书题材就是一个很好的例子:像《银河系漫游指南》这样的图书可能被归入几个内容类别,在本例中为“科幻”和“喜剧”

在 Firestore 文档中存储数组

Cloud Firestore 中,我们可以使用值的数组对这种情况进行建模。任何可编码类型(例如 StringInt 等)都支持此操作。以下代码展示了如何向 Book 模型添加题材数组:

public struct BookWithGenre: Codable {
  @DocumentID var id: String?
  var title: String
  var numberOfPages: Int
  var author: String
  var genres: [String]
}

由于这适用于任何可编码类型,因此我们也可以使用自定义类型。假设我们想为每本图书存储一个书签列表。除了书签名称之外,我们还希望存储书签的颜色,如下所示:

在 Firestore 文档中存储自定义类型的数组

如需以这种方式存储书签,您只需实现一个 Tag 结构体来代表书签并将其设为可编码:

struct Tag: Codable, Hashable {
  var title: String
  var color: String
}

这样,我们就可以在 Book 文档中存储 Tags 的数组了。

struct BookWithTags: Codable {
  @DocumentID var id: String?
  var title: String
  var numberOfPages: Int
  var author: String
  var tags: [Tag]
}

关于映射文档 ID 的简要说明

在继续映射更多类型之前,我们先来探讨一下文档 ID。

在前面的一些示例中,我们使用了 @DocumentID 属性封装容器来将 Cloud Firestore 文档的 ID 映射到 Swift 类型的 id 属性。这一点很重要,原因包括:

  • 当用户进行本地更改时,这有助于我们了解要更新哪个文档。
  • SwiftUI 的 List 要求其元素为 Identifiable,以防止元素在插入时发生跳动。

需要指出的是,在回写文档时,Cloud Firestore 的编码器不会对标记为 @DocumentID 的属性进行编码。这是因为文档 ID 不是文档本身的属性,因此将其写入文档是错误行为。

处理嵌套类型(例如本指南前面示例中的 Book 的书签数组)时,无需添加 @DocumentID 属性:嵌套属性是 Cloud Firestore 文档的一部分,不构成单独的文档,因此它们不需要文档 ID。

日期和时间

Cloud Firestore 内置了用于处理日期和时间的数据类型,而由于 Cloud Firestore 支持 Codable,使用它们非常简单。

我们来看一下这个描写所有编程语言之母 Ada(发明于 1843 年)的文档:

在 Firestore 文档中存储日期

用于映射此文档的 Swift 类型可能如下所示:

struct ProgrammingLanguage: Codable {
  @DocumentID var id: String?
  var name: String
  var year: Date
}

如果没有关于 @ServerTimestamp 的对话,我们将无法离开关于日期和时间的这一部分。在应用中处理时间戳时,此属性封装容器会是一个强大的引擎。

在任何分布式系统中,个别系统上的时钟有可能不是一直完全同步。您可能认为这无关紧要,但请想象一下,对于股票交易系统,时钟运行稍微不同步的影响:即使一毫秒的偏差也可能会在执行交易时导致数百万美元的差额。

Cloud Firestoree 按以下方式处理标有 @ServerTimestamp 的属性:如果属性在您进行存储时为 nil(例如,使用 addDocument()),则 Cloud Firestore 会使用将其写入到数据库时的服务器时间戳填充此字段。如果您调用 addDocument()updateData() 时该字段并非 nil,则 Cloud Firestore 将不会改动属性值。这样即可轻松实现 createdAtlastUpdatedAt 等字段。

地理坐标点

地理定位在我们的应用中非常普遍。通过存储这类数据,许多实用的功能成为了可能。例如,存储任务的位置可能会很有用,这样当您到达目的地时,应用可以就此任务提醒您。

Cloud Firestore 具有内置数据类型 GeoPoint,可以存储任何位置的经度和纬度。要对 Cloud Firestore 文档位置进行映射(位置可为源或目标),可以使用 GeoPoint 类型:

struct Office: Codable {
  @DocumentID var id: String?
  var name: String
  var location: GeoPoint
}

Swift 中的对应类型为 CLLocationCoordinate2D,我们可以通过以下操作在这两种类型之间进行映射:

CLLocationCoordinate2D(latitude: office.location.latitude,
                      longitude: office.location.longitude)

如需详细了解如何按实际位置查询文档,请参阅此解决方案指南

枚举

枚举可能是 Swift 中最被低估的语言功能之一;它们的功能远不止显而易见的那些。枚举的一个常见用例是为某些内容的离散状态建模。例如,我们可能在编写一个用于管理文章的应用。为了跟踪文章的状态,我们可能需要使用枚举 Status

enum Status: String, Codable {
  case draft
  case inReview
  case approved
  case published
}

Cloud Firestore 本身不支持枚举(即,它无法强制实施这组值),但我们仍然可以利用枚举可以映射为类型这一点,并选择可编码的类型。在此示例中,我们选择了 String,这意味着当存储在 Cloud Firestore 文档中时,所有枚举值都将映射为字符串或由字符串映射而来。

此外,由于 Swift 支持自定义原始值,我们甚至可以自定义哪些值指代哪种枚举情况。例如,如果我们决定将 Status.inReview 情况存储为“审核中”,只需按如下所示更新上述枚举:

enum Status: String, Codable {
  case draft
  case inReview = "in review"
  case approved
  case published
}

自定义映射

有时,我们要映射的 Cloud Firestore 文档的属性名称与 Swift 数据模型中的属性名称不一致。例如,我们的一位同事可能是 Python 开发者,并决定为其所有属性名称都选择蛇形命名法。

不用担心,Codable 可以帮上忙!

对于此类情况,我们可以使用 CodingKeys。这是一个枚举,我们可以将其添加到可编码结构体,以指定如何映射某些属性。

请参考此文档:

属性名称采用蛇形命名法的 Firestore 文档

为了将此文档映射到名称属性类型为 String 的结构体,我们需要向 ProgrammingLanguage 结构体添加一个 CodingKeys 枚举,并指定文档中属性的名称:

struct ProgrammingLanguage: Codable {
  @DocumentID var id: String?
  var name: String
  var year: Date
  
  enum CodingKeys: String, CodingKey {
    case id
    case name = "language_name"
    case year
  }
}

默认情况下,Codable API 将使用 Swift 类型的属性名称来确定我们尝试映射的 Cloud Firestore 文档上的属性名称。因此,只要属性名称匹配,就无需向可编码类型添加 CodingKeys。不过,针对特定类型使用 CodingKeys 后,我们需要添加所有要映射的属性名称。

在上面的代码段中,我们定义了一个 id 属性,并可能希望将其用作 SwiftUI List 视图中的标识符。如果未在 CodingKeys 中指定该属性,则在提取数据时不会对其进行映射,因此会变为 nil。这会导致 List 视图被第一个文档填充。

在映射过程中,任何未在相应 CodingKeys 枚举上列为情况的属性都将被忽略。如果我们特别想从映射中排除某些属性,这会非常方便。

例如,如果我们不希望映射 reasonWhyILoveThis 属性,只需将其从 CodingKeys 枚举中移除即可:

struct ProgrammingLanguage: Identifiable, Codable {
  @DocumentID var id: String?
  var name: String
  var year: Date
  var reasonWhyILoveThis: String = ""
  
  enum CodingKeys: String, CodingKey {
    case id
    case name = "language_name"
    case year
  }
}

有时,我们可能希望将一个空属性写回 Cloud Firestore 文档。Swift 采用可选属性概念来表示没有值,而 Cloud Firestore 也支持 null 值。不过,对具有 nil 值的可选属性进行编码的默认行为是直接省略它们。利用 @ExplicitNull,我们可以在编码 Swift 可选属性时在某种程度上控制如何处理它们:通过将可选属性标记为 @ExplicitNull,我们可以告知 Cloud Firestoree 如果此属性包含 nil 值,则将其以 null 值写入文档。

使用自定义编码器和解码器来映射颜色

关于如何使用 Codable 映射数据,我们的最后一个主题是介绍自定义编码器和解码器。本部分不涉及 Cloud Firestore 原生数据类型,但自定义编码器和解码器在 Cloud Firestore 应用中的用途非常广泛。

“如何映射颜色”是最常见的开发者问题之一,不仅仅对于 Cloud Firestore,对于 Swift 与 JSON 之间的映射也是如此。市面上有很多解决方案,但大多数解决方案都侧重于 JSON,且几乎所有解决方案都将颜色映射为由其 RGB 成分构成的嵌套字典。

看来应该有一个更好、更简单的解决方案。为什么不使用网页颜色(或者更具体地说,CSS 十六进制颜色码表示法)呢?这些颜色易于使用(本质上只是字符串),甚至还支持透明度!

为了能够将 Swift Color 映射到其十六进制值,我们需要创建一个将 Codable 添加到 Color 的 Swift 扩展程序。

extension Color {

 init(hex: String) {
    let rgba = hex.toRGBA()

    self.init(.sRGB,
              red: Double(rgba.r),
              green: Double(rgba.g),
              blue: Double(rgba.b),
              opacity: Double(rgba.alpha))
    }

    //... (code for translating between hex and RGBA omitted for brevity)

}

extension Color: Codable {
  
  public init(from decoder: Decoder) throws {
    let container = try decoder.singleValueContainer()
    let hex = try container.decode(String.self)

    self.init(hex: hex)
  }
  
  public func encode(to encoder: Encoder) throws {
    var container = encoder.singleValueContainer()
    try container.encode(toHex)
  }

}

通过使用 decoder.singleValueContainer(),我们可以将 String 解码为其等效的 Color,而不必嵌套 RGBA 成分。此外,您还可以在应用的网页界面中使用这些值,而无需先进行转换。

这样,我们就可以更新用于映射标记的代码,从而更轻松地直接处理标记颜色,而不必在应用的界面代码中手动进行映射:

struct Tag: Codable, Hashable {
  var title: String
  var color: Color
}

struct BookWithTags: Codable {
  @DocumentID var id: String?
  var title: String
  var numberOfPages: Int
  var author: String
  var tags: [Tag]
}

处理错误

在上面的代码段中,我们有意将错误处理代码保持在最低限度,但在生产应用中,您需要确保妥善处理所有错误。

以下代码段说明了如何处理您可能遇到的任何错误情况:

class MappingSimpleTypesViewModel: ObservableObject {
  @Published var book: Book = .empty
  @Published var errorMessage: String?
  
  private var db = Firestore.firestore()
  
  func fetchAndMap() {
    fetchBook(documentId: "hitchhiker")
  }
  
  func fetchAndMapNonExisting() {
    fetchBook(documentId: "does-not-exist")
  }
  
  func fetchAndTryMappingInvalidData() {
    fetchBook(documentId: "invalid-data")
  }
  
  private func fetchBook(documentId: String) {
    let docRef = db.collection("books").document(documentId)
    
    docRef.getDocument(as: Book.self) { result in
      switch result {
      case .success(let book):
        // A Book value was successfully initialized from the DocumentSnapshot.
        self.book = book
        self.errorMessage = nil
      case .failure(let error):
        // A Book value could not be initialized from the DocumentSnapshot.
        switch error {
        case DecodingError.typeMismatch(_, let context):
          self.errorMessage = "\(error.localizedDescription): \(context.debugDescription)"
        case DecodingError.valueNotFound(_, let context):
          self.errorMessage = "\(error.localizedDescription): \(context.debugDescription)"
        case DecodingError.keyNotFound(_, let context):
          self.errorMessage = "\(error.localizedDescription): \(context.debugDescription)"
        case DecodingError.dataCorrupted(let key):
          self.errorMessage = "\(error.localizedDescription): \(key)"
        default:
          self.errorMessage = "Error decoding document: \(error.localizedDescription)"
        }
      }
    }
  }
}

处理实时更新中的错误

前面的代码段演示了如何在提取单个文档时处理错误。除了提取数据一次之外,Cloud Firestore 还支持使用快照监听器在应用有更新可用时立即对其进行更新:我们可以在集合(或查询)中注册快照监听器,每当有更新时,Cloud Firestore 都会调用我们的监听器。

以下代码段说明了如何注册快照监听器、使用 Codable 映射数据、处理可能发生的任何错误,还说明了如何向集合添加新文档。您会发现,您不需要更新包含映射文档的本地数组,因为快照监听器中的代码会处理这一操作。

class MappingColorsViewModel: ObservableObject {
  @Published var colorEntries = [ColorEntry]()
  @Published var newColor = ColorEntry.empty
  @Published var errorMessage: String?
  
  private var db = Firestore.firestore()
  private var listenerRegistration: ListenerRegistration?
  
  public func unsubscribe() {
    if listenerRegistration != nil {
      listenerRegistration?.remove()
      listenerRegistration = nil
    }
  }
  
  func subscribe() {
    if listenerRegistration == nil {
      listenerRegistration = db.collection("colors")
        .addSnapshotListener { [weak self] (querySnapshot, error) in
          guard let documents = querySnapshot?.documents else {
            self?.errorMessage = "No documents in 'colors' collection"
            return
          }
          
          self?.colorEntries = documents.compactMap { queryDocumentSnapshot in
            let result = Result { try queryDocumentSnapshot.data(as: ColorEntry.self) }
            
            switch result {
            case .success(let colorEntry):
              if let colorEntry = colorEntry {
                // A ColorEntry value was successfully initialized from the DocumentSnapshot.
                self?.errorMessage = nil
                return colorEntry
              }
              else {
                // A nil value was successfully initialized from the DocumentSnapshot,
                // or the DocumentSnapshot was nil.
                self?.errorMessage = "Document doesn't exist."
                return nil
              }
            case .failure(let error):
              // A ColorEntry value could not be initialized from the DocumentSnapshot.
              switch error {
              case DecodingError.typeMismatch(_, let context):
                self?.errorMessage = "\(error.localizedDescription): \(context.debugDescription)"
              case DecodingError.valueNotFound(_, let context):
                self?.errorMessage = "\(error.localizedDescription): \(context.debugDescription)"
              case DecodingError.keyNotFound(_, let context):
                self?.errorMessage = "\(error.localizedDescription): \(context.debugDescription)"
              case DecodingError.dataCorrupted(let key):
                self?.errorMessage = "\(error.localizedDescription): \(key)"
              default:
                self?.errorMessage = "Error decoding document: \(error.localizedDescription)"
              }
              return nil
            }
          }
        }
    }
  }
  
  func addColorEntry() {
    let collectionRef = db.collection("colors")
    do {
      let newDocReference = try collectionRef.addDocument(from: newColor)
      print("ColorEntry stored with new document reference: \(newDocReference)")
    }
    catch {
      print(error)
    }
  }
}

本博文中使用的所有代码段都是可从此 GitHub 代码库下载的一个示例应用的一部分。

动手试试 Codable 吧!

Swift 的 Codable API 提供了一种强大且灵活的方法,可将序列化格式的数据映射到应用数据模型,或从应用数据模型映射为序列化格式的数据。在本指南中,您已了解了在使用 Cloud Firestore 作为数据存储区的应用中使用该 API 有多么简单。

从具有简单数据类型的基本示例开始,我们逐步增加了数据模型的复杂性,同时能够依靠 Codable 和 Firebase 的实现来执行映射。

如需详细了解 Codable,建议您参考以下资源:

虽然我们已经尽全力编写了关于映射 Cloud Firestore 文档的综合性指南,但本文并非详尽无遗,并且您可能正在使用其他策略来映射您的类型。使用下方的发送反馈按钮,告诉我们您使用什么策略来映射其他类型的 Cloud Firestore 数据或在 Swift 中表示数据。

综上所述,没有理由不使用 Cloud Firestore 的 Codable 支持。