Swift 4 中引入了 Swift 的 Codable API,支持用户利用编译器的强大功能,更轻松地将数据从序列化格式映射到 Swift 类型。
您可能已经在使用 Codable 将数据从 Web API 映射到应用的数据模型(或者反之),但其灵活的用途远不止于此。
在本指南中,我们将了解如何使用 Codable 将 Cloud Firestore 中的数据映射到 Swift 类型,以及如何实现反向映射。
从Cloud Firestore 提取文档时,您的应用会收到一个键值对字典(或者一系列字典,如果其中一个操作返回多个文档的话)。
您当然可以继续直接使用 Swift 中的字典,这些字典提供的极高灵活性可能正是您的用例所需的。不过,这种方法并不具备类型安全性,很容易出现属性名称拼写错误,或者忘记映射团队在上周发布炫酷新功能时添加的新属性,从而导致引入难以排查的 bug。
过去,许多开发者通过实现简单的映射层,利用该层将字典映射到 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
,以防止元素在插入时发生跳动。
需要指出的是,在回写文档时,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 Firestoree 按以下方式处理标有 @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 开发者,并决定为其所有属性名称都选择蛇形命名法。
不用担心,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
视图中的标识符。如果未在 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 Firestoree 如果此属性包含 nil
值,则将其以 null 值写入文档。
使用自定义编码器和解码器来映射颜色
关于如何使用 Codable 映射数据,我们的最后一个主题是介绍自定义编码器和解码器。本部分不涉及 Cloud Firestore 原生数据类型,但自定义编码器和解码器在 Cloud Firestore 应用中的用途非常广泛。
“如何映射颜色”是最常见的开发者问题之一,不仅仅对于 Cloud Firestore,对于 Swift 与 JSON 之间的映射也是如此。市面上有很多解决方案,但大多数解决方案都侧重于 JSON,且几乎所有解决方案都将颜色映射为由其 RGB 成分构成的嵌套字典。
看来应该有一个更好、更简单的解决方案。为什么不使用网页颜色(或者更具体地说,CSS 十六进制颜色码表示法)呢?这些颜色易于使用(本质上只是字符串),甚至还支持透明度!
为了能够将 Swift Color
映射到其十六进制值,我们需要创建一个将 Codable 添加到 Color
的 Swift 扩展程序。
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 成分。此外,您还可以在应用的网页界面中使用这些值,而无需先进行转换。
这样,我们就可以更新用于映射标记的代码,从而更轻松地直接处理标记颜色,而不必在应用的界面代码中手动进行映射:
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 作为数据存储区的应用中使用该 API 有多么简单。
从具有简单数据类型的基本示例开始,我们逐步增加了数据模型的复杂性,同时能够依靠 Codable 和 Firebase 的实现来执行映射。
如需详细了解 Codable,建议您参考以下资源:
- John Sundell 关于 Codable 基础知识的精彩文章。
- 如果您更喜欢书籍,可以阅读 Mattt 的《Flight School Guide to Swift Codable》(Swift Codable 飞行学校指南)。
- 最后,Donny Wals 的一整套关于 Codable 的系列内容。
虽然我们已经尽全力编写了关于映射 Cloud Firestore 文档的综合性指南,但本文并非详尽无遗,并且您可能正在使用其他策略来映射您的类型。使用下方的发送反馈按钮,告诉我们您使用什么策略来映射其他类型的 Cloud Firestore 数据或在 Swift 中表示数据。
综上所述,没有理由不使用 Cloud Firestore 的 Codable 支持。