แมปข้อมูล 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)
      }
    }
  }
}

แม้ว่าจะดูเหมือนตรงไปตรงมาและใช้งานง่าย แต่โค้ดนี้มีความเปราะบาง ดูแลรักษายาก และเกิดข้อผิดพลาดได้ง่าย

คุณจะเห็นว่าเรากำลังตั้งสมมติฐานเกี่ยวกับประเภทข้อมูลในช่องเอกสาร ข้อมูลนี้อาจจะถูกต้องหรือไม่ถูกต้อง

โปรดทราบว่าเนื่องจากไม่มีสคีมา คุณจึงเพิ่มเอกสารใหม่ลงในคอลเล็กชันและเลือกประเภทอื่นสำหรับช่องได้อย่างง่ายดาย คุณอาจเลือกสตริงสําหรับช่อง numberOfPages โดยไม่ได้ตั้งใจ ซึ่งจะทําให้เกิดปัญหาการแมปที่พบได้ยาก นอกจากนี้ คุณจะต้องอัปเดตโค้ดการแมป เมื่อมีการเพิ่มฟิลด์ใหม่ ซึ่งเป็นเรื่องที่ยุ่งยาก

และอย่าลืมว่าเราไม่ได้ใช้ประโยชน์จากระบบประเภทที่มีประสิทธิภาพของ Swift ซึ่งรู้ประเภทที่ถูกต้องของพร็อพเพอร์ตี้แต่ละรายการของ Book

Codable คืออะไร

ตามเอกสารประกอบของ Apple ระบุว่า Codable คือ "ประเภทที่แปลงตัวเองเป็นการแสดงภายนอกและจากการแสดงภายนอกได้" ที่จริงแล้ว Codable คือตัวแปรแทนประเภทสำหรับโปรโตคอลที่โค้ดได้และถอดรหัสได้ เมื่อสอดคล้องกับประเภท 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

ขณะเขียนโครงสร้าง 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 การทำเครื่องหมายพร็อพเพอร์ตี้หน้าปกใน BookWithCoverImages เป็น "ไม่บังคับ" ช่วยให้เรารับมือกับข้อเท็จจริงที่ว่าเอกสารบางฉบับไม่มีแอตทริบิวต์ปกได้

หากสงสัยว่าทำไมไม่มีข้อมูลโค้ดสําหรับการดึงข้อมูลหรืออัปเดตข้อมูล คุณก็ไม่ต้องกังวลไป เนื่องจากไม่ต้องปรับโค้ดสําหรับการอ่านหรือเขียนจาก/ไปยัง Cloud Firestore: ทั้งหมดนี้ทํางานกับโค้ดที่เราเขียนไว้ในส่วนแรก

อาร์เรย์

บางครั้งเราต้องการจัดเก็บคอลเล็กชันค่าในเอกสาร ประเภทของหนังสือเป็นตัวอย่างที่ดี ตัวอย่างหนังสืออย่าง The Hitchhiker's Guide to the Galaxy อาจจัดอยู่ในหลายหมวดหมู่ เช่น "ไซไฟ" และ "ตลก" เช่น

การจัดเก็บอาร์เรย์ในเอกสาร 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 Wrapper พร็อพเพอร์ตี้ในตัวอย่างก่อนหน้านี้เพื่อจับคู่รหัสเอกสารของเอกสาร 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 และเราแมประหว่าง 2 ประเภทนี้ได้ด้วยการดำเนินการต่อไปนี้

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

ดูข้อมูลเพิ่มเติมเกี่ยวกับการค้นหาเอกสารตามสถานที่ตั้งจริงได้ในคู่มือโซลูชันนี้

Enums

อาจเป็นฟีเจอร์ภาษาที่ไม่ค่อยได้รับคำชื่นชมมากนักใน Swift เพราะมีความสามารถมากกว่าที่เห็น กรณีการใช้งานที่พบบ่อยสำหรับลิสต์แบบจำกัดคือการสร้างโมเดลสถานะแบบไม่ต่อเนื่องของสิ่งหนึ่ง เช่น เราอาจเขียนแอปสำหรับจัดการบทความ ในการติดตามสถานะของบทความ เราอาจใช้ enum Status

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

Cloud Firestore ไม่รองรับ enum ตั้งแต่ต้น (กล่าวคือ บังคับใช้ชุดค่าไม่ได้) แต่เรายังคงใช้ประโยชน์จากข้อเท็จจริงที่ว่า enum พิมพ์ได้และเลือกประเภทการเขียนโค้ดได้ ในตัวอย่างนี้ เราเลือก String ซึ่งหมายความว่าระบบจะแมปค่า Enum ทั้งหมดจาก/ไปยังสตริงเมื่อจัดเก็บไว้ในเอกสาร 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 ที่เราสามารถเพิ่มลงในโครงสร้างที่โค้ดได้เพื่อระบุวิธีแมปแอตทริบิวต์บางอย่าง

ลองดูเอกสารนี้

เอกสาร Firestore ที่มีชื่อแอตทริบิวต์เป็นรูปแบบ Snake Case

ในการแมปเอกสารนี้กับโครงสร้างที่มีพร็อพเพอร์ตี้ชื่อประเภท String เราต้องเพิ่ม enum 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
  }
}

โดยค่าเริ่มต้น Codable API จะใช้ชื่อพร็อพเพอร์ตี้ของประเภท Swift เพื่อกำหนดชื่อแอตทริบิวต์ในเอกสาร Cloud Firestore ที่เราพยายามแมป ดังนั้นตราบใดที่ชื่อแอตทริบิวต์ตรงกัน ก็ไม่จำเป็นต้องเพิ่ม CodingKeys ลงในประเภทที่โค้ดได้ อย่างไรก็ตาม เมื่อใช้ CodingKeys สําหรับประเภทหนึ่งๆ แล้ว เราจะต้องเพิ่มชื่อพร็อพเพอร์ตี้ทั้งหมดที่ต้องการแมป

ในข้อมูลโค้ดด้านบน เราได้กําหนดพร็อพเพอร์ตี้ id ซึ่งเราอาจต้องการใช้เป็นตัวระบุในมุมมอง List ของ SwiftUI หากเราไม่ได้ระบุ ใน CodingKeys ระบบจะไม่แมป URL ดังกล่าวเมื่อดึงข้อมูล จึงกลายเป็น nil ซึ่งจะทำให้มุมมอง List แสดงเอกสารฉบับแรก

ระบบจะไม่สนใจพร็อพเพอร์ตี้ที่ไม่ได้แสดงเป็นเคสใน CodingKeys enum ที่เกี่ยวข้องในระหว่างกระบวนการแมป ซึ่งวิธีนี้อาจสะดวกกว่าหากเราต้องยกเว้นที่พักบางแห่งไม่ให้แมป

ตัวอย่างเช่น หากต้องการยกเว้นการแมปพร็อพเพอร์ตี้ 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 กับค่าฐาน 16 เราต้องสร้างส่วนขยาย 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 นอกจากนี้ คุณยังใช้ค่าเหล่านี้ใน UI ของเว็บในแอปได้โดยไม่ต้องแปลงก่อน

การดำเนินการนี้ช่วยให้เราอัปเดตโค้ดสำหรับการแมปแท็กได้ ซึ่งทำให้จัดการสีแท็กโดยตรงได้ง่ายขึ้นโดยไม่ต้องแมปด้วยตนเองในโค้ด UI ของแอป

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 และจัดการข้อผิดพลาดที่อาจเกิดขึ้น รวมถึงแสดงวิธีเพิ่มเอกสารใหม่ลงในคอลเล็กชัน ดังที่คุณจะเห็น ไม่จำเป็นต้องอัปเดตอาร์เรย์ในเครื่องที่จัดเก็บเอกสารที่แมปไว้ด้วยตนเอง เนื่องจากโค้ดใน 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

Codable API ของ Swift เป็นวิธีที่มีประสิทธิภาพและยืดหยุ่นในการแมปข้อมูลจากรูปแบบที่แปลงเป็นอนุกรมไปยังและจากโมเดลข้อมูลแอปพลิเคชัน ในคู่มือนี้ คุณได้เห็นความง่ายในการใช้ในแอปที่ใช้ Cloud Firestore เป็นพื้นที่เก็บข้อมูล

เราเริ่มต้นจากตัวอย่างพื้นฐานที่มีประเภทข้อมูลง่ายๆ แล้วค่อยๆ เพิ่มความซับซ้อนของโมเดลข้อมูลไปเรื่อยๆ โดยอาศัย Codable และการใช้งาน Firebase เพื่อทำแมปให้เรา

เราขอแนะนำแหล่งข้อมูลต่อไปนี้สำหรับรายละเอียดเพิ่มเติมเกี่ยวกับ Codable

แม้ว่าเราจะพยายามอย่างสุดความสามารถเพื่อรวบรวมคู่มือที่ครอบคลุมสำหรับการแมปเอกสาร Cloud Firestore รายการ แต่ก็ไม่ได้ครอบคลุมทั้งหมด และคุณอาจใช้กลยุทธ์อื่นๆ เพื่อแมปประเภทของคุณ โปรดแจ้งให้เราทราบถึงกลยุทธ์ที่คุณใช้สำหรับการแมปข้อมูลประเภทอื่นๆ ของ Cloud Firestore หรือการแสดงข้อมูลใน Swift โดยใช้ปุ่มส่งความคิดเห็นด้านล่าง

คุณไม่มีเหตุผลใดๆ ที่จะไม่ใช้การสนับสนุนแบบโค้ดได้ของ Cloud Firestore