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