Swift 4 中引入的 Swift Codable API 使我們能夠利用編譯器的強大功能,更輕鬆地將資料從序列化格式對應到 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
每個屬性的正確類型。
無論如何,什麼是可編碼的?
根據 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)
Cloud Firestore 文件中的簡單類型之間的映射
使用可編碼
Cloud Firestore 支援廣泛的資料類型,從簡單字串到巢狀映射。其中大部分直接對應於 Swift 的內建類型。在我們深入研究更複雜的資料類型之前,讓我們先來看看映射一些簡單的資料類型。
若要將 Cloud Firestore 文件對應到 Swift 類型,請依照下列步驟操作:
- 確保您已將
FirebaseFirestore
框架新增至您的專案。您可以使用Swift Package Manager 或 CocoaPods來執行此操作。 - 將
FirebaseFirestore
匯入到您的 Swift 檔案中。 - 使您的類型符合
Codable
。 - (可選,如果您想在
List
檢視中使用該類型)為您的類型新增id
屬性,並使用@DocumentID
告訴 Cloud Firestore 將其對應到文件 ID。我們將在下面更詳細地討論這個問題。 - 使用
documentReference.data(as: )
將文件參考對應到 Swift 類型。 - 使用
documentReference.setData(from: )
將資料從 Swift 類型對應到 Cloud Firestore 文件。 - (可選,但強烈建議)實施正確的錯誤處理。
讓我們相應地更新我們的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 還支援許多其他資料類型,其中一些是結構化類型,可用於在文件內建立巢狀物件。
嵌套自訂類型
我們想要在文件中映射的大多數屬性都是簡單值,例如書名或作者姓名。但是當我們需要儲存更複雜的物件時該怎麼辦?例如,我們可能希望以不同的解析度儲存書籍封面的 URL。
在 Cloud Firestore 中執行此操作的最簡單方法是使用地圖:
在編寫對應的 Swift 結構時,我們可以利用 Cloud Firestore 支援 URL 的事實 - 當儲存包含 URL 的欄位時,它將轉換為字串,反之亦然:
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
上的 cover 屬性標記為可選,我們能夠處理某些文件可能不包含 cover 屬性的事實。
如果您好奇為什麼沒有用於獲取或更新資料的程式碼片段,您會很高興聽到無需調整用於從/向 Cloud 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]
}
由於這適用於任何可編碼類型,因此我們也可以使用自訂類型。想像一下我們想要儲存每本書的標籤清單。除了標籤名稱之外,我們還想儲存標籤的顏色,如下所示:
要以這種方式儲存標籤,我們需要做的就是實作一個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 年發明的文檔:
用於映射此文件的 Swift 類型可能如下所示:
struct ProgrammingLanguage: Codable {
@DocumentID var id: String?
var name: String
var year: Date
}
我們不能離開關於日期和時間的部分而不討論@ServerTimestamp
。當涉及到處理應用程式中的時間戳記時,此屬性包裝器非常強大。
在任何分散式系統中,各系統上的時鐘很可能並非總是完全同步。您可能認為這沒什麼大不了的,但想像一下時鐘運行稍微不同步對股票交易系統的影響:即使是毫秒的偏差也可能導致執行交易時數百萬美元的差異。
Cloud Firestore 如下處理標有@ServerTimestamp
的屬性:如果在儲存屬性時該屬性為nil
(例如使用addDocument()
),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
案例儲存為“正在審核”,我們可以如下更新上述枚舉:
enum Status: String, Codable {
case draft
case inReview = "in review"
case approved
case published
}
自訂映射
有時,我們想要映射的 Cloud Firestore 文件的屬性名稱與 Swift 資料模型中的屬性名稱不符。例如,我們的一位同事可能是一名 Python 開發人員,並決定為所有屬性名稱選擇 Snake_case。
別擔心:Codable 已經為我們提供了保障!
對於這樣的情況,我們可以使用CodingKeys
。這是一個枚舉,我們可以將其新增到可編碼結構中以指定如何映射某些屬性。
考慮這個文檔:
為了將此文件對應到具有String
類型的 name 屬性的結構,我們需要將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
視圖中的識別碼。如果我們沒有在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
值,則將該屬性寫入帶有null 值的文檔。
使用自訂編碼器和解碼器來映射顏色
作為使用 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 元件。另外,您可以在應用程式的 Web 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 的更多詳細信息,我推薦以下資源:
- John Sundell 有一篇關於Codable 基礎知識的好文章。
- 如果您更喜歡書籍,請查看 Mattt 的Flight School Guide to Swift Codable 。
- 最後,Donny Wals 有一個關於 Codable 的完整系列。
儘管我們盡力編寫了映射 Cloud Firestore 文件的綜合指南,但這並不詳盡,您可能會使用其他策略來映射您的類型。使用下面的「傳送回饋」按鈕,讓我們知道您使用什麼策略來對應其他類型的 Cloud Firestore 資料或在 Swift 中表示資料。
確實沒有理由不使用 Cloud Firestore 的 Codable 支援。