Laboratorio de programación de iOS de Cloud Firestore

1. Información general

Objetivos

En este laboratorio de código, creará una aplicación de recomendación de restaurantes respaldada por Firestore en iOS en Swift. Aprenderás a:

  1. Leer y escribir datos en Firestore desde una aplicación de iOS
  2. Escuche los cambios en los datos de Firestore en tiempo real
  3. Utilice la autenticación de Firebase y las reglas de seguridad para proteger los datos de Firestore
  4. Escribir consultas complejas de Firestore

requisitos previos

Antes de comenzar este codelab, asegúrese de haber instalado:

  • Xcode versión 14.0 (o superior)
  • CocoaPods 1.12.0 (o superior)

2. Crear proyecto de consola de Firebase

Agregar Firebase al proyecto

  1. Ve a la consola de Firebase .
  2. Selecciona Crear nuevo proyecto y nombra tu proyecto "Firestore iOS Codelab".

3. Obtenga el proyecto de muestra

Descarga el Código

Comience clonando el proyecto de muestra y ejecutando pod update en el directorio del proyecto:

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

Abra FriendlyEats.xcworkspace en Xcode y ejecútelo (Cmd+R). La aplicación debería compilarse correctamente y fallar inmediatamente al iniciarse, ya que falta un archivo GoogleService-Info.plist . Lo corregiremos en el siguiente paso.

Configurar base de fuego

Siga la documentación para crear un nuevo proyecto de Firestore. Una vez que tenga su proyecto, descargue el archivo GoogleService-Info.plist de su proyecto desde la consola Firebase y arrástrelo a la raíz del proyecto Xcode. Vuelva a ejecutar el proyecto para asegurarse de que la aplicación se configure correctamente y ya no se bloquee al iniciarse. Después de iniciar sesión, debería ver una pantalla en blanco como el ejemplo a continuación. Si no puede iniciar sesión, asegúrese de haber habilitado el método de inicio de sesión con correo electrónico/contraseña en Firebase console en Autenticación.

d5225270159c040b.png

4. Escribir datos en Firestore

En esta sección, escribiremos algunos datos en Firestore para que podamos completar la interfaz de usuario de la aplicación. Esto se puede hacer manualmente a través de la consola de Firebase , pero lo haremos en la propia aplicación para demostrar una escritura básica de Firestore.

El objeto principal del modelo en nuestra aplicación es un restaurante. Los datos de Firestore se dividen en documentos, colecciones y subcolecciones. Almacenaremos cada restaurante como un documento en una colección de nivel superior llamada restaurants . Si desea obtener más información sobre el modelo de datos de Firestore, lea sobre documentos y colecciones en la documentación .

Antes de que podamos agregar datos a Firestore, debemos obtener una referencia a la colección de restaurantes. Agregue lo siguiente al bucle for interno en el método RestaurantsTableViewController.didTapPopulateButton(_:) .

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

Ahora que tenemos una referencia de colección, podemos escribir algunos datos. Agregue lo siguiente justo después de la última línea de código que agregamos:

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)

El código anterior agrega un nuevo documento a la colección de restaurantes. Los datos del documento provienen de un diccionario, que obtenemos de una estructura Restaurant.

Ya casi llegamos: antes de que podamos escribir documentos en Firestore, debemos abrir las reglas de seguridad de Firestore y describir qué partes de nuestra base de datos deberían poder escribir qué usuarios. Por ahora, permitiremos que solo los usuarios autenticados lean y escriban en toda la base de datos. Esto es un poco demasiado permisivo para una aplicación de producción, pero durante el proceso de creación de la aplicación queremos algo lo suficientemente relajado para que no tengamos problemas de autenticación constantemente mientras experimentamos. Al final de este laboratorio de código, hablaremos sobre cómo fortalecer sus reglas de seguridad y limitar la posibilidad de lecturas y escrituras no deseadas.

En la pestaña Reglas de Firebase console, agregue las siguientes reglas y luego haga clic en Publicar .

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;
    }
  }
}

Analizaremos las reglas de seguridad en detalle más adelante, pero si tiene prisa, eche un vistazo a la documentación de las reglas de seguridad .

Ejecute la aplicación e inicie sesión. Luego toque el botón " Poblar " en la parte superior izquierda, que creará un lote de documentos de restaurante, aunque todavía no verá esto en la aplicación.

A continuación, vaya a la pestaña de datos de Firestore en la consola de Firebase. Ahora debería ver nuevas entradas en la colección de restaurantes:

Captura de pantalla 2017-07-06 a las 12.45.38 PM.png

¡Felicitaciones, acaba de escribir datos en Firestore desde una aplicación de iOS! En la siguiente sección, aprenderá cómo recuperar datos de Firestore y mostrarlos en la aplicación.

5. Mostrar datos de Firestore

En esta sección, aprenderá cómo recuperar datos de Firestore y mostrarlos en la aplicación. Los dos pasos clave son crear una consulta y agregar un detector de instantáneas. Este oyente será notificado de todos los datos existentes que coincidan con la consulta y recibirá actualizaciones en tiempo real.

Primero, construyamos la consulta que servirá la lista predeterminada de restaurantes sin filtrar. Echa un vistazo a la implementación de RestaurantsTableViewController.baseQuery() :

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

Esta consulta recupera hasta 50 restaurantes de la colección de nivel superior denominada "restaurantes". Ahora que tenemos una consulta, debemos adjuntar un detector de instantáneas para cargar datos de Firestore en nuestra aplicación. Agrega el siguiente código al método RestaurantsTableViewController.observeQuery() justo después de la llamada a stopObserving() .

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()
}

El código anterior descarga la colección de Firestore y la almacena en una matriz localmente. La llamada addSnapshotListener(_:) agrega un detector de instantáneas a la consulta que actualizará el controlador de vista cada vez que cambien los datos en el servidor. Recibimos actualizaciones automáticamente y no tenemos que enviar cambios manualmente. Recuerde, este detector de instantáneas se puede invocar en cualquier momento como resultado de un cambio del lado del servidor, por lo que es importante que nuestra aplicación pueda manejar los cambios.

Después de asignar nuestros diccionarios a estructuras (consulte Restaurant.swift ), mostrar los datos es solo cuestión de asignar algunas propiedades de vista. Agrega las siguientes líneas a RestaurantTableViewCell.populate(restaurant:) en RestaurantsTableViewController.swift .

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

Este método de llenado se llama desde el método tableView(_:cellForRowAtIndexPath:) de la fuente de datos de la vista de tabla, que se encarga de asignar la colección de tipos de valores anteriores a las celdas individuales de la vista de tabla.

Vuelva a ejecutar la aplicación y verifique que los restaurantes que vimos anteriormente en la consola ahora estén visibles en el simulador o dispositivo. Si completó esta sección con éxito, su aplicación ahora está leyendo y escribiendo datos con Cloud Firestore.

391c0259bf05ac25.png

6. Clasificación y filtrado de datos

Actualmente, nuestra aplicación muestra una lista de restaurantes, pero no hay forma de que el usuario filtre según sus necesidades. En esta sección, utilizará las consultas avanzadas de Firestore para habilitar el filtrado.

Aquí hay un ejemplo de una consulta simple para obtener todos los restaurantes Dim Sum:

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

Como su nombre lo indica, el método whereField(_:isEqualTo:) hará que nuestra consulta descargue solo los miembros de la colección cuyos campos cumplan con las restricciones que establezcamos. En este caso, solo descargará restaurantes donde category sea "Dim Sum" .

En esta aplicación, el usuario puede encadenar varios filtros para crear consultas específicas, como "Pizza en San Francisco" o "Mariscos en Los Ángeles ordenados por popularidad".

Abra RestaurantsTableViewController.swift y agregue el siguiente bloque de código en medio de query(withCategory:city:price:sortBy:) :

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)
}

El fragmento anterior agrega varias cláusulas whereField y order para crear una sola consulta compuesta basada en la entrada del usuario. Ahora nuestra consulta solo devolverá restaurantes que coincidan con los requisitos del usuario.

Ejecute su proyecto y verifique que puede filtrar por precio, ciudad y categoría (asegúrese de escribir exactamente los nombres de la categoría y la ciudad). Mientras prueba, puede ver errores en sus registros que se ven así:

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=...}

Esto se debe a que Firestore requiere índices para la mayoría de las consultas compuestas. Requerir índices en las consultas mantiene a Firestore rápido a escala. Al abrir el vínculo del mensaje de error, se abrirá automáticamente la interfaz de usuario de creación de índices en la consola de Firebase con los parámetros correctos completados. Para obtener más información sobre los índices en Firestore, visite la documentación .

7. Escribir datos en una transacción

En esta sección, agregaremos la posibilidad de que los usuarios envíen reseñas a los restaurantes. Hasta ahora, todas nuestras escrituras han sido atómicas y relativamente simples. Si alguno de ellos tiene un error, es probable que solo solicitemos al usuario que vuelva a intentarlo o que lo vuelva a intentar automáticamente.

Para agregar una calificación a un restaurante, necesitamos coordinar múltiples lecturas y escrituras. Primero se debe enviar la reseña en sí, y luego se debe actualizar el conteo de calificaciones del restaurante y la calificación promedio. Si uno de estos falla pero el otro no, nos quedamos en un estado inconsistente donde los datos en una parte de nuestra base de datos no coinciden con los datos en otra.

Afortunadamente, Firestore brinda una funcionalidad de transacción que nos permite realizar múltiples lecturas y escrituras en una sola operación atómica, lo que garantiza que nuestros datos permanezcan consistentes.

Agrega el siguiente código debajo de todas las declaraciones let en RestaurantDetailViewController.reviewController(_:didSubmitFormWithReview:) .

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)
    }
  }
}

Dentro del bloque de actualización, Firestore tratará todas las operaciones que realicemos con el objeto de transacción como una sola actualización atómica. Si la actualización falla en el servidor, Firestore la volverá a intentar automáticamente varias veces. Esto significa que lo más probable es que nuestra condición de error sea un único error que se produzca repetidamente, por ejemplo, si el dispositivo está completamente fuera de línea o si el usuario no está autorizado para escribir en la ruta en la que intenta hacerlo.

8. Normas de seguridad

Los usuarios de nuestra aplicación no deberían poder leer y escribir todos los datos de nuestra base de datos. Por ejemplo, todos deberían poder ver las calificaciones de un restaurante, pero solo un usuario autenticado debería poder publicar una calificación. No es suficiente escribir un buen código en el cliente, necesitamos especificar nuestro modelo de seguridad de datos en el backend para que sea completamente seguro. En esta sección, aprenderemos a usar las reglas de seguridad de Firebase para proteger nuestros datos.

Primero, echemos un vistazo más profundo a las reglas de seguridad que escribimos al comienzo del laboratorio de programación. Abra Firebase console y vaya a Base de datos > Reglas en la pestaña Firestore .

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;
    }
  }
}

La variable request en las reglas anteriores es una variable global disponible en todas las reglas, y el condicional que agregamos garantiza que la solicitud se autentique antes de permitir que los usuarios hagan algo. Esto evita que los usuarios no autenticados utilicen la API de Firestore para realizar cambios no autorizados en sus datos. Este es un buen comienzo, pero podemos usar las reglas de Firestore para hacer cosas mucho más poderosas.

Restrinjamos las escrituras de revisión para que la ID de usuario de la revisión coincida con la ID del usuario autenticado. Esto asegura que los usuarios no puedan hacerse pasar por otros y dejar reseñas fraudulentas. Reemplace sus reglas de seguridad con lo siguiente:

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;
    }
  }
}

La primera declaración de coincidencia coincide con la subcolección denominada ratings de cualquier documento que pertenezca a la colección restaurants . El allow write condicional evita que se envíe cualquier revisión si la ID de usuario de la revisión no coincide con la del usuario. La segunda declaración de coincidencia permite que cualquier usuario autenticado lea y escriba restaurantes en la base de datos.

Esto funciona muy bien para nuestras reseñas, ya que usamos reglas de seguridad para establecer explícitamente la garantía implícita que escribimos en nuestra aplicación anteriormente: que los usuarios solo pueden escribir sus propias reseñas. Si tuviéramos que agregar una función de edición o eliminación para las reseñas, este mismo conjunto exacto de reglas también evitaría que los usuarios modifiquen o eliminen las reseñas de otros usuarios. Pero las reglas de Firestore también se pueden usar de una manera más granular para limitar las escrituras en campos individuales dentro de los documentos en lugar de en los documentos completos. Podemos usar esto para permitir que los usuarios actualicen solo las calificaciones, la calificación promedio y la cantidad de calificaciones de un restaurante, eliminando la posibilidad de que un usuario malintencionado altere el nombre o la ubicación de un restaurante.

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;
    }
  }
}

Aquí hemos dividido nuestro permiso de escritura en crear y actualizar para que podamos ser más específicos sobre qué operaciones deben permitirse. Cualquier usuario puede escribir restaurantes en la base de datos, preservando la funcionalidad del botón Rellenar que hicimos al comienzo del laboratorio de código, pero una vez que se escribe un restaurante, su nombre, ubicación, precio y categoría no se pueden cambiar. Más específicamente, la última regla requiere que cualquier operación de actualización de restaurante mantenga el mismo nombre, ciudad, precio y categoría de los campos ya existentes en la base de datos.

Para obtener más información sobre lo que puede hacer con las reglas de seguridad, consulte la documentación .

9. Conclusión

En este laboratorio de código, aprendió a realizar lecturas y escrituras básicas y avanzadas con Firestore, así como a proteger el acceso a los datos con reglas de seguridad. Puede encontrar la solución completa en la rama codelab-complete .

Para obtener más información sobre Firestore, visite los siguientes recursos: