Codelab do Cloud Firestore para iOS

1. Visão geral

Metas

Neste codelab, você vai criar um app de recomendação de restaurantes com suporte do Firestore no iOS usando o Swift. Você aprenderá o seguinte:

  1. Ler e gravar dados no Firestore usando um app iOS
  2. Detectar mudanças nos dados do Firestore em tempo real
  3. Usar o Firebase Authentication e as regras de segurança para proteger os dados do Firestore
  4. Escrever consultas complexas do Firestore

Pré-requisitos

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

  • Xcode versão 14.0 (ou mais recente)
  • CocoaPods 1.12.0 (ou versões mais recentes)

2. Acessar o projeto de exemplo

Faça o download do código

Comece clonando o projeto de exemplo 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 (Cmd+R). O app precisa ser compilado corretamente e falhar imediatamente ao ser iniciado, já que está sem um arquivo GoogleService-Info.plist. Isso será corrigido na próxima etapa.

3. Configurar o Firebase

Criar um projeto do Firebase

  1. Faça login no console do Firebase usando sua Conta do Google.
  2. Clique no botão para criar um projeto e insira um nome (por exemplo, FriendlyEats).
  3. Clique em Continuar.
  4. Se solicitado, leia e aceite os Termos do Firebase e clique em Continuar.
  5. (Opcional) Ative a assistência de IA no console do Firebase (chamada de "Gemini no Firebase").
  6. Neste codelab, você não precisa do Google Analytics. Portanto, desative a opção do Google Analytics.
  7. Clique em Criar projeto, aguarde o provisionamento e clique em Continuar.

Conectar seu app ao Firebase

Crie um app iOS no novo projeto do Firebase.

Faça o download do arquivo GoogleService-Info.plist do projeto no Console do Firebase e arraste-o para a raiz do projeto Xcode. Execute o projeto novamente para garantir que o app seja configurado corretamente e não falhe mais na inicialização. Depois de fazer login, você vai ver uma tela em branco como o exemplo abaixo. Se não conseguir fazer login, verifique se você ativou 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, vamos gravar alguns dados no Firestore para preencher a interface do app. Isso pode ser feito manualmente no Console do Firebase, mas vamos fazer no próprio app para demonstrar uma gravação básica do Firestore.

O principal objeto de modelo no app é um restaurante. Os dados do Firestore são divididos em documentos, coleções e subcoleções. Vamos armazenar cada restaurante como um documento em uma coleção de nível superior chamada restaurants. Para saber mais sobre o modelo de dados do Firestore, acesse documentos e coleções na documentação.

Antes de adicionar dados ao Firestore, precisamos ter 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 gravar 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 é obtido de uma struct Restaurant.

Estamos quase lá. Antes de gravar documentos no Firestore, precisamos abrir as regras de segurança do Firestore e descrever quais partes do nosso banco de dados podem ser gravadas por quais usuários. Por enquanto, vamos permitir que apenas usuários autenticados leiam e gravem em todo o banco de dados. Isso é um pouco permissivo demais para um app de produção, mas, durante o processo de criação, queremos algo relaxado o suficiente para não encontrarmos problemas de autenticação constantemente enquanto fazemos testes. Ao final deste codelab, vamos falar sobre como reforçar as 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 /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;
    }
  }
}

Vamos discutir as regras de segurança em detalhes mais tarde, mas se você estiver com pressa, consulte a documentação sobre regras de segurança.

Execute o app e faça login. Em seguida, toque no botão Preencher no canto superior esquerdo, que vai criar um lote de documentos de restaurantes, embora isso ainda não apareça no app.

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

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

Parabéns! Você acabou de gravar dados no Firestore com um app iOS. Na próxima seção, você vai aprender a recuperar dados do Firestore e mostrá-los no app.

5. Mostrar dados do Firestore

Nesta seção, você vai aprender a recuperar dados do Firestore e mostrá-los no app. As duas etapas principais são criar uma consulta e adicionar um listener de snapshot. Esse listener será notificado de todos os dados existentes que correspondem à consulta e receberá atualizações em tempo real.

Primeiro, vamos criar a consulta que vai exibir a lista de restaurantes não filtrada padrão. Confira a implementação de RestaurantsTableViewController.baseQuery():

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

Essa consulta recupera até 50 restaurantes da coleção de nível superior chamada "restaurants". Agora que temos uma consulta, precisamos anexar um listener de snapshot para carregar dados do Firestore no nosso app. Adicione o seguinte código 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 em uma matriz localmente. A chamada addSnapshotListener(_:) adiciona um listener de snapshot à consulta que atualiza o controlador de visualização sempre que os dados mudam no servidor. Recebemos atualizações automaticamente e não precisamos enviar mudanças manualmente. Lembre-se de que esse listener de snapshot pode ser invocado a qualquer momento como resultado de uma mudança do lado do servidor. Por isso, é importante que nosso app possa lidar com mudanças.

Depois de mapear nossos dicionários para structs (consulte Restaurant.swift), mostrar os dados é apenas uma questão de atribuir algumas propriedades de visualização. Adicione as linhas abaixo 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)

Esse método de preenchimento é chamado do método tableView(_:cellForRowAtIndexPath:) da fonte de dados da visualização de tabela, que mapeia a coleção de tipos de valores de antes para as células individuais da visualização de tabela.

Execute o app novamente e verifique se os restaurantes que vimos antes no console agora estão visíveis no simulador ou dispositivo. Se você concluiu esta seção, isso significa que seu app está lendo e gravando dados junto ao Cloud Firestore.

391c0259bf05ac25.png

6. Como classificar e filtrar dados

Atualmente, o app exibe uma lista de restaurantes, mas não há como o usuário filtrar de acordo com as necessidades dele. Nesta seção, você vai usar a consulta avançada do Firestore para ativar a filtragem.

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

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

Como o nome sugere, o método whereField(_:isEqualTo:) faz com que a consulta baixe apenas os membros da coleção cujos campos atendam às restrições definidas. Nesse caso, ele só vai baixar restaurantes em que category é "Dim Sum".

Nesse app, o usuário pode encadear vários filtros para criar consultas específicas, como "Pizza em São Paulo" ou "Frutos do Mar no Rio de Janeiro ordenados por popularidade".

Abra RestaurantsTableViewController.swift e adicione o seguinte bloco de código ao 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 snippet acima adiciona várias cláusulas whereField e order para criar uma única consulta composta com base na entrada do usuário. Agora, a consulta retorna apenas restaurantes que atendem aos requisitos do usuário.

Execute o projeto e verifique se é possível filtrar por preço, cidade e categoria. Digite os nomes da categoria e da cidade exatamente como aparecem. Durante o teste, você pode encontrar erros nos registros semelhantes a este:

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 acontece porque o Firestore exige índices para a maioria das consultas compostas. A exigência de índices em consultas mantém o Firestore rápido em escala. Ao abrir o link pela mensagem de erro, a interface de criação do índice será aberta automaticamente no Console do Firebase com os parâmetros corretos preenchidos. Para saber mais sobre índices no Firestore, acesse a documentação.

7. Gravar dados em uma transação

Nesta seção, vamos adicionar um recurso para que os usuários avaliem os restaurantes. Até agora, todas as nossas gravações foram atômicas e relativamente simples. Se alguma apresentar erro, você provavelmente solicitará que o usuário tente novamente ou o app repetirá a tentativa de forma automática.

Para adicionar uma nota a um restaurante, precisamos coordenar várias leituras e gravações. Primeiro, a avaliação precisa ser enviada e, depois, a contagem e a média de notas do restaurante precisam ser atualizadas. Se uma delas falhar, mas a outra não, ficaremos em um estado inconsistente em que os dados de uma parte do banco de dados não correspondem aos da outra.

Felizmente, o Firestore oferece uma funcionalidade de transação que permite realizar várias leituras e gravações em uma única operação atômica, garantindo que os 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 de atualização, todas as operações feitas usando o objeto de transação serão tratadas como uma única atualização atômica pelo Firestore. Se a atualização falhar no servidor, o Firestore vai tentar de novo automaticamente algumas vezes. Isso significa que nossa condição de erro provavelmente é um único erro que ocorre repetidamente, por exemplo, se o dispositivo estiver completamente off-line 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 app não podem ler e gravar todos os dados no nosso banco de dados. Por exemplo, todos devem poder ver as classificações de um restaurante, mas apenas um usuário autenticado pode postar uma classificação. Não basta escrever um bom código no cliente. É preciso especificar nosso modelo de segurança de dados no back-end para ter total segurança. Nesta seção, vamos aprender a usar as regras de segurança do Firebase para proteger nossos dados.

Primeiro, vamos analisar melhor 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 /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 variável request nas regras é global e está disponível em todas elas. A condição que adicionamos garante que a solicitação seja autenticada antes de permitir que os usuários façam qualquer coisa. Isso impede que usuários não autenticados usem a API Firestore para fazer mudanças não autorizadas nos seus dados. Esse é um bom começo, mas podemos usar as regras do Firestore para fazer coisas muito mais poderosas.

Queremos restringir a escrita de avaliações para que o ID do usuário da avaliação corresponda ao ID do usuário autenticado. Isso garante que os usuários não possam se passar uns pelos outros e deixar avaliações fraudulentas.

A primeira instrução de correspondência corresponde à subcoleção chamada ratings de qualquer documento pertencente à coleção restaurants. A condição allow write impede o envio de uma avaliação se o ID do usuário não corresponder ao do usuário. A segunda instrução de correspondência permite que qualquer usuário autenticado leia e grave restaurantes no banco de dados.

Isso funciona muito bem para nossas avaliações, já que usamos regras de segurança para declarar explicitamente a garantia implícita que escrevemos no nosso app anteriormente: os usuários só podem escrever as próprias avaliações. Se adicionarmos uma função de edição ou exclusão de avaliações, esse mesmo conjunto de regras também vai impedir que os usuários modifiquem ou excluam as avaliações de outras pessoas. No entanto, as regras do Firestore também podem ser usadas de maneira mais granular para limitar gravações em campos individuais dentro de documentos, em vez dos 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, removendo a possibilidade de um usuário malicioso alterar o nome ou a localização de um 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 "create" e "update" para especificar melhor 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. No entanto, depois que um restaurante é gravado, o nome, a localização, o preço e a 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 as regras de segurança, consulte a documentação.

9. Conclusão

Neste codelab, você aprendeu a 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 na ramificação codelab-complete (link em inglês).

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