使用 Swift Codable 對應 Cloud Firestore 資料

Swift 4 推出 Swift 的 Codable API,可讓我們運用編譯器的強大功能,更輕鬆地將序列化格式的資料對應至 Swift 類型。

您可能曾使用 Codable 將資料從網路 API 對應至應用程式的資料模型 (反之亦然),但 Codable 的彈性遠不只如此。

本指南將說明如何使用 Codable 將 Cloud Firestore 資料對應至 Swift 類型,反之亦然。

從 Cloud Firestore 擷取文件時,應用程式會收到鍵/值組合的字典 (如果使用傳回多份文件的作業,則會收到字典陣列)。

現在,您當然可以繼續直接在 Swift 中使用字典,而且字典提供了一些極佳的彈性,可能正是您的用途所需。不過,這種做法並非類型安全,而且很容易因為拼錯屬性名稱,或忘記對應團隊上週推出新功能時新增的屬性,而引入難以追蹤的錯誤。

過去,許多開發人員會實作簡單的對應層,將字典對應至 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 struct 時,我們可以利用 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 中,我們可以使用值陣列來建立模型。任何可編碼類型 (例如 String、Int 等) 都支援這項功能。以下說明如何將類型陣列新增至 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,以免元素在插入時跳動。

值得注意的是,在寫回文件時,標示為 @DocumentID 的屬性不會由 Cloud Firestore 的編碼器編碼。這是因為文件 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 Firestore 會按照下列方式處理標示為 @ServerTimestamp 的屬性:如果您在儲存屬性時 (例如使用 addDocument()),屬性為 nil,Cloud Firestore 會在將屬性寫入資料庫時,在欄位中填入目前的伺服器時間戳記。呼叫 addDocument() 或 updateData() 時,如果欄位不是 nil,Cloud Firestore 會保留屬性值。這樣一來,您就能輕鬆實作 createdAt 和 lastUpdatedAt 等欄位。

地理點

地理位置在應用程式中無所不在。儲存這些資料後,就能實現許多令人期待的功能。舉例來說,儲存工作地點可能很有用,這樣應用程式就能在您抵達目的地時提醒您。

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 案例儲存為「in review」,只要按照下列方式更新上述列舉即可:

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 檢視畫面中做為 ID 使用。如果我們未在 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 Firestore 在屬性包含 nil 值時,將屬性寫入文件,並將值設為空值。

使用自訂編碼器和解碼器對應顏色

在 Codable 對應資料的最後一個主題中,我們將介紹自訂編碼器和解碼器。本節不涵蓋原生 Cloud Firestore 資料類型,但自訂編碼器和解碼器在 Cloud Firestore 應用程式中廣泛實用。

「如何對應顏色」是開發人員最常問的問題之一,不只是 Cloud Firestore,Swift 和 JSON 之間的對應也是如此。市面上有許多解決方案,但大多著重於 JSON,且幾乎所有解決方案都會將顏色對應為由 RGB 元件組成的巢狀字典。

應該有更好、更簡單的解決方案。為什麼不使用網頁顏色 (更具體地說,CSS 十六進位顏色標記法)?這種顏色容易使用 (基本上只是一個字串),甚至支援透明度!

為了將 Swift Color 對應至其十六進位值,我們需要建立 Swift 擴充功能,將 Codable 新增至 Color。

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 元件。此外,您可以在應用程式的網頁 UI 中使用這些值,不必先轉換!

這樣一來,我們就能更新對應標記的程式碼,更輕鬆地直接處理標記顏色,而不必在應用程式的 UI 程式碼中手動對應:

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 做為資料儲存庫的應用程式中使用這項功能。

我們從簡單資料類型的基本範例開始,逐步增加資料模型的複雜度,同時能仰賴 Codable 和 Firebase 的實作來執行對應。

如要進一步瞭解 Codable,建議您參考下列資源:

雖然我們盡力編寫了完整的 Cloud Firestore 文件對應指南,但這份指南並未涵蓋所有內容,您可能需要使用其他策略來對應類型。請使用下方的「提供意見」按鈕,告訴我們您使用哪些策略對應其他類型的 Cloud Firestore 資料,或在 Swift 中表示資料。

因此,您沒有理由不使用 Cloud Firestore 的 Codable 支援。