Universidad del Quindío
Programa de Ingeniería de Sistemas y Computación
Título: Firebase Firestore
Docente: Carlos Andrés Florez V.
Firebase Firestore
Introducción
Firebase es una plataforma de desarrollo de aplicaciones móviles y web proporcionada por Google. Ofrece una variedad de servicios, como autenticación, almacenamiento en la nube, análisis y bases de datos en tiempo real. Uno de los servicios más populares de Firebase es Firestore, una base de datos NoSQL flexible y escalable que permite almacenar y sincronizar datos para aplicaciones móviles y web.
¿Qué es Firestore?
Firestore es una base de datos NoSQL basada en documentos que permite a los desarrolladores almacenar, sincronizar y consultar datos para sus aplicaciones.
Firestore ofrece varias características clave, como:
- Escalabilidad: Firestore está diseñado para manejar grandes volúmenes de datos y tráfico, lo que lo hace adecuado para aplicaciones de cualquier tamaño.
- Sincronización en tiempo real: Firestore permite la sincronización automática de datos entre clientes y la base de datos, lo que facilita la creación de aplicaciones colaborativas y en tiempo real.
- Consultas flexibles: Firestore admite consultas avanzadas, como filtrado, ordenamiento y paginación, lo que permite a los desarrolladores recuperar datos de manera eficiente.
- Seguridad: Firestore ofrece reglas de seguridad basadas en roles que permiten a los desarrolladores controlar el acceso a los datos de manera granular.
- Integración con Firebase: Firestore se integra fácilmente con otros servicios de Firebase, como la autenticación y el almacenamiento en la nube, lo que facilita la creación de aplicaciones completas.
- Soporte multiplataforma: Firestore es compatible con aplicaciones móviles (iOS, Android) y web, lo que permite a los desarrolladores crear aplicaciones multiplataforma con facilidad.
Gracias a estas características, Firestore se ha convertido en una opción popular para desarrolladores que buscan una solución de base de datos flexible y escalable para sus aplicaciones, además que permite un “backendless development”, es decir, desarrollar aplicaciones sin necesidad de gestionar un servidor backend propio.
Representación de datos en Firestore
A diferencia de las bases de datos relacionales tradicionales, Firestore utiliza una estructura de documentos y colecciones. Un documento es una unidad de almacenamiento que contiene campos y valores, mientras que una colección es un grupo de documentos relacionados.
En el siguiente diagrama se muestra la jerarquía de colecciones y documentos en Firestore:
La documentación oficial de Firestore se puede encontrar en los siguientes enlaces:
- Firestore
- Firestore - leer datos
- Firestore - recibir actualizaciones en tiempo real
- Firestore - consultar datos con filtros
Integración de Firestore en una Aplicación Android
A continuación, se presenta un ejemplo práctico de cómo integrar Firestore en una aplicación Android.
1. Configuración del Proyecto
Android Studio tiene una integración directa con Firebase, lo que facilita la configuración del proyecto. Sigue estos pasos:
- Abra Android Studio y cargue el proyecto en el que estamos trabajando.
- Seleccione “Tools” en la barra de menú superior.
- Haga clic en “Firebase” para abrir el asistente de Firebase.
- Seleccione “Cloud Firestore” y luego haga clic en “Get started with Cloud Firestore”.
- En el siguiente paso, haga clic en “Add the Cloud Firestore SDK to your app”. Esto agregará automáticamente las dependencias necesarias a su archivo
build.gradle.kts. Puede que tarde unos momentos en sincronizar el proyecto.
2. Verificar Dependencias
Asegúrese de que las siguientes dependencias estén presentes en su archivo build.gradle.kts:
dependencies {
implementation(libs.firebase.firestore)
}
Y el plugin de Google Services en el archivo build.gradle.kts a nivel de proyecto y del módulo:
plugins {
alias(libs.plugins.google.gms.google.services)
}
Tenga en cuenta que Android Studio agrega automáticamente las dependencias necesarias al usar el asistente de Firebase, adicionalmente, crea el archivo google-services.json en la carpeta app/ que contiene la configuración del proyecto de Firebase.
3. Habilitar Firestore en Firebase Console
Vaya a Firebase Console, seleccione su proyecto y habilite Firestore en la sección “Compilación”. Al dar clic en “Crear base de datos”, elija el modo estándar, la ubicación que prefiera y seleccione comenzar en modo de prueba para facilitar el desarrollo inicial.
4. Crear un nuevo repositorio que maneje Firestore
Cree una nueva clase llamada UserRepositoryImpl.kt en el paquete data.repository.remote o modifique una clase existente para manejar las operaciones de Firestore:
package com.example.demoapp.data.repository.remote
import com.example.demoapp.domain.model.User
import com.example.demoapp.domain.repository.UserRepository
import com.google.firebase.firestore.FirebaseFirestore
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.tasks.await
import jakarta.inject.Inject
import jakarta.inject.Singleton
@Singleton
class UserRepositoryImpl @Inject constructor(
private val firestore: FirebaseFirestore // Se inyecta una instancia de FirebaseFirestore para interactuar con la base de datos
): UserRepository {
// Definimos la colección de usuarios donde se almacenarán los datos
private val collection = firestore.collection("users")
// StateFlow para observar los cambios en la colección de usuarios
private val _users = MutableStateFlow<List<User>>(emptyList())
override val users: StateFlow<List<User>> = _users.asStateFlow()
init {
// Escuchar cambios en tiempo real en la colección de usuarios
collection.addSnapshotListener { snapshot, _ ->
snapshot?.let {
// Se actualiza el StateFlow con la lista de usuarios mapeados desde los documentos
_users.value = it.documents.mapNotNull { snap ->
snap.toObject(User::class.java)?.apply { id = snap.id }
}
}
}
}
override suspend fun save(user: User) {
// Agregar un nuevo documento a la colección de usuarios
collection.add(user).await()
}
override suspend fun findById(id: String): User? {
// Obtener el documento por ID
val snapshot = collection.document(id).get().await()
// Se retorna el objeto User si existe, se mapea el documento a un objeto User y se asigna el ID del documento de Firestore
return snapshot.toObject(User::class.java)?.apply { this.id = snapshot.id }
}
override suspend fun login(email: String, password: String): User? {
// Consultar la colección para encontrar un usuario con el email y password proporcionados
val snapshot = collection
.whereEqualTo("email", email)
.whereEqualTo("password", password)
.get()
.await()
// Si no se encuentra ningún documento, retornar null
if (snapshot.documents.isEmpty()) {
return null
}
// Retornar el primer usuario encontrado, se mapea el documento a un objeto User y se asigna el ID del documento de Firestore
return snapshot.documents.first().toObject(User::class.java)?.apply {
id = snapshot.documents.first().id
}
}
override suspend fun update(user: User) {
// Se actualiza el documento existente dado su ID con los nuevos datos del usuario
collection.document(user.id).set(user).await()
}
override suspend fun getAll(): List<User> {
// Obtener todos los documentos de la colección de usuarios
val snapshot = collection.get().await()
// Mapear los documentos a objetos User, asignando el ID del documento de Firestore
return snapshot.documents.mapNotNull {
it.toObject(User::class.java)?.apply { id = it.id }
}
}
}
Firebase Firestore maneja las operaciones de forma asíncrona, por lo que utilizamos la función await() para esperar a que las tareas se completen dentro de las funciones suspend.
Además, Firestore obliga a que los modelos de datos tengan un constructor vacío, por lo que asegúrese de que la clase User tenga uno. Por ejemplo:
package com.example.demoapp.domain.model
data class User (
var id: String = "",
val name: String = "",
val city: String = "",
val address: String = "",
val email: String = "",
val password: String = "",
val phoneNumber: String = "",
val profilePictureUrl: String = "",
val role: UserRole = UserRole.USER
)
Asignar valores predeterminados a las propiedades garantiza que el constructor vacío esté presente. El id se declara como var para poder asignarle el identificador del documento que genera Firestore al leer los datos.
Agregar valores por defecto no cambia los tipos ni el significado del modelo (es Kotlin idiomático), y todos los campos de User son de tipos que Firestore sabe serializar directamente; por eso el costo de adaptarlo es mínimo. El problema aparece cuando adaptar el modelo exigiría cambiar tipos o estructura: es el caso de Report, que usa LocalDate, una enumeración y un objeto anidado (Location). Modificar el dominio para acomodarlo a la base de datos sí sería mala práctica.
5. Modificar el UserRepository
Dado que nos vamos a conectar a Firestore, debemos asegurarnos de que el repositorio tenga los métodos necesarios y que estos sean suspend para poder usarlos con corutinas:
package com.example.demoapp.domain.repository
import com.example.demoapp.domain.model.User
import kotlinx.coroutines.flow.StateFlow
interface UserRepository {
val users: StateFlow<List<User>>
suspend fun save(user: User)
suspend fun findById(id: String): User?
suspend fun login(email: String, password: String): User?
suspend fun update(user: User)
suspend fun getAll(): List<User>
}
⚠️ Importante: Al ampliar la interfaz con
usersygetAll, la implementación en memoria (data/repository/memory/UserRepositoryImpl, de la guía de arquitectura) dejará de compilar aunque ya no la vayamos a usar, porque toda clase que implementa una interfaz debe implementar todos sus miembros. Dado que esa implementación es solo para pruebas y no se usará en la aplicación final, lo que haremos es eliminar esa clase y su vinculación en el módulo de Dagger-Hilt, dejando solo la implementación que usa Firestore. De esta forma, evitamos tener código muerto que no se mantiene ni se prueba.
6. Crear el repositorio de reportes en Firestore
Apliquemos el mismo patrón al eje central de la aplicación: los reportes. Sin embargo, a diferencia de User, la entidad Report usa tipos que Firestore no sabe serializar directamente: LocalDate para la fecha, la enumeración ReportStatus y el objeto anidado Location. Además, Firestore exige clases con constructor vacío para mapear los documentos.
En lugar de modificar el modelo de dominio para acomodarlo a esas restricciones, crearemos un DTO (Data Transfer Object): una clase que representa el reporte tal como se guarda en Firestore, con funciones para convertir entre el DTO y el modelo de dominio. Cree el archivo ReportDto.kt en el paquete data/remote/report:
package com.example.demoapp.data.remote.report
import com.example.demoapp.domain.model.Location
import com.example.demoapp.domain.model.Report
import com.example.demoapp.domain.model.ReportStatus
import java.time.LocalDate
// Representación de un Report tal como se guarda en Firestore.
// Todos los campos tienen valor por defecto para garantizar el constructor vacío.
data class ReportDto(
val id: String = "",
val title: String = "",
val description: String = "",
val latitude: Double = 0.0,
val longitude: Double = 0.0,
val status: String = ReportStatus.PENDING.name,
val type: String = "",
val photoUrl: String = "",
val ownerId: String = "",
val date: String = ""
) {
// Convierte el DTO (datos de Firestore) al modelo de dominio
fun toDomain(): Report = Report(
id = id,
title = title,
description = description,
location = Location(latitude = latitude, longitude = longitude),
status = runCatching { ReportStatus.valueOf(status) }.getOrDefault(ReportStatus.PENDING),
type = type,
photoUrl = photoUrl,
ownerId = ownerId,
date = runCatching { LocalDate.parse(date) }.getOrDefault(LocalDate.now())
)
companion object {
// Convierte el modelo de dominio al DTO que se guardará en Firestore
fun fromDomain(report: Report): ReportDto = ReportDto(
id = report.id,
title = report.title,
description = report.description,
latitude = report.location.latitude,
longitude = report.location.longitude,
status = report.status.name,
type = report.type,
photoUrl = report.photoUrl,
ownerId = report.ownerId,
date = report.date.toString()
)
}
}
Observe que la ubicación se “aplana” en latitude/longitude, el estado se guarda como texto y la fecha en formato ISO (yyyy-MM-dd). Las funciones toDomain y fromDomain se encargan de las conversiones, y el uso de runCatching evita que un dato mal formado en Firestore rompa la aplicación.
Ahora sí, cree una clase llamada ReportRepositoryImpl.kt en el paquete data.repository.remote que implemente la interfaz ReportRepository (creada en la guía de arquitectura) usando una colección llamada reports:
package com.example.demoapp.data.repository.remote
import com.example.demoapp.data.remote.report.ReportDto
import com.example.demoapp.domain.model.Report
import com.example.demoapp.domain.repository.ReportRepository
import com.google.firebase.firestore.FirebaseFirestore
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.tasks.await
import jakarta.inject.Inject
import jakarta.inject.Singleton
@Singleton
class ReportRepositoryImpl @Inject constructor(
private val firestore: FirebaseFirestore
): ReportRepository {
// Definimos la colección de reportes donde se almacenarán los datos
private val collection = firestore.collection("reports")
// StateFlow para observar los cambios en la colección de reportes
private val _reports = MutableStateFlow<List<Report>>(emptyList())
override val reports: StateFlow<List<Report>> = _reports.asStateFlow()
init {
// Escuchar cambios en tiempo real en la colección de reportes
collection.addSnapshotListener { snapshot, _ ->
snapshot?.let {
// Se mapean los documentos a DTOs y estos al modelo de dominio,
// ordenando los reportes del más reciente al más antiguo
_reports.value = it.documents
.mapNotNull { snap -> snap.toObject(ReportDto::class.java)?.copy(id = snap.id) }
.map { dto -> dto.toDomain() }
.sortedByDescending { report -> report.date }
}
}
}
override suspend fun save(report: Report) {
// Agregar un nuevo documento a la colección de reportes
collection.add(ReportDto.fromDomain(report)).await()
}
override suspend fun findById(id: String): Report? {
// Obtener el documento por ID y mapearlo a un objeto Report
val snapshot = collection.document(id).get().await()
return snapshot.toObject(ReportDto::class.java)?.copy(id = snapshot.id)?.toDomain()
}
}
Gracias al addSnapshotListener, la lista de reportes se mantiene sincronizada en tiempo real: cuando un usuario crea un reporte, todos los dispositivos que observan el StateFlow verán el cambio automáticamente.
Note que, gracias al DTO, las entidades del dominio Report y Location no necesitan ningún cambio: las restricciones de Firestore (constructor vacío, tipos serializables) quedan encapsuladas en ReportDto, dentro de la capa de datos.
7. Inyectar FirebaseFirestore en el Módulo de Dagger-Hilt
Cree un proveedor para FirebaseFirestore en el paquete di. Para ello, cree un archivo llamado FirebaseModule.kt con el siguiente contenido:
package com.example.demoapp.di
import com.google.firebase.firestore.FirebaseFirestore
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import jakarta.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object FirebaseModule {
@Provides
@Singleton
fun provideFirestore(): FirebaseFirestore {
// Proporciona una instancia singleton de FirebaseFirestore en toda la aplicación
return FirebaseFirestore.getInstance()
}
}
8. Configurar Repositorios en el Módulo de Dagger-Hilt
Modifique el módulo de Dagger-Hilt que ya creamos en clases anteriores para enlazar las implementaciones de los repositorios con sus interfaces. Abra el archivo RepositoryModule.kt y revise que tenga el siguiente contenido:
@Binds
@Singleton
abstract fun bindReportRepository(
reportRepositoryImpl: ReportRepositoryImpl
): ReportRepository
@Binds
@Singleton
abstract fun bindUserRepository(
userRepositoryImpl: UserRepositoryImpl
): UserRepository
Asegúrese de importar las clases ReportRepositoryImpl y UserRepositoryImpl correctas, es decir, las del paquete data.repository.remote que creamos anteriormente (y no las implementaciones en memoria).
9. Nuevo estado en RequestResult
Agregue un nuevo estado Loading en la clase sellada RequestResult para representar el estado de carga durante las operaciones asíncronas:
package com.example.demoapp.core.util
sealed class RequestResult {
data class Success(val message: String) : RequestResult()
data class Failure(val errorMessage: String) : RequestResult()
object Loading : RequestResult() // Nuevo estado para representar la carga
}
⚠️ Importante: Al agregar
Loading, todos loswhenexhaustivos sobreRequestResultque existen en el proyecto dejarán de compilar hasta que manejen el nuevo estado. Más abajo ajustaremosLoginScreen,RegisterScreenyReportDetailScreen, pero recuerde revisar también los demás lugares donde usóRequestResult: por ejemplo, elwhen (updateResult)deProfileScreen(guía de permisos) y el flujo deCreateReportScreen(actividad de la guía de arquitectura). En cada uno, agregue la ramais RequestResult.Loading(por ejemplo, mostrando un indicador de carga o simplemente ignorándola).
La idea detrás de RequestResult es proporcionar una máquina de estados para manejar las diferentes etapas de una solicitud, como el éxito, el fracaso y ahora la carga.
El flujo típico de estados sería:
10. Los Casos de Uso no cambian
Los casos de uso no necesitan modificarse: ya eran suspend (desde la guía de arquitectura) y el LoginUserUseCase ya guarda la sesión (lo enriquecimos en la guía de Data Store). Lo único que cambió por debajo es la implementación de los repositorios, que ahora apuntan a Firestore en lugar de a la memoria. Los casos de uso siguen delegando en las interfaces ReportRepository y UserRepository sin enterarse de ese cambio: justamente esa es la ventaja de la arquitectura por capas.
11. Uso de los Casos de Uso en los ViewModels
Como los casos de uso son suspend, asegúrese de invocarlos dentro de una corutina en los ViewModels. Aquí hay algunos ejemplos:
Login
Gracias a que el LoginUserUseCase ya guarda la sesión (desde la guía de Data Store), el LoginViewModel es simple: solo inyecta el caso de uso y no necesita el SessionDataStore. Modifique la función login:
fun login() {
if (isFormValid) {
viewModelScope.launch {
// Indicar que la operación está en curso
_loginResult.value = RequestResult.Loading
// El caso de uso autentica y, si tiene éxito, guarda la sesión
val user = loginUserUseCase(email.value, password.value)
_loginResult.value = if (user != null) {
RequestResult.Success(resources.getString(R.string.login_success))
} else {
RequestResult.Failure(resources.getString(R.string.login_failure))
}
}
}
}
Ahora, en LoginScreen, ajuste el LaunchedEffect para manejar el nuevo estado Loading:
// Efecto para mostrar el snackbar cuando hay resultado
LaunchedEffect(loginResult) {
loginResult?.let { result ->
val message = when (result) {
is RequestResult.Success -> result.message
is RequestResult.Failure -> result.errorMessage
is RequestResult.Loading -> "Cargando..."
}
snackbarHostState.showSnackbar(message)
viewModel.resetLoginResult()
}
}
Register
Modifique la función register en el RegisterViewModel para manejar el registro utilizando Firestore:
fun register(){
if (isFormValid) {
viewModelScope.launch {
// Indicar que la operación está en curso
_registerResult.value = RequestResult.Loading
// Llamar a la función de apoyo para crear el usuario
_registerResult.value = runCatching { create() }
.fold(
onSuccess = { RequestResult.Success("Usuario registrado exitosamente") },
onFailure = {
RequestResult.Failure(
it.message ?: "Error registrando al usuario"
)
}
)
}
}
}
// Función de apoyo para crear el usuario
suspend fun create() {
registerUserUseCase(
// Ya no es necesario generar un ID manualmente, Firestore lo hace automáticamente
User(
name = name.value,
city = city.value,
address = address.value,
email = email.value,
profilePictureUrl = profilePictureUrl.value,
password = password.value
)
)
}
En esta implementación se utiliza runCatching para manejar cualquier excepción que pueda ocurrir durante la creación del usuario y se actualiza el estado _registerResult en consecuencia.
Ajuste RegisterScreen para manejar el nuevo estado Loading, similar a como se hizo en LoginScreen:
ReportDetail
Ajuste el ViewModel ReportDetailViewModel para cargar los detalles del reporte desde Firestore:
package com.example.demoapp.features.report.detail
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.demoapp.core.util.RequestResult
import com.example.demoapp.domain.model.Report
import com.example.demoapp.domain.usecase.report.GetReportByIdUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import jakarta.inject.Inject
@HiltViewModel
class ReportDetailViewModel @Inject constructor(
private val getReportByIdUseCase: GetReportByIdUseCase
) : ViewModel() {
// StateFlow para manejar el resultado de la solicitud
private val _detailResult = MutableStateFlow<RequestResult?>(null)
val detailResult: StateFlow<RequestResult?> = _detailResult.asStateFlow()
// StateFlow para manejar el reporte actual
private val _currentReport = MutableStateFlow<Report?>(null)
val currentReport: StateFlow<Report?> = _currentReport.asStateFlow()
// Función para encontrar un reporte por su ID
fun findById(reportId: String) {
viewModelScope.launch {
_currentReport.value = null
_detailResult.value = RequestResult.Loading
_detailResult.value = runCatching {
getReportByIdUseCase(reportId)?.also { _currentReport.value = it }
}.fold(
onSuccess = { RequestResult.Success("Reporte encontrado") },
onFailure = { RequestResult.Failure(it.message ?: "Error obteniendo el reporte") }
)
}
}
}
Modifique ReportDetailScreen para manejar el nuevo estado Loading, similar a como se hizo en LoginScreen y RegisterScreen.
package com.example.demoapp.features.report.detail
@Composable
fun ReportDetailScreen(
padding: PaddingValues,
reportId: String,
reportViewModel: ReportDetailViewModel = hiltViewModel(),
){
// Se observa el estado del reporte actual desde el ViewModel
val report by reportViewModel.currentReport.collectAsState()
// Efecto para cargar los detalles del reporte cuando el reportId cambia (o al iniciar la pantalla)
LaunchedEffect(reportId) {
reportViewModel.findById(reportId)
}
// Resto del código...
}
12. Ajuste en los demás ViewModels y Screens
Asegúrese de revisar y ajustar cualquier otro ViewModel o Screen que dependa de los casos de uso para asegurarse de que todas las llamadas a funciones suspend se realicen dentro de corutinas y manejen adecuadamente los estados de carga, éxito y error utilizando la clase RequestResult. En particular, revise el CreateReportViewModel: gracias a que el CreateReportUseCase delega en el repositorio, la creación de reportes ahora queda guardada en Firestore sin cambios adicionales, y la lista de reportes se actualizará en tiempo real gracias al addSnapshotListener.
13. Ejecución de la Aplicación
Ejecute la aplicación en un emulador o dispositivo físico para probar la integración de Firestore. Asegúrese de que las operaciones de registro, inicio de sesión, creación de reportes y visualización de la lista y el detalle de los reportes funcionen correctamente con la base de datos Firestore.
Vaya a la consola de Firebase para verificar que los datos se estén almacenando correctamente en Firestore. En la sección de “Firestore Database”, debería poder ver los documentos y colecciones creados por la aplicación.
Actividad Práctica
1. Investigar otras funciones de Firestore
Investigue cómo implementar funciones adicionales de Firestore, como eliminar documentos, realizar consultas avanzadas y manejar errores de red.
2. Cache local con Firestore
Investigue cómo habilitar y utilizar la caché local de Firestore para mejorar el rendimiento de la aplicación y permitir el acceso a los datos sin conexión.