¿Cómo unir datos de dos colecciones de Firestore en Flutter?

9

Tengo una aplicación de chat en Flutter usando Firestore, y tengo dos colecciones principales:

  • chats, Cuya forma impide el auto-ids, y tiene message, timestampy uidcampos.
  • users, que está activado uidy tiene un namecampo

En mi aplicación muestro una lista de mensajes (de la messagescolección), con este widget:

class ChatList extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    var messagesSnapshot = Firestore.instance.collection("chat").orderBy("timestamp", descending: true).snapshots();
    var streamBuilder = StreamBuilder<QuerySnapshot>(
          stream: messagesSnapshot,
          builder: (BuildContext context, AsyncSnapshot<QuerySnapshot> querySnapshot) {
            if (querySnapshot.hasError)
              return new Text('Error: ${querySnapshot.error}');
            switch (querySnapshot.connectionState) {
              case ConnectionState.waiting: return new Text("Loading...");
              default:
                return new ListView(
                  children: querySnapshot.data.documents.map((DocumentSnapshot doc) {
                    return new ListTile(
                      title: new Text(doc['message']),
                      subtitle: new Text(DateTime.fromMillisecondsSinceEpoch(doc['timestamp']).toString()),
                    );
                  }).toList()
                );
            }
          }
        );
        return streamBuilder;
  }
}

Pero ahora quiero mostrar el nombre del usuario (de la userscolección) para cada mensaje.

Normalmente lo llamo una unión del lado del cliente, aunque no estoy seguro de si Flutter tiene un nombre específico para ello.

He encontrado una forma de hacer esto (que he publicado a continuación), pero me pregunto si hay otra forma mejor / más idiomática de hacer este tipo de operación en Flutter.

Entonces: ¿cuál es la forma idiomática en Flutter de buscar el nombre de usuario para cada mensaje en la estructura anterior?

Frank van Puffelen
fuente
Creo que la única solución que he investigado mucho es rxdart
Cenk YAGMUR

Respuestas:

3

Tengo otra versión funcionando que parece un poco mejor que mi respuesta con los dos constructores anidados .

Aquí aislé la carga de datos en un método personalizado, usando una Messageclase dedicada para contener la información de un mensaje Documenty el usuario asociado opcional Document.

class Message {
  final message;
  final timestamp;
  final uid;
  final user;
  const Message(this.message, this.timestamp, this.uid, this.user);
}
class ChatList extends StatelessWidget {
  Stream<List<Message>> getData() async* {
    var messagesStream = Firestore.instance.collection("chat").orderBy("timestamp", descending: true).snapshots();
    var messages = List<Message>();
    await for (var messagesSnapshot in messagesStream) {
      for (var messageDoc in messagesSnapshot.documents) {
        var message;
        if (messageDoc["uid"] != null) {
          var userSnapshot = await Firestore.instance.collection("users").document(messageDoc["uid"]).get();
          message = Message(messageDoc["message"], messageDoc["timestamp"], messageDoc["uid"], userSnapshot["name"]);
        }
        else {
          message = Message(messageDoc["message"], messageDoc["timestamp"], "", "");
        }
        messages.add(message);
      }
      yield messages;
    }
  }
  @override
  Widget build(BuildContext context) {
    var streamBuilder = StreamBuilder<List<Message>>(
          stream: getData(),
          builder: (BuildContext context, AsyncSnapshot<List<Message>> messagesSnapshot) {
            if (messagesSnapshot.hasError)
              return new Text('Error: ${messagesSnapshot.error}');
            switch (messagesSnapshot.connectionState) {
              case ConnectionState.waiting: return new Text("Loading...");
              default:
                return new ListView(
                  children: messagesSnapshot.data.map((Message msg) {
                    return new ListTile(
                      title: new Text(msg.message),
                      subtitle: new Text(DateTime.fromMillisecondsSinceEpoch(msg.timestamp).toString()
                                         +"\n"+(msg.user ?? msg.uid)),
                    );
                  }).toList()
                );
            }
          }
        );
        return streamBuilder;
  }
}

En comparación con la solución con los constructores anidados, este código es más legible, principalmente porque el manejo de datos y el generador de IU están mejor separados. También solo carga los documentos de usuario para los usuarios que han publicado mensajes. Desafortunadamente, si el usuario ha publicado varios mensajes, cargará el documento para cada mensaje. Podría agregar un caché, pero creo que este código ya es un poco largo para lo que logra.

Frank van Puffelen
fuente
1
Si no toma "almacenar información del usuario dentro del mensaje" como respuesta, creo que es lo mejor que puede hacer. Si almacena la información del usuario dentro del mensaje, existe este inconveniente obvio de que la información del usuario puede cambiar en la colección de usuarios, pero no dentro del mensaje. Usando una función de firebase programada, también puede resolver esto. De vez en cuando, puede pasar por la recopilación de mensajes y actualizar la información del usuario de acuerdo con los últimos datos de la recopilación de usuarios.
Ugurcan Yildirim
Personalmente, prefiero una solución más simple como esta en comparación con la combinación de secuencias, a menos que sea realmente necesario. Aún mejor, podríamos refactorizar este método de carga de datos en algo así como una clase de servicio o seguir el patrón BLoC. Como ya ha mencionado, podríamos guardar la información del usuario en un Map<String, UserModel>solo documento y cargarlo una vez.
Joshua Chan
Estuvo de acuerdo Joshua. Me encantaría ver un resumen de cómo se vería esto en un patrón BLoC.
Frank van Puffelen
3

Si estoy leyendo esto correctamente, el problema se resume en: ¿cómo se transforma una secuencia de datos que requiere hacer una llamada asincrónica para modificar los datos en la secuencia?

En el contexto del problema, el flujo de datos es una lista de mensajes, y la llamada asincrónica es buscar los datos del usuario y actualizar los mensajes con estos datos en el flujo.

Es posible hacer esto directamente en un objeto de flujo Dart usando la asyncMap()función. Aquí hay un código Dart puro que demuestra cómo hacerlo:

import 'dart:async';
import 'dart:math' show Random;

final random = Random();

const messageList = [
  {
    'message': 'Message 1',
    'timestamp': 1,
    'uid': 1,
  },
  {
    'message': 'Message 2',
    'timestamp': 2,
    'uid': 2,
  },
  {
    'message': 'Message 3',
    'timestamp': 3,
    'uid': 2,
  },
];

const userList = {
  1: 'User 1',
  2: 'User 2',
  3: 'User 3',
};

class Message {
  final String message;
  final int timestamp;
  final int uid;
  final String user;
  const Message(this.message, this.timestamp, this.uid, this.user);

  @override
  String toString() => '$user => $message';
}

// Mimic a stream of a list of messages
Stream<List<Map<String, dynamic>>> getServerMessagesMock() async* {
  yield messageList;
  while (true) {
    await Future.delayed(Duration(seconds: random.nextInt(3) + 1));
    yield messageList;
  }
}

// Mimic asynchronously fetching a user
Future<String> userMock(int uid) => userList.containsKey(uid)
    ? Future.delayed(
        Duration(milliseconds: 100 + random.nextInt(100)),
        () => userList[uid],
      )
    : Future.value(null);

// Transform the contents of a stream asynchronously
Stream<List<Message>> getMessagesStream() => getServerMessagesMock()
    .asyncMap<List<Message>>((messageList) => Future.wait(
          messageList.map<Future<Message>>(
            (m) async => Message(
              m['message'],
              m['timestamp'],
              m['uid'],
              await userMock(m['uid']),
            ),
          ),
        ));

void main() async {
  print('Streams with async transforms test');
  await for (var messages in getMessagesStream()) {
    messages.forEach(print);
  }
}

La mayor parte del código está imitando los datos que provienen de Firebase como una secuencia de un mapa de mensajes y una función asíncrona para obtener datos del usuario. La función importante aquí es getMessagesStream().

El código se complica ligeramente por el hecho de que es una lista de mensajes que llegan en la transmisión. Para evitar que las llamadas para obtener datos de usuario ocurran de forma síncrona, el código usa a Future.wait()para recopilar ay List<Future<Message>>crear un List<Message>cuando todos los Futuros se hayan completado.

En el contexto de Flutter, puede usar la secuencia proveniente de getMessagesStream()a FutureBuilderpara mostrar los objetos del Mensaje.

Matt S.
fuente
3

Puede hacerlo con RxDart así ... https://pub.dev/packages/rxdart

import 'package:rxdart/rxdart.dart';

class Messages {
  final String messages;
  final DateTime timestamp;
  final String uid;
  final DocumentReference reference;

  Messages.fromMap(Map<String, dynamic> map, {this.reference})
      : messages = map['messages'],
        timestamp = (map['timestamp'] as Timestamp)?.toDate(),
        uid = map['uid'];

  Messages.fromSnapshot(DocumentSnapshot snapshot)
      : this.fromMap(snapshot.data, reference: snapshot.reference);

  @override
  String toString() {
    return 'Messages{messages: $messages, timestamp: $timestamp, uid: $uid, reference: $reference}';
  }
}

class Users {
  final String name;
  final DocumentReference reference;

  Users.fromMap(Map<String, dynamic> map, {this.reference})
      : name = map['name'];

  Users.fromSnapshot(DocumentSnapshot snapshot)
      : this.fromMap(snapshot.data, reference: snapshot.reference);

  @override
  String toString() {
    return 'Users{name: $name, reference: $reference}';
  }
}

class CombineStream {
  final Messages messages;
  final Users users;

  CombineStream(this.messages, this.users);
}

Stream<List<CombineStream>> _combineStream;

@override
  void initState() {
    super.initState();
    _combineStream = Observable(Firestore.instance
        .collection('chat')
        .orderBy("timestamp", descending: true)
        .snapshots())
        .map((convert) {
      return convert.documents.map((f) {

        Stream<Messages> messages = Observable.just(f)
            .map<Messages>((document) => Messages.fromSnapshot(document));

        Stream<Users> user = Firestore.instance
            .collection("users")
            .document(f.data['uid'])
            .snapshots()
            .map<Users>((document) => Users.fromSnapshot(document));

        return Observable.combineLatest2(
            messages, user, (messages, user) => CombineStream(messages, user));
      });
    }).switchMap((observables) {
      return observables.length > 0
          ? Observable.combineLatestList(observables)
          : Observable.just([]);
    })
}

para rxdart 0.23.x

@override
      void initState() {
        super.initState();
        _combineStream = Firestore.instance
            .collection('chat')
            .orderBy("timestamp", descending: true)
            .snapshots()
            .map((convert) {
          return convert.documents.map((f) {

            Stream<Messages> messages = Stream.value(f)
                .map<Messages>((document) => Messages.fromSnapshot(document));

            Stream<Users> user = Firestore.instance
                .collection("users")
                .document(f.data['uid'])
                .snapshots()
                .map<Users>((document) => Users.fromSnapshot(document));

            return Rx.combineLatest2(
                messages, user, (messages, user) => CombineStream(messages, user));
          });
        }).switchMap((observables) {
          return observables.length > 0
              ? Rx.combineLatestList(observables)
              : Stream.value([]);
        })
    }
Cenk YAGMUR
fuente
¡Muy genial! ¿Hay alguna manera de no necesitarlo f.reference.snapshots(), ya que eso es esencialmente volver a cargar la instantánea y preferiría no confiar en que el cliente de Firestore sea lo suficientemente inteligente como para deduplicarlos (aunque estoy casi seguro de que deduplica).
Frank van Puffelen
Lo encontré. En lugar de Stream<Messages> messages = f.reference.snapshots()..., puedes hacerlo Stream<Messages> messages = Observable.just(f).... Lo que me gusta de esta respuesta es que observa los documentos del usuario, por lo que si se actualiza un nombre de usuario en la base de datos, el resultado lo refleja de inmediato.
Frank van Puffelen
Sí, estoy trabajando tan bien así, estoy actualizando mi código
Cenk YAGMUR
1

Idealmente, desea excluir cualquier lógica empresarial, como la carga de datos en un servicio separado o seguir el patrón BloC, por ejemplo:

class ChatBloc {
  final Firestore firestore = Firestore.instance;
  final Map<String, String> userMap = HashMap<String, String>();

  Stream<List<Message>> get messages async* {
    final messagesStream = Firestore.instance.collection('chat').orderBy('timestamp', descending: true).snapshots();
    var messages = List<Message>();
    await for (var messagesSnapshot in messagesStream) {
      for (var messageDoc in messagesSnapshot.documents) {
        final userUid = messageDoc['uid'];
        var message;

        if (userUid != null) {
          // get user data if not in map
          if (userMap.containsKey(userUid)) {
            message = Message(messageDoc['message'], messageDoc['timestamp'], userUid, userMap[userUid]);
          } else {
            final userSnapshot = await Firestore.instance.collection('users').document(userUid).get();
            message = Message(messageDoc['message'], messageDoc['timestamp'], userUid, userSnapshot['name']);
            // add entry to map
            userMap[userUid] = userSnapshot['name'];
          }
        } else {
          message =
              Message(messageDoc['message'], messageDoc['timestamp'], '', '');
        }
        messages.add(message);
      }
      yield messages;
    }
  }
}

Luego puede usar el Bloque en su componente y escuchar la chatBloc.messagestransmisión.

class ChatList extends StatelessWidget {
  final ChatBloc chatBloc = ChatBloc();

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<List<Message>>(
        stream: chatBloc.messages,
        builder: (BuildContext context, AsyncSnapshot<List<Message>> messagesSnapshot) {
          if (messagesSnapshot.hasError)
            return new Text('Error: ${messagesSnapshot.error}');
          switch (messagesSnapshot.connectionState) {
            case ConnectionState.waiting:
              return new Text('Loading...');
            default:
              return new ListView(children: messagesSnapshot.data.map((Message msg) {
                return new ListTile(
                  title: new Text(msg.message),
                  subtitle: new Text('${msg.timestamp}\n${(msg.user ?? msg.uid)}'),
                );
              }).toList());
          }
        });
  }
}
Joshua Chan
fuente
1

Permítame presentar mi versión de una solución RxDart. Utilizo combineLatest2con un ListView.builderpara construir cada mensaje Widget. Durante la construcción de cada mensaje Widget busco el nombre del usuario con el correspondiente uid.

En este fragmento, uso una búsqueda lineal para el nombre del usuario, pero eso se puede mejorar creando un uid -> user namemapa

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/widgets.dart';
import 'package:rxdart/rxdart.dart';

class MessageWidget extends StatelessWidget {
  // final chatStream = Firestore.instance.collection('chat').snapshots();
  // final userStream = Firestore.instance.collection('users').snapshots();
  Stream<QuerySnapshot> chatStream;
  Stream<QuerySnapshot> userStream;

  MessageWidget(this.chatStream, this.userStream);

  @override
  Widget build(BuildContext context) {
    Observable<List<QuerySnapshot>> combinedStream = Observable.combineLatest2(
        chatStream, userStream, (messages, users) => [messages, users]);

    return StreamBuilder(
        stream: combinedStream,
        builder: (_, AsyncSnapshot<List<QuerySnapshot>> snapshots) {
          if (snapshots.hasData) {
            List<DocumentSnapshot> chats = snapshots.data[0].documents;

            // It would be more efficient to convert this list of user documents
            // to a map keyed on the uid which will allow quicker user lookup.
            List<DocumentSnapshot> users = snapshots.data[1].documents;

            return ListView.builder(itemBuilder: (_, index) {
              return Center(
                child: Column(
                  children: <Widget>[
                    Text(chats[index]['message']),
                    Text(getUserName(users, chats[index]['uid'])),
                  ],
                ),
              );
            });
          } else {
            return Text('loading...');
          }
        });
  }

  // This does a linear search through the list of users. However a map
  // could be used to make the finding of the user's name more efficient.
  String getUserName(List<DocumentSnapshot> users, String uid) {
    for (final user in users) {
      if (user['uid'] == uid) {
        return user['name'];
      }
    }
    return 'unknown';
  }
}
Arthur Thompson
fuente
Es genial ver a Arthur. Esto es como una versión mucho más limpia de mi respuesta inicial con los constructores anidados . Definitivamente una de las soluciones más simples para leer.
Frank van Puffelen
0

La primera solución que conseguí es anidar dos StreamBuilderinstancias, una para cada colección / consulta.

class ChatList extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    var messagesSnapshot = Firestore.instance.collection("chat").orderBy("timestamp", descending: true).snapshots();
    var usersSnapshot = Firestore.instance.collection("users").snapshots();
    var streamBuilder = StreamBuilder<QuerySnapshot>(
      stream: messagesSnapshot,
      builder: (BuildContext context, AsyncSnapshot<QuerySnapshot> messagesSnapshot) {
        return StreamBuilder(
          stream: usersSnapshot,
          builder: (context, usersSnapshot) {
            if (messagesSnapshot.hasError || usersSnapshot.hasError || !usersSnapshot.hasData)
              return new Text('Error: ${messagesSnapshot.error}, ${usersSnapshot.error}');
            switch (messagesSnapshot.connectionState) {
              case ConnectionState.waiting: return new Text("Loading...");
              default:
                return new ListView(
                  children: messagesSnapshot.data.documents.map((DocumentSnapshot doc) {
                    var user = "";
                    if (doc['uid'] != null && usersSnapshot.data != null) {
                      user = doc['uid'];
                      print('Looking for user $user');
                      user = usersSnapshot.data.documents.firstWhere((userDoc) => userDoc.documentID == user).data["name"];
                    }
                    return new ListTile(
                      title: new Text(doc['message']),
                      subtitle: new Text(DateTime.fromMillisecondsSinceEpoch(doc['timestamp']).toString()
                                          +"\n"+user),
                    );
                  }).toList()
                );
            }
        });
      }
    );
    return streamBuilder;
  }
}

Como dije en mi pregunta, sé que esta solución no es excelente, pero al menos funciona.

Algunos problemas que veo con esto:

  • Carga todos los usuarios, en lugar de solo los usuarios que publicaron mensajes. En pequeños conjuntos de datos, eso no será un problema, pero a medida que reciba más mensajes / usuarios (y use una consulta para mostrar un subconjunto de ellos) estaré cargando más y más usuarios que no publicaron ningún mensaje.
  • El código no es realmente muy legible con la anidación de dos constructores. Dudo que esto sea Flutter idiomático.

Si conoce una mejor solución, publique como respuesta.

Frank van Puffelen
fuente