Як з'єднати дані з двох колекцій Firestore у Flutter?


9

У мене є програма для чату у Flutter за допомогою Firestore, і у мене є дві основні колекції:

  • chats, Який вводиться на авто ідентифікаторів, і має message, timestampі uidполе.
  • users, яке введено uidі має nameполе

У своєму додатку я показую список повідомлень (із messagesколекції) із цим віджетом:

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

Але тепер я хочу показати ім’я користувача (з usersколекції) для кожного повідомлення.

Я зазвичай закликаю приєднатися до клієнта, хоча я не впевнений, чи має Flutter конкретне ім'я.

Я знайшов один із способів зробити це (про що я розмістив нижче), але цікаво, чи є інший / кращий / ідіоматичніший спосіб зробити цей тип операцій у Flutter.

Отже: який ідіоматичний спосіб у Flutter шукати ім’я користувача для кожного повідомлення у вищевказаній структурі?


Я думаю, що єдине рішення, в якому я дослідив багато rxdart
Cenk YAGMUR

Відповіді:


3

У мене з'явилася інша версія, яка працює трохи краще, ніж моя відповідь з двома вкладеними будівельниками .

Тут я виділив на завантаження даних у спеціальному методі, використовуючи виділений Messageклас для зберігання інформації з повідомлення Documentта необов'язкового асоційованого користувача 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;
  }
}

Порівняно з рішенням із вкладеними будівельниками, цей код є більш читабельним, головним чином тому, що обробку даних та конструктор інтерфейсу краще розділити. Він також завантажує документи користувача лише для користувачів, які опублікували повідомлення. На жаль, якщо користувач опублікував кілька повідомлень, він завантажить документ для кожного повідомлення. Я міг би додати кеш-пам'ять, але думаю, що цей код вже трохи довгий, що він виконує.


1
Якщо ви не приймаєте "зберігання інформації про користувача всередині повідомлення" як відповідь, я думаю, що це найкраще, що ви можете зробити. Якщо ви зберігаєте інформацію про користувача всередині повідомлення, є очевидний недолік того, що інформація про користувача може змінюватися в колекції користувачів, але не всередині повідомлення. Використовуючи заплановану функцію firebase, ви також можете вирішити цю проблему. Час від часу ви можете проходити збір повідомлень та оновлювати інформацію про користувачів відповідно до останніх даних у колекції користувачів.
Ugurcan Yildirim

Особисто я віддаю перевагу більш простому рішенню, як це, порівняно з комбінуванням потоків, якщо насправді це не потрібно. Ще краще, ми могли б переробити цей метод завантаження даних на щось на зразок сервісного класу або слідувати схемі BLoC. Як ви вже згадували, ми можемо зберегти інформацію про користувача в Map<String, UserModel>і завантажувати документ користувача лише один раз.
Джошуа Чан

Погодився Джошуа. Я хотів би побачити опис того, як це виглядатиме в шаблоні BLoC.
Франк ван

3

Якщо я читаю це правильно, проблема резюмує: як ви трансформуєте потік даних, який потребує асинхронного виклику для зміни даних у потоці?

У контексті проблеми потік даних - це список повідомлень, а виклик асинхронізації - це отримання даних користувача та оновлення повідомлень з цими даними в потоці.

Це можна зробити безпосередньо в об'єкт потоку Dart за допомогою asyncMap()функції. Ось чистий код Dart, який демонструє, як це зробити:

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

Більша частина коду імітує дані, що надходять із Firebase, як потік карти повідомлень та функцію асинхронізації для отримання даних користувачів. Тут важлива функціяgetMessagesStream() .

Код трохи ускладнений тим, що це список повідомлень, що надходять у потоці. Щоб запобігти синхронному виникненню викликів для отримання даних користувачів, код використовує a Future.wait()для збору List<Future<Message>>та створення aList<Message> коли завершено все майбутнє.

У контексті Flutter ви можете використовувати потік, що надходить з getMessagesStream()а FutureBuilderдля відображення об'єктів повідомлення.


3

Ви можете зробити це з RxDart так .. 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([]);
    })
}

для 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([]);
        })
    }

Дуже круто! Існує спосіб, коли це не потрібно f.reference.snapshots(), оскільки це по суті перезавантажує знімок, і я вважаю за краще не покладатися на те, щоб клієнт Firestore був досить розумним, щоб зробити їх дублюванням (хоча я майже впевнений, що він дедупує).
Франк ван

Знайшов це. Замість цього Stream<Messages> messages = f.reference.snapshots()...можна зробити Stream<Messages> messages = Observable.just(f).... Мені подобається ця відповідь, що вона спостерігає за документами користувача, тому якщо ім’я користувача буде оновлено в базі даних, результат відображає це відразу.
Франк ван

Так працюю так добре, як те, оновлюючи мій код
Cenk YAGMUR

1

В ідеалі ви хочете виключити будь-яку бізнес-логіку, таку як завантаження даних в окрему службу або за схемою BloC, наприклад:

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

Тоді ви можете просто використовувати блок у своєму компоненті та прослуховувати chatBloc.messagesпотік.

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

1

Дозвольте мені викласти свою версію рішення RxDart. Я використовую combineLatest2a ListView.builderдля створення кожного віджету повідомлень. Під час побудови кожного повідомлення віджет я шукаю ім’я користувача з відповідним uid.

У цьому фрагменті я використовую лінійний пошук для імені користувача, але це можна покращити, створивши uid -> user nameкарту

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

Перше рішення, над яким я працював, - це вкласти два StreamBuilderекземпляри, по одному для кожної колекції / запиту.

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

Як сказано в моєму запитанні, я знаю, що це рішення не є великим, але принаймні воно працює.

З цим я бачу деякі проблеми:

  • Він завантажує всіх користувачів, а не лише тих користувачів, які розміщували повідомлення. У невеликих наборах даних це не буде проблемою, але оскільки я отримую більше повідомлень / користувачів (і використовую запит, щоб показати їх підмножину), я завантажуватиму все більше та більше користувачів, які не публікували жодних повідомлень.
  • Код насправді не дуже читабельний при вкладанні двох будівельників. Я сумніваюся, це ідіоматичний Flutter.

Якщо ви знаєте краще рішення, напишіть як відповідь.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.