Firebase Android Series: Firestore

Tempo de leitura: 9 minutes

Bem-vindo a outro artigo da Firebase Android Series. Aqui, vamos aprender como construir um aplicativo Android usando o Firebase do zero.

Este é o segundo artigo da Firebase Android Series. Uma série de artigos focados em aprender como usar o Firebase em qualquer aspecto que você precise para implementá-lo como parte de seus aplicativos publicados ou para começar a implementá-lo em seus novos.

O módulo com o qual vamos lidar agora é o Cloud Firestore.

O Cloud Firestore é um banco de dados flexível e escalonável para desenvolvimento móvel, web e servidor. Como o Firebase Realtime Database, ele mantém seus dados sincronizados entre aplicativos clientes por meio de ouvintes em tempo real e oferece suporte offline para dispositivos móveis e web para que você possa criar aplicativos responsivos que funcionam independentemente da latência da rede ou da conectividade com a Internet.

Trabalhar com um back-end nem sempre é amigável. Normalmente perdemos muito tempo escolhendo um servidor, criando uma instância de SQL e implementando todo o nosso modelo de banco de dados. Juntamente com pensar em fluxogramas e expor uma API Rest para expor nosso servidor de forma segura …

O Firestore nos permite criar um banco de dados em apenas alguns minutos. Além disso, após alguma prática, você criará modelos de banco de dados não relacionais como um profissional. Continuando com esta série de artigos, usaremos o Firestore para modelar todo o banco de dados de nosso aplicativo de chat. Vamos fazer isso!

 

Configurando o projeto

Para começar a configurar nosso banco de dados Firestore, só precisamos ir para a guia Banco de dados em nosso console do Firebase e clicar em Cloud Firestore.

Depois de clicar em Cloud Firestore, encontraremos o console do Firestore. Lá poderemos começar a criar nosso modelo de banco de dados.

 

Construindo um modelo de banco de dados

O modelo de dados do Cloud Firestore oferece suporte a estruturas de dados hierárquicas flexíveis. Armazene seus dados em documentos, organizados em coleções. Os documentos podem conter objetos aninhados complexos, além de subcoleções.

Por enquanto, queremos apenas permitir que nosso aplicativo registre um usuário, faça o login e envie/receba mensagens. Para esses poucos recursos, vamos dividir nossos dados em 3 coleções diferentes:

  • PrivateData: Contém os documentos relativos aos dados privados de um usuário. Cuidaremos desses dados tornando-os acessíveis apenas para o proprietário.
  • PublicProfile: Contém os dados do usuário que serão públicos para os demais usuários: Nome, foto do perfil, número de mensagens enviadas e última data de login.
  • Messages:: Contém os dados de cada mensagem enviada no aplicativo.

Image for post

Agora que esclarecemos como vamos construir o modelo de banco de dados, vamos passar para o código.

Nota: Se você é totalmente novo no modelo de banco de dados não relacional. Eu sugiro que você dê uma olhada na documentação oficial do Firestore sobre o modelo de banco de dados. Também reserve um tempo para ler este outro artigo que ensina como construir um modelo de banco de dados fácil para o Firestore: (Breve)

 

Operações do Firestore

A API do Firestore é bastante simples e tem apenas alguns métodos para trabalhar. Eles incluem todos os casos possíveis que podemos precisar para construir nosso aplicativo. Esses métodos são: get, set, add, update e delete.

As APIs do Firebase usam a API Google Play Tasks para gerenciar todas as suas chamadas. Se você não está familiarizado com a API de tarefas, pode dar uma olhada aqui.

Database Models

O Firestore nos permite fazer upload e modificar dados por meio de representações de modelos em nossos códigos. Essas representações são classes simples que contêm todos os valores de nosso modelo. Eles são geralmente chamados de POJO (plain old java object).

Em Kotlin, podemos simplesmente criar uma nova classe de dados para nosso modelo como o próximo:

data class FirebasePublicProfile(
        val userData: FirebaseUserData = FirebaseUserData(),
        val lowerCaseUsername: String = "",
        val totalMessages: Int = 0,
        @ServerTimestamp val lastLogin: Timestamp? = null
)

As classes POJOs devem ter um construtor vazio em java para ser usado no Firestore. Em Kotlin, precisamos apenas adicionar um valor padrão a todos os nossos parâmetros.

Os nomes dados às variáveis ​​dentro de nosso POJO serão os mesmos que o Firestore criará no banco de dados. Se estivermos usando a anotação camel case em nosso código e quisermos definir um nome diferente no Firestore, podemos usar a anotação @PropertyName.

Nota: Para criar timestamp ou campos que contenham datas no Firebase, devemos usar a classe Timestamp. Para fazer o Firestore gerar valores de carimbo de data/hora automaticamente quando carregamos dados no banco de dados, devemos usar a anotação @ServerTimestamp e definir o valor como nulo.

 

Add Data

Cada documento no Cloud Firestore é identificado exclusivamente por sua localização no banco de dados. Para referir documentos e coleções no Firestore, é necessário usar referências.

//Get "PrivateData" collection reference
val privateDataRef = firestore.collection("PrivateData")
 
 //Get "PublicProfile/UserId" document reference
 val userId = publicProfile.userData.uid
 val userPublicProfileRef = firestore.collection("PublicProfile").document(userId)

Uma referência é um objeto leve que apenas aponta para um local em seu banco de dados. Você pode criar uma referência independentemente da existência de dados ou não, e criar uma referência não executa nenhuma operação de rede.

Para adicionar dados ao Firestore, só precisamos criar uma nova referência e usar o método add. Esta operação, como qualquer operação na API Firebase, retorna uma Tarefa. Estamos usando o método Tasks.await para tornar nosso código síncrono e fácil de ler, sem callbacks.

private fun addPrivateData(firebasePrivateData: FirebasePrivateData) {
        try {
            //Get "PrivateData" collection reference
            val privateDataRef = firestore.collection("PrivateData")
            Tasks.await(privateDataRef.add(firebasePrivateData))
            //Task successful
        } catch (e: Throwable) {
            //Manage error
        }
    }

 

Set Data

Definir dados no Firestore é um conceito diferente de adicionar. A diferença entre os dois é que add deve ser usado em um CollectionReference que gera um novo ID exclusivo para nosso documento e carrega os dados fornecidos para o Firestore. Por outro lado, set é um método usado sobre uma DocumentReference e precisa do ID do documento para funcionar. Podemos usar nossos próprios IDs ou os gerados pelo Firestore.

private fun setPublicProfile(publicProfile: FirebasePublicProfile) {
       try {
           val userId = publicProfile.userData.uid
           //Get "PublicProfile/UserId" document reference
           val userPublicProfileRef = firestore.collection("PublicProfile").document(userId)
           Tasks.await(userPublicProfileRef.set(publicProfile))
           //Task successful
       } catch (e: Throwable) {
           //Manage error
       }
   }

Haveria casos em que desejaríamos que o Firestore gerasse um novo ID exclusivo e o armazenasse na memória para nossos próprios fins. Para fazer isso, precisamos chamar o método document() sem passar nenhum ID.

private fun createIdAndSetPublicProfile(publicProfile: FirebasePublicProfile) {
       try {
           val publicProfileRef = firestore.collection("PublicProfile")
           //Get "PublicProfile" collection reference and generate a new document without data
           val newGeneratedDocumentRef = publicProfileRef.document()
           //Retrieve the ID of the new document
           val newGeneratedId = newGeneratedDocumentRef.id
           //Use the id for whatever you need
           Tasks.await(newGeneratedDocumentRef.set(publicProfile))
           //Task successful
       } catch (e: Throwable) {
           //Manage error
       }
   }

 

Update Data

O Firestore nos permite atualizar partes específicas de nossos documentos por meio da atualização do método. Ele pode receber por parâmetro uma única String e um objeto Any para atualizar um valor específico único ou um Map<String, Any> para atualizar vários valores da referência dada.

private fun updateUserLastLogin(userId: String) {
       try {
           //Get "PublicProfile/UserId" document reference
           val userPublicProfileRef = firestore.collection("PublicProfile").document(userId)
           Tasks.await(userPublicProfileRef.update("lastLogin", Timestamp.now()))
           //Task successful
       } catch (e: Throwable) {
           //Manage error
       }
   }

 

Delete Data

Para excluir documentos no Firestore, só precisamos chamar delete sobre a referência.

private fun deletePublicProfile(userId : String) {
       try {
           //Get "PublicProfile" collection reference
           val privateDataRef = firestore.collection("PublicProfile").document(userId)
           Tasks.await(privateDataRef.delete())
           //Task successful
       } catch (e: Throwable) {
           //Manage error
       }
   }

Aviso: a exclusão de um documento não exclui suas subcoleções. Quando você exclui um documento que possui subcoleções associadas, as subcoleções não são excluídas. Eles ainda estão acessíveis por referência.

 

Get data

Todos os dados no Firestore são recuperados por meio de DocumentSnapshots e QuerySnapshots. O primeiro representa um único documento enquanto o segundo representa o resultado de uma consulta sobre uma coleção, podendo conter vários DocumentSnapshots.

Podemos recuperar dados do Firestore usando o método get para recuperar dados apenas uma vez ou addSnapshotListener para adicionar um listener para ouvir quaisquer alterações dentro de um documento ou coleção. Vamos dar uma olhada em ambos os casos:

private fun getPublicProfile(userId: String): FirebasePublicProfile? {
        return try {
            //Get "PublicProfile" collection reference
            val privateDataRef = firestore.collection("PublicProfile").document(userId)
            val document = Tasks.await(privateDataRef.get())
            //Check if data exists
            if (document.exists()) {
                //Cast the given DocumentSnapshot to our POJO class
                val publicProfile = document.toObject(FirebasePublicProfile::class.java)
                publicProfile
            } else null
            //Task successful
        } catch (e: Throwable) {
            //Manage error
            null
        }
    }
private fun listenMessages() {
        firestore.collection("Messages")
            .addSnapshotListener({ documentSnapshot, error ->
                if (error != null) {
                    //Manage error
                } else if (documentSnapshot != null) {
                    //Manage our documentSnapshot
                }
            })
    }

No caso do SnapshotListener, precisaremos chamar remove () sobre a instância do instantâneo para fazer o listener parar de funcionar.

Observação: o Firestore sempre retornará um valor de instantâneo, mesmo se não houver dados na referência fornecida. Para verificar se nosso instantâneo contém ou não dados, devemos usar o método exists () sobre nossas referências de DocumentSnapshot.

 

O Cloud Firestore oferece suporte a operações atômicas para leitura e gravação de dados. Em um conjunto de operações atômicas, todas as operações são bem-sucedidas ou nenhuma delas é aplicada. Existem dois tipos de operações atômicas no Cloud Firestore:

Transactions: uma transação é um conjunto de operações de leitura e gravação em um ou mais documentos.
Gravações em lote: uma gravação em lote é um conjunto de operações de gravação em um ou mais documentos.

Batched Writes: uma gravação em lote é um conjunto de operações de gravação em um ou mais documentos.

Cada transação ou lote de gravações pode gravar até 500 documentos. Para limites adicionais relacionados a gravações, consulte Cotas e limites na documentação oficial.

 

Transaction

Uma transação consiste em qualquer número de operações get () seguidas por qualquer número de operações de gravação, como set(), update() ou delete().

As transações nunca aplicam gravações parcialmente. Todas as gravações são executadas no final de uma transação bem-sucedida. Vejamos um exemplo:

private fun followUser(userId: String) {
       try {
           Tasks.await(firestore.runTransaction { transaction ->
               val userReference = firestore
                   .collection("Users")
                   .document(userId)

               val profile = transaction.get(userReference)
               val followers = profile.getLong("followersCount")
               val newFollowers = followers?.plus(1) ?: 1
               transaction.update(userReference, "followersCount", newFollowers)
           })
       } catch (e: Throwable) {
           //Manage error
       }
   }

Você pode ler mais sobre transações na documentação oficial.

Nota: Ao executar transações, as operações de leitura devem vir antes das operações de gravação. Além disso, uma transação falhará se o cliente estiver offline.

 

Gravações em lote

Se você não precisa ler nenhum documento em seu conjunto de operações, pode executar várias operações de gravação como um único lote que contém qualquer combinação de operações set(), update() ou delete(). Um lote de gravações é concluído atomicamente e pode gravar em vários documentos.

As gravações em lote também são úteis para migrar grandes conjuntos de dados para o Cloud Firestore. Os lotes de gravação podem conter até 500 operações e reduzem a sobrecarga de conexão, resultando em uma migração de dados mais rápida. Vejamos um exemplo:

fun sendMessage(message: String, publicProfile: PublicProfile) {
        val newId = firestore.messages().document().id
        val data = publicProfile.userData.toFirebaseUserData()
        val firebaseMessage = FirebaseMessage(data, message)

        try {
            val batch = firestore.batch()
            batch.set(firestore.messages().document(newId), firebaseMessage)
            batch.update(firestore.publicProfileDoc(publicProfile.userData.uid),
                mapOf(TOTAL_MESSAGES to publicProfile.totalMessages.plus(1)))
            Tasks.await(batch.commit())
            //Batch complete
        } catch (e: Throwable) {
            //Manage error
        }
    }

 

Dados de consulta e paginação

O Cloud Firestore oferece uma funcionalidade de consulta poderosa para especificar quais documentos você deseja recuperar de uma coleção. Essas consultas também podem ser usadas com get() ou addSnapshotListener(), conforme descrito acima.

Podemos usar os próximos métodos em nossas referências de banco de dados para consultar nossos dados:

  • whereEqualTo() faz uma comparação ==.
  • whereLessThan() faz uma < comparação.
  • whereLessThanOrEqualTo() faz uma <= comparação.
  • whereGreaterThan() faz uma > comparação.
  • whereGreaterThanOrEqualTo() faz uma >= comparação.
  • orderBy() nos permite ordenar por um determinado campo.
  • limit() limita o número de DocumentSnapshot recebidos.

Todos esses métodos podem ser combinados entre eles para formar a consulta desejada. Para consultas compostas, o Firestore nos forçará a gerar um índice personalizado em nosso console de banco de dados. Sem um índice, nossas consultas compostas falharão.

Paginação

Para paginar nossos dados, o Firestore nos fornece os métodos startAt e endAt, que recebe um DocumentSnapshot e o usa para paginar nossos dados. Isso significa que precisaremos manter na memória o último DocumentSnapshot fornecido por cada consulta realizada quando quisermos implementar a paginação sobre ele.

var lastMessageReceived: DocumentSnapshot? = null

   private fun paginateMessages() {
       val query = firestore.collection("Messages")
           .orderBy("timestamp")
           .limit(15)
       val paginatedQuery =
           if (lastMessageReceived != null) query.startAfter(lastMessageReceived!!)
           else query
       try {
           val messagesDocuments = Tasks.await(paginatedQuery.get())
           //Save the last message of the list of documents
           if (!messagesDocuments.isEmpty) lastMessageReceived = messagesDocuments.documents.last()
           //manage your data
       } catch (e: Throwable) {
           //manage error
       }
   }

Nota: Firestore também nos permite salvar coordenadas geográficas em nosso banco de dados. Usando a classe GeoPoint podemos consultar por distância. Se você quiser ler mais sobre isso, você pode verificar a próxima postagem no Stackoverflow.

 

Boas práticas

Estamos prestes a terminar. Vou compartilhar algumas boas práticas a serem levadas em consideração ao trabalhar com o Firestore em seu aplicativo.

 

Separe seus modelos de banco de dados

Mantenha seus modelos de dados Firestore separados dos modelos de aplicativos. Como você verá no exemplo de código. Para cada POJO do banco de dados, sempre há um FirebaseModel para a mesma classe.

Isso nos permite manter nosso modelo Firestore com seus próprios nomes nas variáveis ​​e fazer conversões complexas mais fáceis, servindo como um gateway no lado do cliente. Também nos permite manter o id de cada documento fora do modelo original (porque já está contido no DocumentSnapshot) sem usar anotações.

 

Mantenha seus modelos Firestore em um único pacote

Ao usar o Firestore em seu aplicativo junto com o ProGuard, você precisa considerar como seus objetos de modelo serão serializados e desserializados após a ofuscação. Se você usar DocumentSnapshot.toObject(Class) para ler dados, você precisará adicionar regras ao proguard-rules.pro mantendo as classes de modelo.

# Firebase
-keep class com.myapp.firebase.models.** { *; }

 

Construa suas referências como uma árvore

Tente manter todas as referências do Firestore juntas e construí-las como uma árvore. Isso tornará mais fácil lê-los quando começarem a ficar mais aninhados. Você também pode usar extensões Kotlin sobre Firestore para torná-los mais detalhados.

fun FirebaseFirestore.publicProfile() = collection(PUBLIC_PROFILE)
fun FirebaseFirestore.publicProfileDoc(uid: String) = publicProfile().document(uid)

fun FirebaseFirestore.privateData() = collection(PRIVATE_DATA)
fun FirebaseFirestore.privateDataDoc(uid: String) = privateData().document(uid)

fun FirebaseFirestore.messages() = collection(MESSAGES)
fun FirebaseFirestore.messageDoc(uid: String) = messages().document(uid)

 

Use o modelo reativo em seu repositório

Eu sugiro que você envolva o Firebase do seu jeito no seu repositório. A API de tarefas feita pelo Google é muito legal, mas gera muitos clichês e torna as operações de concatenação um pouco tediosas se você não estiver familiarizado com ela.

Kotlin coroutines ou RxJava são boas opções para usar em seu repositório junto com o Firebase. Ambos permitirão que você trabalhe de forma mais confortável em seu aplicativo.

 

Amostra Github

Esta série sempre trabalhará no mesmo projeto. Criação de um aplicativo de chat usando Firebase e Kotlin. Você encontrará cada um dos diferentes códigos de artigos em ramos individuais do projeto. (Sample)