使用 Swift Codable 對應 Cloud Firestore 資料

Swift 的 Codable API (自 Swift 4 推出) 讓我們得以運用 編譯器,方便您將序列化格式的資料對應至 Swift 。

您可能已使用過 Codable 將 Web API 的資料對應至應用程式資料 反之亦然

本指南將說明如何運用 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 是型別別名 這些通訊協定的注意事項依據 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 套件管理工具或 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?
}

請注意,我們如何為封面地圖的結構 (CoverImages) 定義 Cloud Firestore 文件將封面屬性標示為 選用 BookWithCoverImages,我們可以處理 文件不得包含封面屬性。

如果想瞭解為何沒有用於擷取或更新資料的程式碼片段, 這時候,您可以很高興地聽到這句話不需要調整程式碼。 都能透過我們先前編寫的程式碼使用 撰寫在初始段落中

陣列

有時候,我們會將一組值儲存在文件中。所屬類別 書籍就是很好的例子:《The Hitchhiker's Guide to the Galaxy》類似書籍 都可能屬於多個類別及「喜劇」:

在 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 對應至 id 屬性 其中一種 Swift 類型這麼做有幾個重要原因:

  • 這有助於我們瞭解哪些文件該更新,以因應使用者在本機 並輸入變更內容
  • 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 的屬性 以下是:如果在儲存時屬性為 nil (使用 addDocument(), 例如),Cloud Firestore 會在欄位中填入目前的伺服器 寫入資料庫時的時間戳記。如果欄位不是「nil」 當您呼叫 addDocument()updateData() 時,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 原生不支援列舉 (也就是說,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 開發人員 因此決定 請為所有屬性名稱選擇 snake_case。

別擔心,Codable 可以幫上忙!

在這些情況下,您可以使用 CodingKeys。這個列舉可以用來 加入共同結構體,以指定特定屬性的對應方式。

請參考以下文件:

含有 snake_cased 屬性名稱的 Firestore 文件

如要將這份文件對應至名稱屬性為 String 的結構, 您需要��� CodingKeys 列舉新增至 ProgrammingLanguage 結構,然後指定 文件中的屬性名稱:

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 如何利用 Codable 繪製地圖資料 自訂編碼器和解碼器模型本章未涵蓋原生 而自訂編碼器和解碼器都很實用 以及 Cloud Firestore 應用程式

「如何標示顏色」是開發人員最常見的問題之一 更可用於 Cloud Firestore 市面上有許多解決方案,但多半只著重 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 程式碼中手動對應:

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 都會呼叫監聽器。

以下程式碼片段說明如何註冊快照事件監聽器和地圖資料 並處理所有可能發生的錯誤以及如何新增 將新文件加入集合如您所見, 自行保留對應文件的本機陣列,因為這個方法 您在快照事件監聽器中的程式碼

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 支援。