Cloud Firestore-iOS-Codelab

1. Übersicht

Ziele

In diesem Codelab erstellen Sie eine Firestore-gestützte App für Restaurantempfehlungen auf iOS in Swift. Nach Abschluss können Sie:

  1. Daten aus einer iOS-App in Firestore lesen und schreiben
  2. Änderungen an Firestore-Daten in Echtzeit überwachen
  3. Firebase Authentication und Sicherheitsregeln zum Schutz von Firestore-Daten verwenden
  4. Komplexe Firestore-Abfragen schreiben

Vorbereitung

Bevor Sie mit diesem Codelab beginnen, müssen Sie Folgendes installiert haben:

  • Xcode Version 14.0 (oder höher)
  • CocoaPods 1.12.0 (oder höher)

2. Firebase Console-Projekt erstellen

Firebase zum Projekt hinzufügen

  1. Rufen Sie die Firebase Console auf.
  2. Wählen Sie Neues Projekt erstellen aus und geben Sie als Projektnamen „Firestore iOS Codelab“ ein.

3. Beispielprojekt abrufen

Code herunterladen

Klonen Sie zuerst das Beispielprojekt und führen Sie pod update im Projektverzeichnis aus:

git clone https://github.com/firebase/friendlyeats-ios
cd friendlyeats-ios
pod update

Öffnen Sie FriendlyEats.xcworkspace in Xcode und führen Sie es aus (Befehlstaste + R). Die App sollte korrekt kompiliert werden und beim Start sofort abstürzen, da eine GoogleService-Info.plist-Datei fehlt. Das korrigieren wir im nächsten Schritt.

Firebase einrichten

Folgen Sie der Anleitung, um ein neues Firestore-Projekt zu erstellen. Laden Sie die GoogleService-Info.plist-Datei Ihres Projekts aus der Firebase Console herunter und ziehen Sie sie in das Stammverzeichnis des Xcode-Projekts. Führen Sie das Projekt noch einmal aus, um sicherzustellen, dass die App richtig konfiguriert ist und beim Start nicht mehr abstürzt. Nach der Anmeldung sollte ein leerer Bildschirm wie im Beispiel unten angezeigt werden. Wenn Sie sich nicht anmelden können, prüfen Sie, ob Sie die Anmeldemethode „E-Mail/Passwort“ in der Firebase Console unter „Authentifizierung“ aktiviert haben.

d5225270159c040b.png

4. Daten in Firestore schreiben

In diesem Abschnitt schreiben wir Daten in Firestore, damit wir die Anwendungs-UI darstellen können. Das geht manuell über die Firebase Console, doch wir tun dies direkt in der App, um Grundlagen des Schreibens in Firestore zu demonstrieren.

Das Hauptmodellobjekt in unserer App ist ein Restaurant. Firestore-Daten werden in Dokumente, Sammlungen und untergeordnete Sammlungen aufgeteilt. Wir speichern jedes Restaurant als Dokument in einer Sammlung auf oberster Ebene namens restaurants. Weitere Informationen zum Firestore-Datenmodell finden Sie in den Dokumenten und Sammlungen der Dokumentation.

Bevor wir Firestore Daten hinzufügen können, müssen wir einen Verweis auf die Sammlung „restaurants“ abrufen. Fügen Sie der inneren For-Schleife in der RestaurantsTableViewController.didTapPopulateButton(_:)-Methode Folgendes hinzu:

let collection = Firestore.firestore().collection("restaurants")

Da wir nun eine Sammlungsreferenz haben, können wir einige Daten schreiben. Fügen Sie direkt nach der letzten Codezeile, die wir hinzugefügt haben, Folgendes ein:

let collection = Firestore.firestore().collection("restaurants")

// ====== ADD THIS ======
let restaurant = Restaurant(
  name: name,
  category: category,
  city: city,
  price: price,
  ratingCount: 0,
  averageRating: 0
)

collection.addDocument(data: restaurant.dictionary)

Mit dem Code oben wird der Sammlung „restaurants“ ein neues Dokument hinzugefügt. Die Dokumentdaten stammen aus einem Wörterbuch, das wir aus einer Restaurantstruktur abrufen.

Wir sind fast am Ziel. Bevor wir Dokumente in Firestore schreiben können, müssen wir die Sicherheitsregeln von Firestore öffnen und beschreiben, welche Teile unserer Datenbank von welchen Nutzern beschreibbar sein sollen. Derzeit können nur authentifizierte Nutzer die gesamte Datenbank lesen und darauf schreiben. Das ist für eine Produktions-App etwas zu locker, aber während des App-Entwicklungsprozesses möchten wir eine lockere Einstellung, damit wir bei Tests nicht ständig auf Authentifizierungsprobleme stoßen. Am Ende dieses Codelabs erfahren Sie, wie Sie Ihre Sicherheitsregeln verschärfen und die Wahrscheinlichkeit unbeabsichtigter Lese- und Schreibvorgänge einschränken können.

Fügen Sie in der Firebase Console auf dem Tab Regeln die folgenden Regeln hinzu und klicken Sie dann auf Veröffentlichen.

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      //
      // WARNING: These rules are insecure! We will replace them with
      // more secure rules later in the codelab
      //
      allow read, write: if request.auth != null;
    }
  }
}

Wir werden später noch ausführlich auf Sicherheitsregeln eingehen. Wenn Sie es aber eilig haben, können Sie sich die Dokumentation zu Sicherheitsregeln ansehen.

Starten Sie die App und melden Sie sich an. Tippen Sie dann links oben auf die Schaltfläche Populate (Befüllen). Dadurch werden mehrere Restaurantdokumente erstellt, die in der App aber noch nicht angezeigt werden.

Rufen Sie als Nächstes in der Firebase Console den Tab Firestore-Daten auf. Sie sollten jetzt neue Einträge in der Sammlung „restaurants“ sehen:

Screen Shot 2017-07-06 at 12.45.38 PM.png

Herzlichen Glückwunsch! Sie haben gerade Daten aus einer iOS-App in Firestore geschrieben. Im nächsten Abschnitt erfahren Sie, wie Sie Daten aus Firestore abrufen und in der App anzeigen.

5. Daten aus Firestore anzeigen

In diesem Abschnitt erfahren Sie, wie Sie Daten aus Firestore abrufen und in der App anzeigen. Die beiden wichtigsten Schritte sind das Erstellen einer Abfrage und das Hinzufügen eines Snapshot-Listeners. Dieser Listener wird über alle vorhandenen Daten informiert, die mit der Abfrage übereinstimmen, und erhält Aktualisierungen in Echtzeit.

Erstellen wir zunächst die Abfrage, die die standardmäßige, ungefilterte Liste von Restaurants liefert. Sehen Sie sich die Implementierung von RestaurantsTableViewController.baseQuery() an:

return Firestore.firestore().collection("restaurants").limit(to: 50)

Mit dieser Abfrage werden bis zu 50 Restaurants aus der übergeordneten Sammlung „restaurants“ abgerufen. Da wir nun eine Abfrage haben, müssen Sie einen Snapshot-Listener anhängen, um Daten aus Firestore in unsere App zu laden. Fügen Sie der Methode RestaurantsTableViewController.observeQuery() direkt nach dem Aufruf von stopObserving() den folgenden Code hinzu.

listener = query.addSnapshotListener { [unowned self] (snapshot, error) in
  guard let snapshot = snapshot else {
    print("Error fetching snapshot results: \(error!)")
    return
  }
  let models = snapshot.documents.map { (document) -> Restaurant in
    if let model = Restaurant(dictionary: document.data()) {
      return model
    } else {
      // Don't use fatalError here in a real app.
      fatalError("Unable to initialize type \(Restaurant.self) with dictionary \(document.data())")
    }
  }
  self.restaurants = models
  self.documents = snapshot.documents

  if self.documents.count > 0 {
    self.tableView.backgroundView = nil
  } else {
    self.tableView.backgroundView = self.backgroundView
  }

  self.tableView.reloadData()
}

Der Code oben lädt die Sammlung aus Firestore herunter und speichert sie lokal in einem Array. Der addSnapshotListener(_:)-Aufruf fügt der Abfrage einen Snapshot-Listener hinzu, der den Ansichts-Controller jedes Mal aktualisiert, wenn sich die Daten auf dem Server ändern. Wir erhalten Updates automatisch und müssen Änderungen nicht manuell übertragen. Denken Sie daran, dass dieser Snapshot-Listener jederzeit aufgrund einer serverseitigen Änderung aufgerufen werden kann. Daher ist es wichtig, dass unsere App Änderungen verarbeiten kann.

Nachdem Sie unsere Wörterbücher Strukturen zugeordnet haben (siehe Restaurant.swift), müssen nur einige Ansichtseigenschaften zugewiesen werden, um die Daten anzuzeigen. Fügen Sie RestaurantTableViewCell.populate(restaurant:) in RestaurantsTableViewController.swift die folgenden Zeilen hinzu.

nameLabel.text = restaurant.name
cityLabel.text = restaurant.city
categoryLabel.text = restaurant.category
starsView.rating = Int(restaurant.averageRating.rounded())
priceLabel.text = priceString(from: restaurant.price)

Diese Methode zum Ausfüllen wird von der tableView(_:cellForRowAtIndexPath:)-Methode der Datenquelle der Tabelle aufgerufen. Diese Methode ordnet die Sammlung der Werttypen von vorhin den einzelnen Zellen der Tabelle zu.

Führen Sie die App noch einmal aus und prüfen Sie, ob die Restaurants, die wir zuvor in der Konsole gesehen haben, jetzt im Simulator oder auf dem Gerät angezeigt werden. Wenn Sie diesen Abschnitt erfolgreich abgeschlossen haben, liest und schreibt Ihre App jetzt Daten mit Cloud Firestore.

391c0259bf05ac25.png

6. Daten sortieren und filtern

Derzeit wird in unserer App eine Liste von Restaurants angezeigt, aber die Nutzer können nicht nach ihren Bedürfnissen filtern. In diesem Abschnitt verwenden Sie die erweiterten Abfragen von Firestore, um das Filtern zu aktivieren.

Hier ein Beispiel für eine einfache Abfrage, mit der alle Dim-Sum-Restaurants abgerufen werden:

let filteredQuery = query.whereField("category", isEqualTo: "Dim Sum")

Wie der Name schon sagt, werden mit der Methode whereField(_:isEqualTo:) nur Mitglieder der Sammlung heruntergeladen, deren Felder die von uns festgelegten Einschränkungen erfüllen. In diesem Fall werden nur Restaurants heruntergeladen, bei denen category = "Dim Sum" ist.

In dieser App können Nutzer mehrere Filter kombinieren, um bestimmte Suchanfragen zu erstellen, z. B. „Pizza in San Francisco“ oder „Meeresfrüchte in Los Angeles nach Beliebtheit sortiert“.

Öffnen Sie RestaurantsTableViewController.swift und fügen Sie den folgenden Codeblock in die Mitte von query(withCategory:city:price:sortBy:) ein:

if let category = category, !category.isEmpty {
  filtered = filtered.whereField("category", isEqualTo: category)
}

if let city = city, !city.isEmpty {
  filtered = filtered.whereField("city", isEqualTo: city)
}

if let price = price {
  filtered = filtered.whereField("price", isEqualTo: price)
}

if let sortBy = sortBy, !sortBy.isEmpty {
  filtered = filtered.order(by: sortBy)
}

Im obigen Snippet werden mehrere whereField- und order-Klauseln hinzugefügt, um eine einzelne zusammengesetzte Abfrage basierend auf der Nutzereingabe zu erstellen. Jetzt werden in unserer Abfrage nur Restaurants zurückgegeben, die den Anforderungen des Nutzers entsprechen.

Führen Sie Ihr Projekt aus und prüfen Sie, ob Sie nach Preis, Ort und Kategorie filtern können. Achten Sie darauf, die Namen der Kategorien und Orte genau einzugeben. Während des Tests werden in Ihren Protokollen möglicherweise Fehler wie dieser angezeigt:

Error fetching snapshot results: Error Domain=io.grpc Code=9 
"The query requires an index. You can create it here: https://console.firebase.google.com/project/project-id/database/firestore/indexes?create_composite=..." 
UserInfo={NSLocalizedDescription=The query requires an index. You can create it here: https://console.firebase.google.com/project/project-id/database/firestore/indexes?create_composite=...}

Das liegt daran, dass Firestore für die meisten zusammengesetzten Abfragen Indexe benötigt. Durch die Indexierung von Abfragen bleibt Firestore auch bei großen Datenmengen schnell. Wenn Sie den Link in der Fehlermeldung öffnen, wird automatisch die Benutzeroberfläche zum Erstellen von Indexen in der Firebase Console geöffnet. Die richtigen Parameter sind bereits ausgefüllt. Weitere Informationen zu Indexen in Firestore finden Sie in der Dokumentation.

7. Daten in einer Transaktion schreiben

In diesem Abschnitt fügen wir die Möglichkeit für Nutzer hinzu, Rezensionen zu Restaurants zu senden. Bisher waren alle unsere Schreibvorgänge atomar und relativ einfach. Wenn bei einem dieser Schritte ein Fehler auftritt, wird der Nutzer wahrscheinlich aufgefordert, es noch einmal zu versuchen, oder es wird automatisch ein neuer Versuch gestartet.

Um einem Restaurant eine Bewertung hinzuzufügen, müssen mehrere Lese- und Schreibvorgänge koordiniert werden. Zuerst muss die Rezension selbst eingereicht werden. Danach müssen die Anzahl der Bewertungen und die durchschnittliche Bewertung des Restaurants aktualisiert werden. Wenn einer dieser Schritte fehlschlägt, der andere aber nicht, sind die Daten in einem Teil der Datenbank nicht mit denen in einem anderen Teil identisch.

Glücklicherweise bietet Firestore Transaktionsfunktionen, mit denen wir mehrere Lese- und Schreibvorgänge in einem einzigen atomaren Vorgang ausführen können, sodass unsere Daten konsistent bleiben.

Fügen Sie den folgenden Code unter allen let-Deklarationen in RestaurantDetailViewController.reviewController(_:didSubmitFormWithReview:) ein.

let firestore = Firestore.firestore()
firestore.runTransaction({ (transaction, errorPointer) -> Any? in

  // Read data from Firestore inside the transaction, so we don't accidentally
  // update using stale client data. Error if we're unable to read here.
  let restaurantSnapshot: DocumentSnapshot
  do {
    try restaurantSnapshot = transaction.getDocument(reference)
  } catch let error as NSError {
    errorPointer?.pointee = error
    return nil
  }

  // Error if the restaurant data in Firestore has somehow changed or is malformed.
  guard let data = restaurantSnapshot.data(),
        let restaurant = Restaurant(dictionary: data) else {

    let error = NSError(domain: "FireEatsErrorDomain", code: 0, userInfo: [
      NSLocalizedDescriptionKey: "Unable to write to restaurant at Firestore path: \(reference.path)"
    ])
    errorPointer?.pointee = error
    return nil
  }

  // Update the restaurant's rating and rating count and post the new review at the 
  // same time.
  let newAverage = (Float(restaurant.ratingCount) * restaurant.averageRating + Float(review.rating))
      / Float(restaurant.ratingCount + 1)

  transaction.setData(review.dictionary, forDocument: newReviewReference)
  transaction.updateData([
    "numRatings": restaurant.ratingCount + 1,
    "avgRating": newAverage
  ], forDocument: reference)
  return nil
}) { (object, error) in
  if let error = error {
    print(error)
  } else {
    // Pop the review controller on success
    if self.navigationController?.topViewController?.isKind(of: NewReviewViewController.self) ?? false {
      self.navigationController?.popViewController(animated: true)
    }
  }
}

Innerhalb des Update-Blocks werden alle Vorgänge, die wir mit dem Transaktionsobjekt ausführen, von Firestore als einzelne atomare Aktualisierung behandelt. Wenn das Update auf dem Server fehlschlägt, wird es von Firestore automatisch mehrmals wiederholt. Das bedeutet, dass unsere Fehlerbedingung höchstwahrscheinlich ein einzelner Fehler ist, der wiederholt auftritt, z. B. wenn das Gerät vollständig offline ist oder der Nutzer nicht berechtigt ist, in den Pfad zu schreiben, in den er schreiben möchte.

8. Sicherheitsregeln

Nutzer unserer App sollten nicht in der Lage sein, alle Daten in unserer Datenbank zu lesen und zu schreiben. So sollten beispielsweise alle Nutzer die Bewertungen eines Restaurants sehen können, aber nur authentifizierte Nutzer sollten eine Bewertung posten dürfen. Es reicht nicht aus, guten Code auf dem Client zu schreiben. Wir müssen unser Datensicherheitsmodell im Back-End angeben, um absolut sicher zu sein. In diesem Abschnitt erfahren Sie, wie Sie Firebase-Sicherheitsregeln zum Schutz unserer Daten verwenden.

Sehen wir uns zuerst die Sicherheitsregeln an, die wir zu Beginn des Codelabs geschrieben haben. Öffnen Sie die Firebase Console und gehen Sie auf dem Tab „Firestore“ zu Datenbank > Regeln.

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      // Only authenticated users can read or write data
      allow read, write: if request.auth != null;
    }
  }
}

Die Variable request in den obigen Regeln ist eine globale Variable, die in allen Regeln verfügbar ist. Mit der hinzugefügten Bedingung wird sichergestellt, dass die Anfrage authentifiziert wird, bevor Nutzer etwas tun können. Dadurch wird verhindert, dass nicht authentifizierte Nutzer die Firestore API verwenden, um nicht autorisierte Änderungen an Ihren Daten vorzunehmen. Das ist ein guter Anfang, aber mit Firestore-Regeln lassen sich wesentlich leistungsstärkere Aufgaben ausführen.

Schränken Sie die Schreibvorgänge für Rezensionen ein, sodass die Nutzer-ID der Rezension mit der des authentifizierten Nutzers übereinstimmen muss. So wird verhindert, dass Nutzer sich als untereinander ausgeben und keine betrügerischen Rezensionen hinterlassen. Ersetzen Sie Ihre Sicherheitsregeln durch Folgendes:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /restaurants/{any}/ratings/{rating} {
      // Users can only write ratings with their user ID
      allow read;
      allow write: if request.auth != null 
                   && request.auth.uid == request.resource.data.userId;
    }
  
    match /restaurants/{any} {
      // Only authenticated users can read or write data
      allow read, write: if request.auth != null;
    }
  }
}

Die erste Match-Anweisung stimmt mit der Untersammlung ratings eines beliebigen Dokuments überein, das zur Sammlung restaurants gehört. Die allow write-Bedingung verhindert dann, dass Rezensionen eingereicht werden, wenn die Nutzer-ID der Rezension nicht mit der des Nutzers übereinstimmt. Die zweite Match-Anweisung ermöglicht es jedem authentifizierten Nutzer, Restaurants zu lesen und in die Datenbank zu schreiben.

Das funktioniert sehr gut für unsere Rezensionen, da wir mithilfe von Sicherheitsregeln die implizite Garantie, die wir zuvor in unsere App aufgenommen haben, explizit festgeschrieben haben, dass Nutzer nur ihre eigenen Rezensionen verfassen können. Wenn wir eine Funktion zum Bearbeiten oder Löschen von Rezensionen hinzufügen würden, würden diese Regeln auch verhindern, dass Nutzer die Rezensionen anderer Nutzer ändern oder löschen. Firestore-Regeln können jedoch auch detaillierter verwendet werden, um Schreibvorgänge auf einzelne Felder innerhalb von Dokumenten zu beschränken, anstatt auf die gesamten Dokumente selbst. So können wir Nutzern ermöglichen, nur die Bewertungen, die durchschnittliche Bewertung und die Anzahl der Bewertungen für ein Restaurant zu aktualisieren. So können wir verhindern, dass böswillige Nutzer den Namen oder Standort eines Restaurants ändern.

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /restaurants/{restaurant} {
      match /ratings/{rating} {
        allow read: if request.auth != null;
        allow write: if request.auth != null 
                     && request.auth.uid == request.resource.data.userId;
      }
    
      allow read: if request.auth != null;
      allow create: if request.auth != null;
      allow update: if request.auth != null
                    && request.resource.data.name == resource.data.name
                    && request.resource.data.city == resource.data.city
                    && request.resource.data.price == resource.data.price
                    && request.resource.data.category == resource.data.category;
    }
  }
}

Hier haben wir die Schreibberechtigung in „Erstellen“ und „Aktualisieren“ unterteilt, damit wir genauer angeben können, welche Vorgänge zulässig sein sollen. Jeder Benutzer kann Restaurants in die Datenbank schreiben, wobei die Funktionalität der Schaltfläche „Ausfüllen“ beibehalten wird, die wir zu Beginn des Codelabs erstellt haben. Sobald ein Restaurant jedoch geschrieben wurde, können sein Name, Standort, Preis und Kategorie nicht mehr geändert werden. Konkret gilt für die letzte Regel, dass bei jeder Aktualisierung eines Restaurants Name, Ort, Preis und Kategorie der bereits vorhandenen Felder in der Datenbank unverändert bleiben müssen.

Weitere Informationen zu den Möglichkeiten mit Sicherheitsregeln finden Sie in der Dokumentation.

9. Fazit

In diesem Codelab haben Sie gelernt, wie Sie mit Firestore einfache und erweiterte Lese- und Schreibvorgänge ausführen und den Datenzugriff mit Sicherheitsregeln schützen. Die vollständige Lösung finden Sie im codelab-complete-Branch.

Weitere Informationen zu Firestore finden Sie in den folgenden Ressourcen: