DEV Community

Fernando Luca De Tena Smith
Fernando Luca De Tena Smith

Posted on

Como traer datos de Firebase Firestore /Realtime Database a Flutter de forma sencilla y sin dependencias

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');
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

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 o num.

*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}'));
}
Enter fullscreen mode Exit fullscreen mode

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);

}
Enter fullscreen mode Exit fullscreen mode

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(),
    };
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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}'));
  });
}
Enter fullscreen mode Exit fullscreen mode

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/

Top comments (0)