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 的程式碼,因為這些程式碼都會與我們在初始部分編寫的程式碼搭配運作。
陣列
有時候,我們會想在文件中儲存值集合。書籍類型就是很好的例子:像是《銀河便車指南》這類書籍可能會歸類為多個類別,在本例中為「科幻」和「喜劇」:
在 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 支援功能。