Codelab do Cloud Firestore para iOS

1. Visão Geral

Metas

Neste codelab, você criará um aplicativo de recomendação de restaurantes apoiado pelo Firestore para iOS em Swift. Você vai aprender como:

  1. Ler e gravar dados no Firestore a partir de um aplicativo iOS
  2. Ouça as alterações nos dados do Firestore em tempo real
  3. Use o Firebase Authentication e as regras de segurança para proteger os dados do Firestore
  4. Escreva consultas complexas do Firestore

Pré-requisitos

Antes de iniciar este codelab, verifique se você instalou:

  • Xcode versão 14.0 (ou superior)
  • CocoaPods 1.12.0 (ou superior)

2. Crie um projeto de console do Firebase

Adicione o Firebase ao projeto

  1. Vá para o console do Firebase .
  2. Selecione Criar novo projeto e nomeie seu projeto como "Firestore iOS Codelab".

3. Obtenha o projeto de amostra

Baixe o código

Comece clonando o projeto de amostra e executando pod update no diretório do projeto:

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

Abra FriendlyEats.xcworkspace no Xcode e execute-o (Cmd+R). O aplicativo deve ser compilado corretamente e travar imediatamente ao iniciar, pois está faltando um arquivo GoogleService-Info.plist . Corrigiremos isso na próxima etapa.

Configurar o Firebase

Siga a documentação para criar um novo projeto do Firestore. Depois de obter seu projeto, baixe o arquivo GoogleService-Info.plist do console do Firebase e arraste-o para a raiz do projeto Xcode. Execute o projeto novamente para garantir que o aplicativo esteja configurado corretamente e não trave mais na inicialização. Após o login, você deverá ver uma tela em branco como no exemplo abaixo. Se você não conseguir fazer login, certifique-se de ter ativado o método de login por e-mail/senha no console do Firebase em Autenticação.

d5225270159c040b.png

4. Gravar dados no Firestore

Nesta seção, gravaremos alguns dados no Firestore para que possamos preencher a IU do aplicativo. Isso pode ser feito manualmente por meio do console do Firebase , mas faremos isso no próprio aplicativo para demonstrar uma gravação básica do Firestore.

O principal objeto modelo em nosso aplicativo é um restaurante. Os dados do Firestore são divididos em documentos, coleções e subcoleções. Armazenaremos cada restaurante como um documento em uma coleção de nível superior chamada restaurants . Se quiser saber mais sobre o modelo de dados do Firestore, leia sobre documentos e coleções na documentação .

Antes de adicionarmos dados ao Firestore, precisamos obter uma referência à coleção de restaurantes. Adicione o seguinte ao loop for interno no método RestaurantsTableViewController.didTapPopulateButton(_:) .

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

Agora que temos uma referência de coleção podemos escrever alguns dados. Adicione o seguinte logo após a última linha de código que adicionamos:

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)

O código acima adiciona um novo documento à coleção de restaurantes. Os dados do documento vêm de um dicionário, que obtemos de uma estrutura Restaurant.

Estamos quase lá: antes de podermos gravar documentos no Firestore, precisamos abrir as regras de segurança do Firestore e descrever quais partes do nosso banco de dados devem poder ser gravadas por quais usuários. Por enquanto, permitiremos que apenas usuários autenticados leiam e gravem em todo o banco de dados. Isso é um pouco permissivo para um aplicativo de produção, mas durante o processo de construção do aplicativo queremos algo relaxado o suficiente para não termos problemas de autenticação constantemente durante os experimentos. No final deste codelab, falaremos sobre como fortalecer suas regras de segurança e limitar a possibilidade de leituras e gravações não intencionais.

Na guia Regras do console do Firebase, adicione as seguintes regras e clique em 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;
    }
  }
}

Discutiremos as regras de segurança em detalhes posteriormente, mas se você estiver com pressa, dê uma olhada na documentação das regras de segurança .

Execute o aplicativo e faça login. Em seguida, toque no botão “ Preencher ” no canto superior esquerdo, que criará um lote de documentos do restaurante, embora você ainda não veja isso no aplicativo.

Em seguida, navegue até a guia de dados do Firestore no console do Firebase. Agora você deve ver novas entradas na coleção de restaurantes:

Captura de tela 06/07/2017 às 12h45.38.png

Parabéns, você acabou de gravar dados no Firestore a partir de um aplicativo iOS! Na próxima seção, você aprenderá como recuperar dados do Firestore e exibi-los no aplicativo.

5. Exibir dados do Firestore

Nesta seção você aprenderá como recuperar dados do Firestore e exibi-los no aplicativo. As duas etapas principais são criar uma consulta e adicionar um ouvinte de instantâneo. Este ouvinte será notificado sobre todos os dados existentes que correspondam à consulta e receberá atualizações em tempo real.

Primeiro, vamos construir a consulta que servirá a lista padrão e não filtrada de restaurantes. Dê uma olhada na implementação de RestaurantsTableViewController.baseQuery() :

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

Esta consulta recupera até 50 restaurantes da coleção de nível superior denominada "restaurantes". Agora que temos uma consulta, precisamos anexar um ouvinte de snapshot para carregar dados do Firestore em nosso aplicativo. Adicione o código a seguir ao método RestaurantsTableViewController.observeQuery() logo após a chamada para 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()
}

O código acima baixa a coleção do Firestore e a armazena localmente em um array. A chamada addSnapshotListener(_:) adiciona um ouvinte de snapshot à consulta que atualizará o controlador de visualização sempre que os dados forem alterados no servidor. Recebemos atualizações automaticamente e não precisamos enviar alterações manualmente. Lembre-se de que esse ouvinte de snapshot pode ser invocado a qualquer momento como resultado de uma alteração no servidor, por isso é importante que nosso aplicativo possa lidar com as alterações.

Depois de mapear nossos dicionários para estruturas (consulte Restaurant.swift ), exibir os dados é apenas uma questão de atribuir algumas propriedades de visualização. Adicione as seguintes linhas a RestaurantTableViewCell.populate(restaurant:) em 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 populate é chamado a partir do método tableView(_:cellForRowAtIndexPath:) da fonte de dados de visualização de tabela, que se encarrega de mapear a coleção de tipos de valores anteriores para as células individuais da visualização de tabela.

Execute o aplicativo novamente e verifique se os restaurantes que vimos anteriormente no console agora estão visíveis no simulador ou dispositivo. Se você concluiu esta seção com sucesso, seu aplicativo agora está lendo e gravando dados com o Cloud Firestore!

391c0259bf05ac25.png

6. Classificação e filtragem de dados

Atualmente nosso aplicativo exibe uma lista de restaurantes, mas não há como o usuário filtrar com base em suas necessidades. Nesta seção, você usará as consultas avançadas do Firestore para ativar a filtragem.

Aqui está um exemplo de consulta simples para buscar todos os restaurantes Dim Sum:

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

Como o próprio nome indica, o método whereField(_:isEqualTo:) fará com que nossa consulta baixe apenas membros da coleção cujos campos atendam às restrições que definimos. Neste caso, só baixará restaurantes cuja category seja "Dim Sum" .

Neste aplicativo o usuário pode encadear vários filtros para criar consultas específicas, como “Pizza em São Francisco” ou “Frutos do mar em Los Angeles encomendados por popularidade”.

Abra RestaurantsTableViewController.swift e adicione o seguinte bloco de código no meio 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)
}

O trecho acima adiciona várias cláusulas whereField e order para criar uma única consulta composta com base na entrada do usuário. Agora nossa consulta retornará apenas restaurantes que atendam aos requisitos do usuário.

Execute seu projeto e verifique se você pode filtrar por preço, cidade e categoria (certifique-se de digitar exatamente os nomes da categoria e da cidade). Durante o teste, você poderá ver erros em seus registros parecidos com estes:

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

Isso ocorre porque o Firestore exige índices para a maioria das consultas compostas. A exigência de índices nas consultas mantém o Firestore rápido em escala. Abrir o link da mensagem de erro abrirá automaticamente a IU de criação de índice no console do Firebase com os parâmetros corretos preenchidos. Para saber mais sobre índices no Firestore, visite a documentação .

7. Gravando dados em uma transação

Nesta seção, adicionaremos a capacidade dos usuários enviarem avaliações sobre restaurantes. Até agora, todas as nossas escritas foram atômicas e relativamente simples. Se algum deles apresentar erro, provavelmente solicitaremos ao usuário que tente novamente ou tente novamente automaticamente.

Para adicionar uma classificação a um restaurante, precisamos coordenar múltiplas leituras e gravações. Primeiro, a avaliação em si deve ser enviada e, em seguida, a contagem de classificações e a classificação média do restaurante precisam ser atualizadas. Se um deles falhar, mas o outro não, ficaremos em um estado inconsistente, onde os dados de uma parte do nosso banco de dados não correspondem aos dados de outra.

Felizmente, o Firestore oferece funcionalidade de transação que nos permite realizar múltiplas leituras e gravações em uma única operação atômica, garantindo que nossos dados permaneçam consistentes.

Adicione o seguinte código abaixo de todas as declarações let em 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 do bloco update, todas as operações que fizermos usando o objeto transaction serão tratadas como uma única atualização atômica pelo Firestore. Se a atualização falhar no servidor, o Firestore tentará novamente algumas vezes automaticamente. Isso significa que nossa condição de erro é provavelmente um único erro que ocorre repetidamente, por exemplo, se o dispositivo estiver completamente offline ou se o usuário não estiver autorizado a gravar no caminho em que está tentando gravar.

8. Regras de segurança

Os usuários do nosso aplicativo não devem ser capazes de ler e gravar todos os dados do nosso banco de dados. Por exemplo, todos deveriam poder ver as classificações de um restaurante, mas apenas um usuário autenticado deveria ter permissão para publicar uma classificação. Não é suficiente escrever um bom código no cliente, precisamos especificar nosso modelo de segurança de dados no backend para ser completamente seguro. Nesta seção aprenderemos como usar as regras de segurança do Firebase para proteger nossos dados.

Primeiro, vamos analisar mais detalhadamente as regras de segurança que escrevemos no início do codelab. Abra o console do Firebase e navegue até Banco de dados > Regras na guia 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;
    }
  }
}

A variável request nas regras acima é uma variável global disponível em todas as regras, e a condicional que adicionamos garante que a solicitação seja autenticada antes de permitir que os usuários façam qualquer coisa. Isso evita que usuários não autenticados usem a API Firestore para fazer alterações não autorizadas nos seus dados. Este é um bom começo, mas podemos usar as regras do Firestore para fazer coisas muito mais poderosas.

Vamos restringir as gravações de revisão para que o ID do usuário da revisão corresponda ao ID do usuário autenticado. Isso garante que os usuários não possam se passar por outros e deixar comentários fraudulentos. Substitua suas regras de segurança pelas seguintes:

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

A primeira instrução de correspondência corresponde à subcoleção nomeada ratings de qualquer documento pertencente à coleção restaurants . A condição allow write evita que qualquer revisão seja enviada se o ID do usuário da revisão não corresponder ao do usuário. A segunda instrução match permite que qualquer usuário autenticado leia e grave restaurantes no banco de dados.

Isso funciona muito bem para nossas avaliações, pois usamos regras de segurança para declarar explicitamente a garantia implícita que escrevemos em nosso aplicativo anteriormente – que os usuários só podem escrever suas próprias avaliações. Se adicionarmos uma função de edição ou exclusão para avaliações, esse mesmo conjunto de regras também impediria que os usuários modificassem ou excluíssem as avaliações de outros usuários. Mas as regras do Firestore também podem ser usadas de maneira mais granular para limitar gravações em campos individuais nos documentos, em vez de nos próprios documentos inteiros. Podemos usar isso para permitir que os usuários atualizem apenas as classificações, a classificação média e o número de classificações de um restaurante, eliminando a possibilidade de um usuário mal-intencionado alterar o nome ou a localização do 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;
    }
  }
}

Aqui dividimos nossa permissão de gravação em criação e atualização para que possamos ser mais específicos sobre quais operações devem ser permitidas. Qualquer usuário pode gravar restaurantes no banco de dados, preservando a funcionalidade do botão Preencher que criamos no início do codelab, mas depois que um restaurante é escrito, seu nome, localização, preço e categoria não podem ser alterados. Mais especificamente, a última regra exige que qualquer operação de atualização de restaurante mantenha o mesmo nome, cidade, preço e categoria dos campos já existentes no banco de dados.

Para saber mais sobre o que você pode fazer com regras de segurança, dê uma olhada na documentação .

9. Conclusão

Neste codelab, você aprendeu como fazer leituras e gravações básicas e avançadas com o Firestore, além de proteger o acesso aos dados com regras de segurança. Você pode encontrar a solução completa no branch codelab-complete .

Para saber mais sobre o Firestore, visite os seguintes recursos: