Comment joindre des données de deux collections Firestore dans Flutter?

9

J'ai une application de chat à Flutter en utilisant Firestore, et j'ai deux collections principales:

  • chats, Qui est claveté sur l' auto-ids, et a message, timestampet les uidchamps.
  • users, qui est activé uidet possède un namechamp

Dans mon application, je montre une liste de messages (de la messagescollection), avec ce 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;
  }
}

Mais maintenant, je veux montrer le nom de l'utilisateur (de la userscollection) pour chaque message.

J'appelle normalement cela une jointure côté client, bien que je ne sois pas sûr que Flutter ait un nom spécifique pour cela.

J'ai trouvé un moyen de le faire (que j'ai posté ci-dessous), mais je me demande s'il existe un autre moyen / meilleur / plus idiomatique pour faire ce type d'opération dans Flutter.

Alors: quelle est la façon idiomatique dans Flutter de rechercher le nom d'utilisateur pour chaque message dans la structure ci-dessus?

Frank van Puffelen
la source
Je pense que la seule solution que j'ai recherchée beaucoup de rxdart
Cenk YAGMUR

Réponses:

3

J'ai obtenu une autre version qui semble légèrement meilleure que ma réponse avec les deux constructeurs imbriqués .

Ici, j'ai isolé le chargement des données dans une méthode personnalisée, en utilisant une Messageclasse dédiée pour contenir les informations d'un message Documentet l'utilisateur associé facultatif 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;
  }
}

Comparé à la solution avec les constructeurs imbriqués, ce code est plus lisible, principalement parce que la gestion des données et le générateur d'interface utilisateur sont mieux séparés. Il charge également uniquement les documents utilisateur pour les utilisateurs qui ont publié des messages. Malheureusement, si l'utilisateur a publié plusieurs messages, il chargera le document pour chaque message. Je pourrais ajouter un cache, mais je pense que ce code est déjà un peu long pour ce qu'il accomplit.

Frank van Puffelen
la source
1
Si vous ne prenez pas "stockage des informations utilisateur dans le message" comme réponse, je pense que c'est le mieux que vous puissiez faire. Si vous stockez les informations utilisateur dans le message, il y a cet inconvénient évident que les informations utilisateur peuvent changer dans la collection des utilisateurs, mais pas dans le message. À l'aide d'une fonction Firebase planifiée, vous pouvez également résoudre ce problème. De temps en temps, vous pouvez parcourir la collecte de messages et mettre à jour les informations utilisateur en fonction des dernières données de la collection d'utilisateurs.
Ugurcan Yildirim
Personnellement, je préfère une solution plus simple comme celle-ci par rapport à la combinaison de flux, sauf si cela est vraiment nécessaire. Encore mieux, nous pourrions refactoriser cette méthode de chargement de données dans quelque chose comme une classe de service ou suivre le modèle BLoC. Comme vous l'avez déjà mentionné, nous pourrions enregistrer les informations utilisateur dans un Map<String, UserModel>et ne charger le document utilisateur qu'une seule fois.
Joshua Chan
D'accord Joshua. J'aimerais voir une description de ce à quoi cela ressemblerait dans un modèle BLoC.
Frank van Puffelen
3

Si je lis cela correctement, le problème résume: comment transformer un flux de données qui nécessite de faire un appel asynchrone pour modifier les données dans le flux?

Dans le contexte du problème, le flux de données est une liste de messages et l'appel asynchrone consiste à récupérer les données utilisateur et à mettre à jour les messages avec ces données dans le flux.

Il est possible de le faire directement dans un objet de flux Dart en utilisant la asyncMap()fonction. Voici du code Dart pur qui montre comment le faire:

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 plupart du code imite les données provenant de Firebase en tant que flux d'une carte de messages et d'une fonction asynchrone pour récupérer les données utilisateur. La fonction importante ici est getMessagesStream().

Le code est légèrement compliqué par le fait qu'il s'agit d'une liste de messages provenant du flux. Pour empêcher les appels à récupérer les données utilisateur de se produire de manière synchrone, le code utilise un Future.wait()pour rassembler un List<Future<Message>>et créer un List<Message>lorsque tous les Futures sont terminés.

Dans le contexte de Flutter, vous pouvez utiliser le flux provenant de getMessagesStream()dans a FutureBuilderpour afficher les objets Message.

Matt S.
la source
3

Vous pouvez le faire avec RxDart comme ça .. 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([]);
    })
}

pour 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
la source
Très cool! Existe-t-il un moyen de ne pas avoir besoin f.reference.snapshots(), car cela consiste essentiellement à recharger l'instantané et je préférerais ne pas compter sur le client Firestore pour qu'il soit assez intelligent pour les dédupliquer (même si je suis presque certain qu'il le fait).
Frank van Puffelen
Je l'ai trouvé. Au lieu de cela Stream<Messages> messages = f.reference.snapshots()..., vous pouvez le faire Stream<Messages> messages = Observable.just(f).... Ce que j'aime dans cette réponse, c'est qu'elle observe les documents utilisateur, donc si un nom d'utilisateur est mis à jour dans la base de données, la sortie le reflète tout de suite.
Frank van Puffelen
Ouais si bien comme ça que je
mets à
1

Idéalement, vous souhaitez exclure toute logique métier telle que le chargement de données dans un service distinct ou en suivant le modèle BloC, par exemple:

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

Ensuite, vous pouvez simplement utiliser le Bloc dans votre composant et écouter le chatBloc.messagesflux.

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
la source
1

Permettez-moi de proposer ma version d'une solution RxDart. J'utilise combineLatest2avec un ListView.builderpour créer chaque widget de message. Lors de la construction de chaque Widget de message je recherche le nom de l'utilisateur avec le correspondantuid .

Dans cet extrait, j'utilise une recherche linéaire pour le nom de l'utilisateur, mais cela peut être amélioré en créant une uid -> user namecarte

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
la source
Très cool de voir Arthur. C'est comme une version beaucoup plus propre de ma réponse initiale avec les constructeurs imbriqués . Certainement l'une des solutions les plus simples à lire.
Frank van Puffelen
0

La première solution que j'ai trouvée consiste à imbriquer deux StreamBuilderinstances, une pour chaque collection / requête.

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

Comme indiqué dans ma question, je sais que cette solution n'est pas excellente, mais au moins cela fonctionne.

Certains problèmes que je vois avec cela:

  • Il charge tous les utilisateurs, au lieu des seuls utilisateurs qui ont posté des messages. Dans les petits ensembles de données, cela ne sera pas un problème, mais à mesure que j'obtiens plus de messages / d'utilisateurs (et que j'utilise une requête pour en afficher un sous-ensemble), je charge de plus en plus d'utilisateurs qui n'ont posté aucun message.
  • Le code n'est pas vraiment très lisible avec l'imbrication de deux constructeurs. Je doute que ce soit un Flutter idiomatique.

Si vous connaissez une meilleure solution, veuillez poster comme réponse.

Frank van Puffelen
la source