Wie verbinde ich Daten aus zwei Firestore-Sammlungen in Flutter?

9

Ich habe eine Chat-App in Flutter mit Firestore und zwei Hauptsammlungen:

  • chatsWird die auf Auto-IDs eingegeben hat , und hat message, timestampund uidFelder.
  • users, die eingegeben uidist und ein nameFeld hat

In meiner App zeige ich eine Liste von Nachrichten (aus der messagesSammlung) mit diesem 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;
  }
}

Aber jetzt möchte ich den Namen des Benutzers (aus der usersSammlung) für jede Nachricht anzeigen.

Normalerweise nenne ich das einen clientseitigen Join, obwohl ich nicht sicher bin, ob Flutter einen bestimmten Namen dafür hat.

Ich habe einen Weg gefunden, dies zu tun (den ich unten gepostet habe), aber ich frage mich, ob es einen anderen / besseren / idiomatischeren Weg gibt, um diese Art von Operation in Flutter durchzuführen.

Also: Wie wird in Flutter idiomatisch nach dem Benutzernamen für jede Nachricht in der obigen Struktur gesucht?

Frank van Puffelen
quelle
Ich denke, die einzige Lösung, die ich viel recherchiert habe
Cenk YAGMUR

Antworten:

3

Ich habe eine andere Version zum Laufen gebracht, die etwas besser zu sein scheint als meine Antwort mit den beiden verschachtelten Buildern .

Hier habe ich das Laden von Daten in einer benutzerdefinierten Methode isoliert und eine dedizierte MessageKlasse verwendet, um die Informationen aus einer Nachricht Documentund dem optionalen zugeordneten Benutzer zu speichern 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;
  }
}

Im Vergleich zur Lösung mit verschachtelten Buildern ist dieser Code besser lesbar, hauptsächlich weil die Datenverarbeitung und der UI-Builder besser voneinander getrennt sind. Außerdem werden nur die Benutzerdokumente für Benutzer geladen, die Nachrichten gepostet haben. Wenn der Benutzer mehrere Nachrichten gepostet hat, wird das Dokument leider für jede Nachricht geladen. Ich könnte einen Cache hinzufügen, aber ich denke, dieser Code ist schon ein bisschen lang für das, was er erreicht.

Frank van Puffelen
quelle
1
Wenn Sie "Speichern von Benutzerinformationen in Nachrichten" nicht als Antwort nehmen, ist dies meiner Meinung nach das Beste, was Sie tun können. Wenn Sie Benutzerinformationen in Nachrichten speichern, gibt es diesen offensichtlichen Nachteil, dass sich Benutzerinformationen in der Benutzersammlung ändern können, jedoch nicht in Nachrichten. Mit einer geplanten Firebase-Funktion können Sie dies auch beheben. Von Zeit zu Zeit können Sie die Nachrichtensammlung durchlaufen und Benutzerinformationen gemäß den neuesten Daten in der Benutzersammlung aktualisieren.
Ugurcan Yildirim
Persönlich bevorzuge ich eine einfachere Lösung wie diese als das Kombinieren von Streams, sofern dies nicht wirklich notwendig ist. Noch besser ist, wir könnten diese Methode zum Laden von Daten in eine Art Serviceklasse umgestalten oder dem BLoC-Muster folgen. Wie Sie bereits erwähnt haben, können wir die Benutzerinformationen in einem speichern und das Benutzerdokument Map<String, UserModel>nur einmal laden.
Joshua Chan
Einverstanden Joshua. Ich würde gerne sehen, wie dies in einem BLoC-Muster aussehen würde.
Frank van Puffelen
3

Wenn ich dies richtig lese, wird das Problem wie folgt abstrahiert: Wie transformieren Sie einen Datenstrom, für den ein asynchroner Aufruf erforderlich ist, um Daten im Datenstrom zu ändern?

Im Kontext des Problems ist der Datenstrom eine Liste von Nachrichten, und der asynchrone Aufruf besteht darin, die Benutzerdaten abzurufen und die Nachrichten mit diesen Daten im Strom zu aktualisieren.

Mit der asyncMap()Funktion ist dies direkt in einem Dart-Stream-Objekt möglich . Hier ist ein reiner Dart-Code, der zeigt, wie es geht:

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

Der größte Teil des Codes ahmt die von Firebase kommenden Daten als Stream einer Nachrichtenkarte und als asynchrone Funktion zum Abrufen von Benutzerdaten nach. Die wichtige Funktion hier ist getMessagesStream().

Der Code wird leicht durch die Tatsache kompliziert, dass es sich um eine Liste von Nachrichten handelt, die in den Stream kommen. Um zu verhindern, dass Aufrufe zum Abrufen von Benutzerdaten synchron erfolgen, verwendet der Code a Future.wait(), um a zu erfassen List<Future<Message>>und a zu erstellen, List<Message>wenn alle Futures abgeschlossen sind.

Im Kontext von Flutter können Sie den Stream aus getMessagesStream()a verwenden FutureBuilder, um die Nachrichtenobjekte anzuzeigen.

Matt S.
quelle
3

Sie können es mit RxDart so machen .. 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([]);
    })
}

für 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
quelle
Sehr cool! Gibt es eine Möglichkeit, dies nicht zu benötigen f.reference.snapshots(), da dadurch der Snapshot im Wesentlichen neu geladen wird und ich mich lieber nicht darauf verlassen möchte, dass der Firestore-Client intelligent genug ist, um diese zu deduplizieren (obwohl ich fast sicher bin, dass er dedupliziert).
Frank van Puffelen
Fand es. Stattdessen Stream<Messages> messages = f.reference.snapshots()...können Sie tun Stream<Messages> messages = Observable.just(f).... Was mir an dieser Antwort gefällt, ist, dass sie die Benutzerdokumente beobachtet. Wenn also ein Benutzername in der Datenbank aktualisiert wird, spiegelt die Ausgabe dies sofort wider.
Frank van Puffelen
Ja, ich arbeite so gut, dass ich meinen Code aktualisiere
Cenk YAGMUR
1

Idealerweise möchten Sie jede Geschäftslogik ausschließen, z. B. das Laden von Daten in einen separaten Dienst oder das Befolgen des BloC-Musters, z.

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

Dann können Sie einfach den Block in Ihrer Komponente verwenden und den chatBloc.messagesStream anhören .

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
quelle
1

Gestatten Sie mir, meine Version einer RxDart-Lösung herauszubringen. Ich benutze combineLatest2mit einem ListView.builder, um jede Nachricht Widget zu erstellen. Während der Erstellung jedes Nachrichten-Widgets suche ich den Namen des Benutzers mit dem entsprechenden uid.

In diesem Snippet verwende ich eine lineare Suche nach dem Namen des Benutzers, aber das kann durch Erstellen einer uid -> user nameKarte verbessert werden

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
quelle
Sehr cool, Arthur zu sehen. Dies ist wie eine viel sauberere Version meiner ersten Antwort mit verschachtelten Buildern . Auf jeden Fall eine der einfacheren Lösungen zum Lesen.
Frank van Puffelen
0

Die erste Lösung, die ich zum Laufen gebracht habe, besteht darin, zwei StreamBuilderInstanzen zu verschachteln , eine für jede Sammlung / Abfrage.

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

Wie in meiner Frage angegeben, weiß ich, dass diese Lösung nicht großartig ist, aber zumindest funktioniert sie.

Einige Probleme sehe ich damit:

  • Es werden alle Benutzer geladen, anstatt nur die Benutzer, die Nachrichten gepostet haben. In kleinen Datenmengen ist das kein Problem, aber wenn ich mehr Nachrichten / Benutzer erhalte (und eine Abfrage verwende, um eine Teilmenge davon anzuzeigen), lade ich immer mehr Benutzer, die keine Nachrichten gepostet haben.
  • Der Code ist beim Verschachteln von zwei Buildern nicht wirklich gut lesbar. Ich bezweifle, dass dies ein idiomatisches Flattern ist.

Wenn Sie eine bessere Lösung kennen, posten Sie diese bitte als Antwort.

Frank van Puffelen
quelle