Bagaimana cara saya menggabungkan data dari dua koleksi Firestore di Flutter?

9

Saya memiliki aplikasi obrolan di Flutter menggunakan Firestore, dan saya memiliki dua koleksi utama:

  • chats, Yang bersemangat pada auto-id, dan memiliki message, timestamp, dan uidbidang.
  • users, yang dikunci uid, dan memiliki namebidang

Di aplikasi saya, saya menunjukkan daftar pesan (dari messageskoleksi), dengan widget ini:

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

Tapi sekarang saya ingin menunjukkan nama pengguna (dari userskoleksi) untuk setiap pesan.

Saya biasanya menyebut pihak klien bergabung, meskipun saya tidak yakin apakah Flutter memiliki nama spesifik untuk itu.

Saya telah menemukan satu cara untuk melakukan ini (yang saya posting di bawah), tetapi bertanya-tanya apakah ada cara lain / lebih baik / lebih idiomatis untuk melakukan jenis operasi ini di Flutter.

Jadi: apa cara idiomatis di Flutter untuk mencari nama pengguna untuk setiap pesan dalam struktur di atas?

Frank van Puffelen
sumber
Saya pikir satu-satunya solusi yang telah saya teliti banyak rxdart
Cenk

Jawaban:

3

Saya mendapatkan versi lain yang berfungsi yang tampaknya sedikit lebih baik daripada jawaban saya dengan dua pembangun bersarang .

Di sini saya mengisolasi pada pemuatan data dalam metode kustom, menggunakan Messagekelas khusus untuk menyimpan informasi dari pesan Documentdan pengguna terkait opsional 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;
  }
}

Dibandingkan dengan solusi dengan pembuat bersarang kode ini lebih mudah dibaca, sebagian besar karena penanganan data dan pembuat UI lebih baik dipisahkan. Itu juga hanya memuat dokumen pengguna untuk pengguna yang telah memposting pesan. Sayangnya, jika pengguna telah memposting banyak pesan, itu akan memuat dokumen untuk setiap pesan. Saya bisa menambahkan cache, tetapi berpikir kode ini sudah agak lama untuk apa yang dicapai.

Frank van Puffelen
sumber
1
Jika Anda tidak mengambil "menyimpan info pengguna di dalam pesan" sebagai jawaban, saya pikir ini adalah yang terbaik yang dapat Anda lakukan. Jika Anda menyimpan info pengguna di dalam pesan, ada kelemahan jelas bahwa info pengguna mungkin berubah dalam koleksi pengguna, tetapi tidak di dalam pesan. Menggunakan fungsi firebase terjadwal, Anda juga dapat menyelesaikan ini. Dari waktu ke waktu, Anda dapat melalui pengumpulan pesan dan memperbarui informasi pengguna sesuai dengan data terbaru dalam koleksi pengguna.
Ugurcan Yildirim
Secara pribadi saya lebih suka solusi yang lebih sederhana seperti ini dibandingkan dengan menggabungkan aliran kecuali benar-benar diperlukan. Lebih baik lagi, kita bisa memperbaiki metode pemuatan data ini menjadi sesuatu seperti kelas layanan atau mengikuti pola BLoC. Seperti yang telah Anda sebutkan, kami dapat menyimpan informasi pengguna dalam Map<String, UserModel>dan hanya memuat dokumen pengguna satu kali.
Joshua Chan
Joshua setuju. Saya ingin melihat tulisan tentang bagaimana ini terlihat dalam pola BLoC.
Frank van Puffelen
3

Jika saya membaca ini dengan benar, masalahnya akan menjadi: bagaimana Anda mengubah aliran data yang mengharuskan Anda membuat panggilan tidak sinkron untuk mengubah data dalam aliran?

Dalam konteks masalah, aliran data adalah daftar pesan, dan panggilan async adalah untuk mengambil data pengguna dan memperbarui pesan dengan data ini di arus.

Dimungkinkan untuk melakukan ini secara langsung dalam objek aliran Dart menggunakan asyncMap()fungsi. Berikut ini beberapa kode Dart murni yang menunjukkan cara melakukannya:

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

Sebagian besar kode meniru data yang berasal dari Firebase sebagai aliran peta pesan, dan fungsi async untuk mengambil data pengguna. Fungsi penting di sini adalah getMessagesStream().

Kode sedikit rumit oleh fakta bahwa itu adalah daftar pesan yang masuk di arus. Untuk mencegah panggilan untuk mengambil data pengguna dari terjadi secara serempak, kode menggunakan a Future.wait()untuk mengumpulkan List<Future<Message>>dan membuat List<Message>ketika semua Futures telah selesai.

Dalam konteks Flutter, Anda dapat menggunakan aliran yang berasal dari getMessagesStream()dalam FutureBuilderuntuk menampilkan objek Pesan.

Matt S.
sumber
3

Anda dapat melakukannya dengan RxDart seperti itu .. 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([]);
    })
}

untuk 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
sumber
Sangat keren! Apakah ada cara untuk tidak perlu f.reference.snapshots(), karena itu pada dasarnya memuat ulang snapshot dan saya lebih suka untuk tidak bergantung pada klien Firestore yang cukup pintar untuk mendupuplikasi mereka (walaupun saya hampir yakin bahwa itu tidak terpotong).
Frank van Puffelen
Menemukannya. Alih-alih Stream<Messages> messages = f.reference.snapshots()..., Anda bisa melakukannya Stream<Messages> messages = Observable.just(f).... Apa yang saya sukai dari jawaban ini adalah bahwa ia mengamati dokumen pengguna, jadi jika nama pengguna diperbarui dalam database, hasilnya langsung mencerminkan itu.
Frank van Puffelen
Ya bekerja sangat baik seperti itu saya memperbarui kode saya
Cenk YAGMUR
1

Idealnya Anda ingin mengecualikan logika bisnis seperti memuat data ke layanan terpisah atau mengikuti pola BloC, misalnya:

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

Kemudian Anda bisa menggunakan Blok di komponen Anda dan mendengarkan chatBloc.messagesaliran.

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

Izinkan saya untuk mengajukan solusi RxDart versi saya. Saya menggunakan combineLatest2dengan ListView.builderuntuk membangun setiap Widget pesan. Selama pembangunan setiap pesan Widget saya mencari nama pengguna dengan yang sesuai uid.

Dalam cuplikan ini saya menggunakan pencarian linear untuk nama pengguna tetapi itu dapat ditingkatkan dengan membuat uid -> user namepeta

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
sumber
Sangat keren melihat Arthur. Ini seperti versi yang lebih bersih dari jawaban awal saya dengan pembangun bersarang . Pasti salah satu solusi yang lebih sederhana untuk dibaca.
Frank van Puffelen
0

Solusi pertama yang saya peroleh adalah dengan membuat dua StreamBuildercontoh, satu untuk setiap koleksi / permintaan.

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

Seperti yang dinyatakan dalam pertanyaan saya, saya tahu solusi ini tidak bagus, tetapi setidaknya itu berfungsi.

Beberapa masalah yang saya lihat dengan ini:

  • Itu memuat semua pengguna, bukan hanya pengguna yang memposting pesan. Dalam kumpulan data kecil yang tidak akan menjadi masalah, tetapi karena saya mendapatkan lebih banyak pesan / pengguna (dan menggunakan kueri untuk menunjukkan sebagian dari mereka) saya akan memuat lebih banyak dan lebih banyak pengguna yang tidak memposting pesan apa pun.
  • Kode ini tidak terlalu mudah dibaca dengan bersarangnya dua pembangun. Saya ragu ini adalah Flutter idiomatik.

Jika Anda tahu solusi yang lebih baik, silakan posting sebagai jawaban.

Frank van Puffelen
sumber