מיפוי נתונים של Cloud Firestore באמצעות Swift Codable

ה-Codable API של Swift, שהוצג ב-Swift 4, מאפשר לנו לנצל את העוצמה של המהדר כדי להקל על מיפוי נתונים מפורמטים בסריאליזציה לסוגי Swift.

יכול להיות שהשתמשתם ב-Codable כדי למפות נתונים מ-Web API למודל הנתונים של האפליקציה (ולהפך), אבל האפשרויות של Codable רחבות הרבה יותר.

במדריך הזה נסביר איך משתמשים ב-Codable כדי למפות נתונים מ-Cloud Firestore לסוגי Swift ולהפך.

כשאחזור מסמך מ-Cloud Firestore, האפליקציה תקבל מילון של צמדי מפתח/ערך (או מערך של מילונים, אם משתמשים באחת מהפעולות שמחזירות כמה מסמכים).

כמובן שעדיין אפשר להשתמש ישירות במילונים ב-Swift, והם מציעים גמישות רבה שעשויה להתאים בדיוק למקרה לדוגמה שלכם. עם זאת, הגישה הזו לא בטוחה מבחינת סוגים, וקלה להכניס באגים שקשה לאתר בגלל שגיאות איות בשמות המאפיינים, או בגלל ששכחתם למפות את המאפיין החדש שהצוות הוסיף כשהשיק את התכונה החדשה והמרגשת בשבוע שעבר.

בעבר, מפתחים רבים עקפו את החסרונות האלה באמצעות הטמעת שכבת מיפוי פשוטה שאפשרה להם למפות מילונים לסוגי Swift. אבל שוב, רוב ההטמעות האלה מבוססות על ציון ידני של המיפוי בין מסמכי Cloud Firestore לבין הסוגים התואמים של מודל הנתונים של האפליקציה.

בעזרת התמיכה של Cloud Firestore ב-Codable API של Swift, העבודה הזו הופכת להיות הרבה יותר קלה:

  • לא תצטרכו יותר להטמיע קוד מיפוי באופן ידני.
  • קל להגדיר איך למפות מאפיינים עם שמות שונים.
  • יש לו תמיכה מובנית בהרבה מהסוגים של 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)
      }
    }
  }
}

אומנם נראה שקל ליישם את הקוד הזה, אבל הוא שביר, קשה לתחזק אותו וסביר להניח שיופיעו בו שגיאות.

כפי שאפשר לראות, אנחנו מבססים הנחות לגבי סוגי הנתונים של שדות המסמך. יכול להיות שהנתונים האלה נכונים ויכול להיות שלא.

חשוב לזכור: מכיוון שאין סכימה, אפשר להוסיף בקלות מסמך חדש לאוסף ולבחור סוג שונה לשדה. יכול להיות שתבחרו בטעות באפשרות string לשדה 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

כשכותבים את ה-struct התואם ב-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]
}

הערה קצרה על מיפוי מזהי מסמכים

לפני שנמשיך למיפוי סוגים נוספים, נדבר רגע על מיפוי מזהי מסמכים.

השתמשנו ב-wrapper של המאפיין @DocumentID בחלק מהדוגמאות הקודמות כדי למפות את מזהה המסמך של המסמכים מסוג Cloud Firestore למאפיין id של סוגי Swift שלנו. יש לכך כמה סיבות:

  • כך נדע איזה מסמך לעדכן במקרה שהמשתמש יבצע שינויים מקומיים.
  • ב-List של SwiftUI, הרכיבים צריכים להיות Identifiable כדי למנוע מרכיבים לקפוץ לכל מקום כשהם מוכנסים.

חשוב לציין שמאפיין שמסומן כ-@DocumentID לא יקודר על ידי המקודד של Cloud Firestore כשכותבים את המסמך חזרה. הסיבה לכך היא שמזהה המסמך הוא לא מאפיין של המסמך עצמו, ולכן כתיבת המזהה במסמך תהיה שגיאה.

כשעובדים עם סוגים בתצוגת עץ (כמו מערך התגים ב-Book בדוגמה הקודמת במדריך הזה), אין צורך להוסיף את הנכס @DocumentID: נכסים בתצוגת עץ הם חלק מהמסמך Cloud Firestore, והם לא מהווים מסמך נפרד. לכן, אין צורך במזהה מסמך.

תאריכים ושעות

ל-Cloud Firestore יש סוג נתונים מובנה לטיפול בתאריכים ובשעות, ותוכלו להשתמש בו בקלות בזכות התמיכה של Cloud Firestore ב-Codable.

נעיף מבט במסמך הזה, שמייצג את אמא של כל שפות התכנות, Ada, שנוצרה בשנת 1843:

אחסון תאריכים במסמך Firestore

סוג Swift למיפוי המסמך הזה עשוי להיראות כך:

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

אי אפשר לסיים את הקטע הזה על תאריכים ושעות בלי לדבר על @ServerTimestamp. ה-wrapper של המאפיין הזה הוא כלי יעיל מאוד לטיפול בחותמות זמן באפליקציה.

בכל מערכת מבוזרת, סביר להניח שהשעונים במערכות השונות לא מסונכרנים לחלוטין כל הזמן. אולי זה נשמע לכם לא משמעותי, אבל נסו לדמיין את ההשלכות של שעון שפועל קצת לא מסונכרן במערכת למסחר במניות: אפילו סטייה של אלפית שנייה עלולה לגרום להבדל של מיליוני דולרים בביצוע עסקה.

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)

מידע נוסף על שליחת שאילתות למסמכים לפי מיקום פיזי זמין במדריך הפתרון הזה.

טיפוסים בני מנייה (enum)

Enums הם כנראה אחת מתכונות השפה הכי לא מוערכות ב-Swift. יש בהן הרבה יותר ממה שנראה במבט ראשון. תרחיש לדוגמה נפוץ לשימוש בממשקי enum הוא בניית מודל למצבים הדיסקרטיים של משהו. לדוגמה, יכול להיות שאנחנו כותבים אפליקציה לניהול מאמרים. כדי לעקוב אחרי הסטטוס של מאמר, אפשר להשתמש ב-enum Status:

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

Cloud Firestore לא תומך ב-enums באופן מקורי (כלומר, הוא לא יכול לאכוף את קבוצת הערכים), אבל עדיין אפשר להשתמש בעובדה שאפשר להקליד enums ולבחור סוג שניתן לקידוד. בדוגמה הזו בחרנו ב-String, כלומר כל ערכי המאפיין המסווג ימופו מחרוזת אליה או ממנה כשהם יישמרו במסמך Cloud Firestore.

בנוסף, מכיוון ש-Swift תומכת בערכים גולמיים מותאמים אישית, אפשר גם להתאים אישית את הערכים שמייצגים כל מקרה של enum. לדוגמה, אם החלטנו לאחסן את הפנייה Status.inReview בתור 'בבדיקה', נוכל פשוט לעדכן את המאפיין enum שלמעלה באופן הבא:

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

התאמה אישית של המיפוי

לפעמים שמות המאפיינים של מסמכי Cloud Firestore שאנחנו רוצים למפות לא תואמים לשמות המאפיינים במודל הנתונים שלנו ב-Swift. לדוגמה, יכול להיות שאחד מעמיתינו הוא מפתח Python והחליט לבחור ב-snake_case לכל שמות המאפיינים שלו.

אל דאגה: Codable תעזור לכם.

במקרים כאלה, אנחנו יכולים להשתמש ב-CodingKeys. זהו enum שאפשר להוסיף ל-struct שניתן לקידוד כדי לציין איך יתבצע המיפוי של מאפיינים מסוימים.

כדאי לעיין במסמך הזה:

מסמך Firestore עם שם מאפיין באותיות רישיות

כדי למפות את המסמך הזה ל-struct שיש לו מאפיין name מסוג String, צריך להוסיף למבנה ProgrammingLanguage את המאפיין enum מסוג 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 שיכול לשמש בתור המזהה בתצוגה List של SwiftUI. אם לא ציינו אותו ב-CodingKeys, הוא לא ימופה בזמן אחזור הנתונים, ולכן יהפוך ל-nil. כתוצאה מכך, התצוגה List תתמלא במסמך הראשון.

כל מאפיין שלא מופיע כמקרה במערך הערכים (enum) התואם של 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 יקרא למאזין שלנו בכל פעם שיש עדכון.

לפניכם קטע קוד שמראה איך לרשום מאזין של snapshot, למפות נתונים באמצעות Codable ולטפל בשגיאות שעשויות להתרחש. בנוסף, מוסבר איך להוסיף מסמך חדש לאוסף. כפי שרואים, אין צורך לעדכן בעצמנו את המערך המקומי שמכיל את המסמכים הממופים, כי הקוד ב-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)
    }
  }
}

כל קטעי הקוד שנעשה בהם שימוש בפוסט הזה הם חלק מאפליקציה לדוגמה שאפשר להוריד ממאגר ה-GitHub הזה.

קדימה, אפשר להשתמש ב-Codable!

ה-API של Codable ב-Swift מספק דרך חזקה וגמישה למיפוי נתונים מפורמטים בסריאליזציה אל מודל הנתונים של האפליקציות וממנו. במדריך הזה ראינו כמה קל להשתמש ב-Cloud Firestore באפליקציות שמשתמשות בו כמאגר נתונים.

התחלנו מדוגמה בסיסית עם סוגי נתונים פשוטים, והוספנו בהדרגה את המורכבות של מודל הנתונים, תוך כדי שאנחנו יכולים להסתמך על ההטמעה של Codable ו-Firebase כדי לבצע את המיפוי בשבילנו.

לפרטים נוספים על Codable, מומלץ לעיין במקורות המידע הבאים:

עשינו כמיטב יכולתנו כדי להכין מדריך מקיף למיפוי מסמכי Cloud Firestore, אבל הוא לא מקיף, ויכול להיות שאתם משתמשים באסטרטגיות אחרות למיפוי הסוגים שלכם. אתם יכולים להשתמש בלחצן שליחת משוב שבהמשך כדי לספר לנו אילו שיטות אתם משתמשים בהן למיפוי של סוגים אחרים של נתוני Cloud Firestore או לייצוג נתונים ב-Swift.

אין סיבה לא להשתמש בתמיכה של Cloud Firestore ב-Codable.