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

ה-API של 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 ולהוסיף לו הערה באמצעות wrapper של המאפיין @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 מהמיפוי, כל מה שצריך לעשות הוא להסיר אותו מה-enum 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 יש גם תמיכה בשליחת עדכונים לאפליקציה בזמן שהם מתרחשים, באמצעות מאזיני תמונות מצב: אנחנו יכולים לרשום מקבץ תמונות מצב (snapshot) באוסף (או שאילתה), ו-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.