Como associo dados de duas coleções do Firestore no Flutter?

9

Eu tenho um aplicativo de bate-papo no Flutter usando o Firestore e tenho duas coleções principais:

  • chats, Que é introduzido na auto-ids, e tem message, timestampe uidcampos.
  • users, que está ativado uide tem um namecampo

No meu aplicativo, mostro uma lista de mensagens (da messagescoleção), com 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;
  }
}

Mas agora eu quero mostrar o nome do usuário (da userscoleção) para cada mensagem.

Normalmente eu chamo isso de associação do lado do cliente, embora não tenha certeza se Flutter tem um nome específico para ela.

Eu encontrei uma maneira de fazer isso (que eu publiquei abaixo), mas me pergunto se existe outra maneira / melhor / mais idiomática de fazer esse tipo de operação no Flutter.

Então: qual é a maneira idiomática no Flutter de procurar o nome de usuário para cada mensagem na estrutura acima?

Frank van Puffelen
fonte
Acho que a única solução que eu pesquisei um monte de rxdart
Cenk Yağmur

Respostas:

3

Eu tenho outra versão funcionando, que parece um pouco melhor do que minha resposta com os dois construtores aninhados .

Aqui, eu isolado no carregamento de dados em um método personalizado, usando uma Messageclasse dedicada para armazenar as informações de uma mensagem Documente o usuário associado 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;
  }
}

Comparado à solução com construtores aninhados, esse código é mais legível, principalmente porque o tratamento de dados e o construtor de UI são melhor separados. Ele também carrega apenas os documentos do usuário para os usuários que postaram mensagens. Infelizmente, se o usuário postou várias mensagens, ele carregará o documento para cada mensagem. Eu poderia adicionar um cache, mas acho que esse código já é um pouco longo para o que ele realiza.

Frank van Puffelen
fonte
11
Se você não aceitar "armazenar informações do usuário dentro da mensagem" como resposta, acho que é o melhor que você pode fazer. Se você armazenar informações do usuário dentro da mensagem, existe uma desvantagem óbvia de que as informações do usuário podem mudar na coleção de usuários, mas não dentro da mensagem. Usando uma função agendada do firebase, você também pode resolver isso. De tempos em tempos, você pode percorrer a coleta de mensagens e atualizar as informações do usuário de acordo com os dados mais recentes da coleção.
Ugurcan Yildirim
Pessoalmente, prefiro uma solução mais simples como essa em comparação à combinação de fluxos, a menos que seja realmente necessário. Melhor ainda, podemos refatorar esse método de carregamento de dados para algo como uma classe de serviço ou seguir o padrão BLoC. Como você já mencionou, podemos salvar as informações do usuário em Map<String, UserModel>e carregar o documento do usuário apenas uma vez.
Joshua Chan
Joshua concordou. Eu adoraria ver uma descrição de como isso ficaria em um padrão BLoC.
Frank van Puffelen
3

Se estou lendo isso corretamente, o problema é resumido em: como você transforma um fluxo de dados que requer uma chamada assíncrona para modificar dados no fluxo?

No contexto do problema, o fluxo de dados é uma lista de mensagens e a chamada assíncrona é buscar os dados do usuário e atualizar as mensagens com esses dados no fluxo.

É possível fazer isso diretamente em um objeto de fluxo Dart usando a asyncMap()função Aqui está um código Dart puro que demonstra como fazê-lo:

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

A maior parte do código está imitando os dados provenientes do Firebase como um fluxo de um mapa de mensagens e uma função assíncrona para buscar dados do usuário. A função importante aqui é getMessagesStream().

O código é um pouco complicado pelo fato de ser uma lista de mensagens que chegam no fluxo. Para impedir que chamadas para buscar dados do usuário ocorram de forma síncrona, o código usa a Future.wait()para reunir a List<Future<Message>>e criar a List<Message>quando todos os futuros tiverem sido concluídos.

No contexto do Flutter, você pode usar o fluxo proveniente de getMessagesStream()a FutureBuilderpara exibir os objetos Message.

Matt S.
fonte
3

Você pode fazer isso com o RxDart assim. 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
fonte
Muito legal! Existe uma maneira de não precisar f.reference.snapshots(), pois isso é essencialmente recarregar o instantâneo e eu preferiria não confiar que o cliente Firestore seja inteligente o suficiente para deduplicá-los (mesmo que eu esteja quase certo de que ele é desduplicado).
Frank van Puffelen
Encontrei. Em vez de Stream<Messages> messages = f.reference.snapshots()..., você pode fazer Stream<Messages> messages = Observable.just(f).... O que eu gosto nessa resposta é que ela observa os documentos do usuário; portanto, se um nome de usuário é atualizado no banco de dados, a saída reflete isso imediatamente.
Frank van Puffelen
Sim trabalhando tão bom assim im atualizando meu código
Cenk Yağmur
1

Idealmente, você deseja excluir qualquer lógica comercial, como carregamento de dados em um serviço separado ou seguir o padrão BloC, por exemplo:

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

Então você pode apenas usar o bloco no seu componente e ouvir o chatBloc.messagesfluxo.

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

Permita-me apresentar minha versão de uma solução RxDart. Eu uso combineLatest2com um ListView.builderpara criar cada widget de mensagem. Durante a construção de cada mensagem Widget, procuro o nome do usuário com o correspondenteuid .

Neste snippet, uso uma pesquisa linear para o nome do usuário, mas isso pode ser melhorado com a criação de um 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
fonte
Muito legal ver Arthur. É como uma versão muito mais limpa da minha resposta inicial com construtores aninhados . Definitivamente, uma das soluções mais simples de ler.
Frank van Puffelen
0

A primeira solução que trabalhei é aninhar duas StreamBuilderinstâncias, uma para cada coleção / 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 afirmado na minha pergunta, sei que esta solução não é ótima, mas pelo menos funciona.

Alguns problemas que vejo com isso:

  • Carrega todos os usuários, em vez de apenas os usuários que postaram mensagens. Em pequenos conjuntos de dados que não serão um problema, mas à medida que recebo mais mensagens / usuários (e uso uma consulta para mostrar um subconjunto deles), carregarei mais e mais usuários que não postaram nenhuma mensagem.
  • O código não é realmente muito legível com o aninhamento de dois construtores. Duvido que isso seja Flutter idiomático.

Se você conhece uma solução melhor, poste como resposta.

Frank van Puffelen
fonte