Jak połączyć dane z dwóch kolekcji Firestore we Flutter?

9

Mam aplikację do czatowania we Flutter przy użyciu Firestore i mam dwie główne kolekcje:

  • chats, Które jest osadzone na auto-ID, i ma message, timestampiuid pól.
  • users, który jest włączony uidi ma namepole

W mojej aplikacji pokazuję listę wiadomości (z messageskolekcji), z tym widgetem:

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

Ale teraz chcę pokazać nazwę użytkownika (z users kolekcji) dla każdej wiadomości.

Zwykle nazywam to dołączeniem po stronie klienta, chociaż nie jestem pewien, czy Flutter ma dla niego określoną nazwę.

Znalazłem jeden sposób na zrobienie tego (co zamieściłem poniżej), ale zastanawiam się, czy istnieje inny / lepszy / bardziej idiomatyczny sposób wykonywania tego typu operacji we Flutter.

Więc: w jaki sposób idiomatyczny sposób Fluttera sprawdza nazwę użytkownika dla każdej wiadomości w powyższej strukturze?

Frank van Puffelen
źródło
Myślę, że jedynym rozwiązaniem, które badałem wiele rxdart
Cenk YAGMUR

Odpowiedzi:

3

Mam inną wersję, która działa nieco lepiej niż moja odpowiedź z dwoma zagnieżdżonymi programami budującymi .

Tutaj wyizolowałem ładowanie danych w metodzie niestandardowej, używając dedykowanej Messageklasy do przechowywania informacji z wiadomości Documenti opcjonalnego powiązanego użytkownika 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;
  }
}

W porównaniu z rozwiązaniem z zagnieżdżonymi konstruktorami ten kod jest bardziej czytelny, głównie dlatego, że obsługa danych i konstruktor interfejsu użytkownika są lepiej oddzielone. Ładuje również dokumenty użytkownika tylko dla użytkowników, którzy opublikowali wiadomości. Niestety, jeśli użytkownik opublikował wiele wiadomości, załaduje dokument dla każdej wiadomości. Mógłbym dodać pamięć podręczną, ale myślę, że ten kod jest już trochę za długi na to, co osiąga.

Frank van Puffelen
źródło
1
Jeśli nie przyjmiesz jako „przechowywania informacji o użytkowniku w wiadomości”, myślę, że to najlepsze, co możesz zrobić. Jeśli przechowujesz informacje o użytkowniku w wiadomości, istnieje oczywista wada, że ​​informacje o użytkowniku mogą ulec zmianie w kolekcji użytkowników, ale nie w wiadomości. Korzystając z funkcji zaplanowanej bazy ogniowej, możesz również rozwiązać ten problem. Od czasu do czasu możesz przeglądać wiadomości i aktualizować informacje o użytkownikach zgodnie z najnowszymi danymi w kolekcji użytkowników.
Ugurcan Yildirim
Osobiście wolę takie prostsze rozwiązanie niż łączenie strumieni, chyba że jest to naprawdę konieczne. Co więcej, moglibyśmy zmienić tę metodę ładowania danych na coś w rodzaju klasy usług lub zastosować wzorzec BLoC. Jak już wspomniałeś, możemy zapisać informacje o użytkowniku w Map<String, UserModel>i załadować dokument użytkownika tylko raz.
Joshua Chan,
Uzgodniony Joshua. Chciałbym zobaczyć opis tego, jak wyglądałoby to we wzorze BLoC.
Frank van Puffelen,
3

Jeśli czytam to poprawnie, problem streszcza się: jak przekształcić strumień danych, który wymaga wykonania asynchronicznego wywołania w celu zmodyfikowania danych w strumieniu?

W kontekście problemu strumień danych jest listą wiadomości, a wywołanie asynchroniczne ma pobrać dane użytkownika i zaktualizować wiadomości o te dane w strumieniu.

Można to zrobić bezpośrednio w obiekcie strumienia Dart za pomocą asyncMap()funkcji. Oto czysty kod Dart, który pokazuje, jak to zrobić:

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

Większość kodu naśladuje dane pochodzące z Firebase jako strumień mapy wiadomości oraz funkcję asynchroniczną do pobierania danych użytkownika. Ważną funkcją jest tutajgetMessagesStream() .

Kod jest nieco skomplikowany przez fakt, że jest to lista wiadomości przychodzących do strumienia. Aby zapobiec wywoływaniu synchronicznych wywołań danych użytkownika, kod używa a Future.wait()do zebrania List<Future<Message>>i utworzenia plikuList<Message> po zakończeniu wszystkich kontraktów futures.

W kontekście trzepotanie, można użyć strumienia pochodzących z getMessagesStream()w sposób FutureBuilderwyświetlać obiekty wiadomość.

Matt S.
źródło
3

Możesz to zrobić za pomocą RxDart w ten sposób .. 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([]);
    })
}

dla 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
źródło
Bardzo fajny! Czy istnieje sposób na niepotrzebne f.reference.snapshots(), ponieważ zasadniczo polega to na ponownym załadowaniu migawki i wolałbym nie polegać na tym, że klient Firestore jest wystarczająco inteligentny, aby je zduplikować (chociaż jestem prawie pewien, że deduplikuje).
Frank van Puffelen,
Znaleziono to. Zamiast tego Stream<Messages> messages = f.reference.snapshots()...możesz zrobić Stream<Messages> messages = Observable.just(f).... W tej odpowiedzi podoba mi się to, że obserwuje dokumenty użytkownika, więc jeśli nazwa użytkownika zostanie zaktualizowana w bazie danych, dane wyjściowe natychmiast ją odzwierciedlą.
Frank van Puffelen,
Tak, działa tak dobrze, że aktualizuję mój kod
Cenk YAGMUR,
1

Idealnie chcesz wykluczyć dowolną logikę biznesową, taką jak ładowanie danych do oddzielnej usługi lub przestrzeganie wzorca BloC, np .:

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

Następnie możesz po prostu użyć bloku w swoim komponencie i słuchać chatBloc.messagesstrumienia.

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
źródło
1

Pozwól mi przedstawić moją wersję rozwiązania RxDart. Używam combineLatest2z, ListView.builderaby zbudować każdą wiadomość Widget. Podczas konstruowania każdej wiadomości Widżet wyszukuję nazwę użytkownika z odpowiednimuid .

W tym fragmencie używam liniowego wyszukiwania nazwy użytkownika, ale można to poprawić, tworząc uid -> user namemapę

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
źródło
Bardzo fajnie widzieć Artura. To jest jak znacznie czystsza wersja mojej początkowej odpowiedzi z zagnieżdżonymi programami budującymi . Zdecydowanie jedno z prostszych rozwiązań do czytania.
Frank van Puffelen,
0

Pierwszym rozwiązaniem, które dostałem, jest zagnieżdżenie dwóch StreamBuilderinstancji, po jednej dla każdej kolekcji / zapytania.

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

Jak stwierdzono w moim pytaniu, wiem, że to rozwiązanie nie jest świetne, ale przynajmniej działa.

Niektóre problemy z tym widzę:

  • Ładuje wszystkich użytkowników, a nie tylko tych, którzy opublikowali wiadomości. W małych zestawach danych nie będzie to problemem, ale gdy otrzymam więcej wiadomości / użytkowników (i użyję zapytania, aby wyświetlić ich podzbiór), będę ładować coraz więcej użytkowników, którzy nie opublikowali żadnych wiadomości.
  • Kod nie jest zbyt czytelny przy zagnieżdżaniu dwóch konstruktorów. Wątpię, żeby to był idiotyczny trzepot.

Jeśli znasz lepsze rozwiązanie, napisz jako odpowiedź.

Frank van Puffelen
źródło