Сопоставление данных Cloud Firestore с помощью Swift Codable

API Codable в Swift, представленный в Swift 4, позволяет нам использовать возможности компилятора для упрощения сопоставления данных из сериализованных форматов с типами Swift.

Возможно, вы использовали Codable для сопоставления данных из веб-API с моделью данных вашего приложения (и наоборот), но этот инструмент гораздо гибче.

В этом руководстве мы рассмотрим, как Codable можно использовать для сопоставления данных из Cloud Firestore с типами Swift и наоборот.

При получении документа из Cloud Firestore ваше приложение получит словарь пар ключ/значение (или массив словарей, если вы используете одну из операций, возвращающих несколько документов).

Конечно, вы можете продолжать использовать словари напрямую в Swift, и они предлагают большую гибкость, которая может идеально подойти для вашей задачи. Однако такой подход не является типобезопасным, и легко допустить ошибки, которые трудно отследить, например, неправильно написать имена атрибутов или забыть сопоставить новый атрибут, добавленный вашей командой при выпуске новой интересной функции на прошлой неделе.

В прошлом многие разработчики обходили эти недостатки, реализуя простой слой сопоставления, который позволял им сопоставлять словари с типами Swift. Но опять же, большинство этих реализаций основаны на ручном указании соответствия между документами Cloud Firestore и соответствующими типами модели данных вашего приложения.

Благодаря поддержке Codable API в Cloud Firestore , это становится намного проще:

  • Вам больше не придётся вручную реализовывать какой-либо код сопоставления.
  • Легко определить, как сопоставлять атрибуты с разными именами.
  • В нём реализована встроенная поддержка многих типов данных 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)

Преобразование типов в простые и промежуточные типы в документах Cloud Firestore с помощью Codable

Cloud Firestore поддерживает широкий набор типов данных, от простых строк до вложенных карт. Большинство из них напрямую соответствуют встроенным типам Swift. Давайте сначала рассмотрим сопоставление простых типов данных, прежде чем переходить к более сложным.

Чтобы сопоставить документы Cloud Firestore с типами Swift, выполните следующие шаги:

  1. Убедитесь, что вы добавили фреймворк FirebaseFirestore в свой проект. Для этого можно использовать либо Swift Package Manager, либо CocoaPods .
  2. Импортируйте FirebaseFirestore в свой Swift-файл.
  3. Приведите свой текст в соответствие с Codable .
  4. (Необязательно, если вы хотите использовать этот тип в представлении List ) Добавьте свойство id к вашему типу и используйте @DocumentID , чтобы указать Cloud Firestore сопоставить его с идентификатором документа. Мы обсудим это подробнее ниже.
  5. Используйте documentReference.data(as: ) для сопоставления ссылки на документ с типом Swift.
  6. Используйте documentReference.setData(from: ) для сопоставления данных из типов Swift с документом Cloud Firestore .
  7. (Необязательно, но настоятельно рекомендуется) Внедрить надлежащую обработку ошибок.

Давайте обновим наш тип 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 автоматически присваивает ему новый идентификатор. Это работает даже в автономном режиме.

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 — использовать карту:

Сохранение вложенного пользовательского типа в документе 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?
}

Обратите внимание, как мы определили структуру CoverImages для карты обложек в документе Cloud Firestore . Указав свойство cover в BookWithCoverImages как необязательное, мы можем обработать тот факт, что некоторые документы могут не содержать атрибута cover.

Если вам интересно, почему нет фрагмента кода для получения или обновления данных, вы будете рады узнать, что нет необходимости изменять код для чтения или записи из/в Cloud Firestore : все это работает с кодом, который мы написали в начальном разделе.

Массивы

Иногда нам нужно хранить в документе набор значений. Хорошим примером являются жанры книг: такая книга, как «Автостопом по Галактике», может относиться к нескольким категориям — в данном случае «Научная фантастика» и «Комедия»:

Сохранение массива в документе 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]
}

Поскольку это работает для любого типа данных, допускающего кодирование, мы можем использовать и пользовательские типы. Представьте, что мы хотим хранить список тегов для каждой книги. Наряду с названием тега, мы хотели бы хранить и цвет тега, вот так:

Хранение массива пользовательских типов в документе Firestore.

Для хранения тегов таким способом нам достаточно реализовать структуру Tag , представляющую тег, и сделать её пригодной для кодирования:

struct Tag: Codable, Hashable {
  var title: String
  var color: String
}

И вот так мы можем хранить массив Tags в наших документах Book !

struct BookWithTags: Codable {
  @DocumentID var id: String?
  var title: String
  var numberOfPages: Int
  var author: String
  var tags: [Tag]
}

Несколько слов о сопоставлении идентификаторов документов.

Прежде чем перейти к сопоставлению других типов, давайте на мгновение поговорим о сопоставлении идентификаторов документов.

В некоторых предыдущих примерах мы использовали обертку свойства @DocumentID для сопоставления идентификатора документа в Cloud Firestore со свойством id наших типов Swift. Это важно по ряду причин:

  • Это помогает нам понять, какой документ следует обновить в случае, если пользователь внесет локальные изменения.
  • В SwiftUI для элементов List требуется, чтобы они были Identifiable , чтобы предотвратить их перемещение при добавлении.

Стоит отметить, что атрибут, помеченный как @DocumentID не будет закодирован кодировщиком Cloud Firestore при записи документа обратно. Это связано с тем, что идентификатор документа не является атрибутом самого документа — поэтому запись его в документ была бы ошибкой.

При работе с вложенными типами (например, массивом тегов в примере с Book , приведенном ранее в этом руководстве) добавлять свойство @DocumentID не требуется: вложенные свойства являются частью документа Cloud Firestore и не представляют собой отдельный документ. Следовательно, им не нужен идентификатор документа.

Даты и время

Cloud Firestore есть встроенный тип данных для обработки дат и времени, и благодаря поддержке Codable в Cloud Firestore , использовать их очень просто.

Давайте взглянем на этот документ, представляющий собой прародительницу всех языков программирования, язык Ада, изобретенный в 1843 году:

Хранение дат в документе Firestore

Тип данных в Swift для сопоставления этого документа может выглядеть следующим образом:

struct ProgrammingLanguage: Codable {
  @DocumentID var id: String?
  var name: String
  var year: Date
}

Мы не можем обойти стороной этот раздел о датах и ​​времени, не упомянув @ServerTimestamp . Этот обертка для свойства — настоящий инструмент для работы с временными метками в вашем приложении.

В любой распределенной системе существует вероятность того, что часы на отдельных системах не всегда полностью синхронизированы. Вам может показаться, что это не имеет большого значения, но представьте себе последствия небольшого рассинхронизации часов для системы биржевой торговли: даже отклонение на миллисекунду может привести к разнице в миллионы долларов при совершении сделки.

Cloud Firestore обрабатывает атрибуты, помеченные аннотацией @ServerTimestamp следующим образом: если атрибут равен nil при сохранении (например, с помощью addDocument() ), Cloud Firestore заполнит поле текущей меткой времени сервера на момент записи в базу данных. Если поле не nil при вызове addDocument() или updateData() , 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)

Чтобы узнать больше о запросах к документам по физическому местоположению, ознакомьтесь с этим руководством по решению .

Перечисления

Перечисления (enums) — пожалуй, одна из самых недооцененных языковых возможностей 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 . Это перечисление, которое можно добавить к структуре, допускающей кодирование, чтобы указать, как будут сопоставляться определенные атрибуты.

Ознакомьтесь с этим документом:

Документ Firestore с именем атрибута, записанным в формате snake_cased.

Чтобы сопоставить этот документ со структурой, имеющей свойство name типа String , нам необходимо добавить перечисление 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
  }
}

По умолчанию API Codable использует имена свойств наших типов Swift для определения имен атрибутов в документах Cloud Firestore , которые мы пытаемся сопоставить. Поэтому, пока имена атрибутов совпадают, нет необходимости добавлять CodingKeys к нашим типам Codable. Однако, как только мы используем CodingKeys для конкретного типа, нам необходимо добавить все имена свойств, которые мы хотим сопоставить.

В приведенном выше фрагменте кода мы определили свойство id , которое, возможно, захотим использовать в качестве идентификатора в представлении List SwiftUI. Если бы мы не указали его в 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 записать это свойство в документ со значением null, если оно содержит значение nil .

Использование пользовательского кодировщика и декодера для сопоставления цветов.

В качестве заключительной темы нашего обзора сопоставления данных с помощью Codable, давайте познакомимся с пользовательскими кодировщиками и декодерами. В этом разделе не рассматривается собственный тип данных Cloud Firestore , но пользовательские кодировщики и декодеры очень полезны в ваших приложениях Cloud Firestore .

«Как сопоставить цвета?» — один из наиболее часто задаваемых вопросов разработчиков, не только по Cloud Firestore , но и по сопоставлению данных между Swift и JSON. Существует множество решений, но большинство из них ориентированы на JSON, и почти все они сопоставляют цвета как вложенный словарь, состоящий из его RGB-компонентов.

Кажется, должно быть более простое и удобное решение. Почему бы нам не использовать веб-цвета (или, точнее, шестнадцатеричную кодировку цветов в CSS) — они просты в использовании (по сути, это просто строка) и даже поддерживают прозрачность!

Чтобы сопоставить Color Swift с его шестнадцатеричным значением, нам нужно создать расширение 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. Кроме того, вы можете использовать эти значения в веб-интерфейсе вашего приложения, не выполняя предварительного преобразования!

Благодаря этому мы можем обновить код для сопоставления тегов, упростив обработку цветов тегов напрямую, вместо того чтобы сопоставлять их вручную в коде пользовательского интерфейса нашего приложения:

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!

API Codable в Swift предоставляет мощный и гибкий способ сопоставления данных из сериализованных форматов с моделью данных вашего приложения и обратно. В этом руководстве вы увидели, насколько легко его использовать в приложениях, использующих Cloud Firestore в качестве хранилища данных.

Начав с простого примера с базовыми типами данных, мы постепенно увеличивали сложность модели данных, при этом полагаясь на реализацию Codable и Firebase для выполнения сопоставления данных.

Для получения более подробной информации о Codable я рекомендую следующие ресурсы:

Хотя мы постарались составить исчерпывающее руководство по сопоставлению документов Cloud Firestore , оно не является полным, и вы можете использовать другие стратегии для сопоставления ваших типов. Используя кнопку «Отправить отзыв» ниже, сообщите нам, какие стратегии вы используете для сопоставления других типов данных Cloud Firestore или для представления данных в Swift.

На самом деле нет никаких причин не использовать поддержку Codable в Cloud Firestore .

,

API Codable в Swift, представленный в Swift 4, позволяет нам использовать возможности компилятора для упрощения сопоставления данных из сериализованных форматов с типами Swift.

Возможно, вы использовали Codable для сопоставления данных из веб-API с моделью данных вашего приложения (и наоборот), но этот инструмент гораздо гибче.

В этом руководстве мы рассмотрим, как Codable можно использовать для сопоставления данных из Cloud Firestore с типами Swift и наоборот.

При получении документа из Cloud Firestore ваше приложение получит словарь пар ключ/значение (или массив словарей, если вы используете одну из операций, возвращающих несколько документов).

Конечно, вы можете продолжать использовать словари напрямую в Swift, и они предлагают большую гибкость, которая может идеально подойти для вашей задачи. Однако такой подход не является типобезопасным, и легко допустить ошибки, которые трудно отследить, например, неправильно написать имена атрибутов или забыть сопоставить новый атрибут, добавленный вашей командой при выпуске новой интересной функции на прошлой неделе.

В прошлом многие разработчики обходили эти недостатки, реализуя простой слой сопоставления, который позволял им сопоставлять словари с типами Swift. Но опять же, большинство этих реализаций основаны на ручном указании соответствия между документами Cloud Firestore и соответствующими типами модели данных вашего приложения.

Благодаря поддержке Codable API в Cloud Firestore , это становится намного проще:

  • Вам больше не придётся вручную реализовывать какой-либо код сопоставления.
  • Легко определить, как сопоставлять атрибуты с разными именами.
  • В нём реализована встроенная поддержка многих типов данных 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)

Преобразование типов в простые и промежуточные типы в документах Cloud Firestore с помощью Codable

Cloud Firestore поддерживает широкий набор типов данных, от простых строк до вложенных карт. Большинство из них напрямую соответствуют встроенным типам Swift. Давайте сначала рассмотрим сопоставление простых типов данных, прежде чем переходить к более сложным.

Чтобы сопоставить документы Cloud Firestore с типами Swift, выполните следующие шаги:

  1. Убедитесь, что вы добавили фреймворк FirebaseFirestore в свой проект. Для этого можно использовать либо Swift Package Manager, либо CocoaPods .
  2. Импортируйте FirebaseFirestore в свой Swift-файл.
  3. Приведите свой текст в соответствие с Codable .
  4. (Необязательно, если вы хотите использовать этот тип в представлении List ) Добавьте свойство id к вашему типу и используйте @DocumentID , чтобы указать Cloud Firestore сопоставить его с идентификатором документа. Мы обсудим это подробнее ниже.
  5. Используйте documentReference.data(as: ) для сопоставления ссылки на документ с типом Swift.
  6. Используйте documentReference.setData(from: ) для сопоставления данных из типов Swift с документом Cloud Firestore .
  7. (Необязательно, но настоятельно рекомендуется) Внедрить надлежащую обработку ошибок.

Давайте обновим наш тип 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 автоматически присваивает ему новый идентификатор. Это работает даже в автономном режиме.

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 — использовать карту:

Сохранение вложенного пользовательского типа в документе 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?
}

Обратите внимание, как мы определили структуру CoverImages для карты обложек в документе Cloud Firestore . Указав свойство cover в BookWithCoverImages как необязательное, мы можем обработать тот факт, что некоторые документы могут не содержать атрибута cover.

Если вам интересно, почему нет фрагмента кода для получения или обновления данных, вы будете рады узнать, что нет необходимости изменять код для чтения или записи из/в Cloud Firestore : все это работает с кодом, который мы написали в начальном разделе.

Массивы

Иногда нам нужно хранить в документе набор значений. Хорошим примером являются жанры книг: такая книга, как «Автостопом по Галактике», может относиться к нескольким категориям — в данном случае «Научная фантастика» и «Комедия»:

Сохранение массива в документе 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]
}

Поскольку это работает для любого типа данных, допускающего кодирование, мы можем использовать и пользовательские типы. Представьте, что мы хотим хранить список тегов для каждой книги. Наряду с названием тега, мы хотели бы хранить и цвет тега, вот так:

Хранение массива пользовательских типов в документе Firestore.

Для хранения тегов таким способом нам достаточно реализовать структуру Tag , представляющую тег, и сделать её пригодной для кодирования:

struct Tag: Codable, Hashable {
  var title: String
  var color: String
}

И вот так мы можем хранить массив Tags в наших документах Book !

struct BookWithTags: Codable {
  @DocumentID var id: String?
  var title: String
  var numberOfPages: Int
  var author: String
  var tags: [Tag]
}

Несколько слов о сопоставлении идентификаторов документов.

Прежде чем перейти к сопоставлению других типов, давайте на мгновение поговорим о сопоставлении идентификаторов документов.

В некоторых предыдущих примерах мы использовали обертку свойства @DocumentID для сопоставления идентификатора документа в Cloud Firestore со свойством id наших типов Swift. Это важно по ряду причин:

  • Это помогает нам понять, какой документ следует обновить в случае, если пользователь внесет локальные изменения.
  • В SwiftUI для элементов List требуется, чтобы они были Identifiable , чтобы предотвратить их перемещение при добавлении.

Стоит отметить, что атрибут, помеченный как @DocumentID не будет закодирован кодировщиком Cloud Firestore при записи документа обратно. Это связано с тем, что идентификатор документа не является атрибутом самого документа — поэтому запись его в документ была бы ошибкой.

При работе с вложенными типами (например, массивом тегов в примере с Book , приведенном ранее в этом руководстве) добавлять свойство @DocumentID не требуется: вложенные свойства являются частью документа Cloud Firestore и не представляют собой отдельный документ. Следовательно, им не нужен идентификатор документа.

Даты и время

Cloud Firestore есть встроенный тип данных для обработки дат и времени, и благодаря поддержке Codable в Cloud Firestore , использовать их очень просто.

Давайте взглянем на этот документ, представляющий собой прародительницу всех языков программирования, язык Ада, изобретенный в 1843 году:

Хранение дат в документе Firestore

Тип данных в Swift для сопоставления этого документа может выглядеть следующим образом:

struct ProgrammingLanguage: Codable {
  @DocumentID var id: String?
  var name: String
  var year: Date
}

Мы не можем обойти стороной этот раздел о датах и ​​времени, не упомянув @ServerTimestamp . Этот обертка для свойства — настоящий инструмент для работы с временными метками в вашем приложении.

В любой распределенной системе существует вероятность того, что часы на отдельных системах не всегда полностью синхронизированы. Вам может показаться, что это не имеет большого значения, но представьте себе последствия небольшого рассинхронизации часов для системы биржевой торговли: даже отклонение на миллисекунду может привести к разнице в миллионы долларов при совершении сделки.

Cloud Firestore обрабатывает атрибуты, помеченные аннотацией @ServerTimestamp следующим образом: если атрибут равен nil при сохранении (например, с помощью addDocument() ), Cloud Firestore заполнит поле текущей меткой времени сервера на момент записи в базу данных. Если поле не nil при вызове addDocument() или updateData() , 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)

Чтобы узнать больше о запросах к документам по физическому местоположению, ознакомьтесь с этим руководством по решению .

Перечисления

Перечисления (enums) — пожалуй, одна из самых недооцененных языковых возможностей 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 . Это перечисление, которое можно добавить к структуре, допускающей кодирование, чтобы указать, как будут сопоставляться определенные атрибуты.

Ознакомьтесь с этим документом:

A Firestore document with a snake_cased attribute name

To map this document to a struct that has a name property of type String , we need to add a CodingKeys enum to the ProgrammingLanguage struct, and specify the name of the attribute in the document:

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
  }
}

By default, the Codable API will use the property names of our Swift types to determine the attribute names on the Cloud Firestore documents we're trying to map. So as long as the attribute names match, there is no need to add CodingKeys to our codable types. However, once we use CodingKeys for a specific type, we need to add all property names we want to map.

In the code snippet above, we've defined an id property which we might want to use as the identifier in a SwiftUI List view. If we didn't specify it in CodingKeys , it wouldn't be mapped when fetching data, and thus become nil . This would result in the List view being filled with the first document.

Any property that is not listed as a case on the respective CodingKeys enum will be ignored during the mapping process. This can actually be convenient if we specifically want to exclude some of the properties from being mapped.

So for example, if we want to exclude the reasonWhyILoveThis property from being mapped, all we need to do is to remove it from the CodingKeys enum:

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
  }
}

Occasionally we might want to write an empty attribute back into the Cloud Firestore document. Swift has the notion of optionals to denote the absence of a value, and Cloud Firestore supports null values as well. However, the default behavior for encoding optionals that have a nil value is to just omit them. @ExplicitNull gives us some control over how Swift optionals are handled when encoding them: by flagging an optional property as @ExplicitNull , we can tell Cloud Firestore to write this property to the document with a null value if it contains a value of nil .

Using a custom encoder and decoder for mapping colors

As a last topic in our coverage of mapping data with Codable, let's introduce custom encoders and decoders. This section doesn't cover a native Cloud Firestore datatype, but custom encoders and decoders are widely useful in your Cloud Firestore apps.

"How can I map colors" is one of the most frequently asked developer questions, not only for Cloud Firestore , but for mapping between Swift and JSON as well. There are plenty of solutions out there, but most of them focus on JSON, and almost all of them map colors as a nested dictionary composed of its RGB components.

It seems there should be a better, simpler solution. Why don't we use web colors (or, to be more specific, CSS hex color notation) — they're easy to use (essentially just a string), and they even support transparency!

To be able to map a Swift Color to its hex value, we need to create a Swift extension that adds Codable to 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)
  }

}

By using decoder.singleValueContainer() , we can decode a String to its Color equivalent, without having to nest the RGBA components. Plus, you can use these values in the web UI of your app, without having to convert them first!

With this, we can update code for mapping tags, making it easier to handle the tag colors directly instead of having to map them manually in our app's UI code:

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]
}

Обработка ошибок

In the above code snippets we intentionally kept error handling at a minimum, but in a production app, you'll want to make sure to gracefully handle any errors.

Here is a code snippet that shows how to use handle any error situations you might run into:

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)"
        }
      }
    }
  }
}

Handling errors in live updates

The previous code snippet demonstrates how to handle errors when fetching a single document. In addition to fetching data once, Cloud Firestore also supports delivering updates to your app as they happen, using so-called snapshot listeners: we can register a snapshot listener on a collection (or query), and Cloud Firestore will call our listener whenever there is an update.

Here is a code snippet that shows how to register a snapshot listener, map data using Codable, and handle any errors that might occur. It also shows how to add a new document to the collection. As you will see, there is no need to update the local array holding the mapped documents ourselves, as this is taken care of by the code in the snapshot listener.

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)
    }
  }
}

All code snippets used in this post are part of a sample application that you can download from this GitHub repository .

Go forth and use Codable!

Swift's Codable API provides a powerful and flexible way to map data from serialized formats to and from your applications data model. In this guide, you saw how easy it is to use in apps that use Cloud Firestore as their datastore.

Starting from a basic example with simple data types, we progressively increased the complexity of the data model, all the while being able to rely on Codable and Firebase's implementation to perform the mapping for us.

For more details about Codable, I recommend the following resources:

Although we did our best to compile a comprehensive guide for mapping Cloud Firestore documents, this is not exhaustive, and you might be using other strategies to map your types. Using the Send feedback button below, let us know what strategies you use for mapping other types of Cloud Firestore data or representing data in Swift.

There really is no reason for not using Cloud Firestore 's Codable support.

,

Swift's Codable API, introduced in Swift 4, enables us to leverage the power of the compiler to make it easier to map data from serialized formats to Swift types.

You might have been using Codable to map data from a web API to your app's data model (and vice versa), but it is much more flexible than that.

In this guide, we're going to look at how Codable can be used to map data from Cloud Firestore to Swift types and vice versa.

When fetching a document from Cloud Firestore , your app will receive a dictionary of key/value pairs (or an array of dictionaries, if you use one of the operations returning multiple documents).

Now, you can certainly continue to directly use dictionaries in Swift, and they offer some great flexibility that might be exactly what your use case calls for. However, this approach isn't type safe and it's easy to introduce hard-to-track-down bugs by misspelling attribute names, or forgetting to map the new attribute your team added when they shipped that exciting new feature last week.

In the past, many developers have worked around these shortcomings by implementing a simple mapping layer that allowed them to map dictionaries to Swift types. But again, most of these implementations are based on manually specifying the mapping between Cloud Firestore documents and the corresponding types of your app's data model.

With Cloud Firestore 's support for Swift's Codable API, this becomes a lot easier:

  • You will no longer have to manually implement any mapping code.
  • It's easy to define how to map attributes with different names.
  • It has built-in support for many of Swift's types.
  • And it's easy to add support for mapping custom types.
  • Best of all: for simple data models, you won't have to write any mapping code at all.

Картографические данные

Cloud Firestore stores data in documents which map keys to values. To fetch data from an individual document, we can call DocumentSnapshot.data() , which returns a dictionary mapping the field names to an Any : func data() -> [String : Any]? .

This means we can use Swift's subscript syntax to access each individual field.

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)
      }
    }
  }
}

While it might seem straightforward and easy to implement, this code is fragile, hard to maintain, and error-prone.

As you can see, we're making assumptions about the data types of the document fields. These might or might not be correct.

Remember, since there is no schema, you can easily add a new document to the collection and choose a different type for a field. You might accidentally choose string for the numberOfPages field, which would result in a difficult-to-find mapping issue. Also, you'll have to update your mapping code whenever a new field is added, which is rather cumbersome.

And let's not forget that we're not taking advantage of Swift's strong type system, which knows exactly the correct type for each of the properties of Book .

What is Codable, anyway?

According to Apple's documentation, Codable is "a type that can convert itself into and out of an external representation." In fact, Codable is a type alias for the Encodable and Decodable protocols. By conforming a Swift type to this protocol, the compiler will synthesize the code needed to encode/decode an instance of this type from a serialized format, such as JSON.

A simple type for storing data about a book might look like this:

struct Book: Codable {
  var title: String
  var numberOfPages: Int
  var author: String
}

As you can see, conforming the type to Codable is minimally invasive. We only had to add the conformance to the protocol; no other changes were required.

With this in place, we can now easily encode a book to a JSON object:

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)")
}

Decoding a JSON object to a Book instance works as follows:

let decoder = JSONDecoder()
let data = /* fetch data from the network */
let decodedBook = try decoder.decode(Book.self, from: data)

Mapping to and from simple types in Cloud Firestore documents using Codable

Cloud Firestore supports a broad set of data types, ranging from simple strings to nested maps. Most of these correspond directly to Swift's built-in types. Let's take a look at mapping some simple data types first before we dive into the more complex ones.

To map Cloud Firestore documents to Swift types, follow these steps:

  1. Make sure you've added the FirebaseFirestore framework to your project. You can use either the Swift Package Manager or CocoaPods to do so.
  2. Import FirebaseFirestore into your Swift file.
  3. Conform your type to Codable .
  4. (Optional, if you want to use the type in a List view) Add an id property to your type, and use @DocumentID to tell Cloud Firestore to map this to the document ID. We'll discuss this in more detail below.
  5. Use documentReference.data(as: ) to map a document reference to a Swift type.
  6. Use documentReference.setData(from: ) to map data from Swift types to a Cloud Firestore document.
  7. (Optional, but highly recommended) Implement proper error handling.

Let's update our Book type accordingly:

struct Book: Codable {
  @DocumentID var id: String?
  var title: String
  var numberOfPages: Int
  var author: String
}

Since this type was already codable, we only had to add the id property and annotate it with the @DocumentID property wrapper.

Taking the previous code snippet for fetching and mapping a document, we can replace all the manual mapping code with a single line:

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)
        }
      }
    }
  }
}

You can write this even more concisely by specifying the type of the document when calling getDocument(as:) . This will perform the mapping for you, and return a Result type containing the mapped document, or an error in case decoding failed:

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)"
    }
  }
}

Updating an existing document is as simple as calling documentReference.setData(from: ) . Including some basic error handling, here is the code to save a Book instance:

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)
    }
  }
}

When adding a new document, Cloud Firestore will automatically take care of assigning a new document ID to the document. This even works when the app is currently offline.

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)
  }
}

In addition to mapping simple data types, Cloud Firestore supports a number of other datatypes, some of which are structured types that you can use to create nested objects inside a document.

Nested custom types

Most attributes we want to map in our documents are simple values, such as the book's title or the author's name. But what about those cases when we need to store a more complex object? For example, we might want to store the URLs to the book's cover in different resolutions.

The easiest way to do this in Cloud Firestore is to use a map:

Storing a nested custom type in a Firestore document

When writing the corresponding Swift struct, we can make use of the fact that Cloud Firestore supports URLs — when storing a field that contains a URL, it will be converted to a string and vice versa:

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?
}

Notice how we defined a struct, CoverImages , for the cover map in the Cloud Firestore document. By marking the cover property on BookWithCoverImages as optional, we're able to handle the fact that some documents might not contain a cover attribute.

If you're curious why there is no code snippet for fetching or updating data, you will be pleased to hear that there is no need to adjust the code for reading or writing from/to Cloud Firestore : all of this works with the code we've written in the initial section.

Массивы

Sometimes, we want to store a collection of values in a document. The genres of a book are a good example: a book like The Hitchhiker's Guide to the Galaxy might fall into several categories — in this case "Sci-Fi" and "Comedy":

Storing an array in a Firestore document

In Cloud Firestore , we can model this using an array of values. This is supported for any codable type (such as String , Int , etc.). The following shows how to add an array of genres to our Book model:

public struct BookWithGenre: Codable {
  @DocumentID var id: String?
  var title: String
  var numberOfPages: Int
  var author: String
  var genres: [String]
}

Since this works for any codable type, we can use custom types as well. Imagine we want to store a list of tags for each book. Along with the name of the tag, we'd like to store the color of the tag as well, like this:

Storing an array of custom types in a Firestore document

To store tags in this way, all we need to do is implement a Tag struct to represent a tag and make it codable:

struct Tag: Codable, Hashable {
  var title: String
  var color: String
}

And just like that, we can store an array of Tags in our Book documents!

struct BookWithTags: Codable {
  @DocumentID var id: String?
  var title: String
  var numberOfPages: Int
  var author: String
  var tags: [Tag]
}

A quick word about mapping document IDs

Before we move on to mapping more types, let's talk about mapping document IDs for a moment.

We used the @DocumentID property wrapper in some of the previous examples to map the document ID of our Cloud Firestore documents to the id property of our Swift types. This is important for a number of reasons:

  • It helps us to know which document to update in case the user makes local changes.
  • SwiftUI's List requires its elements to be Identifiable in order to prevent elements from jumping around when they get inserted.

It's worth pointing out that an attribute marked as @DocumentID will not be encoded by Cloud Firestore 's encoder when writing the document back. This is because the document ID is not an attribute of the document itself — so writing it to the document would be a mistake.

When working with nested types (such as the array of tags on the Book in an earlier example in this guide), it is not required to add a @DocumentID property: nested properties are a part of the Cloud Firestore document, and do not constitute a separate document. Hence, they do not need a document ID.

Даты и время

Cloud Firestore has a built-in data type for handling dates and times, and thanks to Cloud Firestore 's support for Codable, it's straightforward to use them.

Let's take a look at this document which represents the mother of all programming languages, Ada, invented in 1843:

Storing dates in a Firestore document

A Swift type for mapping this document might look like this:

struct ProgrammingLanguage: Codable {
  @DocumentID var id: String?
  var name: String
  var year: Date
}

We cannot leave this section about dates and times without having a conversation about @ServerTimestamp . This property wrapper is a powerhouse when it comes to dealing with timestamps in your app.

In any distributed system, chances are that the clocks on the individual systems are not completely in sync all of the time. You might think this is not a big deal, but imagine the implications of a clock running slightly out of sync for a stock trade system: even a millisecond deviation might result in a difference of millions of dollars when executing a trade.

Cloud Firestore handles attributes marked with @ServerTimestamp as follows: if the attribute is nil when you store it (using addDocument() , for example), Cloud Firestore will populate the field with the current server timestamp at the time of writing it into the database. If the field is not nil when you call addDocument() or updateData() , Cloud Firestore will leave the attribute value untouched. This way, it is easy to implement fields like createdAt and lastUpdatedAt .

Геоточки

Geolocations are ubiquitous in our apps. Many exciting features become possible by storing them. For example, it might be useful to store a location for a task so your app can remind you about a task when you reach a destination.

Cloud Firestore has a built-in data type, GeoPoint , which can store the longitude and latitude of any location. To map locations from/to a Cloud Firestore document, we can use the GeoPoint type:

struct Office: Codable {
  @DocumentID var id: String?
  var name: String
  var location: GeoPoint
}

The corresponding type in Swift is CLLocationCoordinate2D , and we can map between those two types with the following operation:

CLLocationCoordinate2D(latitude: office.location.latitude,
                      longitude: office.location.longitude)

To learn more about querying documents by physical location, check out this solution guide .

Перечисления

Enums are probably one of the most underrated language features in Swift; there's much more to them than meets the eye. A common use case for enums is to model the discrete states of something. For example, we might be writing an app for managing articles. To track the status of an article, we might want to use an enum Status :

enum Status: String, Codable {
  case draft
  case inReview
  case approved
  case published
}

Cloud Firestore doesn't support enums natively (ie, it cannot enforce the set of values), but we can still make use of the fact that enums can be typed, and choose a codable type. In this example, we've chosen String , which means all enum values will be mapped to/from string when stored in a Cloud Firestore document.

And, since Swift supports custom raw values, we can even customize which values refer to which enum case. So for example, if we decided to store the Status.inReview case as "in review", we could just update the above enum as follows:

enum Status: String, Codable {
  case draft
  case inReview = "in review"
  case approved
  case published
}

Customizing the mapping

Sometimes, the attribute names of the Cloud Firestore documents we want to map don't match up with the names of the properties in our data model in Swift. For example, one of our coworkers might be a Python developer, and decided to choose snake_case for all their attribute names.

Not to worry: Codable has us covered!

For cases like these, we can make use of CodingKeys . This is an enum we can add to a codable struct to specify how certain attributes will be mapped.

Consider this document:

A Firestore document with a snake_cased attribute name

To map this document to a struct that has a name property of type String , we need to add a CodingKeys enum to the ProgrammingLanguage struct, and specify the name of the attribute in the document:

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
  }
}

By default, the Codable API will use the property names of our Swift types to determine the attribute names on the Cloud Firestore documents we're trying to map. So as long as the attribute names match, there is no need to add CodingKeys to our codable types. However, once we use CodingKeys for a specific type, we need to add all property names we want to map.

In the code snippet above, we've defined an id property which we might want to use as the identifier in a SwiftUI List view. If we didn't specify it in CodingKeys , it wouldn't be mapped when fetching data, and thus become nil . This would result in the List view being filled with the first document.

Any property that is not listed as a case on the respective CodingKeys enum will be ignored during the mapping process. This can actually be convenient if we specifically want to exclude some of the properties from being mapped.

So for example, if we want to exclude the reasonWhyILoveThis property from being mapped, all we need to do is to remove it from the CodingKeys enum:

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
  }
}

Occasionally we might want to write an empty attribute back into the Cloud Firestore document. Swift has the notion of optionals to denote the absence of a value, and Cloud Firestore supports null values as well. However, the default behavior for encoding optionals that have a nil value is to just omit them. @ExplicitNull gives us some control over how Swift optionals are handled when encoding them: by flagging an optional property as @ExplicitNull , we can tell Cloud Firestore to write this property to the document with a null value if it contains a value of nil .

Using a custom encoder and decoder for mapping colors

As a last topic in our coverage of mapping data with Codable, let's introduce custom encoders and decoders. This section doesn't cover a native Cloud Firestore datatype, but custom encoders and decoders are widely useful in your Cloud Firestore apps.

"How can I map colors" is one of the most frequently asked developer questions, not only for Cloud Firestore , but for mapping between Swift and JSON as well. There are plenty of solutions out there, but most of them focus on JSON, and almost all of them map colors as a nested dictionary composed of its RGB components.

It seems there should be a better, simpler solution. Why don't we use web colors (or, to be more specific, CSS hex color notation) — they're easy to use (essentially just a string), and they even support transparency!

To be able to map a Swift Color to its hex value, we need to create a Swift extension that adds Codable to 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)
  }

}

By using decoder.singleValueContainer() , we can decode a String to its Color equivalent, without having to nest the RGBA components. Plus, you can use these values in the web UI of your app, without having to convert them first!

With this, we can update code for mapping tags, making it easier to handle the tag colors directly instead of having to map them manually in our app's UI code:

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]
}

Обработка ошибок

In the above code snippets we intentionally kept error handling at a minimum, but in a production app, you'll want to make sure to gracefully handle any errors.

Here is a code snippet that shows how to use handle any error situations you might run into:

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)"
        }
      }
    }
  }
}

Handling errors in live updates

The previous code snippet demonstrates how to handle errors when fetching a single document. In addition to fetching data once, Cloud Firestore also supports delivering updates to your app as they happen, using so-called snapshot listeners: we can register a snapshot listener on a collection (or query), and Cloud Firestore will call our listener whenever there is an update.

Here is a code snippet that shows how to register a snapshot listener, map data using Codable, and handle any errors that might occur. It also shows how to add a new document to the collection. As you will see, there is no need to update the local array holding the mapped documents ourselves, as this is taken care of by the code in the snapshot listener.

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)
    }
  }
}

All code snippets used in this post are part of a sample application that you can download from this GitHub repository .

Go forth and use Codable!

Swift's Codable API provides a powerful and flexible way to map data from serialized formats to and from your applications data model. In this guide, you saw how easy it is to use in apps that use Cloud Firestore as their datastore.

Starting from a basic example with simple data types, we progressively increased the complexity of the data model, all the while being able to rely on Codable and Firebase's implementation to perform the mapping for us.

For more details about Codable, I recommend the following resources:

Although we did our best to compile a comprehensive guide for mapping Cloud Firestore documents, this is not exhaustive, and you might be using other strategies to map your types. Using the Send feedback button below, let us know what strategies you use for mapping other types of Cloud Firestore data or representing data in Swift.

There really is no reason for not using Cloud Firestore 's Codable support.