How do I join data from two Firestore collections in Flutter?

前端 未结 6 851
名媛妹妹
名媛妹妹 2020-11-29 02:27

I have a chat app in Flutter using Firestore, and I have two main collections:

  • chats, which is keyed on auto-ids, and has message,
相关标签:
6条回答
  • 2020-11-29 02:54

    You can do it with RxDart like that.. 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([]);
        })
    }
    

    for 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([]);
            })
        }
    
    0 讨论(0)
  • 2020-11-29 02:54

    Allow me to put forth my version of an RxDart solution. I use combineLatest2 with a ListView.builder to build each message Widget. During the construction of each message Widget I lookup the name of the user with the corresponding uid.

    In this snippet I use a linear lookup for the user's name but that can be improved by creating a uid -> user name map

    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';
      }
    }
    
    0 讨论(0)
  • 2020-11-29 02:57

    If I'm reading this correctly, the problem abstracts to: how do you transform a stream of data which requires making an asynchronous call to modify data in the stream?

    In the context of the problem, the stream of data is a list of messages, and the async call is to fetch the user data and update the messages with this data in the stream.

    It's possible to do this directly in a Dart stream object using the asyncMap() function. Here's some pure Dart code that demonstrates how to do it:

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

    Most of the code is mimicking the data coming from Firebase as a stream of a map of messages, and an async function to fetch user data. The important function here is getMessagesStream().

    The code is complicated slightly by the fact that it's a list of messages coming in the stream. To prevent calls to fetch user data from occurring synchronously, the code uses a Future.wait() to gather a List<Future<Message>> and create a List<Message> when all the Futures have completed.

    In the context of Flutter, you can use the stream coming from getMessagesStream() in a FutureBuilder to display the Message objects.

    0 讨论(0)
  • 2020-11-29 02:57

    Ideally you want to exclude any business logic such as data loading into a separate service or following the BloC pattern, e.g.:

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

    Then you can just use the Bloc in your component and listen to the chatBloc.messages stream.

    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());
              }
            });
      }
    }
    
    0 讨论(0)
  • 2020-11-29 03:04

    The first solution I got working is to nest two StreamBuilder instances, one for each collection/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;
      }
    }
    

    As stated in my question, I know this solution is not great, but at least it works.

    Some problems I see with this:

    • It loads all users, instead of just the users who posted messages. In small data sets that won't be a problem, but as I get more messages/users (and use a query to show a subset of them) I'll be loading more and more users who didn't post any messages.
    • The code is not really very readable with the nesting of two builders. I doubt this is idiomatic Flutter.

    If you know a better solution, please post as an answer.

    0 讨论(0)
  • 2020-11-29 03:14

    I got another version working which seems slightly better than my answer with the two nested builders.

    Here I isolated on the data loading in a custom method, using a dedicated Message class to hold the information from a message Document and the optional associated user 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;
      }
    }
    

    Compared to the solution with nested builders this code is more readable, mostly because the data handling and the UI builder are better separated. It also only loads the user documents for users that have posted messages. Unfortunately, if the user has posted multiple messages, it will load the document for each message. I could add a cache, but think this code is already a bit long for what it accomplishes.

    0 讨论(0)
提交回复
热议问题