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

โปรดสังเกตว่าเรากำหนด Struct CoverImages สำหรับแผนที่ภาพปกในเอกสาร Cloud Firestore อย่างไร การทำเครื่องหมายพร็อพเพอร์ตี้หน้าปกในBookWithCoverImagesเป็น "ไม่บังคับ" จะช่วยให้เราจัดการกับความจริงที่ว่าเอกสารบางรายการอาจไม่มีแอตทริบิวต์หน้าปกได้

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

ในระบบแบบกระจาย นาฬิกาในแต่ละระบบอาจไม่ได้ซิงค์กันทั้งหมดตลอดเวลา คุณอาจคิดว่านี่ไม่ใช่เรื่องใหญ่ แต่ลองนึกถึงผลกระทบของนาฬิกาที่ทำงานไม่ตรงกันเล็กน้อยสำหรับระบบการซื้อขายหุ้น แม้แต่ความคลาดเคลื่อนเพียงไม่กี่มิลลิวินาทีก็อาจส่งผลให้เกิดความแตกต่างหลายล้านดอลลาร์เมื่อทำการซื้อขาย

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 เราจำเป็นต้องเพิ่ม CodingKeys enum ลงในโครงสร้าง 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 ระบบจะไม่แมปเมื่อดึงข้อมูล และ CodingKeys จะกลายเป็น 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 ในแอปที่ใช้ Cloud Firestore เป็นดาต้าสตोरนั้นง่ายเพียงใด

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

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

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

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