Codelab do Cloud Firestore para iOS

1. Visão geral

Gols

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

  1. Ler e gravar dados no Firestore a partir de um app iOS
  2. Detectar alterações 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. Criar 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 posterior)

2. Criar projeto do Console do Firebase

Adicionar o Firebase ao projeto

  1. Acesse o Console do Firebase.
  2. Selecione Criar novo projeto e nomeie seu projeto como "Codelab para iOS do Firestore".

3. Acesse o projeto de amostra

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-o (Cmd+R). O app vai ser compilado corretamente e falhar imediatamente na inicialização, já que não tem um arquivo GoogleService-Info.plist. Vamos corrigir isso na próxima etapa.

Configurar o Firebase

Siga a documentação para criar um novo projeto do Firestore. Quando tiver seu projeto, faça o download do arquivo GoogleService-Info.plist dele 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 após a inicialização. Depois de fazer login, você verá uma tela em branco, como a do exemplo abaixo. Se você não conseguir fazer login, verifique se ativou o método de login 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 faremos isso no próprio app para demonstrar uma gravação básica do Firestore.

O principal objeto do modelo no nosso app é 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 adicionar dados ao Firestore, precisamos de uma referência à coleção de restaurantes. Adicione o seguinte à repetição for interna 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 código abaixo 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 um 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 mais descontraído para não enfrentarmos problemas de autenticação constantemente durante os testes. Ao final deste codelab, vamos falar sobre como aumentar a proteção das 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 regras a seguir 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;
    }
  }
}

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

Execute o app e faça login. Em seguida, toque no botão Preencher no canto superior esquerdo. Isso criará um lote de documentos do restaurante. No entanto, essa opção ainda não está disponível no app.

Em seguida, navegue até a guia Dados do Firestore no Console do Firebase. Agora, você verá novas entradas na coleção "Restaurantes":

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

Parabéns, você acabou de gravar dados de um app iOS no Firestore. 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 é notificado sobre todos os dados existentes que correspondem à consulta e recebe atualizações em tempo real.

Primeiro, vamos construir a consulta que vai exibir a lista padrão 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 chamada "restaurantes". Agora que temos uma consulta, precisamos anexar um listener de snapshot para carregar dados do Firestore em nosso app. 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 faz o download da coleção do Firestore e a armazena em uma matriz localmente. A chamada addSnapshotListener(_:) adiciona um listener de snapshot à consulta, que vai atualizar o controlador de visualizações sempre que os dados mudarem no servidor. Recebemos atualizações automaticamente e não precisamos enviar alterações manualmente. Lembre-se de que esse listener de snapshot pode ser invocado a qualquer momento como resultado de uma mudança no lado do servidor. Por isso, é importante que nosso app possa processar essas mudanças.

Depois de mapear nossos dicionários para structs (consulte Restaurant.swift), basta atribuir algumas propriedades de visualização para exibir os dados. 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)

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

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

391c0259bf05ac25.png

6. Classificar e filtrar dados

No momento, nosso app mostra uma lista de restaurantes, mas não há como o usuário filtrar com base nas necessidades dele. Nesta seção, você vai usar a consulta avançada do Firestore para ativar a filtragem.

Veja o exemplo de uma consulta simples para buscar todos os restaurantes de dim sum:

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

Como o nome dele indica, o método whereField(_:isEqualTo:) fará com que nossa consulta faça o download apenas dos membros da coleção cujos campos atendam às restrições definidas. Nesse caso, o download apenas de restaurantes em que category for "Dim Sum" será feito.

Neste app, o usuário pode encadear vários filtros para criar consultas específicas, como "Pizza em São Paulo" ou "Frutos do mar em Los Angeles pedidos 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, nossa consulta retornará apenas restaurantes que correspondam aos requisitos do usuário.

Execute seu projeto e verifique se você pode filtrar por preço, cidade e categoria (não se esqueça de digitar os nomes da categoria e da cidade corretamente). Durante o teste, é possível que você encontre erros nos registros como 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 ocorre porque o Firestore exige índices para a maioria das consultas compostas. Exigir índices em consultas mantém o Firestore rápido em escala. Abrir o link da mensagem de erro abrirá automaticamente a interface de criação do índice no console do Firebase com os parâmetros corretos preenchidos. Para saber mais sobre índices no Firestore, acesse a documentação.

7. Como gravar dados em uma transação

Nesta seção, vamos adicionar um recurso para que os usuários enviem avaliações aos restaurantes. Até agora, todas as nossas gravações foram atômicas e relativamente simples. Se algum deles apresentar erro, provavelmente será solicitado que o usuário tente de novo ou tente de novo automaticamente.

Para adicionar uma classificação a um restaurante, precisamos coordenar várias leituras e gravações. Primeiro, a própria avaliação 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 não o outro, ficamos em um estado inconsistente em que os dados de uma parte do nosso banco de dados não correspondem aos dados de outra.

Felizmente, o Firestore oferece uma funcionalidade de transação que nos permite realizar várias 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 de atualização, todas as operações que fizermos 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 tentará novamente de forma automática algumas vezes. Isso significa que nossa condição de erro provavelmente é um único erro ocorrendo repetidamente, por exemplo, se o dispositivo estiver completamente off-line ou 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 do nosso banco de dados. Por exemplo, todos devem ser capazes de ver as classificações de um restaurante, mas apenas um usuário autenticado deve ter permissão para postar uma avaliação. Escrever um bom código no cliente não basta: precisamos especificar que nosso modelo de segurança de dados no back-end seja completamente seguro. Nesta seção, vamos aprender a usar as regras de segurança do Firebase para proteger nossos dados.

Primeiro, vamos analisar em mais detalhes as regras de segurança que criamos 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 impede que usuários não autenticados usem a API Firestore para fazer alterações não autorizadas nos seus dados. Esse é um bom começo, mas podemos usar as regras do Firestore para fazer coisas muito mais eficientes.

Vamos restringir as gravações de avaliações para que o ID do usuário delas corresponda ao ID do usuário autenticado. Isso impede que os usuários falsifiquem a identidade uns dos outros e façam avaliações fraudulentas. Substitua as regras de segurança pelo seguinte:

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 chamada ratings de qualquer documento pertencente à coleção restaurants. A condicional allow write impedirá que qualquer avaliação seja enviada 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 app anteriormente: que os usuários só podem escrever as próprias avaliações. Se adicionássemos uma função de edição ou exclusão para avaliações, esse mesmo conjunto de regras também impediria que os usuários modifiquem ou excluam avaliações de outros usuários. No entanto, as regras do Firestore também podem ser usadas de maneira mais granular para limitar gravações em campos individuais dos documentos, e não nos documentos inteiros. Podemos usar isso para permitir que os usuários atualizem somente as classificações, a classificação média e o número de avaliações de um restaurante, eliminando a possibilidade de um usuário mal-intencionado 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 criar e atualizar, 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. No entanto, depois de gravar um restaurante, o nome, o local, 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 é possível fazer com as regras de segurança, confira a documentação.

9. Conclusão

Neste codelab, você aprendeu a realizar leituras e gravações básicas e avançadas com o Firestore e aprendeu a proteger o acesso a dados com regras de segurança. Encontre a solução completa na ramificação codelab-complete (link em inglês).

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