Swift 4 中推出的 Swift 可編碼 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 類型,請按照下列步驟操作:
- 請確認您已將
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 也支援多種其他資料類型,其中部分是結構化類型,可用於在文件中建立巢狀物件。
巢狀自訂類型
我們在文件中要對應的大部分屬性都是簡單的值,例如書籍名稱或作者名稱。但如果需要儲存更複雜的物件,該怎麼辦呢?舉例來說,我們可能會想儲存書籍封面的網址,並提供不同解析度的網址。
如要在 Cloud 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 的程式碼:所有這些程式碼都會與我們在初始部分編寫的程式碼搭配運作。
陣列
有時候,我們會將一組值儲存在文件中。最好將書籍類型當做一個好範例:《The Hitchhiker's Guide to the Galaxy》等書籍可能分為多個類別,在本例中為「科幻」和「喜劇」:
在 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
,以免元素在插入時跳躍。
值得一提的是,標示為 @DocumentID
的屬性在寫回文件時,不會由 Cloud Firestore 的編碼器編碼。這是因為文件 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
案例儲存為「in review」,即可更新上述列舉,如下所示:
enum Status: String, Codable {
case draft
case inReview = "in review"
case approved
case published
}
自訂對應項目
有時,我們要對應的 Cloud Firestore 文件屬性名稱,與 Swift 中資料模型屬性名稱不符。舉例來說,我們的同事可能會是 Python 開發人員,並決定為所有屬性名稱選擇 snake_case。
別擔心,Codable 會幫你解決這個問題!
對於這類情況,我們可以使用 CodingKeys
。這是我們可以新增至可編碼結構體的列舉,用來指定如何對應特定屬性。
請參考下列文件:
如要將這份文件對應至名稱屬性類型為 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,建議您參考下列資源:
- John Sundell 有一篇不錯的文章,介紹 Codable 的基本概念。
- 如果您偏好閱讀書籍,請參閱 Mattt 的 Swift Codable 飛行學校指南。
- 最後,Donny Wals 有一系列關於 Codable 的內容。
雖然我們盡力編寫完整的指南,說明如何對應 Cloud Firestore 文件,但這並非詳盡無遺,您可能會使用其他策略來對應類型。請使用下方的「傳送意見」按鈕,告訴我們您在對應其他類型的 Cloud Firestore 資料或在 Swift 中表示資料時,採用哪些策略。
您其實沒有理由不使用 Cloud Firestore 的 Codable 支援功能。