Como traer datos de Firebase Firestore /Realtime Database a Flutter de forma sencilla y sin dependencias
Fernando Luca De Tena Smith
Posted on November 22, 2024
Simplificando el Mapeo de Datos de Firebase Firestore / Real Database en Flutter
Firebase ofrece una integración sencilla con Flutter, pero mapear los datos recuperados a nuestros modelos puede volverse tedioso y propenso a errores, especialmente a medida que las aplicaciones crecen en complejidad.
Aquí os dejo la forma que uso yo en todos mis projectos para simplificar este proceso, garantizando el correcto mapeo de los datos y reduciendo el código repetitivo.
El problema
El método tradicional para obtener datos de Firestore suele ser algo así:
// Ejemplo para traer los datos de un User de Firestore
getUser(userId) async {
final db = FirebaseFirestore.instance;
// Metodo para traer el documento de un User
db.collection('users').doc(userId).get().then((snapshot) {
if (snapshot.exists) {
final user = User.fromMap(snapshot.data()!);
print('Name: ${user.name}');
} else {
print('User not found');
}
});
// Metodo para escuchar los cambios de un User
db.collection('users').doc(userId).snapshots().listen((snapshot) {
if (snapshot.exists) {
final user = User.fromMap(snapshot.data()!);
print('Name: ${user.name}');
} else {
print('User not found');
}
});
}
Este enfoque, aunque funcional, presenta algunas desventajas:
- Código repetitivo: Se requiere mucho código para manejar snapshots, verificar la existencia de datos y realizar el mapeo.
- Manejo de nulos: Firebase permite campos nulos, lo que requiere comprobaciones adicionales para evitar excepciones.
-
Manejo de tipos: Convertir los tipos de datos de Firebase a los tipos de datos de Dart puede ser propenso a errores, especialmente con tipos como
Timestamp
onum
.
*num
suele dar problemas si usamos las librerías de Firebase Firestore en Js/Ts
ya que si guardamos un número decimal cómo 0.0, Firestore lo convierte a 0 y lo guarda como entero. Al recuperarlo en Flutter nos dará error y romperá la app.
La solución: Mapeo Automatizado
Mi método para traer información de Firestore se basa en una combinación de clases abstractas, extensiones y funciones auxiliares para automatizar el proceso de mapeo. El objetivo es reducir el código necesario para interactuar con Firebase y garantizar que los datos se mapeen correctamente a nuestros modelos.
Veamos un ejemplo de cómo se vería el código con esta nueva implementación:
// Ejemplo con mapeo automatizado
getUser(userId) async {
final db = FirebaseFirestore.instance;
DocumentReference refUser(String userId) => db.collection('users').doc(userId);
// Metodo para traer al User de Firestore
final user = await FireDocument(refUser(userId), User()).data;
print('Name: ${user.name}');
// Metodo para escuchar los cambios del User
FireDocument(refUser(userId), User()).stream.listen((user) => print('Name: ${user.name}'));
}
Como podemos observar, el código es mucho más conciso y legible. La magia detrás de esta simplificación reside en la clase FireDocument, y en las extensiones que hay detrás.
Implementación
1.Clase Abstracta FireModel
:
abstract class FireModel {
FireModel();
factory FireModel.fromMap(Map<String, Object?> data) => throw UnimplementedError();
Map<String, dynamic> get toMap;
FireModel toModel(Map<String, Object?> data);
}
Esta clase abstracta define la estructura básica para nuestros modelos de datos. Obliga a implementar los métodos toMap y toModel, que se encargaran de la conversión entre el modelo de datos de Flutter y el mapa de datos de Firebase.
2.Extensiones:
La clave de este sistema reside en las extensiones sobre Object?
, ya que Firebase Firestore y Database nos pueden traer cualquier tipo de dato. Para convertir ese dato a un tipo de Flutter y asegurarnos que se hace bien vamos a crear una función llamada as<T>({T? defaultVal})
. Además extenderemos esta función con otras tres funciones para poder recuperar valores null cuando el dato no se encuentre en el documento y para poder mapear listas rápidamente, llamadas: asOrNull<T>()
, asList<T>()
, asListOrNull<T>()
.
extension FirestoreMapping on Object? {
T as<T>({T? defaultVal}) {
final type = typeOf<T>();
final res = switch (this) {
T val => val,
num val when type == int => val.toInt() as T,
num val when type == double => val.toDouble() as T,
String val when type == Color => Color(int.parse('0x$val')) as T,
dynamic val when type == Timestamp => getTimestamp(val) as T,
_ when defaultVal != null => defaultVal,
_ when type == String => '' as T,
_ when type == int => 0 as T,
_ when type == double => 0.0 as T,
_ when type == bool => false as T,
_ when type == Color => Colors.transparent as T,
_ => throw Exception('Type not supported'),
};
return res;
}
T? asOrNull<T>() => switch (this) {
null => null,
_ => as<T>(),
};
List<T> asList<T>({List<T>? defaultVal}) {
if (this case Iterable val) {
return List<T>.from(val).map((v) => v.as<T>()).toList();
}
return defaultVal ?? [];
}
List<T>? asListOrNull<T>() {
if (this == null) return null;
return asList<T>();
}
}
Type typeOf<X>() => X;
Timestamp getTimestamp(dynamic val) => switch (val) {
Timestamp val => val,
String time => Timestamp.fromDate(DateTime.parse(time)),
{'_seconds': int seconds, '_nanoseconds': int nano} => Timestamp(seconds, nano),
int val => Timestamp.fromMillisecondsSinceEpoch(val),
_ => Timestamp.now(),
};
Cuando llamamos a la función as<T>({T? defaultVal})
debemos especificar en T
el tipo que queremos que sea ese valor: String
, int
, Color
,... Si el valor que viene de Firebase no corresponde con el tipo que hemos pedido la función nos devolverá un valor por defecto, por ejemplo para un String
nos devolverá ""
y para un int
nos devolverá 0
.
Podemos sustituir ese valor por defecto pasando el parámetro opcional defaultVal
en las función.
Si queremos detectar si ese campo está presente en el documento podemos usar las funciones asOrNull<T>()
y asListOrNull<T>()
.
*La función typeOf
un poco más abajo es para que podamos comparar el valor con el tipo que nosotros queremos, cuando hacemos type == String
en el switch.
Por último las extensiones de DocumentSnapshot
y Color
nos van a servir en el siguiente paso. Principalmente Color
yo la uso para poder guardar colores en Firebase usando solo el codigo HEX, pero si no vas a guardar colores, puedes ahorratelo y borrarlo del switch
en la función as<T>()
.
Este sería el resultado de un nuevo modelo usando todo lo anterior:
import 'package:test_model/services/firebase/extensions.dart';
// Extendemos [FireModel] para que la classe [FireDocument] tenga la garantia de que existen las funciones
// [toMap] y [toModel]
class User extends FireModel {
final String id;
String name;
String? avatar;
Color backgroundColor;
List<String> friends;
// Ahora podemos tener el constructor por defecto sin los parametros y simplificar el
// create una instacia "vacia"
User() : this.fromMap({});
// Aqui usamos nuestras extensiones `as<T>()` garantizando los resultados
User.fromMap(Map<String, Object?> data)
: id = data['id'].as<String>(),
name = data['name'].as<String>(),
avatar = data['avatar'].asOrNull<String>(),
backgroundColor = data['backgroundColor'].as<Color>(),
friends = data['friends'].asList<String>();
@override
get toMap => {
'id': id,
'name': name,
'avatar': avatar,
// Aquí uso la extension del Color para recuperar el código HEX y guardalo en Firebase
'backgroundColor': backgroundColor.toMap,
'friends': friends,
};
// Este méto puede llamar al contructor que queramos. En esta caso `fromMap`.
@override
User toModel(Map<String, Object?> data) => User.fromMap(data);
}
3.Clases FireDocument
y FireCollection
:
Estas clases encapsulan la lógica para interactuar con documentos y colecciones de Firebase, respectivamente. Proporcionan métodos como data
, dataOrNull
, stream
y streamOrNull
para obtener los datos de forma segura y mapeados a nuestro modelo.
class FireDocument<T extends FireModel> {
// Esta es la referencia al doc que queremos traer
final DocumentReference ref;
// Le pasamos una instancia de la clase a la que queremos mapear nuestros datos
final T type;
// Estas variables son opcionales, yo las pongo para poder configurar si los datos
// se leen solo de la cache o no. Para poder optimizar costes.
ListenSource streamSource;
GetOptions? dataOptions;
FireDocument(this.ref, this.type, {this.streamSource = ListenSource.defaultSource, this.dataOptions});
// Aqui llamamos a la extension de [DocumentSnapshot] que teníamos antes para el metodo [dataAsMap]
// no es realemente necesario hacerlo asi, pero queda más limpio ya que lo usamos en las collectiones tambien.
T _snapAsT(DocumentSnapshot snap) => type.toModel(snap.dataAsMap) as T;
Future<T> get data => ref.get(dataOptions).then((snap) => _snapAsT(snap));
Future<T?> get dataOrNull => ref.get(dataOptions).then((snap) => snap.exists ? _snapAsT(snap) : null);
Stream<T> get stream => ref.snapshots(source: streamSource).map(_snapAsT);
Stream<T?> get streamOrNull => ref.snapshots(source: streamSource).map((snap) => snap.exists ? _snapAsT(snap) : null);
Future<void> get upSet => ref.set(type.toMap, SetOptions(merge: true));
}
// Esta clase es para lo mismo pero con las collecciones en Firestore.
class FireCollection<T extends FireModel> {
Query query;
final T instance;
ListenSource streamSource;
GetOptions? dataOptions;
FireCollection(this.query, this.instance, {this.streamSource = ListenSource.defaultSource, this.dataOptions});
List<T> _snapsAsListT(QuerySnapshot<Object?> snaps) =>
snaps.docs.map((snap) => instance.toModel(snap.dataAsMap) as T).toList();
Future<List<T>> get data async {
var snapshots = await query.get(dataOptions);
return _snapsAsListT(snapshots);
}
Future<List<T>?> get dataOrNull async {
var snapshots = await query.get(dataOptions);
return snapshots.docs.isEmpty ? null : _snapsAsListT(snapshots);
}
Stream<List<T>> get stream => query.snapshots(source: streamSource).map(_snapsAsListT);
Stream<List<T>?> get streamOrNull => query
.snapshots(source: streamSource)
.map((snapshots) => snapshots.docs.isEmpty ? null : _snapsAsListT(snapshots));
}
Conclusiones
Y listo, este sistema lo podemos usar con cualquier Modelo, y nos permite siempre llamar a la info de Firebase Firestore con facilidad.
Resultado:
getUser(userId) async {
final db = FirebaseFirestore.instance;
DocumentReference refUser(String userId) => db.collection('users').doc(userId);
final user = await FireDocument(refUser(userId), User()).data;
print('Name: ${user.name}');
FireDocument(refUser(userId), User()).stream.listen((user) {
print('Name: ${user.name}');
});
FireCollection(db.collection('users'), User()).stream.listen((users) {
users.forEach((user) => print('Name: ${user.name}'));
});
}
Aunque este ejemplo está más centrado en Firestore, el mapeo y la lógica se pueden adaptar facilmente para Realtime Database.
Este sistema de mapeo automatizado ofrece varias ventajas:
-** Reducción de código repetitivo:** Simplifica la interacción con Firebase.
- Manejo de tipos seguro: Minimiza los errores de conversión de tipos.
- Manejo de nulos: Proporciona métodos para manejar datos nulos de forma segura.
- Fácil de extender: Se puede adaptar fácilmente a nuevos tipos de datos y modelos.
Este enfoque no solo mejora la legibilidad y mantenibilidad de nuestro código, sino que también reduce la probabilidad de errores y nos permite centrarnos en la lógica de nuestra aplicación.
Dejame un comentario con tu opinión, si te ha ayudado e ideas para poder mejorarlo ;)
Gracias por tomarte el tiempo de leer.
Fer Luca.
Sígueme y ayúdame a programar una IA azul con mucho pelo:
TikTok: https://www.tiktok.com/@flucadetena
Youtube: https://www.youtube.com/@Flucadetena
Twitch: https://www.twitch.tv/flucadetena
X: https://x.com/F_lucadetena
Insta: https://www.instagram.com/f.lucadetena/
Posted on November 22, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 22, 2024