Come posso unire i dati di due raccolte Firestore in Flutter?


9

Ho un'app di chat in Flutter con Firestore e ho due raccolte principali:

  • chats, Che è calettato su auto-id, ed ha message, timestampe uidcampi.
  • users, che è impostato su chiave uide ha un namecampo

Nella mia app mostro un elenco di messaggi (dalla messagesraccolta), con questo 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;
  }
}

Ma ora voglio mostrare il nome dell'utente (dalla usersraccolta) per ciascun messaggio.

Normalmente chiamo un join lato client, anche se non sono sicuro che Flutter abbia un nome specifico per questo.

Ho trovato un modo per farlo (che ho pubblicato di seguito), ma mi chiedo se c'è un altro / migliore / più idiomatico modo di fare questo tipo di operazione in Flutter.

Quindi: qual è il modo idiomatico in Flutter di cercare il nome utente per ciascun messaggio nella struttura sopra?


Penso che l'unica soluzione su cui abbia studiato molto rxdart
Cenk YAGMUR,

Risposte:


3

Ho funzionato un'altra versione che sembra leggermente migliore della mia risposta con i due costruttori nidificati .

Qui ho isolato il caricamento dei dati in un metodo personalizzato, utilizzando una Messageclasse dedicata per contenere le informazioni da un messaggio Documente l'utente associato opzionale 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;
  }
}

Rispetto alla soluzione con costruttori nidificati, questo codice è più leggibile, soprattutto perché la gestione dei dati e il builder UI sono meglio separati. Carica anche i documenti utente solo per gli utenti che hanno pubblicato messaggi. Sfortunatamente, se l'utente ha pubblicato più messaggi, caricherà il documento per ciascun messaggio. Potrei aggiungere una cache, ma penso che questo codice sia già un po 'lungo per quello che realizza.


1
Se non consideri la "memorizzazione delle informazioni utente all'interno del messaggio" come risposta, penso che sia la cosa migliore che puoi fare. Se memorizzi le informazioni dell'utente all'interno del messaggio, c'è questo ovvio svantaggio che le informazioni dell'utente potrebbero cambiare nella raccolta degli utenti, ma non all'interno del messaggio. Utilizzando una funzione di base di fuoco pianificata, è possibile risolvere anche questo. Di tanto in tanto, è possibile passare attraverso la raccolta dei messaggi e aggiornare le informazioni degli utenti in base agli ultimi dati nella raccolta degli utenti.
Ugurcan Yildirim,

Personalmente preferisco una soluzione più semplice come questa rispetto alla combinazione di flussi a meno che non sia realmente necessario. Ancora meglio, potremmo trasformare questo metodo di caricamento dei dati in qualcosa come una classe di servizio o seguire il modello BLoC. Come hai già detto, potremmo salvare le informazioni dell'utente in un Map<String, UserModel>e caricare il documento utente solo una volta.
Joshua Chan,

Giosuè concordato. Mi piacerebbe vedere un resoconto di come questo apparirebbe in un modello BLoC.
Frank van Puffelen,

3

Se sto leggendo correttamente, il problema si astiene: come si trasforma un flusso di dati che richiede di effettuare una chiamata asincrona per modificare i dati nel flusso?

Nel contesto del problema, il flusso di dati è un elenco di messaggi e la chiamata asincrona è quella di recuperare i dati dell'utente e aggiornare i messaggi con questi dati nel flusso.

È possibile farlo direttamente in un oggetto stream Dart usando la asyncMap()funzione. Ecco un po 'di codice Dart puro che dimostra come farlo:

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 maggior parte del codice imita i dati provenienti da Firebase come flusso di una mappa di messaggi e una funzione asincrona per recuperare i dati dell'utente. La funzione importante qui è getMessagesStream().

Il codice è leggermente complicato dal fatto che è un elenco di messaggi in arrivo nello stream. Per impedire che le chiamate per recuperare i dati utente avvengano in modo sincrono, il codice utilizza a Future.wait()per raccogliere a List<Future<Message>>e creare un List<Message>quando tutti i Future sono stati completati.

Nel contesto di Flutter, è possibile utilizzare il flusso proveniente da getMessagesStream()in a FutureBuilderper visualizzare gli oggetti Messaggio.


3

Puoi farlo con RxDart in quel modo .. 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([]);
    })
}

per 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([]);
        })
    }

Molto bello! C'è un modo per non averne bisogno f.reference.snapshots(), dato che essenzialmente si sta ricaricando l'istantanea e preferirei non fare affidamento sul fatto che il client Firestore sia abbastanza intelligente da deduplicare quelli (anche se sono quasi certo che lo fa dedupe).
Frank van Puffelen,

Trovato. Invece di Stream<Messages> messages = f.reference.snapshots()..., puoi farlo Stream<Messages> messages = Observable.just(f).... Quello che mi piace di questa risposta è che osserva i documenti dell'utente, quindi se un nome utente viene aggiornato nel database, l'output lo riflette immediatamente.
Frank van Puffelen,

Sì, funziona così bene come quello che sto aggiornando il mio codice
Cenk YAGMUR il

1

Idealmente, si desidera escludere qualsiasi logica aziendale come il caricamento dei dati in un servizio separato o seguendo il modello BloC, ad esempio:

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

Quindi puoi semplicemente usare il blocco nel tuo componente e ascoltare lo chatBloc.messagesstreaming.

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

1

Consentitemi di presentare la mia versione di una soluzione RxDart. Uso combineLatest2con a ListView.builderper creare ogni messaggio Widget. Durante la costruzione di ogni messaggio Widget cerco il nome dell'utente con il corrispondente uid.

In questo frammento utilizzo una ricerca lineare per il nome dell'utente ma che può essere migliorata creando una uid -> user namemappa

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';
  }
}

Molto bello vedere Arthur. Questa è come una versione molto più pulita della mia risposta iniziale con i costruttori nidificati . Sicuramente una delle soluzioni più semplici da leggere.
Frank van Puffelen il

0

La prima soluzione che ho ottenuto è quella di nidificare due StreamBuilderistanze, una per ogni raccolta / query.

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

Come affermato nella mia domanda, so che questa soluzione non è eccezionale, ma almeno funziona.

Alcuni problemi che vedo con questo:

  • Carica tutti gli utenti, anziché solo gli utenti che hanno pubblicato messaggi. In piccoli set di dati che non saranno un problema, ma man mano che ricevo più messaggi / utenti (e utilizzo una query per mostrarne un sottoinsieme) caricherò sempre più utenti che non hanno pubblicato alcun messaggio.
  • Il codice non è davvero molto leggibile con l'annidamento di due costruttori. Dubito che questo sia Flutter idiomatico.

Se conosci una soluzione migliore, pubblica una risposta.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.